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 (