feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes
Rounds out the full rewrite with the remaining feature domains: group management (admin panel, mute, files, announcements, QR-based invite/join), contacts (friend profile, add-friend, blocklist, new-friends), favorites (real media rendering across text/image/video/voice/file/link), settings (profile/security/privacy/general/about), calls (Agora-backed audio/video signaling + call history), and system messages/file assistant. Also folds in a large batch of live-verified fixes surfaced by repeated fresh code-review passes and hands-on E2E testing against the real backend, including: WS reconnect not resubscribing any topic after a disconnect, missing x-im-client-mid on every send path (protocol compliance for retry dedup), group mute/member-settings/self-profile endpoint corrections, relationship-state type fixes across search/QR/ friend-profile, tenant-switch logout gap, password-policy now read from the real per-tenant config instead of guessed, NavRail highlight/ aria-current desync, several call-state-machine races, and responsive layout fixes across all four breakpoints (mobile/tablet/laptop/desktop) including a full mobile rebuild of favorites, call UI, and the settings home/sub-list split. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user