chore(send): collaborative send (#1245)

* chore: reload balance after taker stopped

* chore(send): force open max fee config on submit if missing

* chore(send): store collaborative send attempt

* chore(send): show completion info based on collaborative send attempt

* chore: store payment attempt in session storage

* refactor: externalize PaymentAbortDialog

* chore: active maker/taker alerts
This commit is contained in:
Thebora Kompanioni 2026-05-18 09:01:36 +02:00 committed by GitHub
parent ee5770eeeb
commit 07e387795f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 566 additions and 373 deletions

View file

@ -45,7 +45,6 @@ export default defineConfig(
ignores: [
'./src/components/ui/*.tsx', // shadcn components
'./src/**/*.stories.tsx',
'./src/components/settings/CollaboratorFeesForm.tsx', // TODO: remove
],
files: ['./src/**/*.{ts,tsx}'],
languageOptions: {
@ -76,7 +75,6 @@ export default defineConfig(
extends: [eslintPluginUnicorn.configs.recommended],
ignores: [
'./src/components/ui/*.tsx', // shadcn components
'./src/components/settings/CollaboratorFeesForm.tsx', // TODO: remove
],
rules: {
'unicorn/filename-case': ['off'],

View file

@ -48,7 +48,7 @@ import { EarnReportPage } from './components/earn/report/EarnReportPage'
import { LockWalletConfirmDialog } from './components/ui/jam/LockWalletConfirmDialog'
import { Spinner } from './components/ui/spinner'
import { WalletJarsDetailsPage } from './components/wallet/WalletJarsDetailsPage'
import { useJamSessionInfoContext, useRescanStatus } from './context/JamSessionInfoContext'
import { useJamSessionInfoContext } from './context/JamSessionInfoContext'
import { JamSessionInfoContextProvider } from './context/JamSessionInfoContextProvider'
import { useJamWalletInfoContext } from './context/JamWalletInfoContext'
import { JmWebsocketContextProvider } from './context/JmWebsocketContextProvider'
@ -378,6 +378,7 @@ const RELOAD_WALLET_INFO_DELAY: {
AFTER_RESCAN: Milliseconds
AFTER_UTXO_CHANGE: Milliseconds
AFTER_BLOCK_HEIGHT_CHANGE: Milliseconds
AFTER_TAKER_STOPPED: Milliseconds
} = {
// After rescanning, it is necessary to give the JM backend some time to synchronize.
// A couple of seconds should be enough, however, this depends on the user hardware
@ -392,6 +393,9 @@ const RELOAD_WALLET_INFO_DELAY: {
// Small delay is sufficient after block height change
AFTER_BLOCK_HEIGHT_CHANGE: 210,
// Small delay is sufficient after block height change
AFTER_TAKER_STOPPED: 210,
}
/**
@ -404,10 +408,14 @@ const RELOAD_WALLET_INFO_DELAY: {
* always trigger a reload on demand and inform the user as they see fit.
*/
const WalletInfoAutoReload = () => {
const { rescanInfo: currentRescanInfo } = useRescanStatus()
const {
blockHeight: currentBlockHeight,
takerInfo: { running: currentTakerRunning },
rescanInfo: currentRescanInfo,
} = useJamSessionInfoContext()
const previousRescanningRef = useRef<boolean>(currentRescanInfo.rescanning)
const { blockHeight: currentBlockHeight } = useJamSessionInfoContext()
const previousBlockHeightRef = useRef<number | undefined>(currentBlockHeight)
const previousTakerRunningRef = useRef<boolean>(currentTakerRunning)
const { refetch: refetchWalletBalance, utxosHashHex } = useJamWalletInfoContext()
@ -478,6 +486,26 @@ const WalletInfoAutoReload = () => {
[refetchWalletBalance, utxosHashHex],
)
useEffect(
function refetchWalletInfoAfterTakerStopped() {
const takerStopped = previousTakerRunningRef.current === true && currentTakerRunning === false
previousTakerRunningRef.current = currentTakerRunning
if (!takerStopped) {
return
}
const delayBefore = RELOAD_WALLET_INFO_DELAY.AFTER_TAKER_STOPPED
console.debug('Trigger refetch looking for funds AFTER_TAKER_STOPPED with delay %d...', delayBefore)
const abortCtrl = new AbortController()
refetchWalletBalance({ delayBefore, signal: abortCtrl.signal }).catch((error: unknown) => {
console.error('Error while auto-reloading wallet info AFTER_TAKER_STOPPED finished', error)
})
return () => abortCtrl.abort('useEffect(AFTER_TAKER_STOPPED) ended')
},
[refetchWalletBalance, currentTakerRunning],
)
return <></>
}

View file

@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { startmakerMutation, stopmakerOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
import type { ErrorMessage, StartMakerRequest } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { useMutation, useQuery } from '@tanstack/react-query'
import { AlertTriangleIcon, FileTextIcon, RefreshCwIcon, ShuffleIcon, UnlockIcon } from 'lucide-react'
import { AlertTriangleIcon, FileTextIcon, HourglassIcon, RefreshCwIcon, ShuffleIcon, UnlockIcon } from 'lucide-react'
import type { SubmitHandler } from 'react-hook-form'
import { Trans, useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
@ -214,7 +214,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
toast.success(t('earn.alert_stopped'), { id: 'earn.alert_stopped' })
}, [jmSession, makerRunning, stopMaker.isSuccess, t])
if (!jmSession) {
if (!jmSession || walletInfo.isLoading) {
return <PageLoading />
}
@ -233,6 +233,13 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
<FeeConfigErrorAlert onOpenFeeConfig={() => setShowFeeConfigDialog(true)} className="mb-4" />
)}
{jmSession.coinjoin_in_process === true && (
<Alert variant="warning">
<HourglassIcon className="motion-safe:animate-pulse" />
<AlertDescription>{t('send.text_coinjoin_already_running')}</AlertDescription>
</Alert>
)}
{jmSession.maker_running === true && (
<Alert variant="success" className="motion-safe:animate-in blur-in my-2">
<ShuffleIcon className="motion-safe:animate-pulse" />

View file

@ -0,0 +1,50 @@
import type { ComponentProps } from 'react'
import { useTranslation } from 'react-i18next'
import type { WithRequiredProperty } from '@/types/global'
import { Button } from '../ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog'
import { Spinner } from '../ui/spinner'
type PaymentAbortDialogProps = WithRequiredProperty<
Omit<ComponentProps<typeof Dialog>, 'children'>,
'open' | 'onOpenChange'
> & {
isConfirming: boolean
onConfirm: () => Promise<void>
}
export const PaymentAbortDialog = ({
open,
onOpenChange,
isConfirming,
onConfirm,
...dialogProps
}: PaymentAbortDialogProps) => {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange} {...dialogProps}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('send.confirm_abort_modal.title')}</DialogTitle>
<DialogDescription>{t('send.confirm_abort_modal.text_body')}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isConfirming}>
{t('modal.confirm_button_reject')}
</Button>
<Button variant="destructive" onClick={() => void onConfirm()} disabled={isConfirming}>
{isConfirming ? (
<>
<Spinner className="motion-reduce:hidden" />
{t('global.abort')}
</>
) : (
t('global.abort')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -1,4 +1,4 @@
import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'
import { useMemo, useState, type ComponentProps } from 'react'
import { DialogTitle } from '@radix-ui/react-dialog'
import { InfoIcon } from 'lucide-react'
import { Trans, useTranslation } from 'react-i18next'
@ -33,8 +33,6 @@ type PaymentConfirmDialogProps = WithRequiredProperty<
Omit<ComponentProps<typeof Dialog>, 'children'>,
'open' | 'onOpenChange'
> & {
title: string
subtitle?: ReactNode | string
onConfirm: (values: SendFormValues) => Promise<void>
values: SendFormValues
meta: {
@ -49,8 +47,6 @@ type PaymentConfirmDialogProps = WithRequiredProperty<
export default function PaymentConfirmDialog({
open,
onOpenChange,
title,
subtitle,
onConfirm,
values,
meta,
@ -94,8 +90,20 @@ export default function PaymentConfirmDialog({
<Dialog open={open} onOpenChange={handleClose} {...dialogProps}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center justify-center gap-2 font-semibold">{title}</DialogTitle>
<DialogDescription className="text-center">{subtitle}</DialogDescription>
<DialogTitle className="flex items-center justify-center gap-2 font-semibold">
{t('send.confirm_send_modal.title')}
</DialogTitle>
<DialogDescription className="text-center">
{values.isCoinJoin === true ? (
<span className="light:text-green-600/80 font-semibold text-green-700/80">
{t('send.confirm_send_modal.text_collaborative_tx_enabled')}
</span>
) : (
<span className="text-destructive font-semibold">
{t('send.confirm_send_modal.text_collaborative_tx_disabled')}
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 space-y-1 space-x-4 md:grid-cols-5">

View file

@ -114,7 +114,6 @@ interface SendFormProps {
sourceJarLabelButton?: React.ReactElement
minNumberOfCollaborators?: number
feeConfigValues: JamFeeConfigValues
forceCoinJoinEnabled?: boolean
walletFileName: WalletFileName
jars: Jar[]
walletBalanceSummary: BalanceSummary
@ -130,7 +129,6 @@ export function SendForm({
sourceJarLabelButton,
disabled,
feeConfigValues,
forceCoinJoinEnabled = false,
walletFileName,
minNumberOfCollaborators = MIN_NUM_COLLABORATORS,
jars,
@ -186,7 +184,6 @@ export function SendForm({
const isSweep = useWatch({ control, name: 'amount.isSweep' })
const isCoinJoin = useWatch({ control, name: 'isCoinJoin' })
const collaboratorCount = useWatch({ control, name: 'numCollaborators' })
const isCoinJoinEnabled = forceCoinJoinEnabled || isCoinJoin === true
const destinationAddressInfo = useMemo(() => {
try {
@ -209,19 +206,23 @@ export function SendForm({
if (destinationJarIndex === undefined) return
return jars.find((it) => it.jarIndex === destinationJarIndex)
}, [jars, destinationJarIndex])
const coinjoinPreconditionSummary = useMemo(() => {
if (!sourceJar) return undefined
return buildSweepPreconditionSummary(sourceJar.utxos)
}, [sourceJar])
const hasCoinjoinPreconditionWarning = isCoinJoinEnabled && coinjoinPreconditionSummary?.isFulfilled === false
const hasCoinjoinPreconditionWarning = isCoinJoin && coinjoinPreconditionSummary?.isFulfilled === false
const amountForFeeEstimate = useMemo(() => {
if (values.amount?.isSweep === true) {
return sourceJar?.balanceSummary.calculatedAvailableBalanceInSats
}
return values.amount?.amount
}, [sourceJar, values.amount?.amount, values.amount?.isSweep])
const estimatedMaxCollaboratorFee = useMemo(() => {
if (!isCoinJoinEnabled || values.numCollaborators === undefined || amountForFeeEstimate === undefined) {
if (!isCoinJoin || values.numCollaborators === undefined || amountForFeeEstimate === undefined) {
return undefined
}
@ -230,7 +231,7 @@ export function SendForm({
} catch (_ignoredOnPurpose) {
return undefined
}
}, [amountForFeeEstimate, feeConfigValues, isCoinJoinEnabled, values.numCollaborators])
}, [amountForFeeEstimate, feeConfigValues, isCoinJoin, values.numCollaborators])
const doOnSubmit = handleSubmit(onSubmit)
@ -558,11 +559,8 @@ export function SendForm({
<div className="flex items-center gap-2">
<Switch
id="switch-is-collaborative-transaction"
checked={isCoinJoinEnabled}
checked={isCoinJoin}
onCheckedChange={(checked) => {
if (forceCoinJoinEnabled) {
return
}
setValue('isCoinJoin', checked, {
shouldValidate: true,
shouldDirty: true,
@ -588,7 +586,7 @@ export function SendForm({
</Label>
</div>
{isCoinJoinEnabled && (
{isCoinJoin && (
<div className="space-y-2">
<Field data-invalid={errors.numCollaborators !== undefined}>
<FieldLabel htmlFor="send-num-collaborators">
@ -636,7 +634,7 @@ export function SendForm({
variant={
disabled
? 'outline'
: !isCoinJoinEnabled
: !isCoinJoin
? 'destructive'
: hasCoinjoinPreconditionWarning
? 'secondary'
@ -653,7 +651,7 @@ export function SendForm({
</>
) : (
<>
{!isCoinJoinEnabled ? (
{!isCoinJoin ? (
<>{t('send.button_send_without_improved_privacy')}</>
) : hasCoinjoinPreconditionWarning ? (
<>{t('send.button_send_despite_warning')}</>

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import {
directsendMutation,
docoinjoinMutation,
@ -21,6 +21,7 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { FeeConfigErrorAlert } from '@/components/ui/jam/FeeConfigErrorAlert'
import { PageLoading } from '@/components/ui/jam/PageLoading'
import PageTitle from '@/components/ui/jam/PageTitle'
import { useJamSessionInfoContext } from '@/context/JamSessionInfoContext'
import {
useAddressSummary,
useJamWalletInfoContext,
@ -35,15 +36,15 @@ import { useRefreshSession } from '@/hooks/useRefreshSession'
import { useUtxoSelectionDialog } from '@/hooks/useUtxoSelectionDialog'
import { getErrorReason } from '@/lib/errorReason'
import { withMutationDelay } from '@/lib/queryClient'
import type { WalletFileName } from '@/lib/utils'
import { scrollToTop, type WalletFileName } from '@/lib/utils'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import { jmSessionStore } from '@/store/jmSessionStore'
import { jmTxStore, type JmTxInfo } from '@/store/jmTxStore'
import type { JarIndex } from '@/types/global'
import { Button } from '../ui/button'
import { Card, CardContent } from '../ui/card'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog'
import { Spinner } from '../ui/spinner'
import { PaymentAbortDialog } from './PaymentAbortDialog'
import PaymentConfirmDialog from './PaymentConfirmDialog'
import { SendForm } from './SendForm'
import { UtxoSelectionDialog } from './UtxoSelectionDialog'
@ -60,6 +61,10 @@ type DirectSendResult = {
request: DirectSendRequest
response: DirectSendResponse
}
type CollaborativeSendResult = {
request: DoCoinjoinRequest
response: unknown
}
interface SendPageProps {
walletFileName: WalletFileName
@ -70,8 +75,21 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
const client = useApiClient()
const [formId, setFormId] = useState<number>(0)
const { fetchIfMissing } = useJmConfig({ walletFileName })
const { refetch: refetchWalletInfo, waitForUtxosToBeSpent, setWaitForUtxosToBeSpent } = useJamWalletInfoContext()
const {
isLoading: walletInfoIsLoading,
isFetching: walletInfoIsFetching,
utxosHashHex,
waitForUtxosToBeSpent,
setWaitForUtxosToBeSpent,
} = useJamWalletInfoContext()
const jmSession = useStore(jmSessionStore, (state) => state.state)
const {
takerInfo: { running: takerRunning, currentPaymentAttempt },
rescanInfo,
setCurrentPaymentAttempt,
clearCurrentPaymentAttempt,
} = useJamSessionInfoContext()
const { enabled: isDeveloperMode } = useDeveloperMode()
const feeConfigValidation = useFeeConfigValidation({ walletFileName })
@ -79,36 +97,14 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
const [showPaymentConfirmDialog, setShowPaymentConfirmDialog] = useState(false)
const [showAbortCoinjoinDialog, setShowAbortCoinjoinDialog] = useState(false)
const [sendFromValuesAwaitingConfirmation, setSendFromValuesAwaitingConfirmation] = useState<SendFormValues>()
const [paymentSuccessfulInfoAlert, setPaymentSuccessfulInfoAlert] = useState<SimpleAlert>()
const [nonCollaborativePaymentSuccessInfoAlert, setNonCollaborativePaymentSuccessInfoAlert] = useState<SimpleAlert>()
const [minimumCollaborators, setMinimumCollaborators] = useState<number>()
const [collaborativeFlowError, setCollaborativeFlowError] = useState<string>()
const [sourceJarIndex, setSourceJarIndex] = useState<JarIndex>()
// TODO: "Lifecycle" or state management should be handled outside of this component
const collaborativeLifecycleRef = useRef({
awaitingCompletion: false,
wasRunning: false,
utxoSnapshotAtStart: '',
})
const refetchWalletInfoRef = useRef(refetchWalletInfo)
const currentUtxoSnapshotRef = useRef('')
const { addressSummary } = useAddressSummary()
const { walletBalanceSummary } = useWalletBalanceSummary()
const { jars } = useJars()
const currentUtxoSnapshot = useMemo(() => {
return jars
.flatMap((jar) => jar.utxos.map((utxo) => utxo.utxo))
.toSorted()
.join('|')
}, [jars])
useEffect(() => {
currentUtxoSnapshotRef.current = currentUtxoSnapshot
}, [currentUtxoSnapshot])
useEffect(() => {
refetchWalletInfoRef.current = refetchWalletInfo
}, [refetchWalletInfo])
const sourceJar = useMemo(() => {
if (sourceJarIndex === undefined) return
@ -148,7 +144,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
setCollaborativeFlowError(undefined)
},
onSuccess: () => {
toast.success(t('send.alert_collaborative_started_title'))
toast.info(t('send.alert_collaborative_starting'))
},
onError: (error: ErrorMessage) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
@ -181,6 +177,10 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
onMutate: () => {
setCollaborativeFlowError(undefined)
},
onSuccess: () => {
clearCurrentPaymentAttempt()
toast.info(t('send.alert_collaborative_stopping'))
},
onError: (error: unknown) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
const message = t('send.error_stopping_collaborative_transaction', { reason })
@ -189,10 +189,8 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
},
})
const coinjoinRunning = jmSession?.coinjoin_in_process === true
const isWaitingCoinjoinStart = startCoinjoinMutationIsPending || (startCoinjoinMutationIsSuccess && !coinjoinRunning)
const isWaitingCoinjoinStop = stopCoinjoinMutationIsPending || (stopCoinjoinMutationIsSuccess && coinjoinRunning)
const collaborativeFlowActive = coinjoinRunning || isWaitingCoinjoinStart || isWaitingCoinjoinStop
const isWaitingCoinjoinStart = startCoinjoinMutationIsPending || (startCoinjoinMutationIsSuccess && !takerRunning)
const isWaitingCoinjoinStop = stopCoinjoinMutationIsPending || (stopCoinjoinMutationIsSuccess && takerRunning)
useRefreshSession({
enabled: isWaitingCoinjoinStart || isWaitingCoinjoinStop,
@ -200,117 +198,45 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
refetchDelay: 1_000,
})
useEffect(() => {
if (!collaborativeFlowActive) {
return
}
void refetchWalletInfoRef.current()
const intervalId = window.setInterval(() => {
void refetchWalletInfoRef.current()
}, 3_000)
return () => {
window.clearInterval(intervalId)
}
}, [collaborativeFlowActive])
useRefreshSession({
enabled: takerRunning,
refetchInterval: 5_000,
refetchDelay: 1_000,
})
useEffect(() => {
if (coinjoinRunning && startCoinjoinMutationIsSuccess) {
if (takerRunning && startCoinjoinMutationIsSuccess) {
startCoinjoinMutationReset()
}
}, [coinjoinRunning, startCoinjoinMutationIsSuccess, startCoinjoinMutationReset])
}, [takerRunning, startCoinjoinMutationIsSuccess, startCoinjoinMutationReset])
useEffect(() => {
if (!coinjoinRunning && stopCoinjoinMutationIsSuccess) {
if (!takerRunning && stopCoinjoinMutationIsSuccess) {
stopCoinjoinMutationReset()
}
}, [coinjoinRunning, stopCoinjoinMutationIsSuccess, stopCoinjoinMutationReset])
}, [takerRunning, stopCoinjoinMutationIsSuccess, stopCoinjoinMutationReset])
useEffect(() => {
const state = collaborativeLifecycleRef.current
if (!state.awaitingCompletion) {
return
}
if (coinjoinRunning) {
state.wasRunning = true
return
}
if (!state.wasRunning) {
return
}
state.awaitingCompletion = false
state.wasRunning = false
const utxoSnapshotAtStart = state.utxoSnapshotAtStart
let isCancelled = false
const verifyCollaborativeCompletion = async () => {
const deadline = Date.now() + 9_000
let hasNewTransaction = currentUtxoSnapshotRef.current !== utxoSnapshotAtStart
while (!hasNewTransaction && Date.now() < deadline) {
await refetchWalletInfoRef.current()
if (isCancelled) return
hasNewTransaction = currentUtxoSnapshotRef.current !== utxoSnapshotAtStart
if (hasNewTransaction || Date.now() >= deadline) break
await new Promise((resolve) => window.setTimeout(resolve, 1_000))
}
if (isCancelled) return
queueMicrotask(() => {
setPaymentSuccessfulInfoAlert({
variant: hasNewTransaction ? 'success' : 'warning',
title: hasNewTransaction
? t('send.alert_collaborative_completed_title')
: t('send.alert_collaborative_ended_title'),
description: hasNewTransaction
? t('send.alert_collaborative_completed_description')
: t('send.alert_collaborative_ended_description'),
})
if (hasNewTransaction) {
toast.success(t('send.alert_collaborative_completed_title'))
} else {
toast.warning(t('send.alert_collaborative_ended_title'))
}
})
}
void verifyCollaborativeCompletion()
return () => {
isCancelled = true
}
}, [coinjoinRunning, t])
useEffect(() => {
let isCancelled = false
const abortCtrl = new AbortController()
fetchIfMissing({ section: 'POLICY', field: 'minimum_makers' })
.then((result) => {
if (isCancelled) return
if (abortCtrl.signal.aborted) return
const parsedValue = Number.parseInt(result.value || '', 10)
if (!Number.isInteger(parsedValue) || parsedValue < 1) {
if (!Number.isSafeInteger(parsedValue) || parsedValue < 1) {
return
}
setMinimumCollaborators(parsedValue)
})
.catch((error: unknown) => {
if (isCancelled) return
if (abortCtrl.signal.aborted) return
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
// TODO: i18n, add reason as param
toast.error(`${t('send.error_loading_min_makers_failed')} ${reason}`)
})
return () => {
isCancelled = true
}
return () => abortCtrl.abort()
}, [fetchIfMissing, t])
const triggerNonCollaborativeTransaction = useMutation<DirectSendResult, ErrorMessage, SendFormValues, unknown>({
@ -332,7 +258,8 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
{ throttle: 2_100 },
),
onMutate: () => {
setPaymentSuccessfulInfoAlert(undefined)
setNonCollaborativePaymentSuccessInfoAlert(undefined)
scrollToTop()
},
onSuccess: (result: DirectSendResult, data: SendFormValues) => {
const tx = result.response.txinfo as Required<JmTxInfo>
@ -345,7 +272,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
setWaitForUtxosToBeSpent(inputUtxoIds)
setFormId((current) => current + 1)
setPaymentSuccessfulInfoAlert({
setNonCollaborativePaymentSuccessInfoAlert({
variant: 'success',
title: /* TODO: i18n */ 'Successfully sent non-collaborative transaction',
description: t('send.alert_payment_successful', {
@ -364,61 +291,76 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
},
})
const onPaymentConfirmed: SubmitHandler<SendFormValues> = async (data: SendFormValues) => {
if (data.isCoinJoin !== true) {
const triggerCollaborativeTransaction = useMutation<CollaborativeSendResult, ErrorMessage, SendFormValues, unknown>({
mutationFn: withMutationDelay(
async (data: SendFormValues) => {
const body: DoCoinjoinRequest = buildCollaborativeSendRequest(data)
const response = await startCoinjoinMutationMutateAsync({
path: { walletname: walletFileName },
body,
})
return {
request: body,
response,
}
},
{ throttle: 0 },
),
onMutate: () => {
setNonCollaborativePaymentSuccessInfoAlert(undefined)
scrollToTop()
},
onSuccess: (_result, data) => {
setCurrentPaymentAttempt({
createdAt: Date.now(),
walletFileName,
utxosHashHex,
data,
})
},
})
const onPaymentValuesConfirmed = async (data: SendFormValues) => {
setShowPaymentConfirmDialog(false)
setSendFromValuesAwaitingConfirmation(undefined)
if (data.isCoinJoin === true) {
if (feeConfigValidation.maxFeesConfigMissing) {
toast.error(t('send.taker_error_message_max_fees_config_missing'))
throw new Error(t('send.taker_error_message_max_fees_config_missing'))
}
try {
await triggerCollaborativeTransaction.mutateAsync(data)
} catch (error: unknown) {
console.error('Error while sending collaborative transaction', error)
}
} else {
try {
await triggerNonCollaborativeTransaction.mutateAsync(data)
} catch (error: unknown) {
console.error('Error while sending non-collaborative transaction', error)
}
} else {
if (feeConfigValidation.maxFeesConfigMissing) {
toast.error(t('send.taker_error_message_max_fees_config_missing'))
setShowFeeConfigDialog(true)
return
}
try {
const body: DoCoinjoinRequest = buildCollaborativeSendRequest(data)
setPaymentSuccessfulInfoAlert(undefined)
collaborativeLifecycleRef.current.utxoSnapshotAtStart = currentUtxoSnapshot
await startCoinjoinMutationMutateAsync({
path: { walletname: walletFileName },
body,
})
collaborativeLifecycleRef.current.awaitingCompletion = true
collaborativeLifecycleRef.current.wasRunning = coinjoinRunning
setPaymentSuccessfulInfoAlert({
variant: 'success',
title: t('send.alert_collaborative_started_title'),
description: t('send.alert_collaborative_started_description'),
})
} catch (error: unknown) {
collaborativeLifecycleRef.current.utxoSnapshotAtStart = ''
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
const message = t('send.error_preparing_collaborative_transaction', { reason })
setCollaborativeFlowError(message)
toast.error(message)
}
}
}
const onSubmit: SubmitHandler<SendFormValues> = (data) => {
if (data.isCoinJoin && feeConfigValidation.maxFeesConfigMissing) {
toast.error(t('send.taker_error_message_max_fees_config_missing'))
setShowFeeConfigDialog(true)
return
}
setSendFromValuesAwaitingConfirmation(data)
setShowPaymentConfirmDialog(true)
}
const onAbortCoinjoin = async () => {
collaborativeLifecycleRef.current.awaitingCompletion = false
collaborativeLifecycleRef.current.wasRunning = false
collaborativeLifecycleRef.current.utxoSnapshotAtStart = ''
setPaymentSuccessfulInfoAlert(undefined)
const onAbortCoinjoinConfirmed = async () => {
setShowAbortCoinjoinDialog(false)
await stopCoinjoinMutationMutateAsync()
void refetchWalletInfoRef.current()
}
if (feeConfigValidation.isLoading) {
if (!jmSession || walletInfoIsLoading) {
return <PageLoading />
}
@ -430,53 +372,21 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
open={showFeeConfigDialog}
onOpenChange={setShowFeeConfigDialog}
/>
<Dialog open={showAbortCoinjoinDialog} onOpenChange={setShowAbortCoinjoinDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('send.confirm_abort_modal.title')}</DialogTitle>
<DialogDescription>{t('send.confirm_abort_modal.text_body')}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAbortCoinjoinDialog(false)}>
{t('modal.confirm_button_reject')}
</Button>
<Button
variant="destructive"
onClick={() => void onAbortCoinjoin()}
disabled={stopCoinjoinMutationIsPending}
>
{stopCoinjoinMutationIsPending ? (
<>
<Spinner className="motion-reduce:hidden" />
{t('global.abort')}
</>
) : (
t('global.abort')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<PaymentAbortDialog
open={showAbortCoinjoinDialog}
onOpenChange={setShowAbortCoinjoinDialog}
isConfirming={stopCoinjoinMutationIsPending}
onConfirm={onAbortCoinjoinConfirmed}
/>
<UtxoSelectionDialog {...utxoSelectionDialog.dialogProps} />
{sourceJar && sendFromValuesAwaitingConfirmation && (
<PaymentConfirmDialog
open={showPaymentConfirmDialog}
onOpenChange={setShowPaymentConfirmDialog}
title={t('send.confirm_send_modal.title')}
subtitle={
sendFromValuesAwaitingConfirmation.isCoinJoin === true ? (
<span className="light:text-green-600/80 text-green-700/80">
{t('send.confirm_send_modal.text_collaborative_tx_enabled')}
</span>
) : (
<span className="text-destructive">{t('send.confirm_send_modal.text_collaborative_tx_disabled')}</span>
)
}
values={sendFromValuesAwaitingConfirmation}
onConfirm={async () => {
setShowPaymentConfirmDialog(false)
await onPaymentConfirmed(sendFromValuesAwaitingConfirmation)
}}
onConfirm={onPaymentValuesConfirmed}
meta={{
feeConfigValues: feeConfigValidation.feeConfigValues,
availableUtxos: availableUtxosForPayment,
@ -492,7 +402,12 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
{feeConfigValidation.maxFeesConfigMissing && (
<FeeConfigErrorAlert onOpenFeeConfig={() => setShowFeeConfigDialog(true)} className="mb-4" />
)}
{jmSession?.maker_running === true && (
<Alert variant="warning">
<HourglassIcon className="motion-safe:animate-pulse" />
<AlertDescription>{t('send.text_maker_running')}</AlertDescription>
</Alert>
)}
{collaborativeFlowError && (
<Alert variant="destructive">
<AlertTriangleIcon />
@ -500,34 +415,122 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
<AlertDescription>{collaborativeFlowError}</AlertDescription>
</Alert>
)}
{isWaitingCoinjoinStart && (
<Alert>
<Spinner className="motion-reduce:hidden" />
<AlertTitle>{t('send.alert_collaborative_starting')}</AlertTitle>
</Alert>
)}
{isWaitingCoinjoinStop && (
<Alert variant="default" className="motion-safe:animate-in blur-in my-2">
<Spinner className="motion-reduce:hidden" />
<AlertTitle>{t('send.alert_collaborative_stopping')}</AlertTitle>
</Alert>
)}
{coinjoinRunning && (
{
/*
* With backend "joinmarket-clientserver" there is no direct way of verifying
* that a collaborative send attempt was successful.
* We have to rely on local data `takerCurrentAttempt`
* (which might not be present, e.g. taker was started from a different session)
* for displaying success/error information.
* If data `takerCurrentAttempt` is not present, no message is shown
* when the taker service stops - this is not ideal, but okay.
*/
currentPaymentAttempt !== undefined &&
currentPaymentAttempt.data.isCoinJoin &&
!isWaitingCoinjoinStart &&
takerRunning === false && (
<>
{walletInfoIsFetching ? (
<>
<Alert variant="default" className="motion-safe:animate-in blur-in my-2">
<Spinner className="motion-reduce:hidden" />
<AlertTitle>{t('send.alert_collaborative_awaiting_completion')}</AlertTitle>
</Alert>
</>
) : (
<>
{currentPaymentAttempt.utxosHashHex === utxosHashHex ? (
<Alert variant="warning">
<AlertTriangleIcon />
<AlertTitle>{t('send.alert_collaborative_ended_title')}</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<div>{t('send.alert_collaborative_ended_description')}</div>
<div>
<Button
variant="outline"
size="sm"
onClick={() => {
clearCurrentPaymentAttempt()
}}
>
{t('global.done')}
</Button>
</div>
</AlertDescription>
</Alert>
) : (
<Alert variant="success">
<CheckCircle2Icon />
<AlertTitle>{t('send.alert_collaborative_completed_title')}</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<div>{t('send.alert_collaborative_completed_description')}</div>
<div>
<Button
variant="outline"
size="sm"
onClick={() => {
setFormId((current) => current + 1)
clearCurrentPaymentAttempt()
}}
>
{t('global.done')}
</Button>
</div>
</AlertDescription>
</Alert>
)}
</>
)}
</>
)
}
{takerRunning && !isWaitingCoinjoinStop && (
<Alert variant="warning">
<HourglassIcon />
<AlertTitle>{t('send.text_coinjoin_already_running')}</AlertTitle>
<AlertDescription className="mt-3">
<Button
variant="outline"
size="sm"
onClick={() => setShowAbortCoinjoinDialog(true)}
disabled={isWaitingCoinjoinStop}
>
{isWaitingCoinjoinStop ? (
<>
<Spinner className="motion-reduce:hidden" />
{t('global.abort')}
</>
) : (
t('global.abort')
)}
</Button>
<AlertDescription className="flex flex-col gap-2">
{currentPaymentAttempt && (
<pre>
{JSON.stringify(
{
sourceJar: currentPaymentAttempt.data.source.fromJar,
destinationJar: currentPaymentAttempt.data.destination.fromJar,
destinationAddress: currentPaymentAttempt.data.destination.address,
isSweep: currentPaymentAttempt.data.amount.isSweep === true,
amount:
currentPaymentAttempt.data.amount.isSweep === true
? currentPaymentAttempt.data.amount.sweepAmount
: currentPaymentAttempt.data.amount.amount,
numCollaborators: currentPaymentAttempt.data.numCollaborators,
},
null,
2,
)}
</pre>
)}
<div>
<Button
variant="outline"
size="sm"
onClick={() => setShowAbortCoinjoinDialog(true)}
disabled={isWaitingCoinjoinStop}
>
{t('global.abort')}
</Button>
</div>
</AlertDescription>
</Alert>
)}
@ -536,7 +539,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
<Alert variant="destructive">
<AlertTriangleIcon />
<AlertTitle>{/* TODO: i18n */}Error while sending non-collaborative transaction</AlertTitle>
<AlertDescription className="">
<AlertDescription>
<p>
The exact reason is not entirely clear, only the following is known:{' '}
<span className="inline font-mono font-semibold">
@ -560,19 +563,18 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
<AlertTitle>{/* TODO: i18n*/ t('Waiting for utxos to be marked as spent...')}</AlertTitle>
</Alert>
)}
{paymentSuccessfulInfoAlert && !coinjoinRunning && (
<Alert variant={paymentSuccessfulInfoAlert.variant}>
{nonCollaborativePaymentSuccessInfoAlert && (
<Alert variant={nonCollaborativePaymentSuccessInfoAlert.variant}>
<CheckCircle2Icon />
<AlertTitle>{paymentSuccessfulInfoAlert.title}</AlertTitle>
<AlertDescription className="ext-wrap slashed-zero">
{paymentSuccessfulInfoAlert.description}
<AlertTitle>{nonCollaborativePaymentSuccessInfoAlert.title}</AlertTitle>
<AlertDescription className="text-wrap slashed-zero">
{nonCollaborativePaymentSuccessInfoAlert.description}
</AlertDescription>
</Alert>
)}
</>
)}
{/* Earn Form */}
<Card>
<CardContent>
<SendForm
@ -581,17 +583,19 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
walletFileName={walletFileName}
minNumberOfCollaborators={minimumCollaborators}
feeConfigValues={feeConfigValidation.feeConfigValues}
forceCoinJoinEnabled={collaborativeFlowActive}
jars={jars}
addressSummary={addressSummary}
walletBalanceSummary={walletBalanceSummary}
disabled={
feeConfigValidation.maxFeesConfigMissing ||
jmSession?.maker_running === true ||
collaborativeFlowActive ||
jmSession?.rescanning === true ||
takerRunning ||
rescanInfo.rescanning ||
isWaitingCoinjoinStart ||
isWaitingCoinjoinStop ||
utxoSelectionDialog.isSubmitting ||
triggerNonCollaborativeTransaction.isPending ||
triggerCollaborativeTransaction.isPending ||
currentPaymentAttempt !== undefined ||
waitForUtxosToBeSpent.length > 0
}
debug={isDeveloperMode}

View file

@ -6,7 +6,6 @@ import {
EyeOffIcon,
SunIcon,
MoonIcon,
DollarSignIcon,
FileTextIcon,
BookIcon,
TerminalIcon,
@ -16,6 +15,7 @@ import {
FoldHorizontalIcon,
UnfoldHorizontalIcon,
KeyRoundIcon,
HandCoinsIcon,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import { useTranslation } from 'react-i18next'
@ -126,7 +126,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
</CardHeader>
<CardContent>
<SettingItem
icon={DollarSignIcon}
icon={HandCoinsIcon}
title={t('settings.show_fee_config')}
action={() => setShowFeeConfigDialog(true)}
/>

View file

@ -315,12 +315,12 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
await stopScheduleMutationMutateAsync()
}
if (feeConfigValidation.isLoading || walletInfo.isLoading || jmSession === undefined) {
if (!jmSession || feeConfigValidation.isLoading || walletInfo.isLoading) {
return <PageLoading />
}
return (
<div className="mx-auto max-w-4xl space-y-3 p-4">
<>
<FeeConfigDialog
walletFileName={walletFileName}
feeConfigValidation={feeConfigValidation}
@ -334,118 +334,119 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
disabled={isStartDisabled || isWaitingSchedulerStart}
isStarting={isWaitingSchedulerStart}
/>
<div className="mx-auto max-w-4xl space-y-3 p-4">
<PageTitle title={t('scheduler.title')} subtitle={t('scheduler.subtitle')} />
<PageTitle title={t('scheduler.title')} subtitle={t('scheduler.subtitle')} />
{feeConfigValidation.maxFeesConfigMissing && (
<FeeConfigErrorAlert onOpenFeeConfig={() => setShowFeeConfigDialog(true)} className="mb-4" />
)}
{feeConfigValidation.maxFeesConfigMissing && (
<FeeConfigErrorAlert onOpenFeeConfig={() => setShowFeeConfigDialog(true)} className="mb-4" />
)}
{alertMessage && (
<Alert variant="destructive">
<AlertTitle>{t('global.error')}</AlertTitle>
<AlertDescription>{alertMessage}</AlertDescription>
</Alert>
)}
{alertMessage && (
<Alert variant="destructive">
<AlertTitle>{t('global.error')}</AlertTitle>
<AlertDescription>{alertMessage}</AlertDescription>
</Alert>
)}
{singleCoinJoinRunning && (
<Alert variant="warning">
<HourglassIcon className="motion-safe:animate-pulse" />
<AlertDescription>{t('send.text_coinjoin_already_running')}</AlertDescription>
</Alert>
)}
{singleCoinJoinRunning && (
<Alert variant="warning">
<HourglassIcon />
<AlertDescription>{t('send.text_coinjoin_already_running')}</AlertDescription>
</Alert>
)}
{makerRunning && (
<Alert variant="warning">
<HourglassIcon className="motion-safe:animate-pulse" />
<AlertDescription>{t('send.text_maker_running')}</AlertDescription>
</Alert>
)}
{makerRunning && !schedulerRunning && (
<Alert variant="warning">
<HourglassIcon />
<AlertDescription>{t('send.text_maker_running')}</AlertDescription>
</Alert>
)}
{isWaitingSchedulerStart && (
<Alert>
<Spinner className="motion-reduce:hidden" />
<AlertTitle>{t('scheduler.button_start')}</AlertTitle>
</Alert>
)}
{isWaitingSchedulerStart && (
<Alert>
<Spinner className="motion-reduce:hidden" />
<AlertTitle>{t('scheduler.button_start')}</AlertTitle>
</Alert>
)}
{isWaitingSchedulerStop && (
<Alert>
<Spinner className="motion-reduce:hidden" />
<AlertTitle>{t('scheduler.button_stop')}</AlertTitle>
</Alert>
)}
{isWaitingSchedulerStop && (
<Alert>
<Spinner className="motion-reduce:hidden" />
<AlertTitle>{t('scheduler.button_stop')}</AlertTitle>
</Alert>
)}
{schedulerRunning && currentSchedule && (
<SweepScheduleProgress schedule={currentSchedule} isStopping={isWaitingSchedulerStop} onStop={stopSchedule} />
)}
{schedulerRunning && currentSchedule && (
<SweepScheduleProgress schedule={currentSchedule} isStopping={isWaitingSchedulerStop} onStop={stopSchedule} />
)}
{!schedulerRunning && (
<>
<SweepPreconditionAlert summary={preconditionSummary} />
{!schedulerRunning && (
<>
<SweepPreconditionAlert summary={preconditionSummary} />
<Card>
<CardContent className="space-y-5">
<div className="bg-muted/50 flex items-center justify-between rounded-lg border px-4 py-3">
<div>
<div className="font-medium">{t('scheduler.complete_wallet_title')}</div>
<div className="text-muted-foreground text-sm">{t('scheduler.complete_wallet_subtitle')}</div>
<Card>
<CardContent className="space-y-5">
<div className="bg-muted/50 flex items-center justify-between rounded-lg border px-4 py-3">
<div>
<div className="font-medium">{t('scheduler.complete_wallet_title')}</div>
<div className="text-muted-foreground text-sm">{t('scheduler.complete_wallet_subtitle')}</div>
</div>
<div className="font-semibold">
<Balance valueString={String(walletInfo.walletBalanceSummary.calculatedAvailableBalanceInSats)} />
</div>
</div>
<div className="font-semibold">
<Balance valueString={String(walletInfo.walletBalanceSummary.calculatedAvailableBalanceInSats)} />
</div>
</div>
<p className="text-muted-foreground text-sm">{t('scheduler.description_destination_addresses')}</p>
<p className="text-muted-foreground text-sm">{t('scheduler.description_destination_addresses')}</p>
{showInsecureScheduleTestingToggle && (
<div className="flex items-center gap-2">
<Switch
id="switch-use-insecure-schedule-testing"
checked={useInsecureTestingSettings}
onCheckedChange={onInsecureTestingToggleChange}
disabled={isOperationDisabled || isWaitingSchedulerStart || isWaitingSchedulerStop}
/>
<Label htmlFor="switch-use-insecure-schedule-testing" className="flex flex-col items-start gap-0">
<div className="flex items-center gap-2 font-medium">
Use insecure testing settings
<DevBadge />
</div>
<div className="text-muted-foreground text-sm">
This is completely insecure but makes testing the schedule much faster.
</div>
</Label>
</div>
)}
<SweepDestinationInputs
form={form}
fields={fields}
disabled={isOperationDisabled || isWaitingSchedulerStart || isWaitingSchedulerStop}
/>
<p className="text-muted-foreground text-sm">{t('scheduler.description_fees')}</p>
<Button
type="button"
onClick={() => void onOpenScheduleConfirm()}
disabled={isStartDisabled}
size="xxl"
className="w-full"
>
{isWaitingSchedulerStart ? (
<>
<Spinner className="motion-reduce:hidden" />
{t('scheduler.button_start')}
</>
) : (
t('scheduler.button_start')
{showInsecureScheduleTestingToggle && (
<div className="flex items-center gap-2">
<Switch
id="switch-use-insecure-schedule-testing"
checked={useInsecureTestingSettings}
onCheckedChange={onInsecureTestingToggleChange}
disabled={isOperationDisabled || isWaitingSchedulerStart || isWaitingSchedulerStop}
/>
<Label htmlFor="switch-use-insecure-schedule-testing" className="flex flex-col items-start gap-0">
<div className="flex items-center gap-2 font-medium">
Use insecure testing settings
<DevBadge />
</div>
<div className="text-muted-foreground text-sm">
This is completely insecure but makes testing the schedule much faster.
</div>
</Label>
</div>
)}
</Button>
</CardContent>
</Card>
</>
)}
</div>
<SweepDestinationInputs
form={form}
fields={fields}
disabled={isOperationDisabled || isWaitingSchedulerStart || isWaitingSchedulerStop}
/>
<p className="text-muted-foreground text-sm">{t('scheduler.description_fees')}</p>
<Button
type="button"
onClick={() => void onOpenScheduleConfirm()}
disabled={isStartDisabled}
size="xxl"
className="w-full"
>
{isWaitingSchedulerStart ? (
<>
<Spinner className="motion-reduce:hidden" />
{t('scheduler.button_start')}
</>
) : (
t('scheduler.button_start')
)}
</Button>
</CardContent>
</Card>
</>
)}
</div>
</>
)
}

View file

@ -1,4 +1,4 @@
import { SettingsIcon } from 'lucide-react'
import { AlertTriangleIcon, HandCoinsIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
@ -13,12 +13,15 @@ export const FeeConfigErrorAlert = ({ onOpenFeeConfig, className }: FeeConfigErr
return (
<Alert variant="destructive" className={className}>
<AlertDescription className="flex items-center justify-between">
<span>{t('send.taker_error_message_max_fees_config_missing')}</span>
<Button variant="outline" size="sm" onClick={onOpenFeeConfig} className="ml-4 shrink-0">
<SettingsIcon className="mr-2 h-4 w-4" />
{t('settings.show_fee_config')}
</Button>
<AlertTriangleIcon />
<AlertDescription className="flex flex-col gap-2">
<div>{t('send.taker_error_message_max_fees_config_missing')}</div>
<div>
<Button variant="outline" size="sm" onClick={onOpenFeeConfig}>
<HandCoinsIcon />
{t('settings.show_fee_config')}
</Button>
</div>
</AlertDescription>
</Alert>
)

View file

@ -1,4 +1,6 @@
import { createContext, useContext, type Dispatch, type SetStateAction } from 'react'
import type { SendFormValues } from '@/components/send/types'
import type { WalletFileName } from '@/lib/utils'
export interface RescanInfo {
updatedAt: number
@ -6,10 +8,25 @@ export interface RescanInfo {
progress?: number
}
export interface PaymentAttempt {
createdAt: number
utxosHashHex: string
walletFileName: WalletFileName
data: SendFormValues
}
export interface TakerInfo {
currentPaymentAttempt?: PaymentAttempt
running: boolean
}
interface JamSessionInfoContextType {
blockHeight?: number
takerInfo: TakerInfo
rescanInfo: RescanInfo
setRescanInfo: Dispatch<SetStateAction<RescanInfo>>
setCurrentPaymentAttempt: (val: PaymentAttempt) => void
clearCurrentPaymentAttempt: () => void
}
export const JamSessionInfoContext = createContext<JamSessionInfoContextType | undefined>(undefined)

View file

@ -2,14 +2,35 @@ import type { PropsWithChildren } from 'react'
import { useMemo, useState } from 'react'
import { getrescaninfoOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { useStore } from 'zustand'
import { createStore, useStore } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
import { JAM_RESCAN_PROGRESS_INTERVAL } from '@/constants/jam'
import { useApiClient } from '@/hooks/useApiClient'
import { withQueryDelay } from '@/lib/queryClient'
import type { WalletFileName } from '@/lib/utils'
import { jmSessionStore } from '@/store/jmSessionStore'
import { JamSessionInfoContext } from './JamSessionInfoContext'
import type { RescanInfo } from './JamSessionInfoContext'
import type { PaymentAttempt, RescanInfo, TakerInfo } from './JamSessionInfoContext'
interface PaymentAttemptStoreState {
state?: PaymentAttempt
update: (val: PaymentAttempt) => void
clear: () => void
}
const paymentAttemptStore = createStore<PaymentAttemptStoreState>()(
persist(
(set) => ({
state: undefined,
update: (val) => set(() => ({ state: val })),
clear: () => set({ state: undefined }),
}),
{
name: 'jam-payment-attempt-store',
storage: createJSONStorage(() => sessionStorage),
},
),
)
interface JamSessionInfoContextProviderProps {
walletFileName: WalletFileName
@ -26,6 +47,22 @@ export const JamSessionInfoContextProvider = ({
rescanning: state?.rescanning === true,
})
const {
state: currentPaymentAttempt,
update: setCurrentPaymentAttempt,
clear: clearCurrentPaymentAttempt,
} = useStore(paymentAttemptStore, (state) => state)
const takerInfo = useMemo<TakerInfo>(() => {
const isWalletPayment = currentPaymentAttempt?.walletFileName === walletFileName
const isCoinJoin = currentPaymentAttempt?.data.isCoinJoin === true
const takerIsRunning = state?.coinjoin_in_process === true
return {
currentPaymentAttempt: isWalletPayment && isCoinJoin ? currentPaymentAttempt : undefined,
running: takerIsRunning,
}
}, [state?.coinjoin_in_process, walletFileName, currentPaymentAttempt])
const getrescaninfoQueryOptions = useMemo(
() =>
getrescaninfoOptions({
@ -64,8 +101,12 @@ export const JamSessionInfoContextProvider = ({
const value = {
blockHeight: state?.block_height,
takerRunning: state?.coinjoin_in_process === true,
takerInfo,
rescanInfo,
setRescanInfo,
setCurrentPaymentAttempt,
clearCurrentPaymentAttempt,
}
return <JamSessionInfoContext.Provider value={value}>{children}</JamSessionInfoContext.Provider>

View file

@ -53,7 +53,9 @@ export function useRefreshSession({
useEffect(
function refetchOnWalletLockOrUnlock() {
const abortCtrl = new AbortController()
refetchSessionData().catch(() => {
if (abortCtrl.signal.aborted) return
const isDevMode = jamSettingsStore.getState().state.developerMode
if (isDevMode) {
toast.error(`[DEV] Error while refreshing session data.`, {
@ -61,6 +63,7 @@ export function useRefreshSession({
})
}
})
return () => abortCtrl.abort()
},
[authState?.walletFileName, refetchSessionData],
)

View file

@ -371,15 +371,14 @@
"error_loading_min_makers_failed": "Loading config value 'minimum_makers' failed.",
"alert_payment_successful": "Payment successful: Sent {{ amount }} sats to {{ address }} in transaction {{ txid }}.",
"alert_collaborative_starting": "Starting collaborative transaction...",
"alert_collaborative_started_title": "Collaborative transaction started",
"alert_collaborative_started_description": "JoinMarket accepted your request and is now coordinating the transaction.",
"alert_collaborative_stopping": "Aborting collaborative transaction...",
"alert_collaborative_awaiting_completion": "Awaiting completion of collaborative transaction...",
"alert_collaborative_completed_title": "Collaborative transaction completed",
"alert_collaborative_completed_description": "JoinMarket finished the collaborative transaction successfully.",
"alert_collaborative_ended_title": "Collaborative flow ended",
"alert_collaborative_ended_description": "The collaborative flow stopped, but no new completed transaction was confirmed yet. Please verify the recipient and transaction history.",
"error_starting_collaborative_transaction": "Error while starting collaborative transaction: {{ reason }}",
"error_stopping_collaborative_transaction": "Error while stopping collaborative transaction: {{ reason }}",
"error_preparing_collaborative_transaction": "Error while preparing collaborative transaction: {{ reason }}",
"text_maker_running": "Earn is active. Stop the service in order to send collaborative transactions.",
"text_coinjoin_already_running": "A collaborative transaction is currently in progress.",
"label_recipient": "Recipient",

View file

@ -0,0 +1,36 @@
import { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { PaymentAbortDialog } from '@/components/send/PaymentAbortDialog'
import { Button } from '@/components/ui/button'
const meta: Meta<typeof PaymentAbortDialog> = {
title: 'Dialog/PaymentAbortDialog',
component: PaymentAbortDialog,
tags: ['autodocs'],
render: (args) => {
const [open, setOpen] = useState(false)
return (
<>
<Button onClick={() => setOpen(true)}>Open</Button>
<PaymentAbortDialog {...args} open={open} onOpenChange={() => setOpen(false)} />
</>
)
},
}
export default meta
type Story = StoryObj<typeof PaymentAbortDialog>
export const Default: Story = {
args: {
isConfirming: false,
onConfirm: async () => alert('Confirm clicked!'),
},
}
export const Confirming: Story = {
args: {
isConfirming: true,
onConfirm: async () => alert('Confirm clicked!'),
},
}