diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index f336ea9179..3a1e004597 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -929,7 +929,7 @@ function SideBarContainer({ bannersHeight, isPlatformUser = false }: SideBarCont } function SideBar({ bannersHeight, user }: SideBarProps) { - const { copyToClipboard } = useCopy(); + const { fetchAndCopyToClipboard } = useCopy(); const { t, isLocaleReady } = useLocale(); const orgBranding = useOrgBranding(); const pathname = usePathname(); @@ -967,16 +967,19 @@ function SideBar({ bannersHeight, user }: SideBarProps) { ? { name: "copy_referral_link", href: "", - onClick: async (e: { preventDefault: () => void }) => { + onClick: (e: { preventDefault: () => void }) => { e.preventDefault(); - const res = await fetch("/api/generate-referral-link", { - method: "POST", - }); - const { shortLink } = await res.json(); - copyToClipboard(shortLink, { - onSuccess: () => showToast(t("link_copied"), "success"), - onFailure: () => showToast("Copy to clipboard failed", "error"), - }); + fetchAndCopyToClipboard( + fetch("/api/generate-referral-link", { + method: "POST", + }) + .then((res) => res.json()) + .then((res) => res.shortLink), + { + onSuccess: () => showToast(t("link_copied"), "success"), + onFailure: () => showToast("Copy to clipboard failed", "error"), + } + ); }, icon: "gift", } diff --git a/packages/lib/hooks/useCopy.ts b/packages/lib/hooks/useCopy.ts index 658d32d5dd..b088b83663 100644 --- a/packages/lib/hooks/useCopy.ts +++ b/packages/lib/hooks/useCopy.ts @@ -27,6 +27,7 @@ export function useCopy() { "You need to use a secure context to use clipboard \n Please use the following link: ", text ); + onFailure(); } }; @@ -34,6 +35,37 @@ export function useCopy() { setIsCopied(false); }; + /** @see https://wolfgangrittner.dev/how-to-use-clipboard-api-in-safari/ */ + const fetchAndCopyToClipboard = ( + promise: Promise, + options: { onSuccess?: () => void; onFailure?: () => void } = {} + ) => { + const { onSuccess = noop, onFailure = noop } = options; + if (typeof ClipboardItem && navigator.clipboard.write) { + // NOTE: Safari locks down the clipboard API to only work when triggered + // by a direct user interaction. You can't use it async in a promise. + // But! You can wrap the promise in a ClipboardItem, and give that to + // the clipboard API. + // Found this on https://developer.apple.com/forums/thread/691873 + const text = new ClipboardItem({ + "text/plain": promise + .then((text) => new Blob([text], { type: "text/plain" })) + .catch(() => { + onFailure(); + return ""; + }), + }); + navigator.clipboard.write([text]); + onSuccess(); + } else { + // NOTE: Firefox has support for ClipboardItem and navigator.clipboard.write, + // but those are behind `dom.events.asyncClipboard.clipboardItem` preference. + // Good news is that other than Safari, Firefox does not care about + // Clipboard API being used async in a Promise. + promise.then((text) => copyToClipboard(text, options)).catch(() => onFailure()); + } + }; + useEffect(() => { if (isCopied) { const timer = setTimeout(resetCopyStatus, 3000); // Reset copy status after 3 seconds @@ -41,5 +73,5 @@ export function useCopy() { } }, [isCopied]); - return { isCopied, copyToClipboard, resetCopyStatus }; + return { isCopied, copyToClipboard, resetCopyStatus, fetchAndCopyToClipboard }; }