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";
}