- EmojiPanel: add missing 手势/物品 categories, 12-column grid per README §3.3
- DetailColumn: add 投诉举报 row, real ID/region/签名 via new getUserProfile
API, chevron_right icons now use --color-icon-arrow token (were tertiary
text color), regroup rows so dividers only sit between sections not every
row (matches design-code reference exactly)
- MessageBubble/ConversationView: right-clicked message bubble now gets the
spec's outline while its context menu is open
- ChatListColumn/AppShell: pinned 系统消息/文件助手 rows using confirmed
bootstrap entries/entry_badges topic names ("sys"/"slf"); route to the
existing SectionPlaceholder pattern since their own dedicated layouts are
Phase 8, not built yet
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
446 lines
18 KiB
TypeScript
446 lines
18 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
|
||
import { Link, useOutletContext, useParams } from 'react-router-dom';
|
||
import type { StoredMessage } from 'tinode-sdk';
|
||
import { Drafty } from 'tinode-sdk';
|
||
|
||
import { Icon } from '@/app/Icon';
|
||
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 { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
|
||
import { availableActions, 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';
|
||
import { forwardMessage, sendReply, sendText } from '@/lib/tinode/send';
|
||
import { closeConversation, openConversation } from '@/lib/tinode/topics';
|
||
import { useSessionStore } from '@/stores/session';
|
||
|
||
interface OutletCtx {
|
||
onToggleDetail: () => void;
|
||
/** Whether the detail panel is CURRENTLY shown -- true always at >=1280px (docked, no close
|
||
* affordance), true/false at 960-1279px depending on the drawer's open state. Drives the header
|
||
* "i" button's highlight; previously hardcoded to the breakpoint itself (always blue at
|
||
* 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;
|
||
}
|
||
|
||
interface ContextMenuState {
|
||
x: number;
|
||
y: number;
|
||
msg: StoredMessage;
|
||
}
|
||
|
||
export function ConversationView() {
|
||
const { topicId } = useParams();
|
||
const { onToggleDetail, detailVisible } = useOutletContext<OutletCtx>();
|
||
const myUserId = useSessionStore((s) => s.myUserId);
|
||
const tinode = useTinode();
|
||
const contacts = useChatList();
|
||
const contact = contacts.find((c) => c.topic === topicId);
|
||
const messages = useTopicMessages(topicId);
|
||
const bootstrap = useBootstrap();
|
||
// Keyed by topicId, not a single string -- ConversationView is one long-lived instance reused
|
||
// across every conversation (the route is `:topicId`, React Router doesn't remount on param
|
||
// change), so a plain single draft leaks the PREVIOUS conversation's unsent text into whichever
|
||
// one you open next.
|
||
const [draftByTopic, setDraftByTopic] = useState<Record<string, string>>({});
|
||
const draft = (topicId && draftByTopic[topicId]) || '';
|
||
const setDraft = (value: string) => {
|
||
if (topicId) setDraftByTopic((prev) => ({ ...prev, [topicId]: value }));
|
||
};
|
||
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
||
const [selectMode, setSelectMode] = useState(false);
|
||
const [selectedSeqs, setSelectedSeqs] = useState<Set<number>>(new Set());
|
||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||
const [recording, setRecording] = useState(false);
|
||
const [bulkForwardOpen, setBulkForwardOpen] = useState(false);
|
||
const [replyTo, setReplyTo] = useState<StoredMessage | null>(null);
|
||
const [forwardMsg, setForwardMsg] = useState<StoredMessage | null>(null);
|
||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||
const streamRef = useRef<HTMLDivElement>(null);
|
||
const autoScrollRef = useRef(true);
|
||
|
||
useEffect(() => {
|
||
if (!topicId) return undefined;
|
||
openConversation(topicId).catch(() => {});
|
||
return () => closeConversation(topicId);
|
||
}, [topicId]);
|
||
|
||
useEffect(() => {
|
||
setSelectMode(false);
|
||
setSelectedSeqs(new Set());
|
||
setMenu(null);
|
||
setReplyTo(null);
|
||
setForwardMsg(null);
|
||
setBulkForwardOpen(false);
|
||
setEmojiOpen(false);
|
||
// Unmounting VoiceRecorder stops its media stream via its own cleanup effect -- without this,
|
||
// a recording started in conversation A silently keeps going after navigating to B, and its
|
||
// 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);
|
||
}, [topicId]);
|
||
|
||
useEffect(() => {
|
||
if (autoScrollRef.current) {
|
||
streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight });
|
||
}
|
||
}, [messages.length]);
|
||
|
||
useEffect(() => {
|
||
if (!topicId || messages.length === 0) return;
|
||
tinode.getTopic(topicId).noteRead(0);
|
||
}, [topicId, messages.length, tinode]);
|
||
|
||
if (!topicId) return null;
|
||
const topic = tinode.getTopic(topicId);
|
||
const peerName = contact?.public?.fn || topicId;
|
||
const isGroup = topic.isGroupType();
|
||
|
||
/**
|
||
* "加载更多" (README §3.2/old app's messages-view.jsx): not simple since/before paging -- computes
|
||
* which seq ranges are actually missing from the local cache within the currently-visible window
|
||
* (topic.msgHasMoreMessages) and fills exactly those gaps (topic.getMessagesPage), since messages
|
||
* can have server-side deletions punching holes in an otherwise-contiguous range.
|
||
*/
|
||
const loadOlder = async () => {
|
||
if (loadingOlder) return;
|
||
const el = streamRef.current;
|
||
const min = topic.minMsgSeq();
|
||
const max = topic.maxMsgSeq();
|
||
const gaps = topic.msgHasMoreMessages(min, max, false);
|
||
if (gaps.length === 0) return;
|
||
setLoadingOlder(true);
|
||
const prevHeight = el?.scrollHeight ?? 0;
|
||
const prevTop = el?.scrollTop ?? 0;
|
||
try {
|
||
await topic.getMessagesPage(MESSAGES_PAGE, gaps, min, max);
|
||
} finally {
|
||
setLoadingOlder(false);
|
||
requestAnimationFrame(() => {
|
||
if (el) el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleScroll = () => {
|
||
const el = streamRef.current;
|
||
if (!el) return;
|
||
autoScrollRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||
if (el.scrollTop <= FETCH_PAGE_TRIGGER) {
|
||
loadOlder();
|
||
}
|
||
};
|
||
|
||
const submit = () => {
|
||
const text = draft.trim();
|
||
if (!text) return;
|
||
setDraft('');
|
||
if (replyTo) {
|
||
const label = myUserId && replyTo.from === myUserId ? '我' : peerName;
|
||
sendReply(topic, text, replyTo, label);
|
||
setReplyTo(null);
|
||
} else {
|
||
sendText(topic, text);
|
||
}
|
||
};
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||
if (e.key !== 'Enter' || e.shiftKey) return;
|
||
// Guards against CJK IME candidate-selection Enter keystrokes being mistaken for submit --
|
||
// a real, unfixed bug class in the old app (confirmed by its own research: zero isComposing/
|
||
// compositionstart/keyCode-229 guards anywhere in that codebase).
|
||
if (e.nativeEvent.isComposing) return;
|
||
e.preventDefault();
|
||
submit();
|
||
};
|
||
|
||
const toggleSelect = (seq: number) => {
|
||
setSelectedSeqs((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(seq)) next.delete(seq);
|
||
else next.add(seq);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const exitSelectMode = () => {
|
||
setSelectMode(false);
|
||
setSelectedSeqs(new Set());
|
||
};
|
||
|
||
const bulkDelete = () => {
|
||
if (selectedSeqs.size === 0) return;
|
||
if (!window.confirm(`删除已选择的 ${selectedSeqs.size} 条消息?`)) return;
|
||
topic.delMessagesList([...selectedSeqs], false);
|
||
exitSelectMode();
|
||
};
|
||
|
||
const bulkFavorite = () => {
|
||
if (selectedSeqs.size === 0) return;
|
||
favoriteMessages([...selectedSeqs].map((seq) => ({ topic: topicId, seq }))).catch(() => {});
|
||
exitSelectMode();
|
||
};
|
||
|
||
const bulkForward = (targetTopic: string) => {
|
||
const target = tinode.getTopic(targetTopic);
|
||
for (const seq of selectedSeqs) {
|
||
const msg = topic.findMessage(seq);
|
||
if (msg) forwardMessage(topicId, msg, target);
|
||
}
|
||
setBulkForwardOpen(false);
|
||
exitSelectMode();
|
||
};
|
||
|
||
const handleAction = (action: MessageAction) => {
|
||
const msg = menu?.msg;
|
||
setMenu(null);
|
||
if (!msg || typeof msg.seq !== 'number') return;
|
||
switch (action) {
|
||
case 'copy':
|
||
navigator.clipboard.writeText(Drafty.toPlainText(msg.content ?? '')).catch(() => {});
|
||
break;
|
||
case 'recall':
|
||
recallMessage(topicId, msg.seq)
|
||
.then(() => topic.delMessagesList([msg.seq], true))
|
||
.catch(() => {});
|
||
break;
|
||
case 'delete':
|
||
topic.delMessagesList([msg.seq], false);
|
||
break;
|
||
case 'select':
|
||
setSelectMode(true);
|
||
setSelectedSeqs(new Set([msg.seq]));
|
||
break;
|
||
case 'quote':
|
||
setReplyTo(msg);
|
||
break;
|
||
case 'favorite':
|
||
favoriteMessages([{ topic: topicId, seq: msg.seq }]).catch(() => {});
|
||
break;
|
||
case 'forward':
|
||
setForwardMsg(msg);
|
||
break;
|
||
}
|
||
};
|
||
|
||
const handlePickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
e.target.value = '';
|
||
if (file) sendImage(topic, file).catch(() => {});
|
||
};
|
||
|
||
const handlePickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
e.target.value = '';
|
||
if (file) sendFile(topic, file).catch(() => {});
|
||
};
|
||
|
||
return (
|
||
<div className="flex h-full w-full flex-col bg-surface-chat">
|
||
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-5">
|
||
{selectMode ? (
|
||
<>
|
||
<span className="text-[15px] font-medium text-text-heading">已选择 {selectedSeqs.size} 条消息</span>
|
||
<button type="button" onClick={exitSelectMode} className="text-sm text-accent">
|
||
取消
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="flex items-center gap-2.5">
|
||
<Link to="/messages" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
|
||
<Icon name="arrow_back_ios_new" />
|
||
</Link>
|
||
<span
|
||
className={`grid h-9.5 w-9.5 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(topicId)}`}
|
||
>
|
||
{avatarLetter(peerName)}
|
||
</span>
|
||
<div className="flex flex-col">
|
||
<strong className="text-base font-semibold text-text-heading">{peerName}</strong>
|
||
<span className={`text-xs ${contact?.online ? 'text-online' : 'text-text-tertiary-2'}`}>
|
||
{contact?.online ? '在线' : '离线'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
|
||
<Icon name="call" />
|
||
</button>
|
||
<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">
|
||
<Icon name="search" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onToggleDetail}
|
||
className={`grid h-9.5 w-9.5 place-items-center rounded-lg ${detailVisible ? 'bg-accent-soft-bg text-accent' : 'text-text-tertiary-1'}`}
|
||
>
|
||
<Icon name="info" />
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</header>
|
||
|
||
<div ref={streamRef} onScroll={handleScroll} className="flex flex-1 flex-col gap-4 overflow-y-auto px-7 py-5">
|
||
{loadingOlder ? <div className="py-2 text-center text-xs text-text-tertiary-4">加载中…</div> : null}
|
||
{messages.map((msg) => (
|
||
<MessageRow
|
||
key={msg.seq}
|
||
topic={topic}
|
||
msg={msg}
|
||
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}
|
||
onToggleSelect={toggleSelect}
|
||
onContextMenu={(e, m) => setMenu({ x: e.clientX, y: e.clientY, msg: m })}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{selectMode ? (
|
||
<div className="flex h-18 flex-none items-center justify-around border-t border-border-panel bg-white">
|
||
<button
|
||
type="button"
|
||
onClick={() => setBulkForwardOpen(true)}
|
||
disabled={selectedSeqs.size === 0}
|
||
className="flex flex-col items-center gap-1 text-text-secondary disabled:opacity-40"
|
||
>
|
||
<Icon name="reply" className="text-[22px]" />
|
||
<span className="text-xs">转发</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={bulkFavorite}
|
||
disabled={selectedSeqs.size === 0}
|
||
className="flex flex-col items-center gap-1 text-text-secondary disabled:opacity-40"
|
||
>
|
||
<Icon name="inventory_2" className="text-[22px]" />
|
||
<span className="text-xs">收藏</span>
|
||
</button>
|
||
<button type="button" onClick={bulkDelete} className="flex flex-col items-center gap-1 text-danger-text">
|
||
<Icon name="delete_outline" className="text-[22px]" />
|
||
<span className="text-xs">删除</span>
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
|
||
{replyTo ? (
|
||
<div className="mb-2 flex items-center justify-between rounded-lg bg-surface-input px-3 py-1.5 text-xs text-text-tertiary-1">
|
||
<span className="truncate">
|
||
回复 {myUserId && replyTo.from === myUserId ? '我' : peerName}:{renderDrafty(replyTo.content)}
|
||
</span>
|
||
<button type="button" onClick={() => setReplyTo(null)} className="flex-none text-text-tertiary-3">
|
||
<Icon name="close" className="text-[16px]" />
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
<div className="relative flex items-center gap-1 px-1 pb-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => setEmojiOpen((v) => !v)}
|
||
className={`grid h-8.5 w-8.5 place-items-center rounded text-[22px] ${emojiOpen ? 'bg-accent-soft-bg text-accent' : 'text-icon-default'}`}
|
||
>
|
||
<Icon name="sentiment_satisfied_alt" />
|
||
</button>
|
||
{emojiOpen ? (
|
||
<div className="absolute bottom-full left-0 z-20 mb-2">
|
||
<EmojiPanel
|
||
onPick={(emoji) => {
|
||
setDraft(draft + emoji);
|
||
setEmojiOpen(false);
|
||
}}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<label className="grid h-8.5 w-8.5 cursor-pointer place-items-center rounded text-[22px] text-icon-default">
|
||
<Icon name="image" />
|
||
<input type="file" accept="image/*" onChange={handlePickImage} className="hidden" />
|
||
</label>
|
||
<label className="grid h-8.5 w-8.5 cursor-pointer place-items-center rounded text-[22px] text-icon-default">
|
||
<Icon name="attach_file" />
|
||
<input type="file" onChange={handlePickFile} className="hidden" />
|
||
</label>
|
||
<Icon name="contact_page" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
||
<button
|
||
type="button"
|
||
onClick={() => setRecording(true)}
|
||
className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default"
|
||
>
|
||
<Icon name="mic_none" />
|
||
</button>
|
||
<Icon name="history" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
||
</div>
|
||
{recording ? (
|
||
<VoiceRecorder
|
||
onCancel={() => setRecording(false)}
|
||
onSend={(blob, durationMs) => {
|
||
setRecording(false);
|
||
sendVoice(topic, blob, durationMs).catch(() => {});
|
||
}}
|
||
/>
|
||
) : (
|
||
<>
|
||
<textarea
|
||
value={draft}
|
||
onChange={(e) => setDraft(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
|
||
className="min-h-[72px] w-full resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1"
|
||
/>
|
||
<div className="flex justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={submit}
|
||
disabled={!draft.trim()}
|
||
className="h-9 rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
||
>
|
||
发送
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{menu ? (
|
||
<MessageContextMenu
|
||
x={menu.x}
|
||
y={menu.y}
|
||
actions={availableActions(menu.msg, myUserId, bootstrap.data?.message_policy)}
|
||
onAction={handleAction}
|
||
onClose={() => setMenu(null)}
|
||
/>
|
||
) : null}
|
||
|
||
{forwardMsg ? (
|
||
<ForwardPickerModal
|
||
onClose={() => setForwardMsg(null)}
|
||
onPick={(targetTopic) => {
|
||
forwardMessage(topicId, forwardMsg, tinode.getTopic(targetTopic));
|
||
setForwardMsg(null);
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{bulkForwardOpen ? <ForwardPickerModal onClose={() => setBulkForwardOpen(false)} onPick={bulkForward} /> : null}
|
||
</div>
|
||
);
|
||
}
|