diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index 4e4c7019..74bfeeb2 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -18,7 +18,8 @@ import { getAddressBalance, isFiat, Transaction, - getCashtabProviderStatus, + openCashtabPayment, + initializeCashtabStatus, DECIMALS, CurrencyObject, getCurrencyObject, @@ -415,6 +416,7 @@ export const Widget: React.FunctionComponent = props => { const [text, setText] = useState(`Send any amount of ${thisAddressType}`); const [widgetButtonText, setWidgetButtonText] = useState('Send Payment'); const [opReturn, setOpReturn] = useState(); + const [isCashtabAvailable, setIsCashtabAvailable] = useState(false); const [isAboveMinimumAltpaymentAmount, setIsAboveMinimumAltpaymentAmount] = useState(null); @@ -460,6 +462,19 @@ export const Widget: React.FunctionComponent = props => { setHasPrice(price !== undefined && price > 0) }, [price]) + useEffect(() => { + const initCashtab = async () => { + try { + const isAvailable = await initializeCashtabStatus(); + setIsCashtabAvailable(isAvailable); + } catch (error) { + setIsCashtabAvailable(false); + } + }; + + initCashtab(); + }, []); + useEffect(() => { (async () => { if (isChild !== true) { @@ -597,7 +612,12 @@ export const Widget: React.FunctionComponent = props => { let url; setThisAddressType(thisAddressType); - setWidgetButtonText(`Send with ${thisAddressType} wallet`); + + if (thisAddressType === 'XEC' && isCashtabAvailable) { + setWidgetButtonText('Send with Cashtab'); + } else { + setWidgetButtonText(`Send with ${thisAddressType} wallet`); + } if (thisCurrencyObject && hasPrice) { const convertedAmount = thisCurrencyObject.float / price @@ -628,7 +648,7 @@ export const Widget: React.FunctionComponent = props => { } setUrl(url ?? ""); } - }, [to, thisCurrencyObject, price, thisAmount, opReturn, hasPrice]); + }, [to, thisCurrencyObject, price, thisAmount, opReturn, hasPrice, isCashtabAvailable]); useEffect(() => { try { @@ -693,24 +713,9 @@ export const Widget: React.FunctionComponent = props => { } }, [totalReceived, currency, goalAmount, price, hasPrice, contributionOffset]); - const handleButtonClick = () => { + const handleButtonClick = async () => { if (thisAddressType === 'XEC') { - const hasExtension = getCashtabProviderStatus(); - if (!hasExtension) { - const webUrl = `https://cashtab.com/#/send?bip21=${url}`; - window.open(webUrl, '_blank'); - } else { - return window.postMessage( - { - type: 'FROM_PAGE', - text: 'Cashtab', - txInfo: { - bip21: url - }, - }, - '*', - ); - } + await openCashtabPayment(url); } else { window.location.href = url; } diff --git a/react/lib/util/api-client.ts b/react/lib/util/api-client.ts index 65134121..cdcb1e13 100644 --- a/react/lib/util/api-client.ts +++ b/react/lib/util/api-client.ts @@ -97,11 +97,3 @@ export default { getAddressBalance, }; -export const getCashtabProviderStatus = () => { - const windowAny = window as any - if (window && windowAny.bitcoinAbc && windowAny.bitcoinAbc === 'cashtab') { - return true; - } - return false; -}; - diff --git a/react/lib/util/cashtab.ts b/react/lib/util/cashtab.ts new file mode 100644 index 00000000..fb0a64cd --- /dev/null +++ b/react/lib/util/cashtab.ts @@ -0,0 +1,119 @@ +import { + CashtabConnect, + CashtabExtensionUnavailableError, + CashtabAddressDeniedError, + CashtabTimeoutError +} from 'cashtab-connect'; + +const cashtab = new CashtabConnect(); + +// Cache for extension status to avoid multiple checks +let extensionStatusCache: boolean | null = null; +let extensionStatusPromise: Promise | null = null; + +/** + * Check if the Cashtab extension is available (with caching) + * This function caches the result to avoid multiple extension checks per page load + * @returns Promise - true if extension is available, false otherwise + */ +export const getCashtabProviderStatus = async (): Promise => { + // Return cached result if available + if (extensionStatusCache !== null) { + return extensionStatusCache; + } + + // If a check is already in progress, wait for it + if (extensionStatusPromise !== null) { + return extensionStatusPromise; + } + + extensionStatusPromise = (async () => { + try { + const isAvailable = await cashtab.isExtensionAvailable(); + extensionStatusCache = isAvailable; + return isAvailable; + } catch (error) { + extensionStatusCache = false; + return false; + } finally { + // Clear the promise so future calls can make a fresh check if needed + extensionStatusPromise = null; + } + })(); + + return extensionStatusPromise; +}; + +/** + * Clear the cached extension status (useful for testing or if extension state changes) + */ +export const clearCashtabStatusCache = (): void => { + extensionStatusCache = null; + extensionStatusPromise = null; +}; + + +export const initializeCashtabStatus = async (): Promise => { + return getCashtabProviderStatus(); +}; + +export const waitForCashtabExtension = async (timeout?: number): Promise => { + return cashtab.waitForExtension(timeout); +}; + +/** + * Request the user's eCash address from their Cashtab wallet + * @returns Promise - The user's address + * @throws {CashtabExtensionUnavailableError} When the Cashtab extension is not available + * @throws {CashtabAddressDeniedError} When the user denies the address request + * @throws {CashtabTimeoutError} When the request times out + */ +export const requestCashtabAddress = async (): Promise => { + return cashtab.requestAddress(); +}; + +export const sendXecWithCashtab = async (address: string, amount: string | number): Promise => { + return cashtab.sendXec(address, amount); +}; + +/** + * Open Cashtab with a BIP21 payment URL + * @param bip21Url - The BIP21 formatted payment URL + * @param fallbackUrl - Optional fallback URL if extension is not available + */ +export const openCashtabPayment = async (bip21Url: string, fallbackUrl?: string): Promise => { + try { + const isAvailable = await getCashtabProviderStatus(); + + if (isAvailable) { + const url = new URL(bip21Url); + const address = url.pathname; + const amount = url.searchParams.get('amount'); + + if (amount) { + await sendXecWithCashtab(address, amount); + } else { + const webUrl = fallbackUrl || `https://cashtab.com/#/send?bip21=${encodeURIComponent(bip21Url)}`; + window.open(webUrl, '_blank'); + } + } else { + const webUrl = fallbackUrl || `https://cashtab.com/#/send?bip21=${encodeURIComponent(bip21Url)}`; + window.open(webUrl, '_blank'); + } + } catch (error) { + if (error instanceof CashtabAddressDeniedError) { + // User rejected the transaction - do nothing for now + // This case is handled here in case we want to add specific behavior in the future + return; + } + + const webUrl = fallbackUrl || `https://cashtab.com/#/send?bip21=${encodeURIComponent(bip21Url)}`; + window.open(webUrl, '_blank'); + } +}; + +export { + CashtabExtensionUnavailableError, + CashtabAddressDeniedError, + CashtabTimeoutError +}; diff --git a/react/lib/util/index.ts b/react/lib/util/index.ts index 05af8e8f..11e5cf6e 100644 --- a/react/lib/util/index.ts +++ b/react/lib/util/index.ts @@ -1,5 +1,6 @@ export * from './address'; export * from './api-client'; +export * from './cashtab'; export * from './constants'; export * from './format'; export * from './opReturn'; diff --git a/react/package.json b/react/package.json index 41d93149..c65bc5e7 100644 --- a/react/package.json +++ b/react/package.json @@ -88,6 +88,7 @@ "@types/jest": "^29.5.11", "axios": "1.6.5", "bignumber.js": "9.0.2", + "cashtab-connect": "^1.1.0", "copy-to-clipboard": "3.3.3", "crypto-js": "^4.2.0", "jest": "^29.7.0",