import { Dialog } from "@kobalte/core"; import { createResource, createSignal, For, Match, onCleanup, onMount, ParentComponent, Show, Switch } from "solid-js"; import { useNavigate } from "solid-start"; import close from "~/assets/icons/close.svg"; import currencySwap from "~/assets/icons/currency-swap.svg"; import pencil from "~/assets/icons/pencil.svg"; import { Button, FeesModal, InfoBox, InlineAmount } from "~/components"; import { useI18n } from "~/i18n/context"; import { Network } from "~/logic/mutinyWalletSetup"; import { useMegaStore } from "~/state/megaStore"; import { DIALOG_CONTENT, DIALOG_POSITIONER } from "~/styles/dialogs"; import { satsToUsd, usdToSats } from "~/utils/conversions"; function fiatInputSanitizer(input: string): string { // Make sure only numbers and a single decimal point are allowed const numeric = input.replace(/[^0-9.]/g, "").replace(/(\..*)\./g, "$1"); // Remove leading zeros if not a decimal, add 0 if starts with a decimal const cleaned = numeric.replace(/^0([^.]|$)/g, "$1").replace(/^\./g, "0."); // If there are three characters after the decimal, shift the decimal const shifted = cleaned.match(/(\.[0-9]{3}).*/g) ? (parseFloat(cleaned) * 10).toFixed(2) : cleaned; // Truncate any numbers two past the decimal const twoDecimals = shifted.replace(/(\.[0-9]{2}).*/g, "$1"); return twoDecimals; } function satsInputSanitizer(input: string): string { // Make sure only numbers are allowed const numeric = input.replace(/[^0-9]/g, ""); // If it starts with a 0, remove the 0 const noLeadingZero = numeric.replace(/^0([^.]|$)/g, "$1"); return noLeadingZero; } function SingleDigitButton(props: { character: string; onClick: (c: string) => void; onClear: () => void; fiat: boolean; }) { const i18n = useI18n(); let holdTimer: ReturnType | undefined; const holdThreshold = 500; function onHold() { if ( props.character === "DEL" || props.character === i18n.t("receive.amount_editable.del") ) { holdTimer = setTimeout(() => { props.onClear(); }, holdThreshold); } } function endHold() { clearTimeout(holdTimer); } function onClick() { props.onClick(props.character); clearTimeout(holdTimer); } onCleanup(() => { clearTimeout(holdTimer); }); return ( // Skip the "." if it's fiat } > ); } function BigScalingText(props: { text: string; fiat: boolean }) { const chars = () => props.text.length; const i18n = useI18n(); return (

= 11, "scale-95": chars() === 10, "scale-100": chars() === 9, "scale-105": chars() === 7, "scale-110": chars() === 6, "scale-125": chars() === 5, "scale-150": chars() <= 4 }} > {props.text}  {props.fiat ? i18n.t("common.usd") : i18n.t("common.sats")}

); } function SmallSubtleAmount(props: { text: string; fiat: boolean }) { const i18n = useI18n(); return (

~{props.text}  {props.fiat ? i18n.t("common.usd") : i18n.t("common.sats")} Swap currencies

); } function toDisplayHandleNaN(input: string, _fiat: boolean): string { const parsed = Number(input); //handle decimals so the user can always see the accurate amount if (isNaN(parsed)) { return "0"; } else if (parsed === Math.trunc(parsed) && input.endsWith(".")) { return parsed.toLocaleString() + "."; } else if (parsed === Math.trunc(parsed) && input.endsWith(".0")) { return parsed.toFixed(1); } else if (parsed === Math.trunc(parsed) && input.endsWith(".00")) { return parsed.toFixed(2); } else if ( parsed !== Math.trunc(parsed) && input.endsWith("0") && input.includes(".", input.length - 3) ) { return parsed.toFixed(2); } else { return parsed.toLocaleString(); } } export const AmountEditable: ParentComponent<{ initialAmountSats: string; initialOpen: boolean; setAmountSats: (s: bigint) => void; skipWarnings?: boolean; exitRoute?: string; maxAmountSats?: bigint; fee?: string; }> = (props) => { const i18n = useI18n(); const navigate = useNavigate(); const [isOpen, setIsOpen] = createSignal(props.initialOpen); const [state, _actions] = useMegaStore(); const [mode, setMode] = createSignal<"fiat" | "sats">("sats"); const [localSats, setLocalSats] = createSignal( props.initialAmountSats || "0" ); const [localFiat, setLocalFiat] = createSignal( satsToUsd( state.price, parseInt(props.initialAmountSats || "0") || 0, false ) ); const FIXED_AMOUNTS_SATS = [ { label: i18n.t("receive.amount_editable.fix_amounts.ten_k"), amount: "10000" }, { label: i18n.t("receive.amount_editable.fix_amounts.one_hundred_k"), amount: "100000" }, { label: i18n.t("receive.amount_editable.fix_amounts.one_million"), amount: "1000000" } ]; const FIXED_AMOUNTS_USD = [ { label: "$1", amount: "1" }, { label: "$10", amount: "10" }, { label: "$100", amount: "100" } ]; const CHARACTERS = [ "1", "2", "3", "4", "5", "6", "7", "8", "9", ".", "0", i18n.t("receive.amount_editable.del") ]; const displaySats = () => toDisplayHandleNaN(localSats(), false); const displayFiat = () => `$${toDisplayHandleNaN(localFiat(), true)}`; let satsInputRef!: HTMLInputElement; let fiatInputRef!: HTMLInputElement; const [inboundCapacity] = createResource(async () => { try { const channels = await state.mutiny_wallet?.list_channels(); let inbound = 0; for (const channel of channels) { inbound += channel.size - (channel.balance + channel.reserve); } return inbound; } catch (e) { console.error(e); return 0; } }); const warningText = () => { if ((state.balance?.lightning || 0n) === 0n) { const network = state.mutiny_wallet?.get_network() as Network; if (network === "bitcoin") { return i18n.t("receive.amount_editable.receive_too_small", { amount: "50,000" }); } else { return i18n.t("receive.amount_editable.receive_too_small", { amount: "10,000" }); } } const parsed = Number(localSats()); if (isNaN(parsed)) { return undefined; } if (parsed > (inboundCapacity() || 0)) { return i18n.t("receive.amount_editable.setup_fee_lightning"); } return undefined; }; const betaWarning = () => { const parsed = Number(localSats()); if (isNaN(parsed)) { return undefined; } if (parsed >= 2099999997690000) { // If over 21 million bitcoin, warn that too much return i18n.t("receive.amount_editable.more_than_21m"); } else if (parsed >= 4000000) { // If over 4 million sats, warn that it's a beta bro return i18n.t("receive.amount_editable.too_big_for_beta"); } }; function handleCharacterInput(character: string) { const isFiatMode = mode() === "fiat"; const inputSanitizer = isFiatMode ? fiatInputSanitizer : satsInputSanitizer; const localValue = isFiatMode ? localFiat : localSats; let sane; if ( character === "DEL" || character === i18n.t("receive.amount_editable.del") ) { if (localValue().length <= 1) { sane = "0"; } else { sane = inputSanitizer(localValue().slice(0, -1)); } } else { if (localValue() === "0") { sane = inputSanitizer(character); } else { sane = inputSanitizer(localValue() + character); } } if (isFiatMode) { setLocalFiat(sane); setLocalSats( usdToSats(state.price, parseFloat(sane || "0") || 0, false) ); } else { setLocalSats(sane); setLocalFiat(satsToUsd(state.price, Number(sane) || 0, false)); } // After a button press make sure we re-focus the input focus(); } function handleClear() { const isFiatMode = mode() === "fiat"; if (isFiatMode) { setLocalFiat("0"); setLocalSats(usdToSats(state.price, parseFloat("0") || 0, false)); } else { setLocalSats("0"); setLocalFiat(satsToUsd(state.price, Number("0") || 0, false)); } // After a button press make sure we re-focus the input focus(); } function setFixedAmount(amount: string) { if (mode() === "fiat") { setLocalFiat(amount); setLocalSats( usdToSats(state.price, parseFloat(amount || "0") || 0, false) ); } else { setLocalSats(amount); setLocalFiat(satsToUsd(state.price, Number(amount) || 0, false)); } } function handleClose() { props.setAmountSats(BigInt(props.initialAmountSats)); setIsOpen(false); setLocalSats(props.initialAmountSats); setLocalFiat( satsToUsd( state.price, parseInt(props.initialAmountSats || "0") || 0, false ) ); props.exitRoute && navigate(props.exitRoute); } // What we're all here for in the first place: returning a value function handleSubmit(e: SubmitEvent | MouseEvent) { e.preventDefault(); props.setAmountSats(BigInt(localSats())); setLocalFiat(satsToUsd(state.price, Number(localSats()) || 0, false)); setIsOpen(false); } function handleSatsInput(e: InputEvent) { const { value } = e.target as HTMLInputElement; const sane = satsInputSanitizer(value); setLocalSats(sane); setLocalFiat(satsToUsd(state.price, Number(sane) || 0, false)); } function handleFiatInput(e: InputEvent) { const { value } = e.target as HTMLInputElement; const sane = fiatInputSanitizer(value); setLocalFiat(sane); setLocalSats( usdToSats(state.price, parseFloat(sane || "0") || 0, false) ); } function toggle() { setMode((m) => (m === "sats" ? "fiat" : "sats")); focus(); } onMount(() => { focus(); }); function focus() { // Make sure we actually have the inputs mounted before we try to focus them if (isOpen() && satsInputRef && fiatInputRef) { if (mode() === "sats") { satsInputRef.focus(); } else { fiatInputRef.focus(); } } } // If the user is trying to send the max amount we want to show max minus fee // Otherwise we just the actual amount they've entered const maxOrLocalSats = () => { if ( props.maxAmountSats && props.fee && props.maxAmountSats === BigInt(localSats()) ) { return ( Number(props.maxAmountSats) - Number(props.fee) ).toLocaleString(); } else { return localSats(); } }; return ( {/* */}
{/* TODO: figure out how to submit on enter */}
{/*
*/} (satsInputRef = el)} disabled={mode() === "fiat"} type="text" value={localSats()} onInput={handleSatsInput} inputMode="none" /> (fiatInputRef = el)} disabled={mode() === "sats"} type="text" value={localFiat()} onInput={handleFiatInput} inputMode="none" />
{betaWarning()} {warningText()}
{(amount) => ( )}
{(character) => ( )}
); };