{
- setValue('source.fromJar', jar.jarIndex, { shouldValidate: true })
+
+ {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 (
+ {
+ 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}
+ />
+ )
+ })}
{errors.source?.fromJar?.message ? {errors.source?.fromJar.message} : null}
diff --git a/src/components/send/SendPage.test.tsx b/src/components/send/SendPage.test.tsx
index 5aa3bd87..d7ec0b85 100644
--- a/src/components/send/SendPage.test.tsx
+++ b/src/components/send/SendPage.test.tsx
@@ -209,11 +209,11 @@ vi.mock('./SendForm', () => ({
disabled?: boolean
onSourceJarChange?: (jarIndex: number | undefined) => void
onSubmit: (values: SendFormValues) => void
- sourceJarLabelButton?: React.ReactElement
+ sourceJarLabelButton?: () => React.ReactElement
}) => (
send-form:{String(disabled)}
-
{sourceJarLabelButton}
+
{sourceJarLabelButton?.()}
diff --git a/src/components/ui/jam/SelectableJar.test.tsx b/src/components/ui/jam/SelectableJar.test.tsx
new file mode 100644
index 00000000..cb7c7a6e
--- /dev/null
+++ b/src/components/ui/jam/SelectableJar.test.tsx
@@ -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 }) => (
+ balance:{valueString}
+ ),
+}))
+
+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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ await user.click(screen.getByRole('button'))
+
+ expect(mocks.onClick).toHaveBeenCalled()
+ expect(mocks.onSelect).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/components/ui/jam/SelectableJar.tsx b/src/components/ui/jam/SelectableJar.tsx
index 60e76f9b..a526b7cf 100644
--- a/src/components/ui/jam/SelectableJar.tsx
+++ b/src/components/ui/jam/SelectableJar.tsx
@@ -4,7 +4,8 @@ import { Jar } from './Jar'
interface SelectableJarProps extends ComponentProps {
isSelected: NonNullable['checked']>
- onClick: NonNullable['onChange']>
+ onClick?: NonNullable['onClick']>
+ onSelect: NonNullable['onChange']>
}
export const SelectableJar = ({
@@ -16,20 +17,19 @@ export const SelectableJar = ({
totalWalletBalance,
isSelected,
disabled = false,
+ onSelect,
onClick,
}: SelectableJarProps) => {
const radioRef = useRef(null)
return (
{
- 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',
{
diff --git a/src/components/wallet/BranchEntryTable.test.tsx b/src/components/wallet/BranchEntryTable.test.tsx
index 9c5253f5..ab0af3bb 100644
--- a/src/components/wallet/BranchEntryTable.test.tsx
+++ b/src/components/wallet/BranchEntryTable.test.tsx
@@ -14,7 +14,7 @@ vi.mock('../ui/jam/Address', () => ({
Address: ({ value }: { value: string }) => {value},
}))
-vi.mock('../ui/jam/Balance', () => ({
+vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => {valueString},
}))
diff --git a/src/components/wallet/BranchEntryTable.tsx b/src/components/wallet/BranchEntryTable.tsx
index 9504f6d2..f3cb314d 100644
--- a/src/components/wallet/BranchEntryTable.tsx
+++ b/src/components/wallet/BranchEntryTable.tsx
@@ -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'
diff --git a/src/components/wallet/JarUtxosTable.test.tsx b/src/components/wallet/JarUtxosTable.test.tsx
index bcb81cb6..213a0914 100644
--- a/src/components/wallet/JarUtxosTable.test.tsx
+++ b/src/components/wallet/JarUtxosTable.test.tsx
@@ -26,7 +26,7 @@ vi.mock('../ui/jam/Address', () => ({
Address: ({ value }: { value: string }) => {value},
}))
-vi.mock('../ui/jam/Balance', () => ({
+vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => {valueString},
}))
diff --git a/src/components/wallet/JarUtxosTable.tsx b/src/components/wallet/JarUtxosTable.tsx
index 3f2bed14..f6ac6e5a 100644
--- a/src/components/wallet/JarUtxosTable.tsx
+++ b/src/components/wallet/JarUtxosTable.tsx
@@ -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'
diff --git a/src/components/wallet/WalletJarsDetailsContent.test.tsx b/src/components/wallet/WalletJarsDetailsContent.test.tsx
index 0059db97..fc289e62 100644
--- a/src/components/wallet/WalletJarsDetailsContent.test.tsx
+++ b/src/components/wallet/WalletJarsDetailsContent.test.tsx
@@ -51,7 +51,7 @@ vi.mock('../ui/jam/Address', () => ({
Address: ({ value }: { value: string }) => {value},
}))
-vi.mock('../ui/jam/Balance', () => ({
+vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => {valueString},
}))
@@ -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',
diff --git a/src/components/wallet/WalletJarsDetailsContent.tsx b/src/components/wallet/WalletJarsDetailsContent.tsx
index e145599b..e088f2fa 100644
--- a/src/components/wallet/WalletJarsDetailsContent.tsx
+++ b/src/components/wallet/WalletJarsDetailsContent.tsx
@@ -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'
diff --git a/src/hooks/useUtxoSelectionDialog.ts b/src/hooks/useUtxoSelectionDialog.ts
index 1682b289..e647c249 100644
--- a/src/hooks/useUtxoSelectionDialog.ts
+++ b/src/hooks/useUtxoSelectionDialog.ts
@@ -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 {
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index 0b9c7c7b..0148c2d6 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -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.",
diff --git a/src/lib/balanceSummary.test.ts b/src/lib/balanceSummary.test.ts
index 3c2449c1..b12ce76e 100644
--- a/src/lib/balanceSummary.test.ts
+++ b/src/lib/balanceSummary.test.ts
@@ -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,
})
})
diff --git a/src/lib/balanceSummary.ts b/src/lib/balanceSummary.ts
index b0e65111..e261c496 100644
--- a/src/lib/balanceSummary.ts
+++ b/src/lib/balanceSummary.ts
@@ -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,
diff --git a/src/stories/jam/SelectableJar.stories.tsx b/src/stories/jam/SelectableJar.stories.tsx
index f04497d7..763a559f 100644
--- a/src/stories/jam/SelectableJar.stories.tsx
+++ b/src/stories/jam/SelectableJar.stories.tsx
@@ -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}`)}
/>
))}
@@ -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: {