feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes

Rounds out the full rewrite with the remaining feature domains: group
management (admin panel, mute, files, announcements, QR-based invite/join),
contacts (friend profile, add-friend, blocklist, new-friends), favorites
(real media rendering across text/image/video/voice/file/link), settings
(profile/security/privacy/general/about), calls (Agora-backed audio/video
signaling + call history), and system messages/file assistant.

Also folds in a large batch of live-verified fixes surfaced by repeated
fresh code-review passes and hands-on E2E testing against the real
backend, including: WS reconnect not resubscribing any topic after a
disconnect, missing x-im-client-mid on every send path (protocol
compliance for retry dedup), group mute/member-settings/self-profile
endpoint corrections, relationship-state type fixes across search/QR/
friend-profile, tenant-switch logout gap, password-policy now read from
the real per-tenant config instead of guessed, NavRail highlight/
aria-current desync, several call-state-machine races, and responsive
layout fixes across all four breakpoints (mobile/tablet/laptop/desktop)
including a full mobile rebuild of favorites, call UI, and the settings
home/sub-list split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-09 20:16:33 +08:00
co-authored by Claude Sonnet 5
parent 72b7b79caf
commit 5c3f3b7653
89 changed files with 10158 additions and 442 deletions
+74 -15
View File
@@ -1,22 +1,31 @@
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { CreateGroupModal } from '@/features/groups/CreateGroupModal';
import { fetchAllPages } from '@/lib/api/client';
import { getGroups } from '@/lib/api/groups';
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 { useChatList, useTinode } 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.
* 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). `/files` redirects into a real
* ConversationView on the `slf` topic instead -- README §9.2's own dedicated 3-column file browser
* (categories, storage quota, upload progress) has no backing REST API at all (confirmed via a
* research pass), so a plain conversation view is the honest choice over a quota card with invented
* numbers.
*/
const PINNED_ENTRIES = [
{
@@ -24,7 +33,7 @@ const PINNED_ENTRIES = [
badgeKey: 'system_unread_count' as const,
label: '系统消息',
icon: 'notifications',
route: '/chat/system',
route: '/messages/system',
colorClass: 'bg-warn-bg text-warn',
},
{
@@ -37,16 +46,32 @@ const PINNED_ENTRIES = [
},
];
/** 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. */
/** 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);
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 [query, setQuery] = useState('');
const sorted = [...contacts].sort((a, b) => (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0));
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.public?.fn || c.topic || '').toLowerCase().includes(trimmedQuery.toLowerCase())) : sorted;
const pinned = PINNED_ENTRIES.filter((entry) => bootstrap.data?.entries?.[entry.key]?.enabled);
@@ -55,7 +80,11 @@ export function ChatListColumn() {
<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" />
{/* 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" />
@@ -69,11 +98,17 @@ export function ChatListColumn() {
<div className="flex flex-1 flex-col overflow-y-auto">
{pinned.map((entry) => {
const unread = bootstrap.data?.entry_badges?.[entry.badgeKey] || 0;
// `/files` redirects to `/messages/slf`, so its "currently open" check is keyed off the
// resulting topicId rather than the route it navigates from, unlike `/messages/system`
// which is the real, final path.
const active = entry.route === '/files' ? topicId === 'slf' : 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-list-item bg-white py-2.5 pl-4 pr-3 no-underline hover:bg-[#f6f8fb]"
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" />
@@ -101,11 +136,14 @@ export function ChatListColumn() {
const name = contact.public?.fn || contact.topic;
const preview = formatMessagePreview(contact.latestMessage?.(), myUserId);
const active = topicId === contact.topic;
const isGroup = tinode.getTopic(contact.topic).isGroupType();
const memberCount = isGroup ? memberCountByTopic.get(contact.topic) : undefined;
const muted = !!contact.private?.mute_notifications;
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 py-2.5 pl-4 pr-3 no-underline ${
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]'
}`}
>
@@ -120,12 +158,23 @@ export function ChatListColumn() {
<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' ? (
<span className="flex-none rounded-full bg-[#f1f2f4] px-1.5 py-0.5 text-[11px] text-text-tertiary-3">{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>
{contact.unread ? (
{/* 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>
@@ -136,6 +185,16 @@ export function ChatListColumn() {
})
)}
</div>
{creatingGroup ? (
<CreateGroupModal
onClose={() => setCreatingGroup(false)}
onCreated={(topic) => {
setCreatingGroup(false);
navigate(`/messages/${topic}`);
}}
/>
) : null}
</aside>
);
}