From a3be6fe486a80326b4c24f2b939eaf51fc0c60b0 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 6 Dec 2024 11:00:41 +0200 Subject: [PATCH] TW-1479: [EVM] Transactions history with Alchemy (#1231) * TW-1479: [EVM] Transactions history * TW-1479: [EVM] Transactions history. Refactor * TW-1479: [EVM] Transactions history. ++ Parsing * TW-1479: [EVM] Transaction history. + Extra icon SRC from API * TW-1479: [EVM] Transaction history. Refactor. Assets Images Components * TW-1479: [EVM] Transactions history. Minor fixes * TW-1479: [EVM] Transaction history. + Gas operation * TW-1479: [EVM] Transactions history. [Tezos] Formatting activities. [EVM] Applying metadata. * TW-1479: [EVM] Transactions history. [EVM] + InfiniteScroll * TW-1479: [EVM] Transaction history. Token page * TW-1479: [EVM] Transaction history. API calls. Refactor. With nextPage * TW-1479: [EVM] Transaction history. Token page. Using endpoint V2 * TW-1479: [EVM] Transactions history. [Tezos] Fix 'receive' operations * TW-1479: [EVM] Transactions history. [Tezos] Refactor * TW-1479: [EVM] Transactions history. [Tezos] Clean-up * TW-1479: [EVM] Transactions history. [Tezos] -- lib/temple/activity * TW-1479: [EVM] Transactions history. [Tezos] + Approve * TW-1479: [EVM] Transactions history. + Kind 'Transfer' * TW-1479: [EVM] Transactions history. [Tezos] Restored opers order. Reversed back * TW-1479: [EVM] Transactions history. Fix InfiniteScroll * TW-1479: [EVM] Transactions history. [EVM] + TransferSingle support * TW-1479: [EVM] Transactions history. Refactor. OperationBaseComponent * TW-1479: [EVM] Transactions history. [Tezos] + Bundles * TW-1479: [EVM] Transactions history. [Tezos] Token page. -- Asset icons * TW-1479: [EVM] Transactions history. [EVM] + Bundles * TW-1479: [EVM] Transactions history. Explore Tab -> Page * TW-1479: [EVM] Transactions history. Filters. By network. WIP * TW-1479: [EVM] Transactions history. Multichain. WIP: For EVM * TW-1479: [EVM] Transactions history. Multichain. EVM. Optimization: loops reduction * TW-1479: [EVM] Transactions history. Multichain. + AbortSignal * TW-1479: [EVM] Transactions history. Multichain. + useActivitiesLoadingLogic() * TW-1479: [EVM] Transactions history. InfiniteScroll. Fix loader show-up * TW-1479: [EVM] Transactions history. Multichain. Refactor * TW-1479: [EVM] Transactions history. Approve kind. + Unlimited * TW-1479: [EVM] Transactions history. Amounts crypto & fiat * TW-1479: [EVM] Transactions history. Filters. WIP * TW-1479: [EVM] Transactions history. Refactor * TW-1479: [EVM] Transactions history. Refactor * TW-1479: [EVM] Transactions history. Refactor * TW-1479: [EVM] Transactions history. Filters. Networks dropdown * TW-1479: [EVM] Transactions history. Refactor NetworkIcon tooltips * TW-1479: [EVM] Transactions history. Bundle modal. ++ * TW-1479: [EVM] Transactions history. Multichain list. + Ad * TW-1479: [EVM] Transactions history. + @covalenthq/client-sdk * TW-1479: [EVM] Transactions history. Refactor * TW-1479: [EVM] Transactions history. + Grouping by Date * TW-1479: [EVM] Transactions history. Fix amountSigned. + Green positive amounts * TW-1479: [EVM] Transactions history. + Bundle icons stack. + NFTs rounding * TW-1479: [EVM] Transactions history. ++ Asset Images * TW-1479: [EVM] Transactions history. Fix pipeline * TW-1479: [EVM] Transactions history. Trigger pipeline * TW-1479: [EVM] Transactions history. Trigger pipeline * TW-1479: [EVM] Transactions history. Minor design fixes * TW-1479: [EVM] Transactions history. + Activity status tag elem * TW-1479: [EVM] Transactions history. + Address chips in Bundle modal * TW-1479: [EVM] Transactions history. Refactor. + Transfer types * TW-1479: [EVM] Transactions history. Refactor. Transfer types * TW-1479: [EVM] Transactions history. Minor fixes * TW-1479: [EVM] Transactions history. Multichain. + Fallback icons * TW-1479: [EVM] Transactions history. + New loader * TW-1479: [EVM] Transactions history. Multichain. -- Filters by kind * TW-1479: [EVM] Transactions history. Fix merge * TW-1479: [EVM] Transactions history. Refactor * TW-1479: [EVM] Transactions history. Refactor * TW-1479: [EVM] Transactions history. Multichain. Refactor * TW-1479: [EVM] Transactions history. Refactor * TW-1479: [EVM] Transactions history. Refactor * Fix audit-deps * TW-1479: [EVM] Transactions history. Minor fixes * TW-1479: [EVM] Transactions history. Reduced number of checksumming addresses while parsing * TW-1479: [EVM] Transactions history. Eliminated checksumming addresses while parsing * TW-1479: [EVM] Transactions history. Bundle modal. + Lazy render * TW-1479: [EVM] Transactions history. Minor refactor * fix after merge * TW-1587: [research] Activity History on Alchemy. Transfers * TW-1587: [research] Activity History on Alchemy. Multichain * TW-1587: [research] Activity History on Alchemy. + Approvals * TW-1479: [EVM] Transactions history with Alchemy. -- Covalent SDK * TW-1479: [EVM] Transactions history with Alchemy. Merging from/to transfers correctly * TW-1479: [EVM] Transactions history with Alchemy. ++ Merging transfers * TW-1479: [EVM] Transactions history with Alchemy. + Sort by log index * TW-1587: [research] Activity History on Alchemy. + Block Explorer URLs * TW-1479: [EVM] Transactions history with Alchemy * TW-1479: [EVM] Transactions history with Alchemy. Removed 'alchemy-sdk' * TW-1479: [EVM] Transactions history with Alchemy. Return Optimism to def networks * TW-1479: [EVM] Transactions history with Alchemy. Minor fixes * TW-1479: [EVM] Transactions history with Alchemy. Transfer -> Send/Receive * TW-1479: [EVM] Transactions history with Alchemy. Refactor * fix pipeline * TW-1479: [EVM] Transactions history with Alchemy. Fix Firefox lag --- package.json | 2 +- public/_locales/de/messages.json | 4 - public/_locales/en/messages.json | 1 + public/_locales/uk/messages.json | 3 + src/app/PageRouter.tsx | 2 + src/app/a11y/ContentFader/index.tsx | 17 -- src/app/a11y/ContentFader/styles.module.css | 17 -- src/app/a11y/content-fader/index.tsx | 5 + src/app/a11y/content-fader/styles.module.css | 3 + src/app/atoms/AccountName.tsx | 1 - src/app/atoms/ActionListItem.tsx | 61 ++-- src/app/atoms/Anchor.tsx | 4 +- src/app/atoms/Avatar.tsx | 4 +- src/app/atoms/Identicon.tsx | 40 ++- src/app/atoms/InfiniteScroll.tsx | 82 +++++ src/app/atoms/Loader.tsx | 30 -- src/app/atoms/Loader/index.tsx | 2 - src/app/atoms/LoaderDebounce.tsx | 21 ++ src/app/atoms/ModalInfoBlock.tsx | 2 +- src/app/atoms/Money.tsx | 16 +- src/app/atoms/NetworkLogo.tsx | 154 ++++++---- src/app/atoms/NetworkSelectButton.tsx | 4 +- .../atoms/PageModal/actions-buttons-box.tsx | 39 +-- src/app/atoms/PageModal/index.tsx | 20 +- src/app/atoms/ScrollView.tsx | 7 +- src/app/atoms/SettingsCell.tsx | 6 +- src/app/atoms/SimpleInfiniteScroll.tsx | 7 +- src/app/atoms/SimpleSegmentControl.tsx | 2 +- src/app/atoms/StyledButton.tsx | 17 +- src/app/atoms/TextButton.tsx | 26 +- src/app/atoms/ToggleSwitch.tsx | 2 +- src/app/defaults.ts | 2 - src/app/hooks/ads/use-ad-timeout.ts | 2 +- src/app/icons/base/documents.svg | 6 +- src/app/icons/base/ok.svg | 6 + src/app/icons/base/outLink.svg | 5 +- src/app/icons/collectible-placeholder.svg | 2 +- src/app/icons/loader.svg | 3 +- src/app/icons/monochrome/signal-alt.svg | 11 - .../backup-options-modal.tsx | 2 +- src/app/layouts/PageLayout/index.tsx | 34 +-- src/app/layouts/PageLayout/utils.ts | 0 src/app/layouts/containers.tsx | 2 +- src/app/pages/AccountSettings/index.tsx | 18 +- src/app/pages/Activity/index.tsx | 72 +++++ src/app/pages/ChainSettings/index.tsx | 6 +- .../manage-url-entities-view/edit-modal.tsx | 6 +- .../manage-url-entities-view/index.tsx | 7 +- .../manage-url-entities-view/item.tsx | 6 +- .../ChainSettings/use-chain-operations.ts | 5 +- .../CollectiblePage/CollectiblePageImage.tsx | 4 +- .../Collectibles/CollectiblePage/index.tsx | 2 +- .../components/CollectibleItem.tsx | 76 ++--- .../components/CollectibleItemImage.tsx | 57 ++-- .../components/evm-meta-loading.ts | 2 +- .../components/AudioCollectible.tsx | 4 +- src/app/pages/Home/ActionButtonsBar.tsx | 7 +- src/app/pages/Home/Home.tsx | 3 - .../Home/OtherComponents/AssetBanner.tsx | 6 +- .../pages/Home/OtherComponents/AssetTab.tsx | 58 ++-- .../components/AddTokenModal/AddTokenForm.tsx | 7 +- .../AddTokenModal/SelectNetworkPage.tsx | 23 +- .../Tokens/components/TokensTabBase.tsx | 2 +- src/app/pages/Market/BuyPageOption.tsx | 2 +- .../Send/modals/ConfirmSend/tabs/Details.tsx | 4 +- .../pages/Send/modals/SelectAsset/index.tsx | 9 +- src/app/pages/Settings/Settings.tsx | 6 +- src/app/pages/Unlock/Unlock.tsx | 2 +- .../planets-animation/evm-planet-item.tsx | 5 +- .../pages/Unlock/planets-animation/index.tsx | 16 +- src/app/root-hooks/evm/balances-loading.ts | 2 +- .../evm/tokens-exchange-rates-loading.ts | 2 +- .../root-hooks/evm/tokens-metadata-loading.ts | 2 +- src/app/store/assets-filter-options/state.ts | 14 +- .../evm/collectibles-metadata/selectors.ts | 5 + .../store/evm/tokens-metadata/selectors.ts | 3 + src/app/templates/About/About.tsx | 4 +- src/app/templates/About/links-group-item.tsx | 6 +- src/app/templates/AssetIcon.tsx | 226 +++++--------- src/app/templates/AssetImage.tsx | 80 ----- .../AssetImage/AssetImageStacked.tsx | 62 ++++ src/app/templates/AssetImage/index.tsx | 60 ++++ src/app/templates/ChainSelect/ChainButton.tsx | 49 --- .../templates/ChainSelect/ChainsDropdown.tsx | 122 -------- src/app/templates/ChainSelect/controller.ts | 20 -- src/app/templates/ChainSelect/index.tsx | 91 ------ .../templates/ChainSelect/style.module.css | 11 - src/app/templates/DAppConnection/index.tsx | 7 +- src/app/templates/InFiat.tsx | 54 ++-- src/app/templates/NetworkSelectModal.tsx | 97 ++++-- .../add-network-modal/name-input.tsx | 2 +- .../chains-group-view/item.tsx | 10 +- src/app/templates/NetworksSettings/index.tsx | 4 +- .../PaymentProviderTag.tsx | 2 +- src/app/templates/SearchField.tsx | 8 +- .../SwapForm/SwapFormInput/SwapFormInput.tsx | 2 +- .../SwapForm/SwapRoute/lb-pool-part.tsx | 4 +- src/app/templates/activity/Activity.tsx | 152 ---------- src/app/templates/activity/ActivityItem.tsx | 109 ------- .../ActivityOperationBase/index.tsx | 227 ++++++++++++++ .../ActivityOperationBase/nft.svg | 18 ++ .../ActivityOperationBase/token.svg | 11 + .../ActivityOperationBase/utils.tsx | 124 ++++++++ .../activity/ActivityItem/AddressChip.tsx | 52 ++++ .../activity/ActivityItem/BundleModal.tsx | 46 +++ .../activity/ActivityItem/EvmActivity.tsx | 160 ++++++++++ .../ActivityItem/EvmActivityOperation.tsx | 67 +++++ .../activity/ActivityItem/TezosActivity.tsx | 130 ++++++++ .../ActivityItem/TezosActivityOperation.tsx | 72 +++++ .../templates/activity/ActivityItem/index.ts | 2 + .../activity/ActivityItem/pending-spin.svg | 10 + .../activity/ActivityListContainer.tsx | 43 +++ .../templates/activity/ActivityListView.tsx | 38 +++ .../templates/activity/EvmActivityList.tsx | 101 +++++++ src/app/templates/activity/MoneyDiffView.tsx | 50 ---- src/app/templates/activity/MultichainList.tsx | 236 +++++++++++++++ src/app/templates/activity/OperStack.tsx | 53 ---- src/app/templates/activity/OperStackItem.tsx | 105 ------- .../templates/activity/TezosActivityList.tsx | 114 +++++++ .../templates/activity/grouping-by-date.tsx | 40 +++ src/app/templates/activity/index.ts | 4 + src/app/templates/activity/loading-logic.ts | 50 ++++ src/app/templates/activity/utils.ts | 40 +++ src/app/templates/enabling-setting.tsx | 11 +- .../hypelab-image-promotion.tsx | 2 +- .../components/text-promotion-view.tsx | 2 +- .../templates/partners-promotion/index.tsx | 248 ++++++++-------- src/app/templates/select-with-modal/index.tsx | 6 +- .../select-with-modal/select-modal-option.tsx | 6 +- .../select-with-modal/select-modal.tsx | 2 +- src/lib/activity/evm/fetch.ts | 55 ++++ src/lib/activity/evm/parse.ts | 228 ++++++++++++++ src/lib/activity/index.ts | 3 + .../activity-new => activity/tezos}/fetch.ts | 41 ++- src/lib/activity/tezos/index.ts | 119 ++++++++ src/lib/activity/tezos/pre-parse.ts | 279 ++++++++++++++++++ src/lib/activity/tezos/types.ts | 52 ++++ src/lib/activity/types.ts | 116 ++++++++ src/lib/activity/utils.ts | 5 + src/lib/apis/temple/endpoints/evm/alchemy.ts | 109 +++++++ .../temple/endpoints/evm/api.interfaces.ts | 2 +- .../apis/temple/endpoints/evm/api.utils.ts | 6 +- .../temple/endpoints/evm/{api.ts => index.ts} | 38 ++- src/lib/apis/tzkt/index.ts | 1 - src/lib/apis/tzkt/types.ts | 2 +- src/lib/apis/tzkt/utils.ts | 95 ++++-- src/lib/assets/utils.ts | 10 +- src/lib/i18n/core.ts | 2 +- src/lib/i18n/index.ts | 2 +- src/lib/{temple/front => }/identicon.ts | 59 ++-- src/lib/images-uri.ts | 4 +- src/lib/metadata/index.ts | 68 ++++- src/lib/metadata/utils.ts | 15 +- src/lib/temple/activity-new/helpers.ts | 65 ---- src/lib/temple/activity-new/hook.ts | 77 ----- src/lib/temple/activity-new/index.ts | 3 - src/lib/temple/activity-new/types.ts | 95 ------ src/lib/temple/activity-new/utils.ts | 219 -------------- src/lib/temple/front/index.ts | 2 - src/lib/temple/front/storage.ts | 19 +- src/lib/ui/ImageStacked.tsx | 10 +- src/lib/ui/filter-networks-by-name.ts | 11 - src/lib/ui/hooks/index.ts | 4 +- ...{use-abort-signal.ts => useAbortSignal.ts} | 0 src/lib/ui/hooks/useStopper.ts | 17 -- src/lib/ui/hooks/useTimeout.ts | 11 +- src/lib/ui/search-networks.ts | 18 ++ .../ui/use-styled-button-or-link-props.tsx | 16 +- src/temple/front/block-explorers.ts | 82 +++-- src/temple/front/chains-specs.ts | 11 +- src/temple/front/chains.ts | 20 +- src/temple/front/index.ts | 2 +- src/temple/front/ready/index.ts | 21 +- src/temple/networks.ts | 2 +- tailwind.config.js | 11 +- yarn.lock | 39 ++- 176 files changed, 4110 insertions(+), 2327 deletions(-) delete mode 100644 src/app/a11y/ContentFader/index.tsx delete mode 100644 src/app/a11y/ContentFader/styles.module.css create mode 100644 src/app/a11y/content-fader/index.tsx create mode 100644 src/app/a11y/content-fader/styles.module.css create mode 100644 src/app/atoms/InfiniteScroll.tsx delete mode 100644 src/app/atoms/Loader.tsx create mode 100644 src/app/atoms/LoaderDebounce.tsx create mode 100644 src/app/icons/base/ok.svg delete mode 100644 src/app/icons/monochrome/signal-alt.svg delete mode 100644 src/app/layouts/PageLayout/utils.ts create mode 100644 src/app/pages/Activity/index.tsx delete mode 100644 src/app/templates/AssetImage.tsx create mode 100644 src/app/templates/AssetImage/AssetImageStacked.tsx create mode 100644 src/app/templates/AssetImage/index.tsx delete mode 100644 src/app/templates/ChainSelect/ChainButton.tsx delete mode 100644 src/app/templates/ChainSelect/ChainsDropdown.tsx delete mode 100644 src/app/templates/ChainSelect/controller.ts delete mode 100644 src/app/templates/ChainSelect/index.tsx delete mode 100644 src/app/templates/ChainSelect/style.module.css delete mode 100644 src/app/templates/activity/Activity.tsx delete mode 100644 src/app/templates/activity/ActivityItem.tsx create mode 100644 src/app/templates/activity/ActivityItem/ActivityOperationBase/index.tsx create mode 100644 src/app/templates/activity/ActivityItem/ActivityOperationBase/nft.svg create mode 100644 src/app/templates/activity/ActivityItem/ActivityOperationBase/token.svg create mode 100644 src/app/templates/activity/ActivityItem/ActivityOperationBase/utils.tsx create mode 100644 src/app/templates/activity/ActivityItem/AddressChip.tsx create mode 100644 src/app/templates/activity/ActivityItem/BundleModal.tsx create mode 100644 src/app/templates/activity/ActivityItem/EvmActivity.tsx create mode 100644 src/app/templates/activity/ActivityItem/EvmActivityOperation.tsx create mode 100644 src/app/templates/activity/ActivityItem/TezosActivity.tsx create mode 100644 src/app/templates/activity/ActivityItem/TezosActivityOperation.tsx create mode 100644 src/app/templates/activity/ActivityItem/index.ts create mode 100644 src/app/templates/activity/ActivityItem/pending-spin.svg create mode 100644 src/app/templates/activity/ActivityListContainer.tsx create mode 100644 src/app/templates/activity/ActivityListView.tsx create mode 100644 src/app/templates/activity/EvmActivityList.tsx delete mode 100644 src/app/templates/activity/MoneyDiffView.tsx create mode 100644 src/app/templates/activity/MultichainList.tsx delete mode 100644 src/app/templates/activity/OperStack.tsx delete mode 100644 src/app/templates/activity/OperStackItem.tsx create mode 100644 src/app/templates/activity/TezosActivityList.tsx create mode 100644 src/app/templates/activity/grouping-by-date.tsx create mode 100644 src/app/templates/activity/index.ts create mode 100644 src/app/templates/activity/loading-logic.ts create mode 100644 src/app/templates/activity/utils.ts create mode 100644 src/lib/activity/evm/fetch.ts create mode 100644 src/lib/activity/evm/parse.ts create mode 100644 src/lib/activity/index.ts rename src/lib/{temple/activity-new => activity/tezos}/fetch.ts (89%) create mode 100644 src/lib/activity/tezos/index.ts create mode 100644 src/lib/activity/tezos/pre-parse.ts create mode 100644 src/lib/activity/tezos/types.ts create mode 100644 src/lib/activity/types.ts create mode 100644 src/lib/activity/utils.ts create mode 100644 src/lib/apis/temple/endpoints/evm/alchemy.ts rename src/lib/apis/temple/endpoints/evm/{api.ts => index.ts} (50%) rename src/lib/{temple/front => }/identicon.ts (57%) delete mode 100644 src/lib/temple/activity-new/helpers.ts delete mode 100644 src/lib/temple/activity-new/hook.ts delete mode 100644 src/lib/temple/activity-new/index.ts delete mode 100644 src/lib/temple/activity-new/types.ts delete mode 100644 src/lib/temple/activity-new/utils.ts delete mode 100644 src/lib/ui/filter-networks-by-name.ts rename src/lib/ui/hooks/{use-abort-signal.ts => useAbortSignal.ts} (100%) delete mode 100644 src/lib/ui/hooks/useStopper.ts create mode 100644 src/lib/ui/search-networks.ts diff --git a/package.json b/package.json index e30b4f9cd3..be49127c01 100644 --- a/package.json +++ b/package.json @@ -200,7 +200,7 @@ "ts-prune": "^0.10.3", "typescript": "^5.4.2", "typescript-eslint": "^7", - "use-debounce": "7.0.1", + "use-debounce": "^10", "use-force-update": "1.0.7", "use-onclickoutside": "0.4.1", "util": "0.11.1", diff --git a/public/_locales/de/messages.json b/public/_locales/de/messages.json index 1f1556bb6d..9d0d298830 100644 --- a/public/_locales/de/messages.json +++ b/public/_locales/de/messages.json @@ -454,10 +454,6 @@ "message": "Tezos Mainnet", "description": "Mainnet = main network" }, - "marigoldMainnet": { - "message": "Marigold Mainnet", - "description": "Mainnet = main network" - }, "templeWalletOptions": { "message": "Temple Wallet | Optionen" }, diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index c04a36fffc..e69a1acb9a 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -2469,6 +2469,7 @@ "blockExplorer": { "message": "Block Explorer" }, + "viewOnExplorer": { "message": "View on explorer" }, "viewOnBlockExplorer": { "message": "View on block explorer" }, "specifyTokenId": { "message": "Specify token ID" diff --git a/public/_locales/uk/messages.json b/public/_locales/uk/messages.json index fbb9f62598..3f640eda65 100644 --- a/public/_locales/uk/messages.json +++ b/public/_locales/uk/messages.json @@ -1943,5 +1943,8 @@ }, "zarName": { "message": "Південноафриканський ранд" + }, + "allNetworks": { + "message": "Всі мережі" } } diff --git a/src/app/PageRouter.tsx b/src/app/PageRouter.tsx index 3d182853e0..633d39c4a7 100644 --- a/src/app/PageRouter.tsx +++ b/src/app/PageRouter.tsx @@ -23,6 +23,7 @@ import { useTempleClient } from 'lib/temple/front'; import * as Woozie from 'lib/woozie'; import { TempleChainKind } from 'temple/types'; +import { ActivityPage } from './pages/Activity'; import { ChainSettings } from './pages/ChainSettings'; import { ImportWallet } from './pages/ImportWallet'; import { Market } from './pages/Market'; @@ -75,6 +76,7 @@ const ROUTE_MAP = Woozie.createMap([ )) ], + ['/activity', onlyReady(() => )], ['/connect-ledger', onlyReady(onlyInFullPage(() => ))], ['/receive', onlyReady(() => )], [ diff --git a/src/app/a11y/ContentFader/index.tsx b/src/app/a11y/ContentFader/index.tsx deleted file mode 100644 index 55b9917b68..0000000000 --- a/src/app/a11y/ContentFader/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; - -import clsx from 'clsx'; - -import { useTestnetModeEnabledSelector } from 'app/store/settings/selectors'; - -import ModStyles from './styles.module.css'; - -export const ACTIVATE_CONTENT_FADER_CLASSNAME = ModStyles.fadeContent; - -export const ContentFader = () => { - const testnetModeEnabled = useTestnetModeEnabledSelector(); - - return ( -
- ); -}; diff --git a/src/app/a11y/ContentFader/styles.module.css b/src/app/a11y/ContentFader/styles.module.css deleted file mode 100644 index 1a6818db28..0000000000 --- a/src/app/a11y/ContentFader/styles.module.css +++ /dev/null @@ -1,17 +0,0 @@ -.contentFader { - position: absolute; - inset: 0; - - border-radius: inherit; - - /* Since it always hangs over content, we make it pervious */ - pointer-events: none; - - background-color: black; - opacity: 0; - transition: opacity 200ms ease-in-out; -} - -.fadeContent .contentFader { - opacity: 0.15; -} diff --git a/src/app/a11y/content-fader/index.tsx b/src/app/a11y/content-fader/index.tsx new file mode 100644 index 0000000000..3db76fd327 --- /dev/null +++ b/src/app/a11y/content-fader/index.tsx @@ -0,0 +1,5 @@ +import ModStyles from './styles.module.css'; + +export const ACTIVATE_CONTENT_FADER_CLASSNAME = ModStyles.activateContentFader; + +export const FADABLE_CONTENT_CLASSNAME = ModStyles.fadeableContent; diff --git a/src/app/a11y/content-fader/styles.module.css b/src/app/a11y/content-fader/styles.module.css new file mode 100644 index 0000000000..5f0e4541a7 --- /dev/null +++ b/src/app/a11y/content-fader/styles.module.css @@ -0,0 +1,3 @@ +.activateContentFader .fadeableContent { + filter: brightness(0.85); +} diff --git a/src/app/atoms/AccountName.tsx b/src/app/atoms/AccountName.tsx index 8130654c22..ef80f05451 100644 --- a/src/app/atoms/AccountName.tsx +++ b/src/app/atoms/AccountName.tsx @@ -90,7 +90,6 @@ interface CopyAddressButtonProps { const CopyAddressButton = memo(({ chain, address, onCopy }) => ( { window.navigator.clipboard.writeText(address); onCopy(); diff --git a/src/app/atoms/ActionListItem.tsx b/src/app/atoms/ActionListItem.tsx index ca9f79902d..afeaea4f28 100644 --- a/src/app/atoms/ActionListItem.tsx +++ b/src/app/atoms/ActionListItem.tsx @@ -1,4 +1,4 @@ -import React, { memo } from 'react'; +import React, { FC } from 'react'; import clsx from 'clsx'; @@ -14,33 +14,42 @@ export interface ActionListItemProps extends PropsWithChildren { /** Pass it, if you want it to be called with `false` on click too */ setOpened?: SyncFn; testID?: string; + active?: boolean; danger?: boolean; } -export const ActionListItem = memo( - ({ Icon, linkTo, className, onClick, setOpened, testID, danger, children }) => { - const baseProps = { - testID, - className: clsx( - 'flex items-center py-1.5 px-2 gap-x-1 rounded-md text-font-description', - 'hover:bg-secondary-low', - className - ), - onClick: setOpened - ? () => { - setOpened(false); - onClick?.(); - } - : onClick, - children: ( - <> - {Icon && } +export const ActionListItem: FC = ({ + Icon, + linkTo, + className, + onClick, + setOpened, + testID, + active, + danger, + children +}) => { + const baseProps = { + testID, + className: clsx( + 'flex items-center py-1.5 px-2 gap-x-1 rounded-md text-font-description', + active ? 'bg-grey-4' : danger ? 'hover:bg-error-low' : 'hover:bg-secondary-low', + className + ), + onClick: setOpened + ? () => { + setOpened(false); + onClick?.(); + } + : onClick, + children: ( + <> + {Icon && } - {typeof children === 'string' ? {children} : children} - - ) - }; + {typeof children === 'string' ? {children} : children} + + ) + }; - return linkTo ? : ); diff --git a/src/app/atoms/ToggleSwitch.tsx b/src/app/atoms/ToggleSwitch.tsx index 279829d29f..b1c293dff3 100644 --- a/src/app/atoms/ToggleSwitch.tsx +++ b/src/app/atoms/ToggleSwitch.tsx @@ -38,7 +38,7 @@ export const ToggleSwitch = forwardRef((props, ref) => () => clsx( 'absolute h-full shadow-drop duration-300 ease-out', - small ? 'w-4 rounded-1.25' : 'w-6 rounded-md', + small ? 'w-4 rounded-5' : 'w-6 rounded-md', disabled ? 'bg-lines' : 'bg-white', (() => { if (localChecked) return small ? 'left-3.5 right-0' : 'left-5 right-0'; diff --git a/src/app/defaults.ts b/src/app/defaults.ts index 51b21e1605..abcad43ce6 100644 --- a/src/app/defaults.ts +++ b/src/app/defaults.ts @@ -1,8 +1,6 @@ import { t } from 'lib/i18n'; import { TempleAccountType } from 'lib/temple/types'; -export const OP_STACK_PREVIEW_SIZE = 2; - export class ArtificialError extends Error {} export class NotEnoughFundsError extends ArtificialError {} export class ZeroBalanceError extends NotEnoughFundsError {} diff --git a/src/app/hooks/ads/use-ad-timeout.ts b/src/app/hooks/ads/use-ad-timeout.ts index 37f499ed06..a31386ceda 100644 --- a/src/app/hooks/ads/use-ad-timeout.ts +++ b/src/app/hooks/ads/use-ad-timeout.ts @@ -1,5 +1,5 @@ import { useTimeout } from 'lib/ui/hooks'; export const useAdTimeout = (adIsReady: boolean, onTimeout: () => void, timeMs = 10000) => { - useTimeout(onTimeout, timeMs, !adIsReady); + useTimeout(onTimeout, timeMs, !adIsReady, [onTimeout]); }; diff --git a/src/app/icons/base/documents.svg b/src/app/icons/base/documents.svg index f6cca48335..afa80defbe 100644 --- a/src/app/icons/base/documents.svg +++ b/src/app/icons/base/documents.svg @@ -1,3 +1,5 @@ - - + + diff --git a/src/app/icons/base/ok.svg b/src/app/icons/base/ok.svg new file mode 100644 index 0000000000..4036a9e2d0 --- /dev/null +++ b/src/app/icons/base/ok.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/app/icons/base/outLink.svg b/src/app/icons/base/outLink.svg index d170d2b42e..ac671662a3 100644 --- a/src/app/icons/base/outLink.svg +++ b/src/app/icons/base/outLink.svg @@ -1,6 +1,5 @@ - + + stroke="none" /> diff --git a/src/app/icons/collectible-placeholder.svg b/src/app/icons/collectible-placeholder.svg index 7040272efc..3a364133c8 100644 --- a/src/app/icons/collectible-placeholder.svg +++ b/src/app/icons/collectible-placeholder.svg @@ -1,4 +1,4 @@ - + diff --git a/src/app/icons/loader.svg b/src/app/icons/loader.svg index 68bdf29b06..29745d3290 100644 --- a/src/app/icons/loader.svg +++ b/src/app/icons/loader.svg @@ -1,10 +1,11 @@ - + + - - - - - - diff --git a/src/app/layouts/PageLayout/BackupMnemonicOverlay/backup-options-modal.tsx b/src/app/layouts/PageLayout/BackupMnemonicOverlay/backup-options-modal.tsx index 74fb9f190a..ee8c0f4a4b 100644 --- a/src/app/layouts/PageLayout/BackupMnemonicOverlay/backup-options-modal.tsx +++ b/src/app/layouts/PageLayout/BackupMnemonicOverlay/backup-options-modal.tsx @@ -31,7 +31,7 @@ export const BackupOptionsModal = memo(({ onSelect }) = onClick={onSelect} testID={BackupOptionsModalSelectors.manualBackupButton} > - + {t('backupManually')}
diff --git a/src/app/layouts/PageLayout/index.tsx b/src/app/layouts/PageLayout/index.tsx index 506ab4abba..476aac7e45 100644 --- a/src/app/layouts/PageLayout/index.tsx +++ b/src/app/layouts/PageLayout/index.tsx @@ -2,7 +2,7 @@ import React, { ComponentType, FC, memo, ReactNode, useRef } from 'react'; import clsx from 'clsx'; -import { ContentFader } from 'app/a11y/ContentFader'; +import { FADABLE_CONTENT_CLASSNAME } from 'app/a11y/content-fader'; import DocBg from 'app/a11y/DocBg'; import Spinner from 'app/atoms/Spinner/Spinner'; import { SuspenseContainer } from 'app/atoms/SuspenseContainer'; @@ -87,18 +87,22 @@ const PageLayout: FC> = ({ onTopEdgeVisibilityChange={onTopEdgeVisibilityChange} topEdgeThreshold={topEdgeThreshold} > - {Header ?
: {headerChildren}} - -
- {children} + + +
+ {Header ?
: {headerChildren}} + +
+ {children} +
@@ -159,11 +163,7 @@ const ContentPaper: FC = ({ className )} > - - {children} - - ); diff --git a/src/app/layouts/PageLayout/utils.ts b/src/app/layouts/PageLayout/utils.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/app/layouts/containers.tsx b/src/app/layouts/containers.tsx index e067657f70..ef2e6aac55 100644 --- a/src/app/layouts/containers.tsx +++ b/src/app/layouts/containers.tsx @@ -30,7 +30,7 @@ export const ContentContainer = forwardRef(({ id }) => {
- } Component="div"> + } Component="div"> - + - } Component={Button} onClick={openEditNameModal} testID={AccountSettingsSelectors.editName} > - + {(account.type === TempleAccountType.HD || account.type === TempleAccountType.Imported) && ( - } Component={Button} onClick={openRevealPrivateKeyModal} testID={AccountSettingsSelectors.revealPrivateKey} > - + )}
@@ -203,7 +203,7 @@ export const AccountSettings = memo(({ id }) => { {derivationPaths.map(({ chainName, path }) => ( - (({ id }) => { testIDProperties={{ chainName }} > {chainName === 'tezos' ? : } - + ))} diff --git a/src/app/pages/Activity/index.tsx b/src/app/pages/Activity/index.tsx new file mode 100644 index 0000000000..98f4e9c62a --- /dev/null +++ b/src/app/pages/Activity/index.tsx @@ -0,0 +1,72 @@ +import React, { memo, useCallback, useState } from 'react'; + +import { IconBase } from 'app/atoms'; +import { PageModal } from 'app/atoms/PageModal'; +import { ReactComponent as FilterOffIcon } from 'app/icons/base/filteroff.svg'; +import { ReactComponent as FilterOnIcon } from 'app/icons/base/filteron.svg'; +import PageLayout from 'app/layouts/PageLayout'; +import { useAssetsFilterOptionsSelector } from 'app/store/assets-filter-options/selectors'; +import { FilterChain } from 'app/store/assets-filter-options/state'; +import { + ActivityListContainer, + EvmActivityList, + TezosActivityList, + MultichainActivityList +} from 'app/templates/activity'; +import { NetworkSelectModalContent } from 'app/templates/NetworkSelectModal'; +import { useBooleanState } from 'lib/ui/hooks'; +import { OneOfChains } from 'temple/front'; + +export const ActivityPage = memo(() => { + const { filterChain: initFilterChain } = useAssetsFilterOptionsSelector(); + + const [filterChain, setFilterChain] = useState(initFilterChain); + + const [filtersModalOpen, setFiltersModalOpen, setFiltersModalClosed] = useBooleanState(false); + + const handleFilterChainSelect = useCallback( + (chain: OneOfChains | null) => { + setFilterChain(chain); + setFiltersModalClosed(); + }, + [setFiltersModalClosed] + ); + + return ( + + } + > + + {() => ( + + )} + + + {filterChain ? ( + + {filterChain.kind === 'tezos' ? ( + + ) : ( + + )} + + ) : ( + + + + )} + + ); +}); diff --git a/src/app/pages/ChainSettings/index.tsx b/src/app/pages/ChainSettings/index.tsx index 3920f30fd3..c059fc2e29 100644 --- a/src/app/pages/ChainSettings/index.tsx +++ b/src/app/pages/ChainSettings/index.tsx @@ -3,7 +3,7 @@ import React, { memo, useCallback, useEffect, useState } from 'react'; import { ToggleSwitch } from 'app/atoms'; import { ActionModalBodyContainer, ActionModalButton, ActionModalButtonsContainer } from 'app/atoms/action-modal'; import { ActionsButtonsBox } from 'app/atoms/PageModal/actions-buttons-box'; -import { SettingsCell } from 'app/atoms/SettingsCell'; +import { SettingsCellSingle } from 'app/atoms/SettingsCell'; import { SettingsCellGroup } from 'app/atoms/SettingsCellGroup'; import { StyledButton } from 'app/atoms/StyledButton'; import PageLayout from 'app/layouts/PageLayout'; @@ -60,14 +60,14 @@ const ChainExistentSettings = memo(({ chain, bottomE <>
- } Component="div"> + } Component="div"> - + diff --git a/src/app/pages/ChainSettings/manage-url-entities-view/edit-modal.tsx b/src/app/pages/ChainSettings/manage-url-entities-view/edit-modal.tsx index b5cf92eed6..bf38338cb4 100644 --- a/src/app/pages/ChainSettings/manage-url-entities-view/edit-modal.tsx +++ b/src/app/pages/ChainSettings/manage-url-entities-view/edit-modal.tsx @@ -7,7 +7,7 @@ import { ActionModalBodyContainer, ActionModalButton, ActionModalButtonsContaine import { PageModal } from 'app/atoms/PageModal'; import { ActionsButtonsBox } from 'app/atoms/PageModal/actions-buttons-box'; import { ScrollView } from 'app/atoms/PageModal/scroll-view'; -import { SettingsCell } from 'app/atoms/SettingsCell'; +import { SettingsCellSingle } from 'app/atoms/SettingsCell'; import { SettingsCellGroup } from 'app/atoms/SettingsCellGroup'; import { StyledButton } from 'app/atoms/StyledButton'; import { ReactComponent as DeleteIcon } from 'app/icons/base/delete.svg'; @@ -126,14 +126,14 @@ export const EditUrlEntityModal = ({ onBottomEdgeVisibilityChange={setBottomEdgeIsVisible} > - } Component="div"> + } Component="div"> } /> - + ({ return ( <> - {title}} wrapCellName={false} @@ -104,7 +104,8 @@ export const ManageUrlEntitiesView = ({ - + + {items.map(item => ( ({ const url = getEntityUrl(item); return ( - @@ -61,6 +61,6 @@ export const ManageUrlEntitiesItem = ({ )}
- + ); }; diff --git a/src/app/pages/ChainSettings/use-chain-operations.ts b/src/app/pages/ChainSettings/use-chain-operations.ts index cb17cbc087..6727f4d9aa 100644 --- a/src/app/pages/ChainSettings/use-chain-operations.ts +++ b/src/app/pages/ChainSettings/use-chain-operations.ts @@ -20,7 +20,8 @@ export const useChainOperations = (chainKind: TempleChainKind, chainId: string | const evmChains = useAllEvmChains(); const tezChains = useAllTezosChains(); const chain: OneOfChains = evmChains[chainId] ?? tezChains[chainId]; - const [, setChainSpecs, removeChainSpecs] = useChainSpecs(chainKind, chainId); + const [setChainSpecs, removeChainSpecs] = useChainSpecs(chainKind, chainId); + const { addEvmNetwork, addTezosNetwork, @@ -29,8 +30,10 @@ export const useChainOperations = (chainKind: TempleChainKind, chainId: string | removeEvmNetworks, removeTezosNetworks } = useTempleNetworksActions(); + const { addBlockExplorer, replaceBlockExplorer, removeBlockExplorer, removeAllBlockExplorers } = useChainBlockExplorers(chainKind, chainId); + const activeRpcId = chain.rpc.id; const defaultRpcId = chain.allRpcs[0].id; const activeBlockExplorerId = chain.activeBlockExplorer?.id; diff --git a/src/app/pages/Collectibles/CollectiblePage/CollectiblePageImage.tsx b/src/app/pages/Collectibles/CollectiblePage/CollectiblePageImage.tsx index b80aa8d0ce..533f1047a0 100644 --- a/src/app/pages/Collectibles/CollectiblePage/CollectiblePageImage.tsx +++ b/src/app/pages/Collectibles/CollectiblePage/CollectiblePageImage.tsx @@ -2,7 +2,7 @@ import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { Model3DViewer } from 'app/atoms/Model3DViewer'; import { useCollectiblesListOptionsSelector } from 'app/store/assets-filter-options/selectors'; -import { TezosAssetImage } from 'app/templates/AssetImage'; +import { TezosAssetImageStacked } from 'app/templates/AssetImage'; import { isSvgDataUriInUtf8Encoding, buildObjktCollectibleArtifactUri } from 'lib/images-uri'; import { TokenMetadata } from 'lib/metadata'; import { EvmCollectibleMetadata } from 'lib/metadata/types'; @@ -86,7 +86,7 @@ export const TezosCollectiblePageImage = memo( } return ( - } diff --git a/src/app/pages/Collectibles/CollectiblePage/index.tsx b/src/app/pages/Collectibles/CollectiblePage/index.tsx index 8e78d663e2..8c8f51b518 100644 --- a/src/app/pages/Collectibles/CollectiblePage/index.tsx +++ b/src/app/pages/Collectibles/CollectiblePage/index.tsx @@ -290,7 +290,7 @@ const TezosCollectiblePage = memo(({ tezosChainId, as objktArtifactUri={details?.objktArtifactUri} isAdultContent={details?.isAdultContent} mime={details?.mime} - className="h-full w-full" + className="h-full w-full object-contain" /> diff --git a/src/app/pages/Collectibles/CollectiblesTab/components/CollectibleItem.tsx b/src/app/pages/Collectibles/CollectiblesTab/components/CollectibleItem.tsx index 329f3b2424..051a12ec44 100644 --- a/src/app/pages/Collectibles/CollectiblesTab/components/CollectibleItem.tsx +++ b/src/app/pages/Collectibles/CollectiblesTab/components/CollectibleItem.tsx @@ -5,8 +5,7 @@ import clsx from 'clsx'; import { IconBase, ToggleSwitch } from 'app/atoms'; import Money from 'app/atoms/Money'; -import { EvmNetworkLogo, NetworkLogoFallback, TezosNetworkLogo } from 'app/atoms/NetworkLogo'; -import { TezNetworkLogo } from 'app/atoms/NetworksLogos'; +import { EvmNetworkLogo, TezosNetworkLogo } from 'app/atoms/NetworkLogo'; import { ReactComponent as DeleteIcon } from 'app/icons/base/delete.svg'; import { dispatch } from 'app/store'; import { setEvmCollectibleStatusAction } from 'app/store/evm/assets/actions'; @@ -29,9 +28,7 @@ import { T } from 'lib/i18n'; import { getTokenName } from 'lib/metadata'; import { getCollectibleName, getCollectionName } from 'lib/metadata/utils'; import { atomsToTokens } from 'lib/temple/helpers'; -import { TEZOS_MAINNET_CHAIN_ID } from 'lib/temple/types'; import { useBooleanState } from 'lib/ui/hooks'; -import useTippy, { UseTippyOptions } from 'lib/ui/useTippy'; import { Link } from 'lib/woozie'; import { useEvmChainByChainId, useTezosChainByChainId } from 'temple/front/chains'; import { TempleChainKind } from 'temple/types'; @@ -47,6 +44,7 @@ const ImgWithDetailsContainerStyle = { width: 112, height: 152 }; const ImgStyle = { width: 110, height: 110 }; const manageImgStyle = { width: 42, height: 42 }; const DetailsStyle = { width: 112, height: 40 }; +const NETWORK_IMAGE_DEFAULT_SIZE = 16; interface TezosCollectibleItemProps { assetSlug: string; @@ -66,17 +64,6 @@ export const TezosCollectibleItem = memo( const network = useTezosChainByChainId(tezosChainId); - const tippyProps = useMemo( - () => ({ - trigger: 'mouseenter', - hideOnClick: false, - content: network?.name ?? 'Unknown Network', - animation: 'shift-away-subtle', - placement: 'bottom' - }), - [network] - ); - const storedToken = useStoredTezosCollectibleSelector(accountPkh, tezosChainId, assetSlug); const checked = getAssetStatus(balanceAtomic, storedToken?.status) === 'enabled'; @@ -109,8 +96,6 @@ export const TezosCollectibleItem = memo( [checked, assetSlug, tezosChainId, accountPkh] ); - const networkIconRef = useTippy(tippyProps); - const decimals = metadata?.decimals; const imgContainerStyles = useMemo( @@ -164,16 +149,17 @@ export const TezosCollectibleItem = memo( areDetailsLoading={areDetailsLoading && details === undefined} mime={details?.mime} containerElemRef={wrapperElemRef} + className="object-cover" /> {network && ( -
- {network.chainId === TEZOS_MAINNET_CHAIN_ID ? ( - - ) : ( - - )} -
+ )} @@ -221,6 +207,7 @@ export const TezosCollectibleItem = memo( areDetailsLoading={areDetailsLoading && details === undefined} mime={details?.mime} containerElemRef={wrapperElemRef} + className="object-contain" /> {areDetailsShown && balance && ( @@ -230,9 +217,13 @@ export const TezosCollectibleItem = memo( )} {network && ( -
- -
+ )} @@ -317,19 +308,6 @@ export const EvmCollectibleItem = memo( const network = useEvmChainByChainId(evmChainId); - const tippyProps = useMemo( - () => ({ - trigger: 'mouseenter', - hideOnClick: false, - content: network?.name ?? 'Unknown Network', - animation: 'shift-away-subtle', - placement: 'bottom' - }), - [network] - ); - - const networkIconRef = useTippy(tippyProps); - const imgContainerStyles = useMemo( () => (showDetails ? ImgWithDetailsContainerStyle : ImgContainerStyle), [showDetails] @@ -351,14 +329,15 @@ export const EvmCollectibleItem = memo( )} style={manageImgStyle} > - {metadata && } + {metadata && } {network && ( )} @@ -397,7 +376,7 @@ export const EvmCollectibleItem = memo( )} style={ImgStyle} > - {metadata && } + {metadata && } {showDetails && (
@@ -407,10 +386,11 @@ export const EvmCollectibleItem = memo( {network && ( )}
diff --git a/src/app/pages/Collectibles/CollectiblesTab/components/CollectibleItemImage.tsx b/src/app/pages/Collectibles/CollectiblesTab/components/CollectibleItemImage.tsx index d12c504555..fd1a31c4cc 100644 --- a/src/app/pages/Collectibles/CollectiblesTab/components/CollectibleItemImage.tsx +++ b/src/app/pages/Collectibles/CollectiblesTab/components/CollectibleItemImage.tsx @@ -1,6 +1,7 @@ import React, { memo, useMemo } from 'react'; import { isDefined } from '@rnw-community/shared'; +import clsx from 'clsx'; import { useCollectibleIsAdultSelector } from 'app/store/tezos/collectibles/selectors'; import { buildCollectibleImagesStack, buildEvmCollectibleIconSources } from 'lib/images-uri'; @@ -19,48 +20,52 @@ interface Props { areDetailsLoading: boolean; mime?: string | null; containerElemRef: React.RefObject; + className?: string; } -export const CollectibleItemImage = memo(({ assetSlug, metadata, adultBlur, areDetailsLoading, mime }) => { - const isAdultContent = useCollectibleIsAdultSelector(assetSlug); - const isAdultFlagLoading = areDetailsLoading && !isDefined(isAdultContent); - const shouldShowBlur = isAdultContent && adultBlur; +export const CollectibleItemImage = memo( + ({ assetSlug, metadata, adultBlur, areDetailsLoading, mime, className }) => { + const isAdultContent = useCollectibleIsAdultSelector(assetSlug); + const isAdultFlagLoading = areDetailsLoading && !isDefined(isAdultContent); + const shouldShowBlur = isAdultContent && adultBlur; - const sources = useMemo(() => (metadata ? buildCollectibleImagesStack(metadata) : []), [metadata]); + const sources = useMemo(() => (metadata ? buildCollectibleImagesStack(metadata) : []), [metadata]); - const isAudioCollectible = useMemo(() => Boolean(mime && mime.startsWith('audio')), [mime]); + const isAudioCollectible = useMemo(() => Boolean(mime && mime.startsWith('audio')), [mime]); - return ( - <> - {isAdultFlagLoading ? ( - - ) : shouldShowBlur ? ( - - ) : ( - } - fallback={} - /> - )} - - ); -}); + return ( + <> + {isAdultFlagLoading ? ( + + ) : shouldShowBlur ? ( + + ) : ( + } + fallback={} + /> + )} + + ); + } +); interface EvmCollectibleItemImageProps { metadata: EvmCollectibleMetadata; + className?: string; } -export const EvmCollectibleItemImage = memo(({ metadata }) => { +export const EvmCollectibleItemImage = memo(({ metadata, className }) => { const sources = useMemo(() => buildEvmCollectibleIconSources(metadata), [metadata]); return ( } fallback={} /> diff --git a/src/app/pages/Collectibles/CollectiblesTab/components/evm-meta-loading.ts b/src/app/pages/Collectibles/CollectiblesTab/components/evm-meta-loading.ts index ac62f6982d..b4871b4959 100644 --- a/src/app/pages/Collectibles/CollectiblesTab/components/evm-meta-loading.ts +++ b/src/app/pages/Collectibles/CollectiblesTab/components/evm-meta-loading.ts @@ -9,7 +9,7 @@ import { } from 'app/store/evm/collectibles-metadata/actions'; import { useEvmCollectiblesMetadataRecordSelector } from 'app/store/evm/collectibles-metadata/selectors'; import { useEvmCollectiblesMetadataLoadingSelector } from 'app/store/evm/selectors'; -import { getEvmCollectiblesMetadata } from 'lib/apis/temple/endpoints/evm/api'; +import { getEvmCollectiblesMetadata } from 'lib/apis/temple/endpoints/evm'; import { isSupportedChainId } from 'lib/apis/temple/endpoints/evm/api.utils'; import { fetchEvmCollectiblesMetadataFromChain } from 'lib/evm/on-chain/metadata'; import { useEnabledEvmChains } from 'temple/front'; diff --git a/src/app/pages/Collectibles/components/AudioCollectible.tsx b/src/app/pages/Collectibles/components/AudioCollectible.tsx index a6987abbf2..17229dcab3 100644 --- a/src/app/pages/Collectibles/components/AudioCollectible.tsx +++ b/src/app/pages/Collectibles/components/AudioCollectible.tsx @@ -2,7 +2,7 @@ import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; import { emptyFn } from '@rnw-community/shared'; -import { TezosAssetImage } from 'app/templates/AssetImage'; +import { TezosAssetImageStacked } from 'app/templates/AssetImage'; import { AssetMetadataBase } from 'lib/metadata'; import { CollectibleImageFallback } from './CollectibleImageFallback'; @@ -43,7 +43,7 @@ export const AudioCollectible = memo(({ uri, metadata, className, style, loop hidden={!ready} audioPoster={ - } diff --git a/src/app/pages/Home/ActionButtonsBar.tsx b/src/app/pages/Home/ActionButtonsBar.tsx index bb9466c64a..232559974d 100644 --- a/src/app/pages/Home/ActionButtonsBar.tsx +++ b/src/app/pages/Home/ActionButtonsBar.tsx @@ -72,12 +72,7 @@ export const ActionButtonsBar = memo(({ chainKind, chainId, assetSlug testID={HomeSelectors.swapButton} /> - + (props => { switch (tabSlug) { case 'collectibles': return ; - case 'activity': - return ; default: return ; } diff --git a/src/app/pages/Home/OtherComponents/AssetBanner.tsx b/src/app/pages/Home/OtherComponents/AssetBanner.tsx index a877396e30..5e579d1c96 100644 --- a/src/app/pages/Home/OtherComponents/AssetBanner.tsx +++ b/src/app/pages/Home/OtherComponents/AssetBanner.tsx @@ -4,7 +4,7 @@ import Money from 'app/atoms/Money'; import { DeadEndBoundaryError } from 'app/ErrorBoundary'; import { useEvmTokenMetadataSelector } from 'app/store/evm/tokens-metadata/selectors'; import AddressChip from 'app/templates/AddressChip'; -import { TezosAssetIcon, EvmTokenIcon } from 'app/templates/AssetIcon'; +import { EvmAssetIcon, TezosAssetIcon } from 'app/templates/AssetIcon'; import { EvmBalance, TezosBalance } from 'app/templates/Balance'; import InFiat from 'app/templates/InFiat'; import { setAnotherSelector, setTestID } from 'lib/analytics'; @@ -48,7 +48,7 @@ const TezosAssetBanner = memo(({ tezosChainId, assetSlug return ( <>
- +
(({ evmChainId, assetSlug }) => return ( <>
- +
>>(({ chainKin {chainKind === TempleChainKind.Tezos ? ( ) : ( - + )} )); -interface AssetTabProps { +interface TezosAssetTabProps { chainId: string; assetSlug: string; } -const TezosAssetTab = memo(({ chainId, assetSlug }) => +const TezosAssetTab: FC = ({ chainId, assetSlug }) => isTezAsset(assetSlug) ? ( ) : ( - ) -); + ); const TEZOS_GAS_TABS: TabsBarTabInterface[] = [ { name: 'activity', titleI18nKey: 'activity' }, @@ -52,7 +53,9 @@ const TezosGasTab = memo<{ tezosChainId: string }>(({ tezosChainId }) => { {tabName === 'activity' ? ( - + + + ) : ( @@ -62,21 +65,23 @@ const TezosGasTab = memo<{ tezosChainId: string }>(({ tezosChainId }) => { ); }); -const TEZOS_TOKEN_TABS: TabsBarTabInterface[] = [ +const TOKEN_TABS: TabsBarTabInterface[] = [ { name: 'activity', titleI18nKey: 'activity' }, { name: 'info', titleI18nKey: 'info' } ]; -const TezosTokenTab = memo(({ chainId, assetSlug }) => { +const TezosTokenTab = memo(({ chainId, assetSlug }) => { const tabSlug = useLocationSearchParamValue('tab'); const tabName = tabSlug === 'info' ? 'info' : 'activity'; return ( <> - + {tabName === 'activity' ? ( - + + + ) : ( )} @@ -84,23 +89,34 @@ const TezosTokenTab = memo(({ chainId, assetSlug }) => { ); }); -const EVM_TOKEN_TABS: TabsBarTabInterface[] = [ - { name: 'activity', titleI18nKey: 'activity' }, - { name: 'info', titleI18nKey: 'info' } -]; +interface EvmAssetTabProps { + chainId: number; + assetSlug: string; +} + +const EvmAssetTab: FC = ({ chainId, assetSlug }) => + isEvmNativeTokenSlug(assetSlug) ? ( + + + + ) : ( + + ); -const EvmTokenTab = memo(({ chainId, assetSlug }) => { +const EvmTokenTab = memo(({ chainId, assetSlug }) => { const tabSlug = useLocationSearchParamValue('tab'); const tabName = tabSlug === 'info' ? 'info' : 'activity'; return ( <> - + {tabName === 'activity' ? ( -
{UNDER_DEVELOPMENT_MSG}
+ + + ) : ( - + )} ); diff --git a/src/app/pages/Home/OtherComponents/Tokens/components/AddTokenModal/AddTokenForm.tsx b/src/app/pages/Home/OtherComponents/Tokens/components/AddTokenModal/AddTokenForm.tsx index 9d3c438b75..8b2185ee36 100644 --- a/src/app/pages/Home/OtherComponents/Tokens/components/AddTokenModal/AddTokenForm.tsx +++ b/src/app/pages/Home/OtherComponents/Tokens/components/AddTokenModal/AddTokenForm.tsx @@ -36,7 +36,7 @@ import { fetchOneTokenMetadata } from 'lib/metadata/fetch'; import { TokenMetadataNotFoundError } from 'lib/metadata/on-chain'; import { EvmTokenMetadata } from 'lib/metadata/types'; import { loadContract } from 'lib/temple/contract'; -import { useSafeState } from 'lib/ui/hooks'; +import { useSafeState, useUpdatableRef } from 'lib/ui/hooks'; import { navigate } from 'lib/woozie'; import { OneOfChains, useAccountAddressForEvm, useAccountAddressForTezos, useAllTezosChains } from 'temple/front'; import { validateEvmContractAddress } from 'temple/front/evm/helpers'; @@ -206,10 +206,7 @@ export const AddTokenForm = memo( const loadMetadata = useDebouncedCallback(loadMetadataPure, 500); - const loadMetadataRef = useRef(loadMetadata); - useEffect(() => { - loadMetadataRef.current = loadMetadata; - }, [loadMetadata]); + const loadMetadataRef = useUpdatableRef(loadMetadata); useEffect(() => { if (formValid) { diff --git a/src/app/pages/Home/OtherComponents/Tokens/components/AddTokenModal/SelectNetworkPage.tsx b/src/app/pages/Home/OtherComponents/Tokens/components/AddTokenModal/SelectNetworkPage.tsx index 65d7e6d20a..754afd66a7 100644 --- a/src/app/pages/Home/OtherComponents/Tokens/components/AddTokenModal/SelectNetworkPage.tsx +++ b/src/app/pages/Home/OtherComponents/Tokens/components/AddTokenModal/SelectNetworkPage.tsx @@ -7,21 +7,20 @@ import { IconButton } from 'app/atoms/IconButton'; import { ReactComponent as PlusIcon } from 'app/icons/base/plus.svg'; import { Network } from 'app/templates/NetworkSelectModal'; import { SearchBarField } from 'app/templates/SearchField'; +import { searchAndFilterChains } from 'lib/ui/search-networks'; +import { isSearchStringApplicable } from 'lib/utils/search-items'; import { navigate } from 'lib/woozie'; import { - EvmChain, - TezosChain, + OneOfChains, useAccountAddressForEvm, useAccountAddressForTezos, useEnabledEvmChains, useEnabledTezosChains } from 'temple/front'; -type Network = EvmChain | TezosChain; - interface SelectNetworkPageProps { - selectedNetwork: Network; - onNetworkSelect: (network: Network) => void; + selectedNetwork: OneOfChains; + onNetworkSelect: (network: OneOfChains) => void; } export const SelectNetworkPage: FC = ({ selectedNetwork, onNetworkSelect }) => { @@ -41,14 +40,14 @@ export const SelectNetworkPage: FC = ({ selectedNetwork, const filteredNetworks = useMemo( () => - searchValueDebounced.length - ? searchAndFilterNetworksByName(sortedNetworks, searchValueDebounced) + isSearchStringApplicable(searchValueDebounced) + ? searchAndFilterChains(sortedNetworks, searchValueDebounced) : sortedNetworks, [searchValueDebounced, sortedNetworks] ); const handleNetworkSelect = useCallback( - (network: Network | string) => { + (network: OneOfChains | string) => { if (typeof network === 'string') return; onNetworkSelect(network); @@ -81,9 +80,3 @@ export const SelectNetworkPage: FC = ({ selectedNetwork, ); }; - -const searchAndFilterNetworksByName = (networks: T[], searchValue: string) => { - const preparedSearchValue = searchValue.trim().toLowerCase(); - - return networks.filter(network => network.name.toLowerCase().includes(preparedSearchValue)); -}; diff --git a/src/app/pages/Home/OtherComponents/Tokens/components/TokensTabBase.tsx b/src/app/pages/Home/OtherComponents/Tokens/components/TokensTabBase.tsx index b10aff757f..6933d06d45 100644 --- a/src/app/pages/Home/OtherComponents/Tokens/components/TokensTabBase.tsx +++ b/src/app/pages/Home/OtherComponents/Tokens/components/TokensTabBase.tsx @@ -60,7 +60,7 @@ export const TokensTabBase: FC> = ({ {filtersOpened ? ( ) : ( - 0}> + {manageActive ? null : } {tokensCount === 0 ? ( diff --git a/src/app/pages/Market/BuyPageOption.tsx b/src/app/pages/Market/BuyPageOption.tsx index 2b6517fb6c..94e48ba962 100644 --- a/src/app/pages/Market/BuyPageOption.tsx +++ b/src/app/pages/Market/BuyPageOption.tsx @@ -13,7 +13,7 @@ interface BuyPageOptionProps extends TestIDProps { export const BuyPageOption: FC = ({ Icon, title, to, ...testIDProps }) => ( diff --git a/src/app/pages/Send/modals/ConfirmSend/tabs/Details.tsx b/src/app/pages/Send/modals/ConfirmSend/tabs/Details.tsx index 1ea754f95b..37e60fcab6 100644 --- a/src/app/pages/Send/modals/ConfirmSend/tabs/Details.tsx +++ b/src/app/pages/Send/modals/ConfirmSend/tabs/Details.tsx @@ -42,9 +42,9 @@ export const DetailsTab: FC = ({
{network.name} {chainKind === TempleChainKind.EVM ? ( - + ) : ( - + )}
diff --git a/src/app/pages/Send/modals/SelectAsset/index.tsx b/src/app/pages/Send/modals/SelectAsset/index.tsx index 708dd5510c..7d11601821 100644 --- a/src/app/pages/Send/modals/SelectAsset/index.tsx +++ b/src/app/pages/Send/modals/SelectAsset/index.tsx @@ -265,13 +265,10 @@ const FilterOption = memo(({ network, activeNetwork, attractS const Icon = useMemo(() => { if (isAllNetworks) return ; - if (network.kind === TempleChainKind.Tezos) - return ; + if (network.kind === TempleChainKind.Tezos) return ; if (network.kind === TempleChainKind.EVM) - return ( - - ); + return ; return null; }, [isAllNetworks, network, iconSize]); @@ -299,7 +296,7 @@ const FilterOption = memo(({ network, activeNetwork, attractS type SearchNetwork = string | { name: string }; -/** @deprecated // Rely on fuse.js */ +/** @deprecated // Apply searchAndFilterChains() instead */ const searchAndFilterNetworksByName = (networks: T[], searchValue: string) => { const preparedSearchValue = searchValue.trim().toLowerCase(); diff --git a/src/app/pages/Settings/Settings.tsx b/src/app/pages/Settings/Settings.tsx index 6436396984..29b1fb1aa6 100644 --- a/src/app/pages/Settings/Settings.tsx +++ b/src/app/pages/Settings/Settings.tsx @@ -2,7 +2,7 @@ import React, { FC, ReactNode, memo, useMemo, useState, useCallback, MouseEventH import { IconBase } from 'app/atoms'; import { AccountAvatar } from 'app/atoms/AccountAvatar'; -import { SettingsCell } from 'app/atoms/SettingsCell'; +import { SettingsCellSingle } from 'app/atoms/SettingsCell'; import { SettingsCellGroup } from 'app/atoms/SettingsCellGroup'; import { StyledButton } from 'app/atoms/StyledButton'; import { ReactComponent as AdditionalFeaturesIcon } from 'app/icons/base/additional.svg'; @@ -176,7 +176,7 @@ const Settings = memo(({ tabSlug }) => { {TABS_GROUPS.map((tabs, i) => ( {tabs.map(({ slug, titleI18nKey, Icon, testID }, j) => ( - (({ tabSlug }) => { testID={testID} > - + ))} ))} diff --git a/src/app/pages/Unlock/Unlock.tsx b/src/app/pages/Unlock/Unlock.tsx index 15df55c3fb..6d0dbdf3f2 100644 --- a/src/app/pages/Unlock/Unlock.tsx +++ b/src/app/pages/Unlock/Unlock.tsx @@ -188,7 +188,7 @@ const Unlock: FC = ({ canImportNew = true }) => {
{canImportNew && ( - + )} diff --git a/src/app/pages/Unlock/planets-animation/evm-planet-item.tsx b/src/app/pages/Unlock/planets-animation/evm-planet-item.tsx index ecf19e39a1..ee42045839 100644 --- a/src/app/pages/Unlock/planets-animation/evm-planet-item.tsx +++ b/src/app/pages/Unlock/planets-animation/evm-planet-item.tsx @@ -3,7 +3,6 @@ import React, { memo } from 'react'; import { EvmNetworkLogo } from 'app/atoms/NetworkLogo'; interface EvmPlanetItemProps { - name: string; chainId: number; padding?: 'none' | 'small' | 'medium' | 'large'; } @@ -15,6 +14,6 @@ const paddingClassNames = { large: 'p-1.5' }; -export const EvmPlanetItem = memo(({ name, chainId, padding = 'small' }) => ( - +export const EvmPlanetItem = memo(({ chainId, padding = 'small' }) => ( + )); diff --git a/src/app/pages/Unlock/planets-animation/index.tsx b/src/app/pages/Unlock/planets-animation/index.tsx index 3dfcaf51db..65178df7af 100644 --- a/src/app/pages/Unlock/planets-animation/index.tsx +++ b/src/app/pages/Unlock/planets-animation/index.tsx @@ -23,12 +23,12 @@ const orbitsBase = [ { id: 'tezos', radius: 19, - item: + item: }, { id: 'avalanche', radius: 19, - item: + item: } ] }, @@ -40,12 +40,12 @@ const orbitsBase = [ { id: 'bsc', radius: 19, - item: + item: }, { id: 'polygon', radius: 19, - item: + item: } ] }, @@ -57,22 +57,22 @@ const orbitsBase = [ { id: 'eth', radius: 19, - item: + item: }, { id: 'optimism', radius: 19, - item: + item: }, { id: 'arbitrum', radius: 19, - item: + item: }, { id: 'base', radius: 19, - item: + item: } ] }, diff --git a/src/app/root-hooks/evm/balances-loading.ts b/src/app/root-hooks/evm/balances-loading.ts index d54ca1cd30..e5d6e97119 100644 --- a/src/app/root-hooks/evm/balances-loading.ts +++ b/src/app/root-hooks/evm/balances-loading.ts @@ -5,7 +5,7 @@ import { setEvmBalancesLoadingState } from 'app/store/evm/actions'; import { processLoadedEvmAssetsAction } from 'app/store/evm/assets/actions'; import { processLoadedEvmAssetsBalancesAction } from 'app/store/evm/balances/actions'; import { useAllEvmChainsBalancesLoadingStatesSelector } from 'app/store/evm/selectors'; -import { getEvmBalances } from 'lib/apis/temple/endpoints/evm/api'; +import { getEvmBalances } from 'lib/apis/temple/endpoints/evm'; import { isSupportedChainId } from 'lib/apis/temple/endpoints/evm/api.utils'; import { EVM_BALANCES_SYNC_INTERVAL } from 'lib/fixed-times'; import { useInterval, useMemoWithCompare } from 'lib/ui/hooks'; diff --git a/src/app/root-hooks/evm/tokens-exchange-rates-loading.ts b/src/app/root-hooks/evm/tokens-exchange-rates-loading.ts index 2541191609..9bce2b71a0 100644 --- a/src/app/root-hooks/evm/tokens-exchange-rates-loading.ts +++ b/src/app/root-hooks/evm/tokens-exchange-rates-loading.ts @@ -4,7 +4,7 @@ import { dispatch } from 'app/store'; import { setEvmTokensExchangeRatesLoading } from 'app/store/evm/actions'; import { useEvmTokensExchangeRatesLoadingSelector } from 'app/store/evm/selectors'; import { processLoadedEvmExchangeRatesAction } from 'app/store/evm/tokens-exchange-rates/actions'; -import { getEvmTokensMetadata } from 'lib/apis/temple/endpoints/evm/api'; +import { getEvmTokensMetadata } from 'lib/apis/temple/endpoints/evm'; import { isSupportedChainId } from 'lib/apis/temple/endpoints/evm/api.utils'; import { RATES_SYNC_INTERVAL } from 'lib/fixed-times'; import { useInterval, useMemoWithCompare } from 'lib/ui/hooks'; diff --git a/src/app/root-hooks/evm/tokens-metadata-loading.ts b/src/app/root-hooks/evm/tokens-metadata-loading.ts index 6fa630ff10..777571c7df 100644 --- a/src/app/root-hooks/evm/tokens-metadata-loading.ts +++ b/src/app/root-hooks/evm/tokens-metadata-loading.ts @@ -13,7 +13,7 @@ import { } from 'app/store/evm/tokens-metadata/actions'; import { useEvmTokensMetadataRecordSelector } from 'app/store/evm/tokens-metadata/selectors'; import { isValidFetchedEvmMetadata } from 'app/store/evm/tokens-metadata/utils'; -import { getEvmTokensMetadata } from 'lib/apis/temple/endpoints/evm/api'; +import { getEvmTokensMetadata } from 'lib/apis/temple/endpoints/evm'; import { isSupportedChainId } from 'lib/apis/temple/endpoints/evm/api.utils'; import { toTokenSlug } from 'lib/assets'; import { fetchEvmTokensMetadataFromChain } from 'lib/evm/on-chain/metadata'; diff --git a/src/app/store/assets-filter-options/state.ts b/src/app/store/assets-filter-options/state.ts index 97118a952d..fae6fa1235 100644 --- a/src/app/store/assets-filter-options/state.ts +++ b/src/app/store/assets-filter-options/state.ts @@ -1,16 +1,6 @@ -import { TempleChainKind } from 'temple/types'; +import { BasicChain } from 'temple/front/chains'; -interface EvmChain { - kind: TempleChainKind.EVM; - chainId: number; -} - -interface TezosChain { - kind: TempleChainKind.Tezos; - chainId: string; -} - -export type FilterChain = EvmChain | TezosChain | null; +export type FilterChain = BasicChain | null; export interface AssetsFilterOptionsStateInterface { filterChain: FilterChain; diff --git a/src/app/store/evm/collectibles-metadata/selectors.ts b/src/app/store/evm/collectibles-metadata/selectors.ts index 703e8bafce..b58d8db354 100644 --- a/src/app/store/evm/collectibles-metadata/selectors.ts +++ b/src/app/store/evm/collectibles-metadata/selectors.ts @@ -4,6 +4,11 @@ import { EvmCollectibleMetadata } from 'lib/metadata/types'; export const useEvmCollectiblesMetadataRecordSelector = () => useSelector(({ evmCollectiblesMetadata }) => evmCollectiblesMetadata.metadataRecord); +export const useEvmChainCollectiblesMetadataRecordSelector = ( + chainId: number +): StringRecord | undefined => + useSelector(({ evmCollectiblesMetadata }) => evmCollectiblesMetadata.metadataRecord[chainId]); + export const useEvmCollectibleMetadataSelector = ( chainId: number, collectibleSlug: string diff --git a/src/app/store/evm/tokens-metadata/selectors.ts b/src/app/store/evm/tokens-metadata/selectors.ts index f44468d5f3..7a4774c1f2 100644 --- a/src/app/store/evm/tokens-metadata/selectors.ts +++ b/src/app/store/evm/tokens-metadata/selectors.ts @@ -4,5 +4,8 @@ import { EvmTokenMetadata } from 'lib/metadata/types'; export const useEvmTokensMetadataRecordSelector = () => useSelector(({ evmTokensMetadata }) => evmTokensMetadata.metadataRecord); +export const useEvmChainTokensMetadataRecordSelector = (chainId: number): StringRecord | undefined => + useSelector(({ evmTokensMetadata }) => evmTokensMetadata.metadataRecord[chainId]); + export const useEvmTokenMetadataSelector = (chainId: number, tokenSlug: string): EvmTokenMetadata | undefined => useSelector(({ evmTokensMetadata }) => evmTokensMetadata.metadataRecord[chainId]?.[tokenSlug]); diff --git a/src/app/templates/About/About.tsx b/src/app/templates/About/About.tsx index 7c14eac0f4..7ddd8d94ae 100644 --- a/src/app/templates/About/About.tsx +++ b/src/app/templates/About/About.tsx @@ -2,7 +2,7 @@ import React, { memo } from 'react'; import { VerticalLines } from 'app/atoms/Lines'; import { Logo } from 'app/atoms/Logo'; -import { SettingsCell } from 'app/atoms/SettingsCell'; +import { SettingsCellSingle } from 'app/atoms/SettingsCell'; import { SettingsCellGroup } from 'app/atoms/SettingsCellGroup'; import { ReactComponent as DiscordIcon } from 'app/icons/monochrome/discord.svg'; import { ReactComponent as KnowledgeBaseIcon } from 'app/icons/monochrome/knowledge-base.svg'; @@ -93,7 +93,7 @@ export const About = memo(() => { return (
- (({ item, isLast }) => { const { Icon, key, link, testID } = item; return ( - } cellName={t(key)} @@ -30,6 +30,6 @@ export const LinksGroupItem = memo(({ item, isLast }) => { testID={testID} > - + ); }); diff --git a/src/app/templates/AssetIcon.tsx b/src/app/templates/AssetIcon.tsx index 330d2dda83..474dda164c 100644 --- a/src/app/templates/AssetIcon.tsx +++ b/src/app/templates/AssetIcon.tsx @@ -1,175 +1,95 @@ -import React, { FC, memo, useMemo } from 'react'; +import React, { memo } from 'react'; import clsx from 'clsx'; -import { Identicon } from 'app/atoms'; +import { IdenticonInitials } from 'app/atoms/Identicon'; import { EvmNetworkLogo, TezosNetworkLogo } from 'app/atoms/NetworkLogo'; -import { ReactComponent as CollectiblePlaceholder } from 'app/icons/collectible-placeholder.svg'; -import { useEvmTokenMetadataSelector } from 'app/store/evm/tokens-metadata/selectors'; -import { AssetMetadataBase, getAssetSymbol, isCollectible, useTezosAssetMetadata } from 'lib/metadata'; -import { EvmTokenMetadata } from 'lib/metadata/types'; -import useTippy, { UseTippyOptions } from 'lib/ui/useTippy'; -import { isEvmNativeTokenSlug } from 'lib/utils/evm.utils'; +import { ReactComponent as CollectiblePlaceholderSvg } from 'app/icons/collectible-placeholder.svg'; +import { getAssetSymbol, isEvmCollectibleMetadata, isTezosCollectibleMetadata } from 'lib/metadata/utils'; import { useEvmChainByChainId, useTezosChainByChainId } from 'temple/front/chains'; -import { TezosAssetImage, AssetImageBaseProps, EvmAssetImage } from './AssetImage'; +import { TezosAssetImage, TezosAssetImageProps, EvmAssetImage, EvmAssetImageProps } from './AssetImage'; -interface TezosAssetIconProps extends Omit { - tezosChainId: string; - assetSlug: string; -} +export const TezosAssetIcon = memo(props => ( + +)); -export const TezosAssetIcon = memo(({ tezosChainId, className, style, ...props }) => { - const metadata = useTezosAssetMetadata(props.assetSlug, tezosChainId); - - return ( -
- } - fallback={} - /> -
- ); -}); - -interface EvmAssetIconProps extends Omit { - evmChainId: number; - assetSlug: string; -} +const TezosAssetIconPlaceholder: TezosAssetImageProps['Fallback'] = memo(({ metadata, size, className, style }) => + metadata && isTezosCollectibleMetadata(metadata) ? ( + + ) : ( + + ) +); -export const EvmTokenIcon = memo(({ evmChainId, assetSlug, className, style, ...props }) => { - const network = useEvmChainByChainId(evmChainId); - const tokenMetadata = useEvmTokenMetadataSelector(evmChainId, assetSlug); +const ICON_DEFAULT_SIZE = 40; +const ASSET_IMAGE_DEFAULT_SIZE = 30; +const NETWORK_IMAGE_DEFAULT_SIZE = 16; - const metadata = isEvmNativeTokenSlug(assetSlug) ? network?.currency : tokenMetadata; +export const TezosTokenIconWithNetwork = memo(({ tezosChainId, className, style, ...props }) => { + const network = useTezosChainByChainId(tezosChainId); return ( -
- } - fallback={} - /> +
+ + + {network && ( + + )}
); }); -const ICON_DEFAULT_SIZE = 40; -const ASSET_IMAGE_DEFAULT_SIZE = 30; - -interface TezosTokenIconWithNetworkProps - extends Omit { - tezosChainId: string; - assetSlug: string; -} - -export const TezosTokenIconWithNetwork = memo( - ({ tezosChainId, className, style, ...props }) => { - const network = useTezosChainByChainId(tezosChainId); - const metadata = useTezosAssetMetadata(props.assetSlug, tezosChainId); +export const EvmAssetIcon = memo(props => ( + +)); - const tippyProps = useMemo( - () => ({ - trigger: 'mouseenter', - hideOnClick: false, - content: network?.name ?? 'Unknown Network', - animation: 'shift-away-subtle', - placement: 'bottom-start' - }), - [network] - ); - - const networkIconRef = useTippy(tippyProps); - - return ( -
- } - fallback={} - /> - {network && ( -
- -
- )} -
- ); - } +const EvmAssetIconPlaceholder: EvmAssetImageProps['Fallback'] = memo(({ metadata, size, className, style }) => + metadata && isEvmCollectibleMetadata(metadata) ? ( + + ) : ( + + ) ); -interface EvmTokenIconWithNetworkProps - extends Omit { - evmChainId: number; - assetSlug: string; -} - -export const EvmTokenIconWithNetwork = memo( - ({ evmChainId, assetSlug, className, style, ...props }) => { - const network = useEvmChainByChainId(evmChainId); - const tokenMetadata = useEvmTokenMetadataSelector(evmChainId, assetSlug); - - const metadata = isEvmNativeTokenSlug(assetSlug) ? network?.currency : tokenMetadata; - - const tippyProps = useMemo( - () => ({ - trigger: 'mouseenter', - hideOnClick: false, - content: network?.name ?? 'Unknown Network', - animation: 'shift-away-subtle', - placement: 'bottom-start' - }), - [network] - ); - - const networkIconRef = useTippy(tippyProps); +export const EvmTokenIconWithNetwork = memo(({ evmChainId, className, style, ...props }) => { + const network = useEvmChainByChainId(evmChainId); - return ( -
- } - fallback={} + return ( +
+ + + {network && ( + - {network && ( - - )} -
- ); - } -); - -interface PlaceholderProps { - metadata: EvmTokenMetadata | AssetMetadataBase | nullish; - size?: number; -} - -const AssetIconPlaceholder: FC = ({ metadata, size }) => { - return metadata && isCollectible(metadata) ? ( - - ) : ( - + )} +
); -}; +}); diff --git a/src/app/templates/AssetImage.tsx b/src/app/templates/AssetImage.tsx deleted file mode 100644 index 6f43c24a24..0000000000 --- a/src/app/templates/AssetImage.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React, { FC, useMemo } from 'react'; - -import { buildTokenImagesStack, buildCollectibleImagesStack, buildEvmTokenIconSources } from 'lib/images-uri'; -import { AssetMetadataBase, isCollectibleTokenMetadata } from 'lib/metadata'; -import { EvmAssetMetadataBase } from 'lib/metadata/types'; -import { ImageStacked, ImageStackedProps } from 'lib/ui/ImageStacked'; - -export interface AssetImageBaseProps - extends Pick { - sources: string[]; - metadata?: EvmAssetMetadataBase | AssetMetadataBase; - size?: number; -} - -const AssetImageBase: FC = ({ - sources, - metadata, - className, - size, - style, - loader, - fallback, - onStackLoaded, - onStackFailed -}) => { - const styleMemo: React.CSSProperties = useMemo( - () => ({ - objectFit: 'contain', - maxWidth: '100%', - maxHeight: '100%', - ...style - }), - [style] - ); - - return ( - - ); -}; - -interface TezosAssetImageProps extends Omit { - metadata?: AssetMetadataBase; - fullViewCollectible?: boolean; -} - -export const TezosAssetImage: FC = ({ metadata, fullViewCollectible, ...rest }) => { - const sources = useMemo(() => { - if (metadata && isCollectibleTokenMetadata(metadata)) - return buildCollectibleImagesStack(metadata, fullViewCollectible); - - return buildTokenImagesStack(metadata?.thumbnailUri); - }, [metadata, fullViewCollectible]); - - return ; -}; - -interface EvmAssetImageProps extends Omit { - metadata?: EvmAssetMetadataBase; - evmChainId?: number; -} - -export const EvmAssetImage: FC = ({ evmChainId, metadata, ...rest }) => { - const sources = useMemo( - () => (metadata ? buildEvmTokenIconSources(metadata, evmChainId) : []), - [evmChainId, metadata] - ); - - return ; -}; diff --git a/src/app/templates/AssetImage/AssetImageStacked.tsx b/src/app/templates/AssetImage/AssetImageStacked.tsx new file mode 100644 index 0000000000..b7f8e17242 --- /dev/null +++ b/src/app/templates/AssetImage/AssetImageStacked.tsx @@ -0,0 +1,62 @@ +import React, { FC, useMemo } from 'react'; + +import { + buildTokenImagesStack, + buildCollectibleImagesStack, + buildEvmTokenIconSources, + buildEvmCollectibleIconSources +} from 'lib/images-uri'; +import { AssetMetadataBase, isTezosCollectibleMetadata } from 'lib/metadata'; +import { EvmAssetMetadataBase } from 'lib/metadata/types'; +import { isEvmCollectibleMetadata } from 'lib/metadata/utils'; +import { ImageStacked, ImageStackedProps } from 'lib/ui/ImageStacked'; + +interface AssetImageStackedPropsBase extends Omit { + extraSrc?: string; +} + +export interface TezosAssetImageStackedProps extends AssetImageStackedPropsBase { + metadata?: AssetMetadataBase; + fullViewCollectible?: boolean; +} + +export const TezosAssetImageStacked: FC = ({ + metadata, + fullViewCollectible, + extraSrc, + ...rest +}) => { + const sources = useMemo(() => { + const sources = + metadata && isTezosCollectibleMetadata(metadata) + ? buildCollectibleImagesStack(metadata, fullViewCollectible) + : buildTokenImagesStack(metadata?.thumbnailUri); + + if (extraSrc) sources.push(extraSrc); + + return sources; + }, [metadata, fullViewCollectible, extraSrc]); + + return ; +}; + +export interface EvmAssetImageStackedProps extends AssetImageStackedPropsBase { + metadata?: EvmAssetMetadataBase; + evmChainId: number; +} + +export const EvmAssetImageStacked: FC = ({ evmChainId, metadata, extraSrc, ...rest }) => { + const sources = useMemo(() => { + const sources = metadata + ? isEvmCollectibleMetadata(metadata) + ? buildEvmCollectibleIconSources(metadata) + : buildEvmTokenIconSources(metadata, evmChainId) + : []; + + if (extraSrc) sources.push(extraSrc); + + return sources; + }, [evmChainId, metadata, extraSrc]); + + return ; +}; diff --git a/src/app/templates/AssetImage/index.tsx b/src/app/templates/AssetImage/index.tsx new file mode 100644 index 0000000000..66da1c05dd --- /dev/null +++ b/src/app/templates/AssetImage/index.tsx @@ -0,0 +1,60 @@ +import React, { memo } from 'react'; + +import { AssetMetadataBase, useEvmAssetMetadata, useTezosAssetMetadata } from 'lib/metadata'; +import { EvmAssetMetadataBase } from 'lib/metadata/types'; + +import { + TezosAssetImageStackedProps, + TezosAssetImageStacked, + EvmAssetImageStackedProps, + EvmAssetImageStacked +} from './AssetImageStacked'; + +export { TezosAssetImageStacked }; + +export interface TezosAssetImageProps extends Omit { + tezosChainId: string; + assetSlug: string; + Loader?: Placeholder; + Fallback?: Placeholder; +} + +export const TezosAssetImage = memo(({ Loader, Fallback, ...props }) => { + const { tezosChainId, assetSlug, ...rest } = props; + + const metadata = useTezosAssetMetadata(assetSlug, tezosChainId); + + return ( + : undefined} + fallback={Fallback ? : undefined} + {...rest} + /> + ); +}); + +export interface EvmAssetImageProps extends Omit { + evmChainId: number; + assetSlug: string; + Loader?: Placeholder; + Fallback?: Placeholder; +} + +export const EvmAssetImage = memo(({ Loader, Fallback, ...props }) => { + const { evmChainId, assetSlug, ...rest } = props; + + const metadata = useEvmAssetMetadata(assetSlug, evmChainId); + + return ( + : undefined} + fallback={Fallback ? : undefined} + {...rest} + /> + ); +}); + +type Placeholder = React.ComponentType & { metadata?: M }>; diff --git a/src/app/templates/ChainSelect/ChainButton.tsx b/src/app/templates/ChainSelect/ChainButton.tsx deleted file mode 100644 index 8ad005db93..0000000000 --- a/src/app/templates/ChainSelect/ChainButton.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; - -import clsx from 'clsx'; - -import { Button } from 'app/atoms/Button'; -import { OneOfChains, getNetworkTitle } from 'temple/front'; - -interface Props { - chain: OneOfChains; - selected: boolean; - onClick: EmptyFn; -} - -export const ChainButton: React.FC = ({ chain, selected, onClick }) => { - const disabled = chain.disabled; - const { color, description } = chain.rpc; - - const title = getNetworkTitle(chain); - - return ( - - ); -}; diff --git a/src/app/templates/ChainSelect/ChainsDropdown.tsx b/src/app/templates/ChainSelect/ChainsDropdown.tsx deleted file mode 100644 index 743b9b6b41..0000000000 --- a/src/app/templates/ChainSelect/ChainsDropdown.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import React, { memo, useCallback, useMemo } from 'react'; - -import clsx from 'clsx'; - -import DropdownWrapper from 'app/atoms/DropdownWrapper'; -import { useShortcutAccountSelectModalIsOpened } from 'app/hooks/use-account-select-shortcut'; -import { ReactComponent as SignalAltIcon } from 'app/icons/monochrome/signal-alt.svg'; -import { T } from 'lib/i18n'; -import { PopperRenderProps } from 'lib/ui/Popper'; -import { - TezosChain, - EvmChain, - useEnabledTezosChains, - useEnabledEvmChains, - useAccountAddressForTezos, - useAccountAddressForEvm -} from 'temple/front'; -import { TempleChainTitle } from 'temple/types'; - -import { ChainButton } from './ChainButton'; -import { ChainSelectController } from './controller'; -import styles from './style.module.css'; - -interface Props extends PopperRenderProps { - controller: ChainSelectController; - shouldFilterByCurrentAccount: boolean; -} - -export const ChainsDropdown = memo(({ opened, setOpened, controller, shouldFilterByCurrentAccount }) => { - const selectedChain = controller.value; - - const tezosChains = useEnabledTezosChains(); - const evmChains = useEnabledEvmChains(); - const accountTezAddress = useAccountAddressForTezos(); - const accountEvmAddress = useAccountAddressForEvm(); - - useShortcutAccountSelectModalIsOpened(() => setOpened(false)); - - const handleTezosNetworkSelect = useCallback( - (network: TezosChain) => { - controller.setValue(network); - }, - [controller] - ); - - const handleEvmNetworkSelect = useCallback( - (network: EvmChain) => { - controller.setValue(network); - }, - [controller] - ); - - const h2ClassName = useMemo( - () => - clsx( - 'flex items-center mb-2 px-1 pb-1', - 'border-b border-white border-opacity-25', - 'text-white text-opacity-90 text-font-medium text-center' - ), - [] - ); - - return ( - -
- {(!shouldFilterByCurrentAccount || accountTezAddress) && ( - <> -

- - {TempleChainTitle.tezos} -

- - {tezosChains.map(chain => { - const { chainId } = chain; - const selected = chainId === selectedChain?.chainId && selectedChain.kind === 'tezos'; - - return ( - { - setOpened(false); - - if (!selected) handleTezosNetworkSelect(chain); - }} - /> - ); - })} - - )} - - {(!shouldFilterByCurrentAccount || accountEvmAddress) && ( - <> -

- - {TempleChainTitle.evm} -

- - {evmChains.map(chain => { - const { chainId } = chain; - const selected = chainId === selectedChain?.chainId && selectedChain.kind === 'evm'; - - return ( - { - setOpened(false); - - if (!selected) handleEvmNetworkSelect(chain); - }} - /> - ); - })} - - )} -
-
- ); -}); diff --git a/src/app/templates/ChainSelect/controller.ts b/src/app/templates/ChainSelect/controller.ts deleted file mode 100644 index ceddd300de..0000000000 --- a/src/app/templates/ChainSelect/controller.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useMemo, useState } from 'react'; - -import { OneOfChains, useAccountAddressForTezos, useEthereumMainnetChain, useTezosMainnetChain } from 'temple/front'; - -export interface ChainSelectController { - value: OneOfChains; - setValue: SyncFn; -} - -export const useChainSelectController = (shouldFilterByCurrentAccount = true): ChainSelectController => { - const tezosMainnetChain = useTezosMainnetChain(); - const evmMainnet = useEthereumMainnetChain(); - const accountTezAddress = useAccountAddressForTezos(); - - const [value, setValue] = useState(() => - accountTezAddress || !shouldFilterByCurrentAccount ? tezosMainnetChain : evmMainnet - ); - - return useMemo(() => ({ value, setValue }), [value]); -}; diff --git a/src/app/templates/ChainSelect/index.tsx b/src/app/templates/ChainSelect/index.tsx deleted file mode 100644 index 496d108a63..0000000000 --- a/src/app/templates/ChainSelect/index.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { memo } from 'react'; - -import clsx from 'clsx'; - -import { Lines } from 'app/atoms'; -import { Button } from 'app/atoms/Button'; -import Name from 'app/atoms/Name'; -import { ReactComponent as ChevronDownIcon } from 'app/icons/chevron-down.svg'; -import { T } from 'lib/i18n'; -import Popper from 'lib/ui/Popper'; -import { getNetworkTitle } from 'temple/front/networks'; - -import { ChainsDropdown } from './ChainsDropdown'; -import { ChainSelectController } from './controller'; - -export { useChainSelectController } from './controller'; - -interface Props { - controller: ChainSelectController; - shouldFilterByCurrentAccount?: boolean; -} - -const ChainSelect = memo(({ controller, shouldFilterByCurrentAccount = true }) => { - const selectedChain = controller.value; - - return ( - ( - - )} - > - {({ ref, opened, toggleOpened }) => ( - - )} - - ); -}); - -interface ChainSelectSectionProps extends Props { - onlyForAddressResolution?: boolean; -} - -export const ChainSelectSection = memo(({ onlyForAddressResolution, ...props }) => ( - <> -
-
- - : - - - {onlyForAddressResolution && ( - {`(Only for address resolution)`} - )} -
- -
- - -
- - - -)); diff --git a/src/app/templates/ChainSelect/style.module.css b/src/app/templates/ChainSelect/style.module.css deleted file mode 100644 index 33ae458d74..0000000000 --- a/src/app/templates/ChainSelect/style.module.css +++ /dev/null @@ -1,11 +0,0 @@ -.scroll::-webkit-scrollbar { - display: none; -} - -.scroll { - overflow-y: scroll; - max-height: 31rem; - - -ms-overflow-style: none; - scrollbar-width: none; -} diff --git a/src/app/templates/DAppConnection/index.tsx b/src/app/templates/DAppConnection/index.tsx index 7478903b18..2fc3ee4ca5 100644 --- a/src/app/templates/DAppConnection/index.tsx +++ b/src/app/templates/DAppConnection/index.tsx @@ -5,7 +5,6 @@ import DAppLogo from 'app/atoms/DAppLogo'; import { TezosNetworkLogo } from 'app/atoms/NetworkLogo'; import { StyledButton } from 'app/atoms/StyledButton'; import { ReactComponent as ChevronRightSvg } from 'app/icons/base/chevron_right.svg'; -import { t } from 'lib/i18n'; import { useTypedSWR } from 'lib/swr'; import { TempleTezosChainId } from 'lib/temple/types'; import { Link } from 'lib/woozie'; @@ -43,11 +42,7 @@ export const DAppConnection = memo(() => { {network && (
- +
)}
diff --git a/src/app/templates/InFiat.tsx b/src/app/templates/InFiat.tsx index 3ba7fa149f..fb29601a9d 100644 --- a/src/app/templates/InFiat.tsx +++ b/src/app/templates/InFiat.tsx @@ -1,15 +1,16 @@ import React, { FC, ReactElement, ReactNode, useMemo } from 'react'; -import { isDefined } from '@rnw-community/shared'; import BigNumber from 'bignumber.js'; import Money from 'app/atoms/Money'; import { TestIDProps } from 'lib/analytics'; import { useAssetFiatCurrencyPrice, useFiatCurrency } from 'lib/fiat-currency'; +import { ZERO } from 'lib/utils/numbers'; interface OutputProps { balance: ReactNode; symbol: string; + noPrice: boolean; } interface Props extends TestIDProps { @@ -21,16 +22,11 @@ interface Props extends TestIDProps { shortened?: boolean; smallFractionFont?: boolean; showCents?: boolean; + withSign?: boolean; evm?: boolean; } -const InFiat: FC = props => { - return ; -}; - -export default InFiat; - -const InFiatContent: FC = ({ +const InFiat: FC = ({ evm, chainId, volume, @@ -40,6 +36,7 @@ const InFiatContent: FC = ({ shortened, smallFractionFont, showCents = true, + withSign, testID, testIDProperties }) => { @@ -47,33 +44,36 @@ const InFiatContent: FC = ({ const { selectedFiatCurrency } = useFiatCurrency(); const roundedInFiat = useMemo(() => { - if (!isDefined(price)) return new BigNumber(0); + if (price.isZero()) return ZERO; const inFiat = new BigNumber(volume).times(price); if (showCents) { return inFiat; } + return inFiat.integerValue(); }, [price, showCents, volume]); const cryptoDecimals = showCents ? undefined : 0; - return isDefined(price) - ? children({ - balance: ( - - {roundedInFiat} - - ), - symbol: selectedFiatCurrency.symbol - }) - : null; + return children({ + balance: ( + + {roundedInFiat} + + ), + symbol: selectedFiatCurrency.symbol, + noPrice: price.isZero() + }); }; + +export default InFiat; diff --git a/src/app/templates/NetworkSelectModal.tsx b/src/app/templates/NetworkSelectModal.tsx index 960106efbb..5ab24a277b 100644 --- a/src/app/templates/NetworkSelectModal.tsx +++ b/src/app/templates/NetworkSelectModal.tsx @@ -17,8 +17,9 @@ import { setAssetsFilterChain } from 'app/store/assets-filter-options/actions'; import { FilterChain } from 'app/store/assets-filter-options/state'; import { SearchBarField } from 'app/templates/SearchField'; import { T } from 'lib/i18n'; -import { filterNetworksByName } from 'lib/ui/filter-networks-by-name'; +import { searchAndFilterChains } from 'lib/ui/search-networks'; import { useScrollIntoViewOnMount } from 'lib/ui/use-scroll-into-view'; +import { isSearchStringApplicable } from 'lib/utils/search-items'; import { navigate } from 'lib/woozie'; import { OneOfChains, @@ -32,8 +33,6 @@ import { TempleChainKind } from 'temple/types'; const ALL_NETWORKS = 'All Networks'; -type Network = OneOfChains | string; - interface Props { opened: boolean; selectedNetwork: FilterChain; @@ -41,6 +40,32 @@ interface Props { } export const NetworkSelectModal = memo(({ opened, selectedNetwork, onRequestClose }) => { + const handleNetworkSelect = useCallback( + (network: OneOfChains | null) => { + dispatch(setAssetsFilterChain(network)); + onRequestClose(); + }, + [onRequestClose] + ); + + return ( + + + + ); +}); + +interface ContentProps { + opened: boolean; + selectedNetwork: FilterChain; + handleNetworkSelect: (chain: OneOfChains | null) => void; +} + +export const NetworkSelectModalContent = memo(({ opened, selectedNetwork, handleNetworkSelect }) => { const accountTezAddress = useAccountAddressForTezos(); const accountEvmAddress = useAccountAddressForEvm(); @@ -48,18 +73,19 @@ export const NetworkSelectModal = memo(({ opened, selectedNetwork, onRequ const evmChains = useEnabledEvmChains(); const networks = useMemo( - () => [ALL_NETWORKS, ...(accountTezAddress ? tezosChains : []), ...(accountEvmAddress ? evmChains : [])], + () => [...(accountTezAddress ? tezosChains : []), ...(accountEvmAddress ? evmChains : [])], [accountEvmAddress, accountTezAddress, evmChains, tezosChains] ); const [searchValue, setSearchValue] = useState(''); const [searchValueDebounced] = useDebounce(searchValue, 300); + const inSearch = isSearchStringApplicable(searchValueDebounced); const [attractSelectedNetwork, setAttractSelectedNetwork] = useState(true); - const filteredNetworks = useMemo( - () => (searchValueDebounced.length ? filterNetworksByName(networks, searchValueDebounced) : networks), - [searchValueDebounced, networks] + const searchedNetworks = useMemo( + () => (inSearch ? searchAndFilterChains(networks, searchValueDebounced) : networks), + [inSearch, searchValueDebounced, networks] ); useEffect(() => { @@ -67,51 +93,58 @@ export const NetworkSelectModal = memo(({ opened, selectedNetwork, onRequ else if (!opened) setAttractSelectedNetwork(true); }, [opened, searchValueDebounced]); - useEffect(() => { - if (!opened) setSearchValue(''); - }, [opened]); - - const handleNetworkSelect = useCallback( - (network: Network) => { - dispatch(setAssetsFilterChain(typeof network === 'string' ? null : network)); - onRequestClose(); + const onNetworkSelect = useCallback( + (network: OneOfChains | string) => { + handleNetworkSelect(typeof network === 'string' ? null : network); }, - [onRequestClose] + [handleNetworkSelect] ); return ( - + <>
navigate('settings/networks')} />
-
- {filteredNetworks.length === 0 && } - - {filteredNetworks.map(network => ( +
+ {searchedNetworks.length === 0 ? ( + + ) : ( + !inSearch && ( + + ) + )} + + {searchedNetworks.map(network => ( ))}
- + ); }); interface NetworkProps { - network: Network; + network: OneOfChains | string; activeNetwork: FilterChain; attractSelf?: boolean; showBalance?: boolean; iconSize?: Size; - onClick?: (network: Network) => void; + onClick?: (network: OneOfChains | string) => void; } export const Network: FC = ({ @@ -133,13 +166,10 @@ export const Network: FC = ({ const Icon = useMemo(() => { if (isAllNetworks) return ; - if (network.kind === TempleChainKind.Tezos) - return ; + if (network.kind === TempleChainKind.Tezos) return ; if (network.kind === TempleChainKind.EVM) - return ( - - ); + return ; return null; }, [isAllNetworks, network, iconSize]); @@ -154,8 +184,10 @@ export const Network: FC = ({ >
{Icon} +
- {isAllNetworks ? ALL_NETWORKS : network.name} + {isAllNetworks ? : network.name} + {showBalance && ( :{' '} @@ -164,6 +196,7 @@ export const Network: FC = ({ )}
+
); diff --git a/src/app/templates/NetworksSettings/add-network-modal/name-input.tsx b/src/app/templates/NetworksSettings/add-network-modal/name-input.tsx index d2fa561645..22c5e5d96e 100644 --- a/src/app/templates/NetworksSettings/add-network-modal/name-input.tsx +++ b/src/app/templates/NetworksSettings/add-network-modal/name-input.tsx @@ -229,7 +229,7 @@ const ChainVariant: FC = ({ variant, index, variantsRef, onCl > {variant.name} - + ); }; diff --git a/src/app/templates/NetworksSettings/chains-group-view/item.tsx b/src/app/templates/NetworksSettings/chains-group-view/item.tsx index 7c4eceb050..7b623aff54 100644 --- a/src/app/templates/NetworksSettings/chains-group-view/item.tsx +++ b/src/app/templates/NetworksSettings/chains-group-view/item.tsx @@ -2,7 +2,7 @@ import React, { memo } from 'react'; import { IconBase } from 'app/atoms'; import { EvmNetworkLogo, TezosNetworkLogo } from 'app/atoms/NetworkLogo'; -import { SettingsCell } from 'app/atoms/SettingsCell'; +import { SettingsCellSingle } from 'app/atoms/SettingsCell'; import { ReactComponent as ChevronRightIcon } from 'app/icons/base/chevron_right.svg'; import { ShortenedTextWithTooltip } from 'app/templates/shortened-text-with-tooltip'; import { setAnotherSelector } from 'lib/analytics'; @@ -19,13 +19,13 @@ interface ChainsGroupItemProps { } export const ChainsGroupItem = memo(({ item, isLast }) => ( - + ) : ( - + ) } cellName={ @@ -40,5 +40,5 @@ export const ChainsGroupItem = memo(({ item, isLast }) => {...setAnotherSelector('url', item.rpcBaseURL)} > - + )); diff --git a/src/app/templates/NetworksSettings/index.tsx b/src/app/templates/NetworksSettings/index.tsx index 86b8fa37e8..84c025a848 100644 --- a/src/app/templates/NetworksSettings/index.tsx +++ b/src/app/templates/NetworksSettings/index.tsx @@ -4,8 +4,8 @@ import { EmptyState } from 'app/atoms/EmptyState'; import { useSearchParamsBoolean } from 'app/hooks/use-search-params-boolean'; import { MAIN_CHAINS_IDS } from 'lib/constants'; import { t } from 'lib/i18n'; -import { filterNetworksByName } from 'lib/ui/filter-networks-by-name'; import { useBooleanState } from 'lib/ui/hooks'; +import { searchAndFilterChains } from 'lib/ui/search-networks'; import { SettingsTabProps } from 'lib/ui/settings-tab-props'; import { useAllEvmChains, useAllTezosChains } from 'temple/front'; import { isPossibleTestnetChain } from 'temple/front/chains'; @@ -36,7 +36,7 @@ export const NetworksSettings = memo(({ setHeaderChildren }) = ), [evmChainsRecord, tezosChainsRecord] ); - const matchingChains = useMemo(() => filterNetworksByName(allChains, searchValue), [allChains, searchValue]); + const matchingChains = useMemo(() => searchAndFilterChains(allChains, searchValue), [allChains, searchValue]); const pickChains = useCallback( ({ kind, isDefault }: ChainsFilters) => diff --git a/src/app/templates/PaymentProviderInput/PaymentProvidersMenu/PaymentProviderTag.tsx b/src/app/templates/PaymentProviderInput/PaymentProvidersMenu/PaymentProviderTag.tsx index 2731c7a279..0921728f61 100644 --- a/src/app/templates/PaymentProviderInput/PaymentProvidersMenu/PaymentProviderTag.tsx +++ b/src/app/templates/PaymentProviderInput/PaymentProvidersMenu/PaymentProviderTag.tsx @@ -6,7 +6,7 @@ export interface PaymentProviderTagProps { } export const PaymentProviderTag: FC = ({ bgColor, text }) => ( -
+
{text}
); diff --git a/src/app/templates/SearchField.tsx b/src/app/templates/SearchField.tsx index fabe6226a7..7f7027f73d 100644 --- a/src/app/templates/SearchField.tsx +++ b/src/app/templates/SearchField.tsx @@ -15,9 +15,9 @@ interface Props extends InputHTMLAttributes, TestIDProps { value: string; onValueChange: (value: string) => void; bottomOffset?: string; + /** @deprecated */ containerClassName?: string; onCleanButtonClick?: () => void; - defaultRightMargin?: boolean; } const SearchField = forwardRef( @@ -100,8 +100,12 @@ const SearchField = forwardRef( export default SearchField; +interface SearchBarFieldProps extends Props { + defaultRightMargin?: boolean; +} + export const SearchBarField = memo( - forwardRef( + forwardRef( ({ className, placeholder = 'Search', defaultRightMargin = true, containerClassName, value, ...rest }, ref) => ( = ({ tezosChainId, testId, selectedAs
{selectedAssetSlug ? (
- + {selectedAssetMetadata.symbol} diff --git a/src/app/templates/SwapForm/SwapRoute/lb-pool-part.tsx b/src/app/templates/SwapForm/SwapRoute/lb-pool-part.tsx index 4efbb0539d..cef1c6fb54 100644 --- a/src/app/templates/SwapForm/SwapRoute/lb-pool-part.tsx +++ b/src/app/templates/SwapForm/SwapRoute/lb-pool-part.tsx @@ -3,7 +3,7 @@ import React, { FC, useMemo } from 'react'; import classNames from 'clsx'; import { ReactComponent as Separator } from 'app/icons/separator.svg'; -import { TezosAssetImage } from 'app/templates/AssetImage'; +import { TezosAssetImageStacked } from 'app/templates/AssetImage'; import { SIRS_TOKEN_METADATA } from 'lib/assets/known-tokens'; import useTippy from 'lib/ui/useTippy'; @@ -53,7 +53,7 @@ export const LbPoolPart: FC = ({ amount, isLbOutput, totalChains }) => { style={advancedLbPoolItemStyles} >
- +
diff --git a/src/app/templates/activity/Activity.tsx b/src/app/templates/activity/Activity.tsx deleted file mode 100644 index 892485bdac..0000000000 --- a/src/app/templates/activity/Activity.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import React, { memo, FC, useMemo } from 'react'; - -import clsx from 'clsx'; -import InfiniteScroll from 'react-infinite-scroll-component'; - -import { SyncSpinner } from 'app/atoms'; -import { SuspenseContainer } from 'app/atoms/SuspenseContainer'; -import { useAppEnv } from 'app/env'; -import { DeadEndBoundaryError } from 'app/ErrorBoundary'; -import { useLoadPartnersPromo } from 'app/hooks/use-load-partners-promo'; -import { ReactComponent as LayersIcon } from 'app/icons/layers.svg'; -import { ContentContainer } from 'app/layouts/containers'; -import { useShouldShowPartnersPromoSelector } from 'app/store/partners-promotion/selectors'; -import { useChainSelectController, ChainSelectSection } from 'app/templates/ChainSelect'; -import { PartnersPromotion, PartnersPromotionVariant } from 'app/templates/partners-promotion'; -import { TEMPLE_TOKEN_SLUG } from 'lib/assets'; -import { t, T } from 'lib/i18n/react'; -import useTezosActivities from 'lib/temple/activity-new/hook'; -import { UNDER_DEVELOPMENT_MSG } from 'temple/evm/under_dev_msg'; -import { useAccountAddressForTezos, useTezosChainByChainId } from 'temple/front'; - -import { ActivityItem } from './ActivityItem'; -import { ReactivateAdsBanner } from './ReactivateAdsBanner'; - -const INITIAL_NUMBER = 30; -const LOAD_STEP = 30; - -interface Props { - tezosChainId?: string; - assetSlug?: string; -} - -export const ActivityTab = memo(({ tezosChainId, assetSlug }) => ( - - {tezosChainId ? : } - -)); - -const ActivityWithChainSelect = memo(() => { - const chainSelectController = useChainSelectController(); - const network = chainSelectController.value; - - return ( - <> -
- - - - - {network.kind === 'tezos' ? ( - - ) : ( -
{UNDER_DEVELOPMENT_MSG}
- )} -
- - ); -}); - -interface TezosActivityProps { - tezosChainId: string; - assetSlug?: string; -} - -const TezosActivity: FC = ({ tezosChainId, assetSlug }) => { - const network = useTezosChainByChainId(tezosChainId); - const accountAddress = useAccountAddressForTezos(); - if (!network || !accountAddress) throw new DeadEndBoundaryError(); - - const { - loading, - reachedTheEnd, - list: activities, - loadMore - } = useTezosActivities(network, accountAddress, INITIAL_NUMBER, assetSlug); - - const { popup } = useAppEnv(); - - const shouldShowPartnersPromo = useShouldShowPartnersPromoSelector(); - useLoadPartnersPromo(); - - const promotion = useMemo(() => { - if (shouldShowPartnersPromo) - return ( - - ); - - return assetSlug === TEMPLE_TOKEN_SLUG ? : null; - }, [shouldShowPartnersPromo, assetSlug]); - - if (activities.length === 0 && !loading && reachedTheEnd) { - return ( -
- {promotion} - - - -

- -

-
- ); - } - - const retryInitialLoad = () => loadMore(INITIAL_NUMBER); - const loadMoreActivities = () => loadMore(LOAD_STEP); - - const loadNext = activities.length === 0 ? retryInitialLoad : loadMoreActivities; - - const onScroll = loading || reachedTheEnd ? undefined : buildOnScroll(loadNext); - - return ( -
- {promotion} - - } - onScroll={onScroll} - > - {activities.map(activity => ( - - ))} - -
- ); -}; - -/** - * Build onscroll listener to trigger next loading, when fetching data resulted in error. - * `InfiniteScroll.props.next` won't be triggered in this case. - */ -const buildOnScroll = - (next: EmptyFn) => - ({ target }: { target: EventTarget | null }) => { - const elem: HTMLElement = - target instanceof Document ? (target.scrollingElement! as HTMLElement) : (target as HTMLElement); - const atBottom = 0 === elem.offsetHeight - elem.clientHeight - elem.scrollTop; - if (atBottom) next(); - }; diff --git a/src/app/templates/activity/ActivityItem.tsx b/src/app/templates/activity/ActivityItem.tsx deleted file mode 100644 index 51acdecd2d..0000000000 --- a/src/app/templates/activity/ActivityItem.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React, { useEffect, useState, useMemo, memo } from 'react'; - -import classNames from 'clsx'; -import formatDistanceToNow from 'date-fns/formatDistanceToNow'; - -import { OldStyleHashChip } from 'app/atoms'; -import { MoneyDiffView } from 'app/templates/activity/MoneyDiffView'; -import { OperStack } from 'app/templates/activity/OperStack'; -import { OpenInExplorerChip } from 'app/templates/OpenInExplorerChip'; -import { getDateFnsLocale } from 'lib/i18n'; -import { t } from 'lib/i18n/react'; -import { Activity, buildOperStack, buildMoneyDiffs } from 'lib/temple/activity-new'; - -interface Props { - activity: Activity; - tezosChainId: string; - address: string; -} - -export const ActivityItem = memo(({ tezosChainId, activity, address }) => { - const { hash, addedAt, status } = activity; - - const operStack = useMemo(() => buildOperStack(activity, address), [activity, address]); - const moneyDiffs = useMemo(() => buildMoneyDiffs(activity), [activity]); - - return ( -
-
- - - - -
-
- -
-
- - - - - -
- -
- -
- {moneyDiffs.map(({ assetSlug, diff }, i) => ( - - ))} -
-
-
- ); -}); - -interface ActivityItemStatusCompProps { - activity: Activity; -} - -const ActivityItemStatusComp: React.FC = ({ activity }) => { - const explorerStatus = activity.status; - const content = explorerStatus ?? 'pending'; - const conditionalTextColor = explorerStatus ? 'text-red-600' : 'text-yellow-600'; - - return ( -
- - {t(content) || content} - -
- ); -}; - -type TimeProps = { - children: () => React.ReactElement; -}; - -const Time: React.FC = ({ children }) => { - const [value, setValue] = useState(children); - - useEffect(() => { - const interval = setInterval(() => { - setValue(children()); - }, 5_000); - - return () => { - clearInterval(interval); - }; - }, [setValue, children]); - - return value; -}; diff --git a/src/app/templates/activity/ActivityItem/ActivityOperationBase/index.tsx b/src/app/templates/activity/ActivityItem/ActivityOperationBase/index.tsx new file mode 100644 index 0000000000..fbc152e8d4 --- /dev/null +++ b/src/app/templates/activity/ActivityItem/ActivityOperationBase/index.tsx @@ -0,0 +1,227 @@ +import React, { memo, ReactElement, ReactNode, useMemo } from 'react'; + +import clsx from 'clsx'; + +import { Anchor, Button, HashShortView, IconBase, Money } from 'app/atoms'; +import { EvmNetworkLogo, TezosNetworkLogo } from 'app/atoms/NetworkLogo'; +import { ReactComponent as ChevronRightSvg } from 'app/icons/base/chevron_right.svg'; +import { ReactComponent as OutLinkIcon } from 'app/icons/base/outLink.svg'; +import { EvmAssetImage, TezosAssetImage } from 'app/templates/AssetImage'; +import InFiat from 'app/templates/InFiat'; +import { ActivityOperKindEnum, ActivityOperTransferType, ActivityStatus } from 'lib/activity'; +import { isTransferActivityOperKind } from 'lib/activity/utils'; +import { toEvmAssetSlug, toTezosAssetSlug } from 'lib/assets/utils'; +import { atomsToTokens } from 'lib/temple/helpers'; +import { BasicChain } from 'temple/front/chains'; +import { TempleChainKind } from 'temple/types'; + +import { FaceKind } from '../../utils'; + +import { AssetIconPlaceholder, BundleIconsStack, getIconByKind, getTitleByKind, StatusTag } from './utils'; + +interface Props { + chain: BasicChain; + kind: FaceKind; + transferType?: ActivityOperTransferType; + hash: string; + asset?: ActivityItemBaseAssetProp; + blockExplorerUrl?: string; + status?: ActivityStatus; + withoutAssetIcon?: boolean; + onClick?: EmptyFn; + addressChip?: ReactElement | null; +} + +export interface ActivityItemBaseAssetProp { + contract: string; + tokenId?: string; + /** `null` for 'unlimited' amount */ + amountSigned?: string | null; + decimals: number; + symbol?: string; + iconURL?: string; + nft?: boolean; +} + +export const ActivityOperationBaseComponent = memo( + ({ kind, transferType, hash, chain, asset, blockExplorerUrl, status, withoutAssetIcon, onClick, addressChip }) => { + const isForEvm = chain.kind === TempleChainKind.EVM; + + const assetSlug = asset + ? isForEvm + ? toEvmAssetSlug(asset.contract, asset.tokenId) + : toTezosAssetSlug(asset.contract, asset.tokenId) + : null; + + const amountJsx = useMemo(() => { + if (!asset) return null; + + const symbol = asset.symbol || (kind === ActivityOperKindEnum.approve ? '---' : ''); + const symbolStr = symbol.length > 6 ? `${symbol.slice(0, 6)}...` : symbol; + + return ( +
0 && 'text-success', + onClick && 'group-hover:hidden' + )} + > + {kind === ActivityOperKindEnum.approve ? null : asset.amountSigned ? ( + + {atomsToTokens(asset.amountSigned, asset.decimals)} + + ) : null} + + {symbolStr ? {symbolStr} : null} +
+ ); + }, [asset, kind, onClick]); + + const fiatJsx = useMemo(() => { + if (!asset) return null; + + if (!asset.amountSigned) return asset.amountSigned === null ? 'Unlimited' : null; + + if (kind === ActivityOperKindEnum.approve) + return {atomsToTokens(asset.amountSigned, asset.decimals)}; + + if (!assetSlug) return null; + + const amountForFiat = + kind === 'bundle' || isTransferActivityOperKind(kind) + ? atomsToTokens(asset.amountSigned, asset.decimals) + : null; + + if (!amountForFiat) return null; + + return ( + + {({ balance, symbol, noPrice }) => + noPrice ? ( + No value + ) : ( + <> + {balance} + {symbol} + + ) + } + + ); + }, [asset, kind, assetSlug, chain.chainId, isForEvm]); + + const chipJsx = useMemo( + () => + addressChip ?? ( + + + + + + + + ), + [addressChip, hash, blockExplorerUrl] + ); + + const isNFT = Boolean(asset?.nft); + + const faceIconJsx = useMemo(() => { + if (withoutAssetIcon || !assetSlug) + return ( +
+ +
+ ); + + const className = 'w-full h-full object-cover'; + + const placeholderJsx = ; + + return isForEvm ? ( + + ) : ( + + ); + }, [chain, isForEvm, withoutAssetIcon, kind, transferType, asset?.iconURL, asset?.symbol, isNFT, assetSlug]); + + return ( +
+
+ {kind === 'bundle' ? ( + + {faceIconJsx} + + ) : ( +
{faceIconJsx}
+ )} + + {withoutAssetIcon ? null : isForEvm ? ( + + ) : ( + + )} +
+ +
+
+
+ {getTitleByKind(kind, transferType)} + + +
+ + {amountJsx} +
+ +
+ {chipJsx} + +
{fiatJsx}
+
+
+ + {onClick && ( + + )} +
+ ); + } +); diff --git a/src/app/templates/activity/ActivityItem/ActivityOperationBase/nft.svg b/src/app/templates/activity/ActivityItem/ActivityOperationBase/nft.svg new file mode 100644 index 0000000000..25e8d29562 --- /dev/null +++ b/src/app/templates/activity/ActivityItem/ActivityOperationBase/nft.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/src/app/templates/activity/ActivityItem/ActivityOperationBase/token.svg b/src/app/templates/activity/ActivityItem/ActivityOperationBase/token.svg new file mode 100644 index 0000000000..9a2c57c4ea --- /dev/null +++ b/src/app/templates/activity/ActivityItem/ActivityOperationBase/token.svg @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/src/app/templates/activity/ActivityItem/ActivityOperationBase/utils.tsx b/src/app/templates/activity/ActivityItem/ActivityOperationBase/utils.tsx new file mode 100644 index 0000000000..2f7bee85e4 --- /dev/null +++ b/src/app/templates/activity/ActivityItem/ActivityOperationBase/utils.tsx @@ -0,0 +1,124 @@ +import React, { FC, memo } from 'react'; + +import clsx from 'clsx'; + +import { IdenticonInitials } from 'app/atoms/Identicon'; +import { ReactComponent as DocumentsSvg } from 'app/icons/base/documents.svg'; +import { ReactComponent as IncomeSvg } from 'app/icons/base/income.svg'; +import { ReactComponent as OkSvg } from 'app/icons/base/ok.svg'; +import { ReactComponent as SendSvg } from 'app/icons/base/send.svg'; +import { ActivityOperKindEnum, ActivityOperTransferType, ActivityStatus } from 'lib/activity'; + +import { FaceKind } from '../../utils'; +import { ReactComponent as PendingSpinSvg } from '../pending-spin.svg'; + +import { ReactComponent as NftPlaceholderSvg } from './nft.svg'; +import { ReactComponent as TokenPlaceholderSvg } from './token.svg'; + +const MEDALLION_CLASS_NAME = 'absolute border border-lines'; + +export const BundleIconsStack = memo>( + ({ withoutAssetIcon, isNFT, children }) => { + const bgClassName = withoutAssetIcon ? 'bg-grey-4' : 'bg-white'; + + return ( + <> +
+ +
+ +
+
{children}
+
+ + ); + } +); + +export const StatusTag: FC<{ status?: ActivityStatus }> = ({ status }) => { + if (status === ActivityStatus.failed) return StatusTagFailed; + + if (status === ActivityStatus.pending) return StatusTagPending; + + return null; +}; + +const StatusTagFailed = ( +
+ FAILED +
+); + +const StatusTagPending = ; + +export function getTitleByKind(kind: FaceKind, transferType?: ActivityOperTransferType) { + if (kind === 'bundle') return 'Bundle'; + + if (kind === ActivityOperKindEnum.interaction) return 'Interaction'; + + if (kind === ActivityOperKindEnum.approve) return 'Approve'; + + return transferType == null ? 'Interaction' : TransferTypeTitle[transferType]; +} + +const TransferTypeTitle: Record = { + [ActivityOperTransferType.sendToAccount]: 'Send', + [ActivityOperTransferType.receiveFromAccount]: 'Receive', + [ActivityOperTransferType.send]: 'Transfer', + [ActivityOperTransferType.receive]: 'Transfer' +}; + +export function getIconByKind(kind: FaceKind, transferType?: ActivityOperTransferType) { + if (kind === 'bundle') return DocumentsSvg; + + if (kind === ActivityOperKindEnum.interaction) return DocumentsSvg; + + if (kind === ActivityOperKindEnum.approve) return OkSvg; + + return transferType == null ? DocumentsSvg : TransferTypeIconSvg[transferType]; +} + +const TransferTypeIconSvg: Record = { + [ActivityOperTransferType.sendToAccount]: SendSvg, + [ActivityOperTransferType.receiveFromAccount]: IncomeSvg, + [ActivityOperTransferType.send]: DocumentsSvg, + [ActivityOperTransferType.receive]: DocumentsSvg +}; + +export const AssetIconPlaceholder: FC<{ isNFT: boolean; symbol?: string; className?: string }> = ({ + isNFT, + symbol, + className +}) => { + if (isNFT) return ; + + return symbol ? ( + + ) : ( + + ); +}; diff --git a/src/app/templates/activity/ActivityItem/AddressChip.tsx b/src/app/templates/activity/ActivityItem/AddressChip.tsx new file mode 100644 index 0000000000..29b044af5d --- /dev/null +++ b/src/app/templates/activity/ActivityItem/AddressChip.tsx @@ -0,0 +1,52 @@ +import React, { FC, useMemo } from 'react'; + +import { HashShortView, IconBase } from 'app/atoms'; +import { CopyButton } from 'app/atoms/CopyButton'; +import { ReactComponent as CopySvg } from 'app/icons/base/copy.svg'; +import { ActivityOperKindEnum, EvmOperation, TezosOperation, ActivityOperTransferType } from 'lib/activity'; + +interface Props { + operation: TezosOperation | EvmOperation; +} + +export const OperAddressChip: FC = ({ operation }) => { + const info = useMemo(() => { + switch (operation.kind) { + case ActivityOperKindEnum.approve: + return { title: 'For', address: operation.spenderAddress }; + case ActivityOperKindEnum.interaction: + return operation.withAddress ? { title: 'With', address: operation.withAddress } : undefined; + } + + if (operation.type === ActivityOperTransferType.send || operation.type === ActivityOperTransferType.sendToAccount) + return { title: 'To', address: operation.toAddress }; + + if ( + operation.type === ActivityOperTransferType.receive || + operation.type === ActivityOperTransferType.receiveFromAccount + ) + return { title: 'From', address: operation.fromAddress }; + + return null; + }, [operation]); + + if (!info) return null; + + return ( +
+ {info.title}: + + + + + + + + +
+ ); +}; diff --git a/src/app/templates/activity/ActivityItem/BundleModal.tsx b/src/app/templates/activity/ActivityItem/BundleModal.tsx new file mode 100644 index 0000000000..f4c0e0d70d --- /dev/null +++ b/src/app/templates/activity/ActivityItem/BundleModal.tsx @@ -0,0 +1,46 @@ +import React, { FC, useCallback, useMemo, useState } from 'react'; + +import { ActionsButtonsBox } from 'app/atoms/PageModal'; +import { ScrollView } from 'app/atoms/ScrollView'; +import { SimpleInfiniteScroll } from 'app/atoms/SimpleInfiniteScroll'; +import { StyledButtonAnchor } from 'app/atoms/StyledButton'; +import { T, formatDate } from 'lib/i18n'; + +interface Props { + addedAt: string; + blockExplorerUrl?: string; + children: React.ReactElement[]; +} + +const CHUNK_SIZE = 20; +const SCROLLABLE_ELEM_ID = 'ACTIVITY_BUNDLE_MODAL_SCROLL'; + +export const BundleModalContent: FC = ({ addedAt, blockExplorerUrl, children: items }) => { + const title = useMemo(() => formatDate(addedAt, 'PP'), [addedAt]); + + const [slicedItems, setSlicedItems] = useState(() => items.slice(0, CHUNK_SIZE)); + + const loadNext = useCallback(() => { + if (slicedItems.length === items.length) return; + + setSlicedItems(items.slice(0, slicedItems.length + CHUNK_SIZE)); + }, [slicedItems.length, items]); + + return ( + <> + + +
{title}
+ + {slicedItems} +
+
+ + + + + + + + ); +}; diff --git a/src/app/templates/activity/ActivityItem/EvmActivity.tsx b/src/app/templates/activity/ActivityItem/EvmActivity.tsx new file mode 100644 index 0000000000..c6528a5068 --- /dev/null +++ b/src/app/templates/activity/ActivityItem/EvmActivity.tsx @@ -0,0 +1,160 @@ +import React, { memo, useMemo } from 'react'; + +import { PageModal } from 'app/atoms/PageModal'; +import { EvmActivity, EvmActivityAsset } from 'lib/activity'; +import { isTransferActivityOperKind } from 'lib/activity/utils'; +import { fromAssetSlug, toEvmAssetSlug } from 'lib/assets/utils'; +import { useGetEvmChainAssetMetadata } from 'lib/metadata'; +import { useBooleanState } from 'lib/ui/hooks'; +import { ZERO } from 'lib/utils/numbers'; +import { makeBlockExplorerHref } from 'temple/front/block-explorers'; +import { BasicEvmChain } from 'temple/front/chains'; +import { useGetEvmActiveBlockExplorer } from 'temple/front/ready'; +import { TempleChainKind } from 'temple/types'; + +import { ActivityItemBaseAssetProp, ActivityOperationBaseComponent } from './ActivityOperationBase'; +import { BundleModalContent } from './BundleModal'; +import { EvmActivityOperationComponent } from './EvmActivityOperation'; + +interface Props { + activity: EvmActivity; + chain: BasicEvmChain; + assetSlug?: string; +} + +export const EvmActivityComponent = memo(({ activity, chain, assetSlug }) => { + const { hash, operations, operationsCount, status } = activity; + + const getEvmActiveBlockExplorer = useGetEvmActiveBlockExplorer(); + + const blockExplorerUrl = useMemo(() => { + const blockExplorerBaseUrl = getEvmActiveBlockExplorer(String(chain.chainId))?.url; + if (!blockExplorerBaseUrl) return; + + return makeBlockExplorerHref(blockExplorerBaseUrl, hash, 'tx', TempleChainKind.EVM); + }, [getEvmActiveBlockExplorer, hash, chain.chainId]); + + if (operationsCount === 1) { + const operation = operations.at(0); + + return ( + + ); + } + + return ( + + ); +}); + +interface BatchProps { + activity: EvmActivity; + chain: BasicEvmChain; + assetSlug?: string; + blockExplorerUrl?: string; +} + +const EvmActivityBatchComponent = memo(({ activity, chain, assetSlug, blockExplorerUrl }) => { + const [expanded, , , toggleExpanded] = useBooleanState(false); + + const { hash, operations, status } = activity; + + const getMetadata = useGetEvmChainAssetMetadata(chain.chainId); + + const faceSlug = useMemo(() => { + if (assetSlug) return assetSlug; + + for (const { kind, asset } of operations) { + if (asset?.amountSigned && Number(asset.amountSigned) !== 0 && isTransferActivityOperKind(kind)) { + const slug = toEvmAssetSlug(asset.contract, asset.tokenId); + + const decimals = getMetadata(slug)?.decimals ?? asset.decimals; + + if (decimals != null) return slug; + } + } + + return; + }, [getMetadata, operations, assetSlug]); + + const batchAsset = useMemo(() => { + if (!faceSlug) return; + + let faceAssetBase: EvmActivityAsset | undefined; + let faceAmount = ZERO; + + for (const { kind, asset } of operations) { + if ( + isTransferActivityOperKind(kind) && + asset?.amountSigned && + toEvmAssetSlug(asset.contract, asset.tokenId) === faceSlug + ) { + faceAmount = faceAmount.plus(asset.amountSigned); + if (!faceAssetBase) faceAssetBase = asset; + } + } + + const assetMetadata = getMetadata(faceSlug); + + const decimals = assetMetadata?.decimals ?? faceAssetBase?.decimals; + + if (decimals == null) return; + + const symbol = assetMetadata?.symbol || faceAssetBase?.symbol; + + const [contract, tokenId] = fromAssetSlug(faceSlug); + + return { + ...faceAssetBase, + contract, + tokenId, + amountSigned: faceAmount.toFixed(), + decimals, + symbol + }; + }, [getMetadata, operations, faceSlug]); + + return ( + <> + + + + {() => ( + + {operations.map((operation, j) => ( + + ))} + + )} + + + ); +}); diff --git a/src/app/templates/activity/ActivityItem/EvmActivityOperation.tsx b/src/app/templates/activity/ActivityItem/EvmActivityOperation.tsx new file mode 100644 index 0000000000..3cf9a91543 --- /dev/null +++ b/src/app/templates/activity/ActivityItem/EvmActivityOperation.tsx @@ -0,0 +1,67 @@ +import React, { memo, useMemo } from 'react'; + +import { ActivityOperKindEnum, EvmOperation, ActivityStatus } from 'lib/activity'; +import { toEvmAssetSlug } from 'lib/assets/utils'; +import { useEvmAssetMetadata } from 'lib/metadata'; +import { BasicEvmChain } from 'temple/front/chains'; + +import { getActivityOperTransferType } from '../utils'; + +import { ActivityItemBaseAssetProp, ActivityOperationBaseComponent } from './ActivityOperationBase'; +import { OperAddressChip } from './AddressChip'; + +interface Props { + hash: string; + operation?: EvmOperation; + chain: BasicEvmChain; + blockExplorerUrl?: string; + status?: ActivityStatus; + withoutAssetIcon?: boolean; + withoutOperHashChip?: boolean; +} + +export const EvmActivityOperationComponent = memo( + ({ hash, operation, chain, blockExplorerUrl, status, withoutAssetIcon, withoutOperHashChip }) => { + const assetBase = operation?.asset; + const assetSlug = assetBase?.contract ? toEvmAssetSlug(assetBase.contract) : undefined; + + const assetMetadata = useEvmAssetMetadata(assetSlug ?? '', chain.chainId); + + const asset = useMemo(() => { + if (!assetBase) return; + + const decimals = assetBase.amountSigned === null ? NaN : assetMetadata?.decimals ?? assetBase.decimals; + + if (decimals == null) return; + + const symbol = assetMetadata?.symbol || assetBase.symbol; + + const asset: ActivityItemBaseAssetProp = { + ...assetBase, + decimals, + symbol + }; + + return asset; + }, [assetMetadata, assetBase]); + + const addressChip = useMemo( + () => (withoutOperHashChip && operation ? : null), + [operation, withoutOperHashChip] + ); + + return ( + + ); + } +); diff --git a/src/app/templates/activity/ActivityItem/TezosActivity.tsx b/src/app/templates/activity/ActivityItem/TezosActivity.tsx new file mode 100644 index 0000000000..1e5ee2b2e0 --- /dev/null +++ b/src/app/templates/activity/ActivityItem/TezosActivity.tsx @@ -0,0 +1,130 @@ +import React, { memo, useMemo } from 'react'; + +import { PageModal } from 'app/atoms/PageModal'; +import { TezosActivity } from 'lib/activity'; +import { isTransferActivityOperKind } from 'lib/activity/utils'; +import { useGetChainTokenOrGasMetadata } from 'lib/metadata'; +import { useBooleanState } from 'lib/ui/hooks'; +import { ZERO } from 'lib/utils/numbers'; +import { makeBlockExplorerHref } from 'temple/front/block-explorers'; +import { BasicTezosChain } from 'temple/front/chains'; +import { useGetTezosActiveBlockExplorer } from 'temple/front/ready'; +import { TempleChainKind } from 'temple/types'; + +import { ActivityOperationBaseComponent } from './ActivityOperationBase'; +import { BundleModalContent } from './BundleModal'; +import { TezosActivityOperationComponent, buildTezosOperationAsset } from './TezosActivityOperation'; + +interface Props { + activity: TezosActivity; + chain: BasicTezosChain; + assetSlug?: string; +} + +export const TezosActivityComponent = memo(({ activity, chain, assetSlug }) => { + const { hash, operations, status, operationsCount } = activity; + + const getTezosActiveBlockExplorer = useGetTezosActiveBlockExplorer(); + + const blockExplorerUrl = useMemo(() => { + const blockExplorerBaseUrl = getTezosActiveBlockExplorer(chain.chainId)?.url; + if (!blockExplorerBaseUrl) return; + + return makeBlockExplorerHref(blockExplorerBaseUrl, hash, 'tx', TempleChainKind.Tezos); + }, [getTezosActiveBlockExplorer, hash, chain.chainId]); + + if (operationsCount === 1) { + const operation = operations.at(0); + + return ( + + ); + } + + return ( + + ); +}); + +interface BatchProps { + activity: TezosActivity; + chain: BasicTezosChain; + assetSlug?: string; + blockExplorerUrl?: string; +} + +const TezosActivityBatchComponent = memo(({ activity, chain, assetSlug, blockExplorerUrl }) => { + const [expanded, , , toggleExpanded] = useBooleanState(false); + + const { hash, operations, status } = activity; + + const getMetadata = useGetChainTokenOrGasMetadata(chain.chainId); + + const batchAsset = useMemo(() => { + const faceSlug = + assetSlug || + operations.find( + ({ kind, assetSlug, amountSigned }) => + assetSlug && + amountSigned && + Number(amountSigned) !== 0 && + isTransferActivityOperKind(kind) && + getMetadata(assetSlug) + )?.assetSlug; + + if (!faceSlug) return; + + let faceAmount = ZERO; + + for (const { kind, assetSlug, amountSigned } of operations) { + if (assetSlug === faceSlug && amountSigned && isTransferActivityOperKind(kind)) + faceAmount = faceAmount.plus(amountSigned); + } + + return buildTezosOperationAsset(faceSlug, getMetadata(faceSlug), faceAmount.toFixed()); + }, [getMetadata, operations, assetSlug]); + + return ( + <> + + + + {() => ( + + {operations.map((operation, j) => ( + + ))} + + )} + + + ); +}); diff --git a/src/app/templates/activity/ActivityItem/TezosActivityOperation.tsx b/src/app/templates/activity/ActivityItem/TezosActivityOperation.tsx new file mode 100644 index 0000000000..e44550a40f --- /dev/null +++ b/src/app/templates/activity/ActivityItem/TezosActivityOperation.tsx @@ -0,0 +1,72 @@ +import React, { memo, useMemo } from 'react'; + +import { ActivityOperKindEnum, TezosOperation, ActivityStatus } from 'lib/activity'; +import { fromAssetSlug } from 'lib/assets'; +import { AssetMetadataBase, isTezosCollectibleMetadata, useTezosAssetMetadata } from 'lib/metadata'; +import { BasicTezosChain } from 'temple/front/chains'; + +import { getActivityOperTransferType } from '../utils'; + +import { ActivityItemBaseAssetProp, ActivityOperationBaseComponent } from './ActivityOperationBase'; +import { OperAddressChip } from './AddressChip'; + +interface Props { + hash: string; + operation?: TezosOperation; + chain: BasicTezosChain; + status?: ActivityStatus; + blockExplorerUrl: string | nullish; + withoutAssetIcon?: boolean; + withoutOperHashChip?: boolean; +} + +export const TezosActivityOperationComponent = memo( + ({ hash, operation, chain, blockExplorerUrl, status, withoutAssetIcon, withoutOperHashChip }) => { + const assetSlug = operation?.assetSlug; + const assetMetadata = useTezosAssetMetadata(assetSlug ?? '', chain.chainId); + + const asset = useMemo( + () => (assetSlug ? buildTezosOperationAsset(assetSlug, assetMetadata, operation.amountSigned) : undefined), + [assetMetadata, operation, assetSlug] + ); + + const addressChip = useMemo( + () => (withoutOperHashChip && operation ? : null), + [operation, withoutOperHashChip] + ); + + return ( + + ); + } +); + +export function buildTezosOperationAsset( + assetSlug: string, + assetMetadata: AssetMetadataBase | undefined, + amountSigned: string | nullish +): ActivityItemBaseAssetProp | undefined { + const [contract, tokenId] = fromAssetSlug(assetSlug); + + const decimals = amountSigned === null ? NaN : assetMetadata?.decimals; + if (decimals == null) return; + + return { + contract, + tokenId, + amountSigned, + decimals, + symbol: assetMetadata?.symbol, + nft: assetMetadata ? isTezosCollectibleMetadata(assetMetadata) : undefined + }; +} diff --git a/src/app/templates/activity/ActivityItem/index.ts b/src/app/templates/activity/ActivityItem/index.ts new file mode 100644 index 0000000000..3b3b009344 --- /dev/null +++ b/src/app/templates/activity/ActivityItem/index.ts @@ -0,0 +1,2 @@ +export { TezosActivityComponent } from './TezosActivity'; +export { EvmActivityComponent } from './EvmActivity'; diff --git a/src/app/templates/activity/ActivityItem/pending-spin.svg b/src/app/templates/activity/ActivityItem/pending-spin.svg new file mode 100644 index 0000000000..07b3be96da --- /dev/null +++ b/src/app/templates/activity/ActivityItem/pending-spin.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/src/app/templates/activity/ActivityListContainer.tsx b/src/app/templates/activity/ActivityListContainer.tsx new file mode 100644 index 0000000000..f6f66d1017 --- /dev/null +++ b/src/app/templates/activity/ActivityListContainer.tsx @@ -0,0 +1,43 @@ +import React, { FC, useMemo } from 'react'; + +import { SuspenseContainer } from 'app/atoms/SuspenseContainer'; +import { useShouldShowPartnersPromoSelector } from 'app/store/partners-promotion/selectors'; +import { PartnersPromotion, PartnersPromotionVariant } from 'app/templates/partners-promotion'; +import { TEMPLE_TOKEN_SLUG } from 'lib/assets'; +import { t } from 'lib/i18n/react'; + +import { ReactivateAdsBanner } from './ReactivateAdsBanner'; + +interface Props extends PropsWithChildren { + chainId?: string | number; + assetSlug?: string; +} + +export const ActivityListContainer: FC = ({ children, chainId, assetSlug }) => { + const shouldShowPartnersPromo = useShouldShowPartnersPromoSelector(); + + const promotion = useMemo(() => { + if (shouldShowPartnersPromo) + return ( + + ); + + return assetSlug === TEMPLE_TOKEN_SLUG ? : null; + }, [shouldShowPartnersPromo, chainId, assetSlug]); + + return ( + +
+ {promotion} + + {children} +
+
+ ); +}; diff --git a/src/app/templates/activity/ActivityListView.tsx b/src/app/templates/activity/ActivityListView.tsx new file mode 100644 index 0000000000..62ab214968 --- /dev/null +++ b/src/app/templates/activity/ActivityListView.tsx @@ -0,0 +1,38 @@ +import React, { FC } from 'react'; + +import { EmptyState } from 'app/atoms/EmptyState'; +import { InfiniteScroll } from 'app/atoms/InfiniteScroll'; +import { Loader, PageLoader } from 'app/atoms/Loader'; + +interface Props { + activitiesNumber: number; + isSyncing: boolean; + reachedTheEnd: boolean; + loadNext: EmptyFn; +} + +export const ActivityListView: FC> = ({ + activitiesNumber, + isSyncing, + reachedTheEnd, + loadNext, + children +}) => { + if (activitiesNumber === 0) { + if (isSyncing) return ; + else if (reachedTheEnd) return ; + } + + return ( + } + > + {children} + + ); +}; diff --git a/src/app/templates/activity/EvmActivityList.tsx b/src/app/templates/activity/EvmActivityList.tsx new file mode 100644 index 0000000000..c3feb5951f --- /dev/null +++ b/src/app/templates/activity/EvmActivityList.tsx @@ -0,0 +1,101 @@ +import React, { FC, useMemo } from 'react'; + +import { DeadEndBoundaryError } from 'app/ErrorBoundary'; +import { useLoadPartnersPromo } from 'app/hooks/use-load-partners-promo'; +import { EvmActivity } from 'lib/activity'; +import { getEvmActivities } from 'lib/activity/evm/fetch'; +import { useAccountAddressForEvm } from 'temple/front'; +import { useEvmChainByChainId } from 'temple/front/chains'; + +import { EvmActivityComponent } from './ActivityItem'; +import { ActivityListView } from './ActivityListView'; +import { ActivitiesDateGroup, useGroupingByDate } from './grouping-by-date'; +import { RETRY_AFTER_ERROR_TIMEOUT, useActivitiesLoadingLogic } from './loading-logic'; +import { FilterKind, getActivityFilterKind } from './utils'; + +interface Props { + chainId: number; + assetSlug?: string; + filterKind?: FilterKind; +} + +export const EvmActivityList: FC = ({ chainId, assetSlug, filterKind }) => { + const network = useEvmChainByChainId(chainId); + const accountAddress = useAccountAddressForEvm(); + + if (!network || !accountAddress) throw new DeadEndBoundaryError(); + + useLoadPartnersPromo(); + + const { + activities, + isLoading, + reachedTheEnd, + error, + setActivities, + setIsLoading, + setReachedTheEnd, + setError, + loadNext + } = useActivitiesLoadingLogic( + async (initial, signal) => { + setIsLoading(true); + + const currActivities = initial ? [] : activities; + + const olderThanBlockHeight = currActivities.at(-1)?.blockHeight; + + try { + const newActivities = await getEvmActivities(chainId, accountAddress, assetSlug, olderThanBlockHeight, signal); + + if (signal.aborted) return; + + if (newActivities.length) setActivities(currActivities.concat(newActivities)); + else setReachedTheEnd(true); + } catch (error) { + if (signal.aborted) return; + + console.error(error); + + setError(error); + + setTimeout(() => { + if (!signal.aborted) setError(null); + }, RETRY_AFTER_ERROR_TIMEOUT); + } + + setIsLoading(false); + }, + [chainId, accountAddress, assetSlug] + ); + + const displayActivities = useMemo( + () => (filterKind ? activities.filter(a => getActivityFilterKind(a) === filterKind) : activities), + [activities, filterKind] + ); + + const groupedActivities = useGroupingByDate(displayActivities); + + const contentJsx = useMemo( + () => + groupedActivities.map(([dateStr, activities]) => ( + + {activities.map(activity => ( + + ))} + + )), + [groupedActivities, network, assetSlug] + ); + + return ( + + {contentJsx} + + ); +}; diff --git a/src/app/templates/activity/MoneyDiffView.tsx b/src/app/templates/activity/MoneyDiffView.tsx deleted file mode 100644 index 621c14704e..0000000000 --- a/src/app/templates/activity/MoneyDiffView.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React, { memo, useMemo } from 'react'; - -import BigNumber from 'bignumber.js'; -import classNames from 'clsx'; - -import Money from 'app/atoms/Money'; -import { useAppEnv } from 'app/env'; -import InFiat from 'app/templates/InFiat'; -import { useTezosAssetMetadata, getAssetSymbol } from 'lib/metadata'; - -interface Props { - tezosChainId: string; - assetId: string; - diff: string; - pending?: boolean; - className?: string; -} - -export const MoneyDiffView = memo(({ tezosChainId, assetId: assetSlug, diff, pending = false, className }) => { - const { popup } = useAppEnv(); - const metadata = useTezosAssetMetadata(assetSlug, tezosChainId); - - const diffBN = useMemo(() => new BigNumber(diff).div(metadata ? 10 ** metadata.decimals : 1), [diff, metadata]); - - const conditionalPopupClassName = popup ? 'text-xs' : 'text-sm'; - const conditionalDiffClassName = diffBN.gt(0) ? 'text-green-500' : 'text-red-700'; - const conditionalPendingClassName = pending ? 'text-yellow-600' : conditionalDiffClassName; - const showPlus = diffBN.gt(0) ? '+' : ''; - - return metadata ? ( -
-
- {showPlus} - {diffBN} - {getAssetSymbol(metadata, true)} -
- - {assetSlug && ( - - {({ balance, symbol }) => ( -
- {balance} - {symbol} -
- )} -
- )} -
- ) : null; -}); diff --git a/src/app/templates/activity/MultichainList.tsx b/src/app/templates/activity/MultichainList.tsx new file mode 100644 index 0000000000..1f2b86c9f6 --- /dev/null +++ b/src/app/templates/activity/MultichainList.tsx @@ -0,0 +1,236 @@ +import React, { memo, useMemo } from 'react'; + +import { useLoadPartnersPromo } from 'app/hooks/use-load-partners-promo'; +import { Activity, EvmActivity, TezosActivity } from 'lib/activity'; +import { getEvmActivities } from 'lib/activity/evm/fetch'; +import { parseTezosOperationsGroup } from 'lib/activity/tezos'; +import fetchTezosOperationsGroups from 'lib/activity/tezos/fetch'; +import { TzktApiChainId } from 'lib/apis/tzkt'; +import { isKnownChainId as isKnownTzktChainId } from 'lib/apis/tzkt/api'; +import { isTruthy } from 'lib/utils'; +import { + useAccountAddressForEvm, + useAccountAddressForTezos, + useAllEvmChains, + useAllTezosChains, + useEnabledEvmChains, + useEnabledTezosChains +} from 'temple/front'; + +import { EvmActivityComponent, TezosActivityComponent } from './ActivityItem'; +import { ActivityListView } from './ActivityListView'; +import { ActivitiesDateGroup, useGroupingByDate } from './grouping-by-date'; +import { useActivitiesLoadingLogic } from './loading-logic'; +import { FilterKind, getActivityFilterKind } from './utils'; + +interface Props { + filterKind?: FilterKind; +} + +export const MultichainActivityList = memo(({ filterKind }) => { + useLoadPartnersPromo(); + + const tezosChains = useEnabledTezosChains(); + const evmChains = useEnabledEvmChains(); + + const allTezosChains = useAllTezosChains(); + const allEvmChains = useAllEvmChains(); + + const tezAccAddress = useAccountAddressForTezos(); + const evmAccAddress = useAccountAddressForEvm(); + + const tezosLoaders = useMemo( + () => + tezAccAddress + ? tezosChains + .map(chain => + isKnownTzktChainId(chain.chainId) + ? new TezosActivityLoader(chain.chainId, tezAccAddress, chain.rpcBaseURL) + : null + ) + .filter(isTruthy) + : [], + [tezosChains, tezAccAddress] + ); + + const evmLoaders = useMemo( + () => (evmAccAddress ? evmChains.map(chain => new EvmActivityLoader(chain.chainId, evmAccAddress)) : []), + [evmChains, evmAccAddress] + ); + + const { activities, isLoading, reachedTheEnd, setActivities, setIsLoading, setReachedTheEnd, loadNext } = + useActivitiesLoadingLogic( + async (initial, signal) => { + if (signal.aborted) return; + + setIsLoading(true); + + const currActivities = initial ? [] : activities; + + const allLoaders = [...tezosLoaders, ...evmLoaders]; + const lastEdgeDate = currActivities.at(-1)?.addedAt; + + await Promise.allSettled( + evmLoaders + .map(l => l.loadNext(lastEdgeDate, signal)) + .concat(tezosLoaders.map(l => l.loadNext(lastEdgeDate, signal))) + ); + + if (signal.aborted) return; + + let edgeDate: string | undefined; + + for (const l of allLoaders) { + if (l.reachedTheEnd || l.lastError) continue; + + const lastAct = l.activities.at(-1); + if (!lastAct) continue; + + if (!edgeDate) { + edgeDate = lastAct.addedAt; + continue; + } + + if (lastAct.addedAt > edgeDate) edgeDate = lastAct.addedAt; + } + + const newActivities = allLoaders + .map(l => { + if (!edgeDate) return l.activities; + + // Not optimal, since activities are sorted already: + // return l.activities.filter(a => a.addedAt >= edgeDate); + + const lastIndex = l.activities.findLastIndex(a => a.addedAt >= edgeDate); + + return lastIndex === -1 ? [] : l.activities.slice(0, lastIndex + 1); + }) + .flat(); + + if (currActivities.length === newActivities.length) setReachedTheEnd(true); + else setActivities(newActivities); + + setIsLoading(false); + }, + [tezosLoaders, evmLoaders] + ); + + const displayActivities = useMemo(() => { + const filtered = filterKind ? activities.filter(act => getActivityFilterKind(act) === filterKind) : activities; + + return filtered.toSorted((a, b) => (a.addedAt < b.addedAt ? 1 : -1)); + }, [activities, filterKind]); + + const groupedActivities = useGroupingByDate(displayActivities); + + const contentJsx = useMemo( + () => + groupedActivities.map(([dateStr, activities]) => ( + + {activities.map(activity => + isTezosActivity(activity) ? ( + + ) : ( + + ) + )} + + )), + [groupedActivities, allTezosChains, allEvmChains] + ); + + return ( + + {contentJsx} + + ); +}); + +function isTezosActivity(activity: Activity): activity is TezosActivity { + return 'oldestTzktOperation' in activity; +} + +class EvmActivityLoader { + activities: EvmActivity[] = []; + reachedTheEnd = false; + lastError: unknown; + + constructor(readonly chainId: number, readonly accountAddress: string) {} + + async loadNext(edgeDate: string | undefined, signal: AbortSignal) { + if (edgeDate) { + const lastAct = this.activities.at(-1); + if (lastAct && lastAct.addedAt > edgeDate) return; + } + + try { + const { accountAddress, chainId } = this; + + if (this.reachedTheEnd || this.lastError) return; + + const olderThanBlockHeight = this.activities.at(this.activities.length - 1)?.blockHeight; + + const newActivities = await getEvmActivities(chainId, accountAddress, undefined, olderThanBlockHeight, signal); + + if (signal.aborted) return; + + if (newActivities.length) this.activities = this.activities.concat(newActivities); + else this.reachedTheEnd = true; + + delete this.lastError; + } catch (error) { + if (signal.aborted) return; + + console.error(error); + + this.lastError = error; + } + } +} + +class TezosActivityLoader { + activities: TezosActivity[] = []; + reachedTheEnd = false; + lastError: unknown; + + constructor(readonly chainId: TzktApiChainId, readonly accountAddress: string, private rpcBaseURL: string) {} + + async loadNext(edgeDate: string | undefined, signal: AbortSignal, assetSlug?: string) { + if (edgeDate) { + const lastAct = this.activities.at(-1); + if (lastAct && lastAct.addedAt > edgeDate) return; + } + + try { + const { accountAddress, chainId, rpcBaseURL } = this; + + const lastActivity = this.activities.at(-1); + + const groups = await fetchTezosOperationsGroups(chainId, rpcBaseURL, accountAddress, assetSlug, lastActivity); + + if (signal.aborted) return; + + const newActivities = groups.map(group => parseTezosOperationsGroup(group, chainId, accountAddress)); + + if (newActivities.length) this.activities = this.activities.concat(newActivities); + else this.reachedTheEnd = true; + + delete this.lastError; + } catch (error) { + if (signal.aborted) return; + + console.error(error); + + this.lastError = error; + } + } +} diff --git a/src/app/templates/activity/OperStack.tsx b/src/app/templates/activity/OperStack.tsx deleted file mode 100644 index 70c0a089ee..0000000000 --- a/src/app/templates/activity/OperStack.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React, { memo, useMemo, useState } from 'react'; - -import classNames from 'clsx'; - -import { OP_STACK_PREVIEW_SIZE } from 'app/defaults'; -import { ReactComponent as ChevronRightIcon } from 'app/icons/chevron-right.svg'; -import { ReactComponent as ChevronUpIcon } from 'app/icons/chevron-up.svg'; -import { T } from 'lib/i18n/react'; -import { OperStackItemInterface } from 'lib/temple/activity-new/types'; - -import { OperStackItem } from './OperStackItem'; - -interface Props { - operStack: OperStackItemInterface[]; - className?: string; -} - -export const OperStack = memo(({ operStack, className }) => { - const [expanded, setExpanded] = useState(false); - - const base = useMemo(() => operStack.filter((_, i) => i < OP_STACK_PREVIEW_SIZE), [operStack]); - const rest = useMemo(() => operStack.filter((_, i) => i >= OP_STACK_PREVIEW_SIZE), [operStack]); - - const ExpandIcon = expanded ? ChevronUpIcon : ChevronRightIcon; - - return ( -
- {base.map((item, i) => ( - - ))} - - {expanded && ( - <> - {rest.map((item, i) => ( - - ))} - - )} - - {rest.length > 0 && ( -
- -
- )} -
- ); -}); diff --git a/src/app/templates/activity/OperStackItem.tsx b/src/app/templates/activity/OperStackItem.tsx deleted file mode 100644 index e4394d7f57..0000000000 --- a/src/app/templates/activity/OperStackItem.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React, { memo } from 'react'; - -import { OldStyleHashChip } from 'app/atoms'; -import { ReactComponent as ClipboardIcon } from 'app/icons/clipboard.svg'; -import { TID, T } from 'lib/i18n'; -import { OperStackItemInterface, OperStackItemTypeEnum } from 'lib/temple/activity-new/types'; - -interface Props { - item: OperStackItemInterface; -} - -export const OperStackItem = memo(({ item }) => { - switch (item.type) { - case OperStackItemTypeEnum.Delegation: - return ( - } - argsNode={} - /> - ); - - case OperStackItemTypeEnum.Origination: - return } />; - - case OperStackItemTypeEnum.Interaction: - return ( - - - - - } - argsNode={} - /> - ); - - case OperStackItemTypeEnum.TransferFrom: - return ( - - ↓ - - } - argsNode={} - /> - ); - - case OperStackItemTypeEnum.TransferTo: - return ( - - ↑ - - } - argsNode={} - /> - ); - - case OperStackItemTypeEnum.Other: - return ( - `${w.charAt(0).toUpperCase()}${w.substring(1)}`) - .join(' ')} - /> - ); - } -}); - -interface StackItemBaseProps { - titleNode: React.ReactNode; - argsNode?: React.ReactNode; -} -const StackItemBase: React.FC = ({ titleNode, argsNode }) => { - return ( -
-
{titleNode}
- - {argsNode} -
- ); -}; - -interface StackItemArgsProps { - i18nKey: TID; - args: string[]; -} - -const StackItemArgs = memo(({ i18nKey, args }) => ( - - ( - - - {index === args.length - 1 ? null : ', '} - - ))} - /> - -)); diff --git a/src/app/templates/activity/TezosActivityList.tsx b/src/app/templates/activity/TezosActivityList.tsx new file mode 100644 index 0000000000..0429ca5328 --- /dev/null +++ b/src/app/templates/activity/TezosActivityList.tsx @@ -0,0 +1,114 @@ +import React, { memo, useMemo } from 'react'; + +import { DeadEndBoundaryError } from 'app/ErrorBoundary'; +import { useLoadPartnersPromo } from 'app/hooks/use-load-partners-promo'; +import { TezosActivity } from 'lib/activity'; +import { parseTezosOperationsGroup } from 'lib/activity/tezos'; +import fetchTezosOperationsGroups from 'lib/activity/tezos/fetch'; +import { isKnownChainId } from 'lib/apis/tzkt/api'; +import { useAccountAddressForTezos, useTezosChainByChainId } from 'temple/front'; + +import { TezosActivityComponent } from './ActivityItem'; +import { ActivityListView } from './ActivityListView'; +import { ActivitiesDateGroup, useGroupingByDate } from './grouping-by-date'; +import { RETRY_AFTER_ERROR_TIMEOUT, useActivitiesLoadingLogic } from './loading-logic'; +import { FilterKind, getActivityFilterKind } from './utils'; + +interface Props { + tezosChainId: string; + assetSlug?: string; + filterKind?: FilterKind; +} + +export const TezosActivityList = memo(({ tezosChainId, assetSlug, filterKind }) => { + const network = useTezosChainByChainId(tezosChainId); + const accountAddress = useAccountAddressForTezos(); + + if (!network || !accountAddress) throw new DeadEndBoundaryError(); + + useLoadPartnersPromo(); + + const { chainId, rpcBaseURL } = network; + + const { + activities, + isLoading, + reachedTheEnd, + error, + setActivities, + setIsLoading, + setReachedTheEnd, + setError, + loadNext + } = useActivitiesLoadingLogic( + async (initial, signal) => { + if (!isKnownChainId(chainId)) { + setIsLoading(false); + setReachedTheEnd(true); + return; + } + + setIsLoading(true); + + const currActivities = initial ? [] : activities; + + const olderThan = currActivities.at(-1); + + try { + const groups = await fetchTezosOperationsGroups(chainId, rpcBaseURL, accountAddress, assetSlug, olderThan); + + if (signal.aborted) return; + + const newActivities = groups.map(group => parseTezosOperationsGroup(group, chainId, accountAddress)); + + setActivities(currActivities.concat(newActivities)); + if (newActivities.length === 0) setReachedTheEnd(true); + } catch (error) { + if (signal.aborted) return; + + console.error(error); + + setError(error); + + setTimeout(() => { + if (!signal.aborted) setError(null); + }, RETRY_AFTER_ERROR_TIMEOUT); + } + + setIsLoading(false); + }, + [chainId, accountAddress, assetSlug], + undefined, + isKnownChainId(chainId) + ); + + const displayActivities = useMemo( + () => (filterKind ? activities.filter(act => getActivityFilterKind(act) === filterKind) : activities), + [activities, filterKind] + ); + + const groupedActivities = useGroupingByDate(displayActivities); + + const contentJsx = useMemo( + () => + groupedActivities.map(([dateStr, activities]) => ( + + {activities.map(activity => ( + + ))} + + )), + [groupedActivities, network, assetSlug] + ); + + return ( + + {contentJsx} + + ); +}); diff --git a/src/app/templates/activity/grouping-by-date.tsx b/src/app/templates/activity/grouping-by-date.tsx new file mode 100644 index 0000000000..441db80358 --- /dev/null +++ b/src/app/templates/activity/grouping-by-date.tsx @@ -0,0 +1,40 @@ +import React, { FC, useMemo } from 'react'; + +import { formatDate } from 'lib/i18n'; + +interface AddedAt { + /** ISO string */ + addedAt: string; +} + +export function useGroupingByDate(sorted: T[]) { + return useMemo(() => { + const groupedItems = new Map(); + + for (const item of sorted) { + const dateStr = formatDate(item.addedAt, 'PP'); + + const group = groupedItems.get(dateStr) ?? []; + + if (!groupedItems.has(dateStr)) groupedItems.set(dateStr, group); + + group.push(item); + } + + return Array.from(groupedItems); + }, [sorted]); +} + +interface ActivitiesDateGroupProps extends PropsWithChildren { + title: string; +} + +export const ActivitiesDateGroup: FC = ({ title, children }) => { + return ( + <> +
{title}
+ + {children} + + ); +}; diff --git a/src/app/templates/activity/index.ts b/src/app/templates/activity/index.ts new file mode 100644 index 0000000000..530b2f4cd6 --- /dev/null +++ b/src/app/templates/activity/index.ts @@ -0,0 +1,4 @@ +export { ActivityListContainer } from './ActivityListContainer'; +export { EvmActivityList } from './EvmActivityList'; +export { MultichainActivityList } from './MultichainList'; +export { TezosActivityList } from './TezosActivityList'; diff --git a/src/app/templates/activity/loading-logic.ts b/src/app/templates/activity/loading-logic.ts new file mode 100644 index 0000000000..92ecd5a3f0 --- /dev/null +++ b/src/app/templates/activity/loading-logic.ts @@ -0,0 +1,50 @@ +import { useDidMount, useDidUpdate, useSafeState, useAbortSignal } from 'lib/ui/hooks'; +import { useWillUnmount } from 'lib/ui/hooks/useWillUnmount'; + +export const RETRY_AFTER_ERROR_TIMEOUT = 5_000; + +export function useActivitiesLoadingLogic( + loadActivities: (initial: boolean, signal: AbortSignal) => Promise, + resetDeps: unknown[], + onReset?: EmptyFn, + initialIsLoading = true +) { + const [isLoading, setIsLoading] = useSafeState(initialIsLoading); + const [activities, setActivities] = useSafeState([]); + const [reachedTheEnd, setReachedTheEnd] = useSafeState(false); + const [error, setError] = useSafeState(null); + + const { abort: abortLoading, abortAndRenewSignal } = useAbortSignal(); + + function loadNext() { + if (isLoading || reachedTheEnd || error) return; + + loadActivities(false, abortAndRenewSignal()); + } + + useDidMount(() => void loadActivities(true, abortAndRenewSignal())); + + useWillUnmount(abortLoading); + + useDidUpdate(() => { + setActivities([]); + setIsLoading(true); + setReachedTheEnd(false); + setError(null); + onReset?.(); + + loadActivities(true, abortAndRenewSignal()); + }, resetDeps); + + return { + activities, + isLoading, + reachedTheEnd, + error, + setActivities, + setIsLoading, + setReachedTheEnd, + setError, + loadNext + }; +} diff --git a/src/app/templates/activity/utils.ts b/src/app/templates/activity/utils.ts new file mode 100644 index 0000000000..d2f40b07f2 --- /dev/null +++ b/src/app/templates/activity/utils.ts @@ -0,0 +1,40 @@ +import { ActivityOperKindEnum, Activity, ActivityOperTransferType, TezosOperation, EvmOperation } from 'lib/activity'; + +export type FaceKind = ActivityOperKindEnum | 'bundle'; + +export type FilterKind = 'send' | 'receive' | 'approve' | 'transfer' | 'bundle' | null; + +export function getActivityOperTransferType(operation?: TezosOperation | EvmOperation) { + if (operation?.kind !== ActivityOperKindEnum.transfer) return; + + return operation.type; +} + +export function getActivityFilterKind(activity: Activity): FilterKind { + const { operations, operationsCount } = activity; + + if (operationsCount !== 1) return 'bundle'; + + const operation = operations.at(0); + + if (!operation) return null; + + switch (operation.kind) { + case ActivityOperKindEnum.interaction: + return null; + case ActivityOperKindEnum.approve: + return 'approve'; + } + + switch (operation.type) { + case ActivityOperTransferType.send: + case ActivityOperTransferType.receive: + return 'transfer'; + case ActivityOperTransferType.sendToAccount: + return 'send'; + case ActivityOperTransferType.receiveFromAccount: + return 'receive'; + } + + return null; +} diff --git a/src/app/templates/enabling-setting.tsx b/src/app/templates/enabling-setting.tsx index ae331526aa..19f33d055a 100644 --- a/src/app/templates/enabling-setting.tsx +++ b/src/app/templates/enabling-setting.tsx @@ -1,7 +1,7 @@ import React, { ReactNode, memo } from 'react'; import { ToggleSwitch } from 'app/atoms'; -import { SettingsCell } from 'app/atoms/SettingsCell'; +import { SettingsCellSingle } from 'app/atoms/SettingsCell'; import { SettingsCellGroup } from 'app/atoms/SettingsCellGroup'; interface EnablingSettingProps { @@ -14,15 +14,16 @@ interface EnablingSettingProps { export const EnablingSetting = memo(({ title, enabled, description, onChange, testID }: EnablingSettingProps) => ( - + - - + + {null} - + )); diff --git a/src/app/templates/partners-promotion/components/hypelab-promotion/hypelab-image-promotion.tsx b/src/app/templates/partners-promotion/components/hypelab-promotion/hypelab-image-promotion.tsx index 61972725d2..e4c412775c 100644 --- a/src/app/templates/partners-promotion/components/hypelab-promotion/hypelab-image-promotion.tsx +++ b/src/app/templates/partners-promotion/components/hypelab-promotion/hypelab-image-promotion.tsx @@ -93,7 +93,7 @@ export const HypelabImagePromotion: FC diff --git a/src/app/templates/partners-promotion/components/text-promotion-view.tsx b/src/app/templates/partners-promotion/components/text-promotion-view.tsx index f53d7d1c75..a4e5771b50 100644 --- a/src/app/templates/partners-promotion/components/text-promotion-view.tsx +++ b/src/app/templates/partners-promotion/components/text-promotion-view.tsx @@ -60,7 +60,7 @@ export const TextPromotionView = memo( ; @@ -37,128 +38,131 @@ const shouldBeHiddenTemporarily = (hiddenAt: number) => { return Date.now() - hiddenAt < AD_HIDING_TIMEOUT; }; -export const PartnersPromotion = memo(({ variant, id, pageName, withPersonaProvider }) => { - const isImageAd = variant === PartnersPromotionVariant.Image; - const adsViewerAddress = useAdsViewerPkh(); - const { popup } = useAppEnv(); - const dispatch = useDispatch(); - const hiddenAt = usePromotionHidingTimestampSelector(id); - const shouldShowPartnersPromo = useShouldShowPartnersPromoSelector(); - - const isAnalyticsSentRef = useRef(false); - - const [isHiddenTemporarily, setIsHiddenTemporarily] = useState(shouldBeHiddenTemporarily(hiddenAt)); - const [providerName, setProviderName] = useState('Optimal'); - const [adError, setAdError] = useState(false); - const [adIsReady, setAdIsReady] = useState(false); - - useEffect(() => { - const newIsHiddenTemporarily = shouldBeHiddenTemporarily(hiddenAt); - setIsHiddenTemporarily(newIsHiddenTemporarily); - - if (newIsHiddenTemporarily) { - const timeout = setTimeout( - () => setIsHiddenTemporarily(false), - Math.max(Date.now() - hiddenAt + AD_HIDING_TIMEOUT, 0) - ); - - return () => clearTimeout(timeout); +export const PartnersPromotion = memo( + ({ variant, id, pageName, withPersonaProvider, className }) => { + const isImageAd = variant === PartnersPromotionVariant.Image; + const adsViewerAddress = useAdsViewerPkh(); + const { popup } = useAppEnv(); + const dispatch = useDispatch(); + const hiddenAt = usePromotionHidingTimestampSelector(id); + const shouldShowPartnersPromo = useShouldShowPartnersPromoSelector(); + + const isAnalyticsSentRef = useRef(false); + + const [isHiddenTemporarily, setIsHiddenTemporarily] = useState(shouldBeHiddenTemporarily(hiddenAt)); + const [providerName, setProviderName] = useState('Optimal'); + const [adError, setAdError] = useState(false); + const [adIsReady, setAdIsReady] = useState(false); + + useEffect(() => { + const newIsHiddenTemporarily = shouldBeHiddenTemporarily(hiddenAt); + setIsHiddenTemporarily(newIsHiddenTemporarily); + + if (newIsHiddenTemporarily) { + const timeout = setTimeout( + () => setIsHiddenTemporarily(false), + Math.max(Date.now() - hiddenAt + AD_HIDING_TIMEOUT, 0) + ); + + return () => clearTimeout(timeout); + } + + return; + }, [hiddenAt]); + + const handleAdRectSeen = useCallback(() => { + if (isAnalyticsSentRef.current) return; + + postAdImpression(adsViewerAddress, AdsProviderTitle[providerName], { pageName }); + + isAnalyticsSentRef.current = true; + }, [providerName, pageName, adsViewerAddress]); + + const handleClosePartnersPromoClick = useCallback>( + e => { + e.preventDefault(); + e.stopPropagation(); + dispatch(hidePromotionAction({ timestamp: Date.now(), id })); + }, + [id, dispatch] + ); + + const handleOptimalError = useCallback(() => setProviderName('HypeLab'), []); + const handleHypelabError = useCallback( + () => (withPersonaProvider ? setProviderName('Persona') : setAdError(true)), + [withPersonaProvider] + ); + const handlePersonaError = useCallback(() => setAdError(true), []); + + const handleAdReady = useCallback(() => setAdIsReady(true), []); + + if (!shouldShowPartnersPromo || adError || isHiddenTemporarily) { + return null; } - return; - }, [hiddenAt]); - - const handleAdRectSeen = useCallback(() => { - if (isAnalyticsSentRef.current) return; - - postAdImpression(adsViewerAddress, AdsProviderTitle[providerName], { pageName }); - - isAnalyticsSentRef.current = true; - }, [providerName, pageName, adsViewerAddress]); - - const handleClosePartnersPromoClick = useCallback>( - e => { - e.preventDefault(); - e.stopPropagation(); - dispatch(hidePromotionAction({ timestamp: Date.now(), id })); - }, - [id, dispatch] - ); - - const handleOptimalError = useCallback(() => setProviderName('HypeLab'), []); - const handleHypelabError = useCallback( - () => (withPersonaProvider ? setProviderName('Persona') : setAdError(true)), - [withPersonaProvider] - ); - const handlePersonaError = useCallback(() => setAdError(true), []); - - const handleAdReady = useCallback(() => setAdIsReady(true), []); - - if (!shouldShowPartnersPromo || adError || isHiddenTemporarily) { - return null; + return ( +
+ {(() => { + switch (providerName) { + case 'Optimal': + return ( + + ); + case 'HypeLab': + return ( + + ); + case 'Persona': + return ( + + ); + } + })()} + + {!adIsReady && ( +
+ +
+ )} +
+ ); } - - return ( -
- {(() => { - switch (providerName) { - case 'Optimal': - return ( - - ); - case 'HypeLab': - return ( - - ); - case 'Persona': - return ( - - ); - } - })()} - - {!adIsReady && ( -
- -
- )} -
- ); -}); +); diff --git a/src/app/templates/select-with-modal/index.tsx b/src/app/templates/select-with-modal/index.tsx index 9963a8cdc0..542528be92 100644 --- a/src/app/templates/select-with-modal/index.tsx +++ b/src/app/templates/select-with-modal/index.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from 'react'; import { Button, IconBase } from 'app/atoms'; -import { SettingsCell } from 'app/atoms/SettingsCell'; +import { SettingsCellSingle } from 'app/atoms/SettingsCell'; import { SettingsCellGroup } from 'app/atoms/SettingsCellGroup'; import { ReactComponent as CompactDownIcon } from 'app/icons/base/compact_down.svg'; import { InputContainer } from 'app/templates/InputContainer/InputContainer'; @@ -46,7 +46,7 @@ export const SelectWithModal = any)>({ <> {title}}> - } cellName={} @@ -54,7 +54,7 @@ export const SelectWithModal = any)>({ onClick={openSelectModal} > - + diff --git a/src/app/templates/select-with-modal/select-modal-option.tsx b/src/app/templates/select-with-modal/select-modal-option.tsx index 6639e2a073..ac32c0e5a3 100644 --- a/src/app/templates/select-with-modal/select-modal-option.tsx +++ b/src/app/templates/select-with-modal/select-modal-option.tsx @@ -1,7 +1,7 @@ import React, { ComponentType, useCallback } from 'react'; import { Button, IconBase } from 'app/atoms'; -import { SettingsCell } from 'app/atoms/SettingsCell'; +import { SettingsCellSingle } from 'app/atoms/SettingsCell'; import { SettingsCellGroup } from 'app/atoms/SettingsCellGroup'; import { ReactComponent as OkFillIcon } from 'app/icons/base/ok_fill.svg'; import { setTestID } from 'lib/analytics'; @@ -34,7 +34,7 @@ export const SelectModalOption = ({ return ( - } cellName={} @@ -42,7 +42,7 @@ export const SelectModalOption = ({ {...setTestID(testID)} > {isSelected && } - + ); }; diff --git a/src/app/templates/select-with-modal/select-modal.tsx b/src/app/templates/select-with-modal/select-modal.tsx index 00d1f3d157..25acc5a1c9 100644 --- a/src/app/templates/select-with-modal/select-modal.tsx +++ b/src/app/templates/select-with-modal/select-modal.tsx @@ -55,7 +55,7 @@ export const SelectModal = >({ {searchBarIsVisible && (
- +
)} diff --git a/src/lib/activity/evm/fetch.ts b/src/lib/activity/evm/fetch.ts new file mode 100644 index 0000000000..0ab9bbe0fe --- /dev/null +++ b/src/lib/activity/evm/fetch.ts @@ -0,0 +1,55 @@ +import { groupBy } from 'lodash'; + +import { fetchEvmTransactions } from 'lib/apis/temple/endpoints/evm'; +import { fromAssetSlug } from 'lib/assets'; +import { TempleChainKind } from 'temple/types'; + +import { EvmActivity } from '../types'; + +import { parseApprovalLog, parseTransfer } from './parse'; + +export async function getEvmActivities( + chainId: number, + accAddress: string, + assetSlug?: string, + olderThanBlockHeight?: `${number}`, + signal?: AbortSignal +) { + const accAddressLowercased = accAddress.toLowerCase(); + + const contractAddress = assetSlug ? fromAssetSlug(assetSlug)[0] : undefined; + + const { transfers, approvals: allApprovals } = await fetchEvmTransactions( + accAddress, + chainId, + contractAddress, + olderThanBlockHeight, + signal + ); + + if (!transfers.length && !allApprovals.length) return []; + + const groups = Object.entries(groupBy(transfers, 'hash')); + + return groups.map(([hash, transfers]) => { + const firstTransfer = transfers.at(0)!; + + const approvals = allApprovals.filter(a => a.transactionHash === hash).map(approval => parseApprovalLog(approval)); + + const operations = transfers + .map(transfer => parseTransfer(transfer, accAddressLowercased)) + .concat(approvals) + .sort((a, b) => a.logIndex - b.logIndex); + + return { + chain: TempleChainKind.EVM, + chainId, + hash, + // status: Not provided by the API. Those which `failed`, are included still. + addedAt: firstTransfer.metadata.blockTimestamp, + operations, + operationsCount: operations.length, + blockHeight: `${Number(firstTransfer.blockNum)}` + }; + }); +} diff --git a/src/lib/activity/evm/parse.ts b/src/lib/activity/evm/parse.ts new file mode 100644 index 0000000000..4746aefd99 --- /dev/null +++ b/src/lib/activity/evm/parse.ts @@ -0,0 +1,228 @@ +import { getAddress } from 'viem'; + +import { ActivityOperKindEnum, ActivityOperTransferType, EvmActivityAsset, EvmOperation } from 'lib/activity/types'; +import { AssetTransfersCategory, AssetTransfersWithMetadataResult, Log } from 'lib/apis/temple/endpoints/evm/alchemy'; +import { EVM_TOKEN_SLUG } from 'lib/assets/defaults'; + +export function parseTransfer(transfer: AssetTransfersWithMetadataResult, accAddress: string): EvmOperation { + const fromAddress = transfer.from; + const toAddress = transfer.to; + + const logIndex = getTransferLogIndex(transfer); + + if (!fromAddress || !toAddress) return buildInteraction(transfer, accAddress); + + // (!) Note: Cannot distinguish contract addresses here + const type = + fromAddress === accAddress ? ActivityOperTransferType.sendToAccount : ActivityOperTransferType.receiveFromAccount; + + if (transfer.category === AssetTransfersCategory.EXTERNAL) { + // fromAddress is an account's address for 'external' transfers + + const { decimal, value } = transfer.rawContract; + + const amount = value ? hexToStringInteger(value) : undefined; + const amountSigned = amount ? (fromAddress === accAddress ? `-${amount}` : amount) : undefined; + + const asset: EvmActivityAsset = { + contract: EVM_TOKEN_SLUG, + amountSigned, + symbol: transfer.asset ?? undefined, + decimals: decimal ? Number(decimal) : undefined + }; + + return { + kind: ActivityOperKindEnum.transfer, + type, + fromAddress, + toAddress, + asset, + logIndex + }; + } + + if (transfer.category === AssetTransfersCategory.INTERNAL) { + // fromAddress is contract address for 'internal' transfers + + const { decimal, value } = transfer.rawContract; + + const amount = value ? hexToStringInteger(value) : undefined; + const amountSigned = amount ? (fromAddress === accAddress ? `-${amount}` : amount) : undefined; + + const asset: EvmActivityAsset = { + contract: EVM_TOKEN_SLUG, + amountSigned, + symbol: transfer.asset ?? undefined, + decimals: decimal ? Number(decimal) : undefined + }; + + return { + kind: ActivityOperKindEnum.transfer, + type, + fromAddress, + toAddress, + asset, + logIndex + }; + } + + let contractAddress = transfer.rawContract.address; + const iconURL = contractAddress ? `https://logos.covalenthq.com/tokens/1/${contractAddress}.png` : undefined; + contractAddress = contractAddress ? getAddress(contractAddress) : null; + + if (transfer.category === AssetTransfersCategory.ERC721) { + if (!contractAddress) return buildInteraction(transfer, accAddress); + + const tokenId = transfer.erc721TokenId; + + const asset: EvmActivityAsset = { + contract: contractAddress, + tokenId: tokenId ? hexToStringInteger(tokenId) : undefined, + amountSigned: fromAddress === accAddress ? '-1' : '1', + symbol: transfer.asset ?? undefined, + decimals: 0, + nft: true, + iconURL + }; + + return { + kind: ActivityOperKindEnum.transfer, + type, + fromAddress, + toAddress, + asset, + logIndex + }; + } + + if (transfer.category === AssetTransfersCategory.ERC1155) { + const erc1155Metadata = transfer.erc1155Metadata?.at(0); + + if (!contractAddress || !erc1155Metadata) return buildInteraction(transfer, accAddress); + + const { tokenId, value } = erc1155Metadata; + + const amount = hexToStringInteger(value); + + const asset: EvmActivityAsset = { + contract: contractAddress, + tokenId: tokenId ? hexToStringInteger(tokenId) : undefined, + amountSigned: fromAddress === accAddress ? `-${amount}` : amount, + symbol: transfer.asset ?? undefined, + decimals: 0, + nft: true, + iconURL + }; + + return { + kind: ActivityOperKindEnum.transfer, + type, + fromAddress, + toAddress, + asset, + logIndex + }; + } + + if (transfer.category === AssetTransfersCategory.ERC20) { + if (!contractAddress) return buildInteraction(transfer, accAddress); + + const { decimal, value } = transfer.rawContract; + + const amount = value ? hexToStringInteger(value) : undefined; + const amountSigned = amount ? (fromAddress === accAddress ? `-${amount}` : amount) : undefined; + + const asset: EvmActivityAsset = { + contract: contractAddress, + amountSigned, + symbol: transfer.asset ?? undefined, + decimals: decimal ? Number(decimal) : undefined, + iconURL + }; + + return { + kind: ActivityOperKindEnum.transfer, + type, + fromAddress, + toAddress, + asset, + logIndex + }; + } + + if (transfer.category === AssetTransfersCategory.SPECIALNFT) { + if (!contractAddress) return buildInteraction(transfer, accAddress); + + const tokenId = transfer.tokenId; + + const { decimal, value } = transfer.rawContract; + + const amount = value ? hexToStringInteger(value) : undefined; + const amountSigned = amount ? (fromAddress === accAddress ? `-${amount}` : amount) : undefined; + + const asset: EvmActivityAsset = { + contract: contractAddress, + tokenId: tokenId ? hexToStringInteger(tokenId) : undefined, + amountSigned, + symbol: transfer.asset ?? undefined, + decimals: decimal ? Number(decimal) : undefined, + nft: true, + iconURL + }; + + return { + kind: ActivityOperKindEnum.transfer, + type, + fromAddress, + toAddress, + asset, + logIndex + }; + } + + return buildInteraction(transfer, accAddress); +} + +export function parseApprovalLog(approval: Log): EvmOperation { + const spenderAddress = '0x' + approval.topics.at(2)!.slice(26); + const logIndex = approval.logIndex; + + if (approval.topics[0] !== '0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925') { + // Not Approval, but ApprovalForAll method + if (approval.data.endsWith('0')) + return { kind: ActivityOperKindEnum.interaction, withAddress: approval.address, logIndex }; + + const asset: EvmActivityAsset = { + contract: approval.address, + amountSigned: null, + nft: true + }; + + return { kind: ActivityOperKindEnum.approve, spenderAddress, asset, logIndex }; + } + + const approvalOnERC721 = approval.topics.length === 4; + + const asset: EvmActivityAsset = { + contract: approval.address, + tokenId: approvalOnERC721 ? hexToStringInteger(approval.topics.at(3)!) : undefined, + amountSigned: approvalOnERC721 ? '1' : hexToStringInteger(approval.data), + nft: approvalOnERC721 ? true : undefined // Still exhaustive? + }; + + return { kind: ActivityOperKindEnum.approve, spenderAddress, asset, logIndex }; +} + +function buildInteraction(transfer: AssetTransfersWithMetadataResult, accAddress: string): EvmOperation { + const withAddress = transfer.from === accAddress ? transfer.to ?? undefined : transfer.from; + + return { kind: ActivityOperKindEnum.interaction, withAddress, logIndex: getTransferLogIndex(transfer) }; +} + +function hexToStringInteger(hex: string) { + return BigInt(hex).toString(); +} + +function getTransferLogIndex(transfer: AssetTransfersWithMetadataResult) { + return Number(transfer.uniqueId.split(':').at(2)) || -1; +} diff --git a/src/lib/activity/index.ts b/src/lib/activity/index.ts new file mode 100644 index 0000000000..f76626617f --- /dev/null +++ b/src/lib/activity/index.ts @@ -0,0 +1,3 @@ +export type { Activity, TezosActivity, EvmActivity, TezosOperation, EvmOperation, EvmActivityAsset } from './types'; + +export { ActivityOperKindEnum, ActivityOperTransferType, ActivityStatus } from './types'; diff --git a/src/lib/temple/activity-new/fetch.ts b/src/lib/activity/tezos/fetch.ts similarity index 89% rename from src/lib/temple/activity-new/fetch.ts rename to src/lib/activity/tezos/fetch.ts index e31526a9e9..abd27c34fa 100644 --- a/src/lib/temple/activity-new/fetch.ts +++ b/src/lib/activity/tezos/fetch.ts @@ -5,24 +5,21 @@ import { detectTokenStandard } from 'lib/assets/standards'; import { filterUnique } from 'lib/utils'; import { getReadOnlyTezos } from 'temple/tezos'; -import type { Activity, OperationsGroup } from './types'; -import { operationsGroupToActivity } from './utils'; +import type { TempleTzktOperationsGroup, TezosActivityOlderThan } from './types'; const LIQUIDITY_BAKING_DEX_ADDRESS = 'KT1TxqZ8QtKvLu3V3JH7Gx58n7Co8pgtpQU5'; -export default async function fetchActivities( +export default async function fetchTezosOperationsGroups( chainId: TzktApiChainId, rpcUrl: string, accountAddress: string, assetSlug: string | undefined, - pseudoLimit: number, - olderThan?: Activity -): Promise { + olderThan?: TezosActivityOlderThan, + pseudoLimit = 30 +) { const operations = await fetchOperations(chainId, rpcUrl, accountAddress, assetSlug, pseudoLimit, olderThan); - const groups = await fetchOperGroupsForOperations(chainId, operations, olderThan); - - return groups.map(group => operationsGroupToActivity(group, accountAddress)); + return await fetchOperGroupsForOperations(chainId, operations, olderThan); } /** @@ -37,7 +34,7 @@ async function fetchOperations( accAddress: string, assetSlug: string | undefined, pseudoLimit: number, - olderThan?: Activity + olderThan?: TezosActivityOlderThan ): Promise { if (assetSlug) { const [contractAddress, tokenId] = (assetSlug ?? '').split('_'); @@ -65,7 +62,7 @@ const fetchOperations_TEZ = ( chainId: TzktApiChainId, accountAddress: string, pseudoLimit: number, - olderThan?: Activity + olderThan?: TezosActivityOlderThan ) => TZKT.fetchGetOperationsTransactions(chainId, { 'anyof.sender.target.initiator': accountAddress, @@ -79,7 +76,7 @@ const fetchOperations_Contract = ( chainId: TzktApiChainId, accountAddress: string, pseudoLimit: number, - olderThan?: Activity + olderThan?: TezosActivityOlderThan ) => TZKT.fetchGetAccountOperations(chainId, accountAddress, { type: 'transaction', @@ -95,7 +92,7 @@ const fetchOperations_Token_Fa_1_2 = ( accountAddress: string, contractAddress: string, pseudoLimit: number, - olderThan?: Activity + olderThan?: TezosActivityOlderThan ) => TZKT.fetchGetOperationsTransactions(chainId, { limit: pseudoLimit, @@ -112,7 +109,7 @@ const fetchOperations_Token_Fa_2 = ( contractAddress: string, tokenId = '0', pseudoLimit: number, - olderThan?: Activity + olderThan?: TezosActivityOlderThan ) => TZKT.fetchGetOperationsTransactions(chainId, { limit: pseudoLimit, @@ -130,7 +127,7 @@ async function fetchOperations_Any( chainId: TzktApiChainId, accountAddress: string, pseudoLimit: number, - olderThan?: Activity + olderThan?: TezosActivityOlderThan ) { const limit = pseudoLimit; @@ -169,7 +166,7 @@ function fetchIncomingOperTransactions_Fa_1_2( chainId: TzktApiChainId, accountAddress: string, endLimitation: { limit: number } | { newerThen: string }, - olderThan?: Activity + olderThan?: TezosActivityOlderThan ) { const bottomParams = 'limit' in endLimitation ? endLimitation : { 'timestamp.ge': endLimitation.newerThen }; @@ -189,7 +186,7 @@ function fetchIncomingOperTransactions_Fa_2( chainId: TzktApiChainId, accountAddress: string, endLimitation: { limit: number } | { newerThen: string }, - olderThan?: Activity + olderThan?: TezosActivityOlderThan ) { const bottomParams = 'limit' in endLimitation ? endLimitation : { 'timestamp.ge': endLimitation.newerThen }; @@ -213,16 +210,16 @@ function fetchIncomingOperTransactions_Fa_2( async function fetchOperGroupsForOperations( chainId: TzktApiChainId, operations: TzktOperation[], - olderThan?: Activity + olderThan?: TezosActivityOlderThan ) { const uniqueHashes = filterUnique(operations.map(d => d.hash)); if (olderThan && uniqueHashes[0] === olderThan.hash) uniqueHashes.splice(1); - const groups: OperationsGroup[] = []; + const groups: TempleTzktOperationsGroup[] = []; for (const hash of uniqueHashes) { const operations = await TZKT.refetchOnce429(() => TZKT.fetchGetOperationsByHash(chainId, hash), 1000); - operations.sort((b, a) => a.id - b.id); + groups.push({ hash, operations @@ -237,4 +234,6 @@ async function fetchOperGroupsForOperations( * > `{"code":400,"errors":{"lastId":"The value '331626822238208' is not valid."}}` * > when it's not true! */ -const buildOlderThanParam = (olderThan?: Activity) => ({ 'timestamp.lt': olderThan?.oldestTzktOperation?.timestamp }); +const buildOlderThanParam = (olderThan?: TezosActivityOlderThan) => ({ + 'timestamp.lt': olderThan?.oldestTzktOperation?.timestamp +}); diff --git a/src/lib/activity/tezos/index.ts b/src/lib/activity/tezos/index.ts new file mode 100644 index 0000000000..7aa6a97cef --- /dev/null +++ b/src/lib/activity/tezos/index.ts @@ -0,0 +1,119 @@ +import { TempleTzktOperationsGroup, TezosPreActivityOperation } from 'lib/activity/tezos/types'; +import { toTezosAssetSlug } from 'lib/assets/utils'; +import { isTezosContractAddress } from 'lib/tezos'; +import { TempleChainKind } from 'temple/types'; + +import { + TezosActivity, + ActivityOperKindEnum, + TezosOperation, + ActivityStatus, + ActivityOperTransferType +} from '../types'; +import { isTransferActivityOperKind } from '../utils'; + +import { preparseTezosOperationsGroup } from './pre-parse'; + +export function parseTezosOperationsGroup( + operationsGroup: TempleTzktOperationsGroup, + chainId: string, + address: string +): TezosActivity { + const preActivity = preparseTezosOperationsGroup(operationsGroup, address, chainId); + + const { hash, addedAt, operations: preOperations, oldestTzktOperation, status } = preActivity; + + const operations = preOperations.map(oper => parseTezosPreActivityOperation(oper, address)); + + return { + hash, + chain: TempleChainKind.Tezos, + chainId, + operations, + operationsCount: preOperations.length, + addedAt, + oldestTzktOperation, + status: + status === 'applied' + ? ActivityStatus.applied + : status === 'pending' + ? ActivityStatus.pending + : ActivityStatus.failed + }; +} + +function parseTezosPreActivityOperation(preOperation: TezosPreActivityOperation, address: string): TezosOperation { + let tokenId: string | undefined; + + const operationBase: TezosOperation = (() => { + if (preOperation.type === 'transaction') { + tokenId = preOperation.tokenId; + + if (preOperation.subtype === 'approve' && preOperation.from.address !== address) + return { + kind: ActivityOperKindEnum.approve, + spenderAddress: preOperation.to.at(0)!.address + }; + + if (preOperation.subtype !== 'transfer' || isZero(preOperation.amountSigned)) + return { + kind: ActivityOperKindEnum.interaction, + withAddress: preOperation.destination.address + }; + + // subtype === 'transfer' below + + const fromAddress = preOperation.from.address; + const toAddress = preOperation.to.at(0)!.address; + + if (preOperation.from.address === address) + return { + kind: ActivityOperKindEnum.transfer, + type: + preOperation.to.length === 1 && !isTezosContractAddress(preOperation.to[0].address) + ? ActivityOperTransferType.sendToAccount + : ActivityOperTransferType.send, + fromAddress, + toAddress + }; + + if (preOperation.to.some(member => member.address === address)) + return { + kind: ActivityOperKindEnum.transfer, + type: isTezosContractAddress(preOperation.from.address) + ? ActivityOperTransferType.receive + : ActivityOperTransferType.receiveFromAccount, + fromAddress, + toAddress + }; + + return { + kind: ActivityOperKindEnum.interaction, + withAddress: preOperation.destination.address + }; + } + + if (preOperation.type === 'delegation' && preOperation.sender.address === address && preOperation.destination) { + return { + kind: ActivityOperKindEnum.interaction, + withAddress: preOperation.destination.address + }; + } + + return { + kind: ActivityOperKindEnum.interaction, + withAddress: preOperation.destination?.address + }; + })(); + + if (!preOperation.contract) return operationBase; + + if (isTransferActivityOperKind(operationBase.kind) || operationBase.kind === ActivityOperKindEnum.approve) { + operationBase.assetSlug = toTezosAssetSlug(preOperation.contract, tokenId); + operationBase.amountSigned = preOperation.amountSigned; + } + + return operationBase; +} + +const isZero = (val: string) => Number(val) === 0; diff --git a/src/lib/activity/tezos/pre-parse.ts b/src/lib/activity/tezos/pre-parse.ts new file mode 100644 index 0000000000..d419b8008b --- /dev/null +++ b/src/lib/activity/tezos/pre-parse.ts @@ -0,0 +1,279 @@ +import { TzktOperation, TzktTransactionOperation } from 'lib/apis/tzkt'; +import { + isTzktOperParam, + isTzktOperParam_Fa12, + isTzktOperParam_Fa2_approve, + isTzktOperParam_Fa2_transfer, + isTzktOperParam_LiquidityBaking, + ParameterFa2Transfer +} from 'lib/apis/tzkt/utils'; +import { TEZ_TOKEN_SLUG } from 'lib/assets'; +import { isTruthy } from 'lib/utils'; +import { ZERO } from 'lib/utils/numbers'; + +import { OperationMember } from '../types'; + +import { TempleTzktOperationsGroup } from './types'; +import type { + TezosPreActivityStatus, + TezosPreActivity, + TezosPreActivityOperationBase, + TezosPreActivityTransactionOperation, + TezosPreActivityOtherOperation, + TezosPreActivityOperation +} from './types'; + +export function preparseTezosOperationsGroup( + { hash, operations: groupOperations }: TempleTzktOperationsGroup, + address: string, + chainId: string +): TezosPreActivity { + const lastOperation = groupOperations[groupOperations.length - 1]!; + const addedAt = lastOperation.timestamp; + const operations = groupOperations.map(op => reduceOneTzktOperation(op, address)).filter(isTruthy); + const status = deriveActivityStatus(operations); + + return { + hash, + addedAt, + status, + operations, + oldestTzktOperation: lastOperation, + chainId + }; +} + +/** + * (i) Does not mutate operation object + */ +function reduceOneTzktOperation(operation: TzktOperation, address: string): TezosPreActivityOperation | null { + switch (operation.type) { + case 'transaction': + return reduceOneTzktTransactionOperation(address, operation); + case 'delegation': { + if (operation.sender.address !== address) return null; + + const activityOperBase = buildActivityOperBase(operation, '0', operation.sender.address === address); + const activityOper: TezosPreActivityOtherOperation = { + ...activityOperBase, + sender: operation.sender, + type: 'delegation' + }; + if (operation.newDelegate) activityOper.destination = operation.newDelegate; + return activityOper; + } + case 'origination': { + const amount = operation.contractBalance ? operation.contractBalance.toString() : '0'; + const activityOperBase = buildActivityOperBase(operation, amount, operation.sender.address === address); + const activityOper: TezosPreActivityOtherOperation = { + ...activityOperBase, + sender: operation.sender, + type: 'origination' + }; + if (operation.originatedContract) activityOper.destination = operation.originatedContract; + return activityOper; + } + default: + return null; + } +} + +function reduceOneTzktTransactionOperation( + address: string, + operation: TzktTransactionOperation +): TezosPreActivityTransactionOperation | null { + function _buildReturn(args: { + amount: string; + from: OperationMember; + to: OperationMember | string[]; + contract?: string; + tokenId?: string; + subtype?: TezosPreActivityTransactionOperation['subtype']; + }) { + const { amount, from, to, contract, tokenId, subtype } = args; + + const activityOperBase = buildActivityOperBase( + operation, + amount, + subtype === 'approve' ? false : from.address === address + ); + + const activityOper: TezosPreActivityTransactionOperation = { + ...activityOperBase, + type: 'transaction', + subtype, + destination: operation.target, + from, + to: Array.isArray(to) ? to.map(address => ({ address })) : [to], + contract, + tokenId + }; + + if (isTzktOperParam(operation.parameter)) activityOper.entrypoint = operation.parameter.entrypoint; + + return activityOper; + } + + const parameter = operation.parameter; + + if (parameter == null) { + if (operation.target.address !== address && operation.sender.address !== address) return null; + + const from = operation.sender; + const to = operation.target; + const amount = String(operation.amount); + const contract = TEZ_TOKEN_SLUG; + + return _buildReturn({ amount, from, to, contract, subtype: 'transfer' }); + } else if (isTzktOperParam_Fa2_transfer(parameter)) { + const values = reduceParameterFa2TransferValues(parameter.value, address); + const firstVal = values.at(0); + // (!) Here we abandon other but 1st non-zero-amount values + if (firstVal == null) return null; + + const contract = operation.target.address; + const amount = firstVal.amount; + const tokenId = firstVal.tokenId; + const from = { ...operation.sender, address: firstVal.fromAddress }; + const to = firstVal.toAddresses; + + return _buildReturn({ amount, from, to, contract, tokenId, subtype: 'transfer' }); + } else if (isTzktOperParam_Fa2_approve(parameter)) { + const add_operator = parameter.value[0].add_operator; + + const from = operation.sender; + const to = { address: add_operator.operator }; + const amount = String(operation.amount); + const contract = operation.target.address; + const tokenId = add_operator.token_id; + + return _buildReturn({ + amount, + from, + to, + subtype: 'approve', + contract, + tokenId + }); + } else if (isTzktOperParam_Fa12(parameter)) { + const amount = parameter.value.value; + const contract = operation.target.address; + + if (parameter.entrypoint === 'approve') { + if (amount === '0') return null; + + const from = operation.sender; + const to = { address: parameter.value.spender }; + + return _buildReturn({ amount, from, to, contract, subtype: 'approve' }); + } + + const from = { ...operation.sender, address: parameter.value.from }; + const to = { address: parameter.value.to }; + + return _buildReturn({ amount, from, to, contract, subtype: 'transfer' }); + } else if (isTzktOperParam_LiquidityBaking(parameter)) { + const from = operation.sender; + const to = operation.target; + const contract = operation.target.address; + const amount = parameter.value.quantity; + + return _buildReturn({ amount, from, to, contract, subtype: 'transfer' }); + } else { + const from = operation.sender; + const to = operation.target; + const amount = String(operation.amount); + + return _buildReturn({ amount, from, to }); + } +} + +function buildActivityOperBase(operation: TzktOperation, amount: string, from: boolean) { + const { id, level, sender, timestamp: addedAt } = operation; + const reducedOperation: TezosPreActivityOperationBase = { + id, + level, + sender, + amountSigned: from ? `-${amount}` : amount, + status: stringToActivityStatus(operation.status), + addedAt + }; + + return reducedOperation; +} + +interface ReducedParameterFa2Values { + fromAddress: string; + toAddresses: string[]; + amount: string; + tokenId: string; +} + +/** + * Items with zero cumulative amount value are filtered out + */ +function reduceParameterFa2TransferValues(values: ParameterFa2Transfer['value'], relAddress: string) { + const result: ReducedParameterFa2Values[] = []; + + for (const val of values) { + const firstTx = val.txs.at(0); + if (firstTx == null) continue; + + /* + We assume, that all `val.txs` items have same `token_id` value. + Visit https://tezos.b9lab.com/fa2 - There is a link to code in Smartpy IDE. + Fa2 token-standard/smartcontract literally has it in its code. + */ + const tokenId = firstTx.token_id; + + const fromAddress = val.from_; + + if (fromAddress === relAddress) { + let amount = ZERO; + const toAddresses = val.txs.map(tx => { + amount = amount.plus(tx.amount); + + return tx.to_; + }); + + if (amount.isZero()) continue; + + result.push({ + fromAddress, + toAddresses, + amount: amount.toFixed(), + tokenId + }); + + continue; + } + + const amount = val.txs.reduce((acc, tx) => (tx.to_ === relAddress ? acc.plus(tx.amount) : acc), ZERO); + + if (amount.isZero() === false) + result.push({ + fromAddress, + toAddresses: [relAddress], // Not interested in all the other `tx.to_`s at the moment + amount: amount.toFixed(), + tokenId + }); + } + + return result; +} + +function stringToActivityStatus(status: string): TezosPreActivityStatus { + if (['applied', 'backtracked', 'skipped', 'failed'].includes(status)) return status as TezosPreActivityStatus; + + return 'pending'; +} + +function deriveActivityStatus(items: { status: TezosPreActivityStatus }[]): TezosPreActivityStatus { + if (items.find(o => o.status === 'pending')) return 'pending'; + if (items.find(o => o.status === 'applied')) return 'applied'; + if (items.find(o => o.status === 'backtracked')) return 'backtracked'; + if (items.find(o => o.status === 'skipped')) return 'skipped'; + if (items.find(o => o.status === 'failed')) return 'failed'; + + return items[0]!.status; +} diff --git a/src/lib/activity/tezos/types.ts b/src/lib/activity/tezos/types.ts new file mode 100644 index 0000000000..590fa4ae5b --- /dev/null +++ b/src/lib/activity/tezos/types.ts @@ -0,0 +1,52 @@ +import type { TzktOperation, TzktOperationType } from 'lib/apis/tzkt'; + +import type { OperationMember } from '../types'; + +export interface TempleTzktOperationsGroup { + hash: string; + operations: TzktOperation[]; +} + +export type TezosPreActivityStatus = TzktOperation['status'] | 'pending'; + +export interface TezosActivityOlderThan { + hash: string; + oldestTzktOperation: TzktOperation; +} + +export interface TezosPreActivity extends TezosActivityOlderThan { + /** ISO string */ + addedAt: string; + status: TezosPreActivityStatus; + /** Sorted old-to-new */ + operations: TezosPreActivityOperation[]; + chainId: string; +} + +type PickedPropsFromTzktOperation = Pick; + +export interface TezosPreActivityOperationBase extends PickedPropsFromTzktOperation { + sender: OperationMember; + contract?: string; + tokenId?: string; + status: TezosPreActivityStatus; + amountSigned: string; + addedAt: string; +} + +export interface TezosPreActivityTransactionOperation extends TezosPreActivityOperationBase { + type: 'transaction'; + subtype?: 'transfer' | 'approve'; + from: OperationMember; + /** Optional - parser is not keeping all of `txs`'s `to_`s, reducing to total amount */ + to: OperationMember[]; + destination: OperationMember; + entrypoint?: string; +} + +export interface TezosPreActivityOtherOperation extends TezosPreActivityOperationBase { + type: Exclude; + destination?: OperationMember; +} + +export type TezosPreActivityOperation = TezosPreActivityTransactionOperation | TezosPreActivityOtherOperation; diff --git a/src/lib/activity/types.ts b/src/lib/activity/types.ts new file mode 100644 index 0000000000..cce7b274bc --- /dev/null +++ b/src/lib/activity/types.ts @@ -0,0 +1,116 @@ +import { TempleChainKind } from 'temple/types'; + +import { TezosActivityOlderThan } from './tezos/types'; + +export enum ActivityOperKindEnum { + interaction, + transfer, + approve +} + +export type Activity = TezosActivity | EvmActivity; + +interface ChainActivityBase { + chain: TempleChainKind; + hash: string; + /** Original, not filtered number of operations */ + operationsCount: number; + /** ISO string */ + addedAt: string; + status?: ActivityStatus; +} + +export enum ActivityStatus { + applied, + pending, + failed +} + +interface OperationBase { + kind: ActivityOperKindEnum; +} + +export interface TezosActivity extends ChainActivityBase, TezosActivityOlderThan { + chain: TempleChainKind.Tezos; + chainId: string; + operations: TezosOperation[]; +} + +interface TezosOperationBase extends OperationBase { + assetSlug?: string; + /** `null` for 'unlimited' amount */ + amountSigned?: string | null; +} + +interface TezosApproveOperation extends TezosOperationBase { + kind: ActivityOperKindEnum.approve; + spenderAddress: string; +} + +export enum ActivityOperTransferType { + send, + receive, + sendToAccount, + receiveFromAccount +} + +interface TezosTransferOperation extends TezosOperationBase { + kind: ActivityOperKindEnum.transfer; + type: ActivityOperTransferType; + fromAddress: string; + toAddress: string; +} + +interface TezosInteractionOperation extends TezosOperationBase { + kind: ActivityOperKindEnum.interaction; + withAddress?: string; +} + +export type TezosOperation = TezosApproveOperation | TezosTransferOperation | TezosInteractionOperation; + +export interface EvmActivity extends ChainActivityBase { + chain: TempleChainKind.EVM; + chainId: number; + operations: EvmOperation[]; + blockHeight: `${number}`; +} + +interface EvmOperationBase extends OperationBase { + asset?: EvmActivityAsset; + logIndex: number; +} + +interface EvmApproveOperation extends EvmOperationBase { + kind: ActivityOperKindEnum.approve; + spenderAddress: string; +} + +interface EvmTransferOperation extends EvmOperationBase { + kind: ActivityOperKindEnum.transfer; + type: ActivityOperTransferType; + toAddress: string; + fromAddress: string; +} + +interface EvmInteractionOperation extends EvmOperationBase { + kind: ActivityOperKindEnum.interaction; + withAddress?: string; +} + +export type EvmOperation = EvmApproveOperation | EvmTransferOperation | EvmInteractionOperation; + +export interface EvmActivityAsset { + contract: string; + tokenId?: string; + /** `null` for 'unlimited' amount */ + amountSigned?: string | null; + decimals?: number; + nft?: boolean; + symbol?: string; + iconURL?: string; +} + +export interface OperationMember { + address: string; + alias?: string; +} diff --git a/src/lib/activity/utils.ts b/src/lib/activity/utils.ts new file mode 100644 index 0000000000..f033b4334c --- /dev/null +++ b/src/lib/activity/utils.ts @@ -0,0 +1,5 @@ +import { ActivityOperKindEnum } from './types'; + +export function isTransferActivityOperKind(kind: ActivityOperKindEnum) { + return kind === ActivityOperKindEnum.transfer; +} diff --git a/src/lib/apis/temple/endpoints/evm/alchemy.ts b/src/lib/apis/temple/endpoints/evm/alchemy.ts new file mode 100644 index 0000000000..d4e01d150b --- /dev/null +++ b/src/lib/apis/temple/endpoints/evm/alchemy.ts @@ -0,0 +1,109 @@ +/** Types, taken from Alchemy SDK */ + +export enum AssetTransfersCategory { + /** + * Top level ETH transactions that occur where the `fromAddress` is an + * external user-created address. External addresses have private keys and are + * accessed by users. + */ + EXTERNAL = 'external', + /** + * Top level ETH transactions that occur where the `fromAddress` is an + * internal, smart contract address. For example, a smart contract calling + * another smart contract or sending + */ + INTERNAL = 'internal', + /** ERC20 transfers. */ + ERC20 = 'erc20', + /** ERC721 transfers. */ + ERC721 = 'erc721', + /** ERC1155 transfers. */ + ERC1155 = 'erc1155', + /** Special contracts that don't follow ERC 721/1155, (ex: CryptoKitties). */ + SPECIALNFT = 'specialnft' +} + +export interface AssetTransfersWithMetadataResult extends AssetTransfersResult { + /** Additional metadata about the transfer event. */ + metadata: AssetTransfersMetadata; +} + +interface AssetTransfersResult { + /** The unique ID of the transfer. */ + uniqueId: string; + /** The category of the transfer. */ + category: AssetTransfersCategory; + /** The block number where the transfer occurred. */ + blockNum: string; + /** The from address of the transfer. */ + from: string; + /** The to address of the transfer. */ + to: string | null; + /** + * Converted asset transfer value as a number (raw value divided by contract + * decimal). `null` if ERC721 transfer or contract decimal not available. + */ + value: number | null; + /** + * The raw ERC721 token id of the transfer as a hex string. `null` if not an + * ERC721 transfer. + */ + erc721TokenId: string | null; + /** + * A list of ERC1155 metadata objects if the asset transferred is an ERC1155 + * token. `null` if not an ERC1155 transfer. + */ + erc1155Metadata: ERC1155Metadata[] | null; + /** The token id of the token transferred. */ + tokenId: string | null; + /** + * Returns the token's symbol or ETH for other transfers. `null` if the + * information was not available. + */ + asset: string | null; + /** The transaction hash of the transfer transaction. */ + hash: string; + /** Information about the raw contract of the asset transferred. */ + rawContract: RawContract; +} + +interface ERC1155Metadata { + tokenId: string; + value: string; +} + +interface RawContract { + /** + * The raw transfer value as a hex string. `null` if the transfer was for an + * ERC721 or ERC1155 token. + */ + value: string | null; + /** The contract address. `null` if it was an internal or external transfer. */ + address: string | null; + /** + * The number of decimals in the contract as a hex string. `null` if the value + * is not in the contract and not available from other sources. + */ + decimal: string | null; +} + +interface AssetTransfersMetadata { + /** Timestamp of the block from which the transaction event originated. */ + blockTimestamp: string; +} + +export interface Log { + blockNumber: number; + blockHash: string; + transactionIndex: number; + + removed: boolean; + + address: string; + data: string; + + topics: Array; + + transactionHash: string; + logIndex: number; +} diff --git a/src/lib/apis/temple/endpoints/evm/api.interfaces.ts b/src/lib/apis/temple/endpoints/evm/api.interfaces.ts index fbb41cc879..1778e2c497 100644 --- a/src/lib/apis/temple/endpoints/evm/api.interfaces.ts +++ b/src/lib/apis/temple/endpoints/evm/api.interfaces.ts @@ -13,7 +13,7 @@ export const ChainIDs = [ 179188, 78432, 7777, 986532, 78430, 2195, 11111, 3012, 4337, 534352, 17000, 88, 89, 20765, 84532, 47279324479, 421614, 12027, 12028, 12029, 31330, 31331, 31332, 31333, 31334, 31335, 412346, 8545, 42220, 167008, 336, 999999999, 4200, 686868, 1992, 660279 -]; +] as const; export type ChainID = (typeof ChainIDs)[number]; diff --git a/src/lib/apis/temple/endpoints/evm/api.utils.ts b/src/lib/apis/temple/endpoints/evm/api.utils.ts index 1f9f75830d..30a951beb7 100644 --- a/src/lib/apis/temple/endpoints/evm/api.utils.ts +++ b/src/lib/apis/temple/endpoints/evm/api.utils.ts @@ -14,4 +14,8 @@ const chainIdNativeTokenAddressRecord: Record = { export const isNativeTokenAddress = (chainId: number, address: string) => address === (chainIdNativeTokenAddressRecord[chainId] ?? DEFAULT_NATIVE_TOKEN_ADDRESS); -export const isSupportedChainId = (chainId: number): chainId is ChainID => ChainIDs.includes(chainId); +export const isSupportedChainId = (chainId: number): chainId is ChainID => + ChainIDs.includes( + // @ts-expect-error + chainId + ); diff --git a/src/lib/apis/temple/endpoints/evm/api.ts b/src/lib/apis/temple/endpoints/evm/index.ts similarity index 50% rename from src/lib/apis/temple/endpoints/evm/api.ts rename to src/lib/apis/temple/endpoints/evm/index.ts index af8bec39ef..064b1d3161 100644 --- a/src/lib/apis/temple/endpoints/evm/api.ts +++ b/src/lib/apis/temple/endpoints/evm/index.ts @@ -1,5 +1,6 @@ import { templeWalletApi } from '../templewallet.api'; +import { AssetTransfersWithMetadataResult, Log } from './alchemy'; import { BalancesResponse, ChainID, NftAddressBalanceNftResponse } from './api.interfaces'; export const getEvmBalances = (walletAddress: string, chainId: ChainID) => @@ -12,10 +13,41 @@ export const getEvmTokensMetadata = (walletAddress: string, chainId: ChainID) => export const getEvmCollectiblesMetadata = (walletAddress: string, chainId: ChainID) => buildEvmRequest('/collectibles-metadata', walletAddress, chainId); -const buildEvmRequest = (url: string, walletAddress: string, chainId: ChainID) => +export const fetchEvmTransactions = ( + walletAddress: string, + chainId: number, + contractAddress: string | undefined, + olderThanBlockHeight?: `${number}`, + signal?: AbortSignal +) => + buildEvmRequest( + '/transactions/v2', + walletAddress, + chainId, + { + contractAddress, + olderThanBlockHeight + }, + signal + ); + +interface TransactionsResponse { + transfers: AssetTransfersWithMetadataResult[]; + /** These depend on the blocks gap of returned transfers. */ + approvals: Log[]; +} + +const buildEvmRequest = ( + path: string, + walletAddress: string, + chainId: number, + params?: object, + signal?: AbortSignal +) => templeWalletApi - .get(`evm${url}`, { - params: { walletAddress, chainId } + .get(`evm${path}`, { + params: { ...params, walletAddress, chainId }, + signal }) .then( res => res.data, diff --git a/src/lib/apis/tzkt/index.ts b/src/lib/apis/tzkt/index.ts index e47d4e4e16..e18edea9fd 100644 --- a/src/lib/apis/tzkt/index.ts +++ b/src/lib/apis/tzkt/index.ts @@ -2,7 +2,6 @@ export type { TzktOperation, TzktTokenTransfer, TzktRewardsEntry, - TzktAlias, TzktOperationType, TzktTransactionOperation } from './types'; diff --git a/src/lib/apis/tzkt/types.ts b/src/lib/apis/tzkt/types.ts index 884237653c..51bed52c75 100644 --- a/src/lib/apis/tzkt/types.ts +++ b/src/lib/apis/tzkt/types.ts @@ -9,7 +9,7 @@ export type TzktQuoteCurrency = 'None' | 'Btc' | 'Eur' | 'Usd' | 'Cny' | 'Jpy' | type TzktOperationStatus = 'applied' | 'failed' | 'backtracked' | 'skipped'; -export interface TzktAlias { +interface TzktAlias { alias?: string; address: string; } diff --git a/src/lib/apis/tzkt/utils.ts b/src/lib/apis/tzkt/utils.ts index 256244232a..ec60d48dfe 100644 --- a/src/lib/apis/tzkt/utils.ts +++ b/src/lib/apis/tzkt/utils.ts @@ -3,14 +3,22 @@ import { TzktAccount } from './types'; export const calcTzktAccountSpendableTezBalance = ({ balance, stakedBalance, unstakedBalance }: TzktAccount) => ((balance ?? 0) - (stakedBalance ?? 0) - (unstakedBalance ?? 0)).toFixed(); -type ParameterFa12 = { - entrypoint: string; - value: { - to: string; - from: string; - value: string; - }; -}; +type ParameterFa12 = + | { + entrypoint: 'transfer'; + value: { + to: string; + from: string; + value: string; + }; + } + | { + entrypoint: 'approve'; + value: { + spender: string; + value: string; + }; + }; interface Fa2Transaction { to_: string; @@ -18,22 +26,37 @@ interface Fa2Transaction { token_id: string; } -interface Fa2OpParams { - txs: Fa2Transaction[]; - from_: string; +interface ParameterFa2 { + entrypoint: string; + value: any[]; } -export type ParameterFa2 = { +export interface ParameterFa2Transfer extends ParameterFa2 { entrypoint: string; - value: Fa2OpParams[]; -}; -type ParameterLiquidityBaking = { + value: { + txs: Fa2Transaction[]; + from_: string; + }[]; +} + +interface ParameterFa2Approve extends ParameterFa2 { + entrypoint: 'update_operators'; + value: { + add_operator: { + operator: string; + owner: string; + token_id: string; + }; + }[]; +} + +interface ParameterLiquidityBaking { entrypoint: string; value: { target: string; quantity: string; // can be 'number' or '-number }; -}; +} export function isTzktOperParam(param: any): param is { entrypoint: string; @@ -47,9 +70,24 @@ export function isTzktOperParam(param: any): param is { export function isTzktOperParam_Fa12(param: any): param is ParameterFa12 { if (!isTzktOperParam(param)) return false; if (param.value == null) return false; - if (typeof param.value.to !== 'string') return false; - if (typeof param.value.from !== 'string') return false; - if (typeof param.value.value !== 'string') return false; + + if (param.entrypoint === 'approve') { + const { spender, value } = param.value; + + return typeof spender === 'string' && typeof value === 'string'; + } + + // 'transfer' case + + const { to, from, value } = param.value; + + return typeof from === 'string' && typeof to === 'string' && typeof value === 'string'; +} + +function isTzktOperParam_Fa2(param: any): param is ParameterFa2 { + if (!isTzktOperParam(param)) return false; + if (!Array.isArray(param.value)) return false; + if (param.value[0] == null) return true; return true; } @@ -58,9 +96,22 @@ export function isTzktOperParam_Fa12(param: any): param is ParameterFa12 { * (!) Might only refer to `param.entrypoint === 'transfer'` case * (?) So, would this check be enough? */ -export function isTzktOperParam_Fa2(param: any): param is ParameterFa2 { - if (!isTzktOperParam(param)) return false; - if (!Array.isArray(param.value)) return false; +export function isTzktOperParam_Fa2_approve(param: any): param is ParameterFa2Approve { + if (!isTzktOperParam_Fa2(param)) return false; + const add_operator = param.value[0]?.add_operator; + if (add_operator == null) return false; + + const { operator, owner, token_id } = add_operator; + + return typeof operator === 'string' && typeof owner === 'string' && typeof token_id === 'string'; +} + +/** + * (!) Might only refer to `param.entrypoint === 'transfer'` case + * (?) So, would this check be enough? + */ +export function isTzktOperParam_Fa2_transfer(param: any): param is ParameterFa2Transfer { + if (!isTzktOperParam_Fa2(param)) return false; let item = param.value[0]; if (item == null) return true; if (typeof item.from_ !== 'string') return false; diff --git a/src/lib/assets/utils.ts b/src/lib/assets/utils.ts index d1f5bfb8a8..325ca73db7 100644 --- a/src/lib/assets/utils.ts +++ b/src/lib/assets/utils.ts @@ -4,14 +4,20 @@ import type { AssetMetadataBase } from 'lib/metadata'; import { isTezosDcpChainId } from 'temple/networks'; import { TempleChainKind } from 'temple/types'; -import { TEZ_TOKEN_SLUG, TEZOS_SYMBOL, TEZOS_DCP_SYMBOL } from './defaults'; +import { TEZ_TOKEN_SLUG, EVM_TOKEN_SLUG, TEZOS_SYMBOL, TEZOS_DCP_SYMBOL } from './defaults'; import type { Asset, FA2Token } from './types'; const CHAIN_SLUG_SEPARATOR = ':'; export const getTezosGasSymbol = (chainId: string) => (isTezosDcpChainId(chainId) ? TEZOS_DCP_SYMBOL : TEZOS_SYMBOL); -export const toTokenSlug = (contract: string, id: string | number = 0) => `${contract}_${id}`; +export const toTokenSlug = (contract: string, id?: string | number) => `${contract}_${id || '0'}`; + +export const toTezosAssetSlug = (contract: string, id?: string) => + contract === TEZ_TOKEN_SLUG ? TEZ_TOKEN_SLUG : toTokenSlug(contract, id); + +export const toEvmAssetSlug = (contract: string, id?: string) => + contract === EVM_TOKEN_SLUG ? EVM_TOKEN_SLUG : toTokenSlug(contract, id); export const fromAssetSlug = (slug: string) => slug.split('_') as [contract: T, tokenId?: string]; diff --git a/src/lib/i18n/core.ts b/src/lib/i18n/core.ts index b95c43f20c..6b51f28a23 100644 --- a/src/lib/i18n/core.ts +++ b/src/lib/i18n/core.ts @@ -61,7 +61,7 @@ export function getMessage(messageName: string, substitutions?: Substitutions) { : browser.i18n.getMessage(messageName, substitutions) ?? ''; } -export function getDateFnsLocale() { +function getDateFnsLocale() { return dateFnsLocales[getCurrentLocale()] || enUS; } diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index aadbc536d6..b5545cbe6f 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -1,6 +1,6 @@ export type { TID } from './types'; -export { getMessage, getCurrentLocale, getDateFnsLocale, getNumberSymbols, formatDate } from './core'; +export { getMessage, getCurrentLocale, getNumberSymbols, formatDate } from './core'; export { updateLocale, onInited } from './loading'; diff --git a/src/lib/temple/front/identicon.ts b/src/lib/identicon.ts similarity index 57% rename from src/lib/temple/front/identicon.ts rename to src/lib/identicon.ts index ded1acd509..6fb9ca704e 100644 --- a/src/lib/temple/front/identicon.ts +++ b/src/lib/identicon.ts @@ -5,44 +5,47 @@ import memoizee from 'memoizee'; import * as firstLetters from 'lib/first-letters'; -export type IdenticonType = 'jdenticon' | 'botttsneutral' | 'initials'; +export type IdenticonImgType = 'jdenticon' | 'botttsneutral'; -export type IdenticonOptions = T extends 'jdenticon' +export type ImageIdenticonOptions = T extends 'jdenticon' ? Omit - : T extends 'botttsneutral' - ? Omit - : Omit; + : Omit; -const MAX_INITIALS_LENGTH = 5; -const DEFAULT_FONT_SIZE = 50; +export const buildImageIdenticonUri = memoizee(buildImageIdenticonUriLocal, { + max: 1024, + normalizer: ([hash, size, type, options]) => JSON.stringify([hash, size, type, options]) +}); -function internalGetIdenticonUri( +function buildImageIdenticonUriLocal( hash: string, size: number, type: T, - options?: IdenticonOptions + options?: ImageIdenticonOptions ) { - switch (type) { - case 'jdenticon': - // TODO: implement options interpretation for jdenticon - return `data:image/svg+xml,${encodeURIComponent(jdenticon.toSvg(hash, size))}`; - case 'botttsneutral': - return createAvatar(botttsNeutral, { seed: hash, size, ...options }).toDataUriSync(); - default: - return createAvatar(firstLetters, { - seed: hash, - size, - fontFamily: ['Menlo', 'Monaco', 'monospace'], - fontSize: estimateOptimalFontSize((options as firstLetters.Options | undefined)?.chars || hash.length), - ...options - }).toDataUriSync(); - } + if (type === 'botttsneutral') return createAvatar(botttsNeutral, { seed: hash, size, ...options }).toDataUriSync(); + + // TODO: implement options interpretation for jdenticon + return `data:image/svg+xml,${encodeURIComponent(jdenticon.toSvg(hash, size))}`; } -export const getIdenticonUri = memoizee(internalGetIdenticonUri, { - max: 1024, - normalizer: ([hash, size, type, options]) => JSON.stringify([hash, size, type, options]) -}); +export type InitialsIdenticonOptions = Omit; + +export const buildInitialsIdenticonUri = memoizee( + (seed: string, options?: InitialsIdenticonOptions) => + createAvatar(firstLetters, { + ...options, + seed, + fontFamily: ['Menlo', 'Monaco', 'monospace'], + fontSize: estimateOptimalFontSize(options?.chars ?? 2) + }).toDataUriSync(), + { + max: 1024, + normalizer: ([seed, options]) => JSON.stringify([seed, options]) + } +); + +const MAX_INITIALS_LENGTH = 5; +const DEFAULT_FONT_SIZE = 50; /** * Dicebear renders letters in a viewbox of 100x100 with a font size that is returned by this function. Let's ensure diff --git a/src/lib/images-uri.ts b/src/lib/images-uri.ts index 947fe73ab8..acc91e8e00 100644 --- a/src/lib/images-uri.ts +++ b/src/lib/images-uri.ts @@ -252,9 +252,7 @@ const getEvmCustomChainIconUrl = (chainId: number, metadata: EvmAssetMetadataBas : `${baseUrl}${chainName}/assets/${metadata.address}/logo.png`; }; -export const buildEvmTokenIconSources = (metadata: EvmAssetMetadataBase, chainId?: number) => { - if (!chainId) return []; - +export const buildEvmTokenIconSources = (metadata: EvmAssetMetadataBase, chainId: number) => { const mainFallback = getEvmCustomChainIconUrl(chainId, metadata); return mainFallback ? [getCompressedImageUrl(mainFallback, COMPRESSES_TOKEN_ICON_SIZE)] : []; diff --git a/src/lib/metadata/index.ts b/src/lib/metadata/index.ts index 1c309ab43b..80e916b0fb 100644 --- a/src/lib/metadata/index.ts +++ b/src/lib/metadata/index.ts @@ -1,6 +1,16 @@ import { useCallback, useEffect, useRef } from 'react'; import { dispatch } from 'app/store'; +import { + useEvmCollectibleMetadataSelector, + useEvmChainCollectiblesMetadataRecordSelector, + useEvmCollectiblesMetadataRecordSelector +} from 'app/store/evm/collectibles-metadata/selectors'; +import { + useEvmTokenMetadataSelector, + useEvmChainTokensMetadataRecordSelector, + useEvmTokensMetadataRecordSelector +} from 'app/store/evm/tokens-metadata/selectors'; import { loadCollectiblesMetadataAction } from 'app/store/tezos/collectibles-metadata/actions'; import { useAllCollectiblesMetadataSelector, @@ -17,14 +27,23 @@ import { METADATA_API_LOAD_CHUNK_SIZE } from 'lib/apis/temple'; import { isTezAsset } from 'lib/assets'; import { fromChainAssetSlug } from 'lib/assets/utils'; import { isTruthy } from 'lib/utils'; -import { useAllTezosChains } from 'temple/front'; +import { isEvmNativeTokenSlug } from 'lib/utils/evm.utils'; +import { useAllEvmChains, useAllTezosChains } from 'temple/front'; +import { useEvmChainByChainId } from 'temple/front/chains'; import { isTezosDcpChainId } from 'temple/networks'; import { TEZOS_METADATA, FILM_METADATA } from './defaults'; -import { AssetMetadataBase, TokenMetadata } from './types'; +import { + AssetMetadataBase, + EvmAssetMetadataBase, + EvmCollectibleMetadata, + EvmNativeTokenMetadata, + EvmTokenMetadata, + TokenMetadata +} from './types'; export type { AssetMetadataBase, TokenMetadata } from './types'; -export { isCollectible, isCollectibleTokenMetadata, getAssetSymbol, getTokenName } from './utils'; +export { isCollectible, isTezosCollectibleMetadata, getAssetSymbol, getTokenName } from './utils'; export { TEZOS_METADATA }; @@ -37,6 +56,49 @@ export const useTezosAssetMetadata = (slug: string, tezosChainId: string): Asset return isTezAsset(slug) ? getTezosGasMetadata(tezosChainId) : tokenMetadata || collectibleMetadata; }; +export const useEvmAssetMetadata = (slug: string, evmChainId: number): EvmAssetMetadataBase | undefined => { + const network = useEvmChainByChainId(evmChainId); + const tokenMetadata = useEvmTokenMetadataSelector(evmChainId, slug); + const collectibleMetadata = useEvmCollectibleMetadataSelector(evmChainId, slug); + + return isEvmNativeTokenSlug(slug) ? network?.currency : tokenMetadata || collectibleMetadata; +}; + +export const useGetEvmChainAssetMetadata = (chainId: number) => { + const network = useEvmChainByChainId(chainId); + const tokensMetadatas = useEvmChainTokensMetadataRecordSelector(chainId); + const collectiblesMetadatas = useEvmChainCollectiblesMetadataRecordSelector(chainId); + + return useCallback( + (slug: string) => { + if (isEvmNativeTokenSlug(slug)) return network?.currency; + + return tokensMetadatas?.[slug] || collectiblesMetadatas?.[slug]; + }, + [tokensMetadatas, collectiblesMetadatas, network] + ); +}; + +// @ ts-prune-ignore-next +export const useGetEvmAssetMetadata = () => { + const allEvmChains = useAllEvmChains(); + const tokensMetadatas = useEvmTokensMetadataRecordSelector(); + const collectiblesMetadatas = useEvmCollectiblesMetadataRecordSelector(); + + return useCallback( + (slug: string, chainId: number) => { + if (isEvmNativeTokenSlug(slug)) return allEvmChains[chainId]?.currency; + + return tokensMetadatas[chainId]?.[slug] || collectiblesMetadatas[chainId]?.[slug]; + }, + [tokensMetadatas, collectiblesMetadatas, allEvmChains] + ); +}; + +type EvmAssetMetadataGetter = ( + slug: string +) => EvmNativeTokenMetadata | EvmTokenMetadata | EvmCollectibleMetadata | undefined; + type TokenMetadataGetter = (slug: string) => TokenMetadata | undefined; export const useGetTokenMetadata = () => { diff --git a/src/lib/metadata/utils.ts b/src/lib/metadata/utils.ts index ca2f7df529..4cd54e115c 100644 --- a/src/lib/metadata/utils.ts +++ b/src/lib/metadata/utils.ts @@ -8,12 +8,15 @@ import { TokenMetadata, TezosTokenStandardsEnum, EvmTokenMetadata, - EvmCollectibleMetadata + EvmCollectibleMetadata, + EvmAssetMetadataBase } from './types'; -export function getAssetSymbol(metadata: EvmTokenMetadata | AssetMetadataBase | nullish, short = false) { - if (!metadata || !metadata.symbol) return '???'; +export function getAssetSymbol(metadata: EvmAssetMetadataBase | AssetMetadataBase | nullish, short = false) { + if (!metadata?.symbol) return '???'; + if (!short) return metadata.symbol; + return metadata.symbol === 'tez' ? TEZOS_SYMBOL : metadata.symbol.substring(0, 5); } @@ -36,9 +39,13 @@ export const isCollectible = (metadata: StringRecord) => /** * @deprecated // Assertion here is not safe! */ -export const isCollectibleTokenMetadata = (metadata: AssetMetadataBase): metadata is TokenMetadata => +export const isTezosCollectibleMetadata = (metadata: AssetMetadataBase): metadata is TokenMetadata => isCollectible(metadata); +/** TODO: Better way */ +export const isEvmCollectibleMetadata = (metadata: EvmAssetMetadataBase): metadata is EvmCollectibleMetadata => + 'image' in metadata; + export const buildTokenMetadataFromFetched = ( token: TokenMetadataResponse, address: string, diff --git a/src/lib/temple/activity-new/helpers.ts b/src/lib/temple/activity-new/helpers.ts deleted file mode 100644 index 7b7384a522..0000000000 --- a/src/lib/temple/activity-new/helpers.ts +++ /dev/null @@ -1,65 +0,0 @@ -import BigNumber from 'bignumber.js'; - -import type { Activity } from 'lib/temple/activity-new'; - -import { OperStackItemInterface, OperStackItemTypeEnum } from './types'; - -export function buildOperStack(activity: Activity, address: string) { - const opStack: OperStackItemInterface[] = []; - - for (const oper of activity.operations) { - if (oper.type === 'transaction') { - if (isZero(oper.amountSigned)) { - opStack.push({ - type: OperStackItemTypeEnum.Interaction, - with: oper.destination.address, - entrypoint: oper.entrypoint - }); - } else if (oper.source.address === address) { - opStack.push({ - type: OperStackItemTypeEnum.TransferTo, - to: oper.destination.address - }); - } else if (oper.destination.address === address) { - opStack.push({ - type: OperStackItemTypeEnum.TransferFrom, - from: oper.source.address - }); - } - } else if (oper.type === 'delegation' && oper.source.address === address && oper.destination) { - opStack.push({ - type: OperStackItemTypeEnum.Delegation, - to: oper.destination.address - }); - } else { - opStack.push({ - type: OperStackItemTypeEnum.Other, - name: oper.type - }); - } - } - - return opStack.sort((a, b) => a.type - b.type); -} - -interface MoneyDiff { - assetSlug: string; - diff: string; -} - -export function buildMoneyDiffs(activity: Activity) { - const diffs: MoneyDiff[] = []; - - for (const oper of activity.operations) { - if (oper.type !== 'transaction' || isZero(oper.amountSigned)) continue; - const assetSlug = oper.contractAddress == null ? 'tez' : toTokenSlug(oper.contractAddress, oper.tokenId); - const diff = new BigNumber(oper.amountSigned).toFixed(); - diffs.push({ assetSlug, diff }); - } - - return diffs; -} - -const isZero = (val: BigNumber.Value) => new BigNumber(val).isZero(); - -const toTokenSlug = (contractAddress: string, tokenId: string | number = 0) => `${contractAddress}_${tokenId}`; diff --git a/src/lib/temple/activity-new/hook.ts b/src/lib/temple/activity-new/hook.ts deleted file mode 100644 index 18c3ed927c..0000000000 --- a/src/lib/temple/activity-new/hook.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { isKnownChainId } from 'lib/apis/tzkt/api'; -import { useDidMount, useDidUpdate, useSafeState, useStopper } from 'lib/ui/hooks'; -import { TezosNetworkEssentials } from 'temple/networks'; - -import fetchActivities from './fetch'; -import type { Activity } from './types'; - -type TLoading = 'init' | 'more' | false; - -export default function useTezosActivities( - network: TezosNetworkEssentials, - accountAddress: string, - initialPseudoLimit: number, - assetSlug?: string -) { - const { chainId, rpcBaseURL } = network; - - const [loading, setLoading] = useSafeState(isKnownChainId(chainId) && 'init'); - const [activities, setActivities] = useSafeState([]); - const [reachedTheEnd, setReachedTheEnd] = useSafeState(false); - - const { stop: stopLoading, stopAndBuildChecker } = useStopper(); - - async function loadActivities(pseudoLimit: number, activities: Activity[], shouldStop: () => boolean) { - if (!isKnownChainId(chainId)) { - setLoading(false); - setReachedTheEnd(true); - return; - } - - setLoading(activities.length ? 'more' : 'init'); - const lastActivity = activities[activities.length - 1]; - - let newActivities: Activity[]; - try { - newActivities = await fetchActivities(chainId, rpcBaseURL, accountAddress, assetSlug, pseudoLimit, lastActivity); - if (shouldStop()) return; - } catch (error) { - if (shouldStop()) return; - setLoading(false); - console.error(error); - - return; - } - - setActivities(activities.concat(newActivities)); - setLoading(false); - if (newActivities.length === 0) setReachedTheEnd(true); - } - - /** Loads more of older items */ - function loadMore(pseudoLimit: number) { - if (loading || reachedTheEnd) return; - loadActivities(pseudoLimit, activities, stopAndBuildChecker()); - } - - useDidMount(() => { - loadActivities(initialPseudoLimit, [], stopAndBuildChecker()); - - return stopLoading; - }); - - useDidUpdate(() => { - setActivities([]); - setLoading('init'); - setReachedTheEnd(false); - - loadActivities(initialPseudoLimit, [], stopAndBuildChecker()); - }, [chainId, accountAddress, assetSlug]); - - return { - loading, - reachedTheEnd, - list: activities, - loadMore - }; -} diff --git a/src/lib/temple/activity-new/index.ts b/src/lib/temple/activity-new/index.ts deleted file mode 100644 index 1b18e5035f..0000000000 --- a/src/lib/temple/activity-new/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type { Activity } from './types'; - -export { buildOperStack, buildMoneyDiffs } from './helpers'; diff --git a/src/lib/temple/activity-new/types.ts b/src/lib/temple/activity-new/types.ts deleted file mode 100644 index 596a0400e5..0000000000 --- a/src/lib/temple/activity-new/types.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { TzktOperation, TzktAlias, TzktOperationType } from 'lib/apis/tzkt'; - -export interface OperationsGroup { - hash: string; - operations: TzktOperation[]; -} - -export type ActivityStatus = TzktOperation['status'] | 'pending'; - -export type ActivityMember = TzktAlias; -export interface Activity { - hash: string; - /** ISO string */ - addedAt: string; - status: ActivityStatus; - oldestTzktOperation: TzktOperation; - /** Sorted new-to-old */ - operations: ActivityOperation[]; -} - -type PickedPropsFromTzktOperation = Pick; - -export interface ActivityOperationBase extends PickedPropsFromTzktOperation { - contractAddress?: string; - source: ActivityMember; - status: ActivityStatus; - amountSigned: string; - addedAt: string; -} - -export interface ActivityTransactionOperation extends ActivityOperationBase { - type: 'transaction'; - destination: ActivityMember; - entrypoint?: string; - tokenId?: string; -} - -export interface ActivityOtherOperation extends ActivityOperationBase { - type: Exclude; - destination?: ActivityMember; -} - -export type ActivityOperation = ActivityTransactionOperation | ActivityOtherOperation; - -export enum OperStackItemTypeEnum { - TransferTo, - TransferFrom, - Delegation, - Interaction, - Origination, - Other -} - -export type OperStackItemInterface = - | TransferFromItem - | TransferToItem - | DelegationItem - | InteractionItem - | OriginationItem - | OtherItem; - -interface OperStackItemBase { - type: OperStackItemTypeEnum; -} - -interface TransferFromItem extends OperStackItemBase { - type: OperStackItemTypeEnum.TransferFrom; - from: string; -} - -interface TransferToItem extends OperStackItemBase { - type: OperStackItemTypeEnum.TransferTo; - to: string; -} - -interface DelegationItem extends OperStackItemBase { - type: OperStackItemTypeEnum.Delegation; - to: string; -} - -interface InteractionItem extends OperStackItemBase { - type: OperStackItemTypeEnum.Interaction; - with: string; - entrypoint?: string; -} - -interface OriginationItem extends OperStackItemBase { - type: OperStackItemTypeEnum.Origination; - contract?: string; -} - -interface OtherItem extends OperStackItemBase { - type: OperStackItemTypeEnum.Other; - name: TzktOperationType; -} diff --git a/src/lib/temple/activity-new/utils.ts b/src/lib/temple/activity-new/utils.ts deleted file mode 100644 index b8ea628f7d..0000000000 --- a/src/lib/temple/activity-new/utils.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { BigNumber } from 'bignumber.js'; - -import { TzktOperation, TzktTransactionOperation } from 'lib/apis/tzkt'; -import { - isTzktOperParam, - isTzktOperParam_Fa12, - isTzktOperParam_Fa2, - isTzktOperParam_LiquidityBaking, - ParameterFa2 -} from 'lib/apis/tzkt/utils'; -import { isTruthy } from 'lib/utils'; - -import type { - OperationsGroup, - ActivityStatus, - Activity, - ActivityOperationBase, - ActivityTransactionOperation, - ActivityOtherOperation, - ActivityOperation, - ActivityMember -} from './types'; - -export function operationsGroupToActivity({ hash, operations }: OperationsGroup, address: string): Activity { - const firstOperation = operations[0]!; - const oldestTzktOperation = operations[operations.length - 1]!; - const addedAt = firstOperation.timestamp; - const activityOperations = reduceTzktOperations(operations, address); - const status = deriveActivityStatus(activityOperations); - - return { - hash, - addedAt, - status, - operations: activityOperations, - oldestTzktOperation - }; -} - -function reduceTzktOperations(operations: TzktOperation[], address: string): ActivityOperation[] { - const reducedOperations = operations.map(op => reduceOneTzktOperation(op, address)).filter(isTruthy); - - return reducedOperations; -} - -/** - * (i) Does not mutate operation object - */ -function reduceOneTzktOperation(operation: TzktOperation, address: string): ActivityOperation | null { - switch (operation.type) { - case 'transaction': - return reduceOneTzktTransactionOperation(address, operation); - case 'delegation': { - if (operation.sender.address !== address) return null; - - const activityOperBase = buildActivityOperBase(operation, address, '0', operation.sender); - const activityOper: ActivityOtherOperation = { - ...activityOperBase, - type: 'delegation' - }; - if (operation.newDelegate) activityOper.destination = operation.newDelegate; - return activityOper; - } - case 'origination': { - const source = operation.sender; - const amount = operation.contractBalance ? operation.contractBalance.toString() : '0'; - const activityOperBase = buildActivityOperBase(operation, address, amount, source); - const activityOper: ActivityOtherOperation = { - ...activityOperBase, - type: 'origination' - }; - if (operation.originatedContract) activityOper.destination = operation.originatedContract; - return activityOper; - } - default: - return null; - } -} - -function reduceOneTzktTransactionOperation( - address: string, - operation: TzktTransactionOperation -): ActivityTransactionOperation | null { - function _buildReturn(args: { amount: string; source: ActivityMember; contractAddress?: string; tokenId?: string }) { - const { amount, source, contractAddress, tokenId } = args; - const activityOperBase = buildActivityOperBase(operation, address, amount, source); - const activityOper: ActivityTransactionOperation = { - ...activityOperBase, - type: 'transaction', - destination: operation.target - }; - if (contractAddress != null) activityOper.contractAddress = contractAddress; - if (tokenId != null) activityOper.tokenId = tokenId; - if (isTzktOperParam(operation.parameter)) activityOper.entrypoint = operation.parameter.entrypoint; - return activityOper; - } - - const parameter = operation.parameter; - - if (parameter == null) { - if (operation.target.address !== address && operation.sender.address !== address) return null; - - const source = operation.sender; - const amount = String(operation.amount); - - return _buildReturn({ amount, source }); - } else if (isTzktOperParam_Fa2(parameter)) { - const values = reduceParameterFa2Values(parameter.value, address); - const firstVal = values[0]; - // (!) Here we abandon other but 1st non-zero-amount values - if (firstVal == null) return null; - - const contractAddress = operation.target.address; - const amount = firstVal.amount; - const tokenId = firstVal.tokenId; - const source = firstVal.from === address ? { ...operation.sender, address } : operation.sender; - - return _buildReturn({ amount, source, contractAddress, tokenId }); - } else if (isTzktOperParam_Fa12(parameter)) { - if (parameter.entrypoint === 'approve') return null; - - const source = { ...operation.sender }; - if (parameter.value.from === address) source.address = address; - else if (parameter.value.to === address) source.address = parameter.value.from; - else return null; - - const contractAddress = operation.target.address; - const amount = parameter.value.value; - - return _buildReturn({ amount, source, contractAddress }); - } else if (isTzktOperParam_LiquidityBaking(parameter)) { - const source = operation.sender; - const contractAddress = operation.target.address; - const amount = parameter.value.quantity; - - return _buildReturn({ amount, source, contractAddress }); - } else { - const source = operation.sender; - const amount = String(operation.amount); - - return _buildReturn({ amount, source }); - } -} - -function buildActivityOperBase(operation: TzktOperation, address: string, amount: string, source: ActivityMember) { - const { id, level, timestamp: addedAt } = operation; - const reducedOperation: ActivityOperationBase = { - id, - level, - source, - amountSigned: source.address === address ? `-${amount}` : amount, - status: stringToActivityStatus(operation.status), - addedAt - }; - return reducedOperation; -} - -/** - * Items with zero cumulative amount value are filtered out - */ -function reduceParameterFa2Values(values: ParameterFa2['value'], relAddress: string) { - const result: { - from: string; - amount: string; - tokenId: string; - }[] = []; - - for (const val of values) { - /* - We assume, that all `val.txs` items have same `token_id` value. - Visit https://tezos.b9lab.com/fa2 - There is a link to code in Smartpy IDE. - Fa2 token-standard/smartcontract literally has it in its code. - */ - - const from = val.from_; - if (val.from_ === relAddress) { - const amount = val.txs.reduce((acc, tx) => acc.plus(tx.amount), new BigNumber(0)); - if (amount.isZero()) continue; - result.push({ - from, - amount: amount.toFixed(), - tokenId: val.txs[0]!.token_id - }); - continue; - } - let isValRel = false; - let amount = new BigNumber(0); - for (const tx of val.txs) { - if (tx.to_ === relAddress) { - amount = amount.plus(tx.amount); - if (isValRel === false) isValRel = true; - } - } - if (isValRel && amount.isZero() === false) - result.push({ - from, - amount: amount.toFixed(), - tokenId: val.txs[0]!.token_id - }); - } - - return result; -} - -function stringToActivityStatus(status: string): ActivityStatus { - if (['applied', 'backtracked', 'skipped', 'failed'].includes(status)) return status as ActivityStatus; - - return 'pending'; -} - -function deriveActivityStatus(items: { status: ActivityStatus }[]): ActivityStatus { - if (items.find(o => o.status === 'pending')) return 'pending'; - if (items.find(o => o.status === 'applied')) return 'applied'; - if (items.find(o => o.status === 'backtracked')) return 'backtracked'; - if (items.find(o => o.status === 'skipped')) return 'skipped'; - if (items.find(o => o.status === 'failed')) return 'failed'; - - return items[0]!.status; -} diff --git a/src/lib/temple/front/index.ts b/src/lib/temple/front/index.ts index 2a0c8ae491..4618244eb2 100644 --- a/src/lib/temple/front/index.ts +++ b/src/lib/temple/front/index.ts @@ -17,5 +17,3 @@ export { TempleProvider } from './provider'; export { validateDelegate } from './validate-delegate'; export { validateRecipient } from './validate-recipient'; - -export { type IdenticonType, getIdenticonUri } from './identicon'; diff --git a/src/lib/temple/front/storage.ts b/src/lib/temple/front/storage.ts index 13ca38f245..8ebf228116 100644 --- a/src/lib/temple/front/storage.ts +++ b/src/lib/temple/front/storage.ts @@ -65,18 +65,15 @@ export function usePassiveStorage(key: string, fallback?: T) { } function onStorageChanged(key: string, callback: (newValue: T) => void) { - const handleChanged = ( - changes: { - [s: string]: Storage.StorageChange; - }, - areaName: string - ) => { - if (areaName === 'local' && key in changes) { - callback(changes[key].newValue); + const handleChanged = ((changes: { [s: string]: Storage.StorageChange }) => { + if (key in changes) { + callback(changes[key].newValue as any); } - }; + }) as unknown as (changes: Storage.StorageAreaOnChangedChangesType) => void; - browser.storage.onChanged.addListener(handleChanged); + // (!) Do not sub to all storages at once (via `browser.storage.onChanged`). + // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1838448#c14 + browser.storage.local.onChanged.addListener(handleChanged); - return () => browser.storage.onChanged.removeListener(handleChanged); + return () => browser.storage.local.onChanged.removeListener(handleChanged); } diff --git a/src/lib/ui/ImageStacked.tsx b/src/lib/ui/ImageStacked.tsx index 327a7c71df..a2462dd804 100644 --- a/src/lib/ui/ImageStacked.tsx +++ b/src/lib/ui/ImageStacked.tsx @@ -9,6 +9,7 @@ export interface ImageStackedProps extends React.ImgHTMLAttributes = ({ sources, + size, loader, fallback, style, @@ -36,8 +38,12 @@ export const ImageStacked: FC = ({ width: 0, height: 0 } - : style, - [style, isLoading] + : { + width: size, + height: size, + ...style + }, + [style, isLoading, size] ); const onStackLoadedRef = useRef(onStackLoaded); diff --git a/src/lib/ui/filter-networks-by-name.ts b/src/lib/ui/filter-networks-by-name.ts deleted file mode 100644 index 3efd7feba0..0000000000 --- a/src/lib/ui/filter-networks-by-name.ts +++ /dev/null @@ -1,11 +0,0 @@ -type SearchNetwork = string | { name: string }; - -export const filterNetworksByName = (networks: T[], searchValue: string) => { - const preparedSearchValue = searchValue.trim().toLowerCase(); - - return networks.filter(network => { - if (typeof network === 'string') return network.toLowerCase().includes(preparedSearchValue); - - return network.name.toLowerCase().includes(preparedSearchValue); - }); -}; diff --git a/src/lib/ui/hooks/index.ts b/src/lib/ui/hooks/index.ts index d7537d81fa..80987c6a3a 100644 --- a/src/lib/ui/hooks/index.ts +++ b/src/lib/ui/hooks/index.ts @@ -10,7 +10,7 @@ export { useTimeout } from './useTimeout'; export { useInterval } from './useInterval'; -export { useStopper } from './useStopper'; +export { useAbortSignal } from './useAbortSignal'; export { useMemoWithCompare } from './useMemoWithCompare'; @@ -18,6 +18,4 @@ export { useVanishingState } from './useVanishingState'; export { useBooleanState } from './use-boolean-state'; -export { useAbortSignal } from './use-abort-signal'; - export { useShowErrorIfOnBlur } from './use-show-error-if-on-blur'; diff --git a/src/lib/ui/hooks/use-abort-signal.ts b/src/lib/ui/hooks/useAbortSignal.ts similarity index 100% rename from src/lib/ui/hooks/use-abort-signal.ts rename to src/lib/ui/hooks/useAbortSignal.ts diff --git a/src/lib/ui/hooks/useStopper.ts b/src/lib/ui/hooks/useStopper.ts deleted file mode 100644 index 696c3664a5..0000000000 --- a/src/lib/ui/hooks/useStopper.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useMemo, useRef } from 'react'; - -export function useStopper() { - const symbolRef = useRef(null); - - return useMemo(() => { - const updateSymbolRef = () => (symbolRef.current = Symbol()); - - return { - stop: () => void updateSymbolRef(), - stopAndBuildChecker: () => { - const symb = updateSymbolRef(); - return () => symb !== symbolRef.current; - } - }; - }, [symbolRef]); -} diff --git a/src/lib/ui/hooks/useTimeout.ts b/src/lib/ui/hooks/useTimeout.ts index 5736c5b846..465c958d7c 100644 --- a/src/lib/ui/hooks/useTimeout.ts +++ b/src/lib/ui/hooks/useTimeout.ts @@ -1,17 +1,18 @@ import { useEffect } from 'react'; +import { useUpdatableRef } from './useUpdatableRef'; + const DEFAULT_DEPS: unknown[] = []; -/** - * @arg callback // Must be memoized - */ export const useTimeout = (callback: EmptyFn, timeout: number, condition = true, deps = DEFAULT_DEPS) => { + const callbackRef = useUpdatableRef(callback); + useEffect(() => { if (!condition) return; - const timeoutId = setTimeout(callback, timeout); + const timeoutId = setTimeout(() => void callbackRef.current(), timeout); return () => void clearTimeout(timeoutId); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [condition, timeout, callback, ...deps]); + }, [condition, timeout, ...deps]); }; diff --git a/src/lib/ui/search-networks.ts b/src/lib/ui/search-networks.ts new file mode 100644 index 0000000000..9914516e8d --- /dev/null +++ b/src/lib/ui/search-networks.ts @@ -0,0 +1,18 @@ +import { t } from 'lib/i18n'; +import { searchAndFilterItems } from 'lib/utils/search-items'; +import { OneOfChains } from 'temple/front'; + +export function searchAndFilterChains(networks: OneOfChains[], searchValue: string) { + return searchAndFilterItems( + networks, + searchValue.trim(), + [ + { name: 'name', weight: 1 }, + { name: 'nameI18n', weight: 1 } + ], + ({ name, nameI18nKey }) => ({ + name, + nameI18n: nameI18nKey ? t(nameI18nKey) : undefined + }) + ); +} diff --git a/src/lib/ui/use-styled-button-or-link-props.tsx b/src/lib/ui/use-styled-button-or-link-props.tsx index 29cee686a9..e58599528b 100644 --- a/src/lib/ui/use-styled-button-or-link-props.tsx +++ b/src/lib/ui/use-styled-button-or-link-props.tsx @@ -14,6 +14,7 @@ export interface ButtonLikeStylingProps { size: Size; color: StyledButtonColor; active?: boolean; + disabled?: boolean; loading?: boolean; } @@ -45,6 +46,16 @@ const SIZE_CLASSNAME: Record = { S: 'py-1 px-2 rounded-lg text-font-description-bold' }; +export function useStyledButtonClassName( + { size, color, active, disabled }: ButtonLikeStylingProps, + className?: string +) { + return useMemo( + () => clsx(SIZE_CLASSNAME[size], getStyledButtonColorsClassNames(color, active, disabled), className), + [active, className, color, disabled, size] + ); +} + export function useStyledButtonOrLinkProps(inputProps: ButtonProps & ButtonLikeStylingProps): ButtonProps; export function useStyledButtonOrLinkProps(inputProps: LinkProps & ButtonLikeStylingProps): LinkProps; export function useStyledButtonOrLinkProps({ @@ -59,10 +70,7 @@ export function useStyledButtonOrLinkProps({ const isLink = 'to' in restProps; const disabled = 'disabled' in restProps && restProps.disabled; - const className = useMemo( - () => clsx(SIZE_CLASSNAME[size], getStyledButtonColorsClassNames(color, active, disabled), classNameProp), - [active, classNameProp, color, disabled, size] - ); + const className = useStyledButtonClassName({ size, color, active, disabled }, classNameProp); const children = loading ? (
diff --git a/src/temple/front/block-explorers.ts b/src/temple/front/block-explorers.ts index 4e5eb687f8..a94838ff49 100644 --- a/src/temple/front/block-explorers.ts +++ b/src/temple/front/block-explorers.ts @@ -14,7 +14,7 @@ import { import { EMPTY_FROZEN_OBJ } from 'lib/utils'; import { TempleChainKind } from 'temple/types'; -import { useChainSpecs } from './chains-specs'; +import { useEvmChainsSpecs, useTezosChainsSpecs } from './chains-specs'; export interface BlockExplorer { name: string; @@ -33,8 +33,9 @@ const useBlockExplorersOverrides = () => EMPTY_FROZEN_OBJ ); -export function useBlockExplorers() { +function useAllBlockExplorers() { const [blockExplorersOverrides, setBlockExplorersOverrides] = useBlockExplorersOverrides(); + const allBlockExplorers = useMemo( () => ({ [TempleChainKind.Tezos]: { @@ -49,10 +50,17 @@ export function useBlockExplorers() { [blockExplorersOverrides] ); + return [allBlockExplorers, setBlockExplorersOverrides] as const; +} + +export function useBlockExplorers() { + const [allBlockExplorers, setBlockExplorersOverrides] = useAllBlockExplorers(); + const getChainBlockExplorers = useCallback( (chainKind: TempleChainKind, chainId: string | number) => allBlockExplorers[chainKind]?.[chainId] ?? [], [allBlockExplorers] ); + const setChainBlockExplorers = useCallback( (chainKind: TempleChainKind, chainId: string | number, blockExplorers: BlockExplorer[]) => setBlockExplorersOverrides(prevValue => { @@ -111,19 +119,46 @@ export function useBlockExplorers() { }; } +function useGetBlockExplorers(chainKind: TempleChainKind) { + const [allBlockExplorers] = useAllBlockExplorers(); + + return useCallback( + (chainId: string) => + allBlockExplorers[chainKind]?.[chainId] ?? + DEFAULT_BLOCK_EXPLORERS[chainKind]?.[chainId] ?? + FALLBACK_CHAIN_BLOCK_EXPLORERS, + [allBlockExplorers, chainKind] + ); +} + +export function useGetActiveBlockExplorer(chainKind: TempleChainKind) { + const [tezosChainsSpecs] = useTezosChainsSpecs(); + const [evmChainsSpecs] = useEvmChainsSpecs(); + + const getBlockExplorers = useGetBlockExplorers(chainKind); + + return useCallback( + (chainId: string) => { + const chainsSpecs = chainKind === TempleChainKind.Tezos ? tezosChainsSpecs : evmChainsSpecs; + const chainBlockExplorers = getBlockExplorers(chainId); + const activeBlockExplorerId = chainsSpecs[chainId]?.activeBlockExplorerId; + + if (!activeBlockExplorerId) return chainBlockExplorers[0]; + + return chainBlockExplorers.find(({ id }) => id === activeBlockExplorerId) ?? chainBlockExplorers[0]; + }, + [getBlockExplorers, tezosChainsSpecs, evmChainsSpecs, chainKind] + ); +} + export function useChainBlockExplorers(chainKind: TempleChainKind, chainId: string | number) { const { - allBlockExplorers, addBlockExplorer: genericAddBlockExplorer, removeBlockExplorers: genericRemoveBlockExplorers, replaceBlockExplorer: genericReplaceBlockExplorer } = useBlockExplorers(); - const [{ activeBlockExplorerId }] = useChainSpecs(chainKind, chainId); - const chainBlockExplorers = - allBlockExplorers[chainKind]?.[chainId] ?? - DEFAULT_BLOCK_EXPLORERS[chainKind]?.[chainId] ?? - FALLBACK_CHAIN_BLOCK_EXPLORERS; + const getBlockExplorers = useGetBlockExplorers(chainKind); const addBlockExplorer = useCallback( (blockExplorer: Omit) => @@ -146,19 +181,12 @@ export function useChainBlockExplorers(chainKind: TempleChainKind, chainId: stri genericRemoveBlockExplorers( chainKind, chainId, - chainBlockExplorers.map(({ id }) => id) + getBlockExplorers(String(chainId)).map(({ id }) => id) ), - [chainBlockExplorers, chainId, chainKind, genericRemoveBlockExplorers] - ); - - const activeBlockExplorer = useMemo( - () => chainBlockExplorers.find(({ id }) => id === activeBlockExplorerId) ?? chainBlockExplorers[0], - [activeBlockExplorerId, chainBlockExplorers] + [getBlockExplorers, chainId, chainKind, genericRemoveBlockExplorers] ); return { - chainBlockExplorers, - activeBlockExplorer, addBlockExplorer, removeBlockExplorer, removeAllBlockExplorers, @@ -166,21 +194,29 @@ export function useChainBlockExplorers(chainKind: TempleChainKind, chainId: stri }; } +/** (!) Very expensive hook for lists */ export function useBlockExplorerHref( chainKind: TempleChainKind, chainId: string | number, entityType: BlockExplorerEntityType, hash: string ) { - const { activeBlockExplorer } = useChainBlockExplorers(chainKind, chainId); + const getActiveBlockExplorer = useGetActiveBlockExplorer(chainKind); return useMemo(() => { - if (!activeBlockExplorer) { - return null; - } + const activeBlockExplorer = getActiveBlockExplorer(String(chainId)); + + return activeBlockExplorer ? makeBlockExplorerHref(activeBlockExplorer.url, hash, entityType, chainKind) : null; + }, [getActiveBlockExplorer, chainKind, entityType, hash]); +} - return new URL(chainKind === TempleChainKind.Tezos ? hash : `${entityType}/${hash}`, activeBlockExplorer.url).href; - }, [activeBlockExplorer, chainKind, entityType, hash]); +export function makeBlockExplorerHref( + baseUrl: string, + hash: string, + entityType: BlockExplorerEntityType, + chainKind: TempleChainKind +) { + return new URL(chainKind === TempleChainKind.Tezos ? hash : `${entityType}/${hash}`, baseUrl).href; } const DEFAULT_BLOCK_EXPLORERS_BASE: Record[]>> = { diff --git a/src/temple/front/chains-specs.ts b/src/temple/front/chains-specs.ts index 88ff65ab4a..4743e35d90 100644 --- a/src/temple/front/chains-specs.ts +++ b/src/temple/front/chains-specs.ts @@ -145,16 +145,16 @@ const useSpecs = (storageKey: string, return [totalSpecs, setSpecs] as const; }; + export const useTezosChainsSpecs = () => useSpecs(TEZOS_CHAINS_SPECS_STORAGE_KEY, DEFAULT_TEZOS_CHAINS_SPECS); + export const useEvmChainsSpecs = () => useSpecs(EVM_CHAINS_SPECS_STORAGE_KEY, DEFAULT_EVM_CHAINS_SPECS); export const useChainSpecs = (chainKind: TempleChainKind, chainId: string | number) => { - const [tezosChainsSpecs, setTezosChainsSpecs] = useTezosChainsSpecs(); - const [evmChainsSpecs, setEvmChainsSpecs] = useEvmChainsSpecs(); + const [, setTezosChainsSpecs] = useTezosChainsSpecs(); + const [, setEvmChainsSpecs] = useEvmChainsSpecs(); - const chainSpecs: TezosChainSpecs | EvmChainSpecs = - (chainKind === TempleChainKind.Tezos ? tezosChainsSpecs[chainId] : evmChainsSpecs[chainId]) ?? {}; const setChainSpecs = useCallback( ( newChainSpecs: @@ -178,6 +178,7 @@ export const useChainSpecs = (chainKind: TempleChainKind, chainId: string | numb }, [chainId, chainKind, setEvmChainsSpecs, setTezosChainsSpecs] ); + const removeChainSpecs = useCallback(() => { switch (chainKind) { case TempleChainKind.EVM: @@ -187,5 +188,5 @@ export const useChainSpecs = (chainKind: TempleChainKind, chainId: string | numb } }, [chainId, chainKind, setEvmChainsSpecs, setTezosChainsSpecs]); - return [chainSpecs, setChainSpecs, removeChainSpecs] as const; + return [setChainSpecs, removeChainSpecs] as const; }; diff --git a/src/temple/front/chains.ts b/src/temple/front/chains.ts index 40d140f07a..ea4437256e 100644 --- a/src/temple/front/chains.ts +++ b/src/temple/front/chains.ts @@ -7,6 +7,18 @@ import type { TempleChainKind } from 'temple/types'; import { BlockExplorer } from './block-explorers'; import { useAllTezosChains, useAllEvmChains } from './ready'; +export interface BasicEvmChain { + kind: TempleChainKind.EVM; + chainId: number; +} + +export interface BasicTezosChain { + kind: TempleChainKind.Tezos; + chainId: string; +} + +export type BasicChain = BasicEvmChain | BasicTezosChain; + export interface ChainBase { rpcBaseURL: string; name: string; @@ -18,16 +30,12 @@ export interface ChainBase { default: boolean; } -export interface TezosChain extends ChainBase { - kind: TempleChainKind.Tezos; - chainId: string; +export interface TezosChain extends BasicTezosChain, ChainBase { rpc: StoredTezosNetwork; allRpcs: StoredTezosNetwork[]; } -export interface EvmChain extends ChainBase { - kind: TempleChainKind.EVM; - chainId: number; +export interface EvmChain extends BasicEvmChain, ChainBase { currency: EvmNativeTokenMetadata; rpc: StoredEvmNetwork; allRpcs: StoredEvmNetwork[]; diff --git a/src/temple/front/index.ts b/src/temple/front/index.ts index 436c9f9580..dc17b5170b 100644 --- a/src/temple/front/index.ts +++ b/src/temple/front/index.ts @@ -22,7 +22,7 @@ export { useTezosChainByChainId, useTezosMainnetChain, useEthereumMainnetChain } export { useAccountsGroups } from './groups'; -export { getNetworkTitle, useTezosChainIdLoadingValue, useTempleNetworksActions } from './networks'; +export { useTezosChainIdLoadingValue, useTempleNetworksActions } from './networks'; export { searchAndFilterAccounts, useRelevantAccounts, useVisibleAccounts } from './accounts'; diff --git a/src/temple/front/ready/index.ts b/src/temple/front/ready/index.ts index 5f0fc738f5..b39e4b19d4 100644 --- a/src/temple/front/ready/index.ts +++ b/src/temple/front/ready/index.ts @@ -4,6 +4,9 @@ import constate from 'constate'; import { useTempleClient } from 'lib/temple/front/client'; import { TempleStatus, TempleState, StoredAccount, TempleSettings } from 'lib/temple/types'; +import { TempleChainKind } from 'temple/types'; + +import { useGetActiveBlockExplorer } from '../block-explorers'; import { useReadyTempleAccounts } from './accounts'; import { useReadyTempleTezosNetworks, useReadyTempleEvmNetworks } from './networks'; @@ -28,7 +31,10 @@ export const [ // useSettings, // - useHDGroups + useHDGroups, + // + useGetTezosActiveBlockExplorer, + useGetEvmActiveBlockExplorer ] = constate( useReadyTemple, // @@ -49,7 +55,10 @@ export const [ // v => v.settings, // - v => v.hdGroups + v => v.hdGroups, + // + v => v.getTezosActiveBlockExplorer, + v => v.getEvmActiveBlockExplorer ); function useReadyTemple() { @@ -71,6 +80,9 @@ function useReadyTemple() { const readyTempleAccounts = useReadyTempleAccounts(allAccounts); + const getTezosActiveBlockExplorer = useGetActiveBlockExplorer(TempleChainKind.Tezos); + const getEvmActiveBlockExplorer = useGetActiveBlockExplorer(TempleChainKind.EVM); + /** Error boundary reset */ useLayoutEffect(() => { const evt = new CustomEvent('reseterrorboundary'); @@ -84,7 +96,10 @@ function useReadyTemple() { ...readyTempleAccounts, hdGroups, - settings + settings, + + getTezosActiveBlockExplorer, + getEvmActiveBlockExplorer }; } diff --git a/src/temple/networks.ts b/src/temple/networks.ts index a0f53dfb7b..1a05033c91 100644 --- a/src/temple/networks.ts +++ b/src/temple/networks.ts @@ -172,7 +172,7 @@ export const EVM_DEFAULT_NETWORKS: NonEmptyArray = [ id: 'optimism-mainnet', name: 'OP Mainnet', chain: TempleChainKind.EVM, - chainId: OTHER_COMMON_MAINNET_CHAIN_IDS.avalanche, + chainId: OTHER_COMMON_MAINNET_CHAIN_IDS.optimism, rpcBaseURL: 'https://mainnet.optimism.io', description: 'Optimism Mainnet', color: '#fc0000', diff --git a/tailwind.config.js b/tailwind.config.js index 8e799f7884..2d5cc58f6f 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -37,6 +37,7 @@ module.exports = { xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)', '2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)', inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)', + 'inner-bottom': 'inset 0 -2px 4px 0 rgba(0, 0, 0, 0.06)', outline: '0 0 0 3px rgba(237, 137, 54, 0.5)', none: 'none', // @@ -270,7 +271,6 @@ module.exports = { 45: '45', header: 50, sticky: 100, - 'content-fade': 200, 'overlay-promo': 300, overlay: 400, 'overlay-confirm': 500, @@ -287,9 +287,12 @@ module.exports = { gap: theme => theme('spacing'), borderRadius: { - 0.75: '0.1875rem', // 3px - 1.25: '0.3125rem', // 5px - 2.5: '0.625rem', // 10px + 3: 3, + 5: 5, + 6: 6, + 7: 7, + 8: 8, + 10: 10, circle: '50%', inherit: 'inherit' }, diff --git a/yarn.lock b/yarn.lock index 27bc2a6889..66821b28c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12438,7 +12438,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -12525,7 +12534,14 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -13315,10 +13331,10 @@ use-composed-ref@^1.0.0: dependencies: ts-essentials "^2.0.3" -use-debounce@7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-7.0.1.tgz#380e6191cc13ad29f8e2149a12b5c37cc2891190" - integrity sha512-fOrzIw2wstbAJuv8PC9Vg4XgwyTLEOdq4y/Z3IhVl8DAE4svRcgyEUvrEXu+BMNgMoc3YND6qLT61kkgEKXh7Q== +use-debounce@^10: + version "10.0.3" + resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-10.0.3.tgz#636094a37f7aa2bcc77b26b961481a0b571bf7ea" + integrity sha512-DxQSI9ZKso689WM1mjgGU3ozcxU1TJElBJ3X6S4SMzMNcm2lVH0AHmyXB+K7ewjz2BSUKJTDqTcwtSMRfB89dg== use-force-update@1.0.7: version "1.0.7" @@ -13813,7 +13829,16 @@ word-wrap@~1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==