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 taskAttachmentStore = new TeamTaskAttachmentStore();
|
||||||
const teamMetaStore = new TeamMetaStore();
|
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
|
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -3601,7 +3601,7 @@ export class TeamProvisioningService {
|
||||||
async sendMessageToTeam(
|
async sendMessageToTeam(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
message: string,
|
message: string,
|
||||||
attachments?: { data: string; mimeType: string }[]
|
attachments?: { data: string; mimeType: string; filename?: string }[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const runId = this.getAliveRunId(teamName);
|
const runId = this.getAliveRunId(teamName);
|
||||||
if (!runId) {
|
if (!runId) {
|
||||||
|
|
@ -3615,14 +3615,38 @@ export class TeamProvisioningService {
|
||||||
const contentBlocks: Record<string, unknown>[] = [{ type: 'text', text: message }];
|
const contentBlocks: Record<string, unknown>[] = [{ type: 'text', text: message }];
|
||||||
if (attachments?.length) {
|
if (attachments?.length) {
|
||||||
for (const att of attachments) {
|
for (const att of attachments) {
|
||||||
contentBlocks.push({
|
if (att.mimeType === 'application/pdf') {
|
||||||
type: 'image',
|
// PDF → document block with base64 source
|
||||||
source: {
|
contentBlocks.push({
|
||||||
type: 'base64',
|
type: 'document',
|
||||||
media_type: att.mimeType,
|
source: {
|
||||||
data: att.data,
|
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 { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { isImageMime } from '@renderer/utils/attachmentUtils';
|
||||||
|
|
||||||
|
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
||||||
|
|
||||||
import { AttachmentThumbnail } from './AttachmentThumbnail';
|
import { AttachmentThumbnail } from './AttachmentThumbnail';
|
||||||
import { ImageLightbox } from './ImageLightbox';
|
import { ImageLightbox } from './ImageLightbox';
|
||||||
|
|
||||||
|
|
@ -66,30 +70,61 @@ export const AttachmentDisplay = ({
|
||||||
.map((meta) => {
|
.map((meta) => {
|
||||||
const data = dataById.get(meta.id);
|
const data = dataById.get(meta.id);
|
||||||
if (!data) return null;
|
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;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-1.5 flex flex-wrap gap-2">
|
<div className="mt-1.5 flex flex-wrap gap-2">
|
||||||
{items.map((item, i) => (
|
{items.map((item, i) =>
|
||||||
<AttachmentThumbnail
|
item.isImage && item.dataUrl ? (
|
||||||
key={item.meta.id}
|
<AttachmentThumbnail
|
||||||
src={item.dataUrl}
|
key={item.meta.id}
|
||||||
alt={item.meta.filename}
|
src={item.dataUrl}
|
||||||
size="md"
|
alt={item.meta.filename}
|
||||||
onClick={() => setLightboxIndex(i)}
|
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>
|
</div>
|
||||||
{lightboxIndex !== null && items[lightboxIndex] ? (
|
{lightboxIndex !== null && imageSlides[lightboxIndex] ? (
|
||||||
<ImageLightbox
|
<ImageLightbox
|
||||||
open
|
open
|
||||||
onClose={() => setLightboxIndex(null)}
|
onClose={() => setLightboxIndex(null)}
|
||||||
slides={items.map((item) => ({ src: item.dataUrl, alt: item.meta.filename }))}
|
slides={imageSlides}
|
||||||
index={lightboxIndex}
|
index={lightboxIndex}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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 { Ban, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
||||||
|
|
||||||
import { AttachmentThumbnail } from './AttachmentThumbnail';
|
import { AttachmentThumbnail } from './AttachmentThumbnail';
|
||||||
|
|
||||||
import type { AttachmentPayload } from '@shared/types';
|
import type { AttachmentPayload } from '@shared/types';
|
||||||
|
|
@ -18,7 +20,8 @@ export const AttachmentPreviewItem = ({
|
||||||
onPreview,
|
onPreview,
|
||||||
disabled,
|
disabled,
|
||||||
}: AttachmentPreviewItemProps): React.JSX.Element => {
|
}: 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 (
|
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">
|
<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" />
|
<Ban size={18} className="text-red-400" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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">
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
<span className="max-w-[100px] truncate text-[11px] text-[var(--color-text-secondary)]">
|
<span className="max-w-[100px] truncate text-[11px] text-[var(--color-text-secondary)]">
|
||||||
{attachment.filename}
|
{attachment.filename}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { AlertCircle, X } from 'lucide-react';
|
import { AlertCircle, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { isImageMime } from '@renderer/utils/attachmentUtils';
|
||||||
|
|
||||||
import { AttachmentPreviewItem } from './AttachmentPreviewItem';
|
import { AttachmentPreviewItem } from './AttachmentPreviewItem';
|
||||||
import { ImageLightbox } from './ImageLightbox';
|
import { ImageLightbox } from './ImageLightbox';
|
||||||
|
|
||||||
|
|
@ -107,10 +109,18 @@ export const AttachmentPreviewList = ({
|
||||||
|
|
||||||
if (visibleAttachments.length === 0 && exitingIds.size === 0 && !error) return null;
|
if (visibleAttachments.length === 0 && exitingIds.size === 0 && !error) return null;
|
||||||
|
|
||||||
const lightboxSlides = visibleAttachments.map((att) => ({
|
// Build lightbox slides for images only, with visual→lightbox index mapping
|
||||||
src: `data:${att.mimeType};base64,${att.data}`,
|
const imageSlides: { src: string; alt: string }[] = [];
|
||||||
alt: att.filename,
|
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 (
|
return (
|
||||||
<div className="space-y-1.5 px-1">
|
<div className="space-y-1.5 px-1">
|
||||||
|
|
@ -135,7 +145,11 @@ export const AttachmentPreviewList = ({
|
||||||
<AttachmentPreviewItem
|
<AttachmentPreviewItem
|
||||||
attachment={att}
|
attachment={att}
|
||||||
onRemove={handleRemove}
|
onRemove={handleRemove}
|
||||||
onPreview={() => setLightboxIndex(i)}
|
onPreview={
|
||||||
|
visualToLightbox.has(i)
|
||||||
|
? () => setLightboxIndex(visualToLightbox.get(i)!)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -167,11 +181,11 @@ export const AttachmentPreviewList = ({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{lightboxIndex !== null && lightboxSlides[lightboxIndex] ? (
|
{lightboxIndex !== null && imageSlides[lightboxIndex] ? (
|
||||||
<ImageLightbox
|
<ImageLightbox
|
||||||
open
|
open
|
||||||
onClose={() => setLightboxIndex(null)}
|
onClose={() => setLightboxIndex(null)}
|
||||||
slides={lightboxSlides}
|
slides={imageSlides}
|
||||||
index={lightboxIndex}
|
index={lightboxIndex}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Ban, ImagePlus } from 'lucide-react';
|
import { Ban, Paperclip } from 'lucide-react';
|
||||||
|
|
||||||
interface DropZoneOverlayProps {
|
interface DropZoneOverlayProps {
|
||||||
active: boolean;
|
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;
|
rejected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ export const DropZoneOverlay = ({
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-1.5 text-red-400">
|
<div className="flex flex-col items-center gap-1.5 text-red-400">
|
||||||
<Ban size={24} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -41,8 +41,8 @@ export const DropZoneOverlay = ({
|
||||||
className="flex flex-col items-center gap-1.5"
|
className="flex flex-col items-center gap-1.5"
|
||||||
style={{ color: 'var(--color-accent, #6366f1)' }}
|
style={{ color: 'var(--color-accent, #6366f1)' }}
|
||||||
>
|
>
|
||||||
<ImagePlus size={24} />
|
<Paperclip size={24} />
|
||||||
<span className="text-xs font-medium">Drop images here</span>
|
<span className="text-xs font-medium">Drop files here</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import {
|
||||||
} from '@renderer/utils/taskReferenceUtils';
|
} from '@renderer/utils/taskReferenceUtils';
|
||||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
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';
|
import { MemberBadge } from '../MemberBadge';
|
||||||
|
|
||||||
|
|
@ -107,8 +107,8 @@ export const SendMessageDialog = ({
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const dragCounterRef = useRef(0);
|
const dragCounterRef = useRef(0);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [imageRestrictionError, setImageRestrictionError] = useState<string | null>(null);
|
const [fileRestrictionError, setFileRestrictionError] = useState<string | null>(null);
|
||||||
const imageRestrictionTimerRef = useRef(0);
|
const fileRestrictionTimerRef = useRef(0);
|
||||||
const [actionMode, setActionModeState] = useState<ActionMode>(stickyActionMode);
|
const [actionMode, setActionModeState] = useState<ActionMode>(stickyActionMode);
|
||||||
const actionModeRef = useRef<ActionMode>(stickyActionMode);
|
const actionModeRef = useRef<ActionMode>(stickyActionMode);
|
||||||
const setActionMode = useCallback((mode: ActionMode) => {
|
const setActionMode = useCallback((mode: ActionMode) => {
|
||||||
|
|
@ -279,17 +279,17 @@ export const SendMessageDialog = ({
|
||||||
[addFiles]
|
[addFiles]
|
||||||
);
|
);
|
||||||
|
|
||||||
const showImageRestrictionError = useCallback(() => {
|
const showFileRestrictionError = useCallback(() => {
|
||||||
setImageRestrictionError('Images can only be sent to the team lead');
|
setFileRestrictionError('Files can only be sent to the team lead');
|
||||||
window.clearTimeout(imageRestrictionTimerRef.current);
|
window.clearTimeout(fileRestrictionTimerRef.current);
|
||||||
imageRestrictionTimerRef.current = window.setTimeout(() => {
|
fileRestrictionTimerRef.current = window.setTimeout(() => {
|
||||||
setImageRestrictionError(null);
|
setFileRestrictionError(null);
|
||||||
}, 4000);
|
}, 4000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Cleanup restriction error timer on unmount
|
// Cleanup restriction error timer on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ref = imageRestrictionTimerRef;
|
const ref = fileRestrictionTimerRef;
|
||||||
return () => window.clearTimeout(ref.current);
|
return () => window.clearTimeout(ref.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -320,33 +320,28 @@ export const SendMessageDialog = ({
|
||||||
if (!isLeadRecipient) {
|
if (!isLeadRecipient) {
|
||||||
const files = e.dataTransfer?.files;
|
const files = e.dataTransfer?.files;
|
||||||
if (files?.length) {
|
if (files?.length) {
|
||||||
const hasImages = Array.from(files).some((f) => f.type.startsWith('image/'));
|
showFileRestrictionError();
|
||||||
if (hasImages) {
|
|
||||||
showImageRestrictionError();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (canAttach) handleDrop(e);
|
if (canAttach) handleDrop(e);
|
||||||
},
|
},
|
||||||
[isLeadRecipient, canAttach, handleDrop, showImageRestrictionError]
|
[isLeadRecipient, canAttach, handleDrop, showFileRestrictionError]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePasteWrapper = useCallback(
|
const handlePasteWrapper = useCallback(
|
||||||
(e: React.ClipboardEvent) => {
|
(e: React.ClipboardEvent) => {
|
||||||
if (!isLeadRecipient) {
|
if (!isLeadRecipient) {
|
||||||
const hasImages = Array.from(e.clipboardData.items).some((item) =>
|
const hasFiles = Array.from(e.clipboardData.items).some((item) => item.kind === 'file');
|
||||||
item.type.startsWith('image/')
|
if (hasFiles) {
|
||||||
);
|
|
||||||
if (hasImages) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showImageRestrictionError();
|
showFileRestrictionError();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (canAttach) handlePaste(e);
|
if (canAttach) handlePaste(e);
|
||||||
},
|
},
|
||||||
[isLeadRecipient, canAttach, handlePaste, showImageRestrictionError]
|
[isLeadRecipient, canAttach, handlePaste, showFileRestrictionError]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -386,7 +381,7 @@ export const SendMessageDialog = ({
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
accept="*/*"
|
||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileInputChange}
|
onChange={handleFileInputChange}
|
||||||
|
|
@ -403,15 +398,15 @@ export const SendMessageDialog = ({
|
||||||
disabled={!canAttach}
|
disabled={!canAttach}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
<ImagePlus size={14} />
|
<Paperclip size={14} />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
{!isTeamAlive
|
{!isTeamAlive
|
||||||
? 'Team must be online to attach images'
|
? 'Team must be online to attach files'
|
||||||
: !canAddMore
|
: !canAddMore
|
||||||
? 'Maximum attachments reached'
|
? 'Maximum attachments reached'
|
||||||
: 'Attach images (paste or drag & drop)'}
|
: 'Attach files (paste or drag & drop)'}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
|
|
@ -421,9 +416,9 @@ export const SendMessageDialog = ({
|
||||||
<AttachmentPreviewList
|
<AttachmentPreviewList
|
||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
onRemove={removeAttachment}
|
onRemove={removeAttachment}
|
||||||
error={attachmentError ?? imageRestrictionError}
|
error={attachmentError ?? fileRestrictionError}
|
||||||
disabled={attachmentsBlocked}
|
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'}>
|
<div className={quote ? 'flex flex-col' : 'contents'}>
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,14 @@ import {
|
||||||
stripEncodedTaskReferenceMetadata,
|
stripEncodedTaskReferenceMetadata,
|
||||||
} from '@renderer/utils/taskReferenceUtils';
|
} from '@renderer/utils/taskReferenceUtils';
|
||||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
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 { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types';
|
import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
const MAX_ATTACHMENTS = 5;
|
const MAX_ATTACHMENTS = 5;
|
||||||
const MAX_FILE_SIZE = 20 * 1024 * 1024;
|
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;
|
const LONG_QUOTE_THRESHOLD = 200;
|
||||||
|
|
||||||
interface TaskCommentInputProps {
|
interface TaskCommentInputProps {
|
||||||
|
|
@ -86,45 +86,55 @@ export const TaskCommentInput = ({
|
||||||
trimmed.length <= MAX_TEXT_LENGTH &&
|
trimmed.length <= MAX_TEXT_LENGTH &&
|
||||||
!addingComment;
|
!addingComment;
|
||||||
|
|
||||||
const addFiles = useCallback((files: FileList | File[]) => {
|
const addFiles = useCallback(
|
||||||
setAttachError(null);
|
(files: FileList | File[]) => {
|
||||||
const fileArray = Array.from(files);
|
setAttachError(null);
|
||||||
for (const file of fileArray) {
|
const fileArray = Array.from(files);
|
||||||
if (!ACCEPTED_TYPES.has(file.type)) {
|
for (const file of fileArray) {
|
||||||
setAttachError(`Unsupported type: ${file.type}`);
|
if (categorizeFile(file) === 'unsupported') {
|
||||||
continue;
|
// Insert absolute file path into comment text for unsupported types
|
||||||
}
|
const filePath = (file as { path?: string }).path;
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (filePath) {
|
||||||
setAttachError(`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`);
|
const current = draft.value;
|
||||||
continue;
|
draft.setValue(current ? filePath + '\n' + current : filePath + '\n');
|
||||||
}
|
|
||||||
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 [
|
continue;
|
||||||
...prev,
|
}
|
||||||
{
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
id,
|
setAttachError(
|
||||||
filename: file.name,
|
`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`
|
||||||
mimeType: file.type,
|
);
|
||||||
base64Data: base64,
|
continue;
|
||||||
previewUrl: result,
|
}
|
||||||
size: file.size,
|
const reader = new FileReader();
|
||||||
},
|
reader.onload = () => {
|
||||||
];
|
const result = reader.result as string;
|
||||||
});
|
const base64 = result.split(',')[1];
|
||||||
};
|
if (!base64) return;
|
||||||
reader.readAsDataURL(file);
|
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) => {
|
const removeAttachment = useCallback((id: string) => {
|
||||||
setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
|
setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||||
|
|
@ -179,16 +189,16 @@ export const TaskCommentInput = ({
|
||||||
(e: React.ClipboardEvent) => {
|
(e: React.ClipboardEvent) => {
|
||||||
const items = e.clipboardData?.items;
|
const items = e.clipboardData?.items;
|
||||||
if (!items) return;
|
if (!items) return;
|
||||||
const imageFiles: File[] = [];
|
const pastedFiles: File[] = [];
|
||||||
for (const item of Array.from(items)) {
|
for (const item of Array.from(items)) {
|
||||||
if (item.kind === 'file' && ACCEPTED_TYPES.has(item.type)) {
|
if (item.kind === 'file') {
|
||||||
const file = item.getAsFile();
|
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();
|
e.preventDefault();
|
||||||
addFiles(imageFiles);
|
addFiles(pastedFiles);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[addFiles]
|
[addFiles]
|
||||||
|
|
@ -286,7 +296,7 @@ export const TaskCommentInput = ({
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
accept="*/*"
|
||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|
@ -323,10 +333,10 @@ export const TaskCommentInput = ({
|
||||||
disabled={addingComment || pendingAttachments.length >= MAX_ATTACHMENTS}
|
disabled={addingComment || pendingAttachments.length >= MAX_ATTACHMENTS}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
<ImagePlus size={14} />
|
<Paperclip size={14} />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">Attach image (or paste)</TooltipContent>
|
<TooltipContent side="top">Attach file (or paste)</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import {
|
||||||
} from '@renderer/utils/taskReferenceUtils';
|
} from '@renderer/utils/taskReferenceUtils';
|
||||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
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 { ActionMode } from '@renderer/components/team/messages/ActionModeSelector';
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
|
@ -95,8 +95,8 @@ export const MessageComposer = ({
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const dragCounterRef = useRef(0);
|
const dragCounterRef = useRef(0);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [imageRestrictionError, setImageRestrictionError] = useState<string | null>(null);
|
const [fileRestrictionError, setFileRestrictionError] = useState<string | null>(null);
|
||||||
const imageRestrictionTimerRef = useRef(0);
|
const fileRestrictionTimerRef = useRef(0);
|
||||||
const dismissMentionsRef = useRef<(() => void) | null>(null);
|
const dismissMentionsRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
// Cross-team state
|
// Cross-team state
|
||||||
|
|
@ -331,17 +331,17 @@ export const MessageComposer = ({
|
||||||
[draftAddFiles]
|
[draftAddFiles]
|
||||||
);
|
);
|
||||||
|
|
||||||
const showImageRestrictionError = useCallback(() => {
|
const showFileRestrictionError = useCallback(() => {
|
||||||
setImageRestrictionError('Images can only be sent to the team lead');
|
setFileRestrictionError('Files can only be sent to the team lead');
|
||||||
window.clearTimeout(imageRestrictionTimerRef.current);
|
window.clearTimeout(fileRestrictionTimerRef.current);
|
||||||
imageRestrictionTimerRef.current = window.setTimeout(() => {
|
fileRestrictionTimerRef.current = window.setTimeout(() => {
|
||||||
setImageRestrictionError(null);
|
setFileRestrictionError(null);
|
||||||
}, 4000);
|
}, 4000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Cleanup restriction error timer on unmount
|
// Cleanup restriction error timer on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ref = imageRestrictionTimerRef;
|
const ref = fileRestrictionTimerRef;
|
||||||
return () => window.clearTimeout(ref.current);
|
return () => window.clearTimeout(ref.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -373,39 +373,34 @@ export const MessageComposer = ({
|
||||||
if (!isLeadRecipient) {
|
if (!isLeadRecipient) {
|
||||||
const files = e.dataTransfer?.files;
|
const files = e.dataTransfer?.files;
|
||||||
if (files?.length) {
|
if (files?.length) {
|
||||||
const hasImages = Array.from(files).some((f) => f.type.startsWith('image/'));
|
showFileRestrictionError();
|
||||||
if (hasImages) {
|
|
||||||
showImageRestrictionError();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (canAttach) draftHandleDrop(e);
|
if (canAttach) draftHandleDrop(e);
|
||||||
},
|
},
|
||||||
[isLeadRecipient, canAttach, draftHandleDrop, showImageRestrictionError]
|
[isLeadRecipient, canAttach, draftHandleDrop, showFileRestrictionError]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { handlePaste: draftHandlePaste } = draft;
|
const { handlePaste: draftHandlePaste } = draft;
|
||||||
const handlePasteWrapper = useCallback(
|
const handlePasteWrapper = useCallback(
|
||||||
(e: React.ClipboardEvent) => {
|
(e: React.ClipboardEvent) => {
|
||||||
if (!isLeadRecipient) {
|
if (!isLeadRecipient) {
|
||||||
const hasImages = Array.from(e.clipboardData.items).some((item) =>
|
const hasFiles = Array.from(e.clipboardData.items).some((item) => item.kind === 'file');
|
||||||
item.type.startsWith('image/')
|
if (hasFiles) {
|
||||||
);
|
|
||||||
if (hasImages) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showImageRestrictionError();
|
showFileRestrictionError();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (canAttach) draftHandlePaste(e);
|
if (canAttach) draftHandlePaste(e);
|
||||||
},
|
},
|
||||||
[isLeadRecipient, canAttach, draftHandlePaste, showImageRestrictionError]
|
[isLeadRecipient, canAttach, draftHandlePaste, showFileRestrictionError]
|
||||||
);
|
);
|
||||||
|
|
||||||
const remaining = MAX_TEXT_LENGTH - trimmed.length;
|
const remaining = MAX_TEXT_LENGTH - trimmed.length;
|
||||||
const hasAttachmentPreviewContent =
|
const hasAttachmentPreviewContent =
|
||||||
draft.attachments.length > 0 || Boolean(draft.attachmentError ?? imageRestrictionError);
|
draft.attachments.length > 0 || Boolean(draft.attachmentError ?? fileRestrictionError);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -426,7 +421,7 @@ export const MessageComposer = ({
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
accept="*/*"
|
||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileInputChange}
|
onChange={handleFileInputChange}
|
||||||
|
|
@ -444,15 +439,15 @@ export const MessageComposer = ({
|
||||||
disabled={!canAttach}
|
disabled={!canAttach}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
<ImagePlus size={14} />
|
<Paperclip size={14} />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
{!isTeamAlive
|
{!isTeamAlive
|
||||||
? 'Team must be online to attach images'
|
? 'Team must be online to attach files'
|
||||||
: !draft.canAddMore
|
: !draft.canAddMore
|
||||||
? 'Maximum attachments reached'
|
? 'Maximum attachments reached'
|
||||||
: 'Attach images (paste or drag & drop)'}
|
: 'Attach files (paste or drag & drop)'}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
|
|
@ -839,10 +834,10 @@ export const MessageComposer = ({
|
||||||
<AttachmentPreviewList
|
<AttachmentPreviewList
|
||||||
attachments={draft.attachments}
|
attachments={draft.attachments}
|
||||||
onRemove={draft.removeAttachment}
|
onRemove={draft.removeAttachment}
|
||||||
error={draft.attachmentError ?? imageRestrictionError}
|
error={draft.attachmentError ?? fileRestrictionError}
|
||||||
onDismissError={draft.clearAttachmentError}
|
onDismissError={draft.clearAttachmentError}
|
||||||
disabled={attachmentsBlocked}
|
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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { draftStorage } from '@renderer/services/draftStorage';
|
import { draftStorage } from '@renderer/services/draftStorage';
|
||||||
|
import { categorizeFile } from '@shared/constants/attachments';
|
||||||
import {
|
import {
|
||||||
fileToAttachmentPayload,
|
fileToAttachmentPayload,
|
||||||
MAX_FILES,
|
MAX_FILES,
|
||||||
|
|
@ -13,6 +14,8 @@ import type { AttachmentPayload } from '@shared/types';
|
||||||
interface UseAttachmentsOptions {
|
interface UseAttachmentsOptions {
|
||||||
/** When provided, attachments are persisted to IndexedDB under this key. */
|
/** When provided, attachments are persisted to IndexedDB under this key. */
|
||||||
persistenceKey?: string;
|
persistenceKey?: string;
|
||||||
|
/** Called with unsupported files so the consumer can handle them (e.g. insert paths into text). */
|
||||||
|
onUnsupportedFiles?: (files: File[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseAttachmentsReturn {
|
interface UseAttachmentsReturn {
|
||||||
|
|
@ -57,6 +60,8 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
||||||
const keyRef = useRef(persistenceKey);
|
const keyRef = useRef(persistenceKey);
|
||||||
// eslint-disable-next-line react-hooks/refs -- synchronous ref sync during render is intentional to avoid stale key in callbacks
|
// eslint-disable-next-line react-hooks/refs -- synchronous ref sync during render is intentional to avoid stale key in callbacks
|
||||||
keyRef.current = persistenceKey;
|
keyRef.current = persistenceKey;
|
||||||
|
const onUnsupportedRef = useRef(options?.onUnsupportedFiles);
|
||||||
|
onUnsupportedRef.current = options?.onUnsupportedFiles;
|
||||||
|
|
||||||
// Sync ref with state
|
// Sync ref with state
|
||||||
const updateAttachments = useCallback((next: AttachmentPayload[]) => {
|
const updateAttachments = useCallback((next: AttachmentPayload[]) => {
|
||||||
|
|
@ -162,9 +167,30 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
||||||
const fileArray = Array.from(files);
|
const fileArray = Array.from(files);
|
||||||
if (fileArray.length === 0) return;
|
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 batchSize = 0;
|
||||||
let valid = true;
|
let valid = true;
|
||||||
for (const file of fileArray) {
|
for (const file of supported) {
|
||||||
const validation = validateAttachment(file);
|
const validation = validateAttachment(file);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
setError(validation.error);
|
setError(validation.error);
|
||||||
|
|
@ -176,7 +202,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
|
|
||||||
const newPayloads: AttachmentPayload[] = [];
|
const newPayloads: AttachmentPayload[] = [];
|
||||||
for (const file of fileArray) {
|
for (const file of supported) {
|
||||||
try {
|
try {
|
||||||
const payload = await fileToAttachmentPayload(file);
|
const payload = await fileToAttachmentPayload(file);
|
||||||
newPayloads.push(payload);
|
newPayloads.push(payload);
|
||||||
|
|
@ -244,17 +270,19 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
||||||
const items = event.clipboardData?.items;
|
const items = event.clipboardData?.items;
|
||||||
if (!items) return;
|
if (!items) return;
|
||||||
|
|
||||||
const imageFiles: File[] = [];
|
const supportedFiles: File[] = [];
|
||||||
for (const item of Array.from(items)) {
|
for (const item of Array.from(items)) {
|
||||||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
if (item.kind === 'file') {
|
||||||
const file = item.getAsFile();
|
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();
|
event.preventDefault();
|
||||||
void addFiles(imageFiles);
|
void addFiles(supportedFiles);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[addFiles]
|
[addFiles]
|
||||||
|
|
@ -265,14 +293,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const files = event.dataTransfer?.files;
|
const files = event.dataTransfer?.files;
|
||||||
if (!files?.length) return;
|
if (!files?.length) return;
|
||||||
|
void addFiles(Array.from(files));
|
||||||
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');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[addFiles]
|
[addFiles]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
type ComposerDraftSnapshot,
|
type ComposerDraftSnapshot,
|
||||||
composerDraftStorage,
|
composerDraftStorage,
|
||||||
} from '@renderer/services/composerDraftStorage';
|
} from '@renderer/services/composerDraftStorage';
|
||||||
|
import { categorizeFile } from '@shared/constants/attachments';
|
||||||
import {
|
import {
|
||||||
fileToAttachmentPayload,
|
fileToAttachmentPayload,
|
||||||
MAX_FILES,
|
MAX_FILES,
|
||||||
|
|
@ -325,8 +326,30 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
|
||||||
const fileArray = Array.from(files);
|
const fileArray = Array.from(files);
|
||||||
if (fileArray.length === 0) return;
|
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;
|
let batchSize = 0;
|
||||||
for (const file of fileArray) {
|
for (const file of supported) {
|
||||||
const validation = validateAttachment(file);
|
const validation = validateAttachment(file);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
setAttachmentError(validation.error);
|
setAttachmentError(validation.error);
|
||||||
|
|
@ -336,7 +359,7 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
const newPayloads: AttachmentPayload[] = [];
|
const newPayloads: AttachmentPayload[] = [];
|
||||||
for (const file of fileArray) {
|
for (const file of supported) {
|
||||||
try {
|
try {
|
||||||
const payload = await fileToAttachmentPayload(file);
|
const payload = await fileToAttachmentPayload(file);
|
||||||
newPayloads.push(payload);
|
newPayloads.push(payload);
|
||||||
|
|
@ -363,7 +386,7 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
|
||||||
setIsSaved(false);
|
setIsSaved(false);
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
},
|
},
|
||||||
[scheduleSave]
|
[scheduleSave, setText]
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeAttachment = useCallback(
|
const removeAttachment = useCallback(
|
||||||
|
|
@ -397,17 +420,19 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
|
||||||
const items = event.clipboardData?.items;
|
const items = event.clipboardData?.items;
|
||||||
if (!items) return;
|
if (!items) return;
|
||||||
|
|
||||||
const imageFiles: File[] = [];
|
const supportedFiles: File[] = [];
|
||||||
for (const item of Array.from(items)) {
|
for (const item of Array.from(items)) {
|
||||||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
if (item.kind === 'file') {
|
||||||
const file = item.getAsFile();
|
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();
|
event.preventDefault();
|
||||||
void addFiles(imageFiles);
|
void addFiles(supportedFiles);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[addFiles]
|
[addFiles]
|
||||||
|
|
@ -418,14 +443,7 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const files = event.dataTransfer?.files;
|
const files = event.dataTransfer?.files;
|
||||||
if (!files?.length) return;
|
if (!files?.length) return;
|
||||||
|
void addFiles(Array.from(files));
|
||||||
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');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[addFiles]
|
[addFiles]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { categorizeFile, getEffectiveMimeType, isImageMime } from '@shared/constants/attachments';
|
||||||
|
|
||||||
import type { AttachmentPayload, ImageMimeType } from '@shared/types';
|
import type { AttachmentPayload, ImageMimeType } from '@shared/types';
|
||||||
|
|
||||||
export const ALLOWED_MIME_TYPES = new Set<ImageMimeType>([
|
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 } {
|
export function validateAttachment(file: File): { valid: true } | { valid: false; error: string } {
|
||||||
if (!isImageMimeType(file.type)) {
|
const cat = categorizeFile(file);
|
||||||
return { valid: false, error: `Unsupported file type: ${file.type}` };
|
if (cat === 'unsupported') {
|
||||||
|
return { valid: false, error: `Unsupported file type: ${file.name}` };
|
||||||
}
|
}
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
return { valid: false, error: `File "${file.name}" exceeds 10MB limit` };
|
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();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
const dataUrl = reader.result as string;
|
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] ?? '';
|
const base64 = dataUrl.split(',')[1] ?? '';
|
||||||
resolve({
|
resolve({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
mimeType: file.type,
|
mimeType: getEffectiveMimeType(file),
|
||||||
size: file.size,
|
size: file.size,
|
||||||
data: base64,
|
data: base64,
|
||||||
});
|
});
|
||||||
|
|
@ -45,6 +48,8 @@ export async function fileToAttachmentPayload(file: File): Promise<AttachmentPay
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { categorizeFile, isImageMime };
|
||||||
|
|
||||||
export function formatFileSize(bytes: number): string {
|
export function formatFileSize(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
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 './agentBlocks';
|
||||||
|
export * from './attachments';
|
||||||
export * from './cache';
|
export * from './cache';
|
||||||
export * from './cli';
|
export * from './cli';
|
||||||
export * from './crossTeam';
|
export * from './crossTeam';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue