feat(web): 下线自主注册与协议勾选,修复转发消息报错,转发选择器分组+全选

按产品决定移除面向国内客户不适用的自主注册入口:删除 /register 路由
及 RegisterPage、登录页"马上注册"链接、登录/注册共用的协议勾选组件
Agreement,以及仅注册流程使用的 registerAccount/queueAccExtra 等
死代码;登录不再依赖勾选协议才能提交。

修复转发消息报错(真实高频bug,不限于个别场景):`forwardMessage`/
`sendText` 发送前只是 `tinode.getTopic()` 取一个本地话题对象,从未
真正订阅——SDK 要求话题处于 attached 状态才允许发布,而只有当前正
打开的会话才满足这一点,一旦切换过/离开过某个会话(哪怕之前订阅过
预览),它就会变回未订阅状态。也就是说转发给"当前会话之外"几乎任何
一个联系人都会失败。新增 `ensureTopicAttached()`,在转发前按需临时
订阅目标话题,单条转发和多选批量转发两处都已接入,实机验证对未打开
过的好友/群转发均成功。

转发选择器(ForwardPickerModal)按好友/群聊分组显示(原来混在一个
列表里),并加了"全选/取消全选",避免多个接收方时逐条点选。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-10 19:04:59 +08:00
co-authored by Claude Sonnet 5
parent 1826d2906f
commit 46b3288880
13 changed files with 161 additions and 474 deletions
+24 -14
View File
@@ -28,7 +28,7 @@ import { buildMentionedContent, detectMentionTrigger, type PendingMention } from
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send';
import { closeConversation, openConversation } from '@/lib/tinode/topics';
import { closeConversation, ensureTopicAttached, openConversation } from '@/lib/tinode/topics';
import { useCallStore } from '@/stores/calls';
import { useMessageJumpStore } from '@/stores/messageJump';
import { useSessionStore } from '@/stores/session';
@@ -469,16 +469,23 @@ export function ConversationView() {
// 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, '附言发送失败'));
// A target that isn't the conversation currently open (i.e. almost any real forward target)
// is not actually subscribed, and publishing on an unsubscribed topic is rejected outright by
// the SDK -- see ensureTopicAttached's own comment for why this is the common case, not an
// edge case.
ensureTopicAttached(targetTopic)
.then((target) => {
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, '附言发送失败'));
})
.catch((err) => showError(err, '转发失败'));
}
setBulkForwardOpen(false);
exitSelectMode();
@@ -815,9 +822,12 @@ export function ConversationView() {
onClose={() => setForwardMsg(null)}
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, '附言发送失败'));
ensureTopicAttached(targetTopic)
.then((target) => {
forwardMessage(topicId, forwardMsg, target).catch((err) => showError(err, '转发失败'));
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
})
.catch((err) => showError(err, '转发失败'));
}
setForwardMsg(null);
}}
@@ -1,9 +1,11 @@
import { useState } from 'react';
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 } from '@/lib/tinode/hooks';
import { useChatList, useTinode } from '@/lib/tinode/hooks';
const MAX_COMMENT_LENGTH = 200;
@@ -24,12 +26,26 @@ interface ForwardPickerModalProps {
*/
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);
@@ -39,11 +55,50 @@ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps)
});
};
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()}>
@@ -66,31 +121,31 @@ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps)
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">
{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"
>
<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>
);
})}
{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