From f9f4c23490e8f9da1bde5518e3b1af9e1e662f6c Mon Sep 17 00:00:00 2001 From: chrisling-dev Date: Wed, 1 May 2024 17:51:11 +0800 Subject: [PATCH 1/4] feat: vested multisend done --- apps/multisig/package.json | 2 + apps/multisig/src/components/BlockInput.tsx | 1 + .../src/components/VestingDateRange.tsx | 67 +++ .../src/components/ui/dropdown-menu.tsx | 4 +- apps/multisig/src/components/ui/table.tsx | 81 ++++ apps/multisig/src/components/ui/tooltip.tsx | 2 +- apps/multisig/src/domains/chains/pjs-api.ts | 8 +- apps/multisig/src/domains/multisig/index.ts | 19 +- .../Multisend/MultiLineSendInput.tsx | 409 ------------------ .../Multisend/MultiSendForm.tsx | 68 ++- .../MultisendTable/AddressInputCell.tsx | 130 ++++++ .../MultisendTable/MultisendTable.d.ts | 34 ++ .../MultisendTableAmountUnitDropdown.tsx | 93 ++++ .../MultisendTableBlockInput.tsx | 86 ++++ .../MultisendTable/MultisendTableRow.tsx | 235 ++++++++++ .../Multisend/MultisendTable/atom.ts | 19 + .../Multisend/MultisendTable/index.tsx | 251 +++++++++++ .../Multisend/MultisendTable/utils.ts | 19 + .../NewTransaction/Multisend/index.tsx | 117 ++++- .../Multisend/multisend.types.ts | 9 - .../Send/SendExpandableDetails.tsx | 63 +-- .../TransactionDetailsExpandable.tsx | 94 +++- .../Transactions/TransactionsList.tsx | 12 +- yarn.lock | 101 ++++- 24 files changed, 1328 insertions(+), 596 deletions(-) create mode 100644 apps/multisig/src/components/VestingDateRange.tsx create mode 100644 apps/multisig/src/components/ui/table.tsx delete mode 100644 apps/multisig/src/layouts/NewTransaction/Multisend/MultiLineSendInput.tsx create mode 100644 apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/AddressInputCell.tsx create mode 100644 apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/MultisendTable.d.ts create mode 100644 apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/MultisendTableAmountUnitDropdown.tsx create mode 100644 apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/MultisendTableBlockInput.tsx create mode 100644 apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/MultisendTableRow.tsx create mode 100644 apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/atom.ts create mode 100644 apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/index.tsx create mode 100644 apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/utils.ts delete mode 100644 apps/multisig/src/layouts/NewTransaction/Multisend/multisend.types.ts diff --git a/apps/multisig/package.json b/apps/multisig/package.json index 400c88d6..1d4791cf 100644 --- a/apps/multisig/package.json +++ b/apps/multisig/package.json @@ -51,6 +51,7 @@ "@recoiljs/refine": "0.1.1", "@sentry/react": "^7.37.2", "@sentry/tracing": "^7.37.2", + "@silevis/reactgrid": "^4.1.5", "@substrate/txwrapper-core": "^5.0.0", "@talisman-connect/ui": "^1.1.1", "@talismn/balances": "0.0.0-pr1378-20240327183927", @@ -63,6 +64,7 @@ "@talismn/siws": "0.0.8", "@talismn/ui": "workspace:^", "@talismn/util": "^0.2.0", + "@tanstack/react-table": "^8.16.0", "@uiw/codemirror-themes": "^4.21.13", "@uiw/react-codemirror": "^4.21.13", "@vercel/analytics": "^1.2.2", diff --git a/apps/multisig/src/components/BlockInput.tsx b/apps/multisig/src/components/BlockInput.tsx index 96033d98..19be7c18 100644 --- a/apps/multisig/src/components/BlockInput.tsx +++ b/apps/multisig/src/components/BlockInput.tsx @@ -76,6 +76,7 @@ export const BlockInput: React.FC = ({ blockTime, currentBlock, minBlock, {derivedDate && ( = ({ chainGenesisHash, className, vestingSchedule }) => { + const { api } = useApi(chainGenesisHash) + const blockNumber = useLatestBlockNumber(chainGenesisHash) + const blockTime = useMemo(() => { + if (!api) return + return expectedBlockTime(api) + }, [api]) + + const startDate = useMemo(() => { + if (blockNumber === undefined || blockTime === undefined) return undefined + + const now = new Date() + const blocksDiff = vestingSchedule.start - blockNumber + const msDiff = blocksDiff * blockTime.toNumber() + return new Date(now.getTime() + msDiff) + }, [blockNumber, blockTime, vestingSchedule.start]) + + const endDate = useMemo(() => { + if (blockNumber === undefined || blockTime === undefined) return undefined + + const now = new Date() + const blocksDiff = vestingSchedule.start + vestingSchedule.period - blockNumber + const msDiff = blocksDiff * blockTime.toNumber() + return new Date(now.getTime() + msDiff) + }, [blockNumber, blockTime, vestingSchedule.period, vestingSchedule.start]) + + const startDateString = startDate?.toLocaleDateString() + const endDateString = endDate?.toLocaleDateString() + const sameDay = startDateString === endDateString + const duration = useMemo(() => { + if (blockTime === undefined) return undefined + return vestingSchedule.period * blockTime.toNumber() + }, [blockTime, vestingSchedule.period]) + + return ( + + {startDate?.toLocaleString()} → {endDate?.toLocaleString()} +

+ } + > +

+ {sameDay ? `${startDateString}, ` : ''} + {sameDay ? `≈${startDate?.toLocaleTimeString()}` : startDateString} →{' '} + {sameDay ? `≈${endDate?.toLocaleTimeString()}` : endDate?.toLocaleDateString()} + {!!duration && ( +  (≈{secondsToDuration(duration)}) + )} +

+
+ ) +} diff --git a/apps/multisig/src/components/ui/dropdown-menu.tsx b/apps/multisig/src/components/ui/dropdown-menu.tsx index 50697cd5..b1799cd2 100644 --- a/apps/multisig/src/components/ui/dropdown-menu.tsx +++ b/apps/multisig/src/components/ui/dropdown-menu.tsx @@ -61,7 +61,7 @@ const DropdownMenuContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - 'z-50 min-w-[8rem] overflow-hidden rounded-[12px] border bg-gray-800 p-[4px] text-gray-200 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', + 'z-50 min-w-[8rem] overflow-hidden rounded-[12px] border bg-gray-900 p-[4px] text-gray-200 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', className )} {...props} @@ -79,7 +79,7 @@ const DropdownMenuItem = React.forwardRef< >( + ({ className, ...props }, ref) => ( +
+ + + ) +) +Table.displayName = 'Table' + +const TableHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +) +TableHeader.displayName = 'TableHeader' + +const TableBody = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +) +TableBody.displayName = 'TableBody' + +const TableFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( + tr]:last:border-b-0', className)} + {...props} + /> + ) +) +TableFooter.displayName = 'TableFooter' + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +) +TableRow.displayName = 'TableRow' + +const TableHead = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +) +TableHead.displayName = 'TableHead' + +const TableCell = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +) +TableCell.displayName = 'TableCell' + +const TableCaption = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +) +TableCaption.displayName = 'TableCaption' + +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption } diff --git a/apps/multisig/src/components/ui/tooltip.tsx b/apps/multisig/src/components/ui/tooltip.tsx index 5332c855..e4b3214b 100644 --- a/apps/multisig/src/components/ui/tooltip.tsx +++ b/apps/multisig/src/components/ui/tooltip.tsx @@ -34,7 +34,7 @@ const Tooltip: React.FC {children} - {content} + {content} ) diff --git a/apps/multisig/src/domains/chains/pjs-api.ts b/apps/multisig/src/domains/chains/pjs-api.ts index f27f27f1..94b9a2c7 100644 --- a/apps/multisig/src/domains/chains/pjs-api.ts +++ b/apps/multisig/src/domains/chains/pjs-api.ts @@ -92,7 +92,6 @@ export const pjsApiSelector = atomFamily({ get: (_genesisHash: string) => async ({ get }): Promise => { - const chain = supportedChains.find(({ genesisHash }) => genesisHash === _genesisHash) const customRpcs = get(customRpcsAtom) const rpc = customRpcs[_genesisHash] @@ -103,12 +102,7 @@ export const pjsApiSelector = atomFamily({ api = get(defaultPjsApiSelector(_genesisHash)) } - try { - await api.isReady - return api - } catch (e) { - throw new Error(`Failed to connect to ${chain?.chainName} chain:` + getErrorString(e)) - } + return api }, dangerouslyAllowMutability: true, }), diff --git a/apps/multisig/src/domains/multisig/index.ts b/apps/multisig/src/domains/multisig/index.ts index a62445a8..8e674597 100644 --- a/apps/multisig/src/domains/multisig/index.ts +++ b/apps/multisig/src/domains/multisig/index.ts @@ -375,6 +375,21 @@ const isSubstrateTokensTokenTransfer = (argHuman: any): argHuman is SubstrateTok } const callToTransactionRecipient = (arg: any, chainTokens: BaseToken[]): TransactionRecipient | null => { + if (arg?.section === 'vesting' && arg?.method === 'vestedTransfer') { + const { target, dest, schedule } = arg.args + const targetAddress = Address.fromSs58(parseCallAddressArg(target ?? dest)) + const vestingSchedule = callToVestingSchedule(schedule) + if (vestingSchedule && targetAddress) { + return { + address: targetAddress, + balance: { + token: chainTokens.find(t => t.type === 'substrate-native')!, + amount: vestingSchedule.totalAmount, + }, + vestingSchedule, + } + } + } if (isSubstrateNativeTokenTransfer(arg)) { const nativeToken = chainTokens.find(t => t.type === 'substrate-native') if (!nativeToken) throw Error(`Chain does not have a native token!`) @@ -675,11 +690,11 @@ export const extrinsicToDecoded = ( } } - // check for vested transfer/ remove proxy + // check for vested transfer for (const arg of args) { const obj: any = arg.toHuman() if (obj?.section === 'vesting') { - if (obj.method === 'vestedTransfer' || obj.method === 'removeProxy') { + if (obj.method === 'vestedTransfer') { const { target, dest, schedule } = obj.args const targetAddress = Address.fromSs58(parseCallAddressArg(target ?? dest)) const vestingSchedule = callToVestingSchedule(schedule) diff --git a/apps/multisig/src/layouts/NewTransaction/Multisend/MultiLineSendInput.tsx b/apps/multisig/src/layouts/NewTransaction/Multisend/MultiLineSendInput.tsx deleted file mode 100644 index 81eacdb7..00000000 --- a/apps/multisig/src/layouts/NewTransaction/Multisend/MultiLineSendInput.tsx +++ /dev/null @@ -1,409 +0,0 @@ -import { useState, useMemo, useEffect, useRef, useCallback } from 'react' -import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror' -import { motion } from 'framer-motion' -import { createTheme } from '@uiw/codemirror-themes' -import { tags } from '@lezer/highlight' -import { css } from '@emotion/css' -import { Address, shortenAddress } from '@util/addresses' -import { formatUnits, parseUnits } from '@util/numbers' -import { BaseToken, tokenPriceState } from '@domains/chains' -import { useOnClickOutside } from '@domains/common/useOnClickOutside' -import { MultiSendSend } from './multisend.types' -import { useRecoilValueLoadable } from 'recoil' -import AmountUnitSelector, { AmountUnit } from '@components/AmountUnitSelector' -import FileUploadButton from '@components/FileUploadButton' -import BN from 'bn.js' -import { Info, ToggleLeft, ToggleRight } from '@talismn/icons' -import { Tooltip } from '@components/ui/tooltip' -import { Button } from '@components/ui/button' - -type Props = { - label?: string - token?: BaseToken - onChange: (rows: MultiSendSend[], invalidRows: number[]) => void -} - -const theme = createTheme({ - theme: 'dark', - settings: { - background: 'var(--color-grey800)', - foreground: 'var(--color-grey800)', - selection: 'var(--color-backgroundLighter)', - gutterBackground: 'var(--color-grey800)', - selectionMatch: 'var(--color-backgroundLight)', - gutterActiveForeground: 'var(--color-offWhite)', - caret: 'var(--color-primary)', - gutterForeground: 'var(--color-dim)', - }, - styles: [{ tag: tags.content, color: 'var(--color-offWhite)' }], -}) - -/* try to find an address and amount from given string and perform required formatting */ -const findAddressAndAmount = ( - row: string, - parseAmount: (amount: string) => BN -): { data?: { address: Address; addressString: string; amount: string; amountBn: BN }; error?: string } => { - // try format "address, amount" - let [address, amount] = row.split(',') - - // try format "address amount" - if (!address || !amount) { - ;[address, amount] = row.split(' ') - } - - // try format "address amount" - if (!address || !amount) { - ;[address, amount] = row.split(' ') - } - - // try format "address[tab]amount", common for imported CSV - if (!address || !amount) { - ;[address, amount] = row.split('\t') - } - - // try format "address=amount", common for imported CSV - if (!address || !amount) { - ;[address, amount] = row.split('=') - } - - if (!address || !amount) return { error: 'Invalid Row' } - - const trimmedAddress = address.trim() - const trimmedAmount = amount.trim() - - const parsedAddress = Address.fromSs58(trimmedAddress) - const invalidAmount = trimmedAmount === '' || isNaN(+trimmedAmount) - if (!parsedAddress && invalidAmount) return { error: 'Invalid Row' } - if (!parsedAddress) return { error: 'Invalid Address' } - if (invalidAmount) return { error: 'Invalid Amount' } - - return { - data: { - address: parsedAddress, - addressString: trimmedAddress, - amount: trimmedAmount, - amountBn: parseAmount(trimmedAmount), - }, - } -} - -const exampleAddress = Address.fromSs58('5DFMVCaWNPcSdPVmK7d6g81ZV58vw5jkKbQk8vR4FSxyhJBD') as Address - -export const MultiLineSendInput: React.FC = ({ - label = 'Enter one address and amount on each line.', - onChange, - token, -}) => { - const [amountUnit, setAmountUnit] = useState(AmountUnit.Token) - const [editing, setEditing] = useState(false) - const [isReviewMode, setIsReviewMode] = useState(true) - const [error, setError] = useState() - const [importedFromCsv, setImportedFromCsv] = useState(false) - const [value, setValue] = useState('') - const tokenPrices = useRecoilValueLoadable(tokenPriceState(token)) - - // the native onBlur/onFocus of CodeMiror is a bit buggy, so we use this custom hook to detect blur - const codeMirrorRef = useRef(null) - useOnClickOutside(codeMirrorRef.current?.editor, () => setEditing(false)) - - useEffect(() => { - if (tokenPrices.state === 'hasValue') { - let newAmountUnit = amountUnit - if (newAmountUnit === AmountUnit.Usd30DayEma && !tokenPrices.contents.averages?.ema30) { - newAmountUnit = AmountUnit.Usd7DayEma - } - if (newAmountUnit === AmountUnit.Usd7DayEma && !tokenPrices.contents.averages?.ema7) { - newAmountUnit = AmountUnit.UsdMarket - } - if (newAmountUnit === AmountUnit.UsdMarket && !tokenPrices.contents.current) { - newAmountUnit = AmountUnit.Token - } - setAmountUnit(newAmountUnit) - } - }, [amountUnit, tokenPrices]) - - const parseAmount = useCallback( - (amount: string) => { - if (!token) return new BN(0) - - let tokenAmount = amount - - if (amountUnit !== AmountUnit.Token) { - if (tokenPrices.state === 'hasValue') { - if (amountUnit === AmountUnit.UsdMarket) { - tokenAmount = (parseFloat(amount) / tokenPrices.contents.current).toString() - } else if (amountUnit === AmountUnit.Usd7DayEma) { - if (!tokenPrices.contents.averages?.ema7) return new BN(0) - tokenAmount = (parseFloat(amount) / tokenPrices.contents.averages.ema7).toString() - } else if (amountUnit === AmountUnit.Usd30DayEma) { - if (!tokenPrices.contents.averages?.ema30) return new BN(0) - tokenAmount = (parseFloat(amount) / tokenPrices.contents.averages.ema30).toString() - } - } else { - return new BN(0) - } - } - - return parseUnits(tokenAmount, token.decimals) - }, - [amountUnit, token, tokenPrices] - ) - - /* pre-process every row for validations later */ - const formattedRows = useMemo( - () => - value.split('\n').map(row => ({ - input: row, - validRow: findAddressAndAmount(row, parseAmount), - })), - [parseAmount, value] - ) - - /** - * A formatted string that is displayed in the CodeMirror editor. - * Shows formatted address and amount if user is not editing - */ - const augmentedValue = useMemo(() => { - // when not in review mode, user wants to see the full original input - if (!isReviewMode) return value - return formattedRows - .map(({ validRow: { data, error }, input }) => { - // for invalid rows, we allow empty line for grouping, otherwise we warn user of invalid row - if (error || !data) return input === '' ? '' : `${error ?? 'Invalid Row'}: ${input}` - const addressToFormat = token ? data.address.toSs58(token.chain) : data.addressString - let formattedString = shortenAddress(addressToFormat, 'short') - - if (!token) return `${formattedString}, ${data.amount}` - - // display the right token / usd amount - const tokenAmount = (+formatUnits(data.amountBn, token.decimals)).toFixed(4) - const tokenFullString = `${tokenAmount} ${token.symbol}` - if (amountUnit === AmountUnit.Token) { - formattedString += `, ${tokenFullString}` - } else { - formattedString += `, ${data.amount} USD (${tokenFullString})` - } - - return formattedString - }) - .join('\n') - }, [isReviewMode, value, formattedRows, token, amountUnit]) - - const invalidRows = useMemo(() => { - const indexes: number[] = [] - formattedRows.forEach(({ validRow: { data, error }, input }, i) => { - if ((!data || error) && input !== '') indexes.push(i + 1) - }) - return indexes - }, [formattedRows]) - - const validRows = useMemo(() => { - if (!token) return undefined - return formattedRows - .filter(r => !!r.validRow.data) - .map( - ({ validRow: { data } }) => - ({ - address: data!.address, - amountBn: data!.amountBn, - token, - }!) - ) - }, [token, formattedRows]) - - const handleCsvUpload = async (files: File[]) => { - const [file] = files - if (!file || !token) return - setError(undefined) - const textValue = await file.text() - const rows = textValue.split('\n') - const values: string[] = [] - - rows.forEach(row => { - const rowValues = row.split(',') - const [addressCol, amountCol] = rowValues - - // skip rows that don't have address or amount - const { data } = findAddressAndAmount(`${addressCol}, ${amountCol}`, parseAmount) - values.push(`${data?.addressString ?? addressCol}, ${data?.amount ?? amountCol}`) - }) - - if (values.length === 0) setError('The uploaded CSV file does not have a valid row.') - setImportedFromCsv(values.length > 0) - setValue(values.join('\n')) - setIsReviewMode(false) - } - - useEffect(() => { - if (validRows) onChange(validRows, invalidRows) - }, [invalidRows, onChange, validRows]) - - return ( -
-
-
-

{label}

- {token && ( -

e.g. {shortenAddress(exampleAddress.toSs58(token.chain))}, 55.56

- )} -
-
- -

Format of CSV file:

-
    -
  • First column should be address of recipients.
  • -
  • Second column should be amounts for each recipient.
  • -
-
-

You can also just copy / paste the table.

-
- } - > - - - -
-
-
- {/** Provide hint on why they cant change the input if they're importing from CSV */} - {importedFromCsv && editing && ( - -
- - )} - { - if (isReviewMode) return - setError(undefined) - setValue(val) - }} - onClick={() => { - setIsReviewMode(false) - setEditing(true) - }} - editable={!importedFromCsv && !isReviewMode} - placeholder={`14JVAW...Vkbg5, 10.23456\n14JVAW...Vkbg5 0.23456\n14JVAW...Vkbg5=2.23456`} - theme={theme} - value={augmentedValue} - /> -
-
- { - setIsReviewMode(true) - setAmountUnit(unit) - }} - /> -
- {importedFromCsv && ( - - )} - {value.length > 0 && ( -
setIsReviewMode(!isReviewMode)} - > - {isReviewMode ? : } -

- Review -
- Mode -

-
- )} -
-
- {(invalidRows.length > 0 || error) && ( - -

- {error ?? - (invalidRows.length > 6 - ? 'Many lines have invalid format.' - : `Invalid format at line ${invalidRows.join(', ')}`)} -

-
- )} -
- ) -} diff --git a/apps/multisig/src/layouts/NewTransaction/Multisend/MultiSendForm.tsx b/apps/multisig/src/layouts/NewTransaction/Multisend/MultiSendForm.tsx index 49a15f87..52464df4 100644 --- a/apps/multisig/src/layouts/NewTransaction/Multisend/MultiSendForm.tsx +++ b/apps/multisig/src/layouts/NewTransaction/Multisend/MultiSendForm.tsx @@ -1,29 +1,31 @@ -import { useEffect, useState } from 'react' -import { Loadable } from 'recoil' -import { css } from '@emotion/css' +import { useEffect } from 'react' +import { Loadable, useRecoilState } from 'recoil' import TokensSelect from '@components/TokensSelect' -import { BaseToken } from '@domains/chains' -import { MultiSendSend } from './multisend.types' -import { isEqual } from 'lodash' +import { BaseToken, Chain } from '@domains/chains' import AmountRow from '@components/AmountRow' import BN from 'bn.js' import { Alert } from '@components/Alert' -import { MultiLineSendInput } from './MultiLineSendInput' import { Button } from '@components/ui/button' import { Input } from '@components/ui/input' +import { MultiSendTable } from './MultisendTable' +import { AddressWithName } from '@components/AddressInput' +import { multisendTokenAtom } from './MultisendTable/atom' const MultiSendForm = (props: { name: string tokens: Loadable - sends: MultiSendSend[] setName: (n: string) => void - setSends: (s: MultiSendSend[]) => void onNext: () => void hasNonDelayedPermission?: boolean hasDelayedPermission?: boolean + contacts?: AddressWithName[] + chain: Chain + totalAmount: BN + totalSends: number + disabled: boolean + disableVesting: boolean }) => { - const [selectedToken, setSelectedToken] = useState() - const [hasInvalidRow, setHasInvalidRow] = useState(false) + const [selectedToken, setSelectedToken] = useRecoilState(multisendTokenAtom) useEffect(() => { if ( @@ -34,19 +36,10 @@ const MultiSendForm = (props: { setSelectedToken( props.tokens.contents.find(token => token.id === token.chain.nativeToken.id) ?? props.tokens.contents[0] ) - }, [props.tokens, selectedToken]) + }, [props.tokens, selectedToken, setSelectedToken]) return ( -
+
setSelectedToken(token)} /> - + {/* { // prevent unnecessary re-render if sends are the same if (!isEqual(sends, props.sends)) props.setSends(sends) setHasInvalidRow(invalidRows.length > 0) }} - /> -
div': { - display: 'flex', - justifyContent: 'space-between', - gap: 16, - p: { fontSize: 16 }, - }, - }} - > - {props.sends.length > 0 && selectedToken && !hasInvalidRow && ( + /> */} +
+ {props.totalSends > 0 && selectedToken && ( <>

Total Sends

-

{props.sends.length}

+

{props.totalSends}

Total Amount

@@ -91,7 +78,7 @@ const MultiSendForm = (props: { hideIcon balance={{ token: selectedToken, - amount: props.sends.reduce((acc, send) => acc.add(send.amountBn), new BN(0)), + amount: props.totalAmount, }} />
@@ -114,10 +101,11 @@ const MultiSendForm = (props: {
) : (
diff --git a/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/AddressInputCell.tsx b/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/AddressInputCell.tsx new file mode 100644 index 00000000..32bd3a7e --- /dev/null +++ b/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/AddressInputCell.tsx @@ -0,0 +1,130 @@ +import { AccountDetails } from '@components/AddressInput/AccountDetails' +import { Button } from '@components/ui/button' +import { Popover, PopoverTrigger, PopoverContent } from '@components/ui/popover' +import { Address } from '@util/addresses' +import { XIcon } from 'lucide-react' +import React, { useCallback, useMemo, useRef, useState } from 'react' + +type Props = { + inputRef: (node: HTMLInputElement | null) => void + contacts?: { name: string; address: Address }[] + address?: Address + onChangeAddress: (address?: Address) => void +} & React.DetailedHTMLProps, HTMLInputElement> + +export const AddressInputCell: React.FC = ({ + address, + contacts, + inputRef, + onBlur, + onChangeAddress, + onFocus, + ...inputProps +}) => { + const [value, setValue] = useState('') + const [focus, setFocus] = useState(false) + const localRef = useRef(null) + + const parsedAddress = useMemo(() => { + try { + return Address.fromSs58(value) + } catch { + return false + } + }, [value]) + + const filteredContacts = useMemo(() => { + if (!value) return contacts + return contacts?.filter(contact => { + if ( + contact.name.toLowerCase().includes(value.toLowerCase()) || + contact.address.toSs58().toLowerCase().includes(value.toLowerCase()) + ) + return true + return parsedAddress && contact.address.isEqual(parsedAddress) + }) + }, [contacts, parsedAddress, value]) + + const handleFocus = useCallback( + (e: React.FocusEvent) => { + setFocus(true) + onFocus?.(e) + }, + [onFocus] + ) + + const handleBlur = useCallback( + (e: React.FocusEvent) => { + setFocus(false) + onBlur?.(e) + }, + [onBlur] + ) + + return ( +
+ {address ? ( +
+ contact.address.isEqual(address))?.name} + nameOrAddressOnly + withAddressTooltip + /> + +
+ ) : null} + + + { + inputRef(ref) + // @ts-ignore + localRef.current = ref + }} + {...inputProps} + onFocus={handleFocus} + onBlur={handleBlur} + value={address ? '' : value} + onChange={e => { + setValue(e.target.value) + try { + const validAddress = Address.fromSs58(e.target.value) + if (validAddress) { + onChangeAddress(validAddress) + localRef.current?.blur() + } + } catch {} + }} + /> + + { + e.preventDefault() + }} + > + {filteredContacts?.map(contact => ( +
onChangeAddress(contact.address)} + > + +
+ ))} +
+
+
+ ) +} diff --git a/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/MultisendTable.d.ts b/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/MultisendTable.d.ts new file mode 100644 index 00000000..69d880ec --- /dev/null +++ b/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/MultisendTable.d.ts @@ -0,0 +1,34 @@ +import { Address } from '@util/addresses' + +export type ColumnsInputType = { + recipient: HTMLInputElement | null + amount: HTMLInputElement | null + vested: HTMLButtonElement | null + start: HTMLInputElement | null + end: HTMLInputElement | null +} + +export type TableColumnKeys = keyof ColumnsInputType + +export type MultisendTableKeyDownHandler = ( + event: React.KeyboardEvent, + i: number, + column: TKey +) => void + +export type MultisendTableRefHandler = ( + ref: ColumnsInputType[TKey] | null, + i: number, + column: TKey +) => void + +export type MultisendSend = { + recipient?: Address + amount?: string + amountBN?: bigint + vested?: { + start: number + end: number + } + error?: string +} diff --git a/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/MultisendTableAmountUnitDropdown.tsx b/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/MultisendTableAmountUnitDropdown.tsx new file mode 100644 index 00000000..fd156838 --- /dev/null +++ b/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/MultisendTableAmountUnitDropdown.tsx @@ -0,0 +1,93 @@ +import { AmountUnit } from '@components/AmountUnitSelector' +import { Button } from '@components/ui/button' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@components/ui/dropdown-menu' +import { tokenPriceState } from '@domains/chains' +import { Skeleton } from '@talismn/ui' +import { ChevronDown, DollarSignIcon } from 'lucide-react' +import React, { useEffect } from 'react' +import { useRecoilState, useRecoilValue, useRecoilValueLoadable } from 'recoil' +import { multisendAmountUnitAtom, multisendTokenAtom } from './atom' + +const tokenOption = { + name: 'Tokens', + value: AmountUnit.Token, +} +const usdOption = { + name: 'USD', + value: AmountUnit.UsdMarket, +} + +const weekUsdOption = { + name: 'USD (7D EMA)', + value: AmountUnit.Usd7DayEma, +} + +const monthUsdOption = { + name: 'USD (30D EMA)', + value: AmountUnit.Usd30DayEma, +} + +export const MultisendTableAmountUnitDropdown: React.FC = () => { + const token = useRecoilValue(multisendTokenAtom) + const [unit, setUnit] = useRecoilState(multisendAmountUnitAtom) + const tokenPrices = useRecoilValueLoadable(tokenPriceState(token)) + + useEffect(() => { + if (tokenPrices.state === 'hasValue') { + let newAmountUnit = unit + if (newAmountUnit === AmountUnit.Usd30DayEma && !tokenPrices.contents.averages?.ema30) { + newAmountUnit = AmountUnit.Usd7DayEma + } + if (newAmountUnit === AmountUnit.Usd7DayEma && !tokenPrices.contents.averages?.ema7) { + newAmountUnit = AmountUnit.UsdMarket + } + if (newAmountUnit === AmountUnit.UsdMarket && !tokenPrices.contents.current) { + newAmountUnit = AmountUnit.Token + } + setUnit(newAmountUnit) + } + }, [tokenPrices, unit, setUnit]) + + if (tokenPrices.state === 'loading' || !token) return + + if (tokenPrices.state !== 'hasValue' || (!tokenPrices.contents.averages && !tokenPrices.contents.current)) return null + + const unitOptions = [tokenOption, usdOption] + if (tokenPrices.contents.averages) { + if (tokenPrices.contents.averages.ema7) unitOptions.push(weekUsdOption) + if (tokenPrices.contents.averages.ema30) unitOptions.push(monthUsdOption) + } + + return ( + + + + + + {unitOptions.map(({ name, value }) => ( + setUnit(value)} className={unit === value ? 'bg-gray-800' : ''}> +
+ {value === AmountUnit.Token ? ( + {token.symbol} + ) : ( + + )} +

{value === AmountUnit.Token ? token.symbol : name}

+
+
+ ))} +
+
+ ) +} diff --git a/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/MultisendTableBlockInput.tsx b/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/MultisendTableBlockInput.tsx new file mode 100644 index 00000000..555f06f5 --- /dev/null +++ b/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/MultisendTableBlockInput.tsx @@ -0,0 +1,86 @@ +import { Button } from '@components/ui/button' +import { Calendar } from '@components/ui/calendar' +import { Popover, PopoverContent, PopoverTrigger } from '@components/ui/popover' +import { CalendarIcon } from 'lucide-react' +import { useCallback, useMemo } from 'react' + +type Props = { + blockTime?: number + currentBlock?: number + value?: number + onChange: (blockNumber: number) => void + minBlock?: number + inputRef: (node: HTMLInputElement | null) => void + onKeyDown: React.DetailedHTMLProps, HTMLInputElement>['onKeyDown'] +} + +export const MultisendTableBlockInput: React.FC = ({ + blockTime, + currentBlock, + value, + onChange, + onKeyDown, + minBlock, + inputRef, +}) => { + const derivedDate = useMemo(() => { + if (currentBlock === undefined || blockTime === undefined || value === undefined) return undefined + + const now = new Date() + const blocksDiff = value - currentBlock + const msDiff = blocksDiff * blockTime + return new Date(now.getTime() + msDiff) + }, [blockTime, currentBlock, value]) + + const handleDateChange = useCallback( + (day: Date | undefined) => { + if (!blockTime || !currentBlock || !day) return + const now = new Date() + const msDiff = day.getTime() - now.getTime() + 5000 + const blocksDiff = Math.ceil(msDiff / blockTime) + onChange(blocksDiff + currentBlock) + }, + [blockTime, currentBlock, onChange] + ) + + return ( +
+ + + + + + { + if (minBlock === undefined) return false + if (!blockTime || !currentBlock) return true + const blockNumber = + Math.ceil((date.getTime() - new Date(new Date().toLocaleDateString()).getTime()) / blockTime) + + currentBlock + return blockNumber < minBlock + }} + /> + + + { + const valueNumber = Number(e.target.value) + if (isNaN(valueNumber)) return + onChange(valueNumber) + }} + placeholder="0" + ref={ref => inputRef(ref)} + onKeyDown={onKeyDown} + /> +
+ ) +} diff --git a/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/MultisendTableRow.tsx b/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/MultisendTableRow.tsx new file mode 100644 index 00000000..38c82cc6 --- /dev/null +++ b/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/MultisendTableRow.tsx @@ -0,0 +1,235 @@ +import { TableCell, TableRow } from '@components/ui/table' +import { AddressInputCell } from './AddressInputCell' +import { Switch } from '@components/ui/switch' +import { MultisendSend, MultisendTableKeyDownHandler, MultisendTableRefHandler } from './MultisendTable' +import { AddressWithName } from '@components/AddressInput' +import { MultisendTableBlockInput } from './MultisendTableBlockInput' +import { AmountUnit } from '@components/AmountUnitSelector' +import { parseUnits } from '@util/numbers' +import { useMemo } from 'react' +import { Tooltip } from '@components/ui/tooltip' +import { cn } from '@util/tailwindcss' +import { useRecoilValue } from 'recoil' +import { multisendAmountUnitAtom, multisendTokenAtom } from './atom' +import { validateMultisendRow } from './utils' +import { Address } from '@util/addresses' + +type Props = { + index: number + handleRef: MultisendTableRefHandler + handleKeyDown: MultisendTableKeyDownHandler + contacts?: AddressWithName[] + canVest?: boolean + send: MultisendSend + onSendsChange?: (send: MultisendSend[]) => void + + // details for vesting + currentBlock?: number + blockTime?: number + disableVesting: boolean +} + +/* try to find an address and amount from given string and perform required formatting */ +const findAddressAndAmount = ( + row: string +): { + data?: { address: Address; addressString: string; amount: string; start?: number; end?: number } + error?: string +} => { + // try format "address, amount" + let [address, amount, startBlock, endBlock] = row.split(',') + + // try format "address amount" + if (!address || !amount) { + ;[address, amount, startBlock, endBlock] = row.split(' ') + } + + // try format "address amount" + if (!address || !amount) { + ;[address, amount, startBlock, endBlock] = row.split(' ') + } + + // try format "address[tab]amount", common for imported CSV + if (!address || !amount) { + ;[address, amount, startBlock, endBlock] = row.split('\t') + } + + // try format "address=amount", common for imported CSV + if (!address || !amount) { + ;[address, amount, startBlock, endBlock] = row.split('=') + } + + if (!address && !amount) return { error: 'Empty' } + if (!address || !amount) return { error: 'Invalid Row' } + + const trimmedAddress = address.trim() + const trimmedAmount = amount.trim() + + const parsedAddress = Address.fromSs58(trimmedAddress) + const invalidAmount = trimmedAmount === '' || isNaN(+trimmedAmount) + const start = startBlock ? parseInt(startBlock.trim()) : undefined + const end = endBlock ? parseInt(endBlock.trim()) : undefined + if (start !== undefined && isNaN(start)) return { error: 'Invalid Start Block' } + if (end !== undefined && isNaN(end)) return { error: 'Invalid End Block' } + if (!parsedAddress && invalidAmount) return { error: 'Invalid Row' } + if (!parsedAddress) return { error: 'Invalid Address' } + if (invalidAmount) return { error: 'Invalid Amount' } + + return { + data: { + address: parsedAddress, + addressString: trimmedAddress, + amount: trimmedAmount, + start, + end, + }, + } +} + +export const MultisendTableRow: React.FC = ({ + blockTime, + contacts, + currentBlock, + handleKeyDown, + handleRef, + onSendsChange, + index, + canVest, + disableVesting, + send, +}) => { + const unit = useRecoilValue(multisendAmountUnitAtom) + const token = useRecoilValue(multisendTokenAtom) + const errors = useMemo(() => validateMultisendRow(send, canVest), [canVest, send]) + return ( + + + +
+ handleRef(ref, index, 'recipient')} + className="bg-transparent w-full text-ellipsis" + onKeyDown={e => handleKeyDown(e, index, 'recipient')} + onChangeAddress={address => { + onSendsChange?.([{ ...send, recipient: address }]) + }} + address={send.recipient} + onPaste={e => { + const pastedText = e.clipboardData.getData('text/plain') + const lines = pastedText.split('\n') + const validSends: MultisendSend[] = [] + for (const line of lines) { + const send = findAddressAndAmount(line) + if (send.error && send.error !== 'Empty') return + if (send.data) { + validSends.push({ + recipient: send.data.address, + amount: send.data.amount, + vested: + send.data.start !== undefined && send.data.end !== undefined + ? { start: send.data.start, end: send.data.end } + : undefined, + }) + } + } + + onSendsChange?.(validSends) + }} + /> +
+
+
+ + +
+ handleRef(ref, index, 'amount')} + onKeyDown={e => handleKeyDown(e, index, 'amount')} + disabled={!token} + value={send.amount ?? ''} + onChange={e => { + let amount = e.target.value + if (amount === '.') amount = '0.' + try { + if (unit === AmountUnit.Token && amount !== '') { + // make sure amount can be parsed + parseUnits(amount, token?.decimals ?? 18) + } else if (Number.isNaN(parseFloat(amount)) && amount !== '') return + onSendsChange?.([{ ...send, amount }]) + } catch (e) {} + }} + /> +

{unit === AmountUnit.Token ? token?.symbol : 'USD'}

+
+
+
+ + + {currentBlock === undefined || blockTime === undefined ? ( +

-

+ ) : canVest && !disableVesting ? ( +
+ handleRef(ref, index, 'vested')} + onKeyDown={e => handleKeyDown(e, index, 'vested')} + checked={!!send.vested} + onCheckedChange={checked => + onSendsChange?.([{ ...send, vested: checked ? { start: 0, end: 0 } : undefined }]) + } + /> +
+ ) : ( +

-

+ )} +
+ + {canVest && !disableVesting && send.vested ? ( + +
+ handleRef(ref, index, 'start')} + onChange={blockNumber => + onSendsChange?.([ + { ...send, vested: send.vested ? { ...send.vested, start: blockNumber } : undefined }, + ]) + } + onKeyDown={e => handleKeyDown(e, index, 'start')} + value={send.vested.start} + minBlock={currentBlock === undefined ? undefined : currentBlock + 1} + /> +
+
+ ) : ( + '-' + )} +
+ + {canVest && !disableVesting && send.vested ? ( + +
+ handleRef(ref, index, 'end')} + onChange={blockNumber => { + onSendsChange?.([{ ...send, vested: send.vested ? { ...send.vested, end: blockNumber } : undefined }]) + }} + onKeyDown={e => handleKeyDown(e, index, 'end')} + value={send.vested.end} + minBlock={send.vested.start + 1} + /> +
+
+ ) : ( + '-' + )} +
+
+ ) +} diff --git a/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/atom.ts b/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/atom.ts new file mode 100644 index 00000000..58667f31 --- /dev/null +++ b/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/atom.ts @@ -0,0 +1,19 @@ +import { atom } from 'recoil' +import { MultisendSend } from './MultisendTable' +import { AmountUnit } from '@components/AmountUnitSelector' +import { BaseToken } from '@domains/chains' + +export const multisendSendsAtom = atom({ + key: 'multisendSends', + default: [], +}) + +export const multisendAmountUnitAtom = atom({ + key: 'multisendAmountUnit', + default: AmountUnit.Token, +}) + +export const multisendTokenAtom = atom({ + key: 'multisendToken', + default: undefined, +}) diff --git a/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/index.tsx b/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/index.tsx new file mode 100644 index 00000000..5559cf24 --- /dev/null +++ b/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/index.tsx @@ -0,0 +1,251 @@ +import { Table, TableBody, TableHead, TableHeader, TableRow } from '@components/ui/table' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { AddressWithName } from '@components/AddressInput' +import { + ColumnsInputType, + MultisendSend, + MultisendTableKeyDownHandler, + MultisendTableRefHandler, + TableColumnKeys, +} from './MultisendTable' +import { MultisendTableRow } from './MultisendTableRow' +import { useLatestBlockNumber } from '@domains/chains/useLatestBlockNumber' +import { useApi } from '@domains/chains/pjs-api' +import { expectedBlockTime } from '@domains/common/substratePolyfills' +import { Button } from '@components/ui/button' +import { PlusIcon, XIcon } from 'lucide-react' +import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil' +import { MultisendTableAmountUnitDropdown } from './MultisendTableAmountUnitDropdown' +import { multisendAmountUnitAtom, multisendSendsAtom, multisendTokenAtom } from './atom' +import { AmountUnit } from '@components/AmountUnitSelector' +import { Tooltip } from '@components/ui/tooltip' + +type Props = { + contacts?: AddressWithName[] + chainGenesisHash: string + disableVesting: boolean +} + +const columnsOrder: TableColumnKeys[] = ['recipient', 'amount', 'vested', 'start', 'end'] +const findNextColumn = (column: TableColumnKeys): TableColumnKeys => { + const index = columnsOrder.indexOf(column) + return columnsOrder[index + 1] ?? columnsOrder[0]! +} + +const findPrevColumn = (column: TableColumnKeys): TableColumnKeys => { + const index = columnsOrder.indexOf(column) + return columnsOrder[index - 1] ?? columnsOrder[columnsOrder.length - 1]! +} + +const isInputKeyboardEvent = ( + el: React.KeyboardEvent | null, + key: string +): el is React.KeyboardEvent => { + return key !== 'vested' +} + +export const MultiSendTable: React.FC = ({ chainGenesisHash, contacts, disableVesting }) => { + const [lines, setLines] = useState(5) + const inputRefs = useRef([]) + const lastAddedLine = useRef(4) + const shouldAutoFocus = useRef(false) + const { api } = useApi(chainGenesisHash) + const blockNumber = useLatestBlockNumber(chainGenesisHash) + const [sends, setSends] = useRecoilState(multisendSendsAtom) + const setAmountUnit = useSetRecoilState(multisendAmountUnitAtom) + const token = useRecoilValue(multisendTokenAtom) + + const blockTime = useMemo(() => { + if (!api) return + return expectedBlockTime(api) + }, [api]) + + const [defaultStartBlock, defaultEndBlock] = useMemo(() => { + if (blockTime === undefined || blockNumber === undefined) return [0, 0] + // default to one day from now + const startBlock = (24 * 60 * 60 * 1000) / blockTime.toNumber() + blockNumber + // default to one month from default start date + const endBlock = (30 * 24 * 60 * 60 * 1000) / blockTime.toNumber() + startBlock + return [startBlock, endBlock] + }, [blockNumber, blockTime]) + + useEffect(() => { + if (inputRefs.current.length < lines) { + inputRefs.current = inputRefs.current.concat( + [...Array(lines - inputRefs.current.length)].map(() => ({ + recipient: null, + amount: null, + end: null, + start: null, + vested: null, + })) + ) + } + }, [lines]) + + // empty the list when user leaves page + useEffect(() => { + setSends([]) + setAmountUnit(AmountUnit.Token) + }, [setAmountUnit, setSends]) + + const addLine = useCallback(() => { + shouldAutoFocus.current = false + setLines(_lines => _lines + 1) + }, []) + + const removeLine = useCallback( + (index: number) => { + shouldAutoFocus.current = false + const newInputRefs = [...inputRefs.current] + + newInputRefs.splice(index, 1) + inputRefs.current = [...newInputRefs] + lastAddedLine.current = inputRefs.current.length - 1 + + setLines(_lines => Math.max(_lines - 1, 1)) + setSends(prev => { + const newSends = [...prev] + newSends.splice(index, 1) + return newSends + }) + }, + [setSends] + ) + + const handleKeyDown = useCallback( + (e, i, column) => { + if (e.key === 'Enter' && i === inputRefs.current.length - 1) { + addLine() + shouldAutoFocus.current = true + } else if ((e.key === 'ArrowDown' || e.key === 'Enter') && i < inputRefs.current.length - 1) { + const nextRow = inputRefs.current.findIndex((refs, index) => !!refs[column] && index > i) + inputRefs.current[nextRow]?.[column]?.focus() + e.stopPropagation() + } else if (e.key === 'ArrowUp' && i > 0) { + // @ts-ignore + const nextRow = inputRefs.current.slice(0, i).findLastIndex(refs => !!refs[column]) + // @ts-ignore + if (nextRow > -1) inputRefs.current[nextRow]?.[column]?.focus() + e.stopPropagation() + } else if (e.key === 'ArrowLeft' && column !== 'recipient') { + // only execute this if the cursor is at the beginning of the input + if (isInputKeyboardEvent(e, column) && e.currentTarget.selectionStart !== 0) return + e.stopPropagation() + inputRefs.current[i]?.[findPrevColumn(column)]?.focus() + } else if (e.key === 'ArrowRight' && column !== 'end') { + // only execute this if the cursor is at the end of the input + if (isInputKeyboardEvent(e, column) && e.currentTarget.selectionStart !== e.currentTarget.value.length) return + e.stopPropagation() // prevents scrolling + inputRefs.current[i]?.[findNextColumn(column)]?.focus() + } + }, + [addLine] + ) + + const handleRef = useCallback((ref, i, column) => { + let cur = inputRefs.current[i] + if (!cur) { + cur = { amount: null, recipient: null, end: null, start: null, vested: null } + } + if (ref === null) return + inputRefs.current[i] = { ...cur, [column]: ref } + if ( + column === 'recipient' && + i === inputRefs.current.length - 1 && + i > lastAddedLine.current && + shouldAutoFocus.current + ) { + ref.focus() + lastAddedLine.current = i + } + }, []) + + const handleSendChange = useCallback( + (sends: MultisendSend[], index: number) => { + if (sends.length + index > lines) setLines(sends.length + index) + + setSends(prev => { + const newSends = [...prev] + sends.forEach((send, i) => { + newSends[index + i] = send + if (send.vested?.end === 0 && send.vested?.start === 0) { + newSends[index + i] = { + ...send, + vested: { + end: defaultEndBlock, + start: defaultStartBlock, + }, + } + } + }) + + return newSends + }) + }, + [defaultEndBlock, defaultStartBlock, lines, setSends] + ) + + return ( +
+
+ + + + +
+

