diff --git a/src/components/RecommendedCard.tsx b/src/components/RecommendedCard.tsx index d90d2a2..707c9e2 100644 --- a/src/components/RecommendedCard.tsx +++ b/src/components/RecommendedCard.tsx @@ -6,6 +6,7 @@ import { useI18n } from "../i18n"; import { useMemo } from "react"; import { formatDateYmd } from "../utils/format"; import { officialRecommendationCoverFallbacks } from "./FigmaBanner"; +import { downloadFile } from "./messageStream/utils/downloadFile"; function isPlaceholderAsset(path: string | undefined | null) { return !path || path.includes("placeholder-cover"); @@ -98,7 +99,7 @@ export function RecommendedCard({ } catch { /* ignore */ } - window.open(dl, "_blank", "noopener,noreferrer"); + void downloadFile(dl, r.title).catch(() => {}); }} > diff --git a/src/components/messageStream/bubbles/FileDocBubble.tsx b/src/components/messageStream/bubbles/FileDocBubble.tsx index 0988555..6df5051 100644 --- a/src/components/messageStream/bubbles/FileDocBubble.tsx +++ b/src/components/messageStream/bubbles/FileDocBubble.tsx @@ -1,38 +1,40 @@ -import { Download } from "lucide-react"; +import { ArrowDownToLine } from "lucide-react"; import { postNoBody } from "../../../api"; import { useI18n } from "../../../i18n"; import type { Attachment, Post } from "../../../types/post"; +import { downloadFile } from "../utils/downloadFile"; import { fileIcon } from "../utils/fileIcon"; +import { + filenameWithExtension, + middleEllipsisFilename, +} from "../utils/filenameDisplay"; import { formatBytes } from "../utils/formatBytes"; import { postDisplayText } from "../utils/postText"; function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) { const isImageAsDoc = att.mime.startsWith("image/"); const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename }); + const displayFilename = filenameWithExtension(att.filename, att.mime); + + const handleDownload = () => { + void postNoBody(`/api/posts/${postId}/attachments/${att.id}/download`); + void downloadFile(att.url, displayFilename).catch(() => {}); + }; return ( - { - void postNoBody(`/api/posts/${postId}/attachments/${att.id}/download`); - }} - > -
+
+
+
+ +
+
-
- {att.filename} +
+ {middleEllipsisFilename(displayFilename)}
{formatBytes(att.sizeBytes)}
-
+
); } diff --git a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx index af4669d..f072cff 100644 --- a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx +++ b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx @@ -30,7 +30,7 @@ export function ImageWithTextBubble({ post }: { post: Post }) { /> {text ? ( -
+
{autolink(text)}
diff --git a/src/components/messageStream/bubbles/VideoBubble.tsx b/src/components/messageStream/bubbles/VideoBubble.tsx index 4720ba8..c94ee14 100644 --- a/src/components/messageStream/bubbles/VideoBubble.tsx +++ b/src/components/messageStream/bubbles/VideoBubble.tsx @@ -1,10 +1,11 @@ -import { Download, Play } from "lucide-react"; +import { ArrowDownToLine, Play } from "lucide-react"; import { useRef, useState } from "react"; import { postNoBody } from "../../../api"; import { useI18n } from "../../../i18n"; import type { Post } from "../../../types/post"; import { useVideoPlayer } from "../overlays/VideoPlayer"; import { autolink } from "../utils/autolink"; +import { downloadFile } from "../utils/downloadFile"; import { formatBytes } from "../utils/formatBytes"; import { postDisplayText } from "../utils/postText"; @@ -26,6 +27,7 @@ export function VideoBubble({ post }: { post: Post }) { const ratio = att.width && att.height ? `${att.width} / ${att.height}` : "16 / 9"; const posterUrl = att.posterUrl ?? att.thumbnailUrl; + const duration = formatDuration(att.durationSec); const previewVideoUrl = att.url.includes("#") ? att.url : `${att.url}#t=0.1`; return ( @@ -68,33 +70,31 @@ export function VideoBubble({ post }: { post: Post }) { aria-hidden="true" /> )} -
- { - e.stopPropagation(); - void postNoBody( - `/api/posts/${post.id}/attachments/${att.id}/download`, - ); - }} - className="flex h-8 w-8 items-center justify-center rounded-full bg-black/60 text-white backdrop-blur transition hover:bg-black/75" - aria-label={`Download ${att.filename}`} - > - - -
- {formatDuration(att.durationSec) ? ( +
-
+ + - { e.stopPropagation(); if (postId) { @@ -149,12 +147,13 @@ function LightboxView({ `/api/posts/${postId}/attachments/${current.id}/download`, ); } + void downloadFile(current.url, current.filename).catch(() => {}); }} className="absolute right-16 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20" aria-label="Download" > - + {images.length > 1 ? ( <> @@ -210,7 +209,7 @@ function LightboxView({ /> {caption ? (
-
+
{autolink(caption)}
diff --git a/src/components/messageStream/utils/downloadFile.ts b/src/components/messageStream/utils/downloadFile.ts new file mode 100644 index 0000000..5ac05cf --- /dev/null +++ b/src/components/messageStream/utils/downloadFile.ts @@ -0,0 +1,78 @@ +type SaveFilePicker = (options?: { + suggestedName?: string; + types?: Array<{ + description?: string; + accept: Record; + }>; +}) => Promise<{ + createWritable: () => Promise<{ + write: (data: Blob) => Promise; + close: () => Promise; + }>; +}>; + +type NavigatorWithFileShare = Navigator & { + canShare?: (data: { files?: File[] }) => boolean; + share?: (data: { files?: File[]; title?: string }) => Promise; +}; + +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"; +} diff --git a/src/components/messageStream/utils/filenameDisplay.test.ts b/src/components/messageStream/utils/filenameDisplay.test.ts new file mode 100644 index 0000000..3a0ef5b --- /dev/null +++ b/src/components/messageStream/utils/filenameDisplay.test.ts @@ -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", + ); + }); +}); diff --git a/src/components/messageStream/utils/filenameDisplay.ts b/src/components/messageStream/utils/filenameDisplay.ts new file mode 100644 index 0000000..e9da4ff --- /dev/null +++ b/src/components/messageStream/utils/filenameDisplay.ts @@ -0,0 +1,82 @@ +const MIME_EXTENSION: Record = { + "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 ""; +}