Merge remote-tracking branch 'origin/claude/refactor-chat-composer-UeJUN' into claude/fix-autocompletion-SmJX1

This commit is contained in:
Claude
2026-01-20 17:31:56 +00:00
12 changed files with 1739 additions and 879 deletions

View File

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

View File

@@ -0,0 +1,309 @@
import { useRef, useMemo, useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { NostrEditor, type NostrEditorHandle } from "./editor/NostrEditor";
import { createNostrSuggestions } from "./editor/suggestions";
import { useProfileSearch } from "@/hooks/useProfileSearch";
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
import { useBlossomUpload } from "@/hooks/useBlossomUpload";
import { useAccount } from "@/hooks/useAccount";
import { Loader2, Paperclip, CheckCircle2 } from "lucide-react";
import { toast } from "sonner";
import { hub } from "@/services/hub";
import { NoteBlueprint } from "applesauce-common/blueprints";
import type { SerializedContent } from "./editor/types";
import { lastValueFrom } from "rxjs";
import type { ActionContext } from "applesauce-actions";
import { useEventStore } from "applesauce-react/hooks";
import { addressLoader, profileLoader } from "@/services/loaders";
// Draft storage key prefix
const DRAFT_STORAGE_PREFIX = "grimoire:post-draft:";
// Action builder for creating a short text note
function CreateNoteAction(content: SerializedContent) {
return async ({ factory, sign, publish }: ActionContext) => {
// Build the note using NoteBlueprint
const draft = await factory.create(NoteBlueprint, content.text);
// Add emoji tags if any custom emojis were used
for (const emoji of content.emojiTags) {
draft.tags.push(["emoji", emoji.shortcode, emoji.url]);
}
// Add imeta tags for media attachments
for (const blob of content.blobAttachments) {
const imetaValues = [`url ${blob.url}`, `x ${blob.sha256}`];
if (blob.mimeType) imetaValues.push(`m ${blob.mimeType}`);
if (blob.size) imetaValues.push(`size ${blob.size}`);
draft.tags.push(["imeta", ...imetaValues]);
}
// Sign and publish the event
const event = await sign(draft);
await publish(event);
};
}
export function PostViewer() {
const { pubkey, canSign } = useAccount();
const eventStore = useEventStore();
const { searchProfiles, service: profileService } = useProfileSearch();
const { searchEmojis } = useEmojiSearch();
const editorRef = useRef<NostrEditorHandle>(null);
const [isPublishing, setIsPublishing] = useState(false);
const [isPublished, setIsPublished] = useState(false);
// Use pubkey as draft key - one draft per account, persists across reloads
const draftKey = pubkey ? `${DRAFT_STORAGE_PREFIX}${pubkey}` : null;
// Track if editor is mounted and draft is loaded
const [editorReady, setEditorReady] = useState(false);
const draftLoadedRef = useRef(false);
// Callback when editor mounts - triggers draft loading
const handleEditorReady = useCallback(() => {
setEditorReady(true);
}, []);
// Load draft from localStorage after editor is ready
useEffect(() => {
if (
draftLoadedRef.current ||
!draftKey ||
!editorReady ||
!editorRef.current
)
return;
draftLoadedRef.current = true;
try {
const savedDraft = localStorage.getItem(draftKey);
if (savedDraft) {
const parsed = JSON.parse(savedDraft);
// Use setContent to load draft after editor is mounted
editorRef.current.setContent(parsed);
}
} catch (error) {
console.warn("[PostViewer] Failed to load draft:", error);
}
}, [draftKey, editorReady]);
// Save draft to localStorage when content changes (uses full TipTap JSON)
const saveDraft = useCallback(() => {
if (!draftKey || !editorRef.current) return;
try {
const json = editorRef.current.getJSON();
const text = editorRef.current.getContent();
if (text.trim()) {
localStorage.setItem(draftKey, JSON.stringify(json));
} else {
localStorage.removeItem(draftKey);
}
} catch (error) {
// localStorage might be full or disabled
console.warn("[PostViewer] Failed to save draft:", error);
}
}, [draftKey]);
// Clear draft from localStorage
const clearDraft = useCallback(() => {
if (!draftKey) return;
try {
localStorage.removeItem(draftKey);
} catch (error) {
console.warn("[PostViewer] Failed to clear draft:", error);
}
}, [draftKey]);
// Load contacts and their profiles
useEffect(() => {
if (!pubkey) return;
// Load contacts list (kind 3)
const contactsSubscription = addressLoader({
kind: 3,
pubkey,
identifier: "",
}).subscribe();
// Watch for contacts event and load profiles
const storeSubscription = eventStore
.replaceable(3, pubkey, "")
.subscribe((contactsEvent) => {
if (!contactsEvent) return;
// Extract pubkeys from p tags
const contactPubkeys = contactsEvent.tags
.filter((tag) => tag[0] === "p" && tag[1])
.map((tag) => tag[1]);
// Load profiles for all contacts (batched by profileLoader)
for (const contactPubkey of contactPubkeys) {
profileLoader({
kind: 0,
pubkey: contactPubkey,
identifier: "",
}).subscribe({
next: (event) => {
// Add loaded profile to search service
profileService.addProfiles([event]);
},
});
}
});
return () => {
contactsSubscription.unsubscribe();
storeSubscription.unsubscribe();
};
}, [pubkey, eventStore, profileService]);
// Blossom upload for attachments
const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({
accept: "image/*,video/*,audio/*",
onSuccess: (results) => {
if (results.length > 0 && editorRef.current) {
const { blob, server } = results[0];
editorRef.current.insertBlob({
url: blob.url,
sha256: blob.sha256,
mimeType: blob.type,
size: blob.size,
server,
});
editorRef.current.focus();
}
},
});
// Create suggestions for the editor
const suggestions = useMemo(
() =>
createNostrSuggestions({
searchProfiles,
searchEmojis,
}),
[searchProfiles, searchEmojis],
);
// Handle publishing the post
const handlePublish = useCallback(
async (content: SerializedContent) => {
if (!canSign || !pubkey) {
toast.error("Please sign in to post");
return;
}
if (!content.text.trim()) {
toast.error("Please write something to post");
return;
}
setIsPublishing(true);
try {
// Execute the action (builds, signs, and publishes)
await lastValueFrom(hub.exec(CreateNoteAction, content));
toast.success("Post published!");
setIsPublished(true);
editorRef.current?.clear();
clearDraft(); // Clear draft after successful publish
} catch (error) {
console.error("[PostViewer] Failed to publish:", error);
toast.error(
error instanceof Error ? error.message : "Failed to publish post",
);
} finally {
setIsPublishing(false);
}
},
[canSign, pubkey, clearDraft],
);
// Handle submit button click
const handleSubmitClick = useCallback(() => {
if (editorRef.current) {
const content = editorRef.current.getSerializedContent();
handlePublish(content);
}
}, [handlePublish]);
// Handle content change - save draft and reset published state
const handleChange = useCallback(() => {
if (isPublished) {
setIsPublished(false);
}
saveDraft();
}, [isPublished, saveDraft]);
if (!canSign) {
return (
<div className="h-full flex items-center justify-center p-4">
<div className="text-center text-muted-foreground">
<p className="text-lg font-medium">Sign in to post</p>
<p className="text-sm mt-1">
You need to be signed in with a signing-capable account to create
posts.
</p>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col p-3 gap-2">
<NostrEditor
ref={editorRef}
placeholder="What's on your mind?"
variant="full"
submitBehavior="button-only"
blobPreview="gallery"
minLines={6}
suggestions={suggestions}
onChange={handleChange}
onReady={handleEditorReady}
autoFocus
/>
<div className="flex items-center justify-between">
<Button
type="button"
variant="ghost"
size="icon"
onClick={openUpload}
disabled={isPublishing}
title="Attach file"
>
<Paperclip className="size-4" />
</Button>
<div className="flex items-center gap-2">
{isPublished && (
<span className="text-sm text-green-600 dark:text-green-400 flex items-center gap-1">
<CheckCircle2 className="size-4" />
Published
</span>
)}
<Button
type="button"
onClick={handleSubmitClick}
disabled={isPublishing}
>
{isPublishing ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Publishing...
</>
) : (
"Post"
)}
</Button>
</div>
</div>
{uploadDialog}
</div>
);
}
export default PostViewer;

View File

@@ -47,6 +47,9 @@ const ZapWindow = lazy(() =>
import("./ZapWindow").then((m) => ({ default: m.ZapWindow })),
);
const CountViewer = lazy(() => import("./CountViewer"));
const PostViewer = lazy(() =>
import("./PostViewer").then((m) => ({ default: m.PostViewer })),
);
// Loading fallback component
function ViewerLoading() {
@@ -241,6 +244,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
/>
);
break;
case "post":
content = <PostViewer />;
break;
default:
content = (
<div className="p-4 text-muted-foreground">

View File

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

View File

@@ -0,0 +1,876 @@
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useCallback,
useRef,
} from "react";
import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react";
import {
Extension,
Node,
mergeAttributes,
type AnyExtension,
} from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import Mention from "@tiptap/extension-mention";
import Placeholder from "@tiptap/extension-placeholder";
import type { SuggestionOptions } from "@tiptap/suggestion";
import tippy from "tippy.js";
import type { Instance as TippyInstance } from "tippy.js";
import "tippy.js/dist/tippy.css";
import { nip19 } from "nostr-tools";
import { cn } from "@/lib/utils";
import type {
NostrEditorHandle,
SerializedContent,
BlobAttachment,
EmojiTag,
SuggestionConfig,
SubmitBehavior,
EditorVariant,
BlobPreviewStyle,
SuggestionListHandle,
} from "./types";
// Re-export handle type for consumers
export type { NostrEditorHandle };
export interface NostrEditorProps {
/** Placeholder text when editor is empty */
placeholder?: string;
/** Initial content (plain text or TipTap JSON) */
initialContent?: string | object;
/** Called when content is submitted */
onSubmit?: (content: SerializedContent) => void;
/** Called when content changes */
onChange?: (content: SerializedContent) => void;
/** Called when editor is ready (mounted and initialized) */
onReady?: () => void;
/** Submit behavior: 'enter' (chat), 'ctrl-enter' (post), 'button-only' (external button) */
submitBehavior?: SubmitBehavior;
/** Layout variant: 'inline' (chat), 'multiline' (auto-expand), 'full' (fixed height) */
variant?: EditorVariant;
/** Minimum lines for multiline/full variants */
minLines?: number;
/** Maximum lines for multiline variant (auto-expand limit) */
maxLines?: number;
/** Blob preview style: 'compact' (pill), 'card' (thumbnail), 'gallery' (full-width) */
blobPreview?: BlobPreviewStyle;
/** Suggestion configurations */
suggestions?: SuggestionConfig[];
/** Auto-focus on mount */
autoFocus?: boolean;
/** Additional CSS classes */
className?: string;
}
// Create emoji extension by extending Mention with a different name and custom node view
const EmojiMention = Mention.extend({
name: "emoji",
addAttributes() {
return {
...this.parent?.(),
url: {
default: null,
parseHTML: (element) => element.getAttribute("data-url"),
renderHTML: (attributes) => {
if (!attributes.url) return {};
return { "data-url": attributes.url };
},
},
source: {
default: null,
parseHTML: (element) => element.getAttribute("data-source"),
renderHTML: (attributes) => {
if (!attributes.source) return {};
return { "data-source": attributes.source };
},
},
};
},
renderText({ node }) {
if (node.attrs.source === "unicode") {
return node.attrs.url || "";
}
return `:${node.attrs.id}:`;
},
addNodeView() {
return ({ node }) => {
const { url, source, id } = node.attrs;
const isUnicode = source === "unicode";
const dom = document.createElement("span");
dom.className = "emoji-node";
dom.setAttribute("data-emoji", id || "");
if (isUnicode && url) {
const span = document.createElement("span");
span.className = "emoji-unicode";
span.textContent = url;
span.title = `:${id}:`;
dom.appendChild(span);
} else if (url) {
const img = document.createElement("img");
img.src = url;
img.alt = `:${id}:`;
img.title = `:${id}:`;
img.className = "emoji-image";
img.draggable = false;
img.onerror = () => {
dom.textContent = `:${id}:`;
};
dom.appendChild(img);
} else {
dom.textContent = `:${id}:`;
}
return { dom };
};
},
});
function formatBlobSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
/**
* Create blob attachment node with configurable preview style
*/
function createBlobAttachmentNode(previewStyle: BlobPreviewStyle) {
return Node.create({
name: "blobAttachment",
group: previewStyle === "compact" ? "inline" : "block",
inline: previewStyle === "compact",
atom: true,
addAttributes() {
return {
url: { default: null },
sha256: { default: null },
mimeType: { default: null },
size: { default: null },
server: { default: null },
};
},
parseHTML() {
return [{ tag: 'span[data-blob-attachment="true"]' }];
},
renderHTML({ HTMLAttributes }) {
return [
"span",
mergeAttributes(HTMLAttributes, { "data-blob-attachment": "true" }),
];
},
addNodeView() {
return ({ node }) => {
const { url, mimeType, size } = node.attrs;
const isImage = mimeType?.startsWith("image/");
const isVideo = mimeType?.startsWith("video/");
const isAudio = mimeType?.startsWith("audio/");
const dom = document.createElement(
previewStyle === "compact" ? "span" : "div",
);
dom.contentEditable = "false";
if (previewStyle === "compact") {
// Compact: small inline pill
dom.className =
"blob-attachment inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50 border border-border text-xs align-middle";
if (isImage && url) {
const img = document.createElement("img");
img.src = url;
img.alt = "attachment";
img.className = "h-4 w-4 object-cover rounded";
img.draggable = false;
dom.appendChild(img);
} else {
const icon = document.createElement("span");
icon.className = "text-muted-foreground";
icon.textContent = isVideo ? "🎬" : isAudio ? "🎵" : "📎";
dom.appendChild(icon);
}
const label = document.createElement("span");
label.className = "text-muted-foreground truncate max-w-[80px]";
label.textContent = isImage
? "image"
: isVideo
? "video"
: isAudio
? "audio"
: "file";
dom.appendChild(label);
if (size) {
const sizeEl = document.createElement("span");
sizeEl.className = "text-muted-foreground/70";
sizeEl.textContent = formatBlobSize(size);
dom.appendChild(sizeEl);
}
} else if (previewStyle === "card") {
// Card: medium thumbnail card
dom.className =
"blob-attachment-card my-2 inline-flex items-center gap-3 p-2 rounded-lg bg-muted/30 border border-border max-w-xs";
if (isImage && url) {
const img = document.createElement("img");
img.src = url;
img.alt = "attachment";
img.className = "h-16 w-16 object-cover rounded";
img.draggable = false;
dom.appendChild(img);
} else {
const iconWrapper = document.createElement("div");
iconWrapper.className =
"h-16 w-16 flex items-center justify-center bg-muted rounded";
const icon = document.createElement("span");
icon.className = "text-2xl";
icon.textContent = isVideo ? "🎬" : isAudio ? "🎵" : "📎";
iconWrapper.appendChild(icon);
dom.appendChild(iconWrapper);
}
const info = document.createElement("div");
info.className = "flex flex-col gap-0.5 min-w-0";
const typeLabel = document.createElement("span");
typeLabel.className = "text-sm font-medium capitalize";
typeLabel.textContent = isImage
? "Image"
: isVideo
? "Video"
: isAudio
? "Audio"
: "File";
info.appendChild(typeLabel);
if (size) {
const sizeEl = document.createElement("span");
sizeEl.className = "text-xs text-muted-foreground";
sizeEl.textContent = formatBlobSize(size);
info.appendChild(sizeEl);
}
dom.appendChild(info);
} else {
// Gallery: full-width preview
dom.className = "blob-attachment-gallery my-2 w-full";
if (isImage && url) {
const img = document.createElement("img");
img.src = url;
img.alt = "attachment";
img.className = "max-w-full max-h-64 rounded-lg object-contain";
img.draggable = false;
dom.appendChild(img);
} else if (isVideo && url) {
const video = document.createElement("video");
video.src = url;
video.className = "max-w-full max-h-64 rounded-lg";
video.controls = true;
dom.appendChild(video);
} else if (isAudio && url) {
const audio = document.createElement("audio");
audio.src = url;
audio.className = "w-full";
audio.controls = true;
dom.appendChild(audio);
} else {
const fileCard = document.createElement("div");
fileCard.className =
"inline-flex items-center gap-2 p-3 rounded-lg bg-muted/30 border border-border";
const icon = document.createElement("span");
icon.className = "text-xl";
icon.textContent = "📎";
fileCard.appendChild(icon);
const label = document.createElement("span");
label.className = "text-sm";
label.textContent = size
? `File (${formatBlobSize(size)})`
: "File";
fileCard.appendChild(label);
dom.appendChild(fileCard);
}
}
return { dom };
};
},
});
}
/**
* Create a TipTap suggestion configuration from our SuggestionConfig
*
* Design: TipTap's `items()` function has buggy async handling where results
* arrive out of order. We bypass this entirely by:
* - Using `items()` only as a sync no-op (returns empty array)
* - Doing all async search in `onUpdate` when query changes
* - Updating the component directly when results arrive
*
* This keeps all search logic in one place and avoids TipTap's race conditions.
*/
function createSuggestionConfig<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,
// Don't use items() for async work - TipTap's async handling is buggy
// We do our own search in onUpdate instead
items: () => [],
render: () => {
let component: ReactRenderer<SuggestionListHandle> | null = null;
let popup: TippyInstance[] | null = null;
let editorRef: unknown = null;
let currentQuery = "";
let searchCounter = 0;
// Async search with race condition protection
const doSearch = async (query: string) => {
const thisSearch = ++searchCounter;
const results = await config.search(query);
// Discard if a newer search was started
if (thisSearch !== searchCounter || !component) return;
component.updateProps({ items: results });
};
return {
onStart: (props) => {
editorRef = props.editor;
currentQuery = (props as { query?: string }).query ?? "";
component = new ReactRenderer(config.component as never, {
props: {
items: [],
command: props.command,
onClose: () => popup?.[0]?.hide(),
},
editor: props.editor,
});
if (!props.clientRect) return;
popup = tippy("body", {
getReferenceClientRect: props.clientRect as () => DOMRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: config.placement ?? "bottom-start",
zIndex: 100,
});
// Trigger initial search
doSearch(currentQuery);
},
onUpdate(props) {
const newQuery = (props as { query?: string }).query ?? "";
// Search when query changes
if (newQuery !== currentQuery) {
currentQuery = newQuery;
doSearch(newQuery);
}
// Update command (TipTap regenerates it)
if (component) {
component.updateProps({ command: props.command });
}
// Update popup position
if (props.clientRect && popup?.[0]) {
popup[0].setProps({
getReferenceClientRect: props.clientRect as () => DOMRect,
});
}
},
onKeyDown(props) {
if (props.event.key === "Escape") {
popup?.[0]?.hide();
return true;
}
// Ctrl/Cmd+Enter always submits
if (
props.event.key === "Enter" &&
(props.event.ctrlKey || props.event.metaKey)
) {
popup?.[0]?.hide();
handleSubmitRef.current(editorRef);
return true;
}
return component?.ref?.onKeyDown(props.event) ?? false;
},
onExit() {
popup?.[0]?.destroy();
component?.destroy();
component = null;
popup = null;
currentQuery = "";
searchCounter = 0;
},
};
},
};
}
export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
(
{
placeholder = "Type a message...",
initialContent,
onSubmit,
onChange,
onReady,
submitBehavior = "enter",
variant = "inline",
minLines = 1,
maxLines = 10,
blobPreview = "compact",
suggestions = [],
autoFocus = false,
className = "",
},
ref,
) => {
const handleSubmitRef = useRef<(editor: unknown) => void>(() => {});
// Helper function to serialize editor content
const serializeContent = useCallback(
(editorInstance: {
getJSON: () => { content?: unknown[] };
getText: () => string;
}): SerializedContent => {
let text = "";
const emojiTags: EmojiTag[] = [];
const blobAttachments: BlobAttachment[] = [];
const seenEmojis = new Set<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;
// Find suggestion configs
const mentionConfig = suggestions.find((s) => s.char === "@");
const emojiConfig = suggestions.find((s) => s.char === ":");
const slashConfig = suggestions.find((s) => s.char === "/");
// Build extensions array
const extensions = useMemo(() => {
const isMobile = "ontouchstart" in window || navigator.maxTouchPoints > 0;
// Custom extension for keyboard shortcuts
const SubmitShortcut = Extension.create({
name: "submitShortcut",
addKeyboardShortcuts() {
return {
"Mod-Enter": ({ editor }) => {
handleSubmitRef.current(editor);
return true;
},
Enter: ({ editor }) => {
if (submitBehavior === "button-only") {
// Never submit on Enter, always newline
return editor.commands.setHardBreak();
} else if (submitBehavior === "ctrl-enter") {
// Enter always inserts newline
return editor.commands.setHardBreak();
} else {
// submitBehavior === 'enter'
if (isMobile) {
return editor.commands.setHardBreak();
} else {
handleSubmitRef.current(editor);
return true;
}
}
},
};
},
});
const exts: AnyExtension[] = [
SubmitShortcut,
StarterKit.configure({
hardBreak: { keepMarks: false },
}),
Placeholder.configure({ placeholder }),
createBlobAttachmentNode(blobPreview),
];
// Add mention extension for @ mentions
if (mentionConfig) {
exts.push(
Mention.configure({
HTMLAttributes: { class: "mention" },
suggestion: {
...createSuggestionConfig(mentionConfig, handleSubmitRef),
command: ({
editor,
range,
props,
}: {
editor: unknown;
range: unknown;
props: unknown;
}) => {
const result = mentionConfig.onSelect(props as never);
const ed = editor as {
chain: () => {
focus: () => {
insertContentAt: (
range: unknown,
content: unknown[],
) => { run: () => void };
};
};
};
ed.chain()
.focus()
.insertContentAt(range, [
{ type: result.type, attrs: result.attrs },
{ type: "text", text: " " },
])
.run();
},
},
renderLabel({ node }) {
return `@${node.attrs.label}`;
},
}),
);
}
// Add emoji extension
if (emojiConfig) {
exts.push(
EmojiMention.configure({
HTMLAttributes: { class: "emoji" },
suggestion: {
...createSuggestionConfig(emojiConfig, handleSubmitRef),
command: ({
editor,
range,
props,
}: {
editor: unknown;
range: unknown;
props: unknown;
}) => {
const result = emojiConfig.onSelect(props as never);
const ed = editor as {
chain: () => {
focus: () => {
insertContentAt: (
range: unknown,
content: unknown[],
) => { run: () => void };
};
};
};
ed.chain()
.focus()
.insertContentAt(range, [
{ type: "emoji", attrs: result.attrs },
{ type: "text", text: " " },
])
.run();
},
},
}),
);
}
// Add slash command extension
if (slashConfig) {
const SlashCommand = Mention.extend({ name: "slashCommand" });
exts.push(
SlashCommand.configure({
HTMLAttributes: { class: "slash-command" },
suggestion: {
...createSuggestionConfig(slashConfig, handleSubmitRef),
command: ({
editor,
props,
}: {
editor: unknown;
props: unknown;
}) => {
const ed = editor as { commands: { clearContent: () => void } };
if (slashConfig.clearOnSelect !== false) {
ed.commands.clearContent();
}
if (slashConfig.onExecute) {
slashConfig.onExecute(props as never).catch((error) => {
console.error(
"[NostrEditor] Command execution failed:",
error,
);
});
}
},
},
renderLabel({ node }) {
return `/${node.attrs.label}`;
},
}),
);
}
return exts;
}, [
submitBehavior,
placeholder,
blobPreview,
mentionConfig,
emojiConfig,
slashConfig,
]);
const editor = useEditor({
extensions,
content: initialContent,
editorProps: {
attributes: {
class: "prose prose-sm max-w-none focus:outline-none text-sm",
},
},
autofocus: autoFocus,
onCreate: () => {
// Notify parent that editor is ready for operations like loading drafts
onReady?.();
},
onUpdate: ({ editor }) => {
if (onChange) {
onChange(serializeContent(editor));
}
},
});
// Expose editor methods
useImperativeHandle(
ref,
() => ({
focus: () => editor?.commands.focus(),
clear: () => editor?.commands.clearContent(),
getContent: () => editor?.getText() || "",
getSerializedContent: () => {
if (!editor) return { text: "", emojiTags: [], blobAttachments: [] };
return serializeContent(editor);
},
getJSON: () => editor?.getJSON() || null,
setContent: (content: string | object) => {
if (editor) {
editor.commands.setContent(content);
}
},
isEmpty: () => editor?.isEmpty ?? true,
submit: () => {
if (editor) {
handleSubmit(editor);
}
},
insertText: (text: string) => {
if (editor) {
editor.chain().focus().insertContent(text).run();
}
},
insertBlob: (blob: BlobAttachment) => {
if (editor) {
editor
.chain()
.focus()
.insertContent([
{
type: "blobAttachment",
attrs: {
url: blob.url,
sha256: blob.sha256,
mimeType: blob.mimeType,
size: blob.size,
server: blob.server,
},
},
{ type: "text", text: " " },
])
.run();
}
},
}),
[editor, serializeContent, handleSubmit],
);
// Cleanup on unmount
useEffect(() => {
return () => {
editor?.destroy();
};
}, [editor]);
if (!editor) {
return null;
}
// Inline styles for dynamic height (Tailwind can't do dynamic values)
const getInlineStyles = (): React.CSSProperties => {
const lineHeight = 24;
switch (variant) {
case "inline":
return {};
case "multiline":
return {
minHeight: `${Math.max(minLines, 2) * lineHeight}px`,
maxHeight: `${maxLines * lineHeight}px`,
};
case "full":
return {
height: `${Math.max(minLines, 5) * lineHeight}px`,
};
default:
return {};
}
};
return (
<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",
variant === "full" && "resize-y min-h-[100px]",
className,
)}
style={getInlineStyles()}
>
<EditorContent
editor={editor}
className={cn("flex-1 min-w-0", variant !== "inline" && "h-full")}
/>
</div>
);
},
);
NostrEditor.displayName = "NostrEditor";

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

