Skip to content

Commit

Permalink
feat(Send): Fee breakdown table (#606)
Browse files Browse the repository at this point in the history
* Add `AccordionInfo` component

* Add fee breakdown UI

* Wrap `loadFeeConfigValues` in hook

I'm not sure about the correctness of using useEffect inside the useFeeConfigValues hook,
in particular regarding the effectiveness of the cleanup function (including `abortCtrl.abort()`)

* Wrap mining fee text function in hook

* Wrap estimated max collaborator fee in hook

* Get real fee values

* Fix TS error "TFunction is not generic"

* Improve clarity

* Fix popover header visibility in dark mode

* Use passive voice in "cannot estimate mining fee" text

Co-authored-by: Thebora Kompanioni <[email protected]>

* Fix past usage in "cannot estimate mining fee" text

* Fix popover dark mode colors

* Align UI to the new proposal from editwentyone

* ui: de-emphasize non-effective fee card content

* chore: fix browser warning for fee breakdown subtitle

validateDOMNesting(...): <a> cannot appear as a descendant of <a>.

* chore(wording): Fee Estimation -> Maximum collaborator fee limit

* chore: use css class text-small for fee breakdown labels

* chore: use useMemo in FeeBreakdown component

* feat(FeeBreakdown): open fee modal on click

* ui(FeeBreakdown): add less-or-equal sign

* ui(FeeBreakdown): show max collaborator fee value in title

* ui(FeeBreakdown): open cj fee section by default

* ui(FeeSettings): switch position of mining and collaborator fees

* ui(FeeBreakdown): now works with sweep transactions

* ui(FeeBreakdown): market maker fees -> collaborator fees

* fix(actions): temporarily print linter debug messages

* build(deps): update to prettier v2.8.7

* ui(FeeBreakdown): emphasize collaborator fee note

* fix(FeeBreakdown): improve handling of missing config values

* chore(FeeBreakdown): Remove unused `AccordionInfo`

* revert: temporarily print linter debug messages

---------

Co-authored-by: Thebora Kompanioni <[email protected]>
Co-authored-by: theborakompanioni <[email protected]>
  • Loading branch information
3 people authored Aug 28, 2023
1 parent c64a393 commit 7a6b920
Show file tree
Hide file tree
Showing 16 changed files with 500 additions and 211 deletions.
4 changes: 0 additions & 4 deletions src/components/Jam.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,3 @@
text-decoration: none;
color: var(--bs-body-color);
}

