Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 90 additions & 37 deletions react/lib/components/Widget/AltpaymentWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ interface AltpaymentProps {
updateAmount: Function;
}

type ShiftCopyField = 'amount' | 'address' | 'id'

export const AltpaymentWidget: React.FunctionComponent<AltpaymentProps> = props => {

const {
Expand Down Expand Up @@ -73,9 +75,13 @@ export const AltpaymentWidget: React.FunctionComponent<AltpaymentProps> = props
const [selectedCoinNetwork, setSelectedCoinNetwork] = useState<string | undefined>(undefined);
const [pairAmountFixedDecimals, setPairAmountFixedDecimals] = useState<string | undefined>(undefined);
const [pairAmount, setPairAmount] = useState<string | undefined>(undefined);
const [copiedField, setCopiedField] = useState<ShiftCopyField | undefined>(undefined);
const [qrCopied, setQrCopied] = useState(false);
const autoRateRequestedRef = useRef(false);
const autoQuoteRequestedRef = useRef(false);
const prevAltpaymentSocketRef = useRef<Socket | undefined>(undefined);
const copiedFieldTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const qrCopiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const getDepositDecimals = (
coin: AltpaymentCoin,
Expand Down Expand Up @@ -206,6 +212,17 @@ export const AltpaymentWidget: React.FunctionComponent<AltpaymentProps> = props
setLoadingPair,
])

useEffect(() => {
return () => {
if (copiedFieldTimeoutRef.current) {
clearTimeout(copiedFieldTimeoutRef.current)
}
if (qrCopiedTimeoutRef.current) {
clearTimeout(qrCopiedTimeoutRef.current)
}
}
}, [])

const handleCoinChange = async (e: React.ChangeEvent<{ name?: string; value: unknown }>) => {
const coinName = e.target.value as string
const selectedCoin = coins.find(c => c.coin === coinName)
Expand Down Expand Up @@ -317,46 +334,46 @@ export const AltpaymentWidget: React.FunctionComponent<AltpaymentProps> = props
setShiftCompleted(false)
}

const showCopyToast = (message: string): void => {
const existingToast = document.getElementById('paybutton-copy-toast')
if (existingToast) {
existingToast.remove()
}

const toast = document.createElement('div')
toast.id = 'paybutton-copy-toast'
toast.textContent = message
toast.style.position = 'fixed'
toast.style.left = '50%'
toast.style.bottom = '16px'
toast.style.transform = 'translateX(-50%)'
toast.style.background = 'rgba(35, 31, 32, 0.9)'
toast.style.color = '#fff'
toast.style.padding = '8px 12px'
toast.style.borderRadius = '6px'
toast.style.fontSize = '12px'
toast.style.lineHeight = '1'
toast.style.zIndex = '2147483647'
toast.style.pointerEvents = 'none'
document.body.appendChild(toast)

setTimeout(() => {
if (toast.parentElement) {
toast.remove()
}
}, 1500)
const showCopiedField = (field: ShiftCopyField): void => {
setCopiedField(field)
if (copiedFieldTimeoutRef.current) {
clearTimeout(copiedFieldTimeoutRef.current)
}
copiedFieldTimeoutRef.current = setTimeout(() => {
setCopiedField(undefined)
copiedFieldTimeoutRef.current = null
}, 1000)
}

const copyToClipboard = async (value: string): Promise<void> => {
const showQrCopied = (): void => {
setQrCopied(true)
if (qrCopiedTimeoutRef.current) {
clearTimeout(qrCopiedTimeoutRef.current)
}
qrCopiedTimeoutRef.current = setTimeout(() => {
setQrCopied(false)
qrCopiedTimeoutRef.current = null
}, 1000)
}

const copyToClipboard = async (
value: string,
options?: { copiedField?: ShiftCopyField; qr?: boolean },
): Promise<void> => {
if (!value) {
return
}

try {
await navigator.clipboard.writeText(value)
showCopyToast('Copied')
if (options?.copiedField) {
showCopiedField(options.copiedField)
}
if (options?.qr) {
showQrCopied()
}
} catch {
showCopyToast('Copy failed')
// Intentionally no failure UI for altpayment copy actions.
}
}

Expand Down Expand Up @@ -488,6 +505,26 @@ export const AltpaymentWidget: React.FunctionComponent<AltpaymentProps> = props
border: '1px solid #b3b3b3', wordBreak: 'break-word', overflowWrap: 'anywhere', flex: '1 1 auto', position: 'relative', minWidth: 0,
})

const ShiftCopiedText = styled('div')({
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '10px',
boxSizing: 'border-box',
fontSize: '14px',
lineHeight: 1.25,
fontWeight: 400,
color: 'rgb(35, 31, 32)',
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
background: '#ffffff',
pointerEvents: 'none',
})

const ShiftValueRow = styled('div')({
display: 'flex',
alignItems: 'center',
Expand Down Expand Up @@ -518,6 +555,7 @@ export const AltpaymentWidget: React.FunctionComponent<AltpaymentProps> = props
margin: '8px auto 4px',
alignSelf: 'center',
textAlign: 'center',
position: 'relative',
cursor: 'pointer',
transition: 'box-shadow 160ms ease, transform 160ms ease',
'&:hover': {
Expand All @@ -526,14 +564,25 @@ export const AltpaymentWidget: React.FunctionComponent<AltpaymentProps> = props
},
})

const QrCopyText = styled('div')({
position: 'absolute',
right: '12px',
bottom: '10px',
background: 'rgba(255, 255, 255, 0.8)',
padding: '0 2px 2px 0',
fontSize: '11px',
lineHeight: 1.2,
color: 'rgb(35, 31, 32)',
pointerEvents: 'none',
})

const QrTitle = styled('div')({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
fontWeight: 600,
marginBottom: '8px',
})

const InlineCoin = styled('span')({
Expand Down Expand Up @@ -715,8 +764,9 @@ export const AltpaymentWidget: React.FunctionComponent<AltpaymentProps> = props
/>
<span>{altpaymentShift.depositAmount}{' '}{altpaymentShift.depositCoin}</span>
</ShiftValueRow>
{copiedField === 'amount' ? <ShiftCopiedText>Copied Amount!</ShiftCopiedText> : null}
</ShiftInput>
<CopyBtn onClick={() => { void copyToClipboard(altpaymentShift.depositAmount) }}>
<CopyBtn data-testid="altpayment-copy-amount" onClick={() => { void copyToClipboard(altpaymentShift.depositAmount, { copiedField: 'amount' }) }}>
<img
src={copyIcon}
alt="Copy"
Expand All @@ -732,15 +782,16 @@ export const AltpaymentWidget: React.FunctionComponent<AltpaymentProps> = props
<ShiftAddress>
{altpaymentShift.depositAddress}
</ShiftAddress>
{copiedField === 'address' ? <ShiftCopiedText>Copied Address!</ShiftCopiedText> : null}
</ShiftInput>
<CopyBtn onClick={() => { void copyToClipboard(altpaymentShift.depositAddress) }}>
<CopyBtn data-testid="altpayment-copy-address" onClick={() => { void copyToClipboard(altpaymentShift.depositAddress, { copiedField: 'address' }) }}>
<img
src={copyIcon}
alt="Copy"
/>
</CopyBtn>
</CopyCtn>
<QrCard onClick={() => { void copyToClipboard(shiftQrValue) }}>
<QrCard data-testid="altpayment-qr-click-area" onClick={() => { void copyToClipboard(shiftQrValue, { qr: true }) }}>
<QrTitle>
<ShiftCurrencyIcon
src={getCoinIconSrc(altpaymentShift.depositCoin)}
Expand All @@ -754,13 +805,15 @@ export const AltpaymentWidget: React.FunctionComponent<AltpaymentProps> = props
level="M"
includeMargin
/>
<QrCopyText>{qrCopied ? 'Payment copied!' : 'Click to copy'}</QrCopyText>
</QrCard>
<ShiftLabel>SideShift ID</ShiftLabel>
<CopyCtn>
<ShiftInput>
{altpaymentShift.id}
<ShiftAddress>{altpaymentShift.id}</ShiftAddress>
{copiedField === 'id' ? <ShiftCopiedText>Copied SideShift ID!</ShiftCopiedText> : null}
</ShiftInput>
<CopyBtn onClick={() => { void copyToClipboard(altpaymentShift.id) }}>
<CopyBtn data-testid="altpayment-copy-id" onClick={() => { void copyToClipboard(altpaymentShift.id, { copiedField: 'id' }) }}>
<img
src={copyIcon}
alt="Copy"
Expand Down
119 changes: 119 additions & 0 deletions react/lib/tests/components/AltpaymentWidget.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { act } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'

import { AltpaymentWidget } from '../../components/Widget/AltpaymentWidget'

const altpaymentShift = {
depositAmount: '0.01',
depositCoin: 'BTC',
depositAddress: 'bc1-test-address',
settleCoin: 'XEC',
id: 'shift-123',
}

const coins = [
{
coin: 'BTC',
name: 'Bitcoin',
networks: ['bitcoin'],
},
]

const baseProps = {
setUseAltpayment: jest.fn(),
setAltpaymentShift: jest.fn(),
shiftCompleted: false,
setShiftCompleted: jest.fn(),
setAltpaymentError: jest.fn(),
coins,
loadingPair: false,
setLoadingPair: jest.fn(),
loadingShift: false,
setLoadingShift: jest.fn(),
setCoinPair: jest.fn(),
altpaymentEditable: false,
addressType: 'XEC',
to: 'ecash:qqtestaddress',
updateAmount: jest.fn(),
preselectedCoin: 'BTC',
}

let writeTextMock: jest.Mock

describe('AltpaymentWidget copy feedback', () => {
beforeEach(() => {
jest.useFakeTimers()
writeTextMock = jest.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: writeTextMock,
},
})
})

afterEach(() => {
jest.clearAllTimers()
jest.useRealTimers()
jest.clearAllMocks()
cleanup()
})

test.each([
['altpayment-copy-amount', '0.01', 'Copied Amount!', '0.01 BTC'],
['altpayment-copy-address', 'bc1-test-address', 'Copied Address!', 'bc1-test-address'],
['altpayment-copy-id', 'shift-123', 'Copied SideShift ID!', 'shift-123'],
])('copy button %s shows temporary inline feedback', async (testId, copiedValue, copiedText, restoredText) => {
render(
<AltpaymentWidget
{...baseProps}
altpaymentShift={altpaymentShift as any}
/>,
)

await act(async () => {
fireEvent.click(screen.getByTestId(testId))
})

expect(writeTextMock).toHaveBeenCalledWith(copiedValue)
await waitFor(() => {
expect(screen.getByText(copiedText)).toBeTruthy()
})

act(() => {
jest.advanceTimersByTime(1000)
})

await waitFor(() => {
expect(screen.getByText(restoredText)).toBeTruthy()
})
})

test('qr click shows payment copied feedback in the card corner', async () => {
render(
<AltpaymentWidget
{...baseProps}
altpaymentShift={altpaymentShift as any}
/>,
)

expect(screen.getByText('Click to copy')).toBeTruthy()

await act(async () => {
fireEvent.click(screen.getByTestId('altpayment-qr-click-area'))
})

expect(writeTextMock).toHaveBeenCalledWith('bitcoin:bc1-test-address?amount=0.01')
await waitFor(() => {
expect(screen.getByText('Payment copied!')).toBeTruthy()
})

act(() => {
jest.advanceTimersByTime(1000)
})

await waitFor(() => {
expect(screen.getByText('Click to copy')).toBeTruthy()
})
})
})
Loading