import { useMemo, useState } from 'react'; import type { Contact } from 'tinode-sdk'; import { Icon } from '@/app/Icon'; import { SelectionCheckbox } from '@/app/SelectionCheckbox'; import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { useEscapeKey } from '@/lib/hooks'; import { useChatList, useTinode } from '@/lib/tinode/hooks'; const MAX_COMMENT_LENGTH = 200; interface ForwardPickerModalProps { onPick: (targetTopics: string[], comment: string) => void; onClose: () => void; } /** * 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 tinode = useTinode(); const [query, setQuery] = useState(''); const [selected, setSelected] = useState>(new Set()); const [comment, setComment] = useState(''); // Both start expanded -- collapsing is for a long list getting in the way of the OTHER section, // not a default state anyone would want on first open. const [collapsed, setCollapsed] = useState<{ friends: boolean; groups: boolean }>({ friends: false, groups: false }); useEscapeKey(onClose); const filtered = contacts.filter((c) => !query.trim() || (c.public?.fn || '').includes(query.trim())); // The list used to mix friends and groups in one flat run -- every other list in this app // (ChatListColumn's pinned/sorted rows, ContactsListColumn) already keeps the two visually // separate, so this was the odd one out and made a specific group hard to spot among dozens of // P2P contacts. const friends = useMemo( () => filtered.filter((c) => !!c.topic && !tinode.getTopic(c.topic).isGroupType()), [filtered, tinode], ); const groups = useMemo( () => filtered.filter((c) => !!c.topic && tinode.getTopic(c.topic).isGroupType()), [filtered, tinode], ); 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 friendTopics = useMemo(() => friends.map((c) => c.topic).filter((t): t is string => !!t), [friends]); const groupTopics = useMemo(() => groups.map((c) => c.topic).filter((t): t is string => !!t), [groups]); const allFriendsSelected = friendTopics.length > 0 && friendTopics.every((t) => selected.has(t)); const allGroupsSelected = groupTopics.length > 0 && groupTopics.every((t) => selected.has(t)); const toggleSectionSelectAll = (topics: string[], allSelected: boolean) => { setSelected((prev) => { if (allSelected) { // Only drop the ones the CURRENT search actually shows -- a selection made under an earlier, // now-cleared query shouldn't be wiped out by un-selecting "all" of a narrower view. const next = new Set(prev); for (const t of topics) next.delete(t); return next; } return new Set([...prev, ...topics]); }); }; const toggleCollapsed = (section: 'friends' | 'groups') => { setCollapsed((prev) => ({ ...prev, [section]: !prev[section] })); }; const send = () => { if (selected.size === 0) return; onPick([...selected], comment.trim()); }; const renderRow = (c: Contact) => { const isSelected = !!c.topic && selected.has(c.topic); return ( ); }; const renderSectionHeader = (label: string, section: 'friends' | 'groups', topics: string[], allSelected: boolean) => (
); return (
e.stopPropagation()}>
转发到{selected.size ? `(已选 ${selected.size} 个接收方)` : ''}
setQuery(e.target.value)} placeholder="搜索会话或联系人" className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2" />
{friends.length ? ( <> {renderSectionHeader('好友', 'friends', friendTopics, allFriendsSelected)} {collapsed.friends ? null : friends.map(renderRow)} ) : null} {groups.length ? ( <> {renderSectionHeader('群聊', 'groups', groupTopics, allGroupsSelected)} {collapsed.groups ? null : groups.map(renderRow)} ) : null} {!friends.length && !groups.length ? (
未找到相关联系人
) : null}