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:
Your Name
2026-09-09 20:16:33 +08:00
co-authored by Claude Sonnet 5
parent 72b7b79caf
commit 5c3f3b7653
89 changed files with 10158 additions and 442 deletions
+71 -10
View File
@@ -74,6 +74,58 @@ export function FileAttachment({ data }: { data: FileData }) {
);
}
interface VideoData {
val?: string;
ref?: string;
mime?: string;
preview?: string;
duration?: number;
}
function formatDuration(ms: number | undefined): string {
const totalSeconds = Math.round((ms || 0) / 1000);
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
}
/** README §3.2: 260x170 like ImageAttachment, `play_circle` overlay + duration badge, no controls
* until tapped. The video's own `val`/`mime` describe the full video body (confirmed live via
* favorites' identical `VD` entity shape, docs/design_handoff plan file Part D) -- not paintable as
* an `<img>`, so the static poster comes from the separate inline `preview` field (a real JPEG,
* confirmed by its base64 signature) instead. Tapping swaps the poster for a real `<video>` playing
* from `val`/`ref`, mirroring AudioAttachment's inline-vs-authenticated-fetch duality. */
export function VideoAttachment({ data }: { data: VideoData }) {
const [playing, setPlaying] = useState(false);
const posterInline = typeof data.preview === 'string' ? `data:image/jpeg;base64,${data.preview}` : null;
const videoInline = typeof data.val === 'string' ? `data:${data.mime || 'video/webm'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(videoInline ? undefined : data.ref);
const videoSrc = videoInline || remote.url;
if (playing && videoSrc) {
// eslint-disable-next-line jsx-a11y/media-has-caption -- user-generated chat media, no captions exist to attach.
return <video src={videoSrc} controls autoPlay className="h-[170px] w-[260px] rounded-lg bg-black object-contain" />;
}
return (
<button
type="button"
onClick={() => setPlaying(true)}
disabled={!videoSrc}
className="relative block h-[170px] w-[260px] overflow-hidden rounded-lg bg-surface-rail"
>
{posterInline ? (
<img src={posterInline} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center">
<Icon name={remote.error ? 'broken_image' : 'videocam'} className="text-[28px] text-text-tertiary-3" />
</div>
)}
{videoSrc ? <Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" /> : null}
{typeof data.duration === 'number' ? (
<span className="absolute bottom-1.5 right-1.5 rounded bg-black/50 px-1 text-[11px] text-white">{formatDuration(data.duration)}</span>
) : null}
</button>
);
}
interface AudioData {
val?: string;
ref?: string;
@@ -81,23 +133,24 @@ interface AudioData {
duration?: number;
}
function formatAudioDuration(ms: number | undefined): string {
const totalSeconds = Math.round((ms || 0) / 1000);
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
}
export function AudioAttachment({ data }: { data: AudioData }) {
const inline = typeof data.val === 'string' ? `data:${data.mime || 'audio/webm'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
const src = inline || remote.url;
const [playing, setPlaying] = useState(false);
const [playError, setPlayError] = useState(false);
const [audioEl] = useState(() => (typeof Audio !== 'undefined' ? new Audio() : null));
useEffect(() => {
if (!audioEl) return undefined;
const onEnded = () => setPlaying(false);
audioEl.addEventListener('ended', onEnded);
return () => audioEl.removeEventListener('ended', onEnded);
// Without this, navigating away (or scrolling this bubble out) while a voice message is still
// playing leaves it playing in the background with no control left on screen to stop it.
return () => {
audioEl.removeEventListener('ended', onEnded);
audioEl.pause();
};
}, [audioEl]);
const toggle = () => {
@@ -108,15 +161,23 @@ export function AudioAttachment({ data }: { data: AudioData }) {
return;
}
if (audioEl.src !== src) audioEl.src = src;
audioEl.play().catch(() => {});
setPlaying(true);
setPlayError(false);
audioEl
.play()
.then(() => setPlaying(true))
.catch(() => setPlayError(true));
};
return (
<button type="button" onClick={toggle} disabled={!src} className="flex w-[200px] items-center gap-2">
<Icon name={playing ? 'pause_circle' : 'play_circle'} className="text-[24px] text-accent" />
<Icon
name={playError ? 'error_outline' : playing ? 'pause_circle' : 'play_circle'}
className={`text-[24px] ${playError ? 'text-danger-text' : 'text-accent'}`}
/>
<span className="h-4 flex-1 rounded bg-[repeating-linear-gradient(90deg,#b7c3d4_0px,#b7c3d4_2px,transparent_2px,transparent_5px)]" />
<span className="flex-none text-xs text-text-secondary">{remote.loading ? '…' : formatAudioDuration(data.duration)}</span>
<span className={`flex-none text-xs ${playError ? 'text-danger-text' : 'text-text-secondary'}`}>
{playError ? '播放失败' : remote.loading ? '…' : formatDuration(data.duration)}
</span>
</button>
);
}
+74 -15
View File
@@ -1,22 +1,31 @@
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { CreateGroupModal } from '@/features/groups/CreateGroupModal';
import { fetchAllPages } from '@/lib/api/client';
import { getGroups } from '@/lib/api/groups';
import { useBootstrap } from '@/lib/api/hooks';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatMessagePreview } from '@/lib/messages/preview';
import { formatListTime } from '@/lib/messages/time';
import { useChatList } from '@/lib/tinode/hooks';
import { useChatList, useTinode } from '@/lib/tinode/hooks';
import { useSessionStore } from '@/stores/session';
/**
* README §2.1's two always-pinned utility rows. Real, confirmed backend facts (docs/IM-前后端对接
* 文档.md:909): topic names are fixed ("slf"/"sys"), but 系统消息's unread comes from a separate
* notifications table (not the topic itself) and 文件助手's own dedicated 3-column layout (README
* §9.2) is Phase 8, not built yet -- so both route to the same SectionPlaceholder-backed "later
* phase" destinations the rest of the nav already uses (contacts/favorites/calls/settings), rather
* than a half-working ConversationView that assumes a regular P2P/group contact.
* notifications table (not the topic itself). `/messages/system` renders a real dedicated timeline
* (SystemMessagesView, off `GET /v1/im/system-notifications`) -- nested under MessagesLayout like
* every `:topicId` conversation, not a standalone top-level route, so the chat list column stays
* docked next to it (a user report caught the list vanishing when this was still `/chat/system`,
* a route outside MessagesLayout entirely). `/files` redirects into a real
* ConversationView on the `slf` topic instead -- README §9.2's own dedicated 3-column file browser
* (categories, storage quota, upload progress) has no backing REST API at all (confirmed via a
* research pass), so a plain conversation view is the honest choice over a quota card with invented
* numbers.
*/
const PINNED_ENTRIES = [
{
@@ -24,7 +33,7 @@ const PINNED_ENTRIES = [
badgeKey: 'system_unread_count' as const,
label: '系统消息',
icon: 'notifications',
route: '/chat/system',
route: '/messages/system',
colorClass: 'bg-warn-bg text-warn',
},
{
@@ -37,16 +46,32 @@ const PINNED_ENTRIES = [
},
];
/** Chat-list row sort: by `touched` (last activity) descending. Pinned-conversations-first isn't
* wired yet -- that's driven by the P2P conversation-settings API (GET/PATCH /conversations/{topic}/
* settings {pinned}), which the detail panel doesn't build until its "置顶聊天" toggle lands. */
/** Chat-list row sort: 置顶 (README §2.1's "置顶 > 最近") first, then by `touched` (last activity)
* descending within each group. The pinned flag is read straight off the SDK's own ambient
* `contact.private.pinned` (confirmed present in tinode-sdk.d.ts's `TopicDescPrivate`) rather than
* a separate REST fetch per topic -- DetailColumn's "置顶聊天" toggle already writes through the
* conversation-settings REST endpoint, and the server mirrors that same flag back onto the topic's
* Tinode-level `private` field, so this is free (no extra request) instead of N per-topic fetches
* just to render the list. */
export function ChatListColumn() {
const { topicId } = useParams();
const location = useLocation();
const navigate = useNavigate();
const myUserId = useSessionStore((s) => s.myUserId);
const tinode = useTinode();
const contacts = useChatList();
const bootstrap = useBootstrap();
// Shared with GroupListView's own `['groups','all']` query (same key) -- React Query dedupes the
// two rather than this list issuing its own redundant fetch, and it's one bulk paginated call
// regardless (docs/IM-前后端对接文档.md:1444), not one request per group row.
const groupsQuery = useQuery({ queryKey: ['groups', 'all'], queryFn: () => fetchAllPages((cursor) => getGroups('', '', cursor)) });
const memberCountByTopic = new Map(groupsQuery.data?.map((g) => [g.topic, g.member_count]));
const [query, setQuery] = useState('');
const sorted = [...contacts].sort((a, b) => (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0));
const [creatingGroup, setCreatingGroup] = useState(false);
const sorted = [...contacts].sort((a, b) => {
const pinDelta = (b.private?.pinned ? 1 : 0) - (a.private?.pinned ? 1 : 0);
return pinDelta || (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0);
});
const trimmedQuery = query.trim();
const filtered = trimmedQuery ? sorted.filter((c) => (c.public?.fn || c.topic || '').toLowerCase().includes(trimmedQuery.toLowerCase())) : sorted;
const pinned = PINNED_ENTRIES.filter((entry) => bootstrap.data?.entries?.[entry.key]?.enabled);
@@ -55,7 +80,11 @@ export function ChatListColumn() {
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
<header className="flex h-15 flex-none items-center justify-between px-5">
<h1 className="m-0 text-xl font-semibold text-text-heading">消息</h1>
<Icon name="add_comment" className="text-text-heading" />
{/* README §5.4's 添加好友 entry point is Phase 4 (contacts), not built yet -- this only
opens 创建群聊 (§4.7) for now, the one thing this icon can actually do today. */}
<button type="button" onClick={() => setCreatingGroup(true)} className="grid h-8 w-8 place-items-center text-text-heading">
<Icon name="add_comment" />
</button>
</header>
<div className="mx-3 mb-2.5 flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
@@ -69,11 +98,17 @@ export function ChatListColumn() {
<div className="flex flex-1 flex-col overflow-y-auto">
{pinned.map((entry) => {
const unread = bootstrap.data?.entry_badges?.[entry.badgeKey] || 0;
// `/files` redirects to `/messages/slf`, so its "currently open" check is keyed off the
// resulting topicId rather than the route it navigates from, unlike `/messages/system`
// which is the real, final path.
const active = entry.route === '/files' ? topicId === 'slf' : location.pathname === entry.route;
return (
<Link
key={entry.key}
to={entry.route}
className="grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item bg-white py-2.5 pl-4 pr-3 no-underline hover:bg-[#f6f8fb]"
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-border-list-item py-2.5 pl-4 pr-3 no-underline ${
active ? 'bg-accent-selected-bg' : 'bg-white hover:bg-[#f6f8fb]'
}`}
>
<span className={`grid h-11 w-11 place-items-center rounded-lg ${entry.colorClass}`}>
<Icon name={entry.icon} className="text-2xl" />
@@ -101,11 +136,14 @@ export function ChatListColumn() {
const name = contact.public?.fn || contact.topic;
const preview = formatMessagePreview(contact.latestMessage?.(), myUserId);
const active = topicId === contact.topic;
const isGroup = tinode.getTopic(contact.topic).isGroupType();
const memberCount = isGroup ? memberCountByTopic.get(contact.topic) : undefined;
const muted = !!contact.private?.mute_notifications;
return (
<Link
key={contact.topic}
to={`/messages/${contact.topic}`}
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item py-2.5 pl-4 pr-3 no-underline ${
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-border-list-item py-2.5 pl-4 pr-3 no-underline ${
active ? 'bg-accent-selected-bg' : 'bg-white hover:bg-[#f6f8fb]'
}`}
>
@@ -120,12 +158,23 @@ export function ChatListColumn() {
<span className="flex min-w-0 flex-col gap-1">
<span className="flex items-center gap-1.5">
<strong className="truncate text-[15px] font-medium text-[#272b31]">{name}</strong>
{/* 02-messages.dc.html #1a/#1c: group rows carry a "N人" member-count pill right
after the name (e.g. "项目协作群 28人"); P2P rows never have one. */}
{typeof memberCount === 'number' ? (
<span className="flex-none rounded-full bg-[#f1f2f4] px-1.5 py-0.5 text-[11px] text-text-tertiary-3">{memberCount}人</span>
) : null}
</span>
<small className="truncate text-[13px] text-text-tertiary-2">{preview}</small>
</span>
<span className="flex flex-none flex-col items-end justify-between gap-1.5">
<time className="text-xs text-text-tertiary-3">{contact.touched ? formatListTime(contact.touched) : ''}</time>
{contact.unread ? (
{/* Same reference mock: a muted conversation's unread count is replaced by a small
muted-bell glyph, not shown alongside or as a louder red badge -- the whole point
of 消息免打扰 (DetailColumn's own toggle, which writes this same `private` field)
is to stop this row from demanding attention the way an unmuted one does. */}
{muted ? (
contact.unread ? <Icon name="notifications_off" className="text-base text-text-tertiary-4" /> : null
) : contact.unread ? (
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
{contact.unread}
</b>
@@ -136,6 +185,16 @@ export function ChatListColumn() {
})
)}
</div>
{creatingGroup ? (
<CreateGroupModal
onClose={() => setCreatingGroup(false)}
onCreated={(topic) => {
setCreatingGroup(false);
navigate(`/messages/${topic}`);
}}
/>
) : null}
</aside>
);
}
+419 -80
View File
@@ -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>
);
}
+27 -9
View File
@@ -20,6 +20,7 @@ import {
import type { MediaType } from '@/lib/api/messages';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useTinode } from '@/lib/tinode/hooks';
import { useCallStore } from '@/stores/calls';
interface DetailColumnProps {
topicId: string;
@@ -36,10 +37,13 @@ function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (
aria-checked={checked}
disabled={disabled}
onClick={onChange}
className={`relative h-[22px] w-10 flex-none rounded-full transition-colors disabled:opacity-50 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
// README §3.4: mobile switches are 44×26 (22×22 knob), one size up from desktop's 40×22
// (18×18 knob) -- same fix as GroupDetailColumn.tsx's identical Toggle copy. The travel
// distance stays 18px at both sizes since track and knob grow by the same 4px.
className={`relative h-[26px] w-11 flex-none rounded-full transition-colors disabled:opacity-50 md:h-[22px] md:w-10 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
>
<span
className={`absolute top-0.5 h-[18px] w-[18px] rounded-full bg-white transition-transform ${checked ? 'translate-x-[20px]' : 'translate-x-0.5'}`}
className={`absolute left-0.5 top-0.5 h-[22px] w-[22px] rounded-full bg-white transition-transform md:h-[18px] md:w-[18px] ${checked ? 'translate-x-[18px]' : ''}`}
/>
</button>
);
@@ -128,8 +132,8 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
};
return (
<aside className="flex h-full w-full flex-none flex-col overflow-y-auto border-l border-border-panel bg-white md:w-[300px]">
<header className="flex h-15 flex-none items-center justify-between border-b border-list-item px-5">
<aside className="flex h-full w-full flex-none flex-col overflow-y-auto border-l border-border-panel bg-white md:w-[320px] lg:w-[300px]">
<header className="flex h-15 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold">聊天设置</strong>
{onClose ? (
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
@@ -138,7 +142,7 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
) : null}
</header>
<div className="flex flex-col items-center gap-1 border-b border-list-item px-5 pb-4 pt-6">
<div className="flex flex-col items-center gap-1 border-b border-border-list-item px-5 pb-4 pt-6">
<span className={`grid h-18 w-18 place-items-center rounded-2xl text-2xl font-medium text-white ${avatarBgClass(topicId)}`}>
{avatarLetter(peerName)}
</span>
@@ -156,18 +160,32 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
<Icon name="chat_bubble_outline" className="text-[20px]" />
<span className="text-xs text-text-secondary">发消息</span>
</button>
<button type="button" className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent">
<button
type="button"
disabled={profileQuery.data?.capabilities?.can_call === false}
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'audio');
}}
className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent disabled:opacity-50"
>
<Icon name="call" className="text-[20px]" />
<span className="text-xs text-text-secondary">语音</span>
</button>
<button type="button" className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent">
<button
type="button"
disabled={profileQuery.data?.capabilities?.can_call === false}
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'video');
}}
className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent disabled:opacity-50"
>
<Icon name="videocam" className="text-[20px]" />
<span className="text-xs text-text-secondary">视频</span>
</button>
</div>
</div>
<div className="border-b border-list-item px-5 pb-3 pt-2 text-sm">
<div className="border-b border-border-list-item px-5 pb-3 pt-2 text-sm">
<button type="button" onClick={() => onOpenHistory('text')} className="flex w-full items-center justify-between py-2">
<span className="text-text-body">查找聊天记录</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
@@ -189,7 +207,7 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
</div>
</div>
<div className="flex flex-col border-b border-list-item px-5 text-sm">
<div className="flex flex-col border-b border-border-list-item px-5 text-sm">
{remarkEditing ? (
<div className="flex items-center gap-2 py-3">
<span className="flex-none text-text-body">备注</span>
@@ -5,27 +5,52 @@ import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { useChatList } from '@/lib/tinode/hooks';
const MAX_COMMENT_LENGTH = 200;
interface ForwardPickerModalProps {
onPick: (targetTopic: string) => void;
onPick: (targetTopics: string[], comment: string) => void;
onClose: () => void;
}
/**
* Simplified single-target contact picker for the message context menu's "转发". The full design
* (README §3.6) supports multi-select + a merge/separate-forward toggle for the multi-select bulk
* bar's forward action -- this is the smaller single-message version, one tap sends immediately.
* README §3.6: multi-select targets + an optional comment, sent in one action. Used by the message
* context menu's single-message "转发", the multi-select bulk bar's "转发", and favorites' bulk
* forward -- all three previously shared this component when it only supported picking ONE target
* and sending immediately with no comment, meaning forwarding to several people required repeating
* the whole picker flow once per recipient. `forwardFavorites` (favorites.ts) already accepts
* `target_topics: string[]`/`comment` server-side; chat-message forwarding has no such batch REST
* endpoint (`forwardMessage` in lib/tinode/send.ts publishes one native {pub} per target instead),
* so callers loop `targetTopics` themselves -- this component only collects the selection.
*/
export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) {
const contacts = useChatList();
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [comment, setComment] = useState('');
useEscapeKey(onClose);
const filtered = contacts.filter((c) => !query.trim() || (c.public?.fn || '').includes(query.trim()));
const toggle = (topic: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(topic)) next.delete(topic);
else next.add(topic);
return next;
});
};
const send = () => {
if (selected.size === 0) return;
onPick([...selected], comment.trim());
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[480px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">转发到</strong>
<div className="flex h-[560px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">
转发到{selected.size ? `(已选 ${selected.size} 个接收方)` : ''}
</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
@@ -43,21 +68,46 @@ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps)
</div>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2">
{filtered.map((c) => (
<button
key={c.topic}
type="button"
onClick={() => c.topic && onPick(c.topic)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail"
>
<span
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(c.topic || '')}`}
{filtered.map((c) => {
const isSelected = !!c.topic && selected.has(c.topic);
return (
<button
key={c.topic}
type="button"
onClick={() => c.topic && toggle(c.topic)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail"
>
{avatarLetter(c.public?.fn)}
</span>
<span className="truncate text-sm text-text-body">{c.public?.fn || c.topic}</span>
</button>
))}
<span
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(c.topic || '')}`}
>
{avatarLetter(c.public?.fn)}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{c.public?.fn || c.topic}</span>
<span
className={`grid h-5 w-5 flex-none place-items-center rounded-full border ${isSelected ? 'border-accent bg-accent' : 'border-[#d5d9df]'}`}
>
{isSelected ? <Icon name="check" className="text-[14px] text-white" /> : null}
</span>
</button>
);
})}
</div>
<div className="flex-none border-t border-border-list-item p-3">
<textarea
value={comment}
onChange={(e) => setComment(e.target.value.slice(0, MAX_COMMENT_LENGTH))}
rows={2}
placeholder="附言(选填)"
className="w-full resize-none rounded-lg bg-surface-input p-2.5 text-sm outline-none"
/>
<button
type="button"
onClick={send}
disabled={selected.size === 0}
className="mt-2 h-9 w-full rounded-lg bg-btn text-sm font-medium text-white disabled:bg-btn-disabled"
>
发送{selected.size ? `(${selected.size})` : ''}
</button>
</div>
</div>
</div>
+159 -46
View File
@@ -1,13 +1,17 @@
import { useState } from 'react';
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { getAllGroupMembers } from '@/lib/api/groups';
import { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem, type MediaType, type SearchHit } from '@/lib/api/messages';
import { checkUrlSafety } from '@/lib/api/security';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
import { renderDrafty } from '@/lib/messages/drafty';
import { formatEventTimestamp } from '@/lib/messages/time';
import { useMessageJumpStore } from '@/stores/messageJump';
import { useSessionStore } from '@/stores/session';
type Tab = 'text' | MediaType;
@@ -55,22 +59,17 @@ function MediaThumb({ item, isVideo }: { item: MediaItem; isVideo: boolean }) {
}
/** Renders a search hit's original message content (text/file/voice/...) via the same Drafty
* formatter regular chat bubbles use -- `snapshot.content` is a real, complete Drafty document
* (docs/IM-前后端对接文档.md's flat `SearchFileHit`-style shape doesn't actually exist on this
* server; see messages.ts's SearchHit doc comment), so this passes it straight through rather than
* picking fields back out of it by hand. */
function SnapshotContent({ item }: { item: SearchHit }) {
* formatter regular chat bubbles use -- `snapshot.content` is either a real, complete Drafty
* document OR (for a plain unformatted text message, confirmed live) a bare string, exactly the
* same duality `renderDrafty` already resolves for every other caller -- so this passes it straight
* through rather than picking fields back out of it by hand. The previous version's manual
* `{txt:content.txt||' ', ...}` reconstruction silently produced a single blank space for the bare-
* string case (`content.txt` on a string primitive is `undefined`), which is exactly what a live
* search against a real plain-text message showed before this fix. */
function SnapshotContent({ item, highlightQuery }: { item: SearchHit; highlightQuery?: string }) {
const content = item.snapshot?.content;
if (!content) return <span className="text-text-tertiary-4">(内容不可用)</span>;
return (
<div className="text-sm text-text-body">
{renderDrafty({
txt: content.txt || ' ',
fmt: (content.fmt as { at?: number; len?: number; tp?: string; key?: number }[] | undefined) || [{ at: -1 }],
ent: (content.ent || []).map((e) => ({ tp: e.tp || '', data: (e.data || {}) as Record<string, unknown> })),
})}
</div>
);
return <div className="text-sm text-text-body">{renderDrafty(content, highlightQuery)}</div>;
}
/**
@@ -131,14 +130,40 @@ interface HistorySearchModalProps {
topicId: string;
initialTab?: Tab;
onClose: () => void;
/** For sender-name/avatar resolution on the 消息 tab (`05-favorites-history.dc.html #1b`'s rows
* each carry the sender's name + colored avatar, e.g. "陈明远"/"接语彤"/"我") -- a group needs a
* member-list lookup per hit since `from` can be anyone; a P2P conversation only ever has two
* possible senders (me or the peer), so the caller's already-resolved `peerName` is enough. */
isGroup: boolean;
peerName: string;
}
/** README §3.4's "查找聊天记录 ›" + 4-icon grid, opened as a centered modal (README §1.5). */
export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: HistorySearchModalProps) {
export function HistorySearchModal({ topicId, initialTab = 'text', onClose, isGroup, peerName }: HistorySearchModalProps) {
const [tab, setTab] = useState<Tab>(initialTab);
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 300);
useEscapeKey(onClose);
const myUserId = useSessionStore((s) => s.myUserId);
const membersQuery = useQuery({
queryKey: ['group-members', topicId],
queryFn: () => getAllGroupMembers(topicId),
enabled: isGroup,
});
// Confirmed live: this endpoint's `from` (both the top-level field and `snapshot.from`) comes back
// with its `usr` prefix STRIPPED -- the same quirk already documented for favorites' own
// `snapshot.from` (see favorites.ts's header comment), just discovered here independently rather
// than assumed to apply. Comparing it directly against `myUserId`/`GroupMember.user_id` (both full
// `usr...` ids) would never match "me" or any real member, so every sender always fell back to the
// raw id. Reconstructing the full id is safe specifically because the stripping is a fixed, known
// prefix removal, not a lossy truncation.
const normalizeSender = (from: string) => (from.startsWith('usr') ? from : `usr${from}`);
const senderLabel = (from: string): string => {
const full = normalizeSender(from);
if (myUserId && full === myUserId) return '我';
if (isGroup) return membersQuery.data?.find((m) => m.user_id === full)?.display_name || from;
return peerName;
};
const searchQuery = useInfiniteQuery({
queryKey: ['topic-search', topicId, debouncedQuery],
@@ -159,31 +184,72 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
const mediaItems = mediaQuery.data?.pages.flatMap((p) => p.items) ?? [];
const fileLinkQuery = useInfiniteQuery({
queryKey: ['topic-search-media', topicId, tab],
queryFn: ({ pageParam }) => searchTopicMessages(topicId, '', tab as 'file' | 'link', pageParam),
queryKey: ['topic-search-media', topicId, tab, debouncedQuery],
// `searchTopicMessages` genuinely supports a keyword filter for file/link results (unlike
// `getTopicMedia`'s image/video browse, which is date-grouped, not keyword-searchable, per its
// own signature) -- this used to hardcode `''`, silently discarding the search box's query the
// moment a user switched off the 消息 tab, even though the box itself is spec'd as one shared
// header element sitting above all four tabs, not a 消息-tab-only control.
queryFn: ({ pageParam }) => searchTopicMessages(topicId, debouncedQuery, tab as 'file' | 'link', pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
enabled: tab === 'file' || tab === 'link',
});
const fileLinkItems = fileLinkQuery.data?.pages.flatMap((p) => p.items) ?? [];
// Search hits carry `seq` (used until now only for the timestamp/`key`) but nothing acted on it --
// clicking a result did nothing at all. ConversationView owns the actual scroll-to/highlight logic
// (it needs the message loaded first, which this modal has no reason to know how to do); this just
// hands off the target and gets out of the way.
const jumpToMessage = (seq: number) => {
useMessageJumpStore.getState().requestJump(topicId, seq);
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[620px] w-[720px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-list-item px-5">
// README §6.3 (mock `05/1e`): mobile is a full-bleed PAGE, not a dimmed centered dialog -- no
// overlay tint, no rounded card, search input lives in the header itself (with a "取消" text
// button instead of a close icon). Desktop (`md`+) keeps the original centered 720x620 dialog
// exactly as before.
<div className="fixed inset-0 z-50 flex items-center justify-center bg-transparent md:bg-[#22262d]/25" onClick={onClose}>
<div
className="flex h-full w-full flex-col bg-white md:h-[620px] md:w-[720px] md:rounded-xl md:shadow-modal"
onClick={(e) => e.stopPropagation()}
>
<header className="hidden h-14 flex-none items-center justify-between border-b border-border-list-item px-5 md:flex">
<strong className="text-[15px] font-semibold text-text-heading">查找聊天记录</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
<header className="flex h-[52px] flex-none items-center gap-2.5 px-4 md:hidden">
<div className="flex h-9.5 flex-1 items-center gap-2 rounded-[10px] bg-surface-search px-2.5">
<Icon name="search" className="text-[20px] text-text-placeholder-2" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={tab === 'text' ? '搜索聊天记录' : tab === 'file' ? '搜索文件' : tab === 'link' ? '搜索链接' : '搜索聊天记录'}
disabled={tab === 'image' || tab === 'video'}
className="w-full min-w-0 bg-transparent text-[15px] outline-none placeholder:text-text-placeholder-2 disabled:cursor-not-allowed"
/>
{query ? (
<button type="button" onClick={() => setQuery('')} className="grid flex-none place-items-center">
<Icon name="cancel" className="text-[18px] text-text-placeholder-2" />
</button>
) : null}
</div>
<button type="button" onClick={onClose} className="flex-none text-[15px] text-accent">
取消
</button>
</header>
<div className="flex flex-none gap-1 border-b border-list-item px-5 pt-3">
<div className="flex flex-none gap-1 overflow-x-auto border-b border-border-list-item px-5 pt-3">
{TABS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 rounded-t-lg px-3 py-2 text-sm ${
className={`flex flex-none items-center gap-1.5 whitespace-nowrap rounded-t-lg px-3 py-2 text-sm ${
tab === t.key ? 'border-b-2 border-accent font-medium text-accent' : 'text-text-secondary'
}`}
>
@@ -193,40 +259,69 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
))}
</div>
<div className="hidden flex-none px-5 py-3 md:block">
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={tab === 'text' ? '搜索聊天记录' : tab === 'file' ? '搜索文件' : tab === 'link' ? '搜索链接' : '搜索聊天记录'}
disabled={tab === 'image' || tab === 'video'}
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2 disabled:cursor-not-allowed"
/>
</div>
</div>
{tab === 'text' ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex-none px-5 py-3">
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索聊天记录"
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 pb-4">
{!debouncedQuery.trim() ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">输入关键词搜索这个会话的聊天记录</div>
) : searchQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">搜索中…</div>
) : searchQuery.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>搜索失败,请重试</span>
<button type="button" onClick={() => searchQuery.refetch()} className="text-accent">
重试
</button>
</div>
) : !searchItems.length ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">没有找到相关消息</div>
) : (
<>
<ul className="flex flex-col gap-1">
{searchItems.map((item) => (
<li key={item.seq} className="rounded-lg px-3 py-2.5 hover:bg-surface-rail">
<div className="flex items-center justify-between text-xs text-text-tertiary-3">
<span>{formatEventTimestamp(new Date(item.ts))}</span>
</div>
<div className="mt-0.5 truncate">
<SnapshotContent item={item} />
</div>
</li>
))}
{searchItems.map((item) => {
const fullFrom = normalizeSender(item.from);
const name = senderLabel(item.from);
return (
<li key={item.seq}>
<button
type="button"
onClick={() => jumpToMessage(item.seq)}
className="flex w-full items-start gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-surface-rail"
>
<span
className={`grid h-10 w-10 flex-none place-items-center rounded-lg text-sm font-medium text-white ${
myUserId && fullFrom === myUserId ? 'bg-accent' : avatarBgClass(fullFrom)
}`}
>
{avatarLetter(name)}
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center justify-between text-xs text-text-tertiary-3">
<span className="truncate text-text-tertiary-2">{name}</span>
<span className="flex-none">{formatEventTimestamp(new Date(item.ts))}</span>
</div>
<div className="truncate">
<SnapshotContent item={item} highlightQuery={debouncedQuery} />
</div>
</div>
</button>
</li>
);
})}
</ul>
{searchQuery.hasNextPage ? (
<LoadMoreButton onClick={() => searchQuery.fetchNextPage()} loading={searchQuery.isFetchingNextPage} />
@@ -239,6 +334,13 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
<div className="flex-1 overflow-y-auto p-5">
{mediaQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : mediaQuery.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => mediaQuery.refetch()} className="text-accent">
重试
</button>
</div>
) : !mediaItems.length ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
) : (
@@ -258,13 +360,24 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
<div className="flex-1 overflow-y-auto p-5">
{fileLinkQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : fileLinkQuery.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => fileLinkQuery.refetch()} className="text-accent">
重试
</button>
</div>
) : !fileLinkItems.length ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
) : (
<>
<div className="flex flex-col gap-2">
{fileLinkItems.map((item) =>
tab === 'link' ? <LinkResult key={item.seq} item={item} /> : <SnapshotContent key={item.seq} item={item} />,
tab === 'link' ? (
<LinkResult key={item.seq} item={item} />
) : (
<SnapshotContent key={item.seq} item={item} highlightQuery={debouncedQuery} />
),
)}
</div>
{fileLinkQuery.hasNextPage ? (
@@ -0,0 +1,79 @@
import { useQuery } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { getGroupMembers, type GroupRole } from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useDebouncedValue } from '@/lib/hooks';
import { MENTION_ALL_SENTINEL, type PendingMention } from '@/lib/messages/mentions';
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
interface MentionPickerProps {
topicId: string;
query: string;
onPick: (choice: PendingMention) => void;
}
/**
* 设计稿 4.3:输入 `@` 触发的 260px 浮层。候选来自 `GET .../members?query=&mentionable=true`
* (协议文档 1052/1735 行)-- 这一个响应同时带 `items`(已经是可提及成员,不用再拿全量列表自己过滤)
* 和 `permissions.can_mention_all`,比向 ConversationView 另外要这两样数据都便宜。"所有人"是虚拟项,
* 不对应真实成员,只在 `can_mention_all` 为真时出现在首位。
*/
export function MentionPicker({ topicId, query, onPick }: MentionPickerProps) {
const debouncedQuery = useDebouncedValue(query, 200);
const membersQuery = useQuery({
queryKey: ['group-mentionable-members', topicId, debouncedQuery],
queryFn: () => getGroupMembers(topicId, { query: debouncedQuery, mentionable: true }),
});
const items = membersQuery.data?.items || [];
const canMentionAll = !!membersQuery.data?.permissions?.can_mention_all;
const showAll = canMentionAll && (!query || '所有人'.includes(query));
if (!showAll && items.length === 0 && !membersQuery.isLoading) return null;
return (
<div className="w-[260px] overflow-hidden rounded-[10px] border border-border-panel bg-white py-1 shadow-menu">
{membersQuery.isLoading ? (
<div className="px-3 py-4 text-center text-xs text-text-tertiary-4">加载中…</div>
) : (
<>
{showAll ? (
<button
type="button"
onClick={() => onPick({ userId: MENTION_ALL_SENTINEL, label: '所有人' })}
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[#f2f6fd]"
>
<span className="grid h-7 w-7 flex-none place-items-center rounded-lg bg-accent text-white">
<Icon name="group" className="text-[16px]" />
</span>
<span className="text-sm text-text-body">所有人</span>
</button>
) : null}
{items.map((m) => (
<button
key={m.user_id}
type="button"
onClick={() => onPick({ userId: m.user_id, label: m.display_name || m.user_id })}
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[#f2f6fd]"
>
<span className={`grid h-7 w-7 flex-none place-items-center rounded-lg text-xs font-medium text-white ${avatarBgClass(m.user_id)}`}>
{avatarLetter(m.display_name)}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
{m.role !== 'member' ? (
<span
className={`flex-none rounded-[3px] border px-1 text-[10px] leading-[14px] ${
m.role === 'owner' ? 'border-warn text-warn' : 'border-accent text-accent'
}`}
>
{ROLE_LABEL[m.role]}
</span>
) : null}
</button>
))}
</>
)}
</div>
);
}
+101 -20
View File
@@ -1,26 +1,68 @@
import type { StoredMessage, Topic } from 'tinode-sdk';
import { Link } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { isRecalled, isServerDeleted, systemNoticeText } from '@/lib/messages/dispatch';
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted, systemNoticeText, type ContactCardContent } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
import { retryMessage } from '@/lib/tinode/send';
/** 设计稿 4.3:气泡上方角色标签,边框样式(不是填色)-- 群主 #ff951c、管理员 #1677ff,均不加背景。
* 与 GroupMembersModal/GroupDetailColumn 里另外两种角色标签(填色 tag、圆角数字角标)故意不共用
* 一个组件:三处的视觉规格互不相同,抽象成一个带 variant 参数的组件不会比各写各的更清楚。 */
/** Distinct from a regular Drafty-rendered bubble -- see `contactCardContentOf`'s own doc comment
* for why this is dispatched by `head['x-im-type']` rather than a Drafty style tag. Tapping opens
* the real profile page, the one thing a contact-card message is fundamentally for. */
function ContactCardBubble({ content }: { content: ContactCardContent }) {
const name = content.display_name || content.public_account || content.user_id;
return (
<Link to={`/contacts/${content.user_id}`} className="flex w-[220px] items-center gap-3 no-underline">
<Avatar id={content.user_id} name={name} avatarFileId={content.avatar_file_id} className="h-11 w-11 flex-none rounded-lg text-base" />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-text-body">{name}</span>
{content.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {content.public_account}</span> : null}
</span>
</Link>
);
}
function MessageRoleTag({ role }: { role: 'owner' | 'admin' | undefined }) {
if (role === 'owner') return <span className="rounded-[3px] border border-warn px-1 text-[10px] leading-[14px] text-warn">群主</span>;
if (role === 'admin') return <span className="rounded-[3px] border border-accent px-1 text-[10px] leading-[14px] text-accent">管理员</span>;
return null;
}
interface MessageRowProps {
topic: Topic;
msg: StoredMessage;
mine: boolean;
isGroup: boolean;
peerName: string;
/** Only meaningful when `isGroup` -- the sender's real group-scoped name/role, resolved by
* ConversationView from its own member-list fetch. `peerName` alone is the GROUP's name, wrong
* for per-message attribution once there's more than one possible sender. */
senderName?: string;
senderRole?: 'owner' | 'admin' | 'member';
selectMode?: boolean;
selected?: boolean;
/** README §3.5: "选中气泡加 outline:2px solid #1677ff" -- true while this message is the open
* context menu's target. */
menuActive?: boolean;
/** True for a few seconds right after jumping here from a 查找聊天记录 search result (ConversationView's
* scroll-to-message flow) -- a brief background flash, distinct from `menuActive`'s permanent-while-
* open outline, since the two states mean different things (context menu open vs. "you asked to find
* this one") and can't reuse the same visual without conflating them. */
highlighted?: boolean;
onToggleSelect?: (seq: number) => void;
onContextMenu?: (e: React.MouseEvent, msg: StoredMessage) => void;
/** Fired when the inline "重试" retry (below) itself fails -- e.g. still offline. Without this the
* failed-send icon just silently stays put with no feedback at all that the retry attempt was
* even made. */
onError?: (message: string) => void;
}
export function MessageRow({
@@ -29,20 +71,28 @@ export function MessageRow({
mine,
isGroup,
peerName,
senderName,
senderRole,
selectMode,
selected,
menuActive,
highlighted,
onToggleSelect,
onContextMenu,
onError,
}: MessageRowProps) {
if (isServerDeleted(msg)) {
return <div className="py-1 text-center text-xs text-text-tertiary-4">此消息已被删除</div>;
return (
<div data-seq={msg.seq} className="py-1 text-center text-xs text-text-tertiary-4">
此消息已被删除
</div>
);
}
const noticeText = systemNoticeText(msg);
if (noticeText) {
return (
<div className="flex flex-col items-center gap-1 py-1">
<div data-seq={msg.seq} className="flex flex-col items-center gap-1 py-1">
<div className="text-xs text-text-tertiary-4">{noticeText}</div>
{msg.ts ? <div className="text-xs text-text-tertiary-4">{formatEventTimestamp(msg.ts)}</div> : null}
</div>
@@ -52,23 +102,33 @@ export function MessageRow({
const failed = isFailedStatus(topic, msg);
const sending = isSendingStatus(topic, msg);
const statusLabel = mine ? outgoingStatusLabel(topic, msg, isGroup) : msg.ts ? formatBubbleTime(msg.ts) : '';
const callResult = callResultDisplay(msg, mine);
const contactCard = contactCardContentOf(msg);
const retry = () => {
if (typeof msg.seq === 'number') retryMessage(topic, msg.seq);
if (typeof msg.seq === 'number') retryMessage(topic, msg.seq)?.catch(() => onError?.('重试发送失败'));
};
return (
<div className="flex items-center gap-2">
<div data-seq={msg.seq} className={`flex items-center gap-2 ${highlighted ? 'animate-[jump-highlight_2s_ease-out]' : ''}`}>
{selectMode ? (
<button
type="button"
onClick={() => typeof msg.seq === 'number' && onToggleSelect?.(msg.seq)}
className={`grid h-5 w-5 flex-none place-items-center rounded-full text-[13px] text-white ${
selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2]'
}`}
>
{selected ? <Icon name="check" className="text-[14px]" /> : null}
</button>
// A recalled message (content nulled out, rendered as the "⚠ 消息已删除" placeholder below)
// has nothing real to forward/favorite -- an empty flex-none spacer of the same size keeps
// every row's content left-edge aligned instead of the checkbox's absence shifting this one
// row over, while still refusing to let it join a batch action.
isRecalled(msg) ? (
<div className="h-5 w-5 flex-none" />
) : (
<button
type="button"
onClick={() => typeof msg.seq === 'number' && onToggleSelect?.(msg.seq)}
className={`grid h-5 w-5 flex-none place-items-center rounded-full text-[13px] text-white ${
selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2]'
}`}
>
{selected ? <Icon name="check" className="text-[14px]" /> : null}
</button>
)
) : null}
<div
onContextMenu={(e) => {
@@ -76,25 +136,46 @@ export function MessageRow({
e.preventDefault();
onContextMenu?.(e, msg);
}}
className={`flex flex-1 items-end gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}
className={`flex flex-1 items-start gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}
>
<span
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${
mine ? 'bg-accent' : avatarBgClass(msg.from || peerName)
mine ? 'bg-accent' : avatarBgClass(msg.from || (isGroup ? '' : peerName))
}`}
>
{avatarLetter(mine ? '我' : peerName)}
{avatarLetter(mine ? '我' : isGroup ? senderName || msg.from : peerName)}
</span>
<div className={`flex max-w-[60%] flex-col gap-1 ${mine ? 'items-end' : 'items-start'}`}>
{/* README §3.2: the failed-send icon sits beside the bubble, not below it with the retry text. */}
<div className={`flex items-end gap-1.5 ${mine ? 'flex-row-reverse' : ''}`}>
{isGroup ? (
<div className={`flex items-center gap-1 px-0.5 text-xs text-text-tertiary-4 ${mine ? 'flex-row-reverse' : ''}`}>
<span>{mine ? '我' : senderName || msg.from}</span>
<MessageRoleTag role={senderRole === 'owner' || senderRole === 'admin' ? senderRole : undefined} />
</div>
) : null}
{/* README §3.2: the failed-send icon sits beside the bubble, not below it with the retry text.
items-center (not -end) so the icon stays vertically centered on the bubble regardless of
how many lines the bubble wraps to; no flex-row-reverse here (unlike the outer avatar row)
-- this icon only ever appears on `mine` bubbles and belongs on the OUTER edge, away from
the avatar, which is what the DOM order [icon, bubble] already gives without reversing it. */}
<div className="flex items-center gap-1.5">
{failed ? <Icon name="error" className="flex-none text-[20px] text-danger-text" /> : null}
<div
className={`px-3.5 py-2.5 text-[15px] leading-[1.55] break-words ${
mine ? 'rounded-[12px_4px_12px_12px] bg-bubble-mine' : 'rounded-[4px_12px_12px_12px] bg-white shadow-bubble'
} ${isRecalled(msg) ? 'text-text-tertiary-4 italic' : ''} ${menuActive ? 'outline outline-2 outline-offset-2 outline-accent' : ''}`}
>
{isRecalled(msg) ? '⚠ 消息已删除' : renderDrafty(msg.content)}
{isRecalled(msg) ? (
'⚠ 消息已删除'
) : callResult ? (
<span className="flex items-center gap-1.5">
<Icon name={callResult.icon} className={`text-[18px] ${callResult.danger ? 'text-danger-text' : 'text-current'}`} />
{callResult.text}
</span>
) : contactCard ? (
<ContactCardBubble content={contactCard} />
) : (
renderDrafty(msg.content)
)}
</div>
</div>
{failed ? (
@@ -90,7 +90,7 @@ export function MessageContextMenu({ x, y, actions, recallRemainingSeconds, onAc
</button>
);
})}
{trailing.length ? <div className="my-1 h-px bg-list-item" /> : null}
{trailing.length ? <div className="my-1 h-px bg-border-list-item" /> : null}
{trailing.map((action) => {
const item = ITEMS[action];
return (
+104 -17
View File
@@ -1,38 +1,111 @@
import { useLayoutEffect, useState } from 'react';
import { Outlet, useParams } from 'react-router-dom';
import { Outlet, useLocation, useParams } from 'react-router-dom';
import { ChatListColumn } from '@/features/messages/ChatListColumn';
import { DetailColumn } from '@/features/messages/DetailColumn';
import { HistorySearchModal } from '@/features/messages/HistorySearchModal';
import { OfflineBanner } from '@/features/messages/OfflineBanner';
import { GroupDetailColumn } from '@/features/groups/GroupDetailColumn';
import type { MediaType } from '@/lib/api/messages';
import { useMediaQuery } from '@/lib/hooks';
import { useChatList } from '@/lib/tinode/hooks';
import { useChatList, useTinode } from '@/lib/tinode/hooks';
/** Group topics (`grp...`) get the group-specific settings rail; everything else (`usr...` P2P,
* `slf...` self) keeps the existing DetailColumn. `isGroupType()` is a pure name-prefix check on
* the SDK's Topic wrapper (tinode-sdk's Topic.topicType()) -- no subscribe/network call, safe to
* read on every render. */
function ConversationDetailPanel({
isGroup,
topicId,
peerName,
onClose,
onOpenHistory,
}: {
isGroup: boolean;
topicId: string;
peerName: string;
onClose?: () => void;
onOpenHistory: (tab: 'text' | MediaType) => void;
}) {
return isGroup ? (
<GroupDetailColumn topicId={topicId} peerName={peerName} onClose={onClose} onOpenHistory={onOpenHistory} />
) : (
<DetailColumn topicId={topicId} peerName={peerName} onClose={onClose} onOpenHistory={onOpenHistory} />
);
}
const DETAIL_PANEL_OPEN_KEY = 'im-detail-panel-open';
function readPersistedDetailPanelOpen(): boolean {
try {
return localStorage.getItem(DETAIL_PANEL_OPEN_KEY) === '1';
} catch {
return false;
}
}
/**
* The 4-tier responsive split described in the design handoff:
* - <640 and 640-959: list OR main, whichever the route implies (topicId present -> main; the
* conversation header then needs its own back-to-list affordance, since NavRail alone doesn't
* provide one at this tier -- see ConversationView).
* - 960-1279: list + main together, detail as an overlay drawer toggled by the header's "i" icon.
* - >=1280: list + main + detail all docked side by side.
* - 960-1279: list + main together, detail as an overlay drawer toggled by the header's "i" icon
* (README's `detailDrawerOpen`, explicitly "三列临时" -- temporary, not persisted).
* - >=1280: list + main + detail all docked side by side, but the detail column itself is STILL
* closed by default and toggled by the same "i" icon (README's `detailPanelOpen`, "桌面持久化"
* -- desktop, persisted to localStorage) -- this is a real column reserved in the 4-column
* diagram, not an always-on panel with no way to close it; that was this layout's own bug until
* a user report caught it (the "i" button did nothing at this width, and the panel had no close
* affordance at all).
* Below 960px (both the two-column and single-column tiers), the detail panel instead pushes as a
* full-page overlay -- README §1.4's "详情/设置等作为整页推入" convention -- since there's no room
* to dock or drawer it in without covering the conversation entirely anyway.
*/
export function MessagesLayout() {
const { topicId } = useParams();
const location = useLocation();
const [detailOpen, setDetailOpen] = useState(false);
const [detailPanelOpen, setDetailPanelOpen] = useState(readPersistedDetailPanelOpen);
const [historyTab, setHistoryTab] = useState<'text' | MediaType | null>(null);
const isDocked = useMediaQuery('(min-width: 1280px)');
const showListOnNarrow = !topicId;
// Which of the 3 detail-panel render sites below is allowed to actually mount -- previously all
// three were gated ONLY by `detailPanelOpen`/`detailOpen` and picked apart visually with `hidden`/
// `md:`/`lg:` classes, so crossing the 1280px breakpoint while a panel was open left the OTHER
// tier's copy still truthy and mounted-but-hidden (confirmed: `detailPanelOpen` and `detailOpen`
// are two independent booleans that only toggleDetail() keeps in sync, and it only ever updates
// the one matching `isDocked` AT CLICK TIME -- resizing across 1280px afterward does not reconcile
// the other one). A hidden-but-mounted GroupDetailColumn/DetailColumn still runs its own queries
// and effects for no visible reason, and briefly having two real instances of the same panel alive
// is exactly the kind of thing that turns into a real bug the moment either one has a side effect
// that isn't safe to run twice.
const isMdUp = useMediaQuery('(min-width: 960px)');
// `/messages/system` is a real Outlet child (SystemMessagesView) but doesn't match `:topicId`, so
// it needs its own check here -- otherwise this reads as "no conversation selected" and collapses
// to the same list-only view as the bare `/messages` index at narrow widths.
const showListOnNarrow = !topicId && location.pathname !== '/messages/system';
const contacts = useChatList();
const tinode = useTinode();
const peerName = contacts.find((c) => c.topic === topicId)?.public?.fn || topicId || '';
// The "i" button should read as active whenever the detail panel is actually visible right now --
// always true when docked (>=1280px, no close affordance), otherwise whatever the drawer/full-page
// toggle state is. Previously hardcoded to the breakpoint itself instead of this real state.
const detailVisible = isDocked || detailOpen;
const isGroup = !!topicId && tinode.getTopic(topicId).isGroupType();
const detailVisible = isDocked ? detailPanelOpen : detailOpen;
const toggleDetail = () => {
if (isDocked) {
setDetailPanelOpen((v) => {
const next = !v;
try {
localStorage.setItem(DETAIL_PANEL_OPEN_KEY, next ? '1' : '0');
} catch {
// Private-browsing/quota errors just mean the preference won't survive a reload -- the
// toggle itself still works for the rest of this session.
}
return next;
});
} else {
setDetailOpen((v) => !v);
}
};
// MessagesLayout is one long-lived instance reused across every conversation (route is
// `:topicId`) -- a history-search modal left open while switching topics would otherwise keep
@@ -49,28 +122,42 @@ export function MessagesLayout() {
<ChatListColumn />
</div>
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
<Outlet context={{ onToggleDetail: () => setDetailOpen((v) => !v), detailVisible, onOpenHistory: setHistoryTab }} />
<Outlet context={{ onToggleDetail: toggleDetail, detailVisible, onOpenHistory: setHistoryTab }} />
</div>
</div>
{topicId ? (
{isDocked && detailPanelOpen && topicId ? (
<div className="hidden h-full lg:block">
<DetailColumn topicId={topicId} peerName={peerName} onOpenHistory={setHistoryTab} />
<ConversationDetailPanel isGroup={isGroup} topicId={topicId} peerName={peerName} onClose={toggleDetail} onOpenHistory={setHistoryTab} />
</div>
) : null}
{detailOpen && topicId ? (
{!isDocked && isMdUp && detailOpen && topicId ? (
<div className="absolute inset-0 hidden md:block lg:hidden">
<div className="absolute inset-0 bg-[#22262d]/25" onClick={() => setDetailOpen(false)} />
<div className="absolute inset-y-0 right-0 shadow-[-8px_0_32px_rgba(0,0,0,0.12)]">
<DetailColumn topicId={topicId} peerName={peerName} onClose={() => setDetailOpen(false)} onOpenHistory={setHistoryTab} />
<ConversationDetailPanel
isGroup={isGroup}
topicId={topicId}
peerName={peerName}
onClose={() => setDetailOpen(false)}
onOpenHistory={setHistoryTab}
/>
</div>
</div>
) : null}
{detailOpen && topicId ? (
{!isDocked && !isMdUp && detailOpen && topicId ? (
<div className="fixed inset-0 z-30 bg-white md:hidden">
<DetailColumn topicId={topicId} peerName={peerName} onClose={() => setDetailOpen(false)} onOpenHistory={setHistoryTab} />
<ConversationDetailPanel
isGroup={isGroup}
topicId={topicId}
peerName={peerName}
onClose={() => setDetailOpen(false)}
onOpenHistory={setHistoryTab}
/>
</div>
) : null}
{historyTab && topicId ? <HistorySearchModal topicId={topicId} initialTab={historyTab} onClose={() => setHistoryTab(null)} /> : null}
{historyTab && topicId ? (
<HistorySearchModal topicId={topicId} initialTab={historyTab} onClose={() => setHistoryTab(null)} isGroup={isGroup} peerName={peerName} />
) : null}
</div>
);
}
+5 -25
View File
@@ -1,22 +1,16 @@
import { useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { useBootstrap } from '@/lib/api/hooks';
import { retryReconnectNow } from '@/lib/tinode/connection';
import { useReconnectState } from '@/lib/tinode/hooks';
/**
* README §3.7's four top-banner states, overlaying ChatList+Main. Only one shows at a time,
* priority: offline > reconnecting > just-reconnected > realname-reminder (most to least urgent).
* "已重新连接,正在同步 N 条新消息" is shown without a fake N -- the old app never implemented this
* state at all (confirmed by research), and there's no reliable signal here for "how many messages
* arrived while disconnected," so inventing a number would just be dishonest data.
* README §3.7's top-banner states, overlaying ChatList+Main. Only one shows at a time, priority:
* offline > reconnecting > just-reconnected (most to least urgent). "已重新连接,正在同步 N 条新
* 消息" is shown without a fake N -- the old app never implemented this state at all (confirmed by
* research), and there's no reliable signal here for "how many messages arrived while disconnected,"
* so inventing a number would just be dishonest data.
*/
export function OfflineBanner() {
const navigate = useNavigate();
const reconnect = useReconnectState();
const bootstrap = useBootstrap();
const realname = bootstrap.data?.security?.realname;
if (!reconnect.networkOnline) {
return (
@@ -52,19 +46,5 @@ export function OfflineBanner() {
);
}
if (realname && !realname.status.includes('verified') && realname.allow_skip) {
return (
<div className="flex h-10 flex-none items-center justify-between bg-warn-bg px-4 text-[13px] text-[#8a5a12]">
<span className="flex items-center gap-2">
<Icon name="gpp_maybe" className="text-[16px] text-warn" />
根据法规要求,请先完成实名认证后再使用全部功能
</span>
<button type="button" onClick={() => navigate('/settings')} className="text-accent">
去认证
</button>
</div>
);
}
return null;
}
+41
View File
@@ -0,0 +1,41 @@
import type { ReactNode } from 'react';
import { useMutation } from '@tanstack/react-query';
import { checkUrlSafety } from '@/lib/api/security';
/**
* docs/IM-前后端对接文档.md:1822,1898 -- links found in chat content must pass a server-side risk
* check before opening (the server fetches/classifies the URL out-of-band; the client never probes
* it directly). `warn` still lets the user proceed after an explicit confirm; `block` does not.
* Same UX contract as HistorySearchModal.tsx's own (dead-code, see its comment) LinkResult -- this is
* the renderer (lib/messages/drafty.tsx's `LN` case) that component itself falls back to for a hit
* that doesn't carry its own `url` enrichment field, so fixing it here covers both call sites: this
* was previously the only actually-reachable one, since SearchHit's real response has no `url` field.
*/
export function SafeLink({ href, children }: { href: string | undefined; children: ReactNode }) {
const safetyCheck = useMutation({ mutationFn: (url: string) => checkUrlSafety(url, 'chat_message') });
if (!href) return <span className="text-accent underline">{children}</span>;
const open = async () => {
let result;
try {
result = await safetyCheck.mutateAsync(href);
} catch {
window.alert('链接安全检查失败,请稍后重试。');
return;
}
if (result.decision === 'block') {
window.alert('该链接存在安全风险,已被拦截。');
return;
}
if (result.decision === 'warn' && !window.confirm('该链接可能存在风险,是否仍要打开?')) return;
window.open(href, '_blank', 'noopener,noreferrer');
};
return (
<button type="button" onClick={open} disabled={safetyCheck.isPending} className="text-accent underline disabled:opacity-60">
{children}
</button>
);
}
+4 -3
View File
@@ -122,12 +122,13 @@ export function VoiceRecorder({ onSend, onCancel }: VoiceRecorderProps) {
}
return (
<div className="flex h-14 items-center justify-between rounded-lg bg-[#fff0f0] px-4">
<span className="flex items-center gap-2 text-sm font-medium text-danger-text">
<div className="flex h-14 items-center justify-between gap-3 rounded-lg bg-[#fff0f0] px-4">
<span className="flex flex-none items-center gap-2 text-sm font-medium text-danger-text">
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-danger-text" />
录音中 {formatSeconds(seconds)}
</span>
<div className="flex items-center gap-4">
<span className="h-4 min-w-0 flex-1 rounded bg-[repeating-linear-gradient(90deg,#f3b8b8_0px,#f3b8b8_2px,transparent_2px,transparent_5px)]" />
<div className="flex flex-none items-center gap-4">
<button type="button" onClick={() => finish(false)} className="text-sm text-text-secondary">
取消
</button>