修复两个严重崩溃:群管理页面查询键冲突导致整个会话崩溃、禁言列表读取
不存在的嵌套字段崩溃
- ConversationView.tsx和GroupAdminModal.tsx对群禁言列表用了完全相同的
React Query缓存键['group-mutes', topicId],但分别调用getGroupMutes
(单页,{items:[]}包装对象)和getAllGroupMutes(全部页,展开后的纯数组)
两个返回形状不同的函数。两个组件同时挂载时后写入的一方会覆盖缓存,
另一方读到错误形状后对undefined取.length直接崩溃,导致只要打开某个
群的"群管理"面板整个会话界面就白屏。改成两处统一用getAllGroupMutes,
真正共享同一份、同一种形状的数据
- GroupMute类型声明的是嵌套的user:{user_id,display_name},但真实响应
是扁平的{user_id,muted_by,muted_at,reason,status,version},且完全没有
display_name字段。这个字段错配从来没被暴露过,因为此前从未有真实账号
真正禁言过人后去看这个列表——本轮实测"禁言→查看列表"时才第一次真正
用真实数据渲染,立刻崩溃。改成扁平字段+交叉查群成员列表解析真实姓名
(同一模式ConversationView.tsx的memberByUserId、GroupFilesModal的
uploader_id都已经在用)
用全新账号在全新群里完整实测:禁言成员1人正确显示头像+姓名"systester2"
(不再是原始uid或崩溃),解除禁言后列表正确清零,两处此前必崩的入口
(群管理面板本身、禁言列表渲染)全部验证通过。
This commit is contained in:
@@ -410,6 +410,13 @@ function MutesSubView({ topicId, muted, onClose }: { topicId: string; muted: Gro
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
// `GroupMute` itself carries only a bare `user_id` (see its own type comment) -- resolve a real
|
||||||
|
// name/avatar the same way ConversationView.tsx's `memberByUserId` does, rather than rendering the
|
||||||
|
// raw id (this list previously crashed outright reading a `.user.display_name` that never existed
|
||||||
|
// on the real response, confirmed live: `Cannot read properties of undefined (reading 'user_id')`
|
||||||
|
// the moment a real muted member was ever rendered here).
|
||||||
|
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
||||||
|
const memberByUserId = new Map((membersQuery.data || []).map((m) => [m.user_id, m]));
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['group-mutes', topicId] });
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['group-mutes', topicId] });
|
||||||
const muteMutation = useMutation({
|
const muteMutation = useMutation({
|
||||||
mutationFn: (userId: string) => muteGroupMembers(topicId, [{ user_id: userId, expires_at: null }]),
|
mutationFn: (userId: string) => muteGroupMembers(topicId, [{ user_id: userId, expires_at: null }]),
|
||||||
@@ -439,22 +446,25 @@ function MutesSubView({ topicId, muted, onClose }: { topicId: string; muted: Gro
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{muted.map((m) => (
|
{muted.map((m) => {
|
||||||
<div key={m.user.user_id} className="flex items-center gap-3 px-3 py-2">
|
const name = memberByUserId.get(m.user_id)?.display_name || m.user_id;
|
||||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user.user_id)}`}>
|
return (
|
||||||
{avatarLetter(m.user.display_name)}
|
<div key={m.user_id} className="flex items-center gap-3 px-3 py-2">
|
||||||
</span>
|
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
|
||||||
<span className="flex-1 truncate text-sm text-text-body">{m.user.display_name || m.user.user_id}</span>
|
{avatarLetter(name)}
|
||||||
<button
|
</span>
|
||||||
type="button"
|
<span className="flex-1 truncate text-sm text-text-body">{name}</span>
|
||||||
onClick={() => unmuteMutation.mutate(m.user.user_id)}
|
<button
|
||||||
disabled={unmuteMutation.isPending}
|
type="button"
|
||||||
className="text-xs text-accent disabled:opacity-50"
|
onClick={() => unmuteMutation.mutate(m.user_id)}
|
||||||
>
|
disabled={unmuteMutation.isPending}
|
||||||
解除禁言
|
className="text-xs text-accent disabled:opacity-50"
|
||||||
</button>
|
>
|
||||||
</div>
|
解除禁言
|
||||||
))}
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<button type="button" onClick={() => setAdding(true)} className="flex w-full items-center gap-2 px-3 py-2 text-sm text-accent">
|
<button type="button" onClick={() => setAdding(true)} className="flex w-full items-center gap-2 px-3 py-2 text-sm text-accent">
|
||||||
<Icon name="add" className="text-[18px]" />
|
<Icon name="add" className="text-[18px]" />
|
||||||
禁言成员
|
禁言成员
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal
|
|||||||
import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
|
import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
|
||||||
import { useBootstrap } from '@/lib/api/hooks';
|
import { useBootstrap } from '@/lib/api/hooks';
|
||||||
import { favoriteMessages, type MediaType, recallMessage } from '@/lib/api/messages';
|
import { favoriteMessages, type MediaType, recallMessage } from '@/lib/api/messages';
|
||||||
import { getAllGroupMembers, getGroup, getGroupGovernance, getGroupMutes, getGroupSummary } from '@/lib/api/groups';
|
import { getAllGroupMembers, getAllGroupMutes, getGroup, getGroupGovernance, getGroupSummary } from '@/lib/api/groups';
|
||||||
import { getMe } from '@/lib/api/profile';
|
import { getMe } from '@/lib/api/profile';
|
||||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||||
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
|
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
|
||||||
@@ -301,9 +301,20 @@ export function ConversationView() {
|
|||||||
queryFn: () => getGroupGovernance(topicId || ''),
|
queryFn: () => getGroupGovernance(topicId || ''),
|
||||||
enabled: isGroup,
|
enabled: isGroup,
|
||||||
});
|
});
|
||||||
|
// Real, live-confirmed crash (not theoretical): this used to call `getGroupMutes` (single page,
|
||||||
|
// `{items, next_cursor}` shape) under query key `['group-mutes', topicId]` -- the EXACT SAME key
|
||||||
|
// `GroupAdminModal.tsx` uses for `getAllGroupMutes` (all pages, flat array). Both mounted together
|
||||||
|
// (opening 群管理 from a group's own conversation screen, which is the only way to reach it) means
|
||||||
|
// whichever query resolves last overwrites the shared cache entry with ITS shape; the other
|
||||||
|
// consumer then reads `array.items.length` -- `.items` is undefined on a plain array, and unlike
|
||||||
|
// `mutesQuery.data?.items.length`'s leading `?.`, nothing guards THAT specific property access, so
|
||||||
|
// it throws and takes down the whole conversation screen. Matching `getAllGroupMutes` here (same
|
||||||
|
// function, same shape, same key) turns this from two competing queries into one correctly-shared
|
||||||
|
// one -- also fixes this subline's own under-count for any group with more muted members than a
|
||||||
|
// single page holds, the same truncation bug `getAllGroupMutes`'s own doc comment already flags.
|
||||||
const mutesQuery = useQuery({
|
const mutesQuery = useQuery({
|
||||||
queryKey: ['group-mutes', topicId],
|
queryKey: ['group-mutes', topicId],
|
||||||
queryFn: () => getGroupMutes(topicId || ''),
|
queryFn: () => getAllGroupMutes(topicId || ''),
|
||||||
enabled: isGroup,
|
enabled: isGroup,
|
||||||
});
|
});
|
||||||
const memberByUserId = useMemo(() => new Map((membersQuery.data || []).map((m) => [m.user_id, m])), [membersQuery.data]);
|
const memberByUserId = useMemo(() => new Map((membersQuery.data || []).map((m) => [m.user_id, m])), [membersQuery.data]);
|
||||||
@@ -311,7 +322,7 @@ export function ConversationView() {
|
|||||||
const isGroupAdmin = myMember?.role === 'owner' || myMember?.role === 'admin';
|
const isGroupAdmin = myMember?.role === 'owner' || myMember?.role === 'admin';
|
||||||
const groupSublineParts = [
|
const groupSublineParts = [
|
||||||
governanceQuery.data?.join_approval ? '已开启进群验证' : null,
|
governanceQuery.data?.join_approval ? '已开启进群验证' : null,
|
||||||
mutesQuery.data?.items.length ? `${mutesQuery.data.items.length} 位成员被禁言` : null,
|
mutesQuery.data?.length ? `${mutesQuery.data.length} 位成员被禁言` : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
if (!topicId) return null;
|
if (!topicId) return null;
|
||||||
|
|||||||
@@ -239,11 +239,18 @@ export function removeGroupAdmin(topic: string, userId: string, version: number)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Real response is flat (confirmed live: `{muted_at,muted_by,reason,status,user_id,version}`) --
|
||||||
|
* no nested `user` object, and no `display_name` at all (unlike e.g. group members, this endpoint
|
||||||
|
* never returns a readable name, only the bare id -- callers needing one must cross-reference the
|
||||||
|
* member list themselves, same as GroupFilesModal already has to for `uploader_id`). */
|
||||||
export interface GroupMute {
|
export interface GroupMute {
|
||||||
user: { user_id: string; display_name?: string };
|
user_id: string;
|
||||||
muted_by?: string;
|
muted_by?: string;
|
||||||
muted_at: string;
|
muted_at: string;
|
||||||
expires_at: string | null;
|
expires_at?: string | null;
|
||||||
|
reason?: string;
|
||||||
|
status?: string;
|
||||||
|
version?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function muteGroupMembers(topic: string, members: Array<{ user_id: string; expires_at: string | null }>, reason?: string) {
|
export function muteGroupMembers(topic: string, members: Array<{ user_id: string; expires_at: string | null }>, reason?: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user