diff --git a/.vscode/settings.json b/.vscode/settings.json index 74a537f55..cb5f0e30b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,7 @@ "Ekbatanifard", "iadd", "Infotip", + "IPFS", "judgements", "Kusama", "Polkadot", diff --git a/packages/extension-base/src/defaults.ts b/packages/extension-base/src/defaults.ts index 166facc73..57748e31d 100644 --- a/packages/extension-base/src/defaults.ts +++ b/packages/extension-base/src/defaults.ts @@ -12,6 +12,7 @@ const START_WITH_PATH = [ '/send/', '/stake/', '/socialRecovery/', + '/nft/', '/derivefs/' ] as const; diff --git a/packages/extension-polkagate/src/class/nftManager.ts b/packages/extension-polkagate/src/class/nftManager.ts new file mode 100644 index 000000000..ac20daeb9 --- /dev/null +++ b/packages/extension-polkagate/src/class/nftManager.ts @@ -0,0 +1,205 @@ +// Copyright 2019-2024 @polkadot/extension-polkagate authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ItemInformation, ItemMetadata, ItemOnChainInfo } from '../fullscreen/nft/utils/types'; +import type { NftItemsType } from '../util/types'; + +// Define types for listener functions +type Listener = (address: string, nftItemsInformation: ItemInformation[]) => void; +type InitializationListener = () => void; + +// Error class for NFT-specific errors +class NftManagerError extends Error { + constructor (message: string) { + super(message); + this.name = 'NftManagerError'; + } +} + +export default class NftManager { + // Store nft items and listeners + private nfts: NftItemsType = {}; + private listeners = new Set(); + private initializationListeners = new Set(); + private readonly STORAGE_KEY = 'nftItems'; + private isInitialized = false; + private initializationPromise: Promise; + + constructor () { + // Load nft items from storage and set up storage change listener + this.initializationPromise = this.loadFromStorage(); + chrome.storage.onChanged.addListener(this.handleStorageChange); + } + + // Wait for initialization to complete + public async waitForInitialization (): Promise { + return this.initializationPromise; + } + + // Notify all listeners about initialization + private notifyInitializationListeners (): void { + this.initializationListeners.forEach((listener) => { + try { + listener(); + } catch (error) { + console.error('Error in initialization listener:', error); + } + }); + this.initializationListeners.clear(); + } + + // Load nft items from chrome storage + private async loadFromStorage (): Promise { + try { + const result = await chrome.storage.local.get(this.STORAGE_KEY); + + this.nfts = result[this.STORAGE_KEY] as NftItemsType || {}; + this.isInitialized = true; + + this.notifyInitializationListeners(); + this.notifyListeners(); + } catch (error) { + console.error('Failed to load NFT items from storage:', error); + throw new NftManagerError('Failed to load NFT items from storage'); + } + } + + // Save nft items to chrome storage with debouncing + private saveToStorage = (() => { + let timeoutId: ReturnType | null = null; + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + timeoutId = setTimeout(async () => { + try { + await chrome.storage.local.set({ [this.STORAGE_KEY]: this.nfts }); + } catch (error) { + console.error('Failed to save NFT items to storage:', error); + throw new NftManagerError('Failed to save NFT items to storage'); + } + }, 1000); // Debounce for 1 second + }; + })(); + + // Handle changes in chrome storage + private handleStorageChange = (changes: Record, areaName: string) => { + if (areaName === 'local' && changes[this.STORAGE_KEY]) { + this.nfts = changes[this.STORAGE_KEY].newValue as NftItemsType; + this.notifyListeners(); + } + }; + + // Notify all listeners about nfts items changes + private notifyListeners (): void { + if (!this.isInitialized) { + return; + } + + Object.entries(this.nfts).forEach(([address, nftItemsInformation]) => { + this.listeners.forEach((listener) => { + try { + listener(address, nftItemsInformation); + } catch (error) { + console.error('Error in listener:', error); + } + }); + }); + } + + // Get nft items for a specific + get (address: string): ItemInformation[] | null | undefined { + if (!address) { + throw new NftManagerError('Address is required'); + } + + return address in this.nfts && this.nfts[address].length === 0 + ? null + : this.nfts?.[address]; + } + + // Get all nft items + getAll (): NftItemsType | null | undefined { + return this.nfts; + } + + // Set on-chain nft item for a specific address + setOnChainItemsInfo (data: NftItemsType) { + if (!data) { + throw new NftManagerError('NFT items information are required to set on-chain information'); + } + + for (const address in data) { + if (!this.nfts[address]) { + this.nfts[address] = []; + } + + const nftItemsInfo = data[address]; + + const existingItems = new Set( + this.nfts[address].map((item) => this.getItemKey(item)) + ); + + const newItems = nftItemsInfo.filter( + (item) => !existingItems.has(this.getItemKey(item)) + ); + + if (newItems.length > 0) { + this.nfts[address].push(...newItems); + this.saveToStorage(); + this.notifyListeners(); + } + } + } + + private getItemKey (item: ItemOnChainInfo): string { + return `${item.chainName}-${item.collectionId}-${item.itemId}-${item.isNft}`; + } + + // Set nft item detail for a specific address and item + setItemDetail (address: string, nftItemInfo: ItemInformation, nftItemDetail: ItemMetadata | null) { + if (!address || !nftItemInfo || nftItemDetail === undefined) { + throw new NftManagerError('Address, NFT item info, and detail are required'); + } + + if (!this.nfts[address]) { + return; + } + + const itemIndex = this.nfts[address].findIndex( + (item) => this.getItemKey(item) === this.getItemKey(nftItemInfo) + ); + + if (itemIndex === -1) { + return; + } + + this.nfts[address][itemIndex] = { + ...this.nfts[address][itemIndex], + ...(nftItemDetail ?? { noData: true }) + }; + + this.saveToStorage(); + this.notifyListeners(); + } + + // Subscribe a listener to endpoint changes + subscribe (listener: Listener) { + this.listeners.add(listener); + } + + // Unsubscribe a listener from endpoint changes + unsubscribe (listener: Listener) { + this.listeners.delete(listener); + } + + // Cleanup method to remove listeners and clear data + public destroy (): void { + chrome.storage.onChanged.removeListener(this.handleStorageChange); + this.listeners.clear(); + this.nfts = {}; + } +} diff --git a/packages/extension-polkagate/src/components/InputFilter.tsx b/packages/extension-polkagate/src/components/InputFilter.tsx index 34d87a2d9..3357c6eaf 100644 --- a/packages/extension-polkagate/src/components/InputFilter.tsx +++ b/packages/extension-polkagate/src/components/InputFilter.tsx @@ -17,10 +17,11 @@ interface Props { placeholder: string; value?: string; withReset?: boolean; + disabled?: boolean; theme: Theme; } -export default function InputFilter ({ autoFocus = true, label, onChange, placeholder, theme, value, withReset = false }: Props) { +export default function InputFilter ({ autoFocus = true, disabled, label, onChange, placeholder, theme, value, withReset = false }: Props) { const inputRef: React.RefObject | null = useRef(null); const onChangeFilter = useCallback((event: React.ChangeEvent) => { @@ -41,6 +42,7 @@ export default function InputFilter ({ autoFocus = true, label, onChange, placeh autoCapitalize='off' autoCorrect='off' autoFocus={autoFocus} + disabled={disabled} onChange={onChangeFilter} placeholder={placeholder} ref={inputRef} diff --git a/packages/extension-polkagate/src/fullscreen/accountDetails/components/AOC.tsx b/packages/extension-polkagate/src/fullscreen/accountDetails/components/AOC.tsx index f774edfbf..222013d97 100644 --- a/packages/extension-polkagate/src/fullscreen/accountDetails/components/AOC.tsx +++ b/packages/extension-polkagate/src/fullscreen/accountDetails/components/AOC.tsx @@ -135,6 +135,8 @@ function AOC ({ accountAssets, address, hideNumbers, mode = 'Detail', onclick, s } }, [accountAssets]); + const shouldShowCursor = useMemo(() => (mode === 'Detail' && accountAssets && accountAssets.length > 5) || (mode !== 'Detail' && accountAssets && accountAssets.length > 6), [accountAssets, mode]); + return ( @@ -159,7 +161,7 @@ function AOC ({ accountAssets, address, hideNumbers, mode = 'Detail', onclick, s {!!accountAssets?.length && - + {mode === 'Detail' ? accountAssets.length > 5 && <> diff --git a/packages/extension-polkagate/src/fullscreen/accountDetails/components/AccountSetting.tsx b/packages/extension-polkagate/src/fullscreen/accountDetails/components/AccountSetting.tsx index 620cf2bd0..3c6e29753 100644 --- a/packages/extension-polkagate/src/fullscreen/accountDetails/components/AccountSetting.tsx +++ b/packages/extension-polkagate/src/fullscreen/accountDetails/components/AccountSetting.tsx @@ -93,7 +93,7 @@ export default function AccountSetting ({ address, setDisplayPopup }: Props): Re /> } + icon={} onClick={onManageProxies} secondaryIconType='page' text={t('Manage proxies')} @@ -116,26 +116,26 @@ export default function AccountSetting ({ address, setDisplayPopup }: Props): Re /> } + icon={} onClick={onExportAccount} secondaryIconType='popup' text={t('Export account')} /> } + icon={} onClick={goToDeriveAcc} secondaryIconType='popup' text={t('Derive new account')} /> } + icon={} onClick={onRenameAccount} secondaryIconType='popup' text={t('Rename')} /> } + icon={} noBorderButton onClick={onForgetAccount} secondaryIconType='popup' diff --git a/packages/extension-polkagate/src/fullscreen/accountDetails/components/CommonTasks.tsx b/packages/extension-polkagate/src/fullscreen/accountDetails/components/CommonTasks.tsx index e3efe61b3..ffbfb87b1 100644 --- a/packages/extension-polkagate/src/fullscreen/accountDetails/components/CommonTasks.tsx +++ b/packages/extension-polkagate/src/fullscreen/accountDetails/components/CommonTasks.tsx @@ -6,7 +6,7 @@ import type { BalancesInfo } from 'extension-polkagate/src/util/types'; import type { FetchedBalance } from '../../../hooks/useAssetsBalances'; -import { faCoins, faHistory, faPaperPlane, faVoteYea } from '@fortawesome/free-solid-svg-icons'; +import { faCoins, faGem, faHistory, faPaperPlane, faVoteYea } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { ArrowForwardIosRounded as ArrowForwardIosRoundedIcon, Boy as BoyIcon, QrCode2 as QrCodeIcon } from '@mui/icons-material'; import { Divider, Grid, Typography, useTheme } from '@mui/material'; @@ -55,7 +55,6 @@ export const openOrFocusTab = (relativeUrl: string, closeCurrentTab?: boolean): return tab.url === tabUrl; }); - if (existingTab?.id) { chrome.tabs.update(existingTab.id, { active: true }).catch(console.error); } else { @@ -161,6 +160,10 @@ export default function CommonTasks ({ address, assetId, balance, genesisHash, s address && !stakingDisabled && openOrFocusTab(`/poolfs/${address}/`); }, [address, stakingDisabled]); + const onNFTAlbum = useCallback(() => { + address && openOrFocusTab(`/nft/${address}`); + }, [address]); + const goToHistory = useCallback(() => { address && genesisHash && setDisplayPopup(popupNumbers.HISTORY); }, [address, genesisHash, setDisplayPopup]); @@ -252,6 +255,19 @@ export default function CommonTasks ({ address, assetId, balance, genesisHash, s show={(hasSoloStake || hasPoolStake) && !stakingDisabled} text={t('Stake in Pool')} /> + + } + onClick={onNFTAlbum} + secondaryIconType='page' + text={t('NFT album')} + /> accountNft?.slice(0, MAX_NFT_TO_SHOW), [accountNft]); + + const fetchMetadata = useCallback(async () => { + if (!itemsToShow || itemsToShow?.length === 0 || !address) { + return; + } + + const noNeedToFetchMetadata = !itemsToShow.some((nft) => nft.data && (nft.image === undefined && nft.animation_url === undefined)); + + if (noNeedToFetchMetadata) { + setIsLoading(false); + + return; + } + + setIsLoading(true); + + try { + await Promise.all(itemsToShow.map((item) => fetchItemMetadata(address, item))); + } catch (error) { + console.error('Error fetching NFT metadata:', error); + } finally { + setIsLoading(false); + } + }, [address, itemsToShow]); + + useEffect(() => { + if (!itemsToShow || itemsToShow?.length === 0) { + return; + } + + fetchMetadata().catch(console.error); + }, [fetchMetadata, itemsToShow]); + + const goToNft = useCallback(() => { + address && openOrFocusTab(`/nft/${address}`); + }, [address]); + + if (!accountNft || accountNft.length === 0) { + return <>; + } + + if (isLoading) { + return ( + + + + ); + } + + return ( + + + + {itemsToShow?.map(({ image }, index) => ( + + ))} + + + + ); +} + +export default React.memo(NftGrouped); diff --git a/packages/extension-polkagate/src/fullscreen/governance/FullScreenHeader.tsx b/packages/extension-polkagate/src/fullscreen/governance/FullScreenHeader.tsx index d6fd7edc9..59183828b 100644 --- a/packages/extension-polkagate/src/fullscreen/governance/FullScreenHeader.tsx +++ b/packages/extension-polkagate/src/fullscreen/governance/FullScreenHeader.tsx @@ -19,7 +19,7 @@ import { MAX_WIDTH } from './utils/consts'; import InternetConnectivity from './InternetConnectivity'; interface Props { - page?: 'governance' | 'manageIdentity' | 'send' | 'stake' | 'socialRecovery' | 'accountDetails' | 'proxyManagement'; + page?: 'governance' | 'manageIdentity' | 'send' | 'stake' | 'socialRecovery' | 'accountDetails' | 'proxyManagement' | 'nft'; noChainSwitch?: boolean; noAccountDropDown?: boolean; _otherComponents?: React.JSX.Element; @@ -65,6 +65,8 @@ function FullScreenHeader ({ _otherComponents, noAccountDropDown = false, noChai return onAction(`/accountfs/${selectedAddress}/0`); case 'send': return onAction(`/send/${selectedAddress}/${NATIVE_TOKEN_ASSET_ID}`); + case 'nft': + return onAction(`/nft/${selectedAddress}`); default: return null; } diff --git a/packages/extension-polkagate/src/fullscreen/governance/components/DraggableModal.tsx b/packages/extension-polkagate/src/fullscreen/governance/components/DraggableModal.tsx index 0dfdc8f76..a6774515c 100644 --- a/packages/extension-polkagate/src/fullscreen/governance/components/DraggableModal.tsx +++ b/packages/extension-polkagate/src/fullscreen/governance/components/DraggableModal.tsx @@ -13,9 +13,10 @@ interface Props { children: React.ReactElement; open: boolean; onClose: () => void + blurBackdrop?: boolean; } -export function DraggableModal ({ children, maxHeight = 740, minHeight = 615, onClose, open, width = 500 }: Props): React.ReactElement { +export function DraggableModal ({ blurBackdrop, children, maxHeight = 740, minHeight = 615, onClose, open, width = 500 }: Props): React.ReactElement { const theme = useTheme(); const isDarkMode = useMemo(() => theme.palette.mode === 'dark', [theme.palette.mode]); @@ -62,7 +63,7 @@ export function DraggableModal ({ children, maxHeight = 740, minHeight = 615, on outline: 'none' // Remove outline when Box is focused }, bgcolor: 'background.default', - border: isDarkMode ? '0.5px solid' : 'none', + border: isDarkMode && !blurBackdrop ? '0.5px solid' : 'none', borderColor: 'secondary.light', borderRadius: '10px', boxShadow: 24, @@ -79,7 +80,20 @@ export function DraggableModal ({ children, maxHeight = 740, minHeight = 615, on }; return ( - + void, icon: React.ReactNode } +interface AccountButtonType { + text: string; + onClick: () => void; + icon: React.ReactNode; + collapse?: boolean; +} export enum POPUPS_NUMBER { DERIVE_ACCOUNT, @@ -50,17 +57,24 @@ export enum POPUPS_NUMBER { MANAGE_PROFILE } -const AccountButton = ({ icon, onClick, text }: AccountButtonType) => { +const AccountButton = ({ collapse = false, icon, onClick, text }: AccountButtonType) => { const theme = useTheme(); + const collapsedStyle = collapse + ? { + '&:first-child': { '> span': { m: 0 }, m: '0px', minWidth: '48px' }, + '> span': { m: 0 } + } + : {}; + return ( ); }; @@ -92,14 +106,44 @@ const AccountTotal = ({ hideNumbers, totalBalance }: { hideNumbers: boolean | un }; function AccountInformationForHome ({ accountAssets, address, hideNumbers, isChild, selectedAsset, setSelectedAsset }: AddressDetailsProps): React.ReactElement { + const nftManager = useMemo(() => new NftManager(), []); + const { t } = useTranslation(); const theme = useTheme(); const pricesInCurrencies = usePrices(); const currency = useCurrency(); const account = useAccount(address); - const onAction = useContext(ActionContext); const [displayPopup, setDisplayPopup] = useState(); + const [myNfts, setNfts] = useState(); + + useEffect(() => { + if (!address) { + return; + } + + // Handle updates after initialization + const handleNftUpdate = (updatedAddress: string, updatedNfts: ItemInformation[]) => { + if (updatedAddress === address) { + setNfts(updatedNfts); + } + }; + + // Waits for initialization + nftManager.waitForInitialization() + .then(() => { + setNfts(nftManager.get(address)); + }) + .catch(console.error); + + // subscribe to the possible nft items for the account + nftManager.subscribe(handleNftUpdate); + + // Cleanup + return () => { + nftManager.unsubscribe(handleNftUpdate); + }; + }, [address, nftManager]); const calculatePrice = useCallback((amount: BN, decimal: number, price: number) => parseFloat(amountToHuman(amount, decimal)) * price, []); @@ -133,10 +177,6 @@ function AccountInformationForHome ({ accountAssets, address, hideNumbers, isChi }).catch(console.error); }, [address, setSelectedAsset]); - const openSettings = useCallback((): void => { - address && onAction(); - }, [onAction, address]); - const goToDetails = useCallback((): void => { address && openOrFocusTab(`/accountfs/${address}/${selectedAsset?.assetId || '0'}`, true); }, [address, selectedAsset?.assetId]); @@ -161,32 +201,42 @@ function AccountInformationForHome ({ accountAssets, address, hideNumbers, isChi - {(assetsToShow === undefined || (assetsToShow && assetsToShow?.length > 0)) && - + {(assetsToShow === undefined || (assetsToShow && assetsToShow?.length > 0)) && + + } + + + - } + - + } - onClick={openSettings} + onClick={noop} text={t('Settings')} /> } setDisplayPopup={setDisplayPopup} /> - + } onClick={goToDetails} text={t('Details')} diff --git a/packages/extension-polkagate/src/fullscreen/homeFullScreen/partials/FullScreenAccountMenu.tsx b/packages/extension-polkagate/src/fullscreen/homeFullScreen/partials/FullScreenAccountMenu.tsx index f8d710ff2..e5b0852e5 100644 --- a/packages/extension-polkagate/src/fullscreen/homeFullScreen/partials/FullScreenAccountMenu.tsx +++ b/packages/extension-polkagate/src/fullscreen/homeFullScreen/partials/FullScreenAccountMenu.tsx @@ -3,7 +3,7 @@ /* eslint-disable react/jsx-max-props-per-line */ -import { faAddressCard } from '@fortawesome/free-regular-svg-icons'; +import { faAddressCard, faGem } from '@fortawesome/free-regular-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Divider, Grid, Popover, useTheme } from '@mui/material'; import React, { useCallback, useContext } from 'react'; @@ -64,6 +64,10 @@ const Menus = ({ address, handleClose, setDisplayPopup }: { address && onAction(`/socialRecovery/${address}/false`); }, [address, onAction]); + const onNFTAlbum = useCallback(() => { + address && onAction(`/nft/${address}`); + }, [address, onAction]); + const isDisable = useCallback((supportedChains: string[]) => { if (!chain) { return true; @@ -95,7 +99,7 @@ const Menus = ({ address, handleClose, setDisplayPopup }: { } onClick={onManageProxies} - text={t('Manage proxies')} + text={t('Manage proxies')} withHoverEffect /> + + } + onClick={onNFTAlbum} + text={t('NFT album')} + withHoverEffect + /> + } onClick={onForgetAccount} text={t('Forget account')} diff --git a/packages/extension-polkagate/src/fullscreen/nft/components/AudioPlayer.tsx b/packages/extension-polkagate/src/fullscreen/nft/components/AudioPlayer.tsx new file mode 100644 index 000000000..5003cc8df --- /dev/null +++ b/packages/extension-polkagate/src/fullscreen/nft/components/AudioPlayer.tsx @@ -0,0 +1,104 @@ +// Copyright 2019-2024 @polkadot/extension-polkagate authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable react/jsx-max-props-per-line */ + +import type { AudioPlayerProps } from '../utils/types'; + +import { PauseCircle as PauseCircleIcon, PlayCircle as PlayCircleIcon } from '@mui/icons-material'; +import { Grid, IconButton, Slider, Typography } from '@mui/material'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +export default function AudioPlayer ({ audioUrl }: AudioPlayerProps): React.ReactElement { + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const audioRef = useRef(null); + + useEffect(() => { + const audio = audioRef.current; + + if (audio) { + const updateTime = () => setCurrentTime(audio.currentTime); + const updateDuration = () => setDuration(audio.duration); + + audio.addEventListener('timeupdate', updateTime); + audio.addEventListener('loadedmetadata', updateDuration); + + return () => { + audio.removeEventListener('timeupdate', updateTime); + audio.removeEventListener('loadedmetadata', updateDuration); + }; + } + + return undefined; + }, []); + + const togglePlayPause = useCallback(() => { + if (audioRef.current) { + if (isPlaying) { + audioRef.current.pause(); + } else { + audioRef.current.play().catch(console.error); + } + + setIsPlaying(!isPlaying); + } + }, [isPlaying]); + + const handleSeek = useCallback((_event: Event, newValue: number | number[]) => { + const time = newValue as number; + + setCurrentTime(time); + + if (audioRef.current) { + audioRef.current.currentTime = time; + } + }, []); + + const formatTime = useCallback((time: number) => { + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + }, []); + + return ( + + + ); +} diff --git a/packages/extension-polkagate/src/fullscreen/nft/components/Details.tsx b/packages/extension-polkagate/src/fullscreen/nft/components/Details.tsx new file mode 100644 index 000000000..8e58d20c4 --- /dev/null +++ b/packages/extension-polkagate/src/fullscreen/nft/components/Details.tsx @@ -0,0 +1,348 @@ +// Copyright 2019-2024 @polkadot/extension-polkagate authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable react/jsx-max-props-per-line */ +/* eslint-disable camelcase */ + +import type { DetailItemProps, DetailsProp } from '../utils/types'; + +import { Close as CloseIcon, OpenInFull as OpenInFullIcon } from '@mui/icons-material'; +import { Grid, IconButton, Typography, useTheme } from '@mui/material'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { Progress, TwoButtons } from '../../../components'; +import { useTranslation } from '../../../components/translate'; +import { useMetadata } from '../../../hooks'; +import { KODADOT_URL } from '../../../util/constants'; +import { DraggableModal } from '../../governance/components/DraggableModal'; +import { IPFS_GATEWAY } from '../utils/constants'; +import { fetchWithRetry, getContentUrl } from '../utils/util'; +import AudioPlayer from './AudioPlayer'; +import FullScreenNFT from './FullScreenNFT'; +import InfoRow from './InfoRow'; +import ItemAvatar from './ItemAvatar'; + +export const WithLoading = ({ children, loaded }: { loaded: boolean, children: React.ReactElement }) => ( + <> + {!loaded && + + } + {children} + +); + +const Item = React.memo( + function Item ({ animation_url, animationContentType, image, imageContentType, setShowFullscreenDisabled }: DetailItemProps) { + const [loaded, setLoaded] = useState(false); + + const isHtmlContent = animation_url && animationContentType === 'text/html'; + const isAudioOnly = !image && animation_url && animationContentType?.startsWith('audio'); + const isImageWithAudio = image && imageContentType?.startsWith('image') && animation_url && animationContentType?.startsWith('audio'); + + const onLoaded = useCallback(() => { + setLoaded(true); + }, []); + + if (isHtmlContent) { + return ( + <> + {!loaded && + + + + } +