Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
4 changes: 2 additions & 2 deletions frontends/ios/BitBoxApp/BitBoxApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"BitBoxApp/Preview Content\"";
DEVELOPMENT_TEAM = MXZQ99HRD6;
DEVELOPMENT_TEAM = XK248TQN88;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BitBoxApp/Info.plist;
Expand Down Expand Up @@ -689,7 +689,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"BitBoxApp/Preview Content\"";
DEVELOPMENT_TEAM = MXZQ99HRD6;
DEVELOPMENT_TEAM = XK248TQN88;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BitBoxApp/Info.plist;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.container {
align-items: center;
background-color: var(--background-secondary);
bottom: 0;
border-top: 2px solid var(--bottom-navigation-border-color);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const BottomNavigation = ({ activeAccounts, devices }: Props) => {
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;
Expand Down
10 changes: 7 additions & 3 deletions frontends/web/src/components/dialog/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export const Dialog = ({

// ESC closes dialog (fires onClose)
useEsc(() => {
if (open) {
if (open && onClose) {
deactivate(true);
}
});
Expand All @@ -109,6 +109,8 @@ export const Dialog = ({
if (
mouseDownTarget.current === e.currentTarget
&& e.target === e.currentTarget
&& open
&& onClose
) {
deactivate(true);
}
Expand All @@ -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(() => {
Expand Down
3 changes: 1 addition & 2 deletions frontends/web/src/components/terms/pocket-terms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,7 @@ export const PocketTerms = ({ onAgreedTerms }: TProps) => {

<h2 className={style.title}>{t('exchange.pocket.terms.kyc.title')}</h2>
<ul>
<li><p>{t('exchange.pocket.terms.kyc.p1')}</p></li>
<li><p>{t('exchange.pocket.terms.kyc.p2')}</p></li>
<li><p>{t('exchange.pocket.terms.kyc.info')}</p></li>
</ul>
<p>
<A href="https://pocketbitcoin.com/faq">
Expand Down
149 changes: 111 additions & 38 deletions frontends/web/src/hooks/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -71,59 +71,132 @@ export const useFocusTrap = (
ref: React.RefObject<HTMLElement>,
active: boolean,
) => {
const autofocusDelay = 50;

const previouslyFocused = useRef<HTMLElement | null>(null);
const trapEnabled = useRef(false);
const autofocusTimer = useRef<number | null>(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<HTMLElement>(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<HTMLElement>(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<HTMLElement>('[autofocus]:not(:disabled)')
?? ref.current.querySelector<HTMLElement>(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<HTMLElement>('[autofocus]:not(:disabled)') ??
node.querySelector<HTMLElement>(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]);
}, []);
};
3 changes: 1 addition & 2 deletions frontends/web/src/locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion frontends/web/src/routes/settings/more.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Loading