Files
im_webapp/web/src/features/messages/ConversationView.tsx
T
Your NameandClaude Sonnet 5 93d4077d1e fix(web): code review round 1 remaining UI fixes
- 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>
2026-09-08 03:58:06 +08:00

446 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}