fix: call media-connect state, blocklist banner staleness, group rename, avatar URL
- stores/calls.ts, CallRoot.tsx: distinguish signaling-connected from actual media-joined (mediaJoined flag), so a call whose mic never attaches no longer shows a live timer/"网络良好"; propagate a disconnected reason on post-accept media failure so both sides agree on the outcome instead of one side seeing a normal "已结束". - DetailColumn.tsx: invalidate conversation-context alongside blocks on block/unblock, so the composer's blocked-notice banner updates immediately instead of only after a window-focus refetch. - GroupDetailColumn.tsx: add an owner-only group rename control (updateGroup was already implemented but never wired to any UI). - Avatar.tsx: stop re-wrapping an already-absolute avatar_file_id (seen on default-avatar placeholders) into a malformed /v0/file/s/https://... request.
This commit is contained in:
+10
-4
@@ -20,12 +20,18 @@ interface AvatarProps {
|
|||||||
* through every profile-update API in this app but never actually RENDERED anywhere -- every avatar
|
* through every profile-update API in this app but never actually RENDERED anywhere -- every avatar
|
||||||
* call site across the whole codebase drew only the letter-tile fallback, so uploading a custom
|
* call site across the whole codebase drew only the letter-tile fallback, so uploading a custom
|
||||||
* avatar produced zero visible change anywhere, including on the very page the upload happened.
|
* avatar produced zero visible change anywhere, including on the very page the upload happened.
|
||||||
* `avatar_file_id` is a bare file id (e.g. "abc123"), not a ref path -- reconstruct the
|
* `avatar_file_id` is USUALLY a bare file id (e.g. "abc123"), needing the `/v0/file/s/{id}` shape
|
||||||
* `/v0/file/s/{id}` shape `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already
|
* `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already used for chat
|
||||||
* used for chat attachments) expects.
|
* attachments) reconstructed -- but live-confirmed (2026-09-18, real console error): default-avatar
|
||||||
|
* placeholders come back as an ALREADY-COMPLETE OSS URL in this same field (e.g.
|
||||||
|
* "https://im-oss-t1.oss-cn-shenzhen.aliyuncs.com/im-assets/default-avatars/male/2.png"), which this
|
||||||
|
* used to unconditionally re-wrap into "/v0/file/s/https://...", a malformed request that 404s.
|
||||||
|
* `useAuthedBlobUrl`/`fileDownloadUrl` already accept either shape (see hooks.ts's own comment) --
|
||||||
|
* only needed to stop force-prefixing a value that's already a full URL.
|
||||||
*/
|
*/
|
||||||
export function Avatar({ id, name, avatarFileId, className = '', fallbackBgClass }: AvatarProps) {
|
export function Avatar({ id, name, avatarFileId, className = '', fallbackBgClass }: AvatarProps) {
|
||||||
const remote = useAuthedBlobUrl(avatarFileId ? `/v0/file/s/${avatarFileId}` : undefined);
|
const isAbsoluteUrl = !!avatarFileId && /^https?:\/\//.test(avatarFileId);
|
||||||
|
const remote = useAuthedBlobUrl(avatarFileId ? (isAbsoluteUrl ? avatarFileId : `/v0/file/s/${avatarFileId}`) : undefined);
|
||||||
if (remote.url) {
|
if (remote.url) {
|
||||||
return <img src={remote.url} alt="" className={`object-cover ${className}`} />;
|
return <img src={remote.url} alt="" className={`object-cover ${className}`} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ function MobileIncomingCall() {
|
|||||||
* minimizing is pure local UI state, matching the old app's own real behavior (CallPanel is never
|
* minimizing is pure local UI state, matching the old app's own real behavior (CallPanel is never
|
||||||
* unmounted between full-screen and minimized). */
|
* unmounted between full-screen and minimized). */
|
||||||
function MinimizedCallBar() {
|
function MinimizedCallBar() {
|
||||||
const { topic, peerName, connectedAt, muted, toggleMinimize, toggleMute, hangUp } = useCallStore();
|
const { topic, peerName, connectedAt, mediaJoined, muted, toggleMinimize, toggleMute, hangUp } = useCallStore();
|
||||||
const elapsed = useElapsedSeconds(connectedAt);
|
const elapsed = useElapsedSeconds(connectedAt);
|
||||||
return (
|
return (
|
||||||
// `bottom-[106px] sm:bottom-6`: same viewport-vs-Outlet-box fixed-positioning issue as Toast.tsx
|
// `bottom-[106px] sm:bottom-6`: same viewport-vs-Outlet-box fixed-positioning issue as Toast.tsx
|
||||||
@@ -141,7 +141,10 @@ function MinimizedCallBar() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 flex-col">
|
<span className="flex min-w-0 flex-col">
|
||||||
<strong className="truncate text-sm font-medium">{peerName}</strong>
|
<strong className="truncate text-sm font-medium">{peerName}</strong>
|
||||||
<span className="text-xs text-success">{connectedAt ? `通话中 · ${formatElapsed(elapsed)}` : '正在连接…'}</span>
|
{/* `mediaJoined`, not bare `connectedAt` -- signaling accept flips `connectedAt` before
|
||||||
|
media has actually joined (see stores/calls.ts's own comment on this field), so this
|
||||||
|
used to claim "通话中" for a call whose mic never actually attached. */}
|
||||||
|
<span className="text-xs text-success">{connectedAt && mediaJoined ? `通话中 · ${formatElapsed(elapsed)}` : '正在连接…'}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={toggleMute} aria-label={muted ? '取消静音' : '静音'} className="grid h-8 w-8 flex-none place-items-center rounded-full bg-white/10">
|
<button type="button" onClick={toggleMute} aria-label={muted ? '取消静音' : '静音'} className="grid h-8 w-8 flex-none place-items-center rounded-full bg-white/10">
|
||||||
@@ -165,6 +168,7 @@ function CallWindow() {
|
|||||||
phase,
|
phase,
|
||||||
peerName,
|
peerName,
|
||||||
connectedAt,
|
connectedAt,
|
||||||
|
mediaJoined,
|
||||||
muted,
|
muted,
|
||||||
cameraOff,
|
cameraOff,
|
||||||
hasRemoteVideo,
|
hasRemoteVideo,
|
||||||
@@ -189,7 +193,11 @@ function CallWindow() {
|
|||||||
|
|
||||||
if (minimized) return <MinimizedCallBar />;
|
if (minimized) return <MinimizedCallBar />;
|
||||||
|
|
||||||
const statusLabel = phase === 'connected' ? `${formatElapsed(elapsed)} · 网络良好` : '正在等待对方接听…';
|
// Signaling `connected` and actual media both being up are two different things (see stores/
|
||||||
|
// calls.ts's `mediaJoined` comment) -- a real "网络良好" claim needs the second one too, and the
|
||||||
|
// brief signaling-only window in between gets its own label rather than either overstating the
|
||||||
|
// connection or reusing the pre-answer ringing text.
|
||||||
|
const statusLabel = phase === 'connected' ? (mediaJoined ? `${formatElapsed(elapsed)} · 网络良好` : '正在连接中…') : '正在等待对方接听…';
|
||||||
// 06-calls.dc.html #1b (呼出等待中) labels this same button "取消", matching the protocol doc's own
|
// 06-calls.dc.html #1b (呼出等待中) labels this same button "取消", matching the protocol doc's own
|
||||||
// "发起方取消发送 hang-up:cancelled" wording for the pre-connect case -- it was hardcoded to "挂断"
|
// "发起方取消发送 hang-up:cancelled" wording for the pre-connect case -- it was hardcoded to "挂断"
|
||||||
// at all 3 call sites below regardless of phase, which is only actually correct once connected.
|
// at all 3 call sites below regardless of phase, which is only actually correct once connected.
|
||||||
@@ -241,9 +249,13 @@ function CallWindow() {
|
|||||||
<strong className="text-base font-semibold">{peerName}</strong>
|
<strong className="text-base font-semibold">{peerName}</strong>
|
||||||
<span className="flex items-center gap-1 text-xs text-white/70">
|
<span className="flex items-center gap-1 text-xs text-white/70">
|
||||||
{phase === 'connected' ? (
|
{phase === 'connected' ? (
|
||||||
<>
|
mediaJoined ? (
|
||||||
{formatElapsed(elapsed)} · <Icon name="network_wifi" className="text-sm text-[#1fb36a]" /> 网络良好
|
<>
|
||||||
</>
|
{formatElapsed(elapsed)} · <Icon name="network_wifi" className="text-sm text-[#1fb36a]" /> 网络良好
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'正在连接中…'
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
'正在等待对方接听…'
|
'正在等待对方接听…'
|
||||||
)}
|
)}
|
||||||
@@ -294,7 +306,7 @@ function CallWindow() {
|
|||||||
>
|
>
|
||||||
{avatarLetter(peerName)}
|
{avatarLetter(peerName)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-white/70">{phase === 'connected' ? '对方已关闭摄像头' : ''}</span>
|
<span className="text-sm text-white/70">{phase === 'connected' && mediaJoined ? '对方已关闭摄像头' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Mobile fallback (06-calls.dc.html #1f): avatar + name + duration together, since mobile
|
{/* Mobile fallback (06-calls.dc.html #1f): avatar + name + duration together, since mobile
|
||||||
@@ -307,12 +319,12 @@ function CallWindow() {
|
|||||||
</span>
|
</span>
|
||||||
<strong className="mt-2 text-xl font-semibold">{peerName}</strong>
|
<strong className="mt-2 text-xl font-semibold">{peerName}</strong>
|
||||||
<span className="mt-1 text-sm text-white/70">
|
<span className="mt-1 text-sm text-white/70">
|
||||||
{phase === 'connected' ? formatElapsed(elapsed) : '正在等待对方接听…'}
|
{phase === 'connected' ? (mediaJoined ? formatElapsed(elapsed) : '正在连接中…') : '正在等待对方接听…'}
|
||||||
</span>
|
</span>
|
||||||
{/* Desktop's version of this same fallback state shows this explanation (line 285
|
{/* Desktop's version of this same fallback state shows this explanation (line 285
|
||||||
above); mobile silently dropped it, showing only the elapsed-time line with no
|
above); mobile silently dropped it, showing only the elapsed-time line with no
|
||||||
indication of WHY there's no video. */}
|
indication of WHY there's no video. */}
|
||||||
{phase === 'connected' ? <span className="text-sm text-white/70">对方已关闭摄像头</span> : null}
|
{phase === 'connected' && mediaJoined ? <span className="text-sm text-white/70">对方已关闭摄像头</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,12 +19,15 @@ import {
|
|||||||
getGroupSettings,
|
getGroupSettings,
|
||||||
getGroupSummary,
|
getGroupSummary,
|
||||||
leaveGroup,
|
leaveGroup,
|
||||||
|
updateGroup,
|
||||||
updateGroupMemberSettings,
|
updateGroupMemberSettings,
|
||||||
updateMyGroupNickname,
|
updateMyGroupNickname,
|
||||||
type GroupMember,
|
type GroupMember,
|
||||||
|
type GroupProfile,
|
||||||
type GroupSettings,
|
type GroupSettings,
|
||||||
} from '@/lib/api/groups';
|
} from '@/lib/api/groups';
|
||||||
import type { MediaType } from '@/lib/api/messages';
|
import type { MediaType } from '@/lib/api/messages';
|
||||||
|
import { MAX_TITLE_LENGTH } from '@/lib/config';
|
||||||
import { useTinode } from '@/lib/tinode/hooks';
|
import { useTinode } from '@/lib/tinode/hooks';
|
||||||
import { forgetTopic } from '@/lib/tinode/topics';
|
import { forgetTopic } from '@/lib/tinode/topics';
|
||||||
import { requestConfirm } from '@/stores/confirmDialog';
|
import { requestConfirm } from '@/stores/confirmDialog';
|
||||||
@@ -81,6 +84,13 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
|||||||
const myUserId = useSessionStore((s) => s.myUserId);
|
const myUserId = useSessionStore((s) => s.myUserId);
|
||||||
const [nicknameEditing, setNicknameEditing] = useState(false);
|
const [nicknameEditing, setNicknameEditing] = useState(false);
|
||||||
const [nicknameDraft, setNicknameDraft] = useState('');
|
const [nicknameDraft, setNicknameDraft] = useState('');
|
||||||
|
// `updateGroup` (lib/api/groups.ts) has always supported renaming a group -- confirmed live no UI
|
||||||
|
// anywhere ever called it (grep across the whole repo: zero call sites), so a group's name was
|
||||||
|
// fixed forever at whatever was typed into CreateGroupModal's input at creation time, with no way
|
||||||
|
// to fix a typo or rename it later. Owner-only, matching this file's other high-consequence group-
|
||||||
|
// level (as opposed to per-member) actions.
|
||||||
|
const [nameEditing, setNameEditing] = useState(false);
|
||||||
|
const [nameDraft, setNameDraft] = useState('');
|
||||||
const [errorToast, setErrorToast] = useState('');
|
const [errorToast, setErrorToast] = useState('');
|
||||||
const [openModal, setOpenModal] = useState<'members' | 'invite' | 'announcement' | 'files' | 'admin' | 'qrcode' | null>(null);
|
const [openModal, setOpenModal] = useState<'members' | 'invite' | 'announcement' | 'files' | 'admin' | 'qrcode' | null>(null);
|
||||||
const showError = (err: unknown, fallback: string) => setErrorToast(err instanceof Error ? err.message : fallback);
|
const showError = (err: unknown, fallback: string) => setErrorToast(err instanceof Error ? err.message : fallback);
|
||||||
@@ -90,6 +100,8 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setNicknameEditing(false);
|
setNicknameEditing(false);
|
||||||
setNicknameDraft('');
|
setNicknameDraft('');
|
||||||
|
setNameEditing(false);
|
||||||
|
setNameDraft('');
|
||||||
setErrorToast('');
|
setErrorToast('');
|
||||||
setOpenModal(null);
|
setOpenModal(null);
|
||||||
}, [topicId]);
|
}, [topicId]);
|
||||||
@@ -152,6 +164,17 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
|||||||
onError: (err) => showError(err, '昵称保存失败'),
|
onError: (err) => showError(err, '昵称保存失败'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nameMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => updateGroup(topicId, { name, version: groupQuery.data?.version ?? 1 }),
|
||||||
|
// Merge, not replace -- same defensive reasoning as updateSettings above, in case this endpoint
|
||||||
|
// ever turns out to return a partial object like member-settings did.
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(['group', topicId], (prev: GroupProfile | undefined) => (prev ? { ...prev, ...data } : data));
|
||||||
|
setNameEditing(false);
|
||||||
|
},
|
||||||
|
onError: (err) => showError(err, '群名保存失败'),
|
||||||
|
});
|
||||||
|
|
||||||
// Both mutations call forgetTopic() the instant their own REST call succeeds, rather than waiting
|
// Both mutations call forgetTopic() the instant their own REST call succeeds, rather than waiting
|
||||||
// on the async native/`group.dissolved` gone-signal that normally follows a group actually going
|
// on the async native/`group.dissolved` gone-signal that normally follows a group actually going
|
||||||
// away -- real, user-reported bugs fixed by this: (1) the chat list kept showing a broken stale
|
// away -- real, user-reported bugs fixed by this: (1) the chat list kept showing a broken stale
|
||||||
@@ -259,8 +282,49 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
|||||||
avatarFileId={groupQuery.data?.avatar_file_id}
|
avatarFileId={groupQuery.data?.avatar_file_id}
|
||||||
className="h-13 w-13 flex-none rounded-xl text-xl"
|
className="h-13 w-13 flex-none rounded-xl text-xl"
|
||||||
/>
|
/>
|
||||||
<div className="flex min-w-0 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<strong className="truncate text-base font-semibold text-text-heading">{groupQuery.data?.name || peerName}</strong>
|
{nameEditing ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={nameDraft}
|
||||||
|
onChange={(e) => setNameDraft(e.target.value.slice(0, MAX_TITLE_LENGTH))}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && nameDraft.trim() && nameMutation.mutate(nameDraft.trim())}
|
||||||
|
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => nameDraft.trim() && nameMutation.mutate(nameDraft.trim())}
|
||||||
|
disabled={!nameDraft.trim() || nameMutation.isPending}
|
||||||
|
className="flex-none text-sm text-accent disabled:opacity-40"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setNameEditing(false)} className="flex-none text-sm text-text-tertiary-2">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="flex min-w-0 items-center gap-1">
|
||||||
|
<strong className="truncate text-base font-semibold text-text-heading">{groupQuery.data?.name || peerName}</strong>
|
||||||
|
{/* Owner-only -- renaming a group is a group-level action, not a per-member one
|
||||||
|
like the 我在本群的昵称 row further down, so this follows this file's other
|
||||||
|
owner-gated actions (转让群主/解散群聊) rather than opening it to admins too. */}
|
||||||
|
{isOwner ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="修改群名"
|
||||||
|
onClick={() => {
|
||||||
|
setNameDraft(groupQuery.data?.name || '');
|
||||||
|
setNameEditing(true);
|
||||||
|
}}
|
||||||
|
className="flex-none text-text-tertiary-3"
|
||||||
|
>
|
||||||
|
<Icon name="edit" className="text-[16px]" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-xs text-text-tertiary-2">
|
<span className="text-xs text-text-tertiary-2">
|
||||||
群号 {topicId} · {memberCount} 人
|
群号 {topicId} · {memberCount} 人
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -135,7 +135,15 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
|||||||
|
|
||||||
const blockMutation = useMutation({
|
const blockMutation = useMutation({
|
||||||
mutationFn: (blocked: boolean) => (blocked ? blockUser(peerUserId) : unblockUser(peerUserId)),
|
mutationFn: (blocked: boolean) => (blocked ? blockUser(peerUserId) : unblockUser(peerUserId)),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
|
// `['blocks']` alone left the composer's "你已将对方加入黑名单/对方已将你加入黑名单" banner (driven
|
||||||
|
// by a SEPARATE query, `['conversation-context', topicId]` in ConversationView.tsx) showing the
|
||||||
|
// pre-mutation state until something ELSE happened to refetch it -- live-confirmed: toggling block
|
||||||
|
// then re-checking the same conversation immediately still showed the old banner/composer state,
|
||||||
|
// and it only ever caught up after an incidental `refetchOnWindowFocus` (switching tabs and back).
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['blocks'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['conversation-context', topicId] });
|
||||||
|
},
|
||||||
onError: (err) => showError(err, '设置失败,请重试'),
|
onError: (err) => showError(err, '设置失败,请重试'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+27
-6
@@ -35,6 +35,13 @@ interface CallState {
|
|||||||
// dynamic Agora SDK import -> client.join() -> createCameraVideoTrack()) hadn't resolved yet, and
|
// 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.
|
// never re-fire once it did, leaving the local preview box permanently blank for the whole call.
|
||||||
hasLocalVideo: boolean;
|
hasLocalVideo: boolean;
|
||||||
|
// Set once `joinCall()` actually resolves (mic track created and published, or for video, mic+
|
||||||
|
// camera) -- distinct from `phase==='connected'`, which flips the instant the SIGNALING accept
|
||||||
|
// arrives, before any media has actually joined. `CallWindow`'s status line previously showed a
|
||||||
|
// ticking duration + "网络良好" from the moment `phase` became 'connected', which live-confirmed
|
||||||
|
// stays true even when the mic never actually attaches at all (permission denied/no device) -- a
|
||||||
|
// real user reported this as "看起来接通了" for a call where no audio ever worked on either side.
|
||||||
|
mediaJoined: boolean;
|
||||||
// `'busy'` is never a real PERSISTED `head.webrtc` value (confirmed live, see dispatch.ts's own
|
// `'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
|
// 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
|
// own `sendCallHangup(..., 'busy')` puts on the LIVE, ephemeral `{info hang-up}` signal a second
|
||||||
@@ -74,6 +81,7 @@ const IDLE = {
|
|||||||
connectedAt: null,
|
connectedAt: null,
|
||||||
hasRemoteVideo: false,
|
hasRemoteVideo: false,
|
||||||
hasLocalVideo: false,
|
hasLocalVideo: false,
|
||||||
|
mediaJoined: false,
|
||||||
endReason: null,
|
endReason: null,
|
||||||
wasCaller: false,
|
wasCaller: false,
|
||||||
mediaError: null,
|
mediaError: null,
|
||||||
@@ -123,7 +131,7 @@ export const useCallStore = create<CallState>((set, get) => ({
|
|||||||
const join = await joinRtcCall(topic, callId, seq, callType, 'callee');
|
const join = await joinRtcCall(topic, callId, seq, callType, 'callee');
|
||||||
if (get().callId !== callId) return;
|
if (get().callId !== callId) return;
|
||||||
await joinCall(join, callType, (v) => get().setHasRemoteVideo(v));
|
await joinCall(join, callType, (v) => get().setHasRemoteVideo(v));
|
||||||
if (get().callId === callId) set({ hasLocalVideo: callType === 'video' });
|
if (get().callId === callId) set({ hasLocalVideo: callType === 'video', mediaJoined: true });
|
||||||
} catch {
|
} catch {
|
||||||
// Same guard the structurally-identical caller-side accept handler below already has, for the
|
// 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,
|
// identical reason: `callId` here is the ORIGINAL id this whole acceptCall() call was for,
|
||||||
@@ -138,7 +146,14 @@ export const useCallStore = create<CallState>((set, get) => ({
|
|||||||
// just only when THIS call is still the live one.
|
// just only when THIS call is still the live one.
|
||||||
if (get().callId !== callId) return;
|
if (get().callId !== callId) return;
|
||||||
const { topic: currentTopic, seq: currentSeq } = get();
|
const { topic: currentTopic, seq: currentSeq } = get();
|
||||||
if (currentTopic && currentSeq != null) sendCallHangup(getTinode().getTopic(currentTopic), currentSeq, callId);
|
// `reason: 'disconnected'` (not the default no-reason hang-up) so the OTHER side's own
|
||||||
|
// `info.event==='hang-up'` handler can tell this apart from a normal hang-up too -- without it,
|
||||||
|
// the peer's `connectedAt` is already truthy (set the moment accept/accept-event landed, same
|
||||||
|
// as this side's), so its own reason-mapping fell through to 'finished' and both the live
|
||||||
|
// overlay AND the permanent chat-history record showed a normal "通话时长" for a call where
|
||||||
|
// media never actually worked on either end. Live-confirmed: both sides recorded "通话时长
|
||||||
|
// 00:03" for a call that failed getUserMedia immediately after accept.
|
||||||
|
if (currentTopic && currentSeq != null) sendCallHangup(getTinode().getTopic(currentTopic), currentSeq, callId, 'disconnected');
|
||||||
leaveCall();
|
leaveCall();
|
||||||
get()._endWithReason('disconnected');
|
get()._endWithReason('disconnected');
|
||||||
}
|
}
|
||||||
@@ -321,7 +336,7 @@ export function initCallSignaling() {
|
|||||||
.then(async (join) => {
|
.then(async (join) => {
|
||||||
if (useCallStore.getState().callId !== callId) return;
|
if (useCallStore.getState().callId !== callId) return;
|
||||||
await joinCall(join, callType, (v) => useCallStore.getState().setHasRemoteVideo(v));
|
await joinCall(join, callType, (v) => useCallStore.getState().setHasRemoteVideo(v));
|
||||||
if (useCallStore.getState().callId === callId) useCallStore.setState({ hasLocalVideo: callType === 'video' });
|
if (useCallStore.getState().callId === callId) useCallStore.setState({ hasLocalVideo: callType === 'video', mediaJoined: true });
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Same reasoning as acceptCall()'s own catch branch: `connectedAt` was already set (right
|
// Same reasoning as acceptCall()'s own catch branch: `connectedAt` was already set (right
|
||||||
@@ -330,7 +345,9 @@ export function initCallSignaling() {
|
|||||||
// for a call that never actually connected media at all -- go straight to the honest
|
// 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.
|
// 'disconnected' outcome instead, and still tell the peer so their UI doesn't hang.
|
||||||
if (useCallStore.getState().callId !== callId) return;
|
if (useCallStore.getState().callId !== callId) return;
|
||||||
if (joinTopic && seq != null) sendCallHangup(getTinode().getTopic(joinTopic), seq, callId);
|
// `reason: 'disconnected'` -- same fix as acceptCall()'s structurally identical catch,
|
||||||
|
// so the callee's own hang-up handler maps this to 'disconnected' instead of 'finished'.
|
||||||
|
if (joinTopic && seq != null) sendCallHangup(getTinode().getTopic(joinTopic), seq, callId, 'disconnected');
|
||||||
leaveCall();
|
leaveCall();
|
||||||
useCallStore.getState()._endWithReason('disconnected');
|
useCallStore.getState()._endWithReason('disconnected');
|
||||||
});
|
});
|
||||||
@@ -346,8 +363,12 @@ export function initCallSignaling() {
|
|||||||
// already been sending a real `reason:'declined'` all along, just never read back out here),
|
// 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 "通话已结束"
|
// 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
|
// instead of "未接听" -- mirrors the SAME connectedAt-based finished-vs-missed distinction the
|
||||||
// local hangUp() above already makes for its own no-reason-given case.
|
// local hangUp() above already makes for its own no-reason-given case. `'disconnected'` is the
|
||||||
useCallStore.getState()._endWithReason(reason === 'busy' ? 'busy' : reason === 'declined' ? 'declined' : connectedAt ? 'finished' : 'missed');
|
// same fix, for the media-join-failed case: acceptCall()/the accept-handler's own catch above
|
||||||
|
// now send this explicit reason so the OTHER side shows "连接异常" too, not a fake "通话已结束".
|
||||||
|
useCallStore
|
||||||
|
.getState()
|
||||||
|
._endWithReason(reason === 'busy' ? 'busy' : reason === 'declined' ? 'declined' : reason === 'disconnected' ? 'disconnected' : connectedAt ? 'finished' : 'missed');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user