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
@@ -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);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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('提交成功,我们会尽快联系你');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user