修复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
@@ -222,8 +222,9 @@ function formatDuration(ms: number | undefined): string {
* confirmed by its base64 signature) instead. Tapping swaps the poster for a real `<video>` playing
* from `val`/`ref`, mirroring AudioAttachment's inline-vs-authenticated-fetch duality.
*
* `locked`: see ImageAttachment's own comment -- reuses the existing `disabled` (already there for
* a not-yet-loaded video) rather than a second, parallel gating mechanism. */
* `locked`: see ImageAttachment's own comment -- gated in `onClick`, not folded into `disabled`,
* since a disabled <button> never dispatches `click` at all and would swallow the row-toggle this
* click is supposed to bubble up to (unlike FileAttachment's plain anchor, this is a real button). */
export function VideoAttachment({ data, locked }: { data: VideoData; locked?: boolean }) {
const [playing, setPlaying] = useState(false);
const posterInline = typeof data.preview === 'string' ? `data:image/jpeg;base64,${data.preview}` : null;
@@ -245,8 +246,11 @@ export function VideoAttachment({ data, locked }: { data: VideoData; locked?: bo
return (
<button
type="button"
onClick={() => setPlaying(true)}
disabled={!videoSrc || locked}
onClick={() => {
if (locked) return;
setPlaying(true);
}}
disabled={!videoSrc}
className="relative block h-[170px] w-[260px] overflow-hidden rounded-lg bg-surface-rail"
>
{posterUrl ? (
@@ -271,9 +275,10 @@ interface AudioData {
duration?: number;
}
/** `locked`: see ImageAttachment's own comment -- reuses the existing `disabled` (already there for a
* not-yet-loaded voice message) rather than a second, parallel gating mechanism. Ongoing playback
* started BEFORE selectMode was entered is left alone -- this only blocks a NEW toggle action. */
/** `locked`: see VideoAttachment's own comment -- gated in `toggle()`, not folded into `disabled`,
* so the click still bubbles to the row toggle instead of being swallowed by the native button.
* Ongoing playback started BEFORE selectMode was entered is left alone -- this only blocks a NEW
* toggle action. */
export function AudioAttachment({ data, locked }: { data: AudioData; locked?: boolean }) {
const inline = typeof data.val === 'string' ? `data:${data.mime || 'audio/webm'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
@@ -295,6 +300,7 @@ export function AudioAttachment({ data, locked }: { data: AudioData; locked?: bo
}, [audioEl]);
const toggle = () => {
if (locked) return;
if (!audioEl || !src) return;
if (playing) {
audioEl.pause();
@@ -310,7 +316,7 @@ export function AudioAttachment({ data, locked }: { data: AudioData; locked?: bo
};
return (
<button type="button" onClick={toggle} disabled={!src || locked} className="flex w-[200px] items-center gap-2">
<button type="button" onClick={toggle} disabled={!src} className="flex w-[200px] items-center gap-2">
<Icon
name={playError ? 'error_outline' : playing ? 'pause_circle' : 'play_circle'}
className={`text-[24px] ${playError ? 'text-danger-text' : 'text-accent'}`}