- DetailColumn: remark-edit draft and the history-search modal's open
state weren't reset on contact switch (no key={topicId}, same long-
lived instance) -- could silently save a typed remark against the
WRONG contact. History-search state lifted to MessagesLayout so
ConversationView's header search icon (previously wired to nothing)
can open the same modal
- ConversationView: loadOlder's in-flight "restore scroll position"
callback and its loading flag weren't guarded against a conversation
switch happening mid-request, and the scroll-to-bottom effect keyed
only on messages.length so two conversations that happened to load
the same page size never got scrolled down on open
- ChatListColumn: pinned 系统消息/文件助手 rows used solid-color-square
avatars like regular contacts; the actual design-code source
(ChatList.dc.html) and reference screenshots use a light tinted
background with a colored icon instead
- MessageBubble: own ("我") messages hash-colored their avatar like any
contact instead of the design's fixed accent blue
- Every send/recall/favorite/forward action swallowed its own failure
silently (empty .catch or, for forward, no catch at all) -- moved
Toast from features/auth into app/ (shared) and wired real error
feedback through it
- MessageContextMenu: added the recall countdown label, Escape-to-close,
and viewport-edge clamping (a right-click near the window edge could
render partially off-screen)
- EmojiPanel now defaults to 最近 per the reference (was defaulting to
表情)
- HistorySearchModal: pagination (searchTopicMessages/getTopicMedia both
supported cursor/next_cursor already, nothing used it -- silently
truncated at one page), Escape-to-close, and a brief "没有找到" flash
on the first keystroke before the debounce caught up
- A handful of design-token corrections (--shadow-btn, NavRail's border/
spacing, LoginPage's logo radius/brand-panel padding, a placeholder
icon color) verified against screens/*.dc.html's exact pixel values
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
77 lines
4.0 KiB
TypeScript
77 lines
4.0 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
|
|
import { Outlet, 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 type { MediaType } from '@/lib/api/messages';
|
|
import { useMediaQuery } from '@/lib/hooks';
|
|
import { useChatList } from '@/lib/tinode/hooks';
|
|
|
|
/**
|
|
* 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.
|
|
* 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 [detailOpen, setDetailOpen] = useState(false);
|
|
const [historyTab, setHistoryTab] = useState<'text' | MediaType | null>(null);
|
|
const isDocked = useMediaQuery('(min-width: 1280px)');
|
|
const showListOnNarrow = !topicId;
|
|
const contacts = useChatList();
|
|
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;
|
|
|
|
// 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.
|
|
useEffect(() => setHistoryTab(null), [topicId]);
|
|
|
|
return (
|
|
<div className="relative flex h-full w-full">
|
|
<div className="relative flex h-full min-w-0 flex-1">
|
|
<div className="absolute inset-x-0 top-0 z-10">
|
|
<OfflineBanner />
|
|
</div>
|
|
<div className={`h-full w-full md:w-auto md:shrink-0 ${showListOnNarrow ? '' : 'hidden md:flex'}`}>
|
|
<ChatListColumn />
|
|
</div>
|
|
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
|
|
<Outlet context={{ onToggleDetail: () => setDetailOpen((v) => !v), detailVisible, onOpenHistory: setHistoryTab }} />
|
|
</div>
|
|
</div>
|
|
{topicId ? (
|
|
<div className="hidden h-full lg:block">
|
|
<DetailColumn topicId={topicId} peerName={peerName} onOpenHistory={setHistoryTab} />
|
|
</div>
|
|
) : null}
|
|
{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} />
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{detailOpen && topicId ? (
|
|
<div className="fixed inset-0 z-30 bg-white md:hidden">
|
|
<DetailColumn topicId={topicId} peerName={peerName} onClose={() => setDetailOpen(false)} onOpenHistory={setHistoryTab} />
|
|
</div>
|
|
) : null}
|
|
{historyTab && topicId ? <HistorySearchModal topicId={topicId} initialTab={historyTab} onClose={() => setHistoryTab(null)} /> : null}
|
|
</div>
|
|
);
|
|
}
|