Repository: interledger/web-monetization-extension
Pull request #278 (2024-05-20 12:18:48)

feat: Number format for rate of pay
#237 

raducristianpopa: <!-- __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">dac13881d7fc116f39473e7162e12f7a4364cd34</td>
  </tr>
  <tr>
    <td align="center">Latest job logs</td>
    <td align="center"><a href="https://github.com/interledger/web-monetization-extension/actions/runs/9158559953">Run #9158559953</a></td>
  </tr>
  <tr><td align="center"><img src="https://img.shields.io/badge/success-3fb950?style=for-the-badge&label=Chrome (546.12KB)" alt="Badge" /></td><td align="center"><a href="https://github.com/interledger/web-monetization-extension/suites/23967276931/artifacts/1519104463">Download</a></td></tr><tr><td align="center"><img src="https://img.shields.io/badge/success-3fb950?style=for-the-badge&label=Firefox (546.12KB)" alt="Badge" /></td><td align="center"><a href="https://github.com/interledger/web-monetization-extension/suites/23967276931/artifacts/1519104465">Download</a></td></tr>
</tbody>
</table>

Patch:
diff --git a/src/background/container.ts b/src/background/container.ts
index c0aacd6..466ca59 100644
--- a/src/background/container.ts
+++ b/src/background/container.ts
@@ -9,10 +9,12 @@ import {
 } from './services'
 import { createLogger, Logger } from '@/shared/logger'
 import { LOG_LEVEL } from '@/shared/defines'
