fix(feedback): improve attachment failure feedback

This commit is contained in:
Brennan Benson
2026-07-29 17:34:59 -07:00
parent 96b65815e3
commit db6136eefa
9 changed files with 137 additions and 12 deletions
@@ -112,6 +112,7 @@ describe('SidebarFeedbackDialog image submission', () => {
})
await waitFor(() => expect((send as HTMLButtonElement).disabled).toBe(false))
expect(screen.getByRole('button', { name: 'Remove shot.png' })).toBeTruthy()
fireEvent.click(send)
await waitFor(() => expect(mocks.submit).toHaveBeenCalledTimes(1))
expect(mocks.submit.mock.calls[0]?.[0].images).toEqual([
@@ -84,7 +84,8 @@ export function SidebarFeedbackImageAttachments({
type="button"
aria-label={translate(
'auto.components.sidebar.SidebarFeedbackImageAttachments.removeImage',
'Remove image'
'Remove {{fileName}}',
{ fileName: image.name }
)}
disabled={disabled}
onClick={() => onRemove(image.id)}
+13 -1
View File
@@ -658,6 +658,18 @@
},
"pluginCommandKeybindings": {
"group": "Plugins"
},
"feedback": {
"image": {
"attachments": {
"fallbackName": "Image attachment",
"unsupportedType": "{{fileName}} is not a supported image type.",
"empty": "{{fileName}} is empty.",
"tooLarge": "{{fileName}} is larger than {{maxSize}}.",
"tooMany": "You can attach up to {{maxCount}} images.",
"additionalErrors": "{{count}} additional images could not be attached."
}
}
}
},
"hooks": {
@@ -5025,7 +5037,7 @@
"SidebarFeedbackImageAttachments": {
"screenshotsHint": "Attach up to {count} screenshots",
"attachImages": "Attach",
"removeImage": "Remove image"
"removeImage": "Remove {{fileName}}"
}
},
"shared": {
+13 -1
View File
@@ -635,6 +635,18 @@
},
"pluginCommandKeybindings": {
"group": "Plugins"
},
"feedback": {
"image": {
"attachments": {
"fallbackName": "Image attachment",
"unsupportedType": "{{fileName}} is not a supported image type.",
"empty": "{{fileName}} is empty.",
"tooLarge": "{{fileName}} is larger than {{maxSize}}.",
"tooMany": "You can attach up to {{maxCount}} images.",
"additionalErrors": "{{count}} additional images could not be attached."
}
}
}
},
"hooks": {
@@ -5002,7 +5014,7 @@
"SidebarFeedbackImageAttachments": {
"screenshotsHint": "Attach up to {count} screenshots",
"attachImages": "Attach",
"removeImage": "Remove image"
"removeImage": "Remove {{fileName}}"
}
},
"shared": {
+13 -1
View File
@@ -635,6 +635,18 @@
},
"pluginCommandKeybindings": {
"group": "Plugins"
},
"feedback": {
"image": {
"attachments": {
"fallbackName": "Image attachment",
"unsupportedType": "{{fileName}} is not a supported image type.",
"empty": "{{fileName}} is empty.",
"tooLarge": "{{fileName}} is larger than {{maxSize}}.",
"tooMany": "You can attach up to {{maxCount}} images.",
"additionalErrors": "{{count}} additional images could not be attached."
}
}
}
},
"hooks": {
@@ -5002,7 +5014,7 @@
"SidebarFeedbackImageAttachments": {
"screenshotsHint": "Attach up to {count} screenshots",
"attachImages": "Attach",
"removeImage": "Remove image"
"removeImage": "Remove {{fileName}}"
}
},
"shared": {
+13 -1
View File
@@ -635,6 +635,18 @@
},
"pluginCommandKeybindings": {
"group": "Plugins"
},
"feedback": {
"image": {
"attachments": {
"fallbackName": "Image attachment",
"unsupportedType": "{{fileName}} is not a supported image type.",
"empty": "{{fileName}} is empty.",
"tooLarge": "{{fileName}} is larger than {{maxSize}}.",
"tooMany": "You can attach up to {{maxCount}} images.",
"additionalErrors": "{{count}} additional images could not be attached."
}
}
}
},
"hooks": {
@@ -5002,7 +5014,7 @@
"SidebarFeedbackImageAttachments": {
"screenshotsHint": "Attach up to {count} screenshots",
"attachImages": "Attach",
"removeImage": "Remove image"
"removeImage": "Remove {{fileName}}"
}
},
"shared": {
+13 -1
View File
@@ -635,6 +635,18 @@
},
"pluginCommandKeybindings": {
"group": "Plugins"
},
"feedback": {
"image": {
"attachments": {
"fallbackName": "Image attachment",
"unsupportedType": "{{fileName}} is not a supported image type.",
"empty": "{{fileName}} is empty.",
"tooLarge": "{{fileName}} is larger than {{maxSize}}.",
"tooMany": "You can attach up to {{maxCount}} images.",
"additionalErrors": "{{count}} additional images could not be attached."
}
}
}
},
"hooks": {
@@ -5002,7 +5014,7 @@
"SidebarFeedbackImageAttachments": {
"screenshotsHint": "Attach up to {count} screenshots",
"attachImages": "Attach",
"removeImage": "Remove image"
"removeImage": "Remove {{fileName}}"
}
},
"shared": {
@@ -68,6 +68,19 @@ describe('readFeedbackImageFiles', () => {
expect(errors).toEqual(['notes.pdf is not a supported image type.'])
})
it('caps rejection detail so a large drop cannot mount one toast per file', async () => {
const files = Array.from(
{ length: 100 },
(_, index) => new File(['x'], `image-${index}.svg`, { type: 'image/svg+xml' })
)
const { images, errors } = await readFeedbackImageFiles(files, 0)
expect(images).toEqual([])
expect(errors).toHaveLength(5)
expect(errors.at(-1)).toBe('96 additional images could not be attached.')
})
it('reports an oversized image instead of skipping it', async () => {
const { images, errors } = await readFeedbackImageFiles(
[pngFile('huge.png', MAX_FEEDBACK_IMAGE_BYTES + 1)],
@@ -1,3 +1,4 @@
import { translate } from '@/i18n/i18n'
import { createBrowserUuid } from './browser-uuid'
export const MAX_FEEDBACK_IMAGE_COUNT = 4
@@ -10,6 +11,7 @@ export const SUPPORTED_FEEDBACK_IMAGE_TYPES = [
] as const
export const FEEDBACK_IMAGE_FILE_ACCEPT = SUPPORTED_FEEDBACK_IMAGE_TYPES.join(',')
const MAX_FEEDBACK_IMAGE_DETAIL_ERRORS = 4
export type FeedbackImageDraft = {
id: string
@@ -50,6 +52,12 @@ export function formatFeedbackImageSize(bytes: number): string {
: `${Math.max(1, Math.round(bytes / 1024))} KB`
}
function feedbackImageDisplayName(file: File): string {
return (
file.name || translate('auto.lib.feedback.image.attachments.fallbackName', 'Image attachment')
)
}
/**
* Converts picked/pasted/dropped files into drafts. Rejections come back as
* messages rather than being skipped, because silently dropping an attachment
@@ -62,25 +70,57 @@ export async function readFeedbackImageFiles(
const images: FeedbackImageDraft[] = []
const errors: string[] = []
let remaining = MAX_FEEDBACK_IMAGE_COUNT - existingCount
let omittedErrorCount = 0
const addError = (createMessage: () => string): void => {
if (errors.length < MAX_FEEDBACK_IMAGE_DETAIL_ERRORS) {
errors.push(createMessage())
} else {
omittedErrorCount += 1
}
}
try {
for (const file of files) {
const fileName = feedbackImageDisplayName(file)
if (!isSupportedType(file.type)) {
errors.push(`${file.name || 'Image'} is not a supported image type.`)
addError(() =>
translate(
'auto.lib.feedback.image.attachments.unsupportedType',
'{{fileName}} is not a supported image type.',
{ fileName }
)
)
continue
}
if (file.size === 0) {
errors.push(`${file.name || 'Image'} is empty.`)
addError(() =>
translate('auto.lib.feedback.image.attachments.empty', '{{fileName}} is empty.', {
fileName
})
)
continue
}
if (file.size > MAX_FEEDBACK_IMAGE_BYTES) {
errors.push(
`${file.name || 'Image'} is larger than ${formatFeedbackImageSize(MAX_FEEDBACK_IMAGE_BYTES)}.`
addError(() =>
translate(
'auto.lib.feedback.image.attachments.tooLarge',
'{{fileName}} is larger than {{maxSize}}.',
{
fileName,
maxSize: formatFeedbackImageSize(MAX_FEEDBACK_IMAGE_BYTES)
}
)
)
continue
}
if (remaining <= 0) {
errors.push(`You can attach up to ${MAX_FEEDBACK_IMAGE_COUNT} images.`)
addError(() =>
translate(
'auto.lib.feedback.image.attachments.tooMany',
'You can attach up to {{maxCount}} images.',
{ maxCount: MAX_FEEDBACK_IMAGE_COUNT }
)
)
break
}
remaining -= 1
@@ -88,7 +128,7 @@ export async function readFeedbackImageFiles(
// Why: crypto.randomUUID is undefined in non-secure browser contexts (LAN
// web client over plain HTTP); createBrowserUuid falls back safely.
id: `${file.name}-${file.size}-${createBrowserUuid()}`,
name: file.name || 'pasted-image',
name: fileName,
contentType: file.type,
bytes: file.size,
data: new Uint8Array(await file.arrayBuffer()),
@@ -102,6 +142,16 @@ export async function readFeedbackImageFiles(
throw error
}
if (omittedErrorCount > 0) {
errors.push(
translate(
'auto.lib.feedback.image.attachments.additionalErrors',
'{{count}} additional images could not be attached.',
{ count: omittedErrorCount }
)
)
}
return { images, errors }
}