From 7fff1a7b3a6852e8705e858df323b56d832f9f8b Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 18 Sep 2026 15:01:08 +0800 Subject: [PATCH] fix: call media-connect state, blocklist banner staleness, group rename, avatar URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- web/src/app/Avatar.tsx | 14 ++-- web/src/features/calls/CallRoot.tsx | 30 +++++--- web/src/features/groups/GroupDetailColumn.tsx | 68 ++++++++++++++++++- web/src/features/messages/DetailColumn.tsx | 10 ++- web/src/stores/calls.ts | 33 +++++++-- 5 files changed, 133 insertions(+), 22 deletions(-) diff --git a/web/src/app/Avatar.tsx b/web/src/app/Avatar.tsx index 75ad4aa8..06294838 100644 --- a/web/src/app/Avatar.tsx +++ b/web/src/app/Avatar.tsx @@ -20,12 +20,18 @@ interface AvatarProps { * 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 * 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 - * `/v0/file/s/{id}` shape `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already - * used for chat attachments) expects. + * `avatar_file_id` is USUALLY a bare file id (e.g. "abc123"), needing the `/v0/file/s/{id}` shape + * `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already used for chat + * 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) { - 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) { return ; } diff --git a/web/src/features/calls/CallRoot.tsx b/web/src/features/calls/CallRoot.tsx index a8443f56..00ea4fad 100644 --- a/web/src/features/calls/CallRoot.tsx +++ b/web/src/features/calls/CallRoot.tsx @@ -127,7 +127,7 @@ function MobileIncomingCall() { * minimizing is pure local UI state, matching the old app's own real behavior (CallPanel is never * unmounted between full-screen and minimized). */ 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); return ( // `bottom-[106px] sm:bottom-6`: same viewport-vs-Outlet-box fixed-positioning issue as Toast.tsx @@ -141,7 +141,10 @@ function MinimizedCallBar() { {peerName} - {connectedAt ? `通话中 · ${formatElapsed(elapsed)}` : '正在连接…'} + {/* `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. */} + {connectedAt && mediaJoined ? `通话中 · ${formatElapsed(elapsed)}` : '正在连接…'} + + + ) : ( + + {groupQuery.data?.name || peerName} + {/* 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 ? ( + + ) : null} + + )} 群号 {topicId} · {memberCount} 人 diff --git a/web/src/features/messages/DetailColumn.tsx b/web/src/features/messages/DetailColumn.tsx index be9b66aa..c7e036d5 100644 --- a/web/src/features/messages/DetailColumn.tsx +++ b/web/src/features/messages/DetailColumn.tsx @@ -135,7 +135,15 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta const blockMutation = useMutation({ 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, '设置失败,请重试'), }); diff --git a/web/src/stores/calls.ts b/web/src/stores/calls.ts index b4ef59e9..ac2be0b5 100644 --- a/web/src/stores/calls.ts +++ b/web/src/stores/calls.ts @@ -35,6 +35,13 @@ interface CallState { // 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; + // 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 // 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 @@ -74,6 +81,7 @@ const IDLE = { connectedAt: null, hasRemoteVideo: false, hasLocalVideo: false, + mediaJoined: false, endReason: null, wasCaller: false, mediaError: null, @@ -123,7 +131,7 @@ export const useCallStore = create((set, get) => ({ 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' }); + if (get().callId === callId) set({ hasLocalVideo: callType === 'video', mediaJoined: true }); } 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, @@ -138,7 +146,14 @@ export const useCallStore = create((set, get) => ({ // 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); + // `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(); get()._endWithReason('disconnected'); } @@ -321,7 +336,7 @@ export function initCallSignaling() { .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' }); + if (useCallStore.getState().callId === callId) useCallStore.setState({ hasLocalVideo: callType === 'video', mediaJoined: true }); }) .catch(() => { // 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 // '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); + // `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(); 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), // 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'); + // local hangUp() above already makes for its own no-reason-given case. `'disconnected'` is the + // 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'); } }); }