feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes
Rounds out the full rewrite with the remaining feature domains: group management (admin panel, mute, files, announcements, QR-based invite/join), contacts (friend profile, add-friend, blocklist, new-friends), favorites (real media rendering across text/image/video/voice/file/link), settings (profile/security/privacy/general/about), calls (Agora-backed audio/video signaling + call history), and system messages/file assistant. Also folds in a large batch of live-verified fixes surfaced by repeated fresh code-review passes and hands-on E2E testing against the real backend, including: WS reconnect not resubscribing any topic after a disconnect, missing x-im-client-mid on every send path (protocol compliance for retry dedup), group mute/member-settings/self-profile endpoint corrections, relationship-state type fixes across search/QR/ friend-profile, tenant-switch logout gap, password-policy now read from the real per-tenant config instead of guessed, NavRail highlight/ aria-current desync, several call-state-machine races, and responsive layout fixes across all four breakpoints (mobile/tablet/laptop/desktop) including a full mobile rebuild of favorites, call UI, and the settings home/sub-list split. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useOutletContext, useParams } from 'react-router-dom';
|
||||
import type { StoredMessage } from 'tinode-sdk';
|
||||
import { Drafty } from 'tinode-sdk';
|
||||
@@ -8,28 +9,35 @@ 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, type MediaType, recallMessage } from '@/lib/api/messages';
|
||||
import { getAllGroupMembers, getGroup, getGroupGovernance, getGroupMutes, getGroupSummary } from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
|
||||
import { useClickOutside } from '@/lib/hooks';
|
||||
import { availableActions, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions';
|
||||
import { isRecalled } from '@/lib/messages/dispatch';
|
||||
import { renderDrafty } from '@/lib/messages/drafty';
|
||||
import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions';
|
||||
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
|
||||
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
|
||||
import { forwardMessage, sendReply, sendText } from '@/lib/tinode/send';
|
||||
import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send';
|
||||
import { closeConversation, openConversation } from '@/lib/tinode/topics';
|
||||
import { useCallStore } from '@/stores/calls';
|
||||
import { useMessageJumpStore } from '@/stores/messageJump';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
interface OutletCtx {
|
||||
onToggleDetail: () => void;
|
||||
/** Whether the detail panel is CURRENTLY shown -- true always at >=1280px (docked, no close
|
||||
* affordance), true/false at 960-1279px depending on the drawer's open state. Drives the header
|
||||
* "i" button's highlight; previously hardcoded to the breakpoint itself (always blue at
|
||||
* 960-1279px regardless of the drawer's real state, always grey at >=1280px despite the panel
|
||||
* being permanently open there) rather than this actual state. */
|
||||
/** 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). */
|
||||
@@ -42,6 +50,10 @@ interface ContextMenuState {
|
||||
msg: StoredMessage;
|
||||
}
|
||||
|
||||
function notImplemented() {
|
||||
window.alert('该功能暂未开放,如需联系客服。');
|
||||
}
|
||||
|
||||
export function ConversationView() {
|
||||
const { topicId } = useParams();
|
||||
const { onToggleDetail, detailVisible, onOpenHistory } = useOutletContext<OutletCtx>();
|
||||
@@ -60,6 +72,15 @@ export function ConversationView() {
|
||||
const setDraft = (value: string) => {
|
||||
if (topicId) setDraftByTopic((prev) => ({ ...prev, [topicId]: value }));
|
||||
};
|
||||
// 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());
|
||||
@@ -69,9 +90,22 @@ export function ConversationView() {
|
||||
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
|
||||
@@ -85,6 +119,15 @@ export function ConversationView() {
|
||||
// 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;
|
||||
@@ -107,6 +150,11 @@ export function ConversationView() {
|
||||
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
|
||||
@@ -121,6 +169,14 @@ export function ConversationView() {
|
||||
// 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]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -132,15 +188,99 @@ export function ConversationView() {
|
||||
// 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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!topicId || messages.length === 0) return;
|
||||
tinode.getTopic(topicId).noteRead(0);
|
||||
}, [topicId, messages.length, 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();
|
||||
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,
|
||||
});
|
||||
const mutesQuery = useQuery({
|
||||
queryKey: ['group-mutes', topicId],
|
||||
queryFn: () => getGroupMutes(topicId || ''),
|
||||
enabled: isGroup,
|
||||
});
|
||||
const memberByUserId = useMemo(() => new Map((membersQuery.data || []).map((m) => [m.user_id, m])), [membersQuery.data]);
|
||||
const myMember = myUserId ? memberByUserId.get(myUserId) : undefined;
|
||||
const isGroupAdmin = myMember?.role === 'owner' || myMember?.role === 'admin';
|
||||
const groupSublineParts = [
|
||||
governanceQuery.data?.join_approval ? '已开启进群验证' : null,
|
||||
mutesQuery.data?.items.length ? `${mutesQuery.data.items.length} 位成员被禁言` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
if (!topicId) return null;
|
||||
const topic = tinode.getTopic(topicId);
|
||||
const peerName = contact?.public?.fn || topicId;
|
||||
const isGroup = topic.isGroupType();
|
||||
const groupMemberCount = groupProfileQuery.data?.member_count ?? membersQuery.data?.length ?? 0;
|
||||
// `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) return memberByUserId.get(fromUserId)?.display_name || fromUserId;
|
||||
return peerName;
|
||||
};
|
||||
|
||||
/**
|
||||
* "加载更多" (README §3.2/old app's messages-view.jsx): not simple since/before paging -- computes
|
||||
@@ -190,16 +330,92 @@ export function ConversationView() {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
setDraft('');
|
||||
setMentionTrigger(null);
|
||||
if (replyTo) {
|
||||
const label = myUserId && replyTo.from === myUserId ? '我' : peerName;
|
||||
sendReply(topic, text, replyTo, label);
|
||||
// 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.
|
||||
sendReply(topic, text, replyTo, quotedSenderLabel(replyTo.from)).catch((err) => showError(err, '发送失败'));
|
||||
setReplyTo(null);
|
||||
} else if (pendingMentions.length > 0) {
|
||||
sendPrebuiltContent(topic, buildMentionedContent(text, pendingMentions)).catch((err) => showError(err, '发送失败'));
|
||||
} else {
|
||||
sendText(topic, text);
|
||||
sendText(topic, 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 a frame 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.
|
||||
const el = textareaRef.current;
|
||||
requestAnimationFrame(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(inserted.length, inserted.length);
|
||||
});
|
||||
};
|
||||
|
||||
/** 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);
|
||||
requestAnimationFrame(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(pos + insertion.length, pos + insertion.length);
|
||||
});
|
||||
};
|
||||
|
||||
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/
|
||||
@@ -232,15 +448,37 @@ export function ConversationView() {
|
||||
|
||||
const bulkFavorite = () => {
|
||||
if (selectedSeqs.size === 0) return;
|
||||
favoriteMessages([...selectedSeqs].map((seq) => ({ topic: topicId, seq }))).catch((err) => showError(err, '收藏失败'));
|
||||
// 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 = (targetTopic: string) => {
|
||||
const target = tinode.getTopic(targetTopic);
|
||||
for (const seq of selectedSeqs) {
|
||||
const msg = topic.findMessage(seq);
|
||||
if (msg) forwardMessage(topicId, msg, target).catch((err) => showError(err, '转发失败'));
|
||||
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);
|
||||
for (const targetTopic of targetTopics) {
|
||||
const target = tinode.getTopic(targetTopic);
|
||||
for (const seq of orderedSeqs) {
|
||||
const msg = topic.findMessage(seq);
|
||||
// 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.
|
||||
if (msg && !isRecalled(msg)) forwardMessage(topicId, msg, target).catch((err) => showError(err, '转发失败'));
|
||||
}
|
||||
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
|
||||
}
|
||||
setBulkForwardOpen(false);
|
||||
exitSelectMode();
|
||||
@@ -270,7 +508,9 @@ export function ConversationView() {
|
||||
setReplyTo(msg);
|
||||
break;
|
||||
case 'favorite':
|
||||
favoriteMessages([{ topic: topicId, seq: msg.seq }]).catch((err) => showError(err, '收藏失败'));
|
||||
favoriteMessages([{ topic: topicId, seq: msg.seq }])
|
||||
.then(notifyFavoriteResult)
|
||||
.catch((err) => showError(err, '收藏失败'));
|
||||
break;
|
||||
case 'forward':
|
||||
setForwardMsg(msg);
|
||||
@@ -312,19 +552,42 @@ export function ConversationView() {
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<strong className="text-base font-semibold text-text-heading">{peerName}</strong>
|
||||
<span className={`text-xs ${contact?.online ? 'text-online' : 'text-text-tertiary-2'}`}>
|
||||
{contact?.online ? '在线' : '离线'}
|
||||
</span>
|
||||
<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
|
||||
) : (
|
||||
<span className={`text-xs ${contact?.online ? 'text-online' : 'text-text-tertiary-2'}`}>
|
||||
{contact?.online ? '在线' : '离线'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
|
||||
<Icon name="call" />
|
||||
</button>
|
||||
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
|
||||
<Icon name="videocam" />
|
||||
</button>
|
||||
{isGroup ? 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')}
|
||||
@@ -344,27 +607,56 @@ export function ConversationView() {
|
||||
)}
|
||||
</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) => (
|
||||
<MessageRow
|
||||
key={msg.seq}
|
||||
topic={topic}
|
||||
msg={msg}
|
||||
mine={!!myUserId && msg.from === myUserId}
|
||||
isGroup={isGroup}
|
||||
peerName={peerName}
|
||||
selectMode={selectMode}
|
||||
selected={typeof msg.seq === 'number' && selectedSeqs.has(msg.seq)}
|
||||
menuActive={typeof msg.seq === 'number' && menu?.msg.seq === msg.seq}
|
||||
onToggleSelect={toggleSelect}
|
||||
onContextMenu={(e, m) => setMenu({ x: e.clientX, y: e.clientY, msg: m })}
|
||||
/>
|
||||
))}
|
||||
{messages.map((msg) => {
|
||||
const sender = isGroup && msg.from ? memberByUserId.get(msg.from) : undefined;
|
||||
return (
|
||||
<MessageRow
|
||||
key={msg.seq}
|
||||
topic={topic}
|
||||
msg={msg}
|
||||
mine={!!myUserId && msg.from === myUserId}
|
||||
isGroup={isGroup}
|
||||
peerName={peerName}
|
||||
senderName={sender?.display_name}
|
||||
senderRole={sender?.role}
|
||||
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 })}
|
||||
onError={setErrorToast}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectMode ? (
|
||||
<div className="flex h-18 flex-none items-center justify-around border-t border-border-panel bg-white">
|
||||
<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)}
|
||||
@@ -389,52 +681,83 @@ export function ConversationView() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
|
||||
<div ref={mentionContainerRef} className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
|
||||
{replyTo ? (
|
||||
<div className="mb-2 flex items-center justify-between rounded-lg bg-surface-input px-3 py-1.5 text-xs text-text-tertiary-1">
|
||||
<span className="truncate">
|
||||
回复 {myUserId && replyTo.from === myUserId ? '我' : peerName}:{renderDrafty(replyTo.content)}
|
||||
回复 {quotedSenderLabel(replyTo.from)}:{renderDrafty(replyTo.content)}
|
||||
</span>
|
||||
<button type="button" onClick={() => setReplyTo(null)} className="flex-none text-text-tertiary-3">
|
||||
<Icon name="close" className="text-[16px]" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="relative flex items-center gap-1 px-1 pb-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmojiOpen((v) => !v)}
|
||||
className={`grid h-8.5 w-8.5 place-items-center rounded text-[22px] ${emojiOpen ? 'bg-accent-soft-bg text-accent' : 'text-icon-default'}`}
|
||||
>
|
||||
<Icon name="sentiment_satisfied_alt" />
|
||||
</button>
|
||||
{emojiOpen ? (
|
||||
<div className="absolute bottom-full left-0 z-20 mb-2">
|
||||
<EmojiPanel
|
||||
onPick={(emoji) => {
|
||||
setDraft(draft + emoji);
|
||||
setEmojiOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<label className="grid h-8.5 w-8.5 cursor-pointer place-items-center rounded text-[22px] text-icon-default">
|
||||
<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) => {
|
||||
setDraft(draft + emoji);
|
||||
setEmojiOpen(false);
|
||||
}}
|
||||
/>
|
||||
</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 text-[22px] text-icon-default">
|
||||
<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>
|
||||
<Icon name="contact_page" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={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 text-[22px] text-icon-default"
|
||||
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>
|
||||
<Icon name="history" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
||||
<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
|
||||
@@ -446,19 +769,25 @@ export function ConversationView() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
|
||||
className="min-h-[72px] w-full resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
{/* 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}
|
||||
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 rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled disabled:shadow-none"
|
||||
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>
|
||||
@@ -482,8 +811,12 @@ export function ConversationView() {
|
||||
{forwardMsg ? (
|
||||
<ForwardPickerModal
|
||||
onClose={() => setForwardMsg(null)}
|
||||
onPick={(targetTopic) => {
|
||||
forwardMessage(topicId, forwardMsg, tinode.getTopic(targetTopic)).catch((err) => showError(err, '转发失败'));
|
||||
onPick={(targetTopics, comment) => {
|
||||
for (const targetTopic of targetTopics) {
|
||||
const target = tinode.getTopic(targetTopic);
|
||||
forwardMessage(topicId, forwardMsg, target).catch((err) => showError(err, '转发失败'));
|
||||
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
|
||||
}
|
||||
setForwardMsg(null);
|
||||
}}
|
||||
/>
|
||||
@@ -491,7 +824,13 @@ export function ConversationView() {
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user