feat: polish message attachment downloads
This commit is contained in:
78
src/components/messageStream/utils/downloadFile.ts
Normal file
78
src/components/messageStream/utils/downloadFile.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
type SaveFilePicker = (options?: {
|
||||
suggestedName?: string;
|
||||
types?: Array<{
|
||||
description?: string;
|
||||
accept: Record<string, string[]>;
|
||||
}>;
|
||||
}) => Promise<{
|
||||
createWritable: () => Promise<{
|
||||
write: (data: Blob) => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
type NavigatorWithFileShare = Navigator & {
|
||||
canShare?: (data: { files?: File[] }) => boolean;
|
||||
share?: (data: { files?: File[]; title?: string }) => Promise<void>;
|
||||
};
|
||||
|
||||
type WindowWithSavePicker = Window & {
|
||||
showSaveFilePicker?: SaveFilePicker;
|
||||
};
|
||||
|
||||
export async function downloadFile(url: string, filename: string) {
|
||||
const res = await fetch(url, { credentials: "include" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
|
||||
const blob = await res.blob();
|
||||
const safeName = filename || "download";
|
||||
|
||||
if (window.isSecureContext) {
|
||||
const picker = (window as WindowWithSavePicker).showSaveFilePicker;
|
||||
if (picker) {
|
||||
const handle = await picker({
|
||||
suggestedName: safeName,
|
||||
types: blob.type
|
||||
? [
|
||||
{
|
||||
description: "File",
|
||||
accept: { [blob.type]: [extensionFromName(safeName)] },
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(blob);
|
||||
await writable.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const file = new File([blob], safeName, {
|
||||
type: blob.type || "application/octet-stream",
|
||||
});
|
||||
const nav = navigator as NavigatorWithFileShare;
|
||||
if (nav.canShare?.({ files: [file] }) && nav.share) {
|
||||
await nav.share({ files: [file], title: safeName });
|
||||
return;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
triggerDownload(objectUrl, safeName);
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
||||
}
|
||||
|
||||
function triggerDownload(url: string, filename: string) {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.style.display = "none";
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
function extensionFromName(filename: string) {
|
||||
const match = /\.[^.]+$/.exec(filename);
|
||||
return match?.[0] || ".bin";
|
||||
}
|
||||
47
src/components/messageStream/utils/filenameDisplay.test.ts
Normal file
47
src/components/messageStream/utils/filenameDisplay.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
filenameWithExtension,
|
||||
middleEllipsisFilename,
|
||||
} from "./filenameDisplay";
|
||||
|
||||
describe("filenameWithExtension", () => {
|
||||
it("keeps filenames that already have an extension", () => {
|
||||
expect(filenameWithExtension("sample.pdf", "image/png")).toBe("sample.pdf");
|
||||
});
|
||||
|
||||
it("adds common extensions from mime type", () => {
|
||||
expect(filenameWithExtension("uuid-file", "image/png")).toBe(
|
||||
"uuid-file.png",
|
||||
);
|
||||
expect(filenameWithExtension("uuid-file", "audio/mpeg")).toBe(
|
||||
"uuid-file.mp3",
|
||||
);
|
||||
expect(filenameWithExtension("uuid-file", "application/pdf")).toBe(
|
||||
"uuid-file.pdf",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("middleEllipsisFilename", () => {
|
||||
it("keeps short filenames unchanged", () => {
|
||||
expect(middleEllipsisFilename("sample.pdf")).toBe("sample.pdf");
|
||||
});
|
||||
|
||||
it("preserves the extension when truncating", () => {
|
||||
expect(middleEllipsisFilename("afbb9ebe-5af2-425a-9779-9d7.jpg", 22)).toBe(
|
||||
"afbb9ebe-5af2…-9d7.jpg",
|
||||
);
|
||||
expect(middleEllipsisFilename("afbb9ebe-5af2-425a-9779-9d7.png", 22)).toBe(
|
||||
"afbb9ebe-5af2…-9d7.png",
|
||||
);
|
||||
expect(middleEllipsisFilename("afbb9ebe-5af2-425a-9779-9d7.mp3", 22)).toBe(
|
||||
"afbb9ebe-5af2…-9d7.mp3",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles filenames without extension", () => {
|
||||
expect(middleEllipsisFilename("afbb9ebe-5af2-425a-9779-9d7", 18)).toBe(
|
||||
"afbb9ebe-5af2…-9d7",
|
||||
);
|
||||
});
|
||||
});
|
||||
82
src/components/messageStream/utils/filenameDisplay.ts
Normal file
82
src/components/messageStream/utils/filenameDisplay.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
const MIME_EXTENSION: Record<string, string> = {
|
||||
"application/pdf": ".pdf",
|
||||
"application/postscript": ".ai",
|
||||
"application/illustrator": ".ai",
|
||||
"application/zip": ".zip",
|
||||
"application/x-zip-compressed": ".zip",
|
||||
"application/vnd.ms-powerpoint": ".ppt",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
|
||||
".pptx",
|
||||
"application/msword": ".doc",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||
".docx",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/mp3": ".mp3",
|
||||
"video/mp4": ".mp4",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
};
|
||||
|
||||
export function filenameWithExtension(filename: string, mime = ""): string {
|
||||
if (hasFileExtension(filename)) return filename;
|
||||
|
||||
const ext = extensionFromMime(mime);
|
||||
return ext ? `${filename}${ext}` : filename;
|
||||
}
|
||||
|
||||
export function middleEllipsisFilename(
|
||||
filename: string,
|
||||
maxLength = 24,
|
||||
): string {
|
||||
if (filename.length <= maxLength) return filename;
|
||||
|
||||
const { base, ext } = splitFilename(filename);
|
||||
const ellipsis = "…";
|
||||
const availableBaseLength = maxLength - ext.length - ellipsis.length;
|
||||
|
||||
if (availableBaseLength < 3) {
|
||||
return `${filename.slice(0, Math.max(1, maxLength - 1))}${ellipsis}`;
|
||||
}
|
||||
|
||||
const tailLength = Math.min(
|
||||
4,
|
||||
Math.floor(availableBaseLength / 2),
|
||||
base.length,
|
||||
);
|
||||
const headLength = availableBaseLength - tailLength;
|
||||
|
||||
if (headLength + tailLength >= base.length) return filename;
|
||||
return `${base.slice(0, headLength)}${ellipsis}${base.slice(-tailLength)}${ext}`;
|
||||
}
|
||||
|
||||
function splitFilename(filename: string): { base: string; ext: string } {
|
||||
const dotIndex = filename.lastIndexOf(".");
|
||||
if (!hasFileExtension(filename)) return { base: filename, ext: "" };
|
||||
return {
|
||||
base: filename.slice(0, dotIndex),
|
||||
ext: filename.slice(dotIndex),
|
||||
};
|
||||
}
|
||||
|
||||
function hasFileExtension(filename: string): boolean {
|
||||
const dotIndex = filename.lastIndexOf(".");
|
||||
return (
|
||||
dotIndex > 0 &&
|
||||
dotIndex < filename.length - 1 &&
|
||||
filename.length - dotIndex <= 12
|
||||
);
|
||||
}
|
||||
|
||||
function extensionFromMime(mime: string): string {
|
||||
const cleanMime = mime.toLowerCase().split(";")[0].trim();
|
||||
if (!cleanMime) return "";
|
||||
if (MIME_EXTENSION[cleanMime]) return MIME_EXTENSION[cleanMime];
|
||||
|
||||
if (cleanMime.startsWith("image/")) return `.${cleanMime.slice(6)}`;
|
||||
if (cleanMime.startsWith("video/")) return `.${cleanMime.slice(6)}`;
|
||||
if (cleanMime.startsWith("audio/")) return `.${cleanMime.slice(6)}`;
|
||||
|
||||
return "";
|
||||
}
|
||||
Reference in New Issue
Block a user