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() {
|
export function AppShell() {
|
||||||
const contacts = useChatList();
|
const contacts = useChatList();
|
||||||
const bootstrap = useBootstrap();
|
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 =
|
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?.system_unread_count || 0) +
|
||||||
(bootstrap.data?.entry_badges?.file_assistant_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). */
|
/** 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 }) {
|
export function NavRail({ selfInitial = '我', unreadMessages = 0 }: { selfInitial?: string; unreadMessages?: number }) {
|
||||||
return (
|
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">
|
<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-5 grid h-[38px] w-[38px] place-items-center rounded-[10px] bg-accent text-base font-medium text-white">
|
<div className="mb-[22px] grid h-[38px] w-[38px] place-items-center rounded-[10px] bg-accent text-base font-medium text-white">
|
||||||
{selfInitial}
|
{selfInitial}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col items-center gap-1.5">
|
<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 { useState } from 'react';
|
||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import { Icon } from '@/app/Icon';
|
import { Icon } from '@/app/Icon';
|
||||||
import { Toast } from '@/features/auth/components/Toast';
|
import { Toast } from '@/app/Toast';
|
||||||
import { ImApiError } from '@/lib/api/client';
|
import { ImApiError } from '@/lib/api/client';
|
||||||
import { submitTenantApplication, uploadPublicFile } from '@/lib/api/tenant';
|
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. */
|
/** README §10.5 / old app's EnterpriseRegister -- a company registering itself as a new tenant. */
|
||||||
export function EnterpriseRegisterPage() {
|
export function EnterpriseRegisterPage() {
|
||||||
|
const tenantCodeHint = (useLocation().state as { tenantCodeHint?: string } | null)?.tenantCodeHint || undefined;
|
||||||
const [creditCode, setCreditCode] = useState('');
|
const [creditCode, setCreditCode] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
@@ -32,7 +33,12 @@ export function EnterpriseRegisterPage() {
|
|||||||
if (file) {
|
if (file) {
|
||||||
licenseFileId = (await uploadPublicFile(file, 'enterprise_license')).file_id;
|
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)));
|
setReviewDays(Math.max(1, Math.round((data.estimated_review_hours || 24) / 24)));
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
setToast('提交成功,我们会尽快联系你');
|
setToast('提交成功,我们会尽快联系你');
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ export function LoginPage() {
|
|||||||
<div className="flex h-dvh w-full">
|
<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="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="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="absolute left-16 top-14 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="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>
|
<strong className="text-xl font-semibold">{BASE_APP_NAME}</strong>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="m-0 max-w-md text-[40px] leading-tight font-semibold text-balance">
|
<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 className="mt-4 max-w-md text-base text-white/80">
|
||||||
单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在桌面端和手机端保持一致。
|
单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在桌面端和手机端保持一致。
|
||||||
</p>
|
</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>下载桌面客户端</span>
|
||||||
<span>iOS / Android</span>
|
<span>iOS / Android</span>
|
||||||
<span>帮助中心</span>
|
<span>帮助中心</span>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { useState } from 'react';
|
|||||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { Icon } from '@/app/Icon';
|
import { Icon } from '@/app/Icon';
|
||||||
|
import { Toast } from '@/app/Toast';
|
||||||
import { PasswordInput } from '@/features/auth/components/PasswordInput';
|
import { PasswordInput } from '@/features/auth/components/PasswordInput';
|
||||||
import { Toast } from '@/features/auth/components/Toast';
|
|
||||||
import { VerificationCodeButton } from '@/features/auth/components/VerificationCodeButton';
|
import { VerificationCodeButton } from '@/features/auth/components/VerificationCodeButton';
|
||||||
import { confirmPasswordReset, verifyPasswordResetCode } from '@/lib/api/auth';
|
import { confirmPasswordReset, verifyPasswordResetCode } from '@/lib/api/auth';
|
||||||
import { ImApiError } from '@/lib/api/client';
|
import { ImApiError } from '@/lib/api/client';
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function TenantEntryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-dvh w-full items-center justify-center bg-surface-page px-6">
|
<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>
|
</Link>
|
||||||
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-5">
|
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-5">
|
||||||
@@ -71,7 +71,7 @@ export function TenantEntryPage() {
|
|||||||
{submitting ? '进入中…' : '进入企业'}
|
{submitting ? '进入中…' : '进入企业'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import { Icon } from '@/app/Icon';
|
import { Icon } from '@/app/Icon';
|
||||||
import { Toast } from '@/features/auth/components/Toast';
|
import { Toast } from '@/app/Toast';
|
||||||
import { ImApiError } from '@/lib/api/client';
|
import { ImApiError } from '@/lib/api/client';
|
||||||
import { submitTenantEntryFeedback, uploadPublicFile } from '@/lib/api/tenant';
|
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. */
|
/** README §10.6 / `01/1j`'s 问题反馈, reached from the tenant-code screen's "遇到问题?" link. */
|
||||||
export function TenantFeedbackPage() {
|
export function TenantFeedbackPage() {
|
||||||
|
const tenantCodeHint = (useLocation().state as { tenantCodeHint?: string } | null)?.tenantCodeHint || undefined;
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [contact, setContact] = useState('');
|
const [contact, setContact] = useState('');
|
||||||
const [images, setImages] = useState<PendingImage[]>([]);
|
const [images, setImages] = useState<PendingImage[]>([]);
|
||||||
@@ -45,7 +46,12 @@ export function TenantFeedbackPage() {
|
|||||||
for (const item of images) {
|
for (const item of images) {
|
||||||
screenshotFileIds.push((await uploadPublicFile(item.file, 'tenant_entry_feedback')).file_id);
|
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('提交成功,我们会尽快联系你');
|
setToast('提交成功,我们会尽快联系你');
|
||||||
images.forEach((item) => URL.revokeObjectURL(item.url));
|
images.forEach((item) => URL.revokeObjectURL(item.url));
|
||||||
setDescription('');
|
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.
|
// reaches 0 -- a real resource leak, not just a React warning, since nothing else ever clears it.
|
||||||
useEffect(() => () => window.clearInterval(timerRef.current), []);
|
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) => {
|
const tick = (initial: number) => {
|
||||||
window.clearInterval(timerRef.current);
|
window.clearInterval(timerRef.current);
|
||||||
setSeconds(initial);
|
setSeconds(initial);
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function FileAttachment({ data }: { data: FileData }) {
|
|||||||
{typeof data.size === 'number' ? formatFileSize(data.size) : ''} · {remote.loading ? '加载中…' : '点击下载'}
|
{typeof data.size === 'number' ? formatFileSize(data.size) : ''} · {remote.loading ? '加载中…' : '点击下载'}
|
||||||
</span>
|
</span>
|
||||||
</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>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const PINNED_ENTRIES = [
|
|||||||
label: '系统消息',
|
label: '系统消息',
|
||||||
icon: 'notifications',
|
icon: 'notifications',
|
||||||
route: '/chat/system',
|
route: '/chat/system',
|
||||||
colorClass: 'bg-warn',
|
colorClass: 'bg-warn-bg text-warn',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'file_assistant' as const,
|
key: 'file_assistant' as const,
|
||||||
@@ -33,7 +33,7 @@ const PINNED_ENTRIES = [
|
|||||||
label: '文件助手',
|
label: '文件助手',
|
||||||
icon: 'folder',
|
icon: 'folder',
|
||||||
route: '/files',
|
route: '/files',
|
||||||
colorClass: 'bg-success',
|
colorClass: 'bg-success-bg text-success',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ export function ChatListColumn() {
|
|||||||
to={entry.route}
|
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]"
|
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]" />
|
<Icon name={entry.icon} className="text-[22px]" />
|
||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 flex-col gap-1">
|
<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 { Drafty } from 'tinode-sdk';
|
||||||
|
|
||||||
import { Icon } from '@/app/Icon';
|
import { Icon } from '@/app/Icon';
|
||||||
|
import { Toast } from '@/app/Toast';
|
||||||
import { EmojiPanel } from '@/features/messages/EmojiPanel';
|
import { EmojiPanel } from '@/features/messages/EmojiPanel';
|
||||||
import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal';
|
import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal';
|
||||||
import { MessageContextMenu } from '@/features/messages/MessageContextMenu';
|
import { MessageContextMenu } from '@/features/messages/MessageContextMenu';
|
||||||
import { MessageRow } from '@/features/messages/MessageBubble';
|
import { MessageRow } from '@/features/messages/MessageBubble';
|
||||||
import { VoiceRecorder } from '@/features/messages/VoiceRecorder';
|
import { VoiceRecorder } from '@/features/messages/VoiceRecorder';
|
||||||
import { useBootstrap } from '@/lib/api/hooks';
|
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 { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||||
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
|
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 { renderDrafty } from '@/lib/messages/drafty';
|
||||||
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
|
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
|
||||||
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
|
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
|
* 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. */
|
* being permanently open there) rather than this actual state. */
|
||||||
detailVisible: boolean;
|
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 {
|
interface ContextMenuState {
|
||||||
@@ -40,7 +44,7 @@ interface ContextMenuState {
|
|||||||
|
|
||||||
export function ConversationView() {
|
export function ConversationView() {
|
||||||
const { topicId } = useParams();
|
const { topicId } = useParams();
|
||||||
const { onToggleDetail, detailVisible } = useOutletContext<OutletCtx>();
|
const { onToggleDetail, detailVisible, onOpenHistory } = useOutletContext<OutletCtx>();
|
||||||
const myUserId = useSessionStore((s) => s.myUserId);
|
const myUserId = useSessionStore((s) => s.myUserId);
|
||||||
const tinode = useTinode();
|
const tinode = useTinode();
|
||||||
const contacts = useChatList();
|
const contacts = useChatList();
|
||||||
@@ -65,13 +69,26 @@ export function ConversationView() {
|
|||||||
const [replyTo, setReplyTo] = useState<StoredMessage | null>(null);
|
const [replyTo, setReplyTo] = useState<StoredMessage | null>(null);
|
||||||
const [forwardMsg, setForwardMsg] = useState<StoredMessage | null>(null);
|
const [forwardMsg, setForwardMsg] = useState<StoredMessage | null>(null);
|
||||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||||
|
const [errorToast, setErrorToast] = useState('');
|
||||||
const streamRef = useRef<HTMLDivElement>(null);
|
const streamRef = useRef<HTMLDivElement>(null);
|
||||||
const autoScrollRef = useRef(true);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!topicId) return undefined;
|
if (!topicId) return undefined;
|
||||||
openConversation(topicId).catch(() => {});
|
openConversation(topicId).catch(() => {});
|
||||||
return () => closeConversation(topicId);
|
return () => {
|
||||||
|
closeConversation(topicId).catch(() => {});
|
||||||
|
};
|
||||||
}, [topicId]);
|
}, [topicId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -87,13 +104,22 @@ export function ConversationView() {
|
|||||||
// onSend callback (re-created every render off the CURRENT topicId) would publish the eventual
|
// 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.
|
// recording into B instead of the conversation the user was actually recording in.
|
||||||
setRecording(false);
|
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]);
|
}, [topicId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoScrollRef.current) {
|
if (autoScrollRef.current) {
|
||||||
streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight });
|
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(() => {
|
useEffect(() => {
|
||||||
if (!topicId || messages.length === 0) return;
|
if (!topicId || messages.length === 0) return;
|
||||||
@@ -113,6 +139,7 @@ export function ConversationView() {
|
|||||||
*/
|
*/
|
||||||
const loadOlder = async () => {
|
const loadOlder = async () => {
|
||||||
if (loadingOlder) return;
|
if (loadingOlder) return;
|
||||||
|
const calledForTopicId = topicId;
|
||||||
const el = streamRef.current;
|
const el = streamRef.current;
|
||||||
const min = topic.minMsgSeq();
|
const min = topic.minMsgSeq();
|
||||||
const max = topic.maxMsgSeq();
|
const max = topic.maxMsgSeq();
|
||||||
@@ -124,10 +151,17 @@ export function ConversationView() {
|
|||||||
try {
|
try {
|
||||||
await topic.getMessagesPage(MESSAGES_PAGE, gaps, min, max);
|
await topic.getMessagesPage(MESSAGES_PAGE, gaps, min, max);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingOlder(false);
|
// If the user switched to a different conversation while this was in flight, `el` is now
|
||||||
requestAnimationFrame(() => {
|
// showing that OTHER topic's messages -- prevTop/prevHeight were measured against content
|
||||||
if (el) el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
|
// 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 = () => {
|
const bulkFavorite = () => {
|
||||||
if (selectedSeqs.size === 0) return;
|
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();
|
exitSelectMode();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -194,7 +228,7 @@ export function ConversationView() {
|
|||||||
const target = tinode.getTopic(targetTopic);
|
const target = tinode.getTopic(targetTopic);
|
||||||
for (const seq of selectedSeqs) {
|
for (const seq of selectedSeqs) {
|
||||||
const msg = topic.findMessage(seq);
|
const msg = topic.findMessage(seq);
|
||||||
if (msg) forwardMessage(topicId, msg, target);
|
if (msg) forwardMessage(topicId, msg, target).catch((err) => showError(err, '转发失败'));
|
||||||
}
|
}
|
||||||
setBulkForwardOpen(false);
|
setBulkForwardOpen(false);
|
||||||
exitSelectMode();
|
exitSelectMode();
|
||||||
@@ -211,7 +245,7 @@ export function ConversationView() {
|
|||||||
case 'recall':
|
case 'recall':
|
||||||
recallMessage(topicId, msg.seq)
|
recallMessage(topicId, msg.seq)
|
||||||
.then(() => topic.delMessagesList([msg.seq], true))
|
.then(() => topic.delMessagesList([msg.seq], true))
|
||||||
.catch(() => {});
|
.catch((err) => showError(err, '撤回失败'));
|
||||||
break;
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
topic.delMessagesList([msg.seq], false);
|
topic.delMessagesList([msg.seq], false);
|
||||||
@@ -224,7 +258,7 @@ export function ConversationView() {
|
|||||||
setReplyTo(msg);
|
setReplyTo(msg);
|
||||||
break;
|
break;
|
||||||
case 'favorite':
|
case 'favorite':
|
||||||
favoriteMessages([{ topic: topicId, seq: msg.seq }]).catch(() => {});
|
favoriteMessages([{ topic: topicId, seq: msg.seq }]).catch((err) => showError(err, '收藏失败'));
|
||||||
break;
|
break;
|
||||||
case 'forward':
|
case 'forward':
|
||||||
setForwardMsg(msg);
|
setForwardMsg(msg);
|
||||||
@@ -235,13 +269,13 @@ export function ConversationView() {
|
|||||||
const handlePickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handlePickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
e.target.value = '';
|
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 handlePickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
if (file) sendFile(topic, file).catch(() => {});
|
if (file) sendFile(topic, file).catch((err) => showError(err, '文件发送失败'));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
|
||||||
<Icon name="videocam" />
|
<Icon name="videocam" />
|
||||||
</button>
|
</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" />
|
<Icon name="search" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -304,7 +342,6 @@ export function ConversationView() {
|
|||||||
mine={!!myUserId && msg.from === myUserId}
|
mine={!!myUserId && msg.from === myUserId}
|
||||||
isGroup={isGroup}
|
isGroup={isGroup}
|
||||||
peerName={peerName}
|
peerName={peerName}
|
||||||
myUserId={myUserId}
|
|
||||||
selectMode={selectMode}
|
selectMode={selectMode}
|
||||||
selected={typeof msg.seq === 'number' && selectedSeqs.has(msg.seq)}
|
selected={typeof msg.seq === 'number' && selectedSeqs.has(msg.seq)}
|
||||||
menuActive={typeof msg.seq === 'number' && menu?.msg.seq === msg.seq}
|
menuActive={typeof msg.seq === 'number' && menu?.msg.seq === msg.seq}
|
||||||
@@ -392,7 +429,7 @@ export function ConversationView() {
|
|||||||
onCancel={() => setRecording(false)}
|
onCancel={() => setRecording(false)}
|
||||||
onSend={(blob, durationMs) => {
|
onSend={(blob, durationMs) => {
|
||||||
setRecording(false);
|
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}
|
x={menu.x}
|
||||||
y={menu.y}
|
y={menu.y}
|
||||||
actions={availableActions(menu.msg, myUserId, bootstrap.data?.message_policy)}
|
actions={availableActions(menu.msg, myUserId, bootstrap.data?.message_policy)}
|
||||||
|
recallRemainingSeconds={recallRemainingSeconds(menu.msg, myUserId, bootstrap.data?.message_policy)}
|
||||||
onAction={handleAction}
|
onAction={handleAction}
|
||||||
onClose={() => setMenu(null)}
|
onClose={() => setMenu(null)}
|
||||||
/>
|
/>
|
||||||
@@ -433,13 +471,15 @@ export function ConversationView() {
|
|||||||
<ForwardPickerModal
|
<ForwardPickerModal
|
||||||
onClose={() => setForwardMsg(null)}
|
onClose={() => setForwardMsg(null)}
|
||||||
onPick={(targetTopic) => {
|
onPick={(targetTopic) => {
|
||||||
forwardMessage(topicId, forwardMsg, tinode.getTopic(targetTopic));
|
forwardMessage(topicId, forwardMsg, tinode.getTopic(targetTopic)).catch((err) => showError(err, '转发失败'));
|
||||||
setForwardMsg(null);
|
setForwardMsg(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{bulkForwardOpen ? <ForwardPickerModal onClose={() => setBulkForwardOpen(false)} onPick={bulkForward} /> : null}
|
{bulkForwardOpen ? <ForwardPickerModal onClose={() => setBulkForwardOpen(false)} onPick={bulkForward} /> : null}
|
||||||
|
|
||||||
|
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { Icon } from '@/app/Icon';
|
import { Icon } from '@/app/Icon';
|
||||||
import { HistorySearchModal } from '@/features/messages/HistorySearchModal';
|
import { Toast } from '@/app/Toast';
|
||||||
|
import { fetchAllPages } from '@/lib/api/client';
|
||||||
import {
|
import {
|
||||||
blockUser,
|
blockUser,
|
||||||
deleteFriend,
|
deleteFriend,
|
||||||
@@ -24,6 +25,7 @@ interface DetailColumnProps {
|
|||||||
topicId: string;
|
topicId: string;
|
||||||
peerName: string;
|
peerName: string;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
onOpenHistory: (tab: 'text' | MediaType) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
|
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 navigate = useNavigate();
|
||||||
const tinode = useTinode();
|
const tinode = useTinode();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [remarkEditing, setRemarkEditing] = useState(false);
|
const [remarkEditing, setRemarkEditing] = useState(false);
|
||||||
const [remarkDraft, setRemarkDraft] = useState('');
|
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() });
|
// DetailColumn is reused across every contact when docked (no `key={topicId}`, same instance),
|
||||||
const friend = friendsQuery.data?.items.find((f) => f.user_id === topicId);
|
// 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 profileQuery = useQuery({ queryKey: ['user-profile', topicId], queryFn: () => getUserProfile(topicId) });
|
||||||
const idLine = [profileQuery.data?.public_account && `ID:${profileQuery.data.public_account}`, profileQuery.data?.region]
|
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 }) =>
|
mutationFn: (patch: { pinned?: boolean; mute_notifications?: boolean }) =>
|
||||||
updateConversationSettings(topicId, { ...patch, version: settingsQuery.data?.version ?? 0 }),
|
updateConversationSettings(topicId, { ...patch, version: settingsQuery.data?.version ?? 0 }),
|
||||||
onSuccess: (data) => queryClient.setQueryData(['conversation-settings', topicId], data),
|
onSuccess: (data) => queryClient.setQueryData(['conversation-settings', topicId], data),
|
||||||
|
onError: (err) => showError(err, '设置失败,请重试'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const remarkMutation = useMutation({
|
const remarkMutation = useMutation({
|
||||||
@@ -76,15 +88,17 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
|
|||||||
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
||||||
setRemarkEditing(false);
|
setRemarkEditing(false);
|
||||||
},
|
},
|
||||||
|
onError: (err) => showError(err, '备注保存失败'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const blockMutation = useMutation({
|
const blockMutation = useMutation({
|
||||||
mutationFn: (blocked: boolean) => (blocked ? blockUser(topicId) : unblockUser(topicId)),
|
mutationFn: (blocked: boolean) => (blocked ? blockUser(topicId) : unblockUser(topicId)),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
|
||||||
|
onError: (err) => showError(err, '设置失败,请重试'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => getBlocks() });
|
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
|
||||||
const isBlocked = !!blocksQuery.data?.items.some((b) => b.user_id === topicId);
|
const isBlocked = !!blocksQuery.data?.some((b) => b.user_id === topicId);
|
||||||
|
|
||||||
const deleteFriendMutation = useMutation({
|
const deleteFriendMutation = useMutation({
|
||||||
mutationFn: () => deleteFriend(topicId),
|
mutationFn: () => deleteFriend(topicId),
|
||||||
@@ -92,6 +106,7 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
|
|||||||
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
||||||
navigate('/messages');
|
navigate('/messages');
|
||||||
},
|
},
|
||||||
|
onError: (err) => showError(err, '删除好友失败'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const clearHistory = () => {
|
const clearHistory = () => {
|
||||||
@@ -149,7 +164,7 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-b border-list-item px-5 pb-3 pt-2 text-sm">
|
<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>
|
<span className="text-text-body">查找聊天记录</span>
|
||||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||||
</button>
|
</button>
|
||||||
@@ -162,7 +177,7 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
|
|||||||
{ type: 'link' as const, label: '链接', icon: 'link' },
|
{ type: 'link' as const, label: '链接', icon: 'link' },
|
||||||
] satisfies { type: MediaType; label: string; icon: string }[]
|
] satisfies { type: MediaType; label: string; icon: string }[]
|
||||||
).map((item) => (
|
).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" />
|
<Icon name={item.icon} className="text-[22px] text-text-tertiary-4" />
|
||||||
<span className="text-xs text-text-secondary">{item.label}</span>
|
<span className="text-xs text-text-secondary">{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -242,7 +257,7 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
|
|||||||
删除好友
|
删除好友
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{historyTab ? <HistorySearchModal topicId={topicId} initialTab={historyTab} onClose={() => setHistoryTab(null)} /> : null}
|
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ interface EmojiPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EmojiPanel({ onPick }: EmojiPanelProps) {
|
export function EmojiPanel({ onPick }: EmojiPanelProps) {
|
||||||
const [tab, setTab] = useState<EmojiTab>('faces');
|
const [tab, setTab] = useState<EmojiTab>('recent');
|
||||||
const recent = (() => {
|
const recent = (() => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(localStorage.getItem('im-recent-emoji') || '[]') as string[];
|
return JSON.parse(localStorage.getItem('im-recent-emoji') || '[]') as string[];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { Icon } from '@/app/Icon';
|
import { Icon } from '@/app/Icon';
|
||||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||||
|
import { useEscapeKey } from '@/lib/hooks';
|
||||||
import { useChatList } from '@/lib/tinode/hooks';
|
import { useChatList } from '@/lib/tinode/hooks';
|
||||||
|
|
||||||
interface ForwardPickerModalProps {
|
interface ForwardPickerModalProps {
|
||||||
@@ -17,6 +18,7 @@ interface ForwardPickerModalProps {
|
|||||||
export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) {
|
export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) {
|
||||||
const contacts = useChatList();
|
const contacts = useChatList();
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
useEscapeKey(onClose);
|
||||||
const filtered = contacts.filter((c) => !query.trim() || (c.public?.fn || '').includes(query.trim()));
|
const filtered = contacts.filter((c) => !query.trim() || (c.public?.fn || '').includes(query.trim()));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { Icon } from '@/app/Icon';
|
import { Icon } from '@/app/Icon';
|
||||||
import { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem, type MediaType, type SearchHit } from '@/lib/api/messages';
|
import { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem, type MediaType, type SearchHit } from '@/lib/api/messages';
|
||||||
import { checkUrlSafety } from '@/lib/api/security';
|
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 { renderDrafty } from '@/lib/messages/drafty';
|
||||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
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 {
|
interface HistorySearchModalProps {
|
||||||
topicId: string;
|
topicId: string;
|
||||||
initialTab?: Tab;
|
initialTab?: Tab;
|
||||||
@@ -130,24 +138,34 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
|
|||||||
const [tab, setTab] = useState<Tab>(initialTab);
|
const [tab, setTab] = useState<Tab>(initialTab);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const debouncedQuery = useDebouncedValue(query, 300);
|
const debouncedQuery = useDebouncedValue(query, 300);
|
||||||
|
useEscapeKey(onClose);
|
||||||
|
|
||||||
const searchQuery = useQuery({
|
const searchQuery = useInfiniteQuery({
|
||||||
queryKey: ['topic-search', topicId, debouncedQuery],
|
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,
|
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],
|
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',
|
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],
|
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',
|
enabled: tab === 'file' || tab === 'link',
|
||||||
});
|
});
|
||||||
|
const fileLinkItems = fileLinkQuery.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
<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>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto px-5 pb-4">
|
<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>
|
<div className="py-10 text-center text-sm text-text-tertiary-4">输入关键词搜索这个会话的聊天记录</div>
|
||||||
) : searchQuery.isLoading ? (
|
) : searchQuery.isLoading ? (
|
||||||
<div className="py-10 text-center text-sm text-text-tertiary-4">搜索中…</div>
|
<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>
|
<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) => (
|
<ul className="flex flex-col gap-1">
|
||||||
<li key={item.seq} className="rounded-lg px-3 py-2.5 hover:bg-surface-rail">
|
{searchItems.map((item) => (
|
||||||
<div className="flex items-center justify-between text-xs text-text-tertiary-3">
|
<li key={item.seq} className="rounded-lg px-3 py-2.5 hover:bg-surface-rail">
|
||||||
<span>{formatEventTimestamp(new Date(item.ts))}</span>
|
<div className="flex items-center justify-between text-xs text-text-tertiary-3">
|
||||||
</div>
|
<span>{formatEventTimestamp(new Date(item.ts))}</span>
|
||||||
<div className="mt-0.5 truncate">
|
</div>
|
||||||
<SnapshotContent item={item} />
|
<div className="mt-0.5 truncate">
|
||||||
</div>
|
<SnapshotContent item={item} />
|
||||||
</li>
|
</div>
|
||||||
))}
|
</li>
|
||||||
</ul>
|
))}
|
||||||
|
</ul>
|
||||||
|
{searchQuery.hasNextPage ? (
|
||||||
|
<LoadMoreButton onClick={() => searchQuery.fetchNextPage()} loading={searchQuery.isFetchingNextPage} />
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,28 +239,38 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
|
|||||||
<div className="flex-1 overflow-y-auto p-5">
|
<div className="flex-1 overflow-y-auto p-5">
|
||||||
{mediaQuery.isLoading ? (
|
{mediaQuery.isLoading ? (
|
||||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
<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="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) => (
|
<div className="grid grid-cols-4 gap-0.5 overflow-hidden rounded-lg">
|
||||||
<MediaThumb key={item.seq} item={item} isVideo={tab === 'video'} />
|
{mediaItems.map((item) => (
|
||||||
))}
|
<MediaThumb key={item.seq} item={item} isVideo={tab === 'video'} />
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
{mediaQuery.hasNextPage ? (
|
||||||
|
<LoadMoreButton onClick={() => mediaQuery.fetchNextPage()} loading={mediaQuery.isFetchingNextPage} />
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-y-auto p-5">
|
<div className="flex-1 overflow-y-auto p-5">
|
||||||
{fileLinkQuery.isLoading ? (
|
{fileLinkQuery.isLoading ? (
|
||||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
<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="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<>
|
||||||
{fileLinkQuery.data.items.map((item) =>
|
<div className="flex flex-col gap-2">
|
||||||
tab === 'link' ? <LinkResult key={item.seq} item={item} /> : <SnapshotContent key={item.seq} item={item} />,
|
{fileLinkItems.map((item) =>
|
||||||
)}
|
tab === 'link' ? <LinkResult key={item.seq} item={item} /> : <SnapshotContent key={item.seq} item={item} />,
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
{fileLinkQuery.hasNextPage ? (
|
||||||
|
<LoadMoreButton onClick={() => fileLinkQuery.fetchNextPage()} loading={fileLinkQuery.isFetchingNextPage} />
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ interface MessageRowProps {
|
|||||||
mine: boolean;
|
mine: boolean;
|
||||||
isGroup: boolean;
|
isGroup: boolean;
|
||||||
peerName: string;
|
peerName: string;
|
||||||
myUserId: string | null;
|
|
||||||
selectMode?: boolean;
|
selectMode?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
/** README §3.5: "选中气泡加 outline:2px solid #1677ff" -- true while this message is the open
|
/** README §3.5: "选中气泡加 outline:2px solid #1677ff" -- true while this message is the open
|
||||||
@@ -30,7 +29,6 @@ export function MessageRow({
|
|||||||
mine,
|
mine,
|
||||||
isGroup,
|
isGroup,
|
||||||
peerName,
|
peerName,
|
||||||
myUserId,
|
|
||||||
selectMode,
|
selectMode,
|
||||||
selected,
|
selected,
|
||||||
menuActive,
|
menuActive,
|
||||||
@@ -81,7 +79,9 @@ export function MessageRow({
|
|||||||
className={`flex flex-1 items-end gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}
|
className={`flex flex-1 items-end gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}
|
||||||
>
|
>
|
||||||
<span
|
<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)}
|
{avatarLetter(mine ? '我' : peerName)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Icon } from '@/app/Icon';
|
import { Icon } from '@/app/Icon';
|
||||||
import type { MessageAction } from '@/lib/messages/actions';
|
import type { MessageAction } from '@/lib/messages/actions';
|
||||||
@@ -24,28 +24,55 @@ interface MessageContextMenuProps {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
actions: MessageAction[];
|
actions: MessageAction[];
|
||||||
|
/** README §3.5: "撤回...右侧灰字剩余时限「1:42 内」" -- only meaningful when `actions` includes 'recall'. */
|
||||||
|
recallRemainingSeconds?: number | null;
|
||||||
onAction: (action: MessageAction) => void;
|
onAction: (action: MessageAction) => void;
|
||||||
onClose: () => 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). */
|
/** 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(() => {
|
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('click', handler);
|
||||||
window.addEventListener('scroll', handler, true);
|
window.addEventListener('scroll', handler, true);
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('click', handler);
|
window.removeEventListener('click', handler);
|
||||||
window.removeEventListener('scroll', handler, true);
|
window.removeEventListener('scroll', handler, true);
|
||||||
|
window.removeEventListener('keydown', handler);
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [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 leading = actions.filter((a) => a !== 'recall' && a !== 'delete');
|
||||||
const trailing = actions.filter((a) => a === 'recall' || a === 'delete');
|
const trailing = actions.filter((a) => a === 'recall' || a === 'delete');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
className="fixed z-50 w-[180px] rounded-[10px] border border-border-panel bg-white p-1.5 shadow-menu"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -77,6 +104,9 @@ export function MessageContextMenu({ x, y, actions, onAction, onClose }: Message
|
|||||||
>
|
>
|
||||||
<Icon name={item.icon} className="text-[18px]" />
|
<Icon name={item.icon} className="text-[18px]" />
|
||||||
{item.label}
|
{item.label}
|
||||||
|
{action === 'recall' && typeof recallRemainingSeconds === 'number' ? (
|
||||||
|
<span className="ml-auto text-xs text-text-tertiary-3">{formatRecallCountdown(recallRemainingSeconds)}</span>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Outlet, useParams } from 'react-router-dom';
|
import { Outlet, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { ChatListColumn } from '@/features/messages/ChatListColumn';
|
import { ChatListColumn } from '@/features/messages/ChatListColumn';
|
||||||
import { DetailColumn } from '@/features/messages/DetailColumn';
|
import { DetailColumn } from '@/features/messages/DetailColumn';
|
||||||
|
import { HistorySearchModal } from '@/features/messages/HistorySearchModal';
|
||||||
import { OfflineBanner } from '@/features/messages/OfflineBanner';
|
import { OfflineBanner } from '@/features/messages/OfflineBanner';
|
||||||
|
import type { MediaType } from '@/lib/api/messages';
|
||||||
import { useMediaQuery } from '@/lib/hooks';
|
import { useMediaQuery } from '@/lib/hooks';
|
||||||
import { useChatList } from '@/lib/tinode/hooks';
|
import { useChatList } from '@/lib/tinode/hooks';
|
||||||
|
|
||||||
@@ -22,6 +24,7 @@ import { useChatList } from '@/lib/tinode/hooks';
|
|||||||
export function MessagesLayout() {
|
export function MessagesLayout() {
|
||||||
const { topicId } = useParams();
|
const { topicId } = useParams();
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [historyTab, setHistoryTab] = useState<'text' | MediaType | null>(null);
|
||||||
const isDocked = useMediaQuery('(min-width: 1280px)');
|
const isDocked = useMediaQuery('(min-width: 1280px)');
|
||||||
const showListOnNarrow = !topicId;
|
const showListOnNarrow = !topicId;
|
||||||
const contacts = useChatList();
|
const contacts = useChatList();
|
||||||
@@ -31,6 +34,11 @@ export function MessagesLayout() {
|
|||||||
// toggle state is. Previously hardcoded to the breakpoint itself instead of this real state.
|
// toggle state is. Previously hardcoded to the breakpoint itself instead of this real state.
|
||||||
const detailVisible = isDocked || detailOpen;
|
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 (
|
return (
|
||||||
<div className="relative flex h-full w-full">
|
<div className="relative flex h-full w-full">
|
||||||
<div className="relative flex h-full min-w-0 flex-1">
|
<div className="relative flex h-full min-w-0 flex-1">
|
||||||
@@ -41,27 +49,28 @@ export function MessagesLayout() {
|
|||||||
<ChatListColumn />
|
<ChatListColumn />
|
||||||
</div>
|
</div>
|
||||||
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{topicId ? (
|
{topicId ? (
|
||||||
<div className="hidden h-full lg:block">
|
<div className="hidden h-full lg:block">
|
||||||
<DetailColumn topicId={topicId} peerName={peerName} />
|
<DetailColumn topicId={topicId} peerName={peerName} onOpenHistory={setHistoryTab} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{detailOpen && topicId ? (
|
{detailOpen && topicId ? (
|
||||||
<div className="absolute inset-0 hidden md:block lg:hidden">
|
<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-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)]">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{detailOpen && topicId ? (
|
{detailOpen && topicId ? (
|
||||||
<div className="fixed inset-0 z-30 bg-white md:hidden">
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{historyTab && topicId ? <HistorySearchModal topicId={topicId} initialTab={historyTab} onClose={() => setHistoryTab(null)} /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -106,7 +106,7 @@
|
|||||||
--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.18);
|
--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.18);
|
||||||
--shadow-menu: 0 12px 36px rgba(0, 0, 0, 0.16);
|
--shadow-menu: 0 12px 36px rgba(0, 0, 0, 0.16);
|
||||||
--shadow-bubble: 0 1px 2px rgba(0, 0, 0, 0.06);
|
--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);
|
--shadow-focus-ring: 0 0 0 3px rgba(22, 119, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user