jam/src/lib/utils.ts
Thebora Kompanioni eedc54c55d
chore(import): prepare rescan by splitting import flow steps (#1168)
* chore(ui): update alert component

* chore(import): add dev badge to dummy button

* chore(import): show bip-39 compat warning only on unsuccessful submit

* chore(import): display alert when rescan is active

* refactor: rename CreateStepDetailsInput to CreateStepWalletDetails

* chore(import): split import flow steps

* build(deps): update dependencies

 @chromatic-com/storybook    5.0.1  →     5.0.2
 @eslint/js                 9.39.2  →    9.39.4
 @storybook/addon-a11y     10.2.17  →   10.2.19
 @storybook/addon-docs     10.2.17  →   10.2.19
 @storybook/addon-vitest   10.2.17  →   10.2.19
 @storybook/react-vite     10.2.17  →   10.2.19
 @tailwindcss/vite           4.2.1  →     4.2.2
 @types/node               24.10.1  →  24.10.15
 eslint                     9.39.2  →    9.39.4
 eslint-plugin-compat        6.2.0  →     6.2.1
 eslint-plugin-storybook   10.2.17  →   10.2.19
 lint-staged                16.3.3  →    16.3.4
 react-i18next              16.5.6  →    16.5.8
 storybook                 10.2.17  →   10.2.19
 tailwindcss                 4.2.1  →     4.2.2
 typescript-eslint          8.57.0  →    8.57.1
 zustand                    5.0.11  →    5.0.12

* build(deps): update dependencies

 @storybook/addon-a11y    10.2.19  →  10.3.1
 @storybook/addon-docs    10.2.19  →  10.3.1
 @storybook/addon-vitest  10.2.19  →  10.3.1
 @storybook/react-vite    10.2.19  →  10.3.1
 @tanstack/react-query    5.90.21  →  5.94.5
 eslint-plugin-storybook  10.2.19  →  10.3.1
 globals                   17.3.0  →  17.4.0
 jsdom                     28.1.0  →  29.0.1
 lint-staged               16.3.4  →  16.4.0
 react-hook-form           7.71.2  →  7.72.0
 react-i18next             16.5.8  →  16.6.1
 storybook                10.2.19  →  10.3.1

* refactor: seed phrase -> mnemonic phrase

* chore(import): add blockheight and gaplimit params

* chore(import): rescan chain after import

* chore(import): auto reload balance after rescan
2026-04-09 20:31:04 +02:00

270 lines
8.9 KiB
TypeScript

import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { JM_WALLET_FILE_EXTENSION, type OfferType } from '@/constants/jm'
import type { Milliseconds, MnemonicPhrase } from '@/types/global'
const HORIZONTAL_ELLIPSIS = '\u2026' // Horizontal Ellipsis `…`
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export type Unit = 'BTC' | 'sats'
export const BTC: Unit = 'BTC'
export const SATS: Unit = 'sats'
export type TimeInterval = Milliseconds
// can be any of ['sw0reloffer', 'swreloffer', 'reloffer']
export const isRelativeOffer = (offertype: OfferType) => offertype.includes('reloffer')
// can be any of ['sw0absoffer', 'swabsoffer', 'absoffer']
export const isAbsoluteOffer = (offertype: OfferType) => offertype.includes('absoffer')
export type WalletFileName = `${string}.jmdat`
/**
* Formats a wallet name by removing the .jmdat extension
* @param name The full wallet name with .jmdat extension
* @returns The wallet name without the .jmdat extension
*/
export const sanitizeWalletName = (name: WalletFileName) => name.replace(JM_WALLET_FILE_EXTENSION, '')
export const walletDisplayNameToFileName = (name: string) => (name + JM_WALLET_FILE_EXTENSION) as WalletFileName
export const walletDisplayName = (fileName: WalletFileName) => sanitizeWalletName(fileName)
export const sortWallets = (
wallets: WalletFileName[],
activeWalletFileName: WalletFileName | null = null,
): WalletFileName[] => {
if (activeWalletFileName && wallets.includes(activeWalletFileName)) {
return [activeWalletFileName, ...sortWallets(wallets.filter((a) => a !== activeWalletFileName))]
} else {
return wallets.toSorted((a, b) => a.localeCompare(b))
}
}
export const noop: () => Promise<void> = async () => {}
export const shortenStringMiddle = (value: string, chars = 8, separator = HORIZONTAL_ELLIPSIS) => {
const prefixLength = Math.max(Math.floor(chars / 2), 1)
if (value.length <= prefixLength * 2) {
return `${value}`
}
return `${value.substring(0, prefixLength)}${separator}${value.substring(value.length - prefixLength)}`
}
export function debounce<T extends unknown[], U>(callback: (...args: T) => PromiseLike<U> | U, wait: number) {
let timer: ReturnType<typeof setTimeout> | undefined
return (...args: T): Promise<U> => {
clearTimeout(timer)
return new Promise((resolve) => {
timer = setTimeout(() => resolve(callback(...args)), wait)
})
}
}
export function setIntervalDebounced(
callback: () => PromiseLike<void> | void,
delay: number,
onTimerIdChanged: (timerId: NodeJS.Timeout) => void,
onError: (error: unknown, loop: () => void) => void = (_, loop) => loop(),
) {
;(function loop() {
onTimerIdChanged(
setTimeout(() => {
try {
Promise.resolve(callback())
.then(() => loop())
.catch((error) => onError(error, loop))
} catch (error: unknown) {
onError(error, loop)
}
}, delay),
)
})()
}
export const satsToBtc = (value: string) => Number.parseInt(value, 10) / 100_000_000
const isAsciiDigits = (value: string) => {
if (value.length === 0) return false
for (const char of value) {
const codePoint = char.codePointAt(0)
if (codePoint === undefined || codePoint < 48 || codePoint > 57) return false
}
return true
}
export const tryBtcToSat = (value: string): number | undefined => {
const trimmed = value.trim()
if (trimmed === '') return undefined
let sign = 1
let numericPart = trimmed
if (numericPart.startsWith('-')) {
sign = -1
numericPart = numericPart.slice(1)
} else if (numericPart.startsWith('+')) {
numericPart = numericPart.slice(1)
}
// Keep this strict and simple: only plain decimal strings (no scientific notation).
if (numericPart.includes('e') || numericPart.includes('E')) return undefined
const dotIndex = numericPart.indexOf('.')
const hasAtMostOneDot = dotIndex === -1 || !numericPart.includes('.', dotIndex + 1)
if (!hasAtMostOneDot) return undefined
const wholePartRaw = dotIndex === -1 ? numericPart : numericPart.slice(0, dotIndex)
const fractionalPartRaw = dotIndex === -1 ? '' : numericPart.slice(dotIndex + 1)
// Expect: [+-]?\\d+\\.?\\d*
if (!isAsciiDigits(wholePartRaw)) return undefined
if (fractionalPartRaw !== '' && !isAsciiDigits(fractionalPartRaw)) return undefined
const wholePart = Number.parseInt(wholePartRaw, 10)
const fractionalPart =
fractionalPartRaw === '' ? 0 : Number.parseInt((fractionalPartRaw + '00000000').slice(0, 8), 10)
const sats = wholePart * 100_000_000 + fractionalPart
return sats === 0 ? 0 : sign * sats
}
export const SEGWIT_ACTIVATION_BLOCK = 481_824 // https://github.com/bitcoin/bitcoin/blob/v25.0/src/kernel/chainparams.cpp#L86
// if applicable, the genesis date can be used as minimum `since` timestamp
export const BITCOIN_GENESIS_DATE = new Date('2009-01-03T18:15:05Z')
export const DUMMY_SEED_PHRASE: MnemonicPhrase =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'.split(' ')
export const percentageToFactor = (val: number, precision = 6) => {
return Number((val / 100).toFixed(precision))
}
export const isValidNumber = (val: unknown): val is number => {
return val !== undefined && typeof val === 'number' && !Number.isNaN(val)
}
export const formatBtc = (value: number) => {
return value.toLocaleString(undefined, {
minimumFractionDigits: 8,
maximumFractionDigits: 8,
roundingMode: 'trunc',
})
}
export const formatSats = (value: number) => {
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
roundingMode: 'trunc',
})
}
export const factorToPercentage = (val: number, precision = 6) => {
// Value cannot just be multiplied
// e.g. ✗ 0.000027 * 100 == 0.0026999999999999997
// but: ✓ Number((0.000027 * 100).toFixed(6)) = 0.0027
return Number((val * 100).toFixed(precision))
}
export type SemanticVersion = { major: number; minor: number; patch: number; raw?: string }
export const UNKNOWN_VERSION: SemanticVersion = { major: 0, minor: 0, patch: 0, raw: 'unknown' }
const versionRegex = new RegExp(/^v?(\d+)\.(\d+)\.(\d+).*$/)
export const parseSemanticVersion = (raw?: string): SemanticVersion => {
const result = versionRegex.exec(raw || '')
if (!result || result.length < 4) {
return UNKNOWN_VERSION
}
return {
major: Number.parseInt(result[1], 10),
minor: Number.parseInt(result[2], 10),
patch: Number.parseInt(result[3], 10),
raw,
}
}
export const delayedPromise = async (delay: Milliseconds | undefined = 210) => {
await new Promise<void>((resolve) => setTimeout(resolve, delay))
}
// not cryptographically random; returned number is in range [min, max] (both inclusive);
export const pseudoRandomInteger = (min: number, max: number) => {
return Math.round(pseudoRandomFloat(min, max))
}
// not cryptographically random; returned number is in range [min, max] (both inclusive);
export const pseudoRandomFloat = (min: number, max: number) => {
return Math.max(min, Math.min(Math.random() * (max - min) + min, max))
}
/**
* Scrolls to the top of the page.
*
* Hint: There is a small delay before the scrolling is initiated,
* in order to mitigate some weird browser behaviour, where it
* did not properly work without a timeout.
*/
export const scrollToTop = (options?: ScrollOptions) => {
setTimeout(() => window.scrollTo({ behavior: 'smooth', ...options, top: 0, left: 0 }), 21)
}
export const time = (() => {
type Unit = 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second'
// These values don't need to be exact.
// They are only used to approximate a human readable
// representation of a time interval--e.g. "in 2 months".
const UNIT_MILLIS: { [key in Unit]: Milliseconds } = {
year: 24 * 60 * 60 * 1_000 * 365,
month: (24 * 60 * 60 * 1_000 * 365) / 12, // ~30.42 days
day: 24 * 60 * 60 * 1_000,
hour: 60 * 60 * 1_000,
minute: 60 * 1_000,
second: 1_000,
}
const humanReadableDuration = ({
from = Date.now(),
to,
locale,
}: {
from?: Milliseconds
to: Milliseconds
locale: string
}) => humanReadableTimeInterval(timeInterval({ from, to }), locale || 'en')
const timeInterval = ({ from = Date.now(), to }: { from?: Milliseconds; to: Milliseconds }): TimeInterval => {
return to - from
}
const humanReadableTimeInterval = (timeInterval: TimeInterval, locale: string) => {
const rtf = new Intl.RelativeTimeFormat(locale || 'en', { numeric: 'always', style: 'long' })
const sortedUnits = (Object.keys(UNIT_MILLIS) as Unit[])
.toSorted((lhs, rhs) => UNIT_MILLIS[lhs] - UNIT_MILLIS[rhs])
.toReversed()
for (const unit of sortedUnits) {
const limit = UNIT_MILLIS[unit]
if (Math.abs(timeInterval) > limit) {
return rtf.format(Math.round(timeInterval / limit), unit)
}
}
return rtf.format(Math.round(timeInterval / UNIT_MILLIS['second']), 'second')
}
return {
timeInterval,
humanReadableDuration,
}
})()