Skip to content

Commit cea342e

Browse files
benma's agentbenma
authored andcommitted
frontend: extract FW upgrade required dialog and reuse it in send.tsx
And move the check to when selecting the account already, not when clicking Review. `deviceIDs` is re-fetched so we don't have to pass it everywhere.
1 parent 99c500f commit cea342e

File tree

6 files changed

+141
-78
lines changed

6 files changed

+141
-78
lines changed

frontends/web/src/app.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,6 @@ export const App = () => {
216216
<AppRouter
217217
accounts={accounts}
218218
activeAccounts={activeAccounts}
219-
deviceIDs={deviceIDs}
220219
devices={devices}
221220
devicesKey={devicesKey}
222221
/>
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Copyright 2025 Shift Crypto AG
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { useCallback, useContext } from 'react';
18+
import { useNavigate } from 'react-router-dom';
19+
import { useTranslation } from 'react-i18next';
20+
import { Dialog, DialogButtons } from './dialog';
21+
import { getDeviceList } from '@/api/devices';
22+
import { syncDeviceList } from '@/api/devicessync';
23+
import { useSync } from '@/hooks/api';
24+
import { useDefault } from '@/hooks/default';
25+
import { Button } from '@/components/forms';
26+
import { AppContext } from '@/contexts/AppContext';
27+
28+
type TFirmwareUpgradeRequiredDialogProps = {
29+
open: boolean;
30+
onClose: () => void;
31+
};
32+
33+
export const FirmwareUpgradeRequiredDialog = ({ open, onClose }: TFirmwareUpgradeRequiredDialogProps) => {
34+
const { t } = useTranslation();
35+
const navigate = useNavigate();
36+
const { setFirmwareUpdateDialogOpen } = useContext(AppContext);
37+
const devices = useDefault(useSync(getDeviceList, syncDeviceList), {});
38+
const deviceIDs = Object.keys(devices);
39+
40+
const handleUpgrade = useCallback(() => {
41+
setFirmwareUpdateDialogOpen(true);
42+
if (deviceIDs && deviceIDs.length > 0) {
43+
navigate(`/settings/device-settings/${deviceIDs[0] as string}`);
44+
}
45+
onClose();
46+
}, [setFirmwareUpdateDialogOpen, deviceIDs, navigate, onClose]);
47+
48+
return (
49+
<Dialog
50+
title={t('upgradeFirmware.title')}
51+
open={open}
52+
onClose={onClose}>
53+
<p>{t('device.firmwareUpgradeRequired')}</p>
54+
<DialogButtons>
55+
<Button primary onClick={handleUpgrade}>
56+
{t('upgradeFirmware.button')}
57+
</Button>
58+
<Button secondary onClick={onClose}>
59+
{t('dialog.cancel')}
60+
</Button>
61+
</DialogButtons>
62+
</Dialog>
63+
);
64+
};

frontends/web/src/routes/account/send/components/inputs/receiver-address-wrapper.tsx

Lines changed: 63 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,18 @@
1616

1717
import { ChangeEvent, useCallback, useState, useEffect } from 'react';
1818
import { useTranslation } from 'react-i18next';
19+
import { alertUser } from '@/components/alert/Alert';
1920
import { TOption } from '@/components/dropdown/dropdown';
2021
import { InputWithDropdown } from '@/components/forms/input-with-dropdown';
2122
import * as accountApi from '@/api/account';
2223
import { getReceiveAddressList, IAccount } from '@/api/account';
2324
import { statusChanged, syncdone } from '@/api/accountsync';
25+
import { connectKeystore, getKeystoreFeatures } from '@/api/keystores';
2426
import { unsubscribe } from '@/utils/subscriptions';
2527
import { TUnsubscribe } from '@/utils/transport-common';
2628
import { useMountedRef } from '@/hooks/mount';
2729
import { useMediaQuery } from '@/hooks/mediaquery';
30+
import { FirmwareUpgradeRequiredDialog } from '@/components/dialog/firmware-upgrade-required-dialog';
2831
import { SpinnerRingAnimated } from '@/components/spinner/SpinnerAnimation';
2932
import { Logo } from '@/components/icon';
3033
import receiverStyles from './receiver-address-input.module.css';
@@ -73,6 +76,7 @@ export const ReceiverAddressWrapper = ({
7376
children,
7477
}: TReceiverAddressWrapperProps) => {
7578
const { t } = useTranslation();
79+
const [showFirmwareUpgradeDialog, setShowFirmwareUpgradeDialog] = useState(false);
7680
const mounted = useMountedRef();
7781
const isMobile = useMediaQuery('(max-width: 768px)');
7882
const [selectedAccount, setSelectedAccount] = useState<TOption<IAccount | null> | null>(null);
@@ -88,11 +92,34 @@ export const ReceiverAddressWrapper = ({
8892
};
8993
}) : [];
9094

95+
const checkFirmwareSupport = useCallback(async (selectedAccount: accountApi.IAccount) => {
96+
const rootFingerprint = selectedAccount.keystore.rootFingerprint;
97+
const connectResult = await connectKeystore(rootFingerprint);
98+
if (!connectResult.success) {
99+
return false;
100+
}
101+
const featuresResult = await getKeystoreFeatures(rootFingerprint);
102+
if (!featuresResult.success) {
103+
alertUser(featuresResult.errorMessage || t('genericError'));
104+
return false;
105+
}
106+
if (!featuresResult.features?.supportsSendToSelf) {
107+
setShowFirmwareUpgradeDialog(true);
108+
return false;
109+
}
110+
return true;
111+
}, [t]);
112+
91113
const handleSendToAccount = useCallback(async (selectedOption: TAccountOption) => {
92114
if (selectedOption.value === null || selectedOption.disabled) {
93115
return;
94116
}
95117
const selectedAccountValue = selectedOption.value;
118+
119+
const supported = await checkFirmwareSupport(selectedAccountValue);
120+
if (!supported) {
121+
return;
122+
}
96123
setSelectedAccount(selectedOption);
97124
try {
98125
const receiveAddresses = await getReceiveAddressList(selectedAccountValue.code)();
@@ -104,7 +131,7 @@ export const ReceiverAddressWrapper = ({
104131
} catch (e) {
105132
console.error(e);
106133
}
107-
}, [onInputChange, onAccountChange]);
134+
}, [onInputChange, onAccountChange, checkFirmwareSupport]);
108135

109136
const handleReset = useCallback(() => {
110137
setSelectedAccount(null);
@@ -141,34 +168,40 @@ export const ReceiverAddressWrapper = ({
141168
}, [accounts, checkAccountStatus]);
142169

143170
return (
144-
<InputWithDropdown
145-
id="recipientAddress"
146-
label={t('send.address.label')}
147-
error={error}
148-
align="left"
149-
placeholder={t('send.address.placeholder')}
150-
onInput={(e: ChangeEvent<HTMLInputElement>) => onInputChange(e.target.value)}
151-
value={recipientAddress}
152-
disabled={selectedAccount !== null}
153-
autoFocus={!isMobile}
154-
dropdownOptions={accountOptions}
155-
dropdownValue={selectedAccount}
156-
onDropdownChange={(selected) => {
157-
if (selected && selected.value !== null && !(selected as TAccountOption).disabled) {
158-
handleSendToAccount(selected as TAccountOption);
159-
}
160-
}}
161-
dropdownPlaceholder={t('send.sendToAccount.placeholder')}
162-
dropdownTitle={t('send.sendToAccount.title')}
163-
renderOptions={(e, isSelectedValue) => <AccountOption option={e} isSelectedValue={isSelectedValue} />}
164-
isOptionDisabled={(option) => (option as TAccountOption).disabled || false}
165-
labelSection={selectedAccount ? (
166-
<span role="button" id="sendToSelf" className={receiverStyles.action} onClick={handleReset}>
167-
{t('generic.reset')}
168-
</span>
169-
) : undefined}
170-
>
171-
{children}
172-
</InputWithDropdown>
171+
<>
172+
<InputWithDropdown
173+
id="recipientAddress"
174+
label={t('send.address.label')}
175+
error={error}
176+
align="left"
177+
placeholder={t('send.address.placeholder')}
178+
onInput={(e: ChangeEvent<HTMLInputElement>) => onInputChange(e.target.value)}
179+
value={recipientAddress}
180+
disabled={selectedAccount !== null}
181+
autoFocus={!isMobile}
182+
dropdownOptions={accountOptions}
183+
dropdownValue={selectedAccount}
184+
onDropdownChange={(selected) => {
185+
if (selected && selected.value !== null && !(selected as TAccountOption).disabled) {
186+
handleSendToAccount(selected as TAccountOption);
187+
}
188+
}}
189+
dropdownPlaceholder={t('send.sendToAccount.placeholder')}
190+
dropdownTitle={t('send.sendToAccount.title')}
191+
renderOptions={(e, isSelectedValue) => <AccountOption option={e} isSelectedValue={isSelectedValue} />}
192+
isOptionDisabled={(option) => (option as TAccountOption).disabled || false}
193+
labelSection={selectedAccount ? (
194+
<span role="button" id="sendToSelf" className={receiverStyles.action} onClick={handleReset}>
195+
{t('generic.reset')}
196+
</span>
197+
) : undefined}
198+
>
199+
{children}
200+
</InputWithDropdown>
201+
<FirmwareUpgradeRequiredDialog
202+
open={showFirmwareUpgradeDialog}
203+
onClose={() => setShowFirmwareUpgradeDialog(false)}
204+
/>
205+
</>
173206
);
174207
};

frontends/web/src/routes/account/send/send.tsx

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import { NoteInput } from './components/inputs/note-input';
4242
import { FiatValue } from './components/fiat-value';
4343
import { TProposalError, txProposalErrorHandling } from './services';
4444
import { CoinControl } from './coin-control';
45-
import { connectKeystore, getKeystoreFeatures } from '@/api/keystores';
45+
import { connectKeystore } from '@/api/keystores';
4646
import style from './send.module.css';
4747

4848
type TProps = {
@@ -78,7 +78,6 @@ export const Send = ({
7878
activeCurrency,
7979
}: TProps) => {
8080
const { t } = useTranslation();
81-
8281
const selectedUTXOsRef = useRef<TSelectedUTXOs>({});
8382
const [utxoDialogActive, setUtxoDialogActive] = useState(false);
8483
// in case there are multiple parallel tx proposals we can ignore all other but the last one
@@ -135,17 +134,6 @@ export const Send = ({
135134
if (!connectResult.success) {
136135
return;
137136
}
138-
if (selectedReceiverAccount) {
139-
const featuresResult = await getKeystoreFeatures(rootFingerprint);
140-
if (!featuresResult.success) {
141-
alertUser(featuresResult.errorMessage || t('genericError'));
142-
return;
143-
}
144-
if (!featuresResult.features?.supportsSendToSelf) {
145-
alertUser(t('device.firmwareUpgradeRequired'));
146-
return;
147-
}
148-
}
149137
setIsConfirming(true);
150138
try {
151139
const result = await accountApi.sendTx(account.code, note);
@@ -156,7 +144,7 @@ export const Send = ({
156144
// The following method allows pressing escape again.
157145
setIsConfirming(false);
158146
}
159-
}, [account.code, account.keystore.rootFingerprint, note, selectedReceiverAccount, t]);
147+
}, [account.code, account.keystore.rootFingerprint, note]);
160148

161149
const getValidTxInputData = useCallback((): Required<accountApi.TTxInput> | false => {
162150
if (

frontends/web/src/routes/market/pocket.tsx

Lines changed: 11 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,12 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { createRef, useContext, useEffect, useState, useRef } from 'react';
17+
import { createRef, useEffect, useState, useRef } from 'react';
1818
import { useTranslation } from 'react-i18next';
1919
import { useNavigate } from 'react-router-dom';
2020
import { RequestAddressV0Message, MessageVersion, parseMessage, serializeMessage, V0MessageType, PaymentRequestV0Message } from 'request-address';
21-
import { AppContext } from '@/contexts/AppContext';
2221
import { getConfig } from '@/utils/config';
23-
import { Dialog, DialogButtons } from '@/components/dialog/dialog';
22+
import { Dialog } from '@/components/dialog/dialog';
2423
import { confirmation } from '@/components/confirm/Confirm';
2524
import { verifyAddress, getPocketURL, TMarketAction } from '@/api/market';
2625
import { AccountCode, getInfo, getTransactionList, hasPaymentRequest, signAddress, proposeTx, sendTx, TTxInput } from '@/api/account';
@@ -33,26 +32,23 @@ import { alertUser } from '@/components/alert/Alert';
3332
import { MarketGuide } from './guide';
3433
import { convertScriptType } from '@/utils/request-addess';
3534
import { parseExternalBtcAmount } from '@/api/coins';
35+
import { FirmwareUpgradeRequiredDialog } from '@/components/dialog/firmware-upgrade-required-dialog';
3636
import style from './iframe.module.css';
37-
import { Button } from '@/components/forms';
3837

3938
type TProps = {
4039
action: TMarketAction;
4140
code: AccountCode;
42-
deviceIDs: string[];
4341
}
4442

4543
export const Pocket = ({
4644
action,
4745
code,
48-
deviceIDs,
4946
}: TProps) => {
5047
const { t } = useTranslation();
5148
const navigate = useNavigate();
5249

5350
// Pocket sell only works if the FW supports payment requests
5451
const hasPaymentRequestResponse = useLoad(() => hasPaymentRequest(code));
55-
const { setFirmwareUpdateDialogOpen } = useContext(AppContext);
5652
const [fwRequiredDialog, setFwRequiredDialog] = useState(false);
5753

5854
const [height, setHeight] = useState(0);
@@ -91,7 +87,7 @@ export const Pocket = ({
9187
alertUser(hasPaymentRequestResponse?.errorMessage);
9288
}
9389
}
94-
}, [action, deviceIDs, hasPaymentRequestResponse, navigate, setFirmwareUpdateDialogOpen, t]);
90+
}, [action, hasPaymentRequestResponse, t]);
9591

9692
useEffect(() => {
9793
if (config) {
@@ -382,27 +378,13 @@ export const Pocket = ({
382378
medium centered>
383379
<div className="text-center">{t('buy.pocket.verifyBitBox02')}</div>
384380
</Dialog>
385-
<Dialog title={t('upgradeFirmware.title')} open={fwRequiredDialog}>
386-
{t('device.firmwareUpgradeRequired')}
387-
<DialogButtons>
388-
<Button
389-
primary
390-
onClick={() => {
391-
setFirmwareUpdateDialogOpen(true);
392-
navigate(`/settings/device-settings/${deviceIDs[0] as string}`);
393-
}}>
394-
{t('upgradeFirmware.button')}
395-
</Button>
396-
<Button
397-
secondary
398-
onClick={() => {
399-
setFwRequiredDialog(false);
400-
navigate(-1);
401-
}}>
402-
{t('dialog.cancel')}
403-
</Button>
404-
</DialogButtons>
405-
</Dialog>
381+
<FirmwareUpgradeRequiredDialog
382+
open={fwRequiredDialog}
383+
onClose={() => {
384+
setFwRequiredDialog(false);
385+
navigate(-1);
386+
}}
387+
/>
406388
</div>
407389
</div>
408390
<MarketGuide vendor="pocket" translationContext="bitcoin" />

frontends/web/src/routes/router.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ import { More } from '@/routes/settings/more';
5555

5656
type TAppRouterProps = {
5757
devices: TDevices;
58-
deviceIDs: string[];
5958
accounts: IAccount[];
6059
activeAccounts: IAccount[];
6160
devicesKey: ((input: string) => string)
@@ -70,7 +69,7 @@ const InjectParams = ({ children }: TInjectParamsProps) => {
7069
return React.cloneElement(children as React.ReactElement, params);
7170
};
7271

73-
export const AppRouter = ({ devices, deviceIDs, devicesKey, accounts, activeAccounts }: TAppRouterProps) => {
72+
export const AppRouter = ({ devices, devicesKey, accounts, activeAccounts }: TAppRouterProps) => {
7473
const hasAccounts = accounts.length > 0;
7574
const Homepage = (<DeviceSwitch
7675
key={devicesKey('device-switch-default')}
@@ -199,15 +198,13 @@ export const AppRouter = ({ devices, deviceIDs, devicesKey, accounts, activeAcco
199198
<Pocket
200199
action="buy"
201200
code={''}
202-
deviceIDs={deviceIDs}
203201
/>
204202
</InjectParams>);
205203

206204
const PocketSellEl = (<InjectParams>
207205
<Pocket
208206
action="sell"
209207
code={''}
210-
deviceIDs={deviceIDs}
211208
/>
212209
</InjectParams>);
213210

0 commit comments

Comments
 (0)