mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 08:57:04 +02:00
Merge remote-tracking branch 'origin/claude/refactor-chat-composer-UeJUN' into claude/fix-autocompletion-SmJX1
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"
|
||||
|
||||
309
src/components/PostViewer.tsx
Normal file
309
src/components/PostViewer.tsx
Normal 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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
876
src/components/editor/NostrEditor.tsx
Normal file
876
src/components/editor/NostrEditor.tsx
Normal 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";
|
||||
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;
|
||||
}
|
||||
126
src/components/editor/types.ts
Normal file
126
src/components/editor/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -23,6 +23,7 @@ export type AppId =
|
||||
| "blossom"
|
||||
| "wallet"
|
||||
| "zap"
|
||||
| "post"
|
||||
| "win";
|
||||
|
||||
export interface WindowInstance {
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user