网页版支持粘贴/选择多张图片暂存,可配文字一起发送
老板反馈:粘贴图片、选择文件都是立即发送,体验不好;想要能粘贴多张图片,并且可以带着文字一起发, 不带文字也要能发。 - 粘贴图片、点击图片图标选图(新增 multiple 多选)现在都不再立即发送,改成暂存进输入框上方的 缩略图预览条,可以继续粘贴/选择叠加更多图片,每张都能单独点掉重新移除 - 发送(Enter / 点击发送按钮)时,把暂存的图片按添加顺序依次发出去,再发文字(如果有输入的话); 没有文字、只有图片也能发;没有图片、只有文字保持原有行为不变 - 引用回复只附着在这一批里实际发出的第一条消息上,跟"引用只作用于下一条消息"的既有规则保持一致 - 单张图片发送失败不影响这一批里的其他图片和后面的文字,各自独立报错提示 - 参考设计图(语音图片发送状态.png)确认了这个后端每种附件类型都是独立气泡,没有"图文合并成一条 消息"的协议形态,所以是按多条消息依次发送实现的,不是改协议 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3d48abdf73
commit
6bcfc08943
@@ -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<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);
|
||||
@@ -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 `<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
|
||||
@@ -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<HTMLTextAreaElement>) => {
|
||||
@@ -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
|
||||
* `<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>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
@@ -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 `<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. */
|
||||
/** 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. */
|
||||
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) 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() {
|
||||
</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
|
||||
@@ -1173,7 +1292,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/*" onChange={handlePickImage} className="hidden" />
|
||||
<input type="file" accept="image/*" multiple 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" />
|
||||
@@ -1244,7 +1363,7 @@ export function ConversationView() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!draft.trim()}
|
||||
disabled={!draft.trim() && pendingImages.length === 0}
|
||||
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"
|
||||
>
|
||||
发送
|
||||
|
||||
Reference in New Issue
Block a user