feat(web): image and file message attachments (send + render)
- 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 <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 <img src>/<a href> -- 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 <img> 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
6fcd779530
commit
59d1033a92
@@ -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 <img src="..."> or <a href="..."> 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 (
|
||||
<div className="grid h-[170px] w-[260px] place-items-center rounded-lg bg-surface-rail">
|
||||
<Icon name={remote.error ? 'broken_image' : 'image'} className="text-[28px] text-text-tertiary-3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <img src={src} alt="" className="max-h-[260px] max-w-[260px] rounded-lg object-cover" />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<a
|
||||
href={href}
|
||||
download={data.name}
|
||||
onClick={(e) => {
|
||||
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"
|
||||
>
|
||||
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg" style={{ backgroundColor: style.bg }}>
|
||||
<Icon name={style.icon} className="text-[20px]" style={{ color: style.fg }} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-text-body">{data.name || '文件'}</span>
|
||||
<span className="text-xs text-text-tertiary-2">
|
||||
{typeof data.size === 'number' ? formatFileSize(data.size) : ''} · {remote.loading ? '加载中…' : '点击下载'}
|
||||
</span>
|
||||
</span>
|
||||
<Icon name="download" className="ml-auto flex-none text-[20px] text-text-placeholder-1" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (file) sendImage(topic, file).catch(() => {});
|
||||
};
|
||||
|
||||
const handlePickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (file) sendFile(topic, file).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-surface-chat">
|
||||
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-5">
|
||||
@@ -277,8 +290,14 @@ export function ConversationView() {
|
||||
) : null}
|
||||
<div className="flex items-center gap-1 px-1 pb-1">
|
||||
<Icon name="sentiment_satisfied_alt" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
||||
<Icon name="image" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
||||
<Icon name="attach_file" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
||||
<label className="grid h-8.5 w-8.5 cursor-pointer place-items-center rounded text-[22px] text-icon-default">
|
||||
<Icon name="image" />
|
||||
<input type="file" accept="image/*" onChange={handlePickImage} className="hidden" />
|
||||
</label>
|
||||
<label className="grid h-8.5 w-8.5 cursor-pointer place-items-center rounded text-[22px] text-icon-default">
|
||||
<Icon name="attach_file" />
|
||||
<input type="file" onChange={handlePickFile} className="hidden" />
|
||||
</label>
|
||||
<Icon name="contact_page" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
||||
<Icon name="mic_none" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
||||
<Icon name="history" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
||||
|
||||
Reference in New Issue
Block a user