创建群聊、消息多选、我的收藏多选、黑名单多选、转发选择器——这5个 地方各自手写了一份几乎一样的"圆形+选中变实心+里面check图标"勾选框 markup,样式上有细微且无意的差异(比如有的选中态多画了一圈边框、 用的灰色深浅不完全一致),上次只修了转发选择器一处,用户指出创建 群聊那里同样丑、而且问了"这种组件不该统一做吗"——確实该統一。 新增 `app/SelectionCheckbox.tsx`,把上次在转发选择器里验证过的 矢量勾选图标方案抽成共享组件,5处全部换成调用它。以后这个视觉样式 要再调整,改一个文件就够了,不会再出现"修好了一处,另外四处还是 老样子"的情况。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
187 lines
8.4 KiB
TypeScript
187 lines
8.4 KiB
TypeScript
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>
|
||
);
|
||
}
|