220 lines
13 KiB
TypeScript
220 lines
13 KiB
TypeScript
import { useState } from 'react';
|
|
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
|
|
|
import { Avatar } from '@/app/Avatar';
|
|
import { Icon } from '@/app/Icon';
|
|
import { CreateGroupModal } from '@/features/groups/CreateGroupModal';
|
|
import { friendAvatarLookup } from '@/features/messages/chat-list';
|
|
import { fetchAllPages } from '@/lib/api/client';
|
|
import { getFriends } from '@/lib/api/friends';
|
|
import { getGroups } from '@/lib/api/groups';
|
|
import { useBootstrap } from '@/lib/api/hooks';
|
|
import { getMe } from '@/lib/api/profile';
|
|
import { formatMessagePreview } from '@/lib/messages/preview';
|
|
import { formatListTime } from '@/lib/messages/time';
|
|
import { useChatList, useTinode } from '@/lib/tinode/hooks';
|
|
import { useSessionStore } from '@/stores/session';
|
|
|
|
/**
|
|
* README §2.1's always-pinned utility row. Real, confirmed backend fact (docs/IM-前后端对接
|
|
* 文档.md:909): topic name is fixed ("sys"), but 系统消息's unread comes from a separate
|
|
* notifications table (not the topic itself). `/messages/system` renders a real dedicated timeline
|
|
* (SystemMessagesView, off `GET /v1/im/system-notifications`) -- nested under MessagesLayout like
|
|
* every `:topicId` conversation, not a standalone top-level route, so the chat list column stays
|
|
* docked next to it (a user report caught the list vanishing when this was still `/chat/system`,
|
|
* a route outside MessagesLayout entirely).
|
|
*
|
|
* 文件助手 (a pinned entry that redirected into a plain ConversationView on the `slf` topic --
|
|
* README §9.2's own dedicated 3-column file browser has no backing REST API at all, confirmed via a
|
|
* research pass) was deliberately removed from here: the user found it confusing -- it's really
|
|
* just a chat thread with yourself wearing a folder icon, and DetailColumn's friend-only actions
|
|
* (拉黑/举报/删除好友) made no sense pointed at yourself. Hidden across every entry point (NavRail,
|
|
* here, ContactsListColumn, SettingsLayout's mobile shortcuts) rather than half-built into a real
|
|
* file browser the backend can't support yet. `slf` itself is untouched -- if it ever has real
|
|
* message history it still surfaces as an perfectly ordinary contact row, sorted like any other.
|
|
*/
|
|
const PINNED_ENTRIES = [
|
|
{
|
|
key: 'system_messages' as const,
|
|
badgeKey: 'system_unread_count' as const,
|
|
label: '系统消息',
|
|
icon: 'notifications',
|
|
route: '/messages/system',
|
|
colorClass: 'bg-warn-bg text-warn',
|
|
},
|
|
];
|
|
|
|
/** Chat-list row sort: 置顶 (README §2.1's "置顶 > 最近") first, then by `touched` (last activity)
|
|
* descending within each group. The pinned flag is read straight off the SDK's own ambient
|
|
* `contact.private.pinned` (confirmed present in tinode-sdk.d.ts's `TopicDescPrivate`) rather than
|
|
* a separate REST fetch per topic -- DetailColumn's "置顶聊天" toggle already writes through the
|
|
* conversation-settings REST endpoint, and the server mirrors that same flag back onto the topic's
|
|
* Tinode-level `private` field, so this is free (no extra request) instead of N per-topic fetches
|
|
* just to render the list. */
|
|
export function ChatListColumn() {
|
|
const { topicId } = useParams();
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const myUserId = useSessionStore((s) => s.myUserId);
|
|
// `slf` (chat with yourself) never has its own `.public.fn` -- shares AppShell's `['me']` query
|
|
// key so this is a cache read, not an extra request, in the common case that already mounted it.
|
|
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
|
|
const tinode = useTinode();
|
|
const contacts = useChatList();
|
|
const bootstrap = useBootstrap();
|
|
// Shared with GroupListView's own `['groups','all']` query (same key) -- React Query dedupes the
|
|
// two rather than this list issuing its own redundant fetch, and it's one bulk paginated call
|
|
// regardless (docs/IM-前后端对接文档.md:1444), not one request per group row.
|
|
const groupsQuery = useQuery({ queryKey: ['groups', 'all'], queryFn: () => fetchAllPages((cursor) => getGroups('', '', cursor)) });
|
|
const memberCountByTopic = new Map(groupsQuery.data?.map((g) => [g.topic, g.member_count]));
|
|
const avatarByGroupTopic = new Map(groupsQuery.data?.map((g) => [g.topic, g.avatar_file_id]));
|
|
// Same real gap as the conversation screen's header/message-row avatars (see ConversationView.tsx):
|
|
// a P2P row's `contact` object comes from the Tinode SDK's own local cache, which was never taught
|
|
// to carry the REST-only `avatar_file_id` this app uploads avatars through -- shares ContactsListColumn's
|
|
// exact `['friends']` query key, so this is a cache read whenever that page has already been visited.
|
|
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
|
|
const avatarByPeer = friendAvatarLookup(friendsQuery.data);
|
|
const [query, setQuery] = useState('');
|
|
const [creatingGroup, setCreatingGroup] = useState(false);
|
|
const sorted = [...contacts].sort((a, b) => {
|
|
const pinDelta = (b.private?.pinned ? 1 : 0) - (a.private?.pinned ? 1 : 0);
|
|
return pinDelta || (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0);
|
|
});
|
|
const trimmedQuery = query.trim();
|
|
const filtered = trimmedQuery
|
|
? sorted.filter((c) =>
|
|
(c.topic === 'slf' ? meQuery.data?.profile.nickname || '我' : 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>
|
|
{/* README §5.4's 添加好友 entry point is Phase 4 (contacts), not built yet -- this only
|
|
opens 创建群聊 (§4.7) for now, the one thing this icon can actually do today. */}
|
|
<button type="button" onClick={() => setCreatingGroup(true)} className="grid h-8 w-8 place-items-center text-text-heading">
|
|
<Icon name="add_comment" />
|
|
</button>
|
|
</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;
|
|
const active = location.pathname === entry.route;
|
|
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-border-list-item py-2.5 pl-4 pr-3 no-underline ${
|
|
active ? 'bg-accent-selected-bg' : 'bg-white hover:bg-[#f6f8fb]'
|
|
}`}
|
|
>
|
|
<span className={`grid h-11 w-11 place-items-center rounded-lg ${entry.colorClass}`}>
|
|
<Icon name={entry.icon} className="text-2xl" />
|
|
</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.topic === 'slf' ? meQuery.data?.profile.nickname || '我' : contact.public?.fn || contact.topic;
|
|
const preview = formatMessagePreview(contact.latestMessage?.(), myUserId);
|
|
const active = topicId === contact.topic;
|
|
const isGroup = tinode.getTopic(contact.topic).isGroupType();
|
|
// `GET /groups` (this file's own groupsQuery) can return `member_count: 0` for a group
|
|
// the single-group `GET /groups/{topic}` correctly reports as several members -- the same
|
|
// dual-counter split already tracked as 问题8 in the backend issue list, newly confirmed
|
|
// live to surface as a full zero rather than a smaller mismatch. No real group has zero
|
|
// members (the owner alone is one), so the `> 0` check below treats an exact zero as
|
|
// "don't trust this" and hides the pill instead of showing a number already proven wrong.
|
|
const memberCount = isGroup ? memberCountByTopic.get(contact.topic) : undefined;
|
|
const muted = !!contact.private?.mute_notifications;
|
|
const avatarFileId =
|
|
contact.topic === 'slf' ? meQuery.data?.profile.avatar_file_id : isGroup ? avatarByGroupTopic.get(contact.topic) : avatarByPeer.get(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-border-list-item py-2.5 pl-4 pr-3 no-underline ${
|
|
active ? 'bg-accent-selected-bg' : 'bg-white hover:bg-[#f6f8fb]'
|
|
}`}
|
|
>
|
|
<span className="relative block h-11 w-11 flex-none">
|
|
<Avatar id={contact.topic || ''} name={name} avatarFileId={avatarFileId} className="h-11 w-11 rounded-lg text-base" />
|
|
{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>
|
|
{/* 02-messages.dc.html #1a/#1c: group rows carry a "N人" member-count pill right
|
|
after the name (e.g. "项目协作群 28人"); P2P rows never have one. */}
|
|
{typeof memberCount === 'number' && memberCount > 0 ? (
|
|
<span className="flex-none rounded bg-[#f1f2f4] px-1.5 py-0.5 text-[11px] text-text-tertiary-4">{memberCount}人</span>
|
|
) : null}
|
|
</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>
|
|
{/* Same reference mock: a muted conversation's unread count is replaced by a small
|
|
muted-bell glyph, not shown alongside or as a louder red badge -- the whole point
|
|
of 消息免打扰 (DetailColumn's own toggle, which writes this same `private` field)
|
|
is to stop this row from demanding attention the way an unmuted one does. */}
|
|
{muted ? (
|
|
contact.unread ? <Icon name="notifications_off" className="text-base text-text-tertiary-4" /> : null
|
|
) : 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>
|
|
|
|
{creatingGroup ? (
|
|
<CreateGroupModal
|
|
onClose={() => setCreatingGroup(false)}
|
|
onCreated={(topic) => {
|
|
setCreatingGroup(false);
|
|
navigate(`/messages/${topic}`);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</aside>
|
|
);
|
|
}
|