修复codereview复核确认的多选/转发/收藏页/资料页等前端bug

- 多选模式下Drafty链接、名片消息不再意外触发跳转/开新标签页
- 语音/视频消息在多选模式下点击可正常冒泡选中该行
- 转发排除未确认/发送失败消息,识别uncertain转发结果并单独提示
- 群文件"更多操作"菜单竞态、群成员移出状态竞态
- 收藏页链接卡片new URL()崩溃点加防御,并给登录后应用区域加错误边界
- 个人资料页乐观锁版本冲突时清空草稿避免静默覆盖并发修改

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-27 16:47:32 +08:00
co-authored by Claude Sonnet 5
parent e3eed8bdd4
commit 9abae5030b
10 changed files with 142 additions and 27 deletions
+14 -5
View File
@@ -23,7 +23,7 @@ import { getConversationContext, getUserProfile } from '@/lib/api/friends';
import { getMe } from '@/lib/api/profile';
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
import { useClickOutside } from '@/lib/hooks';
import { availableActions, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions';
import { availableActions, isConfirmedSeq, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions';
import { flatTextContentOf, isRecalled } from '@/lib/messages/dispatch';
import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions';
import { buildReplyInfo, replySnapshotText } from '@/lib/messages/reply';
@@ -821,10 +821,12 @@ export function ConversationView() {
// 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.
// "forwarded from" tag rather than the message the user actually picked. Checkbox selection also
// doesn't refuse a still-sending/failed message (its placeholder seq is a plain `number`, same as
// a real one) -- that message has no server-side (topic, seq) yet for forwardMessages() to find.
const sources = orderedSeqs
.map((seq) => topic.findMessage(seq))
.filter((msg): msg is StoredMessage => !!msg && !isRecalled(msg))
.filter((msg): msg is StoredMessage => !!msg && !isRecalled(msg) && isConfirmedSeq(msg))
.map((msg) => ({ topic: topicId, seq: msg.seq as number }));
// One REST call now covers every (source, target) pair -- forwardMessages()'s own comment
// explains why this replaced per-target `ensureTopicAttached()` + per-message client-side
@@ -848,6 +850,11 @@ export function ConversationView() {
? forwardMessages(sources, targetTopics)
.then((res) => {
if (res.failed.length > 0) showError(undefined, `${res.failed.length} 条消息转发失败`);
// 'sent'/'pending' both mean the server has it; 'uncertain' (e.g. a timeout mid-write)
// does not -- silently lumping it in with success would leave the user with no signal
// that this particular copy may never actually land in the target conversation.
const uncertain = res.items.filter((item) => item.status === 'uncertain').length;
if (uncertain > 0) notify(`${uncertain} 条转发结果暂不确定,请稍后在目标会话中确认`);
})
.catch((err) => showError(err, '转发失败'))
: Promise.resolve();
@@ -1247,10 +1254,12 @@ export function ConversationView() {
// "pending" persistence, landing the comment BEFORE the forwarded content it's meant to
// follow).
const forwardDone =
typeof forwardMsg.seq === 'number'
? forwardMessages([{ topic: topicId, seq: forwardMsg.seq }], targetTopics)
isConfirmedSeq(forwardMsg)
? forwardMessages([{ topic: topicId, seq: forwardMsg.seq as number }], targetTopics)
.then((res) => {
if (res.failed.length > 0) showError(undefined, `${res.failed.length} 个目标转发失败`);
const uncertain = res.items.filter((item) => item.status === 'uncertain').length;
if (uncertain > 0) notify(`${uncertain} 个目标转发结果暂不确定,请稍后在目标会话中确认`);
})
.catch((err) => showError(err, '转发失败'))
: Promise.resolve();