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:
co-authored by
Claude Sonnet 5
parent
1826d2906f
commit
46b3288880
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user