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 arkd.Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# First image used to build the sources
FROM golang:1.25.3 AS builder
FROM golang:1.25.5 AS builder

ARG TARGETOS
ARG TARGETARCH
Expand Down
2 changes: 1 addition & 1 deletion arkdwallet.Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# First stage: build the ark-wallet-daemon binary
FROM golang:1.25.3 AS builder
FROM golang:1.25.5 AS builder

ARG VERSION
ARG TARGETOS
Expand Down
2 changes: 1 addition & 1 deletion nak.Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# build stage
FROM golang:1.25.3-alpine AS builder
FROM golang:1.25.5-alpine AS builder

# Install build dependencies for CGO (required by nak's lmdb dependency)
RUN apk add --no-cache gcc musl-dev
Expand Down
47 changes: 32 additions & 15 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { pwaIsInstalled } from './lib/pwa'
import FlexCol from './components/FlexCol'
import WalletIcon from './icons/Wallet'
import AppsIcon from './icons/Apps'
import Focusable from './components/Focusable'

setupIonicReact()

Expand Down Expand Up @@ -65,6 +66,16 @@ export default function App() {
.finally(() => setJsCapabilitiesChecked(true))
}, [])

// Global escape key to go back to wallet
useEffect(() => {
if (!navigate) return
const handleGlobalDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') navigate(Pages.Wallet)
}
window.addEventListener('keydown', handleGlobalDown)
return () => window.removeEventListener('keydown', handleGlobalDown)
}, [navigate])