.small-text {
font-size: 0.8rem;
}
4 changes: 1 addition & 3 deletions src/components/Jam.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -391,9 +391,7 @@ export default function Jam({ wallet }: JamProps) {
<Sprite symbol="checkmark" width="25" height="25" className="text-secondary" />
<div className="d-flex flex-column">
<div>{t('scheduler.complete_wallet_title')}</div>
<div className={`text-secondary ${styles['small-text']}`}>
{t('scheduler.complete_wallet_subtitle')}
</div>
<div className="text-secondary text-small">{t('scheduler.complete_wallet_subtitle')}</div>
</div>
</div>
<>
Expand Down
90 changes: 45 additions & 45 deletions src/components/PaymentConfirmModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,57 +4,15 @@ import * as rb from 'react-bootstrap'
import Sprite from './Sprite'
import Balance from './Balance'
import { useSettings } from '../context/SettingsContext'
import { estimateMaxCollaboratorFee, FeeValues, toTxFeeValueUnit } from '../hooks/Fees'

import { isValidNumber } from '../utils'
import { FeeValues, useEstimatedMaxCollaboratorFee, toTxFeeValueUnit } from '../hooks/Fees'
import { ConfirmModal, ConfirmModalProps } from './Modal'

import styles from './PaymentConfirmModal.module.css'
import { AmountSats } from '../libs/JmWalletApi'
import { jarInitial } from './jars/Jar'
import { isValidNumber } from '../utils'

interface PaymentDisplayInfo {
sourceJarIndex?: JarIndex
destination: String
amount: AmountSats
isSweep: boolean
isCoinjoin: boolean
numCollaborators?: number
feeConfigValues?: FeeValues
showPrivacyInfo?: boolean
}

interface PaymentConfirmModalProps extends ConfirmModalProps {
data: PaymentDisplayInfo
}

export function PaymentConfirmModal({
data: {
sourceJarIndex,
destination,
amount,
isSweep,
isCoinjoin,
numCollaborators,
feeConfigValues,
showPrivacyInfo = true,
},
...confirmModalProps
}: PaymentConfirmModalProps) {
const useMiningFeeText = ({ feeConfigValues }: { feeConfigValues?: FeeValues }) => {
const { t } = useTranslation()
const settings = useSettings()

const estimatedMaxCollaboratorFee = useMemo(() => {
if (!isCoinjoin || !feeConfigValues) return null
if (!isValidNumber(amount) || !isValidNumber(numCollaborators)) return null
if (!isValidNumber(feeConfigValues.max_cj_fee_abs) || !isValidNumber(feeConfigValues.max_cj_fee_rel)) return null
return estimateMaxCollaboratorFee({
amount,
collaborators: numCollaborators!,
maxFeeAbs: feeConfigValues.max_cj_fee_abs!,
maxFeeRel: feeConfigValues.max_cj_fee_rel!,
})
}, [amount, isCoinjoin, numCollaborators, feeConfigValues])

const miningFeeText = useMemo(() => {
if (!feeConfigValues) return null
Expand Down Expand Up @@ -89,6 +47,48 @@ export function PaymentConfirmModal({
}
}, [t, feeConfigValues])

return miningFeeText
}

interface PaymentDisplayInfo {
sourceJarIndex?: JarIndex
destination: String
amount: AmountSats
isSweep: boolean
isCoinjoin: boolean
numCollaborators?: number
feeConfigValues?: FeeValues
showPrivacyInfo?: boolean
}

interface PaymentConfirmModalProps extends ConfirmModalProps {
data: PaymentDisplayInfo
}

export function PaymentConfirmModal({
data: {
sourceJarIndex,
destination,
amount,
isSweep,
isCoinjoin,
numCollaborators,
feeConfigValues,
showPrivacyInfo = true,
},
...confirmModalProps
}: PaymentConfirmModalProps) {
const { t } = useTranslation()
const settings = useSettings()

const miningFeeText = useMiningFeeText({ feeConfigValues })
const estimatedMaxCollaboratorFee = useEstimatedMaxCollaboratorFee({
isCoinjoin,
feeConfigValues,
amount,
numCollaborators: numCollaborators || null,
})

return (
<ConfirmModal {...confirmModalProps}>
<rb.Container className="mt-2">
Expand Down
2 changes: 1 addition & 1 deletion src/components/ScheduleProgress.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ const ScheduleProgress = ({ schedule }) => {
</Trans>
)}
</p>
<p className={['text-secondary', styles['text-small']].join(' ')}>{t('scheduler.progress_description')}</p>
<p className="text-secondary text-small">{t('scheduler.progress_description')}</p>
</div>
<div className={styles['schedule-progress']}>
<div className={styles['progress-container']}>
Expand Down
4 changes: 0 additions & 4 deletions src/components/ScheduleProgress.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,3 @@
width: 100%;
}
}

.text-small {
font-size: 0.8rem;
}
172 changes: 172 additions & 0 deletions src/components/Send/FeeBreakdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { useMemo, PropsWithChildren } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import classNames from 'classnames'
import Balance from '../Balance'
import * as rb from 'react-bootstrap'
import { useSettings } from '../../context/SettingsContext'
import { SATS, formatSats, factorToPercentage } from '../../utils'
import { FeeValues } from '../../hooks/Fees'
import { AmountSats } from '../../libs/JmWalletApi'

interface FeeBreakdownProps {
feeConfigValues?: FeeValues
numCollaborators: number | null
amount: number | null
onClick?: () => void
}

type FeeCardProps = {
amount: AmountSats | null
feeConfigValue: number | undefined
highlight: boolean
subtitle?: React.ReactNode
onClick?: () => void
}
const FeeCard = ({ amount, feeConfigValue, highlight, subtitle, onClick }: FeeCardProps) => {
const settings = useSettings()
const { t } = useTranslation()

return (
<rb.Card onClick={onClick} border={highlight ? (settings.theme === 'dark' ? 'light' : 'dark') : undefined}>
<rb.Card.Body
className={classNames('text-center py-2', {
'text-muted': !highlight,
})}
>
<div className="fs-5">
{feeConfigValue === undefined ? (
t('send.fee_breakdown.placeholder_config_value_not_present')
) : (
<>
{amount === null ? (
t('send.fee_breakdown.placeholder_amount_missing_amount')
) : (
<>
&le;
<Balance convertToUnit={SATS} valueString={amount.toString()} showBalance={true} />
</>
)}
</>
)}
</div>
<div className="text-secondary text-small">{subtitle}</div>
</rb.Card.Body>
</rb.Card>
)
}

const FeeBreakdown = ({
feeConfigValues,
numCollaborators,
amount,
onClick = () => {},
}: PropsWithChildren<FeeBreakdownProps>) => {
const { t } = useTranslation()

/** eg: "0.03%" */
const maxSettingsRelativeFee = useMemo(
() =>
feeConfigValues?.max_cj_fee_rel
? `${factorToPercentage(feeConfigValues.max_cj_fee_rel)}%`
: t('send.fee_breakdown.placeholder_config_value_not_present'),
[feeConfigValues, t]
)

/** eg: 44658 (expressed in sats) */
const maxEstimatedRelativeFee = useMemo(
() =>
feeConfigValues?.max_cj_fee_rel && numCollaborators && amount && amount > 0
? Math.ceil(amount * feeConfigValues.max_cj_fee_rel) * numCollaborators
: null,
[feeConfigValues, amount, numCollaborators]
)

/** eg: "8,636 sats" */
const maxSettingsAbsoluteFee = useMemo(
() =>
feeConfigValues?.max_cj_fee_abs
? `${formatSats(feeConfigValues.max_cj_fee_abs)} sats`
: t('send.fee_breakdown.placeholder_config_value_not_present'),
[feeConfigValues, t]
)

/** eg: 77724 (expressed in sats) */
const maxEstimatedAbsoluteFee = useMemo(
() =>
feeConfigValues?.max_cj_fee_abs && numCollaborators ? feeConfigValues.max_cj_fee_abs * numCollaborators : null,
[feeConfigValues, numCollaborators]
)

const isAbsoluteFeeHighlighted = useMemo(
() =>
maxEstimatedAbsoluteFee && maxEstimatedRelativeFee ? maxEstimatedAbsoluteFee > maxEstimatedRelativeFee : false,
[maxEstimatedAbsoluteFee, maxEstimatedRelativeFee]
)

const isRelativeFeeHighlighted = useMemo(
() =>
maxEstimatedAbsoluteFee && maxEstimatedRelativeFee ? maxEstimatedRelativeFee > maxEstimatedAbsoluteFee : false,
[maxEstimatedAbsoluteFee, maxEstimatedRelativeFee]
)

return (
<rb.Row className="mb-2">
<rb.Col>
<rb.Form.Label
className={classNames('mb-1', 'text-small', {
'text-muted': !isAbsoluteFeeHighlighted,
})}
>
{t('send.fee_breakdown.absolute_limit')}
</rb.Form.Label>
<FeeCard
highlight={isAbsoluteFeeHighlighted}
amount={maxEstimatedAbsoluteFee}
feeConfigValue={feeConfigValues?.max_cj_fee_abs}
subtitle={
<Trans
i18nKey="send.fee_breakdown.fee_card_subtitle"
components={{
1: <span className="text-decoration-underline link-secondary" />,
}}
values={{
numCollaborators,
maxFee: maxSettingsAbsoluteFee,
}}
/>
}
onClick={onClick}
/>
</rb.Col>
<rb.Col>
<rb.Form.Label
className={classNames('mb-1', 'text-small', {
'text-muted': !isRelativeFeeHighlighted,
})}
>
{t('send.fee_breakdown.relative_limit')}
</rb.Form.Label>
<FeeCard
highlight={isRelativeFeeHighlighted}
amount={maxEstimatedRelativeFee}
feeConfigValue={feeConfigValues?.max_cj_fee_rel}
subtitle={
<Trans
i18nKey="send.fee_breakdown.fee_card_subtitle"
components={{
1: <span className="text-decoration-underline link-secondary" />,
}}
values={{
numCollaborators,
maxFee: maxSettingsRelativeFee,
}}
/>
}
onClick={onClick}
/>
</rb.Col>
</rb.Row>
)
}

export default FeeBreakdown
Loading

0 comments on commit 7a6b920

Please sign in to comment.