1318 lines
72 KiB
TypeScript
1318 lines
72 KiB
TypeScript
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>
|
||
);
|
||
}
|