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  }