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
+4 -1
View File
@@ -15,8 +15,11 @@ import { NavRail } from './NavRail';
export function AppShell() {
const contacts = useChatList();
const bootstrap = useBootstrap();
// "sys"/"slf" have their own authoritative unread source in entry_badges (bootstrap.ts) -- excluded
// here in case they ever also show up as regular Tinode subscriptions in `contacts`, which would
// otherwise double-count the same unread messages into this badge.
const unreadMessages =
contacts.reduce((sum, c) => sum + (c.unread || 0), 0) +
contacts.reduce((sum, c) => (c.topic === 'sys' || c.topic === 'slf' ? sum : sum + (c.unread || 0)), 0) +
(bootstrap.data?.entry_badges?.system_unread_count || 0) +
(bootstrap.data?.entry_badges?.file_assistant_unread_count || 0);
+2 -2
View File
@@ -45,8 +45,8 @@ 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 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">
<nav className="flex h-full w-16 flex-none flex-col items-center border-r border-[#e6e9ee] bg-surface-rail pb-3 pt-3.5">
<div className="mb-[22px] grid h-[38px] w-[38px] place-items-center rounded-[10px] bg-accent text-base font-medium text-white">
{selfInitial}
</div>
<div className="flex flex-1 flex-col items-center gap-1.5">
+38
View File
@@ -0,0 +1,38 @@
import { useEffect, useRef } from 'react';
import { Icon } from '@/app/Icon';
import { TOAST_DURATION } from '@/lib/config';
interface ToastProps {
message: string;
variant?: 'success' | 'error';
onDone: () => void;
}
export function Toast({ message, variant = 'success', onDone }: ToastProps) {
// Call sites pass an inline `onDone` arrow, so a plain `[message, onDone]` dependency array
// restarts the auto-dismiss timer on every unrelated parent re-render (e.g. typing in an
// unrelated field after the toast appears) instead of only when a NEW message is shown.
const onDoneRef = useRef(onDone);
useEffect(() => {
onDoneRef.current = onDone;
});
useEffect(() => {
if (!message) return undefined;
const timer = window.setTimeout(() => onDoneRef.current(), TOAST_DURATION);
return () => window.clearTimeout(timer);
}, [message]);
if (!message) return null;
return (
<div
role="status"
className="fixed bottom-8 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-text-heading/94 px-4 py-3 text-sm text-white"
>
<Icon name={variant === 'error' ? 'error' : 'check_circle'} className={`text-[18px] ${variant === 'error' ? 'text-danger-text' : 'text-online'}`} />
{message}
</div>
);
}
@@ -1,9 +1,9 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/features/auth/components/Toast';
import { Toast } from '@/app/Toast';
import { ImApiError } from '@/lib/api/client';
import { submitTenantApplication, uploadPublicFile } from '@/lib/api/tenant';
@@ -12,6 +12,7 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** README §10.5 / old app's EnterpriseRegister -- a company registering itself as a new tenant. */
export function EnterpriseRegisterPage() {
const tenantCodeHint = (useLocation().state as { tenantCodeHint?: string } | null)?.tenantCodeHint || undefined;
const [creditCode, setCreditCode] = useState('');
const [email, setEmail] = useState('');
const [file, setFile] = useState<File | null>(null);
@@ -32,7 +33,12 @@ export function EnterpriseRegisterPage() {
if (file) {
licenseFileId = (await uploadPublicFile(file, 'enterprise_license')).file_id;
}
const data = await submitTenantApplication({ credit_code: creditCode, enterprise_email: email || undefined, license_file_id: licenseFileId });
const data = await submitTenantApplication({
credit_code: creditCode,
enterprise_email: email || undefined,
license_file_id: licenseFileId,
tenant_code_hint: tenantCodeHint,
});
setReviewDays(Math.max(1, Math.round((data.estimated_review_hours || 24) / 24)));
setSubmitted(true);
setToast('提交成功,我们会尽快联系你');
+3 -3
View File
@@ -67,8 +67,8 @@ export function LoginPage() {
<div className="flex h-dvh w-full">
<div className="relative hidden shrink-0 flex-col justify-center overflow-hidden bg-gradient-to-br from-[#1677ff] to-[#0f56c4] px-16 text-white min-[900px]:flex min-[900px]:w-[56%]">
<div className="pointer-events-none absolute inset-x-0 top-0 h-96 bg-[radial-gradient(circle_at_20%_-10%,rgba(255,255,255,0.25),transparent_60%)]" />
<div className="absolute left-16 top-10 flex items-center gap-2.5">
<div className="grid h-10 w-10 place-items-center rounded-xl bg-white text-lg font-bold text-accent">M</div>
<div className="absolute left-16 top-14 flex items-center gap-2.5">
<div className="grid h-10 w-10 place-items-center rounded-[10px] bg-white text-lg font-bold text-accent">M</div>
<strong className="text-xl font-semibold">{BASE_APP_NAME}</strong>
</div>
<h2 className="m-0 max-w-md text-[40px] leading-tight font-semibold text-balance">
@@ -77,7 +77,7 @@ export function LoginPage() {
<p className="mt-4 max-w-md text-base text-white/80">
单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在桌面端和手机端保持一致。
</p>
<div className="absolute bottom-10 left-16 flex gap-5 text-[13px] text-white/75">
<div className="absolute bottom-14 left-16 flex gap-5 text-[13px] text-white/75">
<span>下载桌面客户端</span>
<span>iOS / Android</span>
<span>帮助中心</span>
+1 -1
View File
@@ -3,8 +3,8 @@ import { useState } from 'react';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { PasswordInput } from '@/features/auth/components/PasswordInput';
import { Toast } from '@/features/auth/components/Toast';
import { VerificationCodeButton } from '@/features/auth/components/VerificationCodeButton';
import { confirmPasswordReset, verifyPasswordResetCode } from '@/lib/api/auth';
import { ImApiError } from '@/lib/api/client';
+2 -2
View File
@@ -38,7 +38,7 @@ export function TenantEntryPage() {
return (
<div className="relative flex h-dvh w-full items-center justify-center bg-surface-page px-6">
<Link to="/enterprise-register" className="absolute right-6 top-6 text-sm text-accent no-underline">
<Link to="/enterprise-register" state={{ tenantCodeHint: code.trim() }} className="absolute right-6 top-6 text-sm text-accent no-underline">
企业注册
</Link>
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-5">
@@ -71,7 +71,7 @@ export function TenantEntryPage() {
{submitting ? '进入中…' : '进入企业'}
</button>
</form>
<Link to="/feedback" className="absolute bottom-8 text-sm text-accent no-underline">
<Link to="/feedback" state={{ tenantCodeHint: code.trim() }} className="absolute bottom-8 text-sm text-accent no-underline">
遇到问题?
</Link>
</div>
+9 -3
View File
@@ -1,9 +1,9 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/features/auth/components/Toast';
import { Toast } from '@/app/Toast';
import { ImApiError } from '@/lib/api/client';
import { submitTenantEntryFeedback, uploadPublicFile } from '@/lib/api/tenant';
@@ -14,6 +14,7 @@ interface PendingImage {
/** README §10.6 / `01/1j`'s 问题反馈, reached from the tenant-code screen's "遇到问题?" link. */
export function TenantFeedbackPage() {
const tenantCodeHint = (useLocation().state as { tenantCodeHint?: string } | null)?.tenantCodeHint || undefined;
const [description, setDescription] = useState('');
const [contact, setContact] = useState('');
const [images, setImages] = useState<PendingImage[]>([]);
@@ -45,7 +46,12 @@ export function TenantFeedbackPage() {
for (const item of images) {
screenshotFileIds.push((await uploadPublicFile(item.file, 'tenant_entry_feedback')).file_id);
}
await submitTenantEntryFeedback({ description: description.trim(), screenshot_file_ids: screenshotFileIds, contact: contact.trim() || undefined });
await submitTenantEntryFeedback({
description: description.trim(),
tenant_code_hint: tenantCodeHint,
screenshot_file_ids: screenshotFileIds,
contact: contact.trim() || undefined,
});
setToast('提交成功,我们会尽快联系你');
images.forEach((item) => URL.revokeObjectURL(item.url));
setDescription('');
@@ -23,6 +23,14 @@ export function VerificationCodeButton({ phone, scene, onChallenge, onError }: V
// reaches 0 -- a real resource leak, not just a React warning, since nothing else ever clears it.
useEffect(() => () => window.clearInterval(timerRef.current), []);
// A countdown started for one phone number must not block sending a code to a DIFFERENT one --
// otherwise correcting a mistyped number mid-cooldown leaves the button stuck disabled (still
// counting down for the old number) until the old timer happens to run out on its own.
useEffect(() => {
window.clearInterval(timerRef.current);
setSeconds(0);
}, [phone]);
const tick = (initial: number) => {
window.clearInterval(timerRef.current);
setSeconds(initial);
@@ -60,7 +60,7 @@ export function FileAttachment({ data }: { data: FileData }) {
{typeof data.size === 'number' ? formatFileSize(data.size) : ''} · {remote.loading ? '加载中…' : '点击下载'}
</span>
</span>
<Icon name="download" className="ml-auto flex-none text-[20px] text-text-placeholder-1" />
<Icon name="download" className="ml-auto flex-none text-[20px] text-text-placeholder-2" />
</a>
);
}
+3 -3
View File
@@ -25,7 +25,7 @@ const PINNED_ENTRIES = [
label: '系统消息',
icon: 'notifications',
route: '/chat/system',
colorClass: 'bg-warn',
colorClass: 'bg-warn-bg text-warn',
},
{
key: 'file_assistant' as const,
@@ -33,7 +33,7 @@ const PINNED_ENTRIES = [
label: '文件助手',
icon: 'folder',
route: '/files',
colorClass: 'bg-success',
colorClass: 'bg-success-bg text-success',
},
];
@@ -75,7 +75,7 @@ export function ChatListColumn() {
to={entry.route}
className="grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item bg-white px-4 py-2.5 no-underline hover:bg-[#f6f8fb]"
>
<span className={`grid h-11 w-11 place-items-center rounded-lg text-white ${entry.colorClass}`}>
<span className={`grid h-11 w-11 place-items-center rounded-lg ${entry.colorClass}`}>
<Icon name={entry.icon} className="text-[22px]" />
</span>
<span className="flex min-w-0 flex-col gap-1">
+59 -19
View File
@@ -5,16 +5,17 @@ import type { StoredMessage } from 'tinode-sdk';
import { Drafty } from 'tinode-sdk';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { EmojiPanel } from '@/features/messages/EmojiPanel';
import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal';
import { MessageContextMenu } from '@/features/messages/MessageContextMenu';
import { MessageRow } from '@/features/messages/MessageBubble';
import { VoiceRecorder } from '@/features/messages/VoiceRecorder';
import { useBootstrap } from '@/lib/api/hooks';
import { favoriteMessages, recallMessage } from '@/lib/api/messages';
import { favoriteMessages, type MediaType, recallMessage } from '@/lib/api/messages';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
import { availableActions, type MessageAction } from '@/lib/messages/actions';
import { availableActions, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions';
import { renderDrafty } from '@/lib/messages/drafty';
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
@@ -30,6 +31,9 @@ interface OutletCtx {
* 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;
/** Opens the shared history-search modal (owned by MessagesLayout, the closest common ancestor of
* this view and DetailColumn, which can also open it from its own "查找聊天记录" row). */
onOpenHistory: (tab: 'text' | MediaType) => void;
}
interface ContextMenuState {
@@ -40,7 +44,7 @@ interface ContextMenuState {
export function ConversationView() {
const { topicId } = useParams();
const { onToggleDetail, detailVisible } = useOutletContext<OutletCtx>();
const { onToggleDetail, detailVisible, onOpenHistory } = useOutletContext<OutletCtx>();
const myUserId = useSessionStore((s) => s.myUserId);
const tinode = useTinode();
const contacts = useChatList();
@@ -65,13 +69,26 @@ export function ConversationView() {
const [replyTo, setReplyTo] = useState<StoredMessage | null>(null);
const [forwardMsg, setForwardMsg] = useState<StoredMessage | null>(null);
const [emojiOpen, setEmojiOpen] = useState(false);
const [errorToast, setErrorToast] = useState('');
const streamRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const topicIdRef = useRef(topicId);
useEffect(() => {
topicIdRef.current = topicId;
});
// Every "fire and forget" send/recall/favorite/forward action below used to swallow its own
// failure with an empty `.catch(() => {})` (or, for forward, no catch at all) -- e.g. an
// over-size-limit attachment would throw a friendly Chinese error from uploadFile() that then
// just vanished, leaving the user staring at nothing happening with no idea why.
const showError = (err: unknown, fallback: string) => setErrorToast(err instanceof Error ? err.message : fallback);
useEffect(() => {
if (!topicId) return undefined;
openConversation(topicId).catch(() => {});
return () => closeConversation(topicId);
return () => {
closeConversation(topicId).catch(() => {});
};
}, [topicId]);
useEffect(() => {
@@ -87,13 +104,22 @@ export function ConversationView() {
// 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);
// Without these two, a "加载更多" in flight when the user switches conversations leaves a
// stale spinner showing (never resolves against the topic it started on) and, if the PREVIOUS
// conversation had been scrolled up when it was left, the ref carries that `false` into the
// NEW conversation and silently skips scrolling it to the bottom on open.
setLoadingOlder(false);
autoScrollRef.current = true;
}, [topicId]);
useEffect(() => {
if (autoScrollRef.current) {
streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight });
}
}, [messages.length]);
// `topicId` is included so switching between two conversations that both happen to have
// already loaded the same number of messages (e.g. both exactly MESSAGES_PAGE on first open)
// still scrolls the newly-opened one to the bottom -- `messages.length` alone wouldn't change.
}, [messages.length, topicId]);
useEffect(() => {
if (!topicId || messages.length === 0) return;
@@ -113,6 +139,7 @@ export function ConversationView() {
*/
const loadOlder = async () => {
if (loadingOlder) return;
const calledForTopicId = topicId;
const el = streamRef.current;
const min = topic.minMsgSeq();
const max = topic.maxMsgSeq();
@@ -124,10 +151,17 @@ export function ConversationView() {
try {
await topic.getMessagesPage(MESSAGES_PAGE, gaps, min, max);
} finally {
setLoadingOlder(false);
requestAnimationFrame(() => {
if (el) el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
});
// If the user switched to a different conversation while this was in flight, `el` is now
// showing that OTHER topic's messages -- prevTop/prevHeight were measured against content
// that isn't there anymore, so applying them would jump the NEW conversation's scroll
// position to a meaningless spot. Only the topic that actually asked for more history should
// ever clear its own loading flag or touch the scroll position.
if (topicIdRef.current === calledForTopicId) {
setLoadingOlder(false);
requestAnimationFrame(() => {
if (el) el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
});
}
}
};
@@ -186,7 +220,7 @@ export function ConversationView() {
const bulkFavorite = () => {
if (selectedSeqs.size === 0) return;
favoriteMessages([...selectedSeqs].map((seq) => ({ topic: topicId, seq }))).catch(() => {});
favoriteMessages([...selectedSeqs].map((seq) => ({ topic: topicId, seq }))).catch((err) => showError(err, '收藏失败'));
exitSelectMode();
};
@@ -194,7 +228,7 @@ export function ConversationView() {
const target = tinode.getTopic(targetTopic);
for (const seq of selectedSeqs) {
const msg = topic.findMessage(seq);
if (msg) forwardMessage(topicId, msg, target);
if (msg) forwardMessage(topicId, msg, target).catch((err) => showError(err, '转发失败'));
}
setBulkForwardOpen(false);
exitSelectMode();
@@ -211,7 +245,7 @@ export function ConversationView() {
case 'recall':
recallMessage(topicId, msg.seq)
.then(() => topic.delMessagesList([msg.seq], true))
.catch(() => {});
.catch((err) => showError(err, '撤回失败'));
break;
case 'delete':
topic.delMessagesList([msg.seq], false);
@@ -224,7 +258,7 @@ export function ConversationView() {
setReplyTo(msg);
break;
case 'favorite':
favoriteMessages([{ topic: topicId, seq: msg.seq }]).catch(() => {});
favoriteMessages([{ topic: topicId, seq: msg.seq }]).catch((err) => showError(err, '收藏失败'));
break;
case 'forward':
setForwardMsg(msg);
@@ -235,13 +269,13 @@ export function ConversationView() {
const handlePickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) sendImage(topic, file).catch(() => {});
if (file) sendImage(topic, file).catch((err) => showError(err, '图片发送失败'));
};
const handlePickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) sendFile(topic, file).catch(() => {});
if (file) sendFile(topic, file).catch((err) => showError(err, '文件发送失败'));
};
return (
@@ -279,7 +313,11 @@ export function ConversationView() {
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
<Icon name="videocam" />
</button>
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
<button
type="button"
onClick={() => onOpenHistory('text')}
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1"
>
<Icon name="search" />
</button>
<button
@@ -304,7 +342,6 @@ 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)}
menuActive={typeof msg.seq === 'number' && menu?.msg.seq === msg.seq}
@@ -392,7 +429,7 @@ export function ConversationView() {
onCancel={() => setRecording(false)}
onSend={(blob, durationMs) => {
setRecording(false);
sendVoice(topic, blob, durationMs).catch(() => {});
sendVoice(topic, blob, durationMs).catch((err) => showError(err, '语音发送失败'));
}}
/>
) : (
@@ -424,6 +461,7 @@ export function ConversationView() {
x={menu.x}
y={menu.y}
actions={availableActions(menu.msg, myUserId, bootstrap.data?.message_policy)}
recallRemainingSeconds={recallRemainingSeconds(menu.msg, myUserId, bootstrap.data?.message_policy)}
onAction={handleAction}
onClose={() => setMenu(null)}
/>
@@ -433,13 +471,15 @@ export function ConversationView() {
<ForwardPickerModal
onClose={() => setForwardMsg(null)}
onPick={(targetTopic) => {
forwardMessage(topicId, forwardMsg, tinode.getTopic(targetTopic));
forwardMessage(topicId, forwardMsg, tinode.getTopic(targetTopic)).catch((err) => showError(err, '转发失败'));
setForwardMsg(null);
}}
/>
) : null}
{bulkForwardOpen ? <ForwardPickerModal onClose={() => setBulkForwardOpen(false)} onPick={bulkForward} /> : null}
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
</div>
);
}
+26 -11
View File
@@ -1,10 +1,11 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { HistorySearchModal } from '@/features/messages/HistorySearchModal';
import { Toast } from '@/app/Toast';
import { fetchAllPages } from '@/lib/api/client';
import {
blockUser,
deleteFriend,
@@ -24,6 +25,7 @@ interface DetailColumnProps {
topicId: string;
peerName: string;
onClose?: () => void;
onOpenHistory: (tab: 'text' | MediaType) => void;
}
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
@@ -43,16 +45,25 @@ function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (
);
}
export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps) {
export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: DetailColumnProps) {
const navigate = useNavigate();
const tinode = useTinode();
const queryClient = useQueryClient();
const [remarkEditing, setRemarkEditing] = useState(false);
const [remarkDraft, setRemarkDraft] = useState('');
const [historyTab, setHistoryTab] = useState<'text' | MediaType | null>(null);
const [errorToast, setErrorToast] = useState('');
const showError = (err: unknown, fallback: string) => setErrorToast(err instanceof Error ? err.message : fallback);
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => getFriends() });
const friend = friendsQuery.data?.items.find((f) => f.user_id === topicId);
// DetailColumn is reused across every contact when docked (no `key={topicId}`, same instance),
// so without this an in-progress remark edit for the PREVIOUS contact stays open and could be
// saved against the NEW one.
useEffect(() => {
setRemarkEditing(false);
setRemarkDraft('');
}, [topicId]);
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
const friend = friendsQuery.data?.find((f) => f.user_id === topicId);
const profileQuery = useQuery({ queryKey: ['user-profile', topicId], queryFn: () => getUserProfile(topicId) });
const idLine = [profileQuery.data?.public_account && `ID:${profileQuery.data.public_account}`, profileQuery.data?.region]
@@ -68,6 +79,7 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
mutationFn: (patch: { pinned?: boolean; mute_notifications?: boolean }) =>
updateConversationSettings(topicId, { ...patch, version: settingsQuery.data?.version ?? 0 }),
onSuccess: (data) => queryClient.setQueryData(['conversation-settings', topicId], data),
onError: (err) => showError(err, '设置失败,请重试'),
});
const remarkMutation = useMutation({
@@ -76,15 +88,17 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
queryClient.invalidateQueries({ queryKey: ['friends'] });
setRemarkEditing(false);
},
onError: (err) => showError(err, '备注保存失败'),
});
const blockMutation = useMutation({
mutationFn: (blocked: boolean) => (blocked ? blockUser(topicId) : unblockUser(topicId)),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
onError: (err) => showError(err, '设置失败,请重试'),
});
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => getBlocks() });
const isBlocked = !!blocksQuery.data?.items.some((b) => b.user_id === topicId);
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
const isBlocked = !!blocksQuery.data?.some((b) => b.user_id === topicId);
const deleteFriendMutation = useMutation({
mutationFn: () => deleteFriend(topicId),
@@ -92,6 +106,7 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
queryClient.invalidateQueries({ queryKey: ['friends'] });
navigate('/messages');
},
onError: (err) => showError(err, '删除好友失败'),
});
const clearHistory = () => {
@@ -149,7 +164,7 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
</div>
<div className="border-b border-list-item px-5 pb-3 pt-2 text-sm">
<button type="button" onClick={() => setHistoryTab('text')} className="flex w-full items-center justify-between py-2">
<button type="button" onClick={() => onOpenHistory('text')} className="flex w-full items-center justify-between py-2">
<span className="text-text-body">查找聊天记录</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
@@ -162,7 +177,7 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
{ type: 'link' as const, label: '链接', icon: 'link' },
] satisfies { type: MediaType; label: string; icon: string }[]
).map((item) => (
<button key={item.type} type="button" onClick={() => setHistoryTab(item.type)} className="flex flex-col items-center gap-1 py-1">
<button key={item.type} type="button" onClick={() => onOpenHistory(item.type)} className="flex flex-col items-center gap-1 py-1">
<Icon name={item.icon} className="text-[22px] text-text-tertiary-4" />
<span className="text-xs text-text-secondary">{item.label}</span>
</button>
@@ -242,7 +257,7 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
删除好友
</button>
{historyTab ? <HistorySearchModal topicId={topicId} initialTab={historyTab} onClose={() => setHistoryTab(null)} /> : null}
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
</aside>
);
}
+1 -1
View File
@@ -31,7 +31,7 @@ interface EmojiPanelProps {
}
export function EmojiPanel({ onPick }: EmojiPanelProps) {
const [tab, setTab] = useState<EmojiTab>('faces');
const [tab, setTab] = useState<EmojiTab>('recent');
const recent = (() => {
try {
return JSON.parse(localStorage.getItem('im-recent-emoji') || '[]') as string[];
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { Icon } from '@/app/Icon';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { useChatList } from '@/lib/tinode/hooks';
interface ForwardPickerModalProps {
@@ -17,6 +18,7 @@ interface ForwardPickerModalProps {
export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) {
const contacts = useChatList();
const [query, setQuery] = useState('');
useEscapeKey(onClose);
const filtered = contacts.filter((c) => !query.trim() || (c.public?.fn || '').includes(query.trim()));
return (
@@ -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>
)}
+3 -3
View File
@@ -14,7 +14,6 @@ interface MessageRowProps {
mine: boolean;
isGroup: boolean;
peerName: string;
myUserId: string | null;
selectMode?: boolean;
selected?: boolean;
/** README §3.5: "选中气泡加 outline:2px solid #1677ff" -- true while this message is the open
@@ -30,7 +29,6 @@ export function MessageRow({
mine,
isGroup,
peerName,
myUserId,
selectMode,
selected,
menuActive,
@@ -81,7 +79,9 @@ export function MessageRow({
className={`flex flex-1 items-end gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}
>
<span
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)}`}
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${
mine ? 'bg-accent' : avatarBgClass(msg.from || peerName)
}`}
>
{avatarLetter(mine ? '我' : peerName)}
</span>
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Icon } from '@/app/Icon';
import type { MessageAction } from '@/lib/messages/actions';
@@ -24,28 +24,55 @@ interface MessageContextMenuProps {
x: number;
y: number;
actions: MessageAction[];
/** README §3.5: "撤回...右侧灰字剩余时限「1:42 内」" -- only meaningful when `actions` includes 'recall'. */
recallRemainingSeconds?: number | null;
onAction: (action: MessageAction) => void;
onClose: () => void;
}
function formatRecallCountdown(seconds: number): string {
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')} 内`;
}
/** README §3.5: 180px popup, divider before the destructive-leaning tail (recall/delete). */
export function MessageContextMenu({ x, y, actions, onAction, onClose }: MessageContextMenuProps) {
export function MessageContextMenu({ x, y, actions, recallRemainingSeconds, onAction, onClose }: MessageContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ left: x, top: y });
useEffect(() => {
const handler = () => onClose();
const handler = (e: KeyboardEvent | MouseEvent | Event) => {
if (e instanceof KeyboardEvent && e.key !== 'Escape') return;
onClose();
};
window.addEventListener('click', handler);
window.addEventListener('scroll', handler, true);
window.addEventListener('keydown', handler);
return () => {
window.removeEventListener('click', handler);
window.removeEventListener('scroll', handler, true);
window.removeEventListener('keydown', handler);
};
}, [onClose]);
// Right-clicking near the window's right/bottom edge would otherwise render this partially
// off-screen -- clamp against the menu's actual measured size once it's in the DOM.
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const left = Math.min(x, window.innerWidth - rect.width - 8);
const top = Math.min(y, window.innerHeight - rect.height - 8);
setPos({ left: Math.max(8, left), top: Math.max(8, top) });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [x, y]);
const leading = actions.filter((a) => a !== 'recall' && a !== 'delete');
const trailing = actions.filter((a) => a === 'recall' || a === 'delete');
return (
<div
style={{ left: x, top: y }}
ref={ref}
style={pos}
className="fixed z-50 w-[180px] rounded-[10px] border border-border-panel bg-white p-1.5 shadow-menu"
onClick={(e) => e.stopPropagation()}
>
@@ -77,6 +104,9 @@ export function MessageContextMenu({ x, y, actions, onAction, onClose }: Message
>
<Icon name={item.icon} className="text-[18px]" />
{item.label}
{action === 'recall' && typeof recallRemainingSeconds === 'number' ? (
<span className="ml-auto text-xs text-text-tertiary-3">{formatRecallCountdown(recallRemainingSeconds)}</span>
) : null}
</button>
);
})}
+14 -5
View File
@@ -1,10 +1,12 @@
import { useState } from 'react';
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';
@@ -22,6 +24,7 @@ import { useChatList } from '@/lib/tinode/hooks';
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();
@@ -31,6 +34,11 @@ export function MessagesLayout() {
// 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">
@@ -41,27 +49,28 @@ export function MessagesLayout() {
<ChatListColumn />
</div>
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
<Outlet context={{ onToggleDetail: () => setDetailOpen((v) => !v), detailVisible }} />
<Outlet context={{ onToggleDetail: () => setDetailOpen((v) => !v), detailVisible, onOpenHistory: setHistoryTab }} />
</div>
</div>
{topicId ? (
<div className="hidden h-full lg:block">
<DetailColumn topicId={topicId} peerName={peerName} />
<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)} />
<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)} />
<DetailColumn topicId={topicId} peerName={peerName} onClose={() => setDetailOpen(false)} onOpenHistory={setHistoryTab} />
</div>
) : null}
{historyTab && topicId ? <HistorySearchModal topicId={topicId} initialTab={historyTab} onClose={() => setHistoryTab(null)} /> : null}
</div>
);
}
+1 -1
View File
@@ -106,7 +106,7 @@
--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.18);
--shadow-menu: 0 12px 36px rgba(0, 0, 0, 0.16);
--shadow-bubble: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-btn: 0 4px 12px rgba(54, 139, 250, 0.2);
--shadow-btn: 0 6px 16px rgba(54, 139, 250, 0.2);
--shadow-focus-ring: 0 0 0 3px rgba(22, 119, 255, 0.1);
}