import { useLayoutEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; 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 { getMe } from '@/lib/api/profile'; import { useMediaQuery } from '@/lib/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 ? ( ) : ( ); } 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 * (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)'); // 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(); // `slf` (chat with yourself) isn't in the friends/contacts list `contacts` comes from, so the // lookup below always misses for it -- same gap ConversationView.tsx's own header already had and // fixed the same way, just computed independently here for this panel's title/detail-column // props. Shares AppShell's `['me']` query key, so this is a cache read once anything else has // already mounted it, not a second request. const isSelf = topicId === 'slf'; const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe, enabled: isSelf }); const peerName = isSelf ? meQuery.data?.profile.nickname || '我' : contacts.find((c) => c.topic === topicId)?.public?.fn || topicId || ''; 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 // showing results for whichever topic it was opened against, not the one now on screen. useLayoutEffect(() => setHistoryTab(null), [topicId]); return (
{isDocked && detailPanelOpen && topicId ? (
) : null} {!isDocked && isMdUp && detailOpen && topicId ? (
setDetailOpen(false)} />
setDetailOpen(false)} onOpenHistory={setHistoryTab} />
) : null} {!isDocked && !isMdUp && detailOpen && topicId ? (
setDetailOpen(false)} onOpenHistory={setHistoryTab} />
) : null} {historyTab && topicId ? ( setHistoryTab(null)} isGroup={isGroup} peerName={peerName} /> ) : null}
); }