Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: prevent adding unsupported hooks for your wallet #5020

Merged
merged 8 commits into from
Oct 28, 2024
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from 'react'

import ICON_HOOK from '@cowprotocol/assets/cow-swap/hook.svg'
import { HookDappWalletCompatibility } from '@cowprotocol/hook-dapp-lib'
import { Command } from '@cowprotocol/types'
import { BannerOrientation, DismissableInlineBanner } from '@cowprotocol/ui'
import { useIsSmartContractWallet } from '@cowprotocol/wallet'

import { NewModal } from 'common/pure/NewModal'

Expand All @@ -20,75 +20,59 @@ import { HookDetailHeader } from '../../pure/HookDetailHeader'
import { HookListItem } from '../../pure/HookListItem'
import { HookListsTabs } from '../../pure/HookListsTabs'
import { HookDapp, HookDappIframe } from '../../types/hooks'
import { findHookDappById, isHookDappIframe } from '../../utils'
import { findHookDappById, isHookCompatible, isHookDappIframe } from '../../utils'
import { HookDappContainer } from '../HookDappContainer'
import { HookSearchInput } from '../HookSearchInput'

interface HookStoreModal {
onDismiss: Command
isPreHook: boolean
hookToEdit?: string
walletType: HookDappWalletCompatibility
}

