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}
);
}