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
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { act, render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import { ApI18nProvider } from '../../../i18n';
import { type AutopilotChatConfiguration, AutopilotChatService } from '../../service';
import { AutopilotAttachmentsProvider, useAttachments } from '../providers/attachments-provider';
import { AutopilotChatServiceProvider } from '../providers/chat-service.provider';
import { AutopilotChatStateProvider } from '../providers/chat-state-provider';
import { AutopilotErrorProvider, useError } from '../providers/error-provider';
import {
type ChatAttachmentRejection,
checkAttachmentFiles,
getAcceptedFileTypes,
useAttachmentIntake,
} from './use-attachment-intake';

let instanceCounter = 0;
let take: ReturnType<typeof useAttachmentIntake> | undefined;
let state: { names: string[]; error?: string } = { names: [] };

function Probe() {
take = useAttachmentIntake();
const { attachments } = useAttachments();
const { error } = useError();
state = { names: attachments.map(({ name }) => name), error: error?.message };
return null;
}

function setup(allowedAttachments: AutopilotChatConfiguration['allowedAttachments']) {
const service = AutopilotChatService.Instantiate({
instanceName: `intake-${++instanceCounter}`,
config: { allowedAttachments },
});
render(
<AutopilotChatServiceProvider chatServiceInstance={service}>
<ApI18nProvider component="chat">
<AutopilotChatStateProvider>
<AutopilotErrorProvider>
<AutopilotAttachmentsProvider>
<Probe />
</AutopilotAttachmentsProvider>
</AutopilotErrorProvider>
</AutopilotChatStateProvider>
</ApI18nProvider>
</AutopilotChatServiceProvider>
);
}

const file = (name: string, size = 10) =>
new File(['x'.repeat(size)], name, { type: 'text/plain', lastModified: 1 });
const reject = (name: string, reason: ChatAttachmentRejection['reason']) => ({
file: file(name),
reason,
});
const types = { 'text/plain': ['.txt'] };

describe('useAttachmentIntake', () => {
it('accepts every MIME type and extension of the allowed types', () => {
expect(getAcceptedFileTypes({ 'text/plain': ['.txt'], 'image/png': ['.png'] })).toEqual([
'text/plain',
'image/png',
'.txt',
'.png',
]);
});

it('checks picked files by MIME type or extension, size, then the count left', () => {
const allowed = { multiple: true, types, maxSize: 15, maxCount: 3 };
const picked = [
new File(['x'], 'a.TXT', { type: '' }),
new File(['x'], 'b.bin', { type: 'text/plain' }),
new File(['x'], 'c.exe', { type: 'application/x-msdownload' }),
new File(['x'.repeat(20)], 'd.txt', { type: 'text/plain' }),
new File(['x'], 'e.txt', { type: 'text/plain' }),
];

const { accepted, rejected } = checkAttachmentFiles(picked, allowed, 1);

expect(accepted.map(({ name }) => name)).toEqual(['a.TXT', 'b.bin']);
expect(rejected.map(({ file, reason }) => [file.name, reason])).toEqual([
['c.exe', 'type'],
['d.txt', 'size'],
['e.txt', 'count'],
]);
});

it('attaches accepted files and names the rejected ones', async () => {
setup({ multiple: true, types, maxSize: 1024, maxCount: 5 });

await act(() => take?.([file('a.txt')], [reject('big.txt', 'size'), reject('x.exe', 'type')]));

expect(state.names).toEqual(['a.txt']);
expect(state.error).toMatch(/big\.txt/);
expect(state.error).toMatch(/x\.exe/);
});

it('rejects the whole batch when there are too many files', async () => {
setup({ multiple: true, types, maxSize: 1024, maxCount: 1 });

await act(() => take?.([file('a.txt')], [reject('b.txt', 'count')]));

expect(state.names).toEqual([]);
expect(state.error).toBeTruthy();
});

it('allows one file at a time when multiple is off', async () => {
setup({ multiple: false, types, maxSize: 1024 });

await act(() => take?.([file('a.txt'), file('b.txt')], []));
expect(state.names).toEqual([]);
expect(state.error).toBeTruthy();

await act(() => take?.([file('a.txt')], []));
expect(state.names).toEqual(['a.txt']);
expect(state.error).toBeUndefined();
});
});
169 changes: 169 additions & 0 deletions packages/apollo-react/src/chat/headless/hooks/use-attachment-intake.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import React from 'react';

import type { AutopilotChatAllowedAttachments, AutopilotChatFileInfo } from '../../service';
import { useAttachments } from '../providers/attachments-provider';
import { useChatState } from '../providers/chat-state-provider';
import { useError } from '../providers/error-provider';
import { parseFiles } from '../utils/file-reader';

/**
* Why a dropped, pasted or picked file was turned away, whatever library checked it. `other`
* counts as a rejection but has no message of its own.
*/
export type ChatAttachmentRejectionReason = 'type' | 'size' | 'count' | 'other';

export interface ChatAttachmentRejection {
file: File;
reason: ChatAttachmentRejectionReason;
}

/**
* The `accept` entries for `allowedAttachments.types` (MIME type to extensions): every MIME type
* and every extension, so a file a browser mislabels still matches by its extension.
*/
export function getAcceptedFileTypes(types: AutopilotChatAllowedAttachments['types']): string[] {
return [...Object.keys(types), ...Object.values(types).flat()];
}

/**
* Checks picked files against `allowedAttachments` the way the drop targets do: a type matches by
* MIME type or extension, then size, then the count left after `existingCount`.
*/
export function checkAttachmentFiles(
files: readonly File[],
allowed: AutopilotChatAllowedAttachments,
existingCount = 0
): { accepted: File[]; rejected: ChatAttachmentRejection[] } {
const accept = getAcceptedFileTypes(allowed.types).map((entry) => entry.toLowerCase());
const remaining =
allowed.maxCount === undefined ? Number.POSITIVE_INFINITY : allowed.maxCount - existingCount;
const accepted: File[] = [];
const rejected: ChatAttachmentRejection[] = [];

for (const file of files) {
const name = file.name.toLowerCase();
const typeAccepted =
accept.length === 0 ||
accept.some((entry) =>
entry.startsWith('.') ? name.endsWith(entry) : entry === file.type.toLowerCase()
);
if (!typeAccepted) {
rejected.push({ file, reason: 'type' });
} else if (file.size > allowed.maxSize) {
rejected.push({ file, reason: 'size' });
} else if (accepted.length >= remaining) {
rejected.push({ file, reason: 'count' });
} else {
accepted.push(file);
}
}
return { accepted, rejected };
}

/**
* Takes files checked against `allowedAttachments` into the draft, as Material's dropzone does:
* a second file when only one is allowed, or more than `maxCount`, rejects the whole batch with
* one message; oversized and wrongly typed files are named in the error and the rest are
* attached. Clears the error when nothing was rejected.
*/
export function useAttachmentIntake() {
const { _ } = useLingui();
const { addAttachments, attachments } = useAttachments();
const { setError } = useError();
const { allowedAttachments } = useChatState();
const attachmentsCount = React.useRef(attachments.length);
attachmentsCount.current = attachments.length;

const handleRejections = React.useCallback(
(parsedFiles: AutopilotChatFileInfo[], rejections: ChatAttachmentRejection[]) => {
if (
!allowedAttachments.multiple &&
(parsedFiles.length > 1 || attachmentsCount.current > 0 || rejections.length > 1)
) {
setError(
_(
msg({
id: 'autopilot-chat.error.multiple-files',
message: `Only one file can be attached at a time`,
})
)
);
return true;
}

if (rejections.some(({ reason }) => reason === 'count')) {
const maxCount = allowedAttachments.maxCount;
setError(
_(
msg({
id: 'autopilot-chat.error.too-many-files',
message: `Maximum ${maxCount} files allowed`,
})
)
);
return true;
}

const errorMessages: string[] = [];
for (const { reason, file } of rejections) {
const fileName = file.name;
if (reason === 'size') {
const fileSizeMB = Math.round(file.size / 1024 / 1024);
const maxSize = allowedAttachments.maxSize / 1024 / 1024;
errorMessages.push(
_(
msg({
id: 'autopilot-chat.error.file-too-large',
message: `${fileName} (${fileSizeMB}MB) exceeds maximum size of ${maxSize}MB`,
})
)
);
} else if (reason === 'type') {
errorMessages.push(
_(
msg({
id: 'autopilot-chat.error.file-invalid-type',
message: `${fileName} has an invalid file type`,
})
)
);
}
}
if (errorMessages.length > 0) {
setError(errorMessages.join('\n'));
}
return false;
},
[
_,
setError,
allowedAttachments.maxCount,
allowedAttachments.maxSize,
allowedAttachments.multiple,
]
);

return React.useCallback(
async (accepted: File[], rejected: ChatAttachmentRejection[]) => {
try {
const parsedFiles = await parseFiles(accepted);
if (handleRejections(parsedFiles, rejected)) {
return;
}
if (rejected.length === 0) {
setError(undefined);
}
if (allowedAttachments.multiple) {
addAttachments(parsedFiles);
} else if (parsedFiles.length === 1) {
addAttachments([parsedFiles[0] as AutopilotChatFileInfo]);
}
} catch (error) {
setError(error as string);
}
},
[addAttachments, setError, allowedAttachments.multiple, handleRejections]
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import React from 'react';

import type { AutopilotChatAllowedAttachments } from '../../service';

/**
* The attach limits as both renderers list them by the attach button: the most files (when more
* than one is allowed), the largest size, and the allowed extensions.
*/
export function useAttachmentLimits(allowed: AutopilotChatAllowedAttachments): string[] {
const { _ } = useLingui();

return React.useMemo(() => {
const lines: string[] = [];
const maxCount = allowed.maxCount;
const maxSize = allowed.maxSize / 1024 / 1024;
const fileTypes = Object.values(allowed.types).flat().join(', ');
if (maxCount && maxCount > 1 && allowed.multiple) {
lines.push(
_(
msg({
id: 'autopilot-chat.input.actions.attachments.max-count',
message: `Maximum ${maxCount} files`,
})
)
);
}
lines.push(
_(
msg({
id: 'autopilot-chat.input.actions.attachments.max-size',
message: `Maximum ${maxSize}MB per file`,
})
),
_(
msg({
id: 'autopilot-chat.input.actions.attachments.allowed-types',
message: `Allowed types: ${fileTypes}`,
})
)
);
return lines;
}, [_, allowed]);
}
Loading
Loading