diff --git a/web/src/features/messages/AttachmentPreview.tsx b/web/src/features/messages/AttachmentPreview.tsx
index afa4d88b..6579dc0d 100644
--- a/web/src/features/messages/AttachmentPreview.tsx
+++ b/web/src/features/messages/AttachmentPreview.tsx
@@ -79,6 +79,7 @@ interface VideoData {
ref?: string;
mime?: string;
preview?: string;
+ previewRef?: string;
duration?: number;
}
@@ -98,6 +99,13 @@ export function VideoAttachment({ data }: { data: VideoData }) {
const posterInline = typeof data.preview === 'string' ? `data:image/jpeg;base64,${data.preview}` : null;
const videoInline = typeof data.val === 'string' ? `data:${data.mime || 'video/webm'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(videoInline ? undefined : data.ref);
+ // Mobile-sent videos (see mobileAttachmentContentOf) have no inline base64 poster at all -- their
+ // thumbnail is a separately-hosted file (`previewRef`, the same `/v0/file/s/{id}` shape as the
+ // video body itself), not the base64 `preview` field this component was built around for the web
+ // client's own Drafty `VD` entities (confirmed live, favorites' identical shape). Only fetched when
+ // there's no inline poster, same short-circuit `remote` above already uses for the video body.
+ const posterRemote = useAuthedBlobUrl(posterInline ? undefined : data.previewRef);
+ const posterUrl = posterInline || posterRemote.url;
const videoSrc = videoInline || remote.url;
if (playing && videoSrc) {
@@ -111,11 +119,11 @@ export function VideoAttachment({ data }: { data: VideoData }) {
disabled={!videoSrc}
className="relative block h-[170px] w-[260px] overflow-hidden rounded-lg bg-surface-rail"
>
- {posterInline ? (
-
+ {posterUrl ? (
+
) : (
-
+
)}
{videoSrc ? : null}
diff --git a/web/src/features/messages/MessageBubble.tsx b/web/src/features/messages/MessageBubble.tsx
index b11e4724..a8302b2e 100644
--- a/web/src/features/messages/MessageBubble.tsx
+++ b/web/src/features/messages/MessageBubble.tsx
@@ -5,8 +5,17 @@ import { Link } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
-import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted, systemNoticeText, type ContactCardContent } from '@/lib/messages/dispatch';
+import {
+ callResultDisplay,
+ contactCardContentOf,
+ isRecalled,
+ isServerDeleted,
+ mobileAttachmentContentOf,
+ systemNoticeText,
+ type ContactCardContent,
+} from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
+import { AudioAttachment, FileAttachment, ImageAttachment, VideoAttachment } from '@/features/messages/AttachmentPreview';
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
import { groupSenderLabel } from '@/lib/messages/sender-name';
@@ -111,6 +120,7 @@ export function MessageRow({
const statusLabel = mine ? outgoingStatusLabel(topic, msg, isGroup) : msg.ts ? formatBubbleTime(msg.ts) : '';
const callResult = callResultDisplay(msg, mine);
const contactCard = contactCardContentOf(msg);
+ const mobileAttachment = mobileAttachmentContentOf(msg);
const retry = () => {
if (typeof msg.seq !== 'number') return;
@@ -200,6 +210,16 @@ export function MessageRow({
) : contactCard ? (
+ ) : mobileAttachment ? (
+ mobileAttachment.kind === 'image' ? (
+
+ ) : mobileAttachment.kind === 'video' ? (
+
+ ) : mobileAttachment.kind === 'voice' ? (
+
+ ) : (
+
+ )
) : (
renderDrafty(msg.content)
)}
diff --git a/web/src/lib/messages/dispatch.ts b/web/src/lib/messages/dispatch.ts
index 52d287cf..2b60a8e8 100644
--- a/web/src/lib/messages/dispatch.ts
+++ b/web/src/lib/messages/dispatch.ts
@@ -83,6 +83,69 @@ export function contactCardContentOf(msg: StoredMessage): ContactCardContent | n
return isContactCardContent(msg.content) ? msg.content : null;
}
+export interface MobileAttachmentContent {
+ kind: 'image' | 'file' | 'voice' | 'video';
+ ref: string;
+ previewRef?: string;
+ mime?: string;
+ name?: string;
+ size?: number;
+ width?: number;
+ height?: number;
+ duration?: number;
+}
+
+const MOBILE_ATTACHMENT_KINDS = new Set(['image', 'file', 'voice', 'video']);
+
+/**
+ * Real, live-confirmed shape (2026-09-23, a real "seq测试" group with real Android/iOS senders):
+ * this backend's mobile apps send image/file/voice/video messages as a FLAT, non-Drafty content
+ * object straight off the device upload response -- `{file_id, thumbnail_file_id, mime, width,
+ * height, name, size, ref?, preref?}` -- tagged only by `head['x-im-type']` (one of these 4 values),
+ * the exact same "flat object dispatched by head, not by Drafty entity" shape `contactCardContentOf`
+ * above already documents for `contact_card`. The web client's OWN sender (attachments.ts) never
+ * produces this shape -- it always builds a real Drafty `{txt,fmt,ent}` document (`Drafty.insertImage`
+ * etc.) and only uses `x-im-type` as a side-channel category tag, so `renderDrafty`/
+ * `formatMessagePreview` were never built to recognize this flat shape and silently show nothing /
+ * "undefined" for it (confirmed live: 3 real messages from 3 different mobile senders in the same
+ * group rendered as completely blank bubbles, and the chat list's own preview read the literal string
+ * "undefined" for the most recent one) -- checked BEFORE ever calling `renderDrafty`/`Drafty.preview`
+ * on `msg.content`, same as every other flat-content case in this file.
+ *
+ * `ref`/`preref` (a real, already-resolved `/v0/file/s/{id}` path) are present on some real messages
+ * but not others -- confirmed on a real file/voice/video message but absent on 2 of 3 real image
+ * messages in the same group (present on the one from an Android-camera-named file, absent on the
+ * two from UUID-named files, so this looks like a per-platform difference, not random) -- falls back
+ * to constructing the same `/v0/file/s/{file_id}` path this app already uses for avatar_file_id
+ * whenever `ref`/`preref` themselves are missing, rather than assuming they're always there.
+ */
+export function mobileAttachmentContentOf(msg: StoredMessage): MobileAttachmentContent | null {
+ const headType = msg.head?.['x-im-type'];
+ if (typeof headType !== 'string' || !MOBILE_ATTACHMENT_KINDS.has(headType)) return null;
+ const c = msg.content as unknown as Record | null | undefined;
+ if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
+ // A real Drafty document (this app's own sent attachments) always has `ent`/`fmt` -- the mobile
+ // flat shape never does. Guards against a future web-sent message that happens to reuse the same
+ // `x-im-type` tag on a genuine Drafty body (exactly how the web client's own sender uses it today).
+ if ('ent' in c || 'fmt' in c) return null;
+ const fileId = typeof c.file_id === 'string' ? c.file_id : undefined;
+ const ref = typeof c.ref === 'string' ? c.ref : fileId ? `/v0/file/s/${fileId}` : undefined;
+ if (!ref) return null;
+ const thumbId = typeof c.thumbnail_file_id === 'string' ? c.thumbnail_file_id : undefined;
+ const previewRef = typeof c.preref === 'string' ? c.preref : thumbId ? `/v0/file/s/${thumbId}` : undefined;
+ return {
+ kind: headType as MobileAttachmentContent['kind'],
+ ref,
+ previewRef,
+ mime: typeof c.mime === 'string' ? c.mime : undefined,
+ name: typeof c.name === 'string' ? c.name : undefined,
+ size: typeof c.size === 'number' ? c.size : undefined,
+ width: typeof c.width === 'number' ? c.width : undefined,
+ height: typeof c.height === 'number' ? c.height : undefined,
+ duration: typeof c.duration === 'number' ? c.duration : undefined,
+ };
+}
+
export function formatCallDuration(ms: number): string {
const totalSeconds = Math.round(ms / 1000);
return `${String(Math.floor(totalSeconds / 60)).padStart(2, '0')}:${String(totalSeconds % 60).padStart(2, '0')}`;
diff --git a/web/src/lib/messages/preview.ts b/web/src/lib/messages/preview.ts
index d03035dc..99a97a99 100644
--- a/web/src/lib/messages/preview.ts
+++ b/web/src/lib/messages/preview.ts
@@ -1,6 +1,13 @@
import { Drafty, type DraftyContent, type StoredMessage } from 'tinode-sdk';
-import { callResultDisplay, contactCardContentOf, flatTextContentOf, isServerDeleted, systemNoticeText } from '@/lib/messages/dispatch';
+import {
+ callResultDisplay,
+ contactCardContentOf,
+ flatTextContentOf,
+ isServerDeleted,
+ mobileAttachmentContentOf,
+ systemNoticeText,
+} from '@/lib/messages/dispatch';
/**
* One-line chat-list preview text (README §2.1): plain text renders as-is, everything else falls
@@ -26,6 +33,25 @@ export function formatMessagePreview(msg: StoredMessage | undefined, myUserId: s
if (contactCardContentOf(msg)) return `${prefix}[名片]`;
const callResult = callResultDisplay(msg, msg.from === myUserId);
if (callResult) return `${prefix}${callResult.text}`;
+ // Same flat, non-Drafty shape mobileAttachmentContentOf documents (mobile-sent image/file/voice/
+ // video) -- without this it falls through to the Drafty-entity branches below the same way the
+ // flat-text case above used to, and produces the same literal "undefined" (confirmed live: the
+ // chat list's own preview for a real mobile-sent image read exactly that).
+ const mobileAttachment = mobileAttachmentContentOf(msg);
+ if (mobileAttachment) {
+ switch (mobileAttachment.kind) {
+ case 'image':
+ return `${prefix}[图片]`;
+ case 'video':
+ return `${prefix}[视频]`;
+ case 'voice': {
+ const totalSeconds = Math.round((mobileAttachment.duration || 0) / 1000);
+ return `${prefix}[语音] ${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
+ }
+ case 'file':
+ return `${prefix}[文件] ${mobileAttachment.name || ''}`;
+ }
+ }
// Same flat `{text}` shape `systemNoticeText` above already knows about, but NOT gated to
// `system_notice`/`system_action` -- a real, live-confirmed example is a group's own
// announcement-publish notice (`head['x-im-type']:"text"`, ordinary sender, not a centered system