decred.org/dcrdex@v1.0.3/client/webserver/site/src/js/app.ts (about)

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