import type { StoredMessage } from 'tinode-sdk'; import { callContentOf, callDurationMsOf, callStateOf } from '@/lib/tinode/calls'; /** * Two different "message is gone" protocol paths render differently (confirmed against the old * app's chat-message.jsx, a real, previously-hit gap): `msg.hi` is the server's own soft-delete * marker (renders as a centered system-style line, no bubble at all); Tinode's recall protocol * instead nulls out `content` on an otherwise-normal message (still a regular aligned bubble, just * with placeholder text). Getting these swapped either crashes on a null content or silently * misrenders a recalled message as a deleted-system-line. */ export function isServerDeleted(msg: StoredMessage): boolean { return !!(msg as StoredMessage & { hi?: boolean }).hi; } export function isRecalled(msg: StoredMessage): boolean { return !isServerDeleted(msg) && msg.content == null; } interface SystemNoticeContent { text: string; } function isSystemNoticeContent(content: unknown): content is SystemNoticeContent { return !!content && typeof content === 'object' && typeof (content as SystemNoticeContent).text === 'string'; } /** * Plain centered system-event text (e.g. "你们已成为好友,现在可以开始聊天了") -- backend-custom * `head['x-im-type']`, not a Drafty content type. Doesn't yet cover the actionable group-invitation * card variant (same head type, but with a real join button) since that's group-chat territory * (Phase 3); when that lands it needs to be excluded here the same way the old app excludes it. */ export function systemNoticeText(msg: StoredMessage): string | null { const headType = msg.head?.['x-im-type']; if (headType !== 'system_notice' && headType !== 'system_action') return null; return isSystemNoticeContent(msg.content) ? msg.content.text : null; } export interface ContactCardContent { user_id: string; display_name?: string; avatar_file_id?: string; public_account?: string; } function isContactCardContent(content: unknown): content is ContactCardContent { return !!content && typeof content === 'object' && typeof (content as ContactCardContent).user_id === 'string'; } /** * README §3.3's 名片 composer button has no send path anywhere in this app (no client here ever * constructs one -- see AddFriendModal/ConversationView's own `notImplemented()` for that button), * but a contact-card message CAN still arrive from another client. Confirmed real shape via * docs/IM-前后端对接文档.md:311's worked example -- a backend-custom `head['x-im-type']: * "contact_card"` with a FLAT `{user_id,display_name,avatar_file_id,public_account}` content, NOT a * Drafty entity. This app's own `drafty.tsx` used to assume (per a stale header comment inherited * from the SDK's generic ambient type list) that contact cards would show up as a Drafty `TC`-tagged * entity the same way images/audio do -- they don't, so `renderDrafty` was silently fed a * non-Drafty-shaped object for this message type and rendered nothing. This is dispatched the same * way `systemNoticeText`/`callResultDisplay` are: by `head['x-im-type']`, checked BEFORE ever calling * `renderDrafty` on `msg.content`. */ export function contactCardContentOf(msg: StoredMessage): ContactCardContent | null { if (msg.head?.['x-im-type'] !== 'contact_card') return null; return isContactCardContent(msg.content) ? msg.content : null; } 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')}`; } /** * README §7's "通话结果消息" -- icon + text mapped from `head.webrtc`, confirmed live (doc line * 1953/1954): `missed` alone covers both "未接听" and "已取消" (a callee who let it ring out and a * caller who cancelled before pickup both replace to the SAME wire value) -- direction picks the * text, not the wire value, using `mine` (was the ORIGINAL invite sent by me, i.e. was I the * caller) exactly the way this app already threads that prop through MessageRow. No `busy` value * exists in the confirmed real enum (only accepted/finished/disconnected/missed/declined) despite * the old app's own UI vocabulary having one -- not rendered here since nothing legitimate can * produce it yet. */ export function callResultDisplay(msg: StoredMessage, mine: boolean): { icon: string; text: string; danger: boolean } | null { const content = callContentOf(msg); const state = callStateOf(msg); if (!content || !state) return null; const icon = content.callType === 'video' ? 'videocam' : 'call'; switch (state) { case 'finished': { const duration = callDurationMsOf(msg); return { icon, danger: false, text: duration > 0 ? `通话时长 ${formatCallDuration(duration)}` : '已结束' }; } case 'missed': return { icon: 'phone_missed', danger: true, text: mine ? '已取消' : '未接听' }; case 'declined': return { icon: 'phone_missed', danger: true, text: '已拒绝' }; case 'disconnected': return { icon: 'phone_missed', danger: true, text: '异常结束' }; case 'accepted': case 'started': return { icon, danger: false, text: '通话中' }; default: return null; } }