diff --git a/CHANGELOG.md b/CHANGELOG.md index b4a718f4ad..f6e93582ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,13 +25,14 @@ - Move "Export" (export transactions) to account info page - Show coinfinty logo when requesting an address - Update decimal formatting for stablecoin transactions +- Change block explorer to mempool.space +- Integrate Bitrefill and add spending section ## v4.48.8 - Bundle BitBox02 Nova firmware version v9.23.3 ## v4.48.7 - ios: fix Pocket user verification button -- Change block explorer to mempool.space ## v4.48.6 - Android: restore support for Android 6 and Android 5 @@ -39,7 +40,6 @@ ## v4.48.5 - Bundle BitBox02 firmware version v9.23.2 - iOS: fix wrong timezone when confirming time on BitBox02 (it would always show the time in UTC) -- Integrate Bitrefill and add spending section ## v4.48.4 - macOS: fix potential USB communication issue with BitBox02 bootloaders { const { pathname } = useLocation(); const deviceID = Object.keys(devices)[0]; const isBitBox02 = deviceID && devices[deviceID] === 'bitbox02'; - const versionInfo = useLoad(isBitBox02 ? () => getVersion(deviceID) : null, [deviceID]); + const versionInfo = useLoad(isBitBox02 ? () => getVersion(deviceID) : null, [deviceID, isBitBox02]); const canUpgrade = versionInfo ? versionInfo.canUpgrade : false; const onlyHasOneAccount = activeAccounts.length === 1; diff --git a/frontends/web/src/components/dialog/dialog.tsx b/frontends/web/src/components/dialog/dialog.tsx index 36d238639b..41fad83ba5 100644 --- a/frontends/web/src/components/dialog/dialog.tsx +++ b/frontends/web/src/components/dialog/dialog.tsx @@ -93,7 +93,7 @@ export const Dialog = ({ // ESC closes dialog (fires onClose) useEsc(() => { - if (open) { + if (open && onClose) { deactivate(true); } }); @@ -109,6 +109,8 @@ export const Dialog = ({ if ( mouseDownTarget.current === e.currentTarget && e.target === e.currentTarget + && open + && onClose ) { deactivate(true); } @@ -117,8 +119,10 @@ export const Dialog = ({ // Close button handler const handleCloseClick = useCallback(() => { - deactivate(true); - }, [deactivate]); + if (open && onClose) { + deactivate(true); + } + }, [deactivate, onClose, open]); // Back button handler (mobile) const closeHandler = useCallback(() => { diff --git a/frontends/web/src/components/terms/pocket-terms.tsx b/frontends/web/src/components/terms/pocket-terms.tsx index 85d2576f13..9f23f4ed7d 100644 --- a/frontends/web/src/components/terms/pocket-terms.tsx +++ b/frontends/web/src/components/terms/pocket-terms.tsx @@ -59,8 +59,7 @@ export const PocketTerms = ({ onAgreedTerms }: TProps) => {

{t('exchange.pocket.terms.kyc.title')}

