From 42ae451c1b21fd8c3fd8c8427ec733c8b6b3eca4 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 19 Jul 2026 05:38:42 +0200 Subject: [PATCH] fix(share): robust clipboard copy with an iOS-safe fallback navigator.clipboard.writeText is intermittent on iOS Safari (bug 9: the download share-link copy 'often does neither'). Add lib/clipboard.ts copyText(): a gesture-safe execCommand path FIRST (before any await can spend the transient user activation), async Clipboard API as fallback. Migrate all five copy call sites to it. --- frontend/src/components/DownloadCenter.tsx | 6 +- frontend/src/components/GlassTuner.tsx | 7 ++- frontend/src/components/SavedViewsWidget.tsx | 6 +- frontend/src/components/ShareDialog.tsx | 6 +- frontend/src/components/Sidebar.tsx | 6 +- frontend/src/lib/clipboard.ts | 61 ++++++++++++++++++++ 6 files changed, 77 insertions(+), 15 deletions(-) create mode 100644 frontend/src/lib/clipboard.ts diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 5d734340..6b7137ef 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -31,6 +31,7 @@ import { useMe } from "../lib/useMe"; import { api, type DownloadJob } from "../lib/api"; import { invalidateDownloads } from "../lib/downloads"; import { notify } from "../lib/notifications"; +import { copyText } from "../lib/clipboard"; import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format"; import { inputCls } from "./ui/form"; @@ -129,10 +130,9 @@ function displayUrl(url: string): string { function SourceRef({ url }: { url: string }) { const { t } = useTranslation(); const copy = async () => { - try { - await navigator.clipboard.writeText(url); + if (await copyText(url)) { notify({ level: "success", message: t("downloads.source.copied") }); - } catch { + } else { notify({ level: "error", message: t("downloads.source.copyFailed") }); } }; diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx index 4b28d925..70e84d25 100644 --- a/frontend/src/components/GlassTuner.tsx +++ b/frontend/src/components/GlassTuner.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import Overlay from "./Overlay"; +import { copyText } from "../lib/clipboard"; import { usePrefs } from "./PrefsProvider"; /** @@ -205,11 +206,11 @@ export default function GlassTuner() { return lines.length ? `${scope} {\n${lines.join("\n")}\n}` : "/* all values at defaults */"; }, [vars, palette, themeTick]); - function copy() { - navigator.clipboard?.writeText(cssOut).then(() => { + async function copy() { + if (await copyText(cssOut)) { setCopied(true); setTimeout(() => setCopied(false), 1200); - }); + } } function persistOpen(v: boolean) { diff --git a/frontend/src/components/SavedViewsWidget.tsx b/frontend/src/components/SavedViewsWidget.tsx index b6fce26e..4d94ad25 100644 --- a/frontend/src/components/SavedViewsWidget.tsx +++ b/frontend/src/components/SavedViewsWidget.tsx @@ -20,6 +20,7 @@ import { CSS } from "@dnd-kit/utilities"; import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api"; import { filtersToParams, shareUrl } from "../lib/urlState"; import { notify } from "../lib/notifications"; +import { copyText } from "../lib/clipboard"; import { accountKey, LS, writeJSON } from "../lib/storage"; import { useConfirm } from "./ConfirmProvider"; @@ -108,10 +109,9 @@ export default function SavedViewsWidget({ } async function share(v: SavedView) { - try { - await navigator.clipboard.writeText(shareUrl(asFilters(v.filters))); + if (await copyText(shareUrl(asFilters(v.filters)))) { notify({ level: "success", message: t("views.shareCopied") }); - } catch { + } else { notify({ level: "warning", message: t("sidebar.shareFailed") }); } } diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index d4a9eae0..eb287058 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -6,6 +6,7 @@ import clsx from "clsx"; import Modal from "./Modal"; import { useConfirm } from "./ConfirmProvider"; import { notify } from "../lib/notifications"; +import { copyText } from "../lib/clipboard"; import { api, type DownloadJob, type ShareLink } from "../lib/api"; import { inputCls, btnCls } from "./ui/form"; const EXPIRY_DAYS = [null, 1, 7, 30] as const; @@ -87,11 +88,10 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void } const fullUrl = window.location.origin + link.url; const copy = async () => { - try { - await navigator.clipboard.writeText(fullUrl); + if (await copyText(fullUrl)) { setCopied(true); setTimeout(() => setCopied(false), 1500); - } catch { + } else { notify({ level: "error", message: t("downloads.share.copyFailed") }); } }; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 126ae058..d8f1edd7 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -5,6 +5,7 @@ import { Library, Pencil, Share2, SlidersHorizontal, User } from "lucide-react"; import { api, type FeedFilters, type Tag } from "../lib/api"; import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { shareUrl } from "../lib/urlState"; +import { copyText } from "../lib/clipboard"; import { notify } from "../lib/notifications"; import { useDebounced } from "../lib/useDebounced"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; @@ -125,10 +126,9 @@ export default function Sidebar({ const active = activeCount > 0; async function shareView() { - try { - await navigator.clipboard.writeText(shareUrl(filters)); + if (await copyText(shareUrl(filters))) { notify({ level: "success", message: t("sidebar.shareCopied") }); - } catch { + } else { notify({ level: "warning", message: t("sidebar.shareFailed") }); } } diff --git a/frontend/src/lib/clipboard.ts b/frontend/src/lib/clipboard.ts new file mode 100644 index 00000000..e559a6ff --- /dev/null +++ b/frontend/src/lib/clipboard.ts @@ -0,0 +1,61 @@ +// Copy text to the clipboard, robust across iOS Safari and non-secure origins. +// +// The synchronous `execCommand("copy")` path (a hidden, selected textarea) is the +// battle-tested, user-gesture-safe primitive: it works on iOS Safari and every +// desktop browser. We run it FIRST, inside the click gesture, before any `await` +// can spend the page's transient user activation — awaiting a rejected async +// `navigator.clipboard.writeText` first is exactly what makes the fallback fail on +// iOS. Only if the sync path fails (some sandboxed / permission-locked contexts +// disable execCommand) do we fall back to the async Clipboard API. Returns true on +// success so callers can toast. +export async function copyText(text: string): Promise { + if (legacyCopy(text)) return true; + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // both paths failed + } + } + return false; +} + +function legacyCopy(text: string): boolean { + const active = document.activeElement as HTMLElement | null; + // Preserve any text the user had selected on the page — focusing our textarea + // collapses it, so snapshot the range and restore it afterwards. + const sel = document.getSelection(); + const savedRange = sel && sel.rangeCount ? sel.getRangeAt(0) : null; + const ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); // suppress the iOS keyboard + // Hidden but still selectable: iOS won't copy from `display:none`/`opacity:0`, so + // clip it out of view while keeping a live, user-selectable node. + ta.style.position = "fixed"; + ta.style.top = "0"; + ta.style.left = "0"; + ta.style.width = "1px"; + ta.style.height = "1px"; + ta.style.padding = "0"; + ta.style.border = "none"; + ta.style.clip = "rect(0, 0, 0, 0)"; + ta.style.userSelect = "text"; + ta.style.setProperty("-webkit-user-select", "text"); + document.body.appendChild(ta); + try { + ta.focus(); + ta.select(); + ta.setSelectionRange(0, text.length); // iOS needs an explicit range + return document.execCommand("copy"); + } catch { + return false; + } finally { + document.body.removeChild(ta); + active?.focus?.(); // restore focus we borrowed for the copy + if (savedRange) { + sel!.removeAllRanges(); + sel!.addRange(savedRange); + } + } +}