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 }