diff --git a/CHANGELOG.md b/CHANGELOG.md index cd69bfd..b87e4fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/frontend/apps/conversations/src/components/CustomToast.tsx b/src/frontend/apps/conversations/src/components/CustomToast.tsx index 81b9e7f..7d0f9fd 100644 --- a/src/frontend/apps/conversations/src/components/CustomToast.tsx +++ b/src/frontend/apps/conversations/src/components/CustomToast.tsx @@ -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; `} > - + - - {message} - + + {message} + + + {actionLabel && actionHref && !isMobile && ( + + {actionLabel} + + )} + ); diff --git a/src/frontend/apps/conversations/src/components/ToastProvider.tsx b/src/frontend/apps/conversations/src/components/ToastProvider.tsx index 4dc152e..3ff584d 100644 --- a/src/frontend/apps/conversations/src/components/ToastProvider.tsx +++ b/src/frontend/apps/conversations/src/components/ToastProvider.tsx @@ -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([]); 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} /> ))} diff --git a/src/frontend/apps/conversations/src/features/chat/components/InputChat.tsx b/src/frontend/apps/conversations/src/features/chat/components/InputChat.tsx index fc44b56..6ff134e 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/InputChat.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/InputChat.tsx @@ -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(null); const textareaRef = useRef(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 ? ( - <> - 🚫 - - - {t('File type not supported (yet)')} - - - {t( - 'We currently support only specific file types...', - )} - - - {t( - 'Use the "{{attach_file_btn}}" button to have a better view.', - { attach_file_btn: t('Add attach file') }, - )} - - - - ) : ( - <> - - - - {t('Add file')} - - - {t( - 'To add a file to the conversation, drop it here.', - )} - - - - )} + + + + {t('Add file')} + + + {t('To add a file to the conversation, drop it here.')} + + )}