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