fix(web): code review round 2 -- messaging UI bugs and design fidelity

- 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>
This commit is contained in:
Your Name
2026-09-08 04:48:53 +08:00
co-authored by Claude Sonnet 5
parent d3a0eb0dbc
commit c9f4615c40
20 changed files with 287 additions and 97 deletions
@@ -1,11 +1,11 @@
import { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem, type MediaType, type SearchHit } from '@/lib/api/messages';
import { checkUrlSafety } from '@/lib/api/security';
import { useAuthedBlobUrl, useDebouncedValue } from '@/lib/hooks';
import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
import { renderDrafty } from '@/lib/messages/drafty';
import { formatEventTimestamp } from '@/lib/messages/time';
@@ -119,6 +119,14 @@ function LinkResult({ item }: { item: SearchHit & { url?: string; title?: string
);
}
function LoadMoreButton({ onClick, loading }: { onClick: () => void; loading: boolean }) {
return (
<button type="button" onClick={onClick} disabled={loading} className="py-3 text-center text-sm text-accent disabled:opacity-60">
{loading ? '加载中…' : '加载更多'}
</button>
);
}
interface HistorySearchModalProps {
topicId: string;
initialTab?: Tab;
@@ -130,24 +138,34 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
const [tab, setTab] = useState<Tab>(initialTab);
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 300);
useEscapeKey(onClose);
const searchQuery = useQuery({
const searchQuery = useInfiniteQuery({
queryKey: ['topic-search', topicId, debouncedQuery],
queryFn: () => searchTopicMessages(topicId, debouncedQuery, 'text'),
queryFn: ({ pageParam }) => searchTopicMessages(topicId, debouncedQuery, 'text', pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
enabled: tab === 'text' && debouncedQuery.trim().length > 0,
});
const searchItems = searchQuery.data?.pages.flatMap((p) => p.items) ?? [];
const mediaQuery = useQuery({
const mediaQuery = useInfiniteQuery({
queryKey: ['topic-media', topicId, tab],
queryFn: () => getTopicMedia(topicId, tab as MediaFetchType),
queryFn: ({ pageParam }) => getTopicMedia(topicId, tab as MediaFetchType, pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
enabled: tab === 'image' || tab === 'video',
});
const mediaItems = mediaQuery.data?.pages.flatMap((p) => p.items) ?? [];
const fileLinkQuery = useQuery({
const fileLinkQuery = useInfiniteQuery({
queryKey: ['topic-search-media', topicId, tab],
queryFn: () => searchTopicMessages(topicId, '', tab as 'file' | 'link'),
queryFn: ({ pageParam }) => searchTopicMessages(topicId, '', tab as 'file' | 'link', pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
enabled: tab === 'file' || tab === 'link',
});
const fileLinkItems = fileLinkQuery.data?.pages.flatMap((p) => p.items) ?? [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
@@ -190,25 +208,30 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 pb-4">
{!query.trim() ? (
{!debouncedQuery.trim() ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">输入关键词搜索这个会话的聊天记录</div>
) : searchQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">搜索中…</div>
) : !searchQuery.data?.items.length ? (
) : !searchItems.length ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">没有找到相关消息</div>
) : (
<ul className="flex flex-col gap-1">
{searchQuery.data.items.map((item) => (
<li key={item.seq} className="rounded-lg px-3 py-2.5 hover:bg-surface-rail">
<div className="flex items-center justify-between text-xs text-text-tertiary-3">
<span>{formatEventTimestamp(new Date(item.ts))}</span>
</div>
<div className="mt-0.5 truncate">
<SnapshotContent item={item} />
</div>
</li>
))}
</ul>
<>
<ul className="flex flex-col gap-1">
{searchItems.map((item) => (
<li key={item.seq} className="rounded-lg px-3 py-2.5 hover:bg-surface-rail">
<div className="flex items-center justify-between text-xs text-text-tertiary-3">
<span>{formatEventTimestamp(new Date(item.ts))}</span>
</div>
<div className="mt-0.5 truncate">
<SnapshotContent item={item} />
</div>
</li>
))}
</ul>
{searchQuery.hasNextPage ? (
<LoadMoreButton onClick={() => searchQuery.fetchNextPage()} loading={searchQuery.isFetchingNextPage} />
) : null}
</>
)}
</div>
</div>
@@ -216,28 +239,38 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
<div className="flex-1 overflow-y-auto p-5">
{mediaQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : !mediaQuery.data?.items.length ? (
) : !mediaItems.length ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
) : (
<div className="grid grid-cols-4 gap-0.5 overflow-hidden rounded-lg">
{mediaQuery.data.items.map((item) => (
<MediaThumb key={item.seq} item={item} isVideo={tab === 'video'} />
))}
</div>
<>
<div className="grid grid-cols-4 gap-0.5 overflow-hidden rounded-lg">
{mediaItems.map((item) => (
<MediaThumb key={item.seq} item={item} isVideo={tab === 'video'} />
))}
</div>
{mediaQuery.hasNextPage ? (
<LoadMoreButton onClick={() => mediaQuery.fetchNextPage()} loading={mediaQuery.isFetchingNextPage} />
) : null}
</>
)}
</div>
) : (
<div className="flex-1 overflow-y-auto p-5">
{fileLinkQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : !fileLinkQuery.data?.items.length ? (
) : !fileLinkItems.length ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
) : (
<div className="flex flex-col gap-2">
{fileLinkQuery.data.items.map((item) =>
tab === 'link' ? <LinkResult key={item.seq} item={item} /> : <SnapshotContent key={item.seq} item={item} />,
)}
</div>
<>
<div className="flex flex-col gap-2">
{fileLinkItems.map((item) =>
tab === 'link' ? <LinkResult key={item.seq} item={item} /> : <SnapshotContent key={item.seq} item={item} />,
)}
</div>
{fileLinkQuery.hasNextPage ? (
<LoadMoreButton onClick={() => fileLinkQuery.fetchNextPage()} loading={fileLinkQuery.isFetchingNextPage} />
) : null}
</>
)}
</div>
)}