Skip to content
Open
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
19 changes: 19 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { LockWalletConfirmDialog } from './components/ui/jam/LockWalletConfirmDi
import { Spinner } from './components/ui/spinner'
import { WalletJarsDetailsPage } from './components/wallet/WalletJarsDetailsPage'
import { JamSessionInfoContextProvider } from './context/JamSessionInfoContextProvider'
import { useIdleAutoLock } from './hooks/useIdleAutoLock'
import { useJmWebsocket } from './hooks/useJmWebsocket'
import { jmSessionStore } from './store/jmSessionStore'
import { jmTxStore, type JmTxInfo } from './store/jmTxStore'
Expand Down Expand Up @@ -231,6 +232,7 @@ function App() {
<RefreshApiToken />
<RefreshJmSession />
<HandleJmWebsocketMessages />
<IdleAutoLock />
{walletFileName && <LoadFeeConfigData walletFileName={walletFileName} />}
{lockWalletDialogContext && (
<LockWalletConfirmDialog
Expand Down Expand Up @@ -410,4 +412,21 @@ function LoadFeeConfigData({ walletFileName }: { walletFileName: WalletFileName
return <></>
}

function IdleAutoLock() {
const autoLockTimeout = useStore(jamSettingsStore, (state) => state.state.autoLockTimeout ?? 0)
const hasAuthToken = useStore(authStore, (state) => state.state?.auth?.token !== undefined)
const makerRunning = useStore(jmSessionStore, (state) => state.state?.maker_running === true)
const coinjoinInProgress = useStore(jmSessionStore, (state) => {
return state.state?.coinjoin_in_process === true || (state.state?.schedule?.length || 0) > 0
})

const shouldTrackIdle = hasAuthToken && autoLockTimeout > 0 && !makerRunning && !coinjoinInProgress

useIdleAutoLock(() => {
clearAuthAndQueryCache()
}, shouldTrackIdle ? autoLockTimeout : 0)

return <></>
}

export default App
47 changes: 47 additions & 0 deletions src/components/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ArrowLeftRightIcon,
LockKeyholeIcon,
BookKeyIcon,
TimerIcon,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import { useTranslation } from 'react-i18next'
Expand All @@ -22,6 +23,7 @@ import { useStore } from 'zustand'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { BtcSymbol, SatSymbol } from '@/components/ui/jam/CurrencySymbol'
import PageTitle from '@/components/ui/jam/PageTitle'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { isDebugFeatureEnabled, isDevMode } from '@/constants/debugFeatures'
import { JAM_SEED_MODAL_TIMEOUT } from '@/constants/jam'
Expand Down Expand Up @@ -139,6 +141,8 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
disabled={hashedPassword === undefined}
/>
<Separator className="opacity-50" />
<AutoLockTimeoutSelector />
<Separator className="opacity-50" />
<SettingItem
icon={LockKeyholeIcon}
title={t('settings.button_lock_wallet')}
Expand Down Expand Up @@ -257,6 +261,49 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
)
}

const AUTO_LOCK_OPTIONS = [
{ value: '0', label: 'settings.auto_lock_disabled' },
{ value: '5', label: 'settings.auto_lock_minutes' },
{ value: '15', label: 'settings.auto_lock_minutes' },
{ value: '30', label: 'settings.auto_lock_minutes' },
{ value: '60', label: 'settings.auto_lock_minutes' },
]

const AutoLockTimeoutSelector = () => {
const { t } = useTranslation()
const jamSettings = useStore(jamSettingsStore)
const currentValue = String(jamSettings.state.autoLockTimeout ?? 0)

return (
<div className="flex items-center justify-between py-2">
<div className="flex items-center gap-2">
<div className="bg-muted/50 flex h-7 w-7 items-center justify-center rounded-lg border">
<TimerIcon className="text-muted-foreground h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium">{t('settings.auto_lock_timeout')}</p>
</div>
</div>

<Select
value={currentValue}
onValueChange={(value) => jamSettings.update({ autoLockTimeout: Number(value) })}
>
<SelectTrigger className="h-7 w-32 text-xs" aria-label={t('settings.auto_lock_timeout')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{AUTO_LOCK_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label, { count: Number(option.value) })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

const GitHubIcon = ({ className }: { className: string }) => (
<svg className={className} fill="currentColor" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>GitHub</title>
Expand Down
109 changes: 109 additions & 0 deletions src/hooks/useIdleAutoLock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createIdleTracker } from './useIdleAutoLock'

function createMockWindow() {
const listeners = new Map<string, Set<EventListener>>()
const addSpy = vi.fn((event: string, handler: EventListener) => {
if (!listeners.has(event)) listeners.set(event, new Set())
listeners.get(event)!.add(handler)
})
const removeSpy = vi.fn((event: string, handler: EventListener) => {
listeners.get(event)?.delete(handler)
})
const win = {
addEventListener: addSpy,
removeEventListener: removeSpy,
dispatch(event: string) {
listeners.get(event)?.forEach((handler) => handler(new Event(event)))
},
} as unknown as Window & { dispatch: (event: string) => void }
return { win, addSpy, removeSpy }
}

describe('createIdleTracker', () => {
beforeEach(() => {
vi.useFakeTimers()
})

afterEach(() => {
vi.useRealTimers()
})

it('fires callback after timeout period of inactivity', () => {
const onLock = vi.fn()
const { win } = createMockWindow()
const tracker = createIdleTracker(onLock, 5 * 60 * 1_000, win)
tracker.start()

vi.advanceTimersByTime(4 * 60 * 1_000)
expect(onLock).not.toHaveBeenCalled()

vi.advanceTimersByTime(1 * 60 * 1_000)
expect(onLock).toHaveBeenCalledTimes(1)
})

it('resets timer on user activity', () => {
const onLock = vi.fn()
const { win } = createMockWindow()
const tracker = createIdleTracker(onLock, 5 * 60 * 1_000, win)
tracker.start()

// Advance 4 minutes
vi.advanceTimersByTime(4 * 60 * 1_000)
expect(onLock).not.toHaveBeenCalled()

// Simulate mouse activity
win.dispatch('mousemove')

// 4 more minutes from activity (would be 8 total, but timer restarted)
vi.advanceTimersByTime(4 * 60 * 1_000)
expect(onLock).not.toHaveBeenCalled()

// 1 more minute → 5 since last activity
vi.advanceTimersByTime(1 * 60 * 1_000)
expect(onLock).toHaveBeenCalledTimes(1)
})

it('cleans up timers and listeners on stop', () => {
const onLock = vi.fn()
const { win, removeSpy } = createMockWindow()
const tracker = createIdleTracker(onLock, 5 * 60 * 1_000, win)
tracker.start()

tracker.stop()
expect(removeSpy).toHaveBeenCalled()

vi.advanceTimersByTime(10 * 60 * 1_000)
expect(onLock).not.toHaveBeenCalled()
})

it('resets on keyboard activity', () => {
const onLock = vi.fn()
const { win } = createMockWindow()
const tracker = createIdleTracker(onLock, 1 * 60 * 1_000, win)
tracker.start()

vi.advanceTimersByTime(50_000)
win.dispatch('keydown')

vi.advanceTimersByTime(50_000)
expect(onLock).not.toHaveBeenCalled()

vi.advanceTimersByTime(10_000)
expect(onLock).toHaveBeenCalledTimes(1)
})

it('registers listeners for all activity events', () => {
const onLock = vi.fn()
const { win, addSpy } = createMockWindow()
const tracker = createIdleTracker(onLock, 60_000, win)
tracker.start()

const calls = addSpy.mock.calls.map((c) => c[0])
expect(calls).toContain('mousemove')
expect(calls).toContain('mousedown')
expect(calls).toContain('keydown')
expect(calls).toContain('touchstart')
expect(calls).toContain('scroll')
})
})
45 changes: 45 additions & 0 deletions src/hooks/useIdleAutoLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useEffect, useRef } from 'react'

const ACTIVITY_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'] as const

export function createIdleTracker(onLock: () => void, timeoutMs: number, win: Window) {
let timer: ReturnType<typeof setTimeout> | undefined

const resetTimer = () => {
if (timer) clearTimeout(timer)
timer = setTimeout(onLock, timeoutMs)
}

return {
start() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: If start returns the stop function, AbortController can be used to simplify the listener removal.

resetTimer()
for (const event of ACTIVITY_EVENTS) {
win.addEventListener(event, resetTimer, { passive: true })
Copy link
Copy Markdown
Collaborator

@theborakompanioni theborakompanioni Feb 25, 2026

Choose a reason for hiding this comment

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

Can/should resetTimer be debounced?

}
},
stop() {
if (timer) clearTimeout(timer)
timer = undefined
for (const event of ACTIVITY_EVENTS) {
win.removeEventListener(event, resetTimer)
}
},
}
}

export function useIdleAutoLock(onLock: () => void, timeoutMinutes: number) {
const onLockRef = useRef(onLock)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just curious.. why does not the timer not reset when onLock changes?


useEffect(() => {
onLockRef.current = onLock
})

useEffect(() => {
if (timeoutMinutes <= 0) return

const tracker = createIdleTracker(() => onLockRef.current(), timeoutMinutes * 60 * 1_000, window)
tracker.start()

return () => tracker.stop()
}, [timeoutMinutes])
}
3 changes: 3 additions & 0 deletions src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,9 @@
"show_logs": "Show logs",
"show_fee_config": "Adjust fee limits",
"show_lock_confirmation": "Ask confirmation before locking",
"auto_lock_timeout": "Auto-lock after inactivity",
"auto_lock_disabled": "Disabled",
"auto_lock_minutes": "{{count}} min",
"button_lock_wallet": "Lock wallet",
"button_locking_wallet": "Locking...",
"button_switch_wallet": "Switch wallet",
Expand Down
1 change: 1 addition & 0 deletions src/store/jamSettingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type JamSettings = {
privateMode: boolean
currencyUnit: Currency
cheatsheetForceOpenAt?: number
autoLockTimeout?: number
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can this be seconds instead of minutes and called autoLockTimeoutInSeconds?: Seconds?
It is stored in local storage, so a user looking at it has no type information and does not know what unit it is.

}

interface JamSettingsStoreState {
Expand Down
Loading