useEffect(() => {
if (aspInfo.unreachable) return navigate(Pages.Unavailable)
if (jsCapabilitiesChecked && !isCapable) return navigate(Pages.Unavailable)
Expand Down Expand Up @@ -137,23 +148,29 @@ export default function App() {
{tab === Tabs.Settings ? comp : <></>}
</IonTab>
<IonTabBar slot='bottom'>
<IonTabButton tab={Tabs.Wallet} selected={tab === Tabs.Wallet} onClick={handleWallet}>
<FlexCol centered gap='6px' testId='tab-wallet'>
<WalletIcon />
Wallet
</FlexCol>
<IonTabButton tab={Tabs.Wallet} onClick={handleWallet} selected={tab === Tabs.Wallet}>
<Focusable>
<FlexCol centered gap='6px' padding='5px' testId='tab-wallet'>
<WalletIcon />
Wallet
</FlexCol>
</Focusable>
</IonTabButton>
<IonTabButton tab={Tabs.Apps} selected={tab === Tabs.Apps} onClick={handleApps}>
<FlexCol centered gap='6px' testId='tab-apps'>
<AppsIcon />
Apps
</FlexCol>
<IonTabButton tab={Tabs.Apps} onClick={handleApps} selected={tab === Tabs.Apps}>
<Focusable>
<FlexCol centered gap='6px' padding='5px' testId='tab-apps'>
<AppsIcon />
Apps
</FlexCol>
</Focusable>
</IonTabButton>
<IonTabButton tab={Tabs.Settings} selected={tab === Tabs.Settings} onClick={handleSettings}>
<FlexCol centered gap='6px' testId='tab-settings'>
<SettingsIcon />
Settings
</FlexCol>
<IonTabButton tab={Tabs.Settings} onClick={handleSettings} selected={tab === Tabs.Settings}>
<Focusable>
<FlexCol centered gap='6px' padding='5px' testId='tab-settings'>
<SettingsIcon />
Settings
</FlexCol>
</Focusable>
</IonTabButton>
</IonTabBar>
</IonTabs>
Expand Down
65 changes: 44 additions & 21 deletions src/components/ExpandAddresses.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import Text, { TextSecondary } from './Text'
import { prettyLongText } from '../lib/format'
import ChevronDownIcon from '../icons/ChevronDown'
Expand All @@ -11,6 +11,7 @@ import { copyToClipboard } from '../lib/clipboard'
import CheckMarkIcon from '../icons/CheckMark'
import { useIonToast } from '@ionic/react'
import { copiedToClipboard } from '../lib/toast'
import Focusable from './Focusable'

interface ExpandAddressesProps {
bip21uri: string
Expand All @@ -32,6 +33,19 @@ export default function ExpandAddresses({

const [present] = useIonToast()

useEffect(() => {
const handleArrowDown = (event: KeyboardEvent) => {
if (event.key === 'ArrowDown') {
if (!expand) setExpand(true)
}
if (event.key === 'ArrowUp') {
if (expand) setExpand(false)
}
}
window.addEventListener('keydown', handleArrowDown)
return () => window.removeEventListener('keydown', handleArrowDown)
}, [expand])
Comment on lines +36 to +47
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Global keyboard listener may cause conflicts.

The window-level keyboard listener for ArrowDown/ArrowUp will be active whenever this component is mounted, regardless of whether the component is focused or visible. This could interfere with other parts of the application that use these keys for navigation.

Consider one of these approaches:

  • Attach the listener to the component's container ref instead of window
  • Check if the component or one of its descendants has focus before handling the event
  • Add an event.target check to ensure the event originated from within this component
🔎 Proposed fix using a container ref
+import { useEffect, useRef, useState } from 'react'
-import { useEffect, useState } from 'react'

 export default function ExpandAddresses({
   bip21uri,
   boardingAddr,
   offchainAddr,
   invoice,
   onClick,
 }: ExpandAddressesProps) {
   const [copied, setCopied] = useState('')
   const [expand, setExpand] = useState(false)
+  const containerRef = useRef<HTMLDivElement>(null)

   const [present] = useIonToast()

   useEffect(() => {
     const handleArrowDown = (event: KeyboardEvent) => {
+      // Only handle if the event target is within this component
+      if (!containerRef.current?.contains(event.target as Node)) return
+      
       if (event.key === 'ArrowDown') {
         if (!expand) setExpand(true)
       }
       if (event.key === 'ArrowUp') {
         if (expand) setExpand(false)
       }
     }
     window.addEventListener('keydown', handleArrowDown)
     return () => window.removeEventListener('keydown', handleArrowDown)
   }, [expand])

   // ... rest of component

   return (
-    <div style={{ margin: '0 auto', maxWidth: '100%', width: '300px' }}>
+    <div ref={containerRef} style={{ margin: '0 auto', maxWidth: '100%', width: '300px' }}>
       {/* ... */}
     </div>
   )
 }
🤖 Prompt for AI Agents
In src/components/ExpandAddresses.tsx around lines 36-47, the current
window-level keydown listener for ArrowDown/ArrowUp runs whenever the component
is mounted and can conflict with other parts of the app; change the
implementation to use a container ref (create a ref for the component wrapper
and attach the keydown listener to ref.current instead of window), or—if you
prefer keeping a global listener—first check that the event originated inside
the component by verifying event.target is contained within the container ref
(or that document.activeElement is a descendant of the container) before
toggling expand, and ensure you add/remove the listener from the ref element on
mount/unmount and include the ref in the effect dependencies for proper cleanup.


const handleCopy = async (value: string) => {
await copyToClipboard(value)
present(copiedToClipboard)
Expand All @@ -43,33 +57,42 @@ export default function ExpandAddresses({
setExpand(!expand)
}

const ExpandLine = ({ title, value }: { title: string; value: string }) => (
<FlexRow between onClick={() => onClick(value)}>
<FlexCol gap='0'>
<TextSecondary>{title}</TextSecondary>
<Text>{prettyLongText(value, 12)}</Text>
</FlexCol>
<Shadow flex onClick={() => handleCopy(value)}>
{copied === value ? <CheckMarkIcon /> : <CopyIcon />}
</Shadow>
</FlexRow>
const onEnter = (value: string) => {
handleCopy(value)
onClick(value)
}

const ExpandLine = ({ testId, title, value }: { testId: string; title: string; value: string }) => (
<Focusable onEnter={() => onEnter(value)}>
<FlexRow between onClick={() => onClick(value)}>
<FlexCol gap='0'>
<TextSecondary>{title}</TextSecondary>
<Text>{prettyLongText(value, 12)}</Text>
</FlexCol>
<Shadow flex onClick={() => handleCopy(value)} testId={testId + '-address-copy'}>
{copied === value ? <CheckMarkIcon /> : <CopyIcon />}
</Shadow>
</FlexRow>
</Focusable>
)

return (
<div style={{ margin: '0 auto', maxWidth: '100%', width: '300px' }}>
<Shadow>
<FlexRow between onClick={handleExpand}>
<Text>Copy address</Text>
{expand ? <ChevronUpIcon /> : <ChevronDownIcon />}
</FlexRow>
</Shadow>
<Focusable onEnter={handleExpand}>
<Shadow>
<FlexRow between onClick={handleExpand}>
<Text>Copy address</Text>
{expand ? <ChevronUpIcon /> : <ChevronDownIcon />}
</FlexRow>
</Shadow>
</Focusable>
{expand ? (
<div style={{ padding: '1rem 0 0 0.5rem', width: '100%' }}>
<FlexCol gap='0.21rem'>
{bip21uri ? <ExpandLine title='BIP21' value={bip21uri} /> : null}
{boardingAddr ? <ExpandLine title='BTC address' value={boardingAddr} /> : null}
{offchainAddr ? <ExpandLine title='Ark address' value={offchainAddr} /> : null}
{invoice ? <ExpandLine title='Lightning invoice' value={invoice} /> : null}
{bip21uri ? <ExpandLine testId='bip21' title='BIP21' value={bip21uri} /> : null}
{boardingAddr ? <ExpandLine testId='btc' title='BTC address' value={boardingAddr} /> : null}
{offchainAddr ? <ExpandLine testId='ark' title='Ark address' value={offchainAddr} /> : null}
{invoice ? <ExpandLine testId='invoice' title='Lightning invoice' value={invoice} /> : null}
</FlexCol>
</div>
) : null}
Expand Down
55 changes: 55 additions & 0 deletions src/components/Focusable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { ReactNode } from 'react'

export default function Focusable({
ariaLabel,
children,
inactive,
onEscape,
onEnter,
round,
role,
fit,
id,
}: {
ariaLabel?: string
children: ReactNode
inactive?: boolean
onEscape?: () => void
onEnter?: () => void
round?: boolean
role?: string
fit?: boolean
id?: string
}) {
const style: React.CSSProperties = {
borderRadius: round ? '0.5rem' : undefined,
width: fit ? 'fit-content' : '100%',
}

const handleKeyDown = (e: React.KeyboardEvent) => {
if (onEnter && ['Enter', ' '].includes(e.key)) {
e.stopPropagation()
e.preventDefault()
onEnter()
}
if (onEscape && e.key === 'Escape') {
e.stopPropagation()
e.preventDefault()
onEscape()
}
}

return (
<div
id={id}
style={style}
className='focusable'
role={role ?? 'button'}
onKeyDown={handleKeyDown}
tabIndex={inactive ? -1 : 0}
aria-label={ariaLabel ?? 'Focusable element'}
>
{children}
</div>
)
}
23 changes: 16 additions & 7 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Shadow from './Shadow'
import Text from './Text'
import FlexRow from './FlexRow'
import React from 'react'
import Focusable from './Focusable'

interface HeaderProps {
auxAriaLabel?: string
Expand All @@ -15,8 +16,8 @@ interface HeaderProps {
}

export default function Header({ auxAriaLabel, auxFunc, auxText, back, text, auxIcon }: HeaderProps) {
const SideButton = (text: string, onClick = () => {}) => (
<Shadow onClick={onClick}>
const SideButton = (text: string) => (
<Shadow>
<Text color='dark80' centered tiny wrap>
{text}
</Text>
Expand All @@ -34,18 +35,26 @@ export default function Header({ auxAriaLabel, auxFunc, auxText, back, text, aux
return (
<IonHeader style={{ boxShadow: 'none' }}>
<FlexRow between>
<div style={{ minWidth: '4rem' }}>
<div style={{ minWidth: '4rem', marginLeft: '0.5rem' }}>
{back ? (
<div onClick={back} style={{ cursor: 'pointer', marginLeft: '0.5rem' }} aria-label='Go back'>
<BackIcon />
</div>
<Focusable onEnter={back} fit round>
<div onClick={back} style={{ cursor: 'pointer' }} aria-label='Go back'>
<BackIcon />
</div>
</Focusable>
) : (
'\u00A0'
)}
</div>
<IonTitle className='ion-text-center'>{text}</IonTitle>
<div style={style} onClick={auxFunc} aria-label={auxAriaLabel}>
{auxText ? SideButton(auxText) : auxIcon ? auxIcon : '\u00A0'}
{auxText || auxIcon ? (
<Focusable onEnter={auxFunc} fit round>
{auxText ? SideButton(auxText) : <div style={{ padding: '0.5rem' }}>{auxIcon}</div>}
</Focusable>
) : (
'\u00A0'
)}
</div>
</FlexRow>
</IonHeader>
Expand Down
31 changes: 15 additions & 16 deletions src/components/InputAmount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import InputContainer from './InputContainer'
import { ConfigContext } from '../providers/config'
import { prettyNumber } from '../lib/format'
import { LimitsContext } from '../providers/limits'
import Focusable from './Focusable'

interface InputAmountProps {
disabled?: boolean
Expand Down Expand Up @@ -46,15 +47,12 @@ export default function InputAmount({
const [error, setError] = useState('')
const [otherValue, setOtherValue] = useState('')

const firstRun = useRef(true)
const input = useRef<HTMLIonInputElement>(null)

// focus input when focus prop changes
useEffect(() => {
if (focus && firstRun.current) {
firstRun.current = false
input.current?.setFocus()
}
})
if (focus && input.current) input.current.setFocus()
}, [focus])

useEffect(() => {
setOtherValue(useFiat ? prettyNumber(sats) : prettyNumber(toFiat(sats), 2))
Expand Down Expand Up @@ -98,16 +96,17 @@ export default function InputAmount({
</IonText>
</IonInput>
{onMax && !disabled && !readOnly ? (
<IonText
slot='end'
style={{ ...fontStyle, marginLeft: '0.5rem', color: 'var(--purpletext)', cursor: 'pointer' }}
onClick={onMax}
role='button'
tabIndex={0}
aria-label='Set maximum amount'
>
Max
</IonText>
<Focusable onEnter={onMax} fit>
<IonText
slot='end'
role='button'
onClick={onMax}
aria-label='Set maximum amount'
style={{ ...fontStyle, marginLeft: '0.5rem', color: 'var(--purpletext)', cursor: 'pointer' }}
>
Max
</IonText>
</Focusable>
) : null}
</InputContainer>
</>
Expand Down
9 changes: 3 additions & 6 deletions src/components/InputPassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,12 @@ interface InputPasswordProps {
export default function InputPassword({ focus, label, onChange, onEnter, strength, placeholder }: InputPasswordProps) {
const right = strength ? <StrengthLabel strength={strength} /> : undefined

const firstRun = useRef(true)
const input = useRef<HTMLIonInputElement>(null)

// focus input when focus prop changes
useEffect(() => {
if (focus && firstRun.current) {
firstRun.current = false
input.current?.setFocus()
}
})
if (focus && input.current) input.current.setFocus()
}, [focus, input.current])
Comment on lines +20 to +23
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Remove input.current from the dependency array.

Including input.current in the dependency array is unnecessary and potentially problematic. React refs don't trigger re-renders when .current changes, so this dependency serves no purpose. The guard if (focus && input.current) inside the effect is sufficient to ensure the element exists before focusing.

🔎 Proposed fix
   // focus input when focus prop changes
   useEffect(() => {
     if (focus && input.current) input.current.setFocus()
-  }, [focus, input.current])
+  }, [focus])
🤖 Prompt for AI Agents
In src/components/InputPassword.tsx around lines 20 to 23, the useEffect
currently lists [focus, input.current] as dependencies; remove input.current
from the dependency array and leave only [focus] because refs do not trigger
re-renders and the existing guard if (focus && input.current) is sufficient to
safely call input.current.setFocus(); update the dependency array to [focus] (or
add an eslint-disable-next-line for exhaustive-deps if your linter complains) so
the effect only runs when focus changes.


return (
<InputContainer label={label} right={right}>
Expand Down
Loading