diff --git a/src/App.tsx b/src/App.tsx index 0888165..66ef903 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { I18nProvider } from "./i18n"; import { MotionProvider } from "./motion"; import { ToastProvider } from "./components/Toast"; import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide"; +import { InAppDownloadGuideProvider } from "./components/InAppDownloadGuide"; import { FavoritesProvider } from "./favorites/FavoritesProvider"; import { AutoInjectedLogin } from "./wallet/AutoInjectedLogin"; import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider"; @@ -62,120 +63,127 @@ export default function App() { - - - - - - - - - }> - } - /> - } /> - } - /> - } - /> - } - /> - } /> - } - /> - } - /> + + + + + + + + + + }> + + } + /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> - {localizedHomeRoutes.map((route) => ( - + {localizedHomeRoutes.map((route) => ( + + + } + /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + ))} + + + {/* Legacy long-form language URLs → short-code + redirects. Shared links (e.g. WeChat) keep working. */} + {legacyLanguageRedirects.map((redirect) => ( + + } /> - } /> } - /> - } - /> - } - /> - } - /> - } - /> - } + path={`${redirect.from}/*`} + element={ + + } /> ))} - - {/* Legacy long-form language URLs → short-code - redirects. Shared links (e.g. WeChat) keep working. */} - {legacyLanguageRedirects.map((redirect) => ( - + {adminEnabled ? ( + AdminRouteTree() + ) : ( - } + path={`${adminUiPrefix}/*`} + element={} /> - - } - /> - - ))} + )} - {adminEnabled ? ( - AdminRouteTree() - ) : ( } /> - )} - - } - /> - - - - - - - + + + + + + + + diff --git a/src/components/InAppDownloadGuide.tsx b/src/components/InAppDownloadGuide.tsx new file mode 100644 index 0000000..65acbc5 --- /dev/null +++ b/src/components/InAppDownloadGuide.tsx @@ -0,0 +1,179 @@ +import { Copy, X } from "lucide-react"; +import { useEffect, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { useI18n } from "../i18n"; +import { useToast } from "./Toast"; +import { + IN_APP_DOWNLOAD_GUIDE_EVENT, + type InAppDownloadGuideDetail, +} from "./messageStream/utils/downloadFile"; +import { inAppBrowserName } from "../utils/inAppBrowser"; + +async function copyTextToClipboard(text: string): Promise { + try { + if ( + typeof navigator !== "undefined" && + navigator.clipboard && + typeof navigator.clipboard.writeText === "function" + ) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // fall through to legacy path + } + try { + const ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); + ta.style.position = "fixed"; + ta.style.top = "0"; + ta.style.left = "0"; + ta.style.opacity = "0"; + document.body.append(ta); + ta.select(); + const ok = document.execCommand("copy"); + ta.remove(); + return ok; + } catch { + return false; + } +} + +export function InAppDownloadGuideProvider({ + children, +}: { + children: ReactNode; +}) { + const { t } = useI18n(); + const { showToast } = useToast(); + const [detail, setDetail] = useState(null); + + useEffect(() => { + const onShow = (event: Event) => { + const ce = event as CustomEvent; + if (!ce.detail) return; + setDetail(ce.detail); + }; + window.addEventListener(IN_APP_DOWNLOAD_GUIDE_EVENT, onShow); + return () => + window.removeEventListener(IN_APP_DOWNLOAD_GUIDE_EVENT, onShow); + }, []); + + useEffect(() => { + if (!detail) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setDetail(null); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [detail]); + + const close = () => setDetail(null); + + const handleCopy = async () => { + if (!detail) return; + const ok = await copyTextToClipboard(detail.url); + if (ok) { + showToast(t("inAppDownloadCopied")); + } else { + showToast(t("inAppDownloadCopyFail"), "error"); + } + }; + + const browser = inAppBrowserName(); + const intro = browser + ? t("inAppDownloadIntroNamed").replace("{browser}", browser) + : t("inAppDownloadIntro"); + + return ( + <> + {children} + {detail + ? createPortal( +
+
event.stopPropagation()} + > +
+
+

+ {t("download")} +

+

+ {t("inAppDownloadTitle")} +

+
+ +
+ +
+

{intro}

+ +
    +
  1. + + 1 + + + {t("inAppDownloadStepCopy")} + +
  2. +
  3. + + 2 + + + {t("inAppDownloadStepOpen")} + +
  4. +
  5. + + 3 + + + {t("inAppDownloadStepDownload")} + +
  6. +
+ +
+

+ {detail.url} +

+
+ + +
+
+
, + document.body, + ) + : null} + + ); +} diff --git a/src/components/LatestUpdateCard.tsx b/src/components/LatestUpdateCard.tsx index 43fc786..ef30bff 100644 --- a/src/components/LatestUpdateCard.tsx +++ b/src/components/LatestUpdateCard.tsx @@ -38,7 +38,9 @@ function LatestActions({ if (!attachment || isDownloading) return; setIsDownloading(true); try { - await downloadAttachment(post.id, attachment.id, attachment.filename); + await downloadAttachment(post.id, attachment.id, attachment.filename, { + sizeBytes: attachment.sizeBytes, + }); const mediaKind = mediaSaveKindFromAttachment(attachment); if (mediaKind) showSaveToAlbumGuide(mediaKind); } catch { diff --git a/src/components/messageStream/AttachmentDownloadPill.tsx b/src/components/messageStream/AttachmentDownloadPill.tsx index 6ddbedc..863d46d 100644 --- a/src/components/messageStream/AttachmentDownloadPill.tsx +++ b/src/components/messageStream/AttachmentDownloadPill.tsx @@ -53,7 +53,9 @@ export function AttachmentDownloadPill({ pauseActiveVideos(); setIsDownloading(true); try { - await downloadAttachment(postId, attachment.id, attachment.filename); + await downloadAttachment(postId, attachment.id, attachment.filename, { + sizeBytes: attachment.sizeBytes, + }); const mediaKind = mediaSaveKindFromAttachment(attachment); if (mediaKind) showSaveToAlbumGuide(mediaKind); } catch { diff --git a/src/components/messageStream/BubbleAttachmentDownloadButton.tsx b/src/components/messageStream/BubbleAttachmentDownloadButton.tsx index 68e8825..ee425b3 100644 --- a/src/components/messageStream/BubbleAttachmentDownloadButton.tsx +++ b/src/components/messageStream/BubbleAttachmentDownloadButton.tsx @@ -32,7 +32,9 @@ export function BubbleAttachmentDownloadButton({ pauseActiveVideos(); setIsDownloading(true); try { - await downloadAttachment(postId, attachment.id, displayFilename); + await downloadAttachment(postId, attachment.id, displayFilename, { + sizeBytes: attachment.sizeBytes, + }); const mediaKind = mediaSaveKindFromAttachment(attachment); if (mediaKind) showSaveToAlbumGuide(mediaKind); } catch { diff --git a/src/components/messageStream/bubbles/FileDocBubble.tsx b/src/components/messageStream/bubbles/FileDocBubble.tsx index a109184..69e20ae 100644 --- a/src/components/messageStream/bubbles/FileDocBubble.tsx +++ b/src/components/messageStream/bubbles/FileDocBubble.tsx @@ -102,7 +102,9 @@ function LatestFileCard({ post }: { post: Post }) { if (isDownloading) return; setIsDownloading(true); try { - await downloadAttachment(post.id, att.id, displayFilename); + await downloadAttachment(post.id, att.id, displayFilename, { + sizeBytes: att.sizeBytes, + }); const mediaKind = mediaSaveKindFromAttachment(att); if (mediaKind) showSaveToAlbumGuide(mediaKind); } catch { diff --git a/src/components/messageStream/bubbles/VideoBubble.tsx b/src/components/messageStream/bubbles/VideoBubble.tsx index 6b096be..a6a2232 100644 --- a/src/components/messageStream/bubbles/VideoBubble.tsx +++ b/src/components/messageStream/bubbles/VideoBubble.tsx @@ -183,7 +183,9 @@ function AttachmentListDownloadButton({ pauseActiveVideos(); setIsDownloading(true); try { - await downloadAttachment(postId, attachment.id, attachment.filename); + await downloadAttachment(postId, attachment.id, attachment.filename, { + sizeBytes: attachment.sizeBytes, + }); showSaveToAlbumGuide("video"); } catch { showToast(t("downloadFail"), "error"); diff --git a/src/components/messageStream/utils/downloadFile.ts b/src/components/messageStream/utils/downloadFile.ts index 4e26764..a1787ff 100644 --- a/src/components/messageStream/utils/downloadFile.ts +++ b/src/components/messageStream/utils/downloadFile.ts @@ -1,4 +1,18 @@ 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) => { @@ -18,12 +32,62 @@ export async function downloadAttachment( postId: string, attachmentId: string, filename: string, + options?: DownloadOptions, ) { - return downloadFile(attachmentDownloadUrl(postId, attachmentId), filename); + return downloadFile( + attachmentDownloadUrl(postId, attachmentId), + filename, + options, + ); } -export async function downloadFile(url: string, filename: string) { - triggerDownload(url, filename || "download"); +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(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) { diff --git a/src/locales/en.ts b/src/locales/en.ts index c3e97cf..dd01afb 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -264,4 +264,16 @@ export const enDict: Dict = { featureUnavailableDesc: "This feature is not available yet.", confirm: "Got it", backToHome: "Back to Home", + inAppDownloadTitle: "Please open in your system browser to download", + inAppDownloadIntro: + "Your current in-app browser cannot download files. Open the page in your system browser (Safari, Chrome, etc.) and tap download again.", + inAppDownloadIntroNamed: + "{browser} cannot download files directly. Open the page in your system browser (Safari, Chrome, etc.) and tap download again.", + inAppDownloadStepCopy: "Tap “Copy link” below.", + inAppDownloadStepOpen: + "Open the menu (top-right), choose “Open in browser”, and paste the link if needed.", + inAppDownloadStepDownload: + "Tap the download button on the page — the file will save to your downloads.", + inAppDownloadCopied: "Link copied", + inAppDownloadCopyFail: "Could not copy the link, please copy it manually", }; diff --git a/src/locales/id.ts b/src/locales/id.ts index 2111f0d..568bc52 100644 --- a/src/locales/id.ts +++ b/src/locales/id.ts @@ -267,4 +267,16 @@ export const idDict: Dict = { featureUnavailableDesc: "Fitur ini belum tersedia.", confirm: "Mengerti", backToHome: "Kembali ke Beranda", + inAppDownloadTitle: "Silakan buka di peramban sistem untuk mengunduh", + inAppDownloadIntro: + "Peramban dalam aplikasi saat ini tidak dapat mengunduh berkas. Buka halaman ini di peramban sistem (Safari, Chrome, dll.) lalu ketuk unduh lagi.", + inAppDownloadIntroNamed: + "{browser} tidak dapat mengunduh berkas secara langsung. Buka halaman ini di peramban sistem (Safari, Chrome, dll.) lalu ketuk unduh lagi.", + inAppDownloadStepCopy: "Ketuk “Salin tautan” di bawah.", + inAppDownloadStepOpen: + "Buka menu di kanan atas, pilih “Buka di peramban”, tempelkan tautan jika diperlukan.", + inAppDownloadStepDownload: + "Di peramban sistem, ketuk lagi tombol unduh dan berkas akan tersimpan.", + inAppDownloadCopied: "Tautan disalin", + inAppDownloadCopyFail: "Tidak dapat menyalin, silakan salin secara manual", }; diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 0eedd3a..c96b231 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -268,4 +268,16 @@ export const jaDict: Dict = { featureUnavailableDesc: "この機能はまだご利用いただけません。", confirm: "了解", backToHome: "ホームへ戻る", + inAppDownloadTitle: "システムブラウザで開いてダウンロードしてください", + inAppDownloadIntro: + "現在のアプリ内ブラウザはファイルをダウンロードできません。Safari や Chrome などのシステムブラウザでページを開いてから、もう一度ダウンロードしてください。", + inAppDownloadIntroNamed: + "{browser} のアプリ内ブラウザはファイルを直接ダウンロードできません。Safari や Chrome などのシステムブラウザでページを開いてから、もう一度ダウンロードしてください。", + inAppDownloadStepCopy: "下の「リンクをコピー」をタップします。", + inAppDownloadStepOpen: + "右上のメニューから「ブラウザで開く」を選び、必要に応じてリンクを貼り付けます。", + inAppDownloadStepDownload: + "システムブラウザで再度ダウンロードボタンをタップすると、ファイルがダウンロード先に保存されます。", + inAppDownloadCopied: "リンクをコピーしました", + inAppDownloadCopyFail: "コピーに失敗しました。手動でコピーしてください", }; diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 17a8f73..d2eaaad 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -263,4 +263,16 @@ export const koDict: Dict = { featureUnavailableDesc: "이 기능은 아직 사용할 수 없습니다.", confirm: "확인", backToHome: "홈으로", + inAppDownloadTitle: "시스템 브라우저에서 열어 다운로드하세요", + inAppDownloadIntro: + "현재 앱 내 브라우저는 파일을 다운로드할 수 없습니다. Safari나 Chrome 등 시스템 브라우저에서 페이지를 연 다음 다시 다운로드해 주세요.", + inAppDownloadIntroNamed: + "{browser} 앱 내 브라우저는 파일을 직접 다운로드할 수 없습니다. Safari나 Chrome 등 시스템 브라우저에서 페이지를 연 다음 다시 다운로드해 주세요.", + inAppDownloadStepCopy: "아래의 “링크 복사”를 누릅니다.", + inAppDownloadStepOpen: + "오른쪽 위 메뉴에서 “브라우저로 열기”를 선택하고 필요하면 링크를 붙여 넣습니다.", + inAppDownloadStepDownload: + "시스템 브라우저에서 다운로드 버튼을 다시 누르면 파일이 저장됩니다.", + inAppDownloadCopied: "링크를 복사했습니다", + inAppDownloadCopyFail: "복사하지 못했습니다. 직접 복사해 주세요", }; diff --git a/src/locales/ms.ts b/src/locales/ms.ts index 69d482d..b7a8f62 100644 --- a/src/locales/ms.ts +++ b/src/locales/ms.ts @@ -266,4 +266,16 @@ export const msDict: Dict = { featureUnavailableDesc: "Ciri ini belum tersedia.", confirm: "Faham", backToHome: "Kembali ke Laman Utama", + inAppDownloadTitle: "Sila buka dalam pelayar sistem untuk muat turun", + inAppDownloadIntro: + "Pelayar dalam aplikasi semasa tidak dapat memuat turun fail. Buka halaman ini dalam pelayar sistem (Safari, Chrome, dsb.), kemudian ketik muat turun semula.", + inAppDownloadIntroNamed: + "{browser} tidak dapat memuat turun fail secara langsung. Buka halaman ini dalam pelayar sistem (Safari, Chrome, dsb.), kemudian ketik muat turun semula.", + inAppDownloadStepCopy: "Ketik “Salin pautan” di bawah.", + inAppDownloadStepOpen: + "Buka menu di atas kanan, pilih “Buka dalam pelayar”, tampal pautan jika perlu.", + inAppDownloadStepDownload: + "Dalam pelayar sistem, ketik butang muat turun semula dan fail akan disimpan.", + inAppDownloadCopied: "Pautan disalin", + inAppDownloadCopyFail: "Tidak dapat menyalin, sila salin secara manual", }; diff --git a/src/locales/vi.ts b/src/locales/vi.ts index ebd3e2c..e0c3a57 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -262,4 +262,16 @@ export const viDict: Dict = { featureUnavailableDesc: "Tính năng này hiện chưa khả dụng.", confirm: "Đã hiểu", backToHome: "Về trang chủ", + inAppDownloadTitle: "Vui lòng mở bằng trình duyệt hệ thống để tải", + inAppDownloadIntro: + "Trình duyệt trong ứng dụng hiện tại không thể tải tệp. Hãy mở trang trong trình duyệt hệ thống (Safari, Chrome…) rồi nhấn tải lại.", + inAppDownloadIntroNamed: + "{browser} không thể tải tệp trực tiếp. Hãy mở trang trong trình duyệt hệ thống (Safari, Chrome…) rồi nhấn tải lại.", + inAppDownloadStepCopy: "Nhấn “Sao chép liên kết” bên dưới.", + inAppDownloadStepOpen: + "Mở menu ở góc trên bên phải, chọn “Mở bằng trình duyệt”, dán liên kết nếu cần.", + inAppDownloadStepDownload: + "Trong trình duyệt hệ thống, nhấn lại nút tải để lưu tệp.", + inAppDownloadCopied: "Đã sao chép liên kết", + inAppDownloadCopyFail: "Không sao chép được, vui lòng tự sao chép", }; diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index d0a3791..e947052 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -245,4 +245,16 @@ export const zhDict: Dict = { featureUnavailableDesc: "该功能暂未开放。", confirm: "知道了", backToHome: "返回首页", + inAppDownloadTitle: "请使用系统浏览器打开后下载", + inAppDownloadIntro: + "当前内置浏览器无法直接下载文件,请在系统浏览器(Safari、Chrome 等)中打开页面后再点击下载。", + inAppDownloadIntroNamed: + "{browser} 内置浏览器无法直接下载文件,请在系统浏览器(Safari、Chrome 等)中打开页面后再点击下载。", + inAppDownloadStepCopy: "点击下方「复制链接」。", + inAppDownloadStepOpen: + "点右上角菜单,选择「在浏览器中打开」,如需要请把刚才复制的链接粘贴进地址栏。", + inAppDownloadStepDownload: + "在系统浏览器中再次点击下载按钮,文件会保存到下载文件夹。", + inAppDownloadCopied: "链接已复制", + inAppDownloadCopyFail: "复制失败,请手动复制", }; diff --git a/src/utils/inAppBrowser.ts b/src/utils/inAppBrowser.ts new file mode 100644 index 0000000..575275a --- /dev/null +++ b/src/utils/inAppBrowser.ts @@ -0,0 +1,43 @@ +/** + * Detect popular in-app WebViews that block file downloads or ignore + * `Content-Disposition: attachment`, so the UI can show an "open in external + * browser" guide instead of silently opening the file inline. + */ + +function ua(): string { + if (typeof navigator === "undefined") return ""; + return navigator.userAgent || ""; +} + +const PATTERNS: Array<{ re: RegExp; name: string }> = [ + { re: /MicroMessenger/i, name: "WeChat" }, + { re: /TokenPocket/i, name: "TokenPocket" }, + { re: /imToken/i, name: "imToken" }, + { re: /Trust(Wallet|Browser)/i, name: "Trust Wallet" }, + { re: /MetaMask/i, name: "MetaMask" }, + { re: /Telegram/i, name: "Telegram" }, + { re: /\bLine\//i, name: "LINE" }, + { re: /FBAN|FBAV/i, name: "Facebook" }, + { re: /Instagram/i, name: "Instagram" }, + { re: /Twitter|\bX\//i, name: "Twitter/X" }, + { re: /Weibo/i, name: "Weibo" }, + { re: /MQQBrowser/i, name: "QQ Browser" }, + { re: /\bQQ\//i, name: "QQ" }, + { re: /MiuiBrowser/i, name: "Mi Browser" }, + { re: /Snapchat/i, name: "Snapchat" }, +]; + +export function isInAppBrowser(): boolean { + const agent = ua(); + if (!agent) return false; + return PATTERNS.some(({ re }) => re.test(agent)); +} + +export function inAppBrowserName(): string | null { + const agent = ua(); + if (!agent) return null; + for (const { re, name } of PATTERNS) { + if (re.test(agent)) return name; + } + return null; +}