decred.org/dcrdex@v1.0.5/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 }