mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 00:46:54 +02:00
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:
@@ -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"
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user