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