From 6bcfc08943e19d76a058094e41183b23f115aef6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 29 Sep 2026 03:06:24 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BD=91=E9=A1=B5=E7=89=88=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=B2=98=E8=B4=B4/=E9=80=89=E6=8B=A9=E5=A4=9A=E5=BC=A0?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=9A=82=E5=AD=98=EF=BC=8C=E5=8F=AF=E9=85=8D?= =?UTF-8?q?=E6=96=87=E5=AD=97=E4=B8=80=E8=B5=B7=E5=8F=91=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 老板反馈:粘贴图片、选择文件都是立即发送,体验不好;想要能粘贴多张图片,并且可以带着文字一起发, 不带文字也要能发。 - 粘贴图片、点击图片图标选图(新增 multiple 多选)现在都不再立即发送,改成暂存进输入框上方的 缩略图预览条,可以继续粘贴/选择叠加更多图片,每张都能单独点掉重新移除 - 发送(Enter / 点击发送按钮)时,把暂存的图片按添加顺序依次发出去,再发文字(如果有输入的话); 没有文字、只有图片也能发;没有图片、只有文字保持原有行为不变 - 引用回复只附着在这一批里实际发出的第一条消息上,跟"引用只作用于下一条消息"的既有规则保持一致 - 单张图片发送失败不影响这一批里的其他图片和后面的文字,各自独立报错提示 - 参考设计图(语音图片发送状态.png)确认了这个后端每种附件类型都是独立气泡,没有"图文合并成一条 消息"的协议形态,所以是按多条消息依次发送实现的,不是改协议 Co-Authored-By: Claude Sonnet 5 --- .../features/messages/ConversationView.tsx | 169 +++++++++++++++--- 1 file changed, 144 insertions(+), 25 deletions(-) diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index 08261b4f..14c861f4 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -65,6 +65,16 @@ interface ContextMenuState { msg: StoredMessage; } +/** A picked/pasted image staged in the composer, not sent yet (老板 2026-09-29: paste/pick used to + * send immediately, with no way to attach more than one image or add a caption). `previewUrl` is a + * local `URL.createObjectURL(file)` for the thumbnail strip -- revoked on removal/send/unmount, see + * those call sites below. */ +interface PendingImage { + id: string; + file: File; + previewUrl: string; +} + export function ConversationView() { const { topicId } = useParams(); const navigate = useNavigate(); @@ -165,6 +175,28 @@ export function ConversationView() { const [mentionsByTopic, setMentionsByTopic] = useState>({}); const pendingMentions = (topicId && mentionsByTopic[topicId]) || []; const [mentionTrigger, setMentionTrigger] = useState<{ start: number; query: string } | null>(null); + // Staged images, same per-topic keying as draftByTopic/mentionsByTopic above and for the identical + // reason (this component is one long-lived instance reused across every conversation route) -- + // without it, photos attached while composing in conversation A would leak into whichever + // conversation the user switches to next, the same bug class draftByTopic's own comment covers. + const [pendingImagesByTopic, setPendingImagesByTopic] = useState>({}); + const pendingImages = (topicId && pendingImagesByTopic[topicId]) || []; + const nextPendingImageIdRef = useRef(0); + // Mirrors pendingImagesByTopic for the unmount-cleanup effect below, which needs the LATEST value + // in its cleanup closure, not whatever was current the one time that effect's own body last ran. + const pendingImagesByTopicRef = useRef(pendingImagesByTopic); + pendingImagesByTopicRef.current = pendingImagesByTopic; + // Revokes every still-outstanding staged-image preview URL when this screen itself unmounts (e.g. + // navigating away from /messages entirely, not just switching conversations, which this component + // survives without unmounting) -- removePendingImage/submit()'s own post-send cleanup below already + // revoke one-by-one for the ordinary remove/send paths, but nothing else runs on a full unmount. + useEffect(() => { + return () => { + Object.values(pendingImagesByTopicRef.current) + .flat() + .forEach((img) => URL.revokeObjectURL(img.previewUrl)); + }; + }, []); const [groupModal, setGroupModal] = useState<'announcement' | 'join-requests' | null>(null); const textareaRef = useRef(null); const [menu, setMenu] = useState(null); @@ -661,13 +693,27 @@ export function ConversationView() { } }; + /** Sends everything staged in the composer -- any attached images, then the typed text -- as one + * Enter/发送 press. Each image is still its own message (see PendingImage's own doc comment and + * handleComposerPaste below for why there's no combined "text+image" message to build instead), + * but the user only has to act once regardless of how many images are attached or whether there's + * a caption at all. Can fire with images and no text ("当然也可以不用文字也能发"), or text and no + * images (the original, unchanged behavior). */ const submit = () => { const text = draft.trim(); - if (!text) return; + const images = pendingImages; + if (!text && images.length === 0) return; setDraft(''); setMentionTrigger(null); + if (topicId) setPendingImagesByTopic((prev) => ({ ...prev, [topicId]: [] })); + // Safe to revoke immediately, not after the sends below settle -- these URLs only ever backed the + // now-cleared thumbnail strip's ``; sendImage reads pixel data from `img.file` itself (a + // real File, independent of this preview URL), never from previewUrl. + images.forEach((img) => URL.revokeObjectURL(img.previewUrl)); const pendingReplyTo = replyTo; setReplyTo(null); + const mentions = pendingMentions; + if (topicId) setMentionsByTopic((prev) => ({ ...prev, [topicId]: [] })); // The composer's own topic can be detached by the time this actually fires -- e.g. a // disconnect/reconnect cycle resets every topic's attached state (see connection.ts's // onReauthenticated), and publishing on a detached topic is rejected outright by the SDK. The @@ -675,20 +721,40 @@ export function ConversationView() { // comment for why it's the common case, not an edge case); the primary composer -- used far // more often than forwarding -- had no equivalent guard at all. ensureTopicAttached(topicId) - .then((target) => { - if (pendingReplyTo) { + .then(async (target) => { + // "The quote applies to the NEXT outgoing message only" (still the rule -- see + // consumePendingReply's own doc comment elsewhere in this file) -- with a whole batch going + // out from one press, "next" means the very FIRST message actually sent below, whichever + // branch that turns out to be; this flag flips off the instant either one claims it. + let replyPending = !!pendingReplyTo; + // Images first, in the order they were staged, each awaited before starting the next -- + // NOT Promise.all'd in parallel, so they land in the chat in staging order rather than racing + // on individual upload time (a later-attached-but-smaller image finishing first). A single + // image's failure (e.g. over the size limit) is reported per-image and does not abort the + // rest of the batch or the caption below -- matches this file's existing bulkForward/ + // bulkFavorite pattern of not letting one bad item block everything else selected alongside it. + for (const img of images) { + const reply = replyPending && pendingReplyTo ? buildReplyInfo(pendingReplyTo) : null; + replyPending = false; + try { + await sendImage(target, img.file, reply); + } catch (err) { + showError(err, `${img.file.name || '图片'}发送失败`); + } + } + if (!text) return; + if (replyPending && pendingReplyTo) { // Mentions inside a reply don't get spliced into real MN entities -- a quoted reply that // also pings someone is a rare enough compound case that it isn't worth a second // stitching path; the "@name" text still sends fine, just without the entity/highlight. return sendReply(target, text, pendingReplyTo); } - if (pendingMentions.length > 0) { - return sendPrebuiltContent(target, buildMentionedContent(text, pendingMentions)); + if (mentions.length > 0) { + return sendPrebuiltContent(target, buildMentionedContent(text, mentions)); } return sendText(target, text); }) .catch((err) => showError(err, '发送失败')); - if (topicId) setMentionsByTopic((prev) => ({ ...prev, [topicId]: [] })); }; const handleDraftChange = (e: React.ChangeEvent) => { @@ -918,13 +984,41 @@ export function ConversationView() { } }; + /** Adds newly picked/pasted files to this topic's staged-image strip -- appended, not replaced, so + * picking/pasting again (from either source, in any mix) keeps adding rather than clobbering + * what's already there. Filters to actual images defensively: the file-picker's own + * `accept="image/*"` and handleComposerPaste's own mime check already narrow this in the normal + * case, but a stray non-image slipping through here would otherwise silently break the thumbnail + * `` below rather than failing loudly. */ + const addPendingImages = (files: File[]) => { + if (!topicId) return; + const images = files.filter((f) => f.type.startsWith('image/')); + if (images.length === 0) return; + const staged: PendingImage[] = images.map((file) => ({ + id: `pimg_${nextPendingImageIdRef.current++}`, + file, + previewUrl: URL.createObjectURL(file), + })); + setPendingImagesByTopic((prev) => ({ ...prev, [topicId]: [...(prev[topicId] || []), ...staged] })); + }; + + const removePendingImage = (id: string) => { + if (!topicId) return; + setPendingImagesByTopic((prev) => { + const list = prev[topicId] || []; + const target = list.find((img) => img.id === id); + if (target) URL.revokeObjectURL(target.previewUrl); + return { ...prev, [topicId]: list.filter((img) => img.id !== id) }; + }); + }; + + /** User report (老板, 2026-09-29): picking or pasting an image used to send it immediately, with no + * way to attach more than one or add a caption -- stages it into the thumbnail strip instead + * (see PendingImage/submit's own doc comments), same as every mainstream IM. Supports the + * browser's native multi-select (file input has `multiple` below). */ const handlePickImage = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; + addPendingImages(Array.from(e.target.files || [])); e.target.value = ''; - if (file) { - const reply = consumePendingReply(); - sendImage(topic, file, reply).catch((err) => showError(err, '图片发送失败')); - } }; const handlePickFile = (e: React.ChangeEvent) => { @@ -936,27 +1030,35 @@ export function ConversationView() { } }; - /** User report (老板, 2026-09-29): "没有复制粘贴图片功能,只能保存在电脑里面发" -- e.g. a screenshot - * tool, or "复制图片" from a browser/chat app, only ever puts raw pixel data on the clipboard, never - * a File the hidden `` above can see; without this, getting that image into a - * message required saving it to disk first and picking it via handlePickImage. `item.getAsFile()` - * returns a real File, indistinguishable from one chosen through the file picker, so this reuses - * the exact same send path. Only preventDefault once an actual image item is found -- an ordinary - * text paste (no image on the clipboard at all) is left completely untouched. */ + /** User report (老板, 2026-09-29): "没有复制粘贴图片功能,只能保存在电脑里面发,图片也没有保存功能", + * followed by "想要做到可以粘贴多张图片并且也可以带着文字一起发" -- e.g. a screenshot tool, or "复制 + * 图片" from a browser/chat app, only ever puts raw pixel data on the clipboard, never a File the + * hidden `` above can see; without this, getting that image into a message + * required saving it to disk first and picking it via handlePickImage. `item.getAsFile()` returns + * a real File, indistinguishable from one chosen through the file picker. + * + * Stages every image item found (not just the first) -- a single OS paste CAN carry several files + * at once (e.g. multi-select several images in Finder, copy, paste here), and this backend has no + * combined "text+image" message to build regardless (see 语音图片发送状态.png design reference: + * every attachment type -- image/voice/file -- is its own bubble, confirmed there's no single + * mixed-content message shape to target), so "add to what's already staged" is both correct today + * and is exactly what lets a caption/more images keep being added across several paste actions. + * Only preventDefault once at least one image item was actually found -- an ordinary text paste + * (no image on the clipboard at all) is left completely untouched, browser handles it natively. */ const handleComposerPaste = (e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; + const files: File[] = []; for (let i = 0; i < items.length; i += 1) { const item = items[i]; if (item.kind === 'file' && item.type.startsWith('image/')) { const file = item.getAsFile(); - if (!file) continue; - e.preventDefault(); - const reply = consumePendingReply(); - sendImage(topic, file, reply).catch((err) => showError(err, '图片发送失败')); - return; + if (file) files.push(file); } } + if (files.length === 0) return; + e.preventDefault(); + addPendingImages(files); }; return ( @@ -1132,6 +1234,23 @@ export function ConversationView() { ) : null} + {pendingImages.length > 0 ? ( +
+ {pendingImages.map((img) => ( +
+ + +
+ ))} +
+ ) : null}