Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 125 additions & 2 deletions client/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { getMempoolDepth, getConfEstimate, calcSegwitFeeGains } from './lib/fees
import { summarizeBlockTemplate } from './lib/block-template'
import { getPriceFeedApiBase } from './lib/high-value-assets'
import { isBitcoinNetwork } from './lib/network'
import { normalizeSearchQuery } from './lib/search-query'
import { debounceAutocompleteQueries, getAutocompletePlan } from './lib/search-autocomplete'
import getPrivacyAnalysis from './lib/privacy-analysis'
import {
highValueAssetDefinitions,
Expand Down Expand Up @@ -73,6 +75,19 @@ const highValueAssetCategory = assetId => `dashboard-high-value-asset-${assetId}

const reservedPaths = [ 'mempool', 'assets', 'search' ]
, NEW_TABLE_ENTRY_MS = 2000
, initialAutocompleteState = {
query: '',
results: [],
loading: false,
focused: false,
dismissed: false,
activeIndex: -1,
resolvedQuery: null
}
, sameAutocompleteView = (a, b) =>
a.loading == b.loading && a.focused == b.focused && a.dismissed == b.dismissed
&& a.activeIndex == b.activeIndex
&& (a.results == b.results || !a.results.length && !b.results.length)

// Make driver source observables rxjs5-compatible via rxjs-compat
setAdapt(stream => O.from(stream))
Expand Down Expand Up @@ -212,7 +227,7 @@ export const trackPendingBlockTemplateEvent = (previous, event) => {
}

export default function main(
{ DOM, HTTP, route, storage, scanner: scan$, search: searchResult$, blinding: unblinded$ },
{ DOM, HTTP, route, storage, scanner: scan$, search: searchResult$, autocomplete: autocompleteResult$, blinding: unblinded$ },
{
pollingEnabled=process.browser,
pollingScheduler,
Expand Down Expand Up @@ -284,12 +299,30 @@ export default function main(

// three ways to search: via the form, using the short /<query> search URL and using the QR scanner.
// this triggers a redirect to /search?q=<query>, which then triggers the search itself.
, searchSubmit$ = on('.search', 'submit')
.map(e => e.target.querySelector('[name=q]').value)
.share()
, searchQuery$ = O.merge(
on('.search', 'submit').map(e => e.target.querySelector('[name=q]').value)
searchSubmit$
, route('/:q([a-zA-Z0-9]+)').map(loc => loc.params.q).filter(q => !reservedPaths.includes(q))
, scan$
)

, autocompleteInput$ = on('.search-bar-input', 'input')
.map(e => normalizeSearchQuery(e.ownerTarget.value))
, autocompleteFocus$ = on('.search-bar-input', 'focus')
, autocompleteText$ = O.merge(
autocompleteInput$
, autocompleteFocus$.map(e => normalizeSearchQuery(e.ownerTarget.value))
)
, autocompleteBlur$ = on('.search', 'focusout')
.filter(e => !e.relatedTarget || !e.ownerTarget.contains(e.relatedTarget))
, autocompleteKeydown$ = on('.search-bar-input', 'keydown')
.filter(e => !e.isComposing && [ 'ArrowDown', 'ArrowUp', 'Enter', 'Escape' ].includes(e.key))
.share()
, autocompleteOptionFocus$ = on('[data-autocomplete-index]', 'focus')
.map(e => +e.ownerTarget.dataset.autocompleteIndex)

// auto-expand when opening with "#expand"
, expandTx$ = route('/tx/:txid').filter(loc => loc.query.expand).map(loc => loc.params.txid)
, expandBl$ = route('/block/:hash').filter(loc => loc.query.expand).map(loc => loc.params.hash)
Expand Down Expand Up @@ -330,6 +363,89 @@ export default function main(
// the translation function for the currently selected language
, t$ = lang$.map(lang => l10n[lang] || l10n[defaultLang])

, autocomplete$ = O.merge(
autocompleteText$.map(query => state => query == state.query
? {
...state,
loading: state.loading
|| (state.dismissed && query != state.resolvedQuery && getAutocompletePlan(query).kind != 'none'),
focused: true,
dismissed: false
}
: ({
...state,
query,
results: [],
loading: getAutocompletePlan(query).kind != 'none',
focused: true,
dismissed: false,
activeIndex: -1
}))
, autocompleteBlur$.mapTo(state => ({
...state,
loading: false,
focused: false,
dismissed: true,
activeIndex: -1
}))
, autocompleteOptionFocus$.map(activeIndex => state => ({
...state,
focused: true,
dismissed: false,
activeIndex
}))
, autocompleteResult$.map(result => state => result.query != state.query ? state : ({
...state,
results: result.results,
loading: state.focused && !state.dismissed && result.loading,
activeIndex: -1,
resolvedQuery: result.loading || result.failed ? state.resolvedQuery : result.query
}))
, autocompleteKeydown$
.filter(e => e.key != 'Enter')
.map(e => state => {
if (e.key == 'Escape') {
if (state.dismissed) return state
if (state.loading || state.results.length) e.preventDefault()
return { ...state, loading: false, dismissed: true, activeIndex: -1 }
}

if (!state.results.length) return state

e.preventDefault()
const offset = e.key == 'ArrowDown' ? 1 : -1
, activeIndex = state.activeIndex < 0
? (offset > 0 ? 0 : state.results.length-1)
: (state.activeIndex + offset + state.results.length) % state.results.length

return { ...state, dismissed: false, activeIndex }
})
, O.merge(page$, searchSubmit$).mapTo(state => ({
...state,
loading: false,
focused: false,
dismissed: true,
activeIndex: -1
}))
)
.startWith(state => state)
.scan((state, mod) => mod(state), initialAutocompleteState)
.shareReplay(1)

// Cancels pending lookups once dismissed, but keeps settled queries so refocusing reuses their results
, autocompleteQuery$ = debounceAutocompleteQueries(autocomplete$.map(state =>
state.loading || state.query == state.resolvedQuery ? state.query : ''))

, selectAutocomplete$ = autocompleteKeydown$
.filter(e => e.key == 'Enter')
.withLatestFrom(autocomplete$, (event, state) => {
const result = state.focused && !state.dismissed && state.results[state.activeIndex]
if (result) event.preventDefault()
return result
})
.filter(Boolean)
.share()

// Scanner state (on/off)
, scanning$ = O.merge(
goScan$.mapTo(true)
Expand Down Expand Up @@ -619,6 +735,7 @@ export default function main(
, tx$, txBlock$, previousBlock$, txAnalysis$, openTx$
, goAddr$, addr$, addrTxs$, addrQR$
, assetMap$, assetList$, goAssetList$, goAsset$, asset$, assetTxs$, unblinded$
, autocomplete$: autocomplete$.distinctUntilChanged(sameAutocompleteView)
, isReady$, loading$, page$, view$, title$
})

Expand Down Expand Up @@ -796,6 +913,7 @@ export default function main(
// Route navigation sink
, navto$ = O.merge(
searchResult$.filter(Boolean).map(result => ({ type: 'replace', ...result }))
, selectAutocomplete$.map(result => ({ type: 'push', pathname: result.pathname }))
, byHeight$.map(hash => ({ type: 'replace', pathname: `/block/${hash}` }))
, pushedtx$.map(txid => ({ type: 'push', pathname: `/tx/${txid}` }))
, selectBlockGridTx$.map(txid => ({ type: 'push', pathname: `/tx/${txid}` }))
Expand All @@ -808,6 +926,7 @@ export default function main(
, state$, view$, block$, blockTxs$, blocks$, tx$, txBlock$, txAnalysis$, spends$, addr$
, tipHeight$, error$, loading$
, goSearch$, searchResult$, copy$, store$, navto$, scanning$, scan$, selectBlockGridTx$
, autocomplete$, autocompleteQuery$, autocompleteResult$, selectAutocomplete$
, assetMap$, goAssetList$, assetList$
, req$, reply$: dropErrors(HTTP.select()).map(r => [ r.request.category, r.req.method, r.req.url, r.body||r.text, r ]) })

Expand Down Expand Up @@ -854,6 +973,9 @@ export default function main(

on('.table-copy-button', 'click', { preventDefault: true }).subscribe(e => e.stopPropagation())

// Keep focus in the search input so clicking the dropdown doesn't dismiss it before the click lands
on('.search-autocomplete-results', 'mousedown', { preventDefault: true }).subscribe()

const closeOpenTooltips = except => {
document.querySelectorAll('.tooltip.tooltip-open').forEach(tooltip => {
if (tooltip != except) tooltip.classList.remove('tooltip-open')
Expand Down Expand Up @@ -938,6 +1060,7 @@ export default function main(
, route: navto$
, storage: store$
, search: goSearch$
, autocomplete: autocompleteQuery$
, scanner: scanning$
, title: title$
, state: state$
Expand Down
4 changes: 3 additions & 1 deletion client/src/components/asset-icon.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ const stopWatchingImage = vnode => {
if (image) image.onload = image.onerror = null
}

export const assetIconSrc = href => typeof href === 'string' && /^https?:\/\//i.test(href) ? href : null

export default ({ href }) => {
const src = typeof href === 'string' && /^https?:\/\//i.test(href) ? href : null
const src = assetIconSrc(href)
, loading = !!src && process.browser

return (
Expand Down
4 changes: 3 additions & 1 deletion client/src/components/loading.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export const loadingRing = size => <span className={`ring${size ? ` ${size}` : ''}`}></span>

export default (size, { immediate } = {}) =>
<div className="loading-container">
<div className={`spinner${immediate ? ' spinner-immediate' : ''}`}>
<div className={`ring ${size}`}></div>
</div>
</div>
</div>
2 changes: 2 additions & 0 deletions client/src/const.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,7 @@ export const showHighValueAssets = !!process.env.IS_ELEMENTS
&& process.env.SHOW_HIGH_VALUE_ASSETS == '1'
&& nativeAssetId == liquidNativeAssetId

export const autocompleteCategoryOrder = [ 'assets', 'blocks', 'transactions', 'addresses' ]

// Elements only
export const assetTxsPerPage = 25
115 changes: 115 additions & 0 deletions client/src/driver/autocomplete.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import request from 'superagent'
import { tryUnconfidentialAddress } from '../util'
import { autocompleteCategoryOrder } from '../const'
import {
autocompleteResultLimit,
getAutocompletePlan,
lower,
mergeAssetResults,
} from '../lib/search-autocomplete'
import { Observable as O } from '../rxjs'

const autocompleteCacheMs = 60000
, categoryRank = category => autocompleteCategoryOrder.indexOf(category)

const settled = promise => promise
.then(value => ({ value, cacheable: true }))
.catch(error => ({
value: null,
cacheable: !!(error && error.status >= 400 && error.status < 500 && error.status != 429),
}))

const lookupHash = (apiBase, { query }, requester) => Promise.all([
settled(requester(`${apiBase}/tx/${query}/status`).then(_ => ({
category: 'transactions',
id: `transaction:${query}`,
pathname: `/tx/${query}`,
txid: query,
}))),
settled(requester(`${apiBase}/block/${query}`).then(response => ({
category: 'blocks',
id: `block:${query}`,
pathname: `/block/${query}`,
hash: query,
block: response.body,
}))),
settled(requester(`${apiBase}/asset/${query}`).then(response => ({
category: 'assets',
id: `asset:${query}`,
pathname: `/asset/${query}`,
asset: { ...(response.body || {}), asset_id: (response.body && response.body.asset_id) || query },
}))),
]).then(responses => ({
cacheable: responses.every(response => response.cacheable),
results: responses.map(response => response.value)
.filter(Boolean)
.sort((a, b) => categoryRank(a.category) - categoryRank(b.category)),
}))

const lookupAddress = (apiBase, { query }, requester) =>
settled(requester(`${apiBase}/address/${tryUnconfidentialAddress(query)}`).then(_ => ({
category: 'addresses',
id: `address:${query}`,
pathname: `/address/${query}`,
address: query,
}))).then(response => ({
cacheable: response.cacheable,
results: response.value ? [ response.value ] : [],
}))

const lookupAssets = (apiBase, { query, filters }, requester) => Promise.all(filters.map(field =>
settled(requester(`${apiBase}/assets/registry`)
.query({ [field]: query, limit: autocompleteResultLimit, sort_field: field, sort_dir: 'asc' })
.then(response => ({ field, assets: Array.isArray(response.body) ? response.body : [] })))
)).then(responses => ({
cacheable: responses.every(response => response.cacheable),
results: mergeAssetResults(responses.map((response, index) => response.value || {
field: filters[index],
assets: [],
}), query),
}))

const pruneExpired = (cache, now) => cache.forEach((entry, key) => {
if (entry.expiresAt <= now) cache.delete(key)
})

const executePlan = (apiBase, plan, requester) =>
plan.kind == 'hash' ? lookupHash(apiBase, plan, requester)
: plan.kind == 'address' ? lookupAddress(apiBase, plan, requester)
: plan.kind == 'assets' ? lookupAssets(apiBase, plan, requester)
: Promise.resolve({ cacheable: true, results: [] })

export default (apiBase, requester=request) => {
const cache = new Map()

return query$ => O.from(query$)
.switchMap(rawQuery => {
const plan = getAutocompletePlan(rawQuery)
, query = plan.query

if (plan.kind == 'none') {
return O.of({ query, loading: false, results: [] })
}

const cacheKey = plan.kind == 'address' ? `${plan.kind}:${query}` : `${plan.kind}:${lower(query)}`

pruneExpired(cache, Date.now())

const cached = cache.get(cacheKey)
if (cached) {
return O.of({ query, loading: false, results: cached.results })
}

// Cache from the promise so lookups that settle after cancellation are still reused
const lookup = executePlan(apiBase, plan, requester).then(response => {
if (response.cacheable) cache.set(cacheKey, { results: response.results, expiresAt: Date.now() + autocompleteCacheMs })
return response
})

return O.from(lookup)
.map(({ cacheable, results }) => ({ query, loading: false, results, ...(cacheable ? {} : { failed: true }) }))
.catch(_ => O.of({ query, loading: false, results: [], failed: true }))
.startWith({ query, loading: true, results: [] })
})
.share()
}
20 changes: 10 additions & 10 deletions client/src/driver/search.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import request from 'superagent'
import { tryUnconfidentialAddress, isHash256 } from '../util'
import {
normalizeSearchQuery,
isNumberSearch,
isAddressSearch,
matchShortTxOut,
} from '../lib/search-query'
import { Observable as O } from '../rxjs'

const reNumber = /^\d+$/
, reAddrLike = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100})$/
, reShortTxOut = /^(\d+)([x:])(\d+)\2(\d+)$/
, trim = s => s.trim()
, stripUri = s => s.replace(/^(?:bitcoin|liquidnetwork):([^?]+).*/i, '$1')

export default apiBase => {
const tryResource = path =>
request(apiBase + path)
Expand All @@ -17,10 +17,10 @@ export default apiBase => {

// Accepts a stream of query strings, returns a stream of found resource paths
return query$ =>
O.from(query$).map(trim).map(stripUri).flatMap(async query =>
O.from(query$).map(normalizeSearchQuery).flatMap(async query =>

// if its a number, assume its a block height without checking
reNumber.test(query)
isNumberSearch(query)
? `/block-height/${query}`

// if its a 256 bit hash, look it up as a txid or block hash
Expand All @@ -31,7 +31,7 @@ export default apiBase => {
.catch(_ => null)

// lookup as lightning-style short txout identifier
: (matches = query.match(reShortTxOut))
: (matches = matchShortTxOut(query))
? request(`${apiBase}/block-height/${matches[1]}`)
.then(r => r.ok ? r.text : Promise.reject('invalid reply for block height'))
.then(blockhash => request(`${apiBase}/block/${blockhash}/txid/${matches[3]}`))
Expand All @@ -40,7 +40,7 @@ export default apiBase => {
.catch(_ => null)

// lookup as address if it resembles one
: reAddrLike.test(query)
: isAddressSearch(query)
? tryResource(`/address/${tryUnconfidentialAddress(query)}`)
// use the user-provided address and not the (potentially) unconfidential one
.then(_ => `/address/${query}`)
Expand Down
Loading