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
2 changes: 1 addition & 1 deletion frontends/web/src/api/market.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export type TBTCDirectInfoResponse = {
address?: string;
} | {
success: false;
errorMessage: string;
errorMessage: string; // TODO: add 'syncInProgress'
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once the backend gets cleaned up it would be nice to explicitly type all the known errors here. cc @bznein

};
Comment thread
thisconnect marked this conversation as resolved.

export const getBTCDirectInfo = async (action: TMarketAction, code: string): Promise<TBTCDirectInfoResponse> => {
Expand Down
2 changes: 1 addition & 1 deletion frontends/web/src/components/aopp/aopp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export const Aopp = () => {
<Vasp hostname={domain(aopp.callback)} />
</ViewHeader>
<ViewContent>
<p>{t('aopp.syncing')}</p>
<p>{t('account.syncing')}</p>
</ViewContent>
<ViewButtons>
<Button secondary onClick={aoppAPI.cancel}>{t('dialog.cancel')}</Button>
Expand Down
149 changes: 149 additions & 0 deletions frontends/web/src/hooks/account.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: Apache-2.0

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
import { useState } from 'react';
import { useAccountSynced } from './account';
import { syncdone } from '@/api/accountsync';
import * as utils from './mount';
Comment thread
thisconnect marked this conversation as resolved.

vi.mock('@/api/accountsync', () => ({
syncdone: vi.fn(() => () => {}),
}));

const mockSyncdone = vi.mocked(syncdone);

const useMountedRefSpy = vi.spyOn(utils, 'useMountedRef');

describe('useAccountSynced', () => {
beforeEach(() => {
useMountedRefSpy.mockReturnValue({ current: true });
vi.clearAllMocks();
});

it('returns undefined while loading', () => {
const mockApiCall = vi.fn().mockReturnValue(new Promise(() => {}));

const { result } = renderHook(() => (
useAccountSynced('account-code-1' as any, mockApiCall)
));

expect(result.current).toBeUndefined();
});

it('returns api result on success', async () => {
const mockApiCall = vi.fn().mockResolvedValue('result');

const { result } = renderHook(() => (
useAccountSynced('account-code-1' as any, mockApiCall)
));

await waitFor(() => expect(result.current).toBe('result'));

expect(mockApiCall).toHaveBeenCalledTimes(1);
expect(mockSyncdone).toHaveBeenCalledWith(
'account-code-1',
expect.any(Function),
);
});

it('clears previous result when code changes', async () => {
let resolveApiCall: ((value: string) => void) | undefined;

const mockApiCall = vi.fn().mockImplementation(() => (
new Promise<string>((resolve) => {
resolveApiCall = resolve;
})
));

const { result } = renderHook(() => {
const [code, setCode] = useState('account-code-1');

const value = useAccountSynced(
code as any,
mockApiCall,
);

return {
value,
setCode,
};
});

act(() => {
resolveApiCall?.('result-1');
});

await waitFor(() => {
expect(result.current.value).toBe('result-1');
});

act(() => {
result.current.setCode('account-code-2');
});

expect(result.current.value).toBeUndefined();

act(() => {
resolveApiCall?.('result-2');
});

await waitFor(() => {
expect(result.current.value).toBe('result-2');
});

expect(mockApiCall).toHaveBeenCalledTimes(2);
});

it('ignores stale api responses', async () => {
let resolveFirst: ((value: string) => void) | undefined;
let resolveSecond: ((value: string) => void) | undefined;

const mockApiCall = vi
.fn()
.mockImplementationOnce(() => (
new Promise<string>((resolve) => {
resolveFirst = resolve;
})
))
.mockImplementationOnce(() => (
new Promise<string>((resolve) => {
resolveSecond = resolve;
})
));

const { result } = renderHook(() => {
const [code, setCode] = useState('account-code-1');

const value = useAccountSynced(
code as any,
mockApiCall,
);

return {
value,
setCode,
};
});

act(() => {
result.current.setCode('account-code-2');
});

act(() => {
resolveFirst?.('stale-result');
});

expect(result.current.value).toBeUndefined();

act(() => {
resolveSecond?.('fresh-result');
});

await waitFor(() => {
expect(result.current.value).toBe('fresh-result');
});

expect(result.current.value).not.toBe('stale-result');
});
});
38 changes: 38 additions & 0 deletions frontends/web/src/hooks/account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: Apache-2.0

import { useCallback, useEffect, useRef, useState } from 'react';
import type { AccountCode } from '@/api/account';
import { syncdone } from '@/api/accountsync';
import { useMountedRef } from './mount';
Comment thread
thisconnect marked this conversation as resolved.

export const useAccountSynced = <T, >(
code: AccountCode,
apiCall: () => Promise<T>,
): T | undefined => {
const isMounted = useMountedRef();
const apiRequestId = useRef(0);
const [result, setResult] = useState<T | undefined>(undefined);

const callApi = useCallback(async () => {
const requestId = ++apiRequestId.current;
try {
const response = await apiCall();
if (
isMounted.current
&& requestId === apiRequestId.current
) {
setResult(response);
}
Comment thread
thisconnect marked this conversation as resolved.
} catch (error) {
console.error(error);
}
}, [apiCall, isMounted]);
Comment thread
thisconnect marked this conversation as resolved.

useEffect(() => {
setResult(undefined);
callApi();
return syncdone(code, callApi);
}, [code, callApi]);

return result;
};
2 changes: 1 addition & 1 deletion frontends/web/src/locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"maybeProxyError": "Tor proxy enabled. Ensure that your Tor proxy is running properly, or disable the proxy setting.",
"reconnecting": "Lost connection, trying to reconnect…",
"syncedAddressesCount": "Scanned {{count}} addresses",
"syncing": "Syncing the account, please wait…",
"uncoveredFunds": "You have coins on the following uncovered address types of your <strong>{{name}}</strong> account: {{uncovered}}.\nSince the account is insured, only coins received via the <strong>Native Segwit</strong> address type are covered. Coins on different address types, even if they are on the same account, are not insured.\nPlease move all your coins from the unsupported address types to the <strong>Native Segwit</strong> address type, so all your coins on this account are insured.",
"uncoveredFundsLink": "Follow this guide on how to move your coins.",
"warning": "Warning!"
Expand Down Expand Up @@ -122,7 +123,6 @@
"message": "Proceed on {{host}}",
"title": "Address successfully sent"
},
"syncing": "Syncing the account, please wait.",
"title": "Address request",
"xpubRequested": "Sharing your xpub lets external services see your account addresses. Your coins are still safe and remain in your control."
},
Expand Down
16 changes: 8 additions & 8 deletions frontends/web/src/routes/market/bitrefill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { getURLOrigin } from '@/utils/url';
import { ConfirmBitrefill } from './bitrefill-confirm';
import { AppContext } from '@/contexts/AppContext';
import { useVendorIframeResizeHeight, useVendorTerms } from '@/hooks/vendor-iframe';
import { useAccountSynced } from '@/hooks/account';
import style from './iframe.module.css';

