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 ? (
+
+ ) : (
+
+ )}
+
{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 (
+
+
+
+
+
+
+
+
+
+
+
+ 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)})
- )}
-
-
-
+
)
}
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 }) => {
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)}
/>
-