View File

@@ -0,0 +1,126 @@
import type { ComponentType } from "react";
/**
* Represents an emoji tag for NIP-30
*/
export interface EmojiTag {
shortcode: string;
url: string;
}
/**
* Represents a blob attachment for imeta tags (NIP-92)
*/
export interface BlobAttachment {
/** The URL of the blob */
url: string;
/** SHA256 hash of the blob content */
sha256: string;
/** MIME type of the blob */
mimeType?: string;
/** Size in bytes */
size?: number;
/** Blossom server URL */
server?: string;
}
/**
* Result of serializing editor content
*/
export interface SerializedContent {
/** The text content with mentions as nostr: URIs and emoji as :shortcode: */
text: string;
/** Emoji tags to include in the event (NIP-30) */
emojiTags: EmojiTag[];
/** Blob attachments for imeta tags (NIP-92) */
blobAttachments: BlobAttachment[];
}
/**
* Props for suggestion list components
*/
export interface SuggestionListProps<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;
/** Get the full TipTap JSON content (for draft persistence) */
getJSON: () => object | null;
/** Set content from string or TipTap JSON */
setContent: (content: string | object) => void;
isEmpty: () => boolean;
submit: () => void;
/** Insert text at the current cursor position */
insertText: (text: string) => void;
/** Insert a blob attachment with rich preview */
insertBlob: (blob: BlobAttachment) => void;
}

