feat: polish message attachment downloads

This commit is contained in:
TerryM
2026-05-26 18:04:36 +08:00
parent e0629c9df7
commit 532f0112fd
8 changed files with 274 additions and 59 deletions

View File

@@ -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(() => {});
}}
>
<Download className="h-5 w-5" strokeWidth={2.2} />

View File

@@ -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 (
<a
href={att.url}
download={att.filename}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-2 rounded-xl px-1 py-0.5 transition hover:bg-white/5"
onClick={() => {
void postNoBody(`/api/posts/${postId}/attachments/${att.id}/download`);
}}
<div className="group flex items-center gap-2 rounded-xl px-1 py-0.5 transition hover:bg-white/5">
<button
type="button"
onClick={handleDownload}
className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full md:h-12 md:w-12"
aria-label={`Download ${att.filename}`}
>
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full md:h-12 md:w-12">
{isImageAsDoc && att.thumbnailUrl ? (
<>
<img
src={att.thumbnailUrl}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/35">
<Download className="h-4 w-4 text-white" />
</div>
</>
) : (
<div
className="flex h-full w-full items-center justify-center"
@@ -41,16 +43,22 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
<Icon className="h-5 w-5 text-white" strokeWidth={2.2} />
</div>
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/35 text-white opacity-100 transition group-hover:bg-black/45 group-focus-visible:bg-black/45">
<ArrowDownToLine className="h-4 w-4" strokeWidth={2.3} />
</div>
</button>
<div className="min-w-0 flex-1">
<div className="truncate text-[14px] font-medium text-ark-gold2 group-hover:text-ark-gold">
{att.filename}
<div
className="truncate text-[14px] font-medium text-ark-gold2 group-hover:text-ark-gold"
title={displayFilename}
>
{middleEllipsisFilename(displayFilename)}
</div>
<div className="text-[11px] text-neutral-400">
{formatBytes(att.sizeBytes)}
</div>
</div>
</a>
</div>
);
}

View File

@@ -30,7 +30,7 @@ export function ImageWithTextBubble({ post }: { post: Post }) {
/>
</button>
{text ? (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/55 to-transparent px-4 pb-4 pt-16 text-[14px] leading-snug text-neutral-100">
<div className="bg-gradient-to-b from-ark-panel/90 to-ark-panel px-4 py-3 text-[14px] leading-snug text-neutral-100">
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words">
{autolink(text)}
</div>

View File

@@ -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"
/>
)}
<div className="absolute left-3 top-3 z-10 flex items-center gap-1.5 text-xs text-white">
<a
href={att.url}
download={att.filename}
target="_blank"
rel="noreferrer"
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void postNoBody(
`/api/posts/${post.id}/attachments/${att.id}/download`,
);
void downloadFile(att.url, att.filename).catch(() => {});
}}
className="flex h-8 w-8 items-center justify-center rounded-full bg-black/60 text-white backdrop-blur transition hover:bg-black/75"
className="group absolute left-3 top-3 z-10 inline-flex overflow-hidden rounded-full bg-black/45 text-xs text-white shadow-lg ring-1 ring-white/15 backdrop-blur-md transition hover:bg-black/60"
aria-label={`Download ${att.filename}`}
>
<Download className="h-4 w-4" />
</a>
<div className="flex items-center gap-1.5 rounded-full bg-black/55 px-2.5 py-1.5">
{formatDuration(att.durationSec) ? (
<span className="flex h-8 w-8 items-center justify-center bg-white/10 transition group-hover:bg-white/15">
<ArrowDownToLine className="h-4 w-4" strokeWidth={2.3} />
</span>
<span className="flex h-8 items-center gap-1 px-2.5">
{duration ? (
<>
<span>{formatDuration(att.durationSec)}</span>
<span className="opacity-70">·</span>
<span>{duration}</span>
<span className="opacity-60">·</span>
</>
) : null}
<span>{formatBytes(att.sizeBytes)}</span>
</div>
</div>
</span>
</button>
<button
type="button"
onClick={(e) => {

View File

@@ -12,6 +12,7 @@ import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
import { postNoBody } from "../../../api";
import type { Attachment } from "../../../types/post";
import { autolink } from "../utils/autolink";
import { downloadFile } from "../utils/downloadFile";
type LightboxState = {
images: Attachment[];
@@ -137,11 +138,8 @@ function LightboxView({
<X className="h-5 w-5" />
</button>
<a
href={current.url}
download={current.filename}
target="_blank"
rel="noopener noreferrer"
<button
type="button"
onClick={(e) => {
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"
>
<Download className="h-5 w-5" />
</a>
</button>
{images.length > 1 ? (
<>
@@ -210,7 +209,7 @@ function LightboxView({
/>
{caption ? (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent px-4 pb-4 pt-12 text-sm leading-snug text-white sm:px-5 sm:pb-5">
<div className="message-stream-copyable-text max-h-[32vh] overflow-y-auto whitespace-pre-wrap break-words">
<div className="message-stream-copyable-text max-h-[32vh] overflow-y-auto whitespace-pre-wrap break-words [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{autolink(caption)}
</div>
</div>

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

View 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",
);
});
});

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