Recipient

+ + + +
+
+ +
+

Amount

+ +
+
+ Vested + Start Block + End Block +
+
+ + {[...Array(lines)].map((_, i) => ( + handleSendChange(sends, i)} + disableVesting={disableVesting} + /> + ))} + +
+
+ {[...Array(lines)].map((_, i) => ( +
+ +
+ ))} +
+
+
+ ) +} diff --git a/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/utils.ts b/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/utils.ts new file mode 100644 index 00000000..2bdfc638 --- /dev/null +++ b/apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/utils.ts @@ -0,0 +1,19 @@ +import { MultisendSend, TableColumnKeys } from './MultisendTable' + +type TableError = Partial> +export const validateMultisendRow = (send: MultisendSend, canVest?: boolean): TableError | undefined => { + if (send.recipient === undefined && (send.amount === undefined || send.amount === '') && send.vested === undefined) + return undefined + const errors: TableError = {} + if (!send.recipient) errors.recipient = 'Recipient is required' + if (!send.amount) errors.amount = 'Amount is required' + if (canVest && send.vested) { + if (send.vested.start === 0) errors.start = 'Start block is required' + if (send.vested.end === 0) errors.end = 'End block is required' + if (send.vested.start >= send.vested.end) { + errors.start = 'Start block must be before end block' + errors.end = 'End block must be after start block' + } + } + return errors +} diff --git a/apps/multisig/src/layouts/NewTransaction/Multisend/index.tsx b/apps/multisig/src/layouts/NewTransaction/Multisend/index.tsx index 75c7d04f..b664821f 100644 --- a/apps/multisig/src/layouts/NewTransaction/Multisend/index.tsx +++ b/apps/multisig/src/layouts/NewTransaction/Multisend/index.tsx @@ -1,18 +1,22 @@ -import { buildTransferExtrinsic } from '@domains/chains' +import { buildTransferExtrinsic, tokenPriceState } from '@domains/chains' import { pjsApiSelector } from '@domains/chains/pjs-api' import { selectedMultisigChainTokensState, useSelectedMultisig } from '@domains/multisig' import { hasPermission } from '@domains/proxy/util' -import { SubmittableExtrinsic } from '@polkadot/api/types' -import { useEffect, useState } from 'react' -import { useRecoilValueLoadable } from 'recoil' +import { useCallback, useMemo, useState } from 'react' +import { useRecoilValue, useRecoilValueLoadable } from 'recoil' +import BN from 'bn.js' -import { MultiSendSend } from './multisend.types' import MultiSendForm from './MultiSendForm' import { NewTransactionHeader } from '../NewTransactionHeader' import { Share2 } from '@talismn/icons' import { TransactionSidesheet } from '@components/TransactionSidesheet' import { useToast } from '@components/ui/use-toast' +import { useKnownAddresses } from '@hooks/useKnownAddresses' +import { multisendAmountUnitAtom, multisendSendsAtom, multisendTokenAtom } from './MultisendTable/atom' +import { AmountUnit } from '@components/AmountUnitSelector' +import { parseUnits } from '@util/numbers' +import { useVestingScheduleCreator } from '@hooks/useVestingScheduleCreator' enum Step { Details, @@ -23,15 +27,76 @@ const MultiSend = () => { const [step, setStep] = useState(Step.Details) const [name, setName] = useState('') const tokens = useRecoilValueLoadable(selectedMultisigChainTokensState) - const [extrinsic, setExtrinsic] = useState | undefined>() - const [sends, setSends] = useState([]) const [multisig] = useSelectedMultisig() const apiLoadable = useRecoilValueLoadable(pjsApiSelector(multisig.chain.genesisHash)) const { toast } = useToast() const permissions = hasPermission(multisig, 'transfer') + const { addresses } = useKnownAddresses(multisig.orgId) + const newSends = useRecoilValue(multisendSendsAtom) + const unit = useRecoilValue(multisendAmountUnitAtom) + const token = useRecoilValue(multisendTokenAtom) + const tokenPrices = useRecoilValueLoadable(tokenPriceState(token)) + const vestingScheduleCreator = useVestingScheduleCreator(multisig.chain.genesisHash) - useEffect(() => { - if (sends.length > 0 && apiLoadable.state === 'hasValue') { + const parseAmount = useCallback( + (amount: string) => { + if (!token) return new BN(0) + + let tokenAmount = amount + + if (unit !== AmountUnit.Token) { + if (tokenPrices.state === 'hasValue') { + if (unit === AmountUnit.UsdMarket) { + tokenAmount = (parseFloat(amount) / tokenPrices.contents.current).toString() + } else if (unit === AmountUnit.Usd7DayEma) { + if (!tokenPrices.contents.averages?.ema7) return new BN(0) + tokenAmount = (parseFloat(amount) / tokenPrices.contents.averages.ema7).toString() + } else if (unit === AmountUnit.Usd30DayEma) { + if (!tokenPrices.contents.averages?.ema30) return new BN(0) + tokenAmount = (parseFloat(amount) / tokenPrices.contents.averages.ema30).toString() + } + } else { + return new BN(0) + } + } + + return parseUnits(tokenAmount, token.decimals) + }, + [token, tokenPrices.contents, tokenPrices.state, unit] + ) + + const parsedSends = useMemo(() => { + const sends = newSends.map(send => { + if ( + !send || + (send.recipient === undefined && (send.amount === undefined || send.amount === '') && send.vested === undefined) + ) + return null + if (!send.amount || !send.recipient) return undefined + const amountBN = parseAmount(send.amount) + return { + recipient: send.recipient, + amountBN, + vestingSchedule: send.vested, + } + }) + + return sends.filter(send => send !== null) + }, [newSends, parseAmount]) + + const { hasInvalidSend, totalAmount, validSends } = useMemo(() => { + const validSends = parsedSends.filter(send => send !== undefined) + const hasInvalidSend = parsedSends.some(send => send === undefined) + const totalAmount = validSends.reduce((acc, send) => acc.add(send!.amountBN), new BN(0)) + return { + validSends, + hasInvalidSend, + totalAmount, + } + }, [parsedSends]) + + const extrinsic = useMemo(() => { + if (validSends.length > 0 && apiLoadable.state === 'hasValue' && token && vestingScheduleCreator) { if ( !apiLoadable.contents.tx.balances?.transferKeepAlive || !apiLoadable.contents.tx.proxy?.proxy || @@ -40,23 +105,33 @@ const MultiSend = () => { throw Error('chain missing required pallet/s for multisend') } try { - const sendExtrinsics = sends.map(send => { - const balance = { amount: send.amountBn, token: send.token } - return buildTransferExtrinsic(apiLoadable.contents, send.address, balance) + const sendExtrinsics = validSends.map(send => { + const balance = { amount: send!.amountBN, token } + return buildTransferExtrinsic( + apiLoadable.contents, + send!.recipient, + balance, + send?.vestingSchedule + ? vestingScheduleCreator( + balance.amount, + send.vestingSchedule.start, + send.vestingSchedule.end - send.vestingSchedule.start + ) + : undefined + ) }) - const batchAllExtrinsic = apiLoadable.contents.tx.utility.batchAll(sendExtrinsics) - setExtrinsic(batchAllExtrinsic) + return apiLoadable.contents.tx.utility.batchAll(sendExtrinsics) } catch (error) { console.error(error) } } - }, [sends, apiLoadable, multisig.proxyAddress]) + }, [apiLoadable.contents, apiLoadable.state, token, validSends, vestingScheduleCreator]) return ( <> -
-
+
+
} title="Multi-send" /> { setName={setName} tokens={tokens} onNext={() => setStep(Step.Review)} - sends={sends} - setSends={setSends} + contacts={addresses} + chain={multisig.chain} + totalAmount={totalAmount} + totalSends={validSends.length} + disabled={hasInvalidSend} + disableVesting={!vestingScheduleCreator} />
{extrinsic && ( diff --git a/apps/multisig/src/layouts/NewTransaction/Multisend/multisend.types.ts b/apps/multisig/src/layouts/NewTransaction/Multisend/multisend.types.ts deleted file mode 100644 index 31a2c2be..00000000 --- a/apps/multisig/src/layouts/NewTransaction/Multisend/multisend.types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Address } from '@util/addresses' -import { BaseToken } from '@domains/chains' -import BN from 'bn.js' - -export interface MultiSendSend { - token: BaseToken - address: Address - amountBn: BN -} diff --git a/apps/multisig/src/layouts/NewTransaction/Send/SendExpandableDetails.tsx b/apps/multisig/src/layouts/NewTransaction/Send/SendExpandableDetails.tsx index a49bf6b2..b203b4e0 100644 --- a/apps/multisig/src/layouts/NewTransaction/Send/SendExpandableDetails.tsx +++ b/apps/multisig/src/layouts/NewTransaction/Send/SendExpandableDetails.tsx @@ -1,70 +1,15 @@ -import { Tooltip } from '@components/ui/tooltip' -import { useApi } from '@domains/chains/pjs-api' -import { useLatestBlockNumber } from '@domains/chains/useLatestBlockNumber' -import { expectedBlockTime } from '@domains/common/substratePolyfills' +import { VestingDateRange } from '@components/VestingDateRange' import { Transaction, VestingSchedule } from '@domains/multisig' -import { secondsToDuration } from '@util/misc' -import { useMemo } from 'react' type Props = { t: Transaction } const VestingInfo: React.FC = ({ t, vestingSchedule }) => { - const { api } = useApi(t.multisig.chain.genesisHash) - const blockNumber = useLatestBlockNumber(t.multisig.chain.genesisHash) - const blockTime = useMemo(() => { - if (!api) return - return expectedBlockTime(api) - }, [api]) - - const startDate = useMemo(() => { - if (blockNumber === undefined || blockTime === undefined) return undefined - - const now = new Date() - const blocksDiff = vestingSchedule.start - blockNumber - const msDiff = blocksDiff * blockTime.toNumber() - return new Date(now.getTime() + msDiff) - }, [blockNumber, blockTime, vestingSchedule.start]) - - const endDate = useMemo(() => { - if (blockNumber === undefined || blockTime === undefined) return undefined - - const now = new Date() - const blocksDiff = vestingSchedule.start + vestingSchedule.period - blockNumber - const msDiff = blocksDiff * blockTime.toNumber() - return new Date(now.getTime() + msDiff) - }, [blockNumber, blockTime, vestingSchedule.period, vestingSchedule.start]) - - const startDateString = startDate?.toLocaleDateString() - const endDateString = endDate?.toLocaleDateString() - const sameDay = startDateString === endDateString - const duration = useMemo(() => { - if (blockTime === undefined) return undefined - return vestingSchedule.period * blockTime.toNumber() - }, [blockTime, vestingSchedule.period]) return ( -
-
-

Vesting Period

- - {startDate?.toLocaleString()} → {endDate?.toLocaleString()} -

- } - > -

- {sameDay ? `${startDateString}, ` : ''} - {sameDay ? `≈${startDate?.toLocaleTimeString()}` : startDateString} →{' '} - {sameDay ? `≈${endDate?.toLocaleTimeString()}` : endDate?.toLocaleDateString()} - {!!duration && ( -  (≈{secondsToDuration(duration)}) - )} -

-
-
+
+

Vesting Period

+
) } diff --git a/apps/multisig/src/layouts/Overview/Transactions/TransactionDetailsExpandable.tsx b/apps/multisig/src/layouts/Overview/Transactions/TransactionDetailsExpandable.tsx index 0ae3a0fd..794eb77e 100644 --- a/apps/multisig/src/layouts/Overview/Transactions/TransactionDetailsExpandable.tsx +++ b/apps/multisig/src/layouts/Overview/Transactions/TransactionDetailsExpandable.tsx @@ -29,6 +29,8 @@ import { SendExpandableDetails } from '../../../layouts/NewTransaction/Send/Send import { cn } from '@util/tailwindcss' import { isExtrinsicProxyWrapped } from '@util/extrinsics' import { CONFIG } from '@lib/config' +import { VestingDateRange } from '@components/VestingDateRange' +import { Table, TableCell, TableHead, TableHeader, TableRow } from '@components/ui/table' const CopyPasteBox: React.FC<{ content: string; label?: string }> = ({ content, label }) => { const [copied, setCopied] = useState(false) @@ -151,39 +153,83 @@ const ChangeConfigExpandedDetails = ({ t }: { t: Transaction }) => { } const MultiSendExpandedDetails = ({ t }: { t: Transaction }) => { - const recipients = t.decoded?.recipients || [] const { contactByAddress } = useKnownAddresses(t.multisig.orgId) return ( -
- {t.decoded?.recipients.map(({ address, balance }, i) => ( -
-
-
- - {i + 1}/{recipients.length} - -
-
+
+ + + + Recipient + Vested + Amount + + + + {t.decoded?.recipients.map(({ address, balance, vestingSchedule }, i) => ( + + - -
- -
- - - ))} +
+ + {vestingSchedule ? ( +
+ +
+ ) : null} +
+ +
+ +
+
+
+ + //
+ //
+ //
+ //
+ //
+ // + //
+ //
+ // + //
+ //
+ // {vestingSchedule ? ( + //
+ //

Vesting Period

+ // + //
+ // ) : null} + //
+ //
+ //
+ ))} +
) } @@ -271,7 +317,7 @@ const TransactionDetailsHeaderContent: React.FC<{ t: Transaction }> = ({ t }) => return (
-

+

{recipients.length} Send{recipients.length > 1 && 's'}

@@ -412,7 +458,7 @@ const TransactionDetailsExpandable = ({ t }: { t: Transaction }) => {
{icon}
-

{name}

+

{name}

diff --git a/apps/multisig/src/layouts/Overview/Transactions/TransactionsList.tsx b/apps/multisig/src/layouts/Overview/Transactions/TransactionsList.tsx index db1f2382..81176c12 100644 --- a/apps/multisig/src/layouts/Overview/Transactions/TransactionsList.tsx +++ b/apps/multisig/src/layouts/Overview/Transactions/TransactionsList.tsx @@ -121,7 +121,11 @@ export const TransactionsList = ({ > - navigate(`/overview/${value}-tx/${t.draft?.id ?? t.hash}?tab=${value}&teamId=${multisig.id}`) + navigate( + `/overview/${value}-tx/${t.draft?.id ?? t.hash}?tab=${value}&teamId=${multisig.id}${ + window.location.hash + }` + ) } t={t} showDraftBadge @@ -169,17 +173,17 @@ export const TransactionsList = ({ }} onApproveFailed={e => { console.error(e) - navigate(`/overview?tab=${value}&teamId=${multisig.id}`) + navigate(`/overview?tab=${value}&teamId=${multisig.id}${window.location.hash}`) toast({ title: 'Failed to approve transaction', description: e.message, }) }} onClose={() => { - navigate(`/overview?tab=${value}&teamId=${multisig.id}`) + navigate(`/overview?tab=${value}&teamId=${multisig.id}${window.location.hash}`) }} onRejected={({ error }) => { - navigate(`/overview?tab=${value}&teamId=${multisig.id}`) + navigate(`/overview?tab=${value}&teamId=${multisig.id}${window.location.hash}`) if (error) { console.error(error) toast({ diff --git a/yarn.lock b/yarn.lock index 79cb049f..e366c2b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7540,6 +7540,19 @@ __metadata: languageName: node linkType: hard +"@silevis/reactgrid@npm:^4.1.5": + version: 4.1.5 + resolution: "@silevis/reactgrid@npm:4.1.5" + dependencies: + sass: ^1.62.1 + tslib: ^2.5.2 + peerDependencies: + react: ^16.13.1 || ^17.0.0 || ^18.2.0 + react-dom: ^16.13.1 || ^17.0.0 || ^18.2.0 + checksum: db6994e015cc3027391084eb294ab9d842a37e6ce2b7d616b51252b7e677a185e8a2599f5912e6eebe78923c7832edfd303d18bc2f543e2fef7a26da5f578e84 + languageName: node + linkType: hard + "@sinclair/typebox@npm:^0.24.1": version: 0.24.51 resolution: "@sinclair/typebox@npm:0.24.51" @@ -10450,6 +10463,7 @@ __metadata: "@recoiljs/refine": 0.1.1 "@sentry/react": ^7.37.2 "@sentry/tracing": ^7.37.2 + "@silevis/reactgrid": ^4.1.5 "@storybook/addon-actions": ^6.5.16 "@storybook/addon-docs": ^6.5.16 "@storybook/addon-essentials": ^6.5.16 @@ -10473,6 +10487,7 @@ __metadata: "@talismn/siws": 0.0.8 "@talismn/ui": "workspace:^" "@talismn/util": ^0.2.0 + "@tanstack/react-table": ^8.16.0 "@testing-library/jest-dom": ^5.16.5 "@testing-library/react": ^13.4.0 "@testing-library/user-event": ^14.4.3 @@ -10744,6 +10759,25 @@ __metadata: languageName: unknown linkType: soft +"@tanstack/react-table@npm:^8.16.0": + version: 8.16.0 + resolution: "@tanstack/react-table@npm:8.16.0" + dependencies: + "@tanstack/table-core": 8.16.0 + peerDependencies: + react: ">=16.8" + react-dom: ">=16.8" + checksum: 9a80668ba7531b49425d3c08fe34fbd4bbcdf936fbca120114d2d090013242c3ea1b573c1381719289600bc866f2ded9e3e13c7c4923285d2cf4eee1c1d489e7 + languageName: node + linkType: hard + +"@tanstack/table-core@npm:8.16.0": + version: 8.16.0 + resolution: "@tanstack/table-core@npm:8.16.0" + checksum: c2c33c542c60788eb90806feb8f1f0340aa565ef9bb031bc5562d43598394a4089d61007f55ed6ab1fa16bbe93228cb2673b564ecf90b416bf463f42a56f3d85 + languageName: node + linkType: hard + "@testing-library/dom@npm:^8.3.0, @testing-library/dom@npm:^8.5.0": version: 8.20.1 resolution: "@testing-library/dom@npm:8.20.1" @@ -15076,6 +15110,25 @@ __metadata: languageName: node linkType: hard +"chokidar@npm:>=3.0.0 <4.0.0, chokidar@npm:^3.4.1, chokidar@npm:^3.4.2, chokidar@npm:^3.5.2, chokidar@npm:^3.5.3": + version: 3.6.0 + resolution: "chokidar@npm:3.6.0" + dependencies: + anymatch: ~3.1.2 + braces: ~3.0.2 + fsevents: ~2.3.2 + glob-parent: ~5.1.2 + is-binary-path: ~2.1.0 + is-glob: ~4.0.1 + normalize-path: ~3.0.0 + readdirp: ~3.6.0 + dependenciesMeta: + fsevents: + optional: true + checksum: d2f29f499705dcd4f6f3bbed79a9ce2388cf530460122eed3b9c48efeab7a4e28739c6551fd15bec9245c6b9eeca7a32baa64694d64d9b6faeb74ddb8c4a413d + languageName: node + linkType: hard + "chokidar@npm:^2.1.8": version: 2.1.8 resolution: "chokidar@npm:2.1.8" @@ -15099,25 +15152,6 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:^3.4.1, chokidar@npm:^3.4.2, chokidar@npm:^3.5.2, chokidar@npm:^3.5.3": - version: 3.6.0 - resolution: "chokidar@npm:3.6.0" - dependencies: - anymatch: ~3.1.2 - braces: ~3.0.2 - fsevents: ~2.3.2 - glob-parent: ~5.1.2 - is-binary-path: ~2.1.0 - is-glob: ~4.0.1 - normalize-path: ~3.0.0 - readdirp: ~3.6.0 - dependenciesMeta: - fsevents: - optional: true - checksum: d2f29f499705dcd4f6f3bbed79a9ce2388cf530460122eed3b9c48efeab7a4e28739c6551fd15bec9245c6b9eeca7a32baa64694d64d9b6faeb74ddb8c4a413d - languageName: node - linkType: hard - "chownr@npm:^1.1.1": version: 1.1.4 resolution: "chownr@npm:1.1.4" @@ -21043,6 +21077,13 @@ __metadata: languageName: node linkType: hard +"immutable@npm:^4.0.0": + version: 4.3.5 + resolution: "immutable@npm:4.3.5" + checksum: 0e25dd5c314421faede9e1122ab26cdb638cc3edc8678c4a75dee104279b12621a30c80a480fae7f68bc7e81672f1e672e454dc0fdc7e6cf0af10809348387b8 + languageName: node + linkType: hard + "immutable@npm:~3.7.6": version: 3.7.6 resolution: "immutable@npm:3.7.6" @@ -29791,6 +29832,19 @@ __metadata: languageName: node linkType: hard +"sass@npm:^1.62.1": + version: 1.75.0 + resolution: "sass@npm:1.75.0" + dependencies: + chokidar: ">=3.0.0 <4.0.0" + immutable: ^4.0.0 + source-map-js: ">=0.6.2 <2.0.0" + bin: + sass: sass.js + checksum: bfb9f5ddb6a2e1fe0c1ba6191cdb17afa7b40c1eb892c7152f6a29ff2b06dc7a510bdb648f8cca0179dcb3965920ebeb8894f0710b0b450a99db563831345033 + languageName: node + linkType: hard + "sax@npm:~1.2.4": version: 1.2.4 resolution: "sax@npm:1.2.4" @@ -30400,6 +30454,13 @@ __metadata: languageName: node linkType: hard +"source-map-js@npm:>=0.6.2 <2.0.0": + version: 1.2.0 + resolution: "source-map-js@npm:1.2.0" + checksum: 791a43306d9223792e84293b00458bf102a8946e7188f3db0e4e22d8d530b5f80a4ce468eb5ec0bf585443ad55ebbd630bf379c98db0b1f317fd902500217f97 + languageName: node + linkType: hard + "source-map-js@npm:^1.0.1, source-map-js@npm:^1.0.2": version: 1.0.2 resolution: "source-map-js@npm:1.0.2" @@ -32195,7 +32256,7 @@ storybook-preset-craco@artisanofcode/storybook-preset-craco: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.5.0, tslib@npm:^2.5.3, tslib@npm:^2.6.1, tslib@npm:^2.6.2": +"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.5.0, tslib@npm:^2.5.2, tslib@npm:^2.5.3, tslib@npm:^2.6.1, tslib@npm:^2.6.2": version: 2.6.2 resolution: "tslib@npm:2.6.2" checksum: 329ea56123005922f39642318e3d1f0f8265d1e7fcb92c633e0809521da75eeaca28d2cf96d7248229deb40e5c19adf408259f4b9640afd20d13aecc1430f3ad From 03fa255e40d5f635c1eb2ae9b58c34bd970a74c7 Mon Sep 17 00:00:00 2001 From: chrisling-dev Date: Thu, 2 May 2024 15:20:03 +0800 Subject: [PATCH 2/4] feat: upload csv --- .../src/components/FileUploadButton.tsx | 15 +- .../src/domains/auth/AccountWatcher.tsx | 3 +- .../Multisend/MultiSendForm.tsx | 8 -- .../MultisendTable/MultisendTableRow.tsx | 3 +- .../Multisend/MultisendTable/index.tsx | 130 +++++++++++++++++- .../MultisendTable/multisend-copy-pasta.gif | Bin 0 -> 458146 bytes 6 files changed, 134 insertions(+), 25 deletions(-) create mode 100644 apps/multisig/src/layouts/NewTransaction/Multisend/MultisendTable/multisend-copy-pasta.gif diff --git a/apps/multisig/src/components/FileUploadButton.tsx b/apps/multisig/src/components/FileUploadButton.tsx index 9ab874ea..1f32a58b 100644 --- a/apps/multisig/src/components/FileUploadButton.tsx +++ b/apps/multisig/src/components/FileUploadButton.tsx @@ -1,6 +1,6 @@ import { Plus } from '@talismn/icons' -import { Button } from '@talismn/ui' import { useRef } from 'react' +import { Button } from './ui/button' type Props = { label?: string @@ -27,22 +27,13 @@ const FileUploadButton: React.FC = ({ accept, label, multiple, onFiles }) type="file" ref={inputRef} accept={accept} - css={{ visibility: 'hidden', height: 0, width: 0, opacity: 0 }} + className="hidden h-0 w-0 opacity-0" multiple={multiple} onChange={handleFileChange} // @ts-ignore clear the input value so that the same file can be uploaded again onClick={e => (e.target.value = null)} /> -