diff --git a/src/components/App.jsx b/src/components/App.jsx index 9003184da..354551850 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react' import { Route, Routes, Navigate } from 'react-router-dom' import * as rb from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' +import { isFeatureEnabled } from '../constants/features' import Wallets from './Wallets' import CreateWallet from './CreateWallet' import Jam from './Jam' @@ -10,6 +11,8 @@ import Earn from './Earn' import Receive from './Receive' import CurrentWalletMagic from './CurrentWalletMagic' import CurrentWalletAdvanced from './CurrentWalletAdvanced' +import FidelityBond from './FidelityBond' +import { FidelityBondDevOnly } from './FidelityBondDevOnly' import Settings from './Settings' import Navbar from './Navbar' import Layout from './Layout' @@ -129,6 +132,12 @@ export default function App() { } /> } /> } /> + {isFeatureEnabled('fidelityBonds') && ( + } /> + )} + {isFeatureEnabled('fidelityBondsDevOnly') && ( + } /> + )} )} diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index 6dfa10cef..c37d8f19b 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -56,8 +56,9 @@ export function CopyButton({ value, onSuccess, onError, children, ...props }: Pr interface CopyButtonWithConfirmationProps extends CopyButtonProps { text: string - successText: string - successTextTimeout: number + successText?: string + successTextTimeout?: number + disabled?: boolean } export function CopyButtonWithConfirmation({ diff --git a/src/components/CurrentWalletAdvanced.tsx b/src/components/CurrentWalletAdvanced.tsx index 9a95784d6..e6722afb0 100644 --- a/src/components/CurrentWalletAdvanced.tsx +++ b/src/components/CurrentWalletAdvanced.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react' +import { Link } from 'react-router-dom' import * as rb from 'react-bootstrap' -import { useTranslation } from 'react-i18next' +import { Trans, useTranslation } from 'react-i18next' // @ts-ignore import DisplayAccounts from './DisplayAccounts' // @ts-ignore @@ -9,12 +10,16 @@ import DisplayAccountUTXOs from './DisplayAccountUTXOs' import DisplayUTXOs from './DisplayUTXOs' // @ts-ignore import { useCurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext' +import { isFeatureEnabled } from '../constants/features' +import { routes } from '../constants/routes' import styles from './CurrentWalletAdvanced.module.css' type Utxos = any[] type Alert = { message: string; variant: string } export default function CurrentWalletAdvanced() { + const featureFidelityBondsEnabled = isFeatureEnabled('fidelityBonds') + const { t } = useTranslation() const currentWallet = useCurrentWallet() const walletInfo = useCurrentWalletInfo() @@ -76,21 +81,45 @@ export default function CurrentWalletAdvanced() { {!isLoading && walletInfo && ( )} - {!!fidelityBonds?.length && ( -
-
{t('current_wallet_advanced.title_fidelity_bonds')}
- -
- )} + +
+
{t('current_wallet_advanced.title_fidelity_bonds')}
+ {isLoading && ( +
+ + + +
+ )} + + {!isLoading && fidelityBonds && ( + <> + {fidelityBonds.length === 0 ? ( + + <> + + No Fidelity Bond present. + + {featureFidelityBondsEnabled && ( + <> + {' '} + + + Create a Fidelity Bond. + + + + )} + + + ) : ( + + )} + + )} +
<> - { - setShowUTXO(!showUTXO) - }} - className={isLoading ? 'mt-3 mb-3 pe-auto' : 'mb-3'} - > + setShowUTXO(!showUTXO)} className="mb-3"> {showUTXO ? t('current_wallet_advanced.button_hide_utxos') : t('current_wallet_advanced.button_show_utxos')} diff --git a/src/components/FidelityBond.module.css b/src/components/FidelityBond.module.css new file mode 100644 index 000000000..273c836f3 --- /dev/null +++ b/src/components/FidelityBond.module.css @@ -0,0 +1,4 @@ +/* Firefox */ +.fidelity-bond input[type='number'] { + -moz-appearance: unset !important; +} diff --git a/src/components/FidelityBond.tsx b/src/components/FidelityBond.tsx new file mode 100644 index 000000000..a607b2bae --- /dev/null +++ b/src/components/FidelityBond.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' +import * as rb from 'react-bootstrap' + +// @ts-ignore +import PageTitle from './PageTitle' +import { isFeatureEnabled } from '../constants/features' + +import { routes } from '../constants/routes' +import styles from './FidelityBond.module.css' + +export default function FidelityBond() { + const featureEnabled = isFeatureEnabled('fidelityBonds') + const featureAdvancedEnabled = isFeatureEnabled('fidelityBondsDevOnly') + + const { t } = useTranslation() + + if (!featureEnabled) { + return ( +
+

Feature not enabled

+
+ ) + } + + return ( +
+ + + + +
+ + + See the documentation about Fidelity Bonds + {' '} + for more information. + +
+ + + + Fidelity Bonds are currently only available in developer mode. + {featureAdvancedEnabled && ( + <> + {' '} + + Switch to developer view. + + + )} + + +
+
+
+ ) +} diff --git a/src/components/FidelityBondDevOnly.tsx b/src/components/FidelityBondDevOnly.tsx new file mode 100644 index 000000000..8e57612d7 --- /dev/null +++ b/src/components/FidelityBondDevOnly.tsx @@ -0,0 +1,255 @@ +import React, { useEffect, useMemo, useState } from 'react' +import * as rb from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' +import { useCurrentWallet, useReloadCurrentWalletInfo, Utxos } from '../context/WalletContext' +import { CopyButtonWithConfirmation } from '../components/CopyButton' +import { isFeatureEnabled } from '../constants/features' + +// @ts-ignore +import PageTitle from './PageTitle' +// @ts-ignore +import DisplayUTXOs from './DisplayUTXOs' + +import { routes } from '../constants/routes' +import * as Api from '../libs/JmWalletApi' +import styles from './FidelityBond.module.css' +import LockdateForm, { + toYearsRange, + lockdateToTimestamp, + DEFAULT_MAX_TIMELOCK_YEARS, +} from './fidelity_bond/LockdateForm' + +type AlertWithMessage = rb.AlertProps & { message: string } + +const locktimeDisplayString = (lockdate: Api.Lockdate) => { + return new Date(lockdateToTimestamp(lockdate)).toUTCString() +} + +interface DepositFormAdvancedProps { + title: React.ReactElement + [key: string]: unknown +} +const DepositFormAdvanced = ({ title, ...props }: DepositFormAdvancedProps) => { + const { t } = useTranslation() + const currentWallet = useCurrentWallet() + + const yearsRange = useMemo(() => toYearsRange(-1, DEFAULT_MAX_TIMELOCK_YEARS), []) + const [lockdate, setLockdate] = useState(null) + const [address, setAddress] = useState(null) + const [addressLockdate, setAddressLockdate] = useState(null) + const addressLocktimeString = useMemo( + () => (addressLockdate ? locktimeDisplayString(addressLockdate) : null), + [addressLockdate] + ) + const [isLoading, setIsLoading] = useState(true) + const [alert, setAlert] = useState(null) + + useEffect(() => { + if (!currentWallet) return + if (!lockdate) return + + const abortCtrl = new AbortController() + const { name: walletName, token } = currentWallet + + setAlert(null) + + setAddress(null) + setAddressLockdate(null) + + setIsLoading(true) + + Api.getAddressTimelockNew({ walletName, token, lockdate, signal: abortCtrl.signal }) + .then((res) => + res.ok ? res.json() : Api.Helper.throwError(res, t('fidelity_bond.error_loading_timelock_address_failed')) + ) + .then((data) => { + if (abortCtrl.signal.aborted) return + + setAddress(data.address) + setAddressLockdate(lockdate) + }) + // show the loader a little longer to avoid flickering + .then((_) => new Promise((r) => setTimeout(r, 200))) + .catch((err) => { + !abortCtrl.signal.aborted && setAlert({ variant: 'danger', message: err.message }) + }) + .finally(() => !abortCtrl.signal.aborted && setIsLoading(false)) + + return () => abortCtrl.abort() + }, [currentWallet, lockdate, t]) + + return ( + + + {title} + + {alert && {alert.message}} + + + + + + + + + {isLoading ? ( +
+
+ ) : ( + + + Expires at: {addressLocktimeString} + + + )} +
+ +
+ {!isLoading && address && ( + <> +
{address}
+
+ {' '} +
+ + )} +
+
+
+
+
+
+
+ ) +} + +export const FidelityBondDevOnly = () => { + const featureEnabled = isFeatureEnabled('fidelityBondsDevOnly') + const { t } = useTranslation() + const currentWallet = useCurrentWallet() + const reloadCurrentWalletInfo = useReloadCurrentWalletInfo() + const [fidelityBonds, setFidelityBonds] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [alert, setAlert] = useState(null) + + useEffect(() => { + if (!currentWallet) { + setAlert({ variant: 'danger', message: t('current_wallet.error_loading_failed') }) + setIsLoading(false) + return + } + + const abortCtrl = new AbortController() + + setAlert(null) + setIsLoading(true) + setFidelityBonds(null) + + reloadCurrentWalletInfo({ signal: abortCtrl.signal }) + .then((info) => { + if (info) { + const timelockedUtxos = info.data.utxos.utxos.filter((utxo) => utxo.locktime) + setFidelityBonds(timelockedUtxos) + } + }) + .catch((err) => { + const message = err.message || t('current_wallet.error_loading_failed') + !abortCtrl.signal.aborted && setAlert({ variant: 'danger', message }) + }) + .finally(() => !abortCtrl.signal.aborted && setIsLoading(false)) + + return () => abortCtrl.abort() + }, [currentWallet, reloadCurrentWalletInfo, t]) + + if (!featureEnabled) { + return ( +
+

Feature not enabled

+
+ ) + } + + return ( +
+ + + + +
+ + Switch to default view. + +
+ +
+ + + See the documentation about Fidelity Bonds + {' '} + for more information. + +
+ + + + You are in developer mode. It is assumed that you know what you are doing. +
+ + e.g. a transaction creating a Fidelity Bond should have no change, etc. + +
+
+ + {isLoading ? ( +
+
+ ) : ( + <> + {alert && {alert.message}} + + {fidelityBonds && ( + <> +
+ Fidelity Bond} + /> +
+ + {fidelityBonds.length > 0 && ( +
+
{t('current_wallet_advanced.title_fidelity_bonds')}
+ +
+ )} + + )} + + )} +
+
+
+ ) +} diff --git a/src/components/fidelity_bond/LockdateForm.tsx b/src/components/fidelity_bond/LockdateForm.tsx new file mode 100644 index 000000000..945c0df8b --- /dev/null +++ b/src/components/fidelity_bond/LockdateForm.tsx @@ -0,0 +1,135 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import * as rb from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' + +import * as Api from '../../libs/JmWalletApi' + +const dateToLockdate = (date: Date): Api.Lockdate => + `${date.getUTCFullYear()}-${date.getUTCMonth() >= 9 ? '' : '0'}${1 + date.getUTCMonth()}` as Api.Lockdate + +export const lockdateToTimestamp = (lockdate: Api.Lockdate): number => { + const split = lockdate.split('-') + return Date.UTC(parseInt(split[0], 10), parseInt(split[1], 10) - 1, 1) +} + +// a maximum of years for a timelock to be accepted +// this is useful in simple mode - when it should be prevented that users +// lock up their coins for an awful amount of time by accident. +// in "advanced" mode, this can be dropped or increased substantially +export const DEFAULT_MAX_TIMELOCK_YEARS = 10 + +type YearsRange = { + min: number + max: number +} + +export const toYearsRange = (min: number, max: number): YearsRange => { + if (max <= min) { + throw new Error('Invalid values for range of years.') + } + return { min, max } +} + +const initialLockdate = (now: Date, range: YearsRange): Api.Lockdate => { + const year = now.getUTCFullYear() + const month = now.getUTCMonth() + return dateToLockdate(new Date(Date.UTC(year + Math.max(range.min + 1, 1), month, 1))) +} + +interface LockdateFormProps { + initialValue?: Api.Lockdate + onChange: (lockdate: Api.Lockdate) => void + yearsRange?: YearsRange + now?: Date +} + +const LockdateForm = ({ + onChange, + now = new Date(), + yearsRange = toYearsRange(0, DEFAULT_MAX_TIMELOCK_YEARS), + initialValue = initialLockdate(now, yearsRange), +}: LockdateFormProps) => { + const { t } = useTranslation() + + const currentYear = useMemo(() => now.getUTCFullYear(), [now]) + const currentMonth = useMemo(() => now.getUTCMonth() + 1, [now]) // utc month ranges from [0, 11] + + const initialDate = new Date(lockdateToTimestamp(initialValue)) + const [lockdateYear, setLockdateYear] = useState(initialDate.getUTCFullYear()) + const [lockdateMonth, setLockdateMonth] = useState(initialDate.getUTCMonth() + 1) + + const minMonth = useCallback(() => { + if (lockdateYear > currentYear + yearsRange.min) { + return 1 + } + + return currentMonth + 1 // can be '13' - which means it never is valid and user must adapt 'year'. + }, [lockdateYear, currentYear, currentMonth, yearsRange]) + + const isLockdateYearValid = useMemo( + () => lockdateYear >= currentYear + yearsRange.min && lockdateYear <= currentYear + yearsRange.max, + [lockdateYear, currentYear, yearsRange] + ) + const isLockdateMonthValid = useMemo(() => lockdateMonth >= minMonth(), [lockdateMonth, minMonth]) + + useEffect(() => { + if (!isLockdateYearValid || !isLockdateMonthValid) return + + const date = new Date(Date.UTC(lockdateYear, lockdateMonth - 1, 1)) + onChange(dateToLockdate(date)) + }, [lockdateYear, lockdateMonth, isLockdateYearValid, isLockdateMonthValid, onChange]) + + return ( + + + + + Year + + setLockdateYear(parseInt(e.target.value, 10))} + isInvalid={!isLockdateYearValid} + /> + + + Please provide a valid value. + + + + + + + + Month + + setLockdateMonth(parseInt(e.target.value, 10))} + isInvalid={!isLockdateMonthValid} + /> + + + Please provide a valid value. + + + + + + ) +} + +export default LockdateForm diff --git a/src/constants/features.ts b/src/constants/features.ts index d765f6210..49a09dddb 100644 --- a/src/constants/features.ts +++ b/src/constants/features.ts @@ -1,11 +1,15 @@ interface Features { skipWalletBackupConfirmation: boolean + fidelityBonds: boolean + fidelityBondsDevOnly: boolean } const devMode = process.env.NODE_ENV === 'development' const features: Features = { skipWalletBackupConfirmation: devMode, + fidelityBonds: devMode, + fidelityBondsDevOnly: devMode, } type Feature = keyof Features diff --git a/src/constants/routes.ts b/src/constants/routes.ts index 73b034f7f..cdb4f2417 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -6,6 +6,8 @@ export const routes = { receive: '/receive', earn: '/earn', settings: '/settings', + fidelityBonds: '/fidelity-bonds', + fidelityBondsDevOnly: '/fidelity-bonds-dev', wallet: '/wallet', createWallet: '/create-wallet', } diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 6f3ebc1d4..92e322da5 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -1,4 +1,12 @@ { + "global": { + "loading": "Loading", + "button_copy_text": "Copy", + "button_copy_text_confirmed": "Copied" + }, + "app": { + "alert_no_connection": "No connection to backend: {{ connectionError }}" + }, "navbar": { "title": "Jam", "button_create_wallet": "Create Wallet", @@ -10,9 +18,6 @@ "menu_mobile_settings": "Settings", "menu_mobile_wallets": "Wallets" }, - "app": { - "alert_no_connection": "No connection to backend: {{ connectionError }}" - }, "footer": { "warning": "This is alpha software.
<1>Read this before using.", "warning_alert_title": "Warning", diff --git a/src/index.css b/src/index.css index 94aeba17c..120a725d0 100644 --- a/src/index.css +++ b/src/index.css @@ -615,3 +615,12 @@ h2 { :root[data-theme='dark'] .link-dark { color: var(--bs-white); } + +:root[data-theme='dark'] .toast { + background-color: transparent; +} +:root[data-theme='dark'] .toast-header { + color: var(--bs-white); + background-color: var(--bs-gray-800); + border-color: var(--bs-gray-900); +} diff --git a/src/libs/JmWalletApi.ts b/src/libs/JmWalletApi.ts index d7d2ddc34..20fbd062b 100644 --- a/src/libs/JmWalletApi.ts +++ b/src/libs/JmWalletApi.ts @@ -29,9 +29,9 @@ type WithMixdepth = { type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' type YYYY = `2${Digit}${Digit}${Digit}` type MM = '01' | '02' | '03' | '04' | '05' | '06' | '07' | '08' | '09' | '10' | '11' | '12' -type Locktime = `${YYYY}-${MM}` -type WithLocktime = { - locktime: Locktime +export type Lockdate = `${YYYY}-${MM}` +type WithLockdate = { + lockdate: Lockdate } interface ApiRequestContext { @@ -147,8 +147,8 @@ const getAddressNew = async ({ token, signal, walletName, mixdepth }: WalletRequ }) } -const getAddressTimelockNew = async ({ token, signal, walletName, locktime }: WalletRequestContext & WithLocktime) => { - return await fetch(`${basePath()}/v1/wallet/${walletName}/address/timelock/new/${locktime}`, { +const getAddressTimelockNew = async ({ token, signal, walletName, lockdate }: WalletRequestContext & WithLockdate) => { + return await fetch(`${basePath()}/v1/wallet/${walletName}/address/timelock/new/${lockdate}`, { headers: { ...Authorization(token) }, signal, })