Revert "网页版支持粘贴/选择多张图片暂存,可配文字一起发送"

用户反馈这个交互做得不够好,要求退回上一版(保留粘贴发图/保存图片,去掉多图暂存+图文一起发)。

This reverts commit 6bcfc08943.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-29 03:12:29 +08:00
co-authored by Claude Sonnet 5
parent 6bcfc08943
commit 975dc77111
+25 -144
View File
@@ -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<Record<string, PendingMention[]>>({});
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<Record<string, PendingImage[]>>({});
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<HTMLTextAreaElement>(null);
const [menu, setMenu] = useState<ContextMenuState | null>(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 `<img>`; 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<HTMLTextAreaElement>) => {
@@ -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
* `<img>` 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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
@@ -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 `<input type="file">` 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 `<input type="file">` 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<HTMLTextAreaElement>) => {
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() {
</button>
</div>
) : null}
{pendingImages.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-2 px-1">
{pendingImages.map((img) => (
<div key={img.id} className="relative h-14 w-14 flex-none overflow-hidden rounded-lg border border-border-panel">
<img src={img.previewUrl} alt="" className="h-full w-full object-cover" />
<button
type="button"
onClick={() => removePendingImage(img.id)}
aria-label="移除图片"
className="absolute right-0.5 top-0.5 grid h-4.5 w-4.5 place-items-center rounded-full bg-black/60 text-white"
>
<Icon name="close" className="text-[12px]" />
</button>
</div>
))}
</div>
) : null}
<div className="relative flex items-center gap-0.5 px-1 pb-1">
<div ref={emojiContainerRef} className="relative">
<button
@@ -1292,7 +1173,7 @@ export function ConversationView() {
</div>
<label className="grid h-8.5 w-8.5 cursor-pointer place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]">
<Icon name="image" />
<input type="file" accept="image/*" multiple onChange={handlePickImage} className="hidden" />
<input type="file" accept="image/*" onChange={handlePickImage} className="hidden" />
</label>
<label className="grid h-8.5 w-8.5 cursor-pointer place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]">
<Icon name="attach_file" />
@@ -1363,7 +1244,7 @@ export function ConversationView() {
<button
type="button"
onClick={submit}
disabled={!draft.trim() && pendingImages.length === 0}
disabled={!draft.trim()}
className="h-9 flex-none rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled disabled:shadow-none"
>
发送