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)
This commit is contained in:
Claude
2026-01-13 15:28:40 +00:00
parent 71eb7d814a
commit 5c53438718
5 changed files with 229 additions and 11 deletions

View File

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

View File

@@ -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<ProfileSearchResult[]>;
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
searchCommands?: (query: string) => Promise<ChatAction[]>;
@@ -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<string>();
const seenBlobs = new Set<string>();
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],
);

View File

@@ -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[];
}
/**

View File

@@ -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);

View File

@@ -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);