Repository: interledger/web-monetization-extension
Pull request #944 (2025-02-27 13:19:24)

fix(ConnectWalletForm): show current step along with spinner
## Context

Part of https://github.com/interledger/web-monetization-extension/issues/844

## Changes proposed in this pull request

- Save current step title in `TransientState['connect']`
   - Also add little helpers for setting transient state
- Show current step title in connect form's submit button
   - Allow Button to show text along with spinner when `loading=true`

### Demo

[Screencast from 2025-02-28 17-18-30.webm](https://github.com/user-attachments/assets/ec12eee5-1054-4f44-831d-b24f4e1856bf)


github-actions[bot]: <!-- __WM_EXTENSION_BUILD_PREVIEWS__ -->
<h2>Extension builds preview</h2>

<table role="table">
<thead>
  <tr>
    <th>Name</th>
    <th>Link</th>
  </tr>
</thead>
<tbody>
  <tr>
    <td align="center">Latest commit</td>
    <td align="center">07e57d10e57ec3ffc5365f13a61ca9cee383da0c</td>
  </tr>
  <tr>
    <td align="center">Latest job logs</td>
    <td align="center"><a href="https://github.com/interledger/web-monetization-extension/actions/runs/13567360537">Run #13567360537</a></td>
  </tr>
  <tr><td align="center"><img src="https://img.shields.io/badge/success-3fb950?style=for-the-badge&label=Chrome (1.02MB)" alt="Badge" /></td><td align="center"><a href="https://github.com/interledger/web-monetization-extension/suites/34973524399/artifacts/2663325493">Download</a></td></tr><tr><td align="center"><img src="https://img.shields.io/badge/success-3fb950?style=for-the-badge&label=Firefox (1.02MB)" alt="Badge" /></td><td align="center"><a href="https://github.com/interledger/web-monetization-extension/suites/34973524399/artifacts/2663325804">Download</a></td></tr>
</tbody>
</table>

Patch:
diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json
index 0d33559..6c9d851 100755
--- a/src/_locales/en/messages.json
+++ b/src/_locales/en/messages.json
@@ -150,6 +150,9 @@
   "connectWallet_action_connect": {
     "message": "Connect"
   },
+  "connectWallet_text_stepAcceptGrant": {
+    "message": "Waiting for your approval"
+  },
   "connectWallet_error_urlRequired": {
     "message": "The wallet address is required."
   },
@@ -218,6 +221,18 @@
   "connectWalletKeyService_label_consentDecline": {
     "message": "Decline"
   },
+  "connectWalletKeyService_text_stepAddKey": {
+    "message": "Adding public key to wallet"
+  },
+  "connectWalletKeyService_text_stepWaitingLogin": {
+    "message": "Waiting for you to login"
+  },
+  "connectWalletKeyService_text_stepFindingWallet": {
+    "message": "Finding wallet"
+  },
+  "connectWalletKeyService_text_stepAddingKey": {
+    "message": "Adding key"
+  },
   "connectWalletKeyService_error_notImplemented": {
     "message": "Automatic key addition is not yet implemented for the selected wallet provider."
   },
diff --git a/src/background/services/keyAutoAdd.ts b/src/background/services/keyAutoAdd.ts
index ef1728a..af51bc8 100644
--- a/src/background/services/keyAutoAdd.ts
+++ b/src/background/services/keyAutoAdd.ts
@@ -13,6 +13,7 @@ import type { Cradle } from '@/background/container';
 import type {
   BeginPayload,
   KeyAutoAddToBackgroundMessage,
+  StepWithStatus,
 } from '@/content/keyAutoAdd/lib/types';
 
 export const CONNECTION_NAME = 'key-auto-add';
@@ -57,7 +58,7 @@ export class KeyAutoAddService {
         'publicKey',
         'keyId',
       ]);
-      this.updateConnectState();
+      this.setConnectState(this.t('connectWalletKeyService_text_stepAddKey'));
       await this.process(
         keyAddUrl,
         {
@@ -72,7 +73,7 @@ export class KeyAutoAddService {
       await this.validate(walletAddress.id, keyId);
     } catch (error) {
       if (!error.key || !error.key.startsWith('connectWallet_error_')) {
-        this.updateConnectState(error);
+        this.setConnectStateError(error);
       }
       throw error;
     }
@@ -150,6 +151,10 @@ export class KeyAutoAddService {
         );
       } else if (message.action === 'PROGRESS') {
         // can also save progress to show in popup
+        const currentStep = this.getCurrentStep(message.payload.steps);
+        if (currentStep) {
+          this.setConnectState(currentStep.name);
+        }
         for (const p of ports) {
           if (p !== port) p.postMessage(message);
         }
@@ -165,6 +170,13 @@ export class KeyAutoAddService {
     return promise;
   }
 
+  private getCurrentStep(steps: Readonly<StepWithStatus[]>) {
+    return steps
+      .slice()
+      .reverse()
+      .find((step) => step.status !== 'pending');
+  }
+
   private async validate(walletAddressUrl: string, keyId: string) {
     const jwks = await getJWKS(walletAddressUrl);
     if (!jwks.keys.find((key) => key.kid === keyId)) {
@@ -172,17 +184,18 @@ export class KeyAutoAddService {
     }
   }
 
-  private updateConnectState(err?: ErrorWithKeyLike | { message: string }) {
-    if (err) {
-      this.storage.setPopupTransientState('connect', () => ({
-        status: 'error:key',
-        error: isErrorWithKey(err) ? errorWithKeyToJSON(err) : err.message,
-      }));
-    } else {
-      this.storage.setPopupTransientState('connect', () => ({
-        status: 'connecting:key',
-      }));
-    }
+  private setConnectState(currentStep: string) {
+    this.storage.setPopupTransientState('connect', () => ({
+      status: 'connecting:key',
+      currentStep,
+    }));
+  }
+
+  private setConnectStateError(err: ErrorWithKeyLike | { message: string }) {
+    this.storage.setPopupTransientState('connect', () => ({
+      status: 'error:key',
+      error: isErrorWithKey(err) ? errorWithKeyToJSON(err) : err.message,
+    }));
   }
 
   static supports(walletAddress: WalletAddress): boolean {
diff --git a/src/background/services/wallet.ts b/src/background/services/wallet.ts
index 41b68f7..6d21b8f 100644
--- a/src/background/services/wallet.ts
+++ b/src/background/services/wallet.ts
@@ -98,7 +98,6 @@ export class WalletService {
     maxRateOfPay = getRateOfPay(MAX_RATE_OF_PAY);
 
     await this.openPaymentsService.initClient(walletAddress.id);
-    this.setConnectState('connecting');
 
     const appUrl = this.browser.runtime.getURL(APP_URL);
     const intent = InteractionIntent.CONNECT;
@@ -120,6 +119,7 @@ export class WalletService {
         this.windowState.getCurrentWindowId(),
         (url) => url.startsWith(appUrl),
       );
+      this.setConnectState(this.t('connectWallet_text_stepAcceptGrant'));
       cleanupListeners = highlightTabOnPopupOpen(this.browser, tabId);
       await this.outgoingPaymentGrantService.completeOutgoingPaymentGrant(
         walletAmount,
@@ -155,14 +155,17 @@ export class WalletService {
         cleanupListeners = highlightTabOnPopupOpen(this.browser, tabId);
         // add key to wallet and try again
         try {
+          this.setConnectState(
+            this.t('connectWalletKeyService_text_stepAddKey'),
+          );
           await this.addPublicKeyToWallet(walletAddress, tabId);
-          this.setConnectState('connecting');
           const grant =
             await this.outgoingPaymentGrantService.createOutgoingPaymentGrant(
               walletAddress,
               walletAmount,
               intent,
             );
+          this.setConnectState(this.t('connectWallet_text_stepAcceptGrant'));
           await this.outgoingPaymentGrantService.completeOutgoingPaymentGrant(
             walletAmount,
             walletAddress,
@@ -189,7 +192,7 @@ export class WalletService {
       maxRateOfPay,
       connected: true,
     });
-    this.setConnectState(null);
+    this.resetConnectState();
   }
 
   async reconnectWallet({ autoKeyAddConsent }: ReconnectWalletPayload) {
@@ -205,7 +208,7 @@ export class WalletService {
     if (!KeyAutoAddService.supports(walletAddress)) {
       throw new ErrorWithKey('connectWalletKeyService_error_notImplemented');
     }
-    this.setConnectState('connecting');
+    this.setConnectState('Reconnecting wallet');
 
     try {
       await this.validateReconnect();
@@ -225,7 +228,7 @@ export class WalletService {
       }
     }
 
-    this.setConnectState(null);
+    this.resetConnectState();
   }
 
   async disconnectWallet() {
@@ -450,12 +453,14 @@ export class WalletService {
   }
 
   public resetConnectState() {
-    this.setConnectState(null);
+    this.storage.setPopupTransientState('connect', () => null);
   }
 
-  private setConnectState(status: 'connecting' | null) {
-    const state = status ? { status } : null;
-    this.storage.setPopupTransientState('connect', () => state);
+  private setConnectState(currentStep: string) {
+    this.storage.setPopupTransientState('connect', () => ({
+      status: 'connecting',
+      currentStep,
+    }));
   }
 
   private setConnectStateError(err: ErrorWithKeyLike | { message: string }) {
diff --git a/src/pages/popup/components/ConnectWalletForm.tsx b/src/pages/popup/components/ConnectWalletForm.tsx
index b461585..225b355 100644
--- a/src/pages/popup/components/ConnectWalletForm.tsx
+++ b/src/pages/popup/components/ConnectWalletForm.tsx
@@ -444,6 +444,11 @@ export const ConnectWalletForm = ({
             !amount
           }
           loading={isSubmitting}
+          loadingText={
+            state?.status === 'connecting' || state?.status === 'connecting:key'
+              ? state.currentStep
+              : undefined
+          }
         >
           {t('connectWallet_action_connect')}
         </Button>
diff --git a/src/pages/shared/components/ui/Button.tsx b/src/pages/shared/components/ui/Button.tsx
index 5bde587..ccb632f 100644
--- a/src/pages/shared/components/ui/Button.tsx
+++ b/src/pages/shared/components/ui/Button.tsx
@@ -7,7 +7,7 @@ import { cn } from '@/shared/helpers';
 
 const buttonVariants = cva(
   [
-    'relative inline-flex items-center justify-center whitespace-nowrap rounded-xl font-semibold',
+    'relative inline-flex gap-2 items-center justify-center whitespace-nowrap rounded-xl font-semibold',
     'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500',
     'disabled:pointer-events-none disabled:select-none disabled:opacity-50',
   ],
@@ -27,7 +27,7 @@ const buttonVariants = cva(
         true: 'w-full',
       },
       loading: {
-        true: 'text-transparent',
+        true: '',
       },
     },
     defaultVariants: {
@@ -41,6 +41,7 @@ export interface ButtonProps
   extends VariantProps<typeof buttonVariants>,
     React.ButtonHTMLAttributes<HTMLButtonElement> {
   loading?: boolean;
+  loadingText?: string;
   /** Optional only when children are passed */
   'aria-label'?: string;
 }
@@ -52,6 +53,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
       size,
       fullWidth,
       loading = false,
+      loadingText,
       className,
       type = 'button',
       children,
@@ -72,7 +74,14 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
         aria-disabled={props.disabled ?? loading ?? false}
         {...props}
       >
-        {loading ? <LoadingSpinner /> : children}
+        {loading ? (
+          <>
+            <LoadingSpinner />
+            {loadingText}
+          </>
+        ) : (
+          children
+        )}
       </button>
     );
   },
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 13b6c67..7f9638d 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -114,7 +114,7 @@ export type PopupTabInfo = {
 export type PopupTransientState = Partial<{
   connect:
     | null
-    | { status: 'connecting' | 'connecting:key' }
+    | { status: 'connecting' | 'connecting:key'; currentStep: string }
     | { status: 'error' | 'error:key'; error: string | ErrorWithKeyLike };
 }>;
 


Test patch:
diff --git a/src/pages/shared/components/ui/__tests__/Button.test.tsx b/src/pages/shared/components/ui/__tests__/Button.test.tsx
index 356bd3f..e3df36e 100644
--- a/src/pages/shared/components/ui/__tests__/Button.test.tsx
+++ b/src/pages/shared/components/ui/__tests__/Button.test.tsx
@@ -46,6 +46,21 @@ describe('Button', () => {
     expect(queryByRole('button')).toBeDisabled();
   });
 
+  it('should allow text along with loading spinner', () => {
+    const { queryByRole } = render(
+      <Button aria-label="test button" loading loadingText="me loading">
+        My Button
+      </Button>,
+    );
+
+    expect(queryByRole('button')).toBeInTheDocument();
+    expect(queryByRole('button')).toHaveAttribute('disabled');
+    expect(queryByRole('button')).toHaveAttribute('aria-disabled', 'true');
+    expect(queryByRole('button')?.querySelector('svg')).toBeDefined();
+    expect(queryByRole('button')).toHaveTextContent('me loading');
+    expect(queryByRole('button')).toBeDisabled();
+  });
+
   it('should have the `bg-button-base` class by default', () => {
     const { queryByRole } = render(
       <Button aria-label="test button">My Button</Button>,