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()); +}