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',
data: att.data,
},
title: att.filename,
});
} 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,
});
const decoded = Buffer.from(att.data, 'base64').toString('utf-8');
if (decoded.includes('\uFFFD')) {
// Non-UTF-8 file: fallback to base64 document to avoid garbled content
contentBlocks.push({
type: 'document',
source: {
type: 'base64',
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 {
// Image (default) → image block
contentBlocks.push({

View file

@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@ -19,7 +20,7 @@ import {
stripEncodedTaskReferenceMetadata,
} from '@renderer/utils/taskReferenceUtils';
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 type { MentionSuggestion } from '@renderer/types/mention';
@ -254,25 +255,40 @@ export const TaskCommentInput = ({
{/* Pending attachment previews */}
{pendingAttachments.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-1.5">
{pendingAttachments.map((att, idx) => (
<div
key={att.id}
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)]"
onClick={() => setLightboxIndex(idx)}
>
<img src={att.previewUrl} alt={att.filename} className="size-full object-cover" />
<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);
}}
{pendingAttachments.map((att, idx) => {
const isImage = isImageMime(att.mimeType);
const lightboxIdx = isImage
? pendingAttachments.slice(0, idx).filter((a) => isImageMime(a.mimeType)).length
: -1;
return (
<div
key={att.id}
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)]"
onClick={isImage ? () => setLightboxIndex(lightboxIdx) : undefined}
>
<Trash2 size={8} />
</button>
</div>
))}
{isImage ? (
<img src={att.previewUrl} alt={att.filename} className="size-full object-cover" />
) : (
<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>
) : null}
@ -280,13 +296,15 @@ export const TaskCommentInput = ({
<ImageLightbox
open
onClose={() => setLightboxIndex(null)}
slides={pendingAttachments.map((att) => ({
src: att.previewUrl,
alt: att.filename,
title: att.filename,
}))}
slides={pendingAttachments
.filter((att) => isImageMime(att.mimeType))
.map((att) => ({
src: att.previewUrl,
alt: att.filename,
title: att.filename,
}))}
index={lightboxIndex}
showCounter={pendingAttachments.length > 1}
showCounter={pendingAttachments.filter((a) => isImageMime(a.mimeType)).length > 1}
/>
) : null}

View file

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

View file

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

View file

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

View file

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