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

     1  import Doc from './doc'
     2  import BasePage from './basepage'
     3  import State from './state'
     4  import { postJSON } from './http'
     5  import * as forms from './forms'
     6  import * as intl from './locales'
     7  import { setCoinHref } from './coinexplorers'
     8  import {
     9    updateNtfnSetting,
    10    DesktopNtfnSetting,
    11    fetchDesktopNtfnSettings,
    12    desktopNtfnLabels,
    13    Notifier
    14  } from './notifications'
    15  import {
    16    app,
    17    Exchange,
    18    PageElement,
    19    PrepaidBondID
    20  } from './registry'
    21  
    22  const animationLength = 300
    23  
    24  export default class SettingsPage extends BasePage {
    25    body: HTMLElement
    26    currentDEX: Exchange
    27    page: Record<string, PageElement>
    28    forms: PageElement[]
    29    fiatRateSources: PageElement[]
    30    regAssetForm: forms.FeeAssetSelectionForm
    31    confirmRegisterForm: forms.ConfirmRegistrationForm
    32    newWalletForm: forms.NewWalletForm
    33    walletWaitForm: forms.WalletWaitForm
    34    dexAddrForm: forms.DEXAddressForm
    35    appPassResetForm: forms.AppPassResetForm
    36    currentForm: PageElement
    37    keyup: (e: KeyboardEvent) => void
    38  
    39    constructor (body: HTMLElement) {
    40      super()
    41      this.body = body
    42      const page = this.page = Doc.idDescendants(body)
    43  
    44      this.forms = Doc.applySelector(page.forms, ':scope > form')
    45      this.fiatRateSources = Doc.applySelector(page.fiatRateSources, 'input[type=checkbox]')
    46  
    47      page.darkMode.checked = State.fetchLocal(State.darkModeLK) === '1'
    48      Doc.bind(page.darkMode, 'click', () => {
    49        State.storeLocal(State.darkModeLK, page.darkMode.checked || false ? '1' : '0')
    50        if (page.darkMode.checked) {
    51          document.body.classList.add('dark')
    52        } else {
    53          document.body.classList.remove('dark')
    54        }
    55      })
    56  
    57      page.showPokes.checked = State.fetchLocal(State.popupsLK) === '1'
    58      Doc.bind(page.showPokes, 'click', () => {
    59        const show = page.showPokes.checked || false
    60        State.storeLocal(State.popupsLK, show ? '1' : '0')
    61        app().showPopups = show
    62      })
    63  
    64      page.commitHash.textContent = app().commitHash.substring(0, 7)
    65      Doc.bind(page.addADex, 'click', () => {
    66        this.dexAddrForm.refresh()
    67        this.showForm(page.dexAddrForm)
    68      })
    69  
    70      this.fiatRateSources.forEach(src => {
    71        Doc.bind(src, 'change', async () => {
    72          const res = await postJSON('/api/toggleratesource', {
    73            disable: !src.checked,
    74            source: src.value
    75          })
    76          if (!app().checkResponse(res)) {
    77            src.checked = !src.checked
    78          }
    79          // Update asset rate values and disable conversion status.
    80          await app().fetchUser()
    81        })
    82      })
    83  
    84      // Asset selection
    85      this.regAssetForm = new forms.FeeAssetSelectionForm(page.regAssetForm, async (assetID: number, tier: number) => {
    86        if (assetID === PrepaidBondID) {
    87          await app().fetchUser()
    88          window.location.reload()
    89          return
    90        }
    91        const asset = app().assets[assetID]
    92        const wallet = asset.wallet
    93        if (wallet) {
    94          const bondAsset = this.currentDEX.bondAssets[asset.symbol]
    95          const bondsFeeBuffer = await this.getBondsFeeBuffer(assetID, page.regAssetForm)
    96          this.confirmRegisterForm.setAsset(assetID, tier, bondsFeeBuffer)
    97          if (wallet.synced && wallet.balance.available >= 2 * bondAsset.amount + bondsFeeBuffer) {
    98            this.animateConfirmForm(page.regAssetForm)
    99            return
   100          }
   101          this.walletWaitForm.setWallet(assetID, bondsFeeBuffer, tier)
   102          this.slideSwap(page.walletWait)
   103          return
   104        }
   105  
   106        this.confirmRegisterForm.setAsset(assetID, tier, 0)
   107        this.newWalletForm.setAsset(assetID)
   108        this.slideSwap(page.newWalletForm)
   109      })
   110  
   111      // Approve fee payment
   112      this.confirmRegisterForm = new forms.ConfirmRegistrationForm(page.confirmRegForm, () => {
   113        this.registerDEXSuccess()
   114      }, () => {
   115        this.animateRegAsset(page.confirmRegForm)
   116      })
   117  
   118      // Create a new wallet
   119      this.newWalletForm = new forms.NewWalletForm(
   120        page.newWalletForm,
   121        assetID => this.newWalletCreated(assetID, this.confirmRegisterForm.tier),
   122        () => this.animateRegAsset(page.newWalletForm)
   123      )
   124  
   125      this.walletWaitForm = new forms.WalletWaitForm(page.walletWait, () => {
   126        this.animateConfirmForm(page.walletWait)
   127      }, () => { this.animateRegAsset(page.walletWait) })
   128  
   129      // Enter an address for a new DEX
   130      this.dexAddrForm = new forms.DEXAddressForm(page.dexAddrForm, async (xc: Exchange, certFile: string) => {
   131        this.currentDEX = xc
   132        this.confirmRegisterForm.setExchange(xc, certFile)
   133        this.walletWaitForm.setExchange(xc)
   134        this.regAssetForm.setExchange(xc, certFile)
   135        this.animateRegAsset(page.dexAddrForm)
   136      })
   137  
   138      Doc.bind(page.importAccount, 'click', () => this.prepareAccountImport(page.authorizeAccountImportForm))
   139      forms.bind(page.authorizeAccountImportForm, page.authorizeImportAccountConfirm, () => this.importAccount())
   140  
   141      Doc.bind(page.changeAppPW, 'click', () => this.showForm(page.changeAppPWForm))
   142      forms.bind(page.changeAppPWForm, page.submitNewPW, () => this.changeAppPW())
   143  
   144      this.appPassResetForm = new forms.AppPassResetForm(page.resetAppPWForm, async () => {
   145        await app().loadPage('login')
   146        Doc.hide(page.forms)
   147      })
   148      Doc.bind(page.resetAppPW, 'click', () => {
   149        this.appPassResetForm.refresh()
   150        this.showForm(page.resetAppPWForm)
   151        this.appPassResetForm.focus()
   152      })
   153  
   154      Doc.bind(page.accountFile, 'change', () => this.onAccountFileChange())
   155      Doc.bind(page.removeAccount, 'click', () => this.clearAccountFile())
   156      Doc.bind(page.addAccount, 'click', () => page.accountFile.click())
   157  
   158      Doc.bind(page.exportSeed, 'click', () => {
   159        Doc.hide(page.exportSeedErr)
   160        this.showForm(page.exportSeedAuth)
   161      })
   162      forms.bind(page.exportSeedAuth, page.exportSeedSubmit, () => this.submitExportSeedReq())
   163  
   164      Doc.bind(page.gameCodeLink, 'click', () => this.showForm(page.gameCodeForm))
   165      Doc.bind(page.gameCodeSubmit, 'click', () => this.submitGameCode())
   166  
   167      const closePopups = () => {
   168        Doc.hide(page.forms)
   169        page.exportSeedPW.value = ''
   170        page.legacySeed.textContent = ''
   171        page.mnemonic.textContent = ''
   172      }
   173  
   174      Doc.bind(page.forms, 'mousedown', (e: MouseEvent) => {
   175        if (!Doc.mouseInElement(e, this.currentForm)) { closePopups() }
   176      })
   177  
   178      this.keyup = (e: KeyboardEvent) => {
   179        if (e.key === 'Escape') {
   180          closePopups()
   181        }
   182      }
   183      Doc.bind(document, 'keyup', this.keyup)
   184  
   185      page.forms.querySelectorAll('.form-closer').forEach(el => {
   186        Doc.bind(el, 'click', () => { closePopups() })
   187      })
   188  
   189      this.renderDesktopNtfnSettings()
   190    }
   191  
   192    updateNtfnSetting (e: Event) {
   193      const checkbox = e.target as HTMLInputElement
   194      const noteType = checkbox.getAttribute('name')
   195      if (noteType === null) return
   196      const enabled = checkbox.checked
   197      updateNtfnSetting(noteType, enabled)
   198    }
   199  
   200    getBrowserNtfnSettings (): DesktopNtfnSetting {
   201      const permissions = fetchDesktopNtfnSettings()
   202      return permissions
   203    }
   204  
   205    async renderDesktopNtfnSettings () {
   206      const page = this.page
   207      const ntfnSettings = this.getBrowserNtfnSettings()
   208      const labels = desktopNtfnLabels
   209      const tmpl = page.browserNtfnCheckboxTemplate
   210      tmpl.removeAttribute('id')
   211      const container = page.browserNtfnCheckboxContainer
   212      Doc.empty(page.browserNtfnCheckboxContainer)
   213  
   214      Object.keys(labels).forEach((noteType) => {
   215        const html = tmpl.cloneNode(true) as PageElement
   216        const enabled = ntfnSettings[noteType]
   217        const checkbox = Doc.tmplElement(html, 'checkbox')
   218        Doc.tmplElement(html, 'label').textContent = intl.prep(labels[noteType])
   219        checkbox.setAttribute('name', noteType)
   220        if (enabled) checkbox.setAttribute('checked', 'checked')
   221        container.appendChild(html)
   222        Doc.bind(checkbox, 'click', this.updateNtfnSetting)
   223      })
   224  
   225      const enabledCheckbox = page.browserNtfnEnabled
   226  
   227      Doc.bind(enabledCheckbox, 'click', async (e: Event) => {
   228        if (Notifier.ntfnPermissionDenied()) return
   229        const checkbox = e.target as HTMLInputElement
   230        if (checkbox.checked) {
   231          await Notifier.requestNtfnPermission()
   232          checkbox.checked = !Notifier.ntfnPermissionDenied()
   233        }
   234        this.updateNtfnSetting(e)
   235        checkbox.dispatchEvent(new Event('change'))
   236      })
   237  
   238      Doc.bind(enabledCheckbox, 'change', (e: Event) => {
   239        const checkbox = e.target as HTMLInputElement
   240        const permDenied = Notifier.ntfnPermissionDenied()
   241        Doc.setVis(checkbox.checked, page.browserNtfnCheckboxContainer)
   242        Doc.setVis(permDenied, page.browserNtfnBlockedMsg)
   243        checkbox.disabled = permDenied
   244      })
   245  
   246      enabledCheckbox.checked = (Notifier.ntfnPermissionGranted() && ntfnSettings.browserNtfnEnabled)
   247      enabledCheckbox.dispatchEvent(new Event('change'))
   248    }
   249  
   250    /*
   251     * slideSwap animates the replacement of the currently shown form with the
   252     * newForm and sets this.currentForm.
   253     */
   254    slideSwap (newForm: PageElement) {
   255      forms.slideSwap(this.currentForm, newForm)
   256      this.currentForm = newForm
   257    }
   258  
   259    // Retrieve an estimate for the tx fee needed to create new bond reserves.
   260    async getBondsFeeBuffer (assetID: number, form: HTMLElement) {
   261      const loaded = app().loading(form)
   262      const res = await postJSON('/api/bondsfeebuffer', { assetID })
   263      loaded()
   264      if (!app().checkResponse(res)) {
   265        return 0
   266      }
   267      return res.feeBuffer
   268    }
   269  
   270    async newWalletCreated (assetID: number, tier: number) {
   271      const user = await app().fetchUser()
   272      if (!user) return
   273      const page = this.page
   274      const asset = user.assets[assetID]
   275      const wallet = asset.wallet
   276      const bondAmt = this.currentDEX.bondAssets[asset.symbol].amount
   277  
   278      const bondsFeeBuffer = await this.getBondsFeeBuffer(assetID, page.newWalletForm)
   279      this.confirmRegisterForm.setFees(assetID, bondsFeeBuffer)
   280      if (wallet.synced && wallet.balance.available >= 2 * bondAmt + bondsFeeBuffer) {
   281        await this.animateConfirmForm(page.newWalletForm)
   282        return
   283      }
   284  
   285      this.walletWaitForm.setWallet(assetID, bondsFeeBuffer, tier)
   286      this.slideSwap(page.walletWait)
   287    }
   288  
   289    async onAccountFileChange () {
   290      const page = this.page
   291      const files = page.accountFile.files
   292      if (!files || !files.length) return
   293      page.selectedAccount.textContent = files[0].name
   294      Doc.show(page.removeAccount)
   295      Doc.hide(page.addAccount)
   296    }
   297  
   298    /* clearAccountFile cleanup accountFile value and selectedAccount text */
   299    clearAccountFile () {
   300      const page = this.page
   301      page.accountFile.value = ''
   302      page.selectedAccount.textContent = intl.prep(intl.ID_NONE_SELECTED)
   303      Doc.hide(page.removeAccount)
   304      Doc.show(page.addAccount)
   305    }
   306  
   307    async prepareAccountImport (authorizeAccountImportForm: HTMLElement) {
   308      const page = this.page
   309      page.importAccountErr.textContent = ''
   310      this.showForm(authorizeAccountImportForm)
   311    }
   312  
   313    // importAccount imports the account
   314    async importAccount () {
   315      const page = this.page
   316      let accountString = ''
   317      if (page.accountFile.value) {
   318        const files = page.accountFile.files
   319        if (!files || !files.length) {
   320          console.error('importAccount: no file specified')
   321          return
   322        }
   323        accountString = await files[0].text()
   324      }
   325      let account
   326      try {
   327        account = JSON.parse(accountString)
   328      } catch (e) {
   329        page.importAccountErr.textContent = e.message
   330        Doc.show(page.importAccountErr)
   331        return
   332      }
   333      if (typeof account === 'undefined') {
   334        Doc.showFormError(page.importAccountErr, intl.prep(intl.ID_ACCT_UNDEFINED))
   335        return
   336      }
   337      const { bonds = [], ...acctInf } = account
   338      const req = {
   339        account: acctInf,
   340        bonds: bonds
   341      }
   342      const loaded = app().loading(this.body)
   343      const res = await postJSON('/api/importaccount', req)
   344      loaded()
   345      if (!app().checkResponse(res)) {
   346        Doc.showFormError(page.importAccountErr, res.msg)
   347        return
   348      }
   349      await app().fetchUser()
   350      Doc.hide(page.forms)
   351      // Initial method of displaying imported account.
   352      window.location.reload()
   353    }
   354  
   355    async submitExportSeedReq () {
   356      const page = this.page
   357      const pw = page.exportSeedPW.value
   358      const loaded = app().loading(this.body)
   359      const res = await postJSON('/api/exportseed', { pass: pw })
   360      loaded()
   361      if (!app().checkResponse(res)) {
   362        Doc.showFormError(page.exportSeedErr, res.msg)
   363        return
   364      }
   365      page.exportSeedPW.value = ''
   366      if (res.seed.length === 128 && res.seed.split(' ').length === 1) {
   367        page.legacySeed.textContent = res.seed.match(/.{1,32}/g).map((chunk: string) => chunk.match(/.{1,8}/g)?.join(' ')).join('\n')
   368      } else page.mnemonic.textContent = res.seed
   369      this.showForm(page.authorizeSeedDisplay)
   370    }
   371  
   372    /* showForm shows a modal form with a little animation. */
   373    async showForm (form: HTMLElement) {
   374      const page = this.page
   375      this.currentForm = form
   376      this.forms.forEach(form => Doc.hide(form))
   377      form.style.right = '10000px'
   378      Doc.show(page.forms, form)
   379      const shift = (page.forms.offsetWidth + form.offsetWidth) / 2
   380      await Doc.animate(animationLength, progress => {
   381        form.style.right = `${(1 - progress) * shift}px`
   382      }, 'easeOutHard')
   383      form.style.right = '0'
   384    }
   385  
   386    /* gets the contents of the cert file */
   387    async getCertFile () {
   388      let cert = ''
   389      if (this.dexAddrForm.page.certFile.value) {
   390        const files = this.dexAddrForm.page.certFile.files
   391        if (files && files.length) cert = await files[0].text()
   392      }
   393      return cert
   394    }
   395  
   396    /* Called after successful registration to a DEX. */
   397    async registerDEXSuccess () {
   398      window.location.reload()
   399    }
   400  
   401    /* Change application password  */
   402    async changeAppPW () {
   403      const page = this.page
   404      Doc.hide(page.changePWErrMsg)
   405  
   406      const clearValues = () => {
   407        page.appPW.value = ''
   408        page.newAppPW.value = ''
   409        page.confirmNewPW.value = ''
   410      }
   411      // Ensure password fields are nonempty.
   412      if (!page.appPW.value || !page.newAppPW.value || !page.confirmNewPW.value) {
   413        Doc.showFormError(page.changePWErrMsg, intl.prep(intl.ID_NO_APP_PASS_ERROR_MSG))
   414        clearValues()
   415        return
   416      }
   417      // Ensure password confirmation matches.
   418      if (page.newAppPW.value !== page.confirmNewPW.value) {
   419        Doc.showFormError(page.changePWErrMsg, intl.prep(intl.ID_PASSWORD_NOT_MATCH))
   420        clearValues()
   421        return
   422      }
   423      const loaded = app().loading(page.changeAppPW)
   424      const req = {
   425        appPW: page.appPW.value,
   426        newAppPW: page.newAppPW.value
   427      }
   428      clearValues()
   429      const res = await postJSON('/api/changeapppass', req)
   430      loaded()
   431      if (!app().checkResponse(res)) {
   432        Doc.showFormError(page.changePWErrMsg, res.msg)
   433        return
   434      }
   435      Doc.hide(page.forms)
   436    }
   437  
   438    /*
   439     * unload is called by the Application when the user navigates away from
   440     * the /settings page.
   441     */
   442    unload () {
   443      Doc.unbind(document, 'keyup', this.keyup)
   444    }
   445  
   446    /* Swap in the asset selection form and run the animation. */
   447    async animateRegAsset (oldForm: HTMLElement) {
   448      Doc.hide(oldForm)
   449      const form = this.page.regAssetForm
   450      this.currentForm = form
   451      this.regAssetForm.animate()
   452      Doc.show(form)
   453    }
   454  
   455    /* Swap in the confirmation form and run the animation. */
   456    async animateConfirmForm (oldForm: HTMLElement) {
   457      this.confirmRegisterForm.animate()
   458      const form = this.page.confirmRegForm
   459      this.currentForm = form
   460      Doc.hide(oldForm)
   461      Doc.show(form)
   462    }
   463  
   464    async submitGameCode () {
   465      const page = this.page
   466      Doc.hide(page.gameCodeErr)
   467      const code = page.gameCodeInput.value
   468      if (!code) {
   469        page.gameCodeErr.textContent = intl.prep(intl.ID_NO_CODE_PROVIDED)
   470        Doc.show(page.gameCodeErr)
   471        return
   472      }
   473      const msg = page.gameCodeMsg.value || ''
   474      const loaded = app().loading(page.gameCodeForm)
   475      const resp = await postJSON('/api/redeemgamecode', { code, msg })
   476      loaded()
   477      if (!app().checkResponse(resp)) {
   478        page.gameCodeErr.textContent = intl.prep(intl.ID_API_ERROR, { msg: resp.msg })
   479        Doc.show(page.gameCodeErr)
   480        return
   481      }
   482      Doc.show(page.gameCodeSuccess)
   483      page.gameRedeemTx.dataset.explorerCoin = resp.coinString
   484      const dcrBipID = 42
   485      setCoinHref(dcrBipID, page.gameRedeemTx)
   486      page.gameRedeemTx.textContent = resp.coinString
   487      const ui = app().unitInfo(dcrBipID)
   488      page.gameRedeemValue.textContent = Doc.formatCoinValue(resp.win, ui)
   489    }
   490  }