Skip to content
8,555 changes: 8,555 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 3 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,10 @@ export default function App() {

// Boot animation: persists on Loading, then flies to the LogoIcon position when
// Wallet is reached. For any other destination (Unlock, Init, etc.), exits with fly-up.
// Skip in dev with VITE_DEV_NSEC — the fast auto-init races with the animation.
useEffect(() => {
// Start boot animation when we first see the Loading page
if (import.meta.env.DEV && import.meta.env.VITE_DEV_NSEC) return

if (page === Pages.Loading && !bootAnimActive) {
setBootAnimDone(false)
setBootExitMode('fly-up')
Expand All @@ -276,17 +278,12 @@ export default function App() {

if (!bootAnimActive || bootAnimDone) return

// When we reach Wallet, fly to the logo target
if (page === Pages.Wallet) {
setBootExitMode('fly-to-target')
setBootAnimDone(true)
return
}

// If we land on any non-Loading page (Unlock, Init, etc.), fly up and exit.
// For passwordless wallets page goes Loading → Wallet (never Unlock), so this
// only fires for locked wallets or when passwordless auto-boot fails — in both
// cases the overlay must dismiss to reveal the Unlock/Init page underneath.
if (page !== Pages.Loading) {
setBootExitMode('fly-up')
setBootAnimDone(true)
Expand Down
5 changes: 3 additions & 2 deletions src/components/Content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import Refresher from './Refresher'

interface ContentProps {
children: ReactNode
noFade?: boolean
}

export default function Content({ children }: ContentProps) {
export default function Content({ children, noFade }: ContentProps) {
return (
<IonContent>
<IonContent className={noFade ? 'no-content-fade' : undefined}>
<Refresher />
<div className='content-shell'>{children}</div>
</IonContent>
Expand Down
7 changes: 3 additions & 4 deletions src/components/ExpandAddresses.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ import FlexRow from './FlexRow'
import Shadow from './Shadow'
import { copyToClipboard } from '../lib/clipboard'
import CheckMarkIcon from '../icons/CheckMark'
import { useIonToast } from '@ionic/react'
import { copiedToClipboard } from '../lib/toast'
import { useToast } from './Toast'
import Focusable from './Focusable'
import { hapticSubtle } from '../lib/haptics'

Expand All @@ -34,7 +33,7 @@ export default function ExpandAddresses({
const [copied, setCopied] = useState('')
const [expand, setExpand] = useState(false)

const [present] = useIonToast()
const { toast } = useToast()

useEffect(() => {
const handleArrowDown = (event: KeyboardEvent) => {
Expand All @@ -52,7 +51,7 @@ export default function ExpandAddresses({
const handleCopy = async (value: string) => {
hapticSubtle()
await copyToClipboard(value)
present(copiedToClipboard)
toast('Copied to clipboard')
setCopied(value)
}

Expand Down
215 changes: 202 additions & 13 deletions src/components/QrCode.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,220 @@
import { useMemo, useRef } from 'react'
import encodeQR from 'qr'
import FlexCol from './FlexCol'
import { useReducedMotion } from '../hooks/useReducedMotion'

interface QrCodeProps {
value: string
}

// Finder pattern locations (7x7 squares at three corners)
function isFinderPattern(row: number, col: number, size: number): boolean {
if (row < 7 && col < 7) return true
if (row < 7 && col >= size - 7) return true
if (row >= size - 7 && col < 7) return true
return false
}

// Check if a module is in the logo zone (center area to clear for logo)
function isLogoZone(row: number, col: number, size: number, logoModules: number): boolean {
const center = size / 2
const half = logoModules / 2
return row >= center - half && row < center + half && col >= center - half && col < center + half
}

// Render finder pattern with rounded corners
function renderFinderPattern(
originX: number,
originY: number,
moduleSize: number,
fgColor: string,
bgColor: string,
): JSX.Element[] {
const r = moduleSize * 0.6
const elements: JSX.Element[] = []
const key = `fp-${originX}-${originY}`

elements.push(
<rect
key={`${key}-outer`}
x={originX}
y={originY}
width={moduleSize * 7}
height={moduleSize * 7}
rx={r * 2.5}
ry={r * 2.5}
fill={fgColor}
/>,
)

elements.push(
<rect
key={`${key}-inner`}
x={originX + moduleSize}
y={originY + moduleSize}
width={moduleSize * 5}
height={moduleSize * 5}
rx={r * 1.8}
ry={r * 1.8}
fill={bgColor}
/>,
)

elements.push(
<rect
key={`${key}-center`}
x={originX + moduleSize * 2}
y={originY + moduleSize * 2}
width={moduleSize * 3}
height={moduleSize * 3}
rx={r * 1.2}
ry={r * 1.2}
fill={fgColor}
/>,
)

return elements
}

export default function QrCode({ value }: QrCodeProps) {
// encode value to a gif
const qrGifDataUrl = (text: string) => {
const gifBytes = encodeQR(text, 'gif', { scale: 7 })
const blob = new Blob([new Uint8Array(gifBytes)], { type: 'image/gif' })
return URL.createObjectURL(blob)
}
const prefersReduced = useReducedMotion()
const prevMatrixRef = useRef<boolean[][] | null>(null)
const renderCountRef = useRef(0)

const svgContent = useMemo(() => {
if (!value) return null

const matrix = encodeQR(value, 'raw', { ecc: 'medium', border: 0 })
const size = matrix.length
const moduleSize = 10
const quietZone = moduleSize * 2
const svgSize = size * moduleSize + quietZone * 2

const fgColor = 'var(--black)'
const bgColor = 'var(--ion-background-color, #fff)'
const logoColor = 'var(--logo-color)'

const logoModules = Math.ceil(size * 0.2)
const logoZoneSize = logoModules % 2 === 0 ? logoModules + 1 : logoModules

const prevMatrix = prevMatrixRef.current
const isUpdate = prevMatrix !== null && prevMatrix.length === size
const shouldAnimate = isUpdate && !prefersReduced
renderCountRef.current++

const elements: JSX.Element[] = []

// Background
elements.push(<rect key='bg' x={0} y={0} width={svgSize} height={svgSize} rx={moduleSize * 2} fill={bgColor} />)

// Data modules
const dotRadius = moduleSize * 0.42
const centerRow = size / 2
const centerCol = size / 2

for (let row = 0; row < size; row++) {
for (let col = 0; col < size; col++) {
if (isFinderPattern(row, col, size)) continue
if (isLogoZone(row, col, size, logoZoneSize)) continue
if (!matrix[row][col]) continue

const cx = quietZone + col * moduleSize + moduleSize / 2
const cy = quietZone + row * moduleSize + moduleSize / 2

// Determine if this dot is new/changed (animate it)
const isNew = shouldAnimate && (!prevMatrix[row] || !prevMatrix[row][col])
if (isNew) {
const dist = Math.sqrt((row - centerRow) ** 2 + (col - centerCol) ** 2)
const delay = Math.round(dist * 6)
elements.push(
<circle
key={`d-${row}-${col}-${renderCountRef.current}`}
cx={cx}
cy={cy}
r={dotRadius}
fill={fgColor}
style={{
animation: `qr-dot-in 250ms cubic-bezier(0.23, 1, 0.32, 1) ${delay}ms both`,
transformOrigin: `${cx}px ${cy}px`,
}}
/>,
)
} else {
elements.push(<circle key={`d-${row}-${col}`} cx={cx} cy={cy} r={dotRadius} fill={fgColor} />)
}
}
}

// Save matrix for next comparison
prevMatrixRef.current = matrix.map((row) => [...row])

// Finder patterns
const finderPositions = [
[0, 0],
[0, size - 7],
[size - 7, 0],
]
for (const [row, col] of finderPositions) {
const x = quietZone + col * moduleSize
const y = quietZone + row * moduleSize
elements.push(...renderFinderPattern(x, y, moduleSize, fgColor, bgColor))
}

// Logo overlay
const centerX = quietZone + (size * moduleSize) / 2
const centerY = quietZone + (size * moduleSize) / 2
const logoCircleR = logoZoneSize * moduleSize * 0.52

elements.push(<circle key='logo-bg' cx={centerX} cy={centerY} r={logoCircleR} fill={bgColor} />)

const logoInnerSize = logoCircleR * 1.2
const logoOffsetX = centerX - logoInnerSize / 2
const logoOffsetY = centerY - logoInnerSize / 2
const scale = logoInnerSize / 35

elements.push(
<g key='logo' transform={`translate(${logoOffsetX}, ${logoOffsetY}) scale(${scale})`}>
<path d='M0 8.75L8.75 0H26.25L35 8.75V17.5H26.25V8.75H8.75V17.5H2.45431e-07L0 8.75Z' fill={logoColor} />
<path d='M8.75 26.25V17.5H26.25V26.25H8.75Z' fill={logoColor} />
<path d='M8.75 26.25H2.45431e-07V35H8.75V26.25Z' fill={logoColor} />
<path d='M26.25 26.25V35H35V26.25H26.25Z' fill={logoColor} />
</g>,
)

return (
<svg
viewBox={`0 0 ${svgSize} ${svgSize}`}
width='100%'
height='100%'
xmlns='http://www.w3.org/2000/svg'
style={{ display: 'block' }}
>
<style>{`
@keyframes qr-dot-in {
from { transform: scale(0); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
circle { animation: none !important; }
}
`}</style>
{elements}
</svg>
)
}, [value, prefersReduced])

return (
<FlexCol centered>
{value ? (
{svgContent ? (
<div
style={{
backgroundColor: 'white',
borderRadius: '0.5rem',
padding: '0.5rem',
maxWidth: '100%',
width: '300px',
borderRadius: '1rem',
maxWidth: '340px',
overflow: 'hidden',
width: '100%',
}}
>
<img src={qrGifDataUrl(value)} alt='QR Code' style={{ width: '100%' }} />
{svgContent}
</div>
) : null}
</FlexCol>
Expand Down
73 changes: 42 additions & 31 deletions src/components/SheetModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { IonModal } from '@ionic/react'
import CloseIcon from '../icons/Close'
import { useContext } from 'react'
import { NavigationContext, Tabs } from '../providers/navigation'
import { hapticLight } from '../lib/haptics'

interface SheetModalProps {
children?: React.ReactNode
Expand All @@ -8,44 +10,53 @@ interface SheetModalProps {
}

export default function SheetModal({ children, isOpen, onClose }: SheetModalProps) {
const outerStyle: React.CSSProperties = {
maxWidth: '640px',
margin: '0 auto',
width: '100%',
}

const innerStyle: React.CSSProperties = {
backgroundColor: 'var(--ion-background-color)',
borderTop: '1px solid var(--dark50)',
borderRadius: '1rem',
height: '100%',
padding: '1rem',
paddingBottom: '2rem',
width: '100%',
position: 'relative',
}
const { tab } = useContext(NavigationContext)
const hasNavbar = [Tabs.Wallet, Tabs.Apps].includes(tab)

const closeButtonStyle: React.CSSProperties = {
background: 'none',
border: 'none',
color: 'var(--ion-text-color)',
cursor: 'pointer',
padding: 0,
position: 'absolute',
right: '1rem',
top: '1rem',
const handleClose = () => {
hapticLight()
onClose()
}

return (
<IonModal initialBreakpoint={1} isOpen={isOpen} onDidDismiss={onClose}>
<IonModal initialBreakpoint={1} backdropBreakpoint={0} isOpen={isOpen} onDidDismiss={handleClose}>
<div style={outerStyle}>
<div style={innerStyle}>
<button type='button' style={closeButtonStyle} onClick={onClose} aria-label='Close'>
<CloseIcon />
</button>
<div
style={{
...innerStyle,
paddingBottom: hasNavbar ? 'var(--pill-navbar-spacer)' : '2rem',
}}
>
<div style={handleAreaStyle} onClick={handleClose}>
<div style={handleStyle} />
</div>
{children}
</div>
</div>
</IonModal>
)
}

const outerStyle: React.CSSProperties = {
maxWidth: '640px',
margin: '0 auto',
width: '100%',
}

const innerStyle: React.CSSProperties = {
padding: '0 1.25rem',
width: '100%',
}

const handleAreaStyle: React.CSSProperties = {
padding: '12px 0 20px',
cursor: 'grab',
}

const handleStyle: React.CSSProperties = {
backgroundColor: 'var(--dark20)',
borderRadius: '100px',
height: '5px',
margin: '0 auto',
width: '40px',
}
Loading