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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
@@ -1,5 +1,7 @@
|
||||
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
|
||||
@@ -35,3 +37,71 @@ export function systemNoticeText(msg: StoredMessage): string | null {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user