mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-18 13:09:10 +02:00
* 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
270 lines
8.9 KiB
TypeScript
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,
|
|
}
|
|
})()
|