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