diff --git a/__tests__/components/drops/create/utils/CreateDropActionsRow.test.tsx b/__tests__/components/drops/create/utils/CreateDropActionsRow.test.tsx index d49d924434..4902cfaf65 100644 --- a/__tests__/components/drops/create/utils/CreateDropActionsRow.test.tsx +++ b/__tests__/components/drops/create/utils/CreateDropActionsRow.test.tsx @@ -23,7 +23,10 @@ describe('CreateDropActionsRow', () => { const button = screen.getByRole('button', { name: /select audio file/i }); const input = button.querySelector('input') as HTMLInputElement; fireEvent.change(input, { target: { files } }); - expect(toast).toHaveBeenCalledWith({ message: 'You can only upload up to 4 files at a time', type: 'error' }); + expect(toast).toHaveBeenCalledWith({ + message: `You can only upload up to ${MAX_DROP_UPLOAD_FILES} files at a time`, + type: 'error' + }); expect(setFiles).not.toHaveBeenCalled(); }); @@ -38,6 +41,13 @@ describe('CreateDropActionsRow', () => { expect(toast).not.toHaveBeenCalled(); }); + it('does not expose csv in the legacy input picker', () => { + renderComponent({ canAddPart: false, isStormMode: false, setFiles: jest.fn(), breakIntoStorm: jest.fn() }); + const button = screen.getByRole('button', { name: /select audio file/i }); + const input = button.querySelector('input') as HTMLInputElement; + expect(input).toHaveAttribute('accept', 'image/*,video/*,audio/*'); + }); + it('renders break into storm button when allowed and handles click', () => { const handler = jest.fn(); renderComponent({ canAddPart: true, isStormMode: false, setFiles: jest.fn(), breakIntoStorm: handler }); diff --git a/__tests__/components/drops/view/item/content/media/DropListItemContentMedia.test.tsx b/__tests__/components/drops/view/item/content/media/DropListItemContentMedia.test.tsx index 97007829da..52908c4927 100644 --- a/__tests__/components/drops/view/item/content/media/DropListItemContentMedia.test.tsx +++ b/__tests__/components/drops/view/item/content/media/DropListItemContentMedia.test.tsx @@ -6,6 +6,7 @@ jest.mock('@/components/drops/view/item/content/media/DropListItemContentMediaIm jest.mock('@/components/drops/view/item/content/media/DropListItemContentMediaVideo', () => ({ __esModule: true, default: () =>
})); jest.mock('@/components/drops/view/item/content/media/DropListItemContentMediaAudio', () => ({ __esModule: true, default: () =>
})); jest.mock('@/components/drops/view/item/content/media/DropListItemContentMediaGLB', () => ({ __esModule: true, default: () =>
})); +jest.mock('@/components/drops/view/item/content/media/DropMediaAttachmentCard', () => ({ __esModule: true, default: () =>
})); jest.mock('next/dynamic', () => (importer: any) => () =>
); describe('DropListItemContentMedia', () => { @@ -29,6 +30,11 @@ describe('DropListItemContentMedia', () => { expect(screen.getByTestId('glb')).toBeInTheDocument(); }); + it('renders attachment card for csv', () => { + render(); + expect(screen.getByTestId('attachment')).toBeInTheDocument(); + }); + it('renders empty fragment for unknown type', () => { const { container } = render(); expect(container.firstChild).toBeNull(); diff --git a/__tests__/components/drops/view/item/content/media/MediaDisplay.test.tsx b/__tests__/components/drops/view/item/content/media/MediaDisplay.test.tsx index 4a9cfb435d..fd43427476 100644 --- a/__tests__/components/drops/view/item/content/media/MediaDisplay.test.tsx +++ b/__tests__/components/drops/view/item/content/media/MediaDisplay.test.tsx @@ -10,6 +10,9 @@ jest.mock('@/components/drops/view/item/content/media/MediaDisplayVideo', () => jest.mock('@/components/drops/view/item/content/media/MediaDisplayAudio', () => (props: any) => (
)); +jest.mock('@/components/drops/view/item/content/media/DropMediaAttachmentCard', () => (props: any) => ( +
+)); jest.mock('next/dynamic', () => (importFn: any) => importFn().then ? () =>
: () =>
); @@ -41,6 +44,13 @@ describe('MediaDisplay', () => { expect(screen.getByTestId('glb')).toBeInTheDocument(); }); + it('renders attachment card for csv', () => { + render(); + const node = screen.getByTestId('attachment'); + expect(node).toHaveAttribute('data-src', 'file.csv'); + expect(node).toHaveAttribute('data-mime', 'text/csv'); + }); + it('renders empty fragment for unknown', () => { const { container } = render(); expect(container).toBeEmptyDOMElement(); diff --git a/__tests__/components/waves/CreateDropActions.test.tsx b/__tests__/components/waves/CreateDropActions.test.tsx index f951527959..13dace168f 100644 --- a/__tests__/components/waves/CreateDropActions.test.tsx +++ b/__tests__/components/waves/CreateDropActions.test.tsx @@ -269,6 +269,25 @@ describe("CreateDropActions", () => { expect(fileInput).toHaveAttribute("multiple"); }); + it("accepts csv uploads in chat mode only", () => { + const { rerender } = render( + + ); + + let fileInputs = screen.getAllByLabelText("Upload a file"); + let fileInput = fileInputs[0]?.querySelector('input[type="file"]'); + expect(fileInput).toHaveAttribute( + "accept", + "image/*,video/*,audio/*,.csv,text/csv" + ); + + rerender(); + + fileInputs = screen.getAllByLabelText("Upload a file"); + fileInput = fileInputs[0]?.querySelector('input[type="file"]'); + expect(fileInput).toHaveAttribute("accept", "image/*,video/*,audio/*"); + }); + it("passes correct props to StormButton", () => { render( { + it("normalizes csv files to text/csv", () => { + const file = new File(["a,b"], "data.csv", { + type: "application/vnd.ms-excel", + }); + + expect(getContentType(file)).toBe("text/csv"); + }); + + it("keeps non-csv mime types unchanged", () => { + const file = new File(["x"], "image.png", { + type: "image/png", + }); + + expect(getContentType(file)).toBe("image/png"); + }); +}); diff --git a/components/drops/create/utils/file/CreateDropSelectedFileIcon.tsx b/components/drops/create/utils/file/CreateDropSelectedFileIcon.tsx index 36f79e6dc5..c64974b634 100644 --- a/components/drops/create/utils/file/CreateDropSelectedFileIcon.tsx +++ b/components/drops/create/utils/file/CreateDropSelectedFileIcon.tsx @@ -4,6 +4,7 @@ enum FILE_TYPES { IMAGE = "IMAGE", VIDEO = "VIDEO", AUDIO = "AUDIO", + CSV = "CSV", } export default function CreateDropSelectedFileIcon({ @@ -24,6 +25,9 @@ export default function CreateDropSelectedFileIcon({ if (file.type.includes("audio")) { return FILE_TYPES.AUDIO; } + if (file.type === "text/csv" || file.name.toLowerCase().endsWith(".csv")) { + return FILE_TYPES.CSV; + } return null; }; @@ -88,6 +92,24 @@ export default function CreateDropSelectedFileIcon({ > ); + case FILE_TYPES.CSV: + return ( + + ); default: assertUnreachable(fileType); return null; diff --git a/components/drops/create/utils/file/CreateDropSelectedFilePreview.tsx b/components/drops/create/utils/file/CreateDropSelectedFilePreview.tsx index 1023b462a5..d6d0a0fd3e 100644 --- a/components/drops/create/utils/file/CreateDropSelectedFilePreview.tsx +++ b/components/drops/create/utils/file/CreateDropSelectedFilePreview.tsx @@ -3,6 +3,7 @@ enum FILE_TYPES { IMAGE = "IMAGE", VIDEO = "VIDEO", AUDIO = "AUDIO", + CSV = "CSV", UNKNOWN = "UNKNOWN", } @@ -21,6 +22,9 @@ export default function CreateDropSelectedFilePreview({ if (file.type.includes("audio")) { return FILE_TYPES.AUDIO; } + if (file.type === "text/csv" || file.name.toLowerCase().endsWith(".csv")) { + return FILE_TYPES.CSV; + } return FILE_TYPES.UNKNOWN; }; @@ -46,6 +50,18 @@ export default function CreateDropSelectedFilePreview({ Your browser does not support the audio tag. ), + [FILE_TYPES.CSV]: ( +
+
+
+ CSV attachment +
+
+ {file.name} +
+
+
+ ), [FILE_TYPES.UNKNOWN]: <>, }; diff --git a/components/drops/view/item/content/media/DropListItemContentMedia.tsx b/components/drops/view/item/content/media/DropListItemContentMedia.tsx index 16411d3ad5..5ccfe9c332 100644 --- a/components/drops/view/item/content/media/DropListItemContentMedia.tsx +++ b/components/drops/view/item/content/media/DropListItemContentMedia.tsx @@ -10,6 +10,7 @@ import DropListItemContentMediaImage from "./DropListItemContentMediaImage"; import DropListItemContentMediaVideo from "./DropListItemContentMediaVideo"; import SandboxedExternalIframe from "@/components/common/SandboxedExternalIframe"; import InteractiveIcon from "@/components/drops/media/InteractiveIcon"; +import DropMediaAttachmentCard from "./DropMediaAttachmentCard"; enum MediaType { IMAGE = "IMAGE", @@ -17,6 +18,7 @@ enum MediaType { AUDIO = "AUDIO", GLB = "GLB", HTML = "HTML", + CSV = "CSV", UNKNOWN = "UNKNOWN", } @@ -73,6 +75,9 @@ export default function DropListItemContentMedia({ if (media_mime_type === "text/html") { return MediaType.HTML; } + if (media_mime_type === "text/csv") { + return MediaType.CSV; + } return MediaType.UNKNOWN; }; @@ -127,6 +132,13 @@ export default function DropListItemContentMedia({ className="tw-h-full tw-w-full" /> ); + case MediaType.CSV: + return ( + + ); case MediaType.UNKNOWN: return <>; default: diff --git a/components/drops/view/item/content/media/DropMediaAttachmentCard.tsx b/components/drops/view/item/content/media/DropMediaAttachmentCard.tsx new file mode 100644 index 0000000000..92f74eb9da --- /dev/null +++ b/components/drops/view/item/content/media/DropMediaAttachmentCard.tsx @@ -0,0 +1,58 @@ +"use client"; + +import Download from "@/components/download/Download"; +import { getFileInfoFromUrl } from "@/helpers/file.helpers"; + +export default function DropMediaAttachmentCard({ + src, + mimeType, +}: { + readonly src: string; + readonly mimeType: string; +}) { + const fileInfo = getFileInfoFromUrl(src); + const extension = + fileInfo?.extension ?? (mimeType === "text/csv" ? "csv" : ""); + const name = fileInfo?.name ?? "attachment"; + const displayName = extension ? `${name}.${extension}` : name; + const label = mimeType === "text/csv" ? "CSV attachment" : "Attachment"; + + return ( +
+
+
+ +
+
+
+ {label} +
+
+ {displayName} +
+
+
+ +
+ ); +} diff --git a/components/drops/view/item/content/media/MediaDisplay.tsx b/components/drops/view/item/content/media/MediaDisplay.tsx index d0d0596a0d..9ba8413bdc 100644 --- a/components/drops/view/item/content/media/MediaDisplay.tsx +++ b/components/drops/view/item/content/media/MediaDisplay.tsx @@ -4,6 +4,7 @@ import dynamic from "next/dynamic"; import SandboxedExternalIframe from "@/components/common/SandboxedExternalIframe"; import { ImageScale } from "@/helpers/image.helpers"; import MediaDisplayAudio from "./MediaDisplayAudio"; +import DropMediaAttachmentCard from "./DropMediaAttachmentCard"; import MediaDisplayImage from "./MediaDisplayImage"; import MediaDisplayVideo from "./MediaDisplayVideo"; @@ -13,6 +14,7 @@ enum MediaType { AUDIO = "AUDIO", GLB = "GLB", HTML = "HTML", + CSV = "CSV", UNKNOWN = "UNKNOWN", } @@ -63,6 +65,9 @@ export default function MediaDisplay({ if (media_mime_type === "text/html") { return MediaType.HTML; } + if (media_mime_type === "text/csv") { + return MediaType.CSV; + } return MediaType.UNKNOWN; }; @@ -105,6 +110,13 @@ export default function MediaDisplay({ className="tw-h-full tw-w-full" /> ); + case MediaType.CSV: + return ( + + ); case MediaType.UNKNOWN: return <>; default: diff --git a/components/waves/CreateDropActions.tsx b/components/waves/CreateDropActions.tsx index bbf87a31e7..43664c629a 100644 --- a/components/waves/CreateDropActions.tsx +++ b/components/waves/CreateDropActions.tsx @@ -57,6 +57,10 @@ const CreateDropActions: React.FC = memo( e.target.value = ""; }; + const fileInputAccept = isDropMode + ? "image/*,video/*,audio/*" + : "image/*,video/*,audio/*,.csv,text/csv"; + useEffect(() => { if (!showGifPicker || !gifPickerEnabled) return; @@ -167,7 +171,7 @@ const CreateDropActions: React.FC = memo( diff --git a/components/waves/CreateDropContent.tsx b/components/waves/CreateDropContent.tsx index 309c1fd306..d83b7d199a 100644 --- a/components/waves/CreateDropContent.tsx +++ b/components/waves/CreateDropContent.tsx @@ -139,6 +139,9 @@ const isMetadataValuePresent = (value: string | number | null): boolean => { const hasMetadataContent = (metadata: CreateDropMetadataType[]): boolean => metadata.some((item) => isMetadataValuePresent(item.value)); +const isCsvFile = (file: File): boolean => + file.type === "text/csv" || file.name.toLowerCase().endsWith(".csv"); + const hasSubmissionContent = ({ markdown, files, @@ -966,7 +969,24 @@ const CreateDropContent: React.FC = ({ }, [activeDrop, isApp, focusMobileInput]); const handleFileChange = (newFiles: File[]) => { - let updatedFiles = [...files, ...newFiles]; + let acceptedFiles = [...newFiles]; + + if (isDropMode) { + const rejectedCsvFiles = acceptedFiles.filter(isCsvFile); + if (rejectedCsvFiles.length) { + setToast({ + message: "CSV attachments are only supported on chat drops.", + type: "error", + }); + acceptedFiles = acceptedFiles.filter((file) => !isCsvFile(file)); + } + } + + if (!acceptedFiles.length) { + return; + } + + let updatedFiles = [...files, ...acceptedFiles]; let removedCount = 0; if (updatedFiles.length > MAX_DROP_UPLOAD_FILES) { diff --git a/services/uploads/multipartUploadCore.ts b/services/uploads/multipartUploadCore.ts index 6fe1c430bf..bbbd7d5e26 100644 --- a/services/uploads/multipartUploadCore.ts +++ b/services/uploads/multipartUploadCore.ts @@ -27,11 +27,15 @@ interface MultipartUploadCoreParams { } export function getContentType(file: File): string { + const fileName = file.name.toLowerCase(); + if (fileName.endsWith(".csv")) { + return "text/csv"; + } + if (file.type) { return file.type; } - const fileName = file.name.toLowerCase(); if (fileName.endsWith(".glb")) { return "model/gltf-binary"; }