+import { EventsService } from './services/events'
 
 interface Cradle {
   logger: Logger
   browser: Browser
+  events: EventsService
   storage: StorageService
   openPaymentsService: OpenPaymentsService
   monetizationService: MonetizationService
@@ -30,6 +32,7 @@ export const configureContainer = () => {
   container.register({
     logger: asValue(logger),
     browser: asValue(browser),
+    events: asClass(EventsService).singleton(),
     storage: asClass(StorageService)
       .singleton()
       .inject(() => ({
diff --git a/src/background/services/background.ts b/src/background/services/background.ts
index aecef1d..c40e2ee 100644
--- a/src/background/services/background.ts
+++ b/src/background/services/background.ts
@@ -29,6 +29,33 @@ export class Background {
     this.bindOnInstalled()
     this.bindMessageHandler()
     this.bindTabHandlers()
+    this.bindWindowHandlers()
+  }
+
+  bindWindowHandlers() {
+    this.browser.windows.onFocusChanged.addListener(async () => {
+      const windows = await this.browser.windows.getAll({
+        windowTypes: ['normal', 'panel', 'popup']
+      })
+      windows.forEach(async (w) => {
+        const activeTab = (
+          await this.browser.tabs.query({ windowId: w.id, active: true })
+        )[0]
+        if (!activeTab?.id) return
+
+        if (w.focused) {
+          this.logger.debug(
+            `Trying to resume monetization for window=${w.id}, activeTab=${activeTab.id} (URL: ${activeTab.url})`
+          )
+          this.monetizationService.resumePaymentSessionsByTabId(activeTab.id)
+        } else {
+          this.logger.debug(
+            `Trying to pause monetization for window=${w.id}, activeTab=${activeTab.id} (URL: ${activeTab.url})`
+          )
+          this.monetizationService.stopPaymentSessionsByTabId(activeTab.id)
+        }
+      })
+    })
   }
 
   bindTabHandlers() {
@@ -93,9 +120,7 @@ export class Background {
 
             case PopupToBackgroundAction.UPDATE_RATE_OF_PAY:
               return success(
-                await this.storage.set({
-                  rateOfPay: message.payload.rateOfPay
-                })
+                await this.storage.updateRate(message.payload.rateOfPay)
               )
 
             case ContentToBackgroundAction.IS_TAB_MONETIZED:
diff --git a/src/background/services/events.ts b/src/background/services/events.ts
new file mode 100644
index 0000000..d5e0697
--- /dev/null
+++ b/src/background/services/events.ts
@@ -0,0 +1,52 @@
+import { EventEmitter } from 'events'
+
+interface BackgroundEvents {
+  'storage.rate_of_pay_update': {
+    rate: string
+  }
+}
+
+export class EventsService extends EventEmitter {
+  constructor() {
+    super()
+  }
+
+  on<TEvent extends keyof BackgroundEvents>(
+    eventName: TEvent,
+    listener: (param: BackgroundEvents[TEvent]) => void
+  ): this {
+    return super.on(eventName, listener)
+  }
+
+  once<TEvent extends keyof BackgroundEvents>(
+    eventName: TEvent,
+    listener: (param: BackgroundEvents[TEvent]) => void
+  ): this {
+    return super.once(eventName, listener)
+  }
+
+  emit<TEvent extends keyof BackgroundEvents>(
+    eventName: TEvent,
+    ...rest: undefined extends BackgroundEvents[TEvent]
+      ? [param?: BackgroundEvents[TEvent]]
+      : [param: BackgroundEvents[TEvent]]
+  ): boolean {
+    return super.emit(eventName, ...rest)
+  }
+
+  /**
+   * Use `on` instead of `addListener`
+   * @deprecated
+   */
+  addListener(): this {
+    throw new Error('Use `on` instead of `addListner`.')
+  }
+
+  /**
+   * Use `off` instead of `removeListener`
+   * @deprecated
+   */
+  removeListener(): this {
+    throw new Error('Use `off` instead of `removeListener`.')
+  }
+}
diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts
index a77385f..a898c3a 100644
--- a/src/background/services/monetization.ts
+++ b/src/background/services/monetization.ts
@@ -66,6 +66,19 @@ export class MonetizationService {
     }
   }
 
+  stopPaymentSessionsByTabId(tabId: number) {
+    const sessions = this.sessions[tabId]
+
+    if (!sessions) {
+      this.logger.debug(`No active sessions found for tab ${tabId}.`)
+      return
+    }
+
+    for (const session of sessions.values()) {
+      session.stop()
+    }
+  }
+
   stopPaymentSession(
     payload: StopMonetizationPayload,
     sender: Runtime.MessageSender
@@ -98,6 +111,19 @@ export class MonetizationService {
     this.sessions[tabId].get(requestId)?.resume()
   }
 
+  resumePaymentSessionsByTabId(tabId: number) {
+    const sessions = this.sessions[tabId]
+
+    if (!sessions) {
+      this.logger.debug(`No active sessions found for tab ${tabId}.`)
+      return
+    }
+
+    for (const session of sessions.values()) {
+      session.resume()
+    }
+  }
+
   async toggleWM() {
     const { enabled } = await this.storage.get(['enabled'])
     await this.storage.set({ enabled: !enabled })
diff --git a/src/background/services/storage.ts b/src/background/services/storage.ts
index 9e36675..fbc5eec 100644
--- a/src/background/services/storage.ts
+++ b/src/background/services/storage.ts
@@ -1,8 +1,8 @@
 import { Logger } from '@/shared/logger'
 import type { PopupStore, Storage, StorageKey } from '@/shared/types'
-import EventEmitter from 'events'
 import { type Browser } from 'webextension-polyfill'
 import { getCurrentActiveTab } from '../utils'
+import { EventsService } from './events'
 
 const defaultStorage = {
   connected: false,
@@ -21,12 +21,11 @@ const defaultStorage = {
 // Eg:
 // - rate of pay - we should recalculate the amount for every payment session
 // - enabling/disabling WM
-export class StorageService extends EventEmitter {
+export class StorageService {
   constructor(
     private browser: Browser,
-    private logger: Logger
+    private events: EventsService,
   ) {
-    super()
   }
 
   async get<TKey extends StorageKey>(
@@ -106,4 +105,8 @@ export class StorageService extends EventEmitter {
 
     return false
   }
+
+  async updateRate(rate: string): Promise<void> {
+    await this.set({ rateOfPay: rate })
+  }
 }
diff --git a/src/popup/components/ConnectWalletForm.tsx b/src/popup/components/ConnectWalletForm.tsx
index 4802d49..87920ab 100644
--- a/src/popup/components/ConnectWalletForm.tsx
+++ b/src/popup/components/ConnectWalletForm.tsx
@@ -7,7 +7,11 @@ import { Switch } from '@/popup/components/ui/Switch'
 import { Code } from '@/popup/components/ui/Code'
 import { connectWallet } from '@/popup/lib/messages'
 import { getWalletInformation } from '@/shared/helpers'
-import { charIsNumber, getCurrencySymbol } from '@/popup/lib/utils'
+import {
+  charIsNumber,
+  formatNumber,
+  getCurrencySymbol
+} from '@/popup/lib/utils'
 import { useForm } from 'react-hook-form'
 import { numericFormatter } from 'react-number-format'
 
@@ -130,16 +134,7 @@ export const ConnectWalletForm = ({ publicKey }: ConnectWalletFormProps) => {
           onBlur: (e: React.FocusEvent<HTMLInputElement>) => {
             setValue(
               'amount',
-              numericFormatter(e.currentTarget.value, {
-                allowNegative: false,
-                valueIsNumericString: true,
-                allowLeadingZeros: false,
-                decimalSeparator: '.',
-                thousandSeparator: ',',
-                thousandsGroupStyle: 'thousand',
-                fixedDecimalScale: true,
-                decimalScale: currencySymbol.scale
-              })
+              formatNumber(+e.currentTarget.value, currencySymbol.scale)
             )
           }
         })}
diff --git a/src/popup/components/PayWebsiteForm.tsx b/src/popup/components/PayWebsiteForm.tsx
index 4e25d0b..27966c4 100644
--- a/src/popup/components/PayWebsiteForm.tsx
+++ b/src/popup/components/PayWebsiteForm.tsx
@@ -2,7 +2,11 @@ import { Button } from '@/popup/components/ui/Button'
 import { Input } from '@/popup/components/ui/Input'
 import { PopupStateContext } from '@/popup/lib/context'
 import { payWebsite } from '@/popup/lib/messages'
-import { getCurrencySymbol, charIsNumber } from '@/popup/lib/utils'
+import {
+  getCurrencySymbol,
+  charIsNumber,
+  formatNumber
+} from '@/popup/lib/utils'
 import React, { useMemo } from 'react'
 import { useForm } from 'react-hook-form'
 import { numericFormatter } from 'react-number-format'
@@ -104,16 +108,7 @@ export const PayWebsiteForm = () => {
           onBlur: (e: React.FocusEvent<HTMLInputElement>) => {
             setValue(
               'amount',
-              numericFormatter(e.currentTarget.value, {
-                allowNegative: false,
-                valueIsNumericString: true,
-                allowLeadingZeros: false,
-                decimalSeparator: '.',
-                thousandSeparator: ',',
-                thousandsGroupStyle: 'thousand',
-                fixedDecimalScale: true,
-                decimalScale: walletAddress.assetScale
-              })
+              formatNumber(+e.currentTarget.value, walletAddress.assetScale)
             )
           }
         })}
diff --git a/src/popup/components/WalletInformation.tsx b/src/popup/components/WalletInformation.tsx
index f53472a..49b9dca 100644
--- a/src/popup/components/WalletInformation.tsx
+++ b/src/popup/components/WalletInformation.tsx
@@ -2,7 +2,11 @@ import { Input } from '@/popup/components/ui/Input'
 import { Label } from '@/popup/components/ui/Label'
 import React from 'react'
 import { Code } from '@/popup/components/ui/Code'
-import { getCurrencySymbol, transformBalance } from '@/popup/lib/utils'
+import {
+  formatNumber,
+  getCurrencySymbol,
+  transformBalance
+} from '@/popup/lib/utils'
 import { PopupStore } from '@/shared/types'
 import { Button } from '@/popup/components/ui/Button'
 import { disconnectWallet } from '@/popup/lib/messages'
@@ -40,8 +44,11 @@ export const WalletInformation = ({ info }: WalletInformationProps) => {
         label="Remaining balance"
         disabled={true}
         readOnly={true}
-        value={transformBalance(
-          info.amount?.value ?? '0',
+        value={formatNumber(
+          +transformBalance(
+            info.amount?.value ?? '0',
+            info.walletAddress?.assetScale ?? 2
+          ),
           info.walletAddress?.assetScale ?? 2
         )}
       />
diff --git a/src/popup/lib/utils.ts b/src/popup/lib/utils.ts
index 610b45c..1d1ff62 100644
--- a/src/popup/lib/utils.ts
+++ b/src/popup/lib/utils.ts
@@ -29,3 +29,33 @@ export function roundWithPrecision(num: number, precision: number) {
   const multiplier = Math.pow(10, precision)
   return Math.round(num * multiplier) / multiplier
 }
+
+export function formatNumber(
+  value: number,
+  scale: number,
+  allowExponential = false
+): string {
+  // TO DO: handle scale 0
+
+  if (!value) return '0.00'
+  // to avoid floating point issues on multiplication
+  const pow2 = +(value * 100).toFixed(9)
+  const pow4 = +(value * 10 ** 4).toFixed(9)
+
+  if (scale <= 2 || (pow2 >= 1 && pow2 - Math.floor(pow2) === 0)) {
+    return value.toFixed(2)
+  } else if (scale >= 3 && scale <= 4) {
+    return value.toString()
+  } else {
+    if (pow4 >= 1 || !allowExponential) {
+      let fixedScale = 5
+      let powN = +(value * 10 ** fixedScale).toFixed(9)
+      while (powN - Math.floor(powN) > 0 && fixedScale < scale) {
+        ++fixedScale
+        powN = +(value * 10 ** fixedScale).toFixed(9)
+      }
+
+      return value.toFixed(fixedScale)
+    } else return value.toExponential()
+  }
+}
diff --git a/src/popup/pages/Home.tsx b/src/popup/pages/Home.tsx
index 624cb33..c2fe367 100644
--- a/src/popup/pages/Home.tsx
+++ b/src/popup/pages/Home.tsx
@@ -4,7 +4,11 @@ import { WarningSign } from '@/popup/components/Icons'
 import { Slider } from '../components/ui/Slider'
 import { toggleWM, updateRateOfPay as updateRateOfPay_ } from '../lib/messages'
 import { Label } from '../components/ui/Label'
-import { getCurrencySymbol, roundWithPrecision } from '../lib/utils'
+import {
+  formatNumber,
+  getCurrencySymbol,
+  roundWithPrecision
+} from '../lib/utils'
 import { PayWebsiteForm } from '../components/PayWebsiteForm'
 import { debounceAsync } from '@/shared/helpers'
 import { Switch } from '../components/ui/Switch'
@@ -26,11 +30,9 @@ export const Component = () => {
 
   const rate = React.useMemo(() => {
     const r = Number(rateOfPay) / 10 ** walletAddress.assetScale
-    if (roundWithPrecision(r, 2) > 0) {
-      return r.toFixed(2)
-    }
+    const roundedR = roundWithPrecision(r, walletAddress.assetScale)
 
-    return r.toExponential()
+    return formatNumber(roundedR, walletAddress.assetScale, true)
   }, [rateOfPay, walletAddress.assetScale])
 
   const onRateChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 0bb0765..423d4f2 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -61,7 +61,7 @@ export type PopupStore = Omit<
   Storage,
   'privateKey' | 'keyId' | 'exceptionList' | 'token' | 'grant'
 > & {
-    url: string | undefined
+  url: string | undefined
 }
 
 export type DeepNonNullable<T> = {


Test patch:
diff --git a/src/popup/lib/utils.test.ts b/src/popup/lib/utils.test.ts
new file mode 100644
index 0000000..41140e9
--- /dev/null
+++ b/src/popup/lib/utils.test.ts
@@ -0,0 +1,45 @@
+import { formatNumber } from './utils'
+
+describe('formatNumber', () => {
+  it('should display right format for integers', () => {
+    expect(formatNumber(5, 2)).toEqual('5.00')
+
+    expect(formatNumber(5, 4)).toEqual('5.00')
+
+    expect(formatNumber(5, 9)).toEqual('5.00')
+  })
+
+  it('should display right format for real numbers bigger than 1', () => {
+    expect(formatNumber(5.9, 2)).toEqual('5.90')
+
+    expect(formatNumber(5.09, 4)).toEqual('5.09')
+
+    expect(formatNumber(5.009, 4)).toEqual('5.009')
+
+    expect(formatNumber(5.0009, 4)).toEqual('5.0009')
+
+    expect(formatNumber(5.000009, 9)).toEqual('5.000009')
+
+    expect(formatNumber(5.000000009, 9)).toEqual('5.000000009')
+  })
+
+  it('should display right format for real numbers smaller than 1', () => {
+    expect(formatNumber(0.09, 2)).toEqual('0.09')
+
+    expect(formatNumber(0.0009, 4)).toEqual('0.0009')
+
+    expect(formatNumber(0.000000009, 9)).toEqual('0.000000009')
+
+    expect(formatNumber(0.00009, 9)).toEqual('0.00009')
+
+    expect(formatNumber(0.000000009, 9, true)).toEqual('9e-9')
+
+    expect(formatNumber(0.00009, 9, true)).toEqual('9e-5')
+
+    expect(formatNumber(0.0000109, 9, true)).toEqual('1.09e-5')
+
+    expect(formatNumber(0.000010009, 9, true)).toEqual('1.0009e-5')
+
+    expect(formatNumber(0.000100009, 9)).toEqual('0.000100009')
+  })
+})