- EmojiPanel: add missing 手势/物品 categories, 12-column grid per README §3.3
- DetailColumn: add 投诉举报 row, real ID/region/签名 via new getUserProfile
API, chevron_right icons now use --color-icon-arrow token (were tertiary
text color), regroup rows so dividers only sit between sections not every
row (matches design-code reference exactly)
- MessageBubble/ConversationView: right-clicked message bubble now gets the
spec's outline while its context menu is open
- ChatListColumn/AppShell: pinned 系统消息/文件助手 rows using confirmed
bootstrap entries/entry_badges topic names ("sys"/"slf"); route to the
existing SectionPlaceholder pattern since their own dedicated layouts are
Phase 8, not built yet
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
142 lines
6.8 KiB
TypeScript
142 lines
6.8 KiB
TypeScript
import { useState } from 'react';
|
|
|
|
import { Link, useParams } from 'react-router-dom';
|
|
|
|
import { Icon } from '@/app/Icon';
|
|
import { useBootstrap } from '@/lib/api/hooks';
|
|
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
|
import { formatMessagePreview } from '@/lib/messages/preview';
|
|
import { formatListTime } from '@/lib/messages/time';
|
|
import { useChatList } from '@/lib/tinode/hooks';
|
|
import { useSessionStore } from '@/stores/session';
|
|
|
|
/**
|
|
* README §2.1's two always-pinned utility rows. Real, confirmed backend facts (docs/IM-前后端对接
|
|
* 文档.md:909): topic names are fixed ("slf"/"sys"), but 系统消息's unread comes from a separate
|
|
* notifications table (not the topic itself) and 文件助手's own dedicated 3-column layout (README
|
|
* §9.2) is Phase 8, not built yet -- so both route to the same SectionPlaceholder-backed "later
|
|
* phase" destinations the rest of the nav already uses (contacts/favorites/calls/settings), rather
|
|
* than a half-working ConversationView that assumes a regular P2P/group contact.
|
|
*/
|
|
const PINNED_ENTRIES = [
|
|
{
|
|
key: 'system_messages' as const,
|
|
badgeKey: 'system_unread_count' as const,
|
|
label: '系统消息',
|
|
icon: 'notifications',
|
|
route: '/chat/system',
|
|
colorClass: 'bg-warn',
|
|
},
|
|
{
|
|
key: 'file_assistant' as const,
|
|
badgeKey: 'file_assistant_unread_count' as const,
|
|
label: '文件助手',
|
|
icon: 'folder',
|
|
route: '/files',
|
|
colorClass: 'bg-success',
|
|
},
|
|
];
|
|
|
|
/** Chat-list row sort: by `touched` (last activity) descending. Pinned-conversations-first isn't
|
|
* wired yet -- that's driven by the P2P conversation-settings API (GET/PATCH /conversations/{topic}/
|
|
* settings {pinned}), which the detail panel doesn't build until its "置顶聊天" toggle lands. */
|
|
export function ChatListColumn() {
|
|
const { topicId } = useParams();
|
|
const myUserId = useSessionStore((s) => s.myUserId);
|
|
const contacts = useChatList();
|
|
const bootstrap = useBootstrap();
|
|
const [query, setQuery] = useState('');
|
|
const sorted = [...contacts].sort((a, b) => (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0));
|
|
const trimmedQuery = query.trim();
|
|
const filtered = trimmedQuery ? sorted.filter((c) => (c.public?.fn || c.topic || '').toLowerCase().includes(trimmedQuery.toLowerCase())) : sorted;
|
|
const pinned = PINNED_ENTRIES.filter((entry) => bootstrap.data?.entries?.[entry.key]?.enabled);
|
|
|
|
return (
|
|
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
|
|
<header className="flex h-15 flex-none items-center justify-between px-5">
|
|
<h1 className="m-0 text-xl font-semibold text-text-heading">消息</h1>
|
|
<Icon name="add_comment" className="text-text-heading" />
|
|
</header>
|
|
<div className="mx-3 mb-2.5 flex h-9 items-center rounded-lg bg-surface-search px-3">
|
|
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
|
<input
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="搜索会话、联系人、消息"
|
|
className="ml-2 w-full bg-transparent text-sm text-text-body outline-none placeholder:text-text-placeholder-2"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-1 flex-col overflow-y-auto">
|
|
{pinned.map((entry) => {
|
|
const unread = bootstrap.data?.entry_badges?.[entry.badgeKey] || 0;
|
|
return (
|
|
<Link
|
|
key={entry.key}
|
|
to={entry.route}
|
|
className="grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item bg-white px-4 py-2.5 no-underline hover:bg-[#f6f8fb]"
|
|
>
|
|
<span className={`grid h-11 w-11 place-items-center rounded-lg text-white ${entry.colorClass}`}>
|
|
<Icon name={entry.icon} className="text-[22px]" />
|
|
</span>
|
|
<span className="flex min-w-0 flex-col gap-1">
|
|
<strong className="truncate text-[15px] font-medium text-[#272b31]">{entry.label}</strong>
|
|
</span>
|
|
<span className="flex flex-none flex-col items-end justify-between gap-1.5">
|
|
{unread ? (
|
|
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
|
|
{unread}
|
|
</b>
|
|
) : null}
|
|
</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
{filtered.length === 0 ? (
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-text-tertiary-4">
|
|
<Icon name={trimmedQuery ? 'search_off' : 'forum'} className="!text-4xl" />
|
|
<span className="text-sm">{trimmedQuery ? '未找到相关会话' : '暂无会话'}</span>
|
|
</div>
|
|
) : (
|
|
filtered.map((contact) => {
|
|
const name = contact.public?.fn || contact.topic;
|
|
const preview = formatMessagePreview(contact.latestMessage?.(), myUserId);
|
|
const active = topicId === contact.topic;
|
|
return (
|
|
<Link
|
|
key={contact.topic}
|
|
to={`/messages/${contact.topic}`}
|
|
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item px-4 py-2.5 no-underline ${
|
|
active ? 'bg-accent-selected-bg' : 'bg-white hover:bg-[#f6f8fb]'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`relative grid h-11 w-11 place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(contact.topic || '')}`}
|
|
>
|
|
{avatarLetter(name)}
|
|
{contact.online ? (
|
|
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-online" />
|
|
) : null}
|
|
</span>
|
|
<span className="flex min-w-0 flex-col gap-1">
|
|
<span className="flex items-center gap-1.5">
|
|
<strong className="truncate text-[15px] font-medium text-[#272b31]">{name}</strong>
|
|
</span>
|
|
<small className="truncate text-[13px] text-text-tertiary-2">{preview}</small>
|
|
</span>
|
|
<span className="flex flex-none flex-col items-end justify-between gap-1.5">
|
|
<time className="text-xs text-text-tertiary-3">{contact.touched ? formatListTime(contact.touched) : ''}</time>
|
|
{contact.unread ? (
|
|
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
|
|
{contact.unread}
|
|
</b>
|
|
) : null}
|
|
</span>
|
|
</Link>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|