terry-staging #16
12
src/App.tsx
12
src/App.tsx
@@ -10,6 +10,7 @@ import { I18nProvider } from "./i18n";
|
|||||||
import { MotionProvider } from "./motion";
|
import { MotionProvider } from "./motion";
|
||||||
import { ToastProvider } from "./components/Toast";
|
import { ToastProvider } from "./components/Toast";
|
||||||
import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide";
|
import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide";
|
||||||
|
import { InAppDownloadGuideProvider } from "./components/InAppDownloadGuide";
|
||||||
import { FavoritesProvider } from "./favorites/FavoritesProvider";
|
import { FavoritesProvider } from "./favorites/FavoritesProvider";
|
||||||
import { AutoInjectedLogin } from "./wallet/AutoInjectedLogin";
|
import { AutoInjectedLogin } from "./wallet/AutoInjectedLogin";
|
||||||
import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider";
|
import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider";
|
||||||
@@ -62,6 +63,7 @@ export default function App() {
|
|||||||
</RainbowWalletProvider>
|
</RainbowWalletProvider>
|
||||||
</WalletStackErrorBoundary>
|
</WalletStackErrorBoundary>
|
||||||
<FavoritesProvider>
|
<FavoritesProvider>
|
||||||
|
<InAppDownloadGuideProvider>
|
||||||
<SaveToAlbumGuideProvider>
|
<SaveToAlbumGuideProvider>
|
||||||
<AdminRouterModeProvider value="absolute">
|
<AdminRouterModeProvider value="absolute">
|
||||||
<ImageLightboxProvider>
|
<ImageLightboxProvider>
|
||||||
@@ -73,7 +75,9 @@ export default function App() {
|
|||||||
<Route element={<PublicLayout />}>
|
<Route element={<PublicLayout />}>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={<LocalizedHomePage targetLang="en" />}
|
element={
|
||||||
|
<LocalizedHomePage targetLang="en" />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/browse" element={<Browse />} />
|
<Route path="/browse" element={<Browse />} />
|
||||||
<Route
|
<Route
|
||||||
@@ -88,7 +92,10 @@ export default function App() {
|
|||||||
path="/category/:slug"
|
path="/category/:slug"
|
||||||
element={<CategoryPage />}
|
element={<CategoryPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="/search" element={<SearchPage />} />
|
<Route
|
||||||
|
path="/search"
|
||||||
|
element={<SearchPage />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/resource/:id"
|
path="/resource/:id"
|
||||||
element={<PostRedirect />}
|
element={<PostRedirect />}
|
||||||
@@ -176,6 +183,7 @@ export default function App() {
|
|||||||
</ImageLightboxProvider>
|
</ImageLightboxProvider>
|
||||||
</AdminRouterModeProvider>
|
</AdminRouterModeProvider>
|
||||||
</SaveToAlbumGuideProvider>
|
</SaveToAlbumGuideProvider>
|
||||||
|
</InAppDownloadGuideProvider>
|
||||||
</FavoritesProvider>
|
</FavoritesProvider>
|
||||||
</WalletProvider>
|
</WalletProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|||||||
179
src/components/InAppDownloadGuide.tsx
Normal file
179
src/components/InAppDownloadGuide.tsx
Normal file
@@ -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<boolean> {
|
||||||
|
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<InAppDownloadGuideDetail | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onShow = (event: Event) => {
|
||||||
|
const ce = event as CustomEvent<InAppDownloadGuideDetail>;
|
||||||
|
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(
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="in-app-download-guide-title"
|
||||||
|
className="fixed inset-0 z-[140] flex items-center justify-center bg-black/70 px-4 backdrop-blur-sm"
|
||||||
|
onClick={close}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md overflow-hidden rounded-3xl border border-white/10 bg-[#1c1c21] text-neutral-100 shadow-2xl shadow-black/70"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b border-white/10 px-5 py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ark-gold/80">
|
||||||
|
{t("download")}
|
||||||
|
</p>
|
||||||
|
<h2
|
||||||
|
id="in-app-download-guide-title"
|
||||||
|
className="mt-1 text-lg font-semibold text-white"
|
||||||
|
>
|
||||||
|
{t("inAppDownloadTitle")}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={close}
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80"
|
||||||
|
aria-label={t("cancel")}
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 px-5 py-5">
|
||||||
|
<p className="text-sm leading-6 text-neutral-300">{intro}</p>
|
||||||
|
|
||||||
|
<ol className="space-y-3 text-sm leading-6 text-neutral-100">
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-ark-gold text-sm font-bold text-black">
|
||||||
|
1
|
||||||
|
</span>
|
||||||
|
<span className="pt-0.5">
|
||||||
|
{t("inAppDownloadStepCopy")}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-ark-gold text-sm font-bold text-black">
|
||||||
|
2
|
||||||
|
</span>
|
||||||
|
<span className="pt-0.5">
|
||||||
|
{t("inAppDownloadStepOpen")}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-ark-gold text-sm font-bold text-black">
|
||||||
|
3
|
||||||
|
</span>
|
||||||
|
<span className="pt-0.5">
|
||||||
|
{t("inAppDownloadStepDownload")}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/30 p-3">
|
||||||
|
<p className="break-all text-xs leading-5 text-neutral-300">
|
||||||
|
{detail.url}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="flex h-11 w-full items-center justify-center gap-2 rounded-full bg-ark-gold px-4 text-sm font-semibold text-black transition hover:bg-ark-gold2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1c1c21]"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
{t("copyLink")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,7 +38,9 @@ function LatestActions({
|
|||||||
if (!attachment || isDownloading) return;
|
if (!attachment || isDownloading) return;
|
||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
try {
|
try {
|
||||||
await downloadAttachment(post.id, attachment.id, attachment.filename);
|
await downloadAttachment(post.id, attachment.id, attachment.filename, {
|
||||||
|
sizeBytes: attachment.sizeBytes,
|
||||||
|
});
|
||||||
const mediaKind = mediaSaveKindFromAttachment(attachment);
|
const mediaKind = mediaSaveKindFromAttachment(attachment);
|
||||||
if (mediaKind) showSaveToAlbumGuide(mediaKind);
|
if (mediaKind) showSaveToAlbumGuide(mediaKind);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ export function AttachmentDownloadPill({
|
|||||||
pauseActiveVideos();
|
pauseActiveVideos();
|
||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
try {
|
try {
|
||||||
await downloadAttachment(postId, attachment.id, attachment.filename);
|
await downloadAttachment(postId, attachment.id, attachment.filename, {
|
||||||
|
sizeBytes: attachment.sizeBytes,
|
||||||
|
});
|
||||||
const mediaKind = mediaSaveKindFromAttachment(attachment);
|
const mediaKind = mediaSaveKindFromAttachment(attachment);
|
||||||
if (mediaKind) showSaveToAlbumGuide(mediaKind);
|
if (mediaKind) showSaveToAlbumGuide(mediaKind);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ export function BubbleAttachmentDownloadButton({
|
|||||||
pauseActiveVideos();
|
pauseActiveVideos();
|
||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
try {
|
try {
|
||||||
await downloadAttachment(postId, attachment.id, displayFilename);
|
await downloadAttachment(postId, attachment.id, displayFilename, {
|
||||||
|
sizeBytes: attachment.sizeBytes,
|
||||||
|
});
|
||||||
const mediaKind = mediaSaveKindFromAttachment(attachment);
|
const mediaKind = mediaSaveKindFromAttachment(attachment);
|
||||||
if (mediaKind) showSaveToAlbumGuide(mediaKind);
|
if (mediaKind) showSaveToAlbumGuide(mediaKind);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ function LatestFileCard({ post }: { post: Post }) {
|
|||||||
if (isDownloading) return;
|
if (isDownloading) return;
|
||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
try {
|
try {
|
||||||
await downloadAttachment(post.id, att.id, displayFilename);
|
await downloadAttachment(post.id, att.id, displayFilename, {
|
||||||
|
sizeBytes: att.sizeBytes,
|
||||||
|
});
|
||||||
const mediaKind = mediaSaveKindFromAttachment(att);
|
const mediaKind = mediaSaveKindFromAttachment(att);
|
||||||
if (mediaKind) showSaveToAlbumGuide(mediaKind);
|
if (mediaKind) showSaveToAlbumGuide(mediaKind);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -183,7 +183,9 @@ function AttachmentListDownloadButton({
|
|||||||
pauseActiveVideos();
|
pauseActiveVideos();
|
||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
try {
|
try {
|
||||||
await downloadAttachment(postId, attachment.id, attachment.filename);
|
await downloadAttachment(postId, attachment.id, attachment.filename, {
|
||||||
|
sizeBytes: attachment.sizeBytes,
|
||||||
|
});
|
||||||
showSaveToAlbumGuide("video");
|
showSaveToAlbumGuide("video");
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t("downloadFail"), "error");
|
showToast(t("downloadFail"), "error");
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
import { assetUrl } from "../../../api";
|
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() {
|
export function pauseActiveVideos() {
|
||||||
document.querySelectorAll("video").forEach((video) => {
|
document.querySelectorAll("video").forEach((video) => {
|
||||||
@@ -18,12 +32,62 @@ export async function downloadAttachment(
|
|||||||
postId: string,
|
postId: string,
|
||||||
attachmentId: string,
|
attachmentId: string,
|
||||||
filename: 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) {
|
export async function downloadFile(
|
||||||
triggerDownload(url, filename || "download");
|
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) {
|
function triggerDownload(url: string, filename: string) {
|
||||||
|
|||||||
@@ -264,4 +264,16 @@ export const enDict: Dict = {
|
|||||||
featureUnavailableDesc: "This feature is not available yet.",
|
featureUnavailableDesc: "This feature is not available yet.",
|
||||||
confirm: "Got it",
|
confirm: "Got it",
|
||||||
backToHome: "Back to Home",
|
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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -267,4 +267,16 @@ export const idDict: Dict = {
|
|||||||
featureUnavailableDesc: "Fitur ini belum tersedia.",
|
featureUnavailableDesc: "Fitur ini belum tersedia.",
|
||||||
confirm: "Mengerti",
|
confirm: "Mengerti",
|
||||||
backToHome: "Kembali ke Beranda",
|
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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -268,4 +268,16 @@ export const jaDict: Dict = {
|
|||||||
featureUnavailableDesc: "この機能はまだご利用いただけません。",
|
featureUnavailableDesc: "この機能はまだご利用いただけません。",
|
||||||
confirm: "了解",
|
confirm: "了解",
|
||||||
backToHome: "ホームへ戻る",
|
backToHome: "ホームへ戻る",
|
||||||
|
inAppDownloadTitle: "システムブラウザで開いてダウンロードしてください",
|
||||||
|
inAppDownloadIntro:
|
||||||
|
"現在のアプリ内ブラウザはファイルをダウンロードできません。Safari や Chrome などのシステムブラウザでページを開いてから、もう一度ダウンロードしてください。",
|
||||||
|
inAppDownloadIntroNamed:
|
||||||
|
"{browser} のアプリ内ブラウザはファイルを直接ダウンロードできません。Safari や Chrome などのシステムブラウザでページを開いてから、もう一度ダウンロードしてください。",
|
||||||
|
inAppDownloadStepCopy: "下の「リンクをコピー」をタップします。",
|
||||||
|
inAppDownloadStepOpen:
|
||||||
|
"右上のメニューから「ブラウザで開く」を選び、必要に応じてリンクを貼り付けます。",
|
||||||
|
inAppDownloadStepDownload:
|
||||||
|
"システムブラウザで再度ダウンロードボタンをタップすると、ファイルがダウンロード先に保存されます。",
|
||||||
|
inAppDownloadCopied: "リンクをコピーしました",
|
||||||
|
inAppDownloadCopyFail: "コピーに失敗しました。手動でコピーしてください",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -263,4 +263,16 @@ export const koDict: Dict = {
|
|||||||
featureUnavailableDesc: "이 기능은 아직 사용할 수 없습니다.",
|
featureUnavailableDesc: "이 기능은 아직 사용할 수 없습니다.",
|
||||||
confirm: "확인",
|
confirm: "확인",
|
||||||
backToHome: "홈으로",
|
backToHome: "홈으로",
|
||||||
|
inAppDownloadTitle: "시스템 브라우저에서 열어 다운로드하세요",
|
||||||
|
inAppDownloadIntro:
|
||||||
|
"현재 앱 내 브라우저는 파일을 다운로드할 수 없습니다. Safari나 Chrome 등 시스템 브라우저에서 페이지를 연 다음 다시 다운로드해 주세요.",
|
||||||
|
inAppDownloadIntroNamed:
|
||||||
|
"{browser} 앱 내 브라우저는 파일을 직접 다운로드할 수 없습니다. Safari나 Chrome 등 시스템 브라우저에서 페이지를 연 다음 다시 다운로드해 주세요.",
|
||||||
|
inAppDownloadStepCopy: "아래의 “링크 복사”를 누릅니다.",
|
||||||
|
inAppDownloadStepOpen:
|
||||||
|
"오른쪽 위 메뉴에서 “브라우저로 열기”를 선택하고 필요하면 링크를 붙여 넣습니다.",
|
||||||
|
inAppDownloadStepDownload:
|
||||||
|
"시스템 브라우저에서 다운로드 버튼을 다시 누르면 파일이 저장됩니다.",
|
||||||
|
inAppDownloadCopied: "링크를 복사했습니다",
|
||||||
|
inAppDownloadCopyFail: "복사하지 못했습니다. 직접 복사해 주세요",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -266,4 +266,16 @@ export const msDict: Dict = {
|
|||||||
featureUnavailableDesc: "Ciri ini belum tersedia.",
|
featureUnavailableDesc: "Ciri ini belum tersedia.",
|
||||||
confirm: "Faham",
|
confirm: "Faham",
|
||||||
backToHome: "Kembali ke Laman Utama",
|
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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -262,4 +262,16 @@ export const viDict: Dict = {
|
|||||||
featureUnavailableDesc: "Tính năng này hiện chưa khả dụng.",
|
featureUnavailableDesc: "Tính năng này hiện chưa khả dụng.",
|
||||||
confirm: "Đã hiểu",
|
confirm: "Đã hiểu",
|
||||||
backToHome: "Về trang chủ",
|
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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -245,4 +245,16 @@ export const zhDict: Dict = {
|
|||||||
featureUnavailableDesc: "该功能暂未开放。",
|
featureUnavailableDesc: "该功能暂未开放。",
|
||||||
confirm: "知道了",
|
confirm: "知道了",
|
||||||
backToHome: "返回首页",
|
backToHome: "返回首页",
|
||||||
|
inAppDownloadTitle: "请使用系统浏览器打开后下载",
|
||||||
|
inAppDownloadIntro:
|
||||||
|
"当前内置浏览器无法直接下载文件,请在系统浏览器(Safari、Chrome 等)中打开页面后再点击下载。",
|
||||||
|
inAppDownloadIntroNamed:
|
||||||
|
"{browser} 内置浏览器无法直接下载文件,请在系统浏览器(Safari、Chrome 等)中打开页面后再点击下载。",
|
||||||
|
inAppDownloadStepCopy: "点击下方「复制链接」。",
|
||||||
|
inAppDownloadStepOpen:
|
||||||
|
"点右上角菜单,选择「在浏览器中打开」,如需要请把刚才复制的链接粘贴进地址栏。",
|
||||||
|
inAppDownloadStepDownload:
|
||||||
|
"在系统浏览器中再次点击下载按钮,文件会保存到下载文件夹。",
|
||||||
|
inAppDownloadCopied: "链接已复制",
|
||||||
|
inAppDownloadCopyFail: "复制失败,请手动复制",
|
||||||
};
|
};
|
||||||
|
|||||||
43
src/utils/inAppBrowser.ts
Normal file
43
src/utils/inAppBrowser.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user