Skip to content

Commit

Permalink
feat: custom tx fee on direct and collaborative send (#706)
Browse files Browse the repository at this point in the history
* feat(send): ability to specify tx fee in blocks or sats/vbyte

* chore: fix SegmentedTabs active state

* chore: tx fee value and unit can be undefined

* refactor: SegmentedTab onChange handler props

* ui(Confirm): move mining fee section below collaborator fee section

* feat(send): display tx fee input field only with supported backends

* fix(send): add alert if aborting taker operation fails
  • Loading branch information
theborakompanioni authored Jan 27, 2024
1 parent 27e687c commit dc95e64
Show file tree
Hide file tree
Showing 17 changed files with 185 additions and 192 deletions.
6 changes: 3 additions & 3 deletions src/components/Earn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,10 +269,10 @@ const EarnForm = ({
value: OFFERTYPE_REL,
},
]}
onChange={(tab, checked) => {
checked && setFieldValue('offertype', tab.value, true)
value={values.offertype}
onChange={(tab) => {
setFieldValue('offertype', tab.value, true)
}}
initialValue={values.offertype}
disabled={isLoading || isSubmitting}
/>
</rb.Form.Group>
Expand Down
45 changes: 19 additions & 26 deletions src/components/PaymentConfirmModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,18 @@ const feeRange: (txFee: TxFee, txFeeFactor: number) => [number, number] = (txFee
return [minFeeSatsPerVByte, maxFeeSatsPerVByte]
}

const useMiningFeeText = ({ feeConfigValues }: { feeConfigValues?: FeeValues }) => {
const useMiningFeeText = ({ tx_fees, tx_fees_factor }: Pick<FeeValues, 'tx_fees' | 'tx_fees_factor'>) => {
const { t } = useTranslation()

const miningFeeText = useMemo(() => {
if (!feeConfigValues) return null
if (!isValidNumber(feeConfigValues.tx_fees?.value) || !isValidNumber(feeConfigValues.tx_fees_factor)) return null
return useMemo(() => {
if (!isValidNumber(tx_fees?.value) || !isValidNumber(tx_fees_factor)) return null

if (!feeConfigValues.tx_fees?.unit) {
if (!tx_fees?.unit) {
return null
} else if (feeConfigValues.tx_fees.unit === 'blocks') {
return t('send.confirm_send_modal.text_miner_fee_in_targeted_blocks', { count: feeConfigValues.tx_fees.value })
} else if (tx_fees.unit === 'blocks') {
return t('send.confirm_send_modal.text_miner_fee_in_targeted_blocks', { count: tx_fees.value })
} else {
const [minFeeSatsPerVByte, maxFeeSatsPerVByte] = feeRange(
feeConfigValues.tx_fees,
feeConfigValues.tx_fees_factor!,
)
const [minFeeSatsPerVByte, maxFeeSatsPerVByte] = feeRange(tx_fees, tx_fees_factor!)
const fractionDigits = 2

if (minFeeSatsPerVByte.toFixed(fractionDigits) === maxFeeSatsPerVByte.toFixed(fractionDigits)) {
Expand All @@ -56,9 +52,7 @@ const useMiningFeeText = ({ feeConfigValues }: { feeConfigValues?: FeeValues })
}),
})
}
}, [t, feeConfigValues])

return miningFeeText
}, [t, tx_fees, tx_fees_factor])
}

interface PaymentDisplayInfo {
Expand Down Expand Up @@ -92,7 +86,7 @@ export function PaymentConfirmModal({
const { t } = useTranslation()
const settings = useSettings()

const miningFeeText = useMiningFeeText({ feeConfigValues })
const miningFeeText = useMiningFeeText({ ...feeConfigValues })
const estimatedMaxCollaboratorFee = useEstimatedMaxCollaboratorFee({
isCoinjoin,
feeConfigValues,
Expand Down Expand Up @@ -161,17 +155,6 @@ export function PaymentConfirmModal({
)}
</rb.Col>
</rb.Row>

{miningFeeText && (
<rb.Row>
<rb.Col xs={4} md={3} className="text-end">
<strong>{t('send.confirm_send_modal.label_miner_fee')}</strong>
</rb.Col>
<rb.Col xs={8} md={9} className="text-start">
{miningFeeText}
</rb.Col>
</rb.Row>
)}
{isCoinjoin && (
<rb.Row>
<rb.Col xs={4} md={3} className="text-end">
Expand Down Expand Up @@ -213,6 +196,16 @@ export function PaymentConfirmModal({
</rb.Col>
</rb.Row>
)}
{miningFeeText && (
<rb.Row>
<rb.Col xs={4} md={3} className="text-end">
<strong>{t('send.confirm_send_modal.label_miner_fee')}</strong>
</rb.Col>
<rb.Col xs={8} md={9} className="text-start">
{miningFeeText}
</rb.Col>
</rb.Row>
)}
</rb.Container>
</ConfirmModal>
)
Expand Down
26 changes: 13 additions & 13 deletions src/components/SegmentedTabs.module.css
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
.segmented-tabs {
.segmentedTabs {
background-color: var(--bs-gray-300);
border-radius: 0.25rem;
padding: 0.25rem;
width: 100%;
}

.segmented-tab > label {
.segmentedTab > label {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}

:root[data-theme='dark'] .segmented-tabs {
:root[data-theme='dark'] .segmentedTabs {
color: var(--bs-gray-100);
background-color: var(--bs-gray-800);
}

.segmented-tab {
.segmentedTab {
flex: 1 1 0;
}

.segmented-tab input[type='radio'] {
.segmentedTab input[type='radio'] {
appearance: none;
margin: 0;
position: absolute;
Expand All @@ -30,7 +30,7 @@
width: 0;
}

.segmented-tab label {
.segmentedTab label {
background-color: white;
padding: 0.25rem 1rem;
border-radius: 0.1rem;
Expand All @@ -39,33 +39,33 @@
text-align: center;
}

.segmented-tab input[type='radio']:disabled ~ label {
.segmentedTab input[type='radio']:disabled ~ label {
background-color: transparent;
color: var(--bs-gray-600);
opacity: 0.5;
}

:root[data-theme='dark'] .segmented-tab input[type='radio']:disabled ~ label {
:root[data-theme='dark'] .segmentedTab input[type='radio']:disabled ~ label {
color: var(--bs-gray-600);
opacity: 0.5;
}

.segmented-tab input[type='radio']:not(:checked):not(:disabled) ~ label {
.segmentedTab input[type='radio']:not(:checked):not(:disabled) ~ label {
background-color: transparent;
}

.segmented-tab input[type='radio']:not(:disabled) ~ label {
.segmentedTab input[type='radio']:not(:disabled) ~ label {
cursor: pointer;
}
.segmented-tab input[type='radio']:checked:not(:disabled) ~ label {
.segmentedTab input[type='radio']:checked:not(:disabled) ~ label {
background-color: var(--bs-gray-100);
box-shadow: 1px 1px 3px 1px rgba(0, 0, 0, 0.1);
}

:root[data-theme='dark'] .segmented-tab input[type='radio']:checked:not(:disabled) ~ label {
:root[data-theme='dark'] .segmentedTab input[type='radio']:checked:not(:disabled) ~ label {
background-color: var(--bs-gray-600);
}

.segmented-tab input[type='radio']:focus ~ label {
.segmentedTab input[type='radio']:focus ~ label {
box-shadow: 0 0 0 0.25rem var(--bs-focus-ring-color) !important;
}
49 changes: 17 additions & 32 deletions src/components/SegmentedTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,37 @@
import React from 'react'
import { ChangeEvent } from 'react'
import * as rb from 'react-bootstrap'
import styles from './SegmentedTabs.module.css'

type SegmentedTabValue = string
interface SegmentedTab {
label: string
value: string
value: SegmentedTabValue
disabled?: boolean
}

function SegmentedTabFormCheck({ id, name, value, label, disabled, checked, onChange }: rb.FormCheckProps) {
const _onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.stopPropagation()
onChange && onChange(e)
}

return (
<>
<div className={styles['segmented-tab']}>
<input
id={id}
name={name}
type="radio"
value={value}
checked={checked}
onChange={_onChange}
disabled={disabled}
/>
<label htmlFor={id}>{label}</label>
</div>
</>
)
}
const SegmentedTabFormCheck = ({ id, name, value, label, disabled, checked, onChange }: rb.FormCheckProps) => (
<div className={styles.segmentedTab}>
<input id={id} name={name} type="radio" value={value} checked={checked} onChange={onChange} disabled={disabled} />
<label htmlFor={id}>{label}</label>
</div>
)

interface SegmentedTabsProps {
name: string
tabs: SegmentedTab[]
onChange: (tab: SegmentedTab, checked: boolean) => void
initialValue?: string
onChange: (tab: SegmentedTab) => void
value?: SegmentedTabValue
disabled?: boolean
}

export default function SegmentedTabs({ name, tabs, onChange, initialValue, disabled = false }: SegmentedTabsProps) {
const _onChange = (e: React.ChangeEvent<HTMLInputElement>, tab: SegmentedTab) => {
export default function SegmentedTabs({ name, tabs, onChange, value, disabled = false }: SegmentedTabsProps) {
const _onChange = (e: ChangeEvent<HTMLInputElement>, tab: SegmentedTab) => {
e.stopPropagation()
onChange(tab, e.currentTarget.checked)
onChange(tab)
}

return (
<div className={['segmented-tabs-hook', styles['segmented-tabs']].join(' ')}>
<div className={`segmented-tabs-hook ${styles.segmentedTabs}`}>
<div className="d-flex gap-1">
{tabs.map((tab, index) => {
return (
Expand All @@ -56,9 +40,10 @@ export default function SegmentedTabs({ name, tabs, onChange, initialValue, disa
id={`${name}-${index}`}
name={name}
label={tab.label}
value={tab.value}
disabled={disabled || tab.disabled}
checked={value === tab.value}
inline={true}
checked={initialValue === tab.value}
onChange={(e) => _onChange(e, tab)}
/>
)
Expand Down
10 changes: 0 additions & 10 deletions src/components/Send/AmountInputField.module.css
Original file line number Diff line number Diff line change
@@ -1,13 +1,3 @@
.inputLoader {
height: 3.5rem;
border-radius: 0.25rem;
}

.input {
height: 3.5rem;
width: 100%;
}

.button {
font-size: 0.875rem !important;
}
Expand Down
7 changes: 5 additions & 2 deletions src/components/Send/AmountInputField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import * as rb from 'react-bootstrap'
import { useField, useFormikContext } from 'formik'
import classNames from 'classnames'
import Sprite from '../Sprite'
import { AccountBalanceSummary } from '../../context/BalanceSummary'
import { formatBtcDisplayValue } from '../../utils'
Expand All @@ -11,6 +12,7 @@ import styles from './AmountInputField.module.css'
export type AmountInputFieldProps = {
name: string
label: string
className?: string
placeholder?: string
isLoading: boolean
disabled?: boolean
Expand All @@ -21,6 +23,7 @@ export type AmountInputFieldProps = {
export const AmountInputField = ({
name,
label,
className,
placeholder,
isLoading,
disabled = false,
Expand All @@ -39,13 +42,13 @@ export const AmountInputField = ({

{isLoading ? (
<rb.Placeholder as="div" animation="wave">
<rb.Placeholder xs={12} className={styles.inputLoader} />
<rb.Placeholder xs={12} className={className} />
</rb.Placeholder>
) : (
<div className={form.touched[field.name] && !!form.errors[field.name] ? 'is-invalid' : ''}>
<BitcoinAmountInput
ref={ref}
className={styles.input}
className={classNames('slashed-zeroes', className)}
inputGroupTextClassName={styles.inputGroupText}
label={label}
placeholder={placeholder}
Expand Down
9 changes: 0 additions & 9 deletions src/components/Send/DestinationInputField.module.css

This file was deleted.

10 changes: 3 additions & 7 deletions src/components/Send/DestinationInputField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@ import * as Api from '../../libs/JmWalletApi'
import Sprite from '../Sprite'
import { jarName } from '../jars/Jar'
import JarSelectorModal from '../JarSelectorModal'

import { WalletInfo } from '../../context/WalletContext'
import { noop } from '../../utils'
import styles from './DestinationInputField.module.css'

export type DestinationValue = {
value: Api.BitcoinAddress | null
Expand Down Expand Up @@ -88,14 +86,14 @@ export const DestinationInputField = ({
<rb.Form.Label>{label}</rb.Form.Label>
{isLoading ? (
<rb.Placeholder as="div" animation="wave">
<rb.Placeholder xs={12} className={styles.inputLoader} />
<rb.Placeholder xs={12} className={className} />
</rb.Placeholder>
) : (
<>
{field.value.fromJar !== null ? (
<rb.InputGroup hasValidation={false}>
<rb.Form.Control
className={classNames('slashed-zeroes', styles.input, className)}
className={classNames('slashed-zeroes', className)}
value={`${jarName(field.value.fromJar)} (${field.value.value})`}
required
onChange={noop}
Expand All @@ -104,7 +102,6 @@ export const DestinationInputField = ({
/>
<rb.Button
variant="dark"
className={styles.button}
onClick={() => {
form.setFieldValue(field.name, form.initialValues[field.name], true)
setTimeout(() => ref.current?.focus(), 4)
Expand All @@ -124,7 +121,7 @@ export const DestinationInputField = ({
aria-label={label}
name={field.name}
placeholder={t('send.placeholder_recipient')}
className={classNames('slashed-zeroes', styles.input, className)}
className={classNames('slashed-zeroes', className)}
value={field.value.value || ''}
onBlur={field.onBlur}
required
Expand All @@ -144,7 +141,6 @@ export const DestinationInputField = ({
/>
<rb.Button
variant="outline-dark"
className={styles.button}
onClick={() => setDestinationJarPickerShown(true)}
disabled={disabled || !walletInfo}
>
Expand Down
6 changes: 6 additions & 0 deletions src/components/Send/SendForm.module.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
.blurred {
filter: blur(2px);
}

.input {
height: 3.5rem;
width: 100%;
border-radius: 0.25rem;
}
Loading

0 comments on commit dc95e64

Please sign in to comment.