Files
im_webapp/web/src/features/messages/ForwardPickerModal.tsx
T
Your NameandClaude Sonnet 5 4e1f1cbf0f refactor(web): 提取共享的圆形多选勾选框组件,统一5处重复实现
创建群聊、消息多选、我的收藏多选、黑名单多选、转发选择器——这5个
地方各自手写了一份几乎一样的"圆形+选中变实心+里面check图标"勾选框
markup,样式上有细微且无意的差异(比如有的选中态多画了一圈边框、
用的灰色深浅不完全一致),上次只修了转发选择器一处,用户指出创建
群聊那里同样丑、而且问了"这种组件不该统一做吗"——確实该統一。

新增 `app/SelectionCheckbox.tsx`,把上次在转发选择器里验证过的
矢量勾选图标方案抽成共享组件,5处全部换成调用它。以后这个视觉样式
要再调整,改一个文件就够了,不会再出现"修好了一处,另外四处还是
老样子"的情况。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:47:08 +08:00

187 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Set<string>>(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 (
<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"
>
<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>
<SelectionCheckbox selected={isSelected} />
</button>
);
};
const renderSectionHeader = (label: string, section: 'friends' | 'groups', topics: string[], allSelected: boolean) => (
<div className="flex items-center justify-between px-3 pb-1 pt-2">
<button type="button" onClick={() => toggleCollapsed(section)} className="flex items-center gap-0.5 text-xs text-text-tertiary-2">
<Icon name="chevron_right" className={`text-sm transition-transform ${collapsed[section] ? '' : 'rotate-90'}`} />
{label}
</button>
<button type="button" onClick={() => toggleSectionSelectAll(topics, allSelected)} className="text-xs text-accent">
{allSelected ? '取消全选' : '全选'}
</button>
</div>
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<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>
</header>
<div className="flex-none px-4 pt-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-2 py-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 ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-text-tertiary-4">
<Icon name="search_off" className="!text-4xl" />
<span className="text-sm">未找到相关联系人</span>
</div>
) : null}
</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>
);
}