- Detect in-app WebViews (WeChat / TokenPocket / imToken / Telegram / iOS WKWebView, etc.) and show a guide modal asking the user to open the link in their system browser, with a copy-link action. - For normal browsers, fetch the attachment as a Blob and trigger download from a same-origin object URL so the file always lands in the user's Downloads folder with the original filename, even when the browser would otherwise inline-preview the response. - Fall back to the anchor download for files larger than 50MB (avoid loading them entirely into memory) or when fetch fails. - Pass `sizeBytes` from known call sites so the threshold actually applies. - Add localized strings for the guide modal in all 7 locales. See .unipi/docs/debug/2026-06-05-in-app-browser-download-debug.md.
102 lines
3.1 KiB
TypeScript
102 lines
3.1 KiB
TypeScript
import { assetUrl } from "../../../api";
|
|
import { isInAppBrowser } from "../../../utils/inAppBrowser";
|
|
|
|
export const IN_APP_DOWNLOAD_GUIDE_EVENT = "ark:in-app-download-guide";
|
|
|
|
export type InAppDownloadGuideDetail = { url: string; filename: string };
|
|
|
|
/**
|
|
* Files larger than this skip the fetch→Blob path so mobile WebViews and
|
|
* memory-constrained devices do not OOM. They fall back to the anchor
|
|
* download (relies on the backend's `Content-Disposition: attachment`).
|
|
*/
|
|
const MAX_BLOB_DOWNLOAD_BYTES = 50 * 1024 * 1024;
|
|
|
|
export type DownloadOptions = { sizeBytes?: number };
|
|
|
|
export function pauseActiveVideos() {
|
|
document.querySelectorAll("video").forEach((video) => {
|
|
video.pause();
|
|
});
|
|
}
|
|
|
|
export function attachmentDownloadUrl(postId: string, attachmentId: string) {
|
|
return assetUrl(
|
|
`/api/posts/${encodeURIComponent(postId)}/attachments/${encodeURIComponent(
|
|
attachmentId,
|
|
)}/download`,
|
|
);
|
|
}
|
|
|
|
export async function downloadAttachment(
|
|
postId: string,
|
|
attachmentId: string,
|
|
filename: string,
|
|
options?: DownloadOptions,
|
|
) {
|
|
return downloadFile(
|
|
attachmentDownloadUrl(postId, attachmentId),
|
|
filename,
|
|
options,
|
|
);
|
|
}
|
|
|
|
export async function downloadFile(
|
|
url: string,
|
|
filename: string,
|
|
options?: DownloadOptions,
|
|
) {
|
|
const safeFilename = filename || "download";
|
|
|
|
// In-app WebViews (WeChat / TokenPocket / Telegram / iOS WKWebView / …)
|
|
// ignore `Content-Disposition: attachment` and have no system download
|
|
// manager, so an anchor click would just open the file inline. Surface an
|
|
// "open in external browser" guide instead of silently failing the user.
|
|
if (typeof window !== "undefined" && isInAppBrowser()) {
|
|
window.dispatchEvent(
|
|
new CustomEvent<InAppDownloadGuideDetail>(IN_APP_DOWNLOAD_GUIDE_EVENT, {
|
|
detail: { url, filename: safeFilename },
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Normal browsers: prefer fetch → Blob → object URL so the file always lands
|
|
// in the Downloads folder with the original filename, even when the browser
|
|
// would otherwise inline-preview the response (Chrome and Safari do this for
|
|
// PDFs / images regardless of Content-Disposition). Fall back to the anchor
|
|
// download for large files (avoid loading them entirely into memory) or when
|
|
// fetch fails for any reason.
|
|
const sizeBytes = options?.sizeBytes ?? 0;
|
|
if (sizeBytes <= MAX_BLOB_DOWNLOAD_BYTES) {
|
|
try {
|
|
const res = await fetch(url, { credentials: "omit" });
|
|
if (res.ok) {
|
|
const blob = await res.blob();
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
try {
|
|
triggerDownload(objectUrl, safeFilename);
|
|
} finally {
|
|
// Give the browser a moment to start the download before revoking.
|
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 4000);
|
|
}
|
|
return;
|
|
}
|
|
} catch {
|
|
// fall through to anchor fallback
|
|
}
|
|
}
|
|
|
|
triggerDownload(url, safeFilename);
|
|
}
|
|
|
|
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();
|
|
}
|