From b20b69066ece85dd970eb7ec3a02670ca8eea307 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 23 Mar 2026 17:24:48 +0200 Subject: [PATCH] feat(attachments): expand file support and enhance attachment handling - Added support for additional attachment types: PDF and plain text. - Updated the TeamProvisioningService to handle new attachment types with appropriate content blocks. - Enhanced AttachmentDisplay and AttachmentPreview components to differentiate between image and non-image files. - Modified DropZoneOverlay and SendMessageDialog to reflect changes in file handling and messaging. - Improved user experience by allowing file previews for non-image attachments and updating error messages accordingly. - Refactored attachment validation logic to categorize unsupported files and handle them gracefully. --- src/main/ipc/teams.ts | 9 +- .../services/team/TeamProvisioningService.ts | 42 ++++- .../team/attachments/AttachmentDisplay.tsx | 61 ++++-- .../attachments/AttachmentPreviewItem.tsx | 20 +- .../attachments/AttachmentPreviewList.tsx | 28 ++- .../team/attachments/DropZoneOverlay.tsx | 10 +- .../team/dialogs/SendMessageDialog.tsx | 47 +++-- .../team/dialogs/TaskCommentInput.tsx | 106 ++++++----- .../team/messages/MessageComposer.tsx | 49 +++-- src/renderer/hooks/useAttachments.ts | 51 +++-- src/renderer/hooks/useComposerDraft.ts | 50 +++-- src/renderer/utils/attachmentUtils.ts | 13 +- src/shared/constants/attachments.ts | 175 ++++++++++++++++++ src/shared/constants/index.ts | 1 + 14 files changed, 488 insertions(+), 174 deletions(-) create mode 100644 src/shared/constants/attachments.ts diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index a0d0eedc..f28bb017 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -270,7 +270,14 @@ const attachmentStore = new TeamAttachmentStore(); const taskAttachmentStore = new TeamTaskAttachmentStore(); const teamMetaStore = new TeamMetaStore(); -const ALLOWED_ATTACHMENT_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); +const ALLOWED_ATTACHMENT_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + 'application/pdf', + 'text/plain', +]); const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file /** diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 4686214d..dcd1b195 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -3601,7 +3601,7 @@ export class TeamProvisioningService { async sendMessageToTeam( teamName: string, message: string, - attachments?: { data: string; mimeType: string }[] + attachments?: { data: string; mimeType: string; filename?: string }[] ): Promise { const runId = this.getAliveRunId(teamName); if (!runId) { @@ -3615,14 +3615,38 @@ export class TeamProvisioningService { const contentBlocks: Record[] = [{ type: 'text', text: message }]; if (attachments?.length) { for (const att of attachments) { - contentBlocks.push({ - type: 'image', - source: { - type: 'base64', - media_type: att.mimeType, - data: att.data, - }, - }); + if (att.mimeType === 'application/pdf') { + // PDF → document block with base64 source + contentBlocks.push({ + type: 'document', + source: { + type: 'base64', + media_type: 'application/pdf', + data: att.data, + }, + }); + } else if (att.mimeType === 'text/plain') { + // Text file → document block with text source (decode base64 → UTF-8) + contentBlocks.push({ + type: 'document', + source: { + type: 'text', + media_type: 'text/plain', + data: Buffer.from(att.data, 'base64').toString('utf-8'), + }, + title: att.filename, + }); + } else { + // Image (default) → image block + contentBlocks.push({ + type: 'image', + source: { + type: 'base64', + media_type: att.mimeType, + data: att.data, + }, + }); + } } } diff --git a/src/renderer/components/team/attachments/AttachmentDisplay.tsx b/src/renderer/components/team/attachments/AttachmentDisplay.tsx index cd6291c8..33ff6d87 100644 --- a/src/renderer/components/team/attachments/AttachmentDisplay.tsx +++ b/src/renderer/components/team/attachments/AttachmentDisplay.tsx @@ -2,6 +2,10 @@ import { useEffect, useState } from 'react'; import { Loader2 } from 'lucide-react'; +import { isImageMime } from '@renderer/utils/attachmentUtils'; + +import { FileIcon } from '@renderer/components/team/editor/FileIcon'; + import { AttachmentThumbnail } from './AttachmentThumbnail'; import { ImageLightbox } from './ImageLightbox'; @@ -66,30 +70,61 @@ export const AttachmentDisplay = ({ .map((meta) => { const data = dataById.get(meta.id); if (!data) return null; - return { meta, dataUrl: `data:${data.mimeType};base64,${data.data}` }; + const isImage = isImageMime(data.mimeType); + return { + meta, + dataUrl: isImage ? `data:${data.mimeType};base64,${data.data}` : undefined, + isImage, + }; }) - .filter(Boolean) as { meta: AttachmentMeta; dataUrl: string }[]; + .filter(Boolean) as { meta: AttachmentMeta; dataUrl: string | undefined; isImage: boolean }[]; if (items.length === 0) return null; + // Build lightbox slides for images only, with visual→lightbox index mapping + const imageSlides: { src: string; alt: string }[] = []; + const visualToLightbox = new Map(); + items.forEach((item, i) => { + if (item.isImage && item.dataUrl) { + visualToLightbox.set(i, imageSlides.length); + imageSlides.push({ src: item.dataUrl, alt: item.meta.filename }); + } + }); + return ( <>
- {items.map((item, i) => ( - setLightboxIndex(i)} - /> - ))} + {items.map((item, i) => + item.isImage && item.dataUrl ? ( + setLightboxIndex(visualToLightbox.get(i)!) + : undefined + } + /> + ) : ( +
+ + + {item.meta.filename} + +
+ ) + )}
- {lightboxIndex !== null && items[lightboxIndex] ? ( + {lightboxIndex !== null && imageSlides[lightboxIndex] ? ( setLightboxIndex(null)} - slides={items.map((item) => ({ src: item.dataUrl, alt: item.meta.filename }))} + slides={imageSlides} index={lightboxIndex} /> ) : null} diff --git a/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx b/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx index 345e7a1c..d81981f7 100644 --- a/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx +++ b/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx @@ -1,6 +1,8 @@ -import { formatFileSize } from '@renderer/utils/attachmentUtils'; +import { formatFileSize, isImageMime } from '@renderer/utils/attachmentUtils'; import { Ban, X } from 'lucide-react'; +import { FileIcon } from '@renderer/components/team/editor/FileIcon'; + import { AttachmentThumbnail } from './AttachmentThumbnail'; import type { AttachmentPayload } from '@shared/types'; @@ -18,7 +20,8 @@ export const AttachmentPreviewItem = ({ onPreview, disabled, }: AttachmentPreviewItemProps): React.JSX.Element => { - const dataUrl = `data:${attachment.mimeType};base64,${attachment.data}`; + const isImage = isImageMime(attachment.mimeType); + const dataUrl = isImage ? `data:${attachment.mimeType};base64,${attachment.data}` : undefined; return (
@@ -27,7 +30,18 @@ export const AttachmentPreviewItem = ({
) : null} - + {isImage && dataUrl ? ( + + ) : ( +
+ +
+ )}
{attachment.filename} diff --git a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx index fbc3c402..873c4488 100644 --- a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx +++ b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { AlertCircle, X } from 'lucide-react'; +import { isImageMime } from '@renderer/utils/attachmentUtils'; + import { AttachmentPreviewItem } from './AttachmentPreviewItem'; import { ImageLightbox } from './ImageLightbox'; @@ -107,10 +109,18 @@ export const AttachmentPreviewList = ({ if (visibleAttachments.length === 0 && exitingIds.size === 0 && !error) return null; - const lightboxSlides = visibleAttachments.map((att) => ({ - src: `data:${att.mimeType};base64,${att.data}`, - alt: att.filename, - })); + // Build lightbox slides for images only, with visual→lightbox index mapping + const imageSlides: { src: string; alt: string }[] = []; + const visualToLightbox = new Map(); + visibleAttachments.forEach((att, i) => { + if (isImageMime(att.mimeType)) { + visualToLightbox.set(i, imageSlides.length); + imageSlides.push({ + src: `data:${att.mimeType};base64,${att.data}`, + alt: att.filename, + }); + } + }); return (
@@ -135,7 +145,11 @@ export const AttachmentPreviewList = ({ setLightboxIndex(i)} + onPreview={ + visualToLightbox.has(i) + ? () => setLightboxIndex(visualToLightbox.get(i)!) + : undefined + } disabled={disabled} />
@@ -167,11 +181,11 @@ export const AttachmentPreviewList = ({ ) : null}
) : null} - {lightboxIndex !== null && lightboxSlides[lightboxIndex] ? ( + {lightboxIndex !== null && imageSlides[lightboxIndex] ? ( setLightboxIndex(null)} - slides={lightboxSlides} + slides={imageSlides} index={lightboxIndex} /> ) : null} diff --git a/src/renderer/components/team/attachments/DropZoneOverlay.tsx b/src/renderer/components/team/attachments/DropZoneOverlay.tsx index 942ca268..1f7bce80 100644 --- a/src/renderer/components/team/attachments/DropZoneOverlay.tsx +++ b/src/renderer/components/team/attachments/DropZoneOverlay.tsx @@ -1,8 +1,8 @@ -import { Ban, ImagePlus } from 'lucide-react'; +import { Ban, Paperclip } from 'lucide-react'; interface DropZoneOverlayProps { active: boolean; - /** Show a "rejected" variant when images can't be sent to this recipient. */ + /** Show a "rejected" variant when files can't be sent to this recipient. */ rejected?: boolean; } @@ -23,7 +23,7 @@ export const DropZoneOverlay = ({ >
- Images can only be sent to the team lead + Files can only be sent to the team lead
); @@ -41,8 +41,8 @@ export const DropZoneOverlay = ({ className="flex flex-col items-center gap-1.5" style={{ color: 'var(--color-accent, #6366f1)' }} > - - Drop images here + + Drop files here ); diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 10d552d5..6879c46e 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -32,7 +32,7 @@ import { } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { isLeadMember } from '@shared/utils/leadDetection'; -import { AlertCircle, ImagePlus, Send, X } from 'lucide-react'; +import { AlertCircle, Paperclip, Send, X } from 'lucide-react'; import { MemberBadge } from '../MemberBadge'; @@ -107,8 +107,8 @@ export const SendMessageDialog = ({ const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); const fileInputRef = useRef(null); - const [imageRestrictionError, setImageRestrictionError] = useState(null); - const imageRestrictionTimerRef = useRef(0); + const [fileRestrictionError, setFileRestrictionError] = useState(null); + const fileRestrictionTimerRef = useRef(0); const [actionMode, setActionModeState] = useState(stickyActionMode); const actionModeRef = useRef(stickyActionMode); const setActionMode = useCallback((mode: ActionMode) => { @@ -279,17 +279,17 @@ export const SendMessageDialog = ({ [addFiles] ); - const showImageRestrictionError = useCallback(() => { - setImageRestrictionError('Images can only be sent to the team lead'); - window.clearTimeout(imageRestrictionTimerRef.current); - imageRestrictionTimerRef.current = window.setTimeout(() => { - setImageRestrictionError(null); + const showFileRestrictionError = useCallback(() => { + setFileRestrictionError('Files can only be sent to the team lead'); + window.clearTimeout(fileRestrictionTimerRef.current); + fileRestrictionTimerRef.current = window.setTimeout(() => { + setFileRestrictionError(null); }, 4000); }, []); // Cleanup restriction error timer on unmount useEffect(() => { - const ref = imageRestrictionTimerRef; + const ref = fileRestrictionTimerRef; return () => window.clearTimeout(ref.current); }, []); @@ -320,33 +320,28 @@ export const SendMessageDialog = ({ if (!isLeadRecipient) { const files = e.dataTransfer?.files; if (files?.length) { - const hasImages = Array.from(files).some((f) => f.type.startsWith('image/')); - if (hasImages) { - showImageRestrictionError(); - } + showFileRestrictionError(); } return; } if (canAttach) handleDrop(e); }, - [isLeadRecipient, canAttach, handleDrop, showImageRestrictionError] + [isLeadRecipient, canAttach, handleDrop, showFileRestrictionError] ); const handlePasteWrapper = useCallback( (e: React.ClipboardEvent) => { if (!isLeadRecipient) { - const hasImages = Array.from(e.clipboardData.items).some((item) => - item.type.startsWith('image/') - ); - if (hasImages) { + const hasFiles = Array.from(e.clipboardData.items).some((item) => item.kind === 'file'); + if (hasFiles) { e.preventDefault(); - showImageRestrictionError(); + showFileRestrictionError(); } return; } if (canAttach) handlePaste(e); }, - [isLeadRecipient, canAttach, handlePaste, showImageRestrictionError] + [isLeadRecipient, canAttach, handlePaste, showFileRestrictionError] ); return ( @@ -386,7 +381,7 @@ export const SendMessageDialog = ({ fileInputRef.current?.click()} > - + {!isTeamAlive - ? 'Team must be online to attach images' + ? 'Team must be online to attach files' : !canAddMore ? 'Maximum attachments reached' - : 'Attach images (paste or drag & drop)'} + : 'Attach files (paste or drag & drop)'} @@ -421,9 +416,9 @@ export const SendMessageDialog = ({
diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index bd07fa46..5694fb7c 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -19,14 +19,14 @@ import { stripEncodedTaskReferenceMetadata, } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; -import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react'; +import { categorizeFile, getEffectiveMimeType } from '@shared/constants/attachments'; +import { Mic, Paperclip, Send, Trash2, X } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types'; const MAX_ATTACHMENTS = 5; const MAX_FILE_SIZE = 20 * 1024 * 1024; -const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); const LONG_QUOTE_THRESHOLD = 200; interface TaskCommentInputProps { @@ -86,45 +86,55 @@ export const TaskCommentInput = ({ trimmed.length <= MAX_TEXT_LENGTH && !addingComment; - const addFiles = useCallback((files: FileList | File[]) => { - setAttachError(null); - const fileArray = Array.from(files); - for (const file of fileArray) { - if (!ACCEPTED_TYPES.has(file.type)) { - setAttachError(`Unsupported type: ${file.type}`); - continue; - } - if (file.size > MAX_FILE_SIZE) { - setAttachError(`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`); - continue; - } - const reader = new FileReader(); - reader.onload = () => { - const result = reader.result as string; - const base64 = result.split(',')[1]; - if (!base64) return; - const id = crypto.randomUUID(); - setPendingAttachments((prev) => { - if (prev.length >= MAX_ATTACHMENTS) { - setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`); - return prev; + const addFiles = useCallback( + (files: FileList | File[]) => { + setAttachError(null); + const fileArray = Array.from(files); + for (const file of fileArray) { + if (categorizeFile(file) === 'unsupported') { + // Insert absolute file path into comment text for unsupported types + const filePath = (file as { path?: string }).path; + if (filePath) { + const current = draft.value; + draft.setValue(current ? filePath + '\n' + current : filePath + '\n'); } - return [ - ...prev, - { - id, - filename: file.name, - mimeType: file.type, - base64Data: base64, - previewUrl: result, - size: file.size, - }, - ]; - }); - }; - reader.readAsDataURL(file); - } - }, []); + continue; + } + if (file.size > MAX_FILE_SIZE) { + setAttachError( + `File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)` + ); + continue; + } + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + const base64 = result.split(',')[1]; + if (!base64) return; + const id = crypto.randomUUID(); + setPendingAttachments((prev) => { + if (prev.length >= MAX_ATTACHMENTS) { + setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`); + return prev; + } + return [ + ...prev, + { + id, + filename: file.name, + mimeType: getEffectiveMimeType(file), + base64Data: base64, + previewUrl: result, + size: file.size, + }, + ]; + }); + }; + reader.readAsDataURL(file); + } + }, + [draft] + ); const removeAttachment = useCallback((id: string) => { setPendingAttachments((prev) => prev.filter((a) => a.id !== id)); @@ -179,16 +189,16 @@ export const TaskCommentInput = ({ (e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; - const imageFiles: File[] = []; + const pastedFiles: File[] = []; for (const item of Array.from(items)) { - if (item.kind === 'file' && ACCEPTED_TYPES.has(item.type)) { + if (item.kind === 'file') { const file = item.getAsFile(); - if (file) imageFiles.push(file); + if (file && categorizeFile(file) !== 'unsupported') pastedFiles.push(file); } } - if (imageFiles.length > 0) { + if (pastedFiles.length > 0) { e.preventDefault(); - addFiles(imageFiles); + addFiles(pastedFiles); } }, [addFiles] @@ -286,7 +296,7 @@ export const TaskCommentInput = ({ { @@ -323,10 +333,10 @@ export const TaskCommentInput = ({ disabled={addingComment || pendingAttachments.length >= MAX_ATTACHMENTS} onClick={() => fileInputRef.current?.click()} > - + - Attach image (or paste) + Attach file (or paste) diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index cdbc5bd3..d1a14eaa 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -25,7 +25,7 @@ import { } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { isLeadMember } from '@shared/utils/leadDetection'; -import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react'; +import { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'lucide-react'; import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector'; import type { MentionSuggestion } from '@renderer/types/mention'; @@ -95,8 +95,8 @@ export const MessageComposer = ({ const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); const fileInputRef = useRef(null); - const [imageRestrictionError, setImageRestrictionError] = useState(null); - const imageRestrictionTimerRef = useRef(0); + const [fileRestrictionError, setFileRestrictionError] = useState(null); + const fileRestrictionTimerRef = useRef(0); const dismissMentionsRef = useRef<(() => void) | null>(null); // Cross-team state @@ -331,17 +331,17 @@ export const MessageComposer = ({ [draftAddFiles] ); - const showImageRestrictionError = useCallback(() => { - setImageRestrictionError('Images can only be sent to the team lead'); - window.clearTimeout(imageRestrictionTimerRef.current); - imageRestrictionTimerRef.current = window.setTimeout(() => { - setImageRestrictionError(null); + const showFileRestrictionError = useCallback(() => { + setFileRestrictionError('Files can only be sent to the team lead'); + window.clearTimeout(fileRestrictionTimerRef.current); + fileRestrictionTimerRef.current = window.setTimeout(() => { + setFileRestrictionError(null); }, 4000); }, []); // Cleanup restriction error timer on unmount useEffect(() => { - const ref = imageRestrictionTimerRef; + const ref = fileRestrictionTimerRef; return () => window.clearTimeout(ref.current); }, []); @@ -373,39 +373,34 @@ export const MessageComposer = ({ if (!isLeadRecipient) { const files = e.dataTransfer?.files; if (files?.length) { - const hasImages = Array.from(files).some((f) => f.type.startsWith('image/')); - if (hasImages) { - showImageRestrictionError(); - } + showFileRestrictionError(); } return; } if (canAttach) draftHandleDrop(e); }, - [isLeadRecipient, canAttach, draftHandleDrop, showImageRestrictionError] + [isLeadRecipient, canAttach, draftHandleDrop, showFileRestrictionError] ); const { handlePaste: draftHandlePaste } = draft; const handlePasteWrapper = useCallback( (e: React.ClipboardEvent) => { if (!isLeadRecipient) { - const hasImages = Array.from(e.clipboardData.items).some((item) => - item.type.startsWith('image/') - ); - if (hasImages) { + const hasFiles = Array.from(e.clipboardData.items).some((item) => item.kind === 'file'); + if (hasFiles) { e.preventDefault(); - showImageRestrictionError(); + showFileRestrictionError(); } return; } if (canAttach) draftHandlePaste(e); }, - [isLeadRecipient, canAttach, draftHandlePaste, showImageRestrictionError] + [isLeadRecipient, canAttach, draftHandlePaste, showFileRestrictionError] ); const remaining = MAX_TEXT_LENGTH - trimmed.length; const hasAttachmentPreviewContent = - draft.attachments.length > 0 || Boolean(draft.attachmentError ?? imageRestrictionError); + draft.attachments.length > 0 || Boolean(draft.attachmentError ?? fileRestrictionError); return (
fileInputRef.current?.click()} > - + {!isTeamAlive - ? 'Team must be online to attach images' + ? 'Team must be online to attach files' : !draft.canAddMore ? 'Maximum attachments reached' - : 'Attach images (paste or drag & drop)'} + : 'Attach files (paste or drag & drop)'} @@ -839,10 +834,10 @@ export const MessageComposer = ({ ) : null}
diff --git a/src/renderer/hooks/useAttachments.ts b/src/renderer/hooks/useAttachments.ts index ca17ffb3..07f024d7 100644 --- a/src/renderer/hooks/useAttachments.ts +++ b/src/renderer/hooks/useAttachments.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { draftStorage } from '@renderer/services/draftStorage'; +import { categorizeFile } from '@shared/constants/attachments'; import { fileToAttachmentPayload, MAX_FILES, @@ -13,6 +14,8 @@ import type { AttachmentPayload } from '@shared/types'; interface UseAttachmentsOptions { /** When provided, attachments are persisted to IndexedDB under this key. */ persistenceKey?: string; + /** Called with unsupported files so the consumer can handle them (e.g. insert paths into text). */ + onUnsupportedFiles?: (files: File[]) => void; } interface UseAttachmentsReturn { @@ -57,6 +60,8 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR const keyRef = useRef(persistenceKey); // eslint-disable-next-line react-hooks/refs -- synchronous ref sync during render is intentional to avoid stale key in callbacks keyRef.current = persistenceKey; + const onUnsupportedRef = useRef(options?.onUnsupportedFiles); + onUnsupportedRef.current = options?.onUnsupportedFiles; // Sync ref with state const updateAttachments = useCallback((next: AttachmentPayload[]) => { @@ -162,9 +167,30 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR const fileArray = Array.from(files); if (fileArray.length === 0) return; + // Split: supported → attachments, unsupported → callback or error + const supported: File[] = []; + const unsupported: File[] = []; + for (const f of fileArray) { + if (categorizeFile(f) === 'unsupported') { + unsupported.push(f); + } else { + supported.push(f); + } + } + + if (unsupported.length > 0) { + if (onUnsupportedRef.current) { + onUnsupportedRef.current(unsupported); + } else { + setError(`Unsupported file type: ${unsupported[0].name}`); + } + } + + if (supported.length === 0) return; + let batchSize = 0; let valid = true; - for (const file of fileArray) { + for (const file of supported) { const validation = validateAttachment(file); if (!validation.valid) { setError(validation.error); @@ -176,7 +202,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR if (!valid) return; const newPayloads: AttachmentPayload[] = []; - for (const file of fileArray) { + for (const file of supported) { try { const payload = await fileToAttachmentPayload(file); newPayloads.push(payload); @@ -244,17 +270,19 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR const items = event.clipboardData?.items; if (!items) return; - const imageFiles: File[] = []; + const supportedFiles: File[] = []; for (const item of Array.from(items)) { - if (item.kind === 'file' && item.type.startsWith('image/')) { + if (item.kind === 'file') { const file = item.getAsFile(); - if (file) imageFiles.push(file); + if (file && categorizeFile(file) !== 'unsupported') { + supportedFiles.push(file); + } } } - if (imageFiles.length > 0) { + if (supportedFiles.length > 0) { event.preventDefault(); - void addFiles(imageFiles); + void addFiles(supportedFiles); } }, [addFiles] @@ -265,14 +293,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR event.preventDefault(); const files = event.dataTransfer?.files; if (!files?.length) return; - - const allFiles = Array.from(files); - const imageFiles = allFiles.filter((f) => f.type.startsWith('image/')); - if (imageFiles.length > 0) { - void addFiles(imageFiles); - } else if (allFiles.length > 0) { - setError('Only image files are supported'); - } + void addFiles(Array.from(files)); }, [addFiles] ); diff --git a/src/renderer/hooks/useComposerDraft.ts b/src/renderer/hooks/useComposerDraft.ts index 019a21f0..9b0554a0 100644 --- a/src/renderer/hooks/useComposerDraft.ts +++ b/src/renderer/hooks/useComposerDraft.ts @@ -17,6 +17,7 @@ import { type ComposerDraftSnapshot, composerDraftStorage, } from '@renderer/services/composerDraftStorage'; +import { categorizeFile } from '@shared/constants/attachments'; import { fileToAttachmentPayload, MAX_FILES, @@ -325,8 +326,30 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult { const fileArray = Array.from(files); if (fileArray.length === 0) return; + // Split: supported → attachments, unsupported → path prepended to text + const supported: File[] = []; + const unsupportedPaths: string[] = []; + for (const f of fileArray) { + if (categorizeFile(f) === 'unsupported') { + const p = (f as { path?: string }).path; + if (p) unsupportedPaths.push(p); + else setAttachmentError(`Unsupported file: ${f.name}`); + } else { + supported.push(f); + } + } + + // Prepend unsupported file paths to text (independent of attachment validation) + if (unsupportedPaths.length > 0) { + const prefix = unsupportedPaths.join('\n') + '\n'; + const current = textRef.current; + setText(current ? prefix + current : prefix); + } + + if (supported.length === 0) return; + let batchSize = 0; - for (const file of fileArray) { + for (const file of supported) { const validation = validateAttachment(file); if (!validation.valid) { setAttachmentError(validation.error); @@ -336,7 +359,7 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult { } const newPayloads: AttachmentPayload[] = []; - for (const file of fileArray) { + for (const file of supported) { try { const payload = await fileToAttachmentPayload(file); newPayloads.push(payload); @@ -363,7 +386,7 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult { setIsSaved(false); scheduleSave(); }, - [scheduleSave] + [scheduleSave, setText] ); const removeAttachment = useCallback( @@ -397,17 +420,19 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult { const items = event.clipboardData?.items; if (!items) return; - const imageFiles: File[] = []; + const supportedFiles: File[] = []; for (const item of Array.from(items)) { - if (item.kind === 'file' && item.type.startsWith('image/')) { + if (item.kind === 'file') { const file = item.getAsFile(); - if (file) imageFiles.push(file); + if (file && categorizeFile(file) !== 'unsupported') { + supportedFiles.push(file); + } } } - if (imageFiles.length > 0) { + if (supportedFiles.length > 0) { event.preventDefault(); - void addFiles(imageFiles); + void addFiles(supportedFiles); } }, [addFiles] @@ -418,14 +443,7 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult { event.preventDefault(); const files = event.dataTransfer?.files; if (!files?.length) return; - - const allFiles = Array.from(files); - const imageFiles = allFiles.filter((f) => f.type.startsWith('image/')); - if (imageFiles.length > 0) { - void addFiles(imageFiles); - } else if (allFiles.length > 0) { - setAttachmentError('Only image files are supported'); - } + void addFiles(Array.from(files)); }, [addFiles] ); diff --git a/src/renderer/utils/attachmentUtils.ts b/src/renderer/utils/attachmentUtils.ts index c149a557..d1ec8a85 100644 --- a/src/renderer/utils/attachmentUtils.ts +++ b/src/renderer/utils/attachmentUtils.ts @@ -1,3 +1,5 @@ +import { categorizeFile, getEffectiveMimeType, isImageMime } from '@shared/constants/attachments'; + import type { AttachmentPayload, ImageMimeType } from '@shared/types'; export const ALLOWED_MIME_TYPES = new Set([ @@ -16,8 +18,9 @@ export function isImageMimeType(type: string): type is ImageMimeType { } export function validateAttachment(file: File): { valid: true } | { valid: false; error: string } { - if (!isImageMimeType(file.type)) { - return { valid: false, error: `Unsupported file type: ${file.type}` }; + const cat = categorizeFile(file); + if (cat === 'unsupported') { + return { valid: false, error: `Unsupported file type: ${file.name}` }; } if (file.size > MAX_FILE_SIZE) { return { valid: false, error: `File "${file.name}" exceeds 10MB limit` }; @@ -30,12 +33,12 @@ export async function fileToAttachmentPayload(file: File): Promise { const dataUrl = reader.result as string; - // Strip "data:image/png;base64," prefix to get raw base64 + // Strip "data:;base64," prefix to get raw base64 const base64 = dataUrl.split(',')[1] ?? ''; resolve({ id: crypto.randomUUID(), filename: file.name, - mimeType: file.type, + mimeType: getEffectiveMimeType(file), size: file.size, data: base64, }); @@ -45,6 +48,8 @@ export async function fileToAttachmentPayload(file: File): Promise = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', +}; + +/** + * Return the MIME type that should be stored in `AttachmentPayload.mimeType` + * (used by the backend to choose the correct content block type). + */ +export function getEffectiveMimeType(file: File): string { + const cat = categorizeFile(file); + + if (cat === 'image') { + if (file.type && IMAGE_MIME_TYPES.has(file.type)) return file.type; + // Fallback when browser returns empty MIME for an image extension + const ext = file.name.split('.').pop()?.toLowerCase() ?? ''; + return IMAGE_EXT_TO_MIME[ext] ?? 'image/png'; + } + if (cat === 'pdf') return 'application/pdf'; + if (cat === 'text') return 'text/plain'; + + return file.type || 'application/octet-stream'; +} + +// --------------------------------------------------------------------------- +// MIME type guards (used by backend routing & preview components) +// --------------------------------------------------------------------------- + +export function isImageMime(mime: string): boolean { + return IMAGE_MIME_TYPES.has(mime); +} + +export function isPdfMime(mime: string): boolean { + return mime === 'application/pdf'; +} + +export function isTextDocMime(mime: string): boolean { + return mime === 'text/plain'; +} + +export function isNativeAttachmentMime(mime: string): boolean { + return isImageMime(mime) || isPdfMime(mime) || isTextDocMime(mime); +} diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index 0cfa18c1..f0bdc661 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -3,6 +3,7 @@ */ export * from './agentBlocks'; +export * from './attachments'; export * from './cache'; export * from './cli'; export * from './crossTeam';