Files
im_webapp/web/src/features/messages/ConversationView.tsx
T

1318 lines
72 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 { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useQueries, useQuery } from '@tanstack/react-query';
import { Link, useNavigate, useOutletContext, useParams } from 'react-router-dom';
import type { StoredMessage } from 'tinode-sdk';
import { Drafty } from 'tinode-sdk';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { EmojiPanel } from '@/features/messages/EmojiPanel';
import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal';
import { MentionPicker } from '@/features/messages/MentionPicker';
import { MessageContextMenu } from '@/features/messages/MessageContextMenu';
import { MessageRow } from '@/features/messages/MessageBubble';
import { VoiceRecorder } from '@/features/messages/VoiceRecorder';
import { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal';
import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
import { useBootstrap } from '@/lib/api/hooks';
import { favoriteMessages, forwardMessages, getTopicDraft, type MediaType, putTopicDraft, recallMessage } from '@/lib/api/messages';
import { getAllGroupMembers, getAllGroupMutes, getGroup, getGroupGovernance, getGroupSummary } from '@/lib/api/groups';
import { getConversationContext, getUserProfile } from '@/lib/api/friends';
import { getMe } from '@/lib/api/profile';
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
import { useClickOutside } from '@/lib/hooks';
import { availableActions, isConfirmedSeq, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions';
import { flatTextContentOf, isRecalled } from '@/lib/messages/dispatch';
import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions';
import { buildReplyInfo, replySnapshotText } from '@/lib/messages/reply';
import { groupSenderLabel } from '@/lib/messages/sender-name';
import { groupMemberDisplayName } from '@/lib/messages/group-member-name';
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
import { isTopicGone } from '@/lib/tinode/errors';
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
import { sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send';
import {
closeConversation,
ensureTopicAttached,
forgetTopic,
getP2PPeerUserId,
markConfirmedRead,
openConversation,
setActiveConversationTopic,
subscribeTopicGone,
} from '@/lib/tinode/topics';
import { useCallStore } from '@/stores/calls';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify, notImplemented } from '@/stores/globalToast';
import { useMessageJumpStore } from '@/stores/messageJump';
import { useSessionStore } from '@/stores/session';
interface OutletCtx {
onToggleDetail: () => void;
/** Whether the detail panel is CURRENTLY shown -- at >=1280px this is the persisted
* (localStorage) docked-panel state, at 960-1279px the temporary drawer state. Drives the
* header "i" button's highlight. */
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 {
x: number;
y: number;
msg: StoredMessage;
}
export function ConversationView() {
const { topicId } = useParams();
const navigate = useNavigate();
const { onToggleDetail, detailVisible, onOpenHistory } = 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 }));
};
// Server-side draft persistence (Zentao #19: draft cleared on conversation switch/page refresh --
// draftByTopic above only survives WITHIN this one mounted session, never a refresh or a different
// device/tab). `getTopicDraft`/`putTopicDraft`'s own comment covers the real, live `PUT /v1/im/
// topics/{topic}/draft` endpoint this hits (the protocol doc marks it "待实现", which is stale).
//
// draftVersionRef, not state -- purely an implementation detail this optimistic-lock write needs to
// send back, never rendered, so it doesn't need to trigger a re-render when it changes.
const draftVersionRef = useRef<Record<string, number>>({});
// The content this app most recently confirmed IS what the server has (from either a completed load
// or a completed save) -- lets the debounce-save effect below tell "the user actually typed
// something new" apart from "draft just changed because the load effect populated it with what the
// server already had," which would otherwise queue an entirely redundant save of identical content
// the moment every conversation with an existing draft is opened.
const lastSyncedContentRef = useRef<Record<string, string>>({});
// Tracks which topics' server draft has already been fetched THIS session -- fetching again every
// time the user merely glances back at an already-open conversation would risk clobbering local text
// already being typed with whatever stale snapshot the server still has from before this session.
const draftLoadedRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!topicId || draftLoadedRef.current.has(topicId)) return;
draftLoadedRef.current.add(topicId);
const forTopic = topicId;
getTopicDraft(forTopic)
.then((d) => {
draftVersionRef.current[forTopic] = d.version;
lastSyncedContentRef.current[forTopic] = d.content;
if (!d.content) return;
// Local (this-session) text always wins over the server snapshot -- the whole point of
// fetching is recovering a draft AFTER a refresh/new session, not overwriting an edit already
// in progress if this same topic was somehow already typed into before this fetch resolved.
setDraftByTopic((prev) => (prev[forTopic] ? prev : { ...prev, [forTopic]: d.content }));
})
.catch(() => {});
}, [topicId]);
// Debounced (1.5s after the user stops typing) rather than on every keystroke -- draft content
// isn't urgent enough to warrant a network write per character. `pendingDraftRef` (not just the
// timer) is what the flush effect below actually sends, kept up to date on every keystroke
// regardless of whether ITS OWN cleanup below beat the timer to canceling it.
const pendingDraftRef = useRef<{ topic: string; content: string; timer: ReturnType<typeof setTimeout> } | null>(null);
const flushDraftSave = useCallback(() => {
const pending = pendingDraftRef.current;
if (!pending) return;
clearTimeout(pending.timer);
pendingDraftRef.current = null;
const version = draftVersionRef.current[pending.topic] ?? 0;
putTopicDraft(pending.topic, pending.content, version)
.then((d) => {
draftVersionRef.current[pending.topic] = d.version;
lastSyncedContentRef.current[pending.topic] = pending.content;
})
.catch(() => {
// A stale optimistic-lock version (edited on another device/tab in between) or a transient
// network failure -- a personal, single-user, ephemeral draft doesn't warrant a refetch-and-
// retry dance over losing the very latest keystrokes here; the next successful save (the next
// debounce tick, or the next time this conversation is opened) catches back up regardless.
});
}, []);
useEffect(() => {
if (!topicId || !draftLoadedRef.current.has(topicId)) return undefined;
if (draft === lastSyncedContentRef.current[topicId]) return undefined; // nothing actually changed
if (pendingDraftRef.current) clearTimeout(pendingDraftRef.current.timer);
const timer = setTimeout(flushDraftSave, 1500);
pendingDraftRef.current = { topic: topicId, content: draft, timer };
return () => clearTimeout(timer);
}, [topicId, draft, flushDraftSave]);
// Separate from the effect above on purpose: THIS one's cleanup fires flushDraftSave() itself (not
// just clearTimeout) so switching to a different conversation -- or closing/navigating away
// entirely -- saves whatever's pending immediately, instead of abandoning it to a 1.5s timer that
// may never get the chance to fire once its own component instance's timers are gone.
useEffect(() => {
return () => flushDraftSave();
}, [topicId, flushDraftSave]);
// Same per-topic keying as draftByTopic, and for the same reason -- a mention picked while
// composing in group A must not still be "pending" if the user switches to group B and back
// before sending (buildMentionedContent would then splice a real MN entity for a name that's
// just inert leftover text in a completely different conversation's draft).
const [mentionsByTopic, setMentionsByTopic] = useState<Record<string, PendingMention[]>>({});
const pendingMentions = (topicId && mentionsByTopic[topicId]) || [];
const [mentionTrigger, setMentionTrigger] = useState<{ start: number; query: string } | null>(null);
const [groupModal, setGroupModal] = useState<'announcement' | 'join-requests' | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
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 emojiContainerRef = useRef<HTMLDivElement>(null);
const mentionContainerRef = useRef<HTMLDivElement>(null);
// Neither popover had a way to dismiss except re-clicking its own toggle -- a real gap a user
// report caught (open the emoji panel, click elsewhere in the app, and it just sits there).
useClickOutside(emojiContainerRef, () => setEmojiOpen(false), emojiOpen);
useClickOutside(mentionContainerRef, () => setMentionTrigger(null), !!mentionTrigger);
const [errorToast, setErrorToast] = useState('');
const [successToast, setSuccessToast] = useState('');
const streamRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
// "Jump to message" (HistorySearchModal result click, via stores/messageJump.ts). `highlightedSeq`
// drives MessageRow's brief flash; `pendingJumpSeqRef` is a ref (not state) because it's read/written
// across two different effects below without needing a render of its own.
const jumpTarget = useMessageJumpStore((s) => s.target);
const [highlightedSeq, setHighlightedSeq] = useState<number | null>(null);
const pendingJumpSeqRef = useRef<number | null>(null);
// Bumped every time a conversation is (re)opened below -- NOT the same as comparing topicId by
// value, which would false-positive if the user opens A, then B, then A again within a single
// loadOlder() round trip: topicId ends up back at "A" even though the DOM has since been torn
// down and rebuilt by the intervening open/close cycle, so a same-topicId check alone would wrongly
// treat that as "still the same open" and apply a scroll-restore computed against content that
// isn't there anymore.
const openGenerationRef = useRef(0);
// 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);
// docs/IM-前后端对接文档.md §4.16: "成功 Toast 只在至少一项成功时展示,部分失败需给出汇总" -- this
// response's `created_count`/`existing_count`/`failed[]` were being resolved and thrown away
// entirely, so favoriting from a message's context menu or the multi-select bar gave the user
// total silence either way: no confirmation on full success, and no way to learn that some of
// several selected messages weren't actually favorited.
const notifyFavoriteResult = (result: { created_count: number; existing_count: number; failed: Array<{ seq: number; reason: string }> }) => {
if (result.created_count + result.existing_count > 0) setSuccessToast('已收藏');
if (result.failed.length) setErrorToast(`${result.failed.length} 条消息收藏失败`);
};
useEffect(() => {
if (!topicId) return undefined;
// Unlike every other action in this component (see showError's own comment above), a failed
// subscribe here used to just vanish into an empty `.catch(() => {})` -- the user would land on
// a totally blank message list with no error, no retry, and (since the noteRead effect below
// only fires once `messages.length > 0`) a read receipt that can never be sent, leaving the chat
// list's unread badge permanently stuck for a conversation that in fact never loaded at all.
openConversation(topicId)
.then((topic) => {
// A P2P conversation reached via the OTHER user's `usr...` id (the only address that exists
// before a real conversation has ever happened -- e.g. straight from a friend's profile,
// never yet in the chat list) gets handed back a DIFFERENT real topic name by the server
// (its actual `p2p...` hash) the moment the subscribe succeeds -- confirmed live: the SDK's
// own Topic#subscribe (tinode.dev.js) renames+re-keys its local cache entry from `usr...` to
// that real name whenever the topic was `_new` (true for every freshly-constructed Topic
// object, which this always is on first open). Every OTHER piece of this component --
// submit()'s send, the header's presence lookup, MessageBubble's retry -- calls
// `tinode.getTopic(topicId)` fresh off the URL param on its own, completely independently of
// whatever object `openConversation` itself subscribed. Left alone, that second `getTopic`
// call is a cache MISS against the now-renamed key, silently manufacturing a brand new,
// never-subscribed, empty stand-in object -- every send/read/presence check from then on
// operates on that empty stand-in, not the real, subscribed, actually-receiving-data one.
// Real messages keep arriving successfully (confirmed via a raw-protocol repro: the {pub}
// lands and a fresh {sub}'s history dump shows it) but never reach this component's topic
// object, so the chat window shows nothing and looks like sending silently failed. Replacing
// the URL to the real name the instant it's known makes every later `getTopic(topicId)` in
// this component resolve the SAME object subscribe() just populated, not a fresh impostor.
if (topic.name !== topicId) navigate(`/messages/${topic.name}`, { replace: true });
// Real, live-confirmed gap on top of the rename issue above: for exactly this brand-new-P2P-
// via-alias case, `openConversation`'s own `withLaterDesc()` (an incremental "only if changed
// since I last knew" fetch) can come back with the subscribe successful but NO desc AND no
// presence at all -- `topic.public` stays permanently `null` and `topic.online` stays falsy
// (confirmed live: still true 10+ seconds later, no amount of waiting fixes either one),
// which is why the header/chat-list fall back to the raw topic id and "离线" forever on THIS
// specific path (reached live via a friend's profile "发消息" button, never through a fresh
// page load -- a full reload's own `subscribeMeTopic()` happens to paper over this by
// fetching every contact's full subscription state unconditionally, which is exactly why this
// was never caught by any test that reloaded the page as part of "opening" a conversation).
// `me.getMeta(withOneSub(topic))` is the same targeted-refresh call the SDK's own presence
// routing (`_routePres`, tinode.dev.js) uses for "just learned about this topic" -- unlike a
// plain desc fetch on the topic itself, this one comes back with BOTH desc AND online status
// in the same `sub` entry (confirmed live capturing the raw frame during an earlier
// investigation), and `TopicMe`'s own `_processMetaSubs` merges both onto this exact topic
// object before notifying -- wireTopicCallbacks already wired `me`'s `onMetaSub`/
// `onSubsUpdated` at login, so the response correctly invalidates the chat list once it lands.
if (!topic.public) {
const me = tinode.getMeTopic();
me.getMeta(me.startMetaQuery().withOneSub(undefined, topic.name).build()).catch(() => {});
}
})
.catch((err) => {
// A group dissolved before this specific page load ever subscribed to it -- distinct from
// subscribeTopicGone below, which only covers watching it get dissolved live while already
// subscribed. See isTopicGone's own comment for the real, reported bug this closes (a
// deep-link/refresh into an already-dissolved group left the screen sitting on a raw-topic-id,
// "1970/1/1", "0 members" stub instead of leaving). Same user-facing outcome as the live case
// (toast + bounce to the chat list) for consistency, not a special "this group doesn't exist"
// message -- from this user's vantage point both cases are just "this group is gone now".
if (isTopicGone(err)) {
// The failed subscribe attempt above already left a bare, empty stub for this topic in the
// SDK's cache (getTopic() creates one just by being called, before it knows whether the
// subscribe will succeed) -- without this, that stub alone is enough to make the chat list
// keep showing this now-gone group as a broken-looking row even after navigating away.
forgetTopic(topicId);
notify('该群聊已解散');
navigate('/messages', { replace: true });
return;
}
showError(err, '加载会话失败,请重试');
});
return () => {
closeConversation(topicId).catch(() => {});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [topicId]);
// Switching into a chat window should let the user start typing immediately, not require an extra
// click into the input first -- matches every mainstream IM client. Deliberately NOT keyed on
// `messages` (would re-steal focus away from whatever the user is doing every time a new message
// arrives while they're already composing) -- only the initial switch itself should grab focus.
useEffect(() => {
if (!topicId) return;
textareaRef.current?.focus();
}, [topicId]);
// Someone else dissolving the group currently open here (or this account being removed from it)
// fires the SDK's `onDeleteTopic`, not `onData`/`onPres` -- those only invalidate this screen's
// cached snapshots for re-render, they don't tell it the topic itself is gone. Left unhandled, the
// member who happened to be looking at that conversation just keeps staring at a now-defunct screen
// (an actual reported bug: staying on the dissolved group's view with no way back except a manual
// nav). `notify()` (the global toast, not this screen's own `errorToast`) survives the navigate.
useEffect(() => {
return subscribeTopicGone((goneTopicName) => {
if (goneTopicName !== topicId) return;
notify('该群聊已解散');
navigate('/messages', { replace: true });
});
}, [topicId, navigate]);
// useLayoutEffect, not useEffect -- this component never remounts on topicId change (route is
// `:topicId`), so a plain useEffect (which fires after paint) can let one frame render with the
// PREVIOUS conversation's select-mode/menu/etc. still showing over the new conversation's message
// list underneath, self-correcting only on the next tick.
useLayoutEffect(() => {
openGenerationRef.current += 1;
setSelectMode(false);
setSelectedSeqs(new Set());
setMenu(null);
setReplyTo(null);
setForwardMsg(null);
setBulkForwardOpen(false);
setEmojiOpen(false);
// Without this, a mention picker left open while typing in group A reappears in group B after a
// fast conversation switch (isGroup is true there too), anchored to a caret offset that describes
// A's draft, not B's -- picking a suggestion would splice "@Name" into the wrong text at the wrong
// position.
setMentionTrigger(null);
// 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);
// 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;
// An error toast from the previous conversation (e.g. "图片发送失败") would otherwise keep
// showing over whichever conversation is now open, misattributing the failure.
setErrorToast('');
setSuccessToast('');
// A jump-to-message request still pending when the user switches away (its fetch was in flight,
// or it resolved but the scroll-on-mount effect hadn't fired yet) would otherwise survive into
// the NEW conversation and, once ITS messages populate, try to scroll+highlight whatever row in
// B happens to share the SAME seq number the user searched for in A -- seq is per-topic, so a
// collision across two different conversations is completely ordinary, not a rare edge case.
pendingJumpSeqRef.current = null;
setHighlightedSeq(null);
}, [topicId]);
// Tells lib/notifications/push.ts "the user is already looking at this one" so a message arriving
// here doesn't also pop a desktop notification for something already on screen -- cleared on
// switch/unmount so leaving this screen (closing the tab, navigating to /contacts, etc.) doesn't
// leave some OTHER, no-longer-open conversation permanently exempt from notifications.
useEffect(() => {
if (!topicId) return undefined;
setActiveConversationTopic(topicId);
return () => setActiveConversationTopic(null);
}, [topicId]);
useEffect(() => {
if (autoScrollRef.current) {
streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight });
}
// `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]);
// Consumes a pending "jump to message" request (HistorySearchModal result click). Fetches directly
// via `since`/`before` (arbitrary absolute seq bounds per the Tinode wire protocol, not required to
// be adjacent to whatever's already cached) rather than routing through loadOlder()'s gap-fill --
// that machinery is scoped to backfilling holes within the currently-visible min/max span, not
// "there's an entirely separate old cluster somewhere else the user wants to see right now".
useEffect(() => {
if (!jumpTarget || jumpTarget.topic !== topicId) return;
useMessageJumpStore.getState().clear();
const seq = jumpTarget.seq;
pendingJumpSeqRef.current = seq;
if (messages.some((m) => m.seq === seq)) return; // already loaded -- the effect below scrolls to it
autoScrollRef.current = false; // otherwise the scroll-to-bottom effect above fights this jump
const t = tinode.getTopic(topicId);
const half = Math.floor(MESSAGES_PAGE / 2);
t.getMeta(t.startMetaQuery().withData(Math.max(1, seq - half), seq + half + 1, MESSAGES_PAGE).build()).catch(() => {
pendingJumpSeqRef.current = null;
showError(null, '未能加载该消息,可能已被删除');
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jumpTarget, topicId]);
// Performs the actual scroll+highlight once the target row exists in the DOM -- covers both the
// "already loaded" case (this and the effect above run in the same commit, so the ref is already
// set by the time this checks) and the "had to fetch first" case (re-fires once `messages` picks up
// the newly-arrived data via useTopicMessages' own subscription, no manual wiring needed here).
useEffect(() => {
const seq = pendingJumpSeqRef.current;
if (seq == null) return;
const el = streamRef.current?.querySelector(`[data-seq="${seq}"]`);
if (!el) return;
pendingJumpSeqRef.current = null;
el.scrollIntoView({ block: 'center' });
setHighlightedSeq(seq);
window.setTimeout(() => setHighlightedSeq((cur) => (cur === seq ? null : cur)), 2000);
}, [messages, jumpTarget]);
// Gated on the tab actually being visible, not just this conversation being mounted -- a
// conversation left open in a BACKGROUNDED tab used to have every arriving message immediately
// noteRead()'d purely because the route was still mounted, with nobody actually looking at it. That
// made the chat-list unread count (and, since today, the tab-title scroll/favicon badge/desktop
// notification it drives -- see lib/notifications/) silently stay at zero for a conversation that
// was, from the user's real vantage point, sitting unread in a background tab the whole time (a
// real, reported case: systest01 has systest02's conversation open, tabs away, systest02 sends a
// message -- the tab title never picked it up). The effect below catches back up the moment the tab
// becomes visible again while still on this same conversation, so nothing is left permanently
// unread just because this guard skipped it while hidden.
//
// markConfirmedRead alongside noteRead -- this app's own independent "genuinely seen" record (see
// its own comment in topics.ts) that topics.ts's me.onPres correction relies on to undo the SDK's
// separate, unwanted auto-read-mark on a `{pres what:"msg"}` push for a topic that ISN'T actually
// being looked at. noteRead() alone only fixes what THIS component itself would have marked read;
// it doesn't tell that other mechanism what the user has genuinely seen.
useEffect(() => {
if (!topicId || messages.length === 0) return;
if (document.visibilityState !== 'visible') return;
const topic = tinode.getTopic(topicId);
topic.noteRead(0);
markConfirmedRead(topicId, messages[messages.length - 1]?.seq ?? 0);
}, [topicId, messages, tinode]);
useEffect(() => {
if (!topicId) return undefined;
const onVisible = () => {
if (document.visibilityState !== 'visible') return;
tinode.getTopic(topicId).noteRead(0);
markConfirmedRead(topicId, messages[messages.length - 1]?.seq ?? 0);
};
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('focus', onVisible);
return () => {
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('focus', onVisible);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [topicId, tinode]);
// Computed independently of `topic` below (not `topic.isGroupType()`) so this and the group-data
// queries it gates can sit before the `if (!topicId)` early return, alongside every other hook --
// `tinode.getTopic()` is a cheap cache lookup-or-create either way, calling it twice is harmless.
const isGroup = !!topicId && tinode.getTopic(topicId).isGroupType();
// `slf` (chatting with yourself, née "文件助手" -- see ChatListColumn.tsx's PINNED_ENTRIES
// comment on why that framing was deliberately dropped) isn't in your own contacts/friends list,
// so `contact` below is always undefined for it -- shares AppShell's/SettingsLayout's `['me']`
// query so this doesn't add a request beyond whichever of those mounts first.
const isSelf = topicId === 'slf';
// No longer gated to `enabled: isSelf` -- every conversation (not just chatting with yourself)
// needs your OWN avatar_file_id now, for the "mine" message-row avatar below. Shares the same
// `['me']` key NavRail/ChatListColumn/SettingsLayout already keep warm, so this is a cache read in
// the normal case, not a new request.
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
// NOT topicId -- see getP2PPeerUserId's own doc comment. A P2P topic with any real history is
// addressed by a real, opaque `p2p...` name, not the peer's `usr...` id (that only holds true in
// the brief window before the very first subscribe) -- confirmed live: without this, this query
// 404s (`GET /users/{p2p...}/profile`) for every established conversation, and only ever appeared
// to work in earlier testing because that test happened to use a conversation opened for the first
// time in the same session, before the SDK's own rename kicked in.
const peerUserId = !isGroup && !isSelf && topicId ? getP2PPeerUserId(tinode.getTopic(topicId), myUserId) : undefined;
// Real gap this was written to close: the chat header/message-row avatars only ever drew the
// letter-tile fallback, even after a real custom avatar was uploaded and correctly showing up on
// the contacts pages (FriendProfileView/ContactsListColumn) -- those read this exact same REST
// profile, the conversation screen just never fetched it. Same query DetailColumn already runs
// (`['user-profile', peerUserId]`), so this dedupes with it whenever the detail panel happens to be
// open, and only fetches for a real P2P peer, never for a group or `slf`.
const peerProfileQuery = useQuery({
queryKey: ['user-profile', peerUserId],
queryFn: () => getUserProfile(peerUserId || ''),
enabled: !!peerUserId,
});
// Live-confirmed real bug this closes (Zentao #28): sending into a P2P conversation blocked in
// EITHER direction showed only the generic per-bubble "发送失败,点击重试", no reason, on both sides
// -- reading tinode-sdk source confirmed the specific rejection reason can never be recovered
// after a failed send (Topic#publishMessage's own .catch swallows it into a bare `_failed` flag and
// never rethrows), so the only real fix is checking BEFORE attempting to send, not reacting after.
// This endpoint is P2P-only and reports the relationship from either side (`blocked_by_me` on the
// blocker's own view, `blocked_by_peer` on the blocked side) -- see getConversationContext's own
// doc comment for why `getUserProfile`'s similar-looking `capabilities.can_send` can't be reused for
// this (403s entirely from the blocked party's own perspective instead of reporting false).
const conversationContextQuery = useQuery({
queryKey: ['conversation-context', topicId],
queryFn: () => getConversationContext(topicId || ''),
enabled: !!topicId && !isGroup && !isSelf,
});
const blockedNotice =
conversationContextQuery.data?.relationship === 'blocked_by_me'
? '你已将对方加入黑名单,无法发送消息'
: conversationContextQuery.data?.relationship === 'blocked_by_peer'
? '对方已将你加入黑名单,暂时无法发送消息'
: conversationContextQuery.data?.can_send === false
? '暂时无法向对方发送消息'
: null;
const membersQuery = useQuery({
queryKey: ['group-members', topicId],
queryFn: () => getAllGroupMembers(topicId || ''),
enabled: isGroup,
});
const groupProfileQuery = useQuery({
queryKey: ['group', topicId],
queryFn: () => getGroup(topicId || ''),
enabled: isGroup,
});
const groupSummaryQuery = useQuery({
queryKey: ['group-summary', topicId],
queryFn: () => getGroupSummary(topicId || ''),
enabled: isGroup,
});
const governanceQuery = useQuery({
queryKey: ['group-governance', topicId],
queryFn: () => getGroupGovernance(topicId || ''),
enabled: isGroup,
});
// Real, live-confirmed crash (not theoretical): this used to call `getGroupMutes` (single page,
// `{items, next_cursor}` shape) under query key `['group-mutes', topicId]` -- the EXACT SAME key
// `GroupAdminModal.tsx` uses for `getAllGroupMutes` (all pages, flat array). Both mounted together
// (opening 群管理 from a group's own conversation screen, which is the only way to reach it) means
// whichever query resolves last overwrites the shared cache entry with ITS shape; the other
// consumer then reads `array.items.length` -- `.items` is undefined on a plain array, and unlike
// `mutesQuery.data?.items.length`'s leading `?.`, nothing guards THAT specific property access, so
// it throws and takes down the whole conversation screen. Matching `getAllGroupMutes` here (same
// function, same shape, same key) turns this from two competing queries into one correctly-shared
// one -- also fixes this subline's own under-count for any group with more muted members than a
// single page holds, the same truncation bug `getAllGroupMutes`'s own doc comment already flags.
const mutesQuery = useQuery({
queryKey: ['group-mutes', topicId],
queryFn: () => getAllGroupMutes(topicId || ''),
enabled: isGroup,
});
const memberByUserId = useMemo(() => new Map((membersQuery.data || []).map((m) => [m.user_id, m])), [membersQuery.data]);
// `memberByUserId` only ever holds CURRENT members (`getAllGroupMembers`) -- a historical message
// from someone who has since left/been removed has no entry there, and used to fall all the way
// back to the raw `msg.from` uid string in the bubble (confirmed live, 2026-09-20: a real message
// from a member who then left the group rendered as "usrp77kUAkgqpU" instead of "systest02").
// Fetching each such uid's standalone profile (same `getUserProfile`/`['user-profile', id]` P2P
// already uses above) recovers a real display name/avatar in the common case; `retry:false` and a
// swallowed error mean a stranger this account has no visibility into (403, same as the already-
// documented `usrW9ijv236hHA` case) just falls back to the uid exactly as before -- never worse.
const staleSenderIds = useMemo(() => {
if (!isGroup) return [] as string[];
const ids = new Set<string>();
for (const msg of messages) {
if (msg.from && !memberByUserId.has(msg.from)) ids.add(msg.from);
}
return Array.from(ids);
}, [isGroup, messages, memberByUserId]);
const staleSenderQueries = useQueries({
queries: staleSenderIds.map((uid) => ({
queryKey: ['user-profile', uid],
queryFn: () => getUserProfile(uid),
staleTime: Infinity,
retry: false,
})),
});
const staleSenderById = useMemo(() => {
const map = new Map<string, { display_name: string; avatar_file_id?: string }>();
staleSenderIds.forEach((uid, i) => {
const data = staleSenderQueries[i]?.data;
if (data) map.set(uid, data);
});
return map;
}, [staleSenderIds, staleSenderQueries]);
const myMember = myUserId ? memberByUserId.get(myUserId) : undefined;
const isGroupAdmin = myMember?.role === 'owner' || myMember?.role === 'admin';
const groupSublineParts = [
governanceQuery.data?.join_approval ? '已开启进群验证' : null,
mutesQuery.data?.length ? `${mutesQuery.data.length} 位成员被禁言` : null,
].filter(Boolean);
if (!topicId) return null;
const topic = tinode.getTopic(topicId);
const peerName = isSelf ? meQuery.data?.profile.nickname || '我' : contact?.public?.fn || topicId;
const groupMemberCount = groupProfileQuery.data?.member_count ?? membersQuery.data?.length ?? 0;
const myAvatarFileId = meQuery.data?.profile.avatar_file_id;
const peerAvatarFileId = isSelf ? myAvatarFileId : peerProfileQuery.data?.avatar_file_id;
const headerAvatarFileId = isGroup ? groupProfileQuery.data?.avatar_file_id : peerAvatarFileId;
// Color-hash id, not the file lookup above -- topicId is right for a group (its own identity), but
// for P2P this should be the peer's stable usr id (peerUserId), matching the same hash ContactsListColumn/
// FriendProfileView use for this same person, rather than the p2p topic name (which isP2P/self also
// never had a usr-shaped id for in the first place).
const headerAvatarId = isGroup ? topicId : peerUserId || topicId;
// `peerName` is the GROUP's own name for a group topic, not any member's -- reusing it to label
// "who is being quoted" (as both the reply-composer preview and the actual sent quote block used
// to) meant every single group-chat reply permanently displayed "回复 <群名>:…" for every member,
// regardless of who actually sent the quoted message. `memberByUserId` already resolves this
// correctly per-bubble (senderName below); reused here for the identical lookup rather than a
// second, divergent resolution path.
const quotedSenderLabel = (fromUserId: string | undefined) => {
if (myUserId && fromUserId === myUserId) return '我';
if (isGroup && fromUserId) {
const member = memberByUserId.get(fromUserId);
return groupSenderLabel(member && groupMemberDisplayName(member), staleSenderById.get(fromUserId)?.display_name);
}
return peerName;
};
/** Arms a media send with the pending quote and clears the composer's reply state in one step:
* the quote applies to the NEXT outgoing message only (cross-client rule), and a picked image
* or recorded voice is that message. Returns null when nothing is armed or the quoted message
* has become unquotable in the meantime (send proceeds as a plain media message). */
const consumePendingReply = () => {
const pending = replyTo ? buildReplyInfo(replyTo) : null;
setReplyTo(null);
return pending;
};
/**
* "加载更多" (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 calledForGeneration = openGenerationRef.current;
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 {
// If the conversation was closed and reopened (even back to this SAME topicId) while this was
// in flight, `el` now shows content from a different open/close cycle -- prevTop/prevHeight
// were measured against content that isn't there anymore, so applying them would jump the
// current conversation's scroll to a meaningless spot. Comparing topicId alone would miss an
// A->B->A round trip (same string, different generation); only the specific open this request
// was made for should ever clear its own loading flag or touch the scroll position.
if (openGenerationRef.current === calledForGeneration) {
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('');
setMentionTrigger(null);
const pendingReplyTo = replyTo;
setReplyTo(null);
// The composer's own topic can be detached by the time this actually fires -- e.g. a
// disconnect/reconnect cycle resets every topic's attached state (see connection.ts's
// onReauthenticated), and publishing on a detached topic is rejected outright by the SDK. The
// forward flow above already guards against exactly this (see ensureTopicAttached's own
// comment for why it's the common case, not an edge case); the primary composer -- used far
// more often than forwarding -- had no equivalent guard at all.
ensureTopicAttached(topicId)
.then((target) => {
if (pendingReplyTo) {
// Mentions inside a reply don't get spliced into real MN entities -- a quoted reply that
// also pings someone is a rare enough compound case that it isn't worth a second
// stitching path; the "@name" text still sends fine, just without the entity/highlight.
return sendReply(target, text, pendingReplyTo);
}
if (pendingMentions.length > 0) {
return sendPrebuiltContent(target, buildMentionedContent(text, pendingMentions));
}
return sendText(target, text);
})
.catch((err) => showError(err, '发送失败'));
if (topicId) setMentionsByTopic((prev) => ({ ...prev, [topicId]: [] }));
};
const handleDraftChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setDraft(value);
if (isGroup) setMentionTrigger(detectMentionTrigger(value, e.target.selectionStart ?? value.length));
};
const pickMention = (choice: PendingMention) => {
if (!mentionTrigger || !topicId) return;
const before = draft.slice(0, mentionTrigger.start);
const after = draft.slice(mentionTrigger.start + 1 + mentionTrigger.query.length);
const inserted = `${before}@${choice.label} `;
setDraft(inserted + after);
setMentionsByTopic((prev) => ({ ...prev, [topicId]: [...(prev[topicId] || []), choice] }));
setMentionTrigger(null);
setEmojiOpen(false);
// Matches insertMentionTrigger's own restore below -- without an explicit setSelectionRange
// (deferred so the textarea's DOM value has actually updated to `inserted + after` first), the
// cursor doesn't reliably land right after the just-inserted "@Name ", making it awkward to keep
// typing; a bare focus() alone doesn't move the caret to a specific offset. Deferred via
// setTimeout(0), not requestAnimationFrame -- rAF callbacks are paused outright while the tab is
// backgrounded/hidden (confirmed live: an rAF scheduled right after a click never fired at all
// in a hidden tab, a setTimeout(0) scheduled the same way fired normally), and this restore needs
// to run regardless of whether the tab happens to be foregrounded at that exact moment.
const el = textareaRef.current;
setTimeout(() => {
el?.focus();
el?.setSelectionRange(inserted.length, inserted.length);
}, 0);
};
/** README §4.3's composer "@" toolbar icon -- same effect as typing '@' by hand, for groups. */
const insertMentionTrigger = () => {
const el = textareaRef.current;
const pos = el?.selectionStart ?? draft.length;
const needsSpace = pos > 0 && !/\s/.test(draft[pos - 1] ?? ' ');
const insertion = `${needsSpace ? ' ' : ''}@`;
const next = draft.slice(0, pos) + insertion + draft.slice(pos);
setDraft(next);
setMentionTrigger({ start: pos + insertion.length - 1, query: '' });
setEmojiOpen(false);
setTimeout(() => {
el?.focus();
el?.setSelectionRange(pos + insertion.length, pos + insertion.length);
}, 0);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape' && mentionTrigger) {
setMentionTrigger(null);
return;
}
// A picked mention should delete as one atomic "@label" unit on Backspace, not character by
// character (README §4.3 doesn't spell this out explicitly, but every real chat app treats an
// inserted mention this way, and a user report confirmed the plain-textarea default -- delete
// one character at a time -- read as broken here specifically). Only intercepts a plain caret
// (no active selection) sitting right after one of THIS topic's actually-tracked mentions, so
// an unrelated hand-typed "foo@bar" is untouched.
if (e.key === 'Backspace' && topicId && pendingMentions.length > 0) {
const el = e.currentTarget;
if (el.selectionStart === el.selectionEnd) {
const pos = el.selectionStart;
const before = draft.slice(0, pos);
const hit = pendingMentions.find((m) => before.endsWith(`@${m.label} `) || before.endsWith(`@${m.label}`));
if (hit) {
const token = before.endsWith(`@${hit.label} `) ? `@${hit.label} ` : `@${hit.label}`;
const newBefore = before.slice(0, before.length - token.length);
const next = newBefore + draft.slice(pos);
e.preventDefault();
setDraft(next);
setMentionsByTopic((prev) => ({ ...prev, [topicId]: (prev[topicId] || []).filter((m) => m !== hit) }));
requestAnimationFrame(() => el.setSelectionRange(newBefore.length, newBefore.length));
return;
}
}
}
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 = async () => {
if (selectedSeqs.size === 0) return;
if (!(await requestConfirm({ message: `删除已选择的 ${selectedSeqs.size} 条消息?`, danger: true }))) return;
topic.delMessagesList([...selectedSeqs], false);
exitSelectMode();
};
const bulkFavorite = () => {
if (selectedSeqs.size === 0) return;
// Same recalled-mid-session race as bulkForward above -- a message favorited with null content
// would just be a useless empty entry in 我的收藏 with no way to tell what it used to say.
const seqs = [...selectedSeqs].filter((seq) => {
const msg = topic.findMessage(seq);
return !msg || !isRecalled(msg);
});
if (seqs.length) {
favoriteMessages(seqs.map((seq) => ({ topic: topicId, seq })))
.then(notifyFavoriteResult)
.catch((err) => showError(err, '收藏失败'));
}
exitSelectMode();
};
const bulkForward = (targetTopics: string[], comment: string) => {
// `selectedSeqs` is a Set, which iterates in INSERTION (click) order, not numeric order -- a
// user who happened to click message #10 before #3 would otherwise forward them in that click
// order, landing #10 ahead of #3 in the target conversation and misrepresenting which one was
// actually said first.
const orderedSeqs = [...selectedSeqs].sort((a, b) => a - b);
// A message can be recalled by its sender AFTER it was already selected (the checkbox itself
// refuses a NEW selection of a recalled message, but can't retroactively un-select one recalled
// mid-session) -- forwarding null content would silently send an empty bubble with a misleading
// "forwarded from" tag rather than the message the user actually picked. Checkbox selection also
// doesn't refuse a still-sending/failed message (its placeholder seq is a plain `number`, same as
// a real one) -- that message has no server-side (topic, seq) yet for forwardMessages() to find.
const sources = orderedSeqs
.map((seq) => topic.findMessage(seq))
.filter((msg): msg is StoredMessage => !!msg && !isRecalled(msg) && isConfirmedSeq(msg))
.map((msg) => ({ topic: topicId, seq: msg.seq as number }));
// One REST call now covers every (source, target) pair -- forwardMessages()'s own comment
// explains why this replaced per-target `ensureTopicAttached()` + per-message client-side
// `{pub}` publishes (was N sources x M targets separate WS sends, each independently racing
// subscription/attachment; the server handles every pair in one request with real per-pair
// success/failure reporting instead of each WS publish failing silently on its own).
//
// The comment send is chained AFTER this promise settles, not fired in parallel -- confirmed
// live (2026-09-25) that firing both at once is a real, reproducible bug, not a theoretical one:
// the endpoint's response comes back with per-item `status:"pending"`/`reason:
// "MESSAGE_PERSIST_PENDING"` (it acks the request before the forwarded messages are actually
// written server-side), while the comment's own sendText() goes over the plain WS publish path,
// which is fast enough to land FIRST. A 2-source bulk forward reordered exactly this way in a
// live test before this fix (comment appeared above both forwarded messages). Chaining at least
// waits for the request round-trip before starting the comment send, which resolved the
// reordering in the same live scenario after this fix -- see this function's own follow-up
// verification, not a guarantee for every possible load/latency condition given the endpoint's
// own async "pending" semantics.
const forwardDone =
sources.length > 0
? forwardMessages(sources, targetTopics)
.then((res) => {
if (res.failed.length > 0) showError(undefined, `${res.failed.length} 条消息转发失败`);
// 'sent'/'pending' both mean the server has it; 'uncertain' (e.g. a timeout mid-write)
// does not -- silently lumping it in with success would leave the user with no signal
// that this particular copy may never actually land in the target conversation.
const uncertain = res.items.filter((item) => item.status === 'uncertain').length;
if (uncertain > 0) notify(`${uncertain} 条转发结果暂不确定,请稍后在目标会话中确认`);
})
.catch((err) => showError(err, '转发失败'))
: Promise.resolve();
if (comment) {
forwardDone.then(() => {
for (const targetTopic of targetTopics) {
ensureTopicAttached(targetTopic)
.then((target) => sendText(target, comment))
.catch((err) => showError(err, '附言发送失败'));
}
});
}
setBulkForwardOpen(false);
exitSelectMode();
};
const handleAction = (action: MessageAction) => {
const msg = menu?.msg;
setMenu(null);
if (!msg || typeof msg.seq !== 'number') return;
switch (action) {
case 'copy': {
// Real, live-confirmed bug: a flat non-Drafty text message (e.g. the group-announcement
// auto-post, `{text:"..."}` not `{txt:"..."}`) isn't excluded from 'copy' by availableActions
// (it reads fine in the bubble via renderDrafty's own flatTextContentOf fallback -- only this
// handler's `Drafty.toPlainText` didn't know about it), so "复制" silently put the literal
// string "undefined" on the clipboard instead of the text visibly shown on screen.
// Final `?? ''` is real, not paranoia: `availableActions` only excludes 'copy' for a mobile
// attachment whose payload passes `mobileAttachmentContentOf`'s own validation (needs a real
// `file_id`/`ref`) -- a malformed/partial attachment payload that fails that check falls
// through to here with neither a `.text` nor a `.txt` field, and `Drafty.toPlainText` would
// return `undefined` again, which `writeText` coerces to the literal string "undefined" same
// as the original bug -- this stops that regardless of exactly which shape slips through.
const flatText = flatTextContentOf(msg.content);
navigator.clipboard.writeText(flatText ?? Drafty.toPlainText(msg.content ?? '') ?? '').catch(() => {});
break;
}
case 'recall':
recallMessage(topicId, msg.seq)
.then(() => topic.delMessagesList([msg.seq], true))
.catch((err) => showError(err, '撤回失败'));
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 }])
.then(notifyFavoriteResult)
.catch((err) => showError(err, '收藏失败'));
break;
case 'forward':
setForwardMsg(msg);
break;
}
};
const handlePickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) {
const reply = consumePendingReply();
sendImage(topic, file, reply).catch((err) => showError(err, '图片发送失败'));
}
};
const handlePickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) {
const reply = consumePendingReply();
sendFile(topic, file, reply).catch((err) => showError(err, '文件发送失败'));
}
};
/** User report (老板, 2026-09-29): "没有复制粘贴图片功能,只能保存在电脑里面发" -- e.g. a screenshot
* tool, or "复制图片" from a browser/chat app, only ever puts raw pixel data on the clipboard, never
* a File the hidden `<input type="file">` above can see; without this, getting that image into a
* message required saving it to disk first and picking it via handlePickImage. `item.getAsFile()`
* returns a real File, indistinguishable from one chosen through the file picker, so this reuses
* the exact same send path. Only preventDefault once an actual image item is found -- an ordinary
* text paste (no image on the clipboard at all) is left completely untouched. */
const handleComposerPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile();
if (!file) continue;
e.preventDefault();
const reply = consumePendingReply();
sendImage(topic, file, reply).catch((err) => showError(err, '图片发送失败'));
return;
}
}
};
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>
<button type="button" onClick={onToggleDetail} className="flex items-center gap-2.5 text-left">
<Avatar id={headerAvatarId} name={peerName} avatarFileId={headerAvatarFileId} className="h-9.5 w-9.5 flex-none rounded-lg text-sm" />
<div className="flex flex-col">
<strong className="text-base font-semibold text-text-heading">
{peerName}
{isGroup ? <span className="ml-1 font-normal text-text-tertiary-2">({groupMemberCount})</span> : null}
</strong>
{isGroup ? (
groupSublineParts.length ? <span className="text-xs text-text-tertiary-2">{groupSublineParts.join(' · ')}</span> : null
) : isSelf ? null : (
<span className={`text-xs ${contact?.online ? 'text-online' : 'text-text-tertiary-2'}`}>
{contact?.online ? '在线' : '离线'}
</span>
)}
</div>
</button>
</div>
<div className="flex items-center gap-1">
{isGroup || isSelf ? null : (
<>
<button
type="button"
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'audio');
}}
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1"
>
<Icon name="call" />
</button>
<button
type="button"
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'video');
}}
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1"
>
<Icon name="videocam" />
</button>
</>
)}
<button
type="button"
onClick={() => onOpenHistory('text')}
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1"
>
<Icon name="search" />
</button>
<button
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>
{isGroup && groupSummaryQuery.data?.announcement?.content ? (
<button
type="button"
onClick={() => setGroupModal('announcement')}
className="flex items-center gap-2 border-b border-[#eceef1] bg-[#fff8e6] px-5 py-2.5 text-left text-[13px] text-[#6d4c41]"
>
<Icon name="campaign" className="flex-none text-[18px] text-announce" />
<strong className="flex-none font-semibold">群公告</strong>
<span className="min-w-0 flex-1 truncate">{groupSummaryQuery.data.announcement.content}</span>
<Icon name="chevron_right" className="flex-none text-[18px] text-[#c2a26c]" />
</button>
) : null}
{isGroup && isGroupAdmin && !!groupSummaryQuery.data?.pending_join_request_count ? (
<div className="flex items-center gap-2 border-b border-[#eceef1] bg-[#e8f1fe] px-5 py-2.5 text-[13px] text-[#1a4f9c]">
<Icon name="person_add" className="flex-none text-[18px] text-accent" />
<span className="min-w-0 flex-1 truncate">{groupSummaryQuery.data.pending_join_request_count} 条新的进群申请</span>
<button type="button" onClick={() => setGroupModal('join-requests')} className="flex-none font-medium text-accent">
去处理
</button>
</div>
) : null}
<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) => {
const sender = isGroup && msg.from ? memberByUserId.get(msg.from) : undefined;
const staleSender = isGroup && msg.from && !sender ? staleSenderById.get(msg.from) : undefined;
const mine = !!myUserId && msg.from === myUserId;
const rowAvatarFileId = mine
? myAvatarFileId
: isGroup
? sender?.avatar_file_id || staleSender?.avatar_file_id
: peerAvatarFileId;
return (
<MessageRow
key={msg.seq}
topic={topic}
msg={msg}
mine={mine}
isGroup={isGroup}
peerName={peerName}
senderName={sender ? groupMemberDisplayName(sender) : staleSender?.display_name}
senderRole={sender?.role}
avatarFileId={rowAvatarFileId}
selectMode={selectMode}
selected={typeof msg.seq === 'number' && selectedSeqs.has(msg.seq)}
menuActive={typeof msg.seq === 'number' && menu?.msg.seq === msg.seq}
highlighted={typeof msg.seq === 'number' && highlightedSeq === msg.seq}
onToggleSelect={toggleSelect}
onContextMenu={(e, m) => setMenu({ x: e.clientX, y: e.clientY, msg: m })}
resolveQuotedName={quotedSenderLabel}
onError={setErrorToast}
/>
);
})}
</div>
{selectMode ? (
<div className="flex h-18 flex-none items-center justify-center gap-10 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 ref={mentionContainerRef} className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
{blockedNotice ? (
<div className="flex h-18 items-center justify-center text-sm text-text-tertiary-3">{blockedNotice}</div>
) : (
<>
{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">
回复 {quotedSenderLabel(replyTo.from)}:{replySnapshotText(replyTo)}
</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-0.5 px-1 pb-1">
<div ref={emojiContainerRef} className="relative">
<button
type="button"
onClick={() => {
setEmojiOpen((v) => !v);
setMentionTrigger(null);
}}
className={`grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] ${emojiOpen ? 'bg-accent-soft-bg text-accent' : 'text-icon-default hover:bg-[#f2f4f7]'}`}
>
<Icon name="sentiment_satisfied_alt" />
</button>
{emojiOpen ? (
<div className="absolute bottom-full left-0 z-20 mb-2">
<EmojiPanel
onPick={(emoji) => {
// Same restore-focus-and-caret dance as insertMentionTrigger/pickMention above
// -- clicking the emoji button moves DOM focus to it like any button click,
// and without this the textarea stays unfocused until the user clicks back
// into it (confirmed by a user report: Enter didn't send right after picking
// an emoji because the textarea itself had lost focus). Inserts at the actual
// caret position rather than always appending to the end, matching how a
// mention insertion already behaves, so the two don't disagree about where
// "here" is. setTimeout(0), not requestAnimationFrame -- see insertMentionTrigger's
// own comment for why (rAF never fires at all while the tab is hidden).
const el = textareaRef.current;
const pos = el?.selectionStart ?? draft.length;
const next = draft.slice(0, pos) + emoji + draft.slice(pos);
setDraft(next);
setEmojiOpen(false);
setTimeout(() => {
el?.focus();
el?.setSelectionRange(pos + emoji.length, pos + emoji.length);
}, 0);
}}
/>
</div>
) : null}
</div>
<label className="grid h-8.5 w-8.5 cursor-pointer place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]">
<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-md text-[22px] text-icon-default hover:bg-[#f2f4f7]">
<Icon name="attach_file" />
<input type="file" onChange={handlePickFile} className="hidden" />
</label>
<button
type="button"
onClick={notImplemented}
className="grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]"
>
<Icon name="contact_page" />
</button>
{isGroup ? (
<button
type="button"
onClick={insertMentionTrigger}
className="grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]"
>
<Icon name="alternate_email" />
</button>
) : null}
<button
type="button"
onClick={() => setRecording(true)}
className="grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]"
>
<Icon name="mic_none" />
</button>
<button
type="button"
onClick={() => onOpenHistory('text')}
className="grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]"
>
<Icon name="history" />
</button>
{mentionTrigger && isGroup ? (
<div className="absolute bottom-full left-0 z-20 mb-2">
<MentionPicker topicId={topicId} query={mentionTrigger.query} onPick={pickMention} />
</div>
) : null}
</div>
{recording ? (
<VoiceRecorder
onCancel={() => setRecording(false)}
onSend={(blob, durationMs) => {
setRecording(false);
const reply = consumePendingReply();
sendVoice(topic, blob, durationMs, reply).catch((err) => showError(err, '语音发送失败'));
}}
/>
) : (
<>
{/* textarea + send button must share one flex row (design: 02-messages.dc.html's
`display:flex; align-items:flex-end; gap:12px`) -- they were previously two block-level
siblings with no shared flex parent (the actual parent only had `flex-none`, a flex-CHILD
property that does nothing to make ITSELF a flex container), so the button fell to its
own line below the textarea instead of sitting beside it. */}
<div className="flex items-end gap-3">
<textarea
ref={textareaRef}
value={draft}
onChange={handleDraftChange}
onKeyDown={handleKeyDown}
onPaste={handleComposerPaste}
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
className="min-h-[72px] flex-1 resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1"
/>
<button
type="button"
onClick={submit}
disabled={!draft.trim()}
className="h-9 flex-none rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn-sm 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)}
recallRemainingSeconds={recallRemainingSeconds(menu.msg, myUserId, bootstrap.data?.message_policy)}
onAction={handleAction}
onClose={() => setMenu(null)}
/>
) : null}
{forwardMsg ? (
<ForwardPickerModal
onClose={() => setForwardMsg(null)}
onPick={(targetTopics, comment) => {
// Comment is chained AFTER the forward call settles, not fired in parallel -- see
// `bulkForward`'s own comment above for why this matters (confirmed live: firing both
// at once lets the comment's plain WS send outrace the forward endpoint's own async
// "pending" persistence, landing the comment BEFORE the forwarded content it's meant to
// follow).
const forwardDone =
isConfirmedSeq(forwardMsg)
? forwardMessages([{ topic: topicId, seq: forwardMsg.seq as number }], targetTopics)
.then((res) => {
if (res.failed.length > 0) showError(undefined, `${res.failed.length} 个目标转发失败`);
const uncertain = res.items.filter((item) => item.status === 'uncertain').length;
if (uncertain > 0) notify(`${uncertain} 个目标转发结果暂不确定,请稍后在目标会话中确认`);
})
.catch((err) => showError(err, '转发失败'))
: Promise.resolve();
if (comment) {
forwardDone.then(() => {
for (const targetTopic of targetTopics) {
ensureTopicAttached(targetTopic)
.then((target) => sendText(target, comment))
.catch((err) => showError(err, '附言发送失败'));
}
});
}
setForwardMsg(null);
}}
/>
) : null}
{bulkForwardOpen ? <ForwardPickerModal onClose={() => setBulkForwardOpen(false)} onPick={bulkForward} /> : null}
{groupModal === 'announcement' ? (
<GroupAnnouncementModal topicId={topicId} canEdit={isGroupAdmin} onClose={() => setGroupModal(null)} />
) : null}
{groupModal === 'join-requests' ? <JoinRequestsModal topicId={topicId} onClose={() => setGroupModal(null)} /> : null}
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
<Toast message={successToast} variant="success" onDone={() => setSuccessToast('')} />
</div>
);
}