chore(send): jars with frozen funds are selectable (#1370)

* chore(send): remove dev/prod distinction for minimum collaborators

this is already dynamically done by fetching the backend config value

* chore(send): jars with frozen funds are selectable

* ui(send): justify jars and increase space to precondition alert

* build: fix errors in test setups

* feat(send): open utxo selector on click on selected jar

* test: add tests for Jar and SelectableJar

* chore(ui): frozen balance with class 'invisible' instead of 'hidden'

* ui(receive): justify around jars

* chore(i18n): improve missing source jar utxos message
This commit is contained in:
Thebora Kompanioni 2026-07-30 11:35:19 +02:00 committed by GitHub
parent d5b38740c7
commit bce1df33e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 393 additions and 111 deletions

View file

@ -53,7 +53,7 @@ vi.mock('./wallet/WalletJarsDetailsOverlay', () => ({
),
}))
vi.mock('./ui/jam/Balance', () => ({
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => (
<span onClick={onClick}>{valueString}</span>
),
@ -74,6 +74,7 @@ const makeJar = (jarIndex: number, name: string): Jar =>
calculatedTotalBalanceInSats: 100,
calculatedAvailableBalanceInSats: 100,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedAvailableFrozenBalanceInSats: 0,
},
}) as unknown as Jar

View file

