diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index 14c861f4..08261b4f 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -65,16 +65,6 @@ 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(); @@ -175,28 +165,6 @@ 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); @@ -693,27 +661,13 @@ 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(); - const images = pendingImages; - if (!text && images.length === 0) return; + if (!text) 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 @@ -721,40 +675,20 @@ 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(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) { + .then((target) => { + if (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 (mentions.length > 0) { - return sendPrebuiltContent(target, buildMentionedContent(text, mentions)); + if (pendingMentions.length > 0) { + return sendPrebuiltContent(target, buildMentionedContent(text, pendingMentions)); } return sendText(target, text); }) .catch((err) => showError(err, '发送失败')); + if (topicId) setMentionsByTopic((prev) => ({ ...prev, [topicId]: [] })); }; const handleDraftChange = (e: React.ChangeEvent) => { @@ -984,41 +918,13 @@ 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) => { - addPendingImages(Array.from(e.target.files || [])); + const file = e.target.files?.[0]; e.target.value = ''; + if (file) { + const reply = consumePendingReply(); + sendImage(topic, file, reply).catch((err) => showError(err, '图片发送失败')); + } }; const handlePickFile = (e: React.ChangeEvent) => { @@ -1030,35 +936,27 @@ export function ConversationView() { } }; - /** 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. */ + /** 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. */ 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) files.push(file); + if (!file) continue; + e.preventDefault(); + const reply = consumePendingReply(); + sendImage(topic, file, reply).catch((err) => showError(err, '图片发送失败')); + return; } } - if (files.length === 0) return; - e.preventDefault(); - addPendingImages(files); }; return ( @@ -1234,23 +1132,6 @@ export function ConversationView() { ) : null} - {pendingImages.length > 0 ? ( -
- {pendingImages.map((img) => ( -
- - -
- ))} -
- ) : null}