diff --git a/frontends/web/src/hooks/keyboard.ts b/frontends/web/src/hooks/keyboard.ts index 6fa388908c..da6c8d9336 100644 --- a/frontends/web/src/hooks/keyboard.ts +++ b/frontends/web/src/hooks/keyboard.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; /** * gets fired on each keydown and executes the provided callback. @@ -71,59 +71,132 @@ export const useFocusTrap = ( ref: React.RefObject, active: boolean, ) => { + const autofocusDelay = 50; + const previouslyFocused = useRef(null); + const trapEnabled = useRef(false); + const autofocusTimer = useRef(null); + const cancelledAutofocus = useRef(false); - // Focus trap handler - const handleKeyDown = (e: KeyboardEvent) => { - if (!active || !ref.current || e.key !== 'Tab') { - return; - } - const node = ref.current; - const focusables = node.querySelectorAll(FOCUSABLE_SELECTOR); - if (!focusables.length) { - return; - } + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!trapEnabled.current || !ref.current || e.key !== 'Tab') { + return; + } - const first = focusables[0]; - const last = focusables[focusables.length - 1]; + const focusables = Array.from( + ref.current.querySelectorAll(FOCUSABLE_SELECTOR) + ).filter((el) => el.offsetParent !== null); // skip hidden - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last?.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first?.focus(); - } - }; + if (focusables.length === 0) { + return; + } - useKeydown(handleKeyDown); + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const current = document.activeElement as HTMLElement; + + if (e.shiftKey && current && current === first) { + e.preventDefault(); + last?.focus(); + } else if (!e.shiftKey && current && current === last) { + e.preventDefault(); + first?.focus(); + } + }, + [ref] + ); - // Manage mount/unmount lifecycle useEffect(() => { + // cleanup from previous runs + if (autofocusTimer.current) { + window.clearTimeout(autofocusTimer.current); + autofocusTimer.current = null; + } + cancelledAutofocus.current = false; + if (!active || !ref.current) { + trapEnabled.current = false; return; } - // Save previously focused element + const node = ref.current; + trapEnabled.current = true; previouslyFocused.current = document.activeElement as HTMLElement; - // Autofocus first element, but only if nothing inside already has focus - if (!ref.current.contains(document.activeElement)) { - const firstFocusable = ( - ref.current.querySelector('[autofocus]:not(:disabled)') - ?? ref.current.querySelector(FOCUSABLE_SELECTOR) - ); - firstFocusable?.focus({ preventScroll: true }); + // If focus is already inside, don't schedule autofocus. + if (node.contains(document.activeElement)) { + // no autofocus needed + } else { + // If any focus enters the dialog while we're waiting, cancel the autofocus. + const onFocusIn = (e: FocusEvent) => { + if (node.contains(e.target as Node)) { + cancelledAutofocus.current = true; + if (autofocusTimer.current) { + window.clearTimeout(autofocusTimer.current); + autofocusTimer.current = null; + } + } + }; + + document.addEventListener('focusin', onFocusIn, true); + + // Delay longer than the 20ms your Dialog uses, default 50ms. + autofocusTimer.current = window.setTimeout(() => { + autofocusTimer.current = null; + if (cancelledAutofocus.current) { + return; + } + + // final guard: if still nothing inside has focus, focus first focusable + if (!node.contains(document.activeElement)) { + const firstFocusable = + node.querySelector('[autofocus]:not(:disabled)') ?? + node.querySelector(FOCUSABLE_SELECTOR); + firstFocusable?.focus({ preventScroll: true }); + } + }, autofocusDelay); + + // remove focusin listener when effect cleanup runs + // (we'll remove it in the effect return) + return () => { + document.removeEventListener('focusin', onFocusIn, true); + }; } + // If we didn't return above (i.e. no focusin listener set), continue to set up keydown below. + // Set up keydown listener on document + const onKeyDown = (e: KeyboardEvent) => handleKeyDown(e); + document.addEventListener('keydown', onKeyDown); + + return () => { + // cleanup in-case we returned earlier (also safe) + document.removeEventListener('keydown', onKeyDown); + }; + }, [active, ref, autofocusDelay, handleKeyDown]); + + // global keydown listener must be attached separately too so it's always active while trapEnabled + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => handleKeyDown(e); + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [handleKeyDown]); + + // cleanup on deactivate: restore previous focus, clear timers/listeners + useEffect(() => { return () => { - // Restore focus if element is still in DOM - if ( - previouslyFocused.current && - document.body.contains(previouslyFocused.current) - ) { - previouslyFocused.current.focus(); + trapEnabled.current = false; + + if (autofocusTimer.current) { + window.clearTimeout(autofocusTimer.current); + autofocusTimer.current = null; + } + + const prev = previouslyFocused.current; + if (prev && document.body.contains(prev)) { + // restore focus + prev.focus(); } }; - }, [ref, active]); + }, []); }; diff --git a/frontends/web/src/locales/en/app.json b/frontends/web/src/locales/en/app.json index 436fa833a8..409018d721 100644 --- a/frontends/web/src/locales/en/app.json +++ b/frontends/web/src/locales/en/app.json @@ -842,9 +842,8 @@ "title": "Payment Methods and Fees" }, "kyc": { + "info": "Pocket may require identification documents. Please refer to their FAQs for more information.", "link": "Read Pocket FAQs", - "p1": "No additional documents are needed for exchanges up to 1000 EUR/CHF within a 30-day period.", - "p2": "For larger amounts, a call with Pocket is required to complete the KYC/AML process.", "title": "KYC/AML (Know Your Customer / Anti-Money Laundering)" }, "security": { diff --git a/frontends/web/src/routes/market/components/infocontent.tsx b/frontends/web/src/routes/market/components/infocontent.tsx index 400c7ae9ad..d8c2e7af14 100644 --- a/frontends/web/src/routes/market/components/infocontent.tsx +++ b/frontends/web/src/routes/market/components/infocontent.tsx @@ -120,11 +120,11 @@ const PocketInfo = ({ bankTransferFee }: TPocketInfoProps) => {

{t('buy.exchange.infoContent.pocket.verification.title')}


-

{t('buy.exchange.infoContent.pocket.verification.info')}

+

{t('exchange.pocket.terms.kyc.info')}


- - {t('buy.exchange.infoContent.pocket.verification.link')} + + {t('exchange.pocket.terms.kyc.link')}


diff --git a/frontends/web/src/routes/settings/more.tsx b/frontends/web/src/routes/settings/more.tsx index e4d2630d7a..8c4ee67ebc 100644 --- a/frontends/web/src/routes/settings/more.tsx +++ b/frontends/web/src/routes/settings/more.tsx @@ -42,7 +42,7 @@ export const More = ({ devices }: Props) => { useOnlyVisitableOnMobile('/settings/general'); const deviceID = Object.keys(devices)[0]; const isBitBox02 = deviceID && devices[deviceID] === 'bitbox02'; - const versionInfo = useLoad(isBitBox02 ? () => getVersion(deviceID) : null, [deviceID]); + const versionInfo = useLoad(isBitBox02 ? () => getVersion(deviceID) : null, [deviceID, isBitBox02]); const canUpgrade = versionInfo ? versionInfo.canUpgrade : false; return (