From 1c62f83b50e7b64293abcfa8d7bb7f9237181d12 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Fri, 20 Sep 2024 18:53:42 +0530 Subject: [PATCH 01/46] feat: use new UX flow for connecting wallet --- src/popup/components/ConnectWalletForm.tsx | 428 +++++++++++++++------ src/popup/components/LoadingSpinner.tsx | 15 +- src/popup/components/ui/Input.tsx | 16 +- src/shared/helpers.ts | 9 +- 4 files changed, 338 insertions(+), 130 deletions(-) diff --git a/src/popup/components/ConnectWalletForm.tsx b/src/popup/components/ConnectWalletForm.tsx index db48647b..ecef1c02 100644 --- a/src/popup/components/ConnectWalletForm.tsx +++ b/src/popup/components/ConnectWalletForm.tsx @@ -1,20 +1,21 @@ import React from 'react'; -import { useForm } from 'react-hook-form'; import { Button } from '@/popup/components/ui/Button'; import { Input } from '@/popup/components/ui/Input'; -import { Label } from '@/popup/components/ui/Label'; import { Switch } from '@/popup/components/ui/Switch'; import { Code } from '@/popup/components/ui/Code'; +import { ErrorMessage } from '@/popup/components/ErrorMessage'; +import { LoadingSpinner } from '@/popup/components/LoadingSpinner'; import { charIsNumber, formatNumber, getCurrencySymbol, toWalletAddressUrl, } from '@/popup/lib/utils'; +import { cn } from '@/shared/helpers'; import type { WalletAddress } from '@interledger/open-payments'; import type { Response } from '@/shared/messages'; -interface ConnectWalletFormInputs { +interface Inputs { walletAddressUrl: string; amount: string; recurring: boolean; @@ -22,13 +23,10 @@ interface ConnectWalletFormInputs { interface ConnectWalletFormProps { publicKey: string; - defaultValues: Partial; - saveValue?: ( - key: keyof ConnectWalletFormInputs, - val: ConnectWalletFormInputs[typeof key], - ) => void; + defaultValues: Partial; + saveValue?: (key: keyof Inputs, val: Inputs[typeof key]) => void; getWalletInfo: (walletAddressUrl: string) => Promise; - connectWallet: (data: ConnectWalletFormInputs) => Promise; + connectWallet: (data: Inputs) => Promise; onConnect?: () => void; } @@ -40,19 +38,31 @@ export const ConnectWalletForm = ({ saveValue = () => {}, onConnect = () => {}, }: ConnectWalletFormProps) => { - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - clearErrors, - setError, - setValue, - } = useForm({ - criteriaMode: 'firstError', - mode: 'onSubmit', - reValidateMode: 'onBlur', - defaultValues, + const [walletAddressUrl, setWalletAddressUrl] = React.useState< + Inputs['walletAddressUrl'] + >(defaultValues.walletAddressUrl || ''); + const [amount, setAmount] = React.useState( + defaultValues.amount || '', + ); + const [recurring, setRecurring] = React.useState( + defaultValues.recurring || false, + ); + + const [walletAddressInfo, setWalletAddressInfo] = + React.useState(null); + + const [errors, setErrors] = React.useState({ + walletAddressUrl: '', + amount: '', + keyPair: '', + connect: '', + }); + const [isValidating, setIsValidating] = React.useState({ + walletAddressUrl: false, + amount: false, }); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [currencySymbol, setCurrencySymbol] = React.useState<{ symbol: string; scale: number; @@ -60,130 +70,306 @@ export const ConnectWalletForm = ({ const getWalletCurrency = React.useCallback( async (walletAddressUrl: string): Promise => { - clearErrors('walletAddressUrl'); + setErrors((e) => ({ ...e, walletAddressUrl: '' })); if (!walletAddressUrl) return; try { + setIsValidating((e) => ({ ...e, walletAddressUrl: true })); const url = new URL(toWalletAddressUrl(walletAddressUrl)); const walletAddress = await getWalletInfo(url.toString()); - setCurrencySymbol({ - symbol: getCurrencySymbol(walletAddress.assetCode), - scale: walletAddress.assetScale, - }); - } catch { - setError('walletAddressUrl', { - type: 'validate', - message: 'Invalid wallet address.', - }); + setWalletAddressInfo(walletAddress); + } catch (error) { + setErrors((e) => ({ + ...e, + walletAddressUrl: error.message, + })); + } finally { + setIsValidating((e) => ({ ...e, walletAddressUrl: false })); } }, - [clearErrors, setError, getWalletInfo], + [getWalletInfo], ); + React.useEffect(() => { + if (!walletAddressInfo) return; + setCurrencySymbol({ + symbol: getCurrencySymbol(walletAddressInfo.assetCode), + scale: walletAddressInfo.assetScale, + }); + }, [walletAddressInfo]); + React.useEffect(() => { if (defaultValues.walletAddressUrl) { void getWalletCurrency(defaultValues.walletAddressUrl); } }, [defaultValues.walletAddressUrl, getWalletCurrency]); + const handleSubmit = async (ev: React.FormEvent) => { + ev.preventDefault(); + if (errors.amount || errors.walletAddressUrl) { + return; + } + try { + setIsSubmitting(true); + const res = await connectWallet({ + walletAddressUrl: toWalletAddressUrl(walletAddressUrl), + amount, + recurring, + }); + if (res.success) { + onConnect(); + } else { + throw new Error(res.message); + } + } catch (error) { + setErrors((e) => ({ ...e, connect: error.message })); + } finally { + setIsSubmitting(false); + } + }; + return (
{ - const response = await connectWallet({ - ...data, - walletAddressUrl: toWalletAddressUrl(data.walletAddressUrl), - }); - if (response.success) { - return onConnect(); - } - setError('walletAddressUrl', { - type: 'validate', - message: response.message, - }); - })} className="space-y-4" + onSubmit={handleSubmit} > -
- -

- Get a wallet address from a provider before connecting it below. - Please find a list of available wallets{' '} - - here - - . -

- Copy the public key below and paste it into your wallet. -

- + - ) { - const walletAddressUrl = e.currentTarget.value; - getWalletCurrency(walletAddressUrl); - saveValue('walletAddressUrl', walletAddressUrl); - }, - })} - /> - { - if ( - !charIsNumber(e.key) && - e.key !== 'Backspace' && - e.key !== 'Delete' && - e.key !== 'Tab' - ) { - e.preventDefault(); + + {errors.connect && } + +
+ + ) : null } - }} - errorMessage={errors.amount?.message} - {...register('amount', { - required: { value: true, message: 'Amount is required.' }, - valueAsNumber: false, - onBlur(e: React.FocusEvent) { - const val = +e.currentTarget.value; - const amountValue = formatNumber(val, currencySymbol.scale); - setValue('amount', amountValue); - saveValue('amount', amountValue); - }, - })} - /> -
- ) { - saveValue('recurring', ev.currentTarget.checked); - }, - })} - label="Renew amount monthly" + addOnPosition="right" + required={true} + autoComplete="on" + onBlur={async (e) => { + const value = e.currentTarget.value; + if (value === walletAddressUrl) { + if (value || !e.currentTarget.required) { + return; + } + } + setWalletAddressUrl(value); + + const error = validateWalletAddressUrl(value); + setErrors((e) => ({ ...e, walletAddressUrl: error })); + if (!error) { + await getWalletCurrency(value); + } + saveValue('walletAddressUrl', value); + }} />
- + Amount to allocate from your wallet + { + if ( + (!charIsNumber(e.key) && + e.key !== 'Backspace' && + e.key !== 'Delete' && + e.key !== 'Tab') || + (e.key === '.' && e.currentTarget.value.includes('.')) + ) { + e.preventDefault(); + } + }} + onBlur={(e) => { + const value = e.currentTarget.value; + if (value === amount && !e.currentTarget.required) { + return; + } + + const error = validateAmount(value); + setErrors((e) => ({ ...e, amount: error })); + + const amountValue = formatNumber(+value, currencySymbol.scale); + if (!error) { + setAmount(amountValue); + e.currentTarget.value = amountValue; + } + saveValue('amount', error ? value : amountValue); + }} + /> + +
+ ) => { + const value = ev.currentTarget.checked; + setRecurring(value); + saveValue('recurring', value); + }} + /> +
+ + + {errors.keyPair && ( + + )} + +
+ + + {!errors.keyPair && ( + + )} +
); }; + +const ManualKeyPairNeeded: React.FC<{ + error?: { message: string; details: string; whyText: string } | null; + text: string; + learnMoreText: string; + publicKey: string; +}> = ({ error, text, learnMoreText, publicKey }) => { + const ErrorDetails = () => { + if (!error) return null; + return ( +
+ + {error.whyText} + + {error.details} +
+ ); + }; + + return ( +
+ {error && ( +
+ {error.message} +
+ )} +

+ {text}{' '} + + {learnMoreText} + +

+ +
+ ); +}; + +const AutomaticKeyPairNote: React.FC<{ + text: string; + learnMoreText: string; +}> = ({ text, learnMoreText }) => { + return ( +

+ {text} +
+ + {learnMoreText} + +

+ ); +}; + +function validateWalletAddressUrl(value: string): string { + if (!value) { + return 'Wallet address URL is required.'; + } + let url: URL; + try { + url = new URL(toWalletAddressUrl(value)); + } catch { + return 'Invalid wallet address URL.'; + } + + if (url.protocol !== 'https:') { + return 'Wallet address must be a https:// URL or a payment pointer.'; + } + + return ''; +} + +function validateAmount(value: string): string { + if (!value) { + return 'Amount is required.'; + } + const val = Number(value); + if (Number.isNaN(val)) { + return 'Amount must be a number.'; + } + if (val <= 0) { + return 'Amount must be greater than 0.'; + } + return ''; +} diff --git a/src/popup/components/LoadingSpinner.tsx b/src/popup/components/LoadingSpinner.tsx index fc4c5268..6b90c5ab 100644 --- a/src/popup/components/LoadingSpinner.tsx +++ b/src/popup/components/LoadingSpinner.tsx @@ -3,20 +3,25 @@ import React from 'react'; import { Spinner } from '@/popup/components/Icons'; -const loadingSpinnerStyles = cva('animate-spin text-white', { +const loadingSpinnerStyles = cva('animate-spin', { variants: { - variant: { + size: { md: 'h-4 w-4', lg: 'h-6 w-6', }, + color: { + white: 'text-white', + gray: 'text-gray-300', + }, }, defaultVariants: { - variant: 'lg', + size: 'lg', + color: 'white', }, }); export type LoadingIndicatorProps = VariantProps; -export const LoadingSpinner = ({ variant }: LoadingIndicatorProps) => { - return ; +export const LoadingSpinner = ({ size, color }: LoadingIndicatorProps) => { + return ; }; diff --git a/src/popup/components/ui/Input.tsx b/src/popup/components/ui/Input.tsx index 0b7d2786..4a08899a 100644 --- a/src/popup/components/ui/Input.tsx +++ b/src/popup/components/ui/Input.tsx @@ -31,6 +31,7 @@ export interface InputProps errorMessage?: string; disabled?: boolean; addOn?: React.ReactNode; + addOnPosition?: 'left' | 'right'; label?: React.ReactNode; description?: React.ReactNode; } @@ -39,23 +40,32 @@ export const Input = forwardRef(function Input( { type = 'text', addOn, + addOnPosition = 'left', label, description, errorMessage, disabled, className, + id: providedId, ...props }, ref, ) { - const id = React.useId(); + let id = React.useId(); + if (providedId) id = providedId; + return (
{label ? : null} {description ?

{description}

: null}
{addOn ? ( -
+
{addOn}
) : null} @@ -65,7 +75,7 @@ export const Input = forwardRef(function Input( type={type} className={cn( inputVariants({ disabled }), - addOn && 'pl-10', + addOn && (addOnPosition === 'left' ? 'pl-10' : 'pr-10'), errorMessage && 'border-error', className, )} diff --git a/src/shared/helpers.ts b/src/shared/helpers.ts index 83128f5a..a98361c8 100644 --- a/src/shared/helpers.ts +++ b/src/shared/helpers.ts @@ -44,10 +44,17 @@ export const getWalletInformation = async ( Accept: 'application/json', }, }); + if (!response.ok) { + if (response.status === 404) { + throw new Error('This wallet address does not exist.'); + } + throw new Error('Failed to fetch wallet address.'); + } + const json = await response.json(); if (!isWalletAddress(json)) { - throw new Error('Invalid wallet address response.'); + throw new Error('Provided URL is not a valid wallet address.'); } return json; From 0abbe8ef6ceaaddc7c63de1f061b7fdcf521a914 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Fri, 20 Sep 2024 19:05:45 +0530 Subject: [PATCH 02/46] validation improvements --- src/popup/components/ConnectWalletForm.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/popup/components/ConnectWalletForm.tsx b/src/popup/components/ConnectWalletForm.tsx index ecef1c02..44620ac6 100644 --- a/src/popup/components/ConnectWalletForm.tsx +++ b/src/popup/components/ConnectWalletForm.tsx @@ -105,9 +105,15 @@ export const ConnectWalletForm = ({ const handleSubmit = async (ev: React.FormEvent) => { ev.preventDefault(); + const errors = { + walletAddressUrl: validateWalletAddressUrl(walletAddressUrl), + amount: validateAmount(amount, currencySymbol.symbol), + }; + setErrors((e) => ({ ...e, ...errors })); if (errors.amount || errors.walletAddressUrl) { return; } + try { setIsSubmitting(true); const res = await connectWallet({ @@ -212,7 +218,7 @@ export const ConnectWalletForm = ({ return; } - const error = validateAmount(value); + const error = validateAmount(value, currencySymbol.symbol); setErrors((e) => ({ ...e, amount: error })); const amountValue = formatNumber(+value, currencySymbol.scale); @@ -360,7 +366,7 @@ function validateWalletAddressUrl(value: string): string { return ''; } -function validateAmount(value: string): string { +function validateAmount(value: string, currencySymbol: string): string { if (!value) { return 'Amount is required.'; } @@ -369,7 +375,7 @@ function validateAmount(value: string): string { return 'Amount must be a number.'; } if (val <= 0) { - return 'Amount must be greater than 0.'; + return `Amount must be greater than ${currencySymbol}${val}.`; } return ''; } From b84f1425fc5b362ae7b94a8d9a61376442f95c20 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Fri, 20 Sep 2024 19:17:49 +0530 Subject: [PATCH 03/46] reset walletAddressInfo on walletAddressUrl change Code nits --- src/popup/components/ConnectWalletForm.tsx | 29 +++++++++++----------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/popup/components/ConnectWalletForm.tsx b/src/popup/components/ConnectWalletForm.tsx index 44620ac6..4dbf09c6 100644 --- a/src/popup/components/ConnectWalletForm.tsx +++ b/src/popup/components/ConnectWalletForm.tsx @@ -74,6 +74,7 @@ export const ConnectWalletForm = ({ if (!walletAddressUrl) return; try { setIsValidating((e) => ({ ...e, walletAddressUrl: true })); + setWalletAddressInfo(null); const url = new URL(toWalletAddressUrl(walletAddressUrl)); const walletAddress = await getWalletInfo(url.toString()); setWalletAddressInfo(walletAddress); @@ -89,20 +90,6 @@ export const ConnectWalletForm = ({ [getWalletInfo], ); - React.useEffect(() => { - if (!walletAddressInfo) return; - setCurrencySymbol({ - symbol: getCurrencySymbol(walletAddressInfo.assetCode), - scale: walletAddressInfo.assetScale, - }); - }, [walletAddressInfo]); - - React.useEffect(() => { - if (defaultValues.walletAddressUrl) { - void getWalletCurrency(defaultValues.walletAddressUrl); - } - }, [defaultValues.walletAddressUrl, getWalletCurrency]); - const handleSubmit = async (ev: React.FormEvent) => { ev.preventDefault(); const errors = { @@ -133,6 +120,20 @@ export const ConnectWalletForm = ({ } }; + React.useEffect(() => { + if (!walletAddressInfo) return; + setCurrencySymbol({ + symbol: getCurrencySymbol(walletAddressInfo.assetCode), + scale: walletAddressInfo.assetScale, + }); + }, [walletAddressInfo]); + + React.useEffect(() => { + if (defaultValues.walletAddressUrl) { + void getWalletCurrency(defaultValues.walletAddressUrl); + } + }, [defaultValues.walletAddressUrl, getWalletCurrency]); + return (
Date: Fri, 20 Sep 2024 19:19:47 +0530 Subject: [PATCH 04/46] make Amount field readOnly so it's focusable with tab --- src/popup/components/ConnectWalletForm.tsx | 4 ++-- src/popup/components/ui/Input.tsx | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/popup/components/ConnectWalletForm.tsx b/src/popup/components/ConnectWalletForm.tsx index 4dbf09c6..0af2c47b 100644 --- a/src/popup/components/ConnectWalletForm.tsx +++ b/src/popup/components/ConnectWalletForm.tsx @@ -198,8 +198,8 @@ export const ConnectWalletForm = ({ label="Amount" placeholder="5.00" defaultValue={amount} - disabled={!walletAddressInfo?.assetCode} - addOn={currencySymbol.symbol} + readOnly={!walletAddressInfo?.assetCode} + addOn={{currencySymbol.symbol}} errorMessage={errors.amount} required={true} onKeyDown={(e) => { diff --git a/src/popup/components/ui/Input.tsx b/src/popup/components/ui/Input.tsx index 4a08899a..60d40af4 100644 --- a/src/popup/components/ui/Input.tsx +++ b/src/popup/components/ui/Input.tsx @@ -18,6 +18,9 @@ const inputVariants = cva( disabled: { true: 'border-transparent bg-disabled', }, + readOnly: { + true: 'border-transparent bg-disabled', + }, }, defaultVariants: { variant: 'default', @@ -30,6 +33,7 @@ export interface InputProps React.InputHTMLAttributes { errorMessage?: string; disabled?: boolean; + readOnly?: boolean; addOn?: React.ReactNode; addOnPosition?: 'left' | 'right'; label?: React.ReactNode; From 4c1575bc96abdbb1209ad3ef39859676313fce85 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Fri, 20 Sep 2024 19:21:11 +0530 Subject: [PATCH 05/46] correctly reset walletAddressInfo to null on change --- src/popup/components/ConnectWalletForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/popup/components/ConnectWalletForm.tsx b/src/popup/components/ConnectWalletForm.tsx index 0af2c47b..334b7361 100644 --- a/src/popup/components/ConnectWalletForm.tsx +++ b/src/popup/components/ConnectWalletForm.tsx @@ -74,7 +74,6 @@ export const ConnectWalletForm = ({ if (!walletAddressUrl) return; try { setIsValidating((e) => ({ ...e, walletAddressUrl: true })); - setWalletAddressInfo(null); const url = new URL(toWalletAddressUrl(walletAddressUrl)); const walletAddress = await getWalletInfo(url.toString()); setWalletAddressInfo(walletAddress); @@ -172,6 +171,7 @@ export const ConnectWalletForm = ({ return; } } + setWalletAddressInfo(null); setWalletAddressUrl(value); const error = validateWalletAddressUrl(value); From b4229b306aa4a61b5aed975bc15f8cb62e80ecd0 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Fri, 20 Sep 2024 20:43:47 +0530 Subject: [PATCH 06/46] wip: try basic implementation for keyShare --- src/background/container.ts | 7 + src/background/services/background.ts | 10 ++ src/background/services/index.ts | 1 + src/background/services/keyShare.ts | 145 +++++++++++++++++++++ src/popup/components/ConnectWalletForm.tsx | 23 +++- src/popup/pages/Settings.tsx | 15 ++- src/shared/helpers.ts | 14 ++ src/shared/messages.ts | 10 ++ 8 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 src/background/services/keyShare.ts diff --git a/src/background/container.ts b/src/background/container.ts index 3440d2f1..de598b70 100644 --- a/src/background/container.ts +++ b/src/background/container.ts @@ -11,6 +11,7 @@ import { EventsService, Heartbeat, Deduplicator, + KeyShareService, } from './services'; import { createLogger, Logger } from '@/shared/logger'; import { LOG_LEVEL } from '@/shared/defines'; @@ -27,6 +28,7 @@ export interface Cradle { deduplicator: Deduplicator; storage: StorageService; openPaymentsService: OpenPaymentsService; + keyShareService: KeyShareService; monetizationService: MonetizationService; message: MessageManager; sendToPopup: SendToPopup; @@ -64,6 +66,11 @@ export const configureContainer = () => { .inject(() => ({ logger: logger.getLogger('open-payments'), })), + keyShareService: asClass(KeyShareService) + .singleton() + .inject(() => ({ + logger: logger.getLogger('key-share'), + })), monetizationService: asClass(MonetizationService) .singleton() .inject(() => ({ diff --git a/src/background/services/background.ts b/src/background/services/background.ts index 885a6e20..16a6f95d 100644 --- a/src/background/services/background.ts +++ b/src/background/services/background.ts @@ -17,6 +17,7 @@ const ALARM_RESET_OUT_OF_FUNDS = 'reset-out-of-funds'; export class Background { private browser: Cradle['browser']; private openPaymentsService: Cradle['openPaymentsService']; + private keyShareService: Cradle['keyShareService']; private monetizationService: Cradle['monetizationService']; private storage: Cradle['storage']; private logger: Cradle['logger']; @@ -28,6 +29,7 @@ export class Background { constructor({ browser, openPaymentsService, + keyShareService, monetizationService, storage, logger, @@ -39,6 +41,7 @@ export class Background { Object.assign(this, { browser, openPaymentsService, + keyShareService, monetizationService, storage, sendToPopup, @@ -174,6 +177,13 @@ export class Background { } return; + case 'ADD_PUBLIC_KEY_TO_WALLET': + return success( + await this.keyShareService.addPublicKeyToWallet( + message.payload, + ), + ); + case 'RECONNECT_WALLET': { await this.openPaymentsService.reconnectWallet(); await this.monetizationService.resumePaymentSessionActiveTab(); diff --git a/src/background/services/index.ts b/src/background/services/index.ts index f8cf399c..7198bb78 100644 --- a/src/background/services/index.ts +++ b/src/background/services/index.ts @@ -1,4 +1,5 @@ export { OpenPaymentsService } from './openPayments'; +export { KeyShareService } from './keyShare'; export { StorageService } from './storage'; export { MonetizationService } from './monetization'; export { Background } from './background'; diff --git a/src/background/services/keyShare.ts b/src/background/services/keyShare.ts new file mode 100644 index 00000000..4ab4e37b --- /dev/null +++ b/src/background/services/keyShare.ts @@ -0,0 +1,145 @@ +import { withResolvers } from '@/shared/helpers'; +import type { Browser, Runtime, Tabs } from 'webextension-polyfill'; +import type { WalletAddress } from '@interledger/open-payments'; +import type { Cradle } from '@/background/container'; +import type { AddPublicKeyToWalletPayload } from '@/shared/messages'; + +export const CONNECTION_NAME = 'key-share'; + +type OnTabRemovedCallback = Parameters< + Browser['tabs']['onRemoved']['addListener'] +>[0]; +type OnConnectCallback = Parameters< + Browser['runtime']['onConnect']['addListener'] +>[0]; +type OnPortMessageListener = Parameters< + Runtime.Port['onMessage']['addListener'] +>[0]; + +export class KeyShareService { + private browser: Cradle['browser']; + private storage: Cradle['storage']; + private status: null | 'SUCCESS' | 'ERROR' = null; + private tab: Tabs.Tab | null; + + constructor({ browser, storage }: Cradle) { + Object.assign(this, { browser, storage }); + } + + async addPublicKeyToWallet({ + walletAddressInfo, + }: AddPublicKeyToWalletPayload) { + const { publicKey } = await this.storage.get(['publicKey']); + if (!publicKey) { + // won't happen, just added for lint fix + throw new Error('No public key found'); + } + const info = walletAddressToProvider(walletAddressInfo); + try { + await this.process({ + url: info.url, + publicKey, + walletAddressUrl: walletAddressInfo.id, + }); + } catch (error) { + if (this.tab?.id) { + // can redirect to OPEN_PAYMENTS_REDIRECT_URL + await this.browser.tabs.remove(this.tab.id); + } + throw error; + } + } + + getTab() { + return this.tab; + } + + private async process({ + url, + walletAddressUrl, + publicKey, + }: { + url: string; + walletAddressUrl: string; + publicKey: string; + }) { + const { resolve, reject, promise } = withResolvers(); + + const tab = await this.browser.tabs.create({ url }); + this.tab = tab; + if (!tab.id) { + reject(new Error('Could not create tab')); + return promise; + } + + const onTabCloseListener: OnTabRemovedCallback = (tabId) => { + if (tabId !== tab.id) { + // ignore. not our tab + return; + } + + if (this.status === 'SUCCESS') { + // ok + } else { + reject(new Error('Tab closed before completion')); + } + }; + this.browser.tabs.onRemoved.addListener(onTabCloseListener); + + const onConnectListener: OnConnectCallback = (port) => { + if (port.name !== CONNECTION_NAME) return; + if (port.error) { + reject(new Error(port.error.message)); + return; + } + + port.postMessage({ + action: 'BEGIN', + payload: { walletAddressUrl, publicKey }, + }); + + port.onMessage.addListener(onMessageListener); + + port.onDisconnect.addListener(() => { + // wait for connect again so we can send message again if not connected, + // and not errored already (e.g. page refreshed) + }); + }; + + const onMessageListener: OnPortMessageListener = (message: { + action: string; + payload: any; + }) => { + if (message.action === 'SUCCESS') { + resolve(message.payload); + } else if (message.action === 'ERROR') { + reject(message.payload); + } else if (message.action === 'PROGRESS') { + // can save progress to show in popup + } else { + reject(new Error(`Unexpected message: ${JSON.stringify(message)}`)); + } + }; + + this.browser.runtime.onConnect.addListener(onConnectListener); + + return promise; + } +} + +export function walletAddressToProvider(walletAddress: WalletAddress): { + id: string; + url: string; +} { + const { host } = new URL(walletAddress.authServer); + switch (host) { + // case 'ilp.rafiki.money': + // return { + // id: 'rafikiMoney', + // url: 'https://rafiki.money/settings/developer-keys', + // }; + case 'auth.eu1.fynbos.dev': + default: + throw new Error('Not implemented for provided wallet yet'); + } +} diff --git a/src/popup/components/ConnectWalletForm.tsx b/src/popup/components/ConnectWalletForm.tsx index 334b7361..ccc85264 100644 --- a/src/popup/components/ConnectWalletForm.tsx +++ b/src/popup/components/ConnectWalletForm.tsx @@ -13,7 +13,7 @@ import { } from '@/popup/lib/utils'; import { cn } from '@/shared/helpers'; import type { WalletAddress } from '@interledger/open-payments'; -import type { Response } from '@/shared/messages'; +import type { ConnectWalletPayload, Response } from '@/shared/messages'; interface Inputs { walletAddressUrl: string; @@ -26,7 +26,7 @@ interface ConnectWalletFormProps { defaultValues: Partial; saveValue?: (key: keyof Inputs, val: Inputs[typeof key]) => void; getWalletInfo: (walletAddressUrl: string) => Promise; - connectWallet: (data: Inputs) => Promise; + connectWallet: (data: ConnectWalletPayload) => Promise; onConnect?: () => void; } @@ -91,12 +91,16 @@ export const ConnectWalletForm = ({ const handleSubmit = async (ev: React.FormEvent) => { ev.preventDefault(); - const errors = { + const err = { walletAddressUrl: validateWalletAddressUrl(walletAddressUrl), amount: validateAmount(amount, currencySymbol.symbol), }; - setErrors((e) => ({ ...e, ...errors })); - if (errors.amount || errors.walletAddressUrl) { + if (!walletAddressInfo) { + setErrors((e) => ({ ...e, walletAddressUrl: 'Not fetched yet?!' })); + return; + } + setErrors((e) => ({ ...e, ...err })); + if (err.amount || err.walletAddressUrl) { return; } @@ -104,13 +108,20 @@ export const ConnectWalletForm = ({ setIsSubmitting(true); const res = await connectWallet({ walletAddressUrl: toWalletAddressUrl(walletAddressUrl), + walletAddressInfo, amount, recurring, + skipAutoKeyShare: !!errors.keyPair, }); if (res.success) { onConnect(); } else { - throw new Error(res.message); + if (res.message.startsWith('ADD_PUBLIC_KEY_TO_WALLET:')) { + const message = res.message.replace('ADD_PUBLIC_KEY_TO_WALLET:', ''); + setErrors((e) => ({ ...e, keyPair: message })); + } else { + throw new Error(res.message); + } } } catch (error) { setErrors((e) => ({ ...e, connect: error.message })); diff --git a/src/popup/pages/Settings.tsx b/src/popup/pages/Settings.tsx index 068f9d8d..775f9c67 100644 --- a/src/popup/pages/Settings.tsx +++ b/src/popup/pages/Settings.tsx @@ -25,7 +25,20 @@ export const Component = () => { localStorage?.setItem(`connect.${key}`, val.toString()); }} getWalletInfo={getWalletInformation} - connectWallet={(data) => message.send('CONNECT_WALLET', data)} + connectWallet={async (data) => { + if (!data.skipAutoKeyShare) { + const res = await message.send('ADD_PUBLIC_KEY_TO_WALLET', { + walletAddressInfo: data.walletAddressInfo, + }); + if (!res.success) { + return { + ...res, + message: 'ADD_PUBLIC_KEY_TO_WALLET:' + res.message, + }; + } + } + return await message.send('CONNECT_WALLET', data); + }} onConnect={() => { // The popup closes due to redirects on connect, so we don't need to // update any state manually. diff --git a/src/shared/helpers.ts b/src/shared/helpers.ts index a98361c8..502e39db 100644 --- a/src/shared/helpers.ts +++ b/src/shared/helpers.ts @@ -234,6 +234,20 @@ export function isNotNull(value: T | null): value is T { return value !== null; } +/** + * Polyfill for `Promise.withResolvers()` + */ +export function withResolvers() { + let resolve: (value: T | PromiseLike) => void; + let reject: (reason?: any) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + // @ts-expect-error we know TypeScript! + return { resolve, reject, promise }; +} + export const removeQueryParams = (urlString: string) => { const url = new URL(urlString); return url.origin + url.pathname; diff --git a/src/shared/messages.ts b/src/shared/messages.ts index 960d3a33..f87a6d85 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -89,8 +89,14 @@ export class MessageManager { // #region Popup ↦ BG export interface ConnectWalletPayload { walletAddressUrl: string; + walletAddressInfo: WalletAddress; amount: string; recurring: boolean; + skipAutoKeyShare: boolean; +} + +export interface AddPublicKeyToWalletPayload { + walletAddressInfo: WalletAddress; } export interface AddFundsPayload { @@ -115,6 +121,10 @@ export type PopupToBackgroundMessage = { input: ConnectWalletPayload; output: never; }; + ADD_PUBLIC_KEY_TO_WALLET: { + input: AddPublicKeyToWalletPayload; + output: never; + }; RECONNECT_WALLET: { input: never; output: never; From fdee42533fd35a4ea12df3e5c23b23ad6d1e3d15 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:14:01 +0530 Subject: [PATCH 07/46] improvements to ConnectWalletForm UI when key share error --- src/popup/components/ConnectWalletForm.tsx | 34 +++++++++++++--------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/popup/components/ConnectWalletForm.tsx b/src/popup/components/ConnectWalletForm.tsx index ccc85264..c4d55ef6 100644 --- a/src/popup/components/ConnectWalletForm.tsx +++ b/src/popup/components/ConnectWalletForm.tsx @@ -47,6 +47,7 @@ export const ConnectWalletForm = ({ const [recurring, setRecurring] = React.useState( defaultValues.recurring || false, ); + const [autoKeyShareFailed, setAutoKeyShareFailed] = React.useState(false); const [walletAddressInfo, setWalletAddressInfo] = React.useState(null); @@ -106,12 +107,18 @@ export const ConnectWalletForm = ({ try { setIsSubmitting(true); + let skipAutoKeyShare = autoKeyShareFailed; + if (errors.keyPair) { + skipAutoKeyShare = true; + setAutoKeyShareFailed(true); + } + setErrors((e) => ({ ...e, keyPair: '', connect: '' })); const res = await connectWallet({ walletAddressUrl: toWalletAddressUrl(walletAddressUrl), walletAddressInfo, amount, recurring, - skipAutoKeyShare: !!errors.keyPair, + skipAutoKeyShare, }); if (res.success) { onConnect(); @@ -129,7 +136,6 @@ export const ConnectWalletForm = ({ setIsSubmitting(false); } }; - React.useEffect(() => { if (!walletAddressInfo) return; setCurrencySymbol({ @@ -150,7 +156,10 @@ export const ConnectWalletForm = ({ className="space-y-4" onSubmit={handleSubmit} > -