{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();
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 ? (
-
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 "";
+}