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.
This commit is contained in:
@@ -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") });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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") });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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") });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user