🐛(front) Fix send prohibited file types
Block and warn the user that their file type is not being recognized.
This commit is contained in:
@@ -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*/}
|
||||
|
||||
Reference in New Issue
Block a user