diff --git a/apps/core/package.json b/apps/core/package.json index 0a8cd7acfeb..5a73c837e6b 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -36,6 +36,7 @@ "bignumber.js": "^9.1.1", "clsx": "^2.1.1", "formik": "^2.4.2", + "idb-keyval": "^6.2.1", "qrcode.react": "^4.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/apps/core/src/contexts/HiddenAssetsProvider.tsx b/apps/core/src/contexts/HiddenAssetsProvider.tsx new file mode 100644 index 00000000000..3d03e54669f --- /dev/null +++ b/apps/core/src/contexts/HiddenAssetsProvider.tsx @@ -0,0 +1,112 @@ +// Copyright (c) Mysten Labs, Inc. +// Modifications Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { get, set } from 'idb-keyval'; +import { + PropsWithChildren, + createContext, + useCallback, + useContext, + useEffect, + useState, + useRef, +} from 'react'; + +const HIDDEN_ASSET_IDS = 'hidden-asset-ids'; + +export type HiddenAssets = + | { + type: 'loading'; + } + | { + type: 'loaded'; + assetIds: string[]; + }; + +interface HiddenAssetContext { + hiddenAssets: HiddenAssets; + hideAsset: (assetId: string) => string | void; + showAsset: (assetId: string) => string | void; +} + +export const HiddenAssetsContext = createContext({ + hiddenAssets: { + type: 'loading', + }, + hideAsset: () => {}, + showAsset: () => {}, +}); + +export const HiddenAssetsProvider = ({ children }: PropsWithChildren) => { + const [hiddenAssets, setHiddenAssets] = useState({ + type: 'loading', + }); + const hiddenAssetIdsRef = useRef([]); + + useEffect(() => { + (async () => { + try { + const hiddenAssetsFromStorage = (await get(HIDDEN_ASSET_IDS)) ?? []; + hiddenAssetIdsRef.current = hiddenAssetsFromStorage; + setHiddenAssetIds(hiddenAssetsFromStorage); + } catch (error) { + console.error('Failed to load hidden assets from storage:', error); + setHiddenAssetIds([]); + } + })(); + }, []); + + function setHiddenAssetIds(hiddenAssetIds: string[]) { + hiddenAssetIdsRef.current = hiddenAssetIds; + setHiddenAssets({ + type: 'loaded', + assetIds: hiddenAssetIds, + }); + } + + const syncIdb = useCallback(async (nextState: string[], prevState: string[]) => { + try { + await set(HIDDEN_ASSET_IDS, nextState); + } catch (error) { + console.error('Error syncing with IndexedDB:', error); + // Revert to the previous state on failure + setHiddenAssetIds(prevState); + } + }, []); + + const hideAsset = useCallback((assetId: string) => { + const prevIds = [...hiddenAssetIdsRef.current]; + const newHiddenAssetIds = Array.from(new Set([...hiddenAssetIdsRef.current, assetId])); + setHiddenAssetIds(newHiddenAssetIds); + syncIdb(newHiddenAssetIds, prevIds); + return assetId; + }, []); + + const showAsset = useCallback((assetId: string) => { + // Ensure the asset exists in the hidden list + if (!hiddenAssetIdsRef.current.includes(assetId)) return; + + const prevIds = [...hiddenAssetIdsRef.current]; + // Compute the new list of hidden assets + const updatedHiddenAssetIds = hiddenAssetIdsRef.current.filter((id) => id !== assetId); + setHiddenAssetIds(updatedHiddenAssetIds); + syncIdb(updatedHiddenAssetIds, prevIds); + }, []); + + return ( + + {children} + + ); +}; + +export const useHiddenAssets = () => { + return useContext(HiddenAssetsContext); +}; diff --git a/apps/core/src/contexts/index.ts b/apps/core/src/contexts/index.ts index c592171afa8..fe492c59d09 100644 --- a/apps/core/src/contexts/index.ts +++ b/apps/core/src/contexts/index.ts @@ -2,3 +2,4 @@ // SPDX-License-Identifier: Apache-2.0 export * from './ThemeContext'; +export * from './HiddenAssetsProvider'; diff --git a/apps/core/src/hooks/index.ts b/apps/core/src/hooks/index.ts index 1602541e57b..40489d25ff2 100644 --- a/apps/core/src/hooks/index.ts +++ b/apps/core/src/hooks/index.ts @@ -47,6 +47,8 @@ export * from './useOwnedNFT'; export * from './useNftDetails'; export * from './useCountdownByTimestamp'; export * from './useStakeRewardStatus'; +export * from './useGetNFTs'; export * from './useRecognizedPackages'; export * from './stake'; +export * from './ui'; diff --git a/apps/core/src/hooks/ui/index.ts b/apps/core/src/hooks/ui/index.ts new file mode 100644 index 00000000000..37bbc87557c --- /dev/null +++ b/apps/core/src/hooks/ui/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +export * from './usePageAssets'; diff --git a/apps/core/src/hooks/ui/usePageAssets.ts b/apps/core/src/hooks/ui/usePageAssets.ts new file mode 100644 index 00000000000..6fbd8f35836 --- /dev/null +++ b/apps/core/src/hooks/ui/usePageAssets.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 +import { useState, useMemo, useRef, useEffect } from 'react'; +import { useGetNFTs, HiddenAssets, useOnScreen } from '../..'; + +export enum AssetCategory { + Visual = 'Visual', + Other = 'Other', + Hidden = 'Hidden', +} + +export function usePageAssets(address: string | null, hiddenAssets?: HiddenAssets) { + const [selectedAssetCategory, setSelectedAssetCategory] = useState(null); + const observerElem = useRef(null); + const { isIntersecting } = useOnScreen(observerElem); + const { + data: ownedAssets, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + error, + isPending, + isError, + isFetching, + refetch, + } = useGetNFTs(address, hiddenAssets); + + const isAssetsLoaded = !!ownedAssets; + const isSpinnerVisible = isFetchingNextPage && hasNextPage; + + const filteredAssets = (() => { + if (!ownedAssets) return []; + switch (selectedAssetCategory) { + case AssetCategory.Visual: + return ownedAssets.visual; + case AssetCategory.Other: + return ownedAssets.other; + default: + return []; + } + })(); + + const filteredHiddenAssets = useMemo(() => { + return ( + ownedAssets?.hidden + .flatMap((data) => { + return { + data: data, + display: data?.display?.data, + }; + }) + .sort((nftA, nftB) => { + const nameA = nftA.display?.name || ''; + const nameB = nftB.display?.name || ''; + + if (nameA < nameB) { + return -1; + } else if (nameA > nameB) { + return 1; + } + return 0; + }) ?? [] + ); + }, [ownedAssets]); + + // Fetch the next page if the user scrolls to the bottom of the page + useEffect(() => { + if (isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [isIntersecting, fetchNextPage, hasNextPage, isFetchingNextPage]); + + // select the default category if no category is selected and assets are loaded + useEffect(() => { + let computeSelectedCategory = false; + if ( + (selectedAssetCategory === AssetCategory.Visual && ownedAssets?.visual.length === 0) || + (selectedAssetCategory === AssetCategory.Other && ownedAssets?.other.length === 0) || + (selectedAssetCategory === AssetCategory.Hidden && ownedAssets?.hidden.length === 0) || + !selectedAssetCategory + ) { + computeSelectedCategory = true; + } + if (computeSelectedCategory && ownedAssets) { + const defaultCategory = + ownedAssets.visual.length > 0 + ? AssetCategory.Visual + : ownedAssets.other.length > 0 + ? AssetCategory.Other + : ownedAssets.hidden.length > 0 + ? AssetCategory.Hidden + : null; + setSelectedAssetCategory(defaultCategory); + } + }, [ownedAssets]); + + // Fetch the next page if there are no visual assets, other + hidden assets are present in multiples of 50, and there are more pages to fetch + useEffect(() => { + if ( + hasNextPage && + ownedAssets?.visual.length === 0 && + ownedAssets?.other.length + ownedAssets?.hidden.length > 0 && + (ownedAssets.other.length + ownedAssets.hidden.length) % 50 === 0 && + !isFetchingNextPage + ) { + fetchNextPage(); + setSelectedAssetCategory(null); + } + }, [hasNextPage, ownedAssets, isFetchingNextPage]); + + return { + // reexport from useGetNFTs + ownedAssets, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + error, + isPending, + isError, + isFetching, + refetch, + + isAssetsLoaded, + filteredAssets, + filteredHiddenAssets, + selectedAssetCategory, + setSelectedAssetCategory, + observerElem, + isSpinnerVisible, + }; +} diff --git a/apps/wallet/src/ui/app/hooks/useGetNFTs.ts b/apps/core/src/hooks/useGetNFTs.ts similarity index 73% rename from apps/wallet/src/ui/app/hooks/useGetNFTs.ts rename to apps/core/src/hooks/useGetNFTs.ts index 8e891e129be..9fedb74b911 100644 --- a/apps/wallet/src/ui/app/hooks/useGetNFTs.ts +++ b/apps/core/src/hooks/useGetNFTs.ts @@ -2,10 +2,16 @@ // Modifications Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { hasDisplayData, isKioskOwnerToken, useGetOwnedObjects, useKioskClient } from '@iota/core'; +import { + hasDisplayData, + isKioskOwnerToken, + useGetOwnedObjects, + useKioskClient, + HiddenAssets, + COIN_TYPE, +} from '../../'; import { type IotaObjectData } from '@iota/iota-sdk/client'; import { useMemo } from 'react'; -import { useHiddenAssets } from '../pages/home/assets/HiddenAssetsProvider'; type OwnedAssets = { visual: IotaObjectData[]; @@ -18,10 +24,13 @@ export enum AssetFilterTypes { Other = 'other', } -export function useGetNFTs(address?: string | null) { +const OBJECTS_PER_REQ = 50; + +export function useGetNFTs(address?: string | null, hiddenAssets?: HiddenAssets) { const kioskClient = useKioskClient(); const { data, + isFetching, isPending, error, isError, @@ -29,14 +38,14 @@ export function useGetNFTs(address?: string | null) { hasNextPage, fetchNextPage, isLoading, + refetch, } = useGetOwnedObjects( address, { - MatchNone: [{ StructType: '0x2::coin::Coin' }], + MatchNone: [{ StructType: COIN_TYPE }], }, - 50, + OBJECTS_PER_REQ, ); - const { hiddenAssets } = useHiddenAssets(); const assets = useMemo(() => { const ownedAssets: OwnedAssets = { @@ -45,13 +54,16 @@ export function useGetNFTs(address?: string | null) { hidden: [], }; - if (hiddenAssets.type === 'loading') { + if (hiddenAssets?.type === 'loading') { return ownedAssets; } else { const groupedAssets = data?.pages .flatMap((page) => page.data) .reduce((acc, curr) => { - if (curr.data?.objectId && hiddenAssets.assetIds.includes(curr.data?.objectId)) + if ( + curr.data?.objectId && + hiddenAssets?.assetIds?.includes(curr.data?.objectId) + ) acc.hidden.push(curr.data as IotaObjectData); else if (hasDisplayData(curr) || isKioskOwnerToken(kioskClient.network, curr)) acc.visual.push(curr.data as IotaObjectData); @@ -64,6 +76,7 @@ export function useGetNFTs(address?: string | null) { return { data: assets, + isFetching, isLoading, hasNextPage, isFetchingNextPage, @@ -71,5 +84,6 @@ export function useGetNFTs(address?: string | null) { isPending: isPending, isError: isError, error, + refetch, }; } diff --git a/apps/core/src/hooks/useOnScreen.ts b/apps/core/src/hooks/useOnScreen.ts index 33ae1701b8c..a1532e79818 100644 --- a/apps/core/src/hooks/useOnScreen.ts +++ b/apps/core/src/hooks/useOnScreen.ts @@ -19,7 +19,7 @@ export const useOnScreen = (elementRef: MutableRefObject) => { ); observer.observe(node); return () => observer.disconnect(); - }, [elementRef]); + }, [elementRef.current]); return { isIntersecting }; }; diff --git a/apps/ui-kit/src/lib/components/molecules/chip/Chip.tsx b/apps/ui-kit/src/lib/components/molecules/chip/Chip.tsx index 8d98db426aa..3fd593beec5 100644 --- a/apps/ui-kit/src/lib/components/molecules/chip/Chip.tsx +++ b/apps/ui-kit/src/lib/components/molecules/chip/Chip.tsx @@ -47,6 +47,10 @@ interface ChipProps { * Trailing element to show in the chip. */ trailingElement?: React.JSX.Element; + /** + * The button is disabled or not. + */ + disabled?: boolean; } export function Chip({ @@ -58,18 +62,20 @@ export function Chip({ avatar, leadingElement, trailingElement, + disabled, }: ChipProps) { const chipState = selected ? ChipState.Selected : ChipState.Default; return ( = { + [AssetCategory.Visual]: + 'grid-template-visual-assets grid max-h-[600px] gap-md overflow-auto py-sm', + [AssetCategory.Other]: 'flex flex-col overflow-auto py-sm', + [AssetCategory.Hidden]: 'flex flex-col overflow-auto py-sm', +}; + export default function AssetsDashboardPage(): React.JSX.Element { const [selectedAsset, setSelectedAsset] = useState(null); - const [selectedCategory, setSelectedCategory] = useState(AssetCategory.Visual); const account = useCurrentAccount(); - const { data, isFetching, fetchNextPage, hasNextPage, refetch } = useGetOwnedObjects( - account?.address, - { - MatchNone: [{ StructType: COIN_TYPE }], - }, - OBJECTS_PER_REQ, - ); + const accountAddress = account?.address || null; + + const { + isPending, + refetch, + error, + isError, - const assets = (data?.pages || []) - .flatMap((page) => page.data) - .filter((asset) => { - if (!asset.data || !asset.data.objectId) { - return false; - } - if (selectedCategory === AssetCategory.Visual) { - return hasDisplayData(asset); - } - if (selectedCategory === AssetCategory.Other) { - return !hasDisplayData(asset); - } - return false; - }) - .map((asset) => asset.data) - .filter((data): data is IotaObjectData => data !== null && data !== undefined); + ownedAssets, + isAssetsLoaded, + filteredAssets, + selectedAssetCategory, + setSelectedAssetCategory, + observerElem, + isSpinnerVisible, + } = usePageAssets(accountAddress); function onAssetClick(asset: IotaObjectData) { setSelectedAsset(asset); @@ -62,31 +69,69 @@ export default function AssetsDashboardPage(): React.JSX.Element { <div className="px-lg"> - <div className="flex flex-row items-center justify-start gap-xs py-xs"> - {ASSET_CATEGORIES.map((tab) => ( - <Chip - key={tab.label} - label={tab.label} - onClick={() => setSelectedCategory(tab.value)} - selected={selectedCategory === tab.value} + {isError ? ( + <div className="mb-2 flex h-full w-full items-center justify-center p-2"> + <InfoBox + type={InfoBoxType.Error} + title="Sync error (data might be outdated)" + supportingText={error?.message ?? 'An error occurred'} + icon={<Warning />} + style={InfoBoxStyle.Default} /> - ))} - </div> + </div> + ) : ( + <> + {isAssetsLoaded && + Boolean(ownedAssets?.visual.length || ownedAssets?.other.length) ? ( + <div className="flex flex-row items-center justify-start gap-xs py-xs"> + {ASSET_CATEGORIES.map(({ value, label }) => ( + <Chip + key={value} + onClick={() => setSelectedAssetCategory(value)} + label={label} + selected={selectedAssetCategory === value} + disabled={ + AssetCategory.Visual === value + ? !ownedAssets?.visual.length + : !ownedAssets?.other.length + } + /> + ))} + </div> + ) : null} + <Loading loading={isPending}> + <div + className={cl( + 'max-h-[600px]', + selectedAssetCategory && ASSET_LAYOUT[selectedAssetCategory], + )} + > + {filteredAssets.map((asset) => ( + <AssetTileLink + key={asset.digest} + asset={asset} + type={selectedAssetCategory} + onClick={onAssetClick} + /> + ))} + <div ref={observerElem}> + {isSpinnerVisible ? ( + <div className="mt-1 flex h-full w-full justify-center"> + <LoadingIndicator /> + </div> + ) : null} + </div> + </div> + </Loading> - <AssetList - assets={assets} - selectedCategory={selectedCategory} - onClick={onAssetClick} - hasNextPage={hasNextPage} - isFetchingNextPage={isFetching} - fetchNextPage={fetchNextPage} - /> - {selectedAsset && ( - <AssetDialog - onClose={() => setSelectedAsset(null)} - asset={selectedAsset} - refetchAssets={refetch} - /> + {selectedAsset && ( + <AssetDialog + onClose={() => setSelectedAsset(null)} + asset={selectedAsset} + refetchAssets={refetch} + /> + )} + </> )} </div> </Panel> diff --git a/apps/wallet-dashboard/components/AssetsList.tsx b/apps/wallet-dashboard/components/AssetsList.tsx deleted file mode 100644 index adb9e251581..00000000000 --- a/apps/wallet-dashboard/components/AssetsList.tsx +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) 2024 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -import { AssetCategory } from '@/lib/enums'; -import { IotaObjectData } from '@iota/iota-sdk/client'; -import { AssetTileLink } from '@/components'; -import { LoadingIndicator } from '@iota/apps-ui-kit'; -import { useEffect, useRef } from 'react'; -import { useOnScreen } from '@iota/core'; -import cl from 'clsx'; - -interface AssetListProps { - assets: IotaObjectData[]; - selectedCategory: AssetCategory; - hasNextPage: boolean; - isFetchingNextPage: boolean; - fetchNextPage: () => void; - onClick: (asset: IotaObjectData) => void; -} - -const ASSET_LAYOUT: Record<AssetCategory, string> = { - [AssetCategory.Visual]: - 'grid-template-visual-assets grid max-h-[600px] gap-md overflow-auto py-sm', - [AssetCategory.Other]: 'flex flex-col overflow-auto py-sm', -}; - -export function AssetList({ - assets, - selectedCategory, - hasNextPage, - isFetchingNextPage, - fetchNextPage, - onClick, -}: AssetListProps): React.JSX.Element { - const observerElem = useRef<HTMLDivElement | null>(null); - const { isIntersecting } = useOnScreen(observerElem); - const isSpinnerVisible = isFetchingNextPage && hasNextPage; - - useEffect(() => { - if (isIntersecting && hasNextPage && !isFetchingNextPage && fetchNextPage) { - fetchNextPage(); - } - }, [isIntersecting, fetchNextPage, hasNextPage, isFetchingNextPage]); - - return ( - <div className={cl('max-h-[600px]', ASSET_LAYOUT[selectedCategory])}> - {assets.map((asset) => ( - <AssetTileLink - key={asset.digest} - asset={asset} - type={selectedCategory} - onClick={onClick} - /> - ))} - <div ref={observerElem}> - {isSpinnerVisible ? ( - <div className="mt-1 flex h-full w-full justify-center"> - <LoadingIndicator /> - </div> - ) : null} - </div> - </div> - ); -} diff --git a/apps/wallet-dashboard/components/index.ts b/apps/wallet-dashboard/components/index.ts index 5b3562d4e5b..0a713ba5fb6 100644 --- a/apps/wallet-dashboard/components/index.ts +++ b/apps/wallet-dashboard/components/index.ts @@ -28,3 +28,4 @@ export * from './StakeRewardsPanel'; export * from './MigrationOverview'; export * from './SupplyIncreaseVestingOverview'; export * from './staked-timelock-object'; +export * from './loading'; diff --git a/apps/wallet-dashboard/components/loading/Loading.tsx b/apps/wallet-dashboard/components/loading/Loading.tsx new file mode 100644 index 00000000000..cea927d254c --- /dev/null +++ b/apps/wallet-dashboard/components/loading/Loading.tsx @@ -0,0 +1,20 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { LoadingIndicator, type LoadingIndicatorProps } from '@iota/apps-ui-kit'; +import type { ReactNode } from 'react'; + +interface LoadingProps extends LoadingIndicatorProps { + loading: boolean; + children: ReactNode | ReactNode[]; +} + +export function Loading({ loading, children, ...indicatorProps }: LoadingProps) { + return loading ? ( + <div className="flex h-full w-full items-center justify-center"> + <LoadingIndicator {...indicatorProps} /> + </div> + ) : ( + <>{children}</> + ); +} diff --git a/apps/wallet-dashboard/components/loading/index.ts b/apps/wallet-dashboard/components/loading/index.ts new file mode 100644 index 00000000000..0c903d2dc5e --- /dev/null +++ b/apps/wallet-dashboard/components/loading/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +export * from './Loading'; diff --git a/apps/wallet-dashboard/components/tiles/AssetTileLink.tsx b/apps/wallet-dashboard/components/tiles/AssetTileLink.tsx index 6d5d25d23b7..08af2c2e683 100644 --- a/apps/wallet-dashboard/components/tiles/AssetTileLink.tsx +++ b/apps/wallet-dashboard/components/tiles/AssetTileLink.tsx @@ -3,7 +3,7 @@ 'use client'; -import { AssetCategory } from '@/lib/enums'; +import { AssetCategory } from '@iota/core'; import { VisibilityOff } from '@iota/ui-icons'; import { VisualAssetTile } from '.'; import { IotaObjectData } from '@iota/iota-sdk/client'; @@ -11,7 +11,7 @@ import { NonVisualAssetCard } from './NonVisualAssetTile'; interface AssetTileLinkProps { asset: IotaObjectData; - type: AssetCategory; + type: AssetCategory | null; onClick: (asset: IotaObjectData) => void; } diff --git a/apps/wallet-dashboard/lib/enums/assetCategory.enums.ts b/apps/wallet-dashboard/lib/enums/assetCategory.enums.ts index bab9839ceaa..b7d45b16343 100644 --- a/apps/wallet-dashboard/lib/enums/assetCategory.enums.ts +++ b/apps/wallet-dashboard/lib/enums/assetCategory.enums.ts @@ -2,6 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 export enum AssetCategory { - Visual = 'Visual', - Other = 'Other', + Visual = 'visual', + Other = 'other', } diff --git a/apps/wallet/src/ui/app/components/MovedAssetNotification.tsx b/apps/wallet/src/ui/app/components/MovedAssetNotification.tsx new file mode 100644 index 00000000000..f6521663de8 --- /dev/null +++ b/apps/wallet/src/ui/app/components/MovedAssetNotification.tsx @@ -0,0 +1,33 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { type Toast, toast } from 'react-hot-toast'; +import { ButtonUnstyled } from '@iota/apps-ui-kit'; + +interface MovedAssetNotificationProps { + t: Toast; + destination: string; + onUndo: () => void; +} + +export function MovedAssetNotification({ t, destination, onUndo }: MovedAssetNotificationProps) { + return ( + <div + className="flex w-full flex-row items-baseline gap-x-xxs" + onClick={() => toast.dismiss(t.id)} + > + <ButtonUnstyled className="text-body-sm text-neutral-12 dark:text-neutral-92"> + Moved to {destination} + </ButtonUnstyled> + <ButtonUnstyled + onClick={() => { + onUndo(); + toast.dismiss(t.id); + }} + className="ml-auto mr-sm text-body-sm text-neutral-12 dark:text-neutral-92" + > + UNDO + </ButtonUnstyled> + </div> + ); +} diff --git a/apps/wallet/src/ui/app/components/index.ts b/apps/wallet/src/ui/app/components/index.ts index 6166f65e8f4..da27863a37e 100644 --- a/apps/wallet/src/ui/app/components/index.ts +++ b/apps/wallet/src/ui/app/components/index.ts @@ -32,3 +32,4 @@ export * from './receipt-card/TxnAmount'; export * from './transactions-card'; export * from './user-approve-container'; export * from './filters-tags'; +export * from './MovedAssetNotification'; diff --git a/apps/wallet/src/ui/app/pages/home/assets/HiddenAssetsProvider.tsx b/apps/wallet/src/ui/app/pages/home/assets/HiddenAssetsProvider.tsx deleted file mode 100644 index ff94f428a74..00000000000 --- a/apps/wallet/src/ui/app/pages/home/assets/HiddenAssetsProvider.tsx +++ /dev/null @@ -1,205 +0,0 @@ -// Copyright (c) Mysten Labs, Inc. -// Modifications Copyright (c) 2024 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -import { get, set } from 'idb-keyval'; -import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'; -import { type Toast, toast } from 'react-hot-toast'; -import { ButtonUnstyled } from '@iota/apps-ui-kit'; - -const HIDDEN_ASSET_IDS = 'hidden-asset-ids'; - -type HiddenAssets = - | { - type: 'loading'; - } - | { - type: 'loaded'; - assetIds: string[]; - }; - -interface HiddenAssetContext { - hiddenAssets: HiddenAssets; - setHiddenAssetIds: (hiddenAssetIds: string[]) => void; - hideAsset: (assetId: string) => void; - showAsset: (assetId: string) => void; -} - -export const HiddenAssetsContext = createContext<HiddenAssetContext>({ - hiddenAssets: { - type: 'loading', - }, - setHiddenAssetIds: () => {}, - hideAsset: () => {}, - showAsset: () => {}, -}); - -export const HiddenAssetsProvider = ({ children }: { children: ReactNode }) => { - const [hiddenAssets, setHiddenAssets] = useState<HiddenAssets>({ - type: 'loading', - }); - - const hiddenAssetIds = hiddenAssets.type === 'loaded' ? hiddenAssets.assetIds : []; - - useEffect(() => { - (async () => { - const hiddenAssets = (await get<string[]>(HIDDEN_ASSET_IDS)) ?? []; - setHiddenAssetIds(hiddenAssets); - })(); - }, []); - - function setHiddenAssetIds(hiddenAssetIds: string[]) { - setHiddenAssets({ - type: 'loaded', - assetIds: hiddenAssetIds, - }); - } - - const hideAssetId = useCallback( - async (newAssetId: string) => { - if (hiddenAssetIds.includes(newAssetId)) return; - - const newHiddenAssetIds = [...hiddenAssetIds, newAssetId]; - setHiddenAssetIds(newHiddenAssetIds); - await set(HIDDEN_ASSET_IDS, newHiddenAssetIds); - - const undoHideAsset = async (assetId: string) => { - try { - let updatedHiddenAssetIds; - setHiddenAssets((previous) => { - const previousIds = previous.type === 'loaded' ? previous.assetIds : []; - updatedHiddenAssetIds = previousIds.filter((id) => id !== assetId); - return { - type: 'loaded', - assetIds: updatedHiddenAssetIds, - }; - }); - await set(HIDDEN_ASSET_IDS, updatedHiddenAssetIds); - } catch (error) { - // Handle any error that occurred during the unhide process - toast.error('Failed to unhide asset.'); - // Restore the asset ID back to the hidden asset IDs list - setHiddenAssetIds([...hiddenAssetIds, assetId]); - await set(HIDDEN_ASSET_IDS, hiddenAssetIds); - } - }; - - const showAssetHiddenToast = async (objectId: string) => { - toast.success( - (t) => ( - <MovedAssetNotification - t={t} - destination="Hidden Assets" - onUndo={() => undoHideAsset(objectId)} - /> - ), - { - duration: 4000, - }, - ); - }; - showAssetHiddenToast(newAssetId); - }, - [hiddenAssetIds], - ); - - const showAssetId = useCallback( - async (newAssetId: string) => { - if (!hiddenAssetIds.includes(newAssetId)) return; - - try { - const updatedHiddenAssetIds = hiddenAssetIds.filter((id) => id !== newAssetId); - setHiddenAssetIds(updatedHiddenAssetIds); - await set(HIDDEN_ASSET_IDS, updatedHiddenAssetIds); - } catch (error) { - // Handle any error that occurred during the unhide process - toast.error('Failed to show asset.'); - // Restore the asset ID back to the hidden asset IDs list - setHiddenAssetIds([...hiddenAssetIds, newAssetId]); - await set(HIDDEN_ASSET_IDS, hiddenAssetIds); - } - - const undoShowAsset = async (assetId: string) => { - let newHiddenAssetIds; - setHiddenAssets((previous) => { - const previousIds = previous.type === 'loaded' ? previous.assetIds : []; - newHiddenAssetIds = [...previousIds, assetId]; - return { - type: 'loaded', - assetIds: newHiddenAssetIds, - }; - }); - await set(HIDDEN_ASSET_IDS, newHiddenAssetIds); - }; - - const assetShownToast = async (objectId: string) => { - toast.success( - (t) => ( - <MovedAssetNotification - t={t} - destination="Visual Assets" - onUndo={() => undoShowAsset(objectId)} - /> - ), - { - duration: 4000, - }, - ); - }; - - assetShownToast(newAssetId); - }, - [hiddenAssetIds], - ); - - const showAsset = (objectId: string) => { - showAssetId(objectId); - }; - - return ( - <HiddenAssetsContext.Provider - value={{ - hiddenAssets: - hiddenAssets.type === 'loaded' - ? { ...hiddenAssets, assetIds: Array.from(new Set(hiddenAssetIds)) } - : { type: 'loading' }, - setHiddenAssetIds, - hideAsset: hideAssetId, - showAsset, - }} - > - {children} - </HiddenAssetsContext.Provider> - ); -}; - -export const useHiddenAssets = () => { - return useContext(HiddenAssetsContext); -}; - -interface MovedAssetNotificationProps { - t: Toast; - destination: string; - onUndo: () => void; -} -function MovedAssetNotification({ t, destination, onUndo }: MovedAssetNotificationProps) { - return ( - <div - className="flex w-full flex-row items-baseline gap-x-xxs" - onClick={() => toast.dismiss(t.id)} - > - <ButtonUnstyled className="text-body-sm text-neutral-12 dark:text-neutral-92"> - Moved to {destination} - </ButtonUnstyled> - <ButtonUnstyled - onClick={() => { - onUndo(); - toast.dismiss(t.id); - }} - className="ml-auto mr-sm text-body-sm text-neutral-12 dark:text-neutral-92" - > - UNDO - </ButtonUnstyled> - </div> - ); -} diff --git a/apps/wallet/src/ui/app/pages/home/assets/index.tsx b/apps/wallet/src/ui/app/pages/home/assets/index.tsx index e26f9407b3b..b1b2b0dbc7e 100644 --- a/apps/wallet/src/ui/app/pages/home/assets/index.tsx +++ b/apps/wallet/src/ui/app/pages/home/assets/index.tsx @@ -5,7 +5,7 @@ import { useUnlockedGuard } from '_src/ui/app/hooks/useUnlockedGuard'; import { Route, Routes } from 'react-router-dom'; import { NftsPage } from '..'; -import { HiddenAssetsProvider } from './HiddenAssetsProvider'; +import { HiddenAssetsProvider } from '@iota/core'; export function AssetsPage() { if (useUnlockedGuard()) { diff --git a/apps/wallet/src/ui/app/pages/home/nfts/HiddenAsset.tsx b/apps/wallet/src/ui/app/pages/home/nfts/HiddenAsset.tsx index 58ffb29a897..59bc52ff5b3 100644 --- a/apps/wallet/src/ui/app/pages/home/nfts/HiddenAsset.tsx +++ b/apps/wallet/src/ui/app/pages/home/nfts/HiddenAsset.tsx @@ -2,17 +2,18 @@ // Modifications Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { ErrorBoundary } from '_components'; +import { ErrorBoundary, MovedAssetNotification } from '_components'; import { ampli } from '_src/shared/analytics/ampli'; import { type IotaObjectData } from '@iota/iota-sdk/client'; import { useNavigate } from 'react-router-dom'; -import { useHiddenAssets } from '../assets/HiddenAssetsProvider'; +import { toast } from 'react-hot-toast'; import { getKioskIdFromOwnerCap, isKioskOwnerToken, useGetNFTDisplay, useGetObject, useKioskClient, + useHiddenAssets, } from '@iota/core'; import { Card, @@ -39,7 +40,7 @@ export interface HiddenAssetProps { } export function HiddenAsset(item: HiddenAssetProps) { - const { showAsset } = useHiddenAssets(); + const { showAsset, hideAsset } = useHiddenAssets(); const kioskClient = useKioskClient(); const navigate = useNavigate(); const { objectId, type } = item.data!; @@ -65,6 +66,23 @@ export function HiddenAsset(item: HiddenAssetProps) { collectibleType: type!, }); } + + function handleShowAsset() { + showAsset(objectId); + toast.success( + (t) => ( + <MovedAssetNotification + t={t} + destination="Visual Assets" + onUndo={() => hideAsset(objectId)} + /> + ), + { + duration: 4000, + }, + ); + } + return ( <ErrorBoundary> <Card type={CardType.Default} onClick={handleHiddenAssetClick}> @@ -88,9 +106,7 @@ export function HiddenAsset(item: HiddenAssetProps) { <CardBody title={nftMeta?.name ?? 'Asset'} subtitle={formatAddress(objectId)} /> <CardAction type={CardActionType.Link} - onClick={() => { - showAsset(objectId); - }} + onClick={handleShowAsset} icon={<VisibilityOff />} /> </Card> diff --git a/apps/wallet/src/ui/app/pages/home/nfts/VisualAssets.tsx b/apps/wallet/src/ui/app/pages/home/nfts/VisualAssets.tsx index c4e1e046bc5..9e1654738bd 100644 --- a/apps/wallet/src/ui/app/pages/home/nfts/VisualAssets.tsx +++ b/apps/wallet/src/ui/app/pages/home/nfts/VisualAssets.tsx @@ -2,13 +2,17 @@ // Modifications Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { ErrorBoundary, NFTDisplayCard } from '_components'; +import { ErrorBoundary, NFTDisplayCard, MovedAssetNotification } from '_components'; import { ampli } from '_src/shared/analytics/ampli'; import { type IotaObjectData } from '@iota/iota-sdk/client'; import { Link } from 'react-router-dom'; - -import { useHiddenAssets } from '../assets/HiddenAssetsProvider'; -import { getKioskIdFromOwnerCap, isKioskOwnerToken, useKioskClient } from '@iota/core'; +import { toast } from 'react-hot-toast'; +import { + useHiddenAssets, + getKioskIdFromOwnerCap, + isKioskOwnerToken, + useKioskClient, +} from '@iota/core'; import { VisibilityOff } from '@iota/ui-icons'; interface VisualAssetsProps { @@ -16,17 +20,34 @@ interface VisualAssetsProps { } export function VisualAssets({ items }: VisualAssetsProps) { - const { hideAsset } = useHiddenAssets(); + const { hideAsset, showAsset } = useHiddenAssets(); const kioskClient = useKioskClient(); - function handleHideAsset(event: React.MouseEvent<HTMLButtonElement>, object: IotaObjectData) { + async function handleHideAsset( + event: React.MouseEvent<HTMLButtonElement>, + object: IotaObjectData, + ) { event.preventDefault(); event.stopPropagation(); ampli.clickedHideAsset({ objectId: object.objectId, collectibleType: object.type!, }); - hideAsset(object.objectId); + + await hideAsset(object.objectId); + + toast.success( + (t) => ( + <MovedAssetNotification + t={t} + destination="Hidden Assets" + onUndo={() => showAsset(object.objectId)} + /> + ), + { + duration: 4000, + }, + ); } return ( diff --git a/apps/wallet/src/ui/app/pages/home/nfts/index.tsx b/apps/wallet/src/ui/app/pages/home/nfts/index.tsx index 30d5af7b648..e90fbc2ae18 100644 --- a/apps/wallet/src/ui/app/pages/home/nfts/index.tsx +++ b/apps/wallet/src/ui/app/pages/home/nfts/index.tsx @@ -13,19 +13,11 @@ import { } from '@iota/apps-ui-kit'; import { useActiveAddress } from '_app/hooks/useActiveAddress'; import { Loading, NoData, PageTemplate } from '_components'; -import { useGetNFTs } from '_src/ui/app/hooks/useGetNFTs'; -import { useEffect, useMemo, useRef, useState } from 'react'; import { HiddenAssets } from './HiddenAssets'; import { NonVisualAssets } from './NonVisualAssets'; import { VisualAssets } from './VisualAssets'; import { Warning } from '@iota/ui-icons'; -import { useOnScreen } from '@iota/core'; - -enum AssetCategory { - Visual = 'Visual', - Other = 'Other', - Hidden = 'Hidden', -} +import { useHiddenAssets, usePageAssets, AssetCategory } from '@iota/core'; const ASSET_CATEGORIES = [ { @@ -43,102 +35,22 @@ const ASSET_CATEGORIES = [ ]; export function NftsPage() { - const [selectedAssetCategory, setSelectedAssetCategory] = useState<AssetCategory | null>(null); - const observerElem = useRef<HTMLDivElement | null>(null); - const { isIntersecting } = useOnScreen(observerElem); - const accountAddress = useActiveAddress(); + const { hiddenAssets } = useHiddenAssets(); + const { - data: ownedAssets, - hasNextPage, - isFetchingNextPage, - fetchNextPage, - error, isPending, + isAssetsLoaded, isError, - } = useGetNFTs(accountAddress); - - const isAssetsLoaded = !!ownedAssets; - - const isSpinnerVisible = isFetchingNextPage && hasNextPage; - - const filteredAssets = (() => { - if (!ownedAssets) return []; - switch (selectedAssetCategory) { - case AssetCategory.Visual: - return ownedAssets.visual; - case AssetCategory.Other: - return ownedAssets.other; - default: - return []; - } - })(); - - const filteredHiddenAssets = useMemo(() => { - return ( - ownedAssets?.hidden - .flatMap((data) => { - return { - data: data, - display: data?.display?.data, - }; - }) - .sort((nftA, nftB) => { - const nameA = nftA.display?.name || ''; - const nameB = nftB.display?.name || ''; - - if (nameA < nameB) { - return -1; - } else if (nameA > nameB) { - return 1; - } - return 0; - }) ?? [] - ); - }, [ownedAssets]); - - useEffect(() => { - if (isIntersecting && hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } - }, [isIntersecting, fetchNextPage, hasNextPage, isFetchingNextPage]); - - useEffect(() => { - let computeSelectedCategory = false; - if ( - (selectedAssetCategory === AssetCategory.Visual && ownedAssets?.visual.length === 0) || - (selectedAssetCategory === AssetCategory.Other && ownedAssets?.other.length === 0) || - (selectedAssetCategory === AssetCategory.Hidden && ownedAssets?.hidden.length === 0) || - !selectedAssetCategory - ) { - computeSelectedCategory = true; - } - if (computeSelectedCategory && ownedAssets) { - const defaultCategory = - ownedAssets.visual.length > 0 - ? AssetCategory.Visual - : ownedAssets.other.length > 0 - ? AssetCategory.Other - : ownedAssets.hidden.length > 0 - ? AssetCategory.Hidden - : null; - setSelectedAssetCategory(defaultCategory); - } - }, [ownedAssets]); - - useEffect(() => { - // Fetch the next page if there are no visual assets, other + hidden assets are present in multiples of 50, and there are more pages to fetch - if ( - hasNextPage && - ownedAssets?.visual.length === 0 && - ownedAssets?.other.length + ownedAssets?.hidden.length > 0 && - (ownedAssets.other.length + ownedAssets.hidden.length) % 50 === 0 && - !isFetchingNextPage - ) { - fetchNextPage(); - setSelectedAssetCategory(null); - } - }, [hasNextPage, ownedAssets, isFetchingNextPage]); + error, + ownedAssets, + filteredAssets, + filteredHiddenAssets, + selectedAssetCategory, + setSelectedAssetCategory, + isSpinnerVisible, + observerElem, + } = usePageAssets(accountAddress, hiddenAssets); return ( <PageTemplate title="Assets" isTitleCentered> diff --git a/apps/wallet/src/ui/app/redux/slices/iota-objects/coin.ts b/apps/wallet/src/ui/app/redux/slices/iota-objects/coin.ts index d624c690069..267b4f573ee 100644 --- a/apps/wallet/src/ui/app/redux/slices/iota-objects/coin.ts +++ b/apps/wallet/src/ui/app/redux/slices/iota-objects/coin.ts @@ -4,7 +4,7 @@ import type { IotaMoveObject, IotaObjectData } from '@iota/iota-sdk/client'; -const COIN_TYPE = '0x2::coin::Coin'; +export const COIN_TYPE = '0x2::coin::Coin'; const COIN_TYPE_ARG_REGEX = /^0x2::coin::Coin<(.+)>$/; export const IOTA_BIP44_COIN_TYPE = 4218; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71891265c5f..7a2de24440b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -259,6 +259,9 @@ importers: formik: specifier: ^2.4.2 version: 2.4.6(react@18.3.1) + idb-keyval: + specifier: ^6.2.1 + version: 6.2.1 qrcode.react: specifier: ^4.0.1 version: 4.0.1(react@18.3.1)