🐛(front) Fix send prohibited file types

Block and warn the user that their file type is not being recognized.
This commit is contained in:
Eléonore Voisin
2025-11-07 11:15:09 +01:00
parent 5a3d20f4a9
commit a5bc974e5d
4 changed files with 169 additions and 98 deletions
+1
View File
@@ -10,6 +10,7 @@ and this project adheres to
### Fixed
- 🦺(front) Fix send prohibited file types
- 🐛(front) fix target blank links in chat #103
- 🚑️(posthog) pass str instead of UUID for user PK #134
- ⚡️(web-search) keep running when tool call fails #137
@@ -3,6 +3,7 @@ import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { Icon } from '@/components/Icon';
import { useResponsiveStore } from '@/stores';
export type ToastType = 'success' | 'error' | 'info' | 'warning';
@@ -13,6 +14,8 @@ export interface ToastProps {
icon?: string;
duration?: number;
onClose: (id: string) => void;
actionLabel?: string;
actionHref?: string;
}
const getToastConfig = (type: ToastType) => {
@@ -62,11 +65,14 @@ export const Toast = ({
icon,
duration = 4000,
onClose,
actionLabel,
actionHref,
}: ToastProps) => {
const [isVisible, setIsVisible] = useState(false);
const [isLeaving, setIsLeaving] = useState(false);
const config = getToastConfig(type);
const iconToUse = icon || config.icon;
const { isMobile } = useResponsiveStore();
useEffect(() => {
setIsVisible(true);
@@ -102,7 +108,12 @@ export const Toast = ({
overflow: hidden;
`}
>
<Box $direction="row" $align="center" $gap="12px">
<Box
$direction="row"
$align="center"
$gap="12px"
$justify="space-between"
>
<Icon
iconName={iconToUse}
$variation="600"
@@ -111,16 +122,41 @@ export const Toast = ({
color: ${config.color} !important;
`}
/>
<Text
$weight="500"
$size="14px"
$css={css`
color: ${config.color} !important;
padding: 4px;
`}
<Box
$direction="row"
$align="center"
$gap="12px"
$flex={1}
$justify="space-between"
>
{message}
</Text>
<Text
$weight="500"
$size="14px"
$css={css`
color: ${config.color} !important;
padding: 4px;
`}
>
{message}
</Text>
{actionLabel && actionHref && !isMobile && (
<a
href={actionHref}
target="_blank"
rel="noopener noreferrer"
style={{
color: config.color,
fontWeight: '500',
fontSize: '14px',
textDecoration: 'underline',
whiteSpace: 'nowrap',
}}
>
{actionLabel}
</a>
)}
</Box>
</Box>
</Box>
);
@@ -17,6 +17,8 @@ interface ToastItem {
type: ToastType;
icon?: string;
duration?: number;
actionLabel?: string;
actionHref?: string;
}
interface ToastContextType {
@@ -25,6 +27,7 @@ interface ToastContextType {
message: string,
icon?: string,
duration?: number,
options?: { actionLabel?: string; actionHref?: string },
) => void;
}
@@ -46,9 +49,23 @@ export const ToastProvider = ({ children }: ToastProviderProps) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const showToast = useCallback(
(type: ToastType, message: string, icon?: string, duration = 4000) => {
(
type: ToastType,
message: string,
icon?: string,
duration = 4000,
options?: { actionLabel?: string; actionHref?: string },
) => {
const id = Math.random().toString(36).substr(2, 9);
const newToast: ToastItem = { id, message, type, icon, duration };
const newToast: ToastItem = {
id,
message,
type,
icon,
duration,
actionLabel: options?.actionLabel,
actionHref: options?.actionHref,
};
setToasts((prev) => [newToast, ...prev]);
},
@@ -95,6 +112,8 @@ export const ToastProvider = ({ children }: ToastProviderProps) => {
type={toast.type}
icon={toast.icon}
duration={toast.duration}
actionLabel={toast.actionLabel}
actionHref={toast.actionHref}
onClose={removeToast}
/>
))}
@@ -1,8 +1,9 @@
import { Button } from '@openfun/cunningham-react';
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';
import { useToast } from '@/components/ToastProvider';
import { FeatureFlagState, useConfig } from '@/core';
import { LLMModel } from '@/features/chat/api/useLLMConfiguration';
import { useAnalytics } from '@/libs';
@@ -51,10 +52,10 @@ export const InputChat = ({
isUploadingFiles = false,
}: InputChatProps) => {
const { t } = useTranslation();
const { showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isDragActive, setIsDragActive] = useState(false);
const [isDragRejected, setIsDragRejected] = useState(false);
const { isDesktop, isMobile } = useResponsiveStore();
const [currentSuggestionIndex, setCurrentSuggestionIndex] = useState(0);
const { data: conf } = useConfig();
@@ -63,6 +64,29 @@ export const InputChat = ({
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const isFileAccepted = useCallback(
(file: File): boolean => {
const acceptedConfig = conf?.chat_upload_accept;
if (!acceptedConfig) {
return true;
}
const acceptedTypes = acceptedConfig
.split(',')
.map((type) => type.trim());
return acceptedTypes.some((acceptedType) => {
if (acceptedType.startsWith('.')) {
return file.name.toLowerCase().endsWith(acceptedType.toLowerCase());
}
if (acceptedType.endsWith('/*')) {
const baseType = acceptedType.slice(0, -2);
return file.type.startsWith(baseType);
}
return file.type === acceptedType;
});
},
[conf?.chat_upload_accept],
);
const suggestions = [
t('Ask a question'),
t('Turn this list into bullet points'),
@@ -70,6 +94,20 @@ export const InputChat = ({
t('Find recent news about...'),
];
const showToastError = useCallback(() => {
showToast(
'error',
`${t('File type not supported')}`,
undefined,
undefined,
{
actionLabel: t('Know more'),
actionHref:
'https://docs.numerique.gouv.fr/docs/060b7b70-15aa-4d9a-86f5-2d31c3d693d5/',
},
);
}, [showToast, t]);
useEffect(() => {
if (!conf?.FEATURE_FLAGS) {
setWebSearchEnabled(false);
@@ -136,39 +174,10 @@ export const InputChat = ({
return;
}
const isFileAccepted = (file: File): boolean => {
if (!conf?.chat_upload_accept) {
return true;
}
const acceptedTypes = conf.chat_upload_accept
.split(',')
.map((type) => type.trim());
return acceptedTypes.some((acceptedType) => {
// Extension management
if (acceptedType.startsWith('.')) {
return file.name.toLowerCase().endsWith(acceptedType.toLowerCase());
}
// Wildcard MIME type management (ex: image/*)
if (acceptedType.endsWith('/*')) {
const baseType = acceptedType.slice(0, -2);
return file.type.startsWith(baseType);
}
// Exact MIME type management
return file.type === acceptedType;
});
};
const areAllFilesAccepted = (fileList: FileList): boolean => {
return Array.from(fileList).every((file) => isFileAccepted(file));
};
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer?.types.includes('Files')) {
setIsDragActive(true);
setIsDragRejected(false);
}
};
@@ -177,7 +186,6 @@ export const InputChat = ({
// Only hide when leaving the window completely
if (!e.relatedTarget) {
setIsDragActive(false);
setIsDragRejected(false);
}
};
@@ -187,7 +195,7 @@ export const InputChat = ({
// Check for rejected files during drag over (does not work on Safari)
if (e.dataTransfer?.items) {
const items = Array.from(e.dataTransfer.items);
const hasInvalidFile = items.some((item) => {
items.some((item) => {
if (item.kind === 'file') {
// Check file type
const type = item.type;
@@ -196,15 +204,12 @@ export const InputChat = ({
}
return false;
});
setIsDragRejected(hasInvalidFile);
}
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
setIsDragActive(false);
setIsDragRejected(false);
if (!fileUploadEnabled) {
return;
@@ -212,15 +217,22 @@ export const InputChat = ({
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
// Check if all files are accepted
if (!areAllFilesAccepted(droppedFiles)) {
// Display rejection for 2 seconds (mandatory for Safari)
setIsDragActive(true);
setIsDragRejected(true);
setTimeout(() => {
setIsDragActive(false);
setIsDragRejected(false);
}, 2000);
const acceptedFiles: File[] = [];
const rejectedFiles: string[] = [];
Array.from(droppedFiles).forEach((file) => {
if (isFileAccepted(file)) {
acceptedFiles.push(file);
} else {
rejectedFiles.push(file.name);
}
});
if (rejectedFiles.length > 0) {
showToastError();
}
if (acceptedFiles.length === 0) {
return;
}
@@ -229,7 +241,7 @@ export const InputChat = ({
if (prev) {
Array.from(prev).forEach((f) => dt.items.add(f));
}
Array.from(droppedFiles).forEach((f) => {
acceptedFiles.forEach((f) => {
if (
!Array.from(prev || []).some(
(pf) =>
@@ -257,7 +269,13 @@ export const InputChat = ({
window.removeEventListener('dragover', handleDragOver);
window.removeEventListener('drop', handleDrop);
};
}, [fileUploadEnabled, setFiles, conf?.chat_upload_accept]);
}, [
fileUploadEnabled,
setFiles,
showToastError,
conf?.chat_upload_accept,
isFileAccepted,
]);
const isInputDisabled = status !== 'ready' || isUploadingFiles;
@@ -365,48 +383,22 @@ export const InputChat = ({
top: -1px; left: -1px;
border-radius: 12px;
z-index: 1001;
background-color: ${isDragRejected ? '#FFE8E8' : '#EDF0FF'};
background-color: #EDF0FF;
width: 100%;
height: 100%;
outline: 2px solid ${isDragRejected ? '#FF6B6B' : '#90A7FF'};
box-shadow: 0 0 64px 0 ${isDragRejected ? 'rgba(255, 107, 107, 0.25)' : 'rgba(62, 93, 231, 0.25)'};
outline: 2px solid #90A7FF;
box-shadow: 0 0 64px 0 rgba(62, 93, 231, 0.25);
`}
>
{isDragRejected ? (
<>
<Text $css="font-size: 48px;">🚫</Text>
<Box>
<Text $weight="700" $color="#C92A2A">
{t('File type not supported (yet)')}
</Text>
<Text $weight="400" $color="#C92A2A">
{t(
'We currently support only specific file types...',
)}
</Text>
<Text $weight="400" $color="#C92A2A">
{t(
'Use the "{{attach_file_btn}}" button to have a better view.',
{ attach_file_btn: t('Add attach file') },
)}
</Text>
</Box>
</>
) : (
<>
<FilesIcon />
<Box>
<Text $weight="700" $color="#223E9E">
{t('Add file')}
</Text>
<Text $weight="400" $color="#223E9E">
{t(
'To add a file to the conversation, drop it here.',
)}
</Text>
</Box>
</>
)}
<FilesIcon />
<Box>
<Text $weight="700" $color="#223E9E">
{t('Add file')}
</Text>
<Text $weight="400" $color="#223E9E">
{t('To add a file to the conversation, drop it here.')}
</Text>
</Box>
</Box>
)}
<textarea
@@ -507,12 +499,33 @@ export const InputChat = ({
if (!fileList) {
return;
}
const acceptedFiles: File[] = [];
const rejectedFiles: string[] = [];
Array.from(fileList).forEach((file) => {
if (isFileAccepted(file)) {
acceptedFiles.push(file);
} else {
rejectedFiles.push(file.name);
}
});
if (rejectedFiles.length > 0) {
showToastError();
}
if (acceptedFiles.length === 0) {
e.target.value = '';
return;
}
setFiles((prev) => {
const dt = new DataTransfer();
if (prev) {
Array.from(prev).forEach((f: File) => dt.items.add(f));
}
Array.from(fileList).forEach((f: File) => {
acceptedFiles.forEach((f: File) => {
if (
!Array.from(prev || []).some(
(pf) =>
@@ -526,6 +539,8 @@ export const InputChat = ({
});
return dt.files;
});
e.target.value = '';
}}
/>
{/*Aperçu des fichiers*/}