decred.org/dcrdex@v1.0.5/client/webserver/site/src/js/forms.ts (about)

     1  import Doc, { Animation } from './doc'
     2  import { postJSON } from './http'
     3  import State from './state'
     4  import * as intl from './locales'
     5  import { Wave } from './charts'
     6  import {
     7    bondReserveMultiplier,
     8    perTierBaseParcelLimit,
     9    parcelLimitScoreMultiplier,
    10    strongTier
    11  } from './account'
    12  import {
    13    app,
    14    SupportedAsset,
    15    PageElement,
    16    WalletDefinition,
    17    ConfigOption,
    18    Exchange,
    19    Market,
    20    BondAsset,
    21    WalletState,
    22    BalanceNote,
    23    Order,
    24    XYRange,
    25    WalletStateNote,
    26    WalletSyncNote,
    27    WalletInfo,
    28    Token,
    29    WalletCreationNote,
    30    CoreNote,
    31    PrepaidBondID,
    32    WalletTransaction
    33  } from './registry'
    34  import { XYRangeHandler } from './opts'
    35  import { CoinExplorers } from './coinexplorers'
    36  import { MM, setCexElements } from './mmutil'
    37  
    38  interface ConfigOptionInput extends HTMLInputElement {
    39    configOpt: ConfigOption
    40  }
    41  
    42  interface ProgressPoint {
    43    stamp: number
    44    progress: number
    45  }
    46  
    47  interface CurrentAsset {
    48    asset: SupportedAsset
    49    parentAsset?: SupportedAsset
    50    winfo: WalletInfo | Token
    51    // selectedDef is used in a strange way for tokens. If a token's parent wallet
    52    // already exists, then selectedDef is going to be the Token.definition.
    53    // BUT, if the token's parent wallet doesn't exist yet, the NewWalletForm
    54    // operates in a combined configuration mode, and the selectedDef will be the
    55    // currently selected parent asset definition. There is no loss of info
    56    // in such a case, because the token wallet only has one definition.
    57    selectedDef: WalletDefinition
    58  }
    59  
    60  interface WalletConfig {
    61    assetID: number
    62    config: Record<string, string>
    63    walletType: string
    64  }
    65  
    66  interface FormsConfig {
    67    closed?: (closedForm: PageElement | undefined) => void
    68  }
    69  
    70  export class Forms {
    71    formsDiv: PageElement
    72    currentForm: PageElement | undefined
    73    currentFormID: string | undefined
    74    keyup: (e: KeyboardEvent) => void
    75    closed?: (closedForm: PageElement | undefined) => void
    76  
    77    constructor (formsDiv: PageElement, cfg?: FormsConfig) {
    78      this.formsDiv = formsDiv
    79      this.closed = cfg?.closed
    80  
    81      formsDiv.querySelectorAll('.form-closer').forEach(el => {
    82        Doc.bind(el, 'click', () => { this.close() })
    83      })
    84  
    85      Doc.bind(formsDiv, 'mousedown', (e: MouseEvent) => {
    86        if (!this.currentForm) return
    87        if (!Doc.mouseInElement(e, this.currentForm)) { this.close() }
    88      })
    89  
    90      this.keyup = (e: KeyboardEvent) => {
    91        if (e.key === 'Escape') {
    92          this.close()
    93        }
    94      }
    95      Doc.bind(document, 'keyup', this.keyup)
    96    }
    97  
    98    /* showForm shows a modal form with a little animation. */
    99    async show (form: HTMLElement, id?: string): Promise<void> {
   100      this.currentForm = form
   101      this.currentFormID = id
   102      Doc.hide(...Array.from(this.formsDiv.children))
   103      form.style.right = '10000px'
   104      Doc.show(this.formsDiv, form)
   105      const shift = (this.formsDiv.offsetWidth + form.offsetWidth) / 2
   106      await Doc.animate(animationLength, progress => {
   107        form.style.right = `${(1 - progress) * shift}px`
   108      }, 'easeOutHard')
   109      form.style.right = '0'
   110    }
   111  
   112    close (): void {
   113      Doc.hide(this.formsDiv)
   114      const closedForm = this.currentForm
   115      this.currentForm = undefined
   116      this.currentFormID = undefined
   117      if (this.closed) this.closed(closedForm)
   118    }
   119  
   120    exit () {
   121      Doc.unbind(document, 'keyup', this.keyup)
   122    }
   123  }
   124  
   125  /*
   126   * NewWalletForm should be used with the "newWalletForm" template. The enclosing
   127   * <form> element should be the first argument of the constructor.
   128   */
   129  export class NewWalletForm {
   130    page: Record<string, PageElement>
   131    form: HTMLElement
   132    success: (assetID: number) => void
   133    current: CurrentAsset
   134    subform: WalletConfigForm
   135    walletCfgGuide: PageElement
   136    parentSyncer: null | ((w: WalletState) => void)
   137    createUpdater: null | ((note: WalletCreationNote) => void)
   138  
   139    constructor (form: HTMLElement, success: (assetID: number) => void, backFunc?: () => void) {
   140      this.form = form
   141      this.success = success
   142      const page = this.page = Doc.parseTemplate(form)
   143  
   144      if (backFunc) {
   145        Doc.show(page.goBack)
   146        Doc.bind(page.goBack, 'click', () => { backFunc() })
   147      }
   148  
   149      Doc.empty(page.walletTabTmpl)
   150      page.walletTabTmpl.removeAttribute('id')
   151  
   152      // WalletConfigForm will set the global app variable.
   153      this.subform = new WalletConfigForm(page.walletSettings, true)
   154  
   155      this.walletCfgGuide = Doc.tmplElement(form, 'walletCfgGuide')
   156  
   157      bind(form, page.submitAdd, () => this.submit())
   158      bind(form, page.oneBttn, () => this.submit())
   159  
   160      app().registerNoteFeeder({
   161        walletstate: (note: WalletStateNote) => { this.reportWalletState(note.wallet) },
   162        walletsync: (note: WalletSyncNote) => { if (this.parentSyncer) this.parentSyncer(app().walletMap[note.assetID]) },
   163        createwallet: (note: WalletCreationNote) => { this.reportCreationUpdate(note) }
   164      })
   165    }
   166  
   167    /*
   168     * reportWalletState should be called when a 'walletstate' notification is
   169     * received.
   170     * TODO: Let form classes register for notifications.
   171     */
   172    reportWalletState (w: WalletState): void {
   173      if (this.parentSyncer) this.parentSyncer(w)
   174    }
   175  
   176    /*
   177     * reportWalletState should be called when a 'createwallet' notification is
   178     * received.
   179     */
   180    reportCreationUpdate (note: WalletCreationNote) {
   181      if (this.createUpdater) this.createUpdater(note)
   182    }
   183  
   184    async createWallet (assetID: number, walletType: string, parentForm?: WalletConfig) {
   185      const createForm = {
   186        assetID: assetID,
   187        pass: this.page.newWalletPass.value || '',
   188        config: this.subform.map(assetID),
   189        walletType: walletType,
   190        parentForm: parentForm
   191      }
   192  
   193      const ani = new Wave(this.form, { backgroundColor: true })
   194      const res = await postJSON('/api/newwallet', createForm)
   195      ani.stop()
   196      return res
   197    }
   198  
   199    async submit () {
   200      const page = this.page
   201      const newWalletPass = page.newWalletPass as HTMLInputElement
   202      Doc.hide(page.newWalletErr)
   203  
   204      const { asset, parentAsset } = this.current
   205      const selectedDef = this.current.selectedDef
   206      let parentForm
   207      let walletType = selectedDef.type
   208      if (parentAsset) {
   209        walletType = (asset.token as Token).definition.type
   210        parentForm = {
   211          assetID: parentAsset.id,
   212          config: this.subform.map(parentAsset.id),
   213          walletType: selectedDef.type
   214        }
   215      }
   216      // Register the selected asset.
   217      const res = await this.createWallet(asset.id, walletType, parentForm)
   218      if (!app().checkResponse(res)) {
   219        this.setError(res.msg)
   220        return
   221      }
   222      newWalletPass.value = ''
   223      if (parentAsset) await this.runParentSync()
   224      else this.success(this.current.asset.id)
   225    }
   226  
   227    /*
   228     * runParentSync shows a syncing sub-dialog that tracks the parent asset's
   229     * syncProgress and informs the user that the token wallet will be created
   230     * after sync is complete.
   231     */
   232    async runParentSync () {
   233      const { page, current: { parentAsset, asset } } = this
   234      if (!parentAsset) return
   235  
   236      page.parentSyncPct.textContent = '0'
   237      page.parentName.textContent = parentAsset.name
   238      page.parentLogo.src = Doc.logoPath(parentAsset.symbol)
   239      page.childName.textContent = asset.name
   240      page.childLogo.src = Doc.logoPath(asset.symbol)
   241      Doc.hide(page.mainForm)
   242      Doc.show(page.parentSyncing)
   243  
   244      try {
   245        await this.syncParent(parentAsset)
   246        this.success(this.current.asset.id)
   247      } catch (error) {
   248        this.setError(error.message || error)
   249      }
   250      Doc.show(page.mainForm)
   251      Doc.hide(page.parentSyncing)
   252    }
   253  
   254    /*
   255     * syncParent monitors the sync progress of a token's parent asset, generating
   256     * an Error if the token wallet creation does not complete successfully.
   257     */
   258    syncParent (parentAsset: SupportedAsset): Promise<void> {
   259      const { page, current: { asset } } = this
   260      return new Promise((resolve, reject) => {
   261        // First, check if it's already synced.
   262        const w = app().assets[parentAsset.id].wallet
   263        if (w && w.synced) return resolve()
   264        // Not synced, so create a syncer to update the parent sync pane.
   265        this.parentSyncer = (w: WalletState) => {
   266          if (w.assetID !== parentAsset.id) return
   267          page.parentSyncPct.textContent = (w.syncProgress * 100).toFixed(1)
   268        }
   269        // Handle the async result.
   270        this.createUpdater = (note: WalletCreationNote) => {
   271          if (note.assetID !== asset.id) return
   272          switch (note.topic) {
   273            case 'QueuedCreationFailed':
   274              reject(new Error(`${note.subject}: ${note.details}`))
   275              break
   276            case 'QueuedCreationSuccess':
   277              resolve()
   278              break
   279            default:
   280              return
   281          }
   282          this.parentSyncer = null
   283          this.createUpdater = null
   284        }
   285      })
   286    }
   287  
   288    /* setAsset sets the current asset of the NewWalletForm */
   289    async setAsset (assetID: number) {
   290      if (!this.parseAsset(assetID)) return // nothing to change
   291      const page = this.page
   292      const tabs = page.walletTypeTabs
   293      const { winfo, asset, parentAsset } = this.current
   294      page.assetName.textContent = winfo.name
   295      page.newWalletPass.value = ''
   296  
   297      Doc.empty(tabs)
   298      Doc.hide(tabs, page.newWalletErr, page.tokenMsgBox)
   299      this.page.assetLogo.src = Doc.logoPath(asset.symbol)
   300      if (parentAsset) {
   301        page.tokenParentLogo.src = Doc.logoPath(parentAsset.symbol)
   302        page.tokenParentName.textContent = parentAsset.name
   303        Doc.show(page.tokenMsgBox)
   304      }
   305  
   306      const pinfo = parentAsset ? parentAsset.info : null
   307      const walletDefs = pinfo ? pinfo.availablewallets : (winfo as WalletInfo).availablewallets ? (winfo as WalletInfo).availablewallets : [(winfo as Token).definition]
   308  
   309      if (walletDefs.length > 1) {
   310        Doc.show(tabs)
   311        for (const wDef of walletDefs) {
   312          const tab = page.walletTabTmpl.cloneNode(true) as HTMLElement
   313          tab.dataset.tooltip = wDef.description
   314          tab.textContent = wDef.tab
   315          tabs.appendChild(tab)
   316          Doc.bind(tab, 'click', () => {
   317            for (const t of Doc.kids(tabs)) t.classList.remove('selected')
   318            tab.classList.add('selected')
   319            this.update(wDef)
   320          })
   321        }
   322        app().bindTooltips(tabs)
   323        const first = tabs.firstChild as HTMLElement
   324        first.classList.add('selected')
   325      }
   326  
   327      await this.update(this.current.selectedDef)
   328      if (asset.walletCreationPending) await this.runParentSync()
   329    }
   330  
   331    /*
   332    * parseAsset parses the current data for the asset ID.
   333    */
   334    parseAsset (assetID: number) {
   335      if (this.current && this.current.asset.id === assetID) return false
   336      const asset = app().assets[assetID]
   337      const token = asset.token
   338      if (!token) {
   339        if (!asset.info) throw Error('this non-token asset has no wallet info!')
   340        this.current = { asset, winfo: asset.info, selectedDef: asset.info.availablewallets[0] }
   341        return true
   342      }
   343      const parentAsset = app().user.assets[token.parentID]
   344      if (parentAsset.wallet) {
   345        // If the parent asset already has a wallet, there's no need to configure
   346        // the parent too. Just configure the token.
   347        this.current = { asset, winfo: token, selectedDef: token.definition }
   348        return true
   349      }
   350      if (!parentAsset.info) throw Error('this parent has no wallet info!')
   351      this.current = { asset, parentAsset, winfo: token, selectedDef: parentAsset.info.availablewallets[0] }
   352      return true
   353    }
   354  
   355    async update (walletDef: WalletDefinition) {
   356      const page = this.page
   357      this.current.selectedDef = walletDef
   358      Doc.hide(page.walletPassAndSubmitBttn, page.oneBttnBox, page.newWalletPassBox)
   359      const guideLink = walletDef.guidelink
   360      const configOpts = walletDef.configopts || []
   361      // If a config represents a wallet's birthday, we update the default
   362      // selection to the current date if this installation of the client
   363      // generated a seed.
   364      configOpts.map((opt) => {
   365        if (opt.isBirthdayConfig && app().seedGenTime > 0) {
   366          opt.default = toUnixDate(new Date())
   367        }
   368        return opt
   369      })
   370      // Either this is a walletDef for a token's uncreated parent asset, or this
   371      // is the definition for the token.
   372      let containsRequired = false
   373      for (const opt of configOpts) {
   374        if (opt.required) {
   375          containsRequired = true
   376          break
   377        }
   378      }
   379      const { asset, parentAsset, winfo } = this.current
   380      const displayCreateBtn = walletDef.seeded || Boolean(asset.token)
   381      if (displayCreateBtn && !containsRequired) {
   382        Doc.hide(page.walletSettingsHeader)
   383        Doc.show(page.oneBttnBox)
   384      } else if (displayCreateBtn) {
   385        Doc.show(page.walletPassAndSubmitBttn, page.walletSettingsHeader)
   386        page.newWalletPass.value = ''
   387        page.submitAdd.textContent = intl.prep(intl.ID_CREATE)
   388      } else {
   389        Doc.show(page.walletPassAndSubmitBttn, page.walletSettingsHeader)
   390        if (!walletDef.noauth) Doc.show(page.newWalletPassBox)
   391        page.submitAdd.textContent = intl.prep(intl.ID_ADD)
   392      }
   393  
   394      if (parentAsset) {
   395        const parentAndTokenOpts = JSON.parse(JSON.stringify(configOpts))
   396        // Add the regAsset field to the configurations so proper logos will be displayed
   397        // next to them, and map can filter them out. The opts are copied here so the originals
   398        // do not have the regAsset field added to them.
   399        for (const opt of parentAndTokenOpts) opt.regAsset = parentAsset.id
   400        const tokenOpts = (winfo as Token).definition.configopts || []
   401        if (tokenOpts.length > 0) {
   402          const tokenOptsCopy = JSON.parse(JSON.stringify(tokenOpts))
   403          for (const opt of tokenOptsCopy) opt.regAsset = asset.id
   404          parentAndTokenOpts.push(...tokenOptsCopy)
   405        }
   406        this.subform.update(asset.id, parentAndTokenOpts, false)
   407      } else this.subform.update(asset.id, configOpts, false)
   408      this.setGuideLink(guideLink)
   409  
   410      // A seeded or token wallet is internal to Bison Wallet and as such does
   411      // not have an external config file to select.
   412      if (walletDef.seeded || Boolean(this.current.asset.token)) Doc.hide(this.subform.fileSelector)
   413      else Doc.show(this.subform.fileSelector)
   414  
   415      await this.loadDefaults()
   416    }
   417  
   418    setGuideLink (guideLink: string) {
   419      Doc.hide(this.walletCfgGuide)
   420      if (guideLink !== '') {
   421        this.walletCfgGuide.href = guideLink
   422        Doc.show(this.walletCfgGuide)
   423      }
   424    }
   425  
   426    /* setError sets and shows the in-form error message. */
   427    async setError (errMsg: string) {
   428      this.page.newWalletErr.textContent = errMsg
   429      Doc.show(this.page.newWalletErr)
   430    }
   431  
   432    /*
   433     * loadDefaults attempts to load the ExchangeWallet configuration from the
   434     * default wallet config path on the server and will auto-fill the page on
   435     * the subform if settings are found.
   436     */
   437    async loadDefaults () {
   438      // No default config files for seeded assets right now.
   439      const { asset, parentAsset, selectedDef } = this.current
   440      if (!selectedDef.configpath) return
   441      let configID = asset.id
   442      if (parentAsset) {
   443        if (selectedDef.seeded) return
   444        configID = parentAsset.id
   445      }
   446      const loaded = app().loading(this.form)
   447      const res = await postJSON('/api/defaultwalletcfg', {
   448        assetID: configID,
   449        type: selectedDef.type
   450      })
   451      loaded()
   452      if (!app().checkResponse(res)) {
   453        this.setError(res.msg)
   454        return
   455      }
   456      this.subform.setLoadedConfig(res.config)
   457    }
   458  }
   459  
   460  let dynamicInputCounter = 0
   461  
   462  /*
   463   * WalletConfigForm is a dynamically generated sub-form for setting
   464   * asset-specific wallet configuration options.
   465  */
   466  export class WalletConfigForm {
   467    page: Record<string, PageElement>
   468    form: HTMLElement
   469    configElements: [ConfigOption, HTMLElement][]
   470    configOpts: ConfigOption[]
   471    sectionize: boolean
   472    allSettings: PageElement
   473    dynamicOpts: PageElement
   474    textInputTmpl: PageElement
   475    dateInputTmpl: PageElement
   476    checkboxTmpl: PageElement
   477    repeatableTmpl: PageElement
   478    fileSelector: PageElement
   479    fileInput: PageElement
   480    errMsg: PageElement
   481    showOther: PageElement
   482    showIcon: PageElement
   483    hideIcon: PageElement
   484    showHideMsg: PageElement
   485    otherSettings: PageElement
   486    loadedSettingsMsg: PageElement
   487    loadedSettings: PageElement
   488    defaultSettingsMsg: PageElement
   489    defaultSettings: PageElement
   490    assetHasActiveOrders: boolean
   491    assetID: number
   492  
   493    constructor (form: HTMLElement, sectionize: boolean) {
   494      this.page = Doc.idDescendants(form)
   495      this.form = form
   496      // A configElement is a div containing an input and its label.
   497      this.configElements = []
   498      // configOpts is the wallet options provided by core.
   499      this.configOpts = []
   500      this.sectionize = sectionize
   501  
   502      // Get template elements
   503      this.allSettings = Doc.tmplElement(form, 'allSettings')
   504      this.dynamicOpts = Doc.tmplElement(form, 'dynamicOpts')
   505      this.textInputTmpl = Doc.tmplElement(form, 'textInput')
   506      this.textInputTmpl.remove()
   507      this.dateInputTmpl = Doc.tmplElement(form, 'dateInput')
   508      this.dateInputTmpl.remove()
   509      this.checkboxTmpl = Doc.tmplElement(form, 'checkbox')
   510      this.checkboxTmpl.remove()
   511      this.repeatableTmpl = Doc.tmplElement(form, 'repeatableInput')
   512      this.repeatableTmpl.remove()
   513      this.fileSelector = Doc.tmplElement(form, 'fileSelector')
   514      this.fileInput = Doc.tmplElement(form, 'fileInput')
   515      this.errMsg = Doc.tmplElement(form, 'errMsg')
   516      this.showOther = Doc.tmplElement(form, 'showOther')
   517      this.showIcon = Doc.tmplElement(form, 'showIcon')
   518      this.hideIcon = Doc.tmplElement(form, 'hideIcon')
   519      this.showHideMsg = Doc.tmplElement(form, 'showHideMsg')
   520      this.otherSettings = Doc.tmplElement(form, 'otherSettings')
   521      this.loadedSettingsMsg = Doc.tmplElement(form, 'loadedSettingsMsg')
   522      this.loadedSettings = Doc.tmplElement(form, 'loadedSettings')
   523      this.defaultSettingsMsg = Doc.tmplElement(form, 'defaultSettingsMsg')
   524      this.defaultSettings = Doc.tmplElement(form, 'defaultSettings')
   525  
   526      if (!sectionize) Doc.hide(this.showOther)
   527  
   528      Doc.bind(this.fileSelector, 'click', () => this.fileInput.click())
   529  
   530      // config file upload
   531      Doc.bind(this.fileInput, 'change', async () => this.fileInputChanged())
   532  
   533      Doc.bind(this.showOther, 'click', () => {
   534        this.setOtherSettingsViz(this.hideIcon.classList.contains('d-hide'))
   535      })
   536    }
   537  
   538    /*
   539     * fileInputChanged will read the selected file and attempt to load the
   540     * configuration settings. All loaded settings will be made visible for
   541     * inspection by the user.
   542     */
   543    async fileInputChanged () {
   544      Doc.hide(this.errMsg)
   545      if (!this.fileInput.value) return
   546      const files = this.fileInput.files
   547      if (!files || files.length === 0) return
   548      const loaded = app().loading(this.form)
   549      const config = await files[0].text()
   550      if (!config) return
   551      const res = await postJSON('/api/parseconfig', {
   552        configtext: config
   553      })
   554      loaded()
   555      if (!app().checkResponse(res)) {
   556        this.errMsg.textContent = res.msg
   557        Doc.show(this.errMsg)
   558        return
   559      }
   560      if (Object.keys(res.map).length === 0) return
   561      this.dynamicOpts.append(...this.setConfig(res.map))
   562      this.reorder(this.dynamicOpts)
   563      const [loadedOpts, defaultOpts] = [this.loadedSettings.children.length, this.defaultSettings.children.length]
   564      if (loadedOpts === 0) Doc.hide(this.loadedSettings, this.loadedSettingsMsg)
   565      if (defaultOpts === 0) Doc.hide(this.defaultSettings, this.defaultSettingsMsg)
   566      if (loadedOpts + defaultOpts === 0) Doc.hide(this.showOther, this.otherSettings)
   567    }
   568  
   569    addOpt (box: HTMLElement, opt: ConfigOption, insertAfter?: PageElement, skipRepeatN?: boolean): PageElement {
   570      let el: HTMLElement
   571      if (opt.isboolean) el = this.checkboxTmpl.cloneNode(true) as HTMLElement
   572      else if (opt.isdate) el = this.dateInputTmpl.cloneNode(true) as HTMLElement
   573      else if (opt.repeatable) {
   574        el = this.repeatableTmpl.cloneNode(true) as HTMLElement
   575        el.classList.add('repeatable')
   576        Doc.bind(Doc.tmplElement(el, 'add'), 'click', () => {
   577          this.addOpt(box, opt, el, true)
   578        })
   579        if (!skipRepeatN) for (let i = 0; i < (opt.repeatN ? opt.repeatN - 1 : 0); i++) this.addOpt(box, opt, insertAfter, true)
   580      } else el = this.textInputTmpl.cloneNode(true) as HTMLElement
   581      const hiddenFields = app().extensionWallet(this.assetID)?.hiddenFields || []
   582      if (hiddenFields.indexOf(opt.key) !== -1) Doc.hide(el)
   583      this.configElements.push([opt, el])
   584      const input = el.querySelector('input') as ConfigOptionInput
   585      input.dataset.configKey = opt.key
   586      // We need to generate a unique ID only for the <input id> => <label for>
   587      // matching.
   588      dynamicInputCounter++
   589      const elID = 'wcfg-' + String(dynamicInputCounter)
   590      input.id = elID
   591      const label = Doc.safeSelector(el, 'label')
   592      label.htmlFor = elID // 'for' attribute, but 'for' is a keyword
   593      label.prepend(opt.displayname)
   594      if (opt.regAsset !== undefined) {
   595        const logo = new window.Image(15, 15)
   596        logo.src = Doc.logoPathFromID(opt.regAsset || -1)
   597        label.prepend(logo)
   598      }
   599      if (insertAfter) insertAfter.after(el)
   600      else box.appendChild(el)
   601      if (opt.noecho) {
   602        input.type = 'password'
   603        input.autocomplete = 'off'
   604      }
   605      if (opt.description) label.dataset.tooltip = opt.description
   606      if (opt.isboolean) input.checked = opt.default
   607      else if (opt.isdate) {
   608        const getMinMaxVal = (minMax: string | number) => {
   609          if (!minMax) return ''
   610          if (minMax === 'now') return dateToString(new Date())
   611          return dateToString(new Date((minMax as number) * 1000))
   612        }
   613        input.max = getMinMaxVal(opt.max)
   614        input.min = getMinMaxVal(opt.min)
   615        const date = opt.default ? new Date(opt.default * 1000) : new Date()
   616        // UI shows Dates in valueAsDate as UTC, but user interprets local. Set a
   617        // local date string so the UI displays what the user expects. alt:
   618        // input.valueAsDate = dateApplyOffset(date)
   619        input.value = dateToString(date)
   620      } else input.value = opt.default !== null ? opt.default : ''
   621      input.disabled = Boolean(opt.disablewhenactive && this.assetHasActiveOrders)
   622      return el
   623    }
   624  
   625    /*
   626     * update creates the dynamic form.
   627     */
   628    update (assetID: number, configOpts: ConfigOption[] | null, activeOrders: boolean) {
   629      this.assetHasActiveOrders = activeOrders
   630      this.configElements = []
   631      this.configOpts = configOpts || []
   632      this.assetID = assetID
   633      Doc.empty(this.dynamicOpts, this.defaultSettings, this.loadedSettings)
   634  
   635      // If there are no options, just hide the entire form.
   636      if (this.configOpts.length === 0) return Doc.hide(this.form)
   637      Doc.show(this.form)
   638  
   639      this.setOtherSettingsViz(false)
   640      Doc.hide(
   641        this.loadedSettingsMsg, this.loadedSettings, this.defaultSettingsMsg,
   642        this.defaultSettings, this.errMsg
   643      )
   644      const defaultedOpts = []
   645      for (const opt of this.configOpts) {
   646        if (this.sectionize && opt.default !== null) defaultedOpts.push(opt)
   647        else this.addOpt(this.dynamicOpts, opt)
   648      }
   649      if (defaultedOpts.length) {
   650        for (const opt of defaultedOpts) this.addOpt(this.defaultSettings, opt)
   651        Doc.show(this.showOther, this.defaultSettingsMsg, this.defaultSettings)
   652      } else {
   653        Doc.hide(this.showOther)
   654      }
   655      app().bindTooltips(this.allSettings)
   656      if (this.dynamicOpts.children.length) Doc.show(this.dynamicOpts)
   657      else Doc.hide(this.dynamicOpts)
   658    }
   659  
   660    /*
   661     * setOtherSettingsViz sets the visibility of the additional settings section.
   662     */
   663    setOtherSettingsViz (visible: boolean) {
   664      if (visible) {
   665        Doc.hide(this.showIcon)
   666        Doc.show(this.hideIcon, this.otherSettings)
   667        this.showHideMsg.textContent = intl.prep(intl.ID_HIDE_ADDITIONAL_SETTINGS)
   668        return
   669      }
   670      Doc.hide(this.hideIcon, this.otherSettings)
   671      Doc.show(this.showIcon)
   672      this.showHideMsg.textContent = intl.prep(intl.ID_SHOW_ADDITIONAL_SETTINGS)
   673    }
   674  
   675    /*
   676     * setConfig looks for inputs with configOpt keys matching the cfg object, and
   677     * sets the inputs value to the corresponding cfg value. A list of matching
   678     * configElements is returned.
   679     */
   680    setConfig (cfg: Record<string, string>): HTMLElement[] {
   681      const finds: HTMLElement[] = []
   682      const handledRepeatables: Record<string, boolean> = {}
   683      const removes: [ConfigOption, PageElement][] = []
   684      for (const r of [...this.configElements]) {
   685        const [opt, el] = r
   686        const v = cfg[opt.key]
   687        if (v === undefined) continue
   688        if (opt.repeatable) {
   689          if (handledRepeatables[opt.key]) {
   690            el.remove()
   691            removes.push(r)
   692            continue
   693          }
   694          handledRepeatables[opt.key] = true
   695          const vals = v.split(opt.repeatable)
   696          const firstVal = vals[0]
   697          finds.push(el)
   698          Doc.safeSelector(el, 'input').value = firstVal
   699          // Add repeatN - 1 empty elements to the reconfig form. Add them before
   700          // the populated inputs just because of the way we're using the
   701          // insertAfter argument to addOpt.
   702          for (let i = 1; i < (opt.repeatN || 1); i++) finds.push(this.addOpt(el.parentElement as PageElement, opt, el, true))
   703          for (let i = 1; i < vals.length; i++) {
   704            const newEl = this.addOpt(el.parentElement as PageElement, opt, el, true)
   705            Doc.safeSelector(newEl, 'input').value = vals[i]
   706            finds.push(newEl)
   707          }
   708          continue
   709        }
   710        finds.push(el)
   711        const input = Doc.safeSelector(el, 'input') as HTMLInputElement
   712        if (opt.isboolean) input.checked = isTruthyString(v)
   713        else if (opt.isdate) {
   714          input.value = dateToString(new Date(parseInt(v) * 1000))
   715          // alt: input.valueAsDate = dateApplyOffset(...)
   716        } else input.value = v
   717      }
   718      for (const r of removes) {
   719        const i = this.configElements.indexOf(r)
   720        if (i >= 0) this.configElements.splice(i, 1)
   721      }
   722  
   723      return finds
   724    }
   725  
   726    /*
   727     * setLoadedConfig sets the input values for the entries in cfg, and moves
   728     * them to the loadedSettings box.
   729     */
   730    setLoadedConfig (cfg: Record<string, string>) {
   731      const finds = this.setConfig(cfg)
   732      if (!this.sectionize || finds.length === 0) return
   733      this.loadedSettings.append(...finds)
   734      this.reorder(this.loadedSettings)
   735      Doc.show(this.loadedSettings, this.loadedSettingsMsg)
   736      if (this.defaultSettings.children.length === 0) Doc.hide(this.defaultSettings, this.defaultSettingsMsg)
   737    }
   738  
   739    /*
   740     * map reads all inputs and constructs an object from the configOpt keys and
   741     * values.
   742     */
   743    map (assetID: number): Record<string, string> {
   744      const config: Record<string, string> = {}
   745      for (const [opt, el] of this.configElements) {
   746        const input = Doc.safeSelector(el, 'input') as HTMLInputElement
   747        if (opt.regAsset !== undefined && opt.regAsset !== assetID) continue
   748        if (opt.isboolean && opt.key) {
   749          config[opt.key] = input.checked ? '1' : '0'
   750        } else if (opt.isdate && opt.key) {
   751          // Force local time interpretation by appending a time to the date
   752          // string, otherwise the Date constructor considers it UTC.
   753          const minDate = input.min ? toUnixDate(new Date(input.min + 'T00:00')) : Number.MIN_SAFE_INTEGER
   754          const maxDate = input.max ? toUnixDate(new Date(input.max + 'T00:00')) : Number.MAX_SAFE_INTEGER
   755          let date = input.value ? toUnixDate(new Date(input.value + 'T00:00')) : 0
   756          if (date < minDate) date = minDate
   757          else if (date > maxDate) date = maxDate
   758          config[opt.key] = String(date)
   759        } else if (input.value) {
   760          if (opt.repeatable && config[opt.key]) config[opt.key] += opt.repeatable + input.value
   761          else config[opt.key] = input.value
   762        }
   763      }
   764      return config
   765    }
   766  
   767    /*
   768     * reorder sorts the configElements in the box by the order of the
   769     * server-provided configOpts array.
   770     */
   771    reorder (box: HTMLElement) {
   772      const inputs: Record<string, HTMLElement[]> = {}
   773      box.querySelectorAll('input').forEach((input: ConfigOptionInput) => {
   774        const k = input.dataset.configKey
   775        if (!k) return // TS2538
   776        const els = []
   777        for (const [opt, el] of this.configElements) if (opt.key === k) els.push(el)
   778        inputs[k] = els
   779      })
   780      for (const opt of this.configOpts) {
   781        const els = inputs[opt.key] || []
   782        for (const el of els) box.append(el)
   783      }
   784    }
   785  }
   786  
   787  /*
   788   * ConfirmRegistrationForm should be used with the "confirmRegistrationForm"
   789   * template.
   790   */
   791  export class ConfirmRegistrationForm {
   792    form: HTMLElement
   793    success: () => void
   794    page: Record<string, PageElement>
   795    xc: Exchange
   796    certFile: string
   797    bondAssetID: number
   798    tier: number
   799    fees: number
   800  
   801    constructor (form: HTMLElement, success: () => void, goBack: () => void) {
   802      this.form = form
   803      this.success = success
   804      this.page = Doc.parseTemplate(form)
   805      this.certFile = ''
   806  
   807      Doc.bind(this.page.goBack, 'click', () => goBack())
   808      bind(form, this.page.submit, () => this.submitForm())
   809    }
   810  
   811    setExchange (xc: Exchange, certFile: string) {
   812      this.xc = xc
   813      this.certFile = certFile
   814      this.page.host.textContent = xc.host
   815    }
   816  
   817    setAsset (assetID: number, tier: number, fees: number) {
   818      const asset = app().assets[assetID]
   819      const { conversionFactor, unit } = asset.unitInfo.conventional
   820      this.bondAssetID = asset.id
   821      this.tier = tier
   822      this.fees = fees
   823      const page = this.page
   824      const bondAsset = this.xc.bondAssets[asset.symbol]
   825      const bondLock = bondAsset.amount * tier * bondReserveMultiplier
   826      const bondLockConventional = bondLock / conversionFactor
   827      page.tradingTier.textContent = String(tier)
   828      page.logo.src = Doc.logoPath(asset.symbol)
   829      page.bondLock.textContent = Doc.formatFourSigFigs(bondLockConventional)
   830      page.bondUnit.textContent = unit
   831      const r = app().fiatRatesMap[assetID]
   832      Doc.show(page.bondLockUSDBox)
   833      if (r) page.bondLockUSD.textContent = Doc.formatFourSigFigs(bondLockConventional * r)
   834      else Doc.hide(page.bondLockUSDBox)
   835      if (fees) page.feeReserves.textContent = Doc.formatFourSigFigs(fees / conversionFactor)
   836      page.reservesUnit.textContent = unit
   837    }
   838  
   839    setFees (assetID: number, fees: number) {
   840      this.fees = fees
   841      const conversionFactor = app().assets[assetID].unitInfo.conventional.conversionFactor
   842      this.page.feeReserves.textContent = Doc.formatFourSigFigs(fees / conversionFactor)
   843    }
   844  
   845    /* Form expands into its space quickly from the lower-right as it fades in. */
   846    async animate () {
   847      const form = this.form
   848      Doc.animate(400, prog => {
   849        form.style.transform = `scale(${prog})`
   850        form.style.opacity = String(Math.pow(prog, 4))
   851        const offset = `${(1 - prog) * 500}px`
   852        form.style.top = offset
   853        form.style.left = offset
   854      })
   855    }
   856  
   857    /*
   858     * submitForm is called when the form is submitted.
   859     */
   860    async submitForm () {
   861      const { page, bondAssetID, xc, certFile, tier } = this
   862      const asset = app().assets[bondAssetID]
   863      if (!asset) {
   864        page.regErr.innerText = intl.prep(intl.ID_SELECT_WALLET_FOR_FEE_PAYMENT)
   865        Doc.show(page.regErr)
   866        return
   867      }
   868      Doc.hide(page.regErr)
   869      const bondAsset = xc.bondAssets[asset.wallet.symbol]
   870      const dexAddr = xc.host
   871      let form: any
   872      let url: string
   873      if (!app().exchanges[xc.host] || app().exchanges[xc.host].viewOnly) {
   874        form = {
   875          addr: dexAddr,
   876          cert: certFile,
   877          bond: bondAsset.amount * tier,
   878          asset: bondAsset.id
   879        }
   880        url = '/api/postbond'
   881      } else {
   882        form = {
   883          host: dexAddr,
   884          targetTier: tier,
   885          bondAssetID: bondAssetID
   886        }
   887        url = '/api/updatebondoptions'
   888      }
   889      const loaded = app().loading(this.form)
   890      const res = await postJSON(url, form)
   891      loaded()
   892      if (!app().checkResponse(res)) {
   893        page.regErr.textContent = res.msg
   894        Doc.show(page.regErr)
   895        return
   896      }
   897      this.success()
   898    }
   899  }
   900  
   901  interface RegAssetRow {
   902    ready: PageElement
   903  }
   904  
   905  interface MarketLimitsRow {
   906    mkt: Market
   907    tmpl: Record<string, PageElement>
   908    setTier: ((tier: number) => void)
   909  }
   910  
   911  /*
   912   * FeeAssetSelectionForm should be used with the "regAssetForm" template.
   913   */
   914  export class FeeAssetSelectionForm {
   915    form: HTMLElement
   916    success: (assetID: number, tier: number) => Promise<void>
   917    xc: Exchange
   918    selectedAssetID: number
   919    certFile: string
   920    page: Record<string, PageElement>
   921    assetRows: Record<string, RegAssetRow>
   922    marketRows: MarketLimitsRow[]
   923  
   924    constructor (form: HTMLElement, success: (assetID: number, tier: number) => Promise<void>) {
   925      this.form = form
   926      this.certFile = ''
   927      this.success = success
   928      const page = this.page = Doc.parseTemplate(form)
   929      Doc.cleanTemplates(page.currentBondTmpl, page.bondAssetTmpl, page.marketTmpl)
   930  
   931      Doc.bind(page.tradingTierInput, 'input', () => { this.setTier() })
   932      Doc.bind(page.tradingTierInput, 'keyup', (e: KeyboardEvent) => { if (e.key === 'Enter') this.acceptTier() })
   933      Doc.bind(page.submitTradingTier, 'click', () => { this.acceptTier() })
   934  
   935      Doc.bind(page.tierUp, 'click', () => { this.incrementTier(true) })
   936      Doc.bind(page.tierDown, 'click', () => { this.incrementTier(false) })
   937  
   938      Doc.bind(page.goBackToAssets, 'click', () => {
   939        Doc.hide(page.tradingTierForm)
   940        Doc.show(page.assetForm)
   941      })
   942  
   943      Doc.bind(page.whatsABond, 'click', () => {
   944        Doc.hide(page.assetForm)
   945        Doc.show(page.whatsABondPanel)
   946      })
   947  
   948      const hideWhatsABond = () => {
   949        Doc.show(page.assetForm)
   950        Doc.hide(page.whatsABondPanel)
   951      }
   952  
   953      Doc.bind(page.bondGotIt, 'click', () => { hideWhatsABond() })
   954  
   955      Doc.bind(page.whatsABondBack, 'click', () => { hideWhatsABond() })
   956  
   957      Doc.bind(page.usePrepaidBond, 'click', () => { this.showPrepaidBondForm() })
   958      Doc.bind(page.ppbGoBack, 'click', () => { this.hidePrepaidBondForm() })
   959      Doc.bind(page.submitPrepaidBond, 'click', () => { this.submitPrepaidBond() })
   960  
   961      app().registerNoteFeeder({
   962        createwallet: (note: WalletCreationNote) => {
   963          if (note.topic === 'QueuedCreationSuccess') this.walletCreated(note.assetID)
   964        }
   965      })
   966    }
   967  
   968    setTierError (errMsg: string) {
   969      this.page.tradingTierErr.textContent = errMsg
   970      Doc.show(this.page.tradingTierErr)
   971    }
   972  
   973    setAssetError (errMsg: string) {
   974      this.page.regAssetErr.textContent = errMsg
   975      Doc.show(this.page.regAssetErr)
   976    }
   977  
   978    clearErrors () {
   979      Doc.hide(this.page.regAssetErr, this.page.tradingTierErr)
   980    }
   981  
   982    setExchange (xc: Exchange, certFile: string) {
   983      this.xc = xc
   984      this.certFile = certFile
   985      this.assetRows = {}
   986      this.marketRows = []
   987      const page = this.page
   988      Doc.hide(page.assetForm, page.tradingTierForm, page.whatsABondPanel, page.prepaidBonds)
   989      Doc.empty(page.bondAssets, page.markets)
   990      this.clearErrors()
   991  
   992      const addBondRow = (assetID: number, bondAsset: BondAsset) => {
   993        const asset = app().assets[assetID]
   994        if (!asset) return
   995        const { unitInfo: { conventional: { unit, conversionFactor } }, name, symbol } = asset
   996        const tr = page.bondAssetTmpl.cloneNode(true) as HTMLElement
   997        page.bondAssets.appendChild(tr)
   998        const tmpl = Doc.parseTemplate(tr)
   999  
  1000        tmpl.logo.src = Doc.logoPath(symbol)
  1001        tmpl.name.textContent = name
  1002  
  1003        Doc.bind(tr, 'click', () => { this.assetSelected(assetID) })
  1004        tmpl.feeSymbol.textContent = unit
  1005        const bondSizeConventional = bondAsset.amount / conversionFactor
  1006        tmpl.feeAmt.textContent = Doc.formatFourSigFigs(bondSizeConventional)
  1007        const fiatRate = app().fiatRatesMap[assetID]
  1008        Doc.setVis(fiatRate, tmpl.fiatBox)
  1009        if (fiatRate) tmpl.fiatBondAmount.textContent = Doc.formatFourSigFigs(bondSizeConventional * fiatRate)
  1010        this.assetRows[assetID] = { ready: tmpl.ready }
  1011      }
  1012  
  1013      const addMarketRow = (mkt: Market) => {
  1014        const { baseid: baseID, quoteid: quoteID } = mkt
  1015        const [b, q] = [app().assets[baseID], app().assets[quoteID]]
  1016        if (!b || !q) return
  1017        const tr = page.marketTmpl.cloneNode(true) as HTMLElement
  1018        page.markets.appendChild(tr)
  1019        const { symbol: baseSymbol, unitInfo: bui } = xc.assets[baseID]
  1020        const { symbol: quoteSymbol, unitInfo: qui } = xc.assets[quoteID]
  1021        for (const el of Doc.applySelector(tr, '[data-base-ticker]')) el.textContent = bui.conventional.unit
  1022        for (const el of Doc.applySelector(tr, '[data-quote-ticker]')) el.textContent = qui.conventional.unit
  1023  
  1024        const tmpl = Doc.parseTemplate(tr)
  1025        tmpl.baseLogo.src = Doc.logoPath(baseSymbol)
  1026        tmpl.quoteLogo.src = Doc.logoPath(quoteSymbol)
  1027  
  1028        const setTier = (tier: number) => {
  1029          const { parcelsize: parcelSize, lotsize: lotSize } = mkt
  1030          const conventionalLotSize = lotSize / bui.conventional.conversionFactor
  1031          const startingLimit = conventionalLotSize * parcelSize * perTierBaseParcelLimit * tier
  1032          const privilegedLimit = conventionalLotSize * parcelSize * perTierBaseParcelLimit * parcelLimitScoreMultiplier * tier
  1033          tmpl.tradeLimitLow.textContent = Doc.formatFourSigFigs(startingLimit)
  1034          tmpl.tradeLimitHigh.textContent = Doc.formatFourSigFigs(privilegedLimit)
  1035          const baseFiatRate = app().fiatRatesMap[baseID]
  1036          if (baseFiatRate) {
  1037            tmpl.fiatTradeLimitLow.textContent = Doc.formatFourSigFigs(startingLimit * baseFiatRate)
  1038            tmpl.fiatTradeLimitHigh.textContent = Doc.formatFourSigFigs(privilegedLimit * baseFiatRate)
  1039          }
  1040          Doc.setVis(baseFiatRate, page.fiatTradeLowBox, page.fiatTradeHighBox)
  1041        }
  1042  
  1043        setTier(strongTier(xc.auth) || 1)
  1044        this.marketRows.push({ mkt, tmpl, setTier })
  1045      }
  1046  
  1047      for (const { symbol, id: assetID } of Object.values(xc.assets || {})) {
  1048        if (!app().assets[assetID]) continue
  1049        const bondAsset = xc.bondAssets[symbol]
  1050        if (bondAsset) addBondRow(assetID, bondAsset)
  1051      }
  1052  
  1053      for (const mkt of Object.values(xc.markets || {})) addMarketRow(mkt)
  1054  
  1055      // page.host.textContent = xc.host
  1056      page.tradingTierInput.value = xc.auth.targetTier ? String(xc.auth.targetTier) : '1'
  1057  
  1058      if (this.validBondAssetSelected(xc)) this.assetSelected(xc.auth.bondAssetID)
  1059      else Doc.show(page.assetForm)
  1060    }
  1061  
  1062    validBondAssetSelected (xc: Exchange) {
  1063      if (xc.viewOnly) return false
  1064      const { targetTier, bondAssetID } = xc.auth
  1065      if (targetTier < 1) return false
  1066      const a = app().assets[bondAssetID]
  1067      return a && Boolean(xc.bondAssets[a.symbol])
  1068    }
  1069  
  1070    /*
  1071     * walletCreated should be called when an asynchronous wallet creation
  1072     * completes successfully.
  1073     */
  1074    walletCreated (assetID: number) {
  1075      const a = this.assetRows[assetID]
  1076      const asset = app().assets[assetID]
  1077      setReadyMessage(a.ready, asset)
  1078    }
  1079  
  1080    refresh () {
  1081      this.setExchange(this.xc, this.certFile)
  1082    }
  1083  
  1084    assetSelected (assetID: number) {
  1085      this.selectedAssetID = assetID
  1086      this.setTier()
  1087      const { page: { assetForm, tradingTierForm, tradingTierInput } } = this
  1088      Doc.hide(assetForm)
  1089      Doc.show(tradingTierForm)
  1090      tradingTierInput.focus()
  1091    }
  1092  
  1093    setTier () {
  1094      const { page, xc: { bondAssets }, selectedAssetID: assetID } = this
  1095      const { symbol, unitInfo: ui } = app().assets[assetID]
  1096      const { conventional: { conversionFactor, unit } } = ui
  1097  
  1098      const bondAsset = bondAssets[symbol]
  1099      const raw = page.tradingTierInput.value ?? ''
  1100      if (!raw) return
  1101      const tier = parseInt(raw)
  1102      if (isNaN(tier)) {
  1103        this.setTierError(intl.prep(intl.ID_INVALID_TIER_VALUE))
  1104        return
  1105      }
  1106      page.tradingTierInput.value = String(tier)
  1107      page.bondSizeDisplay.textContent = Doc.formatCoinValue(bondAsset.amount, ui)
  1108      for (const el of Doc.applySelector(page.tradingTierForm, '[data-tier]')) el.textContent = String(tier)
  1109      for (const el of Doc.applySelector(page.tradingTierForm, '[data-bond-asset-ticker]')) el.textContent = unit
  1110      const bondLock = bondAsset.amount * tier * bondReserveMultiplier
  1111      page.bondLockDisplay.textContent = Doc.formatCoinValue(bondLock, ui)
  1112      const fiatRate = app().fiatRatesMap[assetID]
  1113      if (fiatRate) page.fiatLockDisplay.textContent = Doc.formatFourSigFigs(bondLock / conversionFactor * fiatRate)
  1114      for (const m of Object.values(this.marketRows)) m.setTier(tier)
  1115      const currentBondAmts: Record<number, number> = {}
  1116      for (const [assetIDStr, { wallet }] of Object.entries(app().assets)) {
  1117        if (!wallet) continue
  1118        const { balance: { bondlocked, bondReserves } } = wallet
  1119        const bonded = bondlocked + bondReserves
  1120        if (bonded > 0) currentBondAmts[parseInt(assetIDStr)] = bonded
  1121      }
  1122      const haveLock = Object.keys(currentBondAmts).length > 0
  1123      Doc.setVis(haveLock, page.currentBondBox)
  1124      if (haveLock) {
  1125        Doc.empty(page.currentBonds)
  1126        for (const [assetIDStr, bondLocked] of Object.entries(currentBondAmts)) {
  1127          const assetID = parseInt(assetIDStr)
  1128          const { unitInfo: ui, symbol, name } = app().assets[assetID]
  1129          const { conventional: { conversionFactor, unit } } = ui
  1130          const tr = page.currentBondTmpl.cloneNode(true) as PageElement
  1131          page.currentBonds.appendChild(tr)
  1132          const tmpl = Doc.parseTemplate(tr)
  1133          tmpl.icon.src = Doc.logoPath(symbol)
  1134          tmpl.name.textContent = name
  1135          tmpl.amt.textContent = Doc.formatCoinValue(bondLocked, ui)
  1136          tmpl.ticker.textContent = unit
  1137          tmpl.name.textContent = name
  1138          const fiatRate = app().fiatRatesMap[assetID]
  1139          Doc.setVis(tmpl.fiatBox)
  1140          if (fiatRate) tmpl.fiatAmt.textContent = Doc.formatFourSigFigs(bondLocked / conversionFactor * fiatRate)
  1141        }
  1142      }
  1143      Doc.setVis(fiatRate, page.fiatLockBox)
  1144    }
  1145  
  1146    acceptTier () {
  1147      const { page, selectedAssetID: assetID } = this
  1148      this.clearErrors()
  1149      const raw = page.tradingTierInput.value ?? ''
  1150      if (!raw) return
  1151      const tier = parseInt(raw)
  1152      if (isNaN(tier)) {
  1153        this.setTierError(intl.prep(intl.ID_INVALID_TIER_VALUE))
  1154        return
  1155      }
  1156      this.success(assetID, tier)
  1157    }
  1158  
  1159    incrementTier (up: boolean) {
  1160      const { page: { tradingTierInput: input } } = this
  1161      input.value = String(Math.max(1, (parseInt(input.value ?? '') || 1) + (up ? 1 : -1)))
  1162      this.setTier()
  1163    }
  1164  
  1165    /*
  1166     * Animation to make the elements sort of expand into their space from the
  1167     * bottom as they fade in.
  1168     */
  1169    async animate () {
  1170      const { page, form } = this
  1171      const extraMargin = 75
  1172      const extraTop = 50
  1173      const regAssetElements = Array.from(page.bondAssets.children) as PageElement[]
  1174      form.style.opacity = '0'
  1175  
  1176      const aniLen = 350
  1177      await Doc.animate(aniLen, prog => {
  1178        for (const el of regAssetElements) {
  1179          el.style.marginTop = `${(1 - prog) * extraMargin}px`
  1180          el.style.transform = `scale(${prog})`
  1181        }
  1182        form.style.opacity = Math.pow(prog, 4).toFixed(1)
  1183        form.style.top = `${(1 - prog) * extraTop}px`
  1184      }, 'easeOut')
  1185    }
  1186  
  1187    showPrepaidBondForm () {
  1188      const { page } = this
  1189      Doc.hide(page.assetForm, page.prepaidBondErr)
  1190      page.prepaidBondCode.value = ''
  1191      Doc.show(page.prepaidBonds)
  1192    }
  1193  
  1194    hidePrepaidBondForm () {
  1195      const { page } = this
  1196      Doc.hide(page.prepaidBonds)
  1197      Doc.show(page.assetForm)
  1198    }
  1199  
  1200    async submitPrepaidBond () {
  1201      const { page, xc: { host } } = this
  1202      Doc.hide(page.prepaidBondErr)
  1203      const code = page.prepaidBondCode.value
  1204      if (!code) {
  1205        page.prepaidBondErr.textContent = intl.prep(intl.ID_INVALID_VALUE)
  1206        Doc.show(page.prepaidBondErr)
  1207        return
  1208      }
  1209      const res = await postJSON('/api/redeemprepaidbond', { host, code, cert: this.certFile })
  1210      if (!app().checkResponse(res)) {
  1211        page.prepaidBondErr.textContent = res.msg
  1212        Doc.show(page.prepaidBondErr)
  1213        return
  1214      }
  1215      this.success(PrepaidBondID, res.tier)
  1216    }
  1217  }
  1218  
  1219  /*
  1220   * setReadyMessage sets an asset's status message on the FeeAssetSelectionForm.
  1221   */
  1222  function setReadyMessage (el: PageElement, asset: SupportedAsset) {
  1223    if (asset.wallet) el.textContent = intl.prep(intl.ID_WALLET_READY)
  1224    else if (asset.walletCreationPending) el.textContent = intl.prep(intl.ID_WALLET_PENDING)
  1225    else el.textContent = intl.prep(intl.ID_SETUP_NEEDED)
  1226    el.classList.remove('readygreen', 'setuporange')
  1227    el.classList.add(asset.wallet ? 'readygreen' : 'setuporange')
  1228  }
  1229  
  1230  /*
  1231   * WalletWaitForm is a form used to track the wallet sync status and balance
  1232   * in preparation for posting a bond.
  1233   */
  1234  export class WalletWaitForm {
  1235    form: HTMLElement
  1236    success: () => void
  1237    goBack: () => void
  1238    page: Record<string, PageElement>
  1239    assetID: number
  1240    parentID?: number
  1241    xc: Exchange
  1242    bondAsset: BondAsset
  1243    progressCache: ProgressPoint[]
  1244    progressed: boolean
  1245    funded: boolean
  1246    // if progressed && funded, stop reporting balance or state; call success()
  1247    bondFeeBuffer: number // in parent asset
  1248    parentAssetSynced: boolean
  1249  
  1250    constructor (form: HTMLElement, success: () => void, goBack: () => void) {
  1251      this.form = form
  1252      this.success = success
  1253      this.page = Doc.parseTemplate(form)
  1254      this.assetID = -1
  1255      this.progressCache = []
  1256      this.progressed = false
  1257      this.funded = false
  1258  
  1259      Doc.bind(this.page.goBack, 'click', () => {
  1260        this.assetID = -1
  1261        goBack()
  1262      })
  1263  
  1264      app().registerNoteFeeder({
  1265        walletstate: (note: WalletStateNote) => this.reportWalletState(note.wallet),
  1266        walletsync: (note: WalletSyncNote) => {
  1267          if (note.assetID !== this.assetID) return
  1268          const w = app().walletMap[note.assetID]
  1269          this.reportProgress(w.synced, w.syncProgress)
  1270        },
  1271        balance: (note: BalanceNote) => this.reportBalance(note.assetID)
  1272      })
  1273    }
  1274  
  1275    /* setExchange sets the exchange for which the fee is being paid. */
  1276    setExchange (xc: Exchange) {
  1277      this.xc = xc
  1278    }
  1279  
  1280    /* setWallet must be called before showing the WalletWaitForm. */
  1281    setWallet (assetID: number, bondFeeBuffer: number, tier: number) {
  1282      this.assetID = assetID
  1283      this.progressCache = []
  1284      this.progressed = false
  1285      this.funded = false
  1286      this.bondFeeBuffer = bondFeeBuffer // in case we're a token, parent's balance must cover
  1287      this.parentAssetSynced = false
  1288      const page = this.page
  1289      const asset = app().assets[assetID]
  1290      const { symbol, unitInfo: ui, wallet: { balance: bal, address, synced, syncProgress }, token } = asset
  1291      this.parentID = token?.parentID
  1292      const bondAsset = this.bondAsset = this.xc.bondAssets[symbol]
  1293  
  1294      const symbolize = (el: PageElement, asset: SupportedAsset) => {
  1295        Doc.empty(el)
  1296        el.appendChild(Doc.symbolize(asset))
  1297      }
  1298  
  1299      for (const span of Doc.applySelector(this.form, '.unit')) symbolize(span, asset)
  1300      page.logo.src = Doc.logoPath(symbol)
  1301      page.depoAddr.textContent = address
  1302  
  1303      Doc.hide(page.syncUncheck, page.syncCheck, page.balUncheck, page.balCheck, page.syncRemainBox, page.bondCostBreakdown)
  1304      Doc.show(page.balanceBox)
  1305  
  1306      let bondLock = 2 * bondAsset.amount * tier
  1307      if (bondFeeBuffer > 0) {
  1308        Doc.show(page.bondCostBreakdown)
  1309        page.bondLockNoFees.textContent = Doc.formatCoinValue(bondLock, ui)
  1310        page.bondLockFees.textContent = Doc.formatCoinValue(bondFeeBuffer, ui)
  1311        bondLock += bondFeeBuffer
  1312        const need = Math.max(bondLock - bal.available + bal.reservesDeficit, 0)
  1313        page.totalForBond.textContent = Doc.formatCoinValue(need, ui)
  1314        Doc.hide(page.sendEnough) // generic msg when no fee info available when
  1315        Doc.hide(page.txFeeBox, page.sendEnoughForToken, page.txFeeBalanceBox) // for tokens
  1316        Doc.hide(page.sendEnoughWithEst) // non-tokens
  1317  
  1318        if (token) {
  1319          Doc.show(page.txFeeBox, page.sendEnoughForToken, page.txFeeBalanceBox)
  1320          const parentAsset = app().assets[token.parentID]
  1321          page.txFee.textContent = Doc.formatCoinValue(bondFeeBuffer, parentAsset.unitInfo)
  1322          page.parentFees.textContent = Doc.formatCoinValue(bondFeeBuffer, parentAsset.unitInfo)
  1323          page.tokenFees.textContent = Doc.formatCoinValue(need, ui)
  1324          symbolize(page.txFeeUnit, parentAsset)
  1325          symbolize(page.parentUnit, parentAsset)
  1326          symbolize(page.parentBalUnit, parentAsset)
  1327          page.parentBal.textContent = parentAsset.wallet ? Doc.formatCoinValue(parentAsset.wallet.balance.available, parentAsset.unitInfo) : '0'
  1328        } else {
  1329          Doc.show(page.sendEnoughWithEst)
  1330        }
  1331        page.fee.textContent = Doc.formatCoinValue(bondLock, ui)
  1332      } else { // show some generic message with no amounts, this shouldn't happen... show wallet error?
  1333        Doc.show(page.sendEnough)
  1334      }
  1335  
  1336      Doc.show(synced ? page.syncCheck : syncProgress >= 1 ? page.syncSpinner : page.syncUncheck)
  1337      Doc.show(bal.available >= 2 * bondAsset.amount + bondFeeBuffer ? page.balCheck : page.balUncheck)
  1338  
  1339      page.progress.textContent = (syncProgress * 100).toFixed(1)
  1340  
  1341      if (synced) {
  1342        this.progressed = true
  1343      }
  1344      this.reportBalance(assetID)
  1345    }
  1346  
  1347    /*
  1348     * reportWalletState sets the progress and balance, ultimately calling the
  1349     * success function if conditions are met.
  1350     */
  1351    reportWalletState (wallet: WalletState) {
  1352      if (this.progressed && this.funded) return
  1353      if (wallet.assetID === this.assetID) this.reportProgress(wallet.synced, wallet.syncProgress)
  1354      this.reportBalance(wallet.assetID)
  1355    }
  1356  
  1357    /*
  1358     * reportBalance sets the balance display and calls success if we go over the
  1359     * threshold.
  1360     */
  1361    reportBalance (assetID: number) {
  1362      if (this.funded || this.assetID === -1) return
  1363      if (assetID !== this.assetID && assetID !== this.parentID) return
  1364      const page = this.page
  1365      const asset = app().assets[this.assetID]
  1366  
  1367      const avail = asset.wallet.balance.available
  1368      page.balance.textContent = Doc.formatCoinValue(avail, asset.unitInfo)
  1369  
  1370      if (asset.token) {
  1371        const parentAsset = app().assets[asset.token.parentID]
  1372        const parentAvail = parentAsset.wallet.balance.available
  1373        page.parentBal.textContent = Doc.formatCoinValue(parentAvail, parentAsset.unitInfo)
  1374        if (parentAvail < this.bondFeeBuffer) return
  1375      }
  1376  
  1377      // NOTE: when/if we allow one-time bond post (no maintenance) from the UI we
  1378      // may allow to proceed as long as they have enough for tx fees. For now,
  1379      // the balance check box will remain unchecked and we will not proceed.
  1380      if (avail < 2 * this.bondAsset.amount + this.bondFeeBuffer) return
  1381  
  1382      Doc.show(page.balCheck)
  1383      Doc.hide(page.balUncheck, page.balanceBox, page.sendEnough)
  1384      this.funded = true
  1385      if (this.progressed) this.success()
  1386    }
  1387  
  1388    /*
  1389     * reportProgress sets the progress display and calls success if we are fully
  1390     * synced.
  1391     */
  1392    reportProgress (synced: boolean, prog: number) {
  1393      const page = this.page
  1394      if (synced) {
  1395        page.progress.textContent = '100'
  1396        Doc.hide(page.syncUncheck, page.syncRemainBox, page.syncSpinner)
  1397        Doc.show(page.syncCheck)
  1398        this.progressed = true
  1399        if (this.funded) this.success()
  1400        return
  1401      } else if (prog === 1) {
  1402        Doc.hide(page.syncUncheck)
  1403        Doc.show(page.syncSpinner)
  1404      } else {
  1405        Doc.hide(page.syncSpinner)
  1406        Doc.show(page.syncUncheck)
  1407      }
  1408      page.progress.textContent = (prog * 100).toFixed(1)
  1409  
  1410      if (prog >= 0.999) {
  1411        Doc.hide(page.syncRemaining)
  1412        Doc.show(page.syncFinishingUp)
  1413        Doc.show(page.syncRemainBox)
  1414        // The final stage of wallet sync process can take a while (it might hang
  1415        // at 99.9% for many minutes, indexing addresses for example), the simplest
  1416        // way to handle it is to keep displaying "finishing up" message until the
  1417        // sync is finished, since we can't reasonably show it progressing over time.
  1418        page.syncFinishingUp.textContent = intl.prep(intl.ID_WALLET_SYNC_FINISHING_UP)
  1419        return
  1420      }
  1421      // Before we get to 99.9% the remaining time estimate must be based on more
  1422      // than one progress report. We'll cache up to the last 20 and look at the
  1423      // difference between the first and last to make the estimate.
  1424      const cacheSize = 20
  1425      const cache = this.progressCache
  1426      cache.push({
  1427        stamp: new Date().getTime(),
  1428        progress: prog
  1429      })
  1430      if (cache.length < 2) {
  1431        // Can't meaningfully estimate remaining until we have at least 2 data points.
  1432        return
  1433      }
  1434      while (cache.length > cacheSize) cache.shift()
  1435      const [first, last] = [cache[0], cache[cache.length - 1]]
  1436      const progDelta = last.progress - first.progress
  1437      if (progDelta === 0) {
  1438        // Having no progress for a while likely means we are experiencing network
  1439        // issues, can't reasonably estimate time remaining in this case.
  1440        return
  1441      }
  1442      Doc.hide(page.syncFinishingUp)
  1443      Doc.show(page.syncRemaining)
  1444      Doc.show(page.syncRemainBox)
  1445      const timeDelta = last.stamp - first.stamp
  1446      const progRate = progDelta / timeDelta
  1447      const toGoProg = 1 - last.progress
  1448      const toGoTime = toGoProg / progRate
  1449      page.syncRemain.textContent = Doc.formatDuration(toGoTime)
  1450    }
  1451  }
  1452  
  1453  interface EarlyAcceleration {
  1454    timePast: number,
  1455    wasAcceleration: boolean
  1456  }
  1457  
  1458  interface PreAccelerate {
  1459    swapRate: number
  1460    suggestedRate: number
  1461    suggestedRange: XYRange
  1462    earlyAcceleration?: EarlyAcceleration
  1463  }
  1464  
  1465  /*
  1466   * AccelerateOrderForm is used to submit an acceleration request for an order.
  1467   */
  1468  export class AccelerateOrderForm {
  1469    form: HTMLElement
  1470    page: Record<string, PageElement>
  1471    order: Order
  1472    acceleratedRate: number
  1473    earlyAcceleration?: EarlyAcceleration
  1474    currencyUnit: string
  1475    success: () => void
  1476  
  1477    constructor (form: HTMLElement, success: () => void) {
  1478      this.form = form
  1479      this.success = success
  1480      const page = this.page = Doc.idDescendants(form)
  1481  
  1482      Doc.bind(page.accelerateSubmit, 'click', () => {
  1483        this.submit()
  1484      })
  1485      Doc.bind(page.submitEarlyConfirm, 'click', () => {
  1486        this.sendAccelerateRequest()
  1487      })
  1488    }
  1489  
  1490    /*
  1491     * displayEarlyAccelerationMsg displays a message asking for confirmation
  1492     * when the user tries to submit an acceleration transaction very soon after
  1493     * the swap transaction was broadcast, or very soon after a previous
  1494     * acceleration.
  1495     */
  1496    displayEarlyAccelerationMsg () {
  1497      const page = this.page
  1498      // this is checked in submit, but another check is needed for ts compiler
  1499      if (!this.earlyAcceleration) return
  1500      page.recentAccelerationTime.textContent = `${Math.floor(this.earlyAcceleration.timePast / 60)}`
  1501      page.recentSwapTime.textContent = `${Math.floor(this.earlyAcceleration.timePast / 60)}`
  1502      if (this.earlyAcceleration.wasAcceleration) {
  1503        Doc.show(page.recentAccelerationMsg)
  1504        Doc.hide(page.recentSwapMsg)
  1505        page.recentAccelerationTime.textContent = `${Math.floor(this.earlyAcceleration.timePast / 60)}`
  1506      } else {
  1507        Doc.show(page.recentSwapMsg)
  1508        Doc.hide(page.recentAccelerationMsg)
  1509        page.recentSwapTime.textContent = `${Math.floor(this.earlyAcceleration.timePast / 60)}`
  1510      }
  1511      Doc.hide(page.configureAccelerationDiv, page.accelerateErr)
  1512      Doc.show(page.earlyAccelerationDiv)
  1513    }
  1514  
  1515    // sendAccelerateRequest makes an accelerate order request to the client
  1516    // backend.
  1517    async sendAccelerateRequest () {
  1518      const order = this.order
  1519      const page = this.page
  1520      const req = {
  1521        orderID: order.id,
  1522        newRate: this.acceleratedRate
  1523      }
  1524      const loaded = app().loading(page.accelerateMainDiv)
  1525      const res = await postJSON('/api/accelerateorder', req)
  1526      loaded()
  1527      if (app().checkResponse(res)) {
  1528        page.accelerateTxID.textContent = res.txID
  1529        Doc.hide(page.accelerateMainDiv, page.preAccelerateErr, page.accelerateErr)
  1530        Doc.show(page.accelerateMsgDiv, page.accelerateSuccess)
  1531        this.success()
  1532      } else {
  1533        page.accelerateErr.textContent = intl.prep(intl.ID_ORDER_ACCELERATION_ERR_MSG, { msg: res.msg })
  1534        Doc.hide(page.earlyAccelerationDiv)
  1535        Doc.show(page.accelerateErr, page.configureAccelerationDiv)
  1536      }
  1537    }
  1538  
  1539    // submit is called when the submit button is clicked.
  1540    async submit () {
  1541      if (this.earlyAcceleration) {
  1542        this.displayEarlyAccelerationMsg()
  1543      } else {
  1544        this.sendAccelerateRequest()
  1545      }
  1546    }
  1547  
  1548    // refresh should be called before the form is displayed. It makes a
  1549    // preaccelerate request to the client backend and sets up the form
  1550    // based on the results.
  1551    async refresh (order: Order) {
  1552      const page = this.page
  1553      this.order = order
  1554      const res = await postJSON('/api/preaccelerate', order.id)
  1555      if (!app().checkResponse(res)) {
  1556        page.preAccelerateErr.textContent = intl.prep(intl.ID_ORDER_ACCELERATION_ERR_MSG, { msg: res.msg })
  1557        Doc.hide(page.accelerateMainDiv, page.accelerateSuccess)
  1558        Doc.show(page.accelerateMsgDiv, page.preAccelerateErr)
  1559        return
  1560      }
  1561      Doc.hide(page.accelerateMsgDiv, page.preAccelerateErr, page.accelerateErr, page.feeEstimateDiv, page.earlyAccelerationDiv)
  1562      Doc.show(page.accelerateMainDiv, page.accelerateSuccess, page.configureAccelerationDiv)
  1563      const preAccelerate: PreAccelerate = res.preAccelerate
  1564      this.earlyAcceleration = preAccelerate.earlyAcceleration
  1565      this.currencyUnit = preAccelerate.suggestedRange.yUnit
  1566      page.accelerateAvgFeeRate.textContent = `${preAccelerate.swapRate} ${preAccelerate.suggestedRange.yUnit}`
  1567      page.accelerateCurrentFeeRate.textContent = `${preAccelerate.suggestedRate} ${preAccelerate.suggestedRange.yUnit}`
  1568      this.acceleratedRate = preAccelerate.suggestedRange.start.y
  1569      const selected = () => { /* do nothing */ }
  1570      const roundY = true
  1571      const updateRate = (_: number, newY: number) => { this.acceleratedRate = newY }
  1572      const rangeHandler = new XYRangeHandler(preAccelerate.suggestedRange, preAccelerate.suggestedRange.start.x, {
  1573        updated: updateRate, changed: () => this.updateAccelerationEstimate(), selected, roundY
  1574      })
  1575      Doc.empty(page.sliderContainer)
  1576      page.sliderContainer.appendChild(rangeHandler.control)
  1577      this.updateAccelerationEstimate()
  1578    }
  1579  
  1580    // updateAccelerationEstimate makes an accelerate estimate request to the
  1581    // client backend using the currently selected rate on the slider, and
  1582    // displays the results.
  1583    async updateAccelerationEstimate () {
  1584      const page = this.page
  1585      const order = this.order
  1586      const req = {
  1587        orderID: order.id,
  1588        newRate: this.acceleratedRate
  1589      }
  1590      const loaded = app().loading(page.sliderContainer)
  1591      const res = await postJSON('/api/accelerationestimate', req)
  1592      loaded()
  1593      if (!app().checkResponse(res)) {
  1594        page.accelerateErr.textContent = intl.prep(intl.ID_ORDER_ACCELERATION_FEE_ERR_MSG, { msg: res.msg })
  1595        Doc.show(page.accelerateErr)
  1596        return
  1597      }
  1598      page.feeRateEstimate.textContent = `${this.acceleratedRate} ${this.currencyUnit}`
  1599      let assetID
  1600      let assetSymbol
  1601      if (order.sell) {
  1602        assetID = order.baseID
  1603        assetSymbol = order.baseSymbol
  1604      } else {
  1605        assetID = order.quoteID
  1606        assetSymbol = order.quoteSymbol
  1607      }
  1608      const unitInfo = app().unitInfo(assetID)
  1609      page.feeEstimate.textContent = `${res.fee / unitInfo.conventional.conversionFactor} ${assetSymbol}`
  1610      Doc.show(page.feeEstimateDiv)
  1611    }
  1612  }
  1613  
  1614  /* DEXAddressForm accepts a DEX address and performs account discovery. */
  1615  export class DEXAddressForm {
  1616    form: HTMLElement
  1617    success: (xc: Exchange, cert: string) => void
  1618    page: Record<string, PageElement>
  1619    knownExchanges: HTMLElement[]
  1620    dexToUpdate?: string
  1621    certPicker: CertificatePicker
  1622  
  1623    constructor (form: HTMLElement, success: (xc: Exchange, cert: string) => void, dexToUpdate?: string) {
  1624      this.form = form
  1625      this.success = success
  1626  
  1627      const page = this.page = Doc.parseTemplate(form)
  1628  
  1629      this.certPicker = new CertificatePicker(form)
  1630  
  1631      Doc.bind(page.skipRegistration, 'change', () => this.showOrHideSubmitBttn())
  1632      Doc.bind(page.showCustom, 'click', () => {
  1633        Doc.hide(page.showCustom)
  1634        Doc.show(page.customBox, page.auth)
  1635      })
  1636  
  1637      this.knownExchanges = Array.from(page.knownXCs.querySelectorAll('.known-exchange'))
  1638      for (const div of this.knownExchanges) {
  1639        Doc.bind(div, 'click', () => {
  1640          const host = div.dataset.host
  1641          for (const d of this.knownExchanges) d.classList.remove('selected')
  1642          return this.checkDEX(host)
  1643        })
  1644      }
  1645  
  1646      bind(form, page.submit, () => this.checkDEX())
  1647  
  1648      if (dexToUpdate) {
  1649        Doc.hide(page.addDexHdr, page.skipRegistrationBox)
  1650        Doc.show(page.updateDexHdr)
  1651        this.dexToUpdate = dexToUpdate
  1652      }
  1653  
  1654      this.refresh()
  1655    }
  1656  
  1657    refresh () {
  1658      const page = this.page
  1659      page.addr.value = ''
  1660      this.certPicker.clearCertFile()
  1661      Doc.hide(page.err)
  1662      if (this.knownExchanges.length === 0 || this.dexToUpdate) {
  1663        Doc.show(page.customBox, page.auth)
  1664        Doc.hide(page.showCustom, page.knownXCs, page.pickServerMsg, page.addCustomMsg)
  1665      } else {
  1666        Doc.hide(page.customBox)
  1667        Doc.show(page.showCustom)
  1668      }
  1669      for (const div of this.knownExchanges) div.classList.remove('selected')
  1670      this.showOrHideSubmitBttn()
  1671    }
  1672  
  1673    /**
  1674     * Show or hide appPWBox depending on if password is required. Show the
  1675     * submit button if connecting a custom server or password is required).
  1676     */
  1677    showOrHideSubmitBttn () {
  1678      const page = this.page
  1679      Doc.setVis(Doc.isDisplayed(page.customBox), page.auth)
  1680    }
  1681  
  1682    skipRegistration () : boolean {
  1683      return this.page.skipRegistration.checked ?? false
  1684    }
  1685  
  1686    /* Just a small size tweak and fade-in. */
  1687    async animate () {
  1688      const form = this.form
  1689      Doc.animate(550, prog => {
  1690        form.style.transform = `scale(${0.9 + 0.1 * prog})`
  1691        form.style.opacity = String(Math.pow(prog, 4))
  1692      }, 'easeOut')
  1693    }
  1694  
  1695    async checkDEX (addr?: string) {
  1696      const page = this.page
  1697      Doc.hide(page.err)
  1698      addr = addr || page.addr.value
  1699      if (addr === '') {
  1700        page.err.textContent = intl.prep(intl.ID_EMPTY_DEX_ADDRESS_MSG)
  1701        Doc.show(page.err)
  1702        return
  1703      }
  1704      const cert = await this.certPicker.file()
  1705      const skipRegistration = this.skipRegistration()
  1706      let endpoint : string, req: any
  1707      if (this.dexToUpdate) {
  1708        endpoint = '/api/updatedexhost'
  1709        req = {
  1710          newHost: addr,
  1711          cert: cert,
  1712          oldHost: this.dexToUpdate
  1713        }
  1714      } else {
  1715        endpoint = skipRegistration ? '/api/adddex' : '/api/discoveracct'
  1716        req = {
  1717          addr: addr,
  1718          cert: cert
  1719        }
  1720      }
  1721  
  1722      const loaded = app().loading(this.form)
  1723      const res = await postJSON(endpoint, req)
  1724      loaded()
  1725      if (!app().checkResponse(res)) {
  1726        if (String(res.msg).includes('certificate required')) {
  1727          Doc.show(page.needCert)
  1728        } else {
  1729          page.err.textContent = res.msg
  1730          Doc.show(page.err)
  1731        }
  1732        return
  1733      }
  1734      await app().fetchUser()
  1735      if (!this.dexToUpdate && (skipRegistration || res.paid || Object.keys(res.xc.auth.pendingBonds).length > 0)) {
  1736        await app().loadPage('markets')
  1737        return
  1738      }
  1739      this.success(res.xc, cert)
  1740    }
  1741  }
  1742  
  1743  /* DiscoverAccountForm performs account discovery for a pre-selected DEX. */
  1744  export class DiscoverAccountForm {
  1745    form: HTMLElement
  1746    addr: string
  1747    success: (xc: Exchange) => void
  1748    page: Record<string, PageElement>
  1749  
  1750    constructor (form: HTMLElement, addr: string, success: (xc: Exchange) => void) {
  1751      this.form = form
  1752      this.addr = addr
  1753      this.success = success
  1754  
  1755      const page = this.page = Doc.parseTemplate(form)
  1756      page.dexHost.textContent = addr
  1757      bind(form, page.submit, () => this.checkDEX())
  1758    }
  1759  
  1760    /* Just a small size tweak and fade-in. */
  1761    async animate () {
  1762      const form = this.form
  1763      Doc.animate(550, prog => {
  1764        form.style.transform = `scale(${0.9 + 0.1 * prog})`
  1765        form.style.opacity = String(Math.pow(prog, 4))
  1766      }, 'easeOut')
  1767    }
  1768  
  1769    async checkDEX () {
  1770      const page = this.page
  1771      Doc.hide(page.err)
  1772      const req = {
  1773        addr: this.addr
  1774      }
  1775      const loaded = app().loading(this.form)
  1776      const res = await postJSON('/api/discoveracct', req)
  1777      loaded()
  1778      if (!app().checkResponse(res)) {
  1779        page.err.textContent = res.msg
  1780        Doc.show(page.err)
  1781        return
  1782      }
  1783      if (res.paid) {
  1784        await app().fetchUser()
  1785        await app().loadPage('markets')
  1786        return
  1787      }
  1788      this.success(res.xc)
  1789    }
  1790  }
  1791  
  1792  /* LoginForm is used to sign into the app. */
  1793  export class LoginForm {
  1794    form: HTMLElement
  1795    success: () => void
  1796    page: Record<string, PageElement>
  1797  
  1798    constructor (form: HTMLElement, success: () => void) {
  1799      this.success = success
  1800      this.form = form
  1801      const page = this.page = Doc.parseTemplate(form)
  1802      bind(form, page.submit, () => { this.submit() })
  1803      app().registerNoteFeeder({
  1804        login: (note: CoreNote) => { this.handleLoginNote(note) }
  1805      })
  1806    }
  1807  
  1808    handleLoginNote (n: CoreNote) {
  1809      if (n.details === '') return
  1810      const loginMsg = Doc.idel(this.form, 'loaderMsg')
  1811      Doc.show(loginMsg)
  1812      if (loginMsg) loginMsg.textContent = n.details
  1813    }
  1814  
  1815    focus () {
  1816      this.page.pw.focus()
  1817    }
  1818  
  1819    refresh () {
  1820      Doc.hide(this.page.errMsg)
  1821      this.page.pw.value = ''
  1822    }
  1823  
  1824    async submit () {
  1825      const page = this.page
  1826      Doc.hide(page.errMsg)
  1827      const pw = page.pw.value || ''
  1828      if (pw === '') {
  1829        Doc.showFormError(page.errMsg, intl.prep(intl.ID_NO_PASS_ERROR_MSG))
  1830        return
  1831      }
  1832      const loaded = app().loading(this.form)
  1833      const res = await postJSON('/api/login', { pass: pw })
  1834      loaded()
  1835      page.pw.value = ''
  1836      if (!app().checkResponse(res)) {
  1837        Doc.showFormError(page.errMsg, res.msg)
  1838        return
  1839      }
  1840      await app().fetchUser()
  1841      res.notes = res.notes || []
  1842      res.notes.reverse()
  1843      res.pokes = res.pokes || []
  1844      app().loggedIn(res.notes, res.pokes)
  1845      this.success()
  1846    }
  1847  
  1848    /* Just a small size tweak and fade-in. */
  1849    async animate () {
  1850      const form = this.form
  1851      Doc.animate(550, prog => {
  1852        form.style.transform = `scale(${0.9 + 0.1 * prog})`
  1853        form.style.opacity = String(Math.pow(prog, 4))
  1854      }, 'easeOut')
  1855    }
  1856  }
  1857  
  1858  const traitNewAddresser = 1 << 1
  1859  
  1860  /*
  1861   * DepositAddress displays a deposit address, a QR code, and a button to
  1862   * generate a new address (if supported).
  1863   */
  1864  export class DepositAddress {
  1865    form: PageElement
  1866    page: Record<string, PageElement>
  1867    assetID: number
  1868    addr: string
  1869  
  1870    constructor (form: PageElement) {
  1871      this.form = form
  1872      const page = this.page = Doc.idDescendants(form)
  1873      Doc.cleanTemplates(page.unifiedReceiverTmpl)
  1874      Doc.bind(page.newDepAddrBttn, 'click', async () => { this.newDepositAddress() })
  1875      // navigator.clipboard can only be accessed on localhost or over https.
  1876      if (window.isSecureContext) {
  1877        Doc.bind(page.copyAddressBtn, 'click', () => { this.copyAddress() })
  1878      } else {
  1879        Doc.hide(page.copyAddressBtn)
  1880      }
  1881    }
  1882  
  1883    /* Display a deposit address. */
  1884    async setAsset (assetID: number) {
  1885      this.assetID = assetID
  1886      const page = this.page
  1887      Doc.hide(page.depositErr, page.depositTokenMsgBox, page.addrUsed)
  1888      const asset = app().assets[assetID]
  1889      page.depositLogo.src = Doc.logoPath(asset.symbol)
  1890      const wallet = app().walletMap[assetID]
  1891      page.depositName.textContent = asset.unitInfo.conventional.unit
  1892      const addr = this.addr = wallet.address
  1893      if ((wallet.traits & traitNewAddresser) !== 0) {
  1894        const res = await postJSON('/api/addressused', { assetID, addr })
  1895        const used = app().checkResponse(res) && res.used
  1896        Doc.setVis(used, page.addrUsed)
  1897      }
  1898      if (asset.token) {
  1899        const parentAsset = app().assets[asset.token.parentID]
  1900        page.depositTokenParentLogo.src = Doc.logoPath(parentAsset.symbol)
  1901        page.depositTokenParentName.textContent = parentAsset.name
  1902        Doc.show(page.depositTokenMsgBox)
  1903      }
  1904      Doc.setVis((wallet.traits & traitNewAddresser) !== 0, page.newDepAddrBttnBox)
  1905      this.setAddress(addr)
  1906    }
  1907  
  1908    setAddress (addr: string) {
  1909      const page = this.page
  1910      Doc.hide(page.unifiedReceivers)
  1911      if (addr.startsWith('unified:')) {
  1912        const receivers = JSON.parse(addr.substring('unified:'.length)) as Record<string, string>
  1913        Doc.empty(page.unifiedReceivers)
  1914        Doc.show(page.unifiedReceivers)
  1915        const defaultReceiverType = 'unified'
  1916        for (const [recvType, recv] of Object.entries(receivers)) {
  1917          const div = page.unifiedReceiverTmpl.cloneNode(true) as PageElement
  1918          page.unifiedReceivers.appendChild(div)
  1919          div.textContent = recvType
  1920          div.dataset.type = recvType
  1921          if (recvType === defaultReceiverType) div.classList.add('selected')
  1922          // tmpl.addr.textContent = recv
  1923          Doc.bind(div, 'click', () => {
  1924            for (const bttn of (Array.from(page.unifiedReceivers.children) as PageElement[])) bttn.classList.toggle('selected', bttn.dataset.type === recvType)
  1925            this.setCentralAddress(recv)
  1926          })
  1927        }
  1928        addr = receivers.unified
  1929      }
  1930  
  1931      this.setCentralAddress(addr)
  1932    }
  1933  
  1934    setCentralAddress (addr: string) {
  1935      const page = this.page
  1936      page.depositAddress.textContent = addr
  1937      page.qrcode.src = `/generateqrcode?address=${addr}`
  1938    }
  1939  
  1940    /* Fetch a new address from the wallet. */
  1941    async newDepositAddress () {
  1942      const { page, assetID, form } = this
  1943      Doc.hide(page.depositErr)
  1944      const loaded = app().loading(form)
  1945      const res = await postJSON('/api/depositaddress', {
  1946        assetID: assetID
  1947      })
  1948      loaded()
  1949      if (!app().checkResponse(res)) {
  1950        page.depositErr.textContent = res.msg
  1951        Doc.show(page.depositErr)
  1952        return
  1953      }
  1954      app().walletMap[assetID].address = res.address
  1955      this.setAddress(res.address)
  1956      Doc.hide(page.addrUsed)
  1957    }
  1958  
  1959    handleTx (assetID: number, tx: WalletTransaction) {
  1960      if (assetID !== this.assetID) return
  1961      const wallet = app().walletMap[assetID]
  1962      if ((wallet.traits & traitNewAddresser) === 0) return
  1963      const { page, addr } = this
  1964      if (tx.amount > 0 && tx.recipient === addr) Doc.show(page.addrUsed)
  1965    }
  1966  
  1967    async copyAddress () {
  1968      const { page, addr } = this
  1969      navigator.clipboard.writeText(addr)
  1970        .then(() => {
  1971          Doc.show(page.copyAlert)
  1972          setTimeout(() => {
  1973            Doc.hide(page.copyAlert)
  1974          }, 800)
  1975        })
  1976        .catch((reason) => {
  1977          console.error('Unable to copy: ', reason)
  1978        })
  1979    }
  1980  }
  1981  
  1982  // AppPassResetForm is used to reset the app apssword using the app seed.
  1983  export class AppPassResetForm {
  1984    form: PageElement
  1985    page: Record<string, PageElement>
  1986    success: () => void
  1987  
  1988    constructor (form: PageElement, success: () => void) {
  1989      this.form = form
  1990      this.success = success
  1991      const page = this.page = Doc.idDescendants(form)
  1992      bind(form, page.resetAppPWSubmitBtn, () => this.resetAppPW())
  1993    }
  1994  
  1995    async resetAppPW () {
  1996      const page = this.page
  1997      const newAppPW = page.newAppPassword.value || ''
  1998      const confirmNewAppPW = page.confirmNewAppPassword.value
  1999      if (newAppPW === '') {
  2000        Doc.showFormError(page.appPWResetErrMsg, intl.prep(intl.ID_NO_PASS_ERROR_MSG))
  2001        return
  2002      }
  2003      if (newAppPW !== confirmNewAppPW) {
  2004        Doc.showFormError(page.appPWResetErrMsg, intl.prep(intl.ID_PASSWORD_NOT_MATCH))
  2005        return
  2006      }
  2007  
  2008      const loaded = app().loading(this.form)
  2009      const res = await postJSON('/api/resetapppassword', {
  2010        newPass: newAppPW,
  2011        seed: page.seedInput.value
  2012      })
  2013      loaded()
  2014      if (!app().checkResponse(res)) {
  2015        Doc.showFormError(page.appPWResetErrMsg, res.msg)
  2016        return
  2017      }
  2018  
  2019      if (Doc.isDisplayed(page.appPWResetErrMsg)) Doc.hide(page.appPWResetErrMsg)
  2020      page.appPWResetSuccessMsg.textContent = intl.prep(intl.ID_PASSWORD_RESET_SUCCESS_MSG)
  2021      Doc.show(page.appPWResetSuccessMsg)
  2022      setTimeout(() => this.success(), 3000) // allow time to view the message
  2023    }
  2024  
  2025    focus () {
  2026      this.page.newAppPassword.focus()
  2027    }
  2028  
  2029    refresh () {
  2030      const page = this.page
  2031      page.newAppPassword.value = ''
  2032      page.confirmNewAppPassword.value = ''
  2033      page.seedInput.value = ''
  2034      Doc.hide(page.appPWResetSuccessMsg, page.appPWResetErrMsg)
  2035    }
  2036  }
  2037  
  2038  export class CertificatePicker {
  2039    page: Record<string, PageElement>
  2040  
  2041    constructor (parent: PageElement) {
  2042      const page = this.page = Doc.parseTemplate(parent)
  2043      page.selectedCert.textContent = intl.prep(intl.ID_NONE_SELECTED)
  2044      Doc.bind(page.certFile, 'change', () => this.onCertFileChange())
  2045      Doc.bind(page.removeCert, 'click', () => this.clearCertFile())
  2046      Doc.bind(page.addCert, 'click', () => page.certFile.click())
  2047    }
  2048  
  2049    /**
  2050     * onCertFileChange when the input certFile changed, read the file
  2051     * and setting cert name into text of selectedCert to display on the view
  2052     */
  2053    async onCertFileChange () {
  2054      const page = this.page
  2055      const files = page.certFile.files
  2056      if (!files || !files.length) return
  2057      page.selectedCert.textContent = files[0].name
  2058      Doc.show(page.removeCert)
  2059      Doc.hide(page.addCert)
  2060    }
  2061  
  2062    /* clearCertFile cleanup certFile value and selectedCert text */
  2063    clearCertFile () {
  2064      const page = this.page
  2065      page.certFile.value = ''
  2066      page.selectedCert.textContent = intl.prep(intl.ID_NONE_SELECTED)
  2067      Doc.hide(page.removeCert)
  2068      Doc.show(page.addCert)
  2069    }
  2070  
  2071    async file (): Promise<string> {
  2072      const page = this.page
  2073      if (page.certFile.value) {
  2074        const files = page.certFile.files
  2075        if (files && files.length) {
  2076          return await files[0].text()
  2077        }
  2078      }
  2079      return ''
  2080    }
  2081  }
  2082  
  2083  export class TokenApprovalForm {
  2084    page: Record<string, PageElement>
  2085    success?: () => void
  2086    assetID: number
  2087    parentID: number
  2088    txFee: number
  2089    host: string
  2090  
  2091    constructor (parent: PageElement, success?: () => void) {
  2092      this.page = Doc.parseTemplate(parent)
  2093      this.success = success
  2094      Doc.bind(this.page.submit, 'click', () => { this.approve() })
  2095    }
  2096  
  2097    async setAsset (assetID: number, host: string) {
  2098      this.assetID = assetID
  2099      this.host = host
  2100      const tokenAsset = app().assets[assetID]
  2101      const parentID = this.parentID = tokenAsset.token?.parentID as number
  2102      const { page } = this
  2103  
  2104      Doc.show(page.submissionElements)
  2105      Doc.hide(page.txMsg, page.errMsg, page.addressBox, page.balanceBox, page.addressBox)
  2106  
  2107      Doc.empty(page.tokenSymbol)
  2108      page.tokenSymbol.appendChild(Doc.symbolize(tokenAsset, true))
  2109      const protocolVersion = app().exchanges[host].assets[assetID].version
  2110      const res = await postJSON('/api/approvetokenfee', {
  2111        assetID: tokenAsset.id,
  2112        version: protocolVersion,
  2113        approving: true
  2114      })
  2115      if (!app().checkResponse(res)) {
  2116        page.errMsg.textContent = res.msg
  2117        Doc.show(page.errMsg)
  2118      } else {
  2119        const { unitInfo: ui, wallet: { address, balance: { available: avail } }, name: parentName } = app().assets[parentID]
  2120        const txFee = this.txFee = res.txFee as number
  2121        let feeText = `${Doc.formatCoinValue(txFee, ui)} ${ui.conventional.unit}`
  2122        const rate = app().fiatRatesMap[parentID]
  2123        if (rate) {
  2124          feeText += ` (${Doc.formatFiatConversion(txFee, rate, ui)} USD)`
  2125        }
  2126        page.feeEstimate.textContent = feeText
  2127        Doc.show(page.balanceBox)
  2128        page.balance.textContent = Doc.formatCoinValue(avail, ui)
  2129        page.parentTicker.textContent = ui.conventional.unit
  2130        page.parentName.textContent = parentName
  2131        if (avail < txFee) {
  2132          Doc.show(page.addressBox)
  2133          page.address.textContent = address
  2134        }
  2135      }
  2136    }
  2137  
  2138    /*
  2139     * approve calls the /api/approvetoken endpoint.
  2140     */
  2141    async approve () {
  2142      const { page, assetID, host, success } = this
  2143      const path = '/api/approvetoken'
  2144      const tokenAsset = app().assets[assetID]
  2145      const res = await postJSON(path, {
  2146        assetID: tokenAsset.id,
  2147        dexAddr: host
  2148      })
  2149      if (!app().checkResponse(res)) {
  2150        page.errMsg.textContent = res.msg
  2151        Doc.show(page.errMsg)
  2152        return
  2153      }
  2154      page.txid.innerText = res.txID
  2155      const assetExplorer = CoinExplorers[tokenAsset.id]
  2156      if (assetExplorer && assetExplorer[app().user.net]) {
  2157        page.txid.href = assetExplorer[app().user.net](res.txID)
  2158      }
  2159      Doc.hide(page.submissionElements, page.balanceBox, page.addressBox)
  2160      Doc.show(page.txMsg)
  2161      if (success) success()
  2162    }
  2163  
  2164    handleBalanceNote (n: BalanceNote) {
  2165      const { page, parentID, txFee } = this
  2166      if (n.assetID !== parentID) return
  2167      page.balance.textContent = Doc.formatCoinValue(n.balance.available, app().assets[parentID].unitInfo)
  2168      if (n.balance.available >= txFee) {
  2169        Doc.hide(page.addressBox)
  2170      } else Doc.hide(page.errMsg)
  2171    }
  2172  }
  2173  
  2174  export class CEXConfigurationForm {
  2175    form: PageElement
  2176    page: Record<string, PageElement>
  2177    updated: (cexName: string, success: boolean) => void
  2178    cexName: string
  2179  
  2180    constructor (form: PageElement, updated: (cexName: string, success: boolean) => void) {
  2181      this.form = form
  2182      this.updated = updated
  2183      this.page = Doc.parseTemplate(form)
  2184      Doc.bind(this.page.cexSubmit, 'click', () => this.submit())
  2185    }
  2186  
  2187    setCEX (cexName: string) {
  2188      this.cexName = cexName
  2189      setCexElements(this.form, cexName)
  2190      const page = this.page
  2191      Doc.hide(page.cexConfigPrompt, page.cexConnectErrBox, page.cexFormErr)
  2192      page.cexApiKeyInput.value = ''
  2193      page.cexSecretInput.value = ''
  2194      const cexStatus = app().mmStatus.cexes[cexName]
  2195      const connectErr = cexStatus?.connectErr
  2196      if (connectErr) {
  2197        Doc.show(page.cexConnectErrBox)
  2198        page.cexConnectErr.textContent = connectErr
  2199        page.cexApiKeyInput.value = cexStatus.config.apiKey
  2200        page.cexSecretInput.value = cexStatus.config.apiSecret
  2201      } else {
  2202        Doc.show(page.cexConfigPrompt)
  2203      }
  2204    }
  2205  
  2206    /*
  2207    * handleCEXSubmit handles clicks on the CEX configuration submission button.
  2208    */
  2209    async submit () {
  2210      const { page, cexName, form } = this
  2211      Doc.hide(page.cexFormErr)
  2212      const apiKey = page.cexApiKeyInput.value
  2213      const apiSecret = page.cexSecretInput.value
  2214      if (!apiKey || !apiSecret) {
  2215        Doc.show(page.cexFormErr)
  2216        page.cexFormErr.textContent = intl.prep(intl.ID_NO_PASS_ERROR_MSG)
  2217        return
  2218      }
  2219      const loaded = app().loading(form)
  2220      try {
  2221        const res = await MM.updateCEXConfig({
  2222          name: cexName,
  2223          apiKey: apiKey,
  2224          apiSecret: apiSecret
  2225        })
  2226        if (!app().checkResponse(res)) throw res
  2227        this.updated(cexName, true)
  2228      } catch (e) {
  2229        Doc.show(page.cexFormErr)
  2230        page.cexFormErr.textContent = intl.prep(intl.ID_API_ERROR, { msg: e.msg ?? String(e) })
  2231        this.updated(cexName, false)
  2232      } finally {
  2233        loaded()
  2234      }
  2235    }
  2236  }
  2237  
  2238  const animationLength = 300
  2239  
  2240  /* Swap form1 for form2 with an animation. */
  2241  export async function slideSwap (form1: HTMLElement, form2: HTMLElement) {
  2242    const shift = document.body.offsetWidth / 2
  2243    await Doc.animate(animationLength, progress => {
  2244      form1.style.right = `${progress * shift}px`
  2245    }, 'easeInHard')
  2246    Doc.hide(form1)
  2247    form1.style.right = '0'
  2248    form2.style.right = String(-shift)
  2249    Doc.show(form2)
  2250    if (form2.querySelector('input')) {
  2251      Doc.safeSelector(form2, 'input').focus()
  2252    }
  2253    await Doc.animate(animationLength, progress => {
  2254      form2.style.right = `${-shift + progress * shift}px`
  2255    }, 'easeOutHard')
  2256    form2.style.right = '0'
  2257  }
  2258  
  2259  export function showSuccess (page: Record<string, PageElement>, msg: string) {
  2260    page.successMessage.textContent = msg
  2261    Doc.show(page.forms, page.checkmarkForm)
  2262    page.checkmarkForm.style.right = '0'
  2263    page.checkmark.style.fontSize = '0px'
  2264  
  2265    const [startR, startG, startB] = State.isDark() ? [223, 226, 225] : [51, 51, 51]
  2266    const [endR, endG, endB] = [16, 163, 16]
  2267    const [diffR, diffG, diffB] = [endR - startR, endG - startG, endB - startB]
  2268  
  2269    return new Animation(1200, (prog: number) => {
  2270      page.checkmark.style.fontSize = `${prog * 80}px`
  2271      page.checkmark.style.color = `rgb(${startR + prog * diffR}, ${startG + prog * diffG}, ${startB + prog * diffB})`
  2272    }, 'easeOutElastic')
  2273  }
  2274  
  2275  /*
  2276   * bind binds the click and submit events and prevents page reloading on
  2277   * submission.
  2278   */
  2279  export function bind (form: HTMLElement, submitBttn: HTMLElement, handler: (e: Event) => void) {
  2280    const wrapper = (e: Event) => {
  2281      if (e.preventDefault) e.preventDefault()
  2282      handler(e)
  2283    }
  2284    Doc.bind(submitBttn, 'click', wrapper)
  2285    Doc.bind(form, 'submit', wrapper)
  2286  }
  2287  
  2288  // isTruthyString will be true if the provided string is recognized as a
  2289  // value representing true.
  2290  function isTruthyString (s: string) {
  2291    return s === '1' || s.toLowerCase() === 'true'
  2292  }
  2293  
  2294  // toUnixDate converts a javascript date object to a unix date, which is
  2295  // the number of *seconds* since the start of the epoch.
  2296  function toUnixDate (date: Date) {
  2297    return Math.floor(date.getTime() / 1000)
  2298  }
  2299  
  2300  // dateApplyOffset shifts a date by the timezone offset. This is used to make
  2301  // UTC dates show the local date. This can be used to prepare a Date so
  2302  // toISOString generates a local date string. This is also used to trick an html
  2303  // input element to show the local date when setting the valueAsDate field. When
  2304  // reading the date back to JS, the value field should be interpreted as local
  2305  // using the "T00:00" suffix, or the Date in valueAsDate should be shifted in
  2306  // the opposite direction.
  2307  function dateApplyOffset (date: Date) {
  2308    return new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000)
  2309  }
  2310  
  2311  // dateToString converts a javascript date object to a YYYY-MM-DD format string,
  2312  // in the local time zone.
  2313  function dateToString (date: Date) {
  2314    return dateApplyOffset(date).toISOString().split('T')[0]
  2315    // Another common hack:
  2316    // date.toLocaleString("sv-SE", { year: "numeric", month: "2-digit", day: "2-digit" })
  2317  }