decred.org/dcrdex@v1.0.3/client/webserver/site/src/js/app.ts (about) 1 import Doc from './doc' 2 import State from './state' 3 import RegistrationPage from './register' 4 import LoginPage from './login' 5 import WalletsPage, { txTypeString } from './wallets' 6 import SettingsPage from './settings' 7 import MarketsPage from './markets' 8 import OrdersPage from './orders' 9 import OrderPage from './order' 10 import MarketMakerPage from './mm' 11 import MarketMakerSettingsPage from './mmsettings' 12 import DexSettingsPage from './dexsettings' 13 import MarketMakerArchivesPage from './mmarchives' 14 import MarketMakerLogsPage from './mmlogs' 15 import InitPage from './init' 16 import { MM } from './mmutil' 17 import { RateEncodingFactor, StatusExecuted, hasActiveMatches } from './orderutil' 18 import { getJSON, postJSON, Errors } from './http' 19 import * as ntfn from './notifications' 20 import ws from './ws' 21 import * as intl from './locales' 22 import { 23 User, 24 SupportedAsset, 25 Exchange, 26 WalletState, 27 BondNote, 28 ReputationNote, 29 CoreNote, 30 OrderNote, 31 Market, 32 Order, 33 Match, 34 BalanceNote, 35 WalletConfigNote, 36 WalletSyncNote, 37 MatchNote, 38 ConnEventNote, 39 SpotPriceNote, 40 UnitInfo, 41 WalletDefinition, 42 WalletBalance, 43 LogMessage, 44 NoteElement, 45 BalanceResponse, 46 APIResponse, 47 RateNote, 48 InFlightOrder, 49 WalletTransaction, 50 TxHistoryResult, 51 WalletNote, 52 TransactionNote, 53 PageElement, 54 ActionRequiredNote, 55 ActionResolvedNote, 56 TransactionActionNote, 57 CoreActionRequiredNote, 58 RejectedRedemptionData, 59 MarketMakingStatus, 60 RunStatsNote, 61 MMBotStatus, 62 CEXNotification, 63 CEXBalanceUpdate, 64 EpochReportNote, 65 CEXProblemsNote 66 } from './registry' 67 import { setCoinHref } from './coinexplorers' 68 69 const idel = Doc.idel // = element by id 70 const bind = Doc.bind 71 const unbind = Doc.unbind 72 73 const notificationRoute = 'notify' 74 const noteCacheSize = 100 75 76 interface Page { 77 unload (): void 78 } 79 80 interface PageClass { 81 new (main: HTMLElement, data: any): Page; 82 } 83 84 interface CoreNotePlus extends CoreNote { 85 el: HTMLElement // Added in app 86 } 87 88 interface UserResponse extends APIResponse { 89 user?: User 90 lang: string 91 langs: string[] 92 inited: boolean 93 mmStatus: MarketMakingStatus 94 } 95 96 /* constructors is a map to page constructors. */ 97 const constructors: Record<string, PageClass> = { 98 login: LoginPage, 99 register: RegistrationPage, 100 markets: MarketsPage, 101 wallets: WalletsPage, 102 settings: SettingsPage, 103 orders: OrdersPage, 104 order: OrderPage, 105 dexsettings: DexSettingsPage, 106 init: InitPage, 107 mm: MarketMakerPage, 108 mmsettings: MarketMakerSettingsPage, 109 mmarchives: MarketMakerArchivesPage, 110 mmlogs: MarketMakerLogsPage 111 } 112 113 interface LangData { 114 name: string 115 flag: string 116 } 117 118 const languageData: Record<string, LangData> = { 119 'en-US': { 120 name: 'English', 121 flag: 'πΊπΈ' // Not π¬π§. MURICA! 122 }, 123 'pt-BR': { 124 name: 'Portugese', 125 flag: 'π§π·' 126 }, 127 'zh-CN': { 128 name: 'Chinese', 129 flag: 'π¨π³' 130 }, 131 'pl-PL': { 132 name: 'Polish', 133 flag: 'π΅π±' 134 }, 135 'de-DE': { 136 name: 'German', 137 flag: 'π©πͺ' 138 }, 139 'ar': { 140 name: 'Arabic', 141 flag: 'πͺπ¬' // Egypt I guess 142 } 143 } 144 145 interface requiredAction { 146 div: PageElement 147 stamp: number 148 uniqueID: string 149 actionID: string 150 selected: boolean 151 } 152 153 // Application is the main javascript web application for Bison Wallet. 154 export default class Application { 155 notes: CoreNotePlus[] 156 pokes: CoreNotePlus[] 157 langs: string[] 158 lang: string 159 mmStatus: MarketMakingStatus 160 inited: boolean 161 authed: boolean 162 user: User 163 seedGenTime: number 164 commitHash: string 165 showPopups: boolean 166 loggers: Record<string, boolean> 167 recorders: Record<string, LogMessage[]> 168 main: HTMLElement 169 header: HTMLElement 170 headerSpace: HTMLElement 171 assets: Record<number, SupportedAsset> 172 exchanges: Record<string, Exchange> 173 walletMap: Record<number, WalletState> 174 fiatRatesMap: Record<number, number> 175 tooltip: HTMLElement 176 page: Record<string, HTMLElement> 177 loadedPage: Page | null 178 popupNotes: HTMLElement 179 popupTmpl: HTMLElement 180 noteReceivers: Record<string, (n: CoreNote) => void>[] 181 txHistoryMap: Record<number, TxHistoryResult> 182 requiredActions: Record<string, requiredAction> 183 184 constructor () { 185 this.notes = [] 186 this.pokes = [] 187 this.seedGenTime = 0 188 this.commitHash = process.env.COMMITHASH || '' 189 this.noteReceivers = [] 190 this.fiatRatesMap = {} 191 this.showPopups = State.fetchLocal(State.popupsLK) === '1' 192 this.txHistoryMap = {} 193 this.requiredActions = {} 194 195 console.log('Bison Wallet, Build', this.commitHash.substring(0, 7)) 196 197 // Set Bootstrap dark theme attribute if dark mode is enabled. 198 if (State.isDark()) { 199 document.body.classList.add('dark') 200 } 201 202 // Loggers can be enabled by setting a truthy value to the loggerID using 203 // enableLogger. Settings are stored across sessions. See docstring for the 204 // log method for more info. 205 this.loggers = State.fetchLocal(State.loggersLK) || {} 206 window.enableLogger = (loggerID, state) => { 207 if (state) this.loggers[loggerID] = true 208 else delete this.loggers[loggerID] 209 State.storeLocal(State.loggersLK, this.loggers) 210 return `${loggerID} logger ${state ? 'enabled' : 'disabled'}` 211 } 212 // Enable logging from anywhere. 213 window.log = (loggerID, ...a) => { this.log(loggerID, ...a) } 214 window.mmStatus = () => this.mmStatus 215 216 // Recorders can record log messages, and then save them to file on request. 217 const recorderKeys = State.fetchLocal(State.recordersLK) || [] 218 this.recorders = {} 219 for (const loggerID of recorderKeys) { 220 console.log('recording', loggerID) 221 this.recorders[loggerID] = [] 222 } 223 window.recordLogger = (loggerID, on) => { 224 if (on) this.recorders[loggerID] = [] 225 else delete this.recorders[loggerID] 226 State.storeLocal(State.recordersLK, Object.keys(this.recorders)) 227 return `${loggerID} recorder ${on ? 'enabled' : 'disabled'}` 228 } 229 window.dumpLogger = loggerID => { 230 const record = this.recorders[loggerID] 231 if (!record) return `no recorder for logger ${loggerID}` 232 const a = document.createElement('a') 233 a.href = `data:application/octet-stream;base64,${window.btoa(JSON.stringify(record, null, 4))}` 234 a.download = `${loggerID}.json` 235 document.body.appendChild(a) 236 a.click() 237 setTimeout(() => { 238 document.body.removeChild(a) 239 }, 0) 240 } 241 242 window.user = () => this.user 243 } 244 245 /** 246 * Start the application. This is the only thing done from the index.js entry 247 * point. Read the id = main element and attach handlers. 248 */ 249 async start () { 250 // Handle back navigation from the browser. 251 bind(window, 'popstate', (e: PopStateEvent) => { 252 const page = e.state?.page 253 if (!page && page !== '') return 254 this.loadPage(page, e.state.data, true) 255 }) 256 // The main element is the interchangeable part of the page that doesn't 257 // include the header. Main should define a data-handler attribute 258 // associated with one of the available constructors. 259 this.main = idel(document, 'main') 260 const handler = this.main.dataset.handler 261 // Don't fetch the user until we know what page we're on. 262 await this.fetchUser() 263 const ignoreCachedLocale = process.env.NODE_ENV === 'development' 264 await intl.loadLocale(this.lang, this.commitHash, ignoreCachedLocale) 265 // The application is free to respond with a page that differs from the 266 // one requested in the omnibox, e.g. routing though a login page. Set the 267 // current URL state based on the actual page. 268 const url = new URL(window.location.href) 269 if (handlerFromPath(url.pathname) !== handler) { 270 url.pathname = `/${handler}` 271 url.search = '' 272 window.history.replaceState({ page: handler }, '', url) 273 } 274 // Attach stuff. 275 this.attachHeader() 276 this.attachActions() 277 this.attachCommon(this.header) 278 this.attach({}) 279 280 // If we are authed, populate notes, otherwise get we'll them from the login 281 // response. 282 if (this.authed) await this.fetchNotes() 283 this.updateMenuItemsDisplay() 284 // initialize desktop notifications 285 ntfn.fetchDesktopNtfnSettings() 286 // Connect the websocket and register the notification route. 287 ws.connect(getSocketURI(), () => this.reconnected()) 288 ws.registerRoute(notificationRoute, (note: CoreNote) => { 289 this.notify(note) 290 }) 291 } 292 293 /* 294 * reconnected is called by the websocket client when a reconnection is made. 295 */ 296 reconnected () { 297 if (this.main?.dataset.handler === 'settings') window.location.assign('/') 298 else window.location.reload() // This triggers another websocket disconnect/connect (!) 299 // a fetchUser() and loadPage(window.history.state.page) might work 300 } 301 302 /* 303 * Fetch and save the user, which is the primary core state that must be 304 * maintained by the Application. 305 */ 306 async fetchUser (): Promise<User | void> { 307 const resp: UserResponse = await getJSON('/api/user') 308 if (!this.checkResponse(resp)) return 309 this.inited = resp.inited 310 this.authed = Boolean(resp.user) 311 this.lang = resp.lang 312 this.langs = resp.langs 313 this.mmStatus = resp.mmStatus 314 if (!resp.user) return 315 const user = resp.user 316 this.seedGenTime = user.seedgentime 317 this.user = user 318 this.assets = user.assets 319 this.exchanges = user.exchanges 320 this.walletMap = {} 321 this.fiatRatesMap = user.fiatRates 322 for (const [assetID, asset] of (Object.entries(user.assets) as [any, SupportedAsset][])) { 323 if (asset.wallet) { 324 this.walletMap[assetID] = asset.wallet 325 } 326 } 327 328 this.updateMenuItemsDisplay() 329 return user 330 } 331 332 async fetchMMStatus () { 333 this.mmStatus = await MM.status() 334 } 335 336 /* Load the page from the server. Insert and bind the DOM. */ 337 async loadPage (page: string, data?: any, skipPush?: boolean): Promise<boolean> { 338 // Close some menus and tooltips. 339 this.tooltip.style.left = '-10000px' 340 Doc.hide(this.page.noteBox, this.page.profileBox) 341 // Parse the request. 342 const url = new URL(`/${page}`, window.location.origin) 343 const requestedHandler = handlerFromPath(page) 344 // Fetch and parse the page. 345 const response = await window.fetch(url.toString()) 346 if (!response.ok) return false 347 const html = await response.text() 348 const doc = Doc.noderize(html) 349 const main = idel(doc, 'main') 350 const delivered = main.dataset.handler 351 // Append the request to the page history. 352 if (!skipPush) { 353 const path = delivered === requestedHandler ? url.toString() : `/${delivered}` 354 window.history.pushState({ page: page, data: data }, '', path) 355 } 356 // Insert page and attach handlers. 357 document.title = doc.title 358 this.main.replaceWith(main) 359 this.main = main 360 this.noteReceivers = [] 361 Doc.empty(this.headerSpace) 362 this.attach(data) 363 return true 364 } 365 366 /* attach binds the common handlers and calls the page constructor. */ 367 attach (data: any) { 368 const handlerID = this.main.dataset.handler 369 if (!handlerID) { 370 console.error('cannot attach to content with no specified handler') 371 return 372 } 373 this.attachCommon(this.main) 374 if (this.loadedPage) this.loadedPage.unload() 375 const constructor = constructors[handlerID] 376 if (constructor) this.loadedPage = new constructor(this.main, data) 377 else this.loadedPage = null 378 379 // Bind the tooltips. 380 this.bindTooltips(this.main) 381 382 if (window.isWebview) { 383 // Bind webview URL handlers 384 this.bindUrlHandlers(this.main) 385 } 386 387 this.bindUnits(this.main) 388 } 389 390 bindTooltips (ancestor: HTMLElement) { 391 ancestor.querySelectorAll('[data-tooltip]').forEach((el: HTMLElement) => { 392 bind(el, 'mouseenter', () => { 393 this.tooltip.textContent = el.dataset.tooltip || '' 394 const lyt = Doc.layoutMetrics(el) 395 let left = lyt.centerX - this.tooltip.offsetWidth / 2 396 if (left < 0) left = 5 397 if (left + this.tooltip.offsetWidth > document.body.offsetWidth) { 398 left = document.body.offsetWidth - this.tooltip.offsetWidth - 5 399 } 400 this.tooltip.style.left = `${left}px` 401 this.tooltip.style.top = `${lyt.bodyTop - this.tooltip.offsetHeight - 5}px` 402 }) 403 bind(el, 'mouseleave', () => { 404 this.tooltip.style.left = '-10000px' 405 }) 406 }) 407 } 408 409 /* 410 * bindUnits binds a hovering unit selection menu to the value or rate 411 * display elements. The menu gives users an option to convert the value 412 * to their preferred units. 413 */ 414 bindUnits (main: PageElement) { 415 const div = document.createElement('div') as PageElement 416 div.classList.add('position-absolute', 'p-3') 417 // div.style.backgroundColor = 'yellow' 418 const rows = document.createElement('div') as PageElement 419 div.appendChild(rows) 420 rows.classList.add('body-bg', 'border') 421 const addRow = (el: PageElement, unit: string, cFactor: number) => { 422 const box = Doc.safeSelector(el, '[data-unit-box]') 423 const atoms = parseInt(box.dataset.atoms as string) 424 const row = document.createElement('div') 425 row.textContent = unit 426 rows.appendChild(row) 427 row.classList.add('p-2', 'hoverbg', 'pointer') 428 Doc.bind(row, 'click', () => { 429 Doc.setText(el, '[data-value]', Doc.formatFourSigFigs(atoms / cFactor, Math.round(Math.log10(cFactor)))) 430 Doc.setText(el, '[data-unit]', unit) 431 }) 432 } 433 for (const el of Doc.applySelector(main, '[data-conversion-value]')) { 434 const box = Doc.safeSelector(el, '[data-unit-box]') 435 Doc.bind(box, 'mouseenter', () => { 436 Doc.empty(rows) 437 box.appendChild(div) 438 const lyt = Doc.layoutMetrics(box) 439 const assetID = parseInt(box.dataset.assetID as string) 440 const { unitInfo: ui } = this.assets[assetID] 441 addRow(el, ui.conventional.unit, ui.conventional.conversionFactor) 442 for (const { unit, conversionFactor } of ui.denominations) addRow(el, unit, conversionFactor) 443 addRow(el, ui.atomicUnit, 1) 444 if (lyt.bodyTop > (div.offsetHeight + this.header.offsetHeight)) { 445 div.style.bottom = 'calc(100% - 1rem)' 446 div.style.top = 'auto' 447 } else { 448 div.style.top = 'calc(100% - 1rem)' 449 div.style.bottom = 'auto' 450 } 451 }) 452 Doc.bind(box, 'mouseleave', () => div.remove()) 453 } 454 } 455 456 bindUrlHandlers (ancestor: HTMLElement) { 457 if (!window.openUrl) return 458 for (const link of Doc.applySelector(ancestor, 'a[target=_blank]')) { 459 Doc.bind(link, 'click', (e: MouseEvent) => { 460 e.preventDefault() 461 window.openUrl(link.href ?? '') 462 }) 463 } 464 } 465 466 /* attachHeader attaches the header element, which unlike the main element, 467 * isn't replaced during page navigation. 468 */ 469 attachHeader () { 470 this.header = idel(document.body, 'header') 471 const page = this.page = Doc.idDescendants(this.header) 472 this.headerSpace = page.headerSpace 473 this.popupNotes = idel(document.body, 'popupNotes') 474 this.popupTmpl = Doc.tmplElement(this.popupNotes, 'note') 475 if (this.popupTmpl) this.popupTmpl.remove() 476 else console.error('popupTmpl element not found') 477 this.tooltip = idel(document.body, 'tooltip') 478 page.noteTmpl.removeAttribute('id') 479 page.noteTmpl.remove() 480 page.pokeTmpl.removeAttribute('id') 481 page.pokeTmpl.remove() 482 page.loader.remove() 483 Doc.show(page.loader) 484 485 bind(page.noteBell, 'click', async () => { 486 Doc.hide(page.pokeList) 487 Doc.show(page.noteList) 488 this.ackNotes() 489 page.noteCat.classList.add('active') 490 page.pokeCat.classList.remove('active') 491 this.showDropdown(page.noteBell, page.noteBox) 492 Doc.hide(page.noteIndicator) 493 for (const note of this.notes) { 494 if (note.acked) { 495 note.el.classList.remove('firstview') 496 } 497 } 498 this.setNoteTimes(page.noteList) 499 this.setNoteTimes(page.pokeList) 500 }) 501 502 bind(page.burgerIcon, 'click', () => { 503 Doc.hide(page.logoutErr) 504 this.showDropdown(page.burgerIcon, page.profileBox) 505 }) 506 507 bind(page.innerNoteIcon, 'click', () => { Doc.hide(page.noteBox) }) 508 bind(page.innerBurgerIcon, 'click', () => { Doc.hide(page.profileBox) }) 509 510 bind(page.profileSignout, 'click', async () => await this.signOut()) 511 512 bind(page.pokeCat, 'click', () => { 513 this.setNoteTimes(page.pokeList) 514 page.pokeCat.classList.add('active') 515 page.noteCat.classList.remove('active') 516 Doc.hide(page.noteList) 517 Doc.show(page.pokeList) 518 this.ackNotes() 519 }) 520 521 bind(page.noteCat, 'click', () => { 522 this.setNoteTimes(page.noteList) 523 page.noteCat.classList.add('active') 524 page.pokeCat.classList.remove('active') 525 Doc.hide(page.pokeList) 526 Doc.show(page.noteList) 527 this.ackNotes() 528 }) 529 530 Doc.cleanTemplates(page.langBttnTmpl) 531 const { name, flag } = languageData[this.lang] 532 page.langFlag.textContent = flag 533 page.langName.textContent = name 534 535 for (const lang of this.langs) { 536 if (lang === this.lang) continue 537 const div = page.langBttnTmpl.cloneNode(true) as PageElement 538 const { name, flag } = languageData[lang] 539 div.textContent = flag 540 div.title = name 541 Doc.bind(div, 'click', () => this.setLanguage(lang)) 542 page.langBttns.appendChild(div) 543 } 544 } 545 546 attachActions () { 547 const { page } = this 548 Object.assign(page, Doc.idDescendants(Doc.idel(document.body, 'requiredActions'))) 549 Doc.cleanTemplates(page.missingNoncesTmpl, page.actionTxTableTmpl, page.tooCheapTmpl, page.lostNonceTmpl) 550 Doc.bind(page.actionsCollapse, 'click', () => { 551 Doc.hide(page.actionDialog) 552 Doc.show(page.actionDialogCollapsed) 553 }) 554 Doc.bind(page.actionDialogCollapsed, 'click', () => { 555 Doc.hide(page.actionDialogCollapsed) 556 Doc.show(page.actionDialog) 557 if (page.actionDialogContent.children.length === 0) this.showOldestAction() 558 }) 559 const showAdjacentAction = (dir: number) => { 560 const selected = Object.values(this.requiredActions).filter((r: requiredAction) => r.selected)[0] 561 const actions = this.sortedActions() 562 const idx = actions.indexOf(selected) 563 this.showRequestedAction(actions[idx + dir].uniqueID) 564 } 565 Doc.bind(page.prevAction, 'click', () => showAdjacentAction(-1)) 566 Doc.bind(page.nextAction, 'click', () => showAdjacentAction(1)) 567 } 568 569 setRequiredActions () { 570 const { user: { actions }, requiredActions } = this 571 if (!actions) return 572 for (const a of actions) this.addAction(a) 573 if (Object.keys(requiredActions).length) { 574 this.showOldestAction() 575 this.blinkAction() 576 } 577 } 578 579 sortedActions () { 580 const actions = Object.values(this.requiredActions) 581 actions.sort((a: requiredAction, b: requiredAction) => a.stamp - b.stamp) 582 return actions 583 } 584 585 showOldestAction () { 586 this.showRequestedAction(this.sortedActions()[0].uniqueID) 587 } 588 589 addAction (req: ActionRequiredNote) { 590 const { page, requiredActions } = this 591 const existingAction = requiredActions[req.uniqueID] 592 if (existingAction && existingAction.actionID === req.actionID) return 593 const div = this.actionForm(req) 594 if (existingAction) { 595 if (existingAction.selected) existingAction.div.replaceWith(div) 596 existingAction.div = div 597 } else { 598 requiredActions[req.uniqueID] = { 599 div, 600 stamp: (new Date()).getTime(), 601 uniqueID: req.uniqueID, 602 actionID: req.actionID, 603 selected: false 604 } 605 const n = Object.keys(requiredActions).length 606 page.actionDialogCount.textContent = String(n) 607 page.actionCount.textContent = String(n) 608 if (Doc.isHidden(page.actionDialog)) { 609 this.showRequestedAction(req.uniqueID) 610 } 611 } 612 } 613 614 blinkAction () { 615 Doc.blink(this.page.actionDialog) 616 Doc.blink(this.page.actionDialogCollapsed) 617 } 618 619 resolveAction (req: ActionResolvedNote) { 620 this.resolveActionWithID(req.uniqueID) 621 } 622 623 resolveActionWithID (uniqueID: string) { 624 const { page, requiredActions } = this 625 const existingAction = requiredActions[uniqueID] 626 if (!existingAction) return 627 delete requiredActions[uniqueID] 628 const rem = Object.keys(requiredActions).length 629 existingAction.div.remove() 630 if (rem === 0) { 631 Doc.hide(page.actionDialog, page.actionDialogCollapsed) 632 return 633 } 634 page.actionDialogCount.textContent = String(rem) 635 page.actionCount.textContent = String(rem) 636 if (existingAction.selected) this.showOldestAction() 637 } 638 639 actionForm (req: ActionRequiredNote) { 640 switch (req.actionID) { 641 case 'tooCheap': 642 return this.tooCheapAction(req) 643 case 'missingNonces': 644 return this.missingNoncesAction(req) 645 case 'lostNonce': 646 return this.lostNonceAction(req) 647 case 'redeemRejected': 648 return this.redeemRejectedAction(req) 649 } 650 throw Error('unknown required action ID ' + req.actionID) 651 } 652 653 actionTxTable (req: ActionRequiredNote) { 654 const { assetID, payload } = req 655 const n = payload as TransactionActionNote 656 const { unitInfo: ui, token } = this.assets[assetID] 657 const table = this.page.actionTxTableTmpl.cloneNode(true) as PageElement 658 const tmpl = Doc.parseTemplate(table) 659 tmpl.lostTxID.textContent = n.tx.id 660 tmpl.lostTxID.dataset.explorerCoin = n.tx.id 661 setCoinHref(token ? token.parentID : assetID, tmpl.lostTxID) 662 tmpl.txAmt.textContent = Doc.formatCoinValue(n.tx.amount, ui) 663 tmpl.amtUnit.textContent = ui.conventional.unit 664 const parentUI = token ? this.unitInfo(token.parentID) : ui 665 tmpl.type.textContent = txTypeString(n.tx.type) 666 tmpl.feeAmount.textContent = Doc.formatCoinValue(n.tx.fees, parentUI) 667 tmpl.feeUnit.textContent = parentUI.conventional.unit 668 switch (req.actionID) { 669 case 'tooCheap': { 670 Doc.show(tmpl.newFeesRow) 671 tmpl.newFees.textContent = Doc.formatCoinValue(n.tx.fees, parentUI) 672 tmpl.newFeesUnit.textContent = parentUI.conventional.unit 673 break 674 } 675 } 676 return table 677 } 678 679 async submitAction (req: ActionRequiredNote, action: any, errMsg: PageElement) { 680 Doc.hide(errMsg) 681 const loading = this.loading(this.page.actionDialog) 682 const res = await postJSON('/api/takeaction', { 683 assetID: req.assetID, 684 actionID: req.actionID, 685 action 686 }) 687 loading() 688 if (!this.checkResponse(res)) { 689 errMsg.textContent = res.msg 690 Doc.show(errMsg) 691 return 692 } 693 this.resolveActionWithID(req.uniqueID) 694 } 695 696 missingNoncesAction (req: ActionRequiredNote) { 697 const { assetID } = req 698 const div = this.page.missingNoncesTmpl.cloneNode(true) as PageElement 699 const tmpl = Doc.parseTemplate(div) 700 const { name } = this.assets[assetID] 701 tmpl.assetName.textContent = name 702 Doc.bind(tmpl.doNothingBttn, 'click', () => { 703 this.submitAction(req, { recover: false }, tmpl.errMsg) 704 }) 705 Doc.bind(tmpl.recoverBttn, 'click', () => { 706 this.submitAction(req, { recover: true }, tmpl.errMsg) 707 }) 708 return div 709 } 710 711 tooCheapAction (req: ActionRequiredNote) { 712 const { assetID, payload } = req 713 const n = payload as TransactionActionNote 714 const div = this.page.tooCheapTmpl.cloneNode(true) as PageElement 715 const tmpl = Doc.parseTemplate(div) 716 const { name } = this.assets[assetID] 717 tmpl.assetName.textContent = name 718 tmpl.txTable.appendChild(this.actionTxTable(req)) 719 const act = (bump: boolean) => { 720 this.submitAction(req, { 721 txID: n.tx.id, 722 bump 723 }, tmpl.errMsg) 724 } 725 Doc.bind(tmpl.keepWaitingBttn, 'click', () => act(false)) 726 Doc.bind(tmpl.addFeesBttn, 'click', () => act(true)) 727 return div 728 } 729 730 lostNonceAction (req: ActionRequiredNote) { 731 const { assetID, payload } = req 732 const n = payload as TransactionActionNote 733 const div = this.page.lostNonceTmpl.cloneNode(true) as PageElement 734 const tmpl = Doc.parseTemplate(div) 735 const { name } = this.assets[assetID] 736 tmpl.assetName.textContent = name 737 tmpl.nonce.textContent = String(n.nonce) 738 tmpl.txTable.appendChild(this.actionTxTable(req)) 739 Doc.bind(tmpl.abandonBttn, 'click', () => { 740 this.submitAction(req, { txID: n.tx.id, abandon: true }, tmpl.errMsg) 741 }) 742 Doc.bind(tmpl.keepWaitingBttn, 'click', () => { 743 this.submitAction(req, { txID: n.tx.id, abandon: false }, tmpl.errMsg) 744 }) 745 Doc.bind(tmpl.replaceBttn, 'click', () => { 746 const replacementID = tmpl.idInput.value 747 if (!replacementID) { 748 tmpl.idInput.focus() 749 Doc.blink(tmpl.idInput) 750 return 751 } 752 this.submitAction(req, { txID: n.tx.id, abandon: false, replacementID }, tmpl.errMsg) 753 }) 754 return div 755 } 756 757 redeemRejectedAction (req: ActionRequiredNote) { 758 const { orderID, coinID, coinFmt, assetID } = req.payload as RejectedRedemptionData 759 const div = this.page.rejectedRedemptionTmpl.cloneNode(true) as PageElement 760 const tmpl = Doc.parseTemplate(div) 761 const { name, token } = this.assets[assetID] 762 tmpl.assetName.textContent = name 763 tmpl.txid.textContent = coinFmt 764 tmpl.txid.dataset.explorerCoin = coinID 765 setCoinHref(token ? token.parentID : assetID, tmpl.txid) 766 Doc.bind(tmpl.doNothingBttn, 'click', () => { 767 this.submitAction(req, { orderID, coinID, retry: false }, tmpl.errMsg) 768 }) 769 Doc.bind(tmpl.tryAgainBttn, 'click', () => { 770 this.submitAction(req, { orderID, coinID, retry: true }, tmpl.errMsg) 771 }) 772 return div 773 } 774 775 showRequestedAction (uniqueID: string) { 776 const { page, requiredActions } = this 777 Doc.hide(page.actionDialogCollapsed) 778 for (const r of Object.values(requiredActions)) r.selected = r.uniqueID === uniqueID 779 Doc.empty(page.actionDialogContent) 780 const action = requiredActions[uniqueID] 781 page.actionDialogContent.appendChild(action.div) 782 Doc.show(page.actionDialog) 783 const actions = this.sortedActions() 784 if (actions.length === 1) { 785 Doc.hide(page.actionsNavigator) 786 return 787 } 788 Doc.show(page.actionsNavigator) 789 const idx = actions.indexOf(action) 790 page.currentAction.textContent = String(idx + 1) 791 page.prevAction.classList.toggle('invisible', idx === 0) 792 page.nextAction.classList.toggle('invisible', idx === actions.length - 1) 793 } 794 795 /* 796 * updateMarketElements sets the textContent for any ticker or asset name 797 * elements or any asset logo src attributes for descendents of ancestor. 798 */ 799 updateMarketElements (ancestor: PageElement, baseID: number, quoteID: number, xc?: Exchange) { 800 const getAsset = (assetID: number) => { 801 const a = this.assets[assetID] 802 if (a) return a 803 if (!xc) throw Error(`no asset found for asset ID ${assetID}`) 804 const xcAsset = xc.assets[assetID] 805 return { unitInfo: xcAsset.unitInfo, name: xcAsset.symbol, symbol: xcAsset.symbol } 806 } 807 const { unitInfo: bui, name: baseName, symbol: baseSymbol } = getAsset(baseID) 808 for (const el of Doc.applySelector(ancestor, '[data-base-name')) el.textContent = baseName 809 for (const img of Doc.applySelector(ancestor, '[data-base-logo]')) img.src = Doc.logoPath(baseSymbol) 810 for (const el of Doc.applySelector(ancestor, '[data-base-ticker]')) el.textContent = bui.conventional.unit 811 const { unitInfo: qui, name: quoteName, symbol: quoteSymbol } = getAsset(quoteID) 812 for (const el of Doc.applySelector(ancestor, '[data-quote-name')) el.textContent = quoteName 813 for (const img of Doc.applySelector(ancestor, '[data-quote-logo]')) img.src = Doc.logoPath(quoteSymbol) 814 for (const el of Doc.applySelector(ancestor, '[data-quote-ticker]')) el.textContent = qui.conventional.unit 815 } 816 817 async setLanguage (lang: string) { 818 await postJSON('/api/setlocale', lang) 819 window.location.reload() 820 } 821 822 /* 823 * showDropdown sets the position and visibility of the specified dropdown 824 * dialog according to the position of its icon button. 825 */ 826 showDropdown (icon: HTMLElement, dialog: HTMLElement) { 827 Doc.hide(this.page.noteBox, this.page.profileBox) 828 Doc.show(dialog) 829 if (window.innerWidth < 500) Object.assign(dialog.style, { left: '0', right: '0', top: '0' }) 830 else { 831 const ico = icon.getBoundingClientRect() 832 const right = `${window.innerWidth - ico.left - ico.width + 5}px` 833 Object.assign(dialog.style, { left: 'auto', right, top: `${ico.top - 4}px` }) 834 } 835 836 const hide = (e: MouseEvent) => { 837 if (!Doc.mouseInElement(e, dialog)) { 838 Doc.hide(dialog) 839 unbind(document, 'click', hide) 840 if (dialog === this.page.noteBox && Doc.isDisplayed(this.page.noteList)) { 841 this.ackNotes() 842 } 843 } 844 } 845 bind(document, 'click', hide) 846 } 847 848 ackNotes () { 849 const acks = [] 850 for (const note of this.notes) { 851 if (note.acked) { 852 note.el.classList.remove('firstview') 853 } else { 854 note.acked = true 855 if (note.id && note.severity > ntfn.POKE) acks.push(note.id) 856 } 857 } 858 if (acks.length) ws.request('acknotes', acks) 859 Doc.hide(this.page.noteIndicator) 860 } 861 862 setNoteTimes (noteList: HTMLElement) { 863 for (const el of (Array.from(noteList.children) as NoteElement[])) { 864 Doc.safeSelector(el, 'span.note-time').textContent = Doc.timeSince(el.note.stamp) 865 } 866 } 867 868 /* 869 * bindInternalNavigation hijacks navigation by click on any local links that 870 * are descendants of ancestor. 871 */ 872 bindInternalNavigation (ancestor: HTMLElement) { 873 const pageURL = new URL(window.location.href) 874 ancestor.querySelectorAll('a').forEach(a => { 875 if (!a.href) return 876 const url = new URL(a.href) 877 if (url.origin === pageURL.origin) { 878 const token = url.pathname.substring(1) 879 const params: Record<string, string> = {} 880 if (url.search) { 881 url.searchParams.forEach((v, k) => { 882 params[k] = v 883 }) 884 } 885 Doc.bind(a, 'click', (e: Event) => { 886 e.preventDefault() 887 this.loadPage(token, params) 888 }) 889 } 890 }) 891 } 892 893 /* 894 * updateMenuItemsDisplay should be called when the user has signed in or out, 895 * and when the user registers a DEX. 896 */ 897 updateMenuItemsDisplay () { 898 const { page, authed, mmStatus } = this 899 if (!page) { 900 // initial page load, header elements not yet attached but menu items 901 // would already be hidden/displayed as appropriate. 902 return 903 } 904 if (!authed) { 905 page.profileBox.classList.remove('authed') 906 Doc.hide(page.noteBell, page.walletsMenuEntry, page.marketsMenuEntry) 907 return 908 } 909 Doc.setVis(Object.keys(this.exchanges).length > 0, page.marketsMenuEntry, page.mmLink) 910 911 page.profileBox.classList.add('authed') 912 Doc.show(page.noteBell, page.walletsMenuEntry, page.marketsMenuEntry) 913 Doc.setVis(mmStatus, page.mmLink) 914 } 915 916 async fetchNotes () { 917 const res = await getJSON('/api/notes') 918 if (!this.checkResponse(res)) return console.error('failed to fetch notes:', res?.msg || String(res)) 919 res.notes.reverse() 920 this.setNotes(res.notes) 921 this.setPokes(res.pokes) 922 this.setRequiredActions() 923 } 924 925 /* attachCommon scans the provided node and handles some common bindings. */ 926 attachCommon (node: HTMLElement) { 927 this.bindInternalNavigation(node) 928 } 929 930 /* 931 * updateBondConfs updates the information for a pending bond. 932 */ 933 updateBondConfs (dexAddr: string, coinID: string, confs: number) { 934 const dex = this.exchanges[dexAddr] 935 for (const bond of dex.auth.pendingBonds) if (bond.coinID === coinID) bond.confs = confs 936 } 937 938 updateTier (host: string, bondedTier: number) { 939 this.exchanges[host].auth.rep.bondedTier = bondedTier 940 } 941 942 /* 943 * handleBondNote is the handler for the 'bondpost'-type notification, which 944 * is used to update the dex tier and registration status. 945 */ 946 handleBondNote (note: BondNote) { 947 if (note.auth) this.exchanges[note.dex].auth = note.auth 948 switch (note.topic) { 949 case 'RegUpdate': 950 if (note.coinID !== null) { // should never be null for RegUpdate 951 this.updateBondConfs(note.dex, note.coinID, note.confirmations) 952 } 953 break 954 case 'BondConfirmed': 955 if (note.tier !== null) { // should never be null for BondConfirmed 956 this.updateTier(note.dex, note.tier) 957 } 958 break 959 default: 960 break 961 } 962 } 963 964 /* 965 * handleTransaction either adds a new transaction to the transaction history 966 * or updates an existing transaction. 967 */ 968 handleTransactionNote (assetID: number, note: TransactionNote) { 969 const txHistory = this.txHistoryMap[assetID] 970 if (!txHistory) return 971 972 if (note.new) { 973 txHistory.txs.unshift(note.transaction) 974 return 975 } 976 977 for (let i = 0; i < txHistory.txs.length; i++) { 978 if (txHistory.txs[i].id === note.transaction.id) { 979 txHistory.txs[i] = note.transaction 980 break 981 } 982 } 983 } 984 985 handleTxHistorySyncedNote (assetID: number) { 986 delete this.txHistoryMap[assetID] 987 } 988 989 loggedIn (notes: CoreNote[], pokes: CoreNote[]) { 990 this.setNotes(notes) 991 this.setPokes(pokes) 992 this.setRequiredActions() 993 } 994 995 /* 996 * setNotes sets the current notification cache and populates the notification 997 * display. 998 */ 999 setNotes (notes: CoreNote[]) { 1000 this.log('notes', 'setNotes', notes) 1001 this.notes = [] 1002 Doc.empty(this.page.noteList) 1003 for (let i = 0; i < notes.length; i++) { 1004 this.prependNoteElement(notes[i]) 1005 } 1006 } 1007 1008 /* 1009 * setPokes sets the current poke cache and populates the pokes display. 1010 */ 1011 setPokes (pokes: CoreNote[]) { 1012 this.log('pokes', 'setPokes', pokes) 1013 this.pokes = [] 1014 Doc.empty(this.page.pokeList) 1015 for (let i = 0; i < pokes.length; i++) { 1016 this.prependPokeElement(pokes[i]) 1017 } 1018 } 1019 1020 botStatus (host: string, baseID: number, quoteID: number): MMBotStatus | undefined { 1021 for (const bot of (this.mmStatus?.bots ?? [])) { 1022 const { config: c } = bot 1023 if (host === c.host && baseID === c.baseID && quoteID === c.quoteID) { 1024 return bot 1025 } 1026 } 1027 } 1028 1029 updateUser (note: CoreNote) { 1030 const { user, assets, walletMap } = this 1031 if (note.type === 'fiatrateupdate') { 1032 this.fiatRatesMap = (note as RateNote).fiatRates 1033 return 1034 } 1035 // Some notes can be received before we get a User during login. 1036 if (!user) return 1037 switch (note.type) { 1038 case 'order': { 1039 const orderNote = note as OrderNote 1040 const order = orderNote.order 1041 const mkt = user.exchanges[order.host].markets[order.market] 1042 const tempID = orderNote.tempID 1043 1044 // Ensure market's inflight orders list is updated. 1045 if (note.topic === 'AsyncOrderSubmitted') { 1046 const inFlight = order as InFlightOrder 1047 inFlight.tempID = tempID 1048 if (!mkt.inflight) mkt.inflight = [inFlight] 1049 else mkt.inflight.push(inFlight) 1050 break 1051 } else if (note.topic === 'AsyncOrderFailure') { 1052 mkt.inflight = mkt.inflight.filter(ord => ord.tempID !== tempID) 1053 break 1054 } else { 1055 for (const i in mkt.inflight || []) { 1056 if (!(mkt.inflight[i].tempID === tempID)) continue 1057 mkt.inflight = mkt.inflight.filter(ord => ord.tempID !== tempID) 1058 break 1059 } 1060 } 1061 1062 // Updates given order in market's orders list if it finds it. 1063 // Returns a bool which indicates if order was found. 1064 mkt.orders = mkt.orders || [] 1065 const updateOrder = (mkt: Market, ord: Order) => { 1066 const i = mkt.orders.findIndex((o: Order) => o.id === ord.id) 1067 if (i === -1) return false 1068 if (note.topic === 'OrderRetired') mkt.orders.splice(i, 1) 1069 else mkt.orders[i] = ord 1070 return true 1071 } 1072 // If the notification order already exists we update it. 1073 // In case market's orders list is empty or the notification order isn't 1074 // part of it we add it manually as this means the order was 1075 // just placed. 1076 if (!updateOrder(mkt, order)) mkt.orders.push(order) 1077 break 1078 } 1079 case 'balance': { 1080 const n: BalanceNote = note as BalanceNote 1081 const asset = user.assets[n.assetID] 1082 // Balance updates can come before the user is fetched after login. 1083 if (!asset) break 1084 const w = asset.wallet 1085 if (w) w.balance = n.balance 1086 break 1087 } 1088 case 'bondpost': 1089 this.handleBondNote(note as BondNote) 1090 break 1091 case 'reputation': { 1092 const n = note as ReputationNote 1093 this.exchanges[n.host].auth.rep = n.rep 1094 break 1095 } 1096 case 'walletstate': 1097 case 'walletconfig': { 1098 // assets can be null if failed to connect to dex server. 1099 if (!assets) return 1100 const wallet = (note as WalletConfigNote)?.wallet 1101 if (!wallet) return 1102 const asset = assets[wallet.assetID] 1103 asset.wallet = wallet 1104 walletMap[wallet.assetID] = wallet 1105 break 1106 } 1107 case 'walletsync': { 1108 const n = note as WalletSyncNote 1109 const w = this.walletMap[n.assetID] 1110 if (w) { 1111 w.syncStatus = n.syncStatus 1112 w.synced = w.syncStatus.synced 1113 w.syncProgress = n.syncProgress 1114 } 1115 break 1116 } 1117 case 'match': { 1118 const n = note as MatchNote 1119 const ord = this.order(n.orderID) 1120 if (ord) updateMatch(ord, n.match) 1121 break 1122 } 1123 case 'conn': { 1124 const n = note as ConnEventNote 1125 const xc = user.exchanges[n.host] 1126 if (xc) xc.connectionStatus = n.connectionStatus 1127 break 1128 } 1129 case 'spots': { 1130 const n = note as SpotPriceNote 1131 const xc = user.exchanges[n.host] 1132 // Spots can come before the user is fetched after login and before/while the 1133 // markets page reload when it receives a dex conn note. 1134 if (!xc || !xc.markets) break 1135 for (const [mktName, spot] of Object.entries(n.spots)) xc.markets[mktName].spot = spot 1136 break 1137 } 1138 case 'fiatrateupdate': { 1139 this.fiatRatesMap = (note as RateNote).fiatRates 1140 break 1141 } 1142 case 'actionrequired': { 1143 const n = note as CoreActionRequiredNote 1144 this.addAction(n.payload) 1145 break 1146 } 1147 case 'walletnote': { 1148 const n = note as WalletNote 1149 switch (n.payload.route) { 1150 case 'transaction': { 1151 const txNote = n.payload as TransactionNote 1152 this.handleTransactionNote(n.payload.assetID, txNote) 1153 break 1154 } 1155 case 'actionRequired': { 1156 const req = n.payload as ActionRequiredNote 1157 this.addAction(req) 1158 this.blinkAction() 1159 break 1160 } 1161 case 'actionResolved': { 1162 this.resolveAction(n.payload as ActionResolvedNote) 1163 } 1164 } 1165 if (n.payload.route === 'transactionHistorySynced') { 1166 this.handleTxHistorySyncedNote(n.payload.assetID) 1167 } 1168 break 1169 } 1170 case 'runstats': { 1171 this.log('mm', { runstats: note }) 1172 const n = note as RunStatsNote 1173 const bot = this.botStatus(n.host, n.baseID, n.quoteID) 1174 if (bot) { 1175 bot.runStats = n.stats 1176 bot.running = Boolean(n.stats) 1177 if (!n.stats) { 1178 bot.latestEpoch = undefined 1179 bot.cexProblems = undefined 1180 } 1181 } 1182 break 1183 } 1184 case 'cexnote': { 1185 const n = note as CEXNotification 1186 switch (n.topic) { 1187 case 'BalanceUpdate': { 1188 const u = n.note as CEXBalanceUpdate 1189 this.mmStatus.cexes[n.cexName].balances[u.assetID] = u.balance 1190 } 1191 } 1192 break 1193 } 1194 case 'epochreport': { 1195 const n = note as EpochReportNote 1196 const bot = this.botStatus(n.host, n.baseID, n.quoteID) 1197 if (bot) bot.latestEpoch = n.report 1198 break 1199 } 1200 case 'cexproblems': { 1201 const n = note as CEXProblemsNote 1202 const bot = this.botStatus(n.host, n.baseID, n.quoteID) 1203 if (bot) bot.cexProblems = n.problems 1204 break 1205 } 1206 } 1207 } 1208 1209 /* 1210 * notify is the top-level handler for notifications received from the client. 1211 * Notifications are propagated to the loadedPage. 1212 */ 1213 notify (note: CoreNote) { 1214 // Handle type-specific updates. 1215 this.log('notes', 'notify', note) 1216 this.updateUser(note) 1217 // Inform the page. 1218 for (const feeder of this.noteReceivers) { 1219 const f = feeder[note.type] 1220 if (!f) continue 1221 try { 1222 f(note) 1223 } catch (error) { 1224 console.error('note feeder error:', error.message ? error.message : error) 1225 console.log(note) 1226 console.log(error.stack) 1227 } 1228 } 1229 // Discard data notifications. 1230 if (note.severity < ntfn.POKE) return 1231 // Poke notifications have their own display. 1232 const { popupTmpl, popupNotes, showPopups } = this 1233 if (showPopups) { 1234 const span = popupTmpl.cloneNode(true) as HTMLElement 1235 Doc.tmplElement(span, 'text').textContent = `${note.subject}: ${ntfn.plainNote(note.details)}` 1236 const indicator = Doc.tmplElement(span, 'indicator') 1237 if (note.severity === ntfn.POKE) { 1238 Doc.hide(indicator) 1239 } else setSeverityClass(indicator, note.severity) 1240 popupNotes.appendChild(span) 1241 Doc.show(popupNotes) 1242 // These take up screen space. Only show max 5 at a time. 1243 while (popupNotes.children.length > 5) popupNotes.removeChild(popupNotes.firstChild as Node) 1244 setTimeout(async () => { 1245 await Doc.animate(500, (progress: number) => { 1246 span.style.opacity = String(1 - progress) 1247 }) 1248 span.remove() 1249 if (popupNotes.children.length === 0) Doc.hide(popupNotes) 1250 }, 6000) 1251 } 1252 // Success and higher severity go to the bell dropdown. 1253 if (note.severity === ntfn.POKE) this.prependPokeElement(note) 1254 else this.prependNoteElement(note) 1255 1256 // show desktop notification 1257 ntfn.desktopNotify(note) 1258 } 1259 1260 /* 1261 * registerNoteFeeder registers a feeder for core notifications. The feeder 1262 * will be de-registered when a new page is loaded. 1263 */ 1264 registerNoteFeeder (receivers: Record<string, (n: CoreNote) => void>) { 1265 this.noteReceivers.push(receivers) 1266 } 1267 1268 /* 1269 * log prints to the console if a logger has been enabled. Loggers are created 1270 * implicitly by passing a loggerID to log. i.e. you don't create a logger, 1271 * you just log to it. Loggers are enabled by invoking a global function, 1272 * enableLogger(loggerID, onOffBoolean), from the browser's js console. Your 1273 * choices are stored across sessions. Some common and useful loggers are 1274 * listed below, but this list is not meant to be comprehensive. 1275 * 1276 * LoggerID Description 1277 * -------- ----------- 1278 * notes Notifications of all levels. 1279 * book Order book feed. 1280 * ws.........Websocket connection status changes. 1281 */ 1282 log (loggerID: string, ...msg: any) { 1283 if (this.loggers[loggerID]) console.log(`${nowString()}[${loggerID}]:`, ...msg) 1284 if (this.recorders[loggerID]) { 1285 this.recorders[loggerID].push({ 1286 time: nowString(), 1287 msg: msg 1288 }) 1289 } 1290 } 1291 1292 prependPokeElement (cn: CoreNote) { 1293 const [el, note] = this.makePoke(cn) 1294 this.pokes.push(note) 1295 while (this.pokes.length > noteCacheSize) this.pokes.shift() 1296 this.prependListElement(this.page.pokeList, note, el) 1297 } 1298 1299 prependNoteElement (cn: CoreNote) { 1300 const [el, note] = this.makeNote(cn) 1301 this.notes.push(note) 1302 while (this.notes.length > noteCacheSize) this.notes.shift() 1303 const noteList = this.page.noteList 1304 this.prependListElement(noteList, note, el) 1305 this.bindUrlHandlers(el) 1306 // Set the indicator color. 1307 if (this.notes.length === 0 || (Doc.isDisplayed(this.page.noteBox) && Doc.isDisplayed(noteList))) return 1308 let unacked = 0 1309 const severity = this.notes.reduce((s, note) => { 1310 if (!note.acked) unacked++ 1311 if (!note.acked && note.severity > s) return note.severity 1312 return s 1313 }, ntfn.IGNORE) 1314 const ni = this.page.noteIndicator 1315 setSeverityClass(ni, severity) 1316 if (unacked) { 1317 ni.textContent = String((unacked > noteCacheSize - 1) ? `${noteCacheSize - 1}+` : unacked) 1318 Doc.show(ni) 1319 } else Doc.hide(ni) 1320 } 1321 1322 prependListElement (noteList: HTMLElement, note: CoreNotePlus, el: NoteElement) { 1323 el.note = note 1324 noteList.prepend(el) 1325 while (noteList.children.length > noteCacheSize) noteList.removeChild(noteList.lastChild as Node) 1326 this.setNoteTimes(noteList) 1327 } 1328 1329 /* 1330 * makeNote constructs a single notification element for the drop-down 1331 * notification list. 1332 */ 1333 makeNote (note: CoreNote): [NoteElement, CoreNotePlus] { 1334 const el = this.page.noteTmpl.cloneNode(true) as NoteElement 1335 if (note.severity > ntfn.POKE) { 1336 const cls = note.severity === ntfn.SUCCESS ? 'good' : note.severity === ntfn.WARNING ? 'warn' : 'bad' 1337 Doc.safeSelector(el, 'div.note-indicator').classList.add(cls) 1338 } 1339 1340 Doc.safeSelector(el, 'div.note-subject').textContent = note.subject 1341 ntfn.insertRichNote(Doc.safeSelector(el, 'div.note-details'), note.details) 1342 const np: CoreNotePlus = { el, ...note } 1343 return [el, np] 1344 } 1345 1346 makePoke (note: CoreNote): [NoteElement, CoreNotePlus] { 1347 const el = this.page.pokeTmpl.cloneNode(true) as NoteElement 1348 Doc.tmplElement(el, 'subject').textContent = `${note.subject}:` 1349 ntfn.insertRichNote(Doc.tmplElement(el, 'details'), note.details) 1350 const np: CoreNotePlus = { el, ...note } 1351 return [el, np] 1352 } 1353 1354 /* 1355 * loading appends the loader to the specified element and displays the 1356 * loading icon. The loader will block all interaction with the specified 1357 * element until Application.loaded is called. 1358 */ 1359 loading (el: HTMLElement): () => void { 1360 const loader = this.page.loader.cloneNode(true) as HTMLElement 1361 el.appendChild(loader) 1362 return () => { loader.remove() } 1363 } 1364 1365 /* orders retrieves a list of orders for the specified dex and market 1366 * including inflight orders. 1367 */ 1368 orders (host: string, mktID: string): Order[] { 1369 let orders: Order[] = [] 1370 const mkt = this.user.exchanges[host].markets[mktID] 1371 if (mkt.orders) orders = orders.concat(mkt.orders) 1372 if (mkt.inflight) orders = orders.concat(mkt.inflight) 1373 return orders 1374 } 1375 1376 /* 1377 * haveActiveOrders returns whether or not there are active orders involving a 1378 * certain asset. 1379 */ 1380 haveActiveOrders (assetID: number): boolean { 1381 for (const xc of Object.values(this.user.exchanges)) { 1382 if (!xc.markets) continue 1383 for (const market of Object.values(xc.markets)) { 1384 if (!market.orders) continue 1385 for (const ord of market.orders) { 1386 if ((ord.baseID === assetID || ord.quoteID === assetID) && 1387 (ord.status < StatusExecuted || hasActiveMatches(ord))) return true 1388 } 1389 } 1390 } 1391 return false 1392 } 1393 1394 /* order attempts to locate an order by order ID. */ 1395 order (oid: string): Order | null { 1396 for (const xc of Object.values(this.user.exchanges)) { 1397 if (!xc || !xc.markets) continue 1398 for (const market of Object.values(xc.markets)) { 1399 if (!market.orders) continue 1400 for (const ord of market.orders) { 1401 if (ord.id === oid) return ord 1402 } 1403 } 1404 } 1405 return null 1406 } 1407 1408 /* 1409 * canAccelerateOrder returns true if the "from" wallet of the order 1410 * supports acceleration, and if the order has unconfirmed swap 1411 * transactions. 1412 */ 1413 canAccelerateOrder (order: Order): boolean { 1414 const walletTraitAccelerator = 1 << 4 1415 let fromAssetID 1416 if (order.sell) fromAssetID = order.baseID 1417 else fromAssetID = order.quoteID 1418 const wallet = this.walletMap[fromAssetID] 1419 if (!wallet || !(wallet.traits & walletTraitAccelerator)) return false 1420 if (order.matches) { 1421 for (let i = 0; i < order.matches?.length; i++) { 1422 const match = order.matches[i] 1423 if (match.swap && match.swap.confs && match.swap.confs.count === 0 && !match.revoked) { 1424 return true 1425 } 1426 } 1427 } 1428 return false 1429 } 1430 1431 /* 1432 * unitInfo fetches unit info [dex.UnitInfo] for the asset. If xc 1433 * [core.Exchange] is provided, and this is not a SupportedAsset, the UnitInfo 1434 * sent from the exchange's assets map [dex.Asset] will be used. 1435 */ 1436 unitInfo (assetID: number, xc?: Exchange): UnitInfo { 1437 const supportedAsset = this.assets[assetID] 1438 if (supportedAsset) return supportedAsset.unitInfo 1439 if (!xc || !xc.assets) { 1440 throw Error(intl.prep(intl.ID_UNSUPPORTED_ASSET_INFO_ERR_MSG, { assetID: `${assetID}` })) 1441 } 1442 return xc.assets[assetID].unitInfo 1443 } 1444 1445 parentAsset (assetID: number) : SupportedAsset { 1446 const asset = this.assets[assetID] 1447 if (!asset.token) return asset 1448 return this.assets[asset.token.parentID] 1449 } 1450 1451 /* 1452 * baseChainSymbol returns the symbol for the asset's parent if the asset is a 1453 * token, otherwise the symbol for the asset itself. 1454 */ 1455 baseChainSymbol (assetID: number) { 1456 const asset = this.user.assets[assetID] 1457 return asset.token ? this.user.assets[asset.token.parentID].symbol : asset.symbol 1458 } 1459 1460 /* 1461 * extensionWallet returns the ExtensionConfiguredWallet for the asset, if 1462 * it exists. 1463 */ 1464 extensionWallet (assetID: number) { 1465 return this.user.extensionModeConfig?.restrictedWallets[this.baseChainSymbol(assetID)] 1466 } 1467 1468 /* conventionalRate converts the encoded atomic rate to a conventional rate */ 1469 conventionalRate (baseID: number, quoteID: number, encRate: number, xc?: Exchange): number { 1470 const [b, q] = [this.unitInfo(baseID, xc), this.unitInfo(quoteID, xc)] 1471 1472 const r = b.conventional.conversionFactor / q.conventional.conversionFactor 1473 return encRate * r / RateEncodingFactor 1474 } 1475 1476 walletDefinition (assetID: number, walletType: string): WalletDefinition { 1477 const asset = this.assets[assetID] 1478 if (asset.token) return asset.token.definition 1479 if (!asset.info) throw Error('where\'s the wallet info?') 1480 if (walletType === '') return asset.info.availablewallets[asset.info.emptyidx] 1481 return asset.info.availablewallets.filter(def => def.type === walletType)[0] 1482 } 1483 1484 currentWalletDefinition (assetID: number): WalletDefinition { 1485 const asset = this.assets[assetID] 1486 if (asset.token) { 1487 return asset.token.definition 1488 } 1489 return this.walletDefinition(assetID, this.assets[assetID].wallet.type) 1490 } 1491 1492 /* 1493 * fetchBalance requests a balance update from the API. The API response does 1494 * include the balance, but we're ignoring it, since a balance update 1495 * notification is received via the Application anyways. 1496 */ 1497 async fetchBalance (assetID: number): Promise<WalletBalance> { 1498 const res: BalanceResponse = await postJSON('/api/balance', { assetID: assetID }) 1499 if (!this.checkResponse(res)) { 1500 throw new Error(`failed to fetch balance for asset ID ${assetID}`) 1501 } 1502 return res.balance 1503 } 1504 1505 /* 1506 * checkResponse checks the response object as returned from the functions in 1507 * the http module. If the response indicates that the request failed, it 1508 * returns false, otherwise, true. 1509 */ 1510 checkResponse (resp: APIResponse): boolean { 1511 return (resp.requestSuccessful && resp.ok) 1512 } 1513 1514 /** 1515 * signOut call to /api/logout, if response with no errors occurred remove auth 1516 * and other privacy-critical cookies/locals and reload the page, otherwise 1517 * show a notification. 1518 */ 1519 async signOut () { 1520 const res = await postJSON('/api/logout') 1521 if (!this.checkResponse(res)) { 1522 if (res.code === Errors.activeOrdersErr) { 1523 this.page.logoutErr.textContent = intl.prep(intl.ID_ACTIVE_ORDERS_LOGOUT_ERR_MSG) 1524 } else { 1525 this.page.logoutErr.textContent = res.msg 1526 } 1527 Doc.show(this.page.logoutErr) 1528 return 1529 } 1530 State.removeCookie(State.authCK) 1531 State.removeCookie(State.pwKeyCK) 1532 State.removeLocal(State.notificationsLK) // Notification storage was DEPRECATED pre-v1. 1533 window.location.href = '/login' 1534 } 1535 1536 /* 1537 * txHistory loads the tx history for an asset. If the results are not 1538 * already cached, they are cached. If we have reached the oldest tx, 1539 * this fact is also cached. If the exact amount of transactions as have been 1540 * made are requested, we will not know if we have reached the last tx until 1541 * a subsequent call. 1542 */ 1543 async txHistory (assetID: number, n: number, after?: string): Promise<TxHistoryResult> { 1544 const url = '/api/txhistory' 1545 const cachedTxHistory = this.txHistoryMap[assetID] 1546 if (!cachedTxHistory) { 1547 const res = await postJSON(url, { 1548 n: n, 1549 assetID: assetID 1550 }) 1551 if (!this.checkResponse(res)) { 1552 throw new Error(res.msg) 1553 } 1554 let txs : WalletTransaction[] | null | undefined = res.txs 1555 if (!txs) { 1556 txs = [] 1557 } 1558 this.txHistoryMap[assetID] = { 1559 txs: txs, 1560 lastTx: txs.length < n 1561 } 1562 return this.txHistoryMap[assetID] 1563 } 1564 const txs : WalletTransaction[] = [] 1565 let lastTx = false 1566 const startIndex = after ? cachedTxHistory.txs.findIndex(tx => tx.id === after) + 1 : 0 1567 if (after && startIndex === -1) { 1568 throw new Error('invalid after tx ' + after) 1569 } 1570 let lastIndex = startIndex 1571 for (let i = startIndex; i < cachedTxHistory.txs.length && txs.length < n; i++) { 1572 txs.push(cachedTxHistory.txs[i]) 1573 lastIndex = i 1574 after = cachedTxHistory.txs[i].id 1575 } 1576 if (cachedTxHistory.lastTx && lastIndex === cachedTxHistory.txs.length - 1) { 1577 lastTx = true 1578 } 1579 if (txs.length < n && !cachedTxHistory.lastTx) { 1580 const res = await postJSON(url, { 1581 n: n - txs.length + 1, // + 1 because first result will be refID 1582 assetID: assetID, 1583 refID: after, 1584 past: true 1585 }) 1586 if (!this.checkResponse(res)) { 1587 throw new Error(res.msg) 1588 } 1589 let resTxs : WalletTransaction[] | null | undefined = res.txs 1590 if (!resTxs) { 1591 resTxs = [] 1592 } 1593 if (resTxs.length > 0 && after) { 1594 if (resTxs[0].id === after) { 1595 resTxs.shift() 1596 } else { 1597 // Implies a bug in the client 1598 console.error('First tx history element != refID') 1599 } 1600 } 1601 cachedTxHistory.lastTx = resTxs.length < n - txs.length 1602 lastTx = cachedTxHistory.lastTx 1603 txs.push(...resTxs) 1604 cachedTxHistory.txs.push(...resTxs) 1605 } 1606 return { txs, lastTx } 1607 } 1608 1609 getWalletTx (assetID: number, txID: string): WalletTransaction | undefined { 1610 const cachedTxHistory = this.txHistoryMap[assetID] 1611 if (!cachedTxHistory) return undefined 1612 return cachedTxHistory.txs.find(tx => tx.id === txID) 1613 } 1614 1615 clearTxHistory (assetID: number) { 1616 delete this.txHistoryMap[assetID] 1617 } 1618 1619 async needsCustomProvider (assetID: number): Promise<boolean> { 1620 const baseChainID = this.assets[assetID]?.token?.parentID ?? assetID 1621 if (!baseChainID) return false 1622 const w = this.walletMap[baseChainID] 1623 if (!w) return false 1624 const traitAccountLocker = 1 << 14 1625 if ((w.traits & traitAccountLocker) === 0) return false 1626 const res = await postJSON('/api/walletsettings', { assetID: baseChainID }) 1627 if (!this.checkResponse(res)) { 1628 console.error(res.msg) 1629 return false 1630 } 1631 const settings = res.map as Record<string, string> 1632 return !settings.providers 1633 } 1634 } 1635 1636 /* getSocketURI returns the websocket URI for the client. */ 1637 function getSocketURI (): string { 1638 const protocol = (window.location.protocol === 'https:') ? 'wss' : 'ws' 1639 return `${protocol}://${window.location.host}/ws` 1640 } 1641 1642 /* 1643 * severityClassMap maps a notification severity level to a CSS class that 1644 * assigns a background color. 1645 */ 1646 const severityClassMap: Record<number, string> = { 1647 [ntfn.SUCCESS]: 'good', 1648 [ntfn.ERROR]: 'bad', 1649 [ntfn.WARNING]: 'warn' 1650 } 1651 1652 /* handlerFromPath parses the handler name from the path. */ 1653 function handlerFromPath (path: string): string { 1654 return path.replace(/^\//, '').split('/')[0].split('?')[0].split('#')[0] 1655 } 1656 1657 /* nowString creates a string formatted like HH:MM:SS.xxx */ 1658 function nowString (): string { 1659 const stamp = new Date() 1660 const h = stamp.getHours().toString().padStart(2, '0') 1661 const m = stamp.getMinutes().toString().padStart(2, '0') 1662 const s = stamp.getSeconds().toString().padStart(2, '0') 1663 const ms = stamp.getMilliseconds().toString().padStart(3, '0') 1664 return `${h}:${m}:${s}.${ms}` 1665 } 1666 1667 function setSeverityClass (el: HTMLElement, severity: number) { 1668 el.classList.remove('bad', 'warn', 'good') 1669 el.classList.add(severityClassMap[severity]) 1670 } 1671 1672 /* updateMatch updates the match in or adds the match to the order. */ 1673 function updateMatch (order: Order, match: Match) { 1674 for (const i in order.matches) { 1675 const m = order.matches[i] 1676 if (m.matchID === match.matchID) { 1677 order.matches[i] = match 1678 return 1679 } 1680 } 1681 order.matches = order.matches || [] 1682 order.matches.push(match) 1683 }