@ledgerhq/live-countervalues-react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.tsx ADDED
@@ -0,0 +1,329 @@
1
+ import { BigNumber } from "bignumber.js";
2
+ import React, {
3
+ createContext,
4
+ useMemo,
5
+ useContext,
6
+ useEffect,
7
+ useReducer,
8
+ useState,
9
+ useCallback,
10
+ ReactElement,
11
+ } from "react";
12
+ import { getAccountCurrency, getAccountUnit } from "@ledgerhq/coin-framework/account/helpers";
13
+ import {
14
+ initialState,
15
+ calculate,
16
+ loadCountervalues,
17
+ exportCountervalues,
18
+ importCountervalues,
19
+ inferTrackingPairForAccounts,
20
+ } from "@ledgerhq/live-countervalues/logic";
21
+ import type {
22
+ CounterValuesState,
23
+ CounterValuesStateRaw,
24
+ CountervaluesSettings,
25
+ TrackingPair,
26
+ } from "@ledgerhq/live-countervalues/types";
27
+ import { useDebounce } from "@ledgerhq/live-hooks/useDebounce";
28
+ import type { Account, AccountLike } from "@ledgerhq/types-live";
29
+ import type { Currency, Unit } from "@ledgerhq/types-cryptoassets";
30
+
31
+ // Polling is the control object you get from the high level <PollingConsumer>{ polling => ...
32
+ export type Polling = {
33
+ // completely wipe all countervalues
34
+ wipe: () => void;
35
+ // one shot poll function
36
+ // TODO: is there any usecases returning promise here?
37
+ // It's a bit tricky to return Promise with current impl
38
+ poll: () => void;
39
+ // start background polling
40
+ start: () => void;
41
+ // stop background polling
42
+ stop: () => void;
43
+ // true when the polling is in progress
44
+ pending: boolean;
45
+ // if the last polling failed, there will be an error
46
+ error: Error | null | undefined;
47
+ };
48
+ export type Props = {
49
+ children: React.ReactNode;
50
+ userSettings: CountervaluesSettings;
51
+ // the time to wait before the first poll when app starts (allow things to render to not do all at boot time)
52
+ pollInitDelay?: number;
53
+ // the minimum time to wait before two automatic polls (then one that happen whatever network/appstate events)
54
+ autopollInterval?: number;
55
+ // debounce time before actually fetching
56
+ debounceDelay?: number;
57
+ savedState?: CounterValuesStateRaw;
58
+ };
59
+
60
+ const CountervaluesPollingContext = createContext<Polling>({
61
+ wipe: () => {},
62
+ poll: () => {},
63
+ start: () => {},
64
+ stop: () => {},
65
+ pending: false,
66
+ error: null,
67
+ });
68
+
69
+ const CountervaluesContext = createContext<CounterValuesState>(initialState);
70
+
71
+ function trackingPairsHash(a: TrackingPair[]) {
72
+ return a
73
+ .map(p => `${p.from.ticker}:${p.to.ticker}:${p.startDate.toISOString().slice(0, 10) || ""}`)
74
+ .sort()
75
+ .join("|");
76
+ }
77
+
78
+ export function useTrackingPairForAccounts(
79
+ accounts: Account[],
80
+ countervalue: Currency,
81
+ ): TrackingPair[] {
82
+ // first we cache the tracking pairs with its hash
83
+ const c = useMemo(() => {
84
+ const pairs = inferTrackingPairForAccounts(accounts, countervalue);
85
+ return { pairs, hash: trackingPairsHash(pairs) };
86
+ }, [accounts, countervalue]);
87
+ // we only want to return the pairs when the hash changes
88
+ // to not recalculate pairs as fast as accounts resynchronizes
89
+ // eslint-disable-next-line react-hooks/exhaustive-deps
90
+ return useMemo(() => c.pairs, [c.hash]);
91
+ }
92
+
93
+ export function Countervalues({
94
+ children,
95
+ userSettings,
96
+ pollInitDelay = 3 * 1000,
97
+ autopollInterval = 8 * 60 * 1000,
98
+ debounceDelay = 1000,
99
+ savedState,
100
+ }: Props): ReactElement {
101
+ const debouncedUserSettings = useDebounce(userSettings, debounceDelay);
102
+ const [{ state, pending, error }, dispatch] = useReducer(fetchReducer, initialFetchState);
103
+
104
+ // flag used to trigger a loadCountervalues
105
+ const [triggerLoad, setTriggerLoad] = useState(false);
106
+ // trigger poll only when userSettings changes. in a debounced way.
107
+ useEffect(() => {
108
+ setTriggerLoad(true);
109
+ }, [debouncedUserSettings]);
110
+
111
+ // loadCountervalues logic
112
+ useEffect(() => {
113
+ if (pending || !triggerLoad) return;
114
+ setTriggerLoad(false);
115
+ dispatch({
116
+ type: "pending",
117
+ });
118
+
119
+ loadCountervalues(state, userSettings).then(
120
+ state => {
121
+ dispatch({
122
+ type: "success",
123
+ payload: state,
124
+ });
125
+ },
126
+ error => {
127
+ dispatch({
128
+ type: "error",
129
+ payload: error,
130
+ });
131
+ },
132
+ );
133
+ }, [pending, state, userSettings, triggerLoad]);
134
+
135
+ // save the state when it changes
136
+ useEffect(() => {
137
+ if (!savedState?.status || !Object.keys(savedState.status).length) return;
138
+ dispatch({
139
+ type: "setCounterValueState",
140
+ payload: importCountervalues(savedState, userSettings),
141
+ }); // eslint-disable-next-line react-hooks/exhaustive-deps
142
+ }, [savedState]);
143
+
144
+ // manage the auto polling loop and the interface for user land to trigger a reload
145
+ const [isPolling, setIsPolling] = useState(true);
146
+ useEffect(() => {
147
+ if (!isPolling) return;
148
+ let pollingTimeout: NodeJS.Timeout;
149
+
150
+ function pollingLoop() {
151
+ setTriggerLoad(true);
152
+ pollingTimeout = setTimeout(pollingLoop, autopollInterval);
153
+ }
154
+
155
+ pollingTimeout = setTimeout(pollingLoop, pollInitDelay);
156
+ return () => clearTimeout(pollingTimeout);
157
+ }, [autopollInterval, pollInitDelay, isPolling]);
158
+
159
+ const polling = useMemo<Polling>(
160
+ () => ({
161
+ wipe: () => {
162
+ dispatch({
163
+ type: "wipe",
164
+ });
165
+ },
166
+ poll: () => setTriggerLoad(true),
167
+ start: () => setIsPolling(true),
168
+ stop: () => setIsPolling(false),
169
+ pending,
170
+ error,
171
+ }),
172
+ [pending, error],
173
+ );
174
+
175
+ return (
176
+ <CountervaluesPollingContext.Provider value={polling}>
177
+ <CountervaluesContext.Provider value={state}>{children}</CountervaluesContext.Provider>
178
+ </CountervaluesPollingContext.Provider>
179
+ );
180
+ }
181
+
182
+ type Action =
183
+ | {
184
+ type: "success";
185
+ payload: CounterValuesState;
186
+ }
187
+ | {
188
+ type: "error";
189
+ payload: Error;
190
+ }
191
+ | {
192
+ type: "pending";
193
+ }
194
+ | {
195
+ type: "wipe";
196
+ }
197
+ | {
198
+ type: "setCounterValueState";
199
+ payload: CounterValuesState;
200
+ };
201
+
202
+ type FetchState = {
203
+ state: CounterValuesState;
204
+ pending: boolean;
205
+ error?: Error;
206
+ };
207
+ const initialFetchState: FetchState = {
208
+ state: initialState,
209
+ pending: false,
210
+ };
211
+
212
+ function fetchReducer(state: FetchState, action: Action): FetchState {
213
+ switch (action.type) {
214
+ case "success":
215
+ return {
216
+ state: action.payload,
217
+ pending: false,
218
+ error: undefined,
219
+ };
220
+
221
+ case "error":
222
+ return { ...state, pending: false, error: action.payload };
223
+
224
+ case "pending":
225
+ return { ...state, pending: true, error: undefined };
226
+
227
+ case "wipe":
228
+ return {
229
+ state: initialState,
230
+ pending: false,
231
+ error: undefined,
232
+ };
233
+
234
+ case "setCounterValueState":
235
+ return { ...state, state: action.payload };
236
+
237
+ default:
238
+ return state;
239
+ }
240
+ }
241
+
242
+ export function useCountervaluesPolling(): Polling {
243
+ return useContext(CountervaluesPollingContext);
244
+ }
245
+ export function useCountervaluesState(): CounterValuesState {
246
+ return useContext(CountervaluesContext);
247
+ }
248
+ export function useCountervaluesExport(): CounterValuesStateRaw {
249
+ const state = useContext(CountervaluesContext);
250
+ return useMemo(() => exportCountervalues(state), [state]);
251
+ }
252
+ export function useCalculate(query: {
253
+ value: number;
254
+ from: Currency;
255
+ to: Currency;
256
+ disableRounding?: boolean;
257
+ date?: Date | null | undefined;
258
+ reverse?: boolean;
259
+ }): number | null | undefined {
260
+ const state = useCountervaluesState();
261
+ return calculate(state, query);
262
+ }
263
+
264
+ export function useCalculateCountervalueCallback({
265
+ to,
266
+ }: {
267
+ to: Currency;
268
+ }): (from: Currency, value: BigNumber) => BigNumber | null | undefined {
269
+ const state = useCountervaluesState();
270
+ return useCallback(
271
+ (from: Currency, value: BigNumber): BigNumber | null | undefined => {
272
+ const countervalue = calculate(state, {
273
+ value: value.toNumber(),
274
+ from,
275
+ to,
276
+ disableRounding: true,
277
+ });
278
+ return typeof countervalue === "number" ? new BigNumber(countervalue) : countervalue;
279
+ },
280
+ [to, state],
281
+ );
282
+ }
283
+
284
+ export function useSendAmount({
285
+ account,
286
+ fiatCurrency,
287
+ cryptoAmount,
288
+ }: {
289
+ account: AccountLike;
290
+ fiatCurrency: Currency;
291
+ cryptoAmount: BigNumber;
292
+ }): {
293
+ cryptoUnit: Unit;
294
+ fiatAmount: BigNumber;
295
+ fiatUnit: Unit;
296
+ calculateCryptoAmount: (fiatAmount: BigNumber) => BigNumber;
297
+ } {
298
+ const cryptoCurrency = getAccountCurrency(account);
299
+ const fiatCountervalue = useCalculate({
300
+ from: cryptoCurrency,
301
+ to: fiatCurrency,
302
+ value: cryptoAmount.toNumber(),
303
+ disableRounding: true,
304
+ });
305
+ const fiatAmount = new BigNumber(fiatCountervalue ?? 0);
306
+ const fiatUnit = fiatCurrency.units[0];
307
+ const cryptoUnit = getAccountUnit(account);
308
+ const state = useCountervaluesState();
309
+ const calculateCryptoAmount = useCallback(
310
+ (fiatAmount: BigNumber) => {
311
+ const cryptoAmount = new BigNumber(
312
+ calculate(state, {
313
+ from: cryptoCurrency,
314
+ to: fiatCurrency,
315
+ value: fiatAmount.toNumber(),
316
+ reverse: true,
317
+ }) ?? 0,
318
+ );
319
+ return cryptoAmount;
320
+ },
321
+ [state, cryptoCurrency, fiatCurrency],
322
+ );
323
+ return {
324
+ cryptoUnit,
325
+ fiatAmount,
326
+ fiatUnit,
327
+ calculateCryptoAmount,
328
+ };
329
+ }
@@ -0,0 +1,117 @@
1
+ import { useTrackingPairForAccounts } from ".";
2
+ import { genAccount } from "@ledgerhq/coin-framework/mocks/account";
3
+ import { renderHook, act } from "@testing-library/react";
4
+ import { getFiatCurrencyByTicker } from "@ledgerhq/cryptoassets";
5
+ import { inferTrackingPairForAccounts } from "@ledgerhq/live-countervalues/logic";
6
+ import { TrackingPair } from "@ledgerhq/live-countervalues/types";
7
+
8
+ describe("useTrackingPairForAccounts", () => {
9
+ const accounts = Array(20)
10
+ .fill(null)
11
+ .map((_, i) => genAccount("test" + i));
12
+ const usd = getFiatCurrencyByTicker("USD");
13
+ const eur = getFiatCurrencyByTicker("EUR");
14
+ const trackingPairs = inferTrackingPairForAccounts(accounts, usd);
15
+
16
+ test("it returns same tracking pairs as when using inferTrackingPairForAccounts", async () => {
17
+ const { result } = renderHook(() => useTrackingPairForAccounts(accounts, usd));
18
+ await act(async () => {
19
+ expect(result.current).toEqual(trackingPairs);
20
+ });
21
+ });
22
+
23
+ test("a re-render preserve the reference", async () => {
24
+ const { result, rerender } = renderHook(() => useTrackingPairForAccounts(accounts, usd));
25
+ let initial: TrackingPair[] | undefined;
26
+ await act(async () => {
27
+ initial = result.current;
28
+ });
29
+ rerender();
30
+ await act(async () => {
31
+ expect(result.current).toBe(initial);
32
+ });
33
+ });
34
+
35
+ test("a re-render preserve the reference even when accounts change", async () => {
36
+ const { result, rerender } = renderHook(() =>
37
+ useTrackingPairForAccounts(accounts.slice(0), usd),
38
+ );
39
+ let initial: TrackingPair[] | undefined;
40
+ await act(async () => {
41
+ initial = result.current;
42
+ });
43
+ rerender();
44
+ await act(async () => {
45
+ expect(result.current).toBe(initial);
46
+ });
47
+ });
48
+
49
+ test("when accounts appears, it properly converge to the trackingPairs", async () => {
50
+ const { result, rerender } = renderHook(added =>
51
+ useTrackingPairForAccounts(!added ? [] : accounts, usd),
52
+ );
53
+ await act(async () => {
54
+ expect(result.current).toEqual([]);
55
+ });
56
+ rerender(true);
57
+ await act(async () => {
58
+ expect(result.current).toEqual(trackingPairs);
59
+ });
60
+ });
61
+
62
+ test("when accounts changes fundamentally, pairs change", async () => {
63
+ const { result, rerender } = renderHook(empty =>
64
+ useTrackingPairForAccounts(empty ? [] : accounts, usd),
65
+ );
66
+ await act(async () => {
67
+ expect(result.current).toEqual(trackingPairs);
68
+ });
69
+ rerender(true);
70
+ await act(async () => {
71
+ expect(result.current).toEqual([]);
72
+ });
73
+ });
74
+
75
+ test("when currency changes, pairs change", async () => {
76
+ const { result, rerender } = renderHook(usesEur =>
77
+ useTrackingPairForAccounts(accounts, usesEur ? eur : usd),
78
+ );
79
+ await act(async () => {
80
+ expect(result.current).toEqual(trackingPairs);
81
+ });
82
+ rerender(true);
83
+ await act(async () => {
84
+ expect(result.current).not.toEqual(trackingPairs);
85
+ });
86
+ });
87
+
88
+ test("if accounts reorder, it doesn't change", async () => {
89
+ const reverse = accounts.slice(0).reverse();
90
+ const { result, rerender } = renderHook(rev =>
91
+ useTrackingPairForAccounts(rev ? reverse : accounts, usd),
92
+ );
93
+ let initial: TrackingPair[] | undefined;
94
+ await act(async () => {
95
+ initial = result.current;
96
+ });
97
+ rerender(true);
98
+ await act(async () => {
99
+ expect(result.current).toBe(initial);
100
+ });
101
+ });
102
+
103
+ test("if accounts doubles, it doesn't change", async () => {
104
+ const doubled = accounts.concat(accounts);
105
+ const { result, rerender } = renderHook(d =>
106
+ useTrackingPairForAccounts(d ? doubled : accounts, usd),
107
+ );
108
+ let initial: TrackingPair[] | undefined;
109
+ await act(async () => {
110
+ initial = result.current;
111
+ });
112
+ rerender(true);
113
+ await act(async () => {
114
+ expect(result.current).toBe(initial);
115
+ });
116
+ });
117
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../tsconfig.base",
3
+ "compilerOptions": {
4
+ "declaration": true,
5
+ "declarationMap": true,
6
+ "noImplicitAny": true,
7
+ "noImplicitThis": true,
8
+ "module": "commonjs",
9
+ "downlevelIteration": true,
10
+ "jsx": "react",
11
+ "lib": ["es2020", "dom"],
12
+ "outDir": "lib"
13
+ },
14
+ "include": ["src/**/*"]
15
+ }