diff --git a/pages/api/media-type.ts b/pages/api/media-type.ts index f64301f094..7489fecdf1 100644 --- a/pages/api/media-type.ts +++ b/pages/api/media-type.ts @@ -22,11 +22,13 @@ export default async function mediaTypeHandler(req: NextApiRequest, res: NextApi return 'video'; } + if (contentType?.startsWith('image')) { + return 'image'; + } + if (contentType?.startsWith('text/html')) { return 'html'; } - - return 'image'; })(); res.status(200).json({ type: mediaType }); } catch (error) { diff --git a/ui/address/tokens/NFTItem.tsx b/ui/address/tokens/NFTItem.tsx index 50a33ab73b..cc61b70dd1 100644 --- a/ui/address/tokens/NFTItem.tsx +++ b/ui/address/tokens/NFTItem.tsx @@ -26,7 +26,8 @@ const NFTItem = ({ token, value, isLoading, withTokenLink, ...tokenInstance }: P diff --git a/ui/blocks/BlocksTable.tsx b/ui/blocks/BlocksTable.tsx index cfd481321d..9a422d0de5 100644 --- a/ui/blocks/BlocksTable.tsx +++ b/ui/blocks/BlocksTable.tsx @@ -33,7 +33,7 @@ const isRollup = config.features.rollup.isEnabled; const BlocksTable = ({ data, isLoading, top, page, showSocketInfo, socketInfoNum, socketInfoAlert }: Props) => { const widthBase = - VALIDATOR_COL_WEIGHT + + (!config.UI.views.block.hiddenFields?.miner ? VALIDATOR_COL_WEIGHT : 0) + GAS_COL_WEIGHT + (!isRollup && !config.UI.views.block.hiddenFields?.total_reward ? REWARD_COL_WEIGHT : 0) + (!isRollup && !config.UI.views.block.hiddenFields?.burnt_fees ? FEES_COL_WEIGHT : 0); diff --git a/ui/shared/Page/PageTitle.tsx b/ui/shared/Page/PageTitle.tsx index d531921cc4..8c6e2f5d53 100644 --- a/ui/shared/Page/PageTitle.tsx +++ b/ui/shared/Page/PageTitle.tsx @@ -143,7 +143,7 @@ const PageTitle = ({ title, contentAfter, withTextAd, backLink, className, isLoa { withTextAd && } { secondRow && ( - + { secondRow } ) } diff --git a/ui/shared/nft/NftMedia.pw.tsx b/ui/shared/nft/NftMedia.pw.tsx index 71d09de045..4b91def420 100644 --- a/ui/shared/nft/NftMedia.pw.tsx +++ b/ui/shared/nft/NftMedia.pw.tsx @@ -10,7 +10,54 @@ test.describe('no url', () => { test('preview +@dark-mode', async({ mount }) => { const component = await mount( - + + , + ); + + await expect(component).toHaveScreenshot(); + }); + + test('with fallback', async({ mount, page }) => { + const IMAGE_URL = 'https://localhost:3000/my-image.jpg'; + + await page.route(IMAGE_URL, (route) => { + return route.fulfill({ + status: 200, + path: './playwright/mocks/image_long.jpg', + }); + }); + + const component = await mount( + + + , + ); + + await expect(component).toHaveScreenshot(); + }); + + test('non-media url and fallback', async({ mount, page }) => { + const ANIMATION_URL = 'https://localhost:3000/my-animation.m3u8'; + const ANIMATION_MEDIA_TYPE_API_URL = `/node-api/media-type?url=${ encodeURIComponent(ANIMATION_URL) }`; + const IMAGE_URL = 'https://localhost:3000/my-image.jpg'; + + await page.route(ANIMATION_MEDIA_TYPE_API_URL, (route) => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ type: undefined }), + }); + }); + + await page.route(IMAGE_URL, (route) => { + return route.fulfill({ + status: 200, + path: './playwright/mocks/image_long.jpg', + }); + }); + + const component = await mount( + + , ); @@ -35,7 +82,7 @@ test.describe('image', () => { test('preview +@dark-mode', async({ mount }) => { const component = await mount( - + , ); @@ -55,7 +102,7 @@ test('image preview hover', async({ mount, page }) => { const component = await mount( - + , ); @@ -75,7 +122,7 @@ test('image fullscreen +@dark-mode +@mobile', async({ mount, page }) => { }); const component = await mount( - + , ); @@ -107,7 +154,7 @@ test.describe('page', () => { test('preview +@dark-mode', async({ mount }) => { const component = await mount( - + , ); diff --git a/ui/shared/nft/NftMedia.tsx b/ui/shared/nft/NftMedia.tsx index e01569c314..ace164692a 100644 --- a/ui/shared/nft/NftMedia.tsx +++ b/ui/shared/nft/NftMedia.tsx @@ -9,29 +9,31 @@ import NftImage from './NftImage'; import NftImageFullscreen from './NftImageFullscreen'; import NftVideo from './NftVideo'; import NftVideoFullscreen from './NftVideoFullscreen'; -import useNftMediaType from './useNftMediaType'; +import useNftMediaInfo from './useNftMediaInfo'; import { mediaStyleProps } from './utils'; interface Props { - url: string | null; + imageUrl: string | null; + animationUrl: string | null; className?: string; isLoading?: boolean; withFullscreen?: boolean; } -const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => { +const NftMedia = ({ imageUrl, animationUrl, className, isLoading, withFullscreen }: Props) => { const [ isMediaLoading, setIsMediaLoading ] = React.useState(true); const [ isLoadingError, setIsLoadingError ] = React.useState(false); const { ref, inView } = useInView({ triggerOnce: true }); - const type = useNftMediaType(url, !isLoading && inView); + const mediaInfo = useNftMediaInfo({ imageUrl, animationUrl, isEnabled: !isLoading && inView }); React.useEffect(() => { - if (!isLoading) { - setIsMediaLoading(Boolean(url)); + if (!isLoading && !mediaInfo) { + setIsMediaLoading(false); + setIsLoadingError(true); } - }, [ isLoading, url ]); + }, [ isLoading, mediaInfo ]); const handleMediaLoaded = React.useCallback(() => { setIsMediaLoading(false); @@ -45,11 +47,17 @@ const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => { const { isOpen, onOpen, onClose } = useDisclosure(); const content = (() => { - if (!url || isLoadingError) { + if (!mediaInfo || isLoadingError) { const styleProps = withFullscreen ? {} : mediaStyleProps; return ; } + const { type, url } = mediaInfo; + + if (!url) { + return null; + } + const props = { src: url, onLoad: handleMediaLoaded, @@ -70,7 +78,13 @@ const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => { })(); const modal = (() => { - if (!url || !withFullscreen) { + if (!mediaInfo || !withFullscreen) { + return null; + } + + const { type, url } = mediaInfo; + + if (!url) { return null; } diff --git a/ui/shared/nft/__screenshots__/NftMedia.pw.tsx_default_no-url-non-media-url-and-fallback-1.png b/ui/shared/nft/__screenshots__/NftMedia.pw.tsx_default_no-url-non-media-url-and-fallback-1.png new file mode 100644 index 0000000000..4273139faf Binary files /dev/null and b/ui/shared/nft/__screenshots__/NftMedia.pw.tsx_default_no-url-non-media-url-and-fallback-1.png differ diff --git a/ui/shared/nft/__screenshots__/NftMedia.pw.tsx_default_no-url-with-fallback-1.png b/ui/shared/nft/__screenshots__/NftMedia.pw.tsx_default_no-url-with-fallback-1.png new file mode 100644 index 0000000000..4273139faf Binary files /dev/null and b/ui/shared/nft/__screenshots__/NftMedia.pw.tsx_default_no-url-with-fallback-1.png differ diff --git a/ui/shared/nft/useNftMediaInfo.tsx b/ui/shared/nft/useNftMediaInfo.tsx new file mode 100644 index 0000000000..7c36f828e9 --- /dev/null +++ b/ui/shared/nft/useNftMediaInfo.tsx @@ -0,0 +1,98 @@ +import { useQuery } from '@tanstack/react-query'; +import React from 'react'; + +import type { StaticRoute } from 'nextjs-routes'; +import { route } from 'nextjs-routes'; + +import type { ResourceError } from 'lib/api/resources'; +import useFetch from 'lib/hooks/useFetch'; + +import type { MediaType } from './utils'; +import { getPreliminaryMediaType } from './utils'; + +interface Params { + imageUrl: string | null; + animationUrl: string | null; + isEnabled: boolean; +} + +interface ReturnType { + type: MediaType | undefined; + url: string | null; +} + +export default function useNftMediaInfo({ imageUrl, animationUrl, isEnabled }: Params): ReturnType | null { + + const primaryQuery = useNftMediaTypeQuery(animationUrl, isEnabled); + const secondaryQuery = useNftMediaTypeQuery(imageUrl, !primaryQuery.isPending && !primaryQuery.data); + + return React.useMemo(() => { + if (primaryQuery.isPending) { + return { + type: undefined, + url: animationUrl, + }; + } + + if (primaryQuery.data) { + return primaryQuery.data; + } + + if (secondaryQuery.isPending) { + return { + type: undefined, + url: imageUrl, + }; + } + + if (secondaryQuery.data) { + return secondaryQuery.data; + } + + return null; + }, [ animationUrl, imageUrl, primaryQuery.data, primaryQuery.isPending, secondaryQuery.data, secondaryQuery.isPending ]); +} + +function useNftMediaTypeQuery(url: string | null, enabled: boolean) { + const fetch = useFetch(); + + return useQuery, ReturnType | null>({ + queryKey: [ 'nft-media-type', url ], + queryFn: async() => { + if (!url) { + return null; + } + + // media could be either image, gif, video or html-page + // so we pre-fetch the resources in order to get its content type + // have to do it via Node.js due to strict CSP for connect-src + // but in order not to abuse our server firstly we check file url extension + // and if it is valid we will trust it and display corresponding media component + + const preliminaryType = getPreliminaryMediaType(url); + + if (preliminaryType) { + return { type: preliminaryType, url }; + } + + const type = await (async() => { + try { + const mediaTypeResourceUrl = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url } }); + const response = await fetch<{ type: MediaType | undefined }, ResourceError>(mediaTypeResourceUrl, undefined, { resource: 'media-type' }); + + return 'type' in response ? response.type : undefined; + } catch (error) { + return; + } + })(); + + if (!type) { + return null; + } + + return { type, url }; + }, + enabled, + staleTime: Infinity, + }); +} diff --git a/ui/shared/nft/useNftMediaType.tsx b/ui/shared/nft/useNftMediaType.tsx deleted file mode 100644 index 4444b122e3..0000000000 --- a/ui/shared/nft/useNftMediaType.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import type { StaticRoute } from 'nextjs-routes'; -import { route } from 'nextjs-routes'; - -import type { ResourceError } from 'lib/api/resources'; -import useFetch from 'lib/hooks/useFetch'; - -import type { MediaType } from './utils'; -import { getPreliminaryMediaType } from './utils'; - -export default function useNftMediaType(url: string | null, isEnabled: boolean) { - - const fetch = useFetch(); - - const { data } = useQuery, MediaType>({ - queryKey: [ 'nft-media-type', url ], - queryFn: async() => { - if (!url) { - return 'image'; - } - - // media could be either image, gif, video or html-page - // so we pre-fetch the resources in order to get its content type - // have to do it via Node.js due to strict CSP for connect-src - // but in order not to abuse our server firstly we check file url extension - // and if it is valid we will trust it and display corresponding media component - - const preliminaryType = getPreliminaryMediaType(url); - - if (preliminaryType) { - return preliminaryType; - } - - try { - const mediaTypeResourceUrl = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url } }); - const response = await fetch<{ type: MediaType | undefined }, ResourceError>(mediaTypeResourceUrl, undefined, { resource: 'media-type' }); - - return 'type' in response ? response.type ?? 'image' : 'image'; - } catch (error) { - return 'image'; - } - }, - enabled: isEnabled && Boolean(url), - staleTime: Infinity, - }); - - return data; -} diff --git a/ui/token/TokenInventoryItem.tsx b/ui/token/TokenInventoryItem.tsx index 72f650d2b8..6add374550 100644 --- a/ui/token/TokenInventoryItem.tsx +++ b/ui/token/TokenInventoryItem.tsx @@ -20,7 +20,8 @@ const TokenInventoryItem = ({ item, token, isLoading }: Props) => { const mediaElement = ( ); diff --git a/ui/tokenInstance/TokenInstanceDetails.tsx b/ui/tokenInstance/TokenInstanceDetails.tsx index 6896f1cc4c..dfff7aacc4 100644 --- a/ui/tokenInstance/TokenInstanceDetails.tsx +++ b/ui/tokenInstance/TokenInstanceDetails.tsx @@ -74,7 +74,8 @@ const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => {