Skip to content

Commit

Permalink
Merge pull request #92 from TalismanSociety/refactor/useKownAddresses
Browse files Browse the repository at this point in the history
Refactor/use kown addresses
  • Loading branch information
UrbanWill authored Oct 28, 2024
2 parents 3be1316 + 5c54c3a commit e0f8eea
Show file tree
Hide file tree
Showing 36 changed files with 503 additions and 668 deletions.
7 changes: 3 additions & 4 deletions apps/multisig/src/components/AddMemberInput.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import toast from 'react-hot-toast'
import AddressInput, { AddressWithName } from './AddressInput'
import AddressInput from './AddressInput'
import { Address } from '../util/addresses'
import { useState } from 'react'
import { Plus } from '@talismn/icons'
Expand All @@ -10,12 +10,11 @@ import { useSelectedMultisig } from '@domains/multisig'
type Props = {
onNewAddress: (a: Address) => void
validateAddress?: (a: Address) => boolean
addresses?: AddressWithName[]
compactInput?: boolean
chain?: Chain
}

export const AddMemberInput: React.FC<Props> = ({ chain, validateAddress, onNewAddress, addresses, compactInput }) => {
export const AddMemberInput: React.FC<Props> = ({ chain, validateAddress, onNewAddress, compactInput }) => {
const [addressInput, setAddressInput] = useState('')
const [address, setAddress] = useState<Address | undefined>()
const [error, setError] = useState<boolean>(false)
Expand Down Expand Up @@ -49,7 +48,7 @@ export const AddMemberInput: React.FC<Props> = ({ chain, validateAddress, onNewA
return (
<form onSubmit={handleSubmit} className="flex items-center gap-[8px]">
<AddressInput
addresses={addresses}
shouldIncludeContacts
value={addressInput}
compact={compactInput}
chain={chain || multisig.chain}
Expand Down
256 changes: 108 additions & 148 deletions apps/multisig/src/components/AddressInput/index.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,26 @@
import { Address } from '@util/addresses'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Chain } from '@domains/chains'
import { useOnClickOutside } from '@domains/common/useOnClickOutside'
import { SelectedAddress } from './SelectedAddressPill'
import { AccountDetails } from './AccountDetails'
import { Input } from '@components/ui/input'
import { useAzeroIDPromise } from '@domains/azeroid/AzeroIDResolver'
import { AlertTriangle } from '@talismn/icons'

export type AddressType = 'Extension' | 'Contacts' | 'Vault' | 'Smart Contract' | undefined

export type AddressWithName = {
address: Address
name: string
type: AddressType
chain?: Chain
extensionName?: string
addressBookName?: string
}
import { useGetInfiniteAddresses } from '@domains/offchain-data/address-book/hooks/useGetInfiniteAddresses'
import { useDebounce } from '@hooks/useDebounce'
import { useKnownAddresses, KnownAddress } from '@hooks/useKnownAddresses'
import { cn } from '@util/tailwindcss'

type Props = {
defaultAddress?: Address
value?: string
onChange: (address: Address | undefined, input: string) => void
addresses?: AddressWithName[]
chain?: Chain
hasError?: boolean
shouldIncludeContacts?: boolean
shouldIncludeSelectedMultisig?: boolean
shouldExcludeExtensionContacts?: boolean
leadingLabel?: string
compact?: boolean
}
Expand All @@ -34,149 +29,134 @@ type Props = {
* Handles validating address input as well as displaying a list of addresses to select from.
* Supports both controlled and uncontrolled usage input.
*/
const AddressInput: React.FC<Props> = ({
const AddressInput = ({
onChange,
value,
defaultAddress,
addresses = [],
chain,
hasError,
hasError = false,
leadingLabel,
compact,
}) => {
shouldIncludeContacts = false,
shouldIncludeSelectedMultisig = false,
shouldExcludeExtensionContacts = false,
}: Props) => {
const [input, setInput] = useState(value ?? '')
const [expanded, setExpanded] = useState(false)
const [querying, setQuerying] = useState(false)
const [address, setAddress] = useState(defaultAddress ?? (value ? Address.fromSs58(value) || undefined : undefined))
const [contact, setContact] = useState<AddressWithName | undefined>(undefined)
const [address, setAddress] = useState<Address | undefined>(
defaultAddress ?? (value ? Address.fromSs58(value) || undefined : undefined)
)
const [contact, setContact] = useState<KnownAddress | undefined>(undefined)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const { resolve, resolving, data, clear } = useAzeroIDPromise()

useEffect(() => {
if (value !== undefined && value === '') {
setAddress(undefined)
setContact(undefined)
}
}, [value])

const blur = useCallback(() => {
setExpanded(false)
setQuerying(false)
}, [])

useOnClickOutside(containerRef.current, blur)

const query = value ?? input

// input displays a non editable pill that shows selected contact's name, address and identicon
const controlledSelectedInput = address !== undefined
const { addresses: knownAddresses } = useKnownAddresses({
includeSelectedMultisig: shouldIncludeSelectedMultisig,
shouldExcludeExtensionContacts,
})
const debouncedQuery = useDebounce(input, 300)
const {
data: contacts,
hasNextPage,
fetchNextPage,
isFetching,
} = useGetInfiniteAddresses({ search: debouncedQuery, isEnabled: shouldIncludeContacts })
useOnClickOutside(containerRef.current, () => setExpanded(false))

const handleQueryChange = useCallback(
(addressString: string) => {
let address: Address | undefined
try {
const parsedAddress = Address.fromSs58(addressString)
if (!parsedAddress) throw new Error('Invalid address')
address = parsedAddress
} catch (e) {
address = undefined
}

if (value === undefined) setInput(addressString)
const address = Address.fromSs58(addressString) || undefined
setInput(addressString)
setAddress(address)
onChange(address, addressString)

return address !== undefined
},
[onChange, value]
[onChange]
)

const handleSelectFromList = (address: Address, contact?: AddressWithName) => {
if (hasError) return
handleQueryChange(address.toSs58(chain))
setAddress(address)
setContact(contact)
blur()
}

const handleClearInput = () => {
setExpanded(addresses.length > 0)

// clear states
handleQueryChange('')
setContact(undefined)
setAddress(undefined)
setQuerying(false)
}

useEffect(() => {
if (data?.address) {
setAddress(data.address)
handleQueryChange(data.address.toSs58(chain))
setQuerying(false)
setExpanded(false)
clear()
}
}, [chain, clear, data, handleQueryChange])

const filteredAddresses = useMemo(() => {
let inputAddress: Address | undefined
try {
const parsedInputAddress = Address.fromSs58(query)
if (parsedInputAddress) inputAddress = parsedInputAddress
} catch (e) {}
// filter client side addresses
const filteredKnownAddresses = knownAddresses.filter(
contact =>
contact.address.toSs58().toLowerCase().includes(input.toLowerCase()) ||
contact.name.toLowerCase().includes(input.toLowerCase())
)

// combine known addresses and contacts that have already been filtered by the search input query
const combinedAddresses = shouldIncludeContacts ? [...filteredKnownAddresses, ...contacts] : filteredKnownAddresses

return addresses.filter(({ address, name }) => {
if (inputAddress && inputAddress.isEqual(address)) return true
if (name.toLowerCase().includes(query.toLowerCase())) return true
const handleSelectFromList = useCallback(
(address: Address, contact?: KnownAddress) => {
if (hasError) return
handleQueryChange(address.toSs58(chain))
setAddress(address)
setContact(contact)
setExpanded(false)
},
[chain, handleQueryChange, hasError]
)

const addressString = address.toSs58(chain)
const genericAddressString = address.toSs58()
return (
addressString.toLowerCase().includes(query.toLowerCase()) ||
genericAddressString.toLowerCase().includes(query.toLowerCase())
)
})
}, [addresses, chain, query])
const handleClearInput = () => {
inputRef.current?.focus()
setExpanded(knownAddresses.length > 0 || shouldIncludeContacts)

const validRawInputAddress = useMemo(() => {
try {
const parsedInputAddress = Address.fromSs58(query)
if (parsedInputAddress) return parsedInputAddress
} catch (e) {}
// clear states
handleQueryChange('')
setContact(undefined)
setAddress(undefined)
}

return undefined
}, [query])
const handleScroll = () => {
if (!dropdownRef.current || !hasNextPage || isFetching || !shouldIncludeContacts) return
const { scrollTop, scrollHeight, clientHeight } = dropdownRef.current
if (scrollTop + clientHeight >= scrollHeight - 10) {
fetchNextPage()
}
}

return (
<div css={{ width: '100%', position: 'relative' }} ref={containerRef}>
{controlledSelectedInput && (
<div className="w-full relative" ref={containerRef}>
{address && (
<SelectedAddress
address={address}
chain={chain}
name={contact?.name ?? addresses.find(t => t.address.isEqual(address))?.name}
name={contact?.name ?? combinedAddresses.find(t => t.address.isEqual(address))?.name}
onClear={handleClearInput}
/>
)}
<Input
ref={inputRef}
label={leadingLabel}
loading={resolving}
placeholder={
controlledSelectedInput ? '' : addresses.length > 0 ? 'Search or paste address...' : 'Enter address...'
}
value={address ? '' : query}
loading={resolving || isFetching}
placeholder={address ? '' : combinedAddresses.length > 0 ? 'Search or paste address...' : 'Enter address...'}
value={address ? '' : input}
onChange={e => {
setQuerying(true)
resolve(e.target.value)
const validInput = handleQueryChange(e.target.value)

// user pasted a valid address, so they're no longer querying
if (validInput) {
setQuerying(false)
if (validInput || combinedAddresses.length > 0) {
setExpanded(true)
}
}}
onFocus={() => setExpanded(addresses.length > 0 || validRawInputAddress !== undefined)}
onClick={() => {
setExpanded(prev => {
if (!prev) {
return combinedAddresses.length > 0 || address !== undefined
}
return !prev
})
}}
onClear={handleClearInput}
showClearButton={!!controlledSelectedInput}
showClearButton={!!address}
hasError={hasError}
/>
{hasError && (
Expand All @@ -186,38 +166,22 @@ const AddressInput: React.FC<Props> = ({
</div>
)}
<div
className={'bg-gray-800 shadow-lg'}
css={{
position: 'absolute',
top: '100%',
marginTop: 8,
left: 0,
// backgroundColor: color.foreground,
width: '100%',
zIndex: 1,
borderRadius: 8,

height: 'max-content',
maxHeight: expanded ? 150 : 0,
overflow: 'hidden',
transition: '0.2s ease-in-out',
overflowY: 'auto',
}}
className={`absolute top-full mt-2 left-0 w-full z-10 rounded-[8px] h-max overflow-hidden overflow-y-auto transition-all duration-200 ease-in-out bg-gray-800 shadow-lg ${
expanded ? 'max-h-[150px]' : 'max-h-0'
}`}
ref={dropdownRef}
onScroll={handleScroll}
>
<div css={{ padding: '8px 0px' }}>
{filteredAddresses.length > 0 ? (
filteredAddresses.map(contact => (
<div className="py-[8px] px-0">
{combinedAddresses.length > 0 ? (
combinedAddresses.map((contact, index) => (
<div
key={contact.address.toSs58(chain)}
key={index} // deliberate use of index as key. Using id or address will cause the UI to render stale data if the list has more than 1 page and the user searches for a new address
onClick={() => handleSelectFromList(contact.address, contact)}
css={{
'display': 'flex',
'alignItems': 'center',
'justifyContent': 'space-between',
'padding': '8px 12px',
'cursor': hasError ? 'not-allowed' : 'pointer',
':hover': { filter: 'brightness(1.2)' },
}}
className={cn(
'flex items-center justify-between py-[8px] px-[12px] cursor-pointer hover:brightness-125',
{ 'cursor-not-allowed': hasError }
)}
>
<AccountDetails
name={contact.name}
Expand All @@ -231,20 +195,16 @@ const AddressInput: React.FC<Props> = ({
<p className="whitespace-nowrap text-[14px] font-bold text-right text-gray-200">{contact.type}</p>
</div>
))
) : address || (!querying && validRawInputAddress) ? (
) : address || (!isFetching && address) ? (
// user pasted an unknown but valid address, show identicon and formatted address to indicate the address is valid
<div
css={{
'padding': '8px 12px',
'cursor': hasError ? 'not-allowed' : 'pointer',
':hover': {
filter: 'brightness(1.2)',
},
}}
onClick={() => handleSelectFromList((validRawInputAddress || address) as Address)}
className={cn('py-[8px] px-[12px] cursor-pointer hover:brightness-125', {
'cursor-not-allowed': hasError,
})}
onClick={() => handleSelectFromList(address)}
>
<AccountDetails
address={(validRawInputAddress || address) as Address}
address={address}
chain={chain}
disableCopy
breakLine={compact}
Expand All @@ -253,7 +213,7 @@ const AddressInput: React.FC<Props> = ({
/>
</div>
) : (
<p css={{ textAlign: 'center', padding: 12 }}>No result found.</p>
<p className="text-center p-[12px]">{isFetching ? 'Loading...' : 'No result found.'}</p>
)}
</div>
</div>
Expand Down
Loading

0 comments on commit e0f8eea

Please sign in to comment.