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 }); +}