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.
This commit is contained in:
parent
32e310a5ff
commit
b20b69066e
14 changed files with 488 additions and 174 deletions
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
const runId = this.getAliveRunId(teamName);
|
||||
if (!runId) {
|
||||
|
|
@ -3615,14 +3615,38 @@ export class TeamProvisioningService {
|
|||
const contentBlocks: Record<string, unknown>[] = [{ 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<number, number>();
|
||||
items.forEach((item, i) => {
|
||||
if (item.isImage && item.dataUrl) {
|
||||
visualToLightbox.set(i, imageSlides.length);
|
||||
imageSlides.push({ src: item.dataUrl, alt: item.meta.filename });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-1.5 flex flex-wrap gap-2">
|
||||
{items.map((item, i) => (
|
||||
<AttachmentThumbnail
|
||||
key={item.meta.id}
|
||||
src={item.dataUrl}
|
||||
alt={item.meta.filename}
|
||||
size="md"
|
||||
onClick={() => setLightboxIndex(i)}
|
||||
/>
|
||||
))}
|
||||
{items.map((item, i) =>
|
||||
item.isImage && item.dataUrl ? (
|
||||
<AttachmentThumbnail
|
||||
key={item.meta.id}
|
||||
src={item.dataUrl}
|
||||
alt={item.meta.filename}
|
||||
size="md"
|
||||
onClick={
|
||||
visualToLightbox.has(i)
|
||||
? () => setLightboxIndex(visualToLightbox.get(i)!)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={item.meta.id}
|
||||
className="flex size-20 flex-col items-center justify-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)]"
|
||||
>
|
||||
<FileIcon fileName={item.meta.filename} className="size-5" />
|
||||
<span className="max-w-[72px] truncate text-[9px] text-[var(--color-text-muted)]">
|
||||
{item.meta.filename}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{lightboxIndex !== null && items[lightboxIndex] ? (
|
||||
{lightboxIndex !== null && imageSlides[lightboxIndex] ? (
|
||||
<ImageLightbox
|
||||
open
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
slides={items.map((item) => ({ src: item.dataUrl, alt: item.meta.filename }))}
|
||||
slides={imageSlides}
|
||||
index={lightboxIndex}
|
||||
/>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="group/att relative flex shrink-0 items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-1.5">
|
||||
|
|
@ -27,7 +30,18 @@ export const AttachmentPreviewItem = ({
|
|||
<Ban size={18} className="text-red-400" />
|
||||
</div>
|
||||
) : null}
|
||||
<AttachmentThumbnail src={dataUrl} alt={attachment.filename} size="sm" onClick={onPreview} />
|
||||
{isImage && dataUrl ? (
|
||||
<AttachmentThumbnail
|
||||
src={dataUrl}
|
||||
alt={attachment.filename}
|
||||
size="sm"
|
||||
onClick={onPreview}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-12 items-center justify-center rounded bg-[var(--color-surface-raised)]">
|
||||
<FileIcon fileName={attachment.filename} className="size-5" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="max-w-[100px] truncate text-[11px] text-[var(--color-text-secondary)]">
|
||||
{attachment.filename}
|
||||
|
|
|
|||
|
|
@ -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<number, number>();
|
||||
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 (
|
||||
<div className="space-y-1.5 px-1">
|
||||
|
|
@ -135,7 +145,11 @@ export const AttachmentPreviewList = ({
|
|||
<AttachmentPreviewItem
|
||||
attachment={att}
|
||||
onRemove={handleRemove}
|
||||
onPreview={() => setLightboxIndex(i)}
|
||||
onPreview={
|
||||
visualToLightbox.has(i)
|
||||
? () => setLightboxIndex(visualToLightbox.get(i)!)
|
||||
: undefined
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -167,11 +181,11 @@ export const AttachmentPreviewList = ({
|
|||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{lightboxIndex !== null && lightboxSlides[lightboxIndex] ? (
|
||||
{lightboxIndex !== null && imageSlides[lightboxIndex] ? (
|
||||
<ImageLightbox
|
||||
open
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
slides={lightboxSlides}
|
||||
slides={imageSlides}
|
||||
index={lightboxIndex}
|
||||
/>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
>
|
||||
<div className="flex flex-col items-center gap-1.5 text-red-400">
|
||||
<Ban size={24} />
|
||||
<span className="text-xs font-medium">Images can only be sent to the team lead</span>
|
||||
<span className="text-xs font-medium">Files can only be sent to the team lead</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -41,8 +41,8 @@ export const DropZoneOverlay = ({
|
|||
className="flex flex-col items-center gap-1.5"
|
||||
style={{ color: 'var(--color-accent, #6366f1)' }}
|
||||
>
|
||||
<ImagePlus size={24} />
|
||||
<span className="text-xs font-medium">Drop images here</span>
|
||||
<Paperclip size={24} />
|
||||
<span className="text-xs font-medium">Drop files here</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>(null);
|
||||
const [imageRestrictionError, setImageRestrictionError] = useState<string | null>(null);
|
||||
const imageRestrictionTimerRef = useRef(0);
|
||||
const [fileRestrictionError, setFileRestrictionError] = useState<string | null>(null);
|
||||
const fileRestrictionTimerRef = useRef(0);
|
||||
const [actionMode, setActionModeState] = useState<ActionMode>(stickyActionMode);
|
||||
const actionModeRef = useRef<ActionMode>(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 = ({
|
|||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
accept="*/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
|
|
@ -403,15 +398,15 @@ export const SendMessageDialog = ({
|
|||
disabled={!canAttach}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<ImagePlus size={14} />
|
||||
<Paperclip size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{!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)'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
|
|
@ -421,9 +416,9 @@ export const SendMessageDialog = ({
|
|||
<AttachmentPreviewList
|
||||
attachments={attachments}
|
||||
onRemove={removeAttachment}
|
||||
error={attachmentError ?? imageRestrictionError}
|
||||
error={attachmentError ?? fileRestrictionError}
|
||||
disabled={attachmentsBlocked}
|
||||
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
|
||||
disabledHint="File attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
|
||||
/>
|
||||
|
||||
<div className={quote ? 'flex flex-col' : 'contents'}>
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
accept="*/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
|
|
@ -323,10 +333,10 @@ export const TaskCommentInput = ({
|
|||
disabled={addingComment || pendingAttachments.length >= MAX_ATTACHMENTS}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<ImagePlus size={14} />
|
||||
<Paperclip size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Attach image (or paste)</TooltipContent>
|
||||
<TooltipContent side="top">Attach file (or paste)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>(null);
|
||||
const [imageRestrictionError, setImageRestrictionError] = useState<string | null>(null);
|
||||
const imageRestrictionTimerRef = useRef(0);
|
||||
const [fileRestrictionError, setFileRestrictionError] = useState<string | null>(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 (
|
||||
<div
|
||||
|
|
@ -426,7 +421,7 @@ export const MessageComposer = ({
|
|||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
accept="*/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
|
|
@ -444,15 +439,15 @@ export const MessageComposer = ({
|
|||
disabled={!canAttach}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<ImagePlus size={14} />
|
||||
<Paperclip size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{!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)'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
|
|
@ -839,10 +834,10 @@ export const MessageComposer = ({
|
|||
<AttachmentPreviewList
|
||||
attachments={draft.attachments}
|
||||
onRemove={draft.removeAttachment}
|
||||
error={draft.attachmentError ?? imageRestrictionError}
|
||||
error={draft.attachmentError ?? fileRestrictionError}
|
||||
onDismissError={draft.clearAttachmentError}
|
||||
disabled={attachmentsBlocked}
|
||||
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
|
||||
disabledHint="File attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<ImageMimeType>([
|
||||
|
|
@ -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<AttachmentPay
|
|||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result as string;
|
||||
// Strip "data:image/png;base64," prefix to get raw base64
|
||||
// Strip "data:<mime>;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<AttachmentPay
|
|||
});
|
||||
}
|
||||
|
||||
export { categorizeFile, isImageMime };
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
|
|
|
|||
175
src/shared/constants/attachments.ts
Normal file
175
src/shared/constants/attachments.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
/**
|
||||
* Attachment file categorization and MIME type helpers.
|
||||
*
|
||||
* Browser MIME types are unreliable:
|
||||
* .ts → "video/mp2t", .json → "application/json", .go/.rs/.yaml → ""
|
||||
* So categorization is ALWAYS by file extension (primary), with browser MIME
|
||||
* used only as a fallback for images.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
||||
|
||||
/** Extensions recognized as image files (fallback when browser MIME is empty). */
|
||||
const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']);
|
||||
|
||||
/** Extensions recognized as text-based files → sent as `document` block with `text/plain`. */
|
||||
export const TEXT_FILE_EXTENSIONS = new Set([
|
||||
// Data
|
||||
'json',
|
||||
'jsonl',
|
||||
'txt',
|
||||
'md',
|
||||
'mdx',
|
||||
'csv',
|
||||
'tsv',
|
||||
// JavaScript / TypeScript
|
||||
'ts',
|
||||
'tsx',
|
||||
'js',
|
||||
'jsx',
|
||||
'mjs',
|
||||
'cjs',
|
||||
// Other languages
|
||||
'py',
|
||||
'go',
|
||||
'rs',
|
||||
'java',
|
||||
'kt',
|
||||
'rb',
|
||||
'c',
|
||||
'h',
|
||||
'cpp',
|
||||
'hpp',
|
||||
'cs',
|
||||
'swift',
|
||||
'dart',
|
||||
'php',
|
||||
'lua',
|
||||
'scala',
|
||||
'ex',
|
||||
'exs',
|
||||
// Web
|
||||
'html',
|
||||
'css',
|
||||
'scss',
|
||||
'less',
|
||||
'vue',
|
||||
'svelte',
|
||||
// Config / markup
|
||||
'xml',
|
||||
'yaml',
|
||||
'yml',
|
||||
'toml',
|
||||
'ini',
|
||||
'cfg',
|
||||
'conf',
|
||||
// Shell
|
||||
'sh',
|
||||
'bash',
|
||||
'zsh',
|
||||
'fish',
|
||||
// Query / schema
|
||||
'sql',
|
||||
'graphql',
|
||||
'gql',
|
||||
'proto',
|
||||
// Misc text
|
||||
'env',
|
||||
'log',
|
||||
'rst',
|
||||
'diff',
|
||||
'patch',
|
||||
// Known filenames that happen to equal their "extension" when split on '.'
|
||||
'dockerfile',
|
||||
'makefile',
|
||||
'gitignore',
|
||||
'dockerignore',
|
||||
'editorconfig',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FileCategory = 'image' | 'pdf' | 'text' | 'unsupported';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Categorization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Categorize a `File` by its **extension** (primary) — browser MIME is
|
||||
* unreliable for anything other than images.
|
||||
*/
|
||||
export function categorizeFile(file: File): FileCategory {
|
||||
// 1. Browser MIME is reliable for images
|
||||
if (IMAGE_MIME_TYPES.has(file.type)) return 'image';
|
||||
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
|
||||
|
||||
// 2. Extension-based checks
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return 'image'; // fallback for empty MIME
|
||||
if (ext === 'pdf') return 'pdf';
|
||||
if (TEXT_FILE_EXTENSIONS.has(ext)) return 'text';
|
||||
|
||||
// 3. Special filenames / patterns
|
||||
const baseName = file.name.toLowerCase();
|
||||
if (baseName.startsWith('.env')) return 'text'; // .env.local, .env.production, etc.
|
||||
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MIME helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IMAGE_EXT_TO_MIME: Record<string, string> = {
|
||||
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);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
export * from './agentBlocks';
|
||||
export * from './attachments';
|
||||
export * from './cache';
|
||||
export * from './cli';
|
||||
export * from './crossTeam';
|
||||
|
|
|
|||
Loading…
Reference in a new issue