mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-17 13:07:34 +02:00
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:
parent
ee5770eeeb
commit
07e387795f
15 changed files with 566 additions and 373 deletions
|
|
@ -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'],
|
||||
|
|
|
|||
34
src/App.tsx
34
src/App.tsx
|
|
@ -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 <></>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
50
src/components/send/PaymentAbortDialog.tsx
Normal file
50
src/components/send/PaymentAbortDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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')}</>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
36
src/stories/jam/dialogs/PaymentAbortDialog.stories.tsx
Normal file
36
src/stories/jam/dialogs/PaymentAbortDialog.stories.tsx
Normal 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!'),
|
||||
},
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue