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:
parent
b20b69066e
commit
5cf9751b41
6 changed files with 80 additions and 48 deletions
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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` };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue