Repository: interledger/web-monetization-extension (ts)

Issue:
Rate of pay slider
### Context

- #47

### Todos

- Add rate of pay slider
- The slider should have these hourly rates:
  - default: 0.60 (i.e. sixty cents per hour)
  - step: 0.01
  - min: 0.00
  - max: 1.20 (not sure about this value at the moment, but it can be easily changed) 
- Whenever the user changes the slider value, update the rate of pay in the extension storage and recalculate the amount that needs to be sent every second (which will be stored in the extension state)
- Display the current rate of pay and the remaining balance
- Add switch component to disable WM for the current website

> [!NOTE]  
> If the slider is dragged to its minimum value (0.00), we can disable the WM for the current website.

Linked to #53 


Pull request:
feat: Rate of pay slider component update
## Context

- update slider component ui
- connect with storage
- update storage to set/get specific values
- add slider tests

<img width="399" alt="image" src="https://github.com/interledger/web-monetization-extension/assets/2699091/b0357438-d222-47da-a328-9c3af70bd511">



Closes #60

Patch:
diff --git a/src/background/Background.ts b/src/background/Background.ts
index e45edb1..1f70618 100644
--- a/src/background/Background.ts
+++ b/src/background/Background.ts
@@ -1,24 +1,28 @@
 import { bytesToHex } from '@noble/hashes/utils'
-import browser, { Runtime, runtime, tabs } from 'webextension-polyfill'
+import { Runtime, runtime, tabs } from 'webextension-polyfill'
 
 import { PaymentFlowService } from '@/background/grantFlow'
 import { exportJWK, generateEd25519KeyPair } from '@/utils/crypto'
-import { defaultData } from '@/utils/storage'
-
-import getSendingPaymentPointerHandler from '../messageHandlers/getSendingPaymentPointerHandler'
-import getStorageData from '../messageHandlers/getStorageData'
-import isMonetizationReadyHandler from '../messageHandlers/isMonetizationReadyHandler'
-import setIncomingPointerHandler from '../messageHandlers/setIncomingPointerHandler'
+import { defaultData, storageApi } from '@/utils/storage'
+
+import {
+  getSendingPaymentPointerHandler,
+  getStorageData,
+  getStorageKey,
+  isMonetizationReadyHandler,
+  setIncomingPointerHandler,
+  setStorageKey,
+} from '../messageHandlers'
 import { tabChangeHandler, tabUpdateHandler } from './tabHandlers'
 
-const storage = browser.storage.local
-
 class Background {
   private messageHandlers: any = [
     isMonetizationReadyHandler,
     setIncomingPointerHandler,
     getSendingPaymentPointerHandler,
     getStorageData,
+    getStorageKey,
+    setStorageKey,
   ]
   private subscriptions: any = []
   // TO DO: remove these from background into storage or state & use injection
@@ -27,10 +31,16 @@ class Background {
   paymentStarted = false
 
   constructor() {
-    storage
-      .set({ data: defaultData })
-      .then(() => console.log('Default data stored successfully'))
-      .catch((error: any) => console.error('Error storing data:', error))
+    this.setStorageDefaultData()
+  }
+
+  // TODO: to be moved to a service
+  async setStorageDefaultData() {
+    try {
+      await storageApi.set({ ...defaultData })
+    } catch (error) {
+      console.error('Error storing data:', error)
+    }
   }
 
   subscribeToMessages() {
@@ -38,7 +48,7 @@ class Background {
       const listener: any = (
         message: EXTMessage,
         sender: Runtime.MessageSender,
-        sendResponse: (res: any) => void,
+        sendResponse: (_res: any) => void,
       ) => {
         if (handler.type === message.type) {
           handler
diff --git a/src/components/slider.tsx b/src/components/slider.tsx
index 2e8b454..6981e7a 100644
--- a/src/components/slider.tsx
+++ b/src/components/slider.tsx
@@ -9,47 +9,44 @@ export interface SliderProps extends React.InputHTMLAttributes<HTMLInputElement>
   min?: number
   max?: number
   value?: number
-  defaultValue?: number
+  onChange?: (_event: React.ChangeEvent<HTMLInputElement>) => void
 }
 
+const sliderClasses = `
+  [&::-webkit-slider-thumb]:appearance-none
+  [&::-webkit-slider-thumb]:w-5
+  [&::-webkit-slider-thumb]:h-5
+  [&::-webkit-slider-thumb]:bg-switch-base
+  [&::-webkit-slider-thumb]:rounded-full
+  [&::-moz-range-thumb]:appearance-none
+  [&::-moz-range-thumb]:w-5
+  [&::-moz-range-thumb]:h-5
+  [&::-moz-range-thumb]:bg-switch-base
+  [&::-moz-range-thumb]:rounded-full
+  w-full h-1 bg-disabled-strong rounded-lg
+  appearance-none cursor-pointer dark:bg-disabled-strong
+`
+
 export const Slider = forwardRef<HTMLInputElement, SliderProps>(function Slider(
-  { errorMessage, defaultValue, value, className, disabled, ...props },
+  { errorMessage, value = 0, className, onChange = () => {}, disabled, ...props },
   ref,
 ) {
-  const [innerValue, setInnerValue] = React.useState<number>(value || defaultValue || 0)
-
   return (
-    <div className="w-100">
-      <input
-        ref={ref}
-        type="range"
-        className={
-          (cn(
-            `[&::-webkit-slider-thumb]:appearance-none
-        [&::-webkit-slider-thumb]:w-5
-        [&::-webkit-slider-thumb]:h-5
-        [&::-webkit-slider-thumb]:bg-switch-base
-        [&::-webkit-slider-thumb]:rounded-full
-        [&::-moz-range-thumb]:appearance-none
-        [&::-moz-range-thumb]:w-5
-        [&::-moz-range-thumb]:h-5
-        [&::-moz-range-thumb]:bg-switch-base
-        [&::-moz-range-thumb]:rounded-full
-        w-full h-1 bg-disabled-strong rounded-lg appearance-none cursor-pointer dark:bg-disabled-strong`,
-            innerValue === 0 &&
-              '[&::-webkit-slider-thumb]:bg-disabled-strong [&::-moz-range-thumb]:bg-disabled-strong',
-          ),
-          className)
-        }
-        disabled={disabled ?? false}
-        aria-disabled={disabled ?? false}
-        aria-invalid={!!errorMessage}
-        aria-describedby={errorMessage}
-        defaultValue={defaultValue}
-        value={innerValue}
-        onChange={e => setInnerValue(Number(e.target.value))}
-        {...props}
-      />
+    <div className="w-full">
+      <div className="h-1 flex items-center">
+        <input
+          ref={ref}
+          type="range"
+          className={sliderClasses + cn(className)}
+          disabled={disabled ?? false}
+          aria-disabled={disabled ?? false}
+          aria-invalid={!!errorMessage}
+          aria-describedby={errorMessage}
+          value={value}
+          onChange={onChange}
+          {...props}
+        />
+      </div>
 
       {errorMessage && <p className="text-error text-sm px-2">{errorMessage}</p>}
     </div>
diff --git a/src/messageHandlers/getStorageData.ts b/src/messageHandlers/getStorageData.ts
index 1152a51..d1ae964 100644
--- a/src/messageHandlers/getStorageData.ts
+++ b/src/messageHandlers/getStorageData.ts
@@ -1,12 +1,17 @@
-import browser from 'webextension-polyfill'
-
-const storage = browser.storage.local
+import { storageApi } from '@/utils/storage'
 
 const getStorageData = async () => {
-  const data = await storage.get('data')
-  return {
-    type: 'SUCCESS',
-    data,
+  try {
+    const data = await storageApi.get('data')
+    return {
+      type: 'SUCCESS',
+      data,
+    }
+  } catch (error) {
+    return {
+      type: 'ERROR',
+      error,
+    }
   }
 }
 
diff --git a/src/messageHandlers/getStorageKey.ts b/src/messageHandlers/getStorageKey.ts
new file mode 100644
index 0000000..a24565b
--- /dev/null
+++ b/src/messageHandlers/getStorageKey.ts
@@ -0,0 +1,18 @@
+import { storageApi } from '@/utils/storage'
+
+const getStorageKey = async (key: any) => {
+  try {
+    const data = await storageApi.get(key)
+    return {
+      type: 'SUCCESS',
+      [key]: data?.[key],
+    }
+  } catch (error) {
+    return {
+      type: 'ERROR',
+      error,
+    }
+  }
+}
+
+export default { callback: getStorageKey, type: 'GET_STORAGE_KEY' }
diff --git a/src/messageHandlers/index.ts b/src/messageHandlers/index.ts
new file mode 100644
index 0000000..00c4971
--- /dev/null
+++ b/src/messageHandlers/index.ts
@@ -0,0 +1,15 @@
+import getSendingPaymentPointerHandler from './getSendingPaymentPointerHandler'
+import getStorageData from './getStorageData'
+import getStorageKey from './getStorageKey'
+import isMonetizationReadyHandler from './isMonetizationReadyHandler'
+import setIncomingPointerHandler from './setIncomingPointerHandler'
+import setStorageKey from './setStorageKey'
+
+export {
+  getSendingPaymentPointerHandler,
+  getStorageData,
+  getStorageKey,
+  isMonetizationReadyHandler,
+  setIncomingPointerHandler,
+  setStorageKey,
+}
diff --git a/src/messageHandlers/setStorageKey.ts b/src/messageHandlers/setStorageKey.ts
new file mode 100644
index 0000000..c800488
--- /dev/null
+++ b/src/messageHandlers/setStorageKey.ts
@@ -0,0 +1,12 @@
+import { storageApi } from '@/utils/storage'
+
+const setStorageKey = async ({ key, value }: { key: string; value: any }) => {
+  try {
+    await storageApi.set({ [key]: value })
+    return { type: 'SUCCESS' }
+  } catch (error) {
+    return { type: 'ERROR', error }
+  }
+}
+
+export default { callback: setStorageKey, type: 'SET_STORAGE_KEY' }
diff --git a/src/popup/pages/Home.tsx b/src/popup/pages/Home.tsx
index a9df481..b4907fa 100644
--- a/src/popup/pages/Home.tsx
+++ b/src/popup/pages/Home.tsx
@@ -1,7 +1,10 @@
 import React, { useEffect, useState } from 'react'
 import { runtime } from 'webextension-polyfill'
 
+import { Slider } from '@/components/slider'
+import { formatCurrency } from '@/utils/formatCurrency'
 import { sendMessage, sendMessageToActiveTab } from '@/utils/sendMessages'
+import { getStorageKey } from '@/utils/storage'
 
 const Success = runtime.getURL('assets/images/web-monetization-success.svg')
 const Fail = runtime.getURL('assets/images/web-monetization-fail.svg')
@@ -28,6 +31,8 @@ const PopupFooter: React.FC<IProps> = ({ isMonetizationReady }) => (
 // --- End of Temporary code until real UI implemented ---
 
 export const Home = () => {
+  const [remainingBalance, setRemainingBalance] = useState(0)
+  const [rateOfPay, setRateOfPay] = useState(0.36)
   const [loading, setLoading] = useState(false)
   const [paymentStarted, setPaymentStarted] = useState(false)
   const [spent, setSpent] = useState(0)
@@ -36,16 +41,28 @@ export const Home = () => {
   const [receivingPaymentPointer, setReceivingPaymentPointer] = useState('')
   const [formData, setFormData] = useState({
     paymentPointer: sendingPaymentPointer || '',
-    amount: 20,
+    amount: 0,
   })
 
   useEffect(() => {
     checkMonetizationReady()
     getSendingPaymentPointer()
     listenForIncomingPayment()
+    getRateOfPay()
+    getRemainingBalance()
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [])
 
+  const getRateOfPay = async () => {
+    const response = await getStorageKey('rateOfPay')
+    response && setRateOfPay(response)
+  }
+
+  const getRemainingBalance = async () => {
+    const response = await getStorageKey('amount')
+    response && setRemainingBalance(response)
+  }
+
   const checkMonetizationReady = async () => {
     const response = await sendMessageToActiveTab({ type: 'IS_MONETIZATION_READY' })
     setIsMonetizationReady(response.data.monetization)
@@ -71,7 +88,6 @@ export const Home = () => {
 
   const getSendingPaymentPointer = async () => {
     const response = await sendMessage({ type: 'GET_SENDING_PAYMENT_POINTER' })
-    console.log('getSendingPaymentPointer', response)
     setSendingPaymentPointer(response.data.sendingPaymentPointerUrl)
 
     const { sendingPaymentPointerUrl: paymentPointer, amount } = response.data
@@ -112,6 +128,14 @@ export const Home = () => {
     await sendMessageToActiveTab({ type: 'STOP_PAYMENTS' })
   }
 
+  const updateRateOfPay = async (event: any) => {
+    setRateOfPay(event.target.value)
+    await sendMessage({
+      type: 'SET_STORAGE_KEY',
+      data: { key: 'rateOfPay', value: event.target.value },
+    })
+  }
+
   return (
     <>
       {!!spent && (
@@ -120,6 +144,15 @@ export const Home = () => {
         </div>
       )}
       <div className="content">
+        <div className="grid gap-4 w-full">
+          <div className="px-2 text-base font-medium text-medium">Current rate of pay</div>
+          <Slider min={0} max={1} step={0.01} value={rateOfPay} onChange={updateRateOfPay} />
+          <div className="flex items-center justify-between w-full">
+            <span>{formatCurrency(rateOfPay)} per hour</span>
+            <span>Remaining balance: ${remainingBalance}</span>
+          </div>
+        </div>
+
         {isMonetizationReady ? (
           <>
             <img src={Success} alt="Success" />
diff --git a/src/providers/popup.provider.tsx b/src/providers/popup.provider.tsx
index 4f2e10b..999e77a 100644
--- a/src/providers/popup.provider.tsx
+++ b/src/providers/popup.provider.tsx
@@ -1,43 +1,35 @@
 import React, { createContext, useEffect, useState } from 'react'
 
-import { getStorageData } from '@/utils/storage'
+import { defaultData, getStorageData } from '@/utils/storage'
 
 import { PopupContextValue, TPopupContext } from './providers.interface'
 
-export const defaultData = {
-  connected: false,
-  wallet: '',
-  amount: 0,
-  amountType: {
-    recurring: true,
-  },
-  rateOfPay: 0.36,
-  wmEnabled: true,
-  accessTokenQuote: '',
-  accessTokenOutgoing: '',
-  refreshToken: '',
-  manageUrl: '',
-}
-
 interface IProps {
   children: React.ReactNode
 }
 
 export const PopupContext = createContext<PopupContextValue>({
-  data: defaultData,
+  data: { ...defaultData },
   setData: () => {},
 })
 
 export const PopupProvider: React.FC<IProps> = ({ children }) => {
-  const [data, setData] = useState<TPopupContext>(defaultData)
+  const [data, setData] = useState<TPopupContext>({ ...defaultData })
 
   useEffect(() => {
-    ;(async () => {
-      const storageData = await getStorageData()
-      setData(storageData as TPopupContext)
-    })()
-
-    // eslint-disable-next-line react-hooks/exhaustive-deps
+    const fetchData = async () => {
+      try {
+        const storageData = await getStorageData()
+        if (storageData) {
+          setData(prevState => ({ ...prevState, ...storageData }))
+        }
+      } catch (error) {
+        console.error('Error fetching storage data:', error)
+        setData(defaultData)
+      }
+    }
+
+    fetchData()
   }, [])
 
   return <PopupContext.Provider value={{ data, setData }}>{children}</PopupContext.Provider>
diff --git a/src/types/message.d.ts b/src/types/message.d.ts
index 1fe988d..2cecc84 100644
--- a/src/types/message.d.ts
+++ b/src/types/message.d.ts
@@ -13,6 +13,8 @@ declare type EXTMessageType =
   | 'LOAD'
   | 'GET_STORAGE_DATA'
   | 'SET_STORAGE_DATA'
+  | 'GET_STORAGE_KEY'
+  | 'SET_STORAGE_KEY'
 
 declare type EXTMessage<T = any> = {
   type: EXTMessageType
diff --git a/src/utils/formatCurrency.ts b/src/utils/formatCurrency.ts
new file mode 100644
index 0000000..e8bef4a
--- /dev/null
+++ b/src/utils/formatCurrency.ts
@@ -0,0 +1,7 @@
+export const formatCurrency = (value: any): string => {
+  if (value < 1) {
+    return `${Math.round(value * 100)}c`
+  } else {
+    return `$${parseFloat(value).toFixed(2)}`
+  }
+}
diff --git a/src/utils/storage.ts b/src/utils/storage.ts
index 8461674..569df53 100755
--- a/src/utils/storage.ts
+++ b/src/utils/storage.ts
@@ -1,20 +1,11 @@
+import browser from 'webextension-polyfill'
+
 import { TPopupContext } from '@/providers/providers.interface'
 import { sendMessage } from '@/utils/sendMessages'
 
-export interface ExtensionStorageData {
-  amount: number
-  amountType: {
-    recurring: boolean
-  }
-  rateOfPay: number
-  wmEnabled: boolean
-  accessTokenQuote: string
-  accessTokenOutgoing: string
-  refreshToken: string
-  manageUrl: string
-}
-
-export const defaultData: ExtensionStorageData = {
+export const defaultData: TPopupContext = {
+  connected: false,
+  wallet: '',
   amount: 0,
   amountType: {
     recurring: true,
@@ -36,3 +27,10 @@ export const getStorageData = async () => {
     return null
   }
 }
+
+export const getStorageKey = async (key: string) => {
+  const response: any = await sendMessage({ type: 'GET_STORAGE_KEY', data: key })
+  return response?.[key]
+}
+
+export const storageApi = browser.storage?.sync || browser.storage?.local


Test patch:
diff --git a/src/components/__tests__/slider.test.tsx b/src/components/__tests__/slider.test.tsx
new file mode 100644
index 0000000..7c7798d
--- /dev/null
+++ b/src/components/__tests__/slider.test.tsx
@@ -0,0 +1,30 @@
+import '@testing-library/jest-dom'
+
+import { render, screen } from '@testing-library/react'
+import React from 'react'
+
+import { Slider } from '../slider'
+
+describe('Slider Component', () => {
+  it('renders without crashing', () => {
+    render(<Slider />)
+    expect(screen.getByRole('slider')).toBeInTheDocument()
+  })
+
+  it('handles disabled prop', () => {
+    render(<Slider disabled={true} />)
+    expect(screen.getByRole('slider')).toBeDisabled()
+  })
+
+  it('displays error message when provided', () => {
+    const errorMessage = 'Error message'
+    render(<Slider errorMessage={errorMessage} />)
+    expect(screen.getByText(errorMessage)).toBeInTheDocument()
+  })
+
+  it('passes additional props to the input', () => {
+    const testName = 'test-name'
+    render(<Slider name={testName} />)
+    expect(screen.getByRole('slider')).toHaveAttribute('name', testName)
+  })
+})
diff --git a/src/providers/__tests__/popup-context.test.tsx b/src/providers/__tests__/popup-context.test.tsx
index 63fbe7e..d5b8064 100644
--- a/src/providers/__tests__/popup-context.test.tsx
+++ b/src/providers/__tests__/popup-context.test.tsx
@@ -1,7 +1,9 @@
 import { act, render, screen } from '@testing-library/react'
 import React, { useContext } from 'react'
 
-import { defaultData, PopupContext, PopupProvider } from '../popup.provider'
+import { defaultData } from '@/utils/storage'
+
+import { PopupContext, PopupProvider } from '../popup.provider'
 
 jest.mock('webextension-polyfill', () => ({
   runtime: {