From ff69675d0da9da1c135452550e595c40be52c1db Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:51:06 -0700 Subject: [PATCH] fix(feedback): stop an unsupported pasted image from eating co-pasted text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paste handler consumed the event whenever the clipboard held any image/* file, but only the four allow-listed types can actually attach. Pasting text alongside an SVG or BMP therefore lost the text and attached nothing — a silent loss of the user's own input, in the dialog where they are mid-sentence. Consume the paste only when something is attachable. Unsupported types still route through readFeedbackImageFiles for their rejection toast, so nothing is dropped silently; the difference is that the default paste is left alone when we have nothing to offer in exchange. Extraction deliberately stays broad. Narrowing it there (as suggested by review) would skip handleAddFiles entirely, and a file paste into a textarea does nothing visible — the image would vanish with no feedback. The drop path is untouched: it must keep cancelling every native file drop or the browser navigates the tab to the file. --- .../sidebar/SidebarFeedbackDialog.tsx | 13 +++++++++--- .../lib/feedback-image-attachments.test.ts | 21 +++++++++++++++++++ .../src/lib/feedback-image-attachments.ts | 10 +++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/sidebar/SidebarFeedbackDialog.tsx b/src/renderer/src/components/sidebar/SidebarFeedbackDialog.tsx index 18504da0acb..9539d1a9187 100644 --- a/src/renderer/src/components/sidebar/SidebarFeedbackDialog.tsx +++ b/src/renderer/src/components/sidebar/SidebarFeedbackDialog.tsx @@ -17,6 +17,7 @@ import type { GitHubViewer } from '../../../../shared/types' import { translate } from '@/i18n/i18n' import { extractImageFilesFromDataTransfer, + hasAttachableFeedbackImage, readFeedbackImageFiles, releaseFeedbackImageDraft, type FeedbackImageDraft @@ -258,10 +259,16 @@ export function SidebarFeedbackDialog({ // screenshot lands whether or not the caret is in the message box. onPaste={(event) => { const pasted = extractImageFilesFromDataTransfer(event.clipboardData) - if (pasted.length > 0) { - event.preventDefault() - handleAddFiles(pasted) + if (pasted.length === 0) { + return } + // Why: consume the paste only when something is actually attachable. + // An unsupported image still routes through for its rejection toast, + // but preventing default there would silently eat co-pasted text. + if (hasAttachableFeedbackImage(pasted)) { + event.preventDefault() + } + handleAddFiles(pasted) }} // Why: dragenter/leave fire per nested child; the hook counts depth so // the highlight only clears once the pointer leaves the dialog. diff --git a/src/renderer/src/lib/feedback-image-attachments.test.ts b/src/renderer/src/lib/feedback-image-attachments.test.ts index 681927e5962..ad979fe8843 100644 --- a/src/renderer/src/lib/feedback-image-attachments.test.ts +++ b/src/renderer/src/lib/feedback-image-attachments.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { MAX_FEEDBACK_IMAGE_BYTES, MAX_FEEDBACK_IMAGE_COUNT, + hasAttachableFeedbackImage, readFeedbackImageFiles } from './feedback-image-attachments' @@ -19,6 +20,26 @@ function pngFile(name: string, size = 8): File { return file } +describe('hasAttachableFeedbackImage', () => { + it('is true when any file is an allow-listed type', () => { + const svg = new File(['x'], 'a.svg', { type: 'image/svg+xml' }) + expect(hasAttachableFeedbackImage([svg, pngFile('a.png')])).toBe(true) + }) + + // Why: the paste handler only consumes the event when this is true. An + // image/* type outside the allow-list must still fall through so co-pasted + // text is not swallowed, while readFeedbackImageFiles raises its toast. + it('is false when every file is an unsupported image type', () => { + const svg = new File(['x'], 'a.svg', { type: 'image/svg+xml' }) + const bmp = new File(['x'], 'a.bmp', { type: 'image/bmp' }) + expect(hasAttachableFeedbackImage([svg, bmp])).toBe(false) + }) + + it('is false for an empty selection', () => { + expect(hasAttachableFeedbackImage([])).toBe(false) + }) +}) + describe('readFeedbackImageFiles', () => { it('reads supported images into drafts with distinct ids', async () => { const { images, errors } = await readFeedbackImageFiles([pngFile('a.png'), pngFile('a.png')], 0) diff --git a/src/renderer/src/lib/feedback-image-attachments.ts b/src/renderer/src/lib/feedback-image-attachments.ts index 8bfe9fe2a77..67e7e0fcad1 100644 --- a/src/renderer/src/lib/feedback-image-attachments.ts +++ b/src/renderer/src/lib/feedback-image-attachments.ts @@ -25,6 +25,16 @@ function isSupportedType(contentType: string): boolean { return (SUPPORTED_FEEDBACK_IMAGE_TYPES as readonly string[]).includes(contentType) } +/** + * Whether a paste should be consumed. Extraction stays broad so unsupported + * image types still reach the rejection toast, but swallowing the paste when + * nothing is attachable would also discard any text riding along on the + * clipboard. + */ +export function hasAttachableFeedbackImage(files: readonly File[]): boolean { + return files.some((file) => isSupportedType(file.type)) +} + export function releaseFeedbackImageDraft(draft: FeedbackImageDraft): void { URL.revokeObjectURL(draft.previewUrl) }