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