diff --git a/src/components/Balance.tsx b/src/components/Balance.tsx index 880d0f67..645ee00a 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 71dd98de..1c04322c 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -10,6 +10,7 @@ type BaseModalProps = { onCancel: () => void backdrop?: rb.ModalProps['backdrop'] size?: rb.ModalProps['size'] + showCloseButtonAndRemoveClassName?: boolean } const BaseModal = ({ isShown, @@ -18,6 +19,7 @@ const BaseModal = ({ onCancel, size, backdrop = 'static', + showCloseButtonAndRemoveClassName = false, }: PropsWithChildren) => { return ( - - {title} + + {title} {children} @@ -65,9 +70,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 +96,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/fb/FidelityBondSteps.tsx b/src/components/fb/FidelityBondSteps.tsx index d5810707..d777b1a6 100644 --- a/src/components/fb/FidelityBondSteps.tsx +++ b/src/components/fb/FidelityBondSteps.tsx @@ -13,6 +13,7 @@ import { CopyButton } from '../CopyButton' import LockdateForm, { LockdateFormProps } from './LockdateForm' import * as fb from './utils' import styles from './FidelityBondSteps.module.css' +import ShowUtxos from './ShowUtxos' const cx = classnamesBind.bind(styles) @@ -176,27 +177,7 @@ const SelectUtxos = ({ walletInfo, jar, utxos, selectedUtxos, onUtxoSelected, on return (
-
- {t('earn.fidelity_bond.select_utxos.description', { jar: jarInitial(jar) })} -
- {utxos.map((utxo, index) => { - return ( - { - if (fb.utxo.isInList(utxo, selectedUtxos)) { - onUtxoDeselected(utxo) - } else { - onUtxoSelected(utxo) - } - }} - /> - ) - })} +
) } diff --git a/src/components/fb/ShowUtxos.module.css b/src/components/fb/ShowUtxos.module.css new file mode 100644 index 00000000..45f86727 --- /dev/null +++ b/src/components/fb/ShowUtxos.module.css @@ -0,0 +1,75 @@ +.joinedUtxoAndCjout { + background-color: #27ae600d !important; + color: #27ae60 !important; +} + +.frozenUtxo { + background-color: #2d9cdb0d !important; + color: #2d9cdb !important; +} + +.depositUtxo { + background-color: none !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; +} diff --git a/src/components/fb/ShowUtxos.tsx b/src/components/fb/ShowUtxos.tsx new file mode 100644 index 00000000..c2e9dac2 --- /dev/null +++ b/src/components/fb/ShowUtxos.tsx @@ -0,0 +1,337 @@ +import { useState, useEffect, useCallback, memo, useRef } from 'react' +import * as rb from 'react-bootstrap' +import { useTranslation } from 'react-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 * as Api from '../../libs/JmWalletApi' +import { WalletInfo, CurrentWallet, useReloadCurrentWalletInfo, Utxos } 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' + +type Tags = 'deposit' | 'non-cj-change' | 'bond' | 'reused' | 'joined' | 'cj-out' + +type UtxoType = { + address: Api.BitcoinAddress + path: string + label: string + id: string + checked: boolean + value: Api.AmountSats + tries: number + triesRemaining: number + external: boolean + mixdepth: number + confirmations: number + frozen: boolean + utxo: Api.UtxoId + locktime?: string + tags: { tag: Tags; color: string }[] +} + +type UtxoList = UtxoType[] + +interface ShowUtxosProps { + utxos: Utxos + index: JarIndex +} + +interface UtxoRowProps { + utxo: UtxoType + index: number + onToggle: (index: number, type: 'frozen' | 'unFrozen') => void + isFrozen: boolean + settings: Settings +} + +interface UtxoListDisplayProps { + utxos: UtxoList + onToggle: (index: number, type: 'frozen' | 'unFrozen') => void + isFrozen: boolean + settings: Settings +} + +// 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: Tags, 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: Tags, 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 } +} + +// Utxos row component +const UtxoRow = memo(({ utxo, index, onToggle, isFrozen, settings }: UtxoRowProps) => { + const address = formatAddress(utxo.address) + const conf = formatConfirmations(utxo.confirmations) + const value = satsToBtc(utxo.value) + const tag = utxo.tags[0].tag + const icon = utxoIcon(tag, isFrozen) + const rowAndTagClass = allotClasses(tag, isFrozen) + + return ( + + + onToggle(index, isFrozen ? 'frozen' : 'unFrozen')} + className={classNames(isFrozen ? styles.squareFrozenToggleButton : styles.squareToggleButton, { + [styles.selected]: utxo.checked, + })} + /> + + + + + {address} + + + {conf.confirmations} + + + + + +
{tag}
+
+
+ ) +}) + +//Table theme to manage view +const TABLE_THEME = { + Table: ` + font-size: 1rem; + --data-table-library_grid-template-columns: 3.5rem 2.5rem 12rem 2fr 3fr 10rem ; + @media only screen and (min-width: 768px) { + --data-table-library_grid-template-columns: 3.5rem 2.5rem 14rem 5fr 3fr 10rem ; + } + `, + BaseCell: ` + padding: 0.55rem 0.35rem !important; + margin: 0.15rem 0px !important; + `, +} + +//Utxo list display component +const UtxoListDisplay = ({ utxos, onToggle, isFrozen, settings }: UtxoListDisplayProps) => { + const tableTheme = useTheme(TABLE_THEME) + + return ( + + {(utxosList: TableTypes.TableProps) => ( + + {utxosList.map((utxo: UtxoType, index: number) => ( + onToggle(index, isFrozen ? 'frozen' : 'unFrozen')}> + + + ))} + + )} +
+ ) +} + +// Main component to show UTXOs +const ShowUtxos = ({ utxos, index }: ShowUtxosProps) => { + const [alert, setAlert] = useState(undefined) + const [showFrozenUtxos, setShowFrozenUtxos] = useState(false) + const [unFrozenUtxos, setUnFrozenUtxos] = useState([]) + const [frozenUtxos, setFrozenUtxos] = useState([]) + const [isLoading, setisLoading] = useState(true) + const { t } = useTranslation() + const reloadCurrentWalletInfo = useReloadCurrentWalletInfo() + const settings = useSettings() + + const isHandleReloadExecuted = useRef(false) + + // Load data from wallet info + const loadData = useCallback( + (walletInfo: WalletInfo) => { + const newUtxos = utxos.map((utxo: any) => ({ + ...utxo, + id: utxo.utxo, + tags: utxoTags(utxo, walletInfo, t), + })) + const frozen = newUtxos.filter((utxo: any) => utxo.frozen).map((utxo: any) => ({ ...utxo, checked: false })) + const unfrozen = newUtxos.filter((utxo: any) => !utxo.frozen).map((utxo: any) => ({ ...utxo, checked: true })) + + setFrozenUtxos(frozen) + setUnFrozenUtxos(unfrozen) + + if (unfrozen.length === 0) { + setAlert({ variant: 'danger', message: t('showUtxos.alertForEmptyUtxos') }) + } else { + setAlert(undefined) + } + + setisLoading(false) + }, + [t, utxos], + ) + + // Reload wallet info + const handleReload = useCallback(async () => { + setisLoading(true) + const abortCtrl = new AbortController() + try { + const walletInfo = await reloadCurrentWalletInfo.reloadAll({ signal: abortCtrl.signal }) + loadData(walletInfo) + } catch (err: any) { + if (!abortCtrl.signal.aborted) { + setAlert({ variant: 'danger', message: err.message, dismissible: true }) + } + } + }, [reloadCurrentWalletInfo, loadData]) + + //Effect to Reload walletInfo + useEffect(() => { + if (!isHandleReloadExecuted.current) { + handleReload() + isHandleReloadExecuted.current = true + } + }, [handleReload]) + + //Effect to set Alert according to the walletInfo + useEffect(() => { + const frozenUtxosToUpdate = frozenUtxos.filter((utxo: UtxoType) => utxo.checked && !utxo.locktime) + const timeLockedUtxo = frozenUtxos.find((utxo: UtxoType) => utxo.checked && utxo.locktime) + const allUnfrozenUnchecked = unFrozenUtxos.every((utxo: UtxoType) => !utxo.checked) + + if (timeLockedUtxo) { + setAlert({ variant: 'danger', message: `${t('showUtxos.alertForTimeLocked')} ${timeLockedUtxo.locktime}` }) + } else if (allUnfrozenUnchecked && frozenUtxosToUpdate.length === 0 && unFrozenUtxos.length > 0) { + setAlert({ variant: 'warning', message: t('showUtxos.alertForUnfreezeUtxos'), dismissible: true }) + } else if (unFrozenUtxos.length !== 0) { + setAlert(undefined) + } + }, [frozenUtxos, unFrozenUtxos, t]) + + // Handler to toggle UTXO selection + const handleToggle = useCallback((utxoIndex: number, type: 'frozen' | 'unFrozen') => { + if (type === 'unFrozen') { + setUnFrozenUtxos((prevUtxos) => + prevUtxos.map((utxo, i) => (i === utxoIndex ? { ...utxo, checked: !utxo.checked } : utxo)), + ) + } else { + setFrozenUtxos((prevUtxos) => + prevUtxos.map((utxo, i) => (i === utxoIndex ? { ...utxo, checked: !utxo.checked } : utxo)), + ) + } + }, []) + + // Handler for the "confirm" button click + const handleConfirm = 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 { + // await Promise.all([ + // ...frozenUtxosToUpdate.map((utxo) => Api.postFreeze({ ...wallet, signal: abortCtrl.signal }, utxo)), + // ...unFrozenUtxosToUpdate.map((utxo) => Api.postFreeze({ ...wallet, signal: abortCtrl.signal }, utxo)), + // ]) + // await handleReload() + // onHide() + } catch (err: any) { + if (!abortCtrl.signal.aborted) { + setAlert({ variant: 'danger', message: err.message, dismissible: true }) + } + } + } + + return ( +
+ {!isLoading ? ( +
+ {alert && ( + + setAlert(undefined)} + /> + + )} + + {frozenUtxos.length > 0 && ( + + +
+
+ +
+
+
+
+ )} + {showFrozenUtxos && ( + + )} +
+ ) : ( +
+
+ )} +
+ ) +} + +export default ShowUtxos diff --git a/src/components/jar_details/UtxoList.tsx b/src/components/jar_details/UtxoList.tsx index 32219a30..e782f080 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 diff --git a/src/context/SettingsContext.tsx b/src/context/SettingsContext.tsx index 33f4e007..6f4d125f 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 }