新增点击头像/昵称查看用户资料弹窗,接入消息气泡/@提及/群成员列表
新增全局 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
2427a3782c
commit
ef31eb2e88
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
<CallRoot />
|
||||
<UserCardRoot />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, string> = { 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 <UserCardPanel key={userId} userId={userId} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25" onClick={closeUserCard}>
|
||||
<div className="w-[300px] overflow-hidden rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end px-3 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeUserCard}
|
||||
className="grid h-7 w-7 place-items-center rounded-full text-text-tertiary-2 hover:bg-surface-input"
|
||||
>
|
||||
<Icon name="close" className="text-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{query.isLoading ? (
|
||||
<div className="flex h-44 items-center justify-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : !profile ? (
|
||||
<div className="flex h-44 flex-col items-center justify-center gap-2 px-6 text-center text-sm text-text-tertiary-4">
|
||||
{query.error instanceof ImApiError && query.error.status === 403 ? (
|
||||
'对方设置了资料保护,暂时无法查看'
|
||||
) : query.error instanceof ImApiError && query.error.status === 404 ? (
|
||||
'未找到该用户'
|
||||
) : (
|
||||
<>
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => query.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center px-6 pb-6">
|
||||
<Avatar id={profile.user_id} name={name} avatarFileId={profile.avatar_file_id} className="h-18 w-18 flex-none rounded-2xl text-xl" />
|
||||
<strong className="mt-3 max-w-full truncate text-base font-semibold text-text-heading">{name}</strong>
|
||||
{profile.remark ? <span className="mt-0.5 max-w-full truncate text-[13px] text-text-tertiary-2">昵称:{profile.display_name}</span> : null}
|
||||
{profile.gender || profile.region ? (
|
||||
<span className="mt-0.5 text-[13px] text-text-tertiary-2">
|
||||
{[profile.gender ? GENDER_LABEL[profile.gender] || profile.gender : null, profile.region].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
) : null}
|
||||
{profile.signature ? <p className="m-0 mt-2 line-clamp-3 text-center text-sm text-text-secondary">{profile.signature}</p> : null}
|
||||
|
||||
{!isSelf ? (
|
||||
<div className="mt-5 flex w-full items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={profile.capabilities?.can_send === false}
|
||||
onClick={() => {
|
||||
closeUserCard();
|
||||
navigate(`/messages/${profile.user_id}`);
|
||||
}}
|
||||
className="flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm disabled:opacity-50"
|
||||
>
|
||||
<Icon name="chat_bubble" className="text-base" />
|
||||
发消息
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={profile.capabilities?.can_call === false}
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase !== 'idle') return;
|
||||
closeUserCard();
|
||||
useCallStore.getState().startCall(profile.user_id, name, 'audio');
|
||||
}}
|
||||
className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50"
|
||||
>
|
||||
<Icon name="call" className="text-base" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={profile.capabilities?.can_call === false}
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase !== 'idle') return;
|
||||
closeUserCard();
|
||||
useCallStore.getState().startCall(profile.user_id, name, 'video');
|
||||
}}
|
||||
className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50"
|
||||
>
|
||||
<Icon name="videocam" className="text-base" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button type="button" onClick={goToFullProfile} className="mt-3 text-[13px] text-accent">
|
||||
查看完整资料
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 }:
|
||||
</button>
|
||||
<div className="grid grid-cols-5 gap-2 md:mt-2">
|
||||
{members.slice(0, 9).map((m) => (
|
||||
<div key={m.user_id} className="relative flex flex-col items-center gap-1">
|
||||
<button
|
||||
key={m.user_id}
|
||||
type="button"
|
||||
onClick={() => openUserCard(m.user_id)}
|
||||
className="relative flex flex-col items-center gap-1"
|
||||
>
|
||||
{/* README §4.10/#1e: mobile member-grid avatars are 44px, one size up from
|
||||
desktop's 40px (unconditional here before this fix). */}
|
||||
<Avatar
|
||||
@@ -374,7 +380,7 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
/>
|
||||
<RoleCorner role={m.role} />
|
||||
<span className="w-full truncate text-center text-[11px] text-text-secondary">{m.display_name || m.user_id}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{canInvite ? (
|
||||
// Goes straight to the invite-candidates view (GroupMembersModal's `initialView=
|
||||
|
||||
@@ -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<GroupRole, string> = { 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) => (
|
||||
<div key={m.user_id} className="flex w-full items-center gap-3 rounded-lg px-3 py-2">
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
|
||||
{avatarLetter(m.display_name)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
|
||||
<button type="button" onClick={() => openUserCard(m.user_id)} className="flex min-w-0 flex-1 items-center gap-3 text-left">
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
|
||||
{avatarLetter(m.display_name)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
|
||||
</button>
|
||||
<RoleTag role={m.role} />
|
||||
{canRemoveMember(myUserId, myRole, m) ? (
|
||||
<button
|
||||
|
||||
@@ -23,6 +23,7 @@ import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/mess
|
||||
import { groupSenderLabel } from '@/lib/messages/sender-name';
|
||||
import { retryMessage } from '@/lib/tinode/send';
|
||||
import { ensureTopicAttached } from '@/lib/tinode/topics';
|
||||
import { openUserCard } from '@/stores/userCard';
|
||||
|
||||
/** 设计稿 4.3:气泡上方角色标签,边框样式(不是填色)-- 群主 #ff951c、管理员 #1677ff,均不加背景。
|
||||
* 与 GroupMembersModal/GroupDetailColumn 里另外两种角色标签(填色 tag、圆角数字角标)故意不共用
|
||||
@@ -173,10 +174,10 @@ export function MessageRow({
|
||||
relationship isn't a case this app has ever needed to support. Not during `selectMode`
|
||||
either -- the checkbox is the only thing a tap in this row should do while bulk-selecting,
|
||||
not navigate away and lose whatever's already been picked. */}
|
||||
{isGroup && !mine && !selectMode && msg.from ? (
|
||||
<Link to={`/contacts/${msg.from}`} className="flex-none no-underline">
|
||||
<Avatar id={msg.from} name={groupSenderLabel(senderName)} avatarFileId={avatarFileId} className="h-9 w-9 rounded-lg text-sm" />
|
||||
</Link>
|
||||
{!mine && !selectMode && msg.from ? (
|
||||
<button type="button" onClick={() => openUserCard(msg.from!)} className="flex-none">
|
||||
<Avatar id={msg.from} name={isGroup ? groupSenderLabel(senderName) : peerName} avatarFileId={avatarFileId} className="h-9 w-9 rounded-lg text-sm" />
|
||||
</button>
|
||||
) : (
|
||||
<Avatar
|
||||
id={msg.from || (isGroup ? '' : peerName)}
|
||||
@@ -190,9 +191,9 @@ export function MessageRow({
|
||||
{isGroup ? (
|
||||
<div className={`flex items-center gap-1 px-0.5 text-xs text-text-tertiary-4 ${mine ? 'flex-row-reverse' : ''}`}>
|
||||
{!mine && !selectMode && msg.from ? (
|
||||
<Link to={`/contacts/${msg.from}`} className="text-text-tertiary-4 no-underline hover:underline">
|
||||
<button type="button" onClick={() => openUserCard(msg.from!)} className="text-text-tertiary-4 hover:underline">
|
||||
{groupSenderLabel(senderName)}
|
||||
</Link>
|
||||
</button>
|
||||
) : (
|
||||
<span>{mine ? '我' : groupSenderLabel(senderName)}</span>
|
||||
)}
|
||||
|
||||
@@ -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 <br key={key} />;
|
||||
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 (
|
||||
<button key={key} type="button" onClick={() => openUserCard(uid)} className="inline border-0 bg-transparent p-0 text-accent hover:underline">
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span key={key} className="text-accent">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case 'HL':
|
||||
return (
|
||||
<mark key={key} className="bg-warn-bg">
|
||||
|
||||
@@ -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 `<UserCardRoot>` 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<UserCardState>(() => ({ userId: null }));
|
||||
|
||||
export function openUserCard(userId: string) {
|
||||
if (!userId) return;
|
||||
useUserCardStore.setState({ userId });
|
||||
}
|
||||
|
||||
export function closeUserCard() {
|
||||
useUserCardStore.setState({ userId: null });
|
||||
}
|
||||
Reference in New Issue
Block a user