Repository: BoltzExchange/boltz-web-app
Pull request #886: feat: improve UX for copying address/invoice on click
Author: login: kiocos
Created: 2025-04-06T00:14:31Z | Merged: 2025-04-08T18:54:53Z

Closes #875

The issue was about adding a "copy on click" functionality to the address/invoice "input", however the functionality was already there, it just wasn't clear due to lack of clues about it.

This PR adds to the "input" the same icon aspect & behavior we already had in other buttons:
![image](https://github.com/user-attachments/assets/00b90ea4-b7b1-4be1-bdeb-626d6ad4be78)
![image](https://github.com/user-attachments/assets/cd1029e8-ec13-4ea7-a011-654cb97c7583)



Linked issue #875: Copy Invoice/Address on Click
Had two users recently that didn't notice or understand our "Copy Invoice" button on Desktop.
![Image](https://github.com/user-attachments/assets/e8864fbf-6709-4439-b165-f3af8702c4f0)

Let's add a "[Click to copy](https://masteringjs.io/tutorials/fundamentals/copy-to-clipboard)" function to the invoice field. Right now one can only mark and copy which copies the invoice with `...` which is the last thing we want.

![Image](https://github.com/user-attachments/assets/75bf6cfa-c0f1-41b0-875a-ae8a81057a57)

Let's do the same for addresses in submarine and chain swaps:
![Image](https://github.com/user-attachments/assets/5fa4531b-614c-4a50-aa45-1546e53b5c1e)

Diff:
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 61031abbe..76968a92a 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -36,10 +36,7 @@ export default [
     {
         rules: {
             "no-async-promise-executor": "off",
-        },
-    },
-    {
-        rules: {
+            "no-console": 1,
             "no-restricted-imports": [
                 "error",
                 {
@@ -86,4 +83,10 @@ export default [
             "@typescript-eslint/no-unnecessary-type-assertion": "off",
         },
     },
+    {
+        files: ["tests/**/*.ts", "e2e/**/*.ts"],
+        rules: {
+            "no-console": "off",
+        },
+    },
 ];
diff --git a/src/components/CopyBox.tsx b/src/components/CopyBox.tsx
new file mode 100644
index 000000000..4d69903c8
--- /dev/null
+++ b/src/components/CopyBox.tsx
@@ -0,0 +1,33 @@
+import { BiRegularCopy } from "solid-icons/bi";
+import { IoCheckmark } from "solid-icons/io";
+import { Show, createSignal } from "solid-js";
+
+import { copyIconTimeout } from "../consts/CopyContent";
+import { clipboard, cropString, isMobile } from "../utils/helper";
+
+const CopyBox = (props: { value: string }) => {
+    const [copyBoxActive, setCopyBoxActive] = createSignal(false);
+    const baseStrLength = isMobile() ? 8 : 13;
+    const maxStrLength = baseStrLength + 3; // 3 is the length of the ellipsis
+
+    const copyBoxText = () => {
+        clipboard(props.value);
+        setCopyBoxActive(true);
+        setTimeout(() => {
+            setCopyBoxActive(false);
+        }, copyIconTimeout);
+    };
+
+    return (
+        <p onClick={copyBoxText} class="copy-box break-word">
+            <Show
+                when={copyBoxActive()}
+                fallback={<BiRegularCopy size={23} data-testid="copy-icon" />}>
+                <IoCheckmark size={23} data-testid="checkmark-icon" />
+            </Show>
+            {cropString(props.value, baseStrLength, maxStrLength)}
+        </p>
+    );
+};
+
+export default CopyBox;
diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx
index ab2b66cf2..ed5eeac11 100644
--- a/src/components/CopyButton.tsx
+++ b/src/components/CopyButton.tsx
@@ -3,6 +3,7 @@ import { IoCheckmark } from "solid-icons/io";
 import type { Accessor } from "solid-js";
 import { Show, createEffect, createSignal, mergeProps } from "solid-js";
 
+import { copyIconTimeout } from "../consts/CopyContent";
 import { useGlobalContext } from "../context/Global";
 import type { DictKey } from "../i18n/i18n";
 import { clipboard } from "../utils/helper";
@@ -32,7 +33,7 @@ const CopyButton = (props: {
         setTimeout(() => {
             setButtonClass(merged.btnClass);
             setButtonActive(false);
-        }, 600);
+        }, copyIconTimeout);
     };
 
     return (
diff --git a/src/components/PayInvoice.tsx b/src/components/PayInvoice.tsx
index 59b301d4e..e3b3110a8 100644
--- a/src/components/PayInvoice.tsx
+++ b/src/components/PayInvoice.tsx
@@ -7,12 +7,14 @@ import QrCode from "../components/QrCode";
 import { BTC } from "../consts/Assets";
 import { useGlobalContext } from "../context/Global";
 import { formatAmount, formatDenomination } from "../utils/denomination";
-import { clipboard, cropString, isMobile } from "../utils/helper";
+import { isMobile } from "../utils/helper";
 import { invoicePrefix } from "../utils/invoice";
 import { enableWebln } from "../utils/webln";
+import CopyBox from "./CopyBox";
 
 const PayInvoice = (props: { sendAmount: number; invoice: string }) => {
     const { t, denomination, separator, webln } = useGlobalContext();
+
     const payWeblnInvoice = async (pr: string) => {
         await enableWebln(async () => {
             const result = await window.webln.sendPayment(pr);
@@ -37,11 +39,7 @@ const PayInvoice = (props: { sendAmount: number; invoice: string }) => {
                 <QrCode data={props.invoice} />
             </a>
             <hr />
-            <p
-                onClick={() => clipboard(props.invoice)}
-                class="address-box break-word">
-                {cropString(props.invoice)}
-            </p>
+            <CopyBox value={props.invoice} />
             <hr />
             <Show when={isMobile()}>
                 <h3>{t("warning_return")}</h3>
diff --git a/src/components/PayOnchain.tsx b/src/components/PayOnchain.tsx
index 99f786551..585805469 100644
--- a/src/components/PayOnchain.tsx
+++ b/src/components/PayOnchain.tsx
@@ -8,7 +8,8 @@ import type { SwapType } from "../consts/Enums";
 import { useGlobalContext } from "../context/Global";
 import { getPairs } from "../utils/boltzClient";
 import { formatAmount, formatDenomination } from "../utils/denomination";
-import { clipboard, cropString, getPair, isMobile } from "../utils/helper";
+import { getPair, isMobile } from "../utils/helper";
+import CopyBox from "./CopyBox";
 import LoadingSpinner from "./LoadingSpinner";
 
 const PayOnchain = (props: {
@@ -81,11 +82,7 @@ const PayOnchain = (props: {
                     <QrCode asset={props.assetSend} data={props.bip21} />
                 </a>
                 <hr />
-                <p
-                    onClick={() => clipboard(props.address)}
-                    class="address-box break-word">
-                    {cropString(props.address)}
-                </p>
+                <CopyBox value={props.address} />
                 <Show when={props.assetSend === BTC}>
                     <hr class="spacer" />
                     <h3>{t("warning_expiry")}</h3>
diff --git a/src/consts/CopyContent.ts b/src/consts/CopyContent.ts
new file mode 100644
index 000000000..462229138
--- /dev/null
+++ b/src/consts/CopyContent.ts
@@ -0,0 +1 @@
+export const copyIconTimeout = 600;
diff --git a/src/style/index.scss b/src/style/index.scss
index e8864b28f..68de1f4ba 100644
--- a/src/style/index.scss
+++ b/src/style/index.scss
@@ -694,12 +694,17 @@ textarea {
     word-break: break-all;
 }
 
-.address-box {
-    font-size: 18px;
+.copy-box {
+    display: flex;
+    align-items: end;
+    justify-content: center;
+    gap: 0.63rem;
+    user-select: none;
+    font-size: 1.13rem;
     background-color: #545c65;
     background-color: rgba(0, 0, 0, 0.2);
-    border-radius: 7px;
-    padding: 10px;
+    border-radius: 0.43rem;
+    padding: 0.63rem;
     border: 1px solid rgba(255, 255, 255, 0.1);
     color: rgba(255, 255, 255, 0.5);
     margin: 0;
diff --git a/src/utils/compat.ts b/src/utils/compat.ts
index 2371a86b9..c7ae7d562 100644
--- a/src/utils/compat.ts
+++ b/src/utils/compat.ts
@@ -222,7 +222,6 @@ const getOutputAmount = async (
 
     if (output.rangeProof?.length !== 0) {
         const { confidential } = await secp.get();
-        console.log(output);
         const unblinded = confidential.unblindOutputWithKey(
             output,
             output.blindingPrivateKey,
diff --git a/tests/components/CopyBox.spec.tsx b/tests/components/CopyBox.spec.tsx
new file mode 100644
index 000000000..aec7f94d4
--- /dev/null
+++ b/tests/components/CopyBox.spec.tsx
@@ -0,0 +1,48 @@
+import { fireEvent, render, screen } from "@solidjs/testing-library";
+
+import CopyBox from "../../src/components/CopyBox";
+import { contextWrapper } from "../helper";
+
+/* eslint-disable @typescript-eslint/unbound-method */
+
+const writeText = vi.fn();
+
+Object.defineProperty(navigator, "clipboard", {
+    value: {
+        writeText,
+    },
+});
+
+describe("CopyBox", () => {
+    beforeEach(() => {
+        writeText.mockClear();
+    });
+
+    test("should stay active for 600ms and copy into clipboard", async () => {
+        const address = "bcrt1qhgpdl988atca59fv2hgh87kcs9td082aucra3d";
+
+        const {
+            container: { firstChild },
+        } = render(() => <CopyBox value={address} />, {
+            wrapper: contextWrapper,
+        });
+
+        const copyBox = firstChild as HTMLParagraphElement;
+
+        expect(copyBox).not.toBeUndefined();
+        expect(screen.getByTestId("copy-icon")).toBeTruthy();
+
+        fireEvent.click(copyBox);
+
+        expect(screen.getByTestId("checkmark-icon")).toBeTruthy();
+        expect(navigator.clipboard.writeText).toHaveBeenCalledWith(address);
+
+        // should still show checkmark
+        await new Promise((resolve) => setTimeout(resolve, 400));
+        expect(screen.getByTestId("checkmark-icon")).toBeTruthy();
+
+        // should show copy icon again
+        await new Promise((resolve) => setTimeout(resolve, 300));
+        expect(screen.getByTestId("copy-icon")).toBeTruthy();
+    });
+});


Reviews:
user: login: kilrau
body: Prevent selection of invoice text works, the icon and tick animation is nice and subtle 👍

Just that the icon currently breaks the UI depending on invoice char length (try with liquid swap, looks like here invoices are at least 1 char longer):
![image](https://github.com/user-attachments/assets/3087a51d-a2ad-47c0-a205-125b8aa09dad)

And screen resolution (lower bar is iPhone SE), either dynamically shorten more on smaller resolutions to always make it fit in one line or center the icon vertically:
![image](https://github.com/user-attachments/assets/0ca94b61-729e-408a-9994-236819516e69)
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-07T08:16:21Z
user: login: kilrau
body: LGTM 🎉
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-08T10:09:13Z
user: login: michael1011
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-08T11:46:00Z
user: login: kiocos
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T15:39:49Z
user: login: michael1011
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-08T15:51:38Z
user: login: kiocos
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T18:02:16Z
user: login: michael1011
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T18:08:18Z
user: login: kiocos
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T18:52:55Z
user: login: michael1011
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-08T18:54:45Z

Review comments:
diff_hunk: @@ -0,0 +1,30 @@
+import { BiRegularCopy } from "solid-icons/bi";
+import { IoCheckmark } from "solid-icons/io";
+import { Show, createSignal } from "solid-js";
+
+import { clipboard, cropString, isMobile } from "../utils/helper";
+
+const CopyBox = (props: { value: string }) => {
+    const [copyBoxActive, setCopyBoxActive] = createSignal(false);
+
+    const copyBoxText = () => {
+        clipboard(props.value);
+        setCopyBoxActive(true);
+        setTimeout(() => {
+            setCopyBoxActive(false);
+        }, 600);
path: src/components/CopyBox.tsx
user: login: michael1011
body: Let's use a constant for `600` and use it in `CopyButton.tsx` too to ensure consistency
created_at: 2025-04-08T11:45:53Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,30 @@
+import { BiRegularCopy } from "solid-icons/bi";
+import { IoCheckmark } from "solid-icons/io";
+import { Show, createSignal } from "solid-js";
+
+import { clipboard, cropString, isMobile } from "../utils/helper";
+
+const CopyBox = (props: { value: string }) => {
+    const [copyBoxActive, setCopyBoxActive] = createSignal(false);
+
+    const copyBoxText = () => {
+        clipboard(props.value);
+        setCopyBoxActive(true);
+        setTimeout(() => {
+            setCopyBoxActive(false);
+        }, 600);
path: src/components/CopyBox.tsx
user: login: kiocos
body: @michael1011 created a `CopyContent.ts` under `/consts` and exported it from there. Or would you rather just export the const from CopyBox?
created_at: 2025-04-08T15:39:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,31 @@
+import { BiRegularCopy } from "solid-icons/bi";
+import { IoCheckmark } from "solid-icons/io";
+import { Show, createSignal } from "solid-js";
+
+import { copyIconTimeout } from "../consts/CopyContent";
+import { clipboard, cropString, isMobile } from "../utils/helper";
+
+const CopyBox = (props: { value: string }) => {
+    const [copyBoxActive, setCopyBoxActive] = createSignal(false);
+
+    const copyBoxText = () => {
+        clipboard(props.value);
+        setCopyBoxActive(true);
+        setTimeout(() => {
+            setCopyBoxActive(false);
+        }, copyIconTimeout);
+    };
+    console.log(isMobile());
path: src/components/CopyBox.tsx
user: login: michael1011
body: ```suggestion
```
Sorry, missed that one the first time
created_at: 2025-04-08T15:51:20Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,31 @@
+import { BiRegularCopy } from "solid-icons/bi";
+import { IoCheckmark } from "solid-icons/io";
+import { Show, createSignal } from "solid-js";
+
+import { copyIconTimeout } from "../consts/CopyContent";
+import { clipboard, cropString, isMobile } from "../utils/helper";
+
+const CopyBox = (props: { value: string }) => {
+    const [copyBoxActive, setCopyBoxActive] = createSignal(false);
+
+    const copyBoxText = () => {
+        clipboard(props.value);
+        setCopyBoxActive(true);
+        setTimeout(() => {
+            setCopyBoxActive(false);
+        }, copyIconTimeout);
+    };
+    console.log(isMobile());
path: src/components/CopyBox.tsx
user: login: kiocos
body: @michael1011 actually my bad for not seeing it either, removed and added eslint rule to warn on `console`
created_at: 2025-04-08T18:02:16Z
author_association: MEMBER
diff_hunk: @@ -222,7 +223,7 @@ const getOutputAmount = async (
 
     if (output.rangeProof?.length !== 0) {
         const { confidential } = await secp.get();
-        console.log(output);
+        log.debug(output);
path: src/utils/compat.ts
user: login: michael1011
body: This one was me debugging a while ago. Can be removed
created_at: 2025-04-08T18:07:41Z
author_association: MEMBER
diff_hunk: @@ -24,7 +25,7 @@ const execCommand = async (command: string): Promise<string> => {
 
         return stdout.trim();
     } catch (error) {
-        console.error(`Failed to execute command: ${command}`, error);
+        log.error(`Failed to execute command: ${command}`, error);
path: e2e/utils.ts
user: login: michael1011
body: Does `log` even work here? It's an E2E test, so I guess we should allow console.log in this folder
created_at: 2025-04-08T18:08:05Z
author_association: MEMBER
diff_hunk: @@ -24,7 +25,7 @@ const execCommand = async (command: string): Promise<string> => {
 
         return stdout.trim();
     } catch (error) {
-        console.error(`Failed to execute command: ${command}`, error);
+        log.error(`Failed to execute command: ${command}`, error);
path: e2e/utils.ts
user: login: kiocos
body: @michael1011  disabled the rule for e2e/ and tests/
created_at: 2025-04-08T18:52:55Z
author_association: MEMBER

PR comments:
user: login: vercel[bot]
created_at: 2025-04-06T00:14:35Z
body: @kiocosta is attempting to deploy a commit to the **boltz** Team on [Vercel](https://vercel.com).

A member of the Team first needs to [authorize it](https://vercel.com/git/authorize?team=boltz&type=github&job=%7B%22headInfo%22%3A%7B%22sha%22%3A%22bd35f5880b3b294d7f3a6bd9a6d54c0b4ac53f3f%22%7D%2C%22id%22%3A%22QmXBrxZ828r4frNzPUTxHdVcAzRqBk8ExQXZLv4iycPEh2%22%2C%22org%22%3A%22BoltzExchange%22%2C%22prId%22%3A886%2C%22repo%22%3A%22boltz-web-app%22%7D).


author_association: NONE
user: login: vercel[bot]
created_at: 2025-04-06T08:59:56Z
body: [vc]: #qbXMPrykv5L/pdc7bx96PEBH6yoxIRg4WtT7lkm0FvE=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ3ZWJhcHAiLCJyb290RGlyZWN0b3J5IjpudWxsLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vYm9sdHovd2ViYXBwL0JYQlNxZEZVS2h4Qm9lVlFYUEVLNlBvNFY3RVIiLCJwcmV2aWV3VXJsIjoid2ViYXBwLWdpdC1mb3JrLWtpb2Nvc3RhLWNvcHktaW5wdXQtb24tY2xpY2stYm9sdHoudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCJ9LHsibmFtZSI6IndlYmFwcC1tYWlubmV0Iiwicm9vdERpcmVjdG9yeSI6bnVsbCwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2JvbHR6L3dlYmFwcC1tYWlubmV0LzRiM0hFZDVRc3JxUkd3eHRxVXlhM0VhWXg0Qk0iLCJwcmV2aWV3VXJsIjoid2ViYXBwLW1haW5uZXQtZ2l0LWZvcmsta2lvY29zdGEtY29weS1pbnB1dC1vbi1jbGljay1ib2x0ei52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated (UTC) |
| :--- | :----- | :------ | :------ |
| **webapp** | ✅ Ready ([Inspect](https://vercel.com/boltz/webapp/BXBSqdFUKhxBoeVQXPEK6Po4V7ER)) | [Visit Preview](https://webapp-git-fork-kiocosta-copy-input-on-click-boltz.vercel.app) | Apr 8, 2025 6:53pm |
| **webapp-mainnet** | ✅ Ready ([Inspect](https://vercel.com/boltz/webapp-mainnet/4b3HEd5QsrqRGwxtqUya3EaYx4BM)) | [Visit Preview](https://webapp-mainnet-git-fork-kiocosta-copy-input-on-click-boltz.vercel.app) | Apr 8, 2025 6:53pm |



author_association: NONE
user: login: kiocos
created_at: 2025-04-08T03:01:30Z
body: >Just that the icon currently breaks the UI depending on invoice char length (try with liquid swap, looks like here invoices are at least 1 char longer):

Should be good now! Tested with LN invoices, liquid invoices, BTC addresses and arbitrarily long random strings.

Watch-out that I am using "isMobile()" to adjust the length of the string; you need to press F5 when you switch between mobile and desktop version so this function runs again and renders accordingly 
author_association: MEMBER