diff --git a/.changeset/wicked-drinks-think.md b/.changeset/wicked-drinks-think.md new file mode 100644 index 0000000000000..096d85f1b933e --- /dev/null +++ b/.changeset/wicked-drinks-think.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Adds alternative text field to image uploads to improve accessibility diff --git a/apps/meteor/client/components/ImageGallery/ImageGallery.tsx b/apps/meteor/client/components/ImageGallery/ImageGallery.tsx index f1220e0dc7892..0804e6c79e3d4 100644 --- a/apps/meteor/client/components/ImageGallery/ImageGallery.tsx +++ b/apps/meteor/client/components/ImageGallery/ImageGallery.tsx @@ -192,14 +192,20 @@ export const ImageGallery = ({ images, onClose, loadMore }: { images: IUpload[]; onReachBeginning={loadMore} initialSlide={images.length - 1} > - {[...images].reverse().map(({ _id, path, url }) => ( + {[...images].reverse().map(({ _id, path, url, description }) => (
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events */} - + {description
diff --git a/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx b/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx index 89da067a16135..2776c1ea81d81 100644 --- a/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx @@ -96,7 +96,9 @@ const DefaultAttachment = (attachment: DefaultAttachmentProps): ReactElement => })} /> )} - {attachment.image_url && } + {attachment.image_url && ( + + )} {/* DEPRECATED */} {isActionAttachment(attachment) && } diff --git a/apps/meteor/client/components/message/content/attachments/file/ImageAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/ImageAttachment.tsx index c59c081d1468c..b1410b6bbf895 100644 --- a/apps/meteor/client/components/message/content/attachments/file/ImageAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/ImageAttachment.tsx @@ -15,6 +15,7 @@ const ImageAttachment = ({ width: 368, height: 368, }, + description, title_link: link, title_link_download: hasDownload, collapsed, @@ -33,6 +34,7 @@ const ImageAttachment = ({ src={getURL(url)} previewUrl={`data:image/png;base64,${imagePreview}`} id={id} + alt={description} /> diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentImage.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentImage.tsx index 072602cc15d5f..572fc622cda9c 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentImage.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentImage.tsx @@ -15,6 +15,7 @@ type AttachmentImageProps = { id: string | undefined; width: number; height: number; + alt?: string; } & ({ loadImage: true } | { loadImage: false; setLoadImage: () => void }); const getDimensions = ( @@ -36,7 +37,7 @@ const getDimensions = ( return { width, height, ratio: (height / width) * 100 }; }; -const AttachmentImage = ({ id, previewUrl, dataSrc, loadImage = true, setLoadImage, src, ...size }: AttachmentImageProps) => { +const AttachmentImage = ({ id, previewUrl, dataSrc, loadImage = true, setLoadImage, src, alt = '', ...size }: AttachmentImageProps) => { const limits = useAttachmentDimensions(); const [error, setError] = useState(false); @@ -82,7 +83,7 @@ const AttachmentImage = ({ id, previewUrl, dataSrc, loadImage = true, setLoadIma className='gallery-item' data-src={dataSrc || src} src={src} - alt='' + alt={alt} width={dimensions.width} height={dimensions.height} loading='lazy' diff --git a/apps/meteor/client/lib/chats/ChatAPI.ts b/apps/meteor/client/lib/chats/ChatAPI.ts index 633b819b355ad..db2e61ad640af 100644 --- a/apps/meteor/client/lib/chats/ChatAPI.ts +++ b/apps/meteor/client/lib/chats/ChatAPI.ts @@ -122,6 +122,7 @@ export type UploadsAPI = { cancel(id: Upload['id']): void; removeUpload(id: Upload['id']): void; editUploadFileName: (id: Upload['id'], fileName: string) => void; + editUploadDescription: (id: Upload['id'], description: string) => void; send(file: File, encrypted?: never): Promise; send(file: File, encrypted: EncryptedFileUploadContent): Promise; }; diff --git a/apps/meteor/client/lib/chats/Upload.ts b/apps/meteor/client/lib/chats/Upload.ts index 798d955032049..6916d90b9101c 100644 --- a/apps/meteor/client/lib/chats/Upload.ts +++ b/apps/meteor/client/lib/chats/Upload.ts @@ -6,6 +6,7 @@ export type NonEncryptedUpload = { readonly url?: string; readonly percentage: number; readonly error?: Error; + readonly description?: string; }; export type EncryptedUpload = NonEncryptedUpload & { diff --git a/apps/meteor/client/lib/chats/flows/processMessageUploads.ts b/apps/meteor/client/lib/chats/flows/processMessageUploads.ts index 2736bd9fbf8d5..10ac33f930e52 100644 --- a/apps/meteor/client/lib/chats/flows/processMessageUploads.ts +++ b/apps/meteor/client/lib/chats/flows/processMessageUploads.ts @@ -59,6 +59,7 @@ const getAttachmentForFile = async (fileToUpload: EncryptedUpload): Promise & { fileName?: string; fileContent?: IE2EEMessage['content'] }; + composedMessage: AtLeast & { + fileName?: string; + fileContent?: IE2EEMessage['content']; + description?: string; + }; })[] = []; const validFiles = filesToUpload.filter((file) => !file.error); @@ -118,7 +123,7 @@ async function continueSendingMessage(store: UploadsAPI, message: IMessage) { confirmFilesQueue.push({ _id: upload.id, name: upload.file.name, - composedMessage: { tmid, msg: currentMsg, fileName: upload.file.name }, + composedMessage: { tmid, msg: currentMsg, fileName: upload.file.name, description: upload.description }, }); continue; } diff --git a/apps/meteor/client/lib/chats/uploads.ts b/apps/meteor/client/lib/chats/uploads.ts index d243389333a9a..b39542618c24a 100644 --- a/apps/meteor/client/lib/chats/uploads.ts +++ b/apps/meteor/client/lib/chats/uploads.ts @@ -63,6 +63,24 @@ class UploadsStore extends Emitter<{ update: void; [x: `cancelling-${Upload['id' } }; + editUploadDescription = (uploadId: Upload['id'], description: string) => { + this.set( + this.uploads.map((upload) => { + if (upload.id !== uploadId) { + return upload; + } + + return { + ...upload, + description, + ...(isEncryptedUpload(upload) && { + metadataForEncryption: { ...upload.metadataForEncryption, description }, + }), + }; + }), + ); + }; + editUploadFileName = (uploadId: Upload['id'], fileName: Upload['file']['name']) => { try { this.set( diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileItem.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileItem.tsx index 82f9b9ff597d2..fb24fcaccac29 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileItem.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileItem.tsx @@ -9,18 +9,20 @@ import { getMimeType } from '../../../../../app/utils/lib/mimeTypes'; import { usePreventPropagation } from '../../../../hooks/usePreventPropagation'; import type { Upload } from '../../../../lib/chats/Upload'; import { formatBytes } from '../../../../lib/utils/formatBytes'; +import { useChat } from '../../contexts/ChatContext'; import FileUploadModal from '../../modals/FileUploadModal'; type MessageComposerFileItemProps = { upload: Upload; onRemove: (id: string) => void; - onEdit: (id: Upload['id'], fileName: string) => void; + onEdit: (id: Upload['id'], fileName: string, description?: string) => void; onCancel: (id: Upload['id']) => void; disabled: boolean; }; const MessageComposerFileItem = ({ upload, onRemove, onEdit, onCancel, disabled, ...props }: MessageComposerFileItemProps) => { const { t } = useTranslation(); + const chat = useChat(); const [isActive, setIsActive] = useState(false); const setModal = useSetModal(); @@ -35,11 +37,13 @@ const MessageComposerFileItem = ({ upload, onRemove, onEdit, onCancel, disabled, setModal( { - onEdit(upload.id, name); + onSubmit={(name, description) => { + onEdit(upload.id, name, description); setModal(null); + chat?.composer?.focus(); }} fileName={upload.file.name} + fileDescription={upload.description} file={upload.file} onClose={() => setModal(null)} />, diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageComposerFiles.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFiles.tsx index c197b935f3a6c..84f0056513d77 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageComposerFiles.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFiles.tsx @@ -1,13 +1,25 @@ import { MessageComposerFileGroup } from '@rocket.chat/ui-composer'; +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import MessageComposerFileItem from './MessageComposerFileItem'; +import type { Upload } from '../../../../lib/chats/Upload'; import { useFileUpload } from '../../body/hooks/useFileUpload'; const MessageComposerFiles = () => { const { t } = useTranslation(); const { uploads, uploadsStore, isProcessingUploads, hasUploads } = useFileUpload(); + const handleEdit = useCallback( + (id: Upload['id'], fileName: string, description?: string) => { + uploadsStore?.editUploadFileName(id, fileName); + if (description !== undefined) { + uploadsStore?.editUploadDescription(id, description); + } + }, + [uploadsStore], + ); + if (!uploadsStore || !hasUploads) { return null; } @@ -19,7 +31,7 @@ const MessageComposerFiles = () => { key={upload.id} upload={upload} onRemove={uploadsStore.removeUpload} - onEdit={uploadsStore.editUploadFileName} + onEdit={handleEdit} onCancel={uploadsStore.cancel} disabled={isProcessingUploads} /> diff --git a/apps/meteor/client/views/room/contextualBar/RoomFiles/components/FileItem.tsx b/apps/meteor/client/views/room/contextualBar/RoomFiles/components/FileItem.tsx index f324402325525..bcaa6fe8aa784 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomFiles/components/FileItem.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomFiles/components/FileItem.tsx @@ -16,7 +16,7 @@ type FileItemProps = { const FileItem = ({ rid, fileData, onClickDelete }: FileItemProps) => { const format = useFormatDateAndTime(); - const { _id, path, name, uploadedAt, type, typeGroup, user } = fileData; + const { _id, path, name, uploadedAt, type, typeGroup, user, description } = fileData; const encryptedAnchorProps = useDownloadFromServiceWorker(path || '', name); const normalizedUsername = user?.username ? normalizeUsername(user.username) : undefined; @@ -24,7 +24,7 @@ const FileItem = ({ rid, fileData, onClickDelete }: FileItemProps) => { return ( <> {typeGroup === 'image' ? ( - + ) : ( { +const ImageItem = ({ id, url, name, timestamp, username, alt = '' }: ImageItemProps) => { return ( {url && ( - + )} diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx index a7f9e8ace84fe..a0923d59effa5 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx @@ -40,13 +40,14 @@ const shouldShowMediaPreview = (file: File, fileType: FilePreviewType | undefine type FilePreviewProps = { file: File; + description?: string; }; -const FilePreview = ({ file }: FilePreviewProps): ReactElement => { +const FilePreview = ({ file, description }: FilePreviewProps): ReactElement => { const fileType = getFileType(file.type); if (shouldShowMediaPreview(file, fileType)) { - return ; + return ; } return ; diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx index 1de7bac04dc47..c8b598e9a1c69 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx @@ -1,12 +1,6 @@ import { Modal, Box, - Field, - FieldGroup, - FieldLabel, - FieldRow, - FieldError, - TextInput, Button, ModalHeader, ModalTitle, @@ -15,10 +9,20 @@ import { ModalFooter, ModalFooterControllers, } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; +import { + TextInput, + TextAreaInput, + Field, + FieldError, + FieldRow, + FieldLabel, + FieldGroup, + FieldDescription, +} from '@rocket.chat/fuselage-forms'; import type { ReactElement, ComponentProps } from 'react'; import { memo, useCallback, useId } from 'react'; -import { useForm } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import FilePreview from './FilePreview'; import { fileUploadIsValidContentType } from '../../../../../app/utils/client/restrictions'; @@ -26,21 +30,22 @@ import { getMimeTypeFromFileName } from '../../../../../app/utils/lib/mimeTypes' type FileUploadModalProps = { onClose: () => void; - onSubmit: (name: string) => void; + onSubmit: (name: string, description?: string) => void; file: File; fileName: string; + fileDescription?: string; }; -const FileUploadModal = ({ onClose, file, fileName, onSubmit }: FileUploadModalProps): ReactElement => { - const t = useTranslation(); +const FileUploadModal = ({ onClose, file, fileName, fileDescription = '', onSubmit }: FileUploadModalProps): ReactElement => { + const { t } = useTranslation(); const fileUploadFormId = useId(); - const fileNameField = useId(); + const isImage = file.type.startsWith('image/'); const { - register, + control, handleSubmit, formState: { errors, isDirty, isSubmitting }, - } = useForm({ mode: 'onBlur', defaultValues: { name: fileName } }); + } = useForm({ mode: 'onBlur', defaultValues: { name: fileName, description: fileDescription } }); const validateFileName = useCallback( (fieldValue: string) => { @@ -58,40 +63,50 @@ const FileUploadModal = ({ onClose, file, fileName, onSubmit }: FileUploadModalP ) => ( - (!isDirty ? onClose() : onSubmit(name)))} {...props} /> + + !isDirty ? onClose() : onSubmit(name.trim(), description?.trim() || undefined), + )} + {...props} + /> )} > {t('FileUpload')} - + - + - {t('Upload_file_name')} + {t('Upload_file_name')} - } /> - {errors.name && ( - - {errors.name.message} - - )} + {errors.name && {errors.name.message}} + {isImage && ( + + {t('Alternative_text')} + {t('Alt_text_description')} + + } /> + + + )} @@ -99,7 +114,7 @@ const FileUploadModal = ({ onClose, file, fileName, onSubmit }: FileUploadModalP - diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/ImagePreview.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/ImagePreview.tsx index 927f9bf1c868d..bbb0f8b857327 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/ImagePreview.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/ImagePreview.tsx @@ -8,9 +8,10 @@ import PreviewSkeleton from './PreviewSkeleton'; type ImagePreviewProps = { url: string; file: File; + alt?: string; }; -const ImagePreview = ({ url, file }: ImagePreviewProps): ReactElement => { +const ImagePreview = ({ url, file, alt = '' }: ImagePreviewProps): ReactElement => { const [error, setError] = useState(false); const [loading, setLoading] = useState(true); @@ -30,6 +31,7 @@ const ImagePreview = ({ url, file }: ImagePreviewProps): ReactElement => { { +const MediaPreview = ({ file, fileType, description }: MediaPreviewProps): ReactElement => { const [loaded, url] = useFileAsDataURL(file); const { t } = useTranslation(); @@ -54,7 +55,7 @@ const MediaPreview = ({ file, fileType }: MediaPreviewProps): ReactElement => { } if (fileType === FilePreviewType.IMAGE) { - return ; + return ; } if (fileType === FilePreviewType.VIDEO) { diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/__snapshots__/FileUploadModal.spec.tsx.snap b/apps/meteor/client/views/room/modals/FileUploadModal/__snapshots__/FileUploadModal.spec.tsx.snap index 87c8b68a6d66d..c42e6ebe31a16 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/__snapshots__/FileUploadModal.spec.tsx.snap +++ b/apps/meteor/client/views/room/modals/FileUploadModal/__snapshots__/FileUploadModal.spec.tsx.snap @@ -31,6 +31,7 @@ exports[`renders Default without crashing 1`] = `
@@ -113,6 +115,7 @@ exports[`renders Default without crashing 1`] = `