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