mirror of
https://github.com/XRPLF/xrpl-dev-portal.git
synced 2025-11-20 03:35:51 +00:00
Migrate the tx-sender page to Redocly
Update convert-template Add basic page Add it to the sidebar Fix a broken link Fix translate usage and add linebreaks Fix indents Add basic sidebar Port over init button logic Migrate submit_and_notify and start dest addr Get the payment button working Componentize the Send XRP Payment button Add basic escrow button Componentize payment channel Migrate Trust For Migrate Send Issued Currency Add partial payment progres bar logic Use the component for the partial payment Add support for escrow finish Log transactions in sidebar Debugging partial payment setup Add support for changing destinationAddress Finish adding bootstrap growl notifications Use 'client' instead of 'api' Move DestinationAddressInput to component and remove ids Split the page into separate files Remove the old files for this page Update links Add space Add comment deprecating bootstrap-growl jquery Fix typing errors PR Comments Pt 1 Small PR fixes Encapsulate isValidDestinationAddress
This commit is contained in:
43
content/dev-tools/components/AlertTemplate.tsx
Normal file
43
content/dev-tools/components/AlertTemplate.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import clsx from 'clsx'
|
||||
import * as React from 'react';
|
||||
import { useTranslate } from '@portal/hooks';
|
||||
|
||||
const alertStyle = {
|
||||
position: "relative",
|
||||
margin: "0px",
|
||||
zIndex: "9999",
|
||||
}
|
||||
|
||||
function typeToClass(type: string): string {
|
||||
if(type === "error") {
|
||||
return "alert-danger"
|
||||
} else if(type === "success") {
|
||||
return "alert-success"
|
||||
} else if(type === "info") {
|
||||
return "alert-info"
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
interface AlertTemplateProps {
|
||||
message: string
|
||||
options: {
|
||||
type: string
|
||||
}
|
||||
style: any
|
||||
close: any // Callback to close the alert early
|
||||
}
|
||||
|
||||
export default function AlertTemplate ({ message, options, style, close }: AlertTemplateProps): React.JSX.Element {
|
||||
const { translate } = useTranslate()
|
||||
return(
|
||||
<div className={clsx("bootstrap-growl alert alert-dismissible", typeToClass(options.type))} style={{ ...alertStyle, ...style }}>
|
||||
<button className="close" data-dismiss="alert" type="button" onClick={close}>
|
||||
<span aria-hidden="true">×</span>
|
||||
<span className="sr-only">{translate("Close")}</span>
|
||||
</button>
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
content/dev-tools/components/DestinationAddressInput.tsx
Normal file
56
content/dev-tools/components/DestinationAddressInput.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslate } from '@portal/hooks';
|
||||
import { clsx } from 'clsx'
|
||||
import { isValidAddress } from 'xrpl'
|
||||
|
||||
function onDestinationAddressChange(
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
setDestinationAddress: React.Dispatch<React.SetStateAction<string>>,
|
||||
setIsValidDestinationAddress: React.Dispatch<React.SetStateAction<boolean>>
|
||||
): void {
|
||||
const newAddress = event.target.value
|
||||
setDestinationAddress(newAddress)
|
||||
setIsValidDestinationAddress(isValidAddress(newAddress))
|
||||
}
|
||||
|
||||
export interface DestinationAddressInputProps {
|
||||
defaultDestinationAddress: string,
|
||||
destinationAddress: string,
|
||||
setDestinationAddress: React.Dispatch<React.SetStateAction<string>>,
|
||||
}
|
||||
|
||||
export function DestinationAddressInput(
|
||||
{
|
||||
defaultDestinationAddress,
|
||||
destinationAddress,
|
||||
setDestinationAddress,
|
||||
} : DestinationAddressInputProps
|
||||
): React.JSX.Element {
|
||||
const { translate } = useTranslate()
|
||||
const [ isValidDestinationAddress, setIsValidDestinationAddress ] = useState(true)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="destination_address">
|
||||
{translate("Destination Address")}
|
||||
</label>
|
||||
<input type="text" className={clsx("form-control",
|
||||
// Defaults to not having "is-valid" / "is-invalid" classes
|
||||
(destinationAddress !== defaultDestinationAddress) && (isValidDestinationAddress ? "is-valid" : "is-invalid"))}
|
||||
id="destination_address"
|
||||
onChange={(event) => onDestinationAddressChange(event, setDestinationAddress, setIsValidDestinationAddress)}
|
||||
aria-describedby="destination_address_help"
|
||||
defaultValue={destinationAddress} />
|
||||
<small id="destination_address_help" className="form-text text-muted">
|
||||
{translate("Send transactions to this XRP Testnet address")}
|
||||
</small>
|
||||
</div>
|
||||
<p className={clsx("devportal-callout caution", !(isValidDestinationAddress && destinationAddress[0] === 'X') && "collapse")}
|
||||
id="x-address-warning">
|
||||
<strong>{translate("Caution:")}</strong>
|
||||
{translate(" This X-address is intended for use on Mainnet. Testnet X-addresses have a \"T\" prefix instead.")}
|
||||
</p>
|
||||
</div>)
|
||||
}
|
||||
199
content/dev-tools/components/InitButton.tsx
Normal file
199
content/dev-tools/components/InitButton.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import * as React from 'react';
|
||||
import { useTranslate } from '@portal/hooks';
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
import { Client, type Wallet, type TxResponse, dropsToXrp } from 'xrpl'
|
||||
import { errorNotif, TESTNET_URL } from '../utils'
|
||||
|
||||
|
||||
export interface InitializationProps {
|
||||
existingClient: Client | undefined,
|
||||
alert, // From useAlert()
|
||||
setClient: React.Dispatch<React.SetStateAction<Client | undefined>>,
|
||||
setBalance: React.Dispatch<React.SetStateAction<number>>,
|
||||
setSendingWallet: React.Dispatch<React.SetStateAction<Wallet | undefined>>,
|
||||
setIsInitEnabled: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
setConnectionReady: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
partialPaymentParams: {
|
||||
setPpIssuerWallet: React.Dispatch<React.SetStateAction<Wallet | undefined>>,
|
||||
setPpWidthPercent: React.Dispatch<React.SetStateAction<number>>,
|
||||
ppCurrencyCode: string
|
||||
}
|
||||
}
|
||||
|
||||
async function setUpForPartialPayments
|
||||
(
|
||||
client: Client,
|
||||
sendingWallet: Wallet,
|
||||
setPpIssuerWallet: React.Dispatch<React.SetStateAction<Wallet | undefined>>,
|
||||
setPpWidthPercent: React.Dispatch<React.SetStateAction<number>>,
|
||||
ppCurrencyCode: string,
|
||||
) {
|
||||
console.debug("Starting partial payment setup...")
|
||||
|
||||
// Causing loader to appear because no longer 0%
|
||||
setPpWidthPercent(1)
|
||||
let ppIssuerWallet;
|
||||
|
||||
// 1. Get a funded address to use as issuer
|
||||
try {
|
||||
ppIssuerWallet = (await client.fundWallet()).wallet
|
||||
setPpIssuerWallet(ppIssuerWallet)
|
||||
} catch(error) {
|
||||
console.log("Error getting issuer address for partial payments:", error)
|
||||
return
|
||||
}
|
||||
|
||||
setPpWidthPercent(20)
|
||||
|
||||
// 2. Set Default Ripple on issuer
|
||||
let resp: TxResponse = await client.submitAndWait({
|
||||
TransactionType: "AccountSet",
|
||||
Account: ppIssuerWallet.address,
|
||||
SetFlag: 8 // asfDefaultRipple
|
||||
}, { wallet: ppIssuerWallet })
|
||||
if (resp === undefined) {
|
||||
console.log("Couldn't set Default Ripple for partial payment issuer")
|
||||
return
|
||||
}
|
||||
setPpWidthPercent(40)
|
||||
|
||||
// 3. Make a trust line from sending address to issuer
|
||||
resp = await client.submitAndWait({
|
||||
TransactionType: "TrustSet",
|
||||
Account: sendingWallet.address,
|
||||
LimitAmount: {
|
||||
currency: ppCurrencyCode,
|
||||
value: "1000000000", // arbitrarily, 1 billion fake currency
|
||||
issuer: ppIssuerWallet.address
|
||||
}
|
||||
}, { wallet: sendingWallet })
|
||||
if (resp === undefined) {
|
||||
console.log("Error making trust line to partial payment issuer")
|
||||
return
|
||||
}
|
||||
setPpWidthPercent(60)
|
||||
|
||||
// 4. Issue fake currency to main sending address
|
||||
resp = await client.submitAndWait({
|
||||
TransactionType: "Payment",
|
||||
Account: ppIssuerWallet.address,
|
||||
Destination: sendingWallet.address,
|
||||
Amount: {
|
||||
currency: ppCurrencyCode,
|
||||
value: "1000000000",
|
||||
issuer: ppIssuerWallet.address
|
||||
}
|
||||
}, { wallet: ppIssuerWallet })
|
||||
if (resp === undefined) {
|
||||
console.log("Error sending fake currency from partial payment issuer")
|
||||
return
|
||||
}
|
||||
setPpWidthPercent(80)
|
||||
|
||||
// 5. Place offer to buy issued currency for XRP
|
||||
// When sending the partial payment, the sender consumes their own offer (!)
|
||||
// so they end up paying themselves issued currency then delivering XRP.
|
||||
resp = await client.submitAndWait({
|
||||
TransactionType: "OfferCreate",
|
||||
Account: sendingWallet.address,
|
||||
TakerGets: "1000000000000000", // 1 billion XRP
|
||||
TakerPays: {
|
||||
currency: ppCurrencyCode,
|
||||
value: "1000000000",
|
||||
issuer: ppIssuerWallet.address
|
||||
}
|
||||
}, { wallet: sendingWallet })
|
||||
if (resp === undefined) {
|
||||
console.log("Error placing order to enable partial payments")
|
||||
return
|
||||
}
|
||||
setPpWidthPercent(100)
|
||||
|
||||
// Done. Enable "Send Partial Payment" button
|
||||
console.log("Done getting ready to send partial payments.")
|
||||
}
|
||||
|
||||
async function onInitClick(
|
||||
props: InitializationProps
|
||||
): Promise<void> {
|
||||
|
||||
const {
|
||||
existingClient,
|
||||
alert, // From useAlert()
|
||||
setClient,
|
||||
setBalance,
|
||||
setSendingWallet,
|
||||
setIsInitEnabled,
|
||||
setConnectionReady,
|
||||
partialPaymentParams
|
||||
} = {...props}
|
||||
|
||||
if(existingClient) {
|
||||
console.log("Already initializing!")
|
||||
return
|
||||
}
|
||||
|
||||
console.log("Connecting to Testnet WebSocket...")
|
||||
const client = new Client(TESTNET_URL)
|
||||
client.on('connected', () => {
|
||||
setConnectionReady(true)
|
||||
})
|
||||
|
||||
client.on('disconnected', (code) => {
|
||||
setConnectionReady(false)
|
||||
})
|
||||
setClient(client)
|
||||
await client.connect()
|
||||
|
||||
console.debug("Getting a sending address from the faucet...")
|
||||
try {
|
||||
const fundResponse = await client.fundWallet()
|
||||
const sendingWallet = fundResponse.wallet
|
||||
setSendingWallet(sendingWallet)
|
||||
// Using Number(...) can result in loss of precision since Number is smaller than the precision of XRP,
|
||||
// but this shouldn't affect the learning tool as that much XRP is not given to any test account.
|
||||
setBalance(Number(dropsToXrp(fundResponse.balance)))
|
||||
setIsInitEnabled(false)
|
||||
await setUpForPartialPayments(
|
||||
client,
|
||||
sendingWallet,
|
||||
partialPaymentParams.setPpIssuerWallet,
|
||||
partialPaymentParams.setPpWidthPercent,
|
||||
partialPaymentParams.ppCurrencyCode,
|
||||
)
|
||||
} catch(error) {
|
||||
console.error(error)
|
||||
errorNotif(alert, "There was an error with the XRP Ledger Testnet Faucet. Reload this page to try again.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function InitButton({
|
||||
isInitEnabled,
|
||||
toInit
|
||||
}: {
|
||||
isInitEnabled: boolean,
|
||||
toInit: InitializationProps
|
||||
}): React.JSX.Element {
|
||||
const { translate } = useTranslate()
|
||||
|
||||
return (<div className="form-group">
|
||||
<button className={clsx("btn btn-primary form-control", isInitEnabled ? "" : "disabled")}
|
||||
type="button" id="init_button"
|
||||
onClick={() => {
|
||||
onInitClick(
|
||||
toInit,
|
||||
)
|
||||
}}
|
||||
disabled={!isInitEnabled}
|
||||
title={isInitEnabled ? "" : "done"}>
|
||||
{translate("Initialize")}
|
||||
</button>
|
||||
{!isInitEnabled && (<div> <i className="fa fa-check-circle"></i></div>)}
|
||||
|
||||
<small className="form-text text-muted">
|
||||
{translate("Set up the necessary Testnet XRP addresses to send test payments.")}
|
||||
</small>
|
||||
</div>)
|
||||
}
|
||||
44
content/dev-tools/components/StatusSidebar.tsx
Normal file
44
content/dev-tools/components/StatusSidebar.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as React from 'react';
|
||||
import { useTranslate } from '@portal/hooks';
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
import { type Wallet } from 'xrpl'
|
||||
|
||||
export function StatusSidebar({
|
||||
balance,
|
||||
sendingWallet,
|
||||
connectionReady,
|
||||
txHistory
|
||||
}:
|
||||
{
|
||||
balance: number,
|
||||
sendingWallet: Wallet | undefined,
|
||||
connectionReady: boolean,
|
||||
txHistory: React.JSX.Element[],
|
||||
}) {
|
||||
const { translate } = useTranslate();
|
||||
|
||||
return (<aside className="right-sidebar col-lg-6 order-lg-4">
|
||||
<div id="connection-status" className="card">
|
||||
<div className="card-header">
|
||||
<h4>{translate("Status")}</h4>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<ul className="list-group list-group-flush">
|
||||
<li className="list-group-item" id="connection-status-label">{translate("XRP Testnet:")}</li>
|
||||
<li className={clsx("list-group-item", (connectionReady ? 'active' : 'disabled'))} id="connection-status-item">{connectionReady ? translate("Connected") : translate("Not Connected")}</li>
|
||||
<li className="list-group-item" id="sending-address-label">{translate("Sending Address:")}</li>
|
||||
<li className="list-group-item disabled sending-address-item">{sendingWallet ? sendingWallet.address : translate("(None)")}</li>
|
||||
<li className="list-group-item" id="balance-label">{translate("Testnet XRP Available:")}</li>
|
||||
<li className="list-group-item disabled" id="balance-item">{balance ? translate(balance.toString()) : translate("(None)")}</li>
|
||||
</ul>
|
||||
<div id="tx-sender-history">
|
||||
<h5 className="m-3">{translate("Transaction History")}</h5>
|
||||
<ul className="list-group list-group-flush">
|
||||
{txHistory}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>)
|
||||
}
|
||||
156
content/dev-tools/components/TransactionButton.tsx
Normal file
156
content/dev-tools/components/TransactionButton.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react'
|
||||
import { useTranslate } from '@portal/hooks';
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
import { type Transaction, type Wallet } from 'xrpl'
|
||||
|
||||
import { SubmitConstData, submitAndUpdateUI, canSendTransaction } from '../utils';
|
||||
|
||||
|
||||
export interface TransactionButtonProps {
|
||||
submitConstData: SubmitConstData,
|
||||
connectionReady: boolean,
|
||||
transaction: Transaction,
|
||||
sendingWallet: Wallet | undefined
|
||||
id: string, // Used to set all ids within component
|
||||
content: {
|
||||
buttonText: string,
|
||||
units: string, // Displays after the input number
|
||||
longerDescription: React.JSX.Element // JSX allows for embedding links within the longer description
|
||||
buttonTitle?: string // Only used while loading bar is activated
|
||||
},
|
||||
inputSettings?: {
|
||||
defaultValue: number, // Should NOT be a dynamic number
|
||||
setInputValue: React.Dispatch<React.SetStateAction<number>>,
|
||||
min: number,
|
||||
max: number,
|
||||
},
|
||||
loadingBar?: {
|
||||
id: string,
|
||||
widthPercent: number,
|
||||
description: string,
|
||||
defaultOn: boolean,
|
||||
},
|
||||
checkBox?: {
|
||||
setCheckValue: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
defaultValue: boolean,
|
||||
description: string,
|
||||
}
|
||||
customOnClick?: Function
|
||||
}
|
||||
|
||||
function shouldDisableButton(
|
||||
connectionReady: boolean,
|
||||
sendingWallet: Wallet | undefined,
|
||||
waitingForTransaction: boolean,
|
||||
loadingBar?: {
|
||||
widthPercent: number
|
||||
}
|
||||
): boolean {
|
||||
return !canSendTransaction(connectionReady, sendingWallet?.address)
|
||||
|| waitingForTransaction
|
||||
|| (!!(loadingBar?.widthPercent) && loadingBar.widthPercent < 100)
|
||||
}
|
||||
|
||||
export function TransactionButton({
|
||||
id,
|
||||
submitConstData,
|
||||
connectionReady,
|
||||
transaction,
|
||||
sendingWallet,
|
||||
content,
|
||||
inputSettings,
|
||||
loadingBar,
|
||||
checkBox,
|
||||
customOnClick
|
||||
}: TransactionButtonProps ) {
|
||||
const { translate } = useTranslate()
|
||||
|
||||
const [waitingForTransaction, setWaitingForTransaction] = useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="form-group" id={id}>
|
||||
|
||||
{/* Optional loading bar - Used for Partial Payments setup and EscrowFinish wait time */}
|
||||
{loadingBar?.id && <div className="progress mb-1" id={loadingBar?.id ?? ""}>
|
||||
<div className={
|
||||
clsx("progress-bar progress-bar-striped w-0",
|
||||
(loadingBar?.widthPercent < 100 && loadingBar?.widthPercent > 0) && "progress-bar-animated")}
|
||||
style={{width: (Math.min(loadingBar?.widthPercent + (loadingBar?.defaultOn ? 1 : 0), 100)).toString() + "%",
|
||||
display: (loadingBar?.widthPercent >= 100) ? 'none' : ''}}>
|
||||
|
||||
</div>
|
||||
{(loadingBar?.widthPercent < 100 && loadingBar?.widthPercent > 0 || (loadingBar.defaultOn && loadingBar?.widthPercent === 0))
|
||||
&& <small className="justify-content-center d-flex position-absolute w-100">
|
||||
{translate(loadingBar?.description)}
|
||||
</small>}
|
||||
</div>}
|
||||
|
||||
<div className="input-group mb-3">
|
||||
|
||||
{/* Loading icon for when transaction is being submitted */}
|
||||
<div className="input-group-prepend">
|
||||
<span className="input-group-text loader" style={{display: waitingForTransaction ? '' : 'none'}}>
|
||||
<img className="throbber" src="/img/xrp-loader-96.png" alt={translate("(loading)")} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button className={clsx("btn btn-primary form-control needs-connection",
|
||||
(shouldDisableButton(connectionReady, sendingWallet, waitingForTransaction, loadingBar) && "disabled"))}
|
||||
type="button" id={id + "_btn"}
|
||||
disabled={shouldDisableButton(connectionReady, sendingWallet, waitingForTransaction, loadingBar)}
|
||||
onClick={async () => {
|
||||
setWaitingForTransaction(true)
|
||||
customOnClick ? await customOnClick() : await submitAndUpdateUI(submitConstData, sendingWallet!, transaction)
|
||||
setWaitingForTransaction(false)
|
||||
}}
|
||||
title={(loadingBar && (loadingBar.widthPercent > 0 && loadingBar.widthPercent < 100)) ? translate(content.buttonTitle) : ""}
|
||||
>
|
||||
{translate(content.buttonText)}
|
||||
</button>
|
||||
|
||||
{inputSettings &&
|
||||
<input id={id + "_amount"} className="form-control" type="number"
|
||||
aria-describedby={id + "amount_help"}
|
||||
defaultValue={inputSettings?.defaultValue}
|
||||
min={inputSettings?.min}
|
||||
max={inputSettings?.max}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// Enforce min / max values
|
||||
let { value, min, max } = event.target;
|
||||
const newValue = Math.max(Number(min), Math.min(Number(max), Number(value)));
|
||||
// Share the value so other logic can update based on it
|
||||
inputSettings?.setInputValue(newValue)
|
||||
}
|
||||
} />}
|
||||
|
||||
{inputSettings && <div className="input-group-append">
|
||||
<span className="input-group-text" id={id + "_help"}>
|
||||
{translate(content.units)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
{/* Used for Escrow */}
|
||||
{checkBox && <span className="input-group-text">
|
||||
(
|
||||
<input type="checkbox"
|
||||
id={id + "_checkbox"}
|
||||
defaultValue={checkBox.defaultValue ? 1 : 0}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => checkBox.setCheckValue(event.target.checked)} />
|
||||
<label className="form-check-label" htmlFor={id + "_checkbox"}>
|
||||
{translate(checkBox.description)}
|
||||
</label>)
|
||||
</span>}
|
||||
</div>
|
||||
|
||||
<small className="form-text text-muted">
|
||||
{content.longerDescription}
|
||||
</small>
|
||||
|
||||
</div>
|
||||
<hr />
|
||||
</div>)
|
||||
}
|
||||
433
content/dev-tools/tx-sender.page.tsx
Normal file
433
content/dev-tools/tx-sender.page.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react'
|
||||
import { useTranslate } from '@portal/hooks';
|
||||
|
||||
import AlertTemplate from './components/AlertTemplate';
|
||||
import { transitions, positions, Provider as AlertProvider } from 'react-alert'
|
||||
import { useAlert } from 'react-alert'
|
||||
|
||||
import { isoTimeToRippleTime, type Client, type Wallet } from 'xrpl'
|
||||
|
||||
import { errorNotif, SubmitConstData, timeout, submitAndUpdateUI } from './utils';
|
||||
|
||||
import { InitButton } from './components/InitButton';
|
||||
import { DestinationAddressInput } from './components/DestinationAddressInput';
|
||||
import { StatusSidebar } from './components/StatusSidebar';
|
||||
import { TransactionButton } from './components/TransactionButton';
|
||||
|
||||
async function onClickCreateEscrow(
|
||||
submitConstData: SubmitConstData,
|
||||
sendingWallet: Wallet | undefined,
|
||||
destinationAddress: string,
|
||||
durationSeconds: number,
|
||||
setEscrowWidthPercent: React.Dispatch<React.SetStateAction<number>>,
|
||||
alsoSendEscrowFinish: boolean) {
|
||||
if (Number.isNaN(durationSeconds) || durationSeconds < 1) {
|
||||
errorNotif(submitConstData.alert, "Error: Escrow duration must be a positive number of seconds")
|
||||
return
|
||||
}
|
||||
|
||||
// This should never happen
|
||||
if(sendingWallet === undefined) {
|
||||
errorNotif(submitConstData.alert, "Error: No sending wallet specified, so unable to submit EscrowCreate")
|
||||
return
|
||||
}
|
||||
|
||||
const finishAfter = isoTimeToRippleTime(new Date()) + durationSeconds
|
||||
|
||||
const escrowCreateResponse = await submitAndUpdateUI(submitConstData, sendingWallet, {
|
||||
TransactionType: "EscrowCreate",
|
||||
Account: sendingWallet.address,
|
||||
Destination: destinationAddress,
|
||||
Amount: "1000000",
|
||||
FinishAfter: finishAfter
|
||||
})
|
||||
|
||||
if (escrowCreateResponse && alsoSendEscrowFinish) {
|
||||
// Wait until there's a ledger with a close time > FinishAfter
|
||||
// to submit the EscrowFinish
|
||||
setEscrowWidthPercent(1)
|
||||
|
||||
const { client } = submitConstData
|
||||
|
||||
let latestCloseTime = -1
|
||||
while (latestCloseTime <= finishAfter) {
|
||||
const secondsLeft = (finishAfter - isoTimeToRippleTime(new Date()))
|
||||
|
||||
setEscrowWidthPercent(Math.min(99, Math.max(0, (1-(secondsLeft / durationSeconds)) * 100)))
|
||||
|
||||
if (secondsLeft <= 0) {
|
||||
// System time has advanced past FinishAfter. But is there a new
|
||||
// enough validated ledger?
|
||||
latestCloseTime = (await client.request({
|
||||
command: "ledger",
|
||||
"ledger_index": "validated"}
|
||||
)).result.ledger.close_time
|
||||
}
|
||||
// Update the progress bar & check again in 1 second.
|
||||
await timeout(1000)
|
||||
}
|
||||
setEscrowWidthPercent(0)
|
||||
|
||||
if(escrowCreateResponse.result.Sequence === undefined) {
|
||||
|
||||
errorNotif(submitConstData.alert,
|
||||
"Error: Unable to get the sequence number from EscrowCreate, so cannot submit an EscrowFinish transaction.")
|
||||
|
||||
console.error(`EscrowCreate did not return a sequence number.
|
||||
This may be because we were unable to look up the transaction in a validated ledger.
|
||||
The EscrowCreate response was ${escrowCreateResponse}`)
|
||||
|
||||
} else {
|
||||
|
||||
// Now submit the EscrowFinish
|
||||
// Future feature: submit from a different sender, just to prove that
|
||||
// escrows can be finished by a third party
|
||||
await submitAndUpdateUI(submitConstData, sendingWallet, {
|
||||
Account: sendingWallet.address,
|
||||
TransactionType: "EscrowFinish",
|
||||
Owner: sendingWallet.address,
|
||||
OfferSequence: escrowCreateResponse.result.Sequence
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Reset in case they click the button again
|
||||
setEscrowWidthPercent(0)
|
||||
}
|
||||
|
||||
function TxSenderBody(): React.JSX.Element {
|
||||
const { translate } = useTranslate();
|
||||
|
||||
const [client, setClient] = useState<Client | undefined>(undefined)
|
||||
|
||||
const alert = useAlert()
|
||||
|
||||
// Sidebar variables
|
||||
const [balance, setBalance] = useState(0)
|
||||
const [sendingWallet, setSendingWallet] = useState<Wallet | undefined>(undefined)
|
||||
const [connectionReady, setConnectionReady] = useState(false)
|
||||
const [txHistory, setTxHistory] = useState([])
|
||||
|
||||
// Used when submitting transactions to trace all transactions in the UI
|
||||
// We cast here since client may be undefined to begin with, but will never be undefined
|
||||
// When actually used since all buttons / transactions are disallowed before the Inititalization
|
||||
// function where Client is defined. (This saves us many unnecessary type assertions later on)
|
||||
const submitConstData = {
|
||||
client,
|
||||
setBalance,
|
||||
setTxHistory,
|
||||
alert,
|
||||
} as SubmitConstData
|
||||
|
||||
// Manage the destinationAddress
|
||||
const defaultDestinationAddress = "rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe"
|
||||
const [destinationAddress, setDestinationAddress] = useState(defaultDestinationAddress)
|
||||
|
||||
const [isInitEnabled, setIsInitEnabled] = useState(true)
|
||||
|
||||
// Partial Payment variables
|
||||
const [ppWidthPercent, setPpWidthPercent] = useState(0)
|
||||
const [ppIssuerWallet, setPpIssuerWallet] = useState<Wallet | undefined>(undefined)
|
||||
const ppCurrencyCode = "BAR"
|
||||
const partialPaymentParams = {
|
||||
setPpIssuerWallet,
|
||||
setPpWidthPercent,
|
||||
ppCurrencyCode,
|
||||
}
|
||||
|
||||
// Payment button variables
|
||||
const defaultDropsToSend = 100000
|
||||
const [dropsToSendForPayment, setDropsToSendForPayment] = useState(defaultDropsToSend)
|
||||
|
||||
// Escrow variables
|
||||
const defaultFinishAfter = 60
|
||||
const [finishAfter, setFinishAfter] = useState(defaultFinishAfter)
|
||||
const [finishEscrowAutomatically, setFinishEscrowAutomatically] = useState(false)
|
||||
const [escrowWidthPercent, setEscrowWidthPercent] = useState(0)
|
||||
|
||||
// Payment Channel variables
|
||||
const defaultPaymentChannelAmount = 100000
|
||||
const [paymentChannelAmount, setPaymentChannelAmount] = useState(defaultPaymentChannelAmount)
|
||||
|
||||
// Issued Currency / Trust Line Variables
|
||||
const trustCurrencyCode = "FOO"
|
||||
|
||||
const defaultIssueAmount = 100
|
||||
const [issueAmount, setIssueAmount] = useState(defaultIssueAmount)
|
||||
|
||||
const defaultTrustLimit = 100000
|
||||
const [trustLimit, setTrustLimit] = useState(defaultTrustLimit)
|
||||
|
||||
const commonTxButtonParams = {
|
||||
submitConstData,
|
||||
connectionReady,
|
||||
sendingWallet
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<StatusSidebar balance={balance} sendingWallet={sendingWallet} connectionReady={connectionReady} txHistory={txHistory}/>
|
||||
|
||||
<main className="main col-md-7 col-lg-6 order-md-3 page-tx-sender" role="main" id="main_content_body">
|
||||
<section className="container-fluid pt-3 p-md-3">
|
||||
<h1>{translate("Transaction Sender")}</h1>
|
||||
<div className="content">
|
||||
<p>{translate("This tool sends transactions to the ")}
|
||||
<a href="dev-tools/xrp-faucets">{translate("XRP Testnet")}</a>
|
||||
{translate(" address of your choice so you can test how you monitor and respond to incoming transactions.")}
|
||||
</p>
|
||||
<form>
|
||||
<InitButton
|
||||
isInitEnabled={isInitEnabled}
|
||||
toInit={{
|
||||
existingClient: client,
|
||||
alert,
|
||||
setClient,
|
||||
setBalance,
|
||||
setSendingWallet,
|
||||
setIsInitEnabled,
|
||||
setConnectionReady,
|
||||
partialPaymentParams
|
||||
}}/>
|
||||
|
||||
<DestinationAddressInput
|
||||
{...{defaultDestinationAddress,
|
||||
destinationAddress,
|
||||
setDestinationAddress,
|
||||
}}/>
|
||||
|
||||
<h3>{translate("Send Transaction")}</h3>
|
||||
|
||||
{/* Send Payment */}
|
||||
<TransactionButton
|
||||
id="send_xrp_payment"
|
||||
{...commonTxButtonParams}
|
||||
transaction={
|
||||
{
|
||||
TransactionType: "Payment",
|
||||
// @ts-expect-error - sendingWallet is guaranteed to be defined by the time this button is clicked.
|
||||
Account: sendingWallet?.address,
|
||||
Destination: destinationAddress,
|
||||
Amount: dropsToSendForPayment.toString()
|
||||
}}
|
||||
content=
|
||||
{{
|
||||
buttonText: "Send XRP Payment",
|
||||
units: "drops of XRP",
|
||||
longerDescription: (<div>{translate("Send a ")}<a href="send-xrp.html">{translate("simple XRP-to-XRP payment")}</a>{translate(".")}</div>),
|
||||
}}
|
||||
inputSettings={
|
||||
{
|
||||
defaultValue: defaultDropsToSend,
|
||||
setInputValue: setDropsToSendForPayment,
|
||||
min: 1,
|
||||
max: 10000000000,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Partial Payments */}
|
||||
<TransactionButton
|
||||
id="send_partial_payment"
|
||||
{...commonTxButtonParams}
|
||||
transaction={
|
||||
{
|
||||
TransactionType: "Payment",
|
||||
// @ts-expect-error - sendingWallet is guaranteed to be defined by the time this button is clicked.
|
||||
Account: sendingWallet?.address,
|
||||
Destination: destinationAddress,
|
||||
Amount: "1000000000000000", // 1 billion XRP
|
||||
SendMax: {
|
||||
value: (Math.random()*.01).toPrecision(15), // random very small amount
|
||||
currency: ppCurrencyCode,
|
||||
// @ts-expect-error - ppIssuerWallet is guaranteed to be defined by the time this button is clicked.
|
||||
issuer: ppIssuerWallet?.address
|
||||
},
|
||||
Flags: 0x00020000 // tfPartialPayment
|
||||
}}
|
||||
content=
|
||||
{{
|
||||
buttonText: "Send Partial Payment",
|
||||
units: "drops of XRP",
|
||||
longerDescription: <div>{translate("Deliver a small amount of XRP with a large ")}
|
||||
<code>{translate("Amount")}</code>{translate(" value, to test your handling of ")}
|
||||
<a href="partial-payments.html">{translate("partial payments")}</a>{translate(".")}</div>,
|
||||
buttonTitle: "(Please wait for partial payments setup to finish)",
|
||||
}}
|
||||
loadingBar={{
|
||||
id: "pp_progress",
|
||||
widthPercent: ppWidthPercent,
|
||||
description: "(Getting ready to send partial payments)",
|
||||
defaultOn: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Escrow */}
|
||||
<TransactionButton
|
||||
id="create_escrow"
|
||||
{...commonTxButtonParams}
|
||||
transaction={
|
||||
{
|
||||
TransactionType: "EscrowCreate",
|
||||
// @ts-expect-error - sendingWallet is guaranteed to be defined by the time this button is clicked.
|
||||
Account: sendingWallet?.address,
|
||||
Destination: destinationAddress,
|
||||
Amount: "1000000",
|
||||
FinishAfter: isoTimeToRippleTime(new Date()) + finishAfter
|
||||
}}
|
||||
content=
|
||||
{{
|
||||
buttonText: translate("Create Escrow"),
|
||||
units: translate("seconds"),
|
||||
longerDescription: (<div>{translate("Create a ")}<a href="escrow.html">{translate("time-based escrow")}</a>
|
||||
{translate(" of 1 XRP for the specified number of seconds.")}</div>),
|
||||
}}
|
||||
inputSettings={
|
||||
{
|
||||
defaultValue: defaultFinishAfter,
|
||||
setInputValue: setFinishAfter,
|
||||
min: 5,
|
||||
max: 10000,
|
||||
}}
|
||||
loadingBar={{
|
||||
id: "escrow_progress",
|
||||
widthPercent: escrowWidthPercent,
|
||||
description: translate("(Waiting to release Escrow when it's ready)"),
|
||||
defaultOn: false,
|
||||
}}
|
||||
checkBox={{
|
||||
setCheckValue: setFinishEscrowAutomatically,
|
||||
defaultValue: finishEscrowAutomatically,
|
||||
description: translate("Finish automatically"),
|
||||
}}
|
||||
customOnClick={() => onClickCreateEscrow(
|
||||
submitConstData,
|
||||
sendingWallet,
|
||||
destinationAddress,
|
||||
finishAfter,
|
||||
setEscrowWidthPercent,
|
||||
finishEscrowAutomatically)}
|
||||
/>
|
||||
|
||||
{/* Payment Channels
|
||||
|
||||
- Future feature: figure out channel ID and enable a button that creates
|
||||
valid claims for the given payment channel to help test redeeming
|
||||
*/}
|
||||
<TransactionButton
|
||||
id="create_payment_channel"
|
||||
{...commonTxButtonParams}
|
||||
transaction={{
|
||||
TransactionType: "PaymentChannelCreate",
|
||||
// @ts-expect-error - sendingWallet is guaranteed to be defined by the time this button is clicked.
|
||||
Account: sendingWallet?.address,
|
||||
Destination: destinationAddress,
|
||||
Amount: paymentChannelAmount.toString(),
|
||||
SettleDelay: 30,
|
||||
// @ts-expect-error - sendingWallet is guaranteed to be defined by the time this button is clicked.
|
||||
PublicKey: sendingWallet?.publicKey
|
||||
}}
|
||||
content={{
|
||||
buttonText: translate("Create Payment Channel"),
|
||||
units: translate("drops of XRP"),
|
||||
longerDescription: (<div>{translate("Create a ")}<a href="payment-channels.html">{translate("payment channel")}</a>
|
||||
{translate(" and fund it with the specified amount of XRP.")}</div>),
|
||||
}}
|
||||
inputSettings={
|
||||
{
|
||||
defaultValue: defaultPaymentChannelAmount,
|
||||
setInputValue: setPaymentChannelAmount,
|
||||
min: 1,
|
||||
max: 10000000000,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Send Issued Currency */}
|
||||
{/* Future feature: Add ability to configure custom currency codes */}
|
||||
<TransactionButton
|
||||
id="send_issued_currency"
|
||||
{...commonTxButtonParams}
|
||||
transaction={
|
||||
{
|
||||
TransactionType: "Payment",
|
||||
// @ts-expect-error - sendingWallet is guaranteed to be defined by the time this button is clicked.
|
||||
Account: sendingWallet?.address,
|
||||
Destination: destinationAddress,
|
||||
Amount: {
|
||||
currency: trustCurrencyCode,
|
||||
value: issueAmount?.toString(),
|
||||
// @ts-expect-error - sendingWallet is guaranteed to be defined by the time this button is clicked.
|
||||
issuer: sendingWallet?.address
|
||||
}
|
||||
}}
|
||||
content={{
|
||||
buttonText: translate("Send Issued Currency"),
|
||||
units: translate(trustCurrencyCode),
|
||||
longerDescription: (<div>{translate("Your destination address needs a ")}
|
||||
<a href="trust-lines-and-issuing.html">{translate("trust line")}</a>{translate(" to ")}
|
||||
<span className="sending-address-item">{translate("(the test sender)")}</span>
|
||||
{translate(" for the currency in question. Otherwise, you'll get tecPATH_DRY.")}</div>),
|
||||
}}
|
||||
inputSettings={
|
||||
{
|
||||
defaultValue: defaultIssueAmount,
|
||||
setInputValue: setIssueAmount,
|
||||
min: 1,
|
||||
max: 10000000000,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Create Trust Line */}
|
||||
<TransactionButton
|
||||
id="trust_for"
|
||||
{...commonTxButtonParams}
|
||||
transaction={
|
||||
{
|
||||
TransactionType: "TrustSet",
|
||||
// @ts-expect-error - sendingWallet is guaranteed to be defined by the time this button is clicked.
|
||||
Account: sendingWallet?.address,
|
||||
LimitAmount: {
|
||||
currency: trustCurrencyCode,
|
||||
value: trustLimit.toString(),
|
||||
issuer: destinationAddress
|
||||
}
|
||||
}}
|
||||
content={{
|
||||
buttonText: translate("Trust for"),
|
||||
units: translate(trustCurrencyCode),
|
||||
longerDescription: (<div>{translate("The test sender creates a ")}
|
||||
<a href="trust-lines-and-issuing.html">{translate("trust line")}</a>
|
||||
{translate(" to your account for the given currency.")}</div>),
|
||||
}}
|
||||
inputSettings={
|
||||
{
|
||||
defaultValue: defaultTrustLimit,
|
||||
setInputValue: setTrustLimit,
|
||||
min: 1,
|
||||
max: 10000000000,
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Wrapper to allow for dynamic alerts when transactions complete
|
||||
export default function TxSender(): React.JSX.Element {
|
||||
|
||||
const alertOptions = {
|
||||
position: positions.BOTTOM_RIGHT,
|
||||
timeout: 7000,
|
||||
offset: '8px',
|
||||
transition: transitions.FADE
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertProvider template={AlertTemplate} {...alertOptions}>
|
||||
<TxSenderBody/>
|
||||
</AlertProvider>
|
||||
)
|
||||
}
|
||||
100
content/dev-tools/utils.tsx
Normal file
100
content/dev-tools/utils.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import * as React from 'react'
|
||||
import { type Client, type Wallet, type Transaction, type TransactionMetadata, type TxResponse, SubmittableTransaction } from 'xrpl'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
|
||||
export const TESTNET_URL = "wss://s.altnet.rippletest.net:51233"
|
||||
|
||||
export function timeout(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Displaying transaction data
|
||||
export function errorNotif(alert: any, msg: string): void {
|
||||
console.log(msg)
|
||||
alert.error(msg)
|
||||
}
|
||||
|
||||
export function successNotif(alert: any, msg: string): void {
|
||||
console.log(msg)
|
||||
alert.show(msg, { type: 'success' })
|
||||
}
|
||||
|
||||
export function logTx(txName: string, hash: string, finalResult: string, setTxHistory: React.Dispatch<React.SetStateAction<React.JSX.Element[]>>) {
|
||||
let classes
|
||||
let icon
|
||||
const txLink = "https://testnet.xrpl.org/transactions/" + hash
|
||||
if (finalResult === "tesSUCCESS") {
|
||||
classes = "text-muted"
|
||||
icon = <i className="fa fa-check-circle"/>
|
||||
} else {
|
||||
classes = "list-group-item-danger"
|
||||
icon = <i className="fa fa-times-circle"/>
|
||||
}
|
||||
const li = <li key={hash} className={clsx("list-group-item fade-in p-1", classes)}>
|
||||
{icon} {txName}: <a href={txLink} target="_blank" className="external-link">{hash}</a>
|
||||
</li>
|
||||
|
||||
setTxHistory((prevState) => [li].concat(prevState))
|
||||
}
|
||||
|
||||
// All unchanging information needed to submit & log data
|
||||
export interface SubmitConstData {
|
||||
client: Client,
|
||||
setBalance: React.Dispatch<React.SetStateAction<number>>,
|
||||
setTxHistory: React.Dispatch<React.SetStateAction<React.JSX.Element[]>>,
|
||||
alert: any,
|
||||
}
|
||||
|
||||
export async function submitAndUpdateUI(
|
||||
submitConstData: SubmitConstData,
|
||||
sendingWallet: Wallet,
|
||||
tx: SubmittableTransaction,
|
||||
silent: boolean = false): Promise<TxResponse<Transaction> | undefined> {
|
||||
|
||||
const { client, setBalance, setTxHistory } = submitConstData
|
||||
|
||||
let prepared;
|
||||
try {
|
||||
// Auto-fill fields like Fee and Sequence
|
||||
prepared = await client.autofill(tx)
|
||||
console.debug("Prepared:", prepared)
|
||||
} catch(error) {
|
||||
console.log(error)
|
||||
if (!silent) {
|
||||
errorNotif(alert, "Error preparing tx: "+error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const {tx_blob, hash} = sendingWallet.sign(prepared)
|
||||
const result = await client.submitAndWait(tx_blob)
|
||||
console.log("The result of submitAndWait is ", result)
|
||||
let finalResult = (result.result.meta as TransactionMetadata).TransactionResult
|
||||
if (!silent) {
|
||||
if (finalResult === "tesSUCCESS") {
|
||||
successNotif(submitConstData.alert, `${tx.TransactionType} tx succeeded (hash: ${hash})`)
|
||||
} else {
|
||||
errorNotif(submitConstData.alert, `${tx.TransactionType} tx failed with code ${finalResult}
|
||||
(hash: ${hash})`)
|
||||
}
|
||||
logTx(tx.TransactionType, hash, finalResult, setTxHistory)
|
||||
}
|
||||
|
||||
setBalance(await client.getXrpBalance(sendingWallet.address))
|
||||
return result
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
if (!silent) {
|
||||
errorNotif(submitConstData.alert, `Error signing & submitting ${tx.TransactionType} tx: ${error}`)
|
||||
}
|
||||
|
||||
setBalance(await client.getXrpBalance(sendingWallet.address))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function canSendTransaction(connectionReady: boolean, sendingAddress: string | undefined): boolean {
|
||||
return connectionReady && !!sendingAddress
|
||||
}
|
||||
@@ -670,6 +670,8 @@
|
||||
href: /dev-tools/xrp-faucets
|
||||
page: /dev-tools/xrp-faucets.page.tsx
|
||||
- label: Transaction Sender
|
||||
href: /dev-tools/tx-sender
|
||||
page: /dev-tools/tx-sender.page.tsx
|
||||
- label: XRPL Learning Portal
|
||||
href: https://learn.xrpl.org/
|
||||
external: true
|
||||
|
||||
@@ -22,6 +22,11 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Replaced by react-alert after the Redocly migration.
|
||||
* Please see tx-sender.page.tsx for an example of react-alerts in action.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
var $;
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ const mainBlock = content.substring(mainBlockOffset + 16, mainBlockEndOffset);
|
||||
const classes = content.match(/{% block mainclasses %}(.+?){% endblock %}/)?.[1] || '';
|
||||
|
||||
const setStatements = mainBlock.match(/{% set ([\w\d]+) = ((.|\n)+?)%}/g);
|
||||
const sets = setStatements.map(setStatement => {
|
||||
const sets = setStatements?.map(setStatement => {
|
||||
const setStatementParts = setStatement.split(' = ');
|
||||
const setStatementName = setStatementParts[0].replace('{% set ', '');
|
||||
const setStatementValue = setStatementParts[1].replace(/%}/g, '');
|
||||
@@ -74,7 +74,7 @@ const jsxWithReplacedTranslate = jsxWithReplacedForLoops.replace(
|
||||
const output = `import * as React from 'react';
|
||||
import { useTranslate } from '@portal/hooks';
|
||||
|
||||
${sets.map(set => `const ${set.name} = ${set.value};`).join('\n\n')}
|
||||
${sets?.map(set => `const ${set.name} = ${set.value};`).join('\n\n')}
|
||||
|
||||
const target= {prefix: ''}; // TODO: fixme
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redocly/portal": "^0.66.0",
|
||||
"clsx": "^2.0.0",
|
||||
"lottie-react": "^2.4.0",
|
||||
"moment": "^2.29.4",
|
||||
"react-alert": "^7.0.3",
|
||||
"xrpl": "^3.0.0-beta.1"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
border: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
#pp_progress small {
|
||||
.progress small {
|
||||
margin-top: .5rem; // Fix "Getting ready to send..." position
|
||||
}
|
||||
|
||||
|
||||
687
styles/yarn.lock
687
styles/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user