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>
108 lines
5.2 KiB
TypeScript
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;
|
|
}
|
|
}
|