From ef31eb2e8865c621c853c0aab2fecb6d91b5bbcc Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 24 Sep 2026 17:45:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=82=B9=E5=87=BB=E5=A4=B4?= =?UTF-8?q?=E5=83=8F/=E6=98=B5=E7=A7=B0=E6=9F=A5=E7=9C=8B=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=B5=84=E6=96=99=E5=BC=B9=E7=AA=97=EF=BC=8C=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E6=B6=88=E6=81=AF=E6=B0=94=E6=B3=A1/@=E6=8F=90?= =?UTF-8?q?=E5=8F=8A/=E7=BE=A4=E6=88=90=E5=91=98=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增全局 UserCardRoot(stores/userCard.ts 驱动,挂载在 AppShell 里,模式同 ConfirmDialogRoot),复用 getUserProfile 数据源,展示头像/昵称/性别地区/签名, 非本人时带发消息/语音/视频快捷操作,底部"查看完整资料"跳转到完整的 /contacts/{id} 页面。 接入点: - MessageBubble.tsx:群聊和单聊的对方头像/昵称(原来只有群聊头像/昵称是 Link 到完整资料页,单聊完全没有交互) - drafty.tsx:@提及实体(Drafty.mention 的 uid 存在 ent[].data.val 里, 之前渲染完全没读这个字段),"所有人"哨兵值除外 - GroupMembersModal.tsx / GroupDetailColumn.tsx:群成员列表的行内和紧凑 头像格子 Co-Authored-By: Claude Sonnet 5 --- web/src/app/AppShell.tsx | 2 + web/src/app/UserCardRoot.tsx | 138 ++++++++++++++++++ web/src/features/groups/GroupDetailColumn.tsx | 10 +- web/src/features/groups/GroupMembersModal.tsx | 11 +- web/src/features/messages/MessageBubble.tsx | 13 +- web/src/lib/messages/drafty.tsx | 16 +- web/src/stores/userCard.ts | 23 +++ 7 files changed, 200 insertions(+), 13 deletions(-) create mode 100644 web/src/app/UserCardRoot.tsx create mode 100644 web/src/stores/userCard.ts diff --git a/web/src/app/AppShell.tsx b/web/src/app/AppShell.tsx index 8ab4a969..5ffc15f0 100644 --- a/web/src/app/AppShell.tsx +++ b/web/src/app/AppShell.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Outlet, useNavigate } from 'react-router-dom'; +import { UserCardRoot } from '@/app/UserCardRoot'; import { CallRoot } from '@/features/calls/CallRoot'; import { useBootstrap } from '@/lib/api/hooks'; import { getMe } from '@/lib/api/profile'; @@ -98,6 +99,7 @@ export function AppShell() { + ); } diff --git a/web/src/app/UserCardRoot.tsx b/web/src/app/UserCardRoot.tsx new file mode 100644 index 00000000..48f46b0b --- /dev/null +++ b/web/src/app/UserCardRoot.tsx @@ -0,0 +1,138 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; + +import { Avatar } from '@/app/Avatar'; +import { Icon } from '@/app/Icon'; +import { ImApiError } from '@/lib/api/client'; +import { getUserProfile } from '@/lib/api/friends'; +import { useEscapeKey } from '@/lib/hooks'; +import { useCallStore } from '@/stores/calls'; +import { useSessionStore } from '@/stores/session'; +import { closeUserCard, useUserCardStore } from '@/stores/userCard'; + +const GENDER_LABEL: Record = { male: '男', female: '女' }; + +/** Mounted once at the app root (App.tsx) -- renders whatever `openUserCard()` (stores/userCard.ts) + * most recently asked for. A lighter, faster-to-reach sibling of FriendProfileView's full `/contacts/ + * :userId` page (same `getUserProfile` data source, same relationship-aware action gating), not a + * 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. + return ; +} + +function UserCardPanel({ userId }: { userId: string }) { + const navigate = useNavigate(); + const myUserId = useSessionStore((s) => s.myUserId); + const isSelf = !!myUserId && myUserId === userId; + + const query = useQuery({ + queryKey: ['user-profile', userId], + queryFn: () => getUserProfile(userId), + }); + const profile = query.data; + const name = profile ? profile.remark || profile.display_name : ''; + + const goToFullProfile = () => { + closeUserCard(); + navigate(isSelf ? '/settings/profile' : `/contacts/${userId}`); + }; + + return ( +
+
e.stopPropagation()}> +
+ +
+ + {query.isLoading ? ( +
加载中…
+ ) : !profile ? ( +
+ {query.error instanceof ImApiError && query.error.status === 403 ? ( + '对方设置了资料保护,暂时无法查看' + ) : query.error instanceof ImApiError && query.error.status === 404 ? ( + '未找到该用户' + ) : ( + <> + 加载失败,请重试 + + + )} +
+ ) : ( +
+ + {name} + {profile.remark ? 昵称:{profile.display_name} : null} + {profile.gender || profile.region ? ( + + {[profile.gender ? GENDER_LABEL[profile.gender] || profile.gender : null, profile.region].filter(Boolean).join(' · ')} + + ) : null} + {profile.signature ?

{profile.signature}

: null} + + {!isSelf ? ( +
+ + + +
+ ) : null} + + +
+ )} +
+
+ ); +} diff --git a/web/src/features/groups/GroupDetailColumn.tsx b/web/src/features/groups/GroupDetailColumn.tsx index 7d2749b7..38284177 100644 --- a/web/src/features/groups/GroupDetailColumn.tsx +++ b/web/src/features/groups/GroupDetailColumn.tsx @@ -34,6 +34,7 @@ import { requestConfirm } from '@/stores/confirmDialog'; import { notify } from '@/stores/globalToast'; import { requestActionToken } from '@/stores/securityChallenge'; import { useSessionStore } from '@/stores/session'; +import { openUserCard } from '@/stores/userCard'; interface GroupDetailColumnProps { topicId: string; @@ -363,7 +364,12 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
{members.slice(0, 9).map((m) => ( -
+
+ ))} {canInvite ? ( // Goes straight to the invite-candidates view (GroupMembersModal's `initialView= diff --git a/web/src/features/groups/GroupMembersModal.tsx b/web/src/features/groups/GroupMembersModal.tsx index c0e86aa5..19cec120 100644 --- a/web/src/features/groups/GroupMembersModal.tsx +++ b/web/src/features/groups/GroupMembersModal.tsx @@ -10,6 +10,7 @@ import { canRemoveMember, getAllGroupMembers, inviteGroupMembers, removeGroupMem import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { useEscapeKey } from '@/lib/hooks'; import { requestConfirm } from '@/stores/confirmDialog'; +import { openUserCard } from '@/stores/userCard'; const ROLE_LABEL: Record = { owner: '群主', admin: '管理员', member: '' }; // README §4.3: role tags are a BORDER style, no fill -- matches MessageBubble.tsx's already-correct @@ -190,10 +191,12 @@ export function GroupMembersModal({ topicId, canInvite, myUserId, myRole, initia ) : ( filtered.map((m) => (
- - {avatarLetter(m.display_name)} - - {m.display_name || m.user_id} + {canRemoveMember(myUserId, myRole, m) ? ( ) : ( {!mine && !selectMode && msg.from ? ( - + ) : ( {mine ? '我' : groupSenderLabel(senderName)} )} diff --git a/web/src/lib/messages/drafty.tsx b/web/src/lib/messages/drafty.tsx index 34cda388..c0c544db 100644 --- a/web/src/lib/messages/drafty.tsx +++ b/web/src/lib/messages/drafty.tsx @@ -5,6 +5,8 @@ import { Drafty, type DraftyContent } from 'tinode-sdk'; import { AudioAttachment, FileAttachment, ImageAttachment, VideoAttachment } from '@/features/messages/AttachmentPreview'; import { SafeLink } from '@/features/messages/SafeLink'; import { flatTextContentOf } from '@/lib/messages/dispatch'; +import { MENTION_ALL_SENTINEL } from '@/lib/messages/mentions'; +import { openUserCard } from '@/stores/userCard'; /** * Bubble content renderer for the Drafty style tags this app currently sends/receives. Grown @@ -72,12 +74,24 @@ function makeFormatNode(highlightQuery: string | undefined) { ); case 'BR': return
; - case 'MN': + case 'MN': { + // The mention's uid lives on the entity's own `data` (Drafty.mention(name, uid) -- SDK + // source: `ent:[{tp:'MN', data:{val:uid}}]`), not on `values`/`children` (that's just the + // rendered label text). MENTION_ALL_SENTINEL ("所有人") isn't a real user -- nothing to open. + const uid = typeof data?.val === 'string' ? data.val : undefined; + if (uid && uid !== MENTION_ALL_SENTINEL) { + return ( + + ); + } return ( {children} ); + } case 'HL': return ( diff --git a/web/src/stores/userCard.ts b/web/src/stores/userCard.ts new file mode 100644 index 00000000..12d889ed --- /dev/null +++ b/web/src/stores/userCard.ts @@ -0,0 +1,23 @@ +import { create } from 'zustand'; + +/** + * "Tap any avatar/name -> profile popup", app-wide (message bubbles, @mentions inside message text, + * group member rows/grid, etc.) -- one global store + a single `` mounted once at the + * app root (App.tsx), the same shape as `stores/confirmDialog.ts`/`stores/calls.ts`: callable from any + * event handler with no natural "own a piece of JSX for this" home (a Drafty-rendered mention span + * deep inside message content, for one), not a per-component `useState`. + */ +interface UserCardState { + userId: string | null; +} + +export const useUserCardStore = create(() => ({ userId: null })); + +export function openUserCard(userId: string) { + if (!userId) return; + useUserCardStore.setState({ userId }); +} + +export function closeUserCard() { + useUserCardStore.setState({ userId: null }); +}