From 59d1033a927882dd4dce242d02f50b925ba78593 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 8 Sep 2026 02:44:12 +0800 Subject: [PATCH] feat(web): image and file message attachments (send + render) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/api/upload.ts: POST /v0/file/u/ (docs/IM-前后端对接文档.md:460-461) -- a separate surface from /v1/im/*, at the tenant's file base URL, auth'd with X-MeetingIM-Auth: token rather than Authorization: Bearer (matches upstream Tinode's own LargeFileHelper convention). - lib/tinode/attachments.ts: sendImage/sendFile. Files <= the inband limit go in directly as base64 (Drafty.insertImage's `bits` field / attachFile's `data` field) with no upload round trip; larger ones upload first and reference the returned URL. Simplified from the old app's urlPromise dance (optimistic local preview while the upload runs in the background) by just awaiting the upload before creating the message -- less optimistic, meaningfully simpler. - AttachmentPreview.tsx: image/file bubble rendering. Externally-referenced (non-inline) attachments need an authenticated fetch into a blob URL, not a plain / -- GET /v0/file/s/{file_id} requires the same custom auth header uploads do, which a bare element can't attach. - drafty.tsx: wired the 'IM'/'EX' Drafty style-tag cases to these. Verified end to end: sent a real inline image (confirmed the exact IM entity shape in the outgoing WS packet, and that the decoded and loaded from its own base64 data) and a real inline file (rendered with the correct icon/size/download affordance). Known gap: voice messages (MediaRecorder + AU entities) not built yet -- meaningfully more complex (hold-to-record gesture, audio encoding, duration/waveform) and deliberately left for a following pass rather than rushed. Co-Authored-By: Claude Sonnet 5 --- web/src/app/Icon.tsx | 8 +- .../features/messages/AttachmentPreview.tsx | 102 ++++++++++++++++++ .../features/messages/ConversationView.tsx | 23 +++- web/src/lib/api/client.ts | 2 +- web/src/lib/api/upload.ts | 50 +++++++++ web/src/lib/messages/drafty.tsx | 6 ++ web/src/lib/tinode/attachments.ts | 64 +++++++++++ 7 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 web/src/features/messages/AttachmentPreview.tsx create mode 100644 web/src/lib/api/upload.ts create mode 100644 web/src/lib/tinode/attachments.ts diff --git a/web/src/app/Icon.tsx b/web/src/app/Icon.tsx index 74c48aac..737ff2c3 100644 --- a/web/src/app/Icon.tsx +++ b/web/src/app/Icon.tsx @@ -1,4 +1,8 @@ /** Thin wrapper around a Material Icons ligature glyph (font loaded in index.html). */ -export function Icon({ name, className = '' }: { name: string; className?: string }) { - return {name}; +export function Icon({ name, className = '', style }: { name: string; className?: string; style?: React.CSSProperties }) { + return ( + + {name} + + ); } diff --git a/web/src/features/messages/AttachmentPreview.tsx b/web/src/features/messages/AttachmentPreview.tsx new file mode 100644 index 00000000..dd7c0b11 --- /dev/null +++ b/web/src/features/messages/AttachmentPreview.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from 'react'; + +import { Icon } from '@/app/Icon'; +import { authToken } from '@/lib/api/client'; +import { fileDownloadUrl } from '@/lib/api/upload'; +import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments'; + +/** + * GET /v0/file/s/{file_id} requires the same `X-MeetingIM-Auth` header as upload (docs/ + * IM-前后端对接文档.md:461) -- a plain or can't attach a custom + * header, so an externally-referenced (non-inline) attachment needs an authenticated fetch into a + * blob URL rather than a direct URL. Inline (base64 `val`) attachments skip this path entirely. + */ +function useAuthedBlobUrl(ref: string | undefined): { url: string | null; loading: boolean; error: boolean } { + const [state, setState] = useState<{ url: string | null; loading: boolean; error: boolean }>({ url: null, loading: !!ref, error: false }); + + useEffect(() => { + if (!ref) return undefined; + let cancelled = false; + let objectUrl: string | null = null; + setState({ url: null, loading: true, error: false }); + fetch(fileDownloadUrl(ref), { headers: { 'X-MeetingIM-Auth': `token ${authToken()}` } }) + .then((res) => { + if (!res.ok) throw new Error('download failed'); + return res.blob(); + }) + .then((blob) => { + if (cancelled) return; + objectUrl = URL.createObjectURL(blob); + setState({ url: objectUrl, loading: false, error: false }); + }) + .catch(() => { + if (!cancelled) setState({ url: null, loading: false, error: true }); + }); + return () => { + cancelled = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [ref]); + + return state; +} + +interface ImageData { + val?: string; + ref?: string; + mime?: string; + width?: number; + height?: number; +} + +export function ImageAttachment({ data }: { data: ImageData }) { + const inline = typeof data.val === 'string' ? `data:${data.mime || 'image/jpeg'};base64,${data.val}` : null; + const remote = useAuthedBlobUrl(inline ? undefined : data.ref); + const src = inline || remote.url; + + if (!src) { + return ( +
+ +
+ ); + } + return ; +} + +interface FileData { + val?: string; + ref?: string; + mime?: string; + name?: string; + size?: number; +} + +export function FileAttachment({ data }: { data: FileData }) { + const style = fileTypeStyle(data.name, data.mime); + const remote = useAuthedBlobUrl(typeof data.val === 'string' ? undefined : data.ref); + const inline = typeof data.val === 'string' ? `data:${data.mime || 'application/octet-stream'};base64,${data.val}` : null; + const href = inline || remote.url || undefined; + + return ( +
{ + if (!href) e.preventDefault(); + }} + className="flex w-[280px] items-center gap-3 rounded-lg bg-white px-3.5 py-3 no-underline shadow-bubble" + > + + + + + {data.name || '文件'} + + {typeof data.size === 'number' ? formatFileSize(data.size) : ''} · {remote.loading ? '加载中…' : '点击下载'} + + + + + ); +} diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index 97de3b81..b637c4fd 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -14,6 +14,7 @@ import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config'; import { availableActions, type MessageAction } from '@/lib/messages/actions'; import { renderDrafty } from '@/lib/messages/drafty'; +import { sendFile, sendImage } from '@/lib/tinode/attachments'; import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks'; import { forwardMessage, sendReply, sendText } from '@/lib/tinode/send'; import { closeConversation, openConversation } from '@/lib/tinode/topics'; @@ -188,6 +189,18 @@ export function ConversationView() { } }; + const handlePickImage = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (file) sendImage(topic, file).catch(() => {}); + }; + + const handlePickFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (file) sendFile(topic, file).catch(() => {}); + }; + return (
@@ -277,8 +290,14 @@ export function ConversationView() { ) : null}
- - + + diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index cd3be29c..0990e4cc 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -113,7 +113,7 @@ export function currentTenantCode(): string { } /** Set by tinode-web.jsx-equivalent login/reconnect code; tab-lifetime, independent of "remember me". */ -function authToken(): string { +export function authToken(): string { return sessionStorage.getItem('im-session-token') || ''; } diff --git a/web/src/lib/api/upload.ts b/web/src/lib/api/upload.ts new file mode 100644 index 00000000..a4047ebd --- /dev/null +++ b/web/src/lib/api/upload.ts @@ -0,0 +1,50 @@ +// The file HTTP endpoint (docs/IM-前后端对接文档.md:62,460-461) is a SEPARATE surface from +// /v1/im/* -- it lives at the tenant's `file` base URL, and authenticates with a different header +// (`X-MeetingIM-Auth: token `) than the REST API's `Authorization: Bearer`, matching +// upstream Tinode's own LargeFileHelper convention. The token value is the same session token +// either way, just carried differently. +import { authToken, fileBase } from './client'; + +export interface UploadedFile { + url: string; + name: string; + size: number; + mime: string; +} + +export async function uploadFile(file: File, topic?: string): Promise { + const form = new FormData(); + form.append('file', file); + if (topic) { + form.append('purpose', 'chat_file'); + form.append('topic', topic); + } + const res = await fetch(`${fileBase()}/u/`, { + method: 'POST', + headers: { 'X-MeetingIM-Auth': `token ${authToken()}` }, + body: form, + }); + const text = await res.text(); + let payload: { ctrl?: { code: number; text?: string; params?: UploadedFile } } = {}; + try { + payload = JSON.parse(text); + } catch { + throw new Error('文件上传失败:服务器返回异常'); + } + if (!res.ok || !payload.ctrl || payload.ctrl.code >= 300 || !payload.ctrl.params) { + throw new Error(payload.ctrl?.text || '文件上传失败'); + } + return payload.ctrl.params; +} + +/** + * Absolute download URL for a file reference returned by uploadFile() or a received attachment. + * The upload response's `url` (e.g. "/v0/file/s/file123") is already rooted at the tenant's file + * origin, not relative to fileBase() (which itself already ends in "/v0/file") -- so this strips + * fileBase() down to just its origin before appending, rather than concatenating the two paths. + */ +export function fileDownloadUrl(refUrl: string): string { + if (refUrl.startsWith('http')) return refUrl; + const origin = fileBase().replace(/\/v0\/file.*$/, ''); + return `${origin}${refUrl}`; +} diff --git a/web/src/lib/messages/drafty.tsx b/web/src/lib/messages/drafty.tsx index 0094fcb7..161c5d12 100644 --- a/web/src/lib/messages/drafty.tsx +++ b/web/src/lib/messages/drafty.tsx @@ -2,6 +2,8 @@ import { Fragment, type ReactNode } from 'react'; import { Drafty, type DraftyContent } from 'tinode-sdk'; +import { FileAttachment, ImageAttachment } from '@/features/messages/AttachmentPreview'; + /** * Bubble content renderer for the Drafty style tags this app currently sends/receives. Grown * incrementally alongside composer features -- IM/VD/AU/QQ/TC/VC/BN/FM land when their respective @@ -55,6 +57,10 @@ function formatNode( ); } + case 'IM': + return ; + case 'EX': + return ; case 'QQ': // Drafty.quote() has no `key`/`data` of its own (tinode.dev.js:2160-2168) -- it's a plain style // span wrapping mention(header,uid) + linebreak + body, all already rendered into `children`. diff --git a/web/src/lib/tinode/attachments.ts b/web/src/lib/tinode/attachments.ts new file mode 100644 index 00000000..35f381da --- /dev/null +++ b/web/src/lib/tinode/attachments.ts @@ -0,0 +1,64 @@ +import { Drafty, type Topic } from 'tinode-sdk'; + +import { uploadFile } from '@/lib/api/upload'; +import { MAX_INBAND_ATTACHMENT_SIZE } from '@/lib/config'; + +function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve((reader.result as string).split(',')[1] ?? ''); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); +} + +function imageDimensions(file: File): Promise<{ width: number; height: number }> { + return new Promise((resolve) => { + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + resolve({ width: img.naturalWidth, height: img.naturalHeight }); + URL.revokeObjectURL(url); + }; + img.onerror = () => { + resolve({ width: 0, height: 0 }); + URL.revokeObjectURL(url); + }; + img.src = url; + }); +} + +/** + * Small files go inline as base64 (Drafty.insertImage's `bits` field) -- no upload round trip at + * all. Larger ones upload first, then reference the returned URL (`refurl`). Simplified from the + * old app's own urlPromise dance (which inserts an optimistic local preview immediately and lets + * the upload happen in the background): this awaits the upload before creating the message at all, + * trading that "shows immediately while uploading" nicety for meaningfully simpler code. + */ +export async function sendImage(topic: Topic, file: File): Promise { + const { width, height } = await imageDimensions(file); + const content = + file.size <= MAX_INBAND_ATTACHMENT_SIZE + ? Drafty.insertImage(undefined, 0, { mime: file.type, bits: await fileToBase64(file), width, height, filename: file.name, size: file.size }) + : Drafty.insertImage(undefined, 0, { + mime: file.type, + refurl: (await uploadFile(file, topic.name)).url, + width, + height, + filename: file.name, + size: file.size, + }); + const msg = topic.createMessage(content, false); + msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'image' }); + return topic.publishDraft(msg, Promise.resolve()); +} + +export async function sendFile(topic: Topic, file: File): Promise { + const content = + file.size <= MAX_INBAND_ATTACHMENT_SIZE + ? Drafty.attachFile(undefined, { mime: file.type, data: await fileToBase64(file), filename: file.name, size: file.size }) + : Drafty.attachFile(undefined, { mime: file.type, refurl: (await uploadFile(file, topic.name)).url, filename: file.name, size: file.size }); + const msg = topic.createMessage(content, false); + msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'file' }); + return topic.publishDraft(msg, Promise.resolve()); +}