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:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user