Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FormEvent, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { FormEvent, type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
AlertCircle,
ArrowLeft,
Expand Down Expand Up @@ -34,6 +34,7 @@ import helpIconUrl from './assets/qortium-help-protoicon-black-transparent.png';
import { AttachmentList } from './attachments';
import {
formatAttachmentSize,
getTransferFiles,
MAX_ATTACHMENT_COUNT,
prepareFeedbackAttachment,
prepareFeedbackBundle,
Expand Down Expand Up @@ -368,22 +369,24 @@ function AttachmentPicker({
disabled,
files,
onChange,
pasteTargetRef,
}: {
disabled: boolean;
files: PreparedFeedbackAttachment[];
onChange: (files: PreparedFeedbackAttachment[]) => void;
pasteTargetRef?: RefObject<HTMLTextAreaElement | null>;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [processing, setProcessing] = useState(false);
const [error, setError] = useState('');

async function addFiles(fileList: FileList | null) {
if (!fileList || fileList.length === 0 || disabled || processing) {
async function addFiles(input: readonly File[]) {
if (input.length === 0 || disabled || processing) {
return;
}

const available = Math.max(0, MAX_ATTACHMENT_COUNT - files.length);
const selected = Array.from(fileList).slice(0, available);
const selected = input.slice(0, available);

if (selected.length === 0) {
setError(`A maximum of ${MAX_ATTACHMENT_COUNT} attachments is supported.`);
Expand All @@ -408,6 +411,29 @@ function AttachmentPicker({
}
}

useEffect(() => {
const target = pasteTargetRef?.current;

if (!target) {
return;
}

function handlePaste(event: ClipboardEvent) {
const pastedFiles = getTransferFiles(event.clipboardData);

if (pastedFiles.length === 0 || disabled || processing || files.length >= MAX_ATTACHMENT_COUNT) {
return;
}

event.preventDefault();
void addFiles(pastedFiles);
}

target.addEventListener('paste', handlePaste);

return () => target.removeEventListener('paste', handlePaste);
}, [disabled, files.length, pasteTargetRef, processing]);

return (
<div className="attachment-picker">
<input
Expand All @@ -416,7 +442,7 @@ function AttachmentPicker({
disabled={disabled || processing || files.length >= MAX_ATTACHMENT_COUNT}
multiple
onChange={(event) => {
void addFiles(event.target.files);
void addFiles(Array.from(event.target.files ?? []));
}}
ref={inputRef}
type="file"
Expand Down Expand Up @@ -504,6 +530,7 @@ function PostComposer({
const [body, setBody] = useState('');
const [attachments, setAttachments] = useState<PreparedFeedbackAttachment[]>([]);
const [draftIdentity, setDraftIdentity] = useState<FeedbackDraftIdentity | null>(null);
const bodyInputRef = useRef<HTMLTextAreaElement>(null);

async function handleSubmit(event: FormEvent) {
event.preventDefault();
Expand Down Expand Up @@ -613,6 +640,7 @@ function PostComposer({
disabled={!canPublish || publishing}
maxLength={12000}
onChange={(event) => setBody(event.target.value)}
ref={bodyInputRef}
rows={8}
value={body}
/>
Expand All @@ -621,6 +649,7 @@ function PostComposer({
disabled={!canAttach || publishing}
files={attachments}
onChange={setAttachments}
pasteTargetRef={bodyInputRef}
/>
<div className="composer__footer">
<span className="publish-name">{publishName || t('status.noName')}</span>
Expand Down Expand Up @@ -781,6 +810,7 @@ function ReplyComposer({
const [body, setBody] = useState('');
const [attachments, setAttachments] = useState<PreparedFeedbackAttachment[]>([]);
const [draftIdentity, setDraftIdentity] = useState<FeedbackDraftIdentity | null>(null);
const bodyInputRef = useRef<HTMLTextAreaElement>(null);

async function handleSubmit(event: FormEvent) {
event.preventDefault();
Expand Down Expand Up @@ -812,6 +842,7 @@ function ReplyComposer({
disabled={!canPublish || publishing}
maxLength={12000}
onChange={(event) => setBody(event.target.value)}
ref={bodyInputRef}
rows={3}
value={body}
/>
Expand All @@ -820,6 +851,7 @@ function ReplyComposer({
disabled={!canAttach || publishing}
files={attachments}
onChange={setAttachments}
pasteTargetRef={bodyInputRef}
/>
<div className="button-row">
<CommandButton
Expand Down
28 changes: 28 additions & 0 deletions src/attachmentUpload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
formatAttachmentSize,
getAttachmentMaxBytes,
getAttachmentService,
getTransferFiles,
MAX_ATTACHMENT_BYTES,
MAX_IMAGE_BYTES,
prepareFeedbackBundle,
Expand All @@ -25,6 +26,33 @@ afterEach(() => {
});

describe('feedback attachment helpers', () => {
it('uses direct clipboard files first and supports item-only screenshots', () => {
const direct = { name: 'direct.png' } as File;
const screenshot = { name: 'clipboard.png' } as File;

expect(
getTransferFiles({
files: { 0: direct, length: 1 },
items: { 0: { getAsFile: () => screenshot, kind: 'file' }, length: 1 },
}),
).toEqual([direct]);
expect(
getTransferFiles({
files: { length: 0 },
items: { 0: { getAsFile: () => screenshot, kind: 'file' }, length: 1 },
}),
).toEqual([screenshot]);
});

it('leaves text-only clipboard data to the textarea', () => {
expect(
getTransferFiles({
files: { length: 0 },
items: { 0: { getAsFile: () => null, kind: 'string' }, length: 1 },
}),
).toEqual([]);
});

it('routes safe raster images and media to matching QDN services', () => {
expect(getAttachmentService({ type: 'image/png' } as File)).toBe('IMAGE');
expect(getAttachmentService({ type: 'image/svg+xml' } as File)).toBe('ATTACHMENT');
Expand Down
41 changes: 41 additions & 0 deletions src/attachmentUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ export type PreparedFeedbackAttachment = {
size: number;
};

type TransferFileItem = {
getAsFile(): File | null;
kind: string;
};

type TransferFileSource = {
files?: ArrayLike<File> | null;
items?: ArrayLike<TransferFileItem> | null;
};

export type PublishedQdnResource = {
resource: {
identifier: string | null;
Expand All @@ -41,6 +51,37 @@ export type PublishMultipleResult = {
published?: PublishedQdnResource[];
};

/**
* Clipboard implementations vary: a copied screenshot can appear in the
* direct FileList, as a file-kind item, or both. Prefer the FileList so the
* browser does not hand the same file to the caller twice; fall back to items
* for screenshot tools that expose no FileList. Text and HTML are excluded so
* ordinary text paste stays with the textarea.
*/
export function getTransferFiles(source: TransferFileSource | null | undefined): File[] {
const directFiles = Array.from(source?.files ?? []);

if (directFiles.length > 0) {
return directFiles;
}

const files: File[] = [];

for (const item of Array.from(source?.items ?? [])) {
if (item.kind !== 'file') {
continue;
}

const file = item.getAsFile();

if (file) {
files.push(file);
}
}

return files;
}

export function getAttachmentService(file: Pick<File, 'type'>): AttachmentService {
const type = file.type.toLowerCase();

Expand Down