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:
co-authored by
Claude Sonnet 5
parent
d3a0eb0dbc
commit
c9f4615c40
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user