feat(attachments): update TypeScript configuration and enhance attachment handling

- Upgraded TypeScript target and library to ES2023 for improved language features.
- Enhanced TeamProvisioningService to better handle plain text attachments with UTF-8 validation.
- Improved TaskCommentInput to differentiate between image and non-image file previews, including a new FileIcon for non-image files.
- Refactored attachment handling in useAttachments and useComposerDraft hooks to simplify file processing.
- Added validation for empty files in attachmentUtils to improve user feedback on unsupported uploads.
This commit is contained in:
iliya 2026-03-23 17:33:39 +02:00
parent b20b69066e
commit 5cf9751b41
6 changed files with 80 additions and 48 deletions

View file

@ -3624,18 +3624,33 @@ export class TeamProvisioningService {
media_type: 'application/pdf', media_type: 'application/pdf',
data: att.data, data: att.data,
}, },
title: att.filename,
}); });
} else if (att.mimeType === 'text/plain') { } else if (att.mimeType === 'text/plain') {
// Text file → document block with text source (decode base64 → UTF-8) // Text file → document block with text source (decode base64 → UTF-8)
contentBlocks.push({ const decoded = Buffer.from(att.data, 'base64').toString('utf-8');
type: 'document', if (decoded.includes('\uFFFD')) {
source: { // Non-UTF-8 file: fallback to base64 document to avoid garbled content
type: 'text', contentBlocks.push({
media_type: 'text/plain', type: 'document',
data: Buffer.from(att.data, 'base64').toString('utf-8'), source: {
}, type: 'base64',
title: att.filename, media_type: 'text/plain',
}); data: att.data,
},
title: att.filename,
});
} else {
contentBlocks.push({
type: 'document',
source: {
type: 'text',
media_type: 'text/plain',
data: decoded,
},
title: att.filename,
});
}
} else { } else {
// Image (default) → image block // Image (default) → image block
contentBlocks.push({ contentBlocks.push({

View file

@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@ -19,7 +20,7 @@ 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 { categorizeFile, getEffectiveMimeType } from '@shared/constants/attachments'; import { categorizeFile, getEffectiveMimeType, isImageMime } from '@shared/constants/attachments';
import { Mic, Paperclip, Send, Trash2, X } from 'lucide-react'; import { Mic, Paperclip, Send, Trash2, X } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
@ -254,25 +255,40 @@ export const TaskCommentInput = ({
{/* Pending attachment previews */} {/* Pending attachment previews */}
{pendingAttachments.length > 0 ? ( {pendingAttachments.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-1.5"> <div className="mb-2 flex flex-wrap gap-1.5">
{pendingAttachments.map((att, idx) => ( {pendingAttachments.map((att, idx) => {
<div const isImage = isImageMime(att.mimeType);
key={att.id} const lightboxIdx = isImage
className="group relative size-14 cursor-pointer overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] transition-colors hover:border-[var(--color-border-emphasis)]" ? pendingAttachments.slice(0, idx).filter((a) => isImageMime(a.mimeType)).length
onClick={() => setLightboxIndex(idx)} : -1;
> return (
<img src={att.previewUrl} alt={att.filename} className="size-full object-cover" /> <div
<button key={att.id}
type="button" className="group relative size-14 cursor-pointer overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] transition-colors hover:border-[var(--color-border-emphasis)]"
className="absolute right-0.5 top-0.5 rounded bg-black/60 p-0.5 text-white opacity-0 transition-opacity hover:bg-red-600 group-hover:opacity-100" onClick={isImage ? () => setLightboxIndex(lightboxIdx) : undefined}
onClick={(e) => {
e.stopPropagation();
removeAttachment(att.id);
}}
> >
<Trash2 size={8} /> {isImage ? (
</button> <img src={att.previewUrl} alt={att.filename} className="size-full object-cover" />
</div> ) : (
))} <div className="flex size-full flex-col items-center justify-center gap-0.5">
<FileIcon fileName={att.filename} className="size-5" />
<span className="max-w-[48px] truncate text-[7px] text-[var(--color-text-muted)]">
{att.filename}
</span>
</div>
)}
<button
type="button"
className="absolute right-0.5 top-0.5 rounded bg-black/60 p-0.5 text-white opacity-0 transition-opacity hover:bg-red-600 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
removeAttachment(att.id);
}}
>
<Trash2 size={8} />
</button>
</div>
);
})}
</div> </div>
) : null} ) : null}
@ -280,13 +296,15 @@ export const TaskCommentInput = ({
<ImageLightbox <ImageLightbox
open open
onClose={() => setLightboxIndex(null)} onClose={() => setLightboxIndex(null)}
slides={pendingAttachments.map((att) => ({ slides={pendingAttachments
src: att.previewUrl, .filter((att) => isImageMime(att.mimeType))
alt: att.filename, .map((att) => ({
title: att.filename, src: att.previewUrl,
}))} alt: att.filename,
title: att.filename,
}))}
index={lightboxIndex} index={lightboxIndex}
showCounter={pendingAttachments.length > 1} showCounter={pendingAttachments.filter((a) => isImageMime(a.mimeType)).length > 1}
/> />
) : null} ) : null}

View file

@ -270,19 +270,17 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
const items = event.clipboardData?.items; const items = event.clipboardData?.items;
if (!items) return; if (!items) return;
const supportedFiles: File[] = []; const pastedFiles: File[] = [];
for (const item of Array.from(items)) { for (const item of Array.from(items)) {
if (item.kind === 'file') { if (item.kind === 'file') {
const file = item.getAsFile(); const file = item.getAsFile();
if (file && categorizeFile(file) !== 'unsupported') { if (file) pastedFiles.push(file);
supportedFiles.push(file);
}
} }
} }
if (supportedFiles.length > 0) { if (pastedFiles.length > 0) {
event.preventDefault(); event.preventDefault();
void addFiles(supportedFiles); void addFiles(pastedFiles);
} }
}, },
[addFiles] [addFiles]

View file

@ -420,19 +420,17 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
const items = event.clipboardData?.items; const items = event.clipboardData?.items;
if (!items) return; if (!items) return;
const supportedFiles: File[] = []; const pastedFiles: File[] = [];
for (const item of Array.from(items)) { for (const item of Array.from(items)) {
if (item.kind === 'file') { if (item.kind === 'file') {
const file = item.getAsFile(); const file = item.getAsFile();
if (file && categorizeFile(file) !== 'unsupported') { if (file) pastedFiles.push(file);
supportedFiles.push(file);
}
} }
} }
if (supportedFiles.length > 0) { if (pastedFiles.length > 0) {
event.preventDefault(); event.preventDefault();
void addFiles(supportedFiles); void addFiles(pastedFiles);
} }
}, },
[addFiles] [addFiles]

View file

@ -22,6 +22,9 @@ export function validateAttachment(file: File): { valid: true } | { valid: false
if (cat === 'unsupported') { if (cat === 'unsupported') {
return { valid: false, error: `Unsupported file type: ${file.name}` }; return { valid: false, error: `Unsupported file type: ${file.name}` };
} }
if (file.size === 0) {
return { valid: false, error: `File "${file.name}" is empty` };
}
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` };
} }

View file

@ -1,8 +1,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2023",
"module": "ESNext", "module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx", "jsx": "react-jsx",
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,