From 5c8f81e92d59b38b83c21739fba0ef7c16505e82 Mon Sep 17 00:00:00 2001 From: apX13_ Date: Mon, 22 Jul 2024 15:38:30 +0530 Subject: [PATCH] feat: quick freeze/unfreeze UTXOs on send page (#771) --- public/sprite.svg | 6 + src/components/Balance.tsx | 19 +- src/components/Modal.tsx | 23 +- src/components/Send/AmountInputField.tsx | 21 +- src/components/Send/SendForm.tsx | 8 +- src/components/Send/ShowUtxos.module.css | 88 ++++ src/components/Send/ShowUtxos.tsx | 381 ++++++++++++++++++ .../Send/SourceJarSelector.module.css | 1 + src/components/Send/SourceJarSelector.tsx | 155 ++++++- src/components/Send/index.tsx | 15 +- src/components/jar_details/UtxoList.tsx | 4 +- src/components/jars/Jar.module.css | 4 + src/components/jars/Jar.tsx | 19 +- src/context/SettingsContext.tsx | 2 +- src/context/WalletContext.tsx | 4 + src/i18n/locales/en/translation.json | 10 + 16 files changed, 720 insertions(+), 40 deletions(-) create mode 100644 src/components/Send/ShowUtxos.module.css create mode 100755 src/components/Send/ShowUtxos.tsx diff --git a/public/sprite.svg b/public/sprite.svg index 67f0b222f..c520136c1 100644 --- a/public/sprite.svg +++ b/public/sprite.svg @@ -361,4 +361,10 @@ + + + + + + diff --git a/src/components/Balance.tsx b/src/components/Balance.tsx index 880d0f67e..645ee00a0 100644 --- a/src/components/Balance.tsx +++ b/src/components/Balance.tsx @@ -39,23 +39,28 @@ interface BalanceComponentProps { symbol?: JSX.Element showSymbol?: boolean frozen?: boolean + isColorChange?: boolean + frozenSymbol?: boolean } const BalanceComponent = ({ symbol, showSymbol = true, frozen = false, + isColorChange = false, + frozenSymbol = true, children, }: PropsWithChildren) => { return ( {children} {showSymbol && symbol} - {frozen && FROZEN_SYMBOL} + {frozen && frozenSymbol && FROZEN_SYMBOL} ) } @@ -75,7 +80,9 @@ const BitcoinBalance = ({ value, ...props }: BitcoinBalanceProps) => { return ( & { value: number const SatsBalance = ({ value, ...props }: SatsBalanceProps) => { return ( - + {formatSats(value)} diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 71dd98de8..40e7c4b36 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -10,6 +10,9 @@ type BaseModalProps = { onCancel: () => void backdrop?: rb.ModalProps['backdrop'] size?: rb.ModalProps['size'] + showCloseButton?: boolean + headerClassName?: string + titleClassName?: string } const BaseModal = ({ isShown, @@ -18,6 +21,9 @@ const BaseModal = ({ onCancel, size, backdrop = 'static', + showCloseButton = false, + headerClassName, + titleClassName, }: PropsWithChildren) => { return ( - - {title} + + {title} {children} @@ -65,9 +71,18 @@ const InfoModal = ({ export type ConfirmModalProps = BaseModalProps & { onConfirm: () => void + disabled?: boolean + confirmVariant?: string } -const ConfirmModal = ({ children, onCancel, onConfirm, ...baseModalProps }: PropsWithChildren) => { +const ConfirmModal = ({ + children, + onCancel, + onConfirm, + disabled = false, + confirmVariant = 'outline-dark', + ...baseModalProps +}: PropsWithChildren) => { const { t } = useTranslation() return ( @@ -82,7 +97,7 @@ const ConfirmModal = ({ children, onCancel, onConfirm, ...baseModalProps }: Prop
{t('modal.confirm_button_reject')}
- onConfirm()}> + onConfirm()} disabled={disabled}> {t('modal.confirm_button_accept')} diff --git a/src/components/Send/AmountInputField.tsx b/src/components/Send/AmountInputField.tsx index e16a89340..522f53812 100644 --- a/src/components/Send/AmountInputField.tsx +++ b/src/components/Send/AmountInputField.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react' +import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import * as rb from 'react-bootstrap' import { useField, useFormikContext } from 'formik' @@ -35,6 +35,25 @@ export const AmountInputField = ({ const form = useFormikContext() const ref = useRef(null) + //Effect to change the field value whenever the sourceJarBalance changes (sourceJarBalance will change when quick freeze/unfreeze is performed or different source jar is selected) + useEffect(() => { + if (!sourceJarBalance) return + + const currentValue = formatBtcDisplayValue(sourceJarBalance.calculatedAvailableBalanceInSats) + + if (field.value?.isSweep && field.value.displayValue !== currentValue) { + form.setFieldValue( + field.name, + { + value: 0, + isSweep: true, + displayValue: formatBtcDisplayValue(sourceJarBalance.calculatedAvailableBalanceInSats), + }, + true, + ) + } + }, [sourceJarBalance, field, form]) + return ( <> diff --git a/src/components/Send/SendForm.tsx b/src/components/Send/SendForm.tsx index 7b16481f9..7eeaf130b 100644 --- a/src/components/Send/SendForm.tsx +++ b/src/components/Send/SendForm.tsx @@ -26,7 +26,7 @@ import { isValidNumCollaborators, } from './helpers' import { AccountBalanceSummary } from '../../context/BalanceSummary' -import { WalletInfo } from '../../context/WalletContext' +import { WalletInfo, CurrentWallet } from '../../context/WalletContext' import { useSettings } from '../../context/SettingsContext' import styles from './SendForm.module.css' import { TxFeeInputField, validateTxFee } from '../settings/TxFeeInputField' @@ -221,6 +221,7 @@ interface InnerSendFormProps { className?: string isLoading: boolean walletInfo?: WalletInfo + wallet: CurrentWallet loadNewWalletAddress: (props: { signal: AbortSignal; jarIndex: JarIndex }) => Promise minNumCollaborators: number feeConfigValues?: FeeValues @@ -233,6 +234,7 @@ const InnerSendForm = ({ className, isLoading, walletInfo, + wallet, loadNewWalletAddress, minNumCollaborators, feeConfigValues, @@ -272,6 +274,7 @@ const InnerSendForm = ({ name="sourceJarIndex" label={t('send.label_source_jar')} walletInfo={walletInfo} + wallet={wallet} isLoading={isLoading} disabled={disabled} variant={showCoinjoinPreconditionViolationAlert ? 'warning' : 'default'} @@ -375,6 +378,7 @@ type SendFormProps = Omit & { onSubmit: (values: SendFormValues) => Promise formRef?: React.Ref> blurred?: boolean + wallet: CurrentWallet } export const SendForm = ({ @@ -383,6 +387,7 @@ export const SendForm = ({ formRef, blurred = false, walletInfo, + wallet, minNumCollaborators, ...innerProps }: SendFormProps) => { @@ -446,6 +451,7 @@ export const SendForm = ({ props={props} className={blurred ? styles.blurred : undefined} walletInfo={walletInfo} + wallet={wallet} minNumCollaborators={minNumCollaborators} {...innerProps} /> diff --git a/src/components/Send/ShowUtxos.module.css b/src/components/Send/ShowUtxos.module.css new file mode 100644 index 000000000..38998a9df --- /dev/null +++ b/src/components/Send/ShowUtxos.module.css @@ -0,0 +1,88 @@ +.joinedUtxoAndCjout { + background-color: #27ae600d !important; + color: #27ae60 !important; +} + +.frozenUtxo { + background-color: #2d9cdb0d !important; + color: #2d9cdb !important; +} + +.depositUtxo { + background-color: var(--bs-body-bg) !important; + color: var(--bs-modal-color) !important; +} + +.changeAndReuseUtxo { + background-color: #eb57570d !important; + color: #eb5757 !important; +} + +.subTitle { + color: #777777 !important; +} + +.utxoTagDeposit { + color: #999999; + border: 1px solid #bbbbbb; + background-color: #dedede !important; + border-radius: 0.35rem; + padding: 0rem 0.25rem; +} + +.utxoTagJoinedAndCjout { + border: 1px solid #27ae60; + background-color: #c6eed7 !important; + border-radius: 0.35rem; + padding: 0rem 0.25rem; +} + +.utxoTagFreeze { + border: 1px solid #2d9cdb; + background-color: #bce7ff !important; + border-radius: 0.35rem; + padding: 0rem 0.25rem; +} + +.utxoTagChangeAndReuse { + border: 1px solid #eb5757; + background-color: #fac7c7 !important; + border-radius: 0.35rem; + padding: 0rem 0.25rem; +} + +.squareToggleButton { + appearance: none; + width: 22px; + height: 22px; + border-radius: 3px; + border: 1px solid var(--bs-body-color); + margin-top: 0.45rem; +} + +.selected { + visibility: visible !important; + background-color: var(--bs-body-color); +} + +.squareFrozenToggleButton { + appearance: none; + width: 22px; + height: 22px; + border-radius: 3px; + border: 1px solid #2d9cdb; + margin-top: 0.45rem; +} + +.utxoListDisplayHeight { + max-height: 17.6rem; +} + +.customHeaderClass { + background-color: var(--bs-gray-800) !important; + padding: var(--bs-modal-header-padding) !important; +} + +.customTitleClass { + color: var(--bs-heading-color) !important; +} diff --git a/src/components/Send/ShowUtxos.tsx b/src/components/Send/ShowUtxos.tsx new file mode 100755 index 000000000..03eacd75b --- /dev/null +++ b/src/components/Send/ShowUtxos.tsx @@ -0,0 +1,381 @@ +import { useState, useEffect, useCallback, memo, useMemo } from 'react' +import * as rb from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import type { TFunction } from 'i18next' +import classNames from 'classnames' +import { Table, Body, Row, Cell } from '@table-library/react-table-library/table' +import { useTheme } from '@table-library/react-table-library/theme' +import * as TableTypes from '@table-library/react-table-library/types/table' +import { WalletInfo, Utxo, useCurrentWalletInfo } from '../../context/WalletContext' +import { useSettings, Settings } from '../../context/SettingsContext' +import Alert from '../Alert' +import Balance from '../Balance' +import { ConfirmModal } from '../Modal' +import Sprite from '../Sprite' +import { utxoTags } from '../jar_details/UtxoList' +import mainStyles from '../MainWalletView.module.css' +import styles from './ShowUtxos.module.css' +import { UtxoList } from './SourceJarSelector' + +interface ShowUtxosProps { + isOpen: boolean + onCancel: () => void + onConfirm: () => void + alert: SimpleAlert | undefined + isLoading: boolean + frozenUtxos: UtxoList + unFrozenUtxos: UtxoList + setFrozenUtxos: (arg: UtxoList) => void + setUnFrozenUtxos: (arg: UtxoList) => void +} + +interface UtxoRowProps { + utxo: Utxo + utxoIndex: number + onToggle?: (index: number, isFrozen: boolean) => void + isFrozen: boolean + settings: Settings + showRadioButton: boolean + showBackgroundColor: boolean + walletInfo: WalletInfo + t: TFunction +} + +interface UtxoListDisplayProps { + utxos: Array + onToggle?: (index: number, isFrozen: boolean) => void + settings: Settings + showRadioButton: boolean + showBackgroundColor: boolean +} + +interface DividerProps { + isState: boolean + setIsState: (arg: boolean) => void + className?: string +} + +// Utility function to format Bitcoin address +const formatAddress = (address: string) => `${address.slice(0, 10)}...${address.slice(-8)}` + +// Utility function to format the confirmations +const formatConfirmations = (conf: number) => { + if (conf === 0) return { symbol: 'confs-0', confirmations: conf } + if (conf === 1) return { symbol: 'confs-1', confirmations: conf } + if (conf === 2) return { symbol: 'confs-2', confirmations: conf } + if (conf === 3) return { symbol: 'confs-3', confirmations: conf } + if (conf === 4) return { symbol: 'confs-4', confirmations: conf } + if (conf === 5) return { symbol: 'confs-5', confirmations: conf } + if (conf > 9999) return { symbol: 'confs-full', confirmations: '9999+' } + return { symbol: 'confs-full', confirmations: conf } +} + +// Utility function to convert Satoshi to Bitcoin +const satsToBtc = (sats: number) => (sats / 100000000).toFixed(8) + +// Utility function to Identifies Icons +const utxoIcon = (tag: string, isFrozen: boolean) => { + if (isFrozen && tag === 'bond') return 'timelock' + if (isFrozen) return 'snowflake' + if (tag === 'deposit' || tag === 'non-cj-change' || tag === 'reused') return 'Unmixed' + if (tag === 'bond') return 'timelock' + return 'mixed' +} + +// Utility function to allot classes +const allotClasses = (tag: string, isFrozen: boolean) => { + if (isFrozen) return { row: styles.frozenUtxo, tag: styles.utxoTagFreeze } + if (tag === 'deposit') return { row: styles.depositUtxo, tag: styles.utxoTagDeposit } + if (tag === 'joined' || tag === 'cj-out') return { row: styles.joinedUtxoAndCjout, tag: styles.utxoTagJoinedAndCjout } + if (tag === 'non-cj-change' || tag === 'reused') + return { row: styles.changeAndReuseUtxo, tag: styles.utxoTagChangeAndReuse } + return { row: styles.depositUtxo, tag: styles.utxoTagDeposit } +} + +const UtxoRow = memo( + ({ + utxo, + utxoIndex, + onToggle, + isFrozen, + showRadioButton, + showBackgroundColor, + settings, + walletInfo, + t, + }: UtxoRowProps) => { + const { address: utxoAddress, confirmations, value, checked, frozen } = utxo + + const address = useMemo(() => formatAddress(utxoAddress), [utxoAddress]) + const conf = useMemo(() => formatConfirmations(confirmations), [confirmations]) + const valueString = useMemo(() => satsToBtc(value).toString(), [value]) + const tag = useMemo(() => utxoTags(utxo, walletInfo, t), [utxo, walletInfo, t]) + + const { icon, rowAndTagClass } = useMemo(() => { + if (tag.length === 0) { + return { icon: 'Unmixed', rowAndTagClass: { row: styles.depositUtxo, tag: styles.utxoTagDeposit } } + } + return { icon: utxoIcon(tag[0].tag, isFrozen), rowAndTagClass: allotClasses(tag[0].tag, isFrozen) } + }, [tag, isFrozen]) + + const ConfirmationCell = () => + confirmations > 9999 ? ( + {confirmations}} + > +
+ + {conf.confirmations} +
+
+ ) : ( +
+ + {conf.confirmations} +
+ ) + + return ( + onToggle && onToggle(utxoIndex, frozen)} + > + {showRadioButton && ( + + { + onToggle && onToggle(utxoIndex, isFrozen) + }} + className={classNames(isFrozen ? styles.squareFrozenToggleButton : styles.squareToggleButton, { + [styles.selected]: checked, + })} + /> + + )} + + + + {address} + + + + + + + +
{tag.length ? tag[0].tag : ''}
+
+
+ ) + }, +) + +const UtxoListDisplay = ({ + utxos, + onToggle, + settings, + showRadioButton = true, + showBackgroundColor = true, +}: UtxoListDisplayProps) => { + const { t } = useTranslation() + const walletInfo = useCurrentWalletInfo() + + //Table theme to manage view + const TABLE_THEME = { + Table: ` + font-size: ${showRadioButton ? '1rem' : '0.87rem'}; + --data-table-library_grid-template-columns: ${showRadioButton ? '3.5rem 2.5rem 12rem 2fr 3fr 10rem ' : '2.5rem 10rem 5fr 3fr 7.5rem'}; + @media only screen and (min-width: 768px) { + --data-table-library_grid-template-columns: ${showRadioButton ? '3.5rem 2.5rem 14rem 5fr 3fr 10rem' : '2.5rem 11rem 5fr 3fr 7.5rem'}; + } + `, + BaseCell: ` + padding:${showRadioButton ? '0.5rem' : '0.55rem'} 0.35rem !important; + margin: 0.15rem 0px !important; + `, + } + const tableTheme = useTheme(TABLE_THEME) + + //Default sort is by date the older ones at the bottom, newer ones at the top. + utxos.sort((a, b) => a.confirmations - b.confirmations) + + return ( +
+ + {(utxosList: TableTypes.TableProps) => ( + + {walletInfo && + utxosList.map((utxo: Utxo, index: number) => { + return ( + + ) + })} + + )} +
+
+ ) +} + +const Divider = ({ isState, setIsState, className }: DividerProps) => { + //Effect for getting back to it's original state when components unMounts + useEffect(() => { + return () => { + setIsState(false) + } + }, [setIsState]) + + return ( + + +
+
+ +
+
+
+
+ ) +} + +const ShowUtxos = ({ + isOpen, + onCancel, + onConfirm, + alert, + isLoading, + frozenUtxos, + unFrozenUtxos, + setFrozenUtxos, + setUnFrozenUtxos, +}: ShowUtxosProps) => { + const { t } = useTranslation() + const settings = useSettings() + const [showFrozenUtxos, setShowFrozenUtxos] = useState(false) + + // Handler to toggle UTXO selection + const handleUtxoCheckedState = useCallback( + (utxoIndex: number, isFrozen: boolean) => { + if (!isFrozen) { + const utxos = unFrozenUtxos.map((utxo: Utxo, i: number) => + i === utxoIndex ? { ...utxo, checked: !utxo.checked } : utxo, + ) + setUnFrozenUtxos(utxos) + } else { + const utxos = frozenUtxos.map((utxo: Utxo, i: number) => + i === utxoIndex ? { ...utxo, checked: !utxo.checked } : utxo, + ) + setFrozenUtxos(utxos) + } + }, + [frozenUtxos, unFrozenUtxos, setUnFrozenUtxos, setFrozenUtxos], + ) + + //Effect to hide the Divider line when there is no unFrozen-UTXOs present + useEffect(() => { + if (unFrozenUtxos.length === 0 && frozenUtxos.length > 0) { + setShowFrozenUtxos(true) + } + }, [unFrozenUtxos.length, frozenUtxos.length]) + + return ( + + {!isLoading ? ( + <> +
+ {unFrozenUtxos.length !== 0 + ? t('show_utxos.show_utxo_subtitle') + : t('show_utxos.show_utxo_subtitle_when_allutxos_are_frozen')} +
+ {alert && ( + + + + )} + + {frozenUtxos.length > 0 && unFrozenUtxos.length > 0 && ( + + )} + {showFrozenUtxos && ( + + )} + + ) : ( +
+
+ )} +
+ ) +} + +export { ShowUtxos, Divider, UtxoListDisplay, UtxoRow } diff --git a/src/components/Send/SourceJarSelector.module.css b/src/components/Send/SourceJarSelector.module.css index 74f910d85..9dc43eea7 100644 --- a/src/components/Send/SourceJarSelector.module.css +++ b/src/components/Send/SourceJarSelector.module.css @@ -7,6 +7,7 @@ gap: 1rem; color: var(--bs-body-color); margin-bottom: 1.5rem; + margin-top: 2rem; } .sourceJarsPlaceholder { diff --git a/src/components/Send/SourceJarSelector.tsx b/src/components/Send/SourceJarSelector.tsx index 3f77374ae..af6e9ccb5 100644 --- a/src/components/Send/SourceJarSelector.tsx +++ b/src/components/Send/SourceJarSelector.tsx @@ -1,10 +1,13 @@ -import { useMemo } from 'react' +import { useState, useMemo, useCallback, useEffect } from 'react' import { useField, useFormikContext } from 'formik' import * as rb from 'react-bootstrap' import { jarFillLevel, SelectableJar } from '../jars/Jar' import { noop } from '../../utils' -import { WalletInfo } from '../../context/WalletContext' +import { WalletInfo, CurrentWallet, useReloadCurrentWalletInfo, Utxo } from '../../context/WalletContext' import styles from './SourceJarSelector.module.css' +import { ShowUtxos } from './ShowUtxos' +import { useTranslation } from 'react-i18next' +import * as Api from '../../libs/JmWalletApi' export type SourceJarSelectorProps = { name: string @@ -12,20 +15,37 @@ export type SourceJarSelectorProps = { className?: string variant: 'default' | 'warning' walletInfo?: WalletInfo + wallet: CurrentWallet isLoading: boolean disabled?: boolean } +interface ShowUtxosProps { + jarIndex?: string + isOpen?: boolean +} + +export type UtxoList = Utxo[] + export const SourceJarSelector = ({ name, label, walletInfo, + wallet, variant, isLoading, disabled = false, }: SourceJarSelectorProps) => { + const { t } = useTranslation() const [field] = useField(name) const form = useFormikContext() + const reloadCurrentWalletInfo = useReloadCurrentWalletInfo() + + const [showUtxos, setShowUtxos] = useState(undefined) + const [alert, setAlert] = useState(undefined) + const [isUtxosLoading, setIsUtxosLoading] = useState(false) + const [unFrozenUtxos, setUnFrozenUtxos] = useState([]) + const [frozenUtxos, setFrozenUtxos] = useState([]) const jarBalances = useMemo(() => { if (!walletInfo) return [] @@ -34,6 +54,74 @@ export const SourceJarSelector = ({ ) }, [walletInfo]) + useEffect(() => { + if (showUtxos?.jarIndex && walletInfo?.utxosByJar) { + const data = Object.entries(walletInfo.utxosByJar).find(([key]) => key === showUtxos.jarIndex) + const utxos: any = data ? data[1] : [] + + const frozenUtxoList = utxos + .filter((utxo: any) => utxo.frozen) + .map((utxo: any) => ({ ...utxo, id: utxo.utxo, checked: false })) + const unFrozenUtxosList = utxos + .filter((utxo: any) => !utxo.frozen) + .map((utxo: any) => ({ ...utxo, id: utxo.utxo, checked: true })) + + setFrozenUtxos(frozenUtxoList) + setUnFrozenUtxos(unFrozenUtxosList) + } + }, [walletInfo, showUtxos?.jarIndex, t]) + + useEffect(() => { + if (frozenUtxos.length === 0 && unFrozenUtxos.length === 0) { + return + } + const frozenUtxosToUpdate = frozenUtxos.filter((utxo: Utxo) => utxo.checked && !utxo.locktime) + const timeLockedUtxo = frozenUtxos.find((utxo: Utxo) => utxo.checked && utxo.locktime) + const allUnFrozenUnchecked = unFrozenUtxos.every((utxo: Utxo) => !utxo.checked) + + if (frozenUtxos.length > 0 && timeLockedUtxo) { + setAlert({ variant: 'danger', message: `${t('show_utxos.alert_for_time_locked')} ${timeLockedUtxo.locktime}` }) + } else if ( + (frozenUtxos.length > 0 || unFrozenUtxos.length > 0) && + allUnFrozenUnchecked && + frozenUtxosToUpdate.length === 0 + ) { + setAlert({ variant: 'warning', message: t('show_utxos.alert_for_unfreeze_utxos'), dismissible: true }) + } else { + setAlert(undefined) + } + }, [frozenUtxos, unFrozenUtxos, t, setAlert]) + + const handleUtxosFrozenState = useCallback(async () => { + const abortCtrl = new AbortController() + const frozenUtxosToUpdate = frozenUtxos + .filter((utxo) => utxo.checked && !utxo.locktime) + .map((utxo) => ({ utxo: utxo.utxo, freeze: false })) + const unFrozenUtxosToUpdate = unFrozenUtxos + .filter((utxo) => !utxo.checked) + .map((utxo) => ({ utxo: utxo.utxo, freeze: true })) + + try { + const res = await Promise.all([ + ...frozenUtxosToUpdate.map((utxo) => Api.postFreeze({ ...wallet, signal: abortCtrl.signal }, utxo)), + ...unFrozenUtxosToUpdate.map((utxo) => Api.postFreeze({ ...wallet, signal: abortCtrl.signal }, utxo)), + ]) + + if (res.length !== 0) { + setIsUtxosLoading(true) + await reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal }) + } + + setShowUtxos(undefined) + } catch (err: any) { + if (!abortCtrl.signal.aborted) { + setAlert({ variant: 'danger', message: err.message, dismissible: true }) + } + } finally { + setIsUtxosLoading(false) + } + }, [frozenUtxos, unFrozenUtxos, wallet, reloadCurrentWalletInfo]) + return ( <> @@ -44,22 +132,55 @@ export const SourceJarSelector = ({ ) : (
- {jarBalances.map((it) => ( - 0} - isSelected={it.accountIndex === field.value} - fillLevel={jarFillLevel( - it.calculatedTotalBalanceInSats, - walletInfo.balanceSummary.calculatedTotalBalanceInSats, - )} - variant={it.accountIndex === field.value ? variant : undefined} - onClick={(jarIndex) => form.setFieldValue(field.name, jarIndex, true)} + {showUtxos?.isOpen && ( + { + setShowUtxos(undefined) + }} + alert={alert} + isLoading={isUtxosLoading} + frozenUtxos={frozenUtxos} + unFrozenUtxos={unFrozenUtxos} + setFrozenUtxos={setFrozenUtxos} + setUnFrozenUtxos={setUnFrozenUtxos} /> - ))} + )} + {jarBalances.map((it) => { + return ( +
+ 0} + isSelected={it.accountIndex === field.value} + fillLevel={jarFillLevel( + it.calculatedTotalBalanceInSats, + walletInfo.balanceSummary.calculatedTotalBalanceInSats, + )} + variant={it.accountIndex === field.value ? variant : undefined} + onClick={(jarIndex: number) => { + form.setFieldValue(field.name, jarIndex, true) + if ( + it.accountIndex === field.value && + !disabled && + !isLoading && + it.calculatedTotalBalanceInSats > 0 + ) { + setShowUtxos({ + jarIndex: it.accountIndex.toString(), + isOpen: true, + }) + } + }} + /> +
+ ) + })}
)} diff --git a/src/components/Send/index.tsx b/src/components/Send/index.tsx index 3fa33fd5d..7f6dd561d 100644 --- a/src/components/Send/index.tsx +++ b/src/components/Send/index.tsx @@ -201,15 +201,13 @@ export default function Send({ wallet }: SendProps) { setAlert({ variant: 'danger', message }) }) - const loadingWalletInfoAndUtxos = reloadCurrentWalletInfo - .reloadUtxos({ signal: abortCtrl.signal }) - .catch((err) => { - if (abortCtrl.signal.aborted) return - const message = t('global.errors.error_loading_wallet_failed', { - reason: err.message || t('global.errors.reason_unknown'), - }) - setAlert({ variant: 'danger', message }) + const loadingWalletInfoAndUtxos = reloadCurrentWalletInfo.reloadAll({ signal: abortCtrl.signal }).catch((err) => { + if (abortCtrl.signal.aborted) return + const message = t('global.errors.error_loading_wallet_failed', { + reason: err.message || t('global.errors.reason_unknown'), }) + setAlert({ variant: 'danger', message }) + }) const loadingMinimumMakerConfig = loadConfigValue({ signal: abortCtrl.signal, @@ -480,6 +478,7 @@ export default function Send({ wallet }: SendProps) { disabled={isOperationDisabled} isLoading={isLoading} walletInfo={walletInfo} + wallet={wallet} minNumCollaborators={minNumCollaborators} loadNewWalletAddress={loadNewWalletAddress} feeConfigValues={feeConfigValues} diff --git a/src/components/jar_details/UtxoList.tsx b/src/components/jar_details/UtxoList.tsx index 32219a307..f6be3702b 100644 --- a/src/components/jar_details/UtxoList.tsx +++ b/src/components/jar_details/UtxoList.tsx @@ -37,7 +37,7 @@ const ADDRESS_STATUS_COLORS: { [key: string]: string } = { type Tag = { tag: string; color: string } -const utxoTags = (utxo: Utxo, walletInfo: WalletInfo, t: TFunction): Tag[] => { +export const utxoTags = (utxo: Utxo, walletInfo: WalletInfo, t: TFunction): Tag[] => { const rawStatus = walletInfo.addressSummary[utxo.address]?.status let status: string | null = null @@ -156,7 +156,7 @@ const toUtxo = (tableNode: TableTypes.TableNode): Utxo => { return utxo as Utxo } -interface UtxoTableRow extends Utxo, TableTypes.TableNode { +interface UtxoTableRow extends Utxo { _icon: JSX.Element _tags: Tag[] _confs: JSX.Element diff --git a/src/components/jars/Jar.module.css b/src/components/jars/Jar.module.css index d95b72761..b45e04f5a 100644 --- a/src/components/jars/Jar.module.css +++ b/src/components/jars/Jar.module.css @@ -95,3 +95,7 @@ .tooltipJarContainer { cursor: zoom-in; } + +.customTooltip { + z-index: auto !important; +} diff --git a/src/components/jars/Jar.tsx b/src/components/jars/Jar.tsx index 7da0271ac..063b9a8f3 100644 --- a/src/components/jars/Jar.tsx +++ b/src/components/jars/Jar.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react' +import { useState, useMemo, useRef } from 'react' import * as rb from 'react-bootstrap' import classnamesBind from 'classnames/bind' import { useSettings } from '../../context/SettingsContext' @@ -21,6 +21,8 @@ export type JarProps = { } export type SelectableJarProps = JarProps & { + tooltipText?: string + isOpen?: boolean isSelectable: boolean isSelected: boolean variant?: 'default' | 'warning' @@ -154,6 +156,8 @@ const Jar = ({ index, balance, frozenBalance, fillLevel, isOpen = false, size }: * A jar with index, balance, and a radio-style selection button. */ const SelectableJar = ({ + tooltipText, + isOpen = false, isSelectable, isSelected, onClick, @@ -161,6 +165,7 @@ const SelectableJar = ({ variant = 'default', ...jarProps }: SelectableJarProps) => { + const target = useRef(null) return (
isSelectable && onClick(index)} + ref={target} > - +
)}
+ {isOpen && isSelectable && ( + + {(props) => ( + + {tooltipText} + + )} + + )}
) } diff --git a/src/context/SettingsContext.tsx b/src/context/SettingsContext.tsx index 33f4e0074..6f4d125f1 100644 --- a/src/context/SettingsContext.tsx +++ b/src/context/SettingsContext.tsx @@ -64,4 +64,4 @@ const useSettingsDispatch = () => { return context.dispatch } -export { SettingsProvider, useSettings, useSettingsDispatch } +export { SettingsProvider, useSettings, useSettingsDispatch, Settings } diff --git a/src/context/WalletContext.tsx b/src/context/WalletContext.tsx index 5bfce6abc..5632f7975 100644 --- a/src/context/WalletContext.tsx +++ b/src/context/WalletContext.tsx @@ -53,6 +53,9 @@ export type Utxo = { // `locktime` in format "yyyy-MM-dd 00:00:00" // NOTE: it is unparsable with safari Date constructor locktime?: string + id: string + checked?: boolean + tags?: { tag: string; color: string }[] } export type Utxos = Utxo[] @@ -192,6 +195,7 @@ export const groupByJar = (utxos: Utxos): UtxosByJar => { return utxos.reduce((res, utxo) => { const { mixdepth } = utxo res[mixdepth] = res[mixdepth] || [] + utxo.id = utxo.utxo res[mixdepth].push(utxo) return res }, {} as UtxosByJar) diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index d990fc0de..fcbdf99f4 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -700,5 +700,15 @@ "utxo_detail_label_locktime": "Locktime", "utxo_detail_label_status": "Address status" } + }, + "show_utxos": { + "select_utxos": "Select UTXOs", + "selected_utxos": "Selected UTXOs", + "show_utxo_title": "Select UTXOs to be considered", + "show_utxo_subtitle": "The following UTXOs are considered in the transaction. Every unselected UTXO will be frozen and can be unfrozen later on.", + "show_utxo_subtitle_when_allutxos_are_frozen": "The following UTXOs are frozen. Please select them to be considered in the transaction.", + "alert_for_unfreeze_utxos": "At least one UTXO is required to perform a transaction", + "alert_for_time_locked": "Selected UTXO is Time Locked till", + "alert_for_empty_utxos": "Please Unfreeze UTXOs to send" } }