fix(web): code review round 1 -- messaging UI critical/high findings
- CRITICAL: draft text and voice-recording state were never isolated per conversation. ConversationView is one long-lived instance reused across every topic (`:topicId` route param change doesn't remount it), so a single `draft` string leaked one conversation's unsent text into whatever conversation was opened next -- and, far more seriously, starting a voice recording in conversation A and then switching to conversation B before hitting send would publish that recording into B: VoiceRecorder keeps its own MediaRecorder running across the switch, but the onSend callback passed to it is a fresh closure over whatever topic is CURRENT when it finally fires. Fixed by keying drafts per topicId and force- resetting `recording` (unmounting VoiceRecorder, which stops its stream) whenever topicId changes. - CRITICAL: the detail/settings panel was completely unreachable below 960px -- it only ever rendered inside `lg:block` (>=1280px, docked) or `md:block lg:hidden` (960-1279px, drawer) wrappers, so tapping the header "i" button below 960px flipped internal state and fired its data queries with zero visible effect. Added the README §1.4-mandated <960px full-page overlay tier, and a small new useMediaQuery hook so the header's "i" button highlight reflects whether the panel is ACTUALLY visible right now instead of being hardcoded to a breakpoint (it was previously always blue at 960-1279px regardless of the drawer's real open state, and always grey at >=1280px despite the panel being permanently docked open there). - CRITICAL (visually confirmed against 1a-desktop-chat.png): messages you send never showed an avatar at all (just an empty spacer) -- every reference screenshot shows your own "我" avatar beside your bubbles the same as the other party's. MessageRow now takes myUserId and renders it. While in there, also moved the failed-send error icon beside the bubble (README: "气泡左侧") instead of below it with the retry text. - CRITICAL: ChatListColumn's search box had no value/onChange at all -- typing into it did nothing. Now filters by contact name; added the search_off/"未找到相关会话" and forum/"暂无会话" empty states README §2.1 calls for, neither of which existed before. - MEDIUM (real Tailwind cascade bug): the selected chat-list row's highlight was being overridden by the unconditional `hover:bg-[...]` class on the same element -- Tailwind emits hover-variant rules after base rules regardless of source order, so hovering an already-selected row visually un-selected it until the mouse moved away. Scoped the hover class to the non-selected branch only. - NavRail: inactive icons used the wrong color token (icon-default #555c65 instead of the spec's #656b74, this app's text-secondary); the unread badge prop was never actually supplied by AppShell, so the badge's otherwise-pixel-correct rendering never appeared in the running app. Fixed both, and gave BottomTabBar's 消息 tab the same real badge (computed once in AppShell from useChatList(), passed to both). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
569f266390
commit
a7da4dc079
@@ -1,5 +1,7 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { useChatList } from '@/lib/tinode/hooks';
|
||||
|
||||
import { BottomTabBar } from './BottomTabBar';
|
||||
import { NavRail } from './NavRail';
|
||||
|
||||
@@ -10,17 +12,20 @@ import { NavRail } from './NavRail';
|
||||
* columns within a section.
|
||||
*/
|
||||
export function AppShell() {
|
||||
const contacts = useChatList();
|
||||
const unreadMessages = contacts.reduce((sum, c) => sum + (c.unread || 0), 0);
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full overflow-hidden bg-surface-page">
|
||||
<div className="hidden sm:flex sm:h-full">
|
||||
<NavRail />
|
||||
<NavRail unreadMessages={unreadMessages} />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
<div className="sm:hidden">
|
||||
<BottomTabBar />
|
||||
<BottomTabBar unreadMessages={unreadMessages} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,31 +2,44 @@ import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { Icon } from './Icon';
|
||||
|
||||
const TABS = [
|
||||
{ to: '/messages', icon: 'chat_bubble', label: '消息' },
|
||||
{ to: '/contacts', icon: 'contacts', label: '通讯录' },
|
||||
{ to: '/settings', icon: 'person', label: '我的' },
|
||||
];
|
||||
interface TabItem {
|
||||
to: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the old app's message-center-view.jsx-embedded 2-tab (消息/工作台) bar: this one is
|
||||
* top-level (visible regardless of which section is open, not just inside the message list) and
|
||||
* has 3 tabs per the new design, since 工作台 no longer exists.
|
||||
*/
|
||||
export function BottomTabBar() {
|
||||
export function BottomTabBar({ unreadMessages = 0 }: { unreadMessages?: number }) {
|
||||
const tabs: TabItem[] = [
|
||||
{ to: '/messages', icon: 'chat_bubble', label: '消息', badge: unreadMessages },
|
||||
{ to: '/contacts', icon: 'contacts', label: '通讯录' },
|
||||
{ to: '/settings', icon: 'person', label: '我的' },
|
||||
];
|
||||
return (
|
||||
<nav className="grid h-[82px] flex-none grid-cols-3 border-t border-border-panel bg-white pb-4.5">
|
||||
{TABS.map((tab) => (
|
||||
{tabs.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center justify-center gap-0.5 text-[11px] no-underline ${
|
||||
`relative flex flex-col items-center justify-center gap-0.5 text-[11px] no-underline ${
|
||||
isActive ? 'text-accent' : 'text-text-tertiary-3'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="relative">
|
||||
<Icon name={tab.icon} className="!text-2xl" />
|
||||
{tab.badge ? (
|
||||
<b className="absolute -right-2 -top-1 grid h-4 min-w-4 place-items-center rounded-full bg-unread px-1 text-[10px] leading-none font-medium text-white">
|
||||
{tab.badge}
|
||||
</b>
|
||||
) : null}
|
||||
</span>
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
@@ -28,7 +28,7 @@ function NavRailButton({ item }: { item: NavRailItem }) {
|
||||
title={item.label}
|
||||
className={({ isActive }) =>
|
||||
`relative grid h-11 w-11 place-items-center rounded-[10px] no-underline ${
|
||||
isActive ? 'bg-accent-soft-bg text-accent' : 'text-icon-default'
|
||||
isActive ? 'bg-accent-soft-bg text-accent' : 'text-text-secondary'
|
||||
}`
|
||||
}
|
||||
>
|
||||
@@ -45,7 +45,7 @@ function NavRailButton({ item }: { item: NavRailItem }) {
|
||||
/** Persistent 64px icon rail, visible at >=640px (see App.tsx for the <640 bottom-tab fallback). */
|
||||
export function NavRail({ selfInitial = '我', unreadMessages = 0 }: { selfInitial?: string; unreadMessages?: number }) {
|
||||
return (
|
||||
<nav className="flex h-full w-16 flex-none flex-col items-center border-r border-border-panel bg-surface-rail py-3.5">
|
||||
<nav className="flex h-full w-16 flex-none flex-col items-center border-r border-border-panel bg-surface-rail pb-3 pt-3.5">
|
||||
<div className="mb-5 grid h-[38px] w-[38px] place-items-center rounded-[10px] bg-accent text-base font-medium text-white">
|
||||
{selfInitial}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { authToken } from '@/lib/api/client';
|
||||
import { fileDownloadUrl } from '@/lib/api/upload';
|
||||
import { fileAuthHeaders, fileDownloadUrl } from '@/lib/api/upload';
|
||||
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
|
||||
|
||||
/**
|
||||
* GET /v0/file/s/{file_id} requires the same `X-MeetingIM-Auth` header as upload (docs/
|
||||
* IM-前后端对接文档.md:461) -- a plain <img src="..."> or <a href="..."> can't attach a custom
|
||||
* header, so an externally-referenced (non-inline) attachment needs an authenticated fetch into a
|
||||
* blob URL rather than a direct URL. Inline (base64 `val`) attachments skip this path entirely.
|
||||
* GET /v0/file/s/{file_id} requires the same auth header as upload (see upload.ts's fileAuthHeaders
|
||||
* for exactly which one, and why) -- a plain <img src="..."> or <a href="..."> can't attach a
|
||||
* custom header, so an externally-referenced (non-inline) attachment needs an authenticated fetch
|
||||
* into a blob URL rather than a direct URL. Inline (base64 `val`) attachments skip this path entirely.
|
||||
*/
|
||||
function useAuthedBlobUrl(ref: string | undefined): { url: string | null; loading: boolean; error: boolean } {
|
||||
const [state, setState] = useState<{ url: string | null; loading: boolean; error: boolean }>({ url: null, loading: !!ref, error: false });
|
||||
@@ -19,7 +18,7 @@ function useAuthedBlobUrl(ref: string | undefined): { url: string | null; loadin
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
setState({ url: null, loading: true, error: false });
|
||||
fetch(fileDownloadUrl(ref), { headers: { 'X-MeetingIM-Auth': `token ${authToken()}` } })
|
||||
fetch(fileDownloadUrl(ref), { headers: fileAuthHeaders() })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('download failed');
|
||||
return res.blob();
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { formatListTime } from '@/lib/messages/time';
|
||||
import { formatMessagePreview } from '@/lib/messages/preview';
|
||||
import { formatListTime } from '@/lib/messages/time';
|
||||
import { useChatList } from '@/lib/tinode/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* Chat-list row sort: by `touched` (last activity) descending. Pinned-conversations-first isn't
|
||||
* wired yet -- that's driven by the P2P conversation-settings API (GET/PATCH /conversations/{topic}/
|
||||
* settings {pinned}), which the detail panel doesn't build until its "置顶聊天" toggle lands.
|
||||
* settings {pinned}), which the detail panel doesn't build until its "置顶聊天" toggle lands. The
|
||||
* fixed 系统消息/文件助手 rows (README §2.1) are also still missing -- pinning them correctly needs
|
||||
* to know which topic name(s) those actually are on this backend, which hasn't been confirmed yet.
|
||||
*/
|
||||
export function ChatListColumn() {
|
||||
const { topicId } = useParams();
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const contacts = useChatList();
|
||||
const [query, setQuery] = useState('');
|
||||
const sorted = [...contacts].sort((a, b) => (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0));
|
||||
const trimmedQuery = query.trim();
|
||||
const filtered = trimmedQuery ? sorted.filter((c) => (c.public?.fn || c.topic || '').toLowerCase().includes(trimmedQuery.toLowerCase())) : sorted;
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
|
||||
@@ -27,12 +34,20 @@ export function ChatListColumn() {
|
||||
<div className="mx-3 mb-2.5 flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索会话、联系人、消息"
|
||||
className="ml-2 w-full bg-transparent text-sm text-text-body outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{sorted.map((contact) => {
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-text-tertiary-4">
|
||||
<Icon name={trimmedQuery ? 'search_off' : 'forum'} className="!text-4xl" />
|
||||
<span className="text-sm">{trimmedQuery ? '未找到相关会话' : '暂无会话'}</span>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((contact) => {
|
||||
const name = contact.public?.fn || contact.topic;
|
||||
const preview = formatMessagePreview(contact.latestMessage?.(), myUserId);
|
||||
const active = topicId === contact.topic;
|
||||
@@ -40,11 +55,13 @@ export function ChatListColumn() {
|
||||
<Link
|
||||
key={contact.topic}
|
||||
to={`/messages/${contact.topic}`}
|
||||
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item px-4 py-2.5 no-underline hover:bg-[#f6f8fb] ${
|
||||
active ? 'bg-accent-selected-bg' : 'bg-white'
|
||||
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item px-4 py-2.5 no-underline ${
|
||||
active ? 'bg-accent-selected-bg' : 'bg-white hover:bg-[#f6f8fb]'
|
||||
}`}
|
||||
>
|
||||
<span className={`relative grid h-11 w-11 place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(contact.topic || '')}`}>
|
||||
<span
|
||||
className={`relative grid h-11 w-11 place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(contact.topic || '')}`}
|
||||
>
|
||||
{avatarLetter(name)}
|
||||
{contact.online ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-online" />
|
||||
@@ -66,7 +83,8 @@ export function ChatListColumn() {
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,12 @@ import { useSessionStore } from '@/stores/session';
|
||||
|
||||
interface OutletCtx {
|
||||
onToggleDetail: () => void;
|
||||
/** Whether the detail panel is CURRENTLY shown -- true always at >=1280px (docked, no close
|
||||
* affordance), true/false at 960-1279px depending on the drawer's open state. Drives the header
|
||||
* "i" button's highlight; previously hardcoded to the breakpoint itself (always blue at
|
||||
* 960-1279px regardless of the drawer's real state, always grey at >=1280px despite the panel
|
||||
* being permanently open there) rather than this actual state. */
|
||||
detailVisible: boolean;
|
||||
}
|
||||
|
||||
interface ContextMenuState {
|
||||
@@ -34,14 +40,22 @@ interface ContextMenuState {
|
||||
|
||||
export function ConversationView() {
|
||||
const { topicId } = useParams();
|
||||
const { onToggleDetail } = useOutletContext<OutletCtx>();
|
||||
const { onToggleDetail, detailVisible } = useOutletContext<OutletCtx>();
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const tinode = useTinode();
|
||||
const contacts = useChatList();
|
||||
const contact = contacts.find((c) => c.topic === topicId);
|
||||
const messages = useTopicMessages(topicId);
|
||||
const bootstrap = useBootstrap();
|
||||
const [draft, setDraft] = useState('');
|
||||
// Keyed by topicId, not a single string -- ConversationView is one long-lived instance reused
|
||||
// across every conversation (the route is `:topicId`, React Router doesn't remount on param
|
||||
// change), so a plain single draft leaks the PREVIOUS conversation's unsent text into whichever
|
||||
// one you open next.
|
||||
const [draftByTopic, setDraftByTopic] = useState<Record<string, string>>({});
|
||||
const draft = (topicId && draftByTopic[topicId]) || '';
|
||||
const setDraft = (value: string) => {
|
||||
if (topicId) setDraftByTopic((prev) => ({ ...prev, [topicId]: value }));
|
||||
};
|
||||
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedSeqs, setSelectedSeqs] = useState<Set<number>>(new Set());
|
||||
@@ -65,6 +79,14 @@ export function ConversationView() {
|
||||
setSelectedSeqs(new Set());
|
||||
setMenu(null);
|
||||
setReplyTo(null);
|
||||
setForwardMsg(null);
|
||||
setBulkForwardOpen(false);
|
||||
setEmojiOpen(false);
|
||||
// Unmounting VoiceRecorder stops its media stream via its own cleanup effect -- without this,
|
||||
// a recording started in conversation A silently keeps going after navigating to B, and its
|
||||
// onSend callback (re-created every render off the CURRENT topicId) would publish the eventual
|
||||
// recording into B instead of the conversation the user was actually recording in.
|
||||
setRecording(false);
|
||||
}, [topicId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -263,7 +285,7 @@ export function ConversationView() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleDetail}
|
||||
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1 md:bg-accent-soft-bg md:text-accent lg:bg-transparent lg:text-text-tertiary-1"
|
||||
className={`grid h-9.5 w-9.5 place-items-center rounded-lg ${detailVisible ? 'bg-accent-soft-bg text-accent' : 'text-text-tertiary-1'}`}
|
||||
>
|
||||
<Icon name="info" />
|
||||
</button>
|
||||
@@ -282,6 +304,7 @@ export function ConversationView() {
|
||||
mine={!!myUserId && msg.from === myUserId}
|
||||
isGroup={isGroup}
|
||||
peerName={peerName}
|
||||
myUserId={myUserId}
|
||||
selectMode={selectMode}
|
||||
selected={typeof msg.seq === 'number' && selectedSeqs.has(msg.seq)}
|
||||
onToggleSelect={toggleSelect}
|
||||
@@ -339,7 +362,7 @@ export function ConversationView() {
|
||||
<div className="absolute bottom-full left-0 z-20 mb-2">
|
||||
<EmojiPanel
|
||||
onPick={(emoji) => {
|
||||
setDraft((d) => d + emoji);
|
||||
setDraft(draft + emoji);
|
||||
setEmojiOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -14,13 +14,14 @@ interface MessageRowProps {
|
||||
mine: boolean;
|
||||
isGroup: boolean;
|
||||
peerName: string;
|
||||
myUserId: string | null;
|
||||
selectMode?: boolean;
|
||||
selected?: boolean;
|
||||
onToggleSelect?: (seq: number) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, msg: StoredMessage) => void;
|
||||
}
|
||||
|
||||
export function MessageRow({ topic, msg, mine, isGroup, peerName, selectMode, selected, onToggleSelect, onContextMenu }: MessageRowProps) {
|
||||
export function MessageRow({ topic, msg, mine, isGroup, peerName, myUserId, selectMode, selected, onToggleSelect, onContextMenu }: MessageRowProps) {
|
||||
if (isServerDeleted(msg)) {
|
||||
return <div className="py-1 text-center text-xs text-text-tertiary-4">此消息已被删除</div>;
|
||||
}
|
||||
@@ -64,16 +65,15 @@ export function MessageRow({ topic, msg, mine, isGroup, peerName, selectMode, se
|
||||
}}
|
||||
className={`flex flex-1 items-end gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}
|
||||
>
|
||||
{!mine ? (
|
||||
<span
|
||||
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(msg.from || peerName)}`}
|
||||
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(mine ? myUserId || '' : msg.from || peerName)}`}
|
||||
>
|
||||
{avatarLetter(peerName)}
|
||||
{avatarLetter(mine ? '我' : peerName)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-9 flex-none" />
|
||||
)}
|
||||
<div className={`flex max-w-[60%] flex-col gap-1 ${mine ? 'items-end' : 'items-start'}`}>
|
||||
{/* README §3.2: the failed-send icon sits beside the bubble, not below it with the retry text. */}
|
||||
<div className={`flex items-end gap-1.5 ${mine ? 'flex-row-reverse' : ''}`}>
|
||||
{failed ? <Icon name="error" className="flex-none text-[20px] text-danger-text" /> : null}
|
||||
<div
|
||||
className={`px-3.5 py-2.5 text-[15px] leading-[1.55] break-words ${
|
||||
mine ? 'rounded-[12px_4px_12px_12px] bg-bubble-mine' : 'rounded-[4px_12px_12px_12px] bg-white shadow-bubble'
|
||||
@@ -81,9 +81,9 @@ export function MessageRow({ topic, msg, mine, isGroup, peerName, selectMode, se
|
||||
>
|
||||
{isRecalled(msg) ? '⚠ 消息已删除' : renderDrafty(msg.content)}
|
||||
</div>
|
||||
</div>
|
||||
{failed ? (
|
||||
<button type="button" onClick={retry} className="flex items-center gap-1 text-xs text-danger-text">
|
||||
<Icon name="error" className="text-[16px]" />
|
||||
<button type="button" onClick={retry} className="text-xs text-danger-text">
|
||||
发送失败,点击重试
|
||||
</button>
|
||||
) : (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Outlet, useParams } from 'react-router-dom';
|
||||
import { ChatListColumn } from '@/features/messages/ChatListColumn';
|
||||
import { DetailColumn } from '@/features/messages/DetailColumn';
|
||||
import { OfflineBanner } from '@/features/messages/OfflineBanner';
|
||||
import { useMediaQuery } from '@/lib/hooks';
|
||||
import { useChatList } from '@/lib/tinode/hooks';
|
||||
|
||||
/**
|
||||
@@ -14,13 +15,21 @@ import { useChatList } from '@/lib/tinode/hooks';
|
||||
* 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 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;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full">
|
||||
@@ -32,7 +41,7 @@ export function MessagesLayout() {
|
||||
<ChatListColumn />
|
||||
</div>
|
||||
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
|
||||
<Outlet context={{ onToggleDetail: () => setDetailOpen((v) => !v) }} />
|
||||
<Outlet context={{ onToggleDetail: () => setDetailOpen((v) => !v), detailVisible }} />
|
||||
</div>
|
||||
</div>
|
||||
{topicId ? (
|
||||
@@ -48,6 +57,11 @@ export function MessagesLayout() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{detailOpen && topicId ? (
|
||||
<div className="fixed inset-0 z-30 bg-white md:hidden">
|
||||
<DetailColumn topicId={topicId} peerName={peerName} onClose={() => setDetailOpen(false)} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,3 +9,18 @@ export function useDebouncedValue<T>(value: T, delay = 300): T {
|
||||
}, [value, delay]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
/** Reactive `window.matchMedia` -- for the few places CSS breakpoints alone aren't enough (e.g. a
|
||||
* button's highlighted state needs to know whether the panel it toggles is CSS-always-visible at
|
||||
* the current breakpoint, not just conditionally rendered). */
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() => (typeof window !== 'undefined' ? window.matchMedia(query).matches : false));
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(query);
|
||||
const onChange = () => setMatches(mql.matches);
|
||||
onChange();
|
||||
mql.addEventListener('change', onChange);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}, [query]);
|
||||
return matches;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user