From 5c534387188c98a2f8e615793b87e1cf03bafdbb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Jan 2026 15:28:40 +0000 Subject: [PATCH] Add rich blob attachments with imeta tags for chat - Add BlobAttachment TipTap extension with inline preview (thumbnail for images, icons for video/audio) - Store full blob metadata (sha256, url, mimeType, size, server) in editor nodes - Convert blob nodes to URLs in content with NIP-92 imeta tags when sending - Add insertBlob method to MentionEditor for programmatic blob insertion - Update NIP-29 and NIP-53 adapters to include imeta tags with blob metadata - Pass blob attachments through entire send flow (editor -> ChatViewer -> adapter) --- src/components/ChatViewer.tsx | 19 ++- src/components/editor/MentionEditor.tsx | 181 +++++++++++++++++++++++- src/lib/chat/adapters/base-adapter.ts | 18 +++ src/lib/chat/adapters/nip-29-adapter.ts | 11 ++ src/lib/chat/adapters/nip-53-adapter.ts | 11 ++ 5 files changed, 229 insertions(+), 11 deletions(-) diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index b9a373e..f935df0 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -40,6 +40,7 @@ import { MentionEditor, type MentionEditorHandle, type EmojiTag, + type BlobAttachment, } from "./editor/MentionEditor"; import { useProfileSearch } from "@/hooks/useProfileSearch"; import { useEmojiSearch } from "@/hooks/useEmojiSearch"; @@ -349,9 +350,15 @@ export function ChatViewer({ accept: "image/*,video/*,audio/*", onSuccess: (results) => { if (results.length > 0 && editorRef.current) { - // Insert the first successful upload URL into the editor - const url = results[0].blob.url; - editorRef.current.insertText(url); + // Insert the first successful upload as a blob attachment with metadata + const { blob, server } = results[0]; + editorRef.current.insertBlob({ + url: blob.url, + sha256: blob.sha256, + mimeType: blob.type, + size: blob.size, + server, + }); editorRef.current.focus(); } }, @@ -476,6 +483,7 @@ export function ChatViewer({ content: string, replyToId?: string, emojiTags?: EmojiTag[], + blobAttachments?: BlobAttachment[], ) => { if (!conversation || !hasActiveAccount || isSending) return; @@ -513,6 +521,7 @@ export function ChatViewer({ await adapter.sendMessage(conversation, content, { replyTo: replyToId, emojiTags, + blobAttachments, }); setReplyTo(undefined); // Clear reply context only on success } catch (error) { @@ -893,9 +902,9 @@ export function ChatViewer({ searchEmojis={searchEmojis} searchCommands={searchCommands} onCommandExecute={handleCommandExecute} - onSubmit={(content, emojiTags) => { + onSubmit={(content, emojiTags, blobAttachments) => { if (content.trim()) { - handleSend(content, replyTo, emojiTags); + handleSend(content, replyTo, emojiTags, blobAttachments); } }} className="flex-1 min-w-0" diff --git a/src/components/editor/MentionEditor.tsx b/src/components/editor/MentionEditor.tsx index ac77b50..d301565 100644 --- a/src/components/editor/MentionEditor.tsx +++ b/src/components/editor/MentionEditor.tsx @@ -7,7 +7,7 @@ import { useRef, } from "react"; import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react"; -import { Extension } from "@tiptap/core"; +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"; @@ -40,6 +40,22 @@ export interface EmojiTag { 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 */ @@ -48,11 +64,17 @@ export interface SerializedContent { 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[]) => void; + onSubmit?: ( + content: string, + emojiTags: EmojiTag[], + blobAttachments: BlobAttachment[], + ) => void; searchProfiles: (query: string) => Promise; searchEmojis?: (query: string) => Promise; searchCommands?: (query: string) => Promise; @@ -70,6 +92,8 @@ export interface MentionEditorHandle { 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 @@ -151,6 +175,107 @@ const EmojiMention = Mention.extend({ }, }); +// 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`; +} + export const MentionEditor = forwardRef< MentionEditorHandle, MentionEditorProps @@ -444,12 +569,14 @@ export const MentionEditor = forwardRef< [searchCommands], ); - // Helper function to serialize editor content with mentions and emojis + // 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(); + const seenBlobs = new Set(); const json = editorInstance.getJSON(); json.content?.forEach((node: any) => { @@ -485,6 +612,23 @@ export const MentionEditor = forwardRef< 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"; @@ -494,6 +638,7 @@ export const MentionEditor = forwardRef< return { text: text.trim(), emojiTags, + blobAttachments, }; }, [], @@ -504,9 +649,10 @@ export const MentionEditor = forwardRef< (editorInstance: any) => { if (!editorInstance || !onSubmit) return; - const { text, emojiTags } = serializeContent(editorInstance); + const { text, emojiTags, blobAttachments } = + serializeContent(editorInstance); if (text) { - onSubmit(text, emojiTags); + onSubmit(text, emojiTags, blobAttachments); editorInstance.commands.clearContent(); } }, @@ -576,6 +722,8 @@ export const MentionEditor = forwardRef< Placeholder.configure({ placeholder, }), + // Add blob attachment extension for media previews + BlobAttachmentNode, ]; // Add emoji extension if search is provided @@ -675,7 +823,7 @@ export const MentionEditor = forwardRef< clear: () => editor?.commands.clearContent(), getContent: () => editor?.getText() || "", getSerializedContent: () => { - if (!editor) return { text: "", emojiTags: [] }; + if (!editor) return { text: "", emojiTags: [], blobAttachments: [] }; return serializeContent(editor); }, isEmpty: () => editor?.isEmpty ?? true, @@ -689,6 +837,27 @@ export const MentionEditor = forwardRef< 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], ); diff --git a/src/lib/chat/adapters/base-adapter.ts b/src/lib/chat/adapters/base-adapter.ts index 48dbdf7..73c6354 100644 --- a/src/lib/chat/adapters/base-adapter.ts +++ b/src/lib/chat/adapters/base-adapter.ts @@ -17,6 +17,22 @@ import type { GetActionsOptions, } from "@/types/chat-actions"; +/** + * Blob attachment metadata for imeta tags (NIP-92) + */ +export interface BlobAttachmentMeta { + /** 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; +} + /** * Options for sending a message */ @@ -25,6 +41,8 @@ export interface SendMessageOptions { replyTo?: string; /** NIP-30 custom emoji tags */ emojiTags?: Array<{ shortcode: string; url: string }>; + /** Blob attachments for imeta tags (NIP-92) */ + blobAttachments?: BlobAttachmentMeta[]; } /** diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index 87b698e..a4dcc30 100644 --- a/src/lib/chat/adapters/nip-29-adapter.ts +++ b/src/lib/chat/adapters/nip-29-adapter.ts @@ -469,6 +469,17 @@ export class Nip29Adapter extends ChatProtocolAdapter { } } + // Add NIP-92 imeta tags for blob attachments + if (options?.blobAttachments) { + for (const blob of options.blobAttachments) { + const imetaParts = [`url ${blob.url}`]; + if (blob.sha256) imetaParts.push(`x ${blob.sha256}`); + if (blob.mimeType) imetaParts.push(`m ${blob.mimeType}`); + if (blob.size) imetaParts.push(`size ${blob.size}`); + tags.push(["imeta", ...imetaParts]); + } + } + // Use kind 9 for group chat messages const draft = await factory.build({ kind: 9, content, tags }); const event = await factory.sign(draft); diff --git a/src/lib/chat/adapters/nip-53-adapter.ts b/src/lib/chat/adapters/nip-53-adapter.ts index 871fa86..54dba8f 100644 --- a/src/lib/chat/adapters/nip-53-adapter.ts +++ b/src/lib/chat/adapters/nip-53-adapter.ts @@ -450,6 +450,17 @@ export class Nip53Adapter extends ChatProtocolAdapter { } } + // Add NIP-92 imeta tags for blob attachments + if (options?.blobAttachments) { + for (const blob of options.blobAttachments) { + const imetaParts = [`url ${blob.url}`]; + if (blob.sha256) imetaParts.push(`x ${blob.sha256}`); + if (blob.mimeType) imetaParts.push(`m ${blob.mimeType}`); + if (blob.size) imetaParts.push(`size ${blob.size}`); + tags.push(["imeta", ...imetaParts]); + } + } + // Use kind 1311 for live chat messages const draft = await factory.build({ kind: 1311, content, tags }); const event = await factory.sign(draft);