diff --git a/cashtab/extension/public/manifest.json b/cashtab/extension/public/manifest.json --- a/cashtab/extension/public/manifest.json +++ b/cashtab/extension/public/manifest.json @@ -3,7 +3,7 @@ "name": "Cashtab", "description": "A browser-integrated eCash wallet from Bitcoin ABC", - "version": "3.21.0", + "version": "3.22.0", "content_scripts": [ { "matches": ["file://*/*", "http://*/*", "https://*/*"], diff --git a/cashtab/package-lock.json b/cashtab/package-lock.json --- a/cashtab/package-lock.json +++ b/cashtab/package-lock.json @@ -1,12 +1,12 @@ { "name": "cashtab", - "version": "2.21.1", + "version": "2.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cashtab", - "version": "2.21.1", + "version": "2.22.0", "dependencies": { "@ant-design/icons": "^5.3.0", "@bitgo/utxo-lib": "^9.33.0", diff --git a/cashtab/package.json b/cashtab/package.json --- a/cashtab/package.json +++ b/cashtab/package.json @@ -1,6 +1,6 @@ { "name": "cashtab", - "version": "2.21.1", + "version": "2.22.0", "private": true, "scripts": { "start": "node scripts/start.js", diff --git a/cashtab/src/components/App/__tests__/App.test.js b/cashtab/src/components/App/__tests__/App.test.js --- a/cashtab/src/components/App/__tests__/App.test.js +++ b/cashtab/src/components/App/__tests__/App.test.js @@ -3,7 +3,7 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, act } from '@testing-library/react'; import userEvent, { PointerEventsCheckLevel, } from '@testing-library/user-event'; @@ -43,6 +43,7 @@ import { createCashtabWallet } from 'wallet'; import { isValidCashtabWallet } from 'validation'; import CashtabCache from 'config/CashtabCache'; +import { CashtabSettings } from 'config/cashtabSettings'; // https://stackoverflow.com/questions/39830580/jest-test-fails-typeerror-window-matchmedia-is-not-a-function Object.defineProperty(window, 'matchMedia', { @@ -93,6 +94,8 @@ // Keep this in the code, because different URLs will have different outputs requiring different parsing const priceApiUrl = `https://api.coingecko.com/api/v3/simple/price?ids=${cryptoId}&vs_currencies=${fiatCode}&include_last_updated_at=true`; const xecPrice = 0.00003; + const nextXecPrice = 0.000042; + const zeroKillingXecPrice = 0.000111; const priceResponse = { ecash: { usd: xecPrice, @@ -101,9 +104,30 @@ }; when(fetch) .calledWith(priceApiUrl) - .mockResolvedValue({ + .mockResolvedValueOnce({ json: () => Promise.resolve(priceResponse), }); + when(fetch) + .calledWith(priceApiUrl) + .mockResolvedValueOnce({ + json: () => + Promise.resolve({ + ...priceResponse, + ecash: { ...priceResponse.ecash, usd: nextXecPrice }, + }), + }); + when(fetch) + .calledWith(priceApiUrl) + .mockResolvedValueOnce({ + json: () => + Promise.resolve({ + ...priceResponse, + ecash: { + ...priceResponse.ecash, + usd: zeroKillingXecPrice, + }, + }), + }); }); afterEach(async () => { jest.clearAllMocks(); @@ -1245,4 +1269,102 @@ ), ); }); + it('We see a price notification if new price is at a new tens level in USD per 1,000,000 XEC, and a special notification if a zero is killed', async () => { + jest.useFakeTimers(); + + const mockedChronik = await initializeCashtabStateForTests( + walletWithXecAndTokens, + localforage, + ); + + render(); + + // Wait for the app to load + await waitFor(() => + expect(screen.queryByTestId('loading-ctn')).not.toBeInTheDocument(), + ); + + // Advance timers more than the price interval + act(() => { + jest.advanceTimersByTime(appConfig.fiatUpdateIntervalMs + 1000); + }); + + // We get a notification for a "tens" price milestone + expect( + await screen.findByText('XEC is now $0.000042 USD'), + ).toBeInTheDocument(); + + // Advance timers more than the price interval + act(() => { + jest.advanceTimersByTime(appConfig.fiatUpdateIntervalMs + 1000); + }); + + // We get a notification for a "tens" price milestone + expect( + await screen.findByText('XEC is now $0.000111 USD'), + ).toBeInTheDocument(); + + // We get a zero killed notification + expect( + await screen.findByText('ZERO KILLED 🔫🔫🔫🔪🔪🔪'), + ).toBeInTheDocument(); + + // Return to normal timers + // Ref https://testing-library.com/docs/using-fake-timers/ + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + it('We do not see price notifications if new price is at a new tens level in JPY per 1,000,000 XEC, because user fiat currency does not support zero killed notifications for JPY', async () => { + jest.useFakeTimers(); + + const mockedChronik = await initializeCashtabStateForTests( + walletWithXecAndTokens, + localforage, + ); + + await localforage.setItem('settings', new CashtabSettings('jpy')); + + render(); + + // Wait for the app to load + await waitFor(() => + expect(screen.queryByTestId('loading-ctn')).not.toBeInTheDocument(), + ); + + // Advance timers more than the price interval + act(() => { + jest.advanceTimersByTime(appConfig.fiatUpdateIntervalMs + 1000); + }); + + // We DO NOT get a notification for a "tens" price milestone + await waitFor(() => + expect( + screen.queryByText('XEC is now $0.000042 USD'), + ).not.toBeInTheDocument(), + ); + + // Advance timers more than the price interval + act(() => { + jest.advanceTimersByTime(appConfig.fiatUpdateIntervalMs + 1000); + }); + + // We DO NOT get a notification for a "tens" price milestone + await waitFor(() => + expect( + screen.queryByText('XEC is now $0.000111 USD'), + ).not.toBeInTheDocument(), + ); + + // We DO NOT get a zero killed notification + await waitFor(() => + expect( + screen.queryByText('ZERO KILLED 🔫🔫🔫🔪🔪🔪'), + ).not.toBeInTheDocument(), + ); + + // Return to normal timers + // Ref https://testing-library.com/docs/using-fake-timers/ + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); }); diff --git a/cashtab/src/wallet/useWallet.js b/cashtab/src/wallet/useWallet.js --- a/cashtab/src/wallet/useWallet.js +++ b/cashtab/src/wallet/useWallet.js @@ -2,7 +2,7 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { getHashArrayFromWallet } from 'utils/cashMethods'; import { isValidCashtabSettings, @@ -60,6 +60,19 @@ const [cashtabState, setCashtabState] = useState(new CashtabState()); const locale = getUserLocale(); + // Ref https://stackoverflow.com/questions/53446020/how-to-compare-oldvalues-and-newvalues-on-react-hooks-useeffect + // Get the previous value of a state variable + const usePrevious = value => { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }, [value]); + return ref.current; + }; + + const prevFiatPrice = usePrevious(fiatPrice); + const prevFiatCurrency = usePrevious(cashtabState.settings.fiatCurrency); + const update = async cashtabState => { if (!cashtabLoaded) { // Wait for cashtab to get state from localforage before updating @@ -699,9 +712,14 @@ // Must be able to end them and set new ones with new currencies const initializeFiatPriceApi = async selectedFiatCurrency => { // Update fiat price and confirm it is set to make sure ap keeps loading state until this is updated + + // Call this instance with showNotifications = false, + // as we do not want to calculate price deltas when the user selects a new foreign currency await fetchXecPrice(selectedFiatCurrency); // Set interval for updating the price with given currency + // Now we call with showNotifications = true, as we want + // to show price changes when the currency has not changed const thisFiatInterval = setInterval(function () { fetchXecPrice(selectedFiatCurrency); }, appConfig.fiatUpdateIntervalMs); @@ -730,6 +748,7 @@ let xecPriceInFiat = xecPriceJson[cryptoId][fiatCode]; if (typeof xecPriceInFiat === 'number') { + // If we have a good fetch return setFiatPrice(xecPriceInFiat); } } catch (err) { @@ -816,6 +835,62 @@ initializeFiatPriceApi(cashtabState.settings.fiatCurrency); }, [cashtabLoaded, cashtabState.settings.fiatCurrency]); + /** + * useEffect + * Depends on fiatPrice and user-set fiatCurrency + * Used to trigger price notifications at new fiatPrice milestones + * Optimized for USD + * Also supports EUR and GBP as these are "close enough", for now anyway + */ + useEffect(() => { + // Do nothing if the user has just changed the fiat currency + if (cashtabState.settings.fiatCurrency !== prevFiatCurrency) { + return; + } + + // We only support currencies that are similar order of magnitude to USD + // USD is the real referencce for "killed zero" + const FIAT_CHANGE_SUPPORTED_CURRENCIES = [ + 'usd', + 'eur', + 'gbp', + 'cad', + 'aud', + ]; + if ( + !FIAT_CHANGE_SUPPORTED_CURRENCIES.includes( + cashtabState.settings.fiatCurrency, + ) + ) { + return; + } + // Otherwise we do support them + if (fiatPrice === null || prevFiatPrice === null) { + return; + } + const priceIncreased = fiatPrice - prevFiatPrice > 0; + if (priceIncreased) { + // We only show price notifications if price has increased + // "tens" for USD price per 1,000,000 XEC + const prevTens = parseInt(Math.floor(prevFiatPrice * 1e5)); + const tens = parseInt(Math.floor(fiatPrice * 1e5)); + if (tens > prevTens) { + // We have passed a $10 milestone + toast( + `XEC is now ${ + supportedFiatCurrencies[ + cashtabState.settings.fiatCurrency + ].symbol + }${fiatPrice} ${cashtabState.settings.fiatCurrency.toUpperCase()}`, + ); + } + if (tens >= 10 && prevTens < 10) { + // We have killed a zero + toast(`ZERO KILLED 🔫🔫🔫🔪🔪🔪`, { autoClose: false }); + } + } + }, [fiatPrice, cashtabState.settings.fiatCurrency]); + // Update websocket subscriptions and websocket onMessage handler whenever // 1. cashtabState changes // 2. or the fiat price updates (the onMessage handler needs to have the most up-to-date