Files
im_webapp/web/src/lib/messages/dispatch.ts
T
Your NameandClaude Sonnet 5 5c3f3b7653 feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes
Rounds out the full rewrite with the remaining feature domains: group
management (admin panel, mute, files, announcements, QR-based invite/join),
contacts (friend profile, add-friend, blocklist, new-friends), favorites
(real media rendering across text/image/video/voice/file/link), settings
(profile/security/privacy/general/about), calls (Agora-backed audio/video
signaling + call history), and system messages/file assistant.

Also folds in a large batch of live-verified fixes surfaced by repeated
fresh code-review passes and hands-on E2E testing against the real
backend, including: WS reconnect not resubscribing any topic after a
disconnect, missing x-im-client-mid on every send path (protocol
compliance for retry dedup), group mute/member-settings/self-profile
endpoint corrections, relationship-state type fixes across search/QR/
friend-profile, tenant-switch logout gap, password-policy now read from
the real per-tenant config instead of guessed, NavRail highlight/
aria-current desync, several call-state-machine races, and responsive
layout fixes across all four breakpoints (mobile/tablet/laptop/desktop)
including a full mobile rebuild of favorites, call UI, and the settings
home/sub-list split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 20:16:33 +08:00

108 lines
5.2 KiB
TypeScript

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;
}
}