按产品决定移除面向国内客户不适用的自主注册入口:删除 /register 路由 及 RegisterPage、登录页"马上注册"链接、登录/注册共用的协议勾选组件 Agreement,以及仅注册流程使用的 registerAccount/queueAccExtra 等 死代码;登录不再依赖勾选协议才能提交。 修复转发消息报错(真实高频bug,不限于个别场景):`forwardMessage`/ `sendText` 发送前只是 `tinode.getTopic()` 取一个本地话题对象,从未 真正订阅——SDK 要求话题处于 attached 状态才允许发布,而只有当前正 打开的会话才满足这一点,一旦切换过/离开过某个会话(哪怕之前订阅过 预览),它就会变回未订阅状态。也就是说转发给"当前会话之外"几乎任何 一个联系人都会失败。新增 `ensureTopicAttached()`,在转发前按需临时 订阅目标话题,单条转发和多选批量转发两处都已接入,实机验证对未打开 过的好友/群转发均成功。 转发选择器(ForwardPickerModal)按好友/群聊分组显示(原来混在一个 列表里),并加了"全选/取消全选",避免多个接收方时逐条点选。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
171 lines
7.2 KiB
TypeScript
171 lines
7.2 KiB
TypeScript
import { useMemo, useState } from 'react';
|
||
|
||
import type { Contact } from 'tinode-sdk';
|
||
|
||
import { Icon } from '@/app/Icon';
|
||
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('');
|
||
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 visibleTopics = useMemo(() => filtered.map((c) => c.topic).filter((t): t is string => !!t), [filtered]);
|
||
const allVisibleSelected = visibleTopics.length > 0 && visibleTopics.every((t) => selected.has(t));
|
||
const toggleSelectAll = () => {
|
||
setSelected((prev) => {
|
||
if (allVisibleSelected) {
|
||
// 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 visibleTopics) next.delete(t);
|
||
return next;
|
||
}
|
||
return new Set([...prev, ...visibleTopics]);
|
||
});
|
||
};
|
||
|
||
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>
|
||
<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>
|
||
);
|
||
};
|
||
|
||
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>
|
||
{visibleTopics.length ? (
|
||
<button type="button" onClick={toggleSelectAll} className="mt-2 text-xs text-accent">
|
||
{allVisibleSelected ? '取消全选' : '全选'}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||
{friends.length ? (
|
||
<>
|
||
<div className="px-3 pb-1 pt-2 text-xs text-text-tertiary-2">好友</div>
|
||
{friends.map(renderRow)}
|
||
</>
|
||
) : null}
|
||
{groups.length ? (
|
||
<>
|
||
<div className="px-3 pb-1 pt-2 text-xs text-text-tertiary-2">群聊</div>
|
||
{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>
|
||
);
|
||
}
|