From a16a42543d6029ad6b2793459a15ea49cd781ffc Mon Sep 17 00:00:00 2001 From: norwnd Date: Tue, 26 Nov 2024 13:30:54 +0200 Subject: [PATCH] ui: markets page rework (mainly order-form) --- client/webserver/jsintl.go | 6 + client/webserver/locales/en-us.go | 6 +- client/webserver/site/src/css/market.scss | 10 +- client/webserver/site/src/html/markets.tmpl | 273 +-- client/webserver/site/src/js/app.ts | 30 + client/webserver/site/src/js/charts.ts | 16 - client/webserver/site/src/js/doc.ts | 31 +- client/webserver/site/src/js/locales.ts | 4 +- client/webserver/site/src/js/markets.ts | 1795 +++++++++++-------- client/webserver/site/src/js/registry.ts | 6 +- 10 files changed, 1263 insertions(+), 914 deletions(-) diff --git a/client/webserver/jsintl.go b/client/webserver/jsintl.go index 072b03393e..cb46a6648c 100644 --- a/client/webserver/jsintl.go +++ b/client/webserver/jsintl.go @@ -3,6 +3,9 @@ package webserver import "decred.org/dcrdex/client/intl" const ( + limitOrderTotalPreview = "LIMIT_ORDER_TOTAL_PREVIEW" + marketOrderTotalPreview = "MARKET_ORDER_TOTAL_PREVIEW" + noQuantityExceedsMax = "NO_QUANTITY_EXCEEDS_MAX" noPassErrMsgID = "NO_PASS_ERROR_MSG" noAppPassErrMsgID = "NO_APP_PASS_ERROR_MSG" setButtonBuyID = "SET_BUTTON_BUY" @@ -222,6 +225,9 @@ const ( ) var enUS = map[string]*intl.Translation{ + limitOrderTotalPreview: {T: "Total: {{ total }} {{ asset }}"}, + marketOrderTotalPreview: {T: "Total: ~ {{ total }} {{ asset }}"}, + noQuantityExceedsMax: {T: "not enough funds"}, noPassErrMsgID: {T: "password cannot be empty"}, noAppPassErrMsgID: {T: "app password cannot be empty"}, passwordNotMatchID: {T: "passwords do not match"}, diff --git a/client/webserver/locales/en-us.go b/client/webserver/locales/en-us.go index c5909fd41c..c7fd8e1757 100644 --- a/client/webserver/locales/en-us.go +++ b/client/webserver/locales/en-us.go @@ -628,8 +628,10 @@ var EnUS = map[string]*intl.Translation{ "Round_trip fees": {T: "Round-trip fees"}, "feegap_tooltip": {T: "The rate adjustment necessary to account for on-chain tx fees"}, "remotegap_tooltip": {T: "The buy-sell spread on the linked cex market"}, - "max_zero_no_fees": {T: ` balance < min fees ~`}, - "max_zero_no_bal": {T: `low balance`}, + "max_zero_no_fees_buy": {T: ` balance < min fees ~`}, + "max_zero_no_fees_sell": {T: ` balance < min fees ~`}, + "max_zero_no_bal_buy": {T: `low balance`}, + "max_zero_no_bal_sell": {T: `low balance`}, "Wallet Options": {T: "Wallet Options"}, "balance_diff": {T: "Balance Diff"}, "usd_diff": {T: "USD Diff"}, diff --git a/client/webserver/site/src/css/market.scss b/client/webserver/site/src/css/market.scss index a0c823a3fe..881c147f3a 100644 --- a/client/webserver/site/src/css/market.scss +++ b/client/webserver/site/src/css/market.scss @@ -36,17 +36,14 @@ div[data-handler=markets] { .order-panel { min-width: 375px; - #orderForm { + #orderFormBuy, + #orderFormSell { input[type=number] { height: 30px; border-radius: 0; font-size: 14px; } - input:focus { - outline: none; - } - span.unitbox { position: absolute; font-size: 14px; @@ -89,7 +86,8 @@ div[data-handler=markets] { } } - #orderPreview, + #orderTotalPreviewBuy, + #orderTotalPreviewSell, .h21 { height: 21px; } diff --git a/client/webserver/site/src/html/markets.tmpl b/client/webserver/site/src/html/markets.tmpl index 4f23a05e34..8d8c59af10 100644 --- a/client/webserver/site/src/html/markets.tmpl +++ b/client/webserver/site/src/html/markets.tmpl @@ -189,7 +189,7 @@ {{- /* ORDER TYPE BUTTONS */ -}} -
+
@@ -248,167 +248,170 @@ [[[cannot_manually_trade]]]
- {{- /* ORDER FORM */ -}} -
+ {{- /* BUY ORDER FORM */ -}} +
- - {{- /* BUY - SELL SELECTOR */ -}} -
- - - -
- - {{- /* MARKET CONFIG */ -}} -
-
- [[[:title:lot_size]]]: - - -
-
- [[[Rate Step]]]: - - +
+
+
+ [[[:title:lot_size]]]: + + +
+
+ [[[Rate Step]]]: + + +
-
-
-
- - [[[Max]]] [[[Buy]]]: - - [[[lot]]] +
+ + [[[Max]]] [[[Buy]]]: + + [[[lot]]] - - , - - - + + = + + + + + - - , [[[max_zero_no_fees]]] + + , [[[max_zero_no_fees_buy]]] - - , [[[max_zero_no_bal]]] + + , [[[max_zero_no_bal_buy]]]
-
- - {{- /* RATE AND QUANTITY INPUTS */ -}} -
- -
- - / + {{- /* RATE AND QUANTITY INPUTS */ -}} +
+ +
+ + / +
-
-
- -
- - [[[:title:lots]]] +
+ +
+ + [[[:title:lots]]] +
+
{{/* spacer */}} +
+ + +
-
{{/* spacer */}} -
- - + {{- /* ORDER PREVIEW */ -}} +
+ {{- /* TIME-IN-FORCE CHECK BOX */ -}} +
+ +
- {{- /* MARKET BUY ORDER QUANTITY INPUT */ -}} -
-
- [[[min trade is about]]] -
-
- -
- - + {{- /* SUBMIT ORDER BUTTON */ -}} +
+ {{/* textContent set by script */}} +
+ +
+
+
+ + {{- /* SELL ORDER FORM */ -}} +
+
+
+
+
+ [[[:title:lot_size]]]: + + +
+
+ [[[Rate Step]]]: + +
-
- - ~ 0 - @ 0 [[[:title:lots]]]
- +
+ + [[[Max]]] [[[Sell]]]: + + [[[lot]]] + + + = + + + + + + + , [[[max_zero_no_fees_sell]]] + + + , [[[max_zero_no_bal_sell]]]
-
- - {{- /* ORDER PREVIEW */ -}} -
- - {{- /* TIME-IN-FORCE CHECK BOX */ -}} -
- - + {{- /* RATE AND QUANTITY INPUTS */ -}} +
+ +
+ + / +
+
+
+ +
+ + [[[:title:lots]]] +
+
{{/* spacer */}} +
+ + +
+
+ {{- /* ORDER PREVIEW */ -}} +
+ {{- /* TIME-IN-FORCE CHECK BOX */ -}} +
+ + +
{{- /* SUBMIT ORDER BUTTON */ -}}
- {{/* textContent set by script */}} + {{/* textContent set by script */}}
- {{- /* ORDER LIMITS */ -}} -
- [[[order_form_remaining_limit]]] -
- -
-
+
- {{- /* BALANCES */ -}} + {{- /* WALLET RELATED ERRORS */ -}}
-
-
- - -
-
{{template "walletIcons"}}
-
- -
-
-
- asset not supported -
-
-
-
-
- -
-
-
-
-
-
- -
-
-
-
- -
-
+ {{- /* ORDER LIMITS */ -}} +
+ [[[order_form_remaining_limit]]] +
{{- /* REPUTATION */ -}}
diff --git a/client/webserver/site/src/js/app.ts b/client/webserver/site/src/js/app.ts index 2375f88c09..b0c4ab4f63 100644 --- a/client/webserver/site/src/js/app.ts +++ b/client/webserver/site/src/js/app.ts @@ -248,6 +248,36 @@ export default class Application { */ async start () { // Handle back navigation from the browser. + bind(window, 'popstate', (e: PopStateEvent) => { + const page = e.state?.page + if (!page && page !== '') return + this.loadPage(page, e.state.data, true) + }) + // disable mouse-wheel based events for number input forms because it's an + // undesirable foot gun, + // setting "passive" to "true" (in "bind" below) seemingly results into smoother + // page-scrolling, see this for details: + // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#using_passive_listeners + const ignoreWheelOnNumberInputs = () => { + if (!document.activeElement) { + return + } + if (document.activeElement instanceof HTMLElement) { + if (document.activeElement.nodeName.toLowerCase() === 'input') { + // blurring this element is a hacky work-around that allows us to get rid of + // default behavior of "number input" field which treats mouse-scrolling as + // number increment/decrement, this solution isn't perfect because it results + // in focus loss (for the input element involved), a proper solution would be + // to implement some mechanism to re-focus input element involved once we are + // done scrolling - perhaps we might consider trying it out in the future + document.activeElement.blur() + } + } + } + bind(document, 'wheel', ignoreWheelOnNumberInputs, { passive: true }) + bind(document, 'mousewheel', ignoreWheelOnNumberInputs, { passive: true }) + bind(document, 'DOMMouseScroll', ignoreWheelOnNumberInputs, { passive: true }) + bind(window, 'popstate', (e: PopStateEvent) => { const page = e.state?.page if (!page && page !== '') return diff --git a/client/webserver/site/src/js/charts.ts b/client/webserver/site/src/js/charts.ts index a41b9a7fba..1d6b7a4e12 100644 --- a/client/webserver/site/src/js/charts.ts +++ b/client/webserver/site/src/js/charts.ts @@ -49,13 +49,6 @@ export interface VolumeReport { sellQuote: number } -export interface DepthReporters { - mouse: (r: MouseReport | null) => void - click: (x: number) => void - volume: (r: VolumeReport) => void - zoom: (z: number) => void -} - export interface CandleReporters { mouse: (r: Candle | null) => void } @@ -66,15 +59,6 @@ export interface ChartReporters { zoom: (bigger: boolean) => void } -export interface DepthLine { - rate: number - color: string -} - -export interface DepthMarker { - rate: number - active: boolean -} interface Theme { body: string axisLabel: string diff --git a/client/webserver/site/src/js/doc.ts b/client/webserver/site/src/js/doc.ts index 4934e526ca..22bfab8466 100644 --- a/client/webserver/site/src/js/doc.ts +++ b/client/webserver/site/src/js/doc.ts @@ -256,6 +256,22 @@ export default class Doc { }, timeout) } + /* + * hidePreservingLayout hides the specified elements without affecting page layout + * (like Doc.hide can). Use Doc.showPreservingLayout to undo. + */ + static hidePreservingLayout (...els: HTMLElement[]) { + for (const el of els) el.style.visibility = 'hidden' + } + + /* + * showPreservingLayout shows the specified elements, undoing changes by made with + * Doc.hidePreservingLayout. + */ + static showPreservingLayout (...els: HTMLElement[]) { + for (const el of els) el.style.visibility = 'visible' + } + /* * show or hide the specified elements, based on value of the truthiness of * vis. @@ -609,19 +625,6 @@ export default class Doc { return result || '0 s' } - /* - * disableMouseWheel can be used to disable the mouse wheel for any - * input. It is very easy to unknowingly scroll up on a number input - * and then submit an unexpected value. This function prevents the - * scroll increment/decrement behavior for a wheel action on a - * number input. - */ - static disableMouseWheel (...inputFields: Element[]) { - for (const inputField of inputFields) { - Doc.bind(inputField, 'wheel', () => { /* pass */ }, { passive: true }) - } - } - // showFormError can be used to set and display error message on forms. static showFormError (el: PageElement, msg: any) { el.textContent = msg @@ -671,7 +674,7 @@ export class Animation { now = new Date().getTime() } f(1) - this.runCompletionFunction() + return this.runCompletionFunction() } /* wait returns a promise that will resolve when the animation completes. */ diff --git a/client/webserver/site/src/js/locales.ts b/client/webserver/site/src/js/locales.ts index ff7b927234..77b6901b7f 100644 --- a/client/webserver/site/src/js/locales.ts +++ b/client/webserver/site/src/js/locales.ts @@ -3,6 +3,9 @@ import { postJSON } from './http' type Locale = Record +export const ID_LIMIT_ORDER_TOTAL_PREVIEW = 'LIMIT_ORDER_TOTAL_PREVIEW' +export const ID_MARKET_ORDER_TOTAL_PREVIEW = 'MARKET_ORDER_TOTAL_PREVIEW' +export const ID_NO_QUANTITY_EXCEEDS_MAX = 'NO_QUANTITY_EXCEEDS_MAX' export const ID_NO_PASS_ERROR_MSG = 'NO_PASS_ERROR_MSG' export const ID_NO_APP_PASS_ERROR_MSG = 'NO_APP_PASS_ERROR_MSG' export const ID_SET_BUTTON_BUY = 'SET_BUTTON_BUY' @@ -70,7 +73,6 @@ export const ID_LOCKED = 'LOCKED' export const ID_IMMATURE = 'IMMATURE' export const ID_FEE_BALANCE = 'FEE_BALANCE' export const ID_CANDLES_LOADING = 'CANDLES_LOADING' -export const ID_DEPTH_LOADING = 'DEPTH_LOADING' export const ID_INVALID_ADDRESS_MSG = 'INVALID_ADDRESS_MSG' export const ID_TXFEE_UNSUPPORTED = 'TXFEE_UNSUPPORTED' export const ID_TXFEE_ERR_MSG = 'TXFEE_ERR_MSG' diff --git a/client/webserver/site/src/js/markets.ts b/client/webserver/site/src/js/markets.ts index 10eaa16078..02372fafb2 100644 --- a/client/webserver/site/src/js/markets.ts +++ b/client/webserver/site/src/js/markets.ts @@ -1,11 +1,10 @@ -import Doc, { WalletIcons } from './doc' +import Doc, { Animation } from './doc' import State from './state' import BasePage from './basepage' import OrderBook from './orderbook' import { ReputationMeter, tradingLimits, strongTier } from './account' import { CandleChart, - DepthLine, CandleReporters, Wave } from './charts' @@ -36,16 +35,12 @@ import { CandlesPayload, TradeForm, BookUpdate, - MaxSell, - MaxBuy, SwapEstimate, MarketOrderBook, APIResponse, PreSwap, PreRedeem, WalletStateNote, - WalletSyncNote, - WalletCreationNote, SpotPriceNote, BondNote, OrderNote, @@ -80,7 +75,7 @@ const candleUpdateRoute = 'candle_update' const unmarketRoute = 'unmarket' const epochMatchSummaryRoute = 'epoch_match_summary' -const anHour = 60 * 60 * 1000 // milliseconds +const animationLength = 500 const maxUserOrdersShown = 10 const buyBtnClass = 'buygreen-bg' @@ -94,8 +89,6 @@ const percentFormatter = new Intl.NumberFormat(Doc.languages(), { maximumFractionDigits: 2 }) -const parentIDNone = 0xFFFFFFFF - interface MetaOrder { div: HTMLElement header: Record @@ -117,10 +110,16 @@ interface CurrentMarket { quote: SupportedAsset baseUnitInfo: UnitInfo quoteUnitInfo: UnitInfo - maxSellRequested: boolean + // maxSell is a cached max order estimate, unlike with buy-orders, for sell-orders + // max doesn't depend on chosen rate maxSell: MaxOrderEstimate | null + // sellBalance helps to track when maxSell cache needs to be flushed, for example + // when wallet balance updates sellBalance: number + // buyBalance helps to track when maxBuys cache needs to be flushed, for example + // when wallet balance updates buyBalance: number + // maxBuys is cached max order estimates, these depend on user-chosen rate maxBuys: Record candleCaches: Record baseCfg: Asset @@ -152,24 +151,29 @@ interface MarketsPageParams { export default class MarketsPage extends BasePage { page: Record main: HTMLElement - maxLoaded: (() => void) | null - maxOrderUpdateCounter: number + // maxLoaded gets called when max buy estimate has been loaded + maxLoadedBuy: (() => void) | null + // maxLoaded gets called when max sell estimate has been loaded + maxLoadedSell: (() => void) | null + // maxBuyLastReqID helps us track the IDs of /maxbuy requests issued, it's + // hard to prevent our app (and the user) from sending multiple of these + // requests in parallel, so instead we keep track of all requests we've issued + // and make use of the result from latest one. + maxBuyLastReqID: number + // maxSellLastReqID same as maxBuyLastReqID but for /maxsell requests. + maxSellLastReqID: number + verifiedOrder: TradeForm market: CurrentMarket openAsset: SupportedAsset currentCreate: SupportedAsset - maxEstimateTimer: number | null book: OrderBook cancelData: CancelData metaOrders: Record preorderCache: Record - currentOrder: TradeForm - depthLines: Record - activeMarkerRate: number | null hovers: HTMLElement[] ogTitle: string candleChart: CandleChart candleDur: string - balanceWgt: BalanceWidget mm: RunningMarketMakerDisplay marketList: MarketList newWalletForm: NewWalletForm @@ -185,6 +189,7 @@ export default class MarketsPage extends BasePage { recentMatchesSortDirection: 1 | -1 stats: [StatsDisplay, StatsDisplay] loadingAnimations: { candles?: Wave } + runningErrAnimations: Animation[] mmRunning: boolean | undefined forms: Forms constructor (main: HTMLElement, pageParams: MarketsPageParams) { @@ -193,22 +198,18 @@ export default class MarketsPage extends BasePage { const page = this.page = Doc.idDescendants(main) this.main = main if (!this.main.parentElement) return // Not gonna happen, but TypeScript cares. - // There may be multiple pending updates to the max order. This makes sure - // that the screen is updated with the most recent one. - this.maxOrderUpdateCounter = 0 + this.maxBuyLastReqID = 0 + this.maxSellLastReqID = 0 this.metaOrders = {} this.recentMatches = [] this.preorderCache = {} - this.depthLines = { - hover: [], - input: [] - } this.hovers = [] // 'Recent Matches' list sort key and direction. this.recentMatchesSortKey = 'age' this.recentMatchesSortDirection = -1 // store original title so we can re-append it when updating market value. this.ogTitle = document.title + this.runningErrAnimations = [] this.forms = new Forms(page.forms, { closed: (closedForm: PageElement | undefined) => { if (closedForm === page.vDetailPane) { @@ -238,34 +239,6 @@ export default class MarketsPage extends BasePage { app().loadPage('register', { host: this.market.dex.host }) }) - // Set up the BalanceWidget. - { - page.walletInfoTmpl.removeAttribute('id') - const bWidget = page.walletInfoTmpl - const qWidget = page.walletInfoTmpl.cloneNode(true) as PageElement - bWidget.after(qWidget) - const wgt = this.balanceWgt = new BalanceWidget(bWidget, qWidget) - const baseIcons = wgt.base.stateIcons.icons - const quoteIcons = wgt.quote.stateIcons.icons - bind(wgt.base.tmpl.connect, 'click', () => { this.unlockWallet(this.market.base.id) }) - bind(wgt.quote.tmpl.connect, 'click', () => { this.unlockWallet(this.market.quote.id) }) - bind(wgt.base.tmpl.expired, 'click', () => { this.unlockWallet(this.market.base.id) }) - bind(wgt.quote.tmpl.expired, 'click', () => { this.unlockWallet(this.market.quote.id) }) - bind(baseIcons.sleeping, 'click', () => { this.unlockWallet(this.market.base.id) }) - bind(quoteIcons.sleeping, 'click', () => { this.unlockWallet(this.market.quote.id) }) - bind(baseIcons.locked, 'click', () => { this.unlockWallet(this.market.base.id) }) - bind(quoteIcons.locked, 'click', () => { this.unlockWallet(this.market.quote.id) }) - bind(baseIcons.disabled, 'click', () => { this.showToggleWalletStatus(this.market.base) }) - bind(quoteIcons.disabled, 'click', () => { this.showToggleWalletStatus(this.market.quote) }) - bind(wgt.base.tmpl.newWalletBttn, 'click', () => { this.showCreate(this.market.base) }) - bind(wgt.quote.tmpl.newWalletBttn, 'click', () => { this.showCreate(this.market.quote) }) - bind(wgt.base.tmpl.walletAddr, 'click', () => { this.showDeposit(this.market.base.id) }) - bind(wgt.quote.tmpl.walletAddr, 'click', () => { this.showDeposit(this.market.quote.id) }) - bind(wgt.base.tmpl.wantProviders, 'click', () => { this.showCustomProviderDialog(this.market.base.id) }) - bind(wgt.quote.tmpl.wantProviders, 'click', () => { this.showCustomProviderDialog(this.market.quote.id) }) - this.depositAddrForm = new DepositAddress(page.deposit) - } - const runningMMDisplayElements: RunningMMDisplayElements = { orderReportForm: page.orderReportForm, dexBalancesRowTmpl: page.dexBalancesRowTmpl, @@ -306,34 +279,17 @@ export default class MarketsPage extends BasePage { bind(page.showTradingReputation, 'click', () => { toggleTradingReputation(true) }) bind(page.hideTradingReputation, 'click', () => { toggleTradingReputation(false) }) - // Buttons to set order type and side. - bind(page.buyBttn, 'click', () => { this.setBuy() }) - bind(page.sellBttn, 'click', () => { this.setSell() }) - - bind(page.limitBttn, 'click', () => { - swapBttns(page.marketBttn, page.limitBttn) - this.setOrderVisibility() - }) - bind(page.marketBttn, 'click', () => { - swapBttns(page.limitBttn, page.marketBttn) - this.setOrderVisibility() - this.setMarketBuyOrderEstimate() + bind(page.maxOrdBuy, 'click', () => { + const maxOrderLots = this.calcMaxOrderLots(false) + page.lotFieldBuy.value = String(maxOrderLots) + this.lotFieldBuyChangeHandler() }) - bind(page.maxOrd, 'click', () => { - if (this.isSell()) { - const maxSell = this.market.maxSell - if (!maxSell) return - page.lotField.value = String(maxSell.swap.lots) - } else { - const maxBuy = this.market.maxBuys[this.adjustedRate()] - if (!maxBuy) return - page.lotField.value = String(maxBuy.swap.lots) - } - this.lotChanged() + bind(page.maxOrdSell, 'click', () => { + const maxOrderLots = this.calcMaxOrderLots(true) + page.lotFieldSell.value = String(maxOrderLots) + this.lotFieldSellChangeHandler() }) - Doc.disableMouseWheel(page.rateField, page.lotField, page.qtyField, page.mktBuyField) - // Handle the full orderbook sent on the 'book' route. ws.registerRoute(bookRoute, (data: BookUpdate) => { this.handleBookRoute(data) }) // Handle the new order for the order book on the 'book_order' route. @@ -353,10 +309,11 @@ export default class MarketsPage extends BasePage { ws.registerRoute(epochMatchSummaryRoute, (data: BookUpdate) => { this.handleEpochMatchSummary(data) }) // Create a wallet this.newWalletForm = new NewWalletForm(page.newWalletForm, async () => { this.createWallet() }) - // Main order form. - bindForm(page.orderForm, page.submitBttn, async () => { this.stepSubmit() }) + // Main order forms. + bindForm(page.orderFormBuy, page.submitBttnBuy, async () => { this.stepSubmitBuy() }) + bindForm(page.orderFormSell, page.submitBttnSell, async () => { this.stepSubmitSell() }) // Order verification form. - bindForm(page.verifyForm, page.vSubmit, async () => { this.submitOrder() }) + bindForm(page.verifyForm, page.vSubmit, async () => { this.submitVerifiedOrder() }) // Cancel order form. bindForm(page.cancelForm, page.cancelSubmit, async () => { this.submitCancel() }) // Order detail view. @@ -416,13 +373,20 @@ export default class MarketsPage extends BasePage { Doc.bind(el, 'click', () => { closePopups() }) }) - // Event listeners for interactions with the various input fields. - bind(page.lotField, ['change', 'keyup'], () => { this.lotChanged() }) - bind(page.qtyField, 'change', () => { this.quantityChanged(true) }) - bind(page.qtyField, 'keyup', () => { this.quantityChanged(false) }) - bind(page.mktBuyField, ['change', 'keyup'], () => { this.marketBuyChanged() }) - bind(page.rateField, 'change', () => { this.rateFieldChanged() }) - bind(page.rateField, 'keyup', () => { this.previewQuoteAmt(true) }) + // Limit order buy: event listeners for handling user interactions. + bind(page.rateFieldBuy, 'change', () => { this.rateFieldBuyChangeHandler() }) + bind(page.rateFieldBuy, 'input', () => { this.rateFieldBuyInputHandler() }) + bind(page.lotFieldBuy, 'change', () => { this.lotFieldBuyChangeHandler() }) + bind(page.lotFieldBuy, 'input', () => { this.lotFieldBuyInputHandler() }) + bind(page.qtyFieldBuy, 'change', () => { this.qtyFieldBuyChangeHandler() }) + bind(page.qtyFieldBuy, 'input', () => { this.qtyFieldBuyInputHandler() }) + // Limit order sell: event listeners for handling user interactions. + bind(page.rateFieldSell, 'change', () => { this.rateFieldSellChangeHandler() }) + bind(page.rateFieldSell, 'input', () => { this.rateFieldSellInputHandler() }) + bind(page.lotFieldSell, 'change', () => { this.lotFieldSellChangeHandler() }) + bind(page.lotFieldSell, 'input', () => { this.lotFieldSellInputHandler() }) + bind(page.qtyFieldSell, 'change', () => { this.qtyFieldSellChangeHandler() }) + bind(page.qtyFieldSell, 'input', () => { this.qtyFieldSellInputHandler() }) // Market search input bindings. bind(page.marketSearchV1, ['change', 'keyup'], () => { this.filterMarkets() }) @@ -548,7 +512,6 @@ export default class MarketsPage extends BasePage { if (first) selected = { host: first.mkt.xc.host, base: first.mkt.baseid, quote: first.mkt.quoteid } } if (selected) this.setMarket(selected.host, selected.base, selected.quote) - else this.balanceWgt.setBalanceVisibility(false) // no market to display balance widget for. // set the initial state for the registration status this.setRegistrationStatusVisibility() @@ -561,44 +524,6 @@ export default class MarketsPage extends BasePage { anis.candles = new Wave(page.candlesChart, { message: intl.prep(intl.ID_CANDLES_LOADING) }) } - /* isSell is true if the user has selected sell in the order options. */ - isSell () { - return this.page.sellBttn.classList.contains('selected') - } - - /* isLimit is true if the user has selected the "limit order" tab. */ - isLimit () { - return this.page.limitBttn.classList.contains('selected') - } - - setBuy () { - const { page } = this - swapBttns(page.sellBttn, page.buyBttn) - page.submitBttn.classList.remove(sellBtnClass) - page.submitBttn.classList.add(buyBtnClass) - page.maxLbl.textContent = intl.prep(intl.ID_BUY) - this.setOrderBttnText() - this.setOrderVisibility() - if (!this.isLimit()) { - this.marketBuyChanged() - } else { - this.currentOrder = this.parseOrder() - this.updateOrderBttnState() - } - } - - setSell () { - const { page } = this - swapBttns(page.buyBttn, page.sellBttn) - page.submitBttn.classList.add(sellBtnClass) - page.submitBttn.classList.remove(buyBtnClass) - page.maxLbl.textContent = intl.prep(intl.ID_SELL) - this.setOrderBttnText() - this.setOrderVisibility() - this.currentOrder = this.parseOrder() - this.updateOrderBttnState() - } - /* hasPendingBonds is true if there are pending bonds */ hasPendingBonds (): boolean { return Object.keys(this.market.dex.auth.pendingBonds || []).length > 0 @@ -647,6 +572,38 @@ export default class MarketsPage extends BasePage { } } + /** + * calcMaxOrderLots returns the maximum order size, in lots (buy or sell, + * depending on what user chose in UI). + * returns 0 in case it cannot estimate it. + */ + async calcMaxOrderLots (sell: boolean): Promise { + if (sell) { + const res = await this.requestMaxSellEstimateCached() + if (!res) { + return 0 + } + return res.swap.lots + } + + const rate = this.adjustedRateAtoms(this.page.rateFieldBuy.value) + const res = await this.requestMaxBuyEstimateCached(rate) + if (!res) { + return 0 + } + return res.swap.lots + } + + /** + * calcMaxOrderQtyAtoms returns the maximum order size, in atoms. + * returns 0 in case it cannot estimate it. + */ + async calcMaxOrderQtyAtoms (sell: boolean): Promise { + const lotSize = this.market.cfg.lotsize + const maxOrderLots = await this.calcMaxOrderLots(sell) + return maxOrderLots * lotSize + } + /* setHighLow calculates the high and low rates over the last 24 hours. */ setHighLow () { let [high, low] = [0, 0] @@ -721,38 +678,23 @@ export default class MarketsPage extends BasePage { } } - /* - * setOrderVisibility sets which form is visible based on the specified - * options. + /** + * defaultRate returns default exchange rate (aka price). */ - setOrderVisibility () { - const page = this.page - if (this.isLimit()) { - Doc.show(page.priceBox, page.tifBox, page.qtyBox, page.maxBox) - Doc.hide(page.mktBuyBox) - this.previewQuoteAmt(true) - } else { - Doc.hide(page.tifBox, page.maxBox, page.priceBox) - if (this.isSell()) { - Doc.hide(page.mktBuyBox) - Doc.show(page.qtyBox) - this.previewQuoteAmt(true) - } else { - Doc.show(page.mktBuyBox) - Doc.hide(page.qtyBox) - this.previewQuoteAmt(false) - } - } - this.updateOrderBttnState() + defaultRate (): number { + // Current exchange rate would be reasonable default Price value. + const { market: { cfg: { baseid, quoteid, spot }, dex } } = this + const rate = spot ? spot.rate : 0 + return app().conventionalRate(baseid, quoteid, rate, dex) } /* resolveOrderFormVisibility displays or hides the 'orderForm' based on * a set of conditions to be met. */ - async resolveOrderFormVisibility () { + async resolveOrderFormVisibility (forseReset?: boolean) { const page = this.page - const showOrderForm = async () : Promise => { + const showOrderForms = async () : Promise => { if (!this.assetsAreSupported().isSupported) return false // assets not supported if (!this.market || this.market.dex.auth.effectiveTier < 1) return false// acct suspended or not registered @@ -764,10 +706,70 @@ export default class MarketsPage extends BasePage { const hasWallets = base && app().assets[base.id].wallet && quote && app().assets[quote.id].wallet if (!hasWallets) return false if (this.mmRunning) return false + + // if order form is already showing we don't want to re-initialize it because + // it might contain user inputs already (hence return right away), unless + // we have been asked to forcefully reset it (which is needed for example when + // user switches to another market - because we are sharing same order form + // between different markets) + if ((Doc.isDisplayed(page.orderFormBuy) || Doc.isDisplayed(page.orderFormSell)) && !forseReset) { + return true + } + + // re-initialize limit order forms + + const lot = '1' + const lotSize = String(this.market.cfg.lotsize / this.market.baseUnitInfo.conventional.conversionFactor) + const rateStep = String(this.market.cfg.ratestep / this.market.rateConversionFactor) + + // Reset limit-order buy form inputs to defaults. + page.lotFieldBuy.min = lot // improves up/down key-press handling, and hover-message + page.lotFieldBuy.step = lot // improves up/down key-press handling, and hover-message + page.lotFieldBuy.value = '1' + // Lots and quantity fields are tightly coupled to each other, when one is + // changed, we need to update the other one as well. + const [,,, adjQtyBuy] = this.parseLotInput(page.lotFieldBuy.value) + page.qtyFieldBuy.min = lotSize // improves up/down key-press handling, and hover-message + page.qtyFieldBuy.step = lotSize // improves up/down key-press handling, and hover-message + page.qtyFieldBuy.value = String(adjQtyBuy) + page.rateFieldBuy.min = rateStep // improves up/down key-press handling, and hover-message + page.rateFieldBuy.step = rateStep // improves up/down key-press handling, and hover-message + // try to set up a default rate + const [inputValidBuy,, adjRateBuy] = this.parseRateInput(String(this.defaultRate())) + if (inputValidBuy) { + page.rateFieldBuy.value = String(adjRateBuy) + } else { + page.rateFieldBuy.value = '' + } + this.previewMaxBuy() // relies on default rate being set + this.previewTotalBuy() // relies on default rate and default quantity being set + + // Reset limit-order sell form inputs to defaults. + page.lotFieldSell.min = lot // improves up/down key-press handling, and hover-message + page.lotFieldSell.step = lot // improves up/down key-press handling, and hover-message + page.lotFieldSell.value = '1' + // Lots and quantity fields are tightly coupled to each other, when one is + // changed, we need to update the other one as well. + const [,,, adjQtySell] = this.parseLotInput(page.lotFieldSell.value) + page.qtyFieldSell.min = lotSize // improves up/down key-press handling, and hover-message + page.qtyFieldSell.step = lotSize // improves up/down key-press handling, and hover-message + page.qtyFieldSell.value = String(adjQtySell) + page.rateFieldSell.min = rateStep // improves up/down key-press handling, and hover-message + page.rateFieldSell.step = rateStep // improves up/down key-press handling, and hover-message + // try to set up a default rate + const [inputValidSell,, adjRateSell] = this.parseRateInput(String(this.defaultRate())) + if (inputValidSell) { + page.rateFieldSell.value = String(adjRateSell) + } else { + page.rateFieldSell.value = '' + } + this.previewMaxSell() // relies on default rate being set + this.previewTotalSell() // relies on default rate and default quantity being set + return true } - Doc.setVis(await showOrderForm(), page.orderForm, page.orderTypeBttns) + Doc.setVis(await showOrderForms(), page.orderFormBuy, page.orderFormSell) if (this.market) { const { auth: { effectiveTier, pendingStrength } } = this.market.dex @@ -782,7 +784,7 @@ export default class MarketsPage extends BasePage { } Doc.setVis(this.mmRunning, page.mmRunning) - if (this.mmRunning) Doc.hide(page.orderForm, page.orderTypeBttns) + if (this.mmRunning) Doc.hide(page.orderFormBuy, page.orderFormSell) } /* setLoaderMsgVisibility displays a message in case a dex asset is not @@ -857,23 +859,15 @@ export default class MarketsPage extends BasePage { if (baseAssetApprovalStatus === ApprovalStatus.Approved && quoteAssetApprovalStatus === ApprovalStatus.Approved) { Doc.hide(page.tokenApproval) - page.sellBttn.removeAttribute('disabled') - page.buyBttn.removeAttribute('disabled') return } if (baseAssetApprovalStatus !== ApprovalStatus.Approved && quoteAssetApprovalStatus === ApprovalStatus.Approved) { - page.sellBttn.setAttribute('disabled', 'disabled') - page.buyBttn.removeAttribute('disabled') - this.setBuy() Doc.show(page.approvalRequiredSell) Doc.hide(page.approvalRequiredBuy, page.approvalRequiredBoth) } if (baseAssetApprovalStatus === ApprovalStatus.Approved && quoteAssetApprovalStatus !== ApprovalStatus.Approved) { - page.buyBttn.setAttribute('disabled', 'disabled') - page.sellBttn.removeAttribute('disabled') - this.setSell() Doc.show(page.approvalRequiredBuy) Doc.hide(page.approvalRequiredSell, page.approvalRequiredBoth) } @@ -952,13 +946,11 @@ export default class MarketsPage extends BasePage { showSection(undefined) this.resolveOrderFormVisibility() } - if (Doc.isHidden(page.orderForm)) { + if (Doc.isHidden(page.orderFormBuy) || Doc.isHidden(page.orderFormSell)) { // wait a couple of seconds before showing the form so the success // message is shown to the user setTimeout(toggle, 5000) - return } - toggle() } else if (market.dex.viewOnly) { page.unregisteredDex.textContent = market.dex.host showSection(page.notRegistered) @@ -974,13 +966,15 @@ export default class MarketsPage extends BasePage { } setOrderBttnText () { - if (this.isSell()) { - this.page.submitBttn.textContent = intl.prep(intl.ID_SET_BUTTON_SELL, { asset: Doc.shortSymbol(this.market.baseCfg.unitInfo.conventional.unit) }) - } else this.page.submitBttn.textContent = intl.prep(intl.ID_SET_BUTTON_BUY, { asset: Doc.shortSymbol(this.market.baseCfg.unitInfo.conventional.unit) }) + this.page.submitBttnSell.textContent = intl.prep(intl.ID_SET_BUTTON_SELL, { asset: Doc.shortSymbol(this.market.baseCfg.unitInfo.conventional.unit) }) + this.page.submitBttnBuy.textContent = intl.prep(intl.ID_SET_BUTTON_BUY, { asset: Doc.shortSymbol(this.market.baseCfg.unitInfo.conventional.unit) }) } - setOrderBttnEnabled (isEnabled: boolean, disabledTooltipMsg?: string) { - const btn = this.page.submitBttn + setOrderBttnBuyEnabled (isEnabled: boolean, disabledTooltipMsg?: string) { + // TODO + console.log('setOrderBttnEnabled', isEnabled) + + const btn = this.page.submitBttnBuy if (isEnabled) { btn.removeAttribute('disabled') btn.removeAttribute('title') @@ -990,55 +984,70 @@ export default class MarketsPage extends BasePage { } } - updateOrderBttnState () { - const { market: mkt, currentOrder: { qty: orderQty, rate: orderRate, isLimit, sell } } = this + setOrderBttnSellEnabled (isEnabled: boolean, disabledTooltipMsg?: string) { + const btn = this.page.submitBttnSell + if (isEnabled) { + btn.removeAttribute('disabled') + btn.removeAttribute('title') + } else { + btn.setAttribute('disabled', 'true') + if (disabledTooltipMsg) btn.setAttribute('title', disabledTooltipMsg) + } + } + + async updateOrderBttnBuyState (order: TradeForm) { + const mkt = this.market const baseWallet = app().assets[this.market.base.id].wallet const quoteWallet = app().assets[mkt.quote.id].wallet if (!baseWallet || !quoteWallet) return + const orderQty = order.qty + const orderRate = order.rate + if (orderQty <= 0 || orderQty < mkt.cfg.lotsize) { - this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_QTY_ERROR)) + this.setOrderBttnBuyEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_QTY_ERROR)) return } - // Market orders - if (!isLimit) { - if (sell) { - this.setOrderBttnEnabled(orderQty <= baseWallet.balance.available, intl.prep(intl.ID_ORDER_BUTTON_SELL_BALANCE_ERROR)) - } else { - this.setOrderBttnEnabled(orderQty <= quoteWallet.balance.available, intl.prep(intl.ID_ORDER_BUTTON_BUY_BALANCE_ERROR)) - } + // Limit buy + const aLot = mkt.cfg.lotsize * (orderRate / OrderUtil.RateEncodingFactor) + if (quoteWallet.balance.available < aLot) { + this.setOrderBttnBuyEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_BUY_BALANCE_ERROR)) return } - - if (!orderRate) { - this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_QTY_RATE_ERROR)) + const res = await this.requestMaxBuyEstimateCached(orderRate) + if (!res) { + this.setOrderBttnBuyEnabled(false, intl.prep(intl.ID_ESTIMATE_UNAVAILABLE)) return } + const enable = orderQty <= res.swap.lots * mkt.cfg.lotsize + this.setOrderBttnBuyEnabled(enable, intl.prep(intl.ID_ORDER_BUTTON_BUY_BALANCE_ERROR)) + } - // Limit sell - if (sell) { - if (baseWallet.balance.available < mkt.cfg.lotsize) { - this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_SELL_BALANCE_ERROR)) - return - } - if (mkt.maxSell) { - this.setOrderBttnEnabled(orderQty <= mkt.maxSell.swap.value, intl.prep(intl.ID_ORDER_BUTTON_SELL_BALANCE_ERROR)) - } + async updateOrderBttnSellState (order: TradeForm) { + const mkt = this.market + const baseWallet = app().assets[this.market.base.id].wallet + const quoteWallet = app().assets[mkt.quote.id].wallet + if (!baseWallet || !quoteWallet) return + + const orderQty = order.qty + + if (orderQty <= 0 || orderQty < mkt.cfg.lotsize) { + this.setOrderBttnSellEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_QTY_ERROR)) return } - // Limit buy - const rate = this.adjustedRate() - const aLot = mkt.cfg.lotsize * (rate / OrderUtil.RateEncodingFactor) - if (quoteWallet.balance.available < aLot) { - this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_BUY_BALANCE_ERROR)) + // Limit sell + if (baseWallet.balance.available < mkt.cfg.lotsize) { + this.setOrderBttnSellEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_SELL_BALANCE_ERROR)) return } - if (mkt.maxBuys[rate]) { - const enable = orderQty <= mkt.maxBuys[rate].swap.lots * mkt.cfg.lotsize - this.setOrderBttnEnabled(enable, intl.prep(intl.ID_ORDER_BUTTON_BUY_BALANCE_ERROR)) + const res = await this.requestMaxSellEstimateCached() + if (!res) { + this.setOrderBttnSellEnabled(false, intl.prep(intl.ID_ESTIMATE_UNAVAILABLE)) + return } + this.setOrderBttnSellEnabled(orderQty <= res.swap.value, intl.prep(intl.ID_ORDER_BUTTON_SELL_BALANCE_ERROR)) } setCandleDurBttns () { @@ -1065,11 +1074,6 @@ export default class MarketsPage extends BasePage { console.log(res.book) } - // reset form inputs - page.lotField.value = '' - page.qtyField.value = '' - page.rateField.value = '' - // clear orderbook. Doc.empty(this.page.buyRows) Doc.empty(this.page.sellRows) @@ -1079,9 +1083,6 @@ export default class MarketsPage extends BasePage { this.recentMatches = [] Doc.empty(page.recentMatchesLiveList) - // Hide the balance widget - this.balanceWgt.setBalanceVisibility(false) - Doc.hide(page.notRegistered, page.bondRequired, page.noWallet) // If we have not yet connected, there is no dex.assets or any other @@ -1103,11 +1104,7 @@ export default class MarketsPage extends BasePage { const [bui, qui] = [app().unitInfo(baseID, dex), app().unitInfo(quoteID, dex)] const rateConversionFactor = OrderUtil.RateEncodingFactor / bui.conventional.conversionFactor * qui.conventional.conversionFactor - Doc.hide(page.maxOrd, page.chartErrMsg) - if (this.maxEstimateTimer) { - window.clearTimeout(this.maxEstimateTimer) - this.maxEstimateTimer = null - } + Doc.hide(page.chartErrMsg) const mktId = marketID(baseCfg.symbol, quoteCfg.symbol) const baseAsset = app().assets[baseID] const quoteAsset = app().assets[quoteID] @@ -1137,11 +1134,13 @@ export default class MarketsPage extends BasePage { this.market = mkt this.mm.setMarket(host, baseID, quoteID) this.mmRunning = undefined - page.lotSize.textContent = Doc.formatCoinValue(mkt.cfg.lotsize, mkt.baseUnitInfo) - page.rateStep.textContent = Doc.formatCoinValue(mkt.cfg.ratestep / rateConversionFactor) + + page.lotSizeBuy.textContent = Doc.formatCoinValue(mkt.cfg.lotsize, mkt.baseUnitInfo) + page.lotSizeSell.textContent = Doc.formatCoinValue(mkt.cfg.lotsize, mkt.baseUnitInfo) + page.rateStepBuy.textContent = Doc.formatCoinValue(mkt.cfg.ratestep / rateConversionFactor) + page.rateStepSell.textContent = Doc.formatCoinValue(mkt.cfg.ratestep / rateConversionFactor) this.displayMessageIfMissingWallet() - this.balanceWgt.setWallets(host, baseID, quoteID) this.setMarketDetails() this.setCurrMarketPrice() @@ -1157,11 +1156,9 @@ export default class MarketsPage extends BasePage { this.setLoaderMsgVisibility() this.setTokenApprovalVisibility() this.setRegistrationStatusVisibility() - this.resolveOrderFormVisibility() + this.resolveOrderFormVisibility(true) this.setOrderBttnText() - this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_QTY_RATE_ERROR)) this.setCandleDurBttns() - this.previewQuoteAmt(false) this.updateTitle() this.reputationMeter.setHost(dex.host) this.updateReputation() @@ -1203,196 +1200,368 @@ export default class MarketsPage extends BasePage { } /* - * parseOrder pulls the order information from the form fields. Data is not + * parseOrderBuy pulls the order information from the form fields. Data is not * validated in any way. */ - parseOrder (): TradeForm { + parseOrderBuy (): TradeForm { const page = this.page - let qtyField = page.qtyField - const limit = this.isLimit() - const sell = this.isSell() const market = this.market - let qtyConv = market.baseUnitInfo.conventional.conversionFactor - if (!limit && !sell) { - qtyField = page.mktBuyField - qtyConv = market.quoteUnitInfo.conventional.conversionFactor + + const qtyConv = market.baseUnitInfo.conventional.conversionFactor + + return { + host: market.dex.host, + isLimit: true, + sell: false, + base: market.base.id, + quote: market.quote.id, + qty: convertToAtoms(page.qtyFieldBuy.value || '', qtyConv), + rate: convertToAtoms(page.rateFieldBuy.value || '', market.rateConversionFactor), // message-rate + tifnow: page.tifNowBuy.checked || false, + options: {} } + } + + /* + * parseOrderSell pulls the order information from the form fields. Data is not + * validated in any way. + */ + parseOrderSell (): TradeForm { + const page = this.page + const market = this.market + + const qtyConv = market.baseUnitInfo.conventional.conversionFactor + return { host: market.dex.host, - isLimit: limit, - sell: sell, + isLimit: true, + sell: true, base: market.base.id, quote: market.quote.id, - qty: convertToAtoms(qtyField.value || '', qtyConv), - rate: convertToAtoms(page.rateField.value || '', market.rateConversionFactor), // message-rate - tifnow: page.tifNow.checked || false, + qty: convertToAtoms(page.qtyFieldSell.value || '', qtyConv), + rate: convertToAtoms(page.rateFieldSell.value || '', market.rateConversionFactor), // message-rate + tifnow: page.tifNowSell.checked || false, options: {} } } /** - * previewQuoteAmt shows quote amount when rate or quantity input are changed + * previewTotalBuy calculates and displays Total value (in quote asset) for the order. + * It also updates order button state based on the values in the order form. */ - previewQuoteAmt (show: boolean) { + previewTotalBuy () { const page = this.page - if (!this.market.base || !this.market.quote) return // Not a supported asset - const order = this.currentOrder = this.parseOrder() - const adjusted = this.adjustedRate() - page.orderErr.textContent = '' - if (adjusted) { - if (order.sell) this.preSell() - else this.preBuy() - } - if (!show || !adjusted || !order.qty) { - page.orderPreview.textContent = '' - return + const market = this.market + + const order = this.parseOrderBuy() + + if (order.qty > 0 && order.rate > 0) { + const quoteQty = order.qty * order.rate / OrderUtil.RateEncodingFactor + const total = Doc.formatCoinValue(quoteQty, market.quoteUnitInfo) + + page.orderTotalPreviewBuy.textContent = intl.prep(intl.ID_LIMIT_ORDER_TOTAL_PREVIEW, { total, asset: market.quoteUnitInfo.conventional.unit }) } - const { unitInfo: { conventional: { unit } } } = app().assets[order.quote] - const quoteQty = order.qty * order.rate / OrderUtil.RateEncodingFactor - const total = Doc.formatCoinValue(quoteQty, this.market.quoteUnitInfo) - page.orderPreview.textContent = intl.prep(intl.ID_ORDER_PREVIEW, { total, asset: unit }) - if (this.isSell()) this.preSell() - else this.preBuy() + this.updateOrderBttnBuyState(order) } /** - * preSell populates the max order message for the largest available sell. + * previewTotalSell calculates and displays Total value (in quote asset) for the order. + * It also updates order button state based on the values in the order form. */ - preSell () { + previewTotalSell () { + const page = this.page + const market = this.market + + const order = this.parseOrderSell() + + if (order.qty > 0 && order.rate > 0) { + const quoteQty = order.qty * order.rate / OrderUtil.RateEncodingFactor + const total = Doc.formatCoinValue(quoteQty, market.quoteUnitInfo) + + page.orderTotalPreviewSell.textContent = intl.prep(intl.ID_LIMIT_ORDER_TOTAL_PREVIEW, { total, asset: market.quoteUnitInfo.conventional.unit }) + } + + this.updateOrderBttnSellState(order) + } + + /** + * previewMaxBuy displays max available size for buy order. + */ + previewMaxBuy () { + const page = this.page const mkt = this.market - const baseWallet = app().assets[mkt.base.id].wallet - if (baseWallet.balance.available < mkt.cfg.lotsize) { - this.setMaxOrder(null) - this.updateOrderBttnState() + + const rate = this.adjustedRateAtoms(this.page.rateFieldBuy.value) + // There is no need to try calculating maxbuy if rate hasn't been set to a + // meaningful value. + if (isNaN(rate) || rate <= 0) { + Doc.hidePreservingLayout(page.maxOrdBuy) return } - if (mkt.maxSell) { - this.setMaxOrder(mkt.maxSell.swap) - this.updateOrderBttnState() + const quoteWallet = app().assets[mkt.quote.id].wallet + if (!quoteWallet) { + console.warn('max order estimate not available, no quote wallet in app assets for:', mkt.quote.id) + Doc.hidePreservingLayout(page.maxOrdBuy) return } - if (mkt.maxSellRequested) return - mkt.maxSellRequested = true - // We only fetch pre-sell once per balance update, so don't delay. - this.scheduleMaxEstimate('/api/maxsell', {}, 0, (res: MaxSell) => { - mkt.maxSellRequested = false - mkt.maxSell = res.maxSell - mkt.sellBalance = baseWallet.balance.available - this.setMaxOrder(res.maxSell.swap) - this.updateOrderBttnState() - }) + Doc.showPreservingLayout(page.maxOrdBuy) + + const aLot = mkt.cfg.lotsize * (rate / OrderUtil.RateEncodingFactor) + if (quoteWallet.balance.available < aLot) { + this.setMaxOrderBuy(null) + return + } + // scheduling with delay of 100ms to potentially deduplicate some requests + this.scheduleMaxBuyEstimate(rate, 100) } /** - * preBuy populates the max order message for the largest available buy. + * previewMaxSell displays max available size for sell order. */ - preBuy () { + previewMaxSell () { + const page = this.page const mkt = this.market - const rate = this.adjustedRate() - const quoteWallet = app().assets[mkt.quote.id].wallet - if (!quoteWallet) return - const aLot = mkt.cfg.lotsize * (rate / OrderUtil.RateEncodingFactor) - if (quoteWallet.balance.available < aLot) { - this.setMaxOrder(null) - this.updateOrderBttnState() + + const baseWallet = app().assets[mkt.base.id].wallet + if (!baseWallet) { + console.warn('max order estimate not available, no base wallet in app assets for:', mkt.base.id) + Doc.hidePreservingLayout(page.maxOrdSell) return } - if (mkt.maxBuys[rate]) { - this.setMaxOrder(mkt.maxBuys[rate].swap) - this.updateOrderBttnState() + + Doc.showPreservingLayout(page.maxOrdSell) + + if (baseWallet.balance.available < mkt.cfg.lotsize) { + this.setMaxOrderSell(null) return } - // 0 delay for first fetch after balance update or market change, otherwise - // meter these at 1 / sec. - const delay = Object.keys(mkt.maxBuys).length ? 350 : 0 - this.scheduleMaxEstimate('/api/maxbuy', { rate }, delay, (res: MaxBuy) => { - mkt.maxBuys[rate] = res.maxBuy - mkt.buyBalance = app().assets[mkt.quote.id].wallet.balance.available - this.setMaxOrder(res.maxBuy.swap) - this.updateOrderBttnState() - }) + // scheduling with delay of 100ms to potentially deduplicate some requests + this.scheduleMaxSellEstimate(100) } /** - * scheduleMaxEstimate shows the loading icon and schedules a call to an order - * estimate api endpoint. If another call to scheduleMaxEstimate is made before + * scheduleMaxBuyEstimate shows the loading icon and schedules a call to an order + * estimate api endpoint. If another call to scheduleMaxBuyEstimate is made before * this one is fired (after delay), this call will be canceled. */ - scheduleMaxEstimate (path: string, args: any, delay: number, success: (res: any) => void) { + scheduleMaxBuyEstimate (rate: number, delay: number) { const page = this.page - if (!this.maxLoaded) this.maxLoaded = app().loading(page.maxOrd) const [bid, qid] = [this.market.base.id, this.market.quote.id] const [bWallet, qWallet] = [app().assets[bid].wallet, app().assets[qid].wallet] if (!bWallet || !bWallet.running || !qWallet || !qWallet.running) return - if (this.maxEstimateTimer) window.clearTimeout(this.maxEstimateTimer) - - Doc.show(page.maxOrd, page.maxLotBox) - Doc.hide(page.maxAboveZero, page.maxZeroNoFees, page.maxZeroNoBal) - page.maxFromLots.textContent = intl.prep(intl.ID_CALCULATING) - page.maxFromLotsLbl.textContent = '' - this.maxOrderUpdateCounter++ - const counter = this.maxOrderUpdateCounter - this.maxEstimateTimer = window.setTimeout(async () => { - this.maxEstimateTimer = null - if (counter !== this.maxOrderUpdateCounter) return - const res = await postJSON(path, { - host: this.market.dex.host, - base: bid, - quote: qid, - ...args - }) - if (counter !== this.maxOrderUpdateCounter) return - if (!app().checkResponse(res)) { - console.warn('max order estimate not available:', res) - page.maxFromLots.textContent = intl.prep(intl.ID_ESTIMATE_UNAVAILABLE) - if (this.maxLoaded) { - this.maxLoaded() - this.maxLoaded = null - } + + Doc.hide(page.maxQtyBoxBuy, page.maxZeroNoFeesBuy, page.maxZeroNoBalBuy) + + page.maxFromLotsBuy.textContent = intl.prep(intl.ID_CALCULATING) + page.maxFromLotsLblBuy.textContent = '' + if (!this.maxLoadedBuy) this.maxLoadedBuy = app().loading(page.maxOrdBuy) + + this.maxBuyLastReqID++ + const reqID = this.maxBuyLastReqID + window.setTimeout(async () => { + if (reqID !== this.maxBuyLastReqID) { + // a fresher request has been issued, no need to execute this one + return + } + const res = await this.requestMaxBuyEstimateCached(rate) + if (reqID !== this.maxBuyLastReqID) { + // a fresher request has been issued, no need to use the result from this one return } - success(res) + if (this.maxLoadedBuy) { + this.maxLoadedBuy() + this.maxLoadedBuy = null + } + if (res) { + this.setMaxOrderBuy(res.swap) + } else { + this.setMaxOrderBuy(null) + } }, delay) } - /* setMaxOrder sets the max order text. */ - setMaxOrder (maxOrder: SwapEstimate | null) { + /** + * scheduleMaxSellEstimate shows the loading icon and schedules a call to an order + * estimate api endpoint. If another call to scheduleMaxSellEstimate is made before + * this one is fired (after delay), this call will be canceled. + */ + scheduleMaxSellEstimate (delay: number) { const page = this.page - if (this.maxLoaded) { - this.maxLoaded() - this.maxLoaded = null + const [bid, qid] = [this.market.base.id, this.market.quote.id] + const [bWallet, qWallet] = [app().assets[bid].wallet, app().assets[qid].wallet] + if (!bWallet || !bWallet.running || !qWallet || !qWallet.running) return + + Doc.hide(page.maxQtyBoxSell, page.maxZeroNoFeesSell, page.maxZeroNoBalSell) + + page.maxFromLotsSell.textContent = intl.prep(intl.ID_CALCULATING) + page.maxFromLotsLblSell.textContent = '' + if (!this.maxLoadedSell) this.maxLoadedSell = app().loading(page.maxOrdSell) + + this.maxSellLastReqID++ + const reqID = this.maxSellLastReqID + window.setTimeout(async () => { + if (reqID !== this.maxSellLastReqID) { + // a fresher request has been issued, no need to execute this one + return + } + const res = await this.requestMaxSellEstimateCached() + if (reqID !== this.maxSellLastReqID) { + // a fresher request has been issued, no need to use the result from this one + return + } + if (this.maxLoadedSell) { + this.maxLoadedSell() + this.maxLoadedSell = null + } + if (res) { + this.setMaxOrderSell(res.swap) + } else { + this.setMaxOrderSell(null) + } + }, delay) + } + + async requestMaxBuyEstimateCached (rate: number): Promise { + const maxBuy = this.market.maxBuys[rate] + if (maxBuy) { + return maxBuy + } + + const res = await this.requestMaxEstimate('/api/maxbuy', { rate }) + if (!res) { + return null } - Doc.show(page.maxOrd, page.maxLotBox) - const sell = this.isSell() - let lots = 0 - if (maxOrder) lots = maxOrder.lots + this.market.maxBuys[rate] = res.maxBuy + // see buyBalance desc for why we are doing this + this.market.buyBalance = app().assets[this.market.quote.id].wallet.balance.available + + return res.maxBuy + } + + async requestMaxSellEstimateCached (): Promise { + const maxSell = this.market.maxSell + if (maxSell) { + return maxSell + } + + const res = await this.requestMaxEstimate('/api/maxsell', {}) + if (!res) { + return null + } + + this.market.maxSell = res.maxSell + // see sellBalance desc for why we are doing this + this.market.sellBalance = app().assets[this.market.base.id].wallet.balance.available + + return res.maxSell + } + + /** + * requestMaxEstimate calls an order estimate api endpoint. If another call to + * requestMaxEstimate is made before this one is finished, this call will be canceled. + */ + async requestMaxEstimate (path: string, args: any): Promise { + const [bid, qid] = [this.market.base.id, this.market.quote.id] + const [bWallet, qWallet] = [app().assets[bid].wallet, app().assets[qid].wallet] + if (!bWallet || !bWallet.running || !qWallet || !qWallet.running) return null + + const res = await postJSON(path, { + host: this.market.dex.host, + base: bid, + quote: qid, + ...args + }) + if (!app().checkResponse(res)) { + return null + } + return res + } + + /* setMaxOrderBuy sets the max order text. */ + setMaxOrderBuy (maxOrder: SwapEstimate | null) { + const page = this.page + + const lots = maxOrder ? maxOrder.lots : 0 + if (lots !== 0) { + Doc.show(page.maxQtyBoxBuy) + } else { + // Don't display 0 quantity for simplicity. + Doc.hide(page.maxQtyBoxBuy) + } - page.maxFromLots.textContent = lots.toString() + page.maxFromLotsBuy.textContent = lots.toString() // XXX add plural into format details, so we don't need this - page.maxFromLotsLbl.textContent = intl.prep(lots === 1 ? intl.ID_LOT : intl.ID_LOTS) + page.maxFromLotsLblBuy.textContent = intl.prep(lots === 1 ? intl.ID_LOT : intl.ID_LOTS) if (!maxOrder) return - const fromAsset = sell ? this.market.base : this.market.quote + const fromAsset = this.market.quote if (lots === 0) { // If we have a maxOrder, see if we can guess why we have no lots. let lotSize = this.market.cfg.lotsize - if (!sell) { - const conversionRate = this.anyRate()[1] - if (conversionRate === 0) return - lotSize = lotSize * conversionRate + const conversionRate = this.anyRate()[1] + if (conversionRate === 0) return + lotSize = lotSize * conversionRate + const haveQty = fromAsset.wallet.balance.available / lotSize > 0 + if (haveQty) { + if (fromAsset.token) { + const { wallet: { balance: { available: feeAvail } }, unitInfo } = app().assets[fromAsset.token.parentID] + if (feeAvail < maxOrder.feeReservesPerLot) { + Doc.show(page.maxZeroNoFeesBuy) + page.maxZeroNoFeesTickerBuy.textContent = unitInfo.conventional.unit + page.maxZeroMinFeesBuy.textContent = Doc.formatCoinValue(maxOrder.feeReservesPerLot, unitInfo) + } + // It looks like we should be able to afford it, but maybe some fees we're not seeing. + // Show nothing. + return + } + // Not a token. Maybe we have enough for the swap but not for fees. + const fundedLots = fromAsset.wallet.balance.available / (lotSize + maxOrder.feeReservesPerLot) + if (fundedLots > 0) return // Not sure why. Could be split txs or utxos. Just show nothing. } + Doc.show(page.maxZeroNoBalBuy) + page.maxZeroNoBalTickerBuy.textContent = fromAsset.unitInfo.conventional.unit + return + } + Doc.show(page.maxAboveZeroBuy) + + page.maxFromAmtBuy.textContent = Doc.formatCoinValue(maxOrder.value || 0, fromAsset.unitInfo) + page.maxFromTickerBuy.textContent = fromAsset.unitInfo.conventional.unit + } + + /* setMaxOrderSell sets the max order text. */ + setMaxOrderSell (maxOrder: SwapEstimate | null) { + const page = this.page + + const lots = maxOrder ? maxOrder.lots : 0 + if (lots !== 0) { + Doc.show(page.maxQtyBoxSell) + } else { + // Don't display 0 quantity for simplicity. + Doc.hide(page.maxQtyBoxSell) + } + + page.maxFromLotsSell.textContent = lots.toString() + // XXX add plural into format details, so we don't need this + page.maxFromLotsLblSell.textContent = intl.prep(lots === 1 ? intl.ID_LOT : intl.ID_LOTS) + if (!maxOrder) return + + const fromAsset = this.market.base + + if (lots === 0) { + // If we have a maxOrder, see if we can guess why we have no lots. + const lotSize = this.market.cfg.lotsize const haveQty = fromAsset.wallet.balance.available / lotSize > 0 if (haveQty) { if (fromAsset.token) { const { wallet: { balance: { available: feeAvail } }, unitInfo } = app().assets[fromAsset.token.parentID] if (feeAvail < maxOrder.feeReservesPerLot) { - Doc.show(page.maxZeroNoFees) - page.maxZeroNoFeesTicker.textContent = unitInfo.conventional.unit - page.maxZeroMinFees.textContent = Doc.formatCoinValue(maxOrder.feeReservesPerLot, unitInfo) + Doc.show(page.maxZeroNoFeesSell) + page.maxZeroNoFeesTickerSell.textContent = unitInfo.conventional.unit + page.maxZeroMinFeesSell.textContent = Doc.formatCoinValue(maxOrder.feeReservesPerLot, unitInfo) } // It looks like we should be able to afford it, but maybe some fees we're not seeing. // Show nothing. @@ -1402,44 +1571,91 @@ export default class MarketsPage extends BasePage { const fundedLots = fromAsset.wallet.balance.available / (lotSize + maxOrder.feeReservesPerLot) if (fundedLots > 0) return // Not sure why. Could be split txs or utxos. Just show nothing. } - Doc.show(page.maxZeroNoBal) - page.maxZeroNoBalTicker.textContent = fromAsset.unitInfo.conventional.unit + Doc.show(page.maxZeroNoBalSell) + page.maxZeroNoBalTickerSell.textContent = fromAsset.unitInfo.conventional.unit return } - Doc.show(page.maxAboveZero) + Doc.show(page.maxAboveZeroSell) - page.maxFromAmt.textContent = Doc.formatCoinValue(maxOrder.value || 0, fromAsset.unitInfo) - page.maxFromTicker.textContent = fromAsset.unitInfo.conventional.unit - // Could subtract the maxOrder.redemptionFees here. - // The qty conversion doesn't fit well with the new design. - // TODO: Make this work somehow? - // const toConversion = sell ? this.adjustedRate() / OrderUtil.RateEncodingFactor : OrderUtil.RateEncodingFactor / this.adjustedRate() - // page.maxToAmt.textContent = Doc.formatCoinValue((maxOrder.value || 0) * toConversion, toAsset.unitInfo) - // page.maxToTicker.textContent = toAsset.symbol.toUpperCase() + page.maxFromAmtSell.textContent = Doc.formatCoinValue(maxOrder.value || 0, fromAsset.unitInfo) + page.maxFromTickerSell.textContent = fromAsset.unitInfo.conventional.unit } /* - * validateOrder performs some basic order sanity checks, returning boolean + * validateOrderBuy performs some basic order sanity checks, returning boolean * true if the order appears valid. */ - validateOrder (order: TradeForm) { + async validateOrderBuy (order: TradeForm) { const { page, market: { cfg: { minimumRate }, rateConversionFactor } } = this - if (order.isLimit) { - if (!order.rate) { - Doc.show(page.orderErr) - page.orderErr.textContent = intl.prep(intl.ID_NO_ZERO_RATE) - return false - } - if (order.rate < minimumRate) { - Doc.show(page.orderErr) - const [r, minRate] = [order.rate / rateConversionFactor, minimumRate / rateConversionFactor] - page.orderErr.textContent = `rate is lower than the market's minimum rate. ${r} < ${minRate}` - return false - } + + const showError = function (err: string) { + page.orderErrBuy.textContent = intl.prep(err) + Doc.show(page.orderErrBuy) + } + + if (!order.rate) { + showError(intl.ID_NO_ZERO_RATE) + return false + } + if (order.rate < minimumRate) { + const [r, minRate] = [order.rate / rateConversionFactor, minimumRate / rateConversionFactor] + showError(`rate is lower than the market's minimum rate. ${r} < ${minRate}`) + return false + } + if (!order.qty) { + // Hints to the user what inputs don't pass validation. + this.animateErrors(highlightOutlineRed(page.qtyFieldBuy)) + showError(intl.ID_NO_ZERO_QUANTITY) + return false + } + // Skipping max order validation step in case we don't have reasonable value + // to compare against. Note, the order still will be re-checked by dexc at + // the placement time, so we have this validation here just to provide snappy + // feedback for the user. + if (order.qty > await this.calcMaxOrderQtyAtoms(order.sell)) { + // Hints to the user what inputs don't pass validation. + this.animateErrors(highlightBackgroundRed(page.maxOrdBuy), highlightOutlineRed(page.lotFieldBuy)) + showError(intl.ID_NO_QUANTITY_EXCEEDS_MAX) + return false + } + return true + } + + /* + * validateOrderSell performs some basic order sanity checks, returning boolean + * true if the order appears valid. + */ + async validateOrderSell (order: TradeForm) { + const { page, market: { cfg: { minimumRate }, rateConversionFactor } } = this + + const showError = function (err: string) { + page.orderErrSell.textContent = intl.prep(err) + Doc.show(page.orderErrSell) + } + + if (!order.rate) { + showError(intl.ID_NO_ZERO_RATE) + return false + } + if (order.rate < minimumRate) { + const [r, minRate] = [order.rate / rateConversionFactor, minimumRate / rateConversionFactor] + showError(`rate is lower than the market's minimum rate. ${r} < ${minRate}`) + return false } if (!order.qty) { - Doc.show(page.orderErr) - page.orderErr.textContent = intl.prep(intl.ID_NO_ZERO_QUANTITY) + // Hints to the user what inputs don't pass validation. + this.animateErrors(highlightOutlineRed(page.qtyFieldSell)) + showError(intl.ID_NO_ZERO_QUANTITY) + return false + } + // Skipping max order validation step in case we don't have reasonable value + // to compare against. Note, the order still will be re-checked by dexc at + // the placement time, so we have this validation here just to provide snappy + // feedback for the user. + if (order.qty > await this.calcMaxOrderQtyAtoms(order.sell)) { + // Hints to the user what inputs don't pass validation. + this.animateErrors(highlightBackgroundRed(page.maxOrdSell), highlightOutlineRed(page.lotFieldSell)) + showError(intl.ID_NO_QUANTITY_EXCEEDS_MAX) return false } return true @@ -1469,7 +1685,7 @@ export default class MarketsPage extends BasePage { * quantity from base to quote or vice-versa, or for display purposes. */ midGapConventional () { - const gap = this.midGap() + const gap = this.midGapAtoms() if (!gap) return gap const { baseUnitInfo: b, quoteUnitInfo: q } = this.market return gap * b.conventional.conversionFactor / q.conventional.conversionFactor @@ -1483,7 +1699,7 @@ export default class MarketsPage extends BasePage { * conventional rate for display or to convert conventional units, use * midGapConventional */ - midGap () { + midGapAtoms () { const book = this.book if (!book) return if (book.buys && book.buys.length) { @@ -1498,21 +1714,6 @@ export default class MarketsPage extends BasePage { return null } - /* - * setMarketBuyOrderEstimate sets the "min. buy" display for the current - * market. - */ - setMarketBuyOrderEstimate () { - const market = this.market - const lotSize = market.cfg.lotsize - const xc = app().user.exchanges[market.dex.host] - const buffer = xc.markets[market.sid].buybuffer - const gap = this.midGapConventional() - if (gap) { - this.page.minMktBuy.textContent = Doc.formatCoinValue(lotSize * buffer * gap, market.baseUnitInfo) - } - } - maxUserOrderCount (): number { const { dex: { host }, cfg: { name: mktID } } = this.market return Math.max(maxUserOrdersShown, app().orders(host, mktID).length) @@ -1637,13 +1838,9 @@ export default class MarketsPage extends BasePage { Doc.bind(tmpl.header, 'click', () => { if (Doc.isDisplayed(tmpl.details)) { Doc.hide(tmpl.details) - header.expander.classList.add('ico-arrowdown') - header.expander.classList.remove('ico-arrowup') return } Doc.show(tmpl.details) - header.expander.classList.remove('ico-arrowdown') - header.expander.classList.add('ico-arrowup') if (currentFloater) currentFloater.remove() }) /** @@ -1748,7 +1945,6 @@ export default class MarketsPage extends BasePage { this.handleBook(mktBook) this.market.bookLoaded = true this.updateTitle() - this.setMarketBuyOrderEstimate() } /* handleBookOrderRoute is the handler for 'book_order' notifications. */ @@ -1866,16 +2062,14 @@ export default class MarketsPage extends BasePage { } Doc.hide(this.page.forms) - this.balanceWgt.updateAsset(this.openAsset.id) } /* showVerify shows the form to accept the currently parsed order information * and confirm submission of the order to the dex. */ - showVerify () { + showVerify (order: TradeForm) { this.preorderCache = {} const page = this.page - const order = this.currentOrder = this.parseOrder() const isSell = order.sell const baseAsset = app().assets[order.base] const quoteAsset = app().assets[order.quote] @@ -1919,38 +2113,16 @@ export default class MarketsPage extends BasePage { const buySellStr = isSell ? intl.prep(intl.ID_SELL) : intl.prep(intl.ID_BUY) page.vSideSubmit.textContent = buySellStr page.vOrderHost.textContent = order.host - if (order.isLimit) { - Doc.show(page.verifyLimit) - Doc.hide(page.verifyMarket) - const orderDesc = `Limit ${buySellStr} Order` - page.vOrderType.textContent = order.tifnow ? orderDesc + ' (immediate)' : orderDesc - page.vRate.textContent = Doc.formatCoinValue(order.rate / this.market.rateConversionFactor) - page.vQty.textContent = Doc.formatCoinValue(order.qty, baseAsset.unitInfo) - const total = order.rate / OrderUtil.RateEncodingFactor * order.qty - page.vTotal.textContent = Doc.formatCoinValue(total, quoteAsset.unitInfo) - // Format total fiat value. - this.showFiatValue(quoteAsset.id, total, page.vFiatTotal) - } else { - Doc.hide(page.verifyLimit) - Doc.show(page.verifyMarket) - page.vOrderType.textContent = `Market ${buySellStr} Order` - const ui = order.sell ? this.market.baseUnitInfo : this.market.quoteUnitInfo - page.vmFromTotal.textContent = Doc.formatCoinValue(order.qty, ui) - page.vmFromAsset.textContent = fromAsset.symbol.toUpperCase() - // Format fromAsset fiat value. - this.showFiatValue(fromAsset.id, order.qty, page.vmFromTotalFiat) - const gap = this.midGap() - if (gap) { - Doc.show(page.vMarketEstimate) - const received = order.sell ? order.qty * gap : order.qty / gap - page.vmToTotal.textContent = Doc.formatCoinValue(received, toAsset.unitInfo) - page.vmToAsset.textContent = toAsset.symbol.toUpperCase() - // Format received value to fiat equivalent. - this.showFiatValue(toAsset.id, received, page.vmTotalFiat) - } else { - Doc.hide(page.vMarketEstimate) - } - } + Doc.show(page.verifyLimit) + Doc.hide(page.verifyMarket) + const orderDesc = `Limit ${buySellStr} Order` + page.vOrderType.textContent = order.tifnow ? orderDesc + ' (immediate)' : orderDesc + page.vRate.textContent = Doc.formatCoinValue(order.rate / this.market.rateConversionFactor) + page.vQty.textContent = Doc.formatCoinValue(order.qty, baseAsset.unitInfo) + const total = order.rate / OrderUtil.RateEncodingFactor * order.qty + page.vTotal.textContent = Doc.formatCoinValue(total, quoteAsset.unitInfo) + // Format total fiat value. + this.showFiatValue(quoteAsset.id, total, page.vFiatTotal) // Visually differentiate between buy/sell orders. if (isSell) { page.vHeader.classList.add(sellBtnClass) @@ -1999,7 +2171,7 @@ export default class MarketsPage extends BasePage { await this.unlockMarketWallets() loaded() Doc.show(page.vPreorder) - this.preOrder(this.parseOrder()) + this.preOrder(this.parseOrderBuy()) } async unlockWallet (assetID: number) { @@ -2007,7 +2179,6 @@ export default class MarketsPage extends BasePage { if (!app().checkResponse(res)) { throw Error('error unlocking wallet ' + res.msg) } - this.balanceWgt.updateAsset(assetID) } /* @@ -2160,7 +2331,7 @@ export default class MarketsPage extends BasePage { let [toFeeAssetUI, fromFeeAssetUI] = [baseFeeAssetUI, quoteFeeAssetUI] let [toExchangeRate, fromExchangeRate] = [baseExchangeRate, quoteExchangeRate] - if (this.currentOrder.sell) { + if (order.sell) { [fromFeeAssetUI, toFeeAssetUI] = [toFeeAssetUI, fromFeeAssetUI]; [fromExchangeRate, toExchangeRate] = [toExchangeRate, fromExchangeRate] } @@ -2182,7 +2353,7 @@ export default class MarketsPage extends BasePage { page.vSwapFeesMax.textContent = Doc.formatCoinValue(swap.estimate.maxFees, fromFeeAssetUI) // Set redemption fee estimates in the details pane. - const midGap = this.midGap() + const midGap = this.midGapAtoms() const estRate = midGap || order.rate / rateConversionFactor const received = order.sell ? swapped * estRate : swapped / estRate const receivedInParentUnits = toExchangeRate > 0 ? received / toExchangeRate : received @@ -2266,30 +2437,91 @@ export default class MarketsPage extends BasePage { } /* - * stepSubmit will examine the current state of wallets and step the user + * stepSubmitBuy will examine the current state of wallets and step the user * through the process of order submission. * NOTE: I expect this process will be streamlined soon such that the wallets * will attempt to be unlocked in the order submission process, negating the * need to unlock ahead of time. */ - stepSubmit () { + stepSubmitBuy () { const page = this.page const market = this.market - Doc.hide(page.orderErr) - if (!this.validateOrder(this.parseOrder())) return + + Doc.hide(page.orderErrBuy) + + const showError = function (err: string, args?: Record) { + page.orderErrBuy.textContent = intl.prep(err, args) + Doc.show(page.orderErrBuy) + } + + const order = this.parseOrderBuy() + + // imitate order button click + this.setOrderBttnBuyEnabled(false) + setTimeout(() => { + this.updateOrderBttnBuyState(order) + }, 300) // 300ms seems fluent + + const valid = this.validateOrderBuy(order) + if (!valid) { + return + } const baseWallet = app().walletMap[market.base.id] const quoteWallet = app().walletMap[market.quote.id] if (!baseWallet) { - page.orderErr.textContent = intl.prep(intl.ID_NO_ASSET_WALLET, { asset: market.base.symbol }) - Doc.show(page.orderErr) + showError(intl.ID_NO_ASSET_WALLET, { asset: market.base.symbol }) return } if (!quoteWallet) { - page.orderErr.textContent = intl.prep(intl.ID_NO_ASSET_WALLET, { asset: market.quote.symbol }) - Doc.show(page.orderErr) + showError(intl.ID_NO_ASSET_WALLET, { asset: market.quote.symbol }) return } - this.showVerify() + this.verifiedOrder = order + this.showVerify(this.verifiedOrder) + } + + /* + * stepSubmitSell will examine the current state of wallets and step the user + * through the process of order submission. + * NOTE: I expect this process will be streamlined soon such that the wallets + * will attempt to be unlocked in the order submission process, negating the + * need to unlock ahead of time. + */ + stepSubmitSell () { + const page = this.page + const market = this.market + + Doc.hide(page.orderErrSell) + + const showError = function (err: string, args?: Record) { + page.orderErrSell.textContent = intl.prep(err, args) + Doc.show(page.orderErrSell) + } + + const order = this.parseOrderSell() + + // imitate order button click + this.setOrderBttnSellEnabled(false) + setTimeout(() => { + this.updateOrderBttnSellState(order) + }, 300) // 300ms seems fluent + + const valid = this.validateOrderSell(order) + if (!valid) { + return + } + const baseWallet = app().walletMap[market.base.id] + const quoteWallet = app().walletMap[market.quote.id] + if (!baseWallet) { + showError(intl.ID_NO_ASSET_WALLET, { asset: market.base.symbol }) + return + } + if (!quoteWallet) { + showError(intl.ID_NO_ASSET_WALLET, { asset: market.quote.symbol }) + return + } + this.verifiedOrder = order + this.showVerify(this.verifiedOrder) } /* Display a deposit address. */ @@ -2373,7 +2605,7 @@ export default class MarketsPage extends BasePage { anyRate (): [number, number, number] { const { cfg: { spot }, baseCfg: { id: baseID }, quoteCfg: { id: quoteID }, rateConversionFactor, bookLoaded } = this.market if (bookLoaded) { - const midGap = this.midGap() + const midGap = this.midGapAtoms() if (midGap) return [midGap * OrderUtil.RateEncodingFactor, midGap, this.midGapConventional() || 0] } if (spot && spot.rate) return [spot.rate, spot.rate / OrderUtil.RateEncodingFactor, spot.rate / rateConversionFactor] @@ -2518,43 +2750,35 @@ export default class MarketsPage extends BasePage { if (!mkt || !mkt.dex || mkt.dex.connectionStatus !== ConnectionStatus.Connected) return this.mm.handleBalanceNote(note) - const wgt = this.balanceWgt - // Display the widget if the balance note is for its base or quote wallet. - if ((note.assetID === wgt.base.id || note.assetID === wgt.quote.id)) wgt.setBalanceVisibility(true) // If there's a balance update, refresh the max order section. const avail = note.balance.available - switch (note.assetID) { - case mkt.baseCfg.id: - // If we're not showing the max order panel yet, don't do anything. - if (!mkt.maxSell) break - if (typeof mkt.sellBalance === 'number' && mkt.sellBalance !== avail) mkt.maxSell = null - if (this.isSell()) this.preSell() - break - case mkt.quoteCfg.id: - if (!Object.keys(mkt.maxBuys).length) break - if (typeof mkt.buyBalance === 'number' && mkt.buyBalance !== avail) mkt.maxBuys = {} - if (!this.isSell()) this.preBuy() + if (note.assetID === mkt.baseCfg.id) { + if (mkt.sellBalance !== avail) mkt.maxSell = null + this.previewMaxSell() + } + if (note.assetID === mkt.quoteCfg.id) { + if (mkt.buyBalance !== avail) mkt.maxBuys = {} + this.previewMaxBuy() } } /* - * submitOrder is attached to the affirmative button on the order validation + * submitVerifiedOrder is attached to the affirmative button on the order validation * form. Clicking the button is the last step in the order submission process. */ - async submitOrder () { + async submitVerifiedOrder () { const page = this.page - Doc.hide(page.orderErr, page.vErr) - const order = this.currentOrder - const req = { order: wireOrder(order) } - if (!this.validateOrder(order)) return + Doc.hide(page.vErr) + const req = { order: wireOrder(this.verifiedOrder) } // Show loader and hide submit button. page.vSubmit.classList.add('d-hide') page.vLoader.classList.remove('d-hide') + Doc.hide(page.vSubmit) + Doc.show(page.vLoader) const res = await postJSON('/api/tradeasync', req) - // Hide loader and show submit button. - page.vSubmit.classList.remove('d-hide') - page.vLoader.classList.add('d-hide') + Doc.hide(page.vLoader) + Doc.show(page.vSubmit) // If error, display error on confirmation modal. if (!app().checkResponse(res)) { page.vErr.textContent = res.msg @@ -2579,115 +2803,355 @@ export default class MarketsPage extends BasePage { const mkt = this.market if (mkt.baseCfg.id === asset.id) mkt.base = asset else if (mkt.quoteCfg.id === asset.id) mkt.quote = asset - this.balanceWgt.updateAsset(asset.id) this.displayMessageIfMissingWallet() this.resolveOrderFormVisibility() } - /* lotChanged is attached to the keyup and change events of the lots input. */ - lotChanged () { + lotFieldBuyInputHandler () { const page = this.page - const lots = parseInt(page.lotField.value || '0') - if (lots <= 0) { - page.lotField.value = page.lotField.value === '' ? '' : '0' - page.qtyField.value = '' - this.previewQuoteAmt(false) - this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_QTY_ERROR)) + + const [inputValid,,, adjQty] = this.parseLotInput(page.lotFieldBuy.value) + if (!inputValid) { + page.orderTotalPreviewBuy.textContent = '' + page.lotFieldBuy.value = '' + page.qtyFieldBuy.value = '' return } - const lotSize = this.market.cfg.lotsize - const orderQty = lots * lotSize - page.lotField.value = String(lots) - // Conversion factor must be a multiple of 10. - page.qtyField.value = String(orderQty / this.market.baseUnitInfo.conventional.conversionFactor) + // Lots and quantity fields are tightly coupled to each other, when one is + // changed, we need to update the other one as well. + page.qtyFieldBuy.value = String(adjQty) + + this.previewTotalBuy() + } - if (!this.isLimit() && this.isSell()) { - const baseWallet = app().assets[this.market.base.id].wallet - this.setOrderBttnEnabled(orderQty <= baseWallet.balance.available, intl.prep(intl.ID_ORDER_BUTTON_SELL_BALANCE_ERROR)) + lotFieldSellInputHandler () { + const page = this.page + + const [inputValid,,, adjQty] = this.parseLotInput(page.lotFieldSell.value) + if (!inputValid) { + page.orderTotalPreviewSell.textContent = '' + page.lotFieldSell.value = '' + page.qtyFieldSell.value = '' + return } - this.previewQuoteAmt(true) + // Lots and quantity fields are tightly coupled to each other, when one is + // changed, we need to update the other one as well. + page.qtyFieldSell.value = String(adjQty) + + this.previewTotalSell() } - /* - * quantityChanged is attached to the keyup and change events of the quantity - * input. - */ - quantityChanged (finalize: boolean) { + lotFieldBuyChangeHandler () { const page = this.page - const order = this.currentOrder = this.parseOrder() - if (order.qty < 0) { - page.lotField.value = '0' - page.qtyField.value = '' - this.previewQuoteAmt(false) + + const [inputValid, adjusted, adjLots, adjQty] = this.parseLotInput(page.lotFieldBuy.value) + if (!inputValid || adjusted) { + // Disable submit button temporarily (that additionally draws his + // attention to order-form) to prevent user clicking on it while input + // auto-adjusting is in progress. Otherwise, he might not notice the rounding. + this.setOrderBttnBuyEnabled(false) + // Let the user know that lot value he's entered was rounded down to the + // nearest integer number. + this.animateErrors(highlightOutlineRed(page.lotFieldBuy), highlightBackgroundRed(page.lotSizeBoxBuy)) + } + if (!inputValid) { + page.orderTotalPreviewBuy.textContent = '' + page.lotFieldBuy.value = '' + page.qtyFieldBuy.value = '' return } - const lotSize = this.market.cfg.lotsize - const lots = Math.floor(order.qty / lotSize) - const adjusted = order.qty = this.currentOrder.qty = lots * lotSize - page.lotField.value = String(lots) + // Lots and quantity fields are tightly coupled to each other, when one is + // changed, we need to update the other one as well. + page.lotFieldBuy.value = String(adjLots) + page.qtyFieldBuy.value = String(adjQty) + + this.previewTotalBuy() + } + + lotFieldSellChangeHandler () { + const page = this.page - if (!order.isLimit && !order.sell) return + const [inputValid, adjusted, adjLots, adjQty] = this.parseLotInput(page.lotFieldSell.value) + if (!inputValid || adjusted) { + // Disable submit button temporarily (that additionally draws his + // attention to order-form) to prevent user clicking on it while input + // auto-adjusting is in progress. Otherwise, he might not notice the rounding. + this.setOrderBttnSellEnabled(false) + // Let the user know that lot value he's entered was rounded down to the + // nearest integer number. + this.animateErrors(highlightOutlineRed(page.lotFieldSell), highlightBackgroundRed(page.lotSizeBoxSell)) + } + if (!inputValid) { + page.orderTotalPreviewSell.textContent = '' + page.lotFieldSell.value = '' + page.qtyFieldSell.value = '' + return + } + // Lots and quantity fields are tightly coupled to each other, when one is + // changed, we need to update the other one as well. + page.lotFieldSell.value = String(adjLots) + page.qtyFieldSell.value = String(adjQty) - // Conversion factor must be a multiple of 10. - if (finalize) page.qtyField.value = String(adjusted / this.market.baseUnitInfo.conventional.conversionFactor) - this.previewQuoteAmt(true) + this.previewTotalSell() } - /* - * marketBuyChanged is attached to the keyup and change events of the quantity - * input for the market-buy form. + /** + * parseLotInput parses lot input and returns: + * 1) whether there are any parsing issues (true if none, false when + * parsing fails) + * 2) whether rounding(adjustment) had happened (true when did) + * 3) adjusted lot value + * 4) adjusted quantity value + * + * If lot value couldn't be parsed (parsing issues), the following + * values are returned: [false, false, 0, 0]. */ - marketBuyChanged () { + parseLotInput (value: string | undefined): [boolean, boolean, number, number] { + const { page, market: { baseUnitInfo: bui, cfg: { lotsize: lotSize } } } = this + + Doc.hide(page.orderErrBuy) + Doc.hide(page.orderErrSell) + + const lotsAdj = parseInt(value || '') + if (isNaN(lotsAdj) || lotsAdj < 0) { + return [false, false, 0, 0] + } + + const rounded = String(lotsAdj) !== value + const adjQty = lotsAdj * lotSize / bui.conventional.conversionFactor + + return [true, rounded, lotsAdj, adjQty] + } + + qtyFieldBuyInputHandler () { const page = this.page - const qty = convertToAtoms(page.mktBuyField.value || '', this.market.quoteUnitInfo.conventional.conversionFactor) - const gap = this.midGap() - if (qty > 0) { - const quoteWallet = app().assets[this.market.quote.id].wallet - this.setOrderBttnEnabled(qty <= quoteWallet.balance.available, intl.prep(intl.ID_ORDER_BUTTON_BUY_BALANCE_ERROR)) - } else { - this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_QTY_ERROR)) + + const [inputValid,, adjLots] = this.parseQtyInput(page.qtyFieldBuy.value) + if (!inputValid) { + page.orderTotalPreviewBuy.textContent = '' + page.lotFieldBuy.value = '' + page.qtyFieldBuy.value = '' + return } - if (!gap || !qty) { - page.mktBuyLots.textContent = '0' - page.mktBuyScore.textContent = '0' + // Lots and quantity fields are tightly coupled to each other, when one is + // changed, we need to update the other one as well. + page.lotFieldBuy.value = String(adjLots) + + this.previewTotalBuy() + } + + qtyFieldSellInputHandler () { + const page = this.page + + const [inputValid,, adjLots] = this.parseQtyInput(page.qtyFieldSell.value) + if (!inputValid) { + page.orderTotalPreviewSell.textContent = '' + page.lotFieldSell.value = '' + page.qtyFieldSell.value = '' return } - const lotSize = this.market.cfg.lotsize - const received = qty / gap - const lots = (received / lotSize) - page.mktBuyLots.textContent = lots.toFixed(1) - page.mktBuyScore.textContent = Doc.formatCoinValue(received, this.market.baseUnitInfo) + // Lots and quantity fields are tightly coupled to each other, when one is + // changed, we need to update the other one as well. + page.lotFieldSell.value = String(adjLots) + + this.previewTotalSell() } - /* - * rateFieldChanged is attached to the keyup and change events of the rate - * input. + qtyFieldBuyChangeHandler () { + const page = this.page + + const [inputValid, adjusted, adjLots, adjQty] = this.parseQtyInput(page.qtyFieldBuy.value) + if (!inputValid || adjusted) { + // Disable submit button temporarily (that additionally draws his + // attention to order-form) to prevent user clicking on it while input + // auto-adjusting is in progress. Otherwise, he might not notice the rounding. + this.setOrderBttnBuyEnabled(false) + // Let the user know that quantity he's entered was rounded down. + this.animateErrors(highlightOutlineRed(page.qtyFieldBuy), highlightBackgroundRed(page.lotSizeBoxBuy)) + } + if (!inputValid) { + page.orderTotalPreviewBuy.textContent = '' + page.lotFieldBuy.value = '' + page.qtyFieldBuy.value = '' + return + } + // Lots and quantity fields are tightly coupled to each other, when one is + // changed, we need to update the other one as well. + page.lotFieldBuy.value = String(adjLots) + page.qtyFieldBuy.value = String(adjQty) + + this.previewTotalBuy() + } + + qtyFieldSellChangeHandler () { + const page = this.page + + const [inputValid, adjusted, adjLots, adjQty] = this.parseQtyInput(page.qtyFieldSell.value) + if (!inputValid || adjusted) { + // Disable submit button temporarily (that additionally draws his + // attention to order-form) to prevent user clicking on it while input + // auto-adjusting is in progress. Otherwise, he might not notice the rounding. + this.setOrderBttnSellEnabled(false) + // Let the user know that quantity he's entered was rounded down. + this.animateErrors(highlightOutlineRed(page.qtyFieldSell), highlightBackgroundRed(page.lotSizeBoxSell)) + } + if (!inputValid) { + page.orderTotalPreviewSell.textContent = '' + page.lotFieldSell.value = '' + page.qtyFieldSell.value = '' + return + } + // Lots and quantity fields are tightly coupled to each other, when one is + // changed, we need to update the other one as well. + page.lotFieldSell.value = String(adjLots) + page.qtyFieldSell.value = String(adjQty) + + this.previewTotalSell() + } + + /** + * parseQtyInput parses quantity input and returns: + * 1) whether there are any parsing issues (true if none, false when + * parsing fails) + * 2) whether rounding(adjustment) had happened (true when did) + * 3) adjusted lot value + * 4) adjusted quantity value + * + * If quantity value couldn't be parsed (parsing issues), the following + * values are returned: [false, false, 0, 0]. */ - rateFieldChanged () { - // Truncate to rate step. If it is a market buy order, do not adjust. - const adjusted = this.adjustedRate() - if (adjusted <= 0) { - this.depthLines.input = [] - this.page.rateField.value = '0' - this.previewQuoteAmt(true) - this.updateOrderBttnState() + parseQtyInput (value: string | undefined): [boolean, boolean, number, number] { + const { page, market: { baseUnitInfo: bui, cfg: { lotsize: lotSizeAtom } } } = this + + Doc.hide(page.orderErrBuy) + Doc.hide(page.orderErrSell) + + const qtyRawAtom = convertToAtoms(value || '', bui.conventional.conversionFactor) + if (isNaN(qtyRawAtom) || qtyRawAtom < 0) { + return [false, false, 0, 0] + } + + const lotsRaw = qtyRawAtom / lotSizeAtom + const adjLots = Math.floor(lotsRaw) + const adjQtyAtom = adjLots * lotSizeAtom + const rounded = adjQtyAtom !== qtyRawAtom + const adjQty = adjQtyAtom / bui.conventional.conversionFactor + + return [true, rounded, adjLots, adjQty] + } + + rateFieldBuyInputHandler () { + const page = this.page + + const [inputValid] = this.parseRateInput(this.page.rateFieldBuy.value) + if (!inputValid) { + page.orderTotalPreviewBuy.textContent = '' + this.previewMaxBuy() + return + } + + this.previewMaxBuy() + this.previewTotalBuy() + } + + rateFieldSellInputHandler () { + const page = this.page + + const [inputValid] = this.parseRateInput(this.page.rateFieldSell.value) + if (!inputValid) { + page.orderTotalPreviewSell.textContent = '' + this.previewMaxSell() + return + } + + this.previewMaxSell() + this.previewTotalSell() + } + + rateFieldBuyChangeHandler () { + const page = this.page + + const [inputValid, adjusted, adjRate] = this.parseRateInput(this.page.rateFieldBuy.value) + if (!inputValid || adjusted) { + // Disable submit button temporarily (that additionally draws his + // attention to order-form) to prevent user clicking on it while input + // auto-adjusting is in progress. Otherwise, he might not notice the rounding. + this.setOrderBttnBuyEnabled(false) + // Let the user know that rate he's entered is invalid or was rounded down. + this.animateErrors(highlightOutlineRed(page.rateFieldBuy), highlightBackgroundRed(page.rateStepBoxBuy)) + } + if (!inputValid) { + page.rateFieldBuy.value = '' + page.orderTotalPreviewBuy.textContent = '' + this.previewMaxBuy() return } - this.currentOrder = this.parseOrder() - const r = adjusted / this.market.rateConversionFactor - this.page.rateField.value = String(r) - this.previewQuoteAmt(true) - this.updateOrderBttnState() + page.rateFieldBuy.value = String(adjRate) + + this.previewMaxBuy() + this.previewTotalBuy() + } + + rateFieldSellChangeHandler () { + const page = this.page + + const [inputValid, adjusted, adjRate] = this.parseRateInput(this.page.rateFieldSell.value) + if (!inputValid || adjusted) { + // Disable submit button temporarily (that additionally draws his + // attention to order-form) to prevent user clicking on it while input + // auto-adjusting is in progress. Otherwise, he might not notice the rounding. + this.setOrderBttnSellEnabled(false) + // Let the user know that rate he's entered is invalid or was rounded down. + this.animateErrors(highlightOutlineRed(page.rateFieldSell), highlightBackgroundRed(page.rateStepBoxSell)) + } + if (!inputValid) { + page.rateFieldSell.value = '' + page.orderTotalPreviewSell.textContent = '' + this.previewMaxSell() + return + } + page.rateFieldSell.value = String(adjRate) + + this.previewMaxSell() + this.previewTotalSell() + } + + /** + * parseRateInput parses rate(price) string (in conventional units) and returns: + * 1) whether there are any parsing issues (true if none, false when + * parsing fails) + * 2) whether rounding(adjustment) to rate-step had happened (true when did) + * 3) adjusted rate(price) value + */ + parseRateInput (rateStr: string | undefined): [boolean, boolean, number] { + const page = this.page + + Doc.hide(page.orderErrBuy) + Doc.hide(page.orderErrSell) + + const rawRateAtom = this.rateAtoms(rateStr) + const adjRateAtom = this.adjustedRateAtoms(rateStr) + const rateParsingIssue = isNaN(rawRateAtom) || rawRateAtom <= 0 + const rounded = adjRateAtom !== rawRateAtom + const adjRate = adjRateAtom / this.market.rateConversionFactor + + return [!rateParsingIssue, rounded, adjRate] + } + + /* + * rateAtoms is the current rate field value in atoms. + */ + rateAtoms (rateStr: string | undefined): number { + if (!rateStr) return NaN + return convertToAtoms(rateStr, this.market.rateConversionFactor) } /* - * adjustedRate is the current rate field rate, rounded down to a - * multiple of rateStep. + * adjustedRateAtoms is the current rate field value in atoms, rounded down + * to a multiple of rateStep. */ - adjustedRate (): number { - const v = this.page.rateField.value - if (!v) return NaN - const rate = convertToAtoms(v, this.market.rateConversionFactor) + adjustedRateAtoms (rateStr: string | undefined): number { + const rate = this.rateAtoms(rateStr) const rateStep = this.market.cfg.ratestep return rate - (rate % rateStep) } @@ -2906,6 +3370,19 @@ export default class MarketsPage extends BasePage { Doc.unbind(document, 'keyup', this.keyup) clearInterval(this.secondTicker) } + + animateErrors (...animations: (() => Animation)[]) { + for (const ani of this.runningErrAnimations) { + // Note, animation might still continue executing in background for 1 tick, + // that shouldn't result in any issues for us though. + ani.stop() + } + + this.runningErrAnimations = [] + for (const ani of animations) { + this.runningErrAnimations.push(ani()) + } + } } /* @@ -3037,201 +3514,6 @@ class MarketRow { } } -interface BalanceWidgetElement { - id: number - parentID: number - cfg: Asset | null - node: PageElement - tmpl: Record - iconBox: PageElement - stateIcons: WalletIcons - parentBal?: PageElement -} - -/* - * BalanceWidget is a display of balance information. Because the wallet can be - * in any number of states, and because every exchange has different funding - * coin confirmation requirements, the BalanceWidget displays a number of state - * indicators and buttons, as well as tabulated balance data with rows for - * locked and immature balance. - */ -class BalanceWidget { - base: BalanceWidgetElement - quote: BalanceWidgetElement - // parentRow: PageElement - dex: Exchange - - constructor (base: HTMLElement, quote: HTMLElement) { - Doc.hide(base, quote) - const btmpl = Doc.parseTemplate(base) - this.base = { - id: 0, - parentID: parentIDNone, - cfg: null, - node: base, - tmpl: btmpl, - iconBox: btmpl.walletState, - stateIcons: new WalletIcons(btmpl.walletState) - } - btmpl.balanceRowTmpl.remove() - - const qtmpl = Doc.parseTemplate(quote) - this.quote = { - id: 0, - parentID: parentIDNone, - cfg: null, - node: quote, - tmpl: qtmpl, - iconBox: qtmpl.walletState, - stateIcons: new WalletIcons(qtmpl.walletState) - } - qtmpl.balanceRowTmpl.remove() - - app().registerNoteFeeder({ - balance: (note: BalanceNote) => { this.updateAsset(note.assetID) }, - walletstate: (note: WalletStateNote) => { this.updateAsset(note.wallet.assetID) }, - walletsync: (note: WalletSyncNote) => { this.updateAsset(note.assetID) }, - createwallet: (note: WalletCreationNote) => { this.updateAsset(note.assetID) } - }) - } - - setBalanceVisibility (connected: boolean) { - if (connected) Doc.show(this.base.node, this.quote.node) - else Doc.hide(this.base.node, this.quote.node) - } - - /* - * setWallet sets the balance widget to display data for specified market and - * will display the widget. - */ - setWallets (host: string, baseID: number, quoteID: number) { - const parentID = (assetID: number) => { - const asset = app().assets[assetID] - if (asset?.token) return asset.token.parentID - return parentIDNone - } - this.dex = app().user.exchanges[host] - this.base.id = baseID - this.base.parentID = parentID(baseID) - this.base.cfg = this.dex.assets[baseID] - this.quote.id = quoteID - this.quote.parentID = parentID(quoteID) - this.quote.cfg = this.dex.assets[quoteID] - this.updateWallet(this.base) - this.updateWallet(this.quote) - this.setBalanceVisibility(this.dex.connectionStatus === ConnectionStatus.Connected) - } - - /* - * updateWallet updates the displayed wallet information based on the - * core.Wallet state. - */ - updateWallet (side: BalanceWidgetElement) { - const { cfg, tmpl, iconBox, stateIcons, id: assetID } = side - if (!cfg) return // no wallet set yet - const asset = app().assets[assetID] - // Just hide everything to start. - Doc.hide( - tmpl.newWalletRow, tmpl.expired, tmpl.unsupported, tmpl.connect, tmpl.spinner, - tmpl.walletState, tmpl.balanceRows, tmpl.walletAddr, tmpl.wantProvidersBox - ) - this.checkNeedsProvider(assetID, tmpl.wantProvidersBox) - tmpl.logo.src = Doc.logoPath(cfg.symbol) - tmpl.addWalletSymbol.textContent = cfg.symbol.toUpperCase() - Doc.empty(tmpl.symbol) - - // Handle an unsupported asset. - if (!asset) { - Doc.show(tmpl.unsupported) - return - } - tmpl.symbol.appendChild(Doc.symbolize(asset, true)) - Doc.show(iconBox) - const wallet = asset.wallet - stateIcons.readWallet(wallet) - // Handle no wallet configured. - if (!wallet) { - if (asset.walletCreationPending) { - Doc.show(tmpl.spinner) - return - } - Doc.show(tmpl.newWalletRow) - return - } - Doc.show(tmpl.walletAddr) - // Parent asset - const bal = wallet.balance - // Handle not connected and no balance known for the DEX. - if (!bal && !wallet.running && !wallet.disabled) { - Doc.show(tmpl.connect) - return - } - // If there is no balance, but the wallet is connected, show the loading - // icon while we fetch an update. - if (!bal) { - app().fetchBalance(assetID) - Doc.show(tmpl.spinner) - return - } - - // We have a wallet and a DEX-specific balance. Set all of the fields. - Doc.show(tmpl.balanceRows) - Doc.empty(tmpl.balanceRows) - const addRow = (title: string, bal: number, ui: UnitInfo, icon?: PageElement) => { - const row = tmpl.balanceRowTmpl.cloneNode(true) as PageElement - tmpl.balanceRows.appendChild(row) - const balTmpl = Doc.parseTemplate(row) - balTmpl.title.textContent = title - balTmpl.bal.textContent = Doc.formatCoinValue(bal, ui) - if (icon) { - balTmpl.bal.append(icon) - side.parentBal = balTmpl.bal - } - } - addRow(intl.prep(intl.ID_AVAILABLE), bal.available, asset.unitInfo) - addRow(intl.prep(intl.ID_LOCKED), bal.locked + bal.contractlocked + bal.bondlocked, asset.unitInfo) - addRow(intl.prep(intl.ID_IMMATURE), bal.immature, asset.unitInfo) - if (asset.token) { - const { wallet: { balance }, unitInfo, symbol } = app().assets[asset.token.parentID] - const icon = document.createElement('img') - icon.src = Doc.logoPath(symbol) - icon.classList.add('micro-icon', 'ms-1') - addRow(intl.prep(intl.ID_FEE_BALANCE), balance.available, unitInfo, icon) - } - - // If the current balance update time is older than an hour, show the - // expiration icon. Request a balance update, if possible. - const expired = new Date().getTime() - new Date(bal.stamp).getTime() > anHour - if (expired && !wallet.disabled) { - Doc.show(tmpl.expired) - if (wallet.running) app().fetchBalance(assetID) - } else Doc.hide(tmpl.expired) - } - - async checkNeedsProvider (assetID: number, el: PageElement) { - Doc.setVis(await app().needsCustomProvider(assetID), el) - } - - /* updateParent updates the side's parent asset balance. */ - updateParent (side: BalanceWidgetElement) { - const { wallet: { balance }, unitInfo } = app().assets[side.parentID] - // firstChild is the text node set before the img child node in addRow. - if (side.parentBal?.firstChild) side.parentBal.firstChild.textContent = Doc.formatCoinValue(balance.available, unitInfo) - } - - /* - * updateAsset updates the info for one side of the existing market. If the - * specified asset ID is not one of the current market's base or quote assets, - * it is silently ignored. - */ - updateAsset (assetID: number) { - if (assetID === this.base.id) this.updateWallet(this.base) - else if (assetID === this.quote.id) this.updateWallet(this.quote) - if (assetID === this.base.parentID) this.updateParent(this.base) - if (assetID === this.quote.parentID) this.updateParent(this.quote) - } -} - /* makeMarket creates a market object that specifies basic market details. */ function makeMarket (host: string, base?: number, quote?: number) { return { @@ -3250,12 +3532,6 @@ function convertToAtoms (s: string, conversionFactor: number) { return Math.round(parseFloat(s) * conversionFactor) } -/* swapBttns changes the 'selected' class of the buttons. */ -function swapBttns (before: HTMLElement, now: HTMLElement) { - before.classList.remove('selected') - now.classList.add('selected') -} - /* * wireOrder prepares a copy of the order with the options field converted to a * string -> string map. @@ -3463,3 +3739,46 @@ function hostColor (host: string): string { hosts.sort() return generateHue(hosts.indexOf(host)) } + +/** + * highlightBackgroundRed returns Animation-factory that will construct Animation that will + * change element background color to red and back in a smooth transition. + * Note: Animation will start when constructed by "new" ^ right away - that's why + * we return constructor-func here (aka factory), instead of constructing Animation + * right away. + */ +function highlightBackgroundRed (element: PageElement): () => Animation { + const [r, g, b, a] = State.isDark() ? [203, 94, 94, 0.8] : [153, 48, 43, 0.6] + return (): Animation => { + return new Animation(animationLength, (progress: number) => { + element.style.backgroundColor = `rgba(${r}, ${g}, ${b}, ${a - a * progress})` + }, + 'easeIn', + () => { + // Setting background color to 'none' SOMETIMES results in a no-op for some reason, wat. + // Hence, setting to 'transparent' instead. + element.style.backgroundColor = 'transparent' + }) + } +} + +/** + * highlightOutlineRed returns Animation-factory that will construct Animation that will + * change element outline color to red and back in a smooth transition. + * Note: Animation will start when constructed by "new" ^ right away - that's why + * we return constructor-func here (aka factory), instead of constructing Animation + * right away. + */ +function highlightOutlineRed (element: PageElement): () => Animation { + const [r, g, b, a] = State.isDark() ? [203, 94, 94, 0.8] : [153, 48, 43, 0.8] + return (): Animation => { + element.style.outline = '2px solid' + return new Animation(animationLength, (progress: number) => { + element.style.outlineColor = `rgba(${r}, ${g}, ${b}, ${a - a * progress})` + }, + 'easeIn', + () => { + element.style.outlineColor = 'transparent' + }) + } +} diff --git a/client/webserver/site/src/js/registry.ts b/client/webserver/site/src/js/registry.ts index 4133b891e6..c862f3666a 100644 --- a/client/webserver/site/src/js/registry.ts +++ b/client/webserver/site/src/js/registry.ts @@ -618,6 +618,8 @@ export interface PageElement extends HTMLElement { options?: HTMLOptionElement[] selectedIndex?: number disabled?: boolean + min?: string + step?: string } export interface BooleanConfig { @@ -694,8 +696,8 @@ export interface TradeForm { sell: boolean base: number quote: number - qty: number - rate: number + qty: number // in atoms + rate: number // in atoms tifnow: boolean options: Record }