feat(feedback): attach images to feedback submissions (#10465)
* feat(feedback): attach images to feedback submissions
Users pasting screenshots into the feedback dialog were silently dropped:
the textarea had no paste handler, the IPC payload had no image field, and
the endpoint had nowhere to put one. Reports arrived saying "images
attached" with nothing attached, which is why feedback-sourced tickets
never have a screenshot to work from.
Adds paste, drag-drop, and a file picker with thumbnail previews (up to 4
images, 8 MB each, png/jpeg/webp/gif). Rejected files raise a toast rather
than disappearing — silent loss is the bug being fixed.
Images ride the existing multipart lane, which previously activated only
for crash diagnostic bundles. Crash submissions still drop images; that
lane already carries bundles and the server rejects them there.
When the server reports imagesDelivered: false the dialog says the
feedback sent but the images did not, instead of a blanket success. A 2xx
without the field counts as delivered so this keeps working against a
server that predates the field.
Requires the marketing-site half to deploy first.
* copy(feedback): shorten attachment hint to 'Attach up to 4 screenshots'
* fix(feedback): make dropped screenshots actually attach
Three defects that discarded a user's image without telling them — the exact
failure this feature exists to fix.
Drag-and-drop never worked. `DataTransfer.files` is empty until the drop
lands, so the dragenter guard always saw zero files and the highlight never
armed. Worse, preload consumes native file drops on document capture with
`stopPropagation()` and routes the paths to the editor, so React's `onDrop`
never ran at all: dropping a screenshot on the dialog opened it in an editor
behind the modal. The drop is now claimed one phase earlier on window capture
and scoped to the dialog element, and the highlight keys off the drag types
the OS advertises — matching useComposerFileDragOver and useSidebarProjectDrop.
`crypto.randomUUID()` is undefined in non-secure browser contexts (the LAN web
client over plain HTTP), so building draft ids with it rejected the read and
dropped every image in the batch with no message and an unhandled rejection.
Use createBrowserUuid, the repo's fallback for exactly this.
`readFeedbackImageFiles` had no rejection handler, so any read failure (file
removed after picking, permission error) silently lost the whole batch.
Also: capacity was checked against a ref mirroring committed state, so two
pastes landing during an in-flight read both saw room for four and the main
process then rejected the entire submission; in-flight batches now count
against capacity. And the non-en catalogs still carried the pre-amendment
English copy for the attachment hint.
* fix(feedback): close the prototype-chain hole in the image allow-list
`contentType in FEEDBACK_IMAGE_EXTENSIONS` walks the prototype chain, so
"constructor", "__proto__", "toString", "valueOf" and "hasOwnProperty" all
cleared the allow-list. feedbackImageFilename then indexed the same object and
named the upload after the inherited value — "feedback-image-1.function
Object() { [native code] }" — and the part went out with that content type.
Only reachable by invoking feedback:submit directly (the renderer screens
types with Array.includes), which is exactly the threat model this function's
own doc comment claims to cover. Object.hasOwn matches the 54 other uses in
the repo and is identical for the four real types.
The inherited values carry no quotes or CRLF, so this was a bypassed allow-list
and a malformed upload, not multipart header injection.
Adds unit coverage for the module, which had none, plus an IPC-level case; all
six new assertions fail against `in`.
* fix(feedback): accept the drag on dragover so the drop can fire
The window-capture drop interception only fires if something first
preventDefaults `dragover`. In Electron that comes free from preload's
document-capture handler, but the same renderer is served to browsers as
web-index.html, where `installWebPreloadApi` builds `window.api` in JS and
installs no drag listeners at all. Nothing else in the renderer
preventDefaults dragover for a native file drag.
So on the web client the dialog is not a valid drop target: `drop` never
fires and the browser falls back to its default action for a file dropped
on a page — it navigates the tab to the file, taking the user's typed
feedback with it. The new types-based dragenter guard makes this worse
than before, because the highlight now arms and invites the drop that the
old `files`-based guard could never light up.
Mirrors useSidebarProjectDrop.onDragOver, which the drop rework already
claimed to match. In Electron it is a harmless duplicate of the
preventDefault preload already applied.
* fix(feedback): revoke batch previews when a read rejects partway
readFeedbackImageFiles creates the object URL for each accepted file as it
goes. If a later file in the same batch fails `arrayBuffer()` — the
removed-after-picking case the new rejection handler was added for — the
whole promise rejects and the already-built drafts are never returned, so
nothing ever revokes their previews.
Each leaked URL pins its blob for the life of the renderer, up to three at
8 MB. Release them before rethrowing; the caller's rejection handler is
unaffected.
* fix(feedback): cancel non-image drops the dialog already accepted
dragover advertises copy for every native file drag over the dialog, but
drop only cancelled for images. On the web client an uncancelled drop
navigates the tab to the file, taking the typed feedback with it.
* fix(feedback): stop image validation from aborting crash reports
buildSubmitBody drops images on the crash lane, but validation ran
unconditionally, so a crash submission carrying an invalid image would
have failed outright over attachments that were never going to be sent —
losing a crash report the user needs delivered. Gate validation the same
way body construction is gated.
Not reachable today (the IPC handler forces submissionType 'feedback' and
internal crash callers pass no images), but the two gates disagreeing is a
trap for the next caller. Raised by CodeRabbit.
Also documents why the image lane deliberately skips the 5xx retry the
text lane performs: replaying up to 32 MiB on a flaky link costs more than
it saves, and the dialog preserves the draft and thumbnails on failure.
* fix(feedback): stop mutating the image-count ref during render
React Doctor fails CI on "Ref mutated during render": the count was
assigned in the component body, where React can discard or replay work
that never commits.
Read the committed count from the callback closure instead of a ref.
Syncing the ref in an effect (the suggested fix) would reintroduce the
race a previous commit removed — right after an add, the ref is stale-low
until the effect flushes, so a paste in that window over-accepts and the
main process rejects the whole submission. The closure value is always the
committed count, and pendingImageReadsRef still covers in-flight reads.
Costs a re-registration of the drop listeners per attach, which is the
same teardown the hook already does when the dialog opens or closes.
* fix(feedback): stop an unsupported pasted image from eating co-pasted text
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.
* fix(feedback): stop the dialog accepting more than the endpoint will take
The endpoint rejects reports over 5000 characters with a 400, which the
dialog surfaces as a generic "Failed to submit feedback. Please try again."
Nothing said length was the problem, so retrying could not help — the draft
survived but the user had no way to know what to change.
Cap the textarea at the same 5000 and show a counter once 500 characters
remain, so the limit is visible before it bites rather than after. The
counter stays hidden until then; an always-on count reads as a word limit
to hit.
Extracted rather than inlined: the dialog is already past the 300-line mark
React Doctor warns on.
* fix(feedback): prevent silent attachment loss
* fix(feedback): improve attachment failure feedback
* fix(feedback): bound attachment response parsing
* fix(feedback): surface response body timeouts
* fix(feedback): harden image delivery
* fix(feedback): bound image preview resources
* fix(feedback): honor atomic image delivery response
Production’s single-message feedback endpoint uploads text and images atomically, then returns 202 {"ok":true} without an imagesDelivered field. Treating that omission as false warned users that every successful production attachment had failed.
Treat a settled successful JSON response with ok: true and no image field as delivered. Explicit imagesDelivered: false still surfaces partial delivery, while malformed, oversized, aborted, and stalled bodies remain unconfirmed or fail through the existing response bound and timeout path.
This commit is contained in:
parent
c67791e4c1
commit
fa2f5de7da
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MAX_FEEDBACK_IMAGE_BYTES,
|
||||
MAX_FEEDBACK_IMAGE_COUNT,
|
||||
appendFeedbackImagesToFormData,
|
||||
feedbackImageFilename,
|
||||
isSupportedFeedbackImageContentType,
|
||||
validateFeedbackImages
|
||||
} from './feedback-image-attachments'
|
||||
|
||||
function image(contentType: string, bytes = 4): { contentType: string; data: Uint8Array } {
|
||||
return { contentType, data: new Uint8Array(bytes).fill(1) }
|
||||
}
|
||||
|
||||
describe('isSupportedFeedbackImageContentType', () => {
|
||||
it.each(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])('accepts %s', (contentType) => {
|
||||
expect(isSupportedFeedbackImageContentType(contentType)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['image/bmp', 'image/svg+xml', 'application/pdf', ''])('rejects %s', (contentType) => {
|
||||
expect(isSupportedFeedbackImageContentType(contentType)).toBe(false)
|
||||
})
|
||||
|
||||
// Why: the allow-list is an object literal, so a prototype member must not
|
||||
// pass as a content type and get spliced into the upload filename.
|
||||
it.each(['constructor', '__proto__', 'toString', 'valueOf', 'hasOwnProperty'])(
|
||||
'rejects the inherited member %s',
|
||||
(contentType) => {
|
||||
expect(isSupportedFeedbackImageContentType(contentType)).toBe(false)
|
||||
expect(validateFeedbackImages([image(contentType)])).toBe('Unsupported image type.')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('feedbackImageFilename', () => {
|
||||
it.each([
|
||||
['image/png', 'feedback-image-1.png'],
|
||||
['image/jpeg', 'feedback-image-1.jpg'],
|
||||
['image/webp', 'feedback-image-1.webp'],
|
||||
['image/gif', 'feedback-image-1.gif']
|
||||
])('names a %s attachment %s', (contentType, expected) => {
|
||||
expect(feedbackImageFilename(0, contentType)).toBe(expected)
|
||||
})
|
||||
|
||||
it('numbers attachments from one', () => {
|
||||
expect(feedbackImageFilename(3, 'image/png')).toBe('feedback-image-4.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendFeedbackImagesToFormData', () => {
|
||||
it('frames only the exact typed-array view', async () => {
|
||||
const backing = new Uint8Array([0, 1, 2, 3])
|
||||
const view = backing.subarray(1, 3)
|
||||
const formData = new FormData()
|
||||
|
||||
appendFeedbackImagesToFormData(formData, [{ contentType: 'image/png', data: view }])
|
||||
|
||||
const blob = formData.get('feedbackImage')
|
||||
expect(blob).toBeInstanceOf(Blob)
|
||||
expect(new Uint8Array(await (blob as Blob).arrayBuffer())).toEqual(new Uint8Array([1, 2]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateFeedbackImages', () => {
|
||||
it('accepts a full batch of supported images', () => {
|
||||
expect(
|
||||
validateFeedbackImages(
|
||||
Array.from({ length: MAX_FEEDBACK_IMAGE_COUNT }, () => image('image/png'))
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects more than the supported count', () => {
|
||||
expect(
|
||||
validateFeedbackImages(
|
||||
Array.from({ length: MAX_FEEDBACK_IMAGE_COUNT + 1 }, () => image('image/png'))
|
||||
)
|
||||
).toBe(`Attach ${MAX_FEEDBACK_IMAGE_COUNT} images or fewer.`)
|
||||
})
|
||||
|
||||
it('rejects malformed attachment collections and byte payloads', () => {
|
||||
expect(validateFeedbackImages('image/png')).toBe('Image attachments must be a list.')
|
||||
expect(validateFeedbackImages([null])).toBe('Invalid image attachment.')
|
||||
expect(validateFeedbackImages([{ contentType: 'image/png', data: '8388608' }])).toBe(
|
||||
'Invalid image attachment bytes.'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an empty attachment', () => {
|
||||
expect(validateFeedbackImages([image('image/png', 0)])).toBe('Image attachment is empty.')
|
||||
})
|
||||
|
||||
it('rejects an attachment over the byte cap', () => {
|
||||
expect(validateFeedbackImages([image('image/png', MAX_FEEDBACK_IMAGE_BYTES + 1)])).toBe(
|
||||
`Each image must be ${MAX_FEEDBACK_IMAGE_BYTES} bytes or fewer.`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { readFetchResponseJsonWithinLimit } from '../../shared/fetch-response-body'
|
||||
|
||||
// Why: mirrors the server allow-list. Slack picks a renderer from the filename
|
||||
// extension, so every accepted type needs one.
|
||||
const FEEDBACK_IMAGE_EXTENSIONS: Record<string, string> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif'
|
||||
}
|
||||
|
||||
export const MAX_FEEDBACK_IMAGE_COUNT = 4
|
||||
export const MAX_FEEDBACK_IMAGE_BYTES = 8 * 1024 * 1024
|
||||
export const MAX_FEEDBACK_IMAGE_RESPONSE_BYTES = 64 * 1024
|
||||
export const FEEDBACK_IMAGE_FORM_FIELD = 'feedbackImage'
|
||||
|
||||
export type FeedbackImageAttachment = {
|
||||
contentType: string
|
||||
data: Uint8Array
|
||||
}
|
||||
|
||||
export function isSupportedFeedbackImageContentType(contentType: string): boolean {
|
||||
// Why: `in` walks the prototype chain, so "constructor" and "__proto__" would
|
||||
// clear the allow-list and name the upload after an inherited member.
|
||||
return Object.hasOwn(FEEDBACK_IMAGE_EXTENSIONS, contentType)
|
||||
}
|
||||
|
||||
export function getSupportedFeedbackImageContentTypes(): string[] {
|
||||
return Object.keys(FEEDBACK_IMAGE_EXTENSIONS)
|
||||
}
|
||||
|
||||
export function feedbackImageFilename(index: number, contentType: string): string {
|
||||
return `feedback-image-${index + 1}.${FEEDBACK_IMAGE_EXTENSIONS[contentType]}`
|
||||
}
|
||||
|
||||
/** Defence in depth: the renderer validates first, but this channel is reachable directly. */
|
||||
export function validateFeedbackImages(images: unknown): string | null {
|
||||
if (!Array.isArray(images)) {
|
||||
return 'Image attachments must be a list.'
|
||||
}
|
||||
if (images.length > MAX_FEEDBACK_IMAGE_COUNT) {
|
||||
return `Attach ${MAX_FEEDBACK_IMAGE_COUNT} images or fewer.`
|
||||
}
|
||||
for (const image of images) {
|
||||
if (!image || typeof image !== 'object') {
|
||||
return 'Invalid image attachment.'
|
||||
}
|
||||
if (typeof image.contentType !== 'string') {
|
||||
return 'Invalid image attachment content type.'
|
||||
}
|
||||
if (!isSupportedFeedbackImageContentType(image.contentType)) {
|
||||
return 'Unsupported image type.'
|
||||
}
|
||||
if (!(image.data instanceof Uint8Array)) {
|
||||
return 'Invalid image attachment bytes.'
|
||||
}
|
||||
if (image.data.byteLength === 0) {
|
||||
return 'Image attachment is empty.'
|
||||
}
|
||||
if (image.data.byteLength > MAX_FEEDBACK_IMAGE_BYTES) {
|
||||
return `Each image must be ${MAX_FEEDBACK_IMAGE_BYTES} bytes or fewer.`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function appendFeedbackImagesToFormData(
|
||||
formData: FormData,
|
||||
images: FeedbackImageAttachment[]
|
||||
): void {
|
||||
for (const [index, image] of images.entries()) {
|
||||
formData.append(
|
||||
FEEDBACK_IMAGE_FORM_FIELD,
|
||||
new Blob([image.data as BlobPart], { type: image.contentType }),
|
||||
feedbackImageFilename(index, image.contentType)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomic servers omit the image field after both the text and images land. */
|
||||
export async function readFeedbackImagesDelivered(response: Response): Promise<boolean> {
|
||||
try {
|
||||
const parsed: unknown = await readFetchResponseJsonWithinLimit(
|
||||
response,
|
||||
MAX_FEEDBACK_IMAGE_RESPONSE_BYTES
|
||||
)
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
return false
|
||||
}
|
||||
if ('imagesDelivered' in parsed) {
|
||||
return (parsed as { imagesDelivered?: unknown }).imagesDelivered === true
|
||||
}
|
||||
return (parsed as { ok?: unknown }).ok === true
|
||||
} catch {}
|
||||
return false
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ vi.mock('electron', () => ({
|
|||
net: { fetch: (...args: unknown[]) => fetchMock(...args) }
|
||||
}))
|
||||
|
||||
import { MAX_FEEDBACK_IMAGE_RESPONSE_BYTES } from './feedback-image-attachments'
|
||||
import { registerFeedbackHandlers, submitFeedback } from './feedback'
|
||||
|
||||
function okResponse(): Response {
|
||||
|
|
@ -392,4 +393,219 @@ describe('submitFeedback', () => {
|
|||
githubEmail: null
|
||||
})
|
||||
})
|
||||
|
||||
describe('image attachments', () => {
|
||||
function pngImage(bytes = 8): { contentType: string; data: Uint8Array } {
|
||||
return { contentType: 'image/png', data: new Uint8Array(bytes).fill(1) }
|
||||
}
|
||||
|
||||
function imageSubmitArgs(
|
||||
images: { contentType: string; data: Uint8Array }[]
|
||||
): Parameters<typeof submitFeedback>[0] {
|
||||
return {
|
||||
feedback: 'images attached',
|
||||
submissionType: 'feedback',
|
||||
githubLogin: 'someone',
|
||||
githubEmail: null,
|
||||
images
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return Response.json(body, { status: 202 })
|
||||
}
|
||||
|
||||
it('sends attached images as multipart form parts', async () => {
|
||||
await submitFeedback(imageSubmitArgs([pngImage(), pngImage()]))
|
||||
|
||||
const body = requestInit().body as FormData
|
||||
expect(body).toBeInstanceOf(FormData)
|
||||
expect(body.getAll('feedbackImage')).toHaveLength(2)
|
||||
expect(body.get('feedback')).toBe('images attached')
|
||||
// Why: multipart must not lose the enrichment fields the JSON lane sends.
|
||||
expect(body.get('submissionType')).toBe('feedback')
|
||||
expect(body.get('appVersion')).toBe('1.2.3-test')
|
||||
})
|
||||
|
||||
it('keeps the JSON lane when nothing is attached', async () => {
|
||||
await submitFeedback(imageSubmitArgs([]))
|
||||
|
||||
expect(requestInit().body).not.toBeInstanceOf(FormData)
|
||||
expect(postedBody().feedback).toBe('images attached')
|
||||
})
|
||||
|
||||
it('reports partial delivery when the server could not attach the images', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ ok: true, imagesDelivered: false }))
|
||||
|
||||
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
|
||||
ok: true,
|
||||
imagesDelivered: false
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts the production atomic-success response when it omits the image result', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ ok: true }))
|
||||
|
||||
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
|
||||
ok: true,
|
||||
imagesDelivered: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reports unconfirmed delivery for a settled non-JSON 2xx', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 202,
|
||||
json: async () => {
|
||||
throw new SyntaxError('Unexpected token')
|
||||
}
|
||||
} as unknown as Response)
|
||||
|
||||
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
|
||||
ok: true,
|
||||
imagesDelivered: false
|
||||
})
|
||||
})
|
||||
|
||||
it('reports unconfirmed delivery when the response body aborts before the deadline', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 202,
|
||||
json: async () => {
|
||||
throw new TypeError('terminated')
|
||||
}
|
||||
} as unknown as Response)
|
||||
|
||||
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
|
||||
ok: true,
|
||||
imagesDelivered: false
|
||||
})
|
||||
expect(requestInit().signal).toMatchObject({ aborted: false })
|
||||
})
|
||||
|
||||
it('bounds the image-delivery response body', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response('x'.repeat(MAX_FEEDBACK_IMAGE_RESPONSE_BYTES + 1), { status: 202 })
|
||||
)
|
||||
|
||||
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
|
||||
ok: true,
|
||||
imagesDelivered: false
|
||||
})
|
||||
})
|
||||
|
||||
it('fails a stalled delivery response body at the attachment timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
fetchMock.mockImplementation((_url: string, init?: RequestInit) =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 202,
|
||||
json: () =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new Error('body aborted')))
|
||||
})
|
||||
} as unknown as Response)
|
||||
)
|
||||
|
||||
const result = submitFeedback(imageSubmitArgs([pngImage()]))
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
ok: false,
|
||||
status: null,
|
||||
error: 'request timed out after 60 seconds'
|
||||
})
|
||||
expect(requestInit().signal).toMatchObject({ aborted: true })
|
||||
})
|
||||
|
||||
it('rejects unsupported image types before any request is made', async () => {
|
||||
const result = await submitFeedback(
|
||||
imageSubmitArgs([{ contentType: 'application/pdf', data: new Uint8Array(4) }])
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: the renderer screens types first, so this lane only matters for a
|
||||
// renderer invoking the channel directly — the case the handler guards.
|
||||
it('rejects a prototype member posing as a content type over IPC', async () => {
|
||||
registerFeedbackHandlers()
|
||||
const result = (await handlers.get('feedback:submit')?.(null, {
|
||||
feedback: 'images attached',
|
||||
githubLogin: null,
|
||||
githubEmail: null,
|
||||
images: [{ contentType: 'constructor', data: new Uint8Array(4).fill(1) }]
|
||||
})) as { ok: boolean; error?: string }
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'Unsupported image type.' })
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects malformed IPC bytes before typed-array normalization', async () => {
|
||||
registerFeedbackHandlers()
|
||||
const result = (await handlers.get('feedback:submit')?.(null, {
|
||||
feedback: 'images attached',
|
||||
githubLogin: null,
|
||||
githubEmail: null,
|
||||
images: [{ contentType: 'image/png', data: '8388608' }]
|
||||
})) as { ok: boolean; error?: string }
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'Invalid image attachment bytes.' })
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects oversized IPC batches before normalizing their entries', async () => {
|
||||
registerFeedbackHandlers()
|
||||
const result = (await handlers.get('feedback:submit')?.(null, {
|
||||
feedback: 'images attached',
|
||||
githubLogin: null,
|
||||
githubEmail: null,
|
||||
images: Array.from({ length: 5 }, () => ({
|
||||
contentType: 'image/png',
|
||||
data: '8388608'
|
||||
}))
|
||||
})) as { ok: boolean; error?: string }
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'Attach 4 images or fewer.' })
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects more images than the supported count', async () => {
|
||||
const result = await submitFeedback(
|
||||
imageSubmitArgs(Array.from({ length: 5 }, () => pngImage()))
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fail a crash report over images it was never going to send', async () => {
|
||||
// Why: the crash lane discards images, so validating them there would
|
||||
// abort a crash report the user needs delivered.
|
||||
await submitFeedback({
|
||||
...diagnosticSubmitArgs(),
|
||||
images: Array.from({ length: 9 }, () => ({
|
||||
contentType: 'application/pdf',
|
||||
data: new Uint8Array(0)
|
||||
}))
|
||||
} as Parameters<typeof submitFeedback>[0])
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const body = requestInit().body as FormData
|
||||
expect(body.getAll('feedbackImage')).toHaveLength(0)
|
||||
expect(body.get('submissionType')).toBe('crash')
|
||||
})
|
||||
|
||||
it('drops images from crash submissions', async () => {
|
||||
await submitFeedback({
|
||||
...diagnosticSubmitArgs(),
|
||||
images: [pngImage()]
|
||||
} as Parameters<typeof submitFeedback>[0])
|
||||
|
||||
const body = requestInit().body as FormData
|
||||
expect(body.getAll('feedbackImage')).toHaveLength(0)
|
||||
expect(body.get('diagnosticBundleSubmissionId')).toBe('bundleabcdefghijklmnop')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import os from 'node:os'
|
||||
import { app, ipcMain, net } from 'electron'
|
||||
import {
|
||||
appendFeedbackImagesToFormData,
|
||||
readFeedbackImagesDelivered,
|
||||
validateFeedbackImages,
|
||||
type FeedbackImageAttachment
|
||||
} from './feedback-image-attachments'
|
||||
|
||||
export type { FeedbackImageAttachment }
|
||||
|
||||
// Why: the production Mac build loads the renderer from a file:// origin, so a
|
||||
// cross-origin POST from fetch() triggers a CORS preflight that the feedback
|
||||
|
|
@ -21,6 +29,7 @@ export type FeedbackSubmitArgs = {
|
|||
submitAnonymously?: boolean
|
||||
githubLogin: string | null
|
||||
githubEmail: string | null
|
||||
images?: FeedbackImageAttachment[]
|
||||
}
|
||||
|
||||
export type FeedbackDiagnosticBundleAttachment = {
|
||||
|
|
@ -40,6 +49,7 @@ type FeedbackSubmitBody = {
|
|||
osRelease: string
|
||||
arch: string
|
||||
diagnosticBundle?: FeedbackDiagnosticBundleAttachment
|
||||
images?: FeedbackImageAttachment[]
|
||||
}
|
||||
|
||||
export type FeedbackRequestFailure = {
|
||||
|
|
@ -48,7 +58,12 @@ export type FeedbackRequestFailure = {
|
|||
}
|
||||
|
||||
export type FeedbackSubmitResult =
|
||||
| { ok: true; diagnosticBundleFailure?: FeedbackRequestFailure }
|
||||
| {
|
||||
ok: true
|
||||
diagnosticBundleFailure?: FeedbackRequestFailure
|
||||
/** Absent when nothing was attached; false when the text landed but the images did not. */
|
||||
imagesDelivered?: boolean
|
||||
}
|
||||
| ({ ok: false } & FeedbackRequestFailure & {
|
||||
diagnosticBundleFailure?: FeedbackRequestFailure
|
||||
})
|
||||
|
|
@ -81,18 +96,21 @@ function buildSubmitBody(args: InternalFeedbackSubmitArgs): FeedbackSubmitBody {
|
|||
arch: process.arch,
|
||||
...(args.submissionType === 'crash' && args.diagnosticBundle
|
||||
? { diagnosticBundle: args.diagnosticBundle }
|
||||
: {})
|
||||
: {}),
|
||||
// Why: images are a feedback-only affordance; crash reports already carry
|
||||
// diagnostic bundles and the server rejects images on that lane.
|
||||
...(args.submissionType !== 'crash' && args.images?.length ? { images: args.images } : {})
|
||||
}
|
||||
}
|
||||
|
||||
async function postFeedback(
|
||||
url: string,
|
||||
body: FeedbackSubmitBody,
|
||||
timeoutMs = FEEDBACK_REQUEST_TIMEOUT_MS
|
||||
timeoutMs = FEEDBACK_REQUEST_TIMEOUT_MS,
|
||||
readResponse?: (response: Response) => Promise<void>
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
// Why: a silent feedback endpoint should not leave IPC or crash-report
|
||||
// submission flows pending forever.
|
||||
// Why: a silent endpoint must not leave feedback IPC pending forever.
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const init: RequestInit = {
|
||||
|
|
@ -100,10 +118,18 @@ async function postFeedback(
|
|||
...feedbackRequestBodyInit(body),
|
||||
signal: controller.signal
|
||||
}
|
||||
return await net.fetch(url, init)
|
||||
const response = await net.fetch(url, init)
|
||||
if (readResponse) {
|
||||
await readResponse(response)
|
||||
}
|
||||
// Why: a response parser may tolerate malformed legacy bodies, but it must
|
||||
// not turn the deadline's aborted body into a confirmed delivery.
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error(`request timed out after ${timeoutMs / 1000} seconds`)
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
// Why: Electron and Node use different AbortError messages. Normalize our
|
||||
// client deadline so support logs explain which request budget expired.
|
||||
// Why: Electron and Node report AbortError differently; keep deadline logs stable.
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error(`request timed out after ${timeoutMs / 1000} seconds`)
|
||||
}
|
||||
|
|
@ -114,7 +140,7 @@ async function postFeedback(
|
|||
}
|
||||
|
||||
function feedbackRequestBodyInit(body: FeedbackSubmitBody): Pick<RequestInit, 'body' | 'headers'> {
|
||||
if (!body.diagnosticBundle) {
|
||||
if (!body.diagnosticBundle && !body.images?.length) {
|
||||
return {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
|
|
@ -130,22 +156,25 @@ function feedbackRequestBodyInit(body: FeedbackSubmitBody): Pick<RequestInit, 'b
|
|||
appendFeedbackFormField(formData, 'platform', body.platform)
|
||||
appendFeedbackFormField(formData, 'osRelease', body.osRelease)
|
||||
appendFeedbackFormField(formData, 'arch', body.arch)
|
||||
appendFeedbackFormField(
|
||||
formData,
|
||||
'diagnosticBundleSubmissionId',
|
||||
body.diagnosticBundle.bundleSubmissionId
|
||||
)
|
||||
appendFeedbackFormField(formData, 'diagnosticBundleBytes', String(body.diagnosticBundle.bytes))
|
||||
appendFeedbackFormField(
|
||||
formData,
|
||||
'diagnosticBundleSpanCount',
|
||||
String(body.diagnosticBundle.spanCount)
|
||||
)
|
||||
formData.append(
|
||||
'diagnosticBundleFile',
|
||||
new Blob([body.diagnosticBundle.content], { type: DIAGNOSTIC_BUNDLE_CONTENT_TYPE }),
|
||||
`orca-diagnostics-${body.diagnosticBundle.bundleSubmissionId}.ndjson`
|
||||
)
|
||||
if (body.diagnosticBundle) {
|
||||
appendFeedbackFormField(
|
||||
formData,
|
||||
'diagnosticBundleSubmissionId',
|
||||
body.diagnosticBundle.bundleSubmissionId
|
||||
)
|
||||
appendFeedbackFormField(formData, 'diagnosticBundleBytes', String(body.diagnosticBundle.bytes))
|
||||
appendFeedbackFormField(
|
||||
formData,
|
||||
'diagnosticBundleSpanCount',
|
||||
String(body.diagnosticBundle.spanCount)
|
||||
)
|
||||
formData.append(
|
||||
'diagnosticBundleFile',
|
||||
new Blob([body.diagnosticBundle.content], { type: DIAGNOSTIC_BUNDLE_CONTENT_TYPE }),
|
||||
`orca-diagnostics-${body.diagnosticBundle.bundleSubmissionId}.ndjson`
|
||||
)
|
||||
}
|
||||
appendFeedbackImagesToFormData(formData, body.images ?? [])
|
||||
|
||||
// Why: multipart avoids JSON-escaping a near-cap NDJSON bundle over the
|
||||
// backend request limit while still submitting one feedback request.
|
||||
|
|
@ -253,7 +282,37 @@ async function submitFeedbackWithDiagnosticBundle(
|
|||
export async function submitFeedback(
|
||||
args: InternalFeedbackSubmitArgs
|
||||
): Promise<FeedbackSubmitResult> {
|
||||
// Why: buildSubmitBody drops images on the crash lane, so validating them
|
||||
// there would abort a crash report over attachments it never meant to send.
|
||||
if (args.submissionType !== 'crash' && args.images !== undefined) {
|
||||
const imageError = validateFeedbackImages(args.images)
|
||||
if (imageError) {
|
||||
return { ok: false, status: null, error: imageError }
|
||||
}
|
||||
}
|
||||
const body = buildSubmitBody(args)
|
||||
if (body.images?.length) {
|
||||
try {
|
||||
let imagesDelivered = true
|
||||
const response = await postFeedback(
|
||||
FEEDBACK_API_URL,
|
||||
body,
|
||||
FEEDBACK_ATTACHMENT_REQUEST_TIMEOUT_MS,
|
||||
async (nextResponse) => {
|
||||
imagesDelivered = nextResponse.ok ? await readFeedbackImagesDelivered(nextResponse) : true
|
||||
}
|
||||
)
|
||||
if (response.ok) {
|
||||
return { ok: true, imagesDelivered }
|
||||
}
|
||||
// Why: the text lane retries 5xx, this one does not. Replaying up to
|
||||
// 32 MiB of attachments on a flaky link costs more than it saves, and the
|
||||
// dialog keeps the draft and thumbnails so the user can resend.
|
||||
return { ok: false, ...responseFailure(response) }
|
||||
} catch (error) {
|
||||
return { ok: false, ...errorFailure(error) }
|
||||
}
|
||||
}
|
||||
if (body.diagnosticBundle) {
|
||||
const bodyWithoutDiagnosticBundle =
|
||||
args.feedbackWithoutDiagnosticBundle !== undefined
|
||||
|
|
@ -283,9 +342,20 @@ export async function submitFeedback(
|
|||
|
||||
export function registerFeedbackHandlers(): void {
|
||||
ipcMain.removeHandler('feedback:submit')
|
||||
ipcMain.handle('feedback:submit', (_event, args: FeedbackSubmitArgs) =>
|
||||
ipcMain.handle('feedback:submit', (_event, args: FeedbackSubmitArgs) => {
|
||||
// Why: validate the raw clone before normalization so a tiny hostile value
|
||||
// cannot become a large main-process typed-array allocation.
|
||||
if (args.images !== undefined) {
|
||||
const imageError = validateFeedbackImages(args.images)
|
||||
if (imageError) {
|
||||
return { ok: false, status: null, error: imageError }
|
||||
}
|
||||
}
|
||||
// Why: crash submissions are main-only. A compromised renderer can invoke
|
||||
// this channel directly, so force the public feedback lane at the boundary.
|
||||
submitFeedback({ ...args, submissionType: 'feedback' })
|
||||
)
|
||||
return submitFeedback({
|
||||
...args,
|
||||
submissionType: 'feedback'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1661,7 +1661,10 @@ export type PreloadApi = {
|
|||
submitAnonymously?: boolean
|
||||
githubLogin: string | null
|
||||
githubEmail: string | null
|
||||
}) => Promise<{ ok: true } | { ok: false; status: number | null; error: string }>
|
||||
images?: { contentType: string; data: Uint8Array }[]
|
||||
}) => Promise<
|
||||
{ ok: true; imagesDelivered?: boolean } | { ok: false; status: number | null; error: string }
|
||||
>
|
||||
}
|
||||
crashReports: {
|
||||
getLatestPending: () => Promise<CrashReportRecord | null>
|
||||
|
|
|
|||
|
|
@ -1236,8 +1236,10 @@ const api = {
|
|||
submitAnonymously?: boolean
|
||||
githubLogin: string | null
|
||||
githubEmail: string | null
|
||||
}): Promise<{ ok: true } | { ok: false; status: number | null; error: string }> =>
|
||||
ipcRenderer.invoke('feedback:submit', args)
|
||||
images?: { contentType: string; data: Uint8Array }[]
|
||||
}): Promise<
|
||||
{ ok: true; imagesDelivered?: boolean } | { ok: false; status: number | null; error: string }
|
||||
> => ipcRenderer.invoke('feedback:submit', args)
|
||||
},
|
||||
|
||||
crashReports: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act, type ReactNode } from 'react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readFeedbackImageFiles: vi.fn(),
|
||||
submit: vi.fn(),
|
||||
toastWarning: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: mocks.toastWarning
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dialog', async () => {
|
||||
const ReactModule = await import('react')
|
||||
const Section = ({ children }: { children?: ReactNode }) => <div>{children}</div>
|
||||
return {
|
||||
Dialog: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
DialogContent: ReactModule.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
children?: ReactNode
|
||||
onOpenAutoFocus?: (event: Event) => void
|
||||
}
|
||||
>(function DialogContent({ children, onOpenAutoFocus: _onOpenAutoFocus, ...props }, ref) {
|
||||
return (
|
||||
<div ref={ref} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}),
|
||||
DialogDescription: Section,
|
||||
DialogFooter: Section,
|
||||
DialogHeader: Section,
|
||||
DialogTitle: Section
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/feedback-image-attachments', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return {
|
||||
...actual,
|
||||
readFeedbackImageFiles: mocks.readFeedbackImageFiles
|
||||
}
|
||||
})
|
||||
|
||||
import { SidebarFeedbackDialog } from './SidebarFeedbackDialog'
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.readFeedbackImageFiles.mockReset()
|
||||
mocks.submit.mockReset()
|
||||
mocks.toastWarning.mockReset()
|
||||
mocks.submit.mockResolvedValue({ ok: true })
|
||||
URL.revokeObjectURL = vi.fn()
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
feedback: { submit: mocks.submit },
|
||||
gh: { viewer: vi.fn().mockResolvedValue(null) },
|
||||
shell: { openUrl: vi.fn() }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('SidebarFeedbackDialog image submission', () => {
|
||||
it('keeps the dialog scrollable within short windows', () => {
|
||||
const { container } = render(<SidebarFeedbackDialog open onOpenChange={vi.fn()} />)
|
||||
const content = container.querySelector('.scrollbar-sleek')
|
||||
|
||||
expect(content?.className).toContain('max-h-[calc(100vh-3rem)]')
|
||||
expect(content?.className).toContain('overflow-y-auto')
|
||||
})
|
||||
|
||||
it('waits for in-flight image reads before enabling Send', async () => {
|
||||
let finishRead: ((value: unknown) => void) | undefined
|
||||
mocks.readFeedbackImageFiles.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finishRead = resolve
|
||||
})
|
||||
)
|
||||
const { container } = render(<SidebarFeedbackDialog open onOpenChange={vi.fn()} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('What could we improve?'), {
|
||||
target: { value: 'Screenshot attached' }
|
||||
})
|
||||
const file = new File(['image'], 'shot.png', { type: 'image/png' })
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="file"]')
|
||||
expect(input).not.toBeNull()
|
||||
fireEvent.change(input!, { target: { files: [file] } })
|
||||
|
||||
const send = screen.getByRole('button', { name: 'Send' })
|
||||
expect((send as HTMLButtonElement).disabled).toBe(true)
|
||||
fireEvent.click(send)
|
||||
expect(mocks.submit).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
finishRead?.({
|
||||
images: [
|
||||
{
|
||||
id: 'shot',
|
||||
name: file.name,
|
||||
contentType: file.type,
|
||||
bytes: file.size,
|
||||
data: new Uint8Array([1]),
|
||||
previewUrl: 'blob:shot'
|
||||
}
|
||||
],
|
||||
errors: []
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => expect((send as HTMLButtonElement).disabled).toBe(false))
|
||||
const remove = screen.getByRole('button', { name: 'Remove shot.png' })
|
||||
expect(remove.dataset.slot).toBe('button')
|
||||
expect(remove.dataset.size).toBe('icon-xs')
|
||||
fireEvent.click(send)
|
||||
await waitFor(() => expect(mocks.submit).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.submit.mock.calls[0]?.[0].images).toEqual([
|
||||
{ contentType: 'image/png', data: new Uint8Array([1]) }
|
||||
])
|
||||
})
|
||||
|
||||
it('warns when the server cannot confirm image delivery', async () => {
|
||||
mocks.readFeedbackImageFiles.mockResolvedValue({
|
||||
images: [
|
||||
{
|
||||
id: 'shot',
|
||||
name: 'shot.png',
|
||||
contentType: 'image/png',
|
||||
bytes: 1,
|
||||
data: new Uint8Array([1]),
|
||||
previewUrl: 'blob:shot'
|
||||
}
|
||||
],
|
||||
errors: []
|
||||
})
|
||||
mocks.submit.mockResolvedValue({ ok: true, imagesDelivered: false })
|
||||
const onOpenChange = vi.fn()
|
||||
const { container } = render(<SidebarFeedbackDialog open onOpenChange={onOpenChange} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('What could we improve?'), {
|
||||
target: { value: 'Screenshot attached' }
|
||||
})
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="file"]')
|
||||
fireEvent.change(input!, {
|
||||
target: { files: [new File(['x'], 'shot.png', { type: 'image/png' })] }
|
||||
})
|
||||
await screen.findByRole('button', { name: 'Remove shot.png' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.toastWarning).toHaveBeenCalledWith(
|
||||
'Feedback sent, but image delivery could not be confirmed.'
|
||||
)
|
||||
)
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('releases image previews when the sidebar unmounts the dialog', async () => {
|
||||
mocks.readFeedbackImageFiles.mockResolvedValue({
|
||||
images: [
|
||||
{
|
||||
id: 'shot',
|
||||
name: 'shot.png',
|
||||
contentType: 'image/png',
|
||||
bytes: 1,
|
||||
data: new Uint8Array([1]),
|
||||
previewUrl: 'blob:shot'
|
||||
}
|
||||
],
|
||||
errors: []
|
||||
})
|
||||
const { container, unmount } = render(<SidebarFeedbackDialog open onOpenChange={vi.fn()} />)
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="file"]')
|
||||
fireEvent.change(input!, {
|
||||
target: { files: [new File(['x'], 'shot.png', { type: 'image/png' })] }
|
||||
})
|
||||
await screen.findByRole('button', { name: 'Remove shot.png' })
|
||||
|
||||
unmount()
|
||||
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:shot')
|
||||
})
|
||||
|
||||
it('does not consume text when the pasted image cannot be attached', () => {
|
||||
mocks.readFeedbackImageFiles.mockResolvedValue({
|
||||
images: [],
|
||||
errors: ['huge.png is larger than 8.0 MB.']
|
||||
})
|
||||
render(<SidebarFeedbackDialog open onOpenChange={vi.fn()} />)
|
||||
const textarea = screen.getByPlaceholderText('What could we improve?')
|
||||
const file = new File(['image'], 'huge.png', { type: 'image/png' })
|
||||
Object.defineProperty(file, 'size', { value: 8 * 1024 * 1024 + 1 })
|
||||
const paste = new Event('paste', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(paste, 'clipboardData', {
|
||||
value: { files: [file] }
|
||||
})
|
||||
|
||||
fireEvent(textarea, paste)
|
||||
|
||||
expect(paste.defaultPrevented).toBe(false)
|
||||
expect(mocks.readFeedbackImageFiles).toHaveBeenCalledWith([file], 0)
|
||||
})
|
||||
|
||||
it('rejects images added after submission starts instead of clearing them unsent', async () => {
|
||||
mocks.submit.mockReturnValue(new Promise(() => {}))
|
||||
const { container } = render(<SidebarFeedbackDialog open onOpenChange={vi.fn()} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('What could we improve?'), {
|
||||
target: { value: 'Initial report' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
|
||||
await waitFor(() => expect(mocks.submit).toHaveBeenCalledTimes(1))
|
||||
const file = new File(['image'], 'late.png', { type: 'image/png' })
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="file"]')
|
||||
|
||||
fireEvent.change(input!, { target: { files: [file] } })
|
||||
|
||||
expect(mocks.readFeedbackImageFiles).not.toHaveBeenCalled()
|
||||
expect(mocks.toastWarning).toHaveBeenCalledWith(
|
||||
'Wait for the current feedback to finish sending before attaching more images.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -15,6 +15,15 @@ import { useMountedRef } from '@/hooks/useMountedRef'
|
|||
import { cn } from '@/lib/utils'
|
||||
import type { GitHubViewer } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
extractImageFilesFromDataTransfer,
|
||||
hasAttachableFeedbackImage,
|
||||
readFeedbackImageFiles,
|
||||
releaseFeedbackImageDraft,
|
||||
type FeedbackImageDraft
|
||||
} from '@/lib/feedback-image-attachments'
|
||||
import { SidebarFeedbackImageAttachments } from './SidebarFeedbackImageAttachments'
|
||||
import { useFeedbackImageDrop } from './use-feedback-image-drop'
|
||||
|
||||
const GITHUB_ISSUES_URL = 'https://github.com/stablyai/orca/issues/'
|
||||
const DISCORD_URL = 'https://discord.gg/fzjDKHxv8Q'
|
||||
|
|
@ -57,8 +66,98 @@ export function SidebarFeedbackDialog({
|
|||
const [viewer, setViewer] = useState<GitHubViewer | null>(null)
|
||||
const [isViewerLoading, setIsViewerLoading] = useState(false)
|
||||
const [submitAnonymously, setSubmitAnonymously] = useState(false)
|
||||
const [images, setImages] = useState<FeedbackImageDraft[]>([])
|
||||
const [pendingImageReadCount, setPendingImageReadCount] = useState(0)
|
||||
const mountedRef = useMountedRef()
|
||||
const feedbackTextareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const liveImageDraftsRef = useRef<FeedbackImageDraft[]>([])
|
||||
|
||||
const clearImages = React.useCallback(() => {
|
||||
liveImageDraftsRef.current.forEach(releaseFeedbackImageDraft)
|
||||
liveImageDraftsRef.current = []
|
||||
setImages([])
|
||||
}, [])
|
||||
|
||||
// Why: object URLs for the thumbnails leak until revoked, so drop them when
|
||||
// the dialog unmounts as well as when an attachment is removed.
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
liveImageDraftsRef.current.forEach(releaseFeedbackImageDraft)
|
||||
liveImageDraftsRef.current = []
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const imageCount = images.length
|
||||
|
||||
// Why: committed state lags the in-flight reads, so batches still being read
|
||||
// count against capacity — otherwise two quick pastes both see room for four.
|
||||
const pendingImageReadsRef = useRef(0)
|
||||
|
||||
const handleAddFiles = React.useCallback(
|
||||
(files: readonly File[]) => {
|
||||
if (files.length === 0) {
|
||||
return
|
||||
}
|
||||
if (isSubmitting) {
|
||||
toast.warning(
|
||||
translate(
|
||||
'auto.components.sidebar.SidebarFeedbackDialog.attachWhileSending',
|
||||
'Wait for the current feedback to finish sending before attaching more images.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
// Why: read the committed count from the closure rather than a ref. A ref
|
||||
// synced in an effect can still be stale-low right after an add, which
|
||||
// over-accepts and gets the whole submission rejected by the main process.
|
||||
const existingCount = imageCount + pendingImageReadsRef.current
|
||||
pendingImageReadsRef.current += files.length
|
||||
setPendingImageReadCount((current) => current + files.length)
|
||||
void readFeedbackImageFiles(files, existingCount).then(
|
||||
({ images: added, errors }) => {
|
||||
pendingImageReadsRef.current -= files.length
|
||||
if (!mountedRef.current) {
|
||||
added.forEach(releaseFeedbackImageDraft)
|
||||
return
|
||||
}
|
||||
setPendingImageReadCount((current) => Math.max(0, current - files.length))
|
||||
if (added.length > 0) {
|
||||
liveImageDraftsRef.current = [...liveImageDraftsRef.current, ...added]
|
||||
setImages((existing) => [...existing, ...added])
|
||||
}
|
||||
// Why: never drop an attachment without telling the user — that
|
||||
// silence is what made screenshots vanish in the first place.
|
||||
errors.forEach((error) => toast.warning(error))
|
||||
},
|
||||
(error: unknown) => {
|
||||
pendingImageReadsRef.current -= files.length
|
||||
console.error('Failed to read feedback image attachments:', error)
|
||||
if (mountedRef.current) {
|
||||
setPendingImageReadCount((current) => Math.max(0, current - files.length))
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.sidebar.SidebarFeedbackDialog.imageReadFailed',
|
||||
'Could not read the attached images. Try attaching them again.'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
[imageCount, isSubmitting, mountedRef]
|
||||
)
|
||||
|
||||
const handleRemoveImage = React.useCallback((id: string) => {
|
||||
const removed = liveImageDraftsRef.current.find((image) => image.id === id)
|
||||
if (removed) {
|
||||
releaseFeedbackImageDraft(removed)
|
||||
liveImageDraftsRef.current = liveImageDraftsRef.current.filter((image) => image.id !== id)
|
||||
}
|
||||
setImages((current) => current.filter((image) => image.id !== id))
|
||||
}, [])
|
||||
|
||||
const { isDragActive, contentRef, dragHandlers } = useFeedbackImageDrop(open, handleAddFiles)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
|
|
@ -92,6 +191,9 @@ export function SidebarFeedbackDialog({
|
|||
}, [open])
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
if (isSubmitting || pendingImageReadsRef.current > 0) {
|
||||
return
|
||||
}
|
||||
const trimmed = feedback.trim()
|
||||
if (!trimmed) {
|
||||
toast.warning(
|
||||
|
|
@ -115,7 +217,11 @@ export function SidebarFeedbackDialog({
|
|||
feedback: trimmed,
|
||||
submitAnonymously,
|
||||
githubLogin: identity.githubLogin,
|
||||
githubEmail: identity.githubEmail
|
||||
githubEmail: identity.githubEmail,
|
||||
images: images.map((image) => ({
|
||||
contentType: image.contentType,
|
||||
data: image.data
|
||||
}))
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
|
|
@ -123,14 +229,26 @@ export function SidebarFeedbackDialog({
|
|||
}
|
||||
|
||||
if (mountedRef.current) {
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.sidebar.SidebarFeedbackDialog.7a46c228b8',
|
||||
'Thanks for the feedback.'
|
||||
// Why: the text reached us but the screenshots did not, so say that
|
||||
// plainly instead of a blanket success the user would misread.
|
||||
if (result.imagesDelivered === false) {
|
||||
toast.warning(
|
||||
translate(
|
||||
'auto.components.sidebar.SidebarFeedbackDialog.imagesNotDelivered',
|
||||
'Feedback sent, but image delivery could not be confirmed.'
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.sidebar.SidebarFeedbackDialog.7a46c228b8',
|
||||
'Thanks for the feedback.'
|
||||
)
|
||||
)
|
||||
}
|
||||
setFeedback('')
|
||||
setSubmitAnonymously(false)
|
||||
clearImages()
|
||||
onOpenChange(false)
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -153,11 +271,30 @@ export function SidebarFeedbackDialog({
|
|||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-lg"
|
||||
ref={contentRef}
|
||||
className="max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-lg"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
feedbackTextareaRef.current?.focus()
|
||||
}}
|
||||
// Why: paste is bound on the dialog rather than the textarea so a
|
||||
// screenshot lands whether or not the caret is in the message box.
|
||||
onPaste={(event) => {
|
||||
const pasted = extractImageFilesFromDataTransfer(event.clipboardData)
|
||||
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, imageCount + pendingImageReadsRef.current)) {
|
||||
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.
|
||||
{...dragHandlers}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">
|
||||
|
|
@ -237,6 +374,14 @@ export function SidebarFeedbackDialog({
|
|||
className="min-h-32 w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
|
||||
<SidebarFeedbackImageAttachments
|
||||
images={images}
|
||||
disabled={isSubmitting}
|
||||
isDragActive={isDragActive}
|
||||
onAddFiles={handleAddFiles}
|
||||
onRemove={handleRemoveImage}
|
||||
/>
|
||||
|
||||
<div className="min-h-9 rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||
{viewer ? (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
|
|
@ -283,7 +428,10 @@ export function SidebarFeedbackDialog({
|
|||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
{translate('auto.components.sidebar.SidebarFeedbackDialog.8bf619e4cf', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void handleSubmit()} disabled={isSubmitting || !feedback.trim()}>
|
||||
<Button
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={isSubmitting || pendingImageReadCount > 0 || !feedback.trim()}
|
||||
>
|
||||
{isSubmitting
|
||||
? translate('auto.components.sidebar.SidebarFeedbackDialog.69969ba364', 'Sending…')
|
||||
: translate('auto.components.sidebar.SidebarFeedbackDialog.f2e42e1307', 'Send')}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
import React from 'react'
|
||||
import { ImagePlus, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
FEEDBACK_IMAGE_FILE_ACCEPT,
|
||||
MAX_FEEDBACK_IMAGE_COUNT,
|
||||
formatFeedbackImageSize,
|
||||
type FeedbackImageDraft
|
||||
} from '@/lib/feedback-image-attachments'
|
||||
|
||||
type SidebarFeedbackImageAttachmentsProps = {
|
||||
images: FeedbackImageDraft[]
|
||||
disabled: boolean
|
||||
isDragActive: boolean
|
||||
onAddFiles: (files: readonly File[]) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
export function SidebarFeedbackImageAttachments({
|
||||
images,
|
||||
disabled,
|
||||
isDragActive,
|
||||
onAddFiles,
|
||||
onRemove
|
||||
}: SidebarFeedbackImageAttachmentsProps): React.JSX.Element {
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const atCapacity = images.length >= MAX_FEEDBACK_IMAGE_COUNT
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border border-dashed border-border/70 px-3 py-2 transition-colors',
|
||||
isDragActive && 'border-ring bg-accent/40'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.SidebarFeedbackImageAttachments.screenshotsHint',
|
||||
'Attach up to {count} screenshots'
|
||||
).replace('{count}', String(MAX_FEEDBACK_IMAGE_COUNT))}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 text-xs"
|
||||
disabled={disabled || atCapacity}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<ImagePlus className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.sidebar.SidebarFeedbackImageAttachments.attachImages',
|
||||
'Attach'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={FEEDBACK_IMAGE_FILE_ACCEPT}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
onAddFiles(Array.from(event.target.files ?? []))
|
||||
// Why: reset so re-picking the same file still fires onChange.
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
|
||||
{images.length > 0 && (
|
||||
<ul className="mt-2 flex flex-wrap gap-2">
|
||||
{images.map((image) => (
|
||||
<li key={image.id} className="group/attachment relative">
|
||||
<img
|
||||
src={image.previewUrl}
|
||||
alt={image.name}
|
||||
className="size-14 rounded border border-border object-cover"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.SidebarFeedbackImageAttachments.removeImage',
|
||||
'Remove {{fileName}}',
|
||||
{ fileName: image.name }
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={() => onRemove(image.id)}
|
||||
className="absolute -right-2 -top-2 rounded-full text-muted-foreground opacity-80 hover:opacity-100 hover:text-foreground focus-visible:opacity-100"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</Button>
|
||||
<span className="mt-0.5 block text-center text-[10px] leading-none text-muted-foreground">
|
||||
{formatFeedbackImageSize(image.bytes)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
/**
|
||||
* Native file drops never reach React in this app: preload claims them on
|
||||
* document capture and routes the paths to the editor. These tests pin the
|
||||
* window-capture interception that keeps a screenshot dropped on the feedback
|
||||
* dialog from being swallowed by that lane.
|
||||
*/
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCA_INTERNAL_FILE_DRAG_TYPE } from '../../../../shared/native-file-drop'
|
||||
import { useFeedbackImageDrop } from './use-feedback-image-drop'
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
let preloadDropSpy: ReturnType<typeof vi.fn<(event: Event) => void>>
|
||||
|
||||
function preloadDropListener(event: Event): void {
|
||||
preloadDropSpy(event)
|
||||
// Why: preload consumes the gesture, which is why React's onDrop never runs.
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
preloadDropSpy = vi.fn()
|
||||
// Registered before the hook mounts, exactly like preload's document listener.
|
||||
document.addEventListener('drop', preloadDropListener, true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.removeEventListener('drop', preloadDropListener, true)
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
container.remove()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function Harness({
|
||||
open,
|
||||
onAddFiles
|
||||
}: {
|
||||
open: boolean
|
||||
onAddFiles: (files: readonly File[]) => void
|
||||
}): React.JSX.Element {
|
||||
const { isDragActive, contentRef, dragHandlers } = useFeedbackImageDrop(open, onAddFiles)
|
||||
return (
|
||||
<div ref={contentRef} data-testid="dialog" data-drag-active={isDragActive} {...dragHandlers}>
|
||||
<span data-testid="child">child</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function renderHarness(
|
||||
open: boolean,
|
||||
onAddFiles: (files: readonly File[]) => void
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(<Harness open={open} onAddFiles={onAddFiles} />)
|
||||
})
|
||||
}
|
||||
|
||||
function dragEvent(type: string, files: File[], types: string[] = ['Files']): Event {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'dataTransfer', { value: { files, types } })
|
||||
return event
|
||||
}
|
||||
|
||||
function pngFile(name = 'shot.png'): File {
|
||||
return new File(['x'], name, { type: 'image/png' })
|
||||
}
|
||||
|
||||
function dialogChild(): HTMLElement {
|
||||
const child = container.querySelector('[data-testid="child"]')
|
||||
if (!child) {
|
||||
throw new Error('harness child missing')
|
||||
}
|
||||
return child as HTMLElement
|
||||
}
|
||||
|
||||
describe('useFeedbackImageDrop', () => {
|
||||
it('claims an image dropped on the dialog before preload can route it away', async () => {
|
||||
const onAddFiles = vi.fn()
|
||||
await renderHarness(true, onAddFiles)
|
||||
|
||||
const event = dragEvent('drop', [pngFile()])
|
||||
act(() => {
|
||||
dialogChild().dispatchEvent(event)
|
||||
})
|
||||
|
||||
expect(onAddFiles).toHaveBeenCalledTimes(1)
|
||||
expect(onAddFiles.mock.calls[0][0].map((file: File) => file.name)).toEqual(['shot.png'])
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(preloadDropSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves drops outside the dialog to the existing native lane', async () => {
|
||||
const onAddFiles = vi.fn()
|
||||
await renderHarness(true, onAddFiles)
|
||||
|
||||
const outside = document.createElement('div')
|
||||
document.body.appendChild(outside)
|
||||
act(() => {
|
||||
outside.dispatchEvent(dragEvent('drop', [pngFile()]))
|
||||
})
|
||||
|
||||
expect(onAddFiles).not.toHaveBeenCalled()
|
||||
expect(preloadDropSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores non-image drops so they keep their existing behavior', async () => {
|
||||
const onAddFiles = vi.fn()
|
||||
await renderHarness(true, onAddFiles)
|
||||
|
||||
const event = dragEvent('drop', [new File(['x'], 'notes.txt', { type: 'text/plain' })])
|
||||
act(() => {
|
||||
dialogChild().dispatchEvent(event)
|
||||
})
|
||||
|
||||
expect(onAddFiles).not.toHaveBeenCalled()
|
||||
expect(preloadDropSpy).toHaveBeenCalledTimes(1)
|
||||
// Why: dragover accepted the drag, so an uncancelled drop navigates the web
|
||||
// client to the file; preload still gets it because propagation continues.
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('stops listening once the dialog is closed', async () => {
|
||||
const onAddFiles = vi.fn()
|
||||
await renderHarness(true, onAddFiles)
|
||||
await renderHarness(false, onAddFiles)
|
||||
|
||||
act(() => {
|
||||
dialogChild().dispatchEvent(dragEvent('drop', [pngFile()]))
|
||||
})
|
||||
|
||||
expect(onAddFiles).not.toHaveBeenCalled()
|
||||
expect(preloadDropSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Why: only preload preventDefaults dragover, and the web client has no
|
||||
// preload — without this the browser rejects the drop and opens the file.
|
||||
it('accepts the drag on dragover so the drop can fire without preload', async () => {
|
||||
await renderHarness(true, vi.fn())
|
||||
|
||||
const event = dragEvent('dragover', [])
|
||||
act(() => {
|
||||
dialogChild().dispatchEvent(event)
|
||||
})
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect((event as DragEvent).dataTransfer?.dropEffect).toBe('copy')
|
||||
})
|
||||
|
||||
it('leaves in-app drags alone on dragover', async () => {
|
||||
await renderHarness(true, vi.fn())
|
||||
|
||||
const event = dragEvent('dragover', [], ['Files', ORCA_INTERNAL_FILE_DRAG_TYPE])
|
||||
act(() => {
|
||||
dialogChild().dispatchEvent(event)
|
||||
})
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('highlights from the advertised drag types, which is all a dragenter exposes', async () => {
|
||||
await renderHarness(true, vi.fn())
|
||||
const dialog = container.querySelector('[data-testid="dialog"]') as HTMLElement
|
||||
|
||||
// Why: DataTransfer.files is empty until drop; only `types` is populated.
|
||||
act(() => {
|
||||
dialogChild().dispatchEvent(dragEvent('dragenter', []))
|
||||
})
|
||||
expect(dialog.dataset.dragActive).toBe('true')
|
||||
|
||||
act(() => {
|
||||
dialogChild().dispatchEvent(dragEvent('dragleave', []))
|
||||
})
|
||||
expect(dialog.dataset.dragActive).toBe('false')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { hasNativeFileDragTypes } from '../../../../shared/native-file-drop'
|
||||
import { extractImageFilesFromDataTransfer } from '@/lib/feedback-image-attachments'
|
||||
|
||||
type FeedbackImageDragHandlers = {
|
||||
onDragEnter: (event: React.DragEvent<HTMLElement>) => void
|
||||
onDragOver: (event: React.DragEvent<HTMLElement>) => void
|
||||
onDragLeave: (event: React.DragEvent<HTMLElement>) => void
|
||||
}
|
||||
|
||||
export type FeedbackImageDrop = {
|
||||
isDragActive: boolean
|
||||
contentRef: React.RefObject<HTMLDivElement | null>
|
||||
dragHandlers: FeedbackImageDragHandlers
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag-and-drop attachment wiring for the feedback dialog. Native file drops
|
||||
* never reach React here: preload consumes them on document capture and routes
|
||||
* the paths to the editor, so the drop is claimed one phase earlier on window.
|
||||
*/
|
||||
export function useFeedbackImageDrop(
|
||||
open: boolean,
|
||||
onAddFiles: (files: readonly File[]) => void
|
||||
): FeedbackImageDrop {
|
||||
const [isDragActive, setIsDragActive] = useState(false)
|
||||
const contentRef = useRef<HTMLDivElement | null>(null)
|
||||
const dragDepthRef = useRef(0)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
dragDepthRef.current = 0
|
||||
setIsDragActive(false)
|
||||
}, [])
|
||||
|
||||
// Why: DataTransfer.files is empty until the drop lands, so the highlight has
|
||||
// to key off the drag types the OS advertises during the drag itself.
|
||||
const onDragEnter = useCallback((event: React.DragEvent<HTMLElement>) => {
|
||||
if (!hasNativeFileDragTypes(event.dataTransfer.types)) {
|
||||
return
|
||||
}
|
||||
dragDepthRef.current += 1
|
||||
setIsDragActive(true)
|
||||
}, [])
|
||||
|
||||
// Why: the web client has no preload to preventDefault dragover for it, and
|
||||
// without that the browser refuses the drop and navigates to the dropped file.
|
||||
const onDragOver = useCallback((event: React.DragEvent<HTMLElement>) => {
|
||||
if (!hasNativeFileDragTypes(event.dataTransfer.types)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
}, [])
|
||||
|
||||
const onDragLeave = useCallback((event: React.DragEvent<HTMLElement>) => {
|
||||
// Why: mirror the enter guard so internal drags can't decrement a counter
|
||||
// enter never incremented.
|
||||
if (!hasNativeFileDragTypes(event.dataTransfer.types)) {
|
||||
return
|
||||
}
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
if (dragDepthRef.current === 0) {
|
||||
setIsDragActive(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const handleDrop = (event: DragEvent): void => {
|
||||
const droppedInDialog = contentRef.current?.contains(event.target as Node) ?? false
|
||||
reset()
|
||||
if (!droppedInDialog || !hasNativeFileDragTypes(event.dataTransfer?.types)) {
|
||||
return
|
||||
}
|
||||
// Why: dragover accepted this drag, and a drop left uncancelled after that
|
||||
// is what makes the browser navigate to the file — including the non-image
|
||||
// drops below, which would otherwise wipe the typed feedback on web.
|
||||
event.preventDefault()
|
||||
const images = extractImageFilesFromDataTransfer(event.dataTransfer)
|
||||
if (images.length === 0) {
|
||||
return
|
||||
}
|
||||
// Why: stop preload's native-drop lane from also opening the screenshot
|
||||
// in an editor behind the dialog.
|
||||
event.stopPropagation()
|
||||
onAddFiles(images)
|
||||
}
|
||||
window.addEventListener('drop', handleDrop, true)
|
||||
window.addEventListener('dragend', reset, true)
|
||||
return () => {
|
||||
window.removeEventListener('drop', handleDrop, true)
|
||||
window.removeEventListener('dragend', reset, true)
|
||||
// Why: a dialog closed mid-drag would otherwise reopen mid-highlight.
|
||||
reset()
|
||||
}
|
||||
}, [onAddFiles, open, reset])
|
||||
|
||||
return { isDragActive, contentRef, dragHandlers: { onDragEnter, onDragOver, onDragLeave } }
|
||||
}
|
||||
|
|
@ -658,6 +658,20 @@
|
|||
},
|
||||
"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.",
|
||||
"dimensionsTooLarge": "{{fileName}} has dimensions that are too large to preview safely.",
|
||||
"invalidImage": "{{fileName}} is not a valid supported image.",
|
||||
"additionalErrors": "{{count}} additional images could not be attached."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
|
|
@ -4224,7 +4238,10 @@
|
|||
"7a46c228b8": "Thanks for the feedback.",
|
||||
"a2fd890d9e": "Please enter feedback before submitting.",
|
||||
"f2e42e1307": "Send",
|
||||
"69969ba364": "Sending…"
|
||||
"69969ba364": "Sending…",
|
||||
"imageReadFailed": "Could not read the attached images. Try attaching them again.",
|
||||
"attachWhileSending": "Wait for the current feedback to finish sending before attaching more images.",
|
||||
"imagesNotDelivered": "Feedback sent, but image delivery could not be confirmed."
|
||||
},
|
||||
"SidebarFilter": {
|
||||
"e3b3898218": "Add project",
|
||||
|
|
@ -5036,6 +5053,11 @@
|
|||
"developer": "Developer",
|
||||
"parkTerminal": "Park terminal"
|
||||
},
|
||||
"SidebarFeedbackImageAttachments": {
|
||||
"screenshotsHint": "Attach up to {count} screenshots",
|
||||
"attachImages": "Attach",
|
||||
"removeImage": "Remove {{fileName}}"
|
||||
},
|
||||
"WorkspaceKanbanSearchField": {
|
||||
"bdb753c78d": "No workspaces match",
|
||||
"4d96c209d6": "{{value0}} of {{value1}} workspaces match",
|
||||
|
|
|
|||
|
|
@ -635,6 +635,20 @@
|
|||
},
|
||||
"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.",
|
||||
"dimensionsTooLarge": "{{fileName}} has dimensions that are too large to preview safely.",
|
||||
"invalidImage": "{{fileName}} is not a valid supported image.",
|
||||
"additionalErrors": "{{count}} additional images could not be attached."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
|
|
@ -4182,7 +4196,10 @@
|
|||
"7a46c228b8": "Gracias por el feedback.",
|
||||
"a2fd890d9e": "Introduce feedback antes de enviarlo.",
|
||||
"f2e42e1307": "Enviar",
|
||||
"69969ba364": "Envío…"
|
||||
"69969ba364": "Envío…",
|
||||
"imageReadFailed": "Could not read the attached images. Try attaching them again.",
|
||||
"attachWhileSending": "Wait for the current feedback to finish sending before attaching more images.",
|
||||
"imagesNotDelivered": "Feedback sent, but image delivery could not be confirmed."
|
||||
},
|
||||
"SidebarFilter": {
|
||||
"e3b3898218": "Agregar proyecto",
|
||||
|
|
@ -5009,6 +5026,11 @@
|
|||
"developer": "Developer",
|
||||
"parkTerminal": "Park terminal"
|
||||
},
|
||||
"SidebarFeedbackImageAttachments": {
|
||||
"screenshotsHint": "Attach up to {count} screenshots",
|
||||
"attachImages": "Attach",
|
||||
"removeImage": "Remove {{fileName}}"
|
||||
},
|
||||
"WorkspaceKanbanSearchField": {
|
||||
"bdb753c78d": "Ningún espacio de trabajo coincide",
|
||||
"4d96c209d6": "{{value0}} de {{value1}} espacios de trabajo coinciden",
|
||||
|
|
|
|||
|
|
@ -635,6 +635,20 @@
|
|||
},
|
||||
"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.",
|
||||
"dimensionsTooLarge": "{{fileName}} has dimensions that are too large to preview safely.",
|
||||
"invalidImage": "{{fileName}} is not a valid supported image.",
|
||||
"additionalErrors": "{{count}} additional images could not be attached."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
|
|
@ -4163,7 +4177,10 @@
|
|||
"7a46c228b8": "フィードバックありがとうございます。",
|
||||
"a2fd890d9e": "送信する前にフィードバックを入力。",
|
||||
"f2e42e1307": "送信",
|
||||
"69969ba364": "送信中…"
|
||||
"69969ba364": "送信中…",
|
||||
"imageReadFailed": "Could not read the attached images. Try attaching them again.",
|
||||
"attachWhileSending": "Wait for the current feedback to finish sending before attaching more images.",
|
||||
"imagesNotDelivered": "Feedback sent, but image delivery could not be confirmed."
|
||||
},
|
||||
"SidebarFilter": {
|
||||
"e3b3898218": "プロジェクトの追加",
|
||||
|
|
@ -5009,6 +5026,11 @@
|
|||
"developer": "Developer",
|
||||
"parkTerminal": "Park terminal"
|
||||
},
|
||||
"SidebarFeedbackImageAttachments": {
|
||||
"screenshotsHint": "Attach up to {count} screenshots",
|
||||
"attachImages": "Attach",
|
||||
"removeImage": "Remove {{fileName}}"
|
||||
},
|
||||
"WorkspaceKanbanSearchField": {
|
||||
"bdb753c78d": "一致するワークスペースはありません",
|
||||
"4d96c209d6": "{{value1}} 件中 {{value0}} 件のワークスペースが一致します",
|
||||
|
|
|
|||
|
|
@ -635,6 +635,20 @@
|
|||
},
|
||||
"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.",
|
||||
"dimensionsTooLarge": "{{fileName}} has dimensions that are too large to preview safely.",
|
||||
"invalidImage": "{{fileName}} is not a valid supported image.",
|
||||
"additionalErrors": "{{count}} additional images could not be attached."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
|
|
@ -4163,7 +4177,10 @@
|
|||
"7a46c228b8": "피드백을 보내주셔서 감사합니다.",
|
||||
"a2fd890d9e": "제출하기 전에 피드백을 입력해 주세요.",
|
||||
"f2e42e1307": "보내기",
|
||||
"69969ba364": "전송 중…"
|
||||
"69969ba364": "전송 중…",
|
||||
"imageReadFailed": "Could not read the attached images. Try attaching them again.",
|
||||
"attachWhileSending": "Wait for the current feedback to finish sending before attaching more images.",
|
||||
"imagesNotDelivered": "Feedback sent, but image delivery could not be confirmed."
|
||||
},
|
||||
"SidebarFilter": {
|
||||
"e3b3898218": "프로젝트 추가",
|
||||
|
|
@ -5009,6 +5026,11 @@
|
|||
"developer": "Developer",
|
||||
"parkTerminal": "Park terminal"
|
||||
},
|
||||
"SidebarFeedbackImageAttachments": {
|
||||
"screenshotsHint": "Attach up to {count} screenshots",
|
||||
"attachImages": "Attach",
|
||||
"removeImage": "Remove {{fileName}}"
|
||||
},
|
||||
"WorkspaceKanbanSearchField": {
|
||||
"bdb753c78d": "일치하는 워크스페이스가 없습니다",
|
||||
"4d96c209d6": "워크스페이스 {{value1}}개 중 {{value0}}개 일치",
|
||||
|
|
|
|||
|
|
@ -635,6 +635,20 @@
|
|||
},
|
||||
"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.",
|
||||
"dimensionsTooLarge": "{{fileName}} has dimensions that are too large to preview safely.",
|
||||
"invalidImage": "{{fileName}} is not a valid supported image.",
|
||||
"additionalErrors": "{{count}} additional images could not be attached."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
|
|
@ -4163,7 +4177,10 @@
|
|||
"7a46c228b8": "感谢您的反馈。",
|
||||
"a2fd890d9e": "请在提交前输入反馈。",
|
||||
"f2e42e1307": "发送",
|
||||
"69969ba364": "正在发送..."
|
||||
"69969ba364": "正在发送...",
|
||||
"imageReadFailed": "Could not read the attached images. Try attaching them again.",
|
||||
"attachWhileSending": "Wait for the current feedback to finish sending before attaching more images.",
|
||||
"imagesNotDelivered": "Feedback sent, but image delivery could not be confirmed."
|
||||
},
|
||||
"SidebarFilter": {
|
||||
"e3b3898218": "添加项目",
|
||||
|
|
@ -5009,6 +5026,11 @@
|
|||
"developer": "Developer",
|
||||
"parkTerminal": "Park terminal"
|
||||
},
|
||||
"SidebarFeedbackImageAttachments": {
|
||||
"screenshotsHint": "Attach up to {count} screenshots",
|
||||
"attachImages": "Attach",
|
||||
"removeImage": "Remove {{fileName}}"
|
||||
},
|
||||
"WorkspaceKanbanSearchField": {
|
||||
"bdb753c78d": "没有匹配的工作区",
|
||||
"4d96c209d6": "{{value1}} 个工作区中有 {{value0}} 个匹配",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
MAX_FEEDBACK_IMAGE_BYTES,
|
||||
MAX_FEEDBACK_IMAGE_COUNT,
|
||||
hasAttachableFeedbackImage,
|
||||
readFeedbackImageFiles
|
||||
} from './feedback-image-attachments'
|
||||
|
||||
beforeEach(() => {
|
||||
let next = 0
|
||||
URL.createObjectURL = vi.fn(() => `blob:feedback-${(next += 1)}`)
|
||||
URL.revokeObjectURL = vi.fn()
|
||||
})
|
||||
|
||||
function pngHeader(width = 1, height = 1): Uint8Array<ArrayBuffer> {
|
||||
const bytes = new Uint8Array(new ArrayBuffer(24))
|
||||
bytes.set([137, 80, 78, 71, 13, 10, 26, 10])
|
||||
const view = new DataView(bytes.buffer)
|
||||
view.setUint32(8, 13)
|
||||
bytes.set([73, 72, 68, 82], 12)
|
||||
view.setUint32(16, width)
|
||||
view.setUint32(20, height)
|
||||
return bytes
|
||||
}
|
||||
|
||||
function pngFile(name: string, size = 24, dimensions = { width: 1, height: 1 }): File {
|
||||
const file = new File([pngHeader(dimensions.width, dimensions.height)], name, {
|
||||
type: 'image/png'
|
||||
})
|
||||
Object.defineProperty(file, 'size', { value: size })
|
||||
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)
|
||||
})
|
||||
|
||||
it('is false when supported files cannot pass validation', () => {
|
||||
expect(hasAttachableFeedbackImage([pngFile('empty.png', 0)])).toBe(false)
|
||||
expect(hasAttachableFeedbackImage([pngFile('huge.png', MAX_FEEDBACK_IMAGE_BYTES + 1)])).toBe(
|
||||
false
|
||||
)
|
||||
expect(hasAttachableFeedbackImage([pngFile('a.png')], MAX_FEEDBACK_IMAGE_COUNT)).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)
|
||||
|
||||
expect(errors).toEqual([])
|
||||
expect(images).toHaveLength(2)
|
||||
expect(new Set(images.map((image) => image.id)).size).toBe(2)
|
||||
expect(images[0].data.byteLength).toBe(24)
|
||||
})
|
||||
|
||||
it('reports an unsupported type instead of skipping it', async () => {
|
||||
const { images, errors } = await readFeedbackImageFiles(
|
||||
[new File(['x'], 'notes.pdf', { type: 'application/pdf' })],
|
||||
0
|
||||
)
|
||||
|
||||
expect(images).toEqual([])
|
||||
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)],
|
||||
0
|
||||
)
|
||||
|
||||
expect(images).toEqual([])
|
||||
expect(errors).toEqual(['huge.png is larger than 8.0 MB.'])
|
||||
})
|
||||
|
||||
it('reports an empty image instead of deferring rejection until submit', async () => {
|
||||
const { images, errors } = await readFeedbackImageFiles([pngFile('empty.png', 0)], 0)
|
||||
|
||||
expect(images).toEqual([])
|
||||
expect(errors).toEqual(['empty.png is empty.'])
|
||||
})
|
||||
|
||||
it('rejects a raster that would exceed the decoded preview budget', async () => {
|
||||
const file = pngFile('huge-dimensions.png', 24, { width: 8192, height: 8192 })
|
||||
|
||||
const { images, errors } = await readFeedbackImageFiles([file], 0)
|
||||
|
||||
expect(images).toEqual([])
|
||||
expect(errors).toEqual([
|
||||
'huge-dimensions.png has dimensions that are too large to preview safely.'
|
||||
])
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid raster bytes instead of mounting a broken preview', async () => {
|
||||
const file = new File(['not an image'], 'broken.png', { type: 'image/png' })
|
||||
|
||||
const { images, errors } = await readFeedbackImageFiles([file], 0)
|
||||
|
||||
expect(images).toEqual([])
|
||||
expect(errors).toEqual(['broken.png is not a valid supported image.'])
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not count a rejected preview against the attachment limit', async () => {
|
||||
const files = [
|
||||
new File(['not an image'], 'broken.png', { type: 'image/png' }),
|
||||
...Array.from({ length: MAX_FEEDBACK_IMAGE_COUNT }, (_, index) =>
|
||||
pngFile(`valid-${index}.png`)
|
||||
)
|
||||
]
|
||||
|
||||
const { images, errors } = await readFeedbackImageFiles(files, 0)
|
||||
|
||||
expect(images).toHaveLength(MAX_FEEDBACK_IMAGE_COUNT)
|
||||
expect(errors).toEqual(['broken.png is not a valid supported image.'])
|
||||
})
|
||||
|
||||
it('reports the overflow once the running count is already at capacity', async () => {
|
||||
const { images, errors } = await readFeedbackImageFiles(
|
||||
[pngFile('a.png')],
|
||||
MAX_FEEDBACK_IMAGE_COUNT
|
||||
)
|
||||
|
||||
expect(images).toEqual([])
|
||||
expect(errors).toEqual([`You can attach up to ${MAX_FEEDBACK_IMAGE_COUNT} images.`])
|
||||
})
|
||||
|
||||
it('revokes previews already created when a later read in the batch fails', async () => {
|
||||
const good = pngFile('good.png')
|
||||
const broken = pngFile('broken.png')
|
||||
broken.arrayBuffer = () => Promise.reject(new Error('file went away'))
|
||||
|
||||
await expect(readFeedbackImageFiles([good, broken], 0)).rejects.toThrow('file went away')
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:feedback-1')
|
||||
})
|
||||
|
||||
it('does not depend on crypto.randomUUID, which LAN web clients do not expose', async () => {
|
||||
const realCrypto = globalThis.crypto
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
configurable: true,
|
||||
value: { getRandomValues: realCrypto.getRandomValues.bind(realCrypto) }
|
||||
})
|
||||
try {
|
||||
const { images, errors } = await readFeedbackImageFiles([pngFile('a.png')], 0)
|
||||
expect(errors).toEqual([])
|
||||
expect(images).toHaveLength(1)
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'crypto', { configurable: true, value: realCrypto })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
import { translate } from '@/i18n/i18n'
|
||||
import { createBrowserUuid } from './browser-uuid'
|
||||
import {
|
||||
INVALID_RASTER_IMAGE_PREVIEW_ERROR,
|
||||
RASTER_IMAGE_PREVIEW_TOO_LARGE_ERROR,
|
||||
assertRasterImagePreviewWithinLimits
|
||||
} from '../../../shared/raster-image-preview-limits'
|
||||
|
||||
export const MAX_FEEDBACK_IMAGE_COUNT = 4
|
||||
export const MAX_FEEDBACK_IMAGE_BYTES = 8 * 1024 * 1024
|
||||
export const SUPPORTED_FEEDBACK_IMAGE_TYPES = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/gif'
|
||||
] 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
|
||||
name: string
|
||||
contentType: string
|
||||
bytes: number
|
||||
data: Uint8Array
|
||||
/** Object URL for the thumbnail; revoke with releaseFeedbackImageDraft. */
|
||||
previewUrl: string
|
||||
}
|
||||
|
||||
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[], existingCount = 0): boolean {
|
||||
return (
|
||||
existingCount < MAX_FEEDBACK_IMAGE_COUNT &&
|
||||
files.some(
|
||||
(file) => isSupportedType(file.type) && file.size > 0 && file.size <= MAX_FEEDBACK_IMAGE_BYTES
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function releaseFeedbackImageDraft(draft: FeedbackImageDraft): void {
|
||||
URL.revokeObjectURL(draft.previewUrl)
|
||||
}
|
||||
|
||||
export function formatFeedbackImageSize(bytes: number): string {
|
||||
return bytes >= 1024 * 1024
|
||||
? `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
: `${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
|
||||
* is the exact failure this feature exists to fix.
|
||||
*/
|
||||
export async function readFeedbackImageFiles(
|
||||
files: readonly File[],
|
||||
existingCount: number
|
||||
): Promise<{ images: FeedbackImageDraft[]; errors: string[] }> {
|
||||
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)) {
|
||||
addError(() =>
|
||||
translate(
|
||||
'auto.lib.feedback.image.attachments.unsupportedType',
|
||||
'{{fileName}} is not a supported image type.',
|
||||
{ fileName }
|
||||
)
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (file.size === 0) {
|
||||
addError(() =>
|
||||
translate('auto.lib.feedback.image.attachments.empty', '{{fileName}} is empty.', {
|
||||
fileName
|
||||
})
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (file.size > 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) {
|
||||
addError(() =>
|
||||
translate(
|
||||
'auto.lib.feedback.image.attachments.tooMany',
|
||||
'You can attach up to {{maxCount}} images.',
|
||||
{ maxCount: MAX_FEEDBACK_IMAGE_COUNT }
|
||||
)
|
||||
)
|
||||
break
|
||||
}
|
||||
const data = new Uint8Array(await file.arrayBuffer())
|
||||
try {
|
||||
assertRasterImagePreviewWithinLimits(data, file.type)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === RASTER_IMAGE_PREVIEW_TOO_LARGE_ERROR) {
|
||||
addError(() =>
|
||||
translate(
|
||||
'auto.lib.feedback.image.attachments.dimensionsTooLarge',
|
||||
'{{fileName}} has dimensions that are too large to preview safely.',
|
||||
{ fileName }
|
||||
)
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (error instanceof Error && error.message === INVALID_RASTER_IMAGE_PREVIEW_ERROR) {
|
||||
addError(() =>
|
||||
translate(
|
||||
'auto.lib.feedback.image.attachments.invalidImage',
|
||||
'{{fileName}} is not a valid supported image.',
|
||||
{ fileName }
|
||||
)
|
||||
)
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
remaining -= 1
|
||||
images.push({
|
||||
// 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: fileName,
|
||||
contentType: file.type,
|
||||
bytes: file.size,
|
||||
data,
|
||||
previewUrl: URL.createObjectURL(file)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// Why: a rejected read never returns these drafts, and an un-revoked object
|
||||
// URL pins its blob for the life of the renderer.
|
||||
images.forEach(releaseFeedbackImageDraft)
|
||||
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 }
|
||||
}
|
||||
|
||||
export function extractImageFilesFromDataTransfer(data: DataTransfer | null): File[] {
|
||||
if (!data) {
|
||||
return []
|
||||
}
|
||||
return Array.from(data.files).filter((file) => file.type.startsWith('image/'))
|
||||
}
|
||||
Loading…
Reference in New Issue