mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 08:57:04 +02:00
feat: add schema-driven composer infrastructure for multi-kind event creation
- Extract shared editor code into core/ module (types, EmojiMention, serialization) - Rename RichEditor to TextEditor (keep RichEditor as backwards-compatible alias) - Create MarkdownEditor variant with formatting toolbar - Create MarkdownToolbar component with bold, italic, code, heading, list buttons - Add ComposerSchema type system for describing event kind schemas - Define schemas for kinds 1, 9, 1111, 1621, 30023, 30818 The schema system captures: - Content type and editor variant - Metadata fields (title, summary, labels) - Context binding (standalone, address, group, comment) - Threading style (nip10, nip22, q-tag) - Relay selection strategy - Draft persistence configuration This lays the groundwork for a generic Composer component that can handle any event kind based on its schema definition. https://claude.ai/code/session_01WpZc66saVdASHKrrnz3Tme
This commit is contained in:
134
src/components/editor/MarkdownEditor.tsx
Normal file
134
src/components/editor/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* MarkdownEditor - TextEditor with markdown formatting toolbar
|
||||
*
|
||||
* Combines the TextEditor with a MarkdownToolbar for easy formatting.
|
||||
* Ideal for long-form content like articles, issues, wiki pages.
|
||||
*/
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
TextEditor,
|
||||
type TextEditorProps,
|
||||
type TextEditorHandle,
|
||||
} from "./TextEditor";
|
||||
import { MarkdownToolbar } from "./MarkdownToolbar";
|
||||
import { MarkdownContent } from "@/components/nostr/MarkdownContent";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
export interface MarkdownEditorProps extends TextEditorProps {
|
||||
/** Show preview toggle button in toolbar */
|
||||
enablePreview?: boolean;
|
||||
/** Initial preview state */
|
||||
initialPreview?: boolean;
|
||||
}
|
||||
|
||||
export interface MarkdownEditorHandle extends TextEditorHandle {
|
||||
/** Toggle preview mode */
|
||||
togglePreview: () => void;
|
||||
/** Check if preview is active */
|
||||
isPreviewActive: () => boolean;
|
||||
}
|
||||
|
||||
export const MarkdownEditor = forwardRef<
|
||||
MarkdownEditorHandle,
|
||||
MarkdownEditorProps
|
||||
>(
|
||||
(
|
||||
{ enablePreview = true, initialPreview = false, className = "", ...props },
|
||||
ref,
|
||||
) => {
|
||||
const textEditorRef = useRef<TextEditorHandle>(null);
|
||||
const [showPreview, setShowPreview] = useState(initialPreview);
|
||||
const [editor, setEditor] = useState<Editor | null>(null);
|
||||
|
||||
// Track when editor is available
|
||||
const handleEditorReady = useCallback(() => {
|
||||
const ed = textEditorRef.current?.getEditor();
|
||||
if (ed) {
|
||||
setEditor(ed);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Toggle preview mode
|
||||
const togglePreview = useCallback(() => {
|
||||
setShowPreview((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Expose handle methods
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focus: () => textEditorRef.current?.focus(),
|
||||
clear: () => textEditorRef.current?.clear(),
|
||||
getContent: () => textEditorRef.current?.getContent() || "",
|
||||
getSerializedContent: () =>
|
||||
textEditorRef.current?.getSerializedContent() || {
|
||||
text: "",
|
||||
emojiTags: [],
|
||||
blobAttachments: [],
|
||||
addressRefs: [],
|
||||
},
|
||||
isEmpty: () => textEditorRef.current?.isEmpty() ?? true,
|
||||
submit: () => textEditorRef.current?.submit(),
|
||||
insertText: (text: string) => textEditorRef.current?.insertText(text),
|
||||
insertBlob: (blob) => textEditorRef.current?.insertBlob(blob),
|
||||
getJSON: () => textEditorRef.current?.getJSON(),
|
||||
setContent: (json) => textEditorRef.current?.setContent(json),
|
||||
getEditor: () => textEditorRef.current?.getEditor(),
|
||||
togglePreview,
|
||||
isPreviewActive: () => showPreview,
|
||||
}),
|
||||
[showPreview, togglePreview],
|
||||
);
|
||||
|
||||
// Get content for preview
|
||||
const previewContent = showPreview
|
||||
? textEditorRef.current?.getContent() || ""
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className={`markdown-editor flex flex-col ${className}`}>
|
||||
<MarkdownToolbar
|
||||
editor={editor}
|
||||
showPreview={showPreview}
|
||||
onTogglePreview={enablePreview ? togglePreview : undefined}
|
||||
disabled={showPreview}
|
||||
/>
|
||||
|
||||
{showPreview ? (
|
||||
<div
|
||||
className="prose prose-sm max-w-none p-3 border border-border rounded bg-muted/30 overflow-y-auto"
|
||||
style={{
|
||||
minHeight: props.minHeight || 200,
|
||||
maxHeight: props.maxHeight || 600,
|
||||
}}
|
||||
>
|
||||
{previewContent ? (
|
||||
<MarkdownContent content={previewContent} />
|
||||
) : (
|
||||
<p className="text-muted-foreground italic">Nothing to preview</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<TextEditor
|
||||
ref={(node) => {
|
||||
textEditorRef.current = node;
|
||||
// Get editor after mount
|
||||
setTimeout(handleEditorReady, 0);
|
||||
}}
|
||||
className="flex-1"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MarkdownEditor.displayName = "MarkdownEditor";
|
||||
275
src/components/editor/MarkdownToolbar.tsx
Normal file
275
src/components/editor/MarkdownToolbar.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* MarkdownToolbar - Formatting toolbar for markdown editors
|
||||
*
|
||||
* Provides buttons for common markdown formatting operations:
|
||||
* - Bold, Italic, Code (inline)
|
||||
* - Heading, Link, Quote, List (block)
|
||||
*
|
||||
* Works with TipTap editor by inserting markdown syntax at cursor.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Code,
|
||||
Link,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Heading2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
export interface MarkdownToolbarProps {
|
||||
editor: Editor | null;
|
||||
/** Whether preview mode is active */
|
||||
showPreview?: boolean;
|
||||
/** Toggle preview mode */
|
||||
onTogglePreview?: () => void;
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
/** Disable all buttons */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
icon,
|
||||
label,
|
||||
shortcut,
|
||||
onClick,
|
||||
disabled,
|
||||
active,
|
||||
}: ToolbarButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={active ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>
|
||||
{label}
|
||||
{shortcut && (
|
||||
<span className="ml-2 text-muted-foreground">{shortcut}</span>
|
||||
)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarkdownToolbar({
|
||||
editor,
|
||||
showPreview,
|
||||
onTogglePreview,
|
||||
className = "",
|
||||
disabled = false,
|
||||
}: MarkdownToolbarProps) {
|
||||
// Wrap selection with markdown syntax
|
||||
const wrapSelection = useCallback(
|
||||
(before: string, after: string = before) => {
|
||||
if (!editor) return;
|
||||
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
|
||||
if (empty) {
|
||||
// No selection - insert placeholder
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent(`${before}text${after}`)
|
||||
.setTextSelection({
|
||||
from: from + before.length,
|
||||
to: from + before.length + 4,
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
// Wrap selection
|
||||
const selectedText = editor.state.doc.textBetween(from, to);
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteSelection()
|
||||
.insertContent(`${before}${selectedText}${after}`)
|
||||
.run();
|
||||
}
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
// Insert at start of line(s)
|
||||
const insertAtLineStart = useCallback(
|
||||
(prefix: string) => {
|
||||
if (!editor) return;
|
||||
|
||||
const { from } = editor.state.selection;
|
||||
|
||||
// Find the start of the current line
|
||||
const $from = editor.state.doc.resolve(from);
|
||||
const lineStart = $from.start();
|
||||
|
||||
editor.chain().focus().insertContentAt(lineStart, prefix).run();
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
// Insert link with placeholder
|
||||
const insertLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
|
||||
if (empty) {
|
||||
// No selection - insert placeholder link
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent("[link text](url)")
|
||||
.setTextSelection({ from: from + 1, to: from + 10 })
|
||||
.run();
|
||||
} else {
|
||||
// Use selection as link text
|
||||
const selectedText = editor.state.doc.textBetween(from, to);
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteSelection()
|
||||
.insertContent(`[${selectedText}](url)`)
|
||||
.setTextSelection({
|
||||
from: from + selectedText.length + 3,
|
||||
to: from + selectedText.length + 6,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
// Check if editor has a specific mark active
|
||||
const isActive = useCallback(
|
||||
(mark: string) => {
|
||||
if (!editor) return false;
|
||||
return editor.isActive(mark);
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const isDisabled = disabled || !editor;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-0.5 border-b border-border pb-2 mb-2 ${className}`}
|
||||
>
|
||||
{/* Text formatting */}
|
||||
<ToolbarButton
|
||||
icon={<Bold className="h-4 w-4" />}
|
||||
label="Bold"
|
||||
shortcut="Ctrl+B"
|
||||
onClick={() => wrapSelection("**")}
|
||||
disabled={isDisabled}
|
||||
active={isActive("bold")}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Italic className="h-4 w-4" />}
|
||||
label="Italic"
|
||||
shortcut="Ctrl+I"
|
||||
onClick={() => wrapSelection("*")}
|
||||
disabled={isDisabled}
|
||||
active={isActive("italic")}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Code className="h-4 w-4" />}
|
||||
label="Inline code"
|
||||
shortcut="Ctrl+`"
|
||||
onClick={() => wrapSelection("`")}
|
||||
disabled={isDisabled}
|
||||
active={isActive("code")}
|
||||
/>
|
||||
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
{/* Block formatting */}
|
||||
<ToolbarButton
|
||||
icon={<Heading2 className="h-4 w-4" />}
|
||||
label="Heading"
|
||||
onClick={() => insertAtLineStart("## ")}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Quote className="h-4 w-4" />}
|
||||
label="Quote"
|
||||
onClick={() => insertAtLineStart("> ")}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<List className="h-4 w-4" />}
|
||||
label="Bullet list"
|
||||
onClick={() => insertAtLineStart("- ")}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<ListOrdered className="h-4 w-4" />}
|
||||
label="Numbered list"
|
||||
onClick={() => insertAtLineStart("1. ")}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
{/* Link */}
|
||||
<ToolbarButton
|
||||
icon={<Link className="h-4 w-4" />}
|
||||
label="Insert link"
|
||||
shortcut="Ctrl+K"
|
||||
onClick={insertLink}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Preview toggle */}
|
||||
{onTogglePreview && (
|
||||
<Button
|
||||
variant={showPreview ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={onTogglePreview}
|
||||
disabled={disabled}
|
||||
className="h-8 px-2 gap-1.5"
|
||||
type="button"
|
||||
>
|
||||
{showPreview ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-xs">Preview</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,45 +34,15 @@ import { nip19 } from "nostr-tools";
|
||||
import { NostrPasteHandler } from "./extensions/nostr-paste-handler";
|
||||
import { FilePasteHandler } from "./extensions/file-paste-handler";
|
||||
|
||||
/**
|
||||
* Represents an emoji tag for NIP-30
|
||||
*/
|
||||
export interface EmojiTag {
|
||||
shortcode: string;
|
||||
url: string;
|
||||
}
|
||||
// Re-export types from core for backwards compatibility
|
||||
export type {
|
||||
EmojiTag,
|
||||
BlobAttachment,
|
||||
SerializedContent,
|
||||
AddressRef,
|
||||
} from "./core";
|
||||
|
||||
/**
|
||||
* Represents a blob attachment for imeta tags (NIP-92)
|
||||
*/
|
||||
export interface BlobAttachment {
|
||||
/** The URL of the blob */
|
||||
url: string;
|
||||
/** SHA256 hash of the blob content */
|
||||
sha256: string;
|
||||
/** MIME type of the blob */
|
||||
mimeType?: string;
|
||||
/** Size in bytes */
|
||||
size?: number;
|
||||
/** Blossom server URL */
|
||||
server?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of serializing editor content
|
||||
* Note: mentions, event quotes, and hashtags are extracted automatically by applesauce
|
||||
* from the text content (nostr: URIs and #hashtags), so we don't need to extract them here.
|
||||
*/
|
||||
export interface SerializedContent {
|
||||
/** The text content with mentions as nostr: URIs and emoji as :shortcode: */
|
||||
text: string;
|
||||
/** Emoji tags to include in the event (NIP-30) */
|
||||
emojiTags: EmojiTag[];
|
||||
/** Blob attachments for imeta tags (NIP-92) */
|
||||
blobAttachments: BlobAttachment[];
|
||||
/** Referenced addresses for a tags (from naddr - not yet handled by applesauce) */
|
||||
addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>;
|
||||
}
|
||||
import type { EmojiTag, BlobAttachment, SerializedContent } from "./core";
|
||||
|
||||
export interface MentionEditorProps {
|
||||
placeholder?: string;
|
||||
@@ -103,84 +73,8 @@ export interface MentionEditorHandle {
|
||||
insertBlob: (blob: BlobAttachment) => void;
|
||||
}
|
||||
|
||||
// Create emoji extension by extending Mention with a different name and custom node view
|
||||
const EmojiMention = Mention.extend({
|
||||
name: "emoji",
|
||||
|
||||
// Add custom attributes for emoji (url and source)
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
url: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-url"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.url) return {};
|
||||
return { "data-url": attributes.url };
|
||||
},
|
||||
},
|
||||
source: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-source"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.source) return {};
|
||||
return { "data-source": attributes.source };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// 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,
|
||||
};
|
||||
};
|
||||
},
|
||||
});
|
||||
// Import shared EmojiMention extension from core
|
||||
import { EmojiMention } from "./core";
|
||||
|
||||
// Create blob attachment extension for media previews
|
||||
const BlobAttachmentNode = Node.create({
|
||||
|
||||
@@ -1,631 +1,11 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react";
|
||||
import { Extension } 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 type { ProfileSearchResult } from "@/services/profile-search";
|
||||
import type { EmojiSearchResult } from "@/services/emoji-search";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { NostrPasteHandler } from "./extensions/nostr-paste-handler";
|
||||
import { FilePasteHandler } from "./extensions/file-paste-handler";
|
||||
import { BlobAttachmentRichNode } from "./extensions/blob-attachment-rich";
|
||||
import { NostrEventPreviewRichNode } from "./extensions/nostr-event-preview-rich";
|
||||
import type {
|
||||
EmojiTag,
|
||||
BlobAttachment,
|
||||
SerializedContent,
|
||||
} from "./MentionEditor";
|
||||
|
||||
export interface RichEditorProps {
|
||||
placeholder?: string;
|
||||
onSubmit?: (
|
||||
content: string,
|
||||
emojiTags: EmojiTag[],
|
||||
blobAttachments: BlobAttachment[],
|
||||
addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>,
|
||||
) => void;
|
||||
onChange?: () => void;
|
||||
searchProfiles: (query: string) => Promise<ProfileSearchResult[]>;
|
||||
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
|
||||
onFilePaste?: (files: File[]) => void;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
/** Minimum height in pixels */
|
||||
minHeight?: number;
|
||||
/** Maximum height in pixels */
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export interface RichEditorHandle {
|
||||
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;
|
||||
/** Get editor state as JSON (for persistence) */
|
||||
getJSON: () => any;
|
||||
/** Set editor content from JSON (for restoration) */
|
||||
setContent: (json: any) => void;
|
||||
}
|
||||
|
||||
// Create emoji extension by extending Mention with a different name and custom node view
|
||||
const EmojiMention = Mention.extend({
|
||||
name: "emoji",
|
||||
|
||||
// Add custom attributes for emoji (url and source)
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
url: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-url"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.url) return {};
|
||||
return { "data-url": attributes.url };
|
||||
},
|
||||
},
|
||||
source: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-source"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.source) return {};
|
||||
return { "data-source": attributes.source };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// Override renderText to return empty string (nodeView handles display)
|
||||
renderText({ node }) {
|
||||
// Return the emoji character for unicode, or empty for custom
|
||||
// This is what gets copied to clipboard
|
||||
if (node.attrs.source === "unicode") {
|
||||
return node.attrs.url || "";
|
||||
}
|
||||
return `:${node.attrs.id}:`;
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node }) => {
|
||||
const { url, source, id } = node.attrs;
|
||||
const isUnicode = source === "unicode";
|
||||
|
||||
// Create wrapper span
|
||||
const dom = document.createElement("span");
|
||||
dom.className = "emoji-node";
|
||||
dom.setAttribute("data-emoji", id || "");
|
||||
|
||||
if (isUnicode && url) {
|
||||
// Unicode emoji - render as text span
|
||||
const span = document.createElement("span");
|
||||
span.className = "emoji-unicode";
|
||||
span.textContent = url;
|
||||
span.title = `:${id}:`;
|
||||
dom.appendChild(span);
|
||||
} else if (url) {
|
||||
// Custom emoji - render as image
|
||||
const img = document.createElement("img");
|
||||
img.src = url;
|
||||
img.alt = `:${id}:`;
|
||||
img.title = `:${id}:`;
|
||||
img.className = "emoji-image";
|
||||
img.draggable = false;
|
||||
img.onerror = () => {
|
||||
// Fallback to shortcode if image fails to load
|
||||
dom.textContent = `:${id}:`;
|
||||
};
|
||||
dom.appendChild(img);
|
||||
} else {
|
||||
// Fallback if no url - show shortcode
|
||||
dom.textContent = `:${id}:`;
|
||||
}
|
||||
|
||||
return {
|
||||
dom,
|
||||
};
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Serialize editor content to plain text with nostr: URIs
|
||||
* Note: hashtags, mentions, and event quotes are extracted automatically by applesauce's
|
||||
* NoteBlueprint from the text content, so we only need to extract what it doesn't handle:
|
||||
* - Custom emojis (for emoji tags)
|
||||
* - Blob attachments (for imeta tags)
|
||||
* - Address references (naddr - not yet supported by applesauce)
|
||||
* @deprecated Use TextEditor instead. RichEditor is kept for backwards compatibility.
|
||||
*/
|
||||
function serializeContent(editor: any): SerializedContent {
|
||||
const emojiTags: EmojiTag[] = [];
|
||||
const blobAttachments: BlobAttachment[] = [];
|
||||
const addressRefs: Array<{
|
||||
kind: number;
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
}> = [];
|
||||
const seenEmojis = new Set<string>();
|
||||
const seenBlobs = new Set<string>();
|
||||
const seenAddrs = new Set<string>();
|
||||
|
||||
// Get plain text representation with single newline between blocks
|
||||
// (TipTap's default is double newline which adds extra blank lines)
|
||||
const text = editor.getText({ blockSeparator: "\n" });
|
||||
|
||||
// Walk the document to collect emoji, blob, and address reference data
|
||||
editor.state.doc.descendants((node: any) => {
|
||||
if (node.type.name === "emoji") {
|
||||
const { id, url, source } = node.attrs;
|
||||
// Only add custom emojis (not unicode) and avoid duplicates
|
||||
if (source !== "unicode" && !seenEmojis.has(id)) {
|
||||
seenEmojis.add(id);
|
||||
emojiTags.push({ shortcode: id, url });
|
||||
}
|
||||
} else if (node.type.name === "blobAttachment") {
|
||||
const { url, sha256, mimeType, size, server } = node.attrs;
|
||||
// Avoid duplicates
|
||||
if (!seenBlobs.has(sha256)) {
|
||||
seenBlobs.add(sha256);
|
||||
blobAttachments.push({ url, sha256, mimeType, size, server });
|
||||
}
|
||||
} else if (node.type.name === "nostrEventPreview") {
|
||||
// Extract address references (naddr) for manual a tags
|
||||
// Note: applesauce handles note/nevent automatically from nostr: URIs
|
||||
const { type, data } = node.attrs;
|
||||
if (type === "naddr" && data) {
|
||||
const addrKey = `${data.kind}:${data.pubkey}:${data.identifier || ""}`;
|
||||
if (!seenAddrs.has(addrKey)) {
|
||||
seenAddrs.add(addrKey);
|
||||
addressRefs.push({
|
||||
kind: data.kind,
|
||||
pubkey: data.pubkey,
|
||||
identifier: data.identifier || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
text,
|
||||
emojiTags,
|
||||
blobAttachments,
|
||||
addressRefs,
|
||||
};
|
||||
}
|
||||
|
||||
export const RichEditor = forwardRef<RichEditorHandle, RichEditorProps>(
|
||||
(
|
||||
{
|
||||
placeholder = "Write your note...",
|
||||
onSubmit,
|
||||
onChange,
|
||||
searchProfiles,
|
||||
searchEmojis,
|
||||
onFilePaste,
|
||||
autoFocus = false,
|
||||
className = "",
|
||||
minHeight = 200,
|
||||
maxHeight = 600,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
// Ref to access handleSubmit from keyboard shortcuts
|
||||
const handleSubmitRef = useRef<(editor: any) => void>(() => {});
|
||||
|
||||
// Create mention suggestion configuration for @ mentions
|
||||
const mentionSuggestion: Omit<SuggestionOptions, "editor"> = useMemo(
|
||||
() => ({
|
||||
char: "@",
|
||||
allowSpaces: false,
|
||||
items: async ({ query }) => {
|
||||
return await searchProfiles(query);
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<ProfileSuggestionListHandle>;
|
||||
let popup: TippyInstance[];
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(ProfileSuggestionList, {
|
||||
props: { items: [], command: props.command },
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
theme: "mention",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0].hide();
|
||||
return true;
|
||||
}
|
||||
return component.ref?.onKeyDown(props.event) || false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0].destroy();
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
[searchProfiles],
|
||||
);
|
||||
|
||||
// Create emoji suggestion configuration for : emojis
|
||||
const emojiSuggestion: Omit<SuggestionOptions, "editor"> | undefined =
|
||||
useMemo(() => {
|
||||
if (!searchEmojis) return undefined;
|
||||
|
||||
return {
|
||||
char: ":",
|
||||
allowSpaces: false,
|
||||
items: async ({ query }) => {
|
||||
return await searchEmojis(query);
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<EmojiSuggestionListHandle>;
|
||||
let popup: TippyInstance[];
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(EmojiSuggestionList, {
|
||||
props: { items: [], command: props.command },
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
theme: "mention",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0].hide();
|
||||
return true;
|
||||
}
|
||||
return component.ref?.onKeyDown(props.event) || false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0].destroy();
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}, [searchEmojis]);
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = useCallback(
|
||||
(editorInstance: any) => {
|
||||
if (editorInstance.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = serializeContent(editorInstance);
|
||||
|
||||
if (onSubmit) {
|
||||
onSubmit(
|
||||
serialized.text,
|
||||
serialized.emojiTags,
|
||||
serialized.blobAttachments,
|
||||
serialized.addressRefs,
|
||||
);
|
||||
// Don't clear content here - let the parent component decide when to clear
|
||||
}
|
||||
},
|
||||
[onSubmit],
|
||||
);
|
||||
|
||||
// Keep ref updated with latest handleSubmit
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
|
||||
// Build extensions array
|
||||
const extensions = useMemo(() => {
|
||||
// Custom extension for keyboard shortcuts
|
||||
const SubmitShortcut = Extension.create({
|
||||
name: "submitShortcut",
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// Ctrl/Cmd+Enter submits
|
||||
"Mod-Enter": ({ editor }) => {
|
||||
handleSubmitRef.current(editor);
|
||||
return true;
|
||||
},
|
||||
// Plain Enter creates a new line (default behavior)
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const exts = [
|
||||
SubmitShortcut,
|
||||
StarterKit.configure({
|
||||
// Enable paragraph, hardBreak, etc. for multi-line
|
||||
hardBreak: {
|
||||
keepMarks: false,
|
||||
},
|
||||
}),
|
||||
Mention.extend({
|
||||
renderText({ node }) {
|
||||
// Serialize to nostr: URI for plain text export
|
||||
try {
|
||||
return `nostr:${nip19.npubEncode(node.attrs.id)}`;
|
||||
} catch (err) {
|
||||
console.error("[Mention] Failed to encode pubkey:", err);
|
||||
return `@${node.attrs.label}`;
|
||||
}
|
||||
},
|
||||
}).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 full-size media previews
|
||||
BlobAttachmentRichNode,
|
||||
// Add nostr event preview extension for full event rendering
|
||||
NostrEventPreviewRichNode,
|
||||
// Add paste handler to transform bech32 strings into previews
|
||||
NostrPasteHandler,
|
||||
// Add file paste handler for clipboard file uploads
|
||||
FilePasteHandler.configure({
|
||||
onFilePaste,
|
||||
}),
|
||||
];
|
||||
|
||||
// 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();
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return exts;
|
||||
}, [mentionSuggestion, emojiSuggestion, onFilePaste, placeholder]);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "prose prose-sm max-w-none focus:outline-none",
|
||||
style: `min-height: ${minHeight}px; max-height: ${maxHeight}px; overflow-y: auto;`,
|
||||
},
|
||||
},
|
||||
autofocus: autoFocus,
|
||||
onUpdate: () => {
|
||||
onChange?.();
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to check if editor view is ready (prevents "view not available" errors)
|
||||
const isEditorReady = useCallback(() => {
|
||||
return editor && editor.view && editor.view.dom;
|
||||
}, [editor]);
|
||||
|
||||
// Expose editor methods
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focus: () => {
|
||||
if (isEditorReady()) {
|
||||
editor?.commands.focus();
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
if (isEditorReady()) {
|
||||
editor?.commands.clearContent();
|
||||
}
|
||||
},
|
||||
getContent: () => {
|
||||
if (!isEditorReady()) return "";
|
||||
return editor?.getText({ blockSeparator: "\n" }) || "";
|
||||
},
|
||||
getSerializedContent: () => {
|
||||
if (!isEditorReady() || !editor)
|
||||
return {
|
||||
text: "",
|
||||
emojiTags: [],
|
||||
blobAttachments: [],
|
||||
addressRefs: [],
|
||||
};
|
||||
return serializeContent(editor);
|
||||
},
|
||||
isEmpty: () => {
|
||||
if (!isEditorReady()) return true;
|
||||
return editor?.isEmpty ?? true;
|
||||
},
|
||||
submit: () => {
|
||||
if (isEditorReady() && editor) {
|
||||
handleSubmit(editor);
|
||||
}
|
||||
},
|
||||
insertText: (text: string) => {
|
||||
if (isEditorReady()) {
|
||||
editor?.commands.insertContent(text);
|
||||
}
|
||||
},
|
||||
insertBlob: (blob: BlobAttachment) => {
|
||||
if (isEditorReady()) {
|
||||
editor?.commands.insertContent({
|
||||
type: "blobAttachment",
|
||||
attrs: blob,
|
||||
});
|
||||
}
|
||||
},
|
||||
getJSON: () => {
|
||||
if (!isEditorReady()) return null;
|
||||
return editor?.getJSON() || null;
|
||||
},
|
||||
setContent: (json: any) => {
|
||||
// Check editor and view are ready before setting content
|
||||
if (isEditorReady() && json) {
|
||||
editor?.commands.setContent(json);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[editor, handleSubmit, isEditorReady],
|
||||
);
|
||||
|
||||
// Handle submit on Ctrl/Cmd+Enter
|
||||
useEffect(() => {
|
||||
// Check both editor and editor.view exist (view may not be ready immediately)
|
||||
if (!editor?.view?.dom) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleSubmit(editor);
|
||||
}
|
||||
};
|
||||
|
||||
editor.view.dom.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
// Also check view.dom exists in cleanup (editor might be destroyed)
|
||||
editor.view?.dom?.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [editor, handleSubmit]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rich-editor ${className}`}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RichEditor.displayName = "RichEditor";
|
||||
export {
|
||||
TextEditor as RichEditor,
|
||||
type TextEditorHandle as RichEditorHandle,
|
||||
type TextEditorProps as RichEditorProps,
|
||||
type EmojiTag,
|
||||
type BlobAttachment,
|
||||
type SerializedContent,
|
||||
} from "./TextEditor";
|
||||
|
||||
479
src/components/editor/TextEditor.tsx
Normal file
479
src/components/editor/TextEditor.tsx
Normal file
@@ -0,0 +1,479 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react";
|
||||
import { Extension } 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 type { ProfileSearchResult } from "@/services/profile-search";
|
||||
import type { EmojiSearchResult } from "@/services/emoji-search";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { NostrPasteHandler } from "./extensions/nostr-paste-handler";
|
||||
import { FilePasteHandler } from "./extensions/file-paste-handler";
|
||||
import { BlobAttachmentRichNode } from "./extensions/blob-attachment-rich";
|
||||
import { NostrEventPreviewRichNode } from "./extensions/nostr-event-preview-rich";
|
||||
import {
|
||||
EmojiMention,
|
||||
serializeEditorContent,
|
||||
emptySerializedContent,
|
||||
type TextEditorHandle,
|
||||
type EmojiTag,
|
||||
type BlobAttachment,
|
||||
type SerializedContent,
|
||||
} from "./core";
|
||||
|
||||
export interface TextEditorProps {
|
||||
placeholder?: string;
|
||||
onSubmit?: (
|
||||
content: string,
|
||||
emojiTags: EmojiTag[],
|
||||
blobAttachments: BlobAttachment[],
|
||||
addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>,
|
||||
) => void;
|
||||
onChange?: () => void;
|
||||
searchProfiles: (query: string) => Promise<ProfileSearchResult[]>;
|
||||
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
|
||||
onFilePaste?: (files: File[]) => void;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
/** Minimum height in pixels */
|
||||
minHeight?: number;
|
||||
/** Maximum height in pixels */
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
// Re-export types and handle for consumers
|
||||
export type { TextEditorHandle, EmojiTag, BlobAttachment, SerializedContent };
|
||||
|
||||
export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
(
|
||||
{
|
||||
placeholder = "Write your note...",
|
||||
onSubmit,
|
||||
onChange,
|
||||
searchProfiles,
|
||||
searchEmojis,
|
||||
onFilePaste,
|
||||
autoFocus = false,
|
||||
className = "",
|
||||
minHeight = 200,
|
||||
maxHeight = 600,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
// Ref to access handleSubmit from keyboard shortcuts
|
||||
const handleSubmitRef = useRef<(editor: any) => void>(() => {});
|
||||
|
||||
// Create mention suggestion configuration for @ mentions
|
||||
const mentionSuggestion: Omit<SuggestionOptions, "editor"> = useMemo(
|
||||
() => ({
|
||||
char: "@",
|
||||
allowSpaces: false,
|
||||
items: async ({ query }) => {
|
||||
return await searchProfiles(query);
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<ProfileSuggestionListHandle>;
|
||||
let popup: TippyInstance[];
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(ProfileSuggestionList, {
|
||||
props: { items: [], command: props.command },
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
theme: "mention",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0].hide();
|
||||
return true;
|
||||
}
|
||||
return component.ref?.onKeyDown(props.event) || false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0].destroy();
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
[searchProfiles],
|
||||
);
|
||||
|
||||
// Create emoji suggestion configuration for : emojis
|
||||
const emojiSuggestion: Omit<SuggestionOptions, "editor"> | undefined =
|
||||
useMemo(() => {
|
||||
if (!searchEmojis) return undefined;
|
||||
|
||||
return {
|
||||
char: ":",
|
||||
allowSpaces: false,
|
||||
items: async ({ query }) => {
|
||||
return await searchEmojis(query);
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<EmojiSuggestionListHandle>;
|
||||
let popup: TippyInstance[];
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(EmojiSuggestionList, {
|
||||
props: { items: [], command: props.command },
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
theme: "mention",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0].hide();
|
||||
return true;
|
||||
}
|
||||
return component.ref?.onKeyDown(props.event) || false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0].destroy();
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}, [searchEmojis]);
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = useCallback(
|
||||
(editorInstance: any) => {
|
||||
if (editorInstance.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = serializeEditorContent(editorInstance);
|
||||
|
||||
if (onSubmit) {
|
||||
onSubmit(
|
||||
serialized.text,
|
||||
serialized.emojiTags,
|
||||
serialized.blobAttachments,
|
||||
serialized.addressRefs,
|
||||
);
|
||||
// Don't clear content here - let the parent component decide when to clear
|
||||
}
|
||||
},
|
||||
[onSubmit],
|
||||
);
|
||||
|
||||
// Keep ref updated with latest handleSubmit
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
|
||||
// Build extensions array
|
||||
const extensions = useMemo(() => {
|
||||
// Custom extension for keyboard shortcuts
|
||||
const SubmitShortcut = Extension.create({
|
||||
name: "submitShortcut",
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// Ctrl/Cmd+Enter submits
|
||||
"Mod-Enter": ({ editor }) => {
|
||||
handleSubmitRef.current(editor);
|
||||
return true;
|
||||
},
|
||||
// Plain Enter creates a new line (default behavior)
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const exts = [
|
||||
SubmitShortcut,
|
||||
StarterKit.configure({
|
||||
// Enable paragraph, hardBreak, etc. for multi-line
|
||||
hardBreak: {
|
||||
keepMarks: false,
|
||||
},
|
||||
}),
|
||||
Mention.extend({
|
||||
renderText({ node }) {
|
||||
// Serialize to nostr: URI for plain text export
|
||||
try {
|
||||
return `nostr:${nip19.npubEncode(node.attrs.id)}`;
|
||||
} catch (err) {
|
||||
console.error("[Mention] Failed to encode pubkey:", err);
|
||||
return `@${node.attrs.label}`;
|
||||
}
|
||||
},
|
||||
}).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 full-size media previews
|
||||
BlobAttachmentRichNode,
|
||||
// Add nostr event preview extension for full event rendering
|
||||
NostrEventPreviewRichNode,
|
||||
// Add paste handler to transform bech32 strings into previews
|
||||
NostrPasteHandler,
|
||||
// Add file paste handler for clipboard file uploads
|
||||
FilePasteHandler.configure({
|
||||
onFilePaste,
|
||||
}),
|
||||
];
|
||||
|
||||
// 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();
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return exts;
|
||||
}, [mentionSuggestion, emojiSuggestion, onFilePaste, placeholder]);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "prose prose-sm max-w-none focus:outline-none",
|
||||
style: `min-height: ${minHeight}px; max-height: ${maxHeight}px; overflow-y: auto;`,
|
||||
},
|
||||
},
|
||||
autofocus: autoFocus,
|
||||
onUpdate: () => {
|
||||
onChange?.();
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to check if editor view is ready (prevents "view not available" errors)
|
||||
const isEditorReady = useCallback(() => {
|
||||
return editor && editor.view && editor.view.dom;
|
||||
}, [editor]);
|
||||
|
||||
// Expose editor methods
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focus: () => {
|
||||
if (isEditorReady()) {
|
||||
editor?.commands.focus();
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
if (isEditorReady()) {
|
||||
editor?.commands.clearContent();
|
||||
}
|
||||
},
|
||||
getContent: () => {
|
||||
if (!isEditorReady()) return "";
|
||||
return editor?.getText({ blockSeparator: "\n" }) || "";
|
||||
},
|
||||
getSerializedContent: () => {
|
||||
if (!isEditorReady() || !editor) return emptySerializedContent();
|
||||
return serializeEditorContent(editor);
|
||||
},
|
||||
isEmpty: () => {
|
||||
if (!isEditorReady()) return true;
|
||||
return editor?.isEmpty ?? true;
|
||||
},
|
||||
submit: () => {
|
||||
if (isEditorReady() && editor) {
|
||||
handleSubmit(editor);
|
||||
}
|
||||
},
|
||||
insertText: (text: string) => {
|
||||
if (isEditorReady()) {
|
||||
editor?.commands.insertContent(text);
|
||||
}
|
||||
},
|
||||
insertBlob: (blob: BlobAttachment) => {
|
||||
if (isEditorReady()) {
|
||||
editor?.commands.insertContent({
|
||||
type: "blobAttachment",
|
||||
attrs: blob,
|
||||
});
|
||||
}
|
||||
},
|
||||
getJSON: () => {
|
||||
if (!isEditorReady()) return null;
|
||||
return editor?.getJSON() || null;
|
||||
},
|
||||
setContent: (json: any) => {
|
||||
// Check editor and view are ready before setting content
|
||||
if (isEditorReady() && json) {
|
||||
editor?.commands.setContent(json);
|
||||
}
|
||||
},
|
||||
getEditor: () => {
|
||||
if (!isEditorReady()) return null;
|
||||
return editor;
|
||||
},
|
||||
}),
|
||||
[editor, handleSubmit, isEditorReady],
|
||||
);
|
||||
|
||||
// Handle submit on Ctrl/Cmd+Enter
|
||||
useEffect(() => {
|
||||
// Check both editor and editor.view exist (view may not be ready immediately)
|
||||
if (!editor?.view?.dom) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleSubmit(editor);
|
||||
}
|
||||
};
|
||||
|
||||
editor.view.dom.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
// Also check view.dom exists in cleanup (editor might be destroyed)
|
||||
editor.view?.dom?.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [editor, handleSubmit]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rich-editor ${className}`}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TextEditor.displayName = "TextEditor";
|
||||
|
||||
// Backwards compatibility alias
|
||||
export const RichEditor = TextEditor;
|
||||
export type RichEditorHandle = TextEditorHandle;
|
||||
export type RichEditorProps = TextEditorProps;
|
||||
90
src/components/editor/core/emoji-mention.ts
Normal file
90
src/components/editor/core/emoji-mention.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* EmojiMention - TipTap extension for emoji autocomplete
|
||||
*
|
||||
* Supports both Unicode emojis and custom Nostr emojis (NIP-30).
|
||||
* Triggered by typing ":" followed by a shortcode.
|
||||
*/
|
||||
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
|
||||
/**
|
||||
* Extended Mention node for emoji support
|
||||
* - Unicode emojis render as text
|
||||
* - Custom emojis render as images with fallback to shortcode
|
||||
*/
|
||||
export 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 appropriate text for clipboard
|
||||
renderText({ node }) {
|
||||
// Return the emoji character for unicode, or :shortcode: for custom
|
||||
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,
|
||||
};
|
||||
};
|
||||
},
|
||||
});
|
||||
23
src/components/editor/core/index.ts
Normal file
23
src/components/editor/core/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Core editor utilities and shared code
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
EmojiTag,
|
||||
BlobAttachment,
|
||||
AddressRef,
|
||||
SerializedContent,
|
||||
BaseEditorHandle,
|
||||
TextEditorHandle,
|
||||
} from "./types";
|
||||
|
||||
// Extensions
|
||||
export { EmojiMention } from "./emoji-mention";
|
||||
|
||||
// Serialization
|
||||
export {
|
||||
serializeEditorContent,
|
||||
serializeEditorContentFromJSON,
|
||||
emptySerializedContent,
|
||||
} from "./serialization";
|
||||
203
src/components/editor/core/serialization.ts
Normal file
203
src/components/editor/core/serialization.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Editor content serialization utilities
|
||||
*
|
||||
* Converts TipTap editor content to plain text with Nostr-specific handling:
|
||||
* - Mentions → nostr:npub...
|
||||
* - Emojis → :shortcode: (custom) or unicode character
|
||||
* - Event references → nostr:note.../nevent.../naddr...
|
||||
* - Blob attachments → URL
|
||||
*
|
||||
* Also extracts metadata for NIP tags:
|
||||
* - Custom emoji tags (NIP-30)
|
||||
* - Blob attachments for imeta tags (NIP-92)
|
||||
* - Address references for a tags (naddr)
|
||||
*/
|
||||
|
||||
import { nip19 } from "nostr-tools";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type {
|
||||
SerializedContent,
|
||||
EmojiTag,
|
||||
BlobAttachment,
|
||||
AddressRef,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Serialize editor content using getText + descendants walk
|
||||
* Used by TextEditor (block-level nodes)
|
||||
*/
|
||||
export function serializeEditorContent(editor: Editor): SerializedContent {
|
||||
const emojiTags: EmojiTag[] = [];
|
||||
const blobAttachments: BlobAttachment[] = [];
|
||||
const addressRefs: AddressRef[] = [];
|
||||
const seenEmojis = new Set<string>();
|
||||
const seenBlobs = new Set<string>();
|
||||
const seenAddrs = new Set<string>();
|
||||
|
||||
// Get plain text representation with single newline between blocks
|
||||
// (TipTap's default is double newline which adds extra blank lines)
|
||||
const text = editor.getText({ blockSeparator: "\n" });
|
||||
|
||||
// Walk the document to collect emoji, blob, and address reference data
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === "emoji") {
|
||||
const { id, url, source } = node.attrs;
|
||||
// Only add custom emojis (not unicode) and avoid duplicates
|
||||
if (source !== "unicode" && !seenEmojis.has(id)) {
|
||||
seenEmojis.add(id);
|
||||
emojiTags.push({ shortcode: id, url });
|
||||
}
|
||||
} else if (node.type.name === "blobAttachment") {
|
||||
const { url, sha256, mimeType, size, server } = node.attrs;
|
||||
// Avoid duplicates
|
||||
if (sha256 && !seenBlobs.has(sha256)) {
|
||||
seenBlobs.add(sha256);
|
||||
blobAttachments.push({ url, sha256, mimeType, size, server });
|
||||
}
|
||||
} else if (node.type.name === "nostrEventPreview") {
|
||||
// Extract address references (naddr) for manual a tags
|
||||
// Note: applesauce handles note/nevent automatically from nostr: URIs
|
||||
const { type, data } = node.attrs;
|
||||
if (type === "naddr" && data) {
|
||||
const addrKey = `${data.kind}:${data.pubkey}:${data.identifier || ""}`;
|
||||
if (!seenAddrs.has(addrKey)) {
|
||||
seenAddrs.add(addrKey);
|
||||
addressRefs.push({
|
||||
kind: data.kind,
|
||||
pubkey: data.pubkey,
|
||||
identifier: data.identifier || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
text,
|
||||
emojiTags,
|
||||
blobAttachments,
|
||||
addressRefs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize editor content by walking JSON structure
|
||||
* Used by ChatEditor (inline nodes)
|
||||
*/
|
||||
export function serializeEditorContentFromJSON(
|
||||
editor: Editor,
|
||||
): SerializedContent {
|
||||
let text = "";
|
||||
const emojiTags: EmojiTag[] = [];
|
||||
const blobAttachments: BlobAttachment[] = [];
|
||||
const addressRefs: AddressRef[] = [];
|
||||
const seenEmojis = new Set<string>();
|
||||
const seenBlobs = new Set<string>();
|
||||
const seenAddrs = new Set<string>();
|
||||
const json = editor.getJSON();
|
||||
|
||||
json.content?.forEach((node: any) => {
|
||||
if (node.type === "paragraph") {
|
||||
node.content?.forEach((child: any) => {
|
||||
if (child.type === "text") {
|
||||
text += child.text;
|
||||
} else if (child.type === "hardBreak") {
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (child.type === "nostrEventPreview") {
|
||||
// Nostr event preview - serialize back to nostr: URI
|
||||
const { type, data } = child.attrs;
|
||||
try {
|
||||
if (type === "note") {
|
||||
text += `nostr:${nip19.noteEncode(data)}`;
|
||||
} else if (type === "nevent") {
|
||||
text += `nostr:${nip19.neventEncode(data)}`;
|
||||
} else if (type === "naddr") {
|
||||
text += `nostr:${nip19.naddrEncode(data)}`;
|
||||
// Extract addressRefs for manual a tags (applesauce doesn't handle naddr yet)
|
||||
const addrKey = `${data.kind}:${data.pubkey}:${data.identifier || ""}`;
|
||||
if (!seenAddrs.has(addrKey)) {
|
||||
seenAddrs.add(addrKey);
|
||||
addressRefs.push({
|
||||
kind: data.kind,
|
||||
pubkey: data.pubkey,
|
||||
identifier: data.identifier || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[serializeEditorContent] Failed to serialize nostr preview:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
text += "\n";
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
text: text.trim(),
|
||||
emojiTags,
|
||||
blobAttachments,
|
||||
addressRefs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty serialized content
|
||||
*/
|
||||
export function emptySerializedContent(): SerializedContent {
|
||||
return {
|
||||
text: "",
|
||||
emojiTags: [],
|
||||
blobAttachments: [],
|
||||
addressRefs: [],
|
||||
};
|
||||
}
|
||||
80
src/components/editor/core/types.ts
Normal file
80
src/components/editor/core/types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Shared types for editor components
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address reference for a-tags (from naddr)
|
||||
*/
|
||||
export interface AddressRef {
|
||||
kind: number;
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of serializing editor content
|
||||
* Note: mentions, event quotes, and hashtags are extracted automatically by applesauce
|
||||
* from the text content (nostr: URIs and #hashtags), so we don't need to extract them here.
|
||||
*/
|
||||
export interface SerializedContent {
|
||||
/** The text content with mentions as nostr: URIs and emoji as :shortcode: */
|
||||
text: string;
|
||||
/** Emoji tags to include in the event (NIP-30) */
|
||||
emojiTags: EmojiTag[];
|
||||
/** Blob attachments for imeta tags (NIP-92) */
|
||||
blobAttachments: BlobAttachment[];
|
||||
/** Referenced addresses for a tags (from naddr - not yet handled by applesauce) */
|
||||
addressRefs: AddressRef[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Common editor handle interface
|
||||
*/
|
||||
export interface BaseEditorHandle {
|
||||
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 */
|
||||
insertBlob: (blob: BlobAttachment) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended editor handle with JSON state support (for drafts)
|
||||
*/
|
||||
export interface TextEditorHandle extends BaseEditorHandle {
|
||||
/** Get editor state as JSON (for persistence) */
|
||||
getJSON: () => any;
|
||||
/** Set editor content from JSON (for restoration) */
|
||||
setContent: (json: any) => void;
|
||||
/** Get the underlying TipTap editor instance (for toolbar integration) */
|
||||
getEditor: () => any | null;
|
||||
}
|
||||
47
src/components/editor/index.ts
Normal file
47
src/components/editor/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Editor components for Nostr content creation
|
||||
*
|
||||
* Available editors:
|
||||
* - TextEditor: Base rich text editor with mentions, emoji, media (formerly RichEditor)
|
||||
* - MarkdownEditor: TextEditor with markdown formatting toolbar
|
||||
* - MentionEditor: Lightweight chat/message editor
|
||||
*
|
||||
* For backwards compatibility, RichEditor is exported as an alias for TextEditor.
|
||||
*/
|
||||
|
||||
// Core types
|
||||
export type {
|
||||
EmojiTag,
|
||||
BlobAttachment,
|
||||
AddressRef,
|
||||
SerializedContent,
|
||||
BaseEditorHandle,
|
||||
TextEditorHandle,
|
||||
} from "./core";
|
||||
|
||||
// Text editor (main editor for posts, articles, etc.)
|
||||
export { TextEditor, type TextEditorProps } from "./TextEditor";
|
||||
|
||||
// Markdown editor (TextEditor + formatting toolbar)
|
||||
export {
|
||||
MarkdownEditor,
|
||||
type MarkdownEditorProps,
|
||||
type MarkdownEditorHandle,
|
||||
} from "./MarkdownEditor";
|
||||
|
||||
// Markdown toolbar (can be used standalone)
|
||||
export { MarkdownToolbar, type MarkdownToolbarProps } from "./MarkdownToolbar";
|
||||
|
||||
// Mention editor (lightweight for chat)
|
||||
export {
|
||||
MentionEditor,
|
||||
type MentionEditorProps,
|
||||
type MentionEditorHandle,
|
||||
} from "./MentionEditor";
|
||||
|
||||
// Backwards compatibility - RichEditor is now TextEditor
|
||||
export {
|
||||
RichEditor,
|
||||
type RichEditorHandle,
|
||||
type RichEditorProps,
|
||||
} from "./RichEditor";
|
||||
41
src/lib/composer/index.ts
Normal file
41
src/lib/composer/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Composer module - Schema-driven event composition
|
||||
*
|
||||
* This module provides types and schemas for composing different Nostr event kinds.
|
||||
*/
|
||||
|
||||
// Schema types
|
||||
export type {
|
||||
ComposerSchema,
|
||||
ComposerContext,
|
||||
ComposerInput,
|
||||
ContentType,
|
||||
EditorVariant,
|
||||
TitleFieldConfig,
|
||||
LabelsConfig,
|
||||
MetadataConfig,
|
||||
CustomFieldConfig,
|
||||
ContextBinding,
|
||||
ThreadingStyle,
|
||||
RelayStrategy,
|
||||
MediaConfig,
|
||||
EmojiConfig,
|
||||
IdentifierConfig,
|
||||
DraftConfig,
|
||||
} from "./schema";
|
||||
|
||||
// Utilities
|
||||
export { slugify } from "./schema";
|
||||
|
||||
// Predefined schemas
|
||||
export {
|
||||
NOTE_SCHEMA,
|
||||
COMMENT_SCHEMA,
|
||||
ISSUE_SCHEMA,
|
||||
GROUP_MESSAGE_SCHEMA,
|
||||
ARTICLE_SCHEMA,
|
||||
WIKI_ARTICLE_SCHEMA,
|
||||
SCHEMAS,
|
||||
getSchema,
|
||||
hasSchema,
|
||||
} from "./schemas";
|
||||
245
src/lib/composer/schema.ts
Normal file
245
src/lib/composer/schema.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* ComposerSchema - Schema definitions for event kind composers
|
||||
*
|
||||
* This module defines the structure for describing how to compose different
|
||||
* Nostr event kinds. Each schema specifies:
|
||||
* - Content type and editor to use
|
||||
* - Metadata fields (title, summary, etc.)
|
||||
* - Context binding (what the event relates to)
|
||||
* - Threading style (for replies)
|
||||
* - Relay selection strategy
|
||||
* - Whether the event is replaceable
|
||||
*/
|
||||
|
||||
import type { AddressPointer, EventPointer } from "nostr-tools/nip19";
|
||||
|
||||
/**
|
||||
* Content type determines which editor to use
|
||||
*/
|
||||
export type ContentType = "text" | "markdown";
|
||||
|
||||
/**
|
||||
* Editor variant to render
|
||||
*/
|
||||
export type EditorVariant = "text" | "markdown" | "chat";
|
||||
|
||||
/**
|
||||
* Title/subject field configuration
|
||||
*/
|
||||
export interface TitleFieldConfig {
|
||||
/** Which tag to use for the title */
|
||||
tag: "title" | "subject" | "name";
|
||||
/** Whether the title is required */
|
||||
required: boolean;
|
||||
/** Label shown in the UI */
|
||||
label: string;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label/tag field configuration (for t-tags)
|
||||
*/
|
||||
export interface LabelsConfig {
|
||||
/** Tag name (usually "t") */
|
||||
tag: string;
|
||||
/** How to handle labels */
|
||||
style: "auto-extract" | "explicit" | "both";
|
||||
/** Label shown in the UI */
|
||||
label?: string;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata fields configuration
|
||||
*/
|
||||
export interface MetadataConfig {
|
||||
title?: TitleFieldConfig;
|
||||
summary?: { tag: "summary" | "description"; label?: string };
|
||||
image?: { tag: "image"; label?: string };
|
||||
publishedAt?: { tag: "published_at"; auto?: boolean };
|
||||
labels?: LabelsConfig;
|
||||
/** Custom domain-specific fields */
|
||||
custom?: CustomFieldConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom field configuration for domain-specific metadata
|
||||
*/
|
||||
export interface CustomFieldConfig {
|
||||
tag: string;
|
||||
type: "text" | "number" | "date" | "timestamp" | "select" | "location";
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
options?: string[]; // For select type
|
||||
}
|
||||
|
||||
/**
|
||||
* Context binding - what this event relates to
|
||||
*/
|
||||
export type ContextBinding =
|
||||
| { type: "standalone" }
|
||||
| { type: "address"; tag: "a"; required: boolean }
|
||||
| { type: "event"; tag: "e"; marker?: "root" }
|
||||
| { type: "group"; tag: "h" }
|
||||
| { type: "comment"; style: "nip22" }
|
||||
| { type: "multi"; tags: ("a" | "e" | "p" | "r")[] };
|
||||
|
||||
/**
|
||||
* Threading style for replies
|
||||
*/
|
||||
export type ThreadingStyle =
|
||||
| { style: "none" }
|
||||
| { style: "nip10"; markers: ("root" | "reply")[] }
|
||||
| { style: "nip22" }
|
||||
| { style: "q-tag" }
|
||||
| { style: "custom"; tag: string; values: string[] };
|
||||
|
||||
/**
|
||||
* Relay selection strategy
|
||||
*/
|
||||
export type RelayStrategy =
|
||||
| { type: "user-outbox" }
|
||||
| { type: "user-outbox"; additional: "context-hints" }
|
||||
| { type: "context-only"; fromContext: true }
|
||||
| { type: "address-hints"; fallback: "user-outbox" };
|
||||
|
||||
/**
|
||||
* Media configuration
|
||||
*/
|
||||
export interface MediaConfig {
|
||||
allowed: boolean;
|
||||
tag: "imeta";
|
||||
types?: string[]; // MIME type filter
|
||||
}
|
||||
|
||||
/**
|
||||
* Emoji configuration
|
||||
*/
|
||||
export interface EmojiConfig {
|
||||
allowed: boolean;
|
||||
tag: "emoji";
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifier configuration (for replaceable events)
|
||||
*/
|
||||
export interface IdentifierConfig {
|
||||
tag: "d";
|
||||
source: "auto" | "from-title" | "user-input" | "prop";
|
||||
/** Function to generate identifier from input */
|
||||
generator?: (input: ComposerInput) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draft configuration
|
||||
*/
|
||||
export interface DraftConfig {
|
||||
supported: boolean;
|
||||
/** Kind to use for drafts (e.g., 30024 for 30023 articles) */
|
||||
draftKind?: number;
|
||||
/** Function to generate storage key */
|
||||
storageKey?: (context: ComposerContext) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input provided to identifier generators
|
||||
*/
|
||||
export interface ComposerInput {
|
||||
title?: string;
|
||||
content: string;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Context provided to the composer
|
||||
*/
|
||||
export interface ComposerContext {
|
||||
/** Repository, article, etc. this event relates to */
|
||||
address?: AddressPointer;
|
||||
/** Event this is replying to */
|
||||
replyTo?: EventPointer;
|
||||
/** Group identifier (for NIP-29) */
|
||||
groupId?: string;
|
||||
/** Single relay (for groups) */
|
||||
groupRelay?: string;
|
||||
/** Window/instance ID (for draft storage) */
|
||||
windowId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete schema for a composable event kind
|
||||
*/
|
||||
export interface ComposerSchema {
|
||||
/** Event kind number */
|
||||
kind: number;
|
||||
|
||||
/** Human-readable name */
|
||||
name: string;
|
||||
|
||||
/** Description for UI */
|
||||
description?: string;
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// CONTENT
|
||||
// ═══════════════════════════════════════════════════════
|
||||
content: {
|
||||
type: ContentType;
|
||||
editor: EditorVariant;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// METADATA
|
||||
// ═══════════════════════════════════════════════════════
|
||||
metadata: MetadataConfig;
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// CONTEXT BINDING
|
||||
// ═══════════════════════════════════════════════════════
|
||||
context: ContextBinding;
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// THREADING
|
||||
// ═══════════════════════════════════════════════════════
|
||||
threading: ThreadingStyle;
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// MEDIA & EMOJI
|
||||
// ═══════════════════════════════════════════════════════
|
||||
media: MediaConfig;
|
||||
emoji: EmojiConfig;
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// IDENTIFIER (for replaceable events)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
identifier?: IdentifierConfig;
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// RELAY STRATEGY
|
||||
// ═══════════════════════════════════════════════════════
|
||||
relays: RelayStrategy;
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// BEHAVIOR
|
||||
// ═══════════════════════════════════════════════════════
|
||||
/** Whether this is a replaceable event (kind 10000-19999 or 30000-39999) */
|
||||
replaceable: boolean;
|
||||
|
||||
/** Draft configuration */
|
||||
drafts: DraftConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a slug from title
|
||||
*/
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/[\s_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
300
src/lib/composer/schemas.ts
Normal file
300
src/lib/composer/schemas.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Predefined schemas for common event kinds
|
||||
*
|
||||
* These schemas define how to compose different Nostr event kinds.
|
||||
* Add new schemas here as more kinds are supported.
|
||||
*/
|
||||
|
||||
import type { ComposerSchema } from "./schema";
|
||||
import { slugify } from "./schema";
|
||||
|
||||
/**
|
||||
* Kind 1: Short text note
|
||||
*/
|
||||
export const NOTE_SCHEMA: ComposerSchema = {
|
||||
kind: 1,
|
||||
name: "Note",
|
||||
description: "Short text note",
|
||||
|
||||
content: {
|
||||
type: "text",
|
||||
editor: "text",
|
||||
placeholder: "What's on your mind?",
|
||||
},
|
||||
|
||||
metadata: {
|
||||
title: {
|
||||
tag: "subject",
|
||||
required: false,
|
||||
label: "Subject (optional)",
|
||||
placeholder: "Add a subject line",
|
||||
},
|
||||
labels: {
|
||||
tag: "t",
|
||||
style: "auto-extract",
|
||||
},
|
||||
},
|
||||
|
||||
context: { type: "standalone" },
|
||||
threading: { style: "nip10", markers: ["root", "reply"] },
|
||||
|
||||
media: { allowed: true, tag: "imeta" },
|
||||
emoji: { allowed: true, tag: "emoji" },
|
||||
|
||||
relays: { type: "user-outbox" },
|
||||
|
||||
replaceable: false,
|
||||
drafts: {
|
||||
supported: true,
|
||||
storageKey: (ctx) => `note-draft-${ctx.windowId || "default"}`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Kind 1111: Comment (NIP-22)
|
||||
*/
|
||||
export const COMMENT_SCHEMA: ComposerSchema = {
|
||||
kind: 1111,
|
||||
name: "Comment",
|
||||
description: "Comment on any Nostr event or external content",
|
||||
|
||||
content: {
|
||||
type: "markdown",
|
||||
editor: "text", // Comments use plain text editor, not markdown toolbar
|
||||
placeholder: "Write a comment...",
|
||||
},
|
||||
|
||||
metadata: {
|
||||
labels: {
|
||||
tag: "t",
|
||||
style: "auto-extract",
|
||||
},
|
||||
},
|
||||
|
||||
context: { type: "comment", style: "nip22" },
|
||||
threading: { style: "nip22" },
|
||||
|
||||
media: { allowed: true, tag: "imeta" },
|
||||
emoji: { allowed: true, tag: "emoji" },
|
||||
|
||||
relays: { type: "user-outbox", additional: "context-hints" },
|
||||
|
||||
replaceable: false,
|
||||
drafts: { supported: false },
|
||||
};
|
||||
|
||||
/**
|
||||
* Kind 1621: Issue (NIP-34)
|
||||
*/
|
||||
export const ISSUE_SCHEMA: ComposerSchema = {
|
||||
kind: 1621,
|
||||
name: "Issue",
|
||||
description: "Bug report or feature request for a repository",
|
||||
|
||||
content: {
|
||||
type: "markdown",
|
||||
editor: "markdown",
|
||||
placeholder: "Describe the issue...",
|
||||
},
|
||||
|
||||
metadata: {
|
||||
title: {
|
||||
tag: "subject",
|
||||
required: false,
|
||||
label: "Title (optional)",
|
||||
placeholder: "Brief description of the issue",
|
||||
},
|
||||
labels: {
|
||||
tag: "t",
|
||||
style: "explicit",
|
||||
label: "Labels",
|
||||
placeholder: "bug, enhancement, help-wanted",
|
||||
},
|
||||
},
|
||||
|
||||
context: { type: "address", tag: "a", required: true },
|
||||
threading: { style: "nip10", markers: ["root", "reply"] },
|
||||
|
||||
media: { allowed: true, tag: "imeta" },
|
||||
emoji: { allowed: true, tag: "emoji" },
|
||||
|
||||
relays: { type: "address-hints", fallback: "user-outbox" },
|
||||
|
||||
replaceable: false,
|
||||
drafts: {
|
||||
supported: true,
|
||||
storageKey: (ctx) =>
|
||||
ctx.address
|
||||
? `issue-draft-${ctx.address.kind}:${ctx.address.pubkey}:${ctx.address.identifier}`
|
||||
: "issue-draft-new",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Kind 9: Group chat message (NIP-29)
|
||||
*/
|
||||
export const GROUP_MESSAGE_SCHEMA: ComposerSchema = {
|
||||
kind: 9,
|
||||
name: "Group Message",
|
||||
description: "Message in a relay-based group",
|
||||
|
||||
content: {
|
||||
type: "text",
|
||||
editor: "chat",
|
||||
placeholder: "Type a message...",
|
||||
},
|
||||
|
||||
metadata: {
|
||||
labels: {
|
||||
tag: "t",
|
||||
style: "auto-extract",
|
||||
},
|
||||
},
|
||||
|
||||
context: { type: "group", tag: "h" },
|
||||
threading: { style: "q-tag" },
|
||||
|
||||
media: { allowed: true, tag: "imeta" },
|
||||
emoji: { allowed: true, tag: "emoji" },
|
||||
|
||||
relays: { type: "context-only", fromContext: true },
|
||||
|
||||
replaceable: false,
|
||||
drafts: { supported: false },
|
||||
};
|
||||
|
||||
/**
|
||||
* Kind 30023: Long-form article (NIP-23)
|
||||
*/
|
||||
export const ARTICLE_SCHEMA: ComposerSchema = {
|
||||
kind: 30023,
|
||||
name: "Article",
|
||||
description: "Long-form content with rich formatting",
|
||||
|
||||
content: {
|
||||
type: "markdown",
|
||||
editor: "markdown",
|
||||
placeholder: "Write your article...",
|
||||
},
|
||||
|
||||
metadata: {
|
||||
title: {
|
||||
tag: "title",
|
||||
required: true,
|
||||
label: "Title",
|
||||
placeholder: "Article title",
|
||||
},
|
||||
summary: {
|
||||
tag: "summary",
|
||||
label: "Summary (optional)",
|
||||
},
|
||||
image: {
|
||||
tag: "image",
|
||||
label: "Cover image URL",
|
||||
},
|
||||
publishedAt: {
|
||||
tag: "published_at",
|
||||
auto: true,
|
||||
},
|
||||
labels: {
|
||||
tag: "t",
|
||||
style: "both",
|
||||
label: "Topics",
|
||||
placeholder: "nostr, development, tutorial",
|
||||
},
|
||||
},
|
||||
|
||||
context: { type: "standalone" },
|
||||
threading: { style: "none" },
|
||||
|
||||
media: { allowed: true, tag: "imeta" },
|
||||
emoji: { allowed: true, tag: "emoji" },
|
||||
|
||||
identifier: {
|
||||
tag: "d",
|
||||
source: "from-title",
|
||||
generator: (input) => slugify(input.title || "untitled"),
|
||||
},
|
||||
|
||||
relays: { type: "user-outbox" },
|
||||
|
||||
replaceable: true,
|
||||
drafts: {
|
||||
supported: true,
|
||||
draftKind: 30024,
|
||||
storageKey: (ctx) => `article-draft-${ctx.windowId || "new"}`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Kind 30818: Wiki article (NIP-54)
|
||||
*/
|
||||
export const WIKI_ARTICLE_SCHEMA: ComposerSchema = {
|
||||
kind: 30818,
|
||||
name: "Wiki Article",
|
||||
description: "Collaborative wiki entry",
|
||||
|
||||
content: {
|
||||
type: "markdown", // Actually AsciiDoc per spec, but we can treat as markdown
|
||||
editor: "markdown",
|
||||
placeholder: "Write the article content...",
|
||||
},
|
||||
|
||||
metadata: {
|
||||
title: {
|
||||
tag: "title",
|
||||
required: false,
|
||||
label: "Display Title",
|
||||
placeholder: "Optional display title (d-tag used if empty)",
|
||||
},
|
||||
summary: {
|
||||
tag: "summary",
|
||||
label: "Summary",
|
||||
},
|
||||
},
|
||||
|
||||
context: { type: "standalone" },
|
||||
threading: { style: "none" },
|
||||
|
||||
media: { allowed: true, tag: "imeta" },
|
||||
emoji: { allowed: true, tag: "emoji" },
|
||||
|
||||
identifier: {
|
||||
tag: "d",
|
||||
source: "user-input", // Wiki articles need explicit topic identifiers
|
||||
},
|
||||
|
||||
relays: { type: "user-outbox" },
|
||||
|
||||
replaceable: true,
|
||||
drafts: {
|
||||
supported: true,
|
||||
storageKey: (ctx) => `wiki-draft-${ctx.windowId || "new"}`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Registry of all schemas by kind
|
||||
*/
|
||||
export const SCHEMAS: Record<number, ComposerSchema> = {
|
||||
1: NOTE_SCHEMA,
|
||||
9: GROUP_MESSAGE_SCHEMA,
|
||||
1111: COMMENT_SCHEMA,
|
||||
1621: ISSUE_SCHEMA,
|
||||
30023: ARTICLE_SCHEMA,
|
||||
30818: WIKI_ARTICLE_SCHEMA,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get schema for a kind, or undefined if not supported
|
||||
*/
|
||||
export function getSchema(kind: number): ComposerSchema | undefined {
|
||||
return SCHEMAS[kind];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a kind has a composer schema
|
||||
*/
|
||||
export function hasSchema(kind: number): boolean {
|
||||
return kind in SCHEMAS;
|
||||
}
|
||||
Reference in New Issue
Block a user