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(); 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>({}); 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>({}); // 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>({}); // 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>(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 } | 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>({}); 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(null); const [menu, setMenu] = useState(null); const [selectMode, setSelectMode] = useState(false); const [selectedSeqs, setSelectedSeqs] = useState>(new Set()); const [loadingOlder, setLoadingOlder] = useState(false); const [recording, setRecording] = useState(false); const [bulkForwardOpen, setBulkForwardOpen] = useState(false); const [replyTo, setReplyTo] = useState(null); const [forwardMsg, setForwardMsg] = useState(null); const [emojiOpen, setEmojiOpen] = useState(false); const emojiContainerRef = useRef(null); const mentionContainerRef = useRef(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(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(null); const pendingJumpSeqRef = useRef(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(); 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(); 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) => { 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) => { 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) => { 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) => { 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 `` 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) => { 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 (
{selectMode ? ( <> 已选择 {selectedSeqs.size} 条消息 ) : ( <>
{isGroup || isSelf ? null : ( <> )}
)}
{isGroup && groupSummaryQuery.data?.announcement?.content ? ( ) : null} {isGroup && isGroupAdmin && !!groupSummaryQuery.data?.pending_join_request_count ? (
{groupSummaryQuery.data.pending_join_request_count} 条新的进群申请
) : null}
{loadingOlder ?
加载中…
: 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 ( setMenu({ x: e.clientX, y: e.clientY, msg: m })} resolveQuotedName={quotedSenderLabel} onError={setErrorToast} /> ); })}
{selectMode ? (
) : (
{blockedNotice ? (
{blockedNotice}
) : ( <> {replyTo ? (
回复 {quotedSenderLabel(replyTo.from)}:{replySnapshotText(replyTo)}
) : null}
{emojiOpen ? (
{ // 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); }} />
) : null}
{isGroup ? ( ) : null} {mentionTrigger && isGroup ? (
) : null}
{recording ? ( 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. */}