diff --git a/apps/browser-extension-wallet/.env.defaults b/apps/browser-extension-wallet/.env.defaults index c5a311b66..b1eefb358 100644 --- a/apps/browser-extension-wallet/.env.defaults +++ b/apps/browser-extension-wallet/.env.defaults @@ -89,6 +89,7 @@ CARDANO_WS_SERVER_URL_PREVIEW=wss://dev-preview.lw.iog.io CARDANO_WS_SERVER_URL_SANCHONET=wss://dev-sanchonet.lw.iog.io # Blockfrost +BLOCKFROST_IPFS_URL=https://ipfs.blockfrost.dev BLOCKFROST_URL_MAINNET=https://cardano-mainnet.blockfrost.io BLOCKFROST_URL_PREPROD=https://cardano-preprod.blockfrost.io BLOCKFROST_URL_PREVIEW=https://cardano-preview.blockfrost.io diff --git a/apps/browser-extension-wallet/.env.example b/apps/browser-extension-wallet/.env.example index ee7b6ae30..be2c296b8 100644 --- a/apps/browser-extension-wallet/.env.example +++ b/apps/browser-extension-wallet/.env.example @@ -77,6 +77,7 @@ CARDANO_WS_SERVER_URL_PREVIEW=wss://dev-preview.lw.iog.io CARDANO_WS_SERVER_URL_SANCHONET=wss://dev-sanchonet.lw.iog.io # Blockfrost +BLOCKFROST_IPFS_URL=https://ipfs.blockfrost.dev BLOCKFROST_URL_MAINNET=https://cardano-mainnet.blockfrost.io BLOCKFROST_URL_PREPROD=https://cardano-preprod.blockfrost.io BLOCKFROST_URL_PREVIEW=https://cardano-preview.blockfrost.io diff --git a/apps/browser-extension-wallet/src/config.ts b/apps/browser-extension-wallet/src/config.ts index af9522cb3..f8c332173 100644 --- a/apps/browser-extension-wallet/src/config.ts +++ b/apps/browser-extension-wallet/src/config.ts @@ -38,6 +38,7 @@ export type Config = { // eslint-disable-next-line complexity const envChecks = (chosenChain: Wallet.ChainName): void => { if ( + !process.env.BLOCKFROST_IPFS_URL || !process.env.CARDANO_SERVICES_URL_MAINNET || !process.env.CARDANO_SERVICES_URL_PREPROD || !process.env.CARDANO_SERVICES_URL_PREVIEW || diff --git a/apps/browser-extension-wallet/src/utils/get-asset-image-url.ts b/apps/browser-extension-wallet/src/utils/get-asset-image-url.ts index 8deb96d21..145741065 100644 --- a/apps/browser-extension-wallet/src/utils/get-asset-image-url.ts +++ b/apps/browser-extension-wallet/src/utils/get-asset-image-url.ts @@ -3,7 +3,7 @@ export const fortmatImgSrc = (params: { img: string; type: string }): string => export const getAssetImageUrl = (image: string): string => { if (image.startsWith('ipfs')) { - return image.replace('ipfs://', 'https://ipfs.blockfrost.dev/ipfs/'); + return `https://ipfs.blockfrost.dev/ipfs/${image.replace('ipfs://', '').replace('ipfs/', '')}`; } if (image.startsWith('data:image/')) return image; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/nfts/components/NftsLayout.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/nfts/components/NftsLayout.tsx index 8e0121f36..5a101f13e 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/nfts/components/NftsLayout.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/nfts/components/NftsLayout.tsx @@ -2,9 +2,6 @@ /* eslint-disable sonarjs/cognitive-complexity */ /* eslint-disable unicorn/no-useless-undefined */ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import debounce from 'lodash/debounce'; -import useResizeObserver, { ObservedSize } from 'use-resize-observer'; -import { useMediaQuery } from 'react-responsive'; import cn from 'classnames'; import styles from './NftsLayout.module.scss'; import { useWalletStore } from '@stores'; @@ -29,7 +26,7 @@ import { useOutputInitialState } from '../../send-transaction'; import { Button, useObservable } from '@lace/common'; -import { DEFAULT_WALLET_BALANCE } from '@src/utils/constants'; +import { APP_MODE_POPUP, DEFAULT_WALLET_BALANCE } from '@src/utils/constants'; import { Skeleton } from 'antd'; import { EducationalList, FundWalletBanner, Layout, SectionLayout } from '@src/views/browser-view/components'; import { DrawerContent } from '@src/views/browser-view/components/Drawer'; @@ -60,14 +57,19 @@ const LACE_APP_ID = 'lace-app'; export const STAKE_POOL_CARD_HEIGHT = 84; export const STAKE_POOL_GRID_ROW_GAP = 12; -export type StakePoolsGridColumnCount = 2 | 3 | 4; +export type NftGridColumnCount = 2 | 4; -const DEFAULT_DEBOUNCE = 200; const increaseViewportBy = { bottom: 100, top: 0 }; // eslint-disable-next-line max-statements, complexity export const NftsLayout = withNftsFoldersContext((): React.ReactElement => { - const { walletInfo, inMemoryWallet, blockchainProvider, environmentName } = useWalletStore(); + const { + walletInfo, + inMemoryWallet, + blockchainProvider, + environmentName, + walletUI: { appMode } + } = useWalletStore(); const [selectedFolderId, setSelectedFolderId] = useState(); const { t } = useTranslation(); const assetsInfo = useAssetInfo(); @@ -235,51 +237,11 @@ export const NftsLayout = withNftsFoldersContext((): React.ReactElement => { }, []); const showCreateFolder = nfts.length > 0 && nftsNotInFolders.length > 0 && process.env.USE_NFT_FOLDERS === 'true'; - const [numberOfItemsPerRow, setNumberOfItemsPerRow] = useState(); - const [containerWidth, setContainerWidth] = useState(); + const numberOfItemsPerRow = appMode === APP_MODE_POPUP ? 2 : 4; const [initialItemsCount, setInitialItemsCount] = useState(0); const ref = useRef(null); - const matchTwoColumnsLayout = useMediaQuery({ maxWidth: 668 }); - const matchThreeColumnsLayout = useMediaQuery({ maxWidth: 1660, minWidth: 668 }); - const matchFourColumnsLayout = useMediaQuery({ minWidth: 1660 }); - const numberOfItemsPerMediaQueryMap: Partial> = useMemo( - () => ({ - 2: matchTwoColumnsLayout, - 3: matchThreeColumnsLayout, - 4: matchFourColumnsLayout - }), - [matchFourColumnsLayout, matchThreeColumnsLayout, matchTwoColumnsLayout] - ); - - const updateNumberOfItemsInRow = useCallback(() => { - if (!ref?.current) return; - - const result = Number( - Object.entries(numberOfItemsPerMediaQueryMap).find(([, matches]) => matches)?.[0] - ) as StakePoolsGridColumnCount; - - setNumberOfItemsPerRow(result); - }, [numberOfItemsPerMediaQueryMap]); - - const setContainerWidthCb = useCallback( - (size: ObservedSize) => { - if (size.width !== containerWidth) { - updateNumberOfItemsInRow(); - setContainerWidth(size.width); - } - }, - [containerWidth, updateNumberOfItemsInRow] - ); - - const onResize = useMemo( - () => debounce(setContainerWidthCb, DEFAULT_DEBOUNCE, { leading: true }), - [setContainerWidthCb] - ); - - useResizeObserver({ onResize, ref }); - const tableReference = useRef(null); const initialRowsCount = useVisibleItemsCount({ containerRef: tableReference, diff --git a/apps/browser-extension-wallet/test/__mocks__/set-env-vars.js b/apps/browser-extension-wallet/test/__mocks__/set-env-vars.js index ec8a6c76d..0a76de7be 100644 --- a/apps/browser-extension-wallet/test/__mocks__/set-env-vars.js +++ b/apps/browser-extension-wallet/test/__mocks__/set-env-vars.js @@ -2,6 +2,7 @@ process.env.CARDANO_SERVICES_URL_PREVIEW = 'https://preview-prod.com'; process.env.CARDANO_SERVICES_URL_PREPROD = 'https://preprod-prod.com'; process.env.CARDANO_SERVICES_URL_MAINNET = 'https://mainnet-url.com'; process.env.CARDANO_SERVICES_URL_SANCHONET = 'https://sanchonet-url.com'; +process.env.BLOCKFROST_IPFS_URL = 'https://ipfs.blockfrost.dev'; process.env.BLOCKFROST_URL_MAINNET = 'https://cardano-mainnet.blockfrost.io'; process.env.BLOCKFROST_URL_PREPROD = 'https://cardano-preprod.blockfrost.io'; process.env.BLOCKFROST_URL_PREVIEW = 'https://cardano-preview.blockfrost.io'; diff --git a/apps/browser-extension-wallet/webpack-utils.js b/apps/browser-extension-wallet/webpack-utils.js index c949e4ac3..ba9973241 100644 --- a/apps/browser-extension-wallet/webpack-utils.js +++ b/apps/browser-extension-wallet/webpack-utils.js @@ -27,7 +27,7 @@ const transformManifest = (content, mode) => { ) .replace( '$BLOCKFROST_URLS', - `${process.env.BLOCKFROST_URL_MAINNET} ${process.env.BLOCKFROST_URL_PREPROD} ${process.env.BLOCKFROST_URL_PREVIEW} ${process.env.BLOCKFROST_URL_SANCHONET}` + `${process.env.BLOCKFROST_IPFS_URL} ${process.env.BLOCKFROST_URL_MAINNET} ${process.env.BLOCKFROST_URL_PREPROD} ${process.env.BLOCKFROST_URL_PREVIEW} ${process.env.BLOCKFROST_URL_SANCHONET}` ) .replace('$LOCALHOST_DEFAULT_SRC', mode === 'development' ? 'http://localhost:3000' : '') .replace('$LOCALHOST_SCRIPT_SRC', mode === 'development' ? 'http://localhost:3000' : '') diff --git a/packages/common/src/ui/hooks/useFetchImage.ts b/packages/common/src/ui/hooks/useFetchImage.ts index 06c05ed87..17e69ec32 100644 --- a/packages/common/src/ui/hooks/useFetchImage.ts +++ b/packages/common/src/ui/hooks/useFetchImage.ts @@ -1,101 +1,94 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { useCallback, useMemo, useReducer } from 'react'; - -enum IMAGE_FETCH_ACTION_TYPES { - FETCHING = 'FETCHING', - FETCHED = 'FETCHED', - FETCH_ERROR = 'FETCH_ERROR' -} - -export enum IMAGE_FETCH_STATUS { - LOADING, - LOADED, - ERROR -} - -interface ImageResponse { - status?: IMAGE_FETCH_STATUS; - src?: string; -} - -interface FetchImageArgs { - url: string; - fallback: string; -} - -type FetchAction = { - type: IMAGE_FETCH_ACTION_TYPES; - payload?: string; -}; - -const handleImageFetch = (image: string) => { - const downloadingImage = new Image(); - - const imageResponse: Promise = new Promise((resolve) => { - const onLoadEvent = (event: any) => { - resolve({ - status: IMAGE_FETCH_STATUS.LOADED, - src: event.path?.[0].currentSrc || image - }); - }; - const onErrorEvent = () => { - resolve({ - status: IMAGE_FETCH_STATUS.ERROR - }); - }; - - downloadingImage.addEventListener('load', onLoadEvent); - downloadingImage.addEventListener('error', onErrorEvent); - }); +import { useState, useEffect } from 'react'; - downloadingImage.src = image; +export type ImageFetchStatus = 'loading' | 'loaded' | 'error'; - return imageResponse; +type ImageFetchStateMap = { + loading: { status: 'loading' }; + loaded: { status: 'loaded'; imageSrc: string }; + error: { status: 'error'; imageSrc: string }; }; -const initialState = { status: IMAGE_FETCH_STATUS.LOADING }; - -const fetchImageReducer = (state: ImageResponse, action: FetchAction): ImageResponse => { - switch (action.type) { - case IMAGE_FETCH_ACTION_TYPES.FETCHING: - return { ...initialState, status: IMAGE_FETCH_STATUS.LOADING }; - case IMAGE_FETCH_ACTION_TYPES.FETCHED: - return { ...initialState, status: IMAGE_FETCH_STATUS.LOADED, src: action.payload }; - case IMAGE_FETCH_ACTION_TYPES.FETCH_ERROR: - return { ...initialState, status: IMAGE_FETCH_STATUS.ERROR }; - default: - return state; +export type UseFetchImageState = ImageFetchStateMap[ImageFetchStatus]; + +const maxConcurrentRequests = 5; +const requestQueue: Set<() => Promise> = new Set(); +let concurrentRequestsCount = 0; + +const processNextRequest = async () => { + if (requestQueue.size > 0 && concurrentRequestsCount < maxConcurrentRequests) { + const [nextRequest] = requestQueue; + requestQueue.delete(nextRequest); + concurrentRequestsCount++; + try { + await nextRequest(); + } finally { + processNextRequest(); + } } }; -const fetchImageDispatcher = async (url: string, dispatcher: React.Dispatch) => { - const response = await handleImageFetch(url); +const fetchImage = async (url: string, controller: AbortController) => { + const response = await fetch(url, { + signal: controller.signal, + headers: { 'Cache-Control': 'public, max-age=86400' } + }); - const type = - response.status === IMAGE_FETCH_STATUS.LOADED - ? IMAGE_FETCH_ACTION_TYPES.FETCHED - : // eslint-disable-next-line unicorn/no-nested-ternary - response.status === IMAGE_FETCH_STATUS.ERROR - ? IMAGE_FETCH_ACTION_TYPES.FETCH_ERROR - : IMAGE_FETCH_ACTION_TYPES.FETCHING; - const dispatchValue = { payload: response.src, type }; + if (!response.ok) throw new Error('Image fetch failed'); - dispatcher(dispatchValue); + const blob = await response.blob(); + return URL.createObjectURL(blob); }; -export const useFetchImage = ({ url, fallback }: FetchImageArgs): [ImageResponse, () => Promise] => { - const [result, dispatch] = useReducer(fetchImageReducer, initialState); - const handleLoad = useCallback(async () => { - await fetchImageDispatcher(url, dispatch); - }, [url]); - - const resultWithFallback = useMemo( - () => ({ - ...result, - src: result?.src || fallback - }), - [result, fallback] - ); - - return [resultWithFallback, handleLoad]; +export const useFetchImage = ({ url, fallbackImage }: { url: string; fallbackImage: string }) => { + const [state, setState] = useState({ status: 'loading' }); + + useEffect(() => { + let realUrl = url; + + if (url.startsWith('data:')) { + // TODO investigate the below problem + use getAssetImageUrl + // Solve broken links e.g. data:image/png;base64,https://storage.something.com/assets/1235 + const httpPosition = url.indexOf('http'); + if (httpPosition === -1) { + setState({ status: 'loaded', imageSrc: url }); + // eslint-disable-next-line @typescript-eslint/no-empty-function + return () => {}; + } + + realUrl = url.slice(httpPosition); + } + + const controller = new AbortController(); + + const loadImage = async () => { + try { + setState({ status: 'loading' }); + + const imageSrc = await fetchImage(realUrl, controller); + + setState({ status: 'loaded', imageSrc }); + } catch { + if (!controller.signal.aborted) { + setState({ status: 'error', imageSrc: fallbackImage }); + } + } finally { + concurrentRequestsCount--; + processNextRequest(); + } + }; + + if (concurrentRequestsCount < maxConcurrentRequests) { + concurrentRequestsCount++; + loadImage(); + } else { + requestQueue.add(loadImage); + } + + return () => { + controller.abort(); + requestQueue.delete(loadImage); + }; + }, [url, fallbackImage]); + + return state; }; diff --git a/packages/core/src/ui/components/AddressCard/HandleAddressCard.tsx b/packages/core/src/ui/components/AddressCard/HandleAddressCard.tsx index edebac145..ddc20d37d 100644 --- a/packages/core/src/ui/components/AddressCard/HandleAddressCard.tsx +++ b/packages/core/src/ui/components/AddressCard/HandleAddressCard.tsx @@ -1,10 +1,10 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import styles from './HandleAddressCard.module.scss'; import { Image, Spin } from 'antd'; import { Base } from './Base'; import symbol from '../../assets/images/handle.png'; import placeholder from '../../assets/images/nft-placeholder.png'; -import { IMAGE_FETCH_STATUS, useFetchImage } from '@lace/common'; +import { useFetchImage } from '@lace/common'; export type Props = { name: string; @@ -14,12 +14,8 @@ export type Props = { }; export const HandleAddressCard = ({ name, image, copiedMessage, onCopyClick }: Readonly): JSX.Element => { - const [imageResponse, handleLoad] = useFetchImage({ url: image, fallback: placeholder }); - const isLoading = imageResponse?.status === IMAGE_FETCH_STATUS.LOADING; - - useEffect(() => { - handleLoad(); - }, [handleLoad]); + const imageResponse = useFetchImage({ url: image, fallbackImage: placeholder }); + const isLoading = imageResponse?.status === 'loading'; return ( diff --git a/packages/core/src/ui/components/Nft/NftImage.tsx b/packages/core/src/ui/components/Nft/NftImage.tsx index 058b0331b..a81b10b85 100644 --- a/packages/core/src/ui/components/Nft/NftImage.tsx +++ b/packages/core/src/ui/components/Nft/NftImage.tsx @@ -1,7 +1,7 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import cn from 'classnames'; import { Spin } from 'antd'; -import { useFetchImage, IMAGE_FETCH_STATUS } from '@lace/common'; +import { useFetchImage } from '@lace/common'; import NFTPlaceholderImage from '../../assets/images/nft-placeholder.png'; import styles from './NftImage.module.scss'; @@ -18,18 +18,15 @@ export const NftImage = ({ detailView = false, withBorder = false }: NftImageProps): React.ReactElement => { - const [imageResponse, handleLoad] = useFetchImage({ url: image, fallback: NFTPlaceholderImage }); + const imageResponse = useFetchImage({ url: image, fallbackImage: NFTPlaceholderImage }); - useEffect(() => { - handleLoad(); - }, [handleLoad]); - - if (imageResponse?.status === IMAGE_FETCH_STATUS.LOADING) + if (imageResponse?.status === 'loading') return (
); + return ( NFT ); }; diff --git a/packages/core/test/__mocks__/set-env-vars.js b/packages/core/test/__mocks__/set-env-vars.js index ec8a6c76d..0a76de7be 100644 --- a/packages/core/test/__mocks__/set-env-vars.js +++ b/packages/core/test/__mocks__/set-env-vars.js @@ -2,6 +2,7 @@ process.env.CARDANO_SERVICES_URL_PREVIEW = 'https://preview-prod.com'; process.env.CARDANO_SERVICES_URL_PREPROD = 'https://preprod-prod.com'; process.env.CARDANO_SERVICES_URL_MAINNET = 'https://mainnet-url.com'; process.env.CARDANO_SERVICES_URL_SANCHONET = 'https://sanchonet-url.com'; +process.env.BLOCKFROST_IPFS_URL = 'https://ipfs.blockfrost.dev'; process.env.BLOCKFROST_URL_MAINNET = 'https://cardano-mainnet.blockfrost.io'; process.env.BLOCKFROST_URL_PREPROD = 'https://cardano-preprod.blockfrost.io'; process.env.BLOCKFROST_URL_PREVIEW = 'https://cardano-preview.blockfrost.io';