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
+104 -17
View File
@@ -1,38 +1,111 @@
import { useLayoutEffect, useState } from 'react';
import { Outlet, useParams } from 'react-router-dom';
import { Outlet, useLocation, useParams } from 'react-router-dom';
import { ChatListColumn } from '@/features/messages/ChatListColumn';
import { DetailColumn } from '@/features/messages/DetailColumn';
import { HistorySearchModal } from '@/features/messages/HistorySearchModal';
import { OfflineBanner } from '@/features/messages/OfflineBanner';
import { GroupDetailColumn } from '@/features/groups/GroupDetailColumn';
import type { MediaType } from '@/lib/api/messages';
import { useMediaQuery } from '@/lib/hooks';
import { useChatList } from '@/lib/tinode/hooks';
import { useChatList, useTinode } from '@/lib/tinode/hooks';
/** Group topics (`grp...`) get the group-specific settings rail; everything else (`usr...` P2P,
* `slf...` self) keeps the existing DetailColumn. `isGroupType()` is a pure name-prefix check on
* the SDK's Topic wrapper (tinode-sdk's Topic.topicType()) -- no subscribe/network call, safe to
* read on every render. */
function ConversationDetailPanel({
isGroup,
topicId,
peerName,
onClose,
onOpenHistory,
}: {
isGroup: boolean;
topicId: string;
peerName: string;
onClose?: () => void;
onOpenHistory: (tab: 'text' | MediaType) => void;
}) {
return isGroup ? (
<GroupDetailColumn topicId={topicId} peerName={peerName} onClose={onClose} onOpenHistory={onOpenHistory} />
) : (
<DetailColumn topicId={topicId} peerName={peerName} onClose={onClose} onOpenHistory={onOpenHistory} />
);
}
const DETAIL_PANEL_OPEN_KEY = 'im-detail-panel-open';
function readPersistedDetailPanelOpen(): boolean {
try {
return localStorage.getItem(DETAIL_PANEL_OPEN_KEY) === '1';
} catch {
return false;
}
}
/**
* The 4-tier responsive split described in the design handoff:
* - <640 and 640-959: list OR main, whichever the route implies (topicId present -> main; the
* conversation header then needs its own back-to-list affordance, since NavRail alone doesn't
* provide one at this tier -- see ConversationView).
* - 960-1279: list + main together, detail as an overlay drawer toggled by the header's "i" icon.
* - >=1280: list + main + detail all docked side by side.
* - 960-1279: list + main together, detail as an overlay drawer toggled by the header's "i" icon
* (README's `detailDrawerOpen`, explicitly "三列临时" -- temporary, not persisted).
* - >=1280: list + main + detail all docked side by side, but the detail column itself is STILL
* closed by default and toggled by the same "i" icon (README's `detailPanelOpen`, "桌面持久化"
* -- desktop, persisted to localStorage) -- this is a real column reserved in the 4-column
* diagram, not an always-on panel with no way to close it; that was this layout's own bug until
* a user report caught it (the "i" button did nothing at this width, and the panel had no close
* affordance at all).
* Below 960px (both the two-column and single-column tiers), the detail panel instead pushes as a
* full-page overlay -- README §1.4's "详情/设置等作为整页推入" convention -- since there's no room
* to dock or drawer it in without covering the conversation entirely anyway.
*/
export function MessagesLayout() {
const { topicId } = useParams();
const location = useLocation();
const [detailOpen, setDetailOpen] = useState(false);
const [detailPanelOpen, setDetailPanelOpen] = useState(readPersistedDetailPanelOpen);
const [historyTab, setHistoryTab] = useState<'text' | MediaType | null>(null);
const isDocked = useMediaQuery('(min-width: 1280px)');
const showListOnNarrow = !topicId;
// Which of the 3 detail-panel render sites below is allowed to actually mount -- previously all
// three were gated ONLY by `detailPanelOpen`/`detailOpen` and picked apart visually with `hidden`/
// `md:`/`lg:` classes, so crossing the 1280px breakpoint while a panel was open left the OTHER
// tier's copy still truthy and mounted-but-hidden (confirmed: `detailPanelOpen` and `detailOpen`
// are two independent booleans that only toggleDetail() keeps in sync, and it only ever updates
// the one matching `isDocked` AT CLICK TIME -- resizing across 1280px afterward does not reconcile
// the other one). A hidden-but-mounted GroupDetailColumn/DetailColumn still runs its own queries
// and effects for no visible reason, and briefly having two real instances of the same panel alive
// is exactly the kind of thing that turns into a real bug the moment either one has a side effect
// that isn't safe to run twice.
const isMdUp = useMediaQuery('(min-width: 960px)');
// `/messages/system` is a real Outlet child (SystemMessagesView) but doesn't match `:topicId`, so
// it needs its own check here -- otherwise this reads as "no conversation selected" and collapses
// to the same list-only view as the bare `/messages` index at narrow widths.
const showListOnNarrow = !topicId && location.pathname !== '/messages/system';
const contacts = useChatList();
const tinode = useTinode();
const peerName = contacts.find((c) => c.topic === topicId)?.public?.fn || topicId || '';
// The "i" button should read as active whenever the detail panel is actually visible right now --
// always true when docked (>=1280px, no close affordance), otherwise whatever the drawer/full-page
// toggle state is. Previously hardcoded to the breakpoint itself instead of this real state.
const detailVisible = isDocked || detailOpen;
const isGroup = !!topicId && tinode.getTopic(topicId).isGroupType();
const detailVisible = isDocked ? detailPanelOpen : detailOpen;
const toggleDetail = () => {
if (isDocked) {
setDetailPanelOpen((v) => {
const next = !v;
try {
localStorage.setItem(DETAIL_PANEL_OPEN_KEY, next ? '1' : '0');
} catch {
// Private-browsing/quota errors just mean the preference won't survive a reload -- the
// toggle itself still works for the rest of this session.
}
return next;
});
} else {
setDetailOpen((v) => !v);
}
};
// MessagesLayout is one long-lived instance reused across every conversation (route is
// `:topicId`) -- a history-search modal left open while switching topics would otherwise keep
@@ -49,28 +122,42 @@ export function MessagesLayout() {
<ChatListColumn />
</div>
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
<Outlet context={{ onToggleDetail: () => setDetailOpen((v) => !v), detailVisible, onOpenHistory: setHistoryTab }} />
<Outlet context={{ onToggleDetail: toggleDetail, detailVisible, onOpenHistory: setHistoryTab }} />
</div>
</div>
{topicId ? (
{isDocked && detailPanelOpen && topicId ? (
<div className="hidden h-full lg:block">
<DetailColumn topicId={topicId} peerName={peerName} onOpenHistory={setHistoryTab} />
<ConversationDetailPanel isGroup={isGroup} topicId={topicId} peerName={peerName} onClose={toggleDetail} onOpenHistory={setHistoryTab} />
</div>
) : null}
{detailOpen && topicId ? (
{!isDocked && isMdUp && detailOpen && topicId ? (
<div className="absolute inset-0 hidden md:block lg:hidden">
<div className="absolute inset-0 bg-[#22262d]/25" onClick={() => setDetailOpen(false)} />
<div className="absolute inset-y-0 right-0 shadow-[-8px_0_32px_rgba(0,0,0,0.12)]">
<DetailColumn topicId={topicId} peerName={peerName} onClose={() => setDetailOpen(false)} onOpenHistory={setHistoryTab} />
<ConversationDetailPanel
isGroup={isGroup}
topicId={topicId}
peerName={peerName}
onClose={() => setDetailOpen(false)}
onOpenHistory={setHistoryTab}
/>
</div>
</div>
) : null}
{detailOpen && topicId ? (
{!isDocked && !isMdUp && detailOpen && topicId ? (
<div className="fixed inset-0 z-30 bg-white md:hidden">
<DetailColumn topicId={topicId} peerName={peerName} onClose={() => setDetailOpen(false)} onOpenHistory={setHistoryTab} />
<ConversationDetailPanel
isGroup={isGroup}
topicId={topicId}
peerName={peerName}
onClose={() => setDetailOpen(false)}
onOpenHistory={setHistoryTab}
/>
</div>
) : null}
{historyTab && topicId ? <HistorySearchModal topicId={topicId} initialTab={historyTab} onClose={() => setHistoryTab(null)} /> : null}
{historyTab && topicId ? (
<HistorySearchModal topicId={topicId} initialTab={historyTab} onClose={() => setHistoryTab(null)} isGroup={isGroup} peerName={peerName} />
) : null}
</div>
);
}