export function HookRegistryList({ onDismiss, isPreHook, hookToEdit }: HookStoreModal) {
export function HookRegistryList({ onDismiss, isPreHook, hookToEdit, walletType }: HookStoreModal) {
const [selectedDapp, setSelectedDapp] = useState<HookDapp | null>(null)
const [dappDetails, setDappDetails] = useState<HookDapp | null>(null)

const [isAllHooksTab, setIsAllHooksTab] = useState<boolean>(true)

const isSmartContractWallet = useIsSmartContractWallet()
const [searchQuery, setSearchQuery] = useState<string>('')
const addCustomHookDapp = useAddCustomHookDapp(isPreHook)
const removeCustomHookDapp = useRemoveCustomHookDapp()
const customHookDapps = useCustomHookDapps(isPreHook)
const hookToEditDetails = useHookById(hookToEdit, isPreHook)

// State for Search Input
const [searchQuery, setSearchQuery] = useState<string>('')

// Clear search input handler
const handleClearSearch = useCallback(() => {
setSearchQuery('')
}, [])

const internalHookDapps = useInternalHookDapps(isPreHook)

const currentDapps = useMemo(() => {
return isAllHooksTab ? internalHookDapps.concat(customHookDapps) : customHookDapps
}, [isAllHooksTab, internalHookDapps, customHookDapps])
const currentDapps = useMemo(
() => (isAllHooksTab ? [...internalHookDapps, ...customHookDapps] : customHookDapps),
[isAllHooksTab, internalHookDapps, customHookDapps],
)

// Compute filteredDapps based on searchQuery
const filteredDapps = useMemo(() => {
if (!searchQuery) return currentDapps

const lowerQuery = searchQuery.toLowerCase()
return currentDapps.filter(({ name = '', descriptionShort = '' }) =>
[name, descriptionShort].some((text) => text.toLowerCase().includes(lowerQuery)),
)
}, [currentDapps, searchQuery])

return currentDapps.filter((dapp) => {
const name = dapp.name?.toLowerCase() || ''
const description = dapp.descriptionShort?.toLowerCase() || ''

return name.includes(lowerQuery) || description.includes(lowerQuery)
const sortedFilteredDapps = useMemo(() => {
return filteredDapps.sort((a, b) => {
const isCompatibleA = isHookCompatible(a, walletType)
const isCompatibleB = isHookCompatible(b, walletType)
return isCompatibleA === isCompatibleB ? 0 : isCompatibleA ? -1 : 1
})
}, [currentDapps, searchQuery])
}, [filteredDapps, walletType])

const customHooksCount = customHookDapps.length
const allHooksCount = internalHookDapps.length + customHooksCount

// Compute title based on selected dapp or details
const title = useMemo(() => {
if (selectedDapp) return selectedDapp.name
if (dappDetails) return 'Hook description'
return 'Hook Store'
}, [selectedDapp, dappDetails])
const title = selectedDapp?.name || (dappDetails ? 'Hook description' : 'Hook Store')

// Handle modal dismiss
const onDismissModal = useCallback(() => {
if (hookToEdit) {
setSelectedDapp(null)
onDismiss()
return
}

if (dappDetails) {
} else if (dappDetails) {
setDappDetails(null)
} else if (selectedDapp) {
setSelectedDapp(null)
Expand All @@ -97,78 +81,80 @@ export function HookRegistryList({ onDismiss, isPreHook, hookToEdit }: HookStore
}
}, [onDismiss, selectedDapp, dappDetails, hookToEdit])

// Handle hookToEditDetails
useEffect(() => {
if (!hookToEditDetails) {
setSelectedDapp(null)
if (hookToEditDetails) {
const foundDapp = findHookDappById(currentDapps, hookToEditDetails)
setSelectedDapp(foundDapp || null)
} else {
setSelectedDapp(findHookDappById(currentDapps, hookToEditDetails) || null)
setSelectedDapp(null)
}
}, [hookToEditDetails, currentDapps])

// Reset dappDetails when tab changes
useEffect(() => {
setDappDetails(null)
}, [isAllHooksTab])

// Handle add custom hook button
const handleAddCustomHook = useCallback(() => {
setIsAllHooksTab(false)
}, [setIsAllHooksTab])
const handleAddCustomHook = () => setIsAllHooksTab(false)
const handleClearSearch = () => setSearchQuery('')

const emptyListMessage = useMemo(
() =>
isAllHooksTab
? searchQuery
? 'No hooks match your search.'
: 'No hooks available.'
: "You haven't added any custom hooks yet. Add a custom hook to get started.",
[isAllHooksTab, searchQuery],
)

// Determine the message for EmptyList based on the active tab and search query
const emptyListMessage = useMemo(() => {
if (isAllHooksTab) {
return searchQuery ? 'No hooks match your search.' : 'No hooks available.'
} else {
return "You haven't added any custom hooks yet. Add a custom hook to get started."
}
}, [isAllHooksTab, searchQuery])

const DappsListContent = (
<>
{isAllHooksTab && (
<DismissableInlineBanner
orientation={BannerOrientation.Horizontal}
customIcon={ICON_HOOK}
iconSize={36}
bannerId="hooks-store-banner-tradeContainer-customHooks"
margin="0 10px 10px"
width="auto"
>
<p>
Can't find a hook that you like?{' '}
<span onClick={handleAddCustomHook} style={{ cursor: 'pointer', textDecoration: 'underline' }}>
Add a custom hook
</span>
</p>
</DismissableInlineBanner>
)}

<HookSearchInput
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value?.trim())}
placeholder="Search hooks by title or description"
ariaLabel="Search hooks"
onClear={handleClearSearch}
/>

{filteredDapps.length > 0 ? (
<HookDappsList>
{filteredDapps.map((dapp) => (
<HookListItem
key={isHookDappIframe(dapp) ? dapp.url : dapp.name}
dapp={dapp}
onRemove={isAllHooksTab ? undefined : () => removeCustomHookDapp(dapp as HookDappIframe)}
onSelect={() => setSelectedDapp(dapp)}
onOpenDetails={() => setDappDetails(dapp)}
/>
))}
</HookDappsList>
) : (
<EmptyList>{emptyListMessage}</EmptyList>
)}
</>
const DappsListContent = useMemo(
() => (
<>
{isAllHooksTab && (
<DismissableInlineBanner
orientation={BannerOrientation.Horizontal}
customIcon={ICON_HOOK}
iconSize={36}
bannerId="hooks-store-banner-tradeContainer-customHooks"
margin="0 10px 10px"
width="auto"
>
<p>
Can't find a hook that you like?{' '}
<span onClick={handleAddCustomHook} style={{ cursor: 'pointer', textDecoration: 'underline' }}>
Add a custom hook
</span>
</p>
</DismissableInlineBanner>
)}

<HookSearchInput
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value?.trim())}
placeholder="Search hooks by title or description"
ariaLabel="Search hooks"
onClear={handleClearSearch}
/>

{sortedFilteredDapps.length > 0 ? (
<HookDappsList>
{sortedFilteredDapps.map((dapp) => (
<HookListItem
key={isHookDappIframe(dapp) ? dapp.url : dapp.name}
dapp={dapp}
walletType={walletType}
onRemove={!isAllHooksTab ? () => removeCustomHookDapp(dapp as HookDappIframe) : undefined}
onSelect={() => setSelectedDapp(dapp)}
onOpenDetails={() => setDappDetails(dapp)}
/>
))}
</HookDappsList>
) : (
<EmptyList>{emptyListMessage}</EmptyList>
)}
</>
),
[isAllHooksTab, searchQuery, sortedFilteredDapps, handleAddCustomHook, handleClearSearch],
)

return (
Expand All @@ -189,37 +175,25 @@ export function HookRegistryList({ onDismiss, isPreHook, hookToEdit }: HookStore
onAddCustomHook={handleAddCustomHook}
/>
)}
{(() => {
if (selectedDapp) {
return (
<>
<HookDetailHeader dapp={selectedDapp} iconSize={58} gap={12} padding="24px 10px" />
<HookDappContainer
isPreHook={isPreHook}
onDismiss={onDismiss}
dapp={selectedDapp}
hookToEdit={hookToEdit}
/>
</>
)
}

if (dappDetails) {
return <HookDappDetails dapp={dappDetails} onSelect={() => setSelectedDapp(dappDetails)} />
}

return isAllHooksTab ? (
DappsListContent
) : (
<AddCustomHookForm
{selectedDapp ? (
<>
<HookDetailHeader dapp={selectedDapp} iconSize={58} gap={12} padding="24px 10px" walletType={walletType} />
<HookDappContainer
isPreHook={isPreHook}
isSmartContractWallet={isSmartContractWallet}
addHookDapp={addCustomHookDapp}
>
{DappsListContent}
</AddCustomHookForm>
)
})()}
onDismiss={onDismiss}
dapp={selectedDapp}
hookToEdit={hookToEdit}
/>
</>
) : dappDetails ? (
<HookDappDetails dapp={dappDetails} onSelect={() => setSelectedDapp(dappDetails)} walletType={walletType} />
) : isAllHooksTab ? (
DappsListContent
) : (
<AddCustomHookForm isPreHook={isPreHook} walletType={walletType} addHookDapp={addCustomHookDapp}>
{DappsListContent}
</AddCustomHookForm>
)}
</NewModal>
</Wrapper>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useState } from 'react'

