From 0ba5c97d784c97a42fc0d01b1405d81193290c6b Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 25 Sep 2026 00:45:34 +0800 Subject: [PATCH] =?UTF-8?q?codereview=E4=BF=AE=E5=A4=8D=EF=BC=9A=E8=B5=84?= =?UTF-8?q?=E6=96=99=E5=BC=B9=E7=AA=97=E5=A5=BD=E5=8F=8B=E5=85=B3=E7=B3=BB?= =?UTF-8?q?=E5=88=A4=E6=96=AD=E3=80=81=E8=BD=AC=E5=8F=91=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E5=85=8B=E9=9A=86=E3=80=81=E8=B4=A6=E5=8F=B7=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E6=AE=8B=E7=95=99=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三路独立codereview(群成员UI+资料弹窗 / 未读红点+转发修复 / 搜索+复制修复) 发现并修复: - UserCardRoot 资料弹窗此前完全没读 profile.relationship,对陌生人也直接 显示"发消息"/通话按钮——现在按好友/待验证/对方已申请/不可添加/添加好友 五态分支,和 FriendProfileView 保持一致,接入 AddFriendModal。 - useEscapeKey 注册位置错误:挂在常驻的 UserCardRoot 而不是随卡片开关的 UserCardPanel,导致从其他弹层里打开资料卡片时,按 Escape 关掉的是底下 那层弹层而不是卡片本身。 - "移出"格子的可见性此前只判断 isAdmin,在"群主+多个管理员、没有普通成员" 的群里会显示格子但点进去一个能移出的都没有——改成同时判断成员列表里 是否真的存在能移出的对象。 - flatForwardPayload 对图片/文件/语音/视频/名片/通话结果类消息,转发时直接 返回了原消息的 content 对象引用而不是拷贝——SDK 出站前会就地删除假值 字段,等于转发的同时悄悄改写了原消息在本地缓存里的内容。改成每次转发都 返回浅拷贝;顺手让检查顺序和其余识别这几种格式的地方保持一致(先判类型 明确的附件/名片/通话结果,再判无类型标记的纯文本),并补上和 mobileAttachmentContentOf/contactCardContentOf 同样的字段合法性校验。 - confirmedReadSeq 等聊天列表相关的模块级缓存此前从未在登出/切换企业时清 空——同一个标签页不刷新页面直接换账号登录,会把上一个账号的"已读到哪" 当成新账号的下限,把新账号真实未读的自聊消息误判为已读。补了统一的 reset监听机制(connection.ts 的 resetTinode 现在会广播),topics.ts 据此 清空所有相关缓存。 - 复制功能补final `?? ''`兜底,防止附件数据本身不完整时仍复制出"undefined"。 Co-Authored-By: Claude Sonnet 5 --- web/src/app/UserCardRoot.tsx | 139 +++++++++++++----- web/src/features/groups/GroupDetailColumn.tsx | 13 +- web/src/features/groups/GroupMembersModal.tsx | 4 +- .../features/messages/ConversationView.tsx | 8 +- .../features/messages/HistorySearchModal.tsx | 9 ++ web/src/lib/tinode/connection.ts | 19 +++ web/src/lib/tinode/send.ts | 50 +++++-- web/src/lib/tinode/topics.ts | 21 ++- 8 files changed, 204 insertions(+), 59 deletions(-) diff --git a/web/src/app/UserCardRoot.tsx b/web/src/app/UserCardRoot.tsx index 48f46b0b..53d6753a 100644 --- a/web/src/app/UserCardRoot.tsx +++ b/web/src/app/UserCardRoot.tsx @@ -1,8 +1,11 @@ +import { useState } from 'react'; + import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { Avatar } from '@/app/Avatar'; import { Icon } from '@/app/Icon'; +import { AddFriendModal } from '@/features/contacts/AddFriendModal'; import { ImApiError } from '@/lib/api/client'; import { getUserProfile } from '@/lib/api/friends'; import { useEscapeKey } from '@/lib/hooks'; @@ -18,9 +21,6 @@ const GENDER_LABEL: Record = { male: '男', female: '女' }; * replacement for it -- "查看完整资料" below still routes there for remark-editing/QR code/etc. */ export function UserCardRoot() { const userId = useUserCardStore((s) => s.userId); - // Always registered (hooks can't be conditional), matching ConfirmDialogRoot's own reasoning -- - // a no-op onEscape while no card is open is harmless. - useEscapeKey(closeUserCard); if (!userId) return null; // key={userId} so switching straight from one card to another (tapping a different @mention // before closing the first) resets the query/local state instead of showing stale data mid-fetch. @@ -29,8 +29,16 @@ export function UserCardRoot() { function UserCardPanel({ userId }: { userId: string }) { const navigate = useNavigate(); + // Registered here, not in the always-mounted UserCardRoot above: useEscapeKey's stack (lib/hooks.ts) + // orders entries by MOUNT time, so an entry registered once at app boot would sit permanently at + // the bottom, below every modal that mounts later -- a real, reproduced bug (open GroupMembersModal + // -> tap a member row to open this card on top of it -> Escape closed the member-list modal + // underneath instead of this card). UserCardPanel only mounts while a card is genuinely open, same + // as every other overlay's own useEscapeKey call, so this now stacks correctly. + useEscapeKey(closeUserCard); const myUserId = useSessionStore((s) => s.myUserId); const isSelf = !!myUserId && myUserId === userId; + const [addingFriend, setAddingFriend] = useState(false); const query = useQuery({ queryKey: ['user-profile', userId], @@ -38,6 +46,18 @@ function UserCardPanel({ userId }: { userId: string }) { }); const profile = query.data; const name = profile ? profile.remark || profile.display_name : ''; + // Real bug this fixes: this card's own doc comment always claimed "same relationship-aware action + // gating" as FriendProfileView.tsx, but until now it never read `profile.relationship` at all -- + // tapping a non-friend's avatar (this card is reachable from many more places than the friends-only + // surfaces FriendProfileView is, e.g. any group member's avatar or an @mention) showed an enabled + // "发消息"/call row with no add-friend affordance anywhere, gated only by `capabilities.can_send`/ + // `can_call` -- a signal ConversationView.tsx's own comment already documents as unreliable for + // exactly this purpose (it 403s from a blocked party's own side instead of reporting false). Mirrors + // FriendProfileView's own isFriend/isPendingOutbound/isPendingInbound branching exactly, just at + // this card's single (not two-size-variant) button size. + const isFriend = profile?.relationship === 'friend'; + const isPendingOutbound = profile?.relationship === 'pending_outbound'; + const isPendingInbound = profile?.relationship === 'pending_inbound'; const goToFullProfile = () => { closeUserCard(); @@ -45,6 +65,7 @@ function UserCardPanel({ userId }: { userId: string }) { }; return ( + <>
e.stopPropagation()}>
@@ -88,42 +109,66 @@ function UserCardPanel({ userId }: { userId: string }) { {!isSelf ? (
- - - + {isFriend ? ( + <> + + + + + ) : isPendingOutbound ? ( + + ) : isPendingInbound ? ( + + ) : profile.capabilities?.can_add_friend === false ? ( + + ) : ( + + )}
) : null} @@ -134,5 +179,21 @@ function UserCardPanel({ userId }: { userId: string }) { )}
+ {/* A true sibling of the card's own backdrop above, not nested inside it: AddFriendModal is + itself a dismissible `fixed inset-0` overlay with its own backdrop-click-to-close handler, + and React events bubble -- nesting it inside the card's backdrop (which ALSO closes on any + click) would make clicking outside AddFriendModal close both overlays at once instead of + just the top one. */} + {addingFriend ? ( + setAddingFriend(false)} + initialTarget={ + profile + ? { user_id: profile.user_id, display_name: profile.display_name, public_account: profile.public_account, avatar_file_id: profile.avatar_file_id } + : undefined + } + /> + ) : null} + ); } diff --git a/web/src/features/groups/GroupDetailColumn.tsx b/web/src/features/groups/GroupDetailColumn.tsx index 38284177..b24acde5 100644 --- a/web/src/features/groups/GroupDetailColumn.tsx +++ b/web/src/features/groups/GroupDetailColumn.tsx @@ -12,6 +12,7 @@ import { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal import { GroupFilesModal } from '@/features/groups/GroupFilesModal'; import { GroupMembersModal } from '@/features/groups/GroupMembersModal'; import { + canRemoveMember, disbandGroup, getGroup, getMyGroupProfile, @@ -242,6 +243,14 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }: const members = membersQuery.data?.items || []; const memberCount = groupQuery.data?.member_count ?? settingsQuery.data?.member_count ?? 0; + // Real gap this fixes: `isAdmin` alone gates the "-移出" tile below, but canRemoveMember (the + // per-row check the tile's own destination list uses) is strictly finer -- an admin can't remove + // the owner or another admin, only the owner can remove an admin. A group with no plain members + // (e.g. [owner, admin(me), admin(other)]) would show the tile yet land on a list with zero "移出" + // buttons anywhere, a dead end. Checked against the same preview page the compact grid above + // already renders from (not exhaustive for a group with more members than that page holds, but + // that's the same tradeoff this component's own member grid already makes, not a new one). + const canRemoveAnyone = isAdmin && members.some((m) => canRemoveMember(myUserId, myRole || 'member', m)); return (