chore(lint): enable eslint recommendedTypeChecked WIP

This commit is contained in:
theborakompanioni 2026-02-06 21:45:51 +01:00
parent e4fc0daae1
commit ea0b0b3fa6
No known key found for this signature in database
GPG key ID: E8070AF0053AAC0D
44 changed files with 184 additions and 180 deletions

View file

@ -76,11 +76,11 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
)}
</div>
<div className="mt-2 flex w-full justify-center gap-4">
<Button size="xxl" className="flex-1" variant="default" onClick={() => navigate(routes.receive)}>
<Button size="xxl" className="flex-1" variant="default" onClick={() => void navigate(routes.receive)}>
<DownloadIcon />
{t('current_wallet.button_deposit')}
</Button>
<Button size="xxl" className="flex-1" variant="outline" onClick={() => navigate(routes.send)}>
<Button size="xxl" className="flex-1" variant="outline" onClick={() => void navigate(routes.send)}>
<UploadIcon />
{t('current_wallet.button_withdraw')}
</Button>

View file

@ -105,7 +105,7 @@ const SwitchWalletPage = ({ walletFileName }: SwitchWalletPageProps) => {
{listWalletsFetching ? (
<Spinner className="size-6" />
) : (
<WalletIcon className="text-primary h-6 w-6" onClick={async () => await listWalletsRefetch()} />
<WalletIcon className="text-primary h-6 w-6" onClick={() => void listWalletsRefetch()} />
)}
</div>
<CardTitle className="text-2xl font-bold">{t('settings.button_switch_wallet')}</CardTitle>
@ -132,7 +132,7 @@ const SwitchWalletPage = ({ walletFileName }: SwitchWalletPageProps) => {
<AlertTitle>{listWalletsErrorAlert.message}</AlertTitle>
<AlertDescription>{listWalletsErrorAlert.error_description}</AlertDescription>
</Alert>
<Button variant="ghost" size="sm" onClick={async () => await listWalletsRefetch()}>
<Button variant="ghost" size="sm" onClick={() => void listWalletsRefetch()}>
<RefreshCwIcon className="h-4 w-4" /> {t('global.retry')}
</Button>
</CardContent>
@ -177,7 +177,7 @@ const SwitchWalletPage = ({ walletFileName }: SwitchWalletPageProps) => {
<Button
variant="outline"
size="sm"
onClick={() => navigate(routes.home)}
onClick={() => void navigate(routes.home)}
className="flex-1"
>
<WalletIcon className="mr-2 h-4 w-4" />

View file

@ -95,7 +95,7 @@ export const CreateStepConfirm = ({ walletFileName, password, seedphrase, onConf
</div>
<Button
onClick={async () => await onConfirm()}
onClick={() => void onConfirm()}
className="w-full"
size="xxl"
disabled={!backupConfirmed || !revealSensitiveInfo.dirty}

View file

@ -83,8 +83,10 @@ export const CreateWalletForm = ({
resolver: yupResolver(schema),
})
const doOnSubmit = handleSubmit(onSubmit)
return (
<form onSubmit={handleSubmit(onSubmit)} className={cn('flex flex-col gap-4', className)} noValidate>
<form onSubmit={(event) => void doOnSubmit(event)} className={cn('flex flex-col gap-4', className)} noValidate>
<div className="space-y-2">
<Field data-invalid={errors.walletName !== undefined}>
<FieldLabel htmlFor="create-wallet-name">{t('create_wallet.label_wallet_name')}</FieldLabel>

View file

@ -158,8 +158,10 @@ export function EarnForm({
const values = useWatch({ control })
const watchOfferType = useWatch({ control, name: 'offerType' })
const doOnSubmit = handleSubmit(onSubmit)
return (
<form onSubmit={handleSubmit(onSubmit)} className={cn('flex flex-col gap-4', className)} noValidate>
<form onSubmit={(event) => void doOnSubmit(event)} className={cn('flex flex-col gap-4', className)} noValidate>
<OfferTypeInput
disabled={disabled}
defaultValue={FORM_INPUT_DEFAULT_VALUES.offerType}

View file

@ -49,16 +49,23 @@ function ErrorView({ title, subtitle, reason, stacktrace }: ErrorViewProps) {
)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function UnknownError({ error }: { error: any }) {
function UnknownError({ error }: { error: unknown }) {
const { t } = useTranslation()
const title = t('error_page.unknown_error.title')
const subtitle = t('error_page.unknown_error.subtitle')
if (!error || typeof error !== 'object') {
return <ErrorView title={title} subtitle={subtitle} reason={t('global.errors.reason_unknown')} />
}
const reason = 'message' in error && error.message ? String(error.message) : undefined
const stacktrace = 'stack' in error && error.stack ? String(error.stack) : undefined
return (
<ErrorView
title={t('error_page.unknown_error.title')}
subtitle={t('error_page.unknown_error.subtitle')}
reason={error.message || t('global.errors.reason_unknown')}
stacktrace={error.stack}
title={title}
subtitle={subtitle}
reason={reason || t('global.errors.reason_unknown')}
stacktrace={stacktrace}
/>
)
}

View file

@ -201,7 +201,7 @@ export function AppNavbar({
className="light:text-green-600 text-green-300"
variant="ghost-navbar"
size="icon"
onClick={() => navigate(rescanningRoute)}
onClick={() => void navigate(rescanningRoute)}
aria-label={t('navbar.text_rescan_in_progress')}
title={t('navbar.text_rescan_in_progress')}
>
@ -213,7 +213,7 @@ export function AppNavbar({
className="light:text-green-600 text-green-300"
variant="ghost-navbar"
size="icon"
onClick={() => navigate(joiningRoute)}
onClick={() => void navigate(joiningRoute)}
aria-label={t('navbar.joining_in_progress')}
title={t('navbar.joining_in_progress')}
>
@ -224,7 +224,7 @@ export function AppNavbar({
<Button
variant="ghost-navbar"
size="icon"
onClick={() => navigate(routes.settings)}
onClick={() => void navigate(routes.settings)}
aria-label={t('navbar.menu_mobile_settings')}
title={t('navbar.menu_mobile_settings')}
>
@ -234,7 +234,7 @@ export function AppNavbar({
className="hidden sm:flex"
variant="ghost-navbar"
size="icon"
onClick={doOnLockWallet}
onClick={() => void doOnLockWallet()}
aria-label={t('settings.button_lock_wallet')}
title={t('settings.button_lock_wallet')}
disabled={isLockingWallet}
@ -245,7 +245,7 @@ export function AppNavbar({
className="hidden sm:flex"
variant="ghost-navbar"
size="icon"
onClick={async () => await onLogout(navigate)}
onClick={() => void onLogout(navigate)}
aria-label={/* TODO: i18n */ 'Logout'}
title={/* TODO: i18n */ 'Logout'}
>

View file

@ -47,7 +47,7 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
const isDeveloperMode = useStore(jamSettingsStore, (state) => state.state.developerMode)
const { isLogsEnabled } = useFeatures()
const { isFeatureEnabled } = useFeatures()
const mainItems = useMemo(
() => [
{
@ -96,7 +96,7 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
url: routes.rescan,
icon: PackageSearchIcon,
},
...(!isLogsEnabled
...(!isFeatureEnabled('logs')
? []
: [
{
@ -106,7 +106,7 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
},
]),
],
[t, isLogsEnabled],
[t, isFeatureEnabled],
)
const devItems = useMemo(

View file

@ -52,7 +52,7 @@ export function LayoutInner({ onLogout, onLockWallet, children }: LayoutInnerPro
const cheatsheet = useCheatsheet()
const [isOrderbookOverlayOpen, setIsOrderbookOverlayOpen] = useState(false)
const [isLogsOverlayOpen, setIsLogsOverlayOpen] = useState(false)
const { isLogsEnabled } = useFeatures()
const { isFeatureEnabled } = useFeatures()
// Adds a keyboard shortcut to toggle the logs overlay.
useEffect(() => {
@ -91,7 +91,7 @@ export function LayoutInner({ onLogout, onLockWallet, children }: LayoutInnerPro
joinmarketVersion={joinmarketVersion}
onClickCheatsheet={() => cheatsheet.onOpenChange(true)}
onClickOrderbook={() => setIsOrderbookOverlayOpen(true)}
onClickLogs={isLogsEnabled ? () => setIsLogsOverlayOpen(true) : undefined}
onClickLogs={isFeatureEnabled('logs') ? () => setIsLogsOverlayOpen(true) : undefined}
/>
<Cheatsheet open={cheatsheet.open} onOpenChange={cheatsheet.onOpenChange} />

View file

@ -92,8 +92,10 @@ export const LoginFormComponent = ({
const values = useWatch({ control })
const doOnSubmit = handleSubmit(onSubmit)
return (
<form onSubmit={handleSubmit(onSubmit)} className={cn('flex flex-col gap-4', className)} noValidate>
<form onSubmit={(event) => void doOnSubmit(event)} className={cn('flex flex-col gap-4', className)} noValidate>
<div className="space-y-2">
<Field data-invalid={errors.walletFileName !== undefined}>
<FieldLabel>{/* TODO: i18n */}Wallet</FieldLabel>

View file

@ -113,7 +113,7 @@ const LoginPage = () => {
{listWalletsFetching ? (
<Spinner className="size-6" />
) : (
<WalletIcon className="text-primary" onClick={async () => await listWalletsRefetch()} />
<WalletIcon className="text-primary" onClick={() => void listWalletsRefetch()} />
)}
</div>
<CardTitle className="text-2xl font-bold">{/*TODO: i18n */}Welcome to Jam</CardTitle>
@ -137,7 +137,7 @@ const LoginPage = () => {
<Button
variant="ghost"
size="sm"
onClick={async () => await listWalletsRefetch()}
onClick={() => void listWalletsRefetch()}
disabled={listWalletsFetching}
>
<RefreshCwIcon className={cn({ 'motion-safe:animate-spin': listWalletsFetching })} />
@ -172,10 +172,10 @@ const LoginPage = () => {
)}
<div className="flex flex-col gap-2">
<Button variant="link" size="sm" onClick={async () => await navigate(routes.createWallet)}>
<Button variant="link" size="sm" onClick={() => void navigate(routes.createWallet)}>
{t('wallets.button_new_wallet')}
</Button>
<Button variant="link" size="sm" onClick={async () => await navigate('/import-wallet')} disabled>
<Button variant="link" size="sm" onClick={() => void navigate('/import-wallet')} disabled>
{/* TODO: implement "import wallet" */}
{t('wallets.button_import_wallet')}
<Badge variant="destructive">Not yet implemented.</Badge>

View file

@ -188,7 +188,7 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
<Button
variant="outline"
size="sm"
onClick={async () => await shareAddress(getAddressQuery.data!.address)}
onClick={() => void shareAddress(getAddressQuery.data!.address)}
disabled={getAddressQuery.isFetching || !getAddressQuery.data?.address}
>
<ShareIcon />

View file

@ -1,7 +1,7 @@
import { useMemo, useState, type ComponentProps } from 'react'
import { yupResolver } from '@hookform/resolvers/yup'
import { getaddress, type ErrorMessage } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { getAddressInfo, validate as isValidBitcoinAddress } from 'bitcoin-address-validation'
import { getAddressInfo, validate as isValidBitcoinAddress, Network } from 'bitcoin-address-validation'
import type { AddressInfo } from 'bitcoin-address-validation'
import { BrushCleaningIcon, MilkIcon, XIcon } from 'lucide-react'
import { useForm, useWatch } from 'react-hook-form'
@ -36,8 +36,8 @@ import type { SendFormValues } from './types'
type AddressFromJarSelectorDialog = Omit<ComponentProps<typeof JarSelectorDialog>, 'onConfirm'> & {
walletFileName: WalletFileName
onError: (error: ErrorMessage) => Promise<void>
onConfirm: (jar: JarIndex, address: AddressInfo) => Promise<void>
onError: (error: ErrorMessage) => void
onConfirm: (jar: JarIndex, address: AddressInfo) => void
}
const AddressFromJarSelectorDialog = ({
@ -62,12 +62,12 @@ const AddressFromJarSelectorDialog = ({
}),
)
if (result.error) {
await onError(result.error)
onError(result.error)
} else if (result.data?.address === undefined) {
await onError(new Error('Missing bitcoin address.'))
onError(new Error('Missing bitcoin address.'))
} else {
const addressInfo = getAddressInfo(result.data.address)
await onConfirm(selectedJarIndex, addressInfo)
onConfirm(selectedJarIndex, addressInfo)
}
}}
/>
@ -255,6 +255,8 @@ export function SendForm({
return jars.find((it) => it.jarIndex === destinationJarIndex)
}, [jars, destinationJarIndex])
const doOnSubmit = handleSubmit(onSubmit)
return (
<>
<AddressFromJarSelectorDialog
@ -265,20 +267,20 @@ export function SendForm({
jars={jars}
disabledJars={sourceJar === undefined ? [] : [sourceJar]}
walletBalanceSummary={walletBalanceSummary}
onError={async (_ignoredOnPurpose) => {
onError={(_ignoredOnPurpose) => {
// TODO: i18n own key `send.error_loading_address_failed`
toast.error(t('receive.error_loading_address_failed'))
setValue('destination.address', undefined, { shouldValidate: true })
setValue('destination.fromJar', undefined, { shouldValidate: true })
}}
onConfirm={async (jarIndex, addressInfo) => {
onConfirm={(jarIndex, addressInfo) => {
setValue('destination.address', addressInfo.address, { shouldValidate: true })
setValue('destination.fromJar', jarIndex, { shouldValidate: true })
setShowAddressFromJarSelectorDialog(false)
}}
/>
<form onSubmit={handleSubmit(onSubmit)} className={cn('flex flex-col gap-4', className)} noValidate>
<form onSubmit={(event) => void doOnSubmit(event)} className={cn('flex flex-col gap-4', className)} noValidate>
<div className="space-y-2">
<Field className="space-y-4" data-invalid={errors.source !== undefined}>
<FieldLabel>{t('send.label_source_jar')}</FieldLabel>
@ -317,7 +319,7 @@ export function SendForm({
<Field data-invalid={errors.destination !== undefined}>
<FieldLabel htmlFor="send-destination">
{t('send.label_recipient')}
{destinationAddressInfo?.network && destinationAddressInfo.network !== 'mainnet' && (
{destinationAddressInfo?.network && destinationAddressInfo.network !== Network.mainnet && (
<Badge variant="outline">{destinationAddressInfo.network}</Badge>
)}
</FieldLabel>

View file

@ -135,7 +135,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
}
}
const onSubmit: SubmitHandler<SendFormValues> = async (data) => {
const onSubmit: SubmitHandler<SendFormValues> = (data) => {
setSendFromValuesAwaitingConfirmation(data)
setShowPaymentConfirmDialog(true)
}

View file

@ -305,7 +305,7 @@ export const FeeLimitDialog = ({ open, onOpenChange, walletFileName }: FeeLimitD
<DevBadge />
</Button>
)}
<Button onClick={handleSubmit} disabled={isSubmitting || isLoadingConfig}>
<Button onClick={() => void handleSubmit()} disabled={isSubmitting || isLoadingConfig}>
{isSubmitting ? t('settings.fees.text_button_submitting') : t('settings.fees.text_button_submit')}
</Button>
</DialogFooter>

View file

@ -6,8 +6,8 @@ import languages from '@/i18n/languages'
export const LanguageSelector = () => {
const { i18n, t } = useTranslation()
const handleLanguageChange = (languageKey: string) => {
i18n.changeLanguage(languageKey)
const handleLanguageChange = async (languageKey: string) => {
await i18n.changeLanguage(languageKey)
}
const getCurrentLanguageDescription = () => {
@ -26,7 +26,7 @@ export const LanguageSelector = () => {
</div>
</div>
<Select value={i18n.resolvedLanguage} onValueChange={handleLanguageChange}>
<Select value={i18n.resolvedLanguage} onValueChange={(value) => void handleLanguageChange(value)}>
<SelectTrigger className="h-7 w-38 text-xs" aria-label="Select language">
<SelectValue placeholder={getCurrentLanguageDescription()} />
</SelectTrigger>

View file

@ -51,9 +51,10 @@ function RescanChainForm({ rescanInfo, onSubmit, disabled }: RescanChainFormProp
resolver: yupResolver(schema),
})
const doOnSubmit = handleSubmit(onSubmit)
return (
/* "handleSubmit" will validate your inputs before invoking "onSubmit" */
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<form onSubmit={(event) => void doOnSubmit(event)} className="space-y-4">
{/* include validation with required or other standard HTML validation rules */}
<div className="space-y-2">
<Label htmlFor="rescanHeight" className="text-sm font-medium">
@ -157,7 +158,7 @@ export const RescanChainPage = ({ walletFileName }: RescanChainProps) => {
return (
<div className="mx-auto max-w-4xl space-y-3 p-4">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => navigate(routes.settings)} title={t('global.back')}>
<Button variant="ghost" onClick={() => void navigate(routes.settings)} title={t('global.back')}>
<ArrowLeftIcon />
<span className="sr-only">{t('global.back')}</span>
</Button>

View file

@ -73,7 +73,7 @@ export const SeedPhraseDialog = ({
useEffect(() => {
if (open && isPasswordVerified && seedQuery.data === undefined) {
seedQuery.refetch()
void seedQuery.refetch()
}
}, [open, isPasswordVerified, seedQuery])

View file

@ -10,7 +10,7 @@ type SettingsItemProps = PropsWithChildren<{
renderIcon?: ({ className }: { className: string }) => ReactNode
title: string
disabled?: boolean
action?: () => Promise<void>
action?: () => void | Promise<void>
}>
export const SettingItem = ({
@ -27,7 +27,7 @@ export const SettingItem = ({
'hover:bg-muted/50 -mx-2 cursor-pointer rounded-md px-2': !disabled,
'cursor-not-allowed opacity-60': disabled,
})}
onClick={!disabled ? action : undefined}
onClick={!disabled && action ? () => void action() : undefined}
>
<div className="flex items-center gap-2">
<div className="bg-muted/50 flex h-7 w-7 items-center justify-center rounded-lg border">
@ -76,7 +76,7 @@ type SettingsSwitchProps = Omit<SettingsItemProps, 'children' | 'action'> & {
}
export const SettingSwitch = ({ checked, onCheckedChange, displayToggle = true, ...props }: SettingsSwitchProps) => {
return (
<SettingItem {...props} action={async () => onCheckedChange && onCheckedChange(!checked)}>
<SettingItem {...props} action={() => onCheckedChange && onCheckedChange(!checked)}>
{displayToggle && <Switch checked={checked} onCheckedChange={onCheckedChange} disabled={props.disabled} />}
</SettingItem>
)

View file

@ -54,7 +54,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
const [showXpubsDialog, setShowXpubsDialog] = useState(false)
const [showFeeLimitDialog, setShowFeeLimitDialog] = useState(false)
const hashedPassword = useStore(authStore, (state) => state.state?.hashed_password)
const { isLogsEnabled } = useFeatures()
const { isFeatureEnabled } = useFeatures()
const [isLockingWallet, setIsLockingWallet] = useState(false)
const doOnLockWallet = async () => {
@ -115,9 +115,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
<SettingItem
icon={DollarSignIcon}
title={t('settings.show_fee_config')}
action={async () => {
setShowFeeLimitDialog(true)
}}
action={() => setShowFeeLimitDialog(true)}
/>
</CardContent>
</Card>
@ -131,14 +129,14 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
<SettingItem
icon={KeyRoundIcon}
title={t('settings.show_seed')}
action={async () => setShowSeedDialog(true)}
action={() => setShowSeedDialog(true)}
disabled={hashedPassword === undefined}
/>
<Separator className="opacity-50" />
<SettingItem
icon={BookKeyIcon}
title={t('settings.show_xpubs')}
action={async () => setShowXpubsDialog(true)}
action={() => setShowXpubsDialog(true)}
disabled={hashedPassword === undefined}
/>
<Separator className="opacity-50" />
@ -157,7 +155,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
icon={FileTextIcon}
title={t('settings.show_logs')}
to={routes.logs}
disabled={!isLogsEnabled}
disabled={!isFeatureEnabled('logs')}
/>
</CardContent>
</Card>

View file

@ -57,7 +57,7 @@ export const TxFeeInputField = ({
<button
key={tab.value}
type="button"
onClick={() => handleUnitChange(tab.value as TxFeeUnit)}
onClick={() => handleUnitChange(tab.value)}
className={cx('rounded-md px-4 py-2 text-sm font-medium transition-colors', {
'bg-primary text-primary-foreground': unit === tab.value,
'bg-muted text-muted-foreground hover:bg-muted/80': unit !== tab.value,

View file

@ -1,19 +1,14 @@
import { useMemo } from 'react'
import { CurrencySymbol } from '@/components/ui/jam/CurrencySymbol'
import { useJamDisplayContext } from '@/context/JamDisplayContext'
import { cn, satsToBtc, btcToSats, isValidNumber, formatBtc, formatSats, SATS, BTC } from '@/lib/utils'
import { cn, satsToBtc, btcToSats, isValidNumber, formatBtc, formatSats, SATS, BTC, type Unit } from '@/lib/utils'
import type { Currency } from '@/types/global'
const DISPLAY_MODE_BTC = 'btc'
const DISPLAY_MODE_SATS = 'sats'
const DISPLAY_MODE_BTC: Currency = 'btc'
const DISPLAY_MODE_SATS: Currency = 'sats'
const DISPLAY_MODE_HIDDEN = 'private'
const getDisplayMode = (
unit: typeof SATS | typeof BTC | undefined,
currency: Currency,
isPrivate: boolean,
showBalance: boolean,
) => {
const getDisplayMode = (unit: Unit | undefined, currency: Currency, isPrivate: boolean, showBalance: boolean) => {
if (!showBalance || isPrivate) return DISPLAY_MODE_HIDDEN
// If convertToUnit is specified, respect it, otherwise use context
@ -129,7 +124,7 @@ const SatsBalance = ({ value, colored = true, ...props }: SatsBalanceProps) => {
interface BalanceProps extends Omit<BalanceComponentProps, 'symbol' | 'children'> {
valueString: string
convertToUnit?: typeof SATS | typeof BTC
convertToUnit?: Unit
showBalance?: boolean
}

View file

@ -62,7 +62,7 @@ function Copyable({
type="button"
className={className}
disabled={disabled}
onClick={() => copyToClipboard(value, valueFallbackInputRef.current!).then(onSuccess, onError)}
onClick={() => void copyToClipboard(value, valueFallbackInputRef.current!).then(onSuccess, onError)}
>
{children}
</button>

View file

@ -52,7 +52,7 @@ export const LockWalletConfirmDialog = ({
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLocking}>
{t('global.cancel')}
</Button>
<Button variant="default" onClick={onConfirm} disabled={isLocking}>
<Button variant="default" onClick={() => void onConfirm()} disabled={isLocking}>
{isLocking ? (
<>
<Spinner className="motion-reduce:hidden" />

View file

@ -76,8 +76,10 @@ export const PasswordVerificationForm = ({
resolver: yupResolver(schema),
})
const doOnSubmit = handleSubmit(onSubmit)
return (
<form onSubmit={handleSubmit(onSubmit)} className={cn('flex flex-col gap-4', className)} noValidate>
<form onSubmit={(event) => void doOnSubmit(event)} className={cn('flex flex-col gap-4', className)} noValidate>
<div className="space-y-2">
<Field data-invalid={errors.password !== undefined}>
<FieldLabel htmlFor="password-verification-input-password">{t(/* TODO: i18n */ 'Password')}</FieldLabel>
@ -111,7 +113,7 @@ export const PasswordVerificationForm = ({
<Field orientation="horizontal" className="justify-end">
{onCancel !== undefined && (
<Button type="button" variant="outline" onClick={onCancel}>
<Button type="button" variant="outline" onClick={() => void onCancel()}>
{t('global.cancel')}
</Button>
)}

View file

@ -164,7 +164,7 @@ const utxoTableColumns = (t: TFunction): ColumnDef<UtxoTableEntry, any>[] => {
}),
columnHelper.accessor('confirmations', {
header: () => t('jar_details.utxo_list.column_title_confirmations'),
cell: (info) => info.getValue(),
cell: (info) => <>{info.getValue()}</>,
meta: {
numeric: true,
align: 'center',

View file

@ -192,7 +192,11 @@ export const UtxosContent = ({ enabled, walletFileName, addressSummary, jar }: U
</div>
</div>
<div className={cn('flex items-center gap-2', {})}>
<Button size="sm" disabled={!operationsEnabled || walletInfo.isFetching} onClick={() => walletInfo.refetch()}>
<Button
size="sm"
disabled={!operationsEnabled || walletInfo.isFetching}
onClick={() => void walletInfo.refetch()}
>
<RefreshCwIcon className={cn({ 'motion-safe:animate-spin': walletInfo.isFetching })} />
{t('global.refresh')}
</Button>
@ -201,7 +205,7 @@ export const UtxosContent = ({ enabled, walletFileName, addressSummary, jar }: U
size="sm"
variant={selectedUtxos.length === 0 ? 'outline' : undefined}
disabled={!operationsEnabled || selectedUtxos.length === 0 || allSelectedUtxosFrozen}
onClick={onFreezeClick}
onClick={() => void onFreezeClick()}
>
{freezeUtxos.isPending ? <Spinner /> : <ThermometerSnowflakeIcon />}
{t('jar_details.utxo_list.button_freeze')}
@ -210,7 +214,7 @@ export const UtxosContent = ({ enabled, walletFileName, addressSummary, jar }: U
size="sm"
variant={selectedUtxos.length === 0 ? 'outline' : undefined}
disabled={!operationsEnabled || selectedUtxos.length === 0 || allSelectedUtxosUnfrozen}
onClick={onUnfreezeClick}
onClick={() => void onUnfreezeClick()}
>
{unfreezeUtxos.isPending ? <Spinner /> : <ThermometerSunIcon />}
{t('jar_details.utxo_list.button_unfreeze')}

View file

@ -2,6 +2,7 @@ import { percentageToFactor, parseSemanticVersion } from '@/lib/utils'
import type { AmountSats, Milliseconds } from '@/types/global'
import { version as packageInfoVersion } from '../../package.json'
import { JM_API_AUTH_TOKEN_EXPIRY, JM_DUST_THRESHOLD, JM_WALLET_FILE_EXTENSION } from './jm'
import { parseAsIntOrDefault } from './meta-env-utils'
export const APP_DISPLAY_VERSION = (() => {
return parseSemanticVersion(packageInfoVersion)
@ -41,7 +42,7 @@ export const OFFER_MINSIZE_DEFAULT: AmountSats = 100_000
export const JAM_JM_SESSION_REFRESH_MIN_INTERVAL: Milliseconds = 5_000
export const JAM_JM_SESSION_REFRESH_DEFAULT_INTERVAL: Milliseconds = 30_000
export const JAM_JM_SESSION_REFRESH_INTERVAL: Milliseconds = Math.max(
import.meta.env.VITE_JAM_JM_SESSION_REFRESH_INTERVAL ?? JAM_JM_SESSION_REFRESH_DEFAULT_INTERVAL,
parseAsIntOrDefault(import.meta.env.VITE_JAM_JM_SESSION_REFRESH_INTERVAL, JAM_JM_SESSION_REFRESH_DEFAULT_INTERVAL),
JAM_JM_SESSION_REFRESH_MIN_INTERVAL,
)
export const JAM_API_AUTH_TOKEN_RENEW_INTERVAL: Milliseconds = Math.round(JM_API_AUTH_TOKEN_EXPIRY * 0.75)
@ -49,29 +50,31 @@ export const JAM_API_AUTH_TOKEN_RENEW_INTERVAL: Milliseconds = Math.round(JM_API
export const JAM_RESCAN_PROGRESS_MIN_INTERVAL: Milliseconds = 5_000
export const JAM_RESCAN_PROGRESS_DEFAULT_INTERVAL: Milliseconds = 21_000
export const JAM_RESCAN_PROGRESS_INTERVAL: Milliseconds = Math.max(
import.meta.env.VITE_JAM_RESCAN_PROGRESS_INTERVAL ?? JAM_RESCAN_PROGRESS_DEFAULT_INTERVAL,
parseAsIntOrDefault(import.meta.env.VITE_JAM_RESCAN_PROGRESS_INTERVAL, JAM_RESCAN_PROGRESS_DEFAULT_INTERVAL),
JAM_RESCAN_PROGRESS_MIN_INTERVAL,
)
const JAM_SEED_MODAL_MIN_TIMEOUT: Milliseconds = 5_000
const JAM_SEED_MODAL_DEFAULT_TIMEOUT: Milliseconds = 30_000
export const JAM_SEED_MODAL_TIMEOUT: Milliseconds = Math.max(
import.meta.env.VITE_JAM_SEED_MODAL_TIMEOUT ?? JAM_SEED_MODAL_DEFAULT_TIMEOUT,
parseAsIntOrDefault(import.meta.env.VITE_JAM_SEED_MODAL_TIMEOUT, JAM_SEED_MODAL_DEFAULT_TIMEOUT),
JAM_SEED_MODAL_MIN_TIMEOUT,
)
// minimum amount of time in milliseconds the connection must stay open to be considered "healthy"
const JAM_JM_WEBSOCKET_CONNECTION_HEALTHY_MIN_DURATION: Milliseconds = 1_000
export const JAM_JM_WEBSOCKET_CONNECTION_HEALTHY_DURATION: Milliseconds = Math.max(
import.meta.env.VITE_JM_WEBSOCKET_CONNECTION_HEALTHY_DURATION ?? 0,
parseAsIntOrDefault(import.meta.env.VITE_JM_WEBSOCKET_CONNECTION_HEALTHY_DURATION, 0),
JAM_JM_WEBSOCKET_CONNECTION_HEALTHY_MIN_DURATION,
)
const JAM_JM_WEBSOCKET_CONNECTION_AUTHENTICATED_MIN_DURATION: Milliseconds = 1_000
const JAM_JM_WEBSOCKET_CONNECTION_AUTHENTICATED_DEFAULT_DURATION: Milliseconds = 3_000
export const JAM_JM_WEBSOCKET_CONNECTION_AUTHENTICATED_DURATION: Milliseconds = Math.max(
import.meta.env.VITE_JM_WEBSOCKET_CONNECTION_AUTHENTICATED_DURATION ??
parseAsIntOrDefault(
import.meta.env.VITE_JM_WEBSOCKET_CONNECTION_AUTHENTICATED_DURATION,
JAM_JM_WEBSOCKET_CONNECTION_AUTHENTICATED_DEFAULT_DURATION,
),
JAM_JM_WEBSOCKET_CONNECTION_AUTHENTICATED_MIN_DURATION,
)
@ -80,16 +83,19 @@ export const JAM_JM_WEBSOCKET_CONNECTION_AUTHENTICATED_DURATION: Milliseconds =
const JAM_JM_WEBSOCKET_KEEPALIVE_MESSAGE_MIN_INTERVAL: Milliseconds = 5_000
const JAM_JM_WEBSOCKET_KEEPALIVE_MESSAGE_DEFAULT_INTERVAL: Milliseconds = 30_000
export const JAM_JM_WEBSOCKET_KEEPALIVE_MESSAGE_INTERVAL: Milliseconds = Math.max(
import.meta.env.VITE_JM_WEBSOCKET_KEEPALIVE_MESSAGE_INTERVAL ?? JAM_JM_WEBSOCKET_KEEPALIVE_MESSAGE_DEFAULT_INTERVAL,
parseAsIntOrDefault(
import.meta.env.VITE_JM_WEBSOCKET_KEEPALIVE_MESSAGE_INTERVAL,
JAM_JM_WEBSOCKET_KEEPALIVE_MESSAGE_DEFAULT_INTERVAL,
),
JAM_JM_WEBSOCKET_KEEPALIVE_MESSAGE_MIN_INTERVAL,
)
export const JAM_JM_WEBSOCKET_RECONNECT_INTERVAL_MIN: Milliseconds = Math.max(
import.meta.env.VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MIN ?? 0,
parseAsIntOrDefault(import.meta.env.VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MIN, 0),
5_000,
)
export const JAM_JM_WEBSOCKET_RECONNECT_INTERVAL_MAX: Milliseconds = Math.max(
import.meta.env.VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MAX ?? 0,
parseAsIntOrDefault(import.meta.env.VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MAX, 0),
60_000,
)

View file

@ -1,4 +1,5 @@
import type { AmountSats, Milliseconds } from '@/types/global'
import { parseAsIntOrDefault } from './meta-env-utils'
export const JM_WALLET_FILE_EXTENSION = '.jmdat'
@ -9,7 +10,10 @@ export const JM_API_AUTH_TOKEN_EXPIRY_MAX: Milliseconds = Math.round(JM_API_AUTH
export const JM_API_AUTH_TOKEN_EXPIRY: Milliseconds = Math.max(
JM_API_AUTH_TOKEN_EXPIRY_MIN,
Math.min(
Number.parseInt(import.meta.env.VITE_JM_API_AUTH_TOKEN_EXPIRY_SECONDS, 10) * 1_000,
parseAsIntOrDefault(
import.meta.env.VITE_JM_API_AUTH_TOKEN_EXPIRY_SECONDS,
JM_API_AUTH_TOKEN_EXPIRY_DEFAULT / 1_000,
) * 1_000,
JM_API_AUTH_TOKEN_EXPIRY_MAX,
),
)

View file

@ -0,0 +1,5 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- okay when trying to parse arbitrary value on purpose
export const parseAsIntOrDefault = (value: any, defaultValue: number) => {
const parsed = Number.parseInt(`${value ?? defaultValue}`, 10)
return Number.isSafeInteger(parsed) ? parsed : defaultValue
}

View file

@ -12,7 +12,6 @@ import {
type AccountMeta,
type AccountSummary,
type AddressMeta,
type AddressStatus,
type AddressSummary,
type FidelityBondSummary,
type Jar,
@ -35,7 +34,7 @@ const toAccountSummary = (walletInfo: WalletInfoApiObject): AccountSummary => {
})
const meta: AccountMeta = {
jarIndex: Number.parseInt(String(__raw.account), 10) as JarIndex,
jarIndex: Number.parseInt(String(__raw.account), 10),
branches,
__raw,
}
@ -58,7 +57,7 @@ const toAddressSummary = (accountSummary: AccountSummary): AddressSummary => {
const meta: AddressMeta = {
address: __raw.address,
status: __raw.status as AddressStatus,
status: __raw.status,
info: {
bech32: info.bech32,
network: info.network,
@ -131,9 +130,9 @@ export const JamWalletInfoContextProvider = ({
const walletBalanceSummary = toBalanceSummary(utxos)
const utxosByJarIndex = utxos.reduce((acc, utxo) => {
const key = utxo.mixdepth as JarIndex
const key: JarIndex = utxo.mixdepth
acc[key] = acc[key] || []
acc[key].push(utxo as Utxo)
acc[key].push(utxo)
return acc
}, {} as UtxosByJarIndex)

View file

@ -4,15 +4,17 @@ import { isDevMode } from '@/constants/debugFeatures'
import { fetchFeatures } from '@/lib/api/logs'
import { authStore } from '@/store/authStore'
export interface Features {
logs?: boolean
}
type SupportedFeature = 'logs' // add on demand
export interface FeatureItem {
type FeatureItem = {
name: string
enabled: boolean
}
type FeaturesApiResponse = {
features: Record<string, boolean> | FeatureItem[]
}
export const useFeatures = () => {
const authState = useStore(authStore, (state) => state.state)
@ -37,41 +39,41 @@ export const useFeatures = () => {
throw new Error(`Features request failed with status ${response.status}`)
}
const data = await response.json()
return data.features as Features
return (await response.json()) as FeaturesApiResponse
},
enabled: !!authState?.auth?.token,
retry: false,
select: (data: FeaturesApiResponse): FeatureItem[] => {
if (!data.features) {
return []
}
// New format: { features: [{ name: 'logs', enabled: true }] }
else if (Array.isArray(data.features)) {
return data.features
}
// Old format: { features: { logs: true } }
else if (typeof data.features === 'object') {
return Object.entries(data.features).map(([name, enabled]) => ({ name, enabled }))
} else {
console.warn('Could not parse feature response. Disabling all optional features.')
return []
}
},
})
const isLogsSupported = () => {
if (error) {
return false
}
if (features) {
// Old format: { features: { logs: true } }
if (typeof features.logs === 'boolean') {
return features.logs
}
// New format: { features: [{ name: 'logs', enabled: true }] }
if (Array.isArray(features)) {
return features.some((feature: FeatureItem) => feature.name === 'logs' && feature.enabled === true)
}
}
return false
const isFeatureSupported = (featureName: SupportedFeature) => {
return features?.some((feature) => feature.name === featureName && feature.enabled === true)
}
const isFeatureEnabled = (featureName: SupportedFeature) => {
return isFeatureSupported(featureName) || isDevMode()
}
// Show logs UI in dev mode even when not supported, but with unsupported message
const isLogsEnabled = isLogsSupported() || (error && isDevMode())
return {
features,
error,
isLoading,
isFetching,
isLogsEnabled,
isLogsSupported: isLogsSupported(),
isFeatureSupported: isFeatureSupported,
isFeatureEnabled: isFeatureEnabled,
}
}

View file

@ -20,7 +20,7 @@ const calcReconnectInterval = (attemptNumber: number): Milliseconds => {
)
}
const basePath: string = import.meta.env.VITE_JM_WEBSOCKET_ENDPOINT_PATH
const basePath: string = String(import.meta.env.VITE_JM_WEBSOCKET_ENDPOINT_PATH)
const basePathWithoutLeadingSlash = basePath.replace(/^\//, '') // remove leading slash
const { protocol, host } = window.location

View file

@ -10,7 +10,7 @@ const resources = languages.reduce((acc, lng) => {
}
}, {})
i18n.use(LanguageDetector).use(initReactI18next).init({
void i18n.use(LanguageDetector).use(initReactI18next).init({
resources,
fallbackLng: 'en',
})

View file

@ -1,7 +1,7 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
i18n.use(initReactI18next).init({
void i18n.use(initReactI18next).init({
lng: 'en',
fallbackLng: 'en',
resources: { en: { translations: {} } },

View file

@ -10,7 +10,7 @@ const buildAuthHeader = (token: string) => {
/**
* Validate response content type
*/
const withExpectedContentTypeOrThrow = async (response: Response, expectedContentType: string) => {
const withExpectedContentTypeOrThrow = (response: Response, expectedContentType: string) => {
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`)
}

View file

@ -30,14 +30,14 @@ export const fetchOrderbook = async (): Promise<OrderbookResponse> => {
throw new Error(`Failed to fetch orderbook: ${response.status}`)
}
const data = await response.json()
const data: unknown = await response.json()
if (!data || !Array.isArray(data.offers)) {
if (!data || typeof data !== 'object' || !('offers' in data) || !Array.isArray(data.offers)) {
console.warn('Unexpected orderbook response structure:', data)
return { offers: [], fidelitybonds: [] }
}
return data
return data as OrderbookResponse
}
export const refreshOrderbook = async (): Promise<Response> => {

View file

@ -28,7 +28,7 @@ describe('BalanceSummary', () => {
frozen: true,
} as Utxo,
{
value: 4,
value: 5,
mixdepth: 0,
// unfrozen and expired
frozen: false,
@ -39,10 +39,9 @@ describe('BalanceSummary', () => {
now,
)
expect(balanceSummary).not.toBeNull()
expect(balanceSummary!.calculatedTotalBalanceInSats).toBe(10)
expect(balanceSummary!.calculatedAvailableBalanceInSats).toBe(5)
expect(balanceSummary!.calculatedFrozenOrLockedBalanceInSats).toBe(5)
expect(balanceSummary.calculatedTotalBalanceInSats).toBe(11)
expect(balanceSummary.calculatedAvailableBalanceInSats).toBe(6)
expect(balanceSummary.calculatedFrozenOrLockedBalanceInSats).toBe(5)
})
it('should populate account balance data', () => {
@ -71,9 +70,8 @@ describe('BalanceSummary', () => {
now,
)
expect(balanceSummary).not.toBeNull()
expect(balanceSummary!.calculatedTotalBalanceInSats).toBe(677777777)
expect(balanceSummary!.calculatedAvailableBalanceInSats).toBe(333333333)
expect(balanceSummary!.calculatedFrozenOrLockedBalanceInSats).toBe(344444444)
expect(balanceSummary.calculatedTotalBalanceInSats).toBe(677777777)
expect(balanceSummary.calculatedAvailableBalanceInSats).toBe(333333333)
expect(balanceSummary.calculatedFrozenOrLockedBalanceInSats).toBe(344444444)
})
})

View file

@ -26,37 +26,8 @@ const calculateFrozenOrLockedBalance = (utxos: Utxo[], refTime: Milliseconds = D
export const toBalanceSummary = (utxos: Utxo[], now?: Milliseconds): BalanceSummary => {
const refTime = now !== undefined ? now : Date.now()
const utxosByAccount = (utxos ?? []).reduce(
(acc, utxo) => {
const key = `${utxo.mixdepth}`
acc[key] = acc[key] || []
acc[key].push(utxo as Utxo)
return acc
},
{} as { [key: string]: Utxo[] },
)
const totalCalculatedByAccount = Object.fromEntries(
Object.entries(utxosByAccount).map(([account, utxos]) => {
return [account, utxos.reduce((acc, utxo) => acc + utxo.value, 0)]
}),
)
const frozenOrLockedCalculatedByAccount = Object.fromEntries(
Object.entries(utxosByAccount).map(([account, utxos]) => {
return [account, calculateFrozenOrLockedBalance(utxos, refTime)]
}),
)
const walletTotalCalculated: AmountSats = Object.values(totalCalculatedByAccount).reduce(
(acc, totalSats) => acc + totalSats,
0,
)
const walletFrozenOrLockedCalculated: AmountSats = Object.values(frozenOrLockedCalculatedByAccount).reduce(
(acc, frozenOrLockedSats) => acc + frozenOrLockedSats,
0,
)
const walletTotalCalculated: AmountSats = utxos.reduce((acc, utxo) => acc + utxo.value, 0)
const walletFrozenOrLockedCalculated: AmountSats = calculateFrozenOrLockedBalance(utxos, refTime)
const walletAvailableCalculated = walletTotalCalculated - walletFrozenOrLockedCalculated
return {

View file

@ -10,18 +10,18 @@ const buildAuthHeader = (token: ApiToken): [string, string] => {
return ['x-jm-authorization', `Bearer ${token}`]
}
async function loggingRequestInterceptor(request: Request) {
function loggingRequestInterceptor(request: Request) {
console.debug('[onRequest]', request)
return request
}
async function loggingResponseInterceptor(response: Response) {
function loggingResponseInterceptor(response: Response) {
console.debug('[onResponse]', response)
return response
}
const createJamAuthenticationMiddleware = () => {
// eslint-disable-next-line unicorn/consistent-function-scoping -- false positive
return async (request: Request) => {
return (request: Request) => {
const authState = authStore.getState().state
if (authState?.auth?.token) {
const authHeader = buildAuthHeader(authState.auth.token)
@ -32,7 +32,7 @@ const createJamAuthenticationMiddleware = () => {
}
export const createApiClient = (): Client => {
const baseUrl: string = import.meta.env.VITE_JM_API_BASE_URL
const baseUrl = String(import.meta.env.VITE_JM_API_BASE_URL)
const clientOptions: ClientOptions = { baseUrl }
console.debug('Setting up JM API client…', clientOptions)

View file

@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
import { DEFAULT_PBKDF_ITERATIONS, hashPassword } from './hash'
describe('hash', () => {
it('DEFAULT_PBKDF_ITERATIONS', async () => {
it('DEFAULT_PBKDF_ITERATIONS', () => {
expect(DEFAULT_PBKDF_ITERATIONS).toBe(210_000)
})

View file

@ -116,7 +116,9 @@ describe('setIntervalDebounced', () => {
const error = new Error('Test error')
const callback = vi.fn().mockRejectedValueOnce(error).mockResolvedValueOnce(undefined)
const onTimerIdChanged = vi.fn()
const onError = vi.fn((_, loop) => loop())
const onError = vi.fn((_, loop) => {
loop()
})
setIntervalDebounced(callback, 1000, onTimerIdChanged, onError)
@ -406,7 +408,7 @@ describe('delayedPromise', () => {
// Initially the promise should not be resolved
let resolved = false
delayPromise.then(() => {
void delayPromise.then(() => {
resolved = true
})
@ -417,13 +419,14 @@ describe('delayedPromise', () => {
// Now the promise should be resolved
await expect(delayPromise).resolves.toBeUndefined()
expect(resolved).toBe(true)
})
it('should not resolve before 500ms', async () => {
const delayPromise = delayedPromise(500)
let resolved = false
delayPromise.then(() => {
void delayPromise.then(() => {
resolved = true
})

View file

@ -75,10 +75,11 @@ export function setIntervalDebounced(
) {
;(function loop() {
onTimerIdChanged(
setTimeout(async () => {
setTimeout(() => {
try {
await callback()
loop()
Promise.resolve(callback())
.then(() => loop())
.catch((error) => onError(error, loop))
} catch (error: unknown) {
onError(error, loop)
}
@ -100,7 +101,7 @@ export const percentageToFactor = (val: number, precision = 6) => {
return Number((val / 100).toFixed(precision))
}
export const isValidNumber = (val: number | undefined | null): val is number => {
export const isValidNumber = (val: unknown): val is number => {
return val !== undefined && typeof val === 'number' && !Number.isNaN(val)
}

View file

@ -21,7 +21,7 @@ const {
// https://vite.dev/config/
export default defineConfig((): UserConfig => {
if (!SUPPORTED_BACKENDS.includes(JAM_BACKEND)) {
throw new Error(`Unsupported backend: Use one of ${SUPPORTED_BACKENDS}`)
throw new Error(`Unsupported backend: Use one of [${SUPPORTED_BACKENDS.join(', ')}]`)
}
if (JAM_BACKEND === BACKEND_STANDALONE && JAM_API_PORT === undefined) {