Merge branch 'development' of github.com:gnosis/safe-react into development

This commit is contained in:
Mati Dastugue 2021-02-17 11:11:53 -03:00
commit 530ba102bf
25 changed files with 238 additions and 163 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "safe-react", "name": "safe-react",
"version": "2.19.2", "version": "3.0.0",
"description": "Allowing crypto users manage funds in a safer way", "description": "Allowing crypto users manage funds in a safer way",
"website": "https://github.com/gnosis/safe-react#readme", "website": "https://github.com/gnosis/safe-react#readme",
"bugs": { "bugs": {

View File

@ -1,7 +1,7 @@
import Checkbox from '@material-ui/core/Checkbox' import Checkbox from '@material-ui/core/Checkbox'
import FormControlLabel from '@material-ui/core/FormControlLabel' import FormControlLabel from '@material-ui/core/FormControlLabel'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import React, { ReactElement, useEffect, useState } from 'react' import React, { ReactElement, useEffect, useRef, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import Button from 'src/components/layout/Button' import Button from 'src/components/layout/Button'
import Link from 'src/components/layout/Link' import Link from 'src/components/layout/Link'
@ -96,7 +96,7 @@ interface CookiesBannerFormProps {
const CookiesBanner = (): ReactElement => { const CookiesBanner = (): ReactElement => {
const classes = useStyles() const classes = useStyles()
const dispatch = useDispatch() const dispatch = useRef(useDispatch())
const [showAnalytics, setShowAnalytics] = useState(false) const [showAnalytics, setShowAnalytics] = useState(false)
const [showIntercom, setShowIntercom] = useState(false) const [showIntercom, setShowIntercom] = useState(false)
@ -110,7 +110,7 @@ const CookiesBanner = (): ReactElement => {
async function fetchCookiesFromStorage() { async function fetchCookiesFromStorage() {
const cookiesState = await loadFromCookie(COOKIES_KEY) const cookiesState = await loadFromCookie(COOKIES_KEY)
if (!cookiesState) { if (!cookiesState) {
dispatch(openCookieBanner({ cookieBannerOpen: true })) dispatch.current(openCookieBanner({ cookieBannerOpen: true }))
} else { } else {
const { acceptedIntercom, acceptedAnalytics, acceptedNecessary } = cookiesState const { acceptedIntercom, acceptedAnalytics, acceptedNecessary } = cookiesState
if (acceptedIntercom === undefined) { if (acceptedIntercom === undefined) {
@ -143,7 +143,7 @@ const CookiesBanner = (): ReactElement => {
await saveCookie(COOKIES_KEY, newState, 365) await saveCookie(COOKIES_KEY, newState, 365)
setShowAnalytics(!isDesktop) setShowAnalytics(!isDesktop)
setShowIntercom(true) setShowIntercom(true)
dispatch(openCookieBanner({ cookieBannerOpen: false })) dispatch.current(openCookieBanner({ cookieBannerOpen: false }))
} }
const closeCookiesBannerHandler = async () => { const closeCookiesBannerHandler = async () => {
@ -159,7 +159,7 @@ const CookiesBanner = (): ReactElement => {
if (!localIntercom && isIntercomLoaded()) { if (!localIntercom && isIntercomLoaded()) {
closeIntercom() closeIntercom()
} }
dispatch(openCookieBanner({ cookieBannerOpen: false })) dispatch.current(openCookieBanner({ cookieBannerOpen: false }))
} }
if (showAnalytics && !isDesktop) { if (showAnalytics && !isDesktop) {
@ -254,7 +254,7 @@ const CookiesBanner = (): ReactElement => {
<img <img
className={classes.intercomImage} className={classes.intercomImage}
src={IntercomIcon} src={IntercomIcon}
onClick={() => dispatch(openCookieBanner({ cookieBannerOpen: true, intercomAlertDisplayed: true }))} onClick={() => dispatch.current(openCookieBanner({ cookieBannerOpen: true, intercomAlertDisplayed: true }))}
/> />
)} )}
{!isDesktop && showBanner?.cookieBannerOpen && ( {!isDesktop && showBanner?.cookieBannerOpen && (

View File

@ -22,7 +22,7 @@ import {
import { UPDATE_TRANSACTION_DETAILS } from 'src/logic/safe/store/actions/fetchTransactionDetails' import { UPDATE_TRANSACTION_DETAILS } from 'src/logic/safe/store/actions/fetchTransactionDetails'
import { AppReduxState } from 'src/store' import { AppReduxState } from 'src/store'
import { getUTCStartOfDate } from 'src/utils/date' import { getLocalStartOfDate } from 'src/utils/date'
import { sameString } from 'src/utils/strings' import { sameString } from 'src/utils/strings'
import { sortObject } from 'src/utils/objects' import { sortObject } from 'src/utils/objects'
@ -78,7 +78,7 @@ export const gatewayTransactions = handleActions<AppReduxState['gatewayTransacti
} }
if (isTransactionSummary(value)) { if (isTransactionSummary(value)) {
const startOfDate = getUTCStartOfDate(value.transaction.timestamp) const startOfDate = getLocalStartOfDate(value.transaction.timestamp)
if (typeof history[startOfDate] === 'undefined') { if (typeof history[startOfDate] === 'undefined') {
history[startOfDate] = [] history[startOfDate] = []
@ -326,6 +326,11 @@ export const gatewayTransactions = handleActions<AppReduxState['gatewayTransacti
} }
case 'queued.next': { case 'queued.next': {
queued.next[nonce] = queued.next[nonce].map((txToUpdate) => { queued.next[nonce] = queued.next[nonce].map((txToUpdate) => {
// prevent setting `PENDING_FAILED` status, if previous status wasn't `PENDING`
if (txStatus === 'PENDING_FAILED' && txToUpdate.txStatus !== 'PENDING') {
return txToUpdate
}
if (typeof id !== 'undefined') { if (typeof id !== 'undefined') {
if (sameString(txToUpdate.id, id)) { if (sameString(txToUpdate.id, id)) {
txToUpdate.txStatus = txStatus txToUpdate.txStatus = txStatus
@ -339,6 +344,11 @@ export const gatewayTransactions = handleActions<AppReduxState['gatewayTransacti
} }
case 'queued.queued': { case 'queued.queued': {
queued.queued[nonce] = queued.queued[nonce].map((txToUpdate) => { queued.queued[nonce] = queued.queued[nonce].map((txToUpdate) => {
// prevent setting `PENDING_FAILED` status, if previous status wasn't `PENDING`
if (txStatus === 'PENDING_FAILED' && txToUpdate.txStatus !== 'PENDING') {
return txToUpdate
}
if (typeof id !== 'undefined') { if (typeof id !== 'undefined') {
if (sameString(txToUpdate.id, id)) { if (sameString(txToUpdate.id, id)) {
txToUpdate.txStatus = txStatus txToUpdate.txStatus = txStatus

View File

@ -20,6 +20,7 @@ import { createTransaction } from 'src/logic/safe/store/actions/createTransactio
import { MULTI_SEND_ADDRESS } from 'src/logic/contracts/safeContracts' import { MULTI_SEND_ADDRESS } from 'src/logic/contracts/safeContracts'
import { DELEGATE_CALL, TX_NOTIFICATION_TYPES, CALL } from 'src/logic/safe/transactions' import { DELEGATE_CALL, TX_NOTIFICATION_TYPES, CALL } from 'src/logic/safe/transactions'
import { encodeMultiSendCall } from 'src/logic/safe/transactions/multisend' import { encodeMultiSendCall } from 'src/logic/safe/transactions/multisend'
import { web3ReadOnly } from 'src/logic/wallets/getWeb3'
import GasEstimationInfo from './GasEstimationInfo' import GasEstimationInfo from './GasEstimationInfo'
import { getNetworkInfo } from 'src/config' import { getNetworkInfo } from 'src/config'
@ -105,6 +106,10 @@ type OwnProps = {
const { nativeCoin } = getNetworkInfo() const { nativeCoin } = getNetworkInfo()
const parseTxValue = (value: string | number): string => {
return web3ReadOnly.utils.toBN(value).toString()
}
export const ConfirmTransactionModal = ({ export const ConfirmTransactionModal = ({
isOpen, isOpen,
app, app,
@ -122,7 +127,10 @@ export const ConfirmTransactionModal = ({
const txRecipient: string | undefined = useMemo(() => (txs.length > 1 ? MULTI_SEND_ADDRESS : txs[0]?.to), [txs]) const txRecipient: string | undefined = useMemo(() => (txs.length > 1 ? MULTI_SEND_ADDRESS : txs[0]?.to), [txs])
const txData: string | undefined = useMemo(() => (txs.length > 1 ? encodeMultiSendCall(txs) : txs[0]?.data), [txs]) const txData: string | undefined = useMemo(() => (txs.length > 1 ? encodeMultiSendCall(txs) : txs[0]?.data), [txs])
const txValue: string | undefined = useMemo(() => (txs.length > 1 ? '0' : txs[0]?.value), [txs]) const txValue: string | undefined = useMemo(
() => (txs.length > 1 ? '0' : txs[0]?.value && parseTxValue(txs[0]?.value)),
[txs],
)
const operation = useMemo(() => (txs.length > 1 ? DELEGATE_CALL : CALL), [txs]) const operation = useMemo(() => (txs.length > 1 ? DELEGATE_CALL : CALL), [txs])
const [manualSafeTxGas, setManualSafeTxGas] = useState(0) const [manualSafeTxGas, setManualSafeTxGas] = useState(0)
const [manualGasPrice, setManualGasPrice] = useState<string | undefined>() const [manualGasPrice, setManualGasPrice] = useState<string | undefined>()

View File

@ -86,13 +86,13 @@ export const RemoveModuleModal = ({ onClose, selectedModulePair }: RemoveModuleM
const removeSelectedModule = async (txParameters: TxParameters): Promise<void> => { const removeSelectedModule = async (txParameters: TxParameters): Promise<void> => {
try { try {
dispatch( await dispatch(
createTransaction({ createTransaction({
safeAddress, safeAddress,
to: safeAddress, to: safeAddress,
valueInWei: '0', valueInWei: '0',
txData, txData,
txNonce: txParameters.ethNonce, txNonce: txParameters.safeNonce,
safeTxGas: txParameters.safeTxGas ? Number(txParameters.safeTxGas) : undefined, safeTxGas: txParameters.safeTxGas ? Number(txParameters.safeTxGas) : undefined,
ethParameters: txParameters, ethParameters: txParameters,
notifiedTransaction: TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX, notifiedTransaction: TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX,

View File

@ -3,6 +3,7 @@ import MenuItem from '@material-ui/core/MenuItem'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close' import Close from '@material-ui/icons/Close'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import { List } from 'immutable' import { List } from 'immutable'
import Field from 'src/components/forms/Field' import Field from 'src/components/forms/Field'
@ -20,6 +21,8 @@ import { SafeOwner } from 'src/logic/safe/store/models/safe'
import { EstimationStatus, useEstimateTransactionGas } from 'src/logic/hooks/useEstimateTransactionGas' import { EstimationStatus, useEstimateTransactionGas } from 'src/logic/hooks/useEstimateTransactionGas'
import { TransactionFees } from 'src/components/TransactionsFees' import { TransactionFees } from 'src/components/TransactionsFees'
import { TxParametersDetail } from 'src/routes/safe/components/Transactions/helpers/TxParametersDetail' import { TxParametersDetail } from 'src/routes/safe/components/Transactions/helpers/TxParametersDetail'
import { createTransaction } from 'src/logic/safe/store/actions/createTransaction'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { styles } from './style' import { styles } from './style'
import { EditableTxParameters } from 'src/routes/safe/components/Transactions/helpers/EditableTxParameters' import { EditableTxParameters } from 'src/routes/safe/components/Transactions/helpers/EditableTxParameters'
@ -30,7 +33,6 @@ const THRESHOLD_FIELD_NAME = 'threshold'
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
type ChangeThresholdModalProps = { type ChangeThresholdModalProps = {
onChangeThreshold: (newThreshold: number) => void
onClose: () => void onClose: () => void
owners?: List<SafeOwner> owners?: List<SafeOwner>
safeAddress: string safeAddress: string
@ -38,13 +40,13 @@ type ChangeThresholdModalProps = {
} }
export const ChangeThresholdModal = ({ export const ChangeThresholdModal = ({
onChangeThreshold,
onClose, onClose,
owners, owners,
safeAddress, safeAddress,
threshold = 1, threshold = 1,
}: ChangeThresholdModalProps): React.ReactElement => { }: ChangeThresholdModalProps): React.ReactElement => {
const classes = useStyles() const classes = useStyles()
const dispatch = useDispatch()
const [data, setData] = useState('') const [data, setData] = useState('')
const [manualSafeTxGas, setManualSafeTxGas] = useState(0) const [manualSafeTxGas, setManualSafeTxGas] = useState(0)
const [manualGasPrice, setManualGasPrice] = useState<string | undefined>() const [manualGasPrice, setManualGasPrice] = useState<string | undefined>()
@ -83,11 +85,21 @@ export const ChangeThresholdModal = ({
const getParametersStatus = () => (threshold > 1 ? 'ETH_DISABLED' : 'ENABLED') const getParametersStatus = () => (threshold > 1 ? 'ETH_DISABLED' : 'ENABLED')
const handleSubmit = (values) => { const handleSubmit = async ({ txParameters }) => {
const newThreshold = values[THRESHOLD_FIELD_NAME] await dispatch(
createTransaction({
safeAddress,
to: safeAddress,
valueInWei: '0',
txData: data,
txNonce: txParameters.safeNonce,
safeTxGas: txParameters.safeTxGas ? Number(txParameters.safeTxGas) : undefined,
ethParameters: txParameters,
notifiedTransaction: TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX,
}),
)
onClose() onClose()
onChangeThreshold(newThreshold)
} }
const closeEditModalCallback = (txParameters: TxParameters) => { const closeEditModalCallback = (txParameters: TxParameters) => {
@ -123,7 +135,7 @@ export const ChangeThresholdModal = ({
</IconButton> </IconButton>
</Row> </Row>
<Hairline /> <Hairline />
<GnoForm initialValues={{ threshold: threshold.toString() }} onSubmit={handleSubmit}> <GnoForm initialValues={{ threshold: threshold.toString(), txParameters }} onSubmit={handleSubmit}>
{() => ( {() => (
<> <>
<Block className={classes.modalContent}> <Block className={classes.modalContent}>

View File

@ -1,6 +1,6 @@
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import Modal from 'src/components/Modal' import Modal from 'src/components/Modal'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
@ -9,18 +9,13 @@ import Button from 'src/components/layout/Button'
import Heading from 'src/components/layout/Heading' import Heading from 'src/components/layout/Heading'
import Paragraph from 'src/components/layout/Paragraph' import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { grantedSelector } from 'src/routes/safe/container/selector' import { grantedSelector } from 'src/routes/safe/container/selector'
import { createTransaction } from 'src/logic/safe/store/actions/createTransaction'
import { import {
safeOwnersSelector, safeOwnersSelector,
safeParamAddressFromStateSelector, safeParamAddressFromStateSelector,
safeThresholdSelector, safeThresholdSelector,
} from 'src/logic/safe/store/selectors' } from 'src/logic/safe/store/selectors'
import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics' import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics'
import { useTransactionParameters } from 'src/routes/safe/container/hooks/useTransactionParameters'
import { EditTxParametersForm } from 'src/routes/safe/components/Transactions/helpers/EditTxParametersForm'
import { ChangeThresholdModal } from './ChangeThreshold' import { ChangeThresholdModal } from './ChangeThreshold'
import { styles } from './style' import { styles } from './style'
@ -30,46 +25,21 @@ const useStyles = makeStyles(styles)
const ThresholdSettings = (): React.ReactElement => { const ThresholdSettings = (): React.ReactElement => {
const classes = useStyles() const classes = useStyles()
const [isModalOpen, setModalOpen] = useState(false) const [isModalOpen, setModalOpen] = useState(false)
const dispatch = useDispatch()
const threshold = useSelector(safeThresholdSelector) || 1 const threshold = useSelector(safeThresholdSelector) || 1
const safeAddress = useSelector(safeParamAddressFromStateSelector) const safeAddress = useSelector(safeParamAddressFromStateSelector)
const owners = useSelector(safeOwnersSelector) const owners = useSelector(safeOwnersSelector)
const granted = useSelector(grantedSelector) const granted = useSelector(grantedSelector)
const txParameters = useTransactionParameters()
const [activeScreen, setActiveScreen] = useState('form')
const toggleModal = () => { const toggleModal = () => {
setModalOpen((prevOpen) => !prevOpen) setModalOpen((prevOpen) => !prevOpen)
} }
const onChangeThreshold = async (newThreshold: number) => {
const safeInstance = await getGnosisSafeInstanceAt(safeAddress)
const txData = safeInstance.methods.changeThreshold(newThreshold).encodeABI()
dispatch(
createTransaction({
safeAddress,
to: safeAddress,
valueInWei: '0',
txData,
txNonce: txParameters.safeNonce,
safeTxGas: txParameters.safeTxGas ? Number(txParameters.safeTxGas) : undefined,
ethParameters: txParameters,
notifiedTransaction: TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX,
}),
)
}
const { trackEvent } = useAnalytics() const { trackEvent } = useAnalytics()
useEffect(() => { useEffect(() => {
trackEvent({ category: SAFE_NAVIGATION_EVENT, action: 'Settings', label: 'Owners' }) trackEvent({ category: SAFE_NAVIGATION_EVENT, action: 'Settings', label: 'Owners' })
}, [trackEvent]) }, [trackEvent])
const closeEditTxParameters = () => setActiveScreen('form')
const getParametersStatus = () => (threshold > 1 ? 'ETH_DISABLED' : 'ENABLED')
return ( return (
<> <>
<Block className={classes.container}> <Block className={classes.container}>
@ -98,22 +68,7 @@ const ThresholdSettings = (): React.ReactElement => {
open={isModalOpen} open={isModalOpen}
title="Change Required Confirmations" title="Change Required Confirmations"
> >
{activeScreen === 'form' && ( <ChangeThresholdModal onClose={toggleModal} owners={owners} safeAddress={safeAddress} threshold={threshold} />
<ChangeThresholdModal
onChangeThreshold={onChangeThreshold}
onClose={toggleModal}
owners={owners}
safeAddress={safeAddress}
threshold={threshold}
/>
)}
{activeScreen === 'editTxParameters' && (
<EditTxParametersForm
txParameters={txParameters}
onClose={closeEditTxParameters}
parametersStatus={getParametersStatus()}
/>
)}
</Modal> </Modal>
</> </>
) )

View File

@ -1,5 +1,4 @@
import { Loader } from '@gnosis.pm/safe-react-components' import { Loader } from '@gnosis.pm/safe-react-components'
import { format } from 'date-fns'
import React, { ReactElement } from 'react' import React, { ReactElement } from 'react'
import { InfiniteScroll, SCROLLABLE_TARGET_ID } from 'src/components/InfiniteScroll' import { InfiniteScroll, SCROLLABLE_TARGET_ID } from 'src/components/InfiniteScroll'
@ -13,6 +12,7 @@ import {
} from './styled' } from './styled'
import { TxHistoryRow } from './TxHistoryRow' import { TxHistoryRow } from './TxHistoryRow'
import { TxLocationContext } from './TxLocationProvider' import { TxLocationContext } from './TxLocationProvider'
import { formatWithSchema } from 'src/utils/date'
export const HistoryTxList = (): ReactElement => { export const HistoryTxList = (): ReactElement => {
const { count, hasMore, next, transactions } = usePagedHistoryTransactions() const { count, hasMore, next, transactions } = usePagedHistoryTransactions()
@ -31,7 +31,7 @@ export const HistoryTxList = (): ReactElement => {
<InfiniteScroll dataLength={transactions.length} next={next} hasMore={hasMore}> <InfiniteScroll dataLength={transactions.length} next={next} hasMore={hasMore}>
{transactions?.map(([timestamp, txs]) => ( {transactions?.map(([timestamp, txs]) => (
<StyledTransactionsGroup key={timestamp}> <StyledTransactionsGroup key={timestamp}>
<SubTitle size="lg">{format(Number(timestamp), 'MMM d, yyyy')}</SubTitle> <SubTitle size="lg">{formatWithSchema(Number(timestamp), 'MMM d, yyyy')}</SubTitle>
<StyledTransactions> <StyledTransactions>
{txs.map((transaction) => ( {txs.map((transaction) => (
<TxHistoryRow key={transaction.id} transaction={transaction} /> <TxHistoryRow key={transaction.id} transaction={transaction} />

View File

@ -4,18 +4,8 @@ import styled from 'styled-components'
import { DataDecoded } from 'src/logic/safe/store/models/types/gateway.d' import { DataDecoded } from 'src/logic/safe/store/models/types/gateway.d'
import { isArrayParameter } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/utils' import { isArrayParameter } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/utils'
import {
DeleteSpendingLimitDetails,
isDeleteAllowance,
isSetAllowance,
ModifySpendingLimitDetails,
} from 'src/routes/safe/components/Transactions/GatewayTransactions/SpendingLimitDetails'
import Value from 'src/routes/safe/components/Transactions/TxsTable/ExpandedTx/TxDescription/Value' import Value from 'src/routes/safe/components/Transactions/TxsTable/ExpandedTx/TxDescription/Value'
const TxDetailsMethodName = styled(Text)`
text-indent: 4px;
`
const TxDetailsMethodParam = styled.div<{ isArrayParameter: boolean }>` const TxDetailsMethodParam = styled.div<{ isArrayParameter: boolean }>`
padding-left: 24px; padding-left: 24px;
display: ${({ isArrayParameter }) => (isArrayParameter ? 'block' : 'flex')}; display: ${({ isArrayParameter }) => (isArrayParameter ? 'block' : 'flex')};
@ -27,7 +17,7 @@ const TxDetailsMethodParam = styled.div<{ isArrayParameter: boolean }>`
` `
const TxInfo = styled.div` const TxInfo = styled.div`
padding: 8px 8px 8px 16px; padding: 8px 0;
` `
const StyledMethodName = styled(Text)` const StyledMethodName = styled(Text)`
@ -35,21 +25,11 @@ const StyledMethodName = styled(Text)`
` `
export const MethodDetails = ({ data }: { data: DataDecoded }): React.ReactElement => { export const MethodDetails = ({ data }: { data: DataDecoded }): React.ReactElement => {
// FixMe: this way won't scale well
if (isSetAllowance(data.method)) {
return <ModifySpendingLimitDetails data={data} />
}
// FixMe: this way won't scale well
if (isDeleteAllowance(data.method)) {
return <DeleteSpendingLimitDetails data={data} />
}
return ( return (
<TxInfo> <TxInfo>
<TxDetailsMethodName size="xl" strong> <Text size="xl" strong>
{data.method} {data.method}
</TxDetailsMethodName> </Text>
{data.parameters?.map((param, index) => ( {data.parameters?.map((param, index) => (
<TxDetailsMethodParam key={`${data.method}_param-${index}`} isArrayParameter={isArrayParameter(param.type)}> <TxDetailsMethodParam key={`${data.method}_param-${index}`} isArrayParameter={isArrayParameter(param.type)}>

View File

@ -60,21 +60,21 @@ export const MultiSendDetails = ({ txData }: { txData: TransactionData }): React
const title = `Send ${amount} ${nativeCoin.name} to:` const title = `Send ${amount} ${nativeCoin.name} to:`
if (dataDecoded) { if (dataDecoded) {
// Backend decoded data
details = <MethodDetails data={dataDecoded} /> details = <MethodDetails data={dataDecoded} />
} else { } else {
// We couldn't decode it but we have data
details = data && <HexEncodedData hexData={data} /> details = data && <HexEncodedData hexData={data} />
} }
return ( return (
details && ( <MultiSendTxGroup
<MultiSendTxGroup key={`${data ?? to}-${index}`}
key={`${data ?? to}-${index}`} actionTitle={actionTitle}
actionTitle={actionTitle} txDetails={{ title, address: to, dataDecoded }}
txDetails={{ title, address: to, dataDecoded }} >
> {details}
{details} </MultiSendTxGroup>
</MultiSendTxGroup>
)
) )
})} })}
</> </>

View File

@ -11,7 +11,7 @@ export const OwnerRow = ({ ownerAddress }: { ownerAddress: string }): ReactEleme
return ( return (
<EthHashInfo <EthHashInfo
hash={ownerAddress} hash={ownerAddress}
name={ownerName} name={ownerName === 'UNKNOWN' ? '' : ownerName}
showIdenticon showIdenticon
showCopyBtn showCopyBtn
explorerUrl={getExplorerInfo(ownerAddress)} explorerUrl={getExplorerInfo(ownerAddress)}

View File

@ -3,7 +3,8 @@ import { ThemeColors } from '@gnosis.pm/safe-react-components/dist/theme'
import { Tooltip } from '@material-ui/core' import { Tooltip } from '@material-ui/core'
import CircularProgress from '@material-ui/core/CircularProgress' import CircularProgress from '@material-ui/core/CircularProgress'
import { createStyles, makeStyles } from '@material-ui/core/styles' import { createStyles, makeStyles } from '@material-ui/core/styles'
import React, { ReactElement, useRef } from 'react' import React, { ReactElement, useContext, useRef } from 'react'
import styled from 'styled-components'
import CustomIconText from 'src/components/CustomIconText' import CustomIconText from 'src/components/CustomIconText'
import { import {
@ -13,15 +14,16 @@ import {
Transaction, Transaction,
} from 'src/logic/safe/store/models/types/gateway.d' } from 'src/logic/safe/store/models/types/gateway.d'
import { TxCollapsedActions } from 'src/routes/safe/components/Transactions/GatewayTransactions/TxCollapsedActions' import { TxCollapsedActions } from 'src/routes/safe/components/Transactions/GatewayTransactions/TxCollapsedActions'
import { formatDateTime, formatTime } from 'src/routes/safe/components/Transactions/GatewayTransactions/utils' import { formatDateTime, formatTime, formatTimeInWords } from 'src/utils/date'
import { KNOWN_MODULES } from 'src/utils/constants' import { KNOWN_MODULES } from 'src/utils/constants'
import styled from 'styled-components' import { sameString } from 'src/utils/strings'
import { AssetInfo, isTokenTransferAsset } from './hooks/useAssetInfo' import { AssetInfo, isTokenTransferAsset } from './hooks/useAssetInfo'
import { TransactionActions } from './hooks/useTransactionActions' import { TransactionActions } from './hooks/useTransactionActions'
import { TransactionStatusProps } from './hooks/useTransactionStatus' import { TransactionStatusProps } from './hooks/useTransactionStatus'
import { TxTypeProps } from './hooks/useTransactionType' import { TxTypeProps } from './hooks/useTransactionType'
import { StyledGroupedTransactions, StyledTransaction } from './styled' import { StyledGroupedTransactions, StyledTransaction } from './styled'
import { TokenTransferAmount } from './TokenTransferAmount' import { TokenTransferAmount } from './TokenTransferAmount'
import { TxLocationContext } from './TxLocationProvider'
import { CalculatedVotes } from './TxQueueCollapsed' import { CalculatedVotes } from './TxQueueCollapsed'
const TxInfo = ({ info }: { info: AssetInfo }) => { const TxInfo = ({ info }: { info: AssetInfo }) => {
@ -126,6 +128,8 @@ export const TxCollapsed = ({
actions, actions,
status, status,
}: TxCollapsedProps): ReactElement => { }: TxCollapsedProps): ReactElement => {
const { txLocation } = useContext(TxLocationContext)
const willBeReplaced = transaction?.txStatus === 'WILL_BE_REPLACED' ? ' will-be-replaced' : '' const willBeReplaced = transaction?.txStatus === 'WILL_BE_REPLACED' ? ' will-be-replaced' : ''
const txCollapsedNonce = ( const txCollapsedNonce = (
@ -149,7 +153,7 @@ export const TxCollapsed = ({
<div className={'tx-time' + willBeReplaced}> <div className={'tx-time' + willBeReplaced}>
<Tooltip classes={tooltipStyles} title={formatDateTime(time)} arrow> <Tooltip classes={tooltipStyles} title={formatDateTime(time)} arrow>
<TooltipContent ref={timestamp}> <TooltipContent ref={timestamp}>
<Text size="xl">{formatTime(time)}</Text> <Text size="xl">{txLocation === 'history' ? formatTime(time) : formatTimeInWords(time)}</Text>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
@ -203,7 +207,7 @@ export const TxCollapsed = ({
{txCollapsedStatus} {txCollapsedStatus}
</StyledGroupedTransactions> </StyledGroupedTransactions>
) : ( ) : (
<StyledTransaction> <StyledTransaction className={sameString(status.text, 'Failed') ? 'failed-transaction' : ''}>
{txCollapsedNonce} {txCollapsedNonce}
{txCollapsedType} {txCollapsedType}
{txCollapsedInfo} {txCollapsedInfo}

View File

@ -1,11 +1,26 @@
import { Icon } from '@gnosis.pm/safe-react-components' import { Icon, theme } from '@gnosis.pm/safe-react-components'
import { Tooltip as TooltipMui } from '@material-ui/core'
import { default as MuiIconButton } from '@material-ui/core/IconButton' import { default as MuiIconButton } from '@material-ui/core/IconButton'
import { withStyles } from '@material-ui/core/styles'
import React, { ReactElement } from 'react' import React, { ReactElement } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import { Transaction } from 'src/logic/safe/store/models/types/gateway.d' import { Transaction } from 'src/logic/safe/store/models/types/gateway.d'
import { useActionButtonsHandlers } from './hooks/useActionButtonsHandlers' import { useActionButtonsHandlers } from './hooks/useActionButtonsHandlers'
const Tooltip = withStyles(() => ({
popper: {
zIndex: 2001,
},
tooltip: {
marginBottom: '4px',
backgroundColor: theme.colors.overlay.color,
border: `1px solid ${theme.colors.icon}`,
boxShadow: `1px 2px 4px ${theme.colors.shadow.color}14`,
color: theme.colors.text,
},
}))(TooltipMui)
const IconButton = styled(MuiIconButton)` const IconButton = styled(MuiIconButton)`
padding: 8px !important; padding: 8px !important;
@ -32,26 +47,25 @@ export const TxCollapsedActions = ({ transaction }: TxCollapsedActionsProps): Re
return ( return (
<> <>
{ {
<IconButton <Tooltip title={transaction.txStatus === 'AWAITING_EXECUTION' ? 'Execute' : 'Confirm'} placement="top">
size="small" <IconButton
type="button" size="small"
onClick={handleConfirmButtonClick} type="button"
disabled={disabledActions} onClick={handleConfirmButtonClick}
onMouseEnter={handleOnMouseEnter} disabled={disabledActions}
onMouseLeave={handleOnMouseLeave} onMouseEnter={handleOnMouseEnter}
> onMouseLeave={handleOnMouseLeave}
<Icon >
type={transaction.txStatus === 'AWAITING_EXECUTION' ? 'rocket' : 'check'} <Icon type={transaction.txStatus === 'AWAITING_EXECUTION' ? 'rocket' : 'check'} color="primary" size="sm" />
color="primary" </IconButton>
size="sm" </Tooltip>
tooltip={transaction.txStatus === 'AWAITING_EXECUTION' ? 'Execute' : 'Confirm'}
/>
</IconButton>
} }
{canCancel && ( {canCancel && (
<IconButton size="small" type="button" onClick={handleCancelButtonClick} disabled={isPending}> <Tooltip title="Cancel" placement="top">
<Icon type="circleCross" color="error" size="sm" tooltip="Cancel" /> <IconButton size="small" type="button" onClick={handleCancelButtonClick} disabled={isPending}>
</IconButton> <Icon type="circleCross" color="error" size="sm" />
</IconButton>
</Tooltip>
)} )}
</> </>
) )

View File

@ -1,11 +1,37 @@
import React, { ReactElement } from 'react' import React, { ReactElement, ReactNode } from 'react'
import { ExpandedTxDetails } from 'src/logic/safe/store/models/types/gateway.d' import { getNetworkInfo } from 'src/config'
import { ExpandedTxDetails, TransactionData } from 'src/logic/safe/store/models/types/gateway.d'
import { fromTokenUnit } from 'src/logic/tokens/utils/humanReadableValue'
import {
DeleteSpendingLimitDetails,
isDeleteAllowance,
isSetAllowance,
ModifySpendingLimitDetails,
} from './SpendingLimitDetails'
import { TxInfoDetails } from './TxInfoDetails'
import { sameString } from 'src/utils/strings' import { sameString } from 'src/utils/strings'
import { HexEncodedData } from './HexEncodedData' import { HexEncodedData } from './HexEncodedData'
import { MethodDetails } from './MethodDetails' import { MethodDetails } from './MethodDetails'
import { MultiSendDetails } from './MultiSendDetails' import { MultiSendDetails } from './MultiSendDetails'
const { nativeCoin } = getNetworkInfo()
type DetailsWithTxInfoProps = {
children: ReactNode
txData: TransactionData
}
const DetailsWithTxInfo = ({ children, txData }: DetailsWithTxInfoProps): ReactElement => (
<>
<TxInfoDetails
address={txData.to}
title={`Send ${txData.value ? fromTokenUnit(txData.value, nativeCoin.decimals) : 'n/a'} ${nativeCoin.symbol} to:`}
/>
{children}
</>
)
type TxDataProps = { type TxDataProps = {
txData: ExpandedTxDetails['txData'] txData: ExpandedTxDetails['txData']
} }
@ -24,7 +50,11 @@ export const TxData = ({ txData }: TxDataProps): ReactElement | null => {
} }
// we render the hex encoded data // we render the hex encoded data
return <HexEncodedData hexData={txData.hexData} /> return (
<DetailsWithTxInfo txData={txData}>
<HexEncodedData hexData={txData.hexData} />
</DetailsWithTxInfo>
)
} }
// known data and particularly `multiSend` data type // known data and particularly `multiSend` data type
@ -32,6 +62,20 @@ export const TxData = ({ txData }: TxDataProps): ReactElement | null => {
return <MultiSendDetails txData={txData} /> return <MultiSendDetails txData={txData} />
} }
// FixMe: this way won't scale well
if (isSetAllowance(txData.dataDecoded.method)) {
return <ModifySpendingLimitDetails data={txData.dataDecoded} />
}
// FixMe: this way won't scale well
if (isDeleteAllowance(txData.dataDecoded.method)) {
return <DeleteSpendingLimitDetails data={txData.dataDecoded} />
}
// we render the decoded data // we render the decoded data
return <MethodDetails data={txData.dataDecoded} /> return (
<DetailsWithTxInfo txData={txData}>
<MethodDetails data={txData.dataDecoded} />
</DetailsWithTxInfo>
)
} }

View File

@ -2,8 +2,9 @@ import { Text } from '@gnosis.pm/safe-react-components'
import React, { ReactElement } from 'react' import React, { ReactElement } from 'react'
import { getExplorerInfo } from 'src/config' import { getExplorerInfo } from 'src/config'
import { formatDateTime } from 'src/utils/date'
import { Creation, Transaction } from 'src/logic/safe/store/models/types/gateway.d' import { Creation, Transaction } from 'src/logic/safe/store/models/types/gateway.d'
import { formatDateTime, NOT_AVAILABLE } from './utils' import { NOT_AVAILABLE } from './utils'
import { InlineEthHashInfo, TxDetailsContainer } from './styled' import { InlineEthHashInfo, TxDetailsContainer } from './styled'
export const TxInfoCreation = ({ transaction }: { transaction: Transaction }): ReactElement | null => { export const TxInfoCreation = ({ transaction }: { transaction: Transaction }): ReactElement | null => {

View File

@ -30,7 +30,15 @@ export const TxInfoDetails = ({ title, address, isTransferType, txInfo }: TxInfo
const knownAddress = recipientName !== 'UNKNOWN' const knownAddress = recipientName !== 'UNKNOWN'
const { txLocation } = useContext<TxLocationProps>(TxLocationContext) const { txLocation } = useContext<TxLocationProps>(TxLocationContext)
const canRepeatTransaction = isTransferType && txLocation === 'history' && txInfo?.direction === 'OUTGOING' const canRepeatTransaction =
// is transfer type by context
isTransferType &&
// not a Collectible
txInfo?.transferInfo.type !== 'ERC721' &&
// in history list
txLocation === 'history' &&
// it's outgoing
txInfo?.direction === 'OUTGOING'
const [sendModalOpen, setSendModalOpen] = useState(false) const [sendModalOpen, setSendModalOpen] = useState(false)
const sendModalOpenHandler = () => { const sendModalOpenHandler = () => {

View File

@ -2,9 +2,10 @@ import { Text } from '@gnosis.pm/safe-react-components'
import React, { ReactElement } from 'react' import React, { ReactElement } from 'react'
import { getExplorerInfo } from 'src/config' import { getExplorerInfo } from 'src/config'
import { formatDateTime } from 'src/utils/date'
import { ExpandedTxDetails, isMultiSigExecutionDetails, Operation } from 'src/logic/safe/store/models/types/gateway.d' import { ExpandedTxDetails, isMultiSigExecutionDetails, Operation } from 'src/logic/safe/store/models/types/gateway.d'
import { InlineEthHashInfo } from './styled' import { InlineEthHashInfo } from './styled'
import { formatDateTime, NOT_AVAILABLE } from './utils' import { NOT_AVAILABLE } from './utils'
export const TxSummary = ({ txDetails }: { txDetails: ExpandedTxDetails }): ReactElement => { export const TxSummary = ({ txDetails }: { txDetails: ExpandedTxDetails }): ReactElement => {
const { txHash, detailedExecutionInfo, executedAt, txData } = txDetails const { txHash, detailedExecutionInfo, executedAt, txData } = txDetails

View File

@ -62,7 +62,7 @@ export const useActionButtonsHandlers = (transaction: Transaction): ActionButton
const isPending = useMemo(() => !!transaction.txStatus.match(/^PENDING.*/), [transaction.txStatus]) const isPending = useMemo(() => !!transaction.txStatus.match(/^PENDING.*/), [transaction.txStatus])
const signaturePending = useCallback(addressInList(transaction.executionInfo?.missingSigners), []) const signaturePending = addressInList(transaction.executionInfo?.missingSigners)
const disabledActions = useMemo( const disabledActions = useMemo(
() => () =>

View File

@ -380,16 +380,20 @@ export const ApproveTxModal = ({
/> />
)} )}
</Row> </Row>
<TransactionFees
gasCostFormatted={gasCostFormatted}
isExecution={isExecution}
isCreation={isCreation}
isOffChainSignature={isOffChainSignature}
txEstimationExecutionStatus={txEstimationExecutionStatus}
/>
</Block> </Block>
{txEstimationExecutionStatus === EstimationStatus.LOADING ? null : (
<Block className={classes.gasCostsContainer}>
<TransactionFees
gasCostFormatted={gasCostFormatted}
isExecution={isExecution}
isCreation={isCreation}
isOffChainSignature={isOffChainSignature}
txEstimationExecutionStatus={txEstimationExecutionStatus}
/>
</Block>
)}
{/* Footer */} {/* Footer */}
<Row align="center" className={classes.buttonRow}> <Row align="center" className={classes.buttonRow}>
<Button minHeight={42} minWidth={140} onClick={onClose}> <Button minHeight={42} minWidth={140} onClick={onClose}>

View File

@ -117,12 +117,14 @@ export const RejectTxModal = ({ isOpen, onClose, gwTransaction }: Props): React.
<TxParametersDetail <TxParametersDetail
txParameters={txParameters} txParameters={txParameters}
onEdit={toggleEditMode} onEdit={toggleEditMode}
compact={false}
parametersStatus={getParametersStatus()} parametersStatus={getParametersStatus()}
isTransactionCreation={isCreation} isTransactionCreation={isCreation}
isTransactionExecution={isExecution} isTransactionExecution={isExecution}
/> />
<Row> </Block>
{txEstimationExecutionStatus === EstimationStatus.LOADING ? null : (
<Block className={classes.gasCostsContainer}>
<TransactionFees <TransactionFees
gasCostFormatted={gasCostFormatted} gasCostFormatted={gasCostFormatted}
isExecution={isExecution} isExecution={isExecution}
@ -130,8 +132,8 @@ export const RejectTxModal = ({ isOpen, onClose, gwTransaction }: Props): React.
isOffChainSignature={isOffChainSignature} isOffChainSignature={isOffChainSignature}
txEstimationExecutionStatus={txEstimationExecutionStatus} txEstimationExecutionStatus={txEstimationExecutionStatus}
/> />
</Row> </Block>
</Block> )}
<Row align="center" className={classes.buttonRow}> <Row align="center" className={classes.buttonRow}>
<Button minHeight={42} minWidth={140} onClick={onClose}> <Button minHeight={42} minWidth={140} onClick={onClose}>
Exit Exit

View File

@ -1,5 +1,5 @@
import { createStyles } from '@material-ui/core' import { createStyles } from '@material-ui/core'
import { border, lg, md, sm } from 'src/theme/variables' import { background, border, lg, md, sm } from 'src/theme/variables'
export const styles = createStyles({ export const styles = createStyles({
heading: { heading: {
@ -30,4 +30,8 @@ export const styles = createStyles({
marginTop: sm, marginTop: sm,
fontSize: md, fontSize: md,
}, },
gasCostsContainer: {
backgroundColor: background,
padding: `0 ${lg}`,
},
}) })

View File

@ -6,7 +6,7 @@ import {
EthHashInfo, EthHashInfo,
IconText, IconText,
} from '@gnosis.pm/safe-react-components' } from '@gnosis.pm/safe-react-components'
import styled from 'styled-components' import styled, { css } from 'styled-components'
export const Wrapper = styled.div` export const Wrapper = styled.div`
display: flex; display: flex;
@ -139,16 +139,16 @@ export const GroupedTransactionsCard = styled(StyledTransactions)`
} }
` `
const gridColumns = { const gridColumns = {
nonce: '0.75fr', nonce: '0.5fr',
type: '3fr', type: '3fr',
info: '3fr', info: '3fr',
time: '1.5fr', time: '2.5fr',
votes: '1.5fr', votes: '1.5fr',
actions: '1fr', actions: '1fr',
status: '3fr', status: '2.5fr',
} }
export const WillBeReplaced = styled.div` const willBeReplaced = css`
.will-be-replaced * { .will-be-replaced * {
color: gray !important; color: gray !important;
text-decoration: line-through !important; text-decoration: line-through !important;
@ -156,7 +156,18 @@ export const WillBeReplaced = styled.div`
} }
` `
export const StyledTransaction = styled(WillBeReplaced)` const failedTransaction = css`
&.failed-transaction {
div[class^='tx-']:not(.tx-status):not(.tx-nonce) {
opacity: 0.5;
}
}
`
export const StyledTransaction = styled.div`
${willBeReplaced};
${failedTransaction};
display: grid; display: grid;
grid-template-columns: ${Object.values(gridColumns).join(' ')}; grid-template-columns: ${Object.values(gridColumns).join(' ')};
width: 100%; width: 100%;
@ -165,6 +176,10 @@ export const StyledTransaction = styled(WillBeReplaced)`
align-self: center; align-self: center;
} }
.tx-votes {
justify-self: center;
}
.tx-actions { .tx-actions {
visibility: hidden; visibility: hidden;
justify-self: end; justify-self: end;
@ -208,7 +223,7 @@ export const GroupedTransactions = styled(StyledTransaction)`
// builds the tree-view layout // builds the tree-view layout
.tree-lines { .tree-lines {
height: 100%; height: 100%;
margin-left: 50%; margin-left: 30px;
position: relative; position: relative;
width: 30%; width: 30%;
@ -286,8 +301,8 @@ export const DisclaimerContainer = styled(StyledTransaction)`
background-color: ${({ theme }) => theme.colors.inputField} !important; background-color: ${({ theme }) => theme.colors.inputField} !important;
border-radius: 4px; border-radius: 4px;
margin: 12px 8px 0 12px; margin: 12px 8px 0 12px;
padding: 8px; padding: 8px 12px;
width: calc(100% - 40px); width: calc(100% - 48px);
.nonce { .nonce {
grid-column-start: 1; grid-column-start: 1;
@ -299,13 +314,15 @@ export const DisclaimerContainer = styled(StyledTransaction)`
} }
` `
export const TxDetailsContainer = styled(WillBeReplaced)` export const TxDetailsContainer = styled.div`
${willBeReplaced};
background-color: ${({ theme }) => theme.colors.separator} !important; background-color: ${({ theme }) => theme.colors.separator} !important;
column-gap: 2px; column-gap: 2px;
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-auto-rows: minmax(min-content, max-content); grid-auto-rows: minmax(min-content, max-content);
grid-template-rows: [tx-summary] minmax(min-content, max-content) [tx-details] minmax(100px, 1fr); grid-template-rows: [tx-summary] minmax(min-content, max-content) [tx-details] minmax(min-content, 1fr);
row-gap: 2px; row-gap: 2px;
width: 100%; width: 100%;

View File

@ -1,5 +1,4 @@
import { BigNumber } from 'bignumber.js' import { BigNumber } from 'bignumber.js'
import format from 'date-fns/format'
import { getNetworkInfo } from 'src/config' import { getNetworkInfo } from 'src/config'
import { import {
@ -25,10 +24,6 @@ export const TX_TABLE_RAW_TX_ID = 'tx'
export const TX_TABLE_RAW_CANCEL_TX_ID = 'cancelTx' export const TX_TABLE_RAW_CANCEL_TX_ID = 'cancelTx'
export const TX_TABLE_EXPAND_ICON = 'expand' export const TX_TABLE_EXPAND_ICON = 'expand'
export const formatTime = (timestamp: number): string => format(timestamp, 'h:mm a')
export const formatDateTime = (timestamp: number): string => format(timestamp, 'MMM d, yyyy - h:mm:ss a')
export const NOT_AVAILABLE = 'n/a' export const NOT_AVAILABLE = 'n/a'
interface AmountData { interface AmountData {

View File

@ -203,7 +203,7 @@ export const EditTxParametersForm = ({
</EthereumOptions> </EthereumOptions>
<StyledLink <StyledLink
href="https://docs.gnosis.io/safe/docs/contracts_tx_execution/#safe-transaction-gas-limit-estimation" href="https://help.gnosis-safe.io/en/articles/4738445-configure-advanced-transaction-parameters-manually"
target="_blank" target="_blank"
> >
<Text size="xl" color="primary"> <Text size="xl" color="primary">

View File

@ -1,4 +1,6 @@
import { formatRelative } from 'date-fns' import format from 'date-fns/format'
import formatRelative from 'date-fns/formatRelative'
import formatDistanceToNow from 'date-fns/formatDistanceToNow'
export const relativeTime = (baseTimeMin: string, resetTimeMin: string): string => { export const relativeTime = (baseTimeMin: string, resetTimeMin: string): string => {
if (resetTimeMin === '0') { if (resetTimeMin === '0') {
@ -17,3 +19,17 @@ export const getUTCStartOfDate = (timestamp: number): number => {
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0) return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0)
} }
export const getLocalStartOfDate = (timestamp: number): number => {
const date = new Date(timestamp)
return date.setHours(0, 0, 0, 0)
}
export const formatWithSchema = (timestamp: number, schema: string): string => format(timestamp, schema)
export const formatTime = (timestamp: number): string => formatWithSchema(timestamp, 'h:mm a')
export const formatDateTime = (timestamp: number): string => formatWithSchema(timestamp, 'MMM d, yyyy - h:mm:ss a')
export const formatTimeInWords = (timestamp: number): string => formatDistanceToNow(timestamp, { addSuffix: true })