fix(messages): render mobile-sent image/file/voice/video attachments
Live-confirmed in a real group ("seq测试", tenant 10001) that image
messages sent from Android and iOS render as completely blank bubbles
on web, and the chat list's own preview for one showed the literal
string "undefined". Root cause confirmed by querying the actual
im_messages/im_group_messages rows: mobile apps send image/file/voice/
video as a flat, non-Drafty content object (`{file_id,
thumbnail_file_id, mime, width, height, name, size, ref?, preref?}`)
tagged only by `head['x-im-type']`, not as a real Drafty `{txt,fmt,ent}`
document the way this web client's own sender (attachments.ts) always
builds. renderDrafty/formatMessagePreview had no case for this shape
and silently produced nothing / "undefined" -- the exact same failure
pattern already fixed once for `contact_card` (see dispatch.ts's
existing contactCardContentOf), just not yet extended to attachments.
Adds mobileAttachmentContentOf (dispatch.ts), normalizing `ref`/
`preref` from `file_id`/`thumbnail_file_id` when the wire message
omits them (confirmed both cases occur on real messages), and wires it
into MessageBubble.tsx (reusing the existing Image/File/Audio/Video
Attachment components) and preview.ts (matching bracket text). Extends
VideoAttachment with a remote `previewRef` fallback alongside its
existing inline-base64 `preview`, since mobile videos' poster is a
separately-hosted file, not inline data.
Verified live against the real message rows: detection and URL
construction are confirmed correct (network panel shows the real OSS-
backed URL, exactly matching the file_id from the DB row); the actual
pixels don't load in this local dev environment because OSS's CORS
whitelist doesn't include localhost, a known, already-documented
limitation of testing via localhost instead of a real *.118im.com
domain, not a defect in this fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0c38adba78
commit
6a65998e42
@@ -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 ? (
|
||||
<img src={posterInline} alt="" className="h-full w-full object-cover" />
|
||||
{posterUrl ? (
|
||||
<img src={posterUrl} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<Icon name={remote.error ? 'broken_image' : 'videocam'} className="text-[28px] text-text-tertiary-3" />
|
||||
<Icon name={posterRemote.error ? 'broken_image' : 'videocam'} className="text-[28px] text-text-tertiary-3" />
|
||||
</div>
|
||||
)}
|
||||
{videoSrc ? <Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" /> : null}
|
||||
|
||||
@@ -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({
|
||||
</span>
|
||||
) : contactCard ? (
|
||||
<ContactCardBubble content={contactCard} />
|
||||
) : mobileAttachment ? (
|
||||
mobileAttachment.kind === 'image' ? (
|
||||
<ImageAttachment data={mobileAttachment} />
|
||||
) : mobileAttachment.kind === 'video' ? (
|
||||
<VideoAttachment data={{ ...mobileAttachment, previewRef: mobileAttachment.previewRef }} />
|
||||
) : mobileAttachment.kind === 'voice' ? (
|
||||
<AudioAttachment data={mobileAttachment} />
|
||||
) : (
|
||||
<FileAttachment data={mobileAttachment} />
|
||||
)
|
||||
) : (
|
||||
renderDrafty(msg.content)
|
||||
)}
|
||||
|
||||
@@ -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<string, unknown> | 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')}`;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user