@ -53,16 +53,16 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
vi.mock('@/components/ui/jam/SelectableJar', () => ({
SelectableJar: ({
name,
onClick,
onSelect,
disabled,
isSelected,
}: {
name?: string
onClick?: () => void
onSelect?: () => void
disabled?: boolean
isSelected?: boolean
}) => (
<button onClick={onClick} disabled={disabled} data-selected={isSelected}>
<button onSelect={onSelect} disabled={disabled} data-selected={isSelected}>
{name}
</button>
),

View file

@ -34,7 +34,7 @@ vi.mock('../ui/jam/Address', () => ({
Address: ({ value }: { value?: string }) => <span data-testid="address">{value}</span>,
}))
vi.mock('../ui/jam/Balance', () => ({
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString?: string }) => <span data-testid="balance">{valueString}</span>,
}))

View file

@ -2,12 +2,12 @@ import { useMemo, type PropsWithChildren } from 'react'
import { ClockIcon, CoinsIcon, CopyIcon } from 'lucide-react'
import { Trans, useTranslation } from 'react-i18next'
import { Card, CardContent, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Balance } from '@/components/ui/jam/Balance'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import * as fb from '@/lib/fidelityBondUtils'
import { time } from '@/lib/utils'
import { Address } from '../ui/jam/Address'
import { Balance } from '../ui/jam/Balance'
interface FidelityBondCardProps {
value: FidelityBondUtxo

View file

@ -72,16 +72,16 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
vi.mock('@/components/ui/jam/SelectableJar', () => ({
SelectableJar: ({
name,
onClick,
onSelect,
disabled,
isSelected,
}: {
name?: string
onClick?: () => void
onSelect?: () => void
disabled?: boolean
isSelected?: boolean
}) => (
<button onClick={onClick} disabled={disabled} data-selected={isSelected}>
<button onClick={onSelect} disabled={disabled} data-selected={isSelected}>
{name}
</button>
),

View file

@ -18,7 +18,7 @@ vi.mock('@/lib/utils', () => ({
isRelativeOffer: (type: string) => type === 'sw0relo',
}))
vi.mock('../ui/jam/Balance', () => ({
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString?: string }) => <span data-testid="balance">{valueString}</span>,
}))

View file

@ -24,7 +24,7 @@ export function FidelityBondJarSelector({ selectedJarIndex, onSelect, isJarDisab
frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats}
isSelected={selectedJarIndex === jar.jarIndex}
onClick={() => onSelect(jar.jarIndex)}
onSelect={() => onSelect(jar.jarIndex)}
disabled={isJarDisabled(jar)}
/>
))}

View file

@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'
import { Link, useNavigate, type NavigateFunction } from 'react-router-dom'
import { DevBadge } from '@/components/dev/DevBadge'
import { Button } from '@/components/ui/button'
import { Balance } from '@/components/ui/jam/Balance'
import { ThemeToggleButton } from '@/components/ui/jam/ThemeToggleButton'
import { Skeleton } from '@/components/ui/skeleton'
import type { SidebarContextProps } from '@/components/ui/use-sidebar'
@ -15,7 +16,6 @@ import type { RescanInfo } from '@/context/JamSessionInfoContext'
import { cn, shortenStringMiddle } from '@/lib/utils'
import type { AmountSats } from '@/types/global'
import { WithActivityIndicator } from '../ui/jam/ActivityIndicator'
import { Balance } from '../ui/jam/Balance'
import { Spinner } from '../ui/spinner'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'

View file

@ -18,6 +18,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { Balance } from '@/components/ui/jam/Balance'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@ -36,7 +37,6 @@ import {
} from '@/lib/utils'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import { jmSessionStore } from '@/store/jmSessionStore'
import { Balance } from '../ui/jam/Balance'
import { Spinner } from '../ui/spinner'
import { OrderbookChart } from './OrderbookChart'
import { OrderbookTable, type OrderTableEntry } from './OrderbookTable'

View file

@ -19,8 +19,8 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
}))
vi.mock('@/components/ui/jam/SelectableJar', () => ({
SelectableJar: ({ name, isSelected, onClick }: { name: string; isSelected: boolean; onClick: () => void }) => (
<button type="button" aria-pressed={isSelected} onClick={onClick}>
SelectableJar: ({ name, isSelected, onSelect }: { name: string; isSelected: boolean; onSelect: () => void }) => (
<button type="button" aria-pressed={isSelected} onClick={onSelect}>
{name}
</button>
),

View file

@ -91,7 +91,7 @@ export const ReceiveForm = ({ className, defaultValues, onSubmit, jars, disabled
>
<Field className="space-y-4" data-invalid={errors.source !== undefined}>
<FieldLabel>{t('receive.label_source_jar')}</FieldLabel>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<div className="flex flex-1 flex-row flex-wrap items-start justify-around gap-8">
{jars.map((jar, index) => (
<SelectableJar
key={index}
@ -102,7 +102,7 @@ export const ReceiveForm = ({ className, defaultValues, onSubmit, jars, disabled
frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats}
isSelected={values.source?.fromJar === jar.jarIndex}
onClick={() => {
onSelect={() => {
setValue('source.fromJar', jar.jarIndex, { shouldValidate: true })
}}
disabled={disabled || isSubmitting}

View file

@ -11,16 +11,16 @@ vi.mock('react-i18next', () => ({
vi.mock('../ui/jam/SelectableJar', () => ({
SelectableJar: ({
name,
onClick,
onSelect,
disabled,
isSelected,
}: {
name?: string
onClick?: () => void
onSelect?: () => void
disabled?: boolean
isSelected?: boolean
}) => (
<button type="button" onClick={onClick} disabled={disabled} data-selected={isSelected}>
<button type="button" onClick={onSelect} disabled={disabled} data-selected={isSelected}>
{name}
</button>
),

View file

@ -90,7 +90,7 @@ export default function JarSelectorDialog({
frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats}
isSelected={selectedJarIndex === jar.jarIndex}
onClick={() =>
onSelect={() =>
setValue('jarIndex', jar.jarIndex, {
shouldDirty: true,
shouldTouch: true,

View file

@ -27,7 +27,7 @@ vi.mock('../ui/jam/Address', () => ({
Address: ({ value }: { value: string }) => <span>address:{value}</span>,
}))
vi.mock('../ui/jam/Balance', () => ({
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => <span>balance:{valueString}</span>,
}))
@ -50,6 +50,7 @@ const sourceJar: Jar = {
calculatedAvailableBalanceInSats: 50_000,
calculatedTotalBalanceInSats: 50_000,
calculatedConfirmedAvailableBalanceInSats: 50_000,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
},
color: '#e2b86a',

View file

@ -3,6 +3,7 @@ import { InfoIcon } from 'lucide-react'
import { Trans, useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { jarBadgeVariant } from '@/components/ui/badge-variants'
import { Balance } from '@/components/ui/jam/Balance'
import type { Jar } from '@/context/JamWalletInfoContext'
import type { Utxo } from '@/hooks/useQueryUtxos'
import type { JamFeeConfigValues } from '@/lib/feeConfig'
@ -13,7 +14,6 @@ import { Button } from '../ui/button'
import { Card, CardContent, CardHeader } from '../ui/card'
import { Dialog, DialogTitle, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '../ui/dialog'
import { Address } from '../ui/jam/Address'
import { Balance } from '../ui/jam/Balance'
import { Spinner } from '../ui/spinner'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { estimateMaxCollaboratorFee, useMiningFeeText, type EstimateMaxCollaboratorFeeResult } from './feeEstimate'

View file

@ -64,11 +64,28 @@ export const createSendFormSchema = (
.object({
source: yup
.object({
fromJar: sourceJarField(
t('send.feedback_invalid_source_jar'),
(jarIndex) =>
(jars.find((it) => it.jarIndex === jarIndex)?.balanceSummary.calculatedAvailableBalanceInSats || 0) > 0,
),
fromJar: sourceJarField(t('send.feedback_invalid_source_jar'), (jarIndex) =>
jars.some((it) => it.jarIndex === jarIndex),
)
.test(
'valid-source-jar-must-unfreeze-utxos-test',
t('send.feedback_invalid_source_jar_must_unfreeze_utxos'),
(jarIndex) => {
const jar = jars.find((it) => it.jarIndex === jarIndex)
if (!jar) return true
if (jar.balanceSummary.calculatedAvailableBalanceInSats > 0) return true
return jar.balanceSummary.calculatedAvailableFrozenBalanceInSats <= 0
},
)
.test(
'valid-source-jar-has-funds-test',
t('send.feedback_invalid_source_jar_no_available_utxos'),
(jarIndex) => {
const jar = jars.find((it) => it.jarIndex === jarIndex)
if (!jar) return true
return jar.balanceSummary.calculatedAvailableBalanceInSats > 0
},
),
})
.required(),
destination: yup

View file

@ -55,13 +55,13 @@ vi.mock('../ui/jam/Address', () => ({
Address: () => <div data-testid="address" />,
}))
vi.mock('../ui/jam/Balance', () => ({
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString?: string }) => <div data-testid="balance">{valueString}</div>,
}))
vi.mock('../ui/jam/SelectableJar', () => ({
SelectableJar: ({ name, onClick, disabled }: { name?: string; onClick?: () => void; disabled?: boolean }) => (
<button onClick={onClick} disabled={disabled}>
SelectableJar: ({ name, onSelect, disabled }: { name?: string; onSelect?: () => void; disabled?: boolean }) => (
<button onClick={onSelect} disabled={disabled}>
{name}
</button>
),

View file

@ -5,11 +5,11 @@ import { getAddressInfo, Network } from 'bitcoin-address-validation'
import type { AddressInfo } from 'bitcoin-address-validation'
import { AlertTriangleIcon, BrushCleaningIcon, MilkIcon, ScanQrCodeIcon, XIcon } from 'lucide-react'
import { FormProvider, useForm, useWatch } from 'react-hook-form'
import type { Resolver, SubmitHandler } from 'react-hook-form'
import type { FieldErrors, Resolver, SubmitHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import QrScannerDialog from '@/components/ui/QrScannerDialog'
import { isDevMode } from '@/constants/debugFeatures'
import { Balance } from '@/components/ui/jam/Balance'
import { MAX_NUM_COLLABORATORS } from '@/constants/jam'
import { JM_MINIMUM_MAKERS_DEFAULT, JM_TAKER_UTXO_AGE } from '@/constants/jm'
import { useDetectNetwork, type AddressSummary, type Jar } from '@/context/JamWalletInfoContext'
@ -31,7 +31,6 @@ import { Field, FieldDescription, FieldError, FieldLabel } from '../ui/field'
import { Input } from '../ui/input'
import { inputVariants } from '../ui/input-variants'
import { Address } from '../ui/jam/Address'
import { Balance } from '../ui/jam/Balance'
import { SatSymbol } from '../ui/jam/CurrencySymbol'
import { SelectableJar } from '../ui/jam/SelectableJar'
import { Slider } from '../ui/slider'
@ -94,12 +93,6 @@ const AddressFromJarSelectorDialog = ({
)
}
// set the default to one collaborator in dev mode
const DEV_INITIAL_NUM_COLLABORATORS_INPUT = 1
// TODO: this value should be dynamic via jm backend settings
const MIN_NUM_COLLABORATORS = isDevMode() ? DEV_INITIAL_NUM_COLLABORATORS_INPUT : JM_MINIMUM_MAKERS_DEFAULT
const FieldPrefixSatSymbol = (
<SatSymbol
width={'18px'}
@ -114,7 +107,8 @@ interface SendFormProps {
className?: string
onSubmit: SubmitHandler<SendFormValues>
onSourceJarChange?: (jarIndex: JarIndex | undefined) => void
sourceJarLabelButton?: React.ReactElement
onSourceJarClicked?: (jarIndex: JarIndex | undefined) => void
sourceJarLabelButton?: (errors?: FieldErrors<{ fromJar: number }>) => React.ReactElement
minNumberOfCollaborators?: number
feeConfigValues: JamFeeConfigValues
walletFileName: WalletFileName
@ -129,11 +123,12 @@ export function SendForm({
className,
onSubmit,
onSourceJarChange,
onSourceJarClicked,
sourceJarLabelButton,
disabled,
feeConfigValues,
walletFileName,
minNumberOfCollaborators = MIN_NUM_COLLABORATORS,
minNumberOfCollaborators = JM_MINIMUM_MAKERS_DEFAULT,
jars,
walletBalanceSummary,
addressSummary,
@ -204,6 +199,15 @@ export function SendForm({
onSourceJarChange?.(sourceJarIndex)
}, [onSourceJarChange, sourceJarIndex])
useEffect(() => {
if (formState.dirtyFields.source?.fromJar) {
console.debug(
'Validating `source.fromJar` as wallet balance summary changed (e.g. frozen/unfrozen utxos, new transaction, etc.)',
)
void trigger('source.fromJar')
}
}, [walletBalanceSummary, trigger, formState.dirtyFields.source?.fromJar])
const destinationJar = useMemo(() => {
if (destinationJarIndex === undefined) return
return jars.find((it) => it.jarIndex === destinationJarIndex)
@ -300,41 +304,58 @@ export function SendForm({
{...sendFormMethods}
>
<form onSubmit={(event) => void doOnSubmit(event)} className={cn('flex flex-col gap-4', className)} noValidate>
<div className="space-y-2">
<Field className="space-y-4" data-invalid={errors.source !== undefined}>
<div className="space-y-4">
<Field className="space-y-4" data-invalid={errors.source?.fromJar !== undefined}>
<div className="flex flex-wrap items-center justify-between gap-2">
<FieldLabel>{t('send.label_source_jar')}</FieldLabel>
{sourceJarLabelButton && <>{sourceJarLabelButton}</>}
{sourceJarLabelButton?.(errors.source)}
</div>
<div className="flex flex-1 flex-row flex-wrap items-center justify-center gap-8">
{jars.map((jar, index) => (
<SelectableJar
key={index}
name={jar.name}
color={jar.color}
totalBalance={jar.balanceSummary.calculatedTotalBalanceInSats}
availableBalance={jar.balanceSummary.calculatedAvailableBalanceInSats}
frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats}
isSelected={sourceJarIndex === jar.jarIndex}
onClick={() => {
setValue('source.fromJar', jar.jarIndex, { shouldValidate: true })
<div className="flex flex-1 flex-row flex-wrap items-start justify-around gap-8">
{jars.map((jar, index) => {
const noFunds =
jar.balanceSummary.calculatedAvailableBalanceInSats <= 0 &&
jar.balanceSummary.calculatedAvailableFrozenBalanceInSats <= 0
if (isSweep === true) {
setValue('amount.isSweep', false, { shouldValidate: true })
setValue('amount.sweepAmount', undefined, { shouldValidate: true })
setValue('amount.amount', undefined, { shouldValidate: true })
}
if (destinationJarIndex === jar.jarIndex) {
setValue('destination.address', '', { shouldValidate: true })
setValue('destination.fromJar', undefined, { shouldValidate: true })
} else if (destinationAddress !== undefined) {
void trigger('destination.address')
}
}}
disabled={disabled || jar.balanceSummary.calculatedAvailableBalanceInSats <= 0}
/>
))}
const isSelected = sourceJarIndex === jar.jarIndex
return (
<SelectableJar
key={index}
name={jar.name}
color={jar.color}
totalBalance={jar.balanceSummary.calculatedTotalBalanceInSats}
availableBalance={jar.balanceSummary.calculatedAvailableBalanceInSats}
frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats}
isSelected={isSelected}
onClick={() => {
if (isSelected) {
onSourceJarClicked?.(sourceJarIndex)
}
}}
onSelect={() => {
setValue('source.fromJar', jar.jarIndex, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
})
if (isSweep === true) {
setValue('amount.isSweep', false, { shouldValidate: true })
setValue('amount.sweepAmount', undefined, { shouldValidate: true })
setValue('amount.amount', undefined, { shouldValidate: true })
}
if (destinationJarIndex === jar.jarIndex) {
setValue('destination.address', '', { shouldValidate: true })
setValue('destination.fromJar', undefined, { shouldValidate: true })
} else if (destinationAddress !== undefined) {
void trigger('destination.address')
}
}}
disabled={disabled || noFunds}
/>
)
})}
</div>
{errors.source?.fromJar?.message ? <FieldError>{errors.source?.fromJar.message}</FieldError> : null}
</Field>

View file

@ -209,11 +209,11 @@ vi.mock('./SendForm', () => ({
disabled?: boolean
onSourceJarChange?: (jarIndex: number | undefined) => void
onSubmit: (values: SendFormValues) => void
sourceJarLabelButton?: React.ReactElement
sourceJarLabelButton?: () => React.ReactElement
}) => (
<div>
send-form:{String(disabled)}
<div>{sourceJarLabelButton}</div>
<div>{sourceJarLabelButton?.()}</div>
<button
onClick={() => {
onSourceJarChange?.(0)
@ -241,6 +241,7 @@ vi.mock('./UtxoSelectionDialog', () => ({
const balanceSummary = {
calculatedAvailableBalanceInSats: 20_000,
calculatedConfirmedAvailableBalanceInSats: 20_000,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedTotalBalanceInSats: 20_000,
}

View file

@ -34,6 +34,7 @@ import type { UtxoId } from '@/hooks/useQueryUtxos'
import { useRefreshSession } from '@/hooks/useRefreshSession'
import { useUtxoSelectionDialog } from '@/hooks/useUtxoSelectionDialog'
import { getErrorReason } from '@/lib/errorReason'
import * as fb from '@/lib/fidelityBondUtils'
import { withMutationDelay } from '@/lib/queryClient'
import { scrollToTop, type WalletFileName } from '@/lib/utils'
import { useDeveloperMode } from '@/store/jamSettingsStore'
@ -114,15 +115,20 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
return jars.find((it) => it.jarIndex === sourceJarIndex)
}, [jars, sourceJarIndex])
const utxoSelectionDialog = useUtxoSelectionDialog({
const { onOpenUtxoSelector, ...utxoSelectionDialog } = useUtxoSelectionDialog({
walletFileName,
sourceJar,
addressSummary,
})
const availableUtxosForPayment = useMemo(() => {
return (sourceJar?.utxos || []).filter((utxo) => !utxo.frozen).toSorted((a, b) => a.confirmations - b.confirmations)
}, [sourceJar])
const isCoinJoin = sendFromValuesAwaitingConfirmation?.isCoinJoin ?? true // assume true
return (sourceJar?.utxos || [])
.filter((utxo) => !utxo.frozen)
.filter((utxo) => !fb.utxo.isLocked(utxo))
.filter((utxo) => (isCoinJoin ? true : !fb.utxo.isFidelityBond(utxo)))
.toSorted((a, b) => a.confirmations - b.confirmations)
}, [sourceJar, sendFromValuesAwaitingConfirmation?.isCoinJoin])
const destinationJar = useMemo(() => {
const destinationJarIndex = sendFromValuesAwaitingConfirmation?.destination?.fromJar
@ -604,17 +610,23 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
}
debug={isDeveloperMode}
onSourceJarChange={setSourceJarIndex}
sourceJarLabelButton={
<Button
type="button"
variant="outline"
disabled={utxoSelectionDialog.utxoSelectorDisabled}
onClick={utxoSelectionDialog.onOpenUtxoSelector}
>
<ListFilterIcon />
{t('show_utxos.text_select_utxos_tooltip')}
</Button>
}
onSourceJarClicked={onOpenUtxoSelector}
sourceJarLabelButton={(errors) => {
const errorSolvedByUtxoSelection = errors?.fromJar?.type === 'valid-source-jar-must-unfreeze-utxos-test'
const animate = utxoSelectionDialog.dialogProps.open === false && errorSolvedByUtxoSelection
return (
<Button
type="button"
variant="outline"
className={animate ? 'animate-shake' : undefined}
disabled={utxoSelectionDialog.utxoSelectorDisabled}
onClick={onOpenUtxoSelector}
>
<ListFilterIcon className={animate ? 'motion-safe:animate-bounce' : undefined} />
{t('show_utxos.text_select_utxos_tooltip')}
</Button>
)
}}
/>
</CardContent>
</Card>

View file

@ -303,6 +303,7 @@ const makeWalletInfo = (overrides: Partial<WalletInfo> = {}): WalletInfo => {
calculatedAvailableBalanceInSats: 100_000,
calculatedTotalBalanceInSats: 100_000,
calculatedConfirmedAvailableBalanceInSats: 100_000,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
},
color: '#e2b86a',
@ -315,6 +316,7 @@ const makeWalletInfo = (overrides: Partial<WalletInfo> = {}): WalletInfo => {
calculatedAvailableBalanceInSats: 0,
calculatedTotalBalanceInSats: 0,
calculatedConfirmedAvailableBalanceInSats: 0,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
},
color: '#ccc',
@ -341,6 +343,7 @@ const makeWalletInfo = (overrides: Partial<WalletInfo> = {}): WalletInfo => {
calculatedAvailableBalanceInSats: 100_000,
calculatedTotalBalanceInSats: 100_000,
calculatedConfirmedAvailableBalanceInSats: 100_000,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
},
walletName: 'wallet.jmdat',

View file

@ -3,8 +3,8 @@ import '@testing-library/jest-dom/vitest'
import { render as reactRender, screen, type RenderOptions } from '@testing-library/react'
import user from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { Balance } from '@/components/ui/jam/Balance'
import { JamDisplayContextProvider } from '@/context/JamDisplayContextProvider'
import { Balance } from './Balance'
const render = (ui: React.ReactNode, options?: Omit<RenderOptions, 'queries'>) => {
const providers = ({ children }: { children: React.ReactNode }) => {

View file

@ -0,0 +1,70 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { type Jar as JarType } from '@/context/JamWalletInfoContext'
import type { AmountSats } from '@/types/global'
import { Jar } from './Jar'
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => (
<span onClick={onClick}>balance:{valueString}</span>
),
}))
const DUMMY_JAR: JarType = {
balanceSummary: {
calculatedTotalBalanceInSats: 5,
calculatedAvailableBalanceInSats: 4,
calculatedConfirmedAvailableBalanceInSats: 3,
calculatedAvailableFrozenBalanceInSats: 2,
calculatedFrozenOrLockedBalanceInSats: 1,
},
color: '#666',
jarIndex: 0,
name: 'Test jar',
utxos: [],
}
describe('Jar', () => {
it('renders correctly', () => {
render(
<Jar
name={DUMMY_JAR.name}
color={DUMMY_JAR.color}
totalBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats}
availableBalance={DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats}
frozenOrLockedBalance={DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
totalWalletBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats + 1}
isSelected={false}
/>,
)
expect(screen.getByText(DUMMY_JAR.name)).toBeInTheDocument()
expect(screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats)).toBeInTheDocument()
expect(
screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats),
).toBeInTheDocument()
expect(
screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats).parentNode,
).not.toHaveClass('invisible')
})
it('does not render zero frozen balance', () => {
const frozenBalance: AmountSats = 0
render(
<Jar
name={DUMMY_JAR.name}
color={DUMMY_JAR.color}
frozenOrLockedBalance={frozenBalance}
totalBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats}
availableBalance={DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats}
totalWalletBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats + 1}
isSelected={false}
/>,
)
expect(screen.getByText(DUMMY_JAR.name)).toBeInTheDocument()
expect(screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats)).toBeInTheDocument()
expect(screen.getByText('balance:' + frozenBalance)).toBeInTheDocument()
expect(screen.getByText('balance:' + frozenBalance).parentNode).toHaveClass('invisible')
})
})

View file

@ -1,7 +1,7 @@
import { Balance } from '@/components/ui/jam/Balance'
import type { JarColor } from '@/context/JamWalletInfoContext'
import { cn } from '@/lib/utils'
import type { AmountSats } from '@/types/global'
import { Balance } from './Balance'
import { JarIcon } from './JarIcon'
interface JarProps {
@ -53,7 +53,7 @@ export function Jar({
</div>
<div
className={cn('text-brand-info flex min-w-[110px] items-center justify-center gap-1 text-xs', {
hidden: frozenOrLockedBalance <= 0,
invisible: frozenOrLockedBalance <= 0,
})}
>
<Balance valueString={String(frozenOrLockedBalance)} frozen={true} />

View file

@ -0,0 +1,131 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach } from 'vitest'
import type { Jar } from '@/context/JamWalletInfoContext'
import { SelectableJar } from './SelectableJar'
const mocks = vi.hoisted(() => ({
onClick: vi.fn(),
onSelect: vi.fn(),
}))
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => (
<span onClick={onClick}>balance:{valueString}</span>
),
}))
const DUMMY_JAR: Jar = {
balanceSummary: {
calculatedTotalBalanceInSats: 5,
calculatedAvailableBalanceInSats: 4,
calculatedConfirmedAvailableBalanceInSats: 3,
calculatedAvailableFrozenBalanceInSats: 2,
calculatedFrozenOrLockedBalanceInSats: 1,
},
color: '#666',
jarIndex: 0,
name: 'Test jar',
utxos: [],
}
describe('SelectableJar', () => {
beforeEach(() => {
mocks.onClick.mockReset()
mocks.onSelect.mockReset()
})
it('renders correctly', () => {
render(
<SelectableJar
name={DUMMY_JAR.name}
color={DUMMY_JAR.color}
totalBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats}
availableBalance={DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats}
frozenOrLockedBalance={DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
totalWalletBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats + 1}
isSelected={false}
onSelect={mocks.onSelect}
onClick={mocks.onClick}
/>,
)
expect(screen.getByText(DUMMY_JAR.name)).toBeInTheDocument()
expect(screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats)).toBeInTheDocument()
expect(
screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats),
).toBeInTheDocument()
})
it('can be selected', async () => {
const user = userEvent.setup()
render(
<SelectableJar
name={DUMMY_JAR.name}
color={DUMMY_JAR.color}
totalBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats}
availableBalance={DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats}
frozenOrLockedBalance={DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
totalWalletBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats + 1}
isSelected={false}
onSelect={mocks.onSelect}
onClick={mocks.onClick}
/>,
)
await user.click(screen.getByRole('button'))
expect(mocks.onClick).toHaveBeenCalled()
expect(mocks.onSelect).toHaveBeenCalled()
})
it('can not be selected if disabled', async () => {
const user = userEvent.setup()
render(
<SelectableJar
name={DUMMY_JAR.name}
color={DUMMY_JAR.color}
totalBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats}
availableBalance={DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats}
frozenOrLockedBalance={DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
totalWalletBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats + 1}
isSelected={false}
onSelect={mocks.onSelect}
onClick={mocks.onClick}
disabled={true}
/>,
)
await user.click(screen.getByRole('button'))
expect(mocks.onClick).not.toHaveBeenCalled()
expect(mocks.onSelect).not.toHaveBeenCalled()
})
it('does not call onSelect if already selected', async () => {
const user = userEvent.setup()
render(
<SelectableJar
name={DUMMY_JAR.name}
color={DUMMY_JAR.color}
totalBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats}
availableBalance={DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats}
frozenOrLockedBalance={DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
totalWalletBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats + 1}
isSelected={true}
onSelect={mocks.onSelect}
onClick={mocks.onClick}
disabled={false}
/>,
)
await user.click(screen.getByRole('button'))
expect(mocks.onClick).toHaveBeenCalled()
expect(mocks.onSelect).not.toHaveBeenCalled()
})
})

View file

@ -4,7 +4,8 @@ import { Jar } from './Jar'
interface SelectableJarProps extends ComponentProps<typeof Jar> {
isSelected: NonNullable<React.ComponentProps<'input'>['checked']>
onClick: NonNullable<React.ComponentProps<'input'>['onChange']>
onClick?: NonNullable<React.ComponentProps<'input'>['onClick']>
onSelect: NonNullable<React.ComponentProps<'input'>['onChange']>
}
export const SelectableJar = ({
@ -16,20 +17,19 @@ export const SelectableJar = ({
totalWalletBalance,
isSelected,
disabled = false,
onSelect,
onClick,
}: SelectableJarProps) => {
const radioRef = useRef<HTMLInputElement>(null)
return (
<button
type="button"
className={cn('flex flex-col items-center gap-4', {
className={cn('flex flex-col items-center gap-1.5', {
'cursor-pointer': !disabled,
'cursor-not-allowed': disabled,
})}
onClick={() => {
if (radioRef.current !== null) {
radioRef.current.click()
}
radioRef.current?.click()
}}
tabIndex={-1}
>
@ -49,7 +49,8 @@ export const SelectableJar = ({
ref={radioRef}
type="radio"
checked={isSelected}
onChange={(event) => !disabled && onClick(event)}
onClick={onClick}
onChange={(event) => !disabled && onSelect(event)}
className={cn(
'light:border-black/50 inline-block h-[1.5rem] w-[1.5rem] appearance-none rounded-full border-1 border-white/50',
{

View file

@ -14,7 +14,7 @@ vi.mock('../ui/jam/Address', () => ({
Address: ({ value }: { value: string }) => <span>{value}</span>,
}))
vi.mock('../ui/jam/Balance', () => ({
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => <span>{valueString}</span>,
}))

View file

@ -19,6 +19,7 @@ import {
type Table as TableType,
} from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { Balance } from '@/components/ui/jam/Balance'
import { TablePagination } from '@/components/ui/jam/TablePagination'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import type { AccountBranch } from '@/context/JamWalletInfoContext'
@ -26,7 +27,6 @@ import type { UtxoTag } from '@/lib/tags'
import { cn } from '@/lib/utils'
import type { AmountSats, BitcoinAddress, HdPath } from '@/types/global'
import { Address } from '../ui/jam/Address'
import { Balance } from '../ui/jam/Balance'
import { SortIcon } from '../ui/jam/SortIcon'
import { StatusBadge } from '../ui/jam/StatusBadge'

View file

@ -26,7 +26,7 @@ vi.mock('../ui/jam/Address', () => ({
Address: ({ value }: { value: string }) => <span>{value}</span>,
}))
vi.mock('../ui/jam/Balance', () => ({
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => <span>{valueString}</span>,
}))

View file

@ -28,6 +28,7 @@ import type { TFunction } from 'i18next'
import { ChevronDownIcon, SnowflakeIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Balance } from '@/components/ui/jam/Balance'
import { TablePagination } from '@/components/ui/jam/TablePagination'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import type { Utxo } from '@/hooks/useQueryUtxos'
@ -38,7 +39,6 @@ import { Button } from '../ui/button'
import { Card, CardContent } from '../ui/card'
import { Checkbox } from '../ui/checkbox'
import { Address } from '../ui/jam/Address'
import { Balance } from '../ui/jam/Balance'
import { SortIcon } from '../ui/jam/SortIcon'
import { StatusBadge } from '../ui/jam/StatusBadge'

View file

@ -51,7 +51,7 @@ vi.mock('../ui/jam/Address', () => ({
Address: ({ value }: { value: string }) => <span>{value}</span>,
}))
vi.mock('../ui/jam/Balance', () => ({
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => <span>{valueString}</span>,
}))
@ -82,6 +82,7 @@ const jars: Jar[] = [
calculatedAvailableBalanceInSats: 12_000,
calculatedConfirmedAvailableBalanceInSats: 12_000,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedTotalBalanceInSats: 12_000,
},
color: '#e2b86a',
@ -94,6 +95,7 @@ const jars: Jar[] = [
calculatedAvailableBalanceInSats: 5_000,
calculatedConfirmedAvailableBalanceInSats: 5_000,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedTotalBalanceInSats: 5_000,
},
color: '#3b5ba9',
@ -106,6 +108,7 @@ const jars: Jar[] = [
calculatedAvailableBalanceInSats: 8_000,
calculatedConfirmedAvailableBalanceInSats: 8_000,
calculatedFrozenOrLockedBalanceInSats: 8_000,
calculatedAvailableFrozenBalanceInSats: 8_000,
calculatedTotalBalanceInSats: 8_000,
},
color: '#5ba93b',

View file

@ -6,6 +6,7 @@ import type { TFunction } from 'i18next'
import { AlertTriangleIcon, RefreshCwIcon, ThermometerSnowflakeIcon, ThermometerSunIcon } from 'lucide-react'
import { Trans, useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Balance } from '@/components/ui/jam/Balance'
import { useJamSessionInfoContext } from '@/context/JamSessionInfoContext'
import {
useAccountSummary,
@ -26,7 +27,6 @@ import { Alert, AlertDescription, AlertTitle } from '../ui/alert'
import { Button } from '../ui/button'
import { ButtonGroup } from '../ui/button-group'
import { Input } from '../ui/input'
import { Balance } from '../ui/jam/Balance'
import { Spinner } from '../ui/spinner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'
import { AccountDetailsTabContent } from './AccountDetailsTabContent'

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { yupResolver } from '@hookform/resolvers/yup'
import { freezeMutation } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
import { useMutation } from '@tanstack/react-query'
@ -116,7 +116,7 @@ export const useUtxoSelectionDialog = ({ walletFileName, sourceJar, addressSumma
},
})
const onOpenUtxoSelector = () => {
const onOpenUtxoSelector = useCallback(() => {
if (!sourceJar) return
toast.dismiss(SEND_AUTO_SELECTION_TOAST_ID)
reset({
@ -124,7 +124,7 @@ export const useUtxoSelectionDialog = ({ walletFileName, sourceJar, addressSumma
rowSelection: initialRowSelection,
})
setOpen(true)
}
}, [sourceJar, reset, initialRowSelection])
const onSubmit = handleSubmit(async ({ rowSelection: submittedRowSelection }) => {
if (!sourceJar) return
@ -239,7 +239,7 @@ export const useUtxoSelectionDialog = ({ walletFileName, sourceJar, addressSumma
shouldValidate: true,
})
},
onSubmit: async () => onSubmit(),
onSubmit,
}
return {

View file

@ -440,6 +440,8 @@
"qr_scan_file_no_qr": "No QR code found in the selected image.",
"qr_scan_bip21_applied": "Payment request applied.",
"feedback_invalid_source_jar": "Please select a jar to send from.",
"feedback_invalid_source_jar_must_unfreeze_utxos": "Please select UTXOs from the source jar.",
"feedback_invalid_source_jar_no_available_utxos": "Please select a jar with spendable balance to send from.",
"feedback_invalid_destination_address": "Please enter a valid destination address.",
"feedback_destination_network_mismatch": "This address does not match your wallet's network.",
"feedback_reused_address": "This address is already used. To preserve your privacy please choose another one.",

View file

@ -44,6 +44,7 @@ describe('BalanceSummary', () => {
calculatedTotalBalanceInSats: 11,
calculatedAvailableBalanceInSats: 6,
calculatedConfirmedAvailableBalanceInSats: 1,
calculatedAvailableFrozenBalanceInSats: 3,
calculatedFrozenOrLockedBalanceInSats: 5,
})
})
@ -86,6 +87,7 @@ describe('BalanceSummary', () => {
calculatedTotalBalanceInSats: 677777777,
calculatedAvailableBalanceInSats: 333333333,
calculatedConfirmedAvailableBalanceInSats: 111111111,
calculatedAvailableFrozenBalanceInSats: 344444444,
calculatedFrozenOrLockedBalanceInSats: 344444444,
})
})
@ -136,6 +138,7 @@ describe('BalanceSummary', () => {
calculatedTotalBalanceInSats: 11,
calculatedAvailableBalanceInSats: 5,
calculatedConfirmedAvailableBalanceInSats: 0,
calculatedAvailableFrozenBalanceInSats: 3,
calculatedFrozenOrLockedBalanceInSats: 6,
})
})

View file

@ -16,6 +16,10 @@ export type BalanceSummary = {
* @description Manually calculated confirmed available balance in sats.
*/
calculatedConfirmedAvailableBalanceInSats: AmountSats
/**
* @description Manually calculated frozen balance in sats, that can be unfrozen.
*/
calculatedAvailableFrozenBalanceInSats: AmountSats
/**
* @description Manually calculated frozen or locked balance in sats.
*/
@ -26,6 +30,7 @@ export const BALANCE_SUMMARY_EMPTY: BalanceSummary = {
calculatedTotalBalanceInSats: 0,
calculatedAvailableBalanceInSats: 0,
calculatedConfirmedAvailableBalanceInSats: 0,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
}
@ -34,14 +39,17 @@ export const toBalanceSummary = (utxos: Utxo[], now?: Milliseconds): BalanceSumm
return utxos
.map((utxo) => {
const isFidelityBond = fb.utxo.isFidelityBond(utxo)
const frozenOrLocked = utxo.frozen || (isFidelityBond && fb.utxo.isLocked(utxo, refTime))
const isLocked = isFidelityBond && fb.utxo.isLocked(utxo, refTime)
const frozen = utxo.frozen && !isLocked
const frozenOrLocked = frozen || isLocked
const available = !frozenOrLocked
return {
total: utxo.value,
available: available ? utxo.value : 0,
confirmedAvailable: available && utxo.confirmations > 0 ? utxo.value : 0,
frozenOrLocked: frozenOrLocked ? utxo.value : 0,
bond: fb.utxo.isFidelityBond(utxo) ? utxo.value : 0,
frozen: frozen ? utxo.value : 0,
bond: isFidelityBond ? utxo.value : 0,
}
})
.reduce(
@ -50,6 +58,7 @@ export const toBalanceSummary = (utxos: Utxo[], now?: Milliseconds): BalanceSumm
calculatedAvailableBalanceInSats: acc.calculatedAvailableBalanceInSats + info.available,
calculatedConfirmedAvailableBalanceInSats:
acc.calculatedConfirmedAvailableBalanceInSats + info.confirmedAvailable,
calculatedAvailableFrozenBalanceInSats: acc.calculatedAvailableFrozenBalanceInSats + info.frozen,
calculatedFrozenOrLockedBalanceInSats: acc.calculatedFrozenOrLockedBalanceInSats + info.frozenOrLocked,
}),
BALANCE_SUMMARY_EMPTY,

View file

@ -76,6 +76,7 @@ export const Selected: Story = {
frozenOrLockedBalance: 0,
totalWalletBalance: 500_000,
isSelected: true,
onSelect: () => alert('Selected'),
onClick: () => alert('Selected jar clicked'),
},
parameters: {
@ -97,6 +98,7 @@ export const Unselected: Story = {
frozenOrLockedBalance: 0,
totalWalletBalance: 500_000,
isSelected: false,
onSelect: () => alert('Selected'),
onClick: () => alert('Unselected jar clicked'),
},
parameters: {
@ -118,6 +120,7 @@ export const EmptyJar: Story = {
frozenOrLockedBalance: 0,
totalWalletBalance: 500_000,
isSelected: false,
onSelect: () => alert('Selected'),
onClick: () => alert('Empty jar clicked'),
},
parameters: {
@ -139,6 +142,7 @@ export const FullJar: Story = {
frozenOrLockedBalance: 0,
totalWalletBalance: 500_000,
isSelected: false,
onSelect: () => alert('Selected'),
onClick: () => alert('Full jar clicked'),
},
parameters: {
@ -161,6 +165,7 @@ export const DisabledJar: Story = {
totalWalletBalance: 500_000,
isSelected: false,
disabled: true,
onSelect: () => alert('Selected'),
onClick: () => alert('Disabled jar clicked'),
},
parameters: {
@ -182,6 +187,7 @@ export const JarGrid: Story = {
frozenOrLockedBalance: 0,
totalWalletBalance: 500_000,
isSelected: true,
onSelect: () => alert('Selected'),
onClick: () => alert('Jar clicked'),
},
parameters: {
@ -216,7 +222,7 @@ export const JarGrid: Story = {
totalWalletBalance={totalWalletBalance}
isSelected={jar.isSelected}
disabled={jar.disabled}
onClick={() => alert(`Clicked on ${jar.name}`)}
onSelect={() => alert(`Clicked on ${jar.name}`)}
/>
))}
</div>
@ -235,6 +241,7 @@ export const Interactive: Story = {
frozenOrLockedBalance: 100_000,
totalWalletBalance: 500_000,
isSelected: false,
onSelect: () => alert('Selected'),
onClick: () => alert('Interactive jar clicked'),
},
parameters: {