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:
Your Name
2026-09-09 20:16:33 +08:00
co-authored by Claude Sonnet 5
parent 72b7b79caf
commit 5c3f3b7653
89 changed files with 10158 additions and 442 deletions
+339
View File
@@ -0,0 +1,339 @@
import { create } from 'zustand';
import { joinRtcCall } from '@/lib/api/calls';
import {
joinCall,
leaveCall,
setCameraOff as agoraSetCameraOff,
setMuted as agoraSetMuted,
switchCamera as agoraSwitchCamera,
} from '@/lib/calls/agora-client';
import { callContentOf, callStateOf, sendCallAccept, sendCallHangup, sendCallInvite, sendCallRinging, type CallWebrtcState } from '@/lib/tinode/calls';
import { getTinode } from '@/lib/tinode/connection';
import { getMessagesSnapshot, subscribeAnyTopicChange, subscribeTopicInfo } from '@/lib/tinode/topics';
import { useSessionStore } from '@/stores/session';
export type CallPhase = 'idle' | 'outgoing' | 'incoming' | 'connected' | 'ended';
/** Generous local ring-timeout fallback -- see startCall's own comment on why this exists. */
const RING_TIMEOUT_MS = 60_000;
interface CallState {
phase: CallPhase;
topic: string | null;
callId: string | null;
seq: number | null;
callType: 'audio' | 'video' | null;
peerName: string;
minimized: boolean;
muted: boolean;
cameraOff: boolean;
connectedAt: number | null;
hasRemoteVideo: boolean;
// Set once joinCall() actually resolves with a local camera track -- CallRoot's self-preview
// effect used to fire once on mount, when the async join chain (invite/accept -> rtc-join REST ->
// dynamic Agora SDK import -> client.join() -> createCameraVideoTrack()) hadn't resolved yet, and
// never re-fire once it did, leaving the local preview box permanently blank for the whole call.
hasLocalVideo: boolean;
// `'busy'` is never a real PERSISTED `head.webrtc` value (confirmed live, see dispatch.ts's own
// comment) so it's deliberately NOT part of `CallWebrtcState` -- but it IS a real value this app's
// own `sendCallHangup(..., 'busy')` puts on the LIVE, ephemeral `{info hang-up}` signal a second
// caller receives, which flows straight into this same field.
endReason: CallWebrtcState | 'busy' | null;
/** Was THIS session the one that placed the call (via startCall), not the one that received it --
* needed to pick "已取消" vs "未接听" for the same wire value the same way dispatch.ts's
* callResultDisplay already does for the permanent chat-bubble record of the same call. */
wasCaller: boolean;
/** docs/IM-前后端对接文档.md §8.6: "切换失败恢复真实按钮状态并给本地提示" -- toggleMute/
* toggleCamera set this on a real Agora-level failure, after rolling the optimistic flip back. */
mediaError: string | null;
startCall: (topic: string, peerName: string, callType: 'audio' | 'video') => Promise<void>;
acceptCall: () => Promise<void>;
rejectCall: () => void;
hangUp: () => void;
toggleMinimize: () => void;
toggleMute: () => Promise<void>;
toggleCamera: () => Promise<void>;
switchCamera: () => Promise<void>;
setHasRemoteVideo: (v: boolean) => void;
_reset: () => void;
_endWithReason: (reason: CallWebrtcState | 'busy') => void;
}
const IDLE = {
phase: 'idle' as const,
topic: null,
callId: null,
seq: null,
callType: null,
peerName: '',
minimized: false,
muted: false,
cameraOff: false,
connectedAt: null,
hasRemoteVideo: false,
hasLocalVideo: false,
endReason: null,
wasCaller: false,
mediaError: null,
};
export const useCallStore = create<CallState>((set, get) => ({
...IDLE,
startCall: async (topic, peerName, callType) => {
const tinodeTopic = getTinode().getTopic(topic);
const callId = getTinode().getNextUniqueId();
set({ ...IDLE, phase: 'outgoing', topic, callId, callType, peerName, wasCaller: true });
try {
const ctrl = await sendCallInvite(tinodeTopic, callId, callType);
const seq = ctrl?.params?.seq;
if (!seq || get().callId !== callId) return;
set({ seq });
// Safety net, not the primary mechanism: the protocol doc only confirms the server marks an
// unanswered call `missed` in the persisted MESSAGE, never confirmed to also push a live
// `{info}` event to the caller for that specific case (unlike accept/hang-up, which do). If
// that push genuinely never comes, nothing else here would ever leave 'outgoing' -- this timer
// is a no-op (cleared by the time it'd fire) on every path that already ends the call some
// other way, and only actually does something on that one unconfirmed gap.
window.setTimeout(() => {
if (get().callId === callId && get().phase === 'outgoing') get().hangUp();
}, RING_TIMEOUT_MS);
} catch {
// The invite itself failed to send -- nothing to ring, don't strand the "正在等待对方接听…"
// overlay forever with no way out but a manual hang-up click.
if (get().callId === callId) get()._reset();
}
// Media is NOT captured/joined here -- docs/IM-前后端对接文档.md §8.7: "接听前媒体轨默认不发给
// 对方". The actual RTC join happens once the peer's 'accept' event arrives (see the
// subscribeTopicInfo handler in initCallSignaling below), mirroring acceptCall()'s own timing on
// the callee side. Previously this joined+published immediately after the invite ack, meaning
// the caller's mic (and camera, for a video call) were live and transmitting for the entire
// ringing period -- before the callee had done anything at all.
},
acceptCall: async () => {
const { topic, callId, seq, callType } = get();
if (!topic || !callId || seq == null || !callType) return;
const tinodeTopic = getTinode().getTopic(topic);
sendCallAccept(tinodeTopic, seq, callId);
set({ phase: 'connected', connectedAt: Date.now() });
try {
const join = await joinRtcCall(topic, callId, seq, callType, 'callee');
if (get().callId !== callId) return;
await joinCall(join, callType, (v) => get().setHasRemoteVideo(v));
if (get().callId === callId) set({ hasLocalVideo: callType === 'video' });
} catch {
// Same guard the structurally-identical caller-side accept handler below already has, for the
// identical reason: `callId` here is the ORIGINAL id this whole acceptCall() call was for,
// captured before the async join even started. Without re-checking it, a hang-up (either
// side) that lands WHILE joinRtcCall()/joinCall() is still in flight -- easily reachable,
// since `phase` already flipped to 'connected' above before the RTC join even begins, making
// the hang-up button clickable during this whole window -- let this catch unconditionally
// clobber whatever CORRECT end-reason that hang-up had already set (or a brand-new
// subsequent call's state) with a false "连接异常". A media/RTC join failure is still a
// distinct outcome from a normal hang-up -- routing it through hangUp() would have mislabeled
// it as "已结束"/"已取消" depending on connectedAt, instead of the honest "连接异常" this is --
// just only when THIS call is still the live one.
if (get().callId !== callId) return;
const { topic: currentTopic, seq: currentSeq } = get();
if (currentTopic && currentSeq != null) sendCallHangup(getTinode().getTopic(currentTopic), currentSeq, callId);
leaveCall();
get()._endWithReason('disconnected');
}
},
rejectCall: () => {
const { topic, seq, callId } = get();
if (topic && seq != null && callId) sendCallHangup(getTinode().getTopic(topic), seq, callId, 'declined');
get()._endWithReason('declined');
},
hangUp: () => {
const { topic, seq, callId, connectedAt } = get();
if (topic && seq != null && callId) sendCallHangup(getTinode().getTopic(topic), seq, callId);
leaveCall();
get()._endWithReason(connectedAt ? 'finished' : 'missed');
},
toggleMinimize: () => set((s) => ({ minimized: !s.minimized })),
// Both used to flip the local flag in the exact same tick as firing (unawaited, uncaught)
// agoraSetMuted/agoraSetCameraOff -- a real Agora-level failure (permission revoked mid-call,
// track already closed) left the button showing the wrong state forever with no rollback and no
// user-facing error, contradicting docs/IM-前后端对接文档.md §8.6's explicit requirement.
toggleMute: async () => {
const next = !get().muted;
set({ muted: next, mediaError: null });
try {
await agoraSetMuted(next);
} catch {
set({ muted: !next, mediaError: '切换静音失败,请重试' });
}
},
toggleCamera: async () => {
const next = !get().cameraOff;
set({ cameraOff: next, mediaError: null });
try {
await agoraSetCameraOff(next);
} catch {
set({ cameraOff: !next, mediaError: '切换摄像头失败,请重试' });
}
},
// switchCamera() (front/back) previously had no error handling at all anywhere in its call chain
// -- agora-client.ts's own version now rolls its internal flag back and rethrows on failure (a
// device with no rear camera, or a browser that doesn't support `facingMode`), but nothing
// surfaced that to the user; CallRoot.tsx's own click handler discarded the promise outright with
// no `.catch()`. Routed through the same mediaError channel as toggleMute/toggleCamera above for
// one consistent user-facing failure convention, not a second bespoke one.
switchCamera: async () => {
try {
await agoraSwitchCamera();
} catch {
set({ mediaError: '切换摄像头失败,请重试' });
}
},
setHasRemoteVideo: (v) => set({ hasRemoteVideo: v }),
_reset: () => set({ ...IDLE }),
// `'ended'` briefly shows a result overlay (CallRoot) before settling back to idle -- previously
// every end-of-call path went straight to `{...IDLE, endReason: reason}` in one `set()`, so `phase`
// was ALREADY 'idle' the instant `endReason` was set; CallRoot's top-level render switch only shows
// something for 'incoming'/'outgoing'/'connected', so the whole result (icon/text) was set but
// never actually rendered by anything, the entire 3s window it existed for.
_endWithReason: (reason) => {
// Preserve exactly the fields EndedCallOverlay needs to render (who/what/how-long) -- everything
// else genuinely IS done (media already torn down by the caller before this runs) and correctly
// resets to its idle default. An earlier version of this spread `...IDLE` over everything, which
// wiped peerName/topic/callType/wasCaller too, leaving the overlay rendering a blank peer name.
const { topic, peerName, callType, connectedAt, wasCaller } = get();
set({ ...IDLE, phase: 'ended', endReason: reason, topic, peerName, callType, connectedAt, wasCaller });
window.setTimeout(() => {
if (useCallStore.getState().phase === 'ended') useCallStore.setState({ ...IDLE });
}, 3000);
},
}));
const seenCallIds = new Set<string>();
let listenersWired = false;
/**
* Global call-signal wiring -- call once from AppShell so incoming calls surface regardless of
* which page is open. `subscribeAnyTopicChange` fires on ANY topic change (new messages, presence,
* etc, not just calls), so this re-scans every contact's latest message on each firing and dedupes
* by `callId` (a plain "did this seq change" check isn't enough: the SAME invite message can
* legitimately re-notify, e.g. once for `onData` and again for an unrelated `onPres` on the same
* topic bumping `notifyTopic`). Auto-declines a second incoming call while one is already active --
* matches the old app's own real behavior (tinode-web.jsx's busy-auto-hangup), not a new policy.
*/
export function initCallSignaling() {
if (listenersWired) return;
listenersWired = true;
subscribeAnyTopicChange(() => {
const myUserId = useSessionStore.getState().myUserId;
if (!myUserId) return;
const peerNames = new Map<string, string>();
getTinode()
.getMeTopic()
.contacts((c) => {
if (c.topic) peerNames.set(c.topic, c.public?.fn || c.topic);
});
for (const topicName of peerNames.keys()) {
const messages = getMessagesSnapshot(topicName);
const latest = messages[messages.length - 1];
if (!latest || latest.from === myUserId || typeof latest.seq !== 'number') continue;
const content = callContentOf(latest);
if (!content || callStateOf(latest) !== 'started' || seenCallIds.has(content.callId)) continue;
seenCallIds.add(content.callId);
// `'ended'` is a terminal display-only phase (_endWithReason's own 3s result-overlay window,
// see below) -- treating it the same as a genuinely live call meant a real new incoming call
// arriving in the 3 seconds right after ANY previous call ended (from anyone, unrelated) got
// auto-declined as "busy" even though nothing was actually in progress anymore.
const currentPhase = useCallStore.getState().phase;
if (currentPhase !== 'idle' && currentPhase !== 'ended') {
// Busy -- auto-decline without ever showing the incoming-call UI for this second call.
sendCallHangup(getTinode().getTopic(topicName), latest.seq, content.callId, 'busy');
continue;
}
useCallStore.setState({
...IDLE,
phase: 'incoming',
topic: topicName,
callId: content.callId,
seq: latest.seq,
callType: content.callType,
peerName: peerNames.get(topicName) || topicName,
});
sendCallRinging(getTinode().getTopic(topicName), latest.seq, content.callId);
// Mirrors startCall()'s own safety-net timer (same RING_TIMEOUT_MS, same reasoning: the doc
// never confirms a live push specifically for "caller's device vanished without ever sending
// an explicit hang-up"). Without this, the callee side had NO timeout at all -- if the
// caller's client crashed/lost network/went to sleep mid-ring, this incoming-call UI stayed up
// forever with only a manual decline as an escape. `hangUp()` (not rejectCall()) is the right
// call here, not just for symmetry: it resolves locally to 'missed' (correct -- this ring was
// never actually declined, it just went unanswered) rather than 'declined', and still notifies
// the caller so their own UI resolves too instead of relying on their side's timeout as well.
const ringingCallId = content.callId;
window.setTimeout(() => {
if (useCallStore.getState().callId === ringingCallId && useCallStore.getState().phase === 'incoming') {
useCallStore.getState().hangUp();
}
}, RING_TIMEOUT_MS);
}
});
subscribeTopicInfo((topicName, info) => {
if (info.what !== 'call' || typeof info.event !== 'string') return;
const store = useCallStore.getState();
const payloadCallId = info.payload?.callId;
if (store.topic !== topicName || (payloadCallId && payloadCallId !== store.callId)) return;
if (info.event === 'ringing' && store.phase === 'outgoing') {
// Purely a UI cue (ring-back tone) -- no state transition needed beyond what's already shown.
return;
}
if (info.event === 'accept' && store.phase === 'outgoing') {
useCallStore.setState({ phase: 'connected', connectedAt: Date.now() });
const { topic: joinTopic, callId, seq, callType } = store;
if (joinTopic && callId && seq != null && callType) {
joinRtcCall(joinTopic, callId, seq, callType, 'caller')
.then(async (join) => {
if (useCallStore.getState().callId !== callId) return;
await joinCall(join, callType, (v) => useCallStore.getState().setHasRemoteVideo(v));
if (useCallStore.getState().callId === callId) useCallStore.setState({ hasLocalVideo: callType === 'video' });
})
.catch(() => {
// Same reasoning as acceptCall()'s own catch branch: `connectedAt` was already set (right
// above, before this join attempt) the instant `accept` came in, so routing this through
// the generic hangUp() would read that truthy `connectedAt` and misreport a real "通话时长"
// for a call that never actually connected media at all -- go straight to the honest
// 'disconnected' outcome instead, and still tell the peer so their UI doesn't hang.
if (useCallStore.getState().callId !== callId) return;
if (joinTopic && seq != null) sendCallHangup(getTinode().getTopic(joinTopic), seq, callId);
leaveCall();
useCallStore.getState()._endWithReason('disconnected');
});
}
return;
}
if (info.event === 'hang-up') {
const reason = info.payload?.reason;
const connectedAt = useCallStore.getState().connectedAt;
leaveCall();
// Collapsing every reason except 'busy' into 'finished' meant a callee who clicked 拒绝 made
// the CALLER's overlay wrongly show "通话已结束" instead of "对方已拒绝" (rejectCall() has
// already been sending a real `reason:'declined'` all along, just never read back out here),
// and a caller who cancelled before pickup made the CALLEE's overlay wrongly show "通话已结束"
// instead of "未接听" -- mirrors the SAME connectedAt-based finished-vs-missed distinction the
// local hangUp() above already makes for its own no-reason-given case.
useCallStore.getState()._endWithReason(reason === 'busy' ? 'busy' : reason === 'declined' ? 'declined' : connectedAt ? 'finished' : 'missed');
}
});
}
+25
View File
@@ -0,0 +1,25 @@
import { create } from 'zustand';
/**
* Transient cross-component instruction: HistorySearchModal (owned by MessagesLayout, a sibling of
* ConversationView, not an ancestor of it) sets this when a search result is clicked; ConversationView
* consumes it and clears it. Matches this codebase's own established pattern for this exact category
* of problem (stores/calls.ts's `useCallStore`), rather than widening ConversationView's Outlet
* context (`onOpenHistory` etc.) with a second, differently-shaped callback, or inventing a URL-param
* convention this app doesn't otherwise use anywhere.
*
* A search result is always for the SAME topic already open behind the modal (HistorySearchModal has
* no cross-conversation search), so `topic` here is mostly a defensive consistency check, not a real
* "jump to a different conversation" feature.
*/
interface MessageJumpState {
target: { topic: string; seq: number } | null;
requestJump: (topic: string, seq: number) => void;
clear: () => void;
}
export const useMessageJumpStore = create<MessageJumpState>((set) => ({
target: null,
requestJump: (topic, seq) => set({ target: { topic, seq } }),
clear: () => set({ target: null }),
}));
+5
View File
@@ -8,6 +8,11 @@ export interface TenantContext {
/** Real Tinode WS host + scheme, parsed from tenants/resolve's `endpoints.websocket`. */
host: string;
secure: boolean;
/** `tenants/resolve`'s own `password_policy` (docs/IM-前后端对接文档.md:534, a real per-tenant
* field, not a guess) -- kept here so every password-entry page (Register/ResetPassword/
* SecuritySettings) reads ONE real source instead of each hardcoding its own guessed minimum
* (they'd drifted to two different numbers, 6 and 8, before this existed). */
passwordPolicy?: { minLength: number; maxLength: number };
}
interface SessionState {