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

     1  import Doc, { Animation, AniToggle } from './doc'
     2  import BasePage from './basepage'
     3  import { postJSON } from './http'
     4  import * as forms from './forms'
     5  import * as intl from './locales'
     6  import { ReputationMeter, strongTier } from './account'
     7  import {
     8    app,
     9    PageElement,
    10    ConnectionStatus,
    11    Exchange,
    12    WalletState,
    13    PrepaidBondID
    14  } from './registry'
    15  
    16  interface Animator {
    17    animate: (() => Promise<void>)
    18  }
    19  
    20  interface BondOptionsForm {
    21    host?: string // Required, but set by updateBondOptions
    22    bondAssetID?: number
    23    targetTier?: number
    24    penaltyComps?: number
    25  }
    26  
    27  const animationLength = 300
    28  
    29  export default class DexSettingsPage extends BasePage {
    30    body: HTMLElement
    31    forms: PageElement[]
    32    currentForm: PageElement
    33    page: Record<string, PageElement>
    34    host: string
    35    accountDisabled:boolean
    36    keyup: (e: KeyboardEvent) => void
    37    dexAddrForm: forms.DEXAddressForm
    38    bondFeeBufferCache: Record<string, number>
    39    newWalletForm: forms.NewWalletForm
    40    regAssetForm: forms.FeeAssetSelectionForm
    41    walletWaitForm: forms.WalletWaitForm
    42    confirmRegisterForm: forms.ConfirmRegistrationForm
    43    reputationMeter: ReputationMeter
    44    animation: Animation
    45    renewToggle: AniToggle
    46  
    47    constructor (body: HTMLElement) {
    48      super()
    49      this.body = body
    50      const host = this.host = body.dataset.host ? body.dataset.host : ''
    51      const xc = app().exchanges[host]
    52      const page = this.page = Doc.idDescendants(body)
    53      this.forms = Doc.applySelector(page.forms, ':scope > form')
    54  
    55      this.confirmRegisterForm = new forms.ConfirmRegistrationForm(page.confirmRegForm, async () => {
    56        this.showSuccess(intl.prep(intl.ID_TRADING_TIER_UPDATED))
    57        this.renewToggle.setState(this.confirmRegisterForm.tier > 0)
    58        await app().fetchUser()
    59        app().updateMenuItemsDisplay()
    60      }, () => {
    61        this.runAnimation(this.regAssetForm, page.regAssetForm)
    62      })
    63      this.confirmRegisterForm.setExchange(xc, '')
    64  
    65      this.walletWaitForm = new forms.WalletWaitForm(page.walletWait, () => {
    66        this.runAnimation(this.confirmRegisterForm, page.confirmRegForm)
    67      }, () => {
    68        this.runAnimation(this.regAssetForm, page.regAssetForm)
    69      })
    70      this.walletWaitForm.setExchange(xc)
    71  
    72      this.newWalletForm = new forms.NewWalletForm(
    73        page.newWalletForm,
    74        assetID => this.newWalletCreated(assetID, this.confirmRegisterForm.tier),
    75        () => this.runAnimation(this.regAssetForm, page.regAssetForm)
    76      )
    77  
    78      this.regAssetForm = new forms.FeeAssetSelectionForm(page.regAssetForm, async (assetID: number, tier: number) => {
    79        if (assetID === PrepaidBondID) {
    80          await app().fetchUser()
    81          this.updateReputation()
    82          this.showSuccess(intl.prep(intl.ID_TRADING_TIER_UPDATED))
    83          return
    84        }
    85        const asset = app().assets[assetID]
    86        const wallet = asset.wallet
    87        if (wallet) {
    88          const loaded = app().loading(page.regAssetForm)
    89          const bondsFeeBuffer = await this.getBondsFeeBuffer(assetID, page.regAssetForm)
    90          this.confirmRegisterForm.setAsset(assetID, tier, bondsFeeBuffer)
    91          loaded()
    92          this.progressTierFormsWithWallet(assetID, wallet)
    93          return
    94        }
    95        this.confirmRegisterForm.setAsset(assetID, tier, 0)
    96        this.newWalletForm.setAsset(assetID)
    97        this.showForm(page.newWalletForm)
    98      })
    99      this.regAssetForm.setExchange(xc, '')
   100  
   101      this.reputationMeter = new ReputationMeter(page.repMeter)
   102      this.reputationMeter.setHost(host)
   103  
   104      Doc.bind(page.exportDexBtn, 'click', () => this.exportAccount())
   105  
   106      this.accountDisabled = body.dataset.disabled === 'true'
   107      Doc.bind(page.toggleAccountStatusBtn, 'click', () => {
   108        if (!this.accountDisabled) this.prepareAccountDisable(page.disableAccountForm)
   109        else this.toggleAccountStatus(false)
   110      })
   111      Doc.bind(page.updateCertBtn, 'click', () => page.certFileInput.click())
   112      Doc.bind(page.updateHostBtn, 'click', () => this.prepareUpdateHost())
   113      Doc.bind(page.certFileInput, 'change', () => this.onCertFileChange())
   114      Doc.bind(page.goBackToSettings, 'click', () => app().loadPage('settings'))
   115  
   116      const showTierForm = () => {
   117        this.regAssetForm.setExchange(app().exchanges[host], '') // reset form
   118        this.showForm(page.regAssetForm)
   119      }
   120      Doc.bind(page.changeTier, 'click', () => { showTierForm() })
   121      const willAutoRenew = xc.auth.targetTier > 0
   122      this.renewToggle = new AniToggle(page.toggleAutoRenew, page.renewErr, willAutoRenew, async (newState: boolean) => {
   123        if (this.accountDisabled) return
   124        if (newState) showTierForm()
   125        else return this.disableAutoRenew()
   126      })
   127      Doc.bind(page.autoRenewBox, 'click', (e: MouseEvent) => {
   128        e.stopPropagation()
   129        if (!this.accountDisabled) page.toggleAutoRenew.click()
   130      })
   131  
   132      page.penaltyCompInput.value = String(xc.auth.penaltyComps)
   133      Doc.bind(page.penaltyCompBox, 'click', (e: MouseEvent) => {
   134        e.stopPropagation()
   135        const xc = app().exchanges[this.host]
   136        page.penaltyCompInput.value = String(xc.auth.penaltyComps)
   137        page.penaltyCompInput.focus()
   138      })
   139  
   140      Doc.bind(page.penaltyCompInput, 'keyup', async (e: KeyboardEvent) => {
   141        Doc.hide(page.penaltyCompsErr)
   142        if (e.key === 'Escape') {
   143          return
   144        }
   145        if (!(e.key === 'Enter')) return
   146        const penaltyComps = parseInt(page.penaltyCompInput.value || '')
   147        if (isNaN(penaltyComps)) {
   148          Doc.show(page.penaltyCompsErr)
   149          page.penaltyCompsErr.textContent = intl.prep(intl.ID_INVALID_COMPS_VALUE)
   150          return
   151        }
   152        const loaded = app().loading(page.otherBondSettings)
   153        try {
   154          await this.updateBondOptions({ penaltyComps })
   155          loaded()
   156        } catch (e) {
   157          loaded()
   158          Doc.show(page.penaltyCompsErr)
   159          page.penaltyCompsErr.textContent = intl.prep(intl.ID_API_ERROR, { msg: e.msg })
   160        }
   161      })
   162  
   163      this.dexAddrForm = new forms.DEXAddressForm(page.dexAddrForm, async (xc: Exchange) => {
   164        app().loadPage(`/dexsettings/${xc.host}`)
   165      }, this.host)
   166  
   167      // forms.bind(page.bondDetailsForm, page.updateBondOptionsConfirm, () => this.updateBondOptions())
   168      forms.bind(page.disableAccountForm, page.disableAccountConfirm, () => this.toggleAccountStatus(true))
   169  
   170      Doc.bind(page.forms, 'mousedown', (e: MouseEvent) => {
   171        if (!Doc.mouseInElement(e, this.currentForm)) { this.closePopups() }
   172      })
   173  
   174      this.keyup = (e: KeyboardEvent) => {
   175        if (e.key === 'Escape') {
   176          this.closePopups()
   177        }
   178      }
   179      Doc.bind(document, 'keyup', this.keyup)
   180  
   181      Doc.applySelector(page.forms, '.form-closer').forEach(el => {
   182        Doc.bind(el, 'click', () => { this.closePopups() })
   183      })
   184  
   185      app().registerNoteFeeder({
   186        conn: () => { this.setConnectionStatus() },
   187        reputation: () => { this.updateReputation() },
   188        feepayment: () => { this.updateReputation() },
   189        bondpost: () => { this.updateReputation() }
   190      })
   191  
   192      this.setConnectionStatus()
   193      this.updateReputation()
   194    }
   195  
   196    unload () {
   197      Doc.unbind(document, 'keyup', this.keyup)
   198    }
   199  
   200    async progressTierFormsWithWallet (assetID: number, wallet: WalletState) {
   201      const { page, confirmRegisterForm: { fees } } = this
   202      const asset = app().assets[assetID]
   203      const { bondAssets } = this.regAssetForm.xc
   204      const bondAsset = bondAssets[asset.symbol]
   205      if (!wallet.open) {
   206        const loaded = app().loading(page.forms)
   207        const res = await postJSON('/api/openwallet', { assetID: assetID })
   208        loaded()
   209        if (!app().checkResponse(res)) {
   210          this.regAssetForm.setAssetError(`error unlocking wallet: ${res.msg}`)
   211          this.runAnimation(this.regAssetForm, page.regAssetForm)
   212        }
   213        return
   214      }
   215      if (wallet.synced && wallet.balance.available >= 2 * bondAsset.amount + fees) {
   216        // If we are raising our tier, we'll show a confirmation form
   217        this.progressTierFormWithSyncedFundedWallet(assetID)
   218        return
   219      }
   220      this.walletWaitForm.setWallet(assetID, fees, this.confirmRegisterForm.tier)
   221      this.showForm(page.walletWait)
   222    }
   223  
   224    async progressTierFormWithSyncedFundedWallet (bondAssetID: number) {
   225      const xc = app().exchanges[this.host]
   226      const targetTier = this.confirmRegisterForm.tier
   227      const page = this.page
   228      const strongTier = xc.auth.liveStrength + xc.auth.pendingStrength - xc.auth.weakStrength
   229      if (targetTier > xc.auth.targetTier && targetTier > strongTier) {
   230        this.runAnimation(this.confirmRegisterForm, page.confirmRegForm)
   231        return
   232      }
   233      // Lowering tier
   234      const loaded = app().loading(this.body)
   235      try {
   236        await this.updateBondOptions({ bondAssetID, targetTier })
   237        loaded()
   238      } catch (e) {
   239        loaded()
   240        this.regAssetForm.setTierError(e.msg)
   241        return
   242      }
   243      // this.animateConfirmForm(page.regAssetForm)
   244      this.showSuccess(intl.prep(intl.ID_TRADING_TIER_UPDATED))
   245    }
   246  
   247    updateReputation () {
   248      const page = this.page
   249      const auth = app().exchanges[this.host].auth
   250      const { rep: { penalties }, targetTier, expiredBonds } = auth
   251      const displayTier = strongTier(auth)
   252      page.targetTier.textContent = String(targetTier)
   253      page.effectiveTier.textContent = String(displayTier)
   254      page.penalties.textContent = String(penalties)
   255      page.bondsPendingRefund.textContent = `${expiredBonds?.length || 0}`
   256      this.reputationMeter.update()
   257    }
   258  
   259    /* showForm shows a modal form with a little animation. */
   260    async showForm (form: HTMLElement) {
   261      const page = this.page
   262      this.currentForm = form
   263      this.forms.forEach(form => Doc.hide(form))
   264      form.style.right = '10000px'
   265      Doc.show(page.forms, form)
   266      const shift = (page.forms.offsetWidth + form.offsetWidth) / 2
   267      await Doc.animate(animationLength, progress => {
   268        form.style.right = `${(1 - progress) * shift}px`
   269      }, 'easeOutHard')
   270      form.style.right = '0'
   271    }
   272  
   273    async runAnimation (ani: Animator, form: PageElement) {
   274      Doc.hide(this.currentForm)
   275      await ani.animate()
   276      this.currentForm = form
   277      Doc.show(form)
   278    }
   279  
   280    closePopups () {
   281      Doc.hide(this.page.forms)
   282      if (this.animation) this.animation.stop()
   283    }
   284  
   285    async showSuccess (msg: string) {
   286      this.forms.forEach(form => Doc.hide(form))
   287      this.currentForm = this.page.checkmarkForm
   288      this.animation = forms.showSuccess(this.page, msg)
   289      await this.animation.wait()
   290      this.animation = new Animation(1500, () => { /* pass */ }, '', () => {
   291        if (this.currentForm === this.page.checkmarkForm) this.closePopups()
   292      })
   293    }
   294  
   295    // exportAccount exports and downloads the account info.
   296    async exportAccount () {
   297      const { page, host } = this
   298      const req = { host }
   299      const loaded = app().loading(this.body)
   300      const res = await postJSON('/api/exportaccount', req)
   301      loaded()
   302      if (!app().checkResponse(res)) {
   303        page.exportAccountErr.textContent = res.msg
   304        Doc.show(page.exportAccountErr)
   305        return
   306      }
   307      res.account.bonds = res.bonds // maintain backward compat of JSON file
   308      const accountForExport = JSON.parse(JSON.stringify(res.account))
   309      const a = document.createElement('a')
   310      a.setAttribute('download', 'dcrAccount-' + host + '.json')
   311      a.setAttribute('href', 'data:text/json,' + JSON.stringify(accountForExport, null, 2))
   312      a.click()
   313      Doc.hide(page.forms)
   314    }
   315  
   316    // toggleAccountStatus enables or disables the account associated with the
   317    // provided host.
   318    async toggleAccountStatus (disable:boolean) {
   319      const page = this.page
   320      Doc.hide(page.errMsg)
   321      let host: string|null = this.host
   322      if (disable) host = page.disableAccountHost.textContent
   323      const req = { host, disable: disable }
   324      const loaded = app().loading(this.body)
   325      const res = await postJSON('/api/toggleaccountstatus', req)
   326      loaded()
   327      if (!app().checkResponse(res)) {
   328        if (disable) {
   329          page.disableAccountErr.textContent = res.msg
   330          Doc.show(page.disableAccountErr)
   331        } else {
   332          page.errMsg.textContent = res.msg
   333          Doc.show(page.errMsg)
   334        }
   335        return
   336      }
   337      if (disable) {
   338        this.page.toggleAccountStatusBtn.textContent = intl.prep(intl.ID_ENABLE_ACCOUNT)
   339        Doc.hide(page.forms)
   340      } else this.page.toggleAccountStatusBtn.textContent = intl.prep(intl.ID_DISABLE_ACCOUNT)
   341  
   342      this.accountDisabled = disable
   343  
   344      // Refresh exchange information since we've just enabled/disabled the
   345      // exchange.
   346      await app().fetchUser()
   347      app().loadPage(`dexsettings/${host}`)
   348    }
   349  
   350    async prepareAccountDisable (disableAccountForm: HTMLElement) {
   351      const page = this.page
   352      page.disableAccountHost.textContent = this.host
   353      page.disableAccountErr.textContent = ''
   354      this.showForm(disableAccountForm)
   355    }
   356  
   357    // Retrieve an estimate for the tx fee needed to create new bond reserves.
   358    async getBondsFeeBuffer (assetID: number, form: HTMLElement) {
   359      const loaded = app().loading(form)
   360      const res = await postJSON('/api/bondsfeebuffer', { assetID })
   361      loaded()
   362      if (!app().checkResponse(res)) {
   363        return 0
   364      }
   365      return res.feeBuffer
   366    }
   367  
   368    async prepareUpdateHost () {
   369      const page = this.page
   370      this.dexAddrForm.refresh()
   371      this.showForm(page.dexAddrForm)
   372    }
   373  
   374    async onCertFileChange () {
   375      const page = this.page
   376      Doc.hide(page.errMsg)
   377      const files = page.certFileInput.files
   378      let cert
   379      if (files && files.length) cert = await files[0].text()
   380      if (!cert) return
   381      const req = { host: this.host, cert: cert }
   382      const loaded = app().loading(this.body)
   383      const res = await postJSON('/api/updatecert', req)
   384      loaded()
   385      if (!app().checkResponse(res)) {
   386        page.errMsg.textContent = res.msg
   387        Doc.show(page.errMsg)
   388      } else {
   389        Doc.show(page.updateCertMsg)
   390        setTimeout(() => { Doc.hide(page.updateCertMsg) }, 5000)
   391      }
   392    }
   393  
   394    setConnectionStatus () {
   395      const page = this.page
   396      const exchange = app().user.exchanges[this.host]
   397      const displayIcons = (connected: boolean) => {
   398        if (connected) {
   399          Doc.hide(page.disconnectedIcon)
   400          Doc.show(page.connectedIcon)
   401        } else {
   402          Doc.show(page.disconnectedIcon)
   403          Doc.hide(page.connectedIcon)
   404        }
   405      }
   406      if (exchange) {
   407        switch (exchange.connectionStatus) {
   408          case ConnectionStatus.Connected:
   409            displayIcons(true)
   410            page.connectionStatus.textContent = intl.prep(intl.ID_CONNECTED)
   411            break
   412          case ConnectionStatus.Disconnected:
   413            displayIcons(false)
   414            if (this.accountDisabled) page.connectionStatus.textContent = intl.prep(intl.ID_ACCOUNT_DISABLED_MSG)
   415            else page.connectionStatus.textContent = intl.prep(intl.ID_DISCONNECTED)
   416            break
   417          case ConnectionStatus.InvalidCert:
   418            displayIcons(false)
   419            page.connectionStatus.textContent = `${intl.prep(intl.ID_DISCONNECTED)} - ${intl.prep(intl.ID_INVALID_CERTIFICATE)}`
   420        }
   421      }
   422    }
   423  
   424    async disableAutoRenew () {
   425      const loaded = app().loading(this.page.otherBondSettings)
   426      try {
   427        this.updateBondOptions({ targetTier: 0 })
   428        loaded()
   429      } catch (e) {
   430        loaded()
   431        throw e
   432      }
   433    }
   434  
   435    /*
   436     * updateBondOptions is called when the form to update bond options is
   437     * submitted.
   438     */
   439    async updateBondOptions (conf: BondOptionsForm): Promise<any> {
   440      conf.host = this.host
   441      await postJSON('/api/updatebondoptions', conf)
   442      const targetTier = conf.targetTier ?? app().exchanges[this.host].auth.targetTier
   443      this.renewToggle.setState(targetTier > 0)
   444    }
   445  
   446    async newWalletCreated (assetID: number, tier: number) {
   447      this.regAssetForm.refresh()
   448      const user = await app().fetchUser()
   449      if (!user) return
   450      const page = this.page
   451      const asset = user.assets[assetID]
   452      const wallet = asset.wallet
   453      const xc = app().exchanges[this.host]
   454      const bondAmt = xc.bondAssets[asset.symbol].amount
   455  
   456      const bondsFeeBuffer = await this.getBondsFeeBuffer(assetID, page.newWalletForm)
   457      this.confirmRegisterForm.setFees(assetID, bondsFeeBuffer)
   458  
   459      if (wallet.synced && wallet.balance.available >= 2 * bondAmt + bondsFeeBuffer) {
   460        this.progressTierFormWithSyncedFundedWallet(assetID)
   461        return
   462      }
   463  
   464      this.walletWaitForm.setWallet(assetID, bondsFeeBuffer, tier)
   465      await this.showForm(page.walletWait)
   466    }
   467  }