diff --git a/.eslintrc.json b/.eslintrc.json index 2b1117a..d349a11 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -54,6 +54,7 @@ "eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier/@typescript-eslint", - "plugin:react/recommended" + "plugin:react/recommended", + "plugin:react-hooks/recommended" ] } diff --git a/package.json b/package.json index a8a2320..84bc2ee 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "eslint": "^7.20.0", "eslint-config-prettier": "^7.2.0", "eslint-plugin-react": "^7.22.0", + "eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-tsdoc": "^0.2.11", "prettier": "^2.2.1", "react-refresh": "^0.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65beb68..a05beee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,7 @@ eslint: 7.20.0 eslint-config-prettier: 7.2.0_eslint@7.20.0 eslint-plugin-react: 7.22.0_eslint@7.20.0 + eslint-plugin-react-hooks: 4.2.0_eslint@7.20.0 eslint-plugin-tsdoc: 0.2.11 prettier: 2.2.1 react-refresh: 0.9.0 @@ -13656,6 +13657,7 @@ eslint: ^7.20.0 eslint-config-prettier: ^7.2.0 eslint-plugin-react: ^7.22.0 + eslint-plugin-react-hooks: ^4.2.0 eslint-plugin-tsdoc: ^0.2.11 file-saver: ^2.0.5 i18next: ^19.7.0 diff --git a/public/locales/en.json b/public/locales/en.json index 29e686f..23c74b4 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -303,5 +303,8 @@ "ws": { "errorToken": "There was an error connecting to the Krist websocket server.", "errorWS": "There was an error connecting to the Krist websocket server (code <1>{{code}})." - } + }, + + "rateLimitTitle": "Rate limit hit", + "rateLimitDescription": "Too many requests were sent to the Krist server in a short period of time. This is probably caused by a bug!" } diff --git a/src/components/wallets/SelectWalletCategory.tsx b/src/components/wallets/SelectWalletCategory.tsx index a851e68..76d9a6e 100644 --- a/src/components/wallets/SelectWalletCategory.tsx +++ b/src/components/wallets/SelectWalletCategory.tsx @@ -17,7 +17,7 @@ onNewCategory?: (name: string) => void; } -export function getSelectWalletCategory({ onNewCategory }: Props): JSX.Element { +export function SelectWalletCategory({ onNewCategory }: Props): JSX.Element { // Required to fetch existing categories const { wallets } = useSelector((s: RootState) => s.wallets, shallowEqual); const existingCategories = [...new Set(Object.values(wallets) diff --git a/src/components/wallets/SelectWalletFormat.tsx b/src/components/wallets/SelectWalletFormat.tsx index e125bec..d16f7f4 100644 --- a/src/components/wallets/SelectWalletFormat.tsx +++ b/src/components/wallets/SelectWalletFormat.tsx @@ -15,7 +15,7 @@ initialFormat: WalletFormatName; } -export function getSelectWalletFormat({ initialFormat }: Props): JSX.Element { +export function SelectWalletFormat({ initialFormat }: Props): JSX.Element { const advancedWalletFormats = useSelector((s: RootState) => s.settings.walletFormats); const { t } = useTranslation(); diff --git a/src/components/wallets/SyncWallets.tsx b/src/components/wallets/SyncWallets.tsx index 0937a1c..422edcc 100644 --- a/src/components/wallets/SyncWallets.tsx +++ b/src/components/wallets/SyncWallets.tsx @@ -19,7 +19,8 @@ useEffect(() => { // TODO: show errors to the user? syncWallets(dispatch, syncNode, wallets).catch(console.error); - }, [syncNode]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dispatch, syncNode]); return null; } diff --git a/src/components/ws/SyncMOTD.tsx b/src/components/ws/SyncMOTD.tsx index fa72c84..87eb9e6 100644 --- a/src/components/ws/SyncMOTD.tsx +++ b/src/components/ws/SyncMOTD.tsx @@ -40,23 +40,16 @@ // Update the MOTD when the sync node changes, and on startup useEffect(() => { - // TODO: show errors to the user? - updateMOTD(dispatch, syncNode).catch(console.error); - }, [syncNode]); - - // Update the MOTD when the sync node reconnects, in case it changes in - // realtime (basically only used for development) - useEffect(() => { if (connectionState !== "connected") return; updateMOTD(dispatch, syncNode).catch(console.error); - }, [connectionState]); + }, [dispatch, syncNode, connectionState]); // When the currency's address prefix changes, or our master password appears, // recalculate the addresses if necessary useEffect(() => { if (!addressPrefix || !masterPassword) return; recalculateWallets(dispatch, masterPassword, wallets, addressPrefix).catch(console.error); - }, [addressPrefix, masterPassword, wallets]); + }, [dispatch, addressPrefix, masterPassword, wallets]); return null; } diff --git a/src/components/ws/SyncWork.tsx b/src/components/ws/SyncWork.tsx index 914dd72..a01b935 100644 --- a/src/components/ws/SyncWork.tsx +++ b/src/components/ws/SyncWork.tsx @@ -31,7 +31,7 @@ useEffect(() => { // TODO: show errors to the user? updateDetailedWork(dispatch, syncNode).catch(console.error); - }, [lastBlockID, syncNode]); + }, [dispatch, lastBlockID, syncNode]); return null; } diff --git a/src/components/ws/WebsocketService.tsx b/src/components/ws/WebsocketService.tsx index 130c8ea..aa98e14 100644 --- a/src/components/ws/WebsocketService.tsx +++ b/src/components/ws/WebsocketService.tsx @@ -11,7 +11,7 @@ import * as nodeActions from "../../store/actions/NodeActions"; import * as api from "../../krist/api/api"; -import { APIResponse, KristAddress, KristBlock, KristTransaction, WSConnectionState, WSIncomingMessage, WSSubscriptionLevel } from "../../krist/api/types"; +import { KristAddress, KristBlock, KristTransaction, WSConnectionState, WSIncomingMessage, WSSubscriptionLevel } from "../../krist/api/types"; import { findWalletByAddress, syncWallet, syncWalletUpdate } from "../../krist/wallets/Wallet"; import WebSocketAsPromised from "websocket-as-promised"; @@ -37,7 +37,7 @@ // TODO: automatically clean this up? private refreshThrottles: Record void> = {}; - constructor(private dispatch: AppDispatch, private syncNode: string) { + constructor(private dispatch: AppDispatch, public syncNode: string) { debug("WS component init"); this.attemptConnect(); } @@ -232,19 +232,30 @@ const [connection, setConnection] = useState(); + // On first render, or if the sync node changes, create the websocket + // connection useEffect(() => { + // Don't reconnect if we already have a connection and the sync node hasn't + // changed (prevents infinite loops) + if (connection && connection.syncNode === syncNode) return; + + // Close any existing connections if (connection) connection.forceClose(); + + // Connect to the Krist websocket server setConnection(new WebsocketConnection(dispatch, syncNode)); // On unmount, force close the existing connection return () => { if (connection) connection.forceClose(); }; - }, [syncNode]); + }, [dispatch, syncNode, connection]); + // If the wallets change, let the websocket service know so that it can keep + // track of events related to any new wallets useEffect(() => { if (connection) connection.setWallets(wallets); - }, [wallets]); + }, [wallets, connection]); return null; } diff --git a/src/krist/api/api.ts b/src/krist/api/api.ts index 800aa7b..0574344 100644 --- a/src/krist/api/api.ts +++ b/src/krist/api/api.ts @@ -1,4 +1,8 @@ +import { notification } from "antd"; +import i18n from "../../utils/i18n"; + import { APIResponse } from "./types"; +import { throttle } from "lodash-es"; export class APIError extends Error { constructor(message: string, public parameter?: string) { @@ -6,6 +10,14 @@ } } +// Realistically, the only situation in which a rate limit will actually be hit +// by KristWeb is if an infinite loop is introduced (e.g. via useEffect), so we +// would want to avoid spamming notifications and making the performance bug +// worse, therefore this notification is throttled to 5 seconds. +const _notifyRateLimit = () => + notification.error({ message: i18n.t("rateLimitTitle"), description: i18n.t("rateLimitDescription") }); +const notifyRateLimit = throttle(_notifyRateLimit, 5000); + export async function request(syncNode: string, method: string, endpoint: string, options?: RequestInit): Promise> { // Let the fetch bubble its error upwards const res = await fetch(syncNode + "/" + endpoint, { @@ -13,6 +25,11 @@ ...options }); + if (res.status === 429) { + notifyRateLimit(); + throw new APIError("rate_limit_hit"); + } + const data: APIResponse = await res.json(); if (!data.ok || data.error) throw new APIError(data.error || "unknown_error", data.parameter); diff --git a/src/krist/api/search.ts b/src/krist/api/search.ts new file mode 100644 index 0000000..bffadc8 --- /dev/null +++ b/src/krist/api/search.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import { KristAddress, KristBlock, KristName, KristTransaction } from "./types"; +import * as api from "./api"; + +interface SearchQueryMatch { + matchedAddress: boolean; + matchedName: boolean; + matchedBlock: boolean; + matchedTransaction: boolean; + strippedName: string; +} + +interface SearchResult { + query: SearchQueryMatch; + + matches: { + exactAddress: KristAddress | boolean; + exactName: KristName | boolean; + exactBlock: KristBlock | boolean; + exactTransaction: KristTransaction | boolean; + }; +} + +interface SearchExtendedResult { + query: SearchQueryMatch; + + matches: { + transactions: { + addressInvolved: number | boolean; + nameInvolved: number | boolean; + metadata: number | boolean; + }; + }; +} + +export async function search(syncNode: string, query?: string): Promise { + if (!query) return; + return api.get(syncNode, "search?q=" + encodeURIComponent(query)); +} + +export async function searchExtended(syncNode: string, query?: string): Promise { + if (!query || query.length < 3) return; + return api.get(syncNode, "search/extended?q=" + encodeURIComponent(query)); +} diff --git a/src/krist/api/types.ts b/src/krist/api/types.ts index 2e4428c..2fccabf 100644 --- a/src/krist/api/types.ts +++ b/src/krist/api/types.ts @@ -20,6 +20,8 @@ time: string; name?: string; metadata?: string; + sent_name?: string; + sent_metaname?: string; type: KristTransactionType; } @@ -33,6 +35,14 @@ time: string; } +export interface KristName { + name: string; + owner: string; + registered: string; + updated?: string | null; + a?: string | null; +} + export interface KristWorkDetailed { work: number; unpaid: number; diff --git a/src/layout/PageLayout.tsx b/src/layout/PageLayout.tsx index 2ecca73..9c94978 100644 --- a/src/layout/PageLayout.tsx +++ b/src/layout/PageLayout.tsx @@ -40,7 +40,7 @@ useEffect(() => { if (siteTitle) document.title = `${siteTitle} - KristWeb`; else if (siteTitleKey) document.title = `${t(siteTitleKey)} - KristWeb`; - }, []); + }, [t, siteTitle, siteTitleKey]); return
{/* Page header */} diff --git a/src/layout/nav/AppHeader.tsx b/src/layout/nav/AppHeader.tsx index 61a341f..51542bd 100644 --- a/src/layout/nav/AppHeader.tsx +++ b/src/layout/nav/AppHeader.tsx @@ -2,13 +2,14 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import React from "react"; -import { Layout, Menu, AutoComplete, Input, Grid } from "antd"; +import { Layout, Menu, Grid } from "antd"; import { SendOutlined, DownloadOutlined, MenuOutlined, SettingOutlined } from "@ant-design/icons"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { Brand } from "./Brand"; +import { Search } from "./Search"; import { ConnectionIndicator } from "./ConnectionIndicator"; import { CymbalIndicator } from "./CymbalIndicator"; @@ -46,11 +47,7 @@ {bps.md &&
} {/* Search box */} -
- - - -
+ {/* Connection indicator */} diff --git a/src/layout/nav/Search.tsx b/src/layout/nav/Search.tsx new file mode 100644 index 0000000..9b62367 --- /dev/null +++ b/src/layout/nav/Search.tsx @@ -0,0 +1,30 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import React, { useState } from "react"; +import { AutoComplete, Input } from "antd"; + +import { useTranslation } from "react-i18next"; + +import { throttle } from "lodash-es"; + +import Debug from "debug"; +const debug = Debug("kristweb:transactions-card"); + +const SEARCH_THROTTLE = 500; + +export function Search(): JSX.Element { + const { t } = useTranslation(); + + const [value, setValue] = useState(""); + + return
+ + + +
; +} diff --git a/src/pages/dashboard/BlockDifficultyCard.tsx b/src/pages/dashboard/BlockDifficultyCard.tsx index 9c55f07..d70c177 100644 --- a/src/pages/dashboard/BlockDifficultyCard.tsx +++ b/src/pages/dashboard/BlockDifficultyCard.tsx @@ -11,8 +11,9 @@ import { Line } from "react-chartjs-2"; import * as api from "../../krist/api/api"; -import { throttle } from "lodash-es"; import { estimateHashRate } from "../../utils/currency"; +import { KristConstants } from "../../krist/api/types"; +import { trailingThrottleState } from "../../utils/promiseThrottle"; import { SmallResult } from "../../components/SmallResult"; import { Statistic } from "./Statistic"; @@ -20,8 +21,6 @@ import Debug from "debug"; const debug = Debug("kristweb:block-difficulty-card"); -const DATA_FETCH_THROTTLE = 300; - // ============================================================================= // Chart.JS theming options // ============================================================================= @@ -107,6 +106,20 @@ } }; +const WORK_THROTTLE = 500; +async function _fetchWorkOverTime(syncNode: string, constants: KristConstants): Promise<{ x: Date; y: number }[]> { + debug("fetching work over time"); + const data = await api.get<{ work: number[] }>(syncNode, "work/day"); + + // Convert the array indices to Dates, based on the fact that the array + // should contain one block per secondsPerBlock (typically 1440 elements, + // one per minute). This can be passed directly into Chart.JS. + return data.work.map((work, i, arr) => ({ + x: new Date(Date.now() - ((arr.length - i) * (constants.seconds_per_block * 1000))), + y: work + })); +} + export function BlockDifficultyCard(): JSX.Element { const { t } = useTranslation(); @@ -116,43 +129,21 @@ const constants = useSelector((s: RootState) => s.node.constants, shallowEqual); const [workOverTime, setWorkOverTime] = useState<{ x: Date; y: number }[] | undefined>(); - const [loading, setLoading] = useState(true); const [error, setError] = useState(); + const [loading, setLoading] = useState(true); const [chartMode, setChartMode] = useState<"linear" | "logarithmic">("linear"); - async function _fetchWorkOverTime(): Promise { - try { - debug("fetching work over time"); - const data = await api.get<{ work: number[] }>(syncNode, "work/day"); - - // Convert the array indices to Dates, based on the fact that the array - // should contain one block per secondsPerBlock (typically 1440 elements, - // one per minute). This can be passed directly into Chart.JS. - const processedWork = data.work.map((work, i, arr) => ({ - x: new Date(Date.now() - ((arr.length - i) * (constants.seconds_per_block * 1000))), - y: work - })); - - setWorkOverTime(processedWork); - } catch (err) { - console.error(err); - setError(err); - } finally { - setLoading(false); - } - } - const fetchWorkOverTime = useMemo(() => - throttle(_fetchWorkOverTime, DATA_FETCH_THROTTLE, { leading: false, trailing: true }), []); + trailingThrottleState(_fetchWorkOverTime, WORK_THROTTLE, false, setWorkOverTime, setError, setLoading), []); // Fetch the new work data whenever the sync node, block ID, or node constants // change. This is usually only going to be triggered by the block ID // changing, which is handled by WebsocketService. useEffect(() => { if (!syncNode) return; - fetchWorkOverTime(); - }, [syncNode, lastBlockID, constants, constants.seconds_per_block]); + fetchWorkOverTime(syncNode, constants); + }, [syncNode, lastBlockID, constants, constants.seconds_per_block, fetchWorkOverTime]); function chart(): JSX.Element { return { + debug("fetching transactions"); + + return lookupTransactions( + syncNode, + Object.values(wallets).map(w => w.address), + { includeMined: true, limit: 5, orderBy: "id", order: "DESC" } + ); +}; + export function TransactionsCard(): JSX.Element { const syncNode = useSelector((s: RootState) => s.node.syncNode); const { wallets } = useSelector((s: RootState) => s.wallets, shallowEqual); const { t } = useTranslation(); const [res, setRes] = useState(); - const [loading, setLoading] = useState(true); const [error, setError] = useState(); + const [loading, setLoading] = useState(true); - async function _fetchTransactions(): Promise { - try { - debug("fetching transactions"); - - setRes(await lookupTransactions( - syncNode, - Object.values(wallets).map(w => w.address), - { includeMined: true, limit: 5, orderBy: "id", order: "DESC" } - )); - } catch (err) { - console.error(err); - setError(err); - } finally { - setLoading(false); - } - }; - - const fetchTransactions = useMemo(() => - throttle(_fetchTransactions, 300, { leading: false, trailing: true }), []); + const fetchTxs = useMemo(() => trailingThrottleState(_fetchTransactions, TRANSACTION_THROTTLE, true, setRes, setError, setLoading), []); useEffect(() => { if (!syncNode || !wallets) return; - fetchTransactions(); - }, [syncNode, wallets]); + fetchTxs(syncNode, wallets); + }, [syncNode, wallets, fetchTxs]); const walletAddressMap = Object.values(wallets) .reduce((o, wallet) => ({ ...o, [wallet.address]: wallet }), {}); diff --git a/src/pages/settings/SettingsTranslations.tsx b/src/pages/settings/SettingsTranslations.tsx index 89ebe7b..da68e4c 100644 --- a/src/pages/settings/SettingsTranslations.tsx +++ b/src/pages/settings/SettingsTranslations.tsx @@ -108,11 +108,6 @@ } | undefined>(); const languages = getLanguages(); - if (!languages) return ; async function loadLanguages() { if (!languages) return; @@ -154,6 +149,12 @@ useMountEffect(() => { loadLanguages().catch(console.error); }); + if (!languages) return ; + return } onClick={exportCSV}> {t("settings.translations.exportCSV")} diff --git a/src/pages/wallets/AddWalletModal.tsx b/src/pages/wallets/AddWalletModal.tsx index 2059e39..b59b19b 100644 --- a/src/pages/wallets/AddWalletModal.tsx +++ b/src/pages/wallets/AddWalletModal.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2020-2021 Drew Lemmy // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt -import React, { useState, useRef, useEffect } from "react"; +import React, { useState, useRef, useEffect, useCallback } from "react"; import { Modal, Form, Input, Checkbox, Collapse, Button, Tooltip, Typography, Row, Col, message, notification, Grid } from "antd"; import { ReloadOutlined } from "@ant-design/icons"; @@ -13,10 +13,10 @@ import { FakeUsernameInput } from "../../components/auth/FakeUsernameInput"; import { CopyInputButton } from "../../components/CopyInputButton"; -import { getSelectWalletCategory } from "../../components/wallets/SelectWalletCategory"; +import { SelectWalletCategory } from "../../components/wallets/SelectWalletCategory"; import { WalletFormatName, applyWalletFormat, formatNeedsUsername } from "../../krist/wallets/formats/WalletFormat"; -import { getSelectWalletFormat } from "../../components/wallets/SelectWalletFormat"; +import { SelectWalletFormat } from "../../components/wallets/SelectWalletFormat"; import { makeV2Address } from "../../krist/AddressAlgo"; import { addWallet, decryptWallet, editWallet, Wallet, ADDRESS_LIST_LIMIT } from "../../krist/wallets/Wallet"; @@ -149,18 +149,18 @@ } /** Update the 'Wallet address' field */ - async function updateCalculatedAddress(format: WalletFormatName | undefined, password: string, username?: string) { + const updateCalculatedAddress = useCallback(async function(format: WalletFormatName | undefined, password: string, username?: string) { const privatekey = await applyWalletFormat(format || "kristwallet", password, username); const address = await makeV2Address(addressPrefix, privatekey); setCalculatedAddress(address); - } + }, [addressPrefix]); - function generateNewPassword() { + const generateNewPassword = useCallback(function() { if (!create || !form) return; const password = generatePassword(); form.setFieldsValue({ password }); updateCalculatedAddress("kristwallet", password); - } + }, [create, form, updateCalculatedAddress]); useEffect(() => { if (visible && form && !form.getFieldValue("password")) { @@ -182,7 +182,7 @@ })(); } } - }, [visible, form, create, editing]); + }, [t, generateNewPassword, updateCalculatedAddress, masterPassword, visible, form, create, editing]); return - {getSelectWalletCategory({ onNewCategory: category => form.setFieldsValue({ category })})} + {SelectWalletCategory({ onNewCategory: category => form.setFieldsValue({ category })})} @@ -327,7 +327,7 @@ {/* Wallet format */} - {getSelectWalletFormat({ initialFormat })} + {SelectWalletFormat({ initialFormat })} {/* Save in KristWeb checkbox */} diff --git a/src/utils/index.ts b/src/utils/index.ts index 4a045a5..a1d3f38 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -85,4 +85,5 @@ } }; +// eslint-disable-next-line react-hooks/exhaustive-deps export const useMountEffect = (fn: EffectCallback): void => useEffect(fn, []); diff --git a/src/utils/promiseThrottle.ts b/src/utils/promiseThrottle.ts new file mode 100644 index 0000000..72392a4 --- /dev/null +++ b/src/utils/promiseThrottle.ts @@ -0,0 +1,57 @@ +import { Dispatch, SetStateAction } from "react"; +import { throttle as lodashThrottle } from "lodash-es"; + +/** + * Based on lodash _.throttle, returns a throttled Promise. + * + * The original function, `F`, must return a Promise. The throttled function's + * first parameter is a callback function, `cb`, which will be invoked with the + * promise returned by `F` when it is actually called. The remaining arguments + * will be passed directly to `F`. + * + * @param fn - The function to be throttled. + * @param timeout - The timeout of the throttle, in milliseconds. + * @param trailing - Whether or not to throttle on the trailing edge of the + * timeout. + */ +export function throttle P, P extends Promise>(fn: F, timeout: number, trailing?: boolean): (cb: (res: P) => void, ...args: Parameters) => void { + return lodashThrottle((cb: (res: P) => void, ...args: Parameters) => { + cb(fn(...args)); + }, timeout, { leading: !trailing, trailing }); +} + +/** + * Based on lodash _.throttle, returns a function throttled on its trailing + * edge. + * + * The original function, `F`, must return a Promise. The throttled function's + * arguments will be passed to `F` if/when it is invoked. The parameters + * `setResult`, `setError`, and `setLoading` can be provided to dispatch React + * state changes based on the fulfillment of the Promise returned by `F`. + * + * @param fn - The function to be throttled. + * @param timeout - The timeout of the throttle, in milliseconds. + * @param trailing - Whether or not to throttle on the trailing edge of the + * timeout. + * @param setResult - React setState hook to call if the original function's + * Promise resolves. + * @param setError - React setState hook to call if the original function's + * Promise fails. + * @param setLoading - React setState hook to call when the original function's + * Promise settles. + */ +export function trailingThrottleState P, P extends Promise, R>( + fn: F, + timeout: number, + trailing?: boolean, + setResult?: Dispatch>, + setError?: Dispatch>, + setLoading?: Dispatch> +): (...args: Parameters) => void { + return lodashThrottle((...args: Parameters) => { + fn(...args) + .then(r => { if (setResult) setResult(r); }) + .catch(err => { if (setError) setError(err); }) + .finally(() => { if (setLoading) setLoading(false); }); + }, timeout, { leading: !trailing, trailing }); +}