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:
Your Name
2026-09-08 03:23:54 +08:00
co-authored by Claude Sonnet 5
parent 569f266390
commit a7da4dc079
9 changed files with 166 additions and 79 deletions
+54 -36
View File
@@ -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,46 +34,57 @@ 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) => {
const name = contact.public?.fn || contact.topic;
const preview = formatMessagePreview(contact.latestMessage?.(), myUserId);
const active = topicId === contact.topic;
return (
<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'
}`}
>
<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" />
) : null}
</span>
<span className="flex min-w-0 flex-col gap-1">
<span className="flex items-center gap-1.5">
<strong className="truncate text-[15px] font-medium text-[#272b31]">{name}</strong>
{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;
return (
<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 ${
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 || '')}`}
>
{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" />
) : null}
</span>
<small className="truncate text-[13px] text-text-tertiary-2">{preview}</small>
</span>
<span className="flex flex-none flex-col items-end justify-between gap-1.5">
<time className="text-xs text-text-tertiary-3">{contact.touched ? formatListTime(contact.touched) : ''}</time>
{contact.unread ? (
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
{contact.unread}
</b>
) : null}
</span>
</Link>
);
})}
<span className="flex min-w-0 flex-col gap-1">
<span className="flex items-center gap-1.5">
<strong className="truncate text-[15px] font-medium text-[#272b31]">{name}</strong>
</span>
<small className="truncate text-[13px] text-text-tertiary-2">{preview}</small>
</span>
<span className="flex flex-none flex-col items-end justify-between gap-1.5">
<time className="text-xs text-text-tertiary-3">{contact.touched ? formatListTime(contact.touched) : ''}</time>
{contact.unread ? (
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
{contact.unread}
</b>
) : null}
</span>
</Link>
);
})
)}
</div>
</aside>
);