codereview修复:资料弹窗好友关系判断、转发内容克隆、账号切换缓存残留等

三路独立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 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-25 00:45:34 +08:00
co-authored by Claude Sonnet 5
parent 105ff76959
commit 0ba5c97d78
8 changed files with 204 additions and 59 deletions
+12 -1
View File
@@ -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 (
<aside className="flex h-full w-full flex-none flex-col overflow-hidden border-l border-border-panel bg-white md:w-[320px] md:overflow-y-auto lg:w-[300px]">
@@ -396,13 +405,15 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
<span className="text-[11px] text-text-secondary">邀请</span>
</button>
) : null}
{isAdmin ? (
{canRemoveAnyone ? (
// Sibling tile to "+邀请" right above -- same dashed-square treatment, mirrored icon.
// A single standalone entry point into the plain member list (not a per-avatar corner
// badge, and not a direct remove action) -- the actual "移出" control already lives on
// each removable row inside that list. Explicit user correction after a first attempt
// put a "-" badge on every removable avatar's own corner: that read as "many separate
// remove buttons" when the intent was one "+"-shaped entry point, mirroring "+邀请".
// Gated on canRemoveAnyone (not bare isAdmin) so the tile doesn't show in a group made
// entirely of owner+admins with nobody actually removable -- a dead end otherwise.
<button type="button" onClick={() => setOpenModal('members')} className="flex flex-col items-center gap-1">
<span className="grid h-11 w-11 place-items-center rounded-lg border border-dashed border-[#c5cad2] text-text-tertiary-3 md:h-10 md:w-10">
<Icon name="remove" className="text-[18px]" />
@@ -30,8 +30,8 @@ interface GroupMembersModalProps {
* from the same settings query) rather than re-deriving from a bare role here, which is exactly
* what silently made that governance toggle a no-op before this was wired through. */
canInvite: boolean;
/** Own user id + role -- drives per-row remove-member eligibility (canRemoveMember below), mirroring
* the backend's own canRemoveIMGroupMember rule exactly rather than re-guessing it client-side. */
/** Own user id + role -- drives per-row remove-member eligibility (canRemoveMember, lib/api/groups.ts),
* mirroring the backend's own canRemoveIMGroupMember rule exactly rather than re-guessing it client-side. */
myUserId: string | null;
myRole: GroupRole;
/** "invite" opens straight into the invite-candidates view, skipping the member list entirely --
@@ -852,8 +852,14 @@ export function ConversationView() {
// (it reads fine in the bubble via renderDrafty's own flatTextContentOf fallback -- only this
// handler's `Drafty.toPlainText` didn't know about it), so "复制" silently put the literal
// string "undefined" on the clipboard instead of the text visibly shown on screen.
// Final `?? ''` is real, not paranoia: `availableActions` only excludes 'copy' for a mobile
// attachment whose payload passes `mobileAttachmentContentOf`'s own validation (needs a real
// `file_id`/`ref`) -- a malformed/partial attachment payload that fails that check falls
// through to here with neither a `.text` nor a `.txt` field, and `Drafty.toPlainText` would
// return `undefined` again, which `writeText` coerces to the literal string "undefined" same
// as the original bug -- this stops that regardless of exactly which shape slips through.
const flatText = flatTextContentOf(msg.content);
navigator.clipboard.writeText(flatText ?? Drafty.toPlainText(msg.content ?? '')).catch(() => {});
navigator.clipboard.writeText(flatText ?? Drafty.toPlainText(msg.content ?? '') ?? '').catch(() => {});
break;
}
case 'recall':
@@ -97,6 +97,15 @@ function SnapshotContent({ item, highlightQuery }: { item: SearchHit; highlightQ
return <span className="text-text-body">{label}</span>;
}
if (contactCardContentOf(asMessage)) return <span className="text-text-body">[名片]</span>;
// Any OTHER flat shape this app doesn't recognize yet (a future/unknown x-im-type, or corrupted
// data) still has neither `fmt`/`ent` (so `Drafty.isPlainText` says "plain text") nor `txt` (so
// `Drafty.toPlainText` returns `undefined`) -- same failure shape as the crash this whole fix
// addresses, just degrading to a silently blank line (highlightMatches' own defensive guard turns
// it into `[]`) instead of throwing. Surface that rather than leaving zero visual signal that
// something is there but unsupported, same spirit as the `(内容不可用)` placeholder above.
if (typeof content === 'object' && !('txt' in content) && !('fmt' in content) && !('ent' in content)) {
return <span className="text-text-tertiary-4">[暂不支持的消息类型]</span>;
}
return <div className="text-sm text-text-body">{renderDrafty(content, highlightQuery)}</div>;
}