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