import ICON_HOOK from '@cowprotocol/assets/cow-swap/hook.svg'
import { HookDappWalletCompatibility } from '@cowprotocol/hook-dapp-lib'
import { BannerOrientation, DismissableInlineBanner } from '@cowprotocol/ui'
import { useWalletInfo } from '@cowprotocol/wallet'
import { useIsSmartContractWallet, useWalletInfo } from '@cowprotocol/wallet'

import { SwapWidget } from 'modules/swap'
import { useIsSellNative } from 'modules/trade'
Expand Down Expand Up @@ -32,6 +33,10 @@ export function HooksStoreWidget() {
const isNativeSell = useIsSellNative()
const isChainIdUnsupported = useIsProviderNetworkUnsupported()

const walletType = useIsSmartContractWallet()
? HookDappWalletCompatibility.SMART_CONTRACT
: HookDappWalletCompatibility.EOA

const onDismiss = useCallback(() => {
setSelectedHookPosition(null)
setHookToEdit(undefined)
Expand Down Expand Up @@ -106,7 +111,12 @@ export function HooksStoreWidget() {
</TradeWidgetWrapper>
<IframeDappsManifestUpdater />
{isHookSelectionOpen && (
<HookRegistryList onDismiss={onDismiss} hookToEdit={hookToEdit} isPreHook={selectedHookPosition === 'pre'} />
<HookRegistryList
walletType={walletType}
onDismiss={onDismiss}
hookToEdit={hookToEdit}
isPreHook={selectedHookPosition === 'pre'}
/>
)}
{isRescueWidgetOpen && <RescueFundsFromProxy onDismiss={() => setRescueWidgetOpen(false)} />}
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Dispatch, SetStateAction, useEffect } from 'react'

import { HookDappBase, HookDappType } from '@cowprotocol/hook-dapp-lib'
import { HookDappBase, HookDappType, HookDappWalletCompatibility } from '@cowprotocol/hook-dapp-lib'
import { useWalletInfo } from '@cowprotocol/wallet'

import { HookDappIframe } from '../../../types/hooks'
Expand All @@ -9,7 +9,7 @@ import { validateHookDappManifest } from '../../../validateHookDappManifest'
interface ExternalDappLoaderProps {
input: string
isPreHook: boolean
isSmartContractWallet: boolean | undefined
walletType: HookDappWalletCompatibility
setDappInfo: Dispatch<SetStateAction<HookDappIframe | null>>
setLoading: Dispatch<SetStateAction<boolean>>
setManifestError: Dispatch<SetStateAction<string | React.ReactNode | null>>
Expand All @@ -20,7 +20,7 @@ export function ExternalDappLoader({
setLoading,
setManifestError,
setDappInfo,
isSmartContractWallet,
walletType,
isPreHook,
}: ExternalDappLoaderProps) {
const { chainId } = useWalletInfo()
Expand All @@ -41,7 +41,7 @@ export function ExternalDappLoader({
data.cow_hook_dapp as HookDappBase,
chainId,
isPreHook,
isSmartContractWallet,
walletType === HookDappWalletCompatibility.SMART_CONTRACT,
)

if (validationError) {
Expand Down Expand Up @@ -70,7 +70,7 @@ export function ExternalDappLoader({
return () => {
isRequestRelevant = false
}
}, [input, isSmartContractWallet, chainId, isPreHook, setDappInfo, setLoading, setManifestError])
}, [input, walletType, chainId, isPreHook, setDappInfo, setLoading, setManifestError])

return null
}
Loading
Loading