// Map coins supported by Bitrefill
Expand Down Expand Up @@ -48,7 +49,9 @@ export const Bitrefill = ({
const { isDevServers } = useContext(AppContext);
const account = findAccount(accounts, code);

const bitrefillInfo = useLoad(() => getBitrefillInfo('spend', code));
const fetchBitrefillInfo = useCallback(() => getBitrefillInfo('spend', code), [code]);
const bitrefillInfo = useAccountSynced(code, fetchBitrefillInfo);


const config = useLoad(getConfig);
const { containerRef, height, iframeLoaded, iframeRef, onIframeLoad } = useVendorIframeResizeHeight();
Expand Down Expand Up @@ -189,12 +192,7 @@ export const Bitrefill = ({
};
}, [handleMessage]);

if (
!account
|| !config
|| !bitrefillInfo?.success
|| !bitrefillInfo.address
) {
if (!account || !config) {
return null;
}

Expand Down Expand Up @@ -222,7 +220,9 @@ export const Bitrefill = ({
/>
) : (
<div style={{ height }}>
{!iframeLoaded && <Spinner text={t('loading')} />}
{!iframeLoaded && (
<Spinner text={t('loading')} />
)}
{ bitrefillInfo?.success && (
<iframe
ref={iframeRef}
Expand Down
14 changes: 12 additions & 2 deletions frontends/web/src/routes/market/btcdirect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { parseExternalBtcAmount } from '@/api/coins';
import { AppContext } from '@/contexts/AppContext';
import { AccountCode, TAccount, proposeTx, sendTx, TTxInput } from '@/api/account';
import { useLoad } from '@/hooks/api';
Comment thread
thisconnect marked this conversation as resolved.
import { useAccountSynced } from '@/hooks/account';
import { useDarkmode } from '@/hooks/darkmode';
import { UseDisableBackButton } from '@/hooks/backbutton';
import { getConfig } from '@/utils/config';
Expand Down Expand Up @@ -48,7 +49,8 @@ export const BTCDirect = ({
const { isDarkMode } = useDarkmode();
const navigate = useNavigate();

const btcdirectInfo = useLoad(() => getBTCDirectInfo(action, code));
const fetchBTCDirectInfo = useCallback(() => getBTCDirectInfo(action, code), [action, code]);
const btcdirectInfo = useAccountSynced(code, fetchBTCDirectInfo);

const [blocking, setBlocking] = useState(false);

Expand Down Expand Up @@ -209,6 +211,8 @@ export const BTCDirect = ({
t('generic.sell', { context: translationContext })
);

const syncInProgress = !btcdirectInfo?.success && btcdirectInfo?.errorMessage === 'syncInProgress';

return (
<div className="contentWithGuide">
<div className="container">
Expand All @@ -230,7 +234,13 @@ export const BTCDirect = ({
) : (
<div style={{ height }}>
<UseDisableBackButton />
{!iframeLoaded && <Spinner text={t('loading')} />}
{!iframeLoaded && (
syncInProgress ? (
<Spinner text={t('account.syncing')} />
) : (
<Spinner text={t('loading')} />
)
)}
{blocking && (
<div className={style.blocking}></div>
)}
Expand Down
Loading