@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/.eslintrc.js +38 -0
- package/.turbo/turbo-build.log +4 -0
- package/.unimportedrc.json +4 -0
- package/CHANGELOG.md +1 -0
- package/LICENSE.txt +21 -0
- package/jest.config.js +14 -0
- package/lib/index.d.ts +48 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +215 -0
- package/lib/index.js.map +1 -0
- package/lib/react.test.d.ts +2 -0
- package/lib/react.test.d.ts.map +1 -0
- package/lib/react.test.js +107 -0
- package/lib/react.test.js.map +1 -0
- package/lib-es/index.d.ts +48 -0
- package/lib-es/index.d.ts.map +1 -0
- package/lib-es/index.js +181 -0
- package/lib-es/index.js.map +1 -0
- package/lib-es/react.test.d.ts +2 -0
- package/lib-es/react.test.d.ts.map +1 -0
- package/lib-es/react.test.js +105 -0
- package/lib-es/react.test.js.map +1 -0
- package/package.json +71 -0
- package/src/index.tsx +329 -0
- package/src/react.test.ts +117 -0
- package/tsconfig.json +15 -0
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
|
+
}
|