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,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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user