View File

@@ -8,6 +8,7 @@ import {
Eye,
EyeOff,
Zap,
PenSquare,
} from "lucide-react";
import accounts from "@/services/accounts";
import { useProfile } from "@/hooks/useProfile";
@@ -145,6 +146,10 @@ export default function UserMenu() {
);
}
function openPost() {
addWindow("post", {}, "New Post");
}
function openWallet() {
addWindow("wallet", {}, "Wallet");
}
@@ -379,6 +384,13 @@ export default function UserMenu() {
>
<UserLabel pubkey={account.pubkey} />
</DropdownMenuLabel>
<DropdownMenuItem
className="cursor-crosshair"
onClick={openPost}
>
<PenSquare className="size-4 mr-2" />
New Post
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ export type AppId =
| "blossom"
| "wallet"
| "zap"
| "post"
| "win";
export interface WindowInstance {

View File

@@ -843,4 +843,16 @@ export const manPages: Record<string, ManPageEntry> = {
category: "Nostr",
defaultProps: {},
},
post: {
name: "post",
section: "1",
synopsis: "post",
description:
"Open a post composer to create and publish a short text note (kind 1). Features profile @mentions autocomplete, custom emoji support, and media attachments via Blossom. Requires a signing-capable account.",
examples: ["post Open post composer"],
seeAlso: ["req", "profile"],
appId: "post",
category: "Nostr",
defaultProps: {},
},
};