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 }