diff --git a/src/containers/App/index.tsx b/src/containers/App/index.tsx
index 246627e07..29e96fa80 100644
--- a/src/containers/App/index.tsx
+++ b/src/containers/App/index.tsx
@@ -26,7 +26,7 @@ import {
AMENDMENTS_ROUTE,
AMENDMENT_ROUTE,
} from './routes'
-import Ledgers from '../Ledgers'
+import { LedgersPage as Ledgers } from '../Ledgers'
import { Ledger } from '../Ledger'
import { AccountsRouter } from '../Accounts/AccountsRouter'
import { Transaction } from '../Transactions'
diff --git a/src/containers/App/test/App.test.jsx b/src/containers/App/test/App.test.jsx
index 98b7baf12..15674340b 100644
--- a/src/containers/App/test/App.test.jsx
+++ b/src/containers/App/test/App.test.jsx
@@ -17,7 +17,7 @@ import { Error } from '../../../rippled/lib/utils'
jest.mock('../../Ledgers/LedgerMetrics', () => ({
__esModule: true,
- default: () => null,
+ LedgerMetrics: () => null,
}))
jest.mock('xrpl-client', () => ({
diff --git a/src/containers/Ledgers/LedgerEntryHash.tsx b/src/containers/Ledgers/LedgerEntryHash.tsx
new file mode 100644
index 000000000..e9ae1fb4e
--- /dev/null
+++ b/src/containers/Ledgers/LedgerEntryHash.tsx
@@ -0,0 +1,39 @@
+import { useTranslation } from 'react-i18next'
+import SuccessIcon from '../shared/images/success.svg'
+import { LedgerEntryValidation } from './LedgerEntryValidator'
+import { LedgerEntryHashTrustedCount } from './LedgerEntryHashTrustedCount'
+
+export const LedgerEntryHash = ({ hash }: { hash: any }) => {
+ const { t } = useTranslation()
+ const shortHash = hash.hash.substr(0, 6)
+ const barStyle = { background: `#${shortHash}` }
+ const validated = hash.validated &&
+ return (
+
+
+
+
{hash.hash.substr(0, 6)}
+ {validated}
+
+
+
+
{t('total')}:
+
{hash.validations.length}
+
+
+
+
+ {hash.validations.map((validation, i) => (
+
+ ))}
+
+
+ )
+}
diff --git a/src/containers/Ledgers/LedgerEntryHashTrustedCount.tsx b/src/containers/Ledgers/LedgerEntryHashTrustedCount.tsx
new file mode 100644
index 000000000..c2016419f
--- /dev/null
+++ b/src/containers/Ledgers/LedgerEntryHashTrustedCount.tsx
@@ -0,0 +1,60 @@
+import { useTranslation } from 'react-i18next'
+import classNames from 'classnames'
+import { useMemo } from 'react'
+import { ValidationStream } from 'xrpl'
+import { useTooltip } from '../shared/components/Tooltip'
+import { useVHSValidators } from '../shared/components/VHSValidators/VHSValidatorsContext'
+
+export const LedgerEntryHashTrustedCount = ({
+ validations,
+}: {
+ validations: ValidationStream[]
+}) => {
+ const { t } = useTranslation()
+ const { hideTooltip, showTooltip } = useTooltip()
+ const { unl, validators } = useVHSValidators()
+
+ const status = useMemo(() => {
+ const missing = [...(unl || [])]
+ validations.forEach((v) => {
+ if (!validators?.[v.validation_public_key]) {
+ return
+ }
+
+ const missingIndex = missing.findIndex(
+ (assumedMissing) => assumedMissing === v.validation_public_key,
+ )
+ if (missingIndex !== -1) {
+ missing.splice(missingIndex, 1)
+ }
+ })
+
+ return {
+ missing: missing.map((v) => validators?.[v]),
+ trustedCount: (unl?.length || 0) - missing.length,
+ }
+ }, [unl, validations])
+
+ return status.trustedCount ? (
+ {
+ const { missing } = status
+
+ missing.length && showTooltip('missing', e, { missing })
+ }}
+ onFocus={() => {}}
+ onKeyUp={() => {}}
+ onMouseLeave={() => hideTooltip()}
+ >
+ {t('unl')}:
+
+ {status.trustedCount}/{unl?.length}
+
+
+ ) : null
+}
diff --git a/src/containers/Ledgers/LedgerEntryTransaction.tsx b/src/containers/Ledgers/LedgerEntryTransaction.tsx
new file mode 100644
index 000000000..acf884051
--- /dev/null
+++ b/src/containers/Ledgers/LedgerEntryTransaction.tsx
@@ -0,0 +1,34 @@
+import classNames from 'classnames'
+import { getAction, getCategory } from '../shared/components/Transaction'
+import { TRANSACTION_ROUTE } from '../App/routes'
+import { TransactionActionIcon } from '../shared/components/TransactionActionIcon/TransactionActionIcon'
+import { RouteLink } from '../shared/routing'
+import { useTooltip } from '../shared/components/Tooltip'
+
+export const LedgerEntryTransaction = ({
+ transaction,
+}: {
+ transaction: any
+}) => {
+ const { hideTooltip, showTooltip } = useTooltip()
+
+ return (
+ showTooltip('tx', e, transaction)}
+ onFocus={() => {}}
+ onMouseLeave={() => hideTooltip()}
+ to={TRANSACTION_ROUTE}
+ params={{ identifier: transaction.hash }}
+ >
+
+ {transaction.hash}
+
+ )
+}
diff --git a/src/containers/Ledgers/LedgerEntryTransactions.tsx b/src/containers/Ledgers/LedgerEntryTransactions.tsx
new file mode 100644
index 000000000..6ce29e6b2
--- /dev/null
+++ b/src/containers/Ledgers/LedgerEntryTransactions.tsx
@@ -0,0 +1,20 @@
+import { memo } from 'react'
+import { Loader } from '../shared/components/Loader'
+import { LedgerEntryTransaction } from './LedgerEntryTransaction'
+
+export const LedgerEntryTransactions = memo(
+ ({ transactions }: { transactions: any[] }) => (
+ <>
+ {!transactions && }
+
+ {transactions?.map((tx) => (
+
+ ))}
+
+ >
+ ),
+ (prevProps, nextProps) =>
+ prevProps.transactions &&
+ nextProps.transactions &&
+ prevProps.transactions.length === nextProps.transactions.length,
+)
diff --git a/src/containers/Ledgers/LedgerEntryValidator.tsx b/src/containers/Ledgers/LedgerEntryValidator.tsx
new file mode 100644
index 000000000..c6a7c25f5
--- /dev/null
+++ b/src/containers/Ledgers/LedgerEntryValidator.tsx
@@ -0,0 +1,49 @@
+import classNames from 'classnames'
+import { useSelectedValidator } from './useSelectedValidator'
+import { useTooltip } from '../shared/components/Tooltip'
+import { useVHSValidators } from '../shared/components/VHSValidators/VHSValidatorsContext'
+
+export const LedgerEntryValidation = ({
+ validation,
+ index,
+}: {
+ validation: any
+ index: number
+}) => {
+ const { showTooltip, hideTooltip } = useTooltip()
+ const { selectedValidator, setSelectedValidator } = useSelectedValidator()
+ const { validators } = useVHSValidators()
+ const className = classNames(
+ 'validation',
+ validators?.[validation.validation_public_key]?.unl && 'trusted',
+ selectedValidator && 'unselected',
+ selectedValidator === validation.validation_public_key && 'selected',
+ )
+
+ return (
+
+ showTooltip('validator', e, {
+ ...validation,
+ v: validators?.[validation.validation_public_key],
+ })
+ }
+ onFocus={() => {}}
+ onKeyUp={() => {}}
+ onMouseLeave={() => hideTooltip()}
+ onClick={() =>
+ setSelectedValidator(
+ selectedValidator &&
+ selectedValidator === validation.validation_public_key
+ ? undefined
+ : validation.validation_public_key,
+ )
+ }
+ >
+ {validation.partial &&
}
+
+ )
+}
diff --git a/src/containers/Ledgers/LedgerListEntry.tsx b/src/containers/Ledgers/LedgerListEntry.tsx
new file mode 100644
index 000000000..1424e1dc0
--- /dev/null
+++ b/src/containers/Ledgers/LedgerListEntry.tsx
@@ -0,0 +1,74 @@
+import { useTranslation } from 'react-i18next'
+import { RouteLink } from '../shared/routing'
+import { LEDGER_ROUTE } from '../App/routes'
+import { Amount } from '../shared/components/Amount'
+import { LedgerEntryHash } from './LedgerEntryHash'
+import { LedgerEntryTransactions } from './LedgerEntryTransactions'
+import { Ledger } from '../shared/components/Streams/types'
+import {
+ Tooltip,
+ TooltipProvider,
+ useTooltip,
+} from '../shared/components/Tooltip'
+
+const SIGMA = '\u03A3'
+
+const LedgerIndex = ({ ledgerIndex }: { ledgerIndex: number }) => {
+ const { t } = useTranslation()
+ const flagLedger = ledgerIndex % 256 === 0
+ return (
+
+
+ {ledgerIndex}
+
+
+ )
+}
+
+export const LedgerListEntryInner = ({ ledger }: { ledger: Ledger }) => {
+ const { tooltip } = useTooltip()
+ const { t } = useTranslation()
+ const time = ledger.closeTime
+ ? new Date(ledger.closeTime).toLocaleTimeString()
+ : null
+
+ return (
+
+
+
+
{time}
+ {/* Render Transaction Count (can be 0) */}
+ {ledger.txCount !== undefined && (
+
+ {t('txn_count')}:{ledger.txCount.toLocaleString()}
+
+ )}
+ {/* Render Total Fees (can be 0) */}
+ {ledger.totalFees !== undefined && (
+
+ {SIGMA} {t('fees')}:
+
+
+
+
+ )}
+
+
+
+ {ledger.hashes.map((hash) => (
+
+ ))}
+
+
+
+ )
+}
+
+export const LedgerListEntry = ({ ledger }: { ledger: Ledger }) => (
+
+
+
+)
diff --git a/src/containers/Ledgers/LedgerMetrics.jsx b/src/containers/Ledgers/LedgerMetrics.jsx
deleted file mode 100644
index 8ee430b8a..000000000
--- a/src/containers/Ledgers/LedgerMetrics.jsx
+++ /dev/null
@@ -1,161 +0,0 @@
-import { withTranslation } from 'react-i18next'
-import { Component } from 'react'
-import PropTypes from 'prop-types'
-import Tooltip from '../shared/components/Tooltip'
-import { renderXRP } from '../shared/utils'
-import PauseIcon from '../shared/images/ic_pause.svg'
-import ResumeIcon from '../shared/images/ic_play.svg'
-import './css/ledgerMetrics.scss'
-import SocketContext from '../shared/SocketContext'
-
-const DEFAULTS = {
- load_fee: '--',
- txn_sec: '--',
- txn_ledger: '--',
- ledger_interval: '--',
- avg_fee: '--',
- quorum: '--',
- nUnl: [],
-}
-
-class LedgerMetrics extends Component {
- constructor(props) {
- super(props)
- this.state = {
- tooltip: null,
- }
- }
-
- static getDerivedStateFromProps(nextProps, prevState) {
- return { ...prevState, ...nextProps }
- }
-
- showTooltip = (event) => {
- const { data, nUnl } = this.state
- this.setState({
- tooltip: {
- nUnl: data.nUnl,
- mode: 'nUnl',
- v: nUnl,
- x: event.pageX,
- y: event.pageY,
- },
- })
- }
-
- hideTooltip = () => this.setState({ tooltip: null })
-
- renderPause() {
- const { t, onPause, paused } = this.props
- const Icon = paused ? ResumeIcon : PauseIcon
- const text = paused ? 'resume' : 'pause'
-
- return (
-
-
- {t(text)}
-
- )
- }
-
- render() {
- const { language, t } = this.props
- const { data: stateData } = this.state
- const data = { ...DEFAULTS, ...stateData }
-
- if (data.load_fee === '--') {
- data.load_fee = data.base_fee || '--'
- }
- delete data.base_fee
- const items = Object.keys(data)
- .map((key) => {
- let content = null
-
- let className = 'label'
- if (data[key] === undefined && key !== 'nUnl') {
- content = '--'
- } else if (key.includes('fee') && !isNaN(data[key])) {
- content = renderXRP(data[key], language)
- } else if (key === 'ledger_interval' && data[key] !== '--') {
- content = `${data[key]} ${t('seconds_short')}`
- } else if (key === 'nUnl' && data[key]?.length === 0) {
- return null
- } else if (key === 'nUnl') {
- content = data[key].length
- className = 'label n-unl-metric'
- return (
- {}}
- onBlur={() => {}}
- onMouseOver={(e) => this.showTooltip(e)}
- onMouseOut={this.hideTooltip}
- tabIndex={0}
- key={key}
- >
-
- {t(key)}
-
-
{content}
-
- )
- } else {
- content = data[key]
- }
-
- return (
-
- )
- })
- .reverse()
-
- const { tooltip } = this.state
-
- // eslint-disable-next-line react/destructuring-assignment
- const isOnline = this.context.getState().online
-
- return (
-
- {isOnline && (
- <>
-
{this.renderPause()}
-
{items}
-
- >
- )}
-
- )
- }
-}
-
-LedgerMetrics.contextType = SocketContext
-
-LedgerMetrics.propTypes = {
- data: PropTypes.shape({}),
- language: PropTypes.string.isRequired,
- t: PropTypes.func.isRequired,
- onPause: PropTypes.func.isRequired,
- paused: PropTypes.bool.isRequired,
-}
-
-LedgerMetrics.defaultProps = {
- data: {},
-}
-
-export default withTranslation()(LedgerMetrics)
diff --git a/src/containers/Ledgers/LedgerMetrics.tsx b/src/containers/Ledgers/LedgerMetrics.tsx
new file mode 100644
index 000000000..83dd48fec
--- /dev/null
+++ b/src/containers/Ledgers/LedgerMetrics.tsx
@@ -0,0 +1,120 @@
+import { useTranslation } from 'react-i18next'
+import { Tooltip, useTooltip } from '../shared/components/Tooltip'
+import { renderXRP } from '../shared/utils'
+import PauseIcon from '../shared/images/ic_pause.svg'
+import ResumeIcon from '../shared/images/ic_play.svg'
+import './css/ledgerMetrics.scss'
+import { useIsOnline } from '../shared/SocketContext'
+import { useLanguage } from '../shared/hooks'
+import { useStreams } from '../shared/components/Streams/StreamsContext'
+
+const DEFAULTS = {
+ load_fee: '--',
+ txn_sec: '--',
+ txn_ledger: '--',
+ ledger_interval: '--',
+ avg_fee: '--',
+ quorum: '--',
+ nUnl: [],
+}
+
+export const LedgerMetrics = ({
+ onPause,
+ paused,
+}: {
+ onPause: any
+ paused: boolean
+}) => {
+ const { metrics: suppliedData } = useStreams()
+ const data = { ...DEFAULTS, ...suppliedData }
+ const { tooltip, showTooltip, hideTooltip } = useTooltip()
+ const { t } = useTranslation()
+ const isOnline = useIsOnline()
+ const language = useLanguage()
+
+ const renderPause = () => {
+ const Icon = paused ? ResumeIcon : PauseIcon
+ const text = paused ? 'resume' : 'pause'
+
+ return (
+
+
+ {t(text)}
+
+ )
+ }
+
+ if (data.load_fee === '--') {
+ data.load_fee = data.base_fee || '--'
+ }
+ delete data.base_fee
+ const items = Object.keys(data)
+ .map((key) => {
+ let content: any = null
+
+ let className = 'label'
+ if (data[key] === undefined && key !== 'nUnl') {
+ content = '--'
+ } else if (key.includes('fee') && !isNaN(data[key])) {
+ content = renderXRP(data[key], language)
+ } else if (key === 'ledger_interval' && data[key] !== '--') {
+ content = `${data[key]} ${t('seconds_short')}`
+ } else if (key === 'nUnl' && data[key]?.length === 0) {
+ return null
+ } else if (key === 'nUnl') {
+ content = data[key].length
+ className = 'label n-unl-metric'
+ return (
+ {}}
+ onBlur={() => {}}
+ onMouseOver={(e) => showTooltip('nUnl', e, { nUnl: data.nUnl })}
+ onMouseOut={() => hideTooltip()}
+ tabIndex={0}
+ key={key}
+ >
+
+ {t(key)}
+
+
{content}
+
+ )
+ } else {
+ content = data[key]
+ }
+
+ return (
+
+ )
+ })
+ .reverse()
+
+ return (
+
+ {isOnline && (
+ <>
+
{renderPause()}
+
{items}
+
+ >
+ )}
+
+ )
+}
diff --git a/src/containers/Ledgers/Ledgers.jsx b/src/containers/Ledgers/Ledgers.jsx
deleted file mode 100644
index f0110e5c5..000000000
--- a/src/containers/Ledgers/Ledgers.jsx
+++ /dev/null
@@ -1,295 +0,0 @@
-import { Component } from 'react'
-import { withTranslation } from 'react-i18next'
-import PropTypes from 'prop-types'
-import { CURRENCY_OPTIONS } from '../shared/transactionUtils'
-import { localizeNumber } from '../shared/utils'
-import Tooltip from '../shared/components/Tooltip'
-import './css/ledgers.scss'
-import SuccessIcon from '../shared/images/success.svg'
-import DomainLink from '../shared/components/DomainLink'
-import { Loader } from '../shared/components/Loader'
-import SocketContext from '../shared/SocketContext'
-import { getAction, getCategory } from '../shared/components/Transaction'
-import { TransactionActionIcon } from '../shared/components/TransactionActionIcon/TransactionActionIcon'
-import { Legend } from './Legend'
-import { RouteLink } from '../shared/routing'
-import { LEDGER_ROUTE, TRANSACTION_ROUTE, VALIDATOR_ROUTE } from '../App/routes'
-
-const SIGMA = '\u03A3'
-
-class Ledgers extends Component {
- constructor(props) {
- super(props)
- this.state = {
- ledgers: [],
- validators: {},
- tooltip: null,
- }
- }
-
- static getDerivedStateFromProps(nextProps, prevState) {
- return {
- selected: nextProps.selected,
- ledgers: nextProps.paused ? prevState.ledgers : nextProps.ledgers,
- validators: nextProps.validators,
- unlCount: nextProps.unlCount,
- }
- }
-
- getMissingValidators = (hash) => {
- const { validators } = this.props
- const unl = {}
-
- Object.keys(validators).forEach((pubkey) => {
- if (validators[pubkey].unl) {
- unl[pubkey] = false
- }
- })
-
- hash.validations.forEach((v) => {
- if (unl[v.pubkey] !== undefined) {
- delete unl[v.pubkey]
- }
- })
-
- return Object.keys(unl).map((pubkey) => validators[pubkey])
- }
-
- showTooltip = (mode, event, data) => {
- const { validators } = this.state
- this.setState({
- tooltip: {
- ...data,
- mode,
- v: mode === 'validator' && validators[data.pubkey],
- x: event.currentTarget.offsetLeft,
- y: event.currentTarget.offsetTop,
- },
- })
- }
-
- hideTooltip = () => this.setState({ tooltip: null })
-
- renderSelected = () => {
- const { validators, selected } = this.state
- const v = validators[selected] || {}
- return (
-
- {v.domain && }
-
- {selected}
-
-
- )
- }
-
- renderLedger = (ledger) => {
- const time = ledger.close_time
- ? new Date(ledger.close_time).toLocaleTimeString()
- : null
- const transactions = ledger.transactions || []
-
- return (
-
-
- {this.renderLedgerIndex(ledger.ledger_index)}
-
{time}
- {this.renderTxnCount(ledger.txn_count)}
- {this.renderFees(ledger.total_fees)}
- {ledger.transactions == null &&
}
-
- {transactions.map(this.renderTransaction)}
-
-
-
{ledger.hashes.map(this.renderHash)}
-
- )
- }
-
- renderLedgerIndex = (ledgerIndex) => {
- const { t } = this.props
- const flagLedger = ledgerIndex % 256 === 0
- return (
-
-
- {ledgerIndex}
-
-
- )
- }
-
- renderTxnCount = (count) => {
- const { t } = this.props
- return count !== undefined ? (
-
- {t('txn_count')}:{count.toLocaleString()}
-
- ) : null
- }
-
- renderFees = (d) => {
- const { t, language } = this.props
- const options = { ...CURRENCY_OPTIONS, currency: 'XRP' }
- const amount = localizeNumber(d, language, options)
- return d !== undefined ? (
-
- {SIGMA} {t('fees')}:{amount}
-
- ) : null
- }
-
- renderTransaction = (tx) => (
- this.showTooltip('tx', e, tx)}
- onFocus={(e) => {}}
- onMouseLeave={this.hideTooltip}
- to={TRANSACTION_ROUTE}
- params={{ identifier: tx.hash }}
- >
-
- {tx.hash}
-
- )
-
- renderHash = (hash) => {
- const { t } = this.props
- const shortHash = hash.hash.substr(0, 6)
- const barStyle = { background: `#${shortHash}` }
- const validated = hash.validated &&
- return (
-
-
-
-
{hash.hash.substr(0, 6)}
- {validated}
-
-
-
-
{t('total')}:
-
{hash.validations.length}
-
- {this.renderTrustedCount(hash)}
-
-
- {hash.validations.map(this.renderValidator)}
-
-
- )
- }
-
- renderTrustedCount = (hash) => {
- const { t } = this.props
- const { unlCount } = this.state
- const className = hash.trusted_count < unlCount ? 'missed' : null
- const missing =
- hash.trusted_count && className === 'missed'
- ? this.getMissingValidators(hash)
- : null
-
- return hash.trusted_count ? (
-
- missing &&
- missing.length &&
- this.showTooltip('missing', e, { missing })
- }
- onFocus={(e) => {}}
- onKeyUp={(e) => {}}
- onMouseLeave={this.hideTooltip}
- >
- {t('unl')}:
-
- {hash.trusted_count}/{unlCount}
-
-
- ) : null
- }
-
- renderValidator = (v, i) => {
- const { setSelected } = this.props
- const { selected: selectedState } = this.state
- const trusted = v.unl ? 'trusted' : ''
- const unselected = selectedState ? 'unselected' : ''
- const selected = selectedState === v.pubkey ? 'selected' : ''
- const className = `validation ${trusted} ${unselected} ${selected} ${v.pubkey}`
- const partial = v.partial ? : null
- return (
- this.showTooltip('validator', e, v)}
- onFocus={(e) => {}}
- onKeyUp={(e) => {}}
- onMouseLeave={this.hideTooltip}
- onClick={() => setSelected(v.pubkey)}
- >
- {partial}
-
- )
- }
-
- render() {
- const { ledgers, selected, tooltip } = this.state
- const { t, language, isOnline } = this.props
- return (
-
- {isOnline && ledgers.length > 0 ? (
- <>
-
-
{selected && this.renderSelected()}
-
- {ledgers.map(this.renderLedger)}{' '}
-
-
{' '}
- >
- ) : (
-
- )}
-
- )
- }
-}
-
-Ledgers.contextType = SocketContext
-
-Ledgers.propTypes = {
- ledgers: PropTypes.arrayOf(PropTypes.shape({})), // eslint-disable-line
- validators: PropTypes.shape({}), // eslint-disable-line
- unlCount: PropTypes.number, // eslint-disable-line
- selected: PropTypes.string, // eslint-disable-line
- setSelected: PropTypes.func,
- language: PropTypes.string.isRequired,
- t: PropTypes.func.isRequired,
- paused: PropTypes.bool,
- isOnline: PropTypes.bool.isRequired,
-}
-
-Ledgers.defaultProps = {
- ledgers: [],
- validators: {},
- unlCount: 0,
- selected: null,
- setSelected: () => {},
- paused: false,
-}
-
-export default withTranslation()(Ledgers)
diff --git a/src/containers/Ledgers/Ledgers.tsx b/src/containers/Ledgers/Ledgers.tsx
new file mode 100644
index 000000000..153a1e47e
--- /dev/null
+++ b/src/containers/Ledgers/Ledgers.tsx
@@ -0,0 +1,66 @@
+import { Tooltip, useTooltip } from '../shared/components/Tooltip'
+import './css/ledgers.scss'
+import DomainLink from '../shared/components/DomainLink'
+import { Loader } from '../shared/components/Loader'
+import { useIsOnline } from '../shared/SocketContext'
+import { Legend } from './Legend'
+import { RouteLink } from '../shared/routing'
+import { VALIDATOR_ROUTE } from '../App/routes'
+import { LedgerListEntry } from './LedgerListEntry'
+import { useSelectedValidator } from './useSelectedValidator'
+import { usePreviousWithPausing } from '../shared/hooks/usePreviousWithPausing'
+import { useStreams } from '../shared/components/Streams/StreamsContext'
+import { Ledger } from '../shared/components/Streams/types'
+import { useVHSValidators } from '../shared/components/VHSValidators/VHSValidatorsContext'
+
+export const Ledgers = ({ paused }: { paused: boolean }) => {
+ const { validators: validatorsFromVHS } = useVHSValidators()
+ const { selectedValidator } = useSelectedValidator()
+ const { ledgers } = useStreams()
+ const localLedgers = usePreviousWithPausing>(
+ ledgers,
+ paused,
+ )
+ const isOnline = useIsOnline()
+ const { tooltip } = useTooltip()
+
+ return (
+
+ {isOnline && ledgers ? (
+ <>
+
+
+ {selectedValidator && validatorsFromVHS && (
+
+ {validatorsFromVHS[selectedValidator].domain && (
+
+ )}
+
+ {selectedValidator}
+
+
+ )}
+
+
+ {localLedgers &&
+ Object.values(localLedgers)
+ .reverse()
+ .slice(0, 20)
+ ?.map((ledger) => (
+
+ ))}{' '}
+
+
{' '}
+ >
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/src/containers/Ledgers/css/ledgers.scss b/src/containers/Ledgers/css/ledgers.scss
index b1e8f8047..470f2bd08 100644
--- a/src/containers/Ledgers/css/ledgers.scss
+++ b/src/containers/Ledgers/css/ledgers.scss
@@ -98,11 +98,10 @@ $ledger-width: 196px;
}
.ledger {
- overflow: visible;
width: $ledger-width;
flex-grow: 0;
flex-shrink: 0;
- margin-left: $ledgers-margin-large;
+ margin-right: $ledgers-margin-large;
animation-duration: 0.4s;
animation-name: ledger-enter;
white-space: normal;
@@ -114,12 +113,10 @@ $ledger-width: 196px;
@keyframes ledger-enter {
from {
- width: 0;
- margin-left: -$ledgers-margin-large;
+ margin-left: -$ledger-width;
}
to {
- width: $ledger-width;
margin-left: 0;
}
}
@@ -128,7 +125,6 @@ $ledger-width: 196px;
min-height: 170px;
padding: $ledgers-margin-large;
border: $ledgers-border;
- border-bottom: 0;
color: $black-40;
font-size: 10px;
line-height: 12px;
@@ -228,7 +224,7 @@ $ledger-width: 196px;
.hash {
overflow: hidden;
- padding: 0px 32px 32px;
+ padding: 0 32px 32px;
border: 1px solid $black-60;
border-top: 0;
background: rgba($black-80, 0.7);
@@ -236,6 +232,10 @@ $ledger-width: 196px;
font-size: 15px;
text-align: left;
+ &:last-child:not(:first-child) {
+ border-bottom: 0;
+ }
+
.bar {
height: 2px;
margin: 0px -32px;
diff --git a/src/containers/Ledgers/index.tsx b/src/containers/Ledgers/index.tsx
index f1b432890..7ef7c2699 100644
--- a/src/containers/Ledgers/index.tsx
+++ b/src/containers/Ledgers/index.tsx
@@ -1,35 +1,18 @@
-import { useContext, useEffect, useState } from 'react'
+import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useTranslation } from 'react-i18next'
-import { useQuery } from 'react-query'
-import axios from 'axios'
-import Log from '../shared/log'
-import { FETCH_INTERVAL_ERROR_MILLIS } from '../shared/utils'
-import Streams from '../shared/components/Streams'
-import LedgerMetrics from './LedgerMetrics'
-import Ledgers from './Ledgers'
-import { Ledger, ValidatorResponse } from './types'
+import { LedgerMetrics } from './LedgerMetrics'
+import { Ledgers } from './Ledgers'
import { useAnalytics } from '../shared/analytics'
-import NetworkContext from '../shared/NetworkContext'
-import { useIsOnline } from '../shared/SocketContext'
-import { useLanguage } from '../shared/hooks'
+import { TooltipProvider } from '../shared/components/Tooltip'
+import { SelectedValidatorProvider } from './useSelectedValidator'
+import { StreamsProvider } from '../shared/components/Streams/StreamsProvider'
+import { VHSValidatorsProvider } from '../shared/components/VHSValidators/VHSValidatorsProvider'
-const FETCH_INTERVAL_MILLIS = 5 * 60 * 1000
-
-const LedgersPage = () => {
+export const LedgersPage = () => {
const { trackScreenLoaded } = useAnalytics()
- const [validators, setValidators] = useState<
- Record
- >({})
- const [ledgers, setLedgers] = useState([])
const [paused, setPaused] = useState(false)
- const [selected, setSelected] = useState(null)
- const [metrics, setMetrics] = useState(undefined)
- const [unlCount, setUnlCount] = useState(undefined)
- const { isOnline } = useIsOnline()
const { t } = useTranslation()
- const network = useContext(NetworkContext)
- const language = useLanguage()
useEffect(() => {
trackScreenLoaded()
@@ -38,73 +21,23 @@ const LedgersPage = () => {
}
}, [trackScreenLoaded])
- const fetchValidators = () => {
- const url = `${process.env.VITE_DATA_URL}/validators/${network}`
-
- return axios
- .get(url)
- .then((resp) => resp.data.validators)
- .then((validatorResponse) => {
- const newValidators: Record = {}
- let newUnlCount = 0
-
- validatorResponse.forEach((v: ValidatorResponse) => {
- if (v.unl === process.env.VITE_VALIDATOR) {
- newUnlCount += 1
- }
- newValidators[v.signing_key] = v
- })
-
- setValidators(newValidators)
- setUnlCount(newUnlCount)
- return true
- })
- .catch((e) => Log.error(e))
- }
-
- useQuery(['fetchValidatorData'], async () => fetchValidators(), {
- refetchInterval: (returnedData, _) =>
- returnedData == null
- ? FETCH_INTERVAL_ERROR_MILLIS
- : FETCH_INTERVAL_MILLIS,
- refetchOnMount: true,
- enabled: !!network,
- })
-
- const updateSelected = (pubkey: string) => {
- setSelected(selected === pubkey ? null : pubkey)
- }
-
const pause = () => setPaused(!paused)
return (
- {isOnline && (
-
- )}
- pause()}
- paused={paused}
- />
-
+
+
+
+
+ pause()} paused={paused} />
+
+
+
+
+
+
+
)
}
-
-export default LedgersPage
diff --git a/src/containers/Ledgers/test/LedgersPage.test.js b/src/containers/Ledgers/test/LedgersPage.test.js
index 2b5f474f4..78a5a12d5 100644
--- a/src/containers/Ledgers/test/LedgersPage.test.js
+++ b/src/containers/Ledgers/test/LedgersPage.test.js
@@ -5,7 +5,7 @@ import configureMockStore from 'redux-mock-store'
import thunk from 'redux-thunk'
import { Provider } from 'react-redux'
import i18n from '../../../i18n/testConfig'
-import Ledgers from '../index'
+import { LedgersPage } from '../index'
import { initialState } from '../../../rootReducer'
import SocketContext from '../../shared/SocketContext'
import NetworkContext from '../../shared/NetworkContext'
@@ -15,6 +15,7 @@ import ledgerMessage from './mock/ledger.json'
import validationMessage from './mock/validation.json'
import rippledResponses from './mock/rippled.json'
import { QuickHarness } from '../../test/utils'
+import { SelectedValidatorProvider } from '../useSelectedValidator'
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
@@ -86,13 +87,15 @@ describe('Ledgers Page container', () => {
return mount(
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
,
)
}
diff --git a/src/containers/Ledgers/useSelectedValidator.tsx b/src/containers/Ledgers/useSelectedValidator.tsx
new file mode 100644
index 000000000..da3b31ab3
--- /dev/null
+++ b/src/containers/Ledgers/useSelectedValidator.tsx
@@ -0,0 +1,42 @@
+import {
+ createContext,
+ Dispatch,
+ FC,
+ SetStateAction,
+ useContext,
+ useMemo,
+ useState,
+} from 'react'
+
+export interface SelectedValidatorContextType {
+ selectedValidator?: string
+ setSelectedValidator: Dispatch>
+}
+
+export const SelectedValidatorContext =
+ createContext({
+ selectedValidator: undefined,
+ setSelectedValidator: (validator: SetStateAction) =>
+ validator,
+ })
+
+export const SelectedValidatorProvider: FC = ({ children }) => {
+ const [selectedValidator, setSelectedValidator] = useState()
+
+ const selectedValidatorValues = useMemo(
+ () => ({
+ selectedValidator,
+ setSelectedValidator,
+ }),
+ [selectedValidator],
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const useSelectedValidator = (): SelectedValidatorContextType =>
+ useContext(SelectedValidatorContext)
diff --git a/src/containers/NFT/NFTHeader/NFTHeader.tsx b/src/containers/NFT/NFTHeader/NFTHeader.tsx
index 5d59d5549..1a46f7ac6 100644
--- a/src/containers/NFT/NFTHeader/NFTHeader.tsx
+++ b/src/containers/NFT/NFTHeader/NFTHeader.tsx
@@ -4,7 +4,7 @@ import { useQuery } from 'react-query'
import { Loader } from '../../shared/components/Loader'
import './styles.scss'
import SocketContext from '../../shared/SocketContext'
-import Tooltip from '../../shared/components/Tooltip'
+import { Tooltip, TooltipInstance } from '../../shared/components/Tooltip'
import { getNFTInfo, getAccountInfo } from '../../../rippled/lib/rippled'
import { formatNFTInfo, formatAccountInfo } from '../../../rippled/lib/utils'
import { localizeDate, BAD_REQUEST, HASH_REGEX } from '../../shared/utils'
@@ -39,7 +39,7 @@ export const NFTHeader = (props: Props) => {
const { tokenId, setError } = props
const rippledSocket = useContext(SocketContext)
const { trackException } = useAnalytics()
- const [tooltip, setTooltip] = useState(null)
+ const [tooltip, setTooltip] = useState(undefined)
const { data, isFetching: loading } = useQuery(
['getNFTInfo', tokenId],
@@ -90,11 +90,16 @@ export const NFTHeader = (props: Props) => {
: undefined
const showTooltip = (event: any, d: any) => {
- setTooltip({ ...d, mode: 'nftId', x: event.pageX, y: event.pageY })
+ setTooltip({
+ data: d,
+ mode: 'nftId',
+ x: event.currentTarget.offsetLeft,
+ y: event.currentTarget.offsetTop,
+ })
}
const hideTooltip = () => {
- setTooltip(null)
+ setTooltip(undefined)
}
const renderHeaderContent = () => {
@@ -154,7 +159,7 @@ export const NFTHeader = (props: Props) => {
{loading ? : renderHeaderContent()}
-
+
)
}
diff --git a/src/containers/Network/Hexagons.jsx b/src/containers/Network/Hexagons.jsx
index a55c0f5de..68c5b2c14 100644
--- a/src/containers/Network/Hexagons.jsx
+++ b/src/containers/Network/Hexagons.jsx
@@ -4,9 +4,8 @@ import { useWindowSize } from 'usehooks-ts'
import { hexbin } from 'd3-hexbin'
import { Loader } from '../shared/components/Loader'
-import Tooltip from '../shared/components/Tooltip'
+import { Tooltip, useTooltip } from '../shared/components/Tooltip'
import './css/hexagons.scss'
-import { useLanguage } from '../shared/hooks'
const MAX_WIDTH = 1200
const getDimensions = (width) => ({
@@ -50,11 +49,10 @@ const prepareHexagons = (data, list, height, radius, prev = []) => {
}
export const Hexagons = ({ list, data }) => {
- const language = useLanguage()
const { width } = useWindowSize()
- const [tooltip, setToolip] = useState()
const [hexagons, setHexagons] = useState([])
const { width: gridWidth, height: gridHeight, radius } = getDimensions(width)
+ const { tooltip, showTooltip, hideTooltip } = useTooltip()
const bin = hexbin()
.extent([
[0, 0],
@@ -65,25 +63,17 @@ export const Hexagons = ({ list, data }) => {
useEffect(() => {
if (width > 0) {
setHexagons((prevHexagons) =>
- prepareHexagons(data, list, gridHeight, radius, prevHexagons),
+ prepareHexagons(
+ Object.values(data),
+ list,
+ gridHeight,
+ radius,
+ prevHexagons,
+ ),
)
}
}, [data, list, width, gridHeight, radius])
- const showTooltip = (event, tooltipData) => {
- setToolip({
- ...tooltipData,
- mode: 'validator',
- v: list[tooltipData.pubkey],
- x: event.nativeEvent.offsetX,
- y: event.nativeEvent.offsetY,
- })
- }
-
- const hideTooltip = () => {
- setToolip(null)
- }
-
const renderHexagon = (d, theHex) => {
const { cookie, pubkey, ledger_hash: ledgerHash } = d
const fill = `#${ledgerHash.substr(0, 6)}`
@@ -93,7 +83,9 @@ export const Hexagons = ({ list, data }) => {
key={`${pubkey}${cookie}${ledgerHash}`}
transform={`translate(${d.x},${d.y})`}
className="hexagon updated"
- onMouseOver={(e) => showTooltip(e, d)}
+ onMouseOver={(e) =>
+ showTooltip('validator', e, { ...d, v: list[d.pubkey] })
+ }
onFocus={() => {}}
onMouseLeave={hideTooltip}
>
@@ -126,12 +118,7 @@ export const Hexagons = ({ list, data }) => {
{hexagons?.length === 0 && }
-
+
)
}
-
-Hexagons.propTypes = {
- list: PropTypes.shape({}).isRequired,
- data: PropTypes.arrayOf(PropTypes.shape({})).isRequired, // eslint-disable-line
-}
diff --git a/src/containers/Network/Validators.tsx b/src/containers/Network/Validators.tsx
index 8d5c1fcd4..da9f62ebe 100644
--- a/src/containers/Network/Validators.tsx
+++ b/src/containers/Network/Validators.tsx
@@ -1,124 +1,92 @@
-import { useContext, useState } from 'react'
-import axios from 'axios'
+import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
-import { useQuery } from 'react-query'
import NetworkTabs from './NetworkTabs'
-import Streams from '../shared/components/Streams'
import ValidatorsTable from './ValidatorsTable'
-import Log from '../shared/log'
-import {
- localizeNumber,
- FETCH_INTERVAL_MILLIS,
- FETCH_INTERVAL_ERROR_MILLIS,
-} from '../shared/utils'
+import { localizeNumber } from '../shared/utils'
import { useLanguage } from '../shared/hooks'
import { Hexagons } from './Hexagons'
-import { StreamValidator, ValidatorResponse } from '../shared/vhsTypes'
-import NetworkContext from '../shared/NetworkContext'
+import { StreamValidator } from '../shared/vhsTypes'
+import { TooltipProvider } from '../shared/components/Tooltip'
+import { useStreams } from '../shared/components/Streams/StreamsContext'
+import { useVHSValidators } from '../shared/components/VHSValidators/VHSValidatorsContext'
export const Validators = () => {
const language = useLanguage()
const { t } = useTranslation()
- const [vList, setVList] = useState>({})
- const [validations, setValidations] = useState([])
- const [metrics, setMetrics] = useState({})
- const [unlCount, setUnlCount] = useState(0)
- const network = useContext(NetworkContext)
+ const { validators: validatorsFromValidations, metrics } = useStreams()
+ const { validators: validatorsFromVHS, unl } = useVHSValidators()
- useQuery(['fetchValidatorsData'], () => fetchData(), {
- refetchInterval: (returnedData, _) =>
- returnedData == null
- ? FETCH_INTERVAL_ERROR_MILLIS
- : FETCH_INTERVAL_MILLIS,
- refetchOnMount: true,
- enabled: process.env.VITE_ENVIRONMENT !== 'custom' || !!network,
- })
-
- function mergeLatest(
- validators: Record,
- live: Record,
- ): Record {
+ const merged = useMemo(() => {
+ if (
+ !validatorsFromVHS ||
+ !(
+ validatorsFromValidations &&
+ Object.keys(validatorsFromValidations).length
+ )
+ ) {
+ return
+ }
const updated: Record = {}
- const keys = new Set(Object.keys(validators).concat(Object.keys(live)))
+ const keys = new Set(
+ Object.keys(validatorsFromVHS).concat(
+ Object.keys(validatorsFromValidations),
+ ),
+ )
keys.forEach((d: string) => {
- const newData: StreamValidator = validators[d] || live[d]
- if (newData.ledger_index == null && live[d] && live[d].ledger_index) {
+ const newData: StreamValidator =
+ validatorsFromVHS[d] || validatorsFromValidations[d]
+ if (
+ newData.ledger_index == null &&
+ validatorsFromValidations[d] &&
+ validatorsFromValidations[d].ledger_index
+ ) {
// VHS uses `current_index` instead of `ledger_index`
// If `ledger_index` isn't defined, then we're still using the VHS data,
// instead of the Streams data
- newData.ledger_index = live[d].ledger_index
- newData.ledger_hash = live[d].ledger_hash
+ newData.ledger_index = validatorsFromValidations[d].ledger_index
+ }
+ if (newData.current_index == null) {
+ newData.signing_key = validatorsFromValidations[d].pubkey
+ }
+ // latest hash and time comes from the validations stream
+ if (validatorsFromValidations[d]) {
+ newData.time = validatorsFromValidations[d].time
+ newData.ledger_hash = validatorsFromValidations[d].ledger_hash
}
+
updated[d] = newData
})
- return updated
- }
-
- function fetchData() {
- const url = `${process.env.VITE_DATA_URL}/validators/${network}`
+ return Object.values(updated)
+ }, [validatorsFromVHS, validatorsFromValidations])
- return axios
- .get(url)
- .then((resp) => resp.data.validators)
- .then((validators) => {
- const newValidatorList: Record = {}
- validators.forEach((v: ValidatorResponse) => {
- newValidatorList[v.signing_key] = v
- })
-
- setVList(() => mergeLatest(newValidatorList, vList))
- setUnlCount(validators.filter((d: any) => Boolean(d.unl)).length)
- return true // indicating success in getting the data
- })
- .catch((e) => Log.error(e))
- }
-
- const updateValidators = (newValidations: StreamValidator[]) => {
- // @ts-ignore - Work around type assignment for complex validation data types
- setValidations(newValidations)
- setVList((value) => {
- const newValidatorsList: Record = { ...value }
- newValidations.forEach((validation: any) => {
- newValidatorsList[validation.pubkey] = {
- ...value[validation.pubkey],
- signing_key: validation.pubkey,
- ledger_index: validation.ledger_index,
- ledger_hash: validation.ledger_hash,
- }
- })
- return mergeLatest(newValidatorsList, value)
- })
- }
+ const validatorCount = useMemo(
+ () => merged && Object.keys(merged).length,
+ [merged],
+ )
- const validatorCount = Object.keys(vList).length
return (
- {network && (
-
- )}
{
// @ts-ignore - Work around for complex type assignment issues
-
+
+
+
}
{t('validators_found')}:
{localizeNumber(validatorCount, language)}
- {unlCount !== 0 && (
+ {unl?.length !== 0 && (
{' '}
- ({t('unl')}: {unlCount})
+ ({t('unl')}: {unl?.length})
)}
-
+
)
diff --git a/src/containers/Network/index.tsx b/src/containers/Network/index.tsx
index 70888bc9d..dace43f78 100644
--- a/src/containers/Network/index.tsx
+++ b/src/containers/Network/index.tsx
@@ -10,6 +10,24 @@ import { UpgradeStatus } from './UpgradeStatus'
import { Nodes } from './Nodes'
import NoMatch from '../NoMatch'
import './css/style.scss'
+import { StreamsProvider } from '../shared/components/Streams/StreamsProvider'
+import { VHSValidatorsProvider } from '../shared/components/VHSValidators/VHSValidatorsProvider'
+
+export const ValidatorsPage = () => (
+
+
+
+
+
+)
+
+export const UpgradeStatusPage = () => (
+
+
+
+
+
+)
export const Network = () => {
const { trackScreenLoaded } = useAnalytics()
@@ -32,8 +50,8 @@ export const Network = () => {
}
const Body = {
- 'upgrade-status': UpgradeStatus,
- validators: Validators,
+ 'upgrade-status': UpgradeStatusPage,
+ validators: ValidatorsPage,
nodes: Nodes,
}[tab]
return (
diff --git a/src/containers/PayStrings/PayStringHeader/index.tsx b/src/containers/PayStrings/PayStringHeader/index.tsx
index 9c85c55ed..b5bc4ea38 100644
--- a/src/containers/PayStrings/PayStringHeader/index.tsx
+++ b/src/containers/PayStrings/PayStringHeader/index.tsx
@@ -1,10 +1,8 @@
import { useState, useRef } from 'react'
-import { useTranslation } from 'react-i18next'
import PayStringLogomark from '../../shared/images/PayString_Logomark.png'
import QuestIcon from '../../shared/images/hover_question.svg'
-import Tooltip from '../../shared/components/Tooltip'
+import { Tooltip } from '../../shared/components/Tooltip/Tooltip'
import './styles.scss'
-import { useLanguage } from '../../shared/hooks'
export interface PayStringHeaderProps {
accountId: string
@@ -12,8 +10,6 @@ export interface PayStringHeaderProps {
export const PayStringHeader = ({ accountId }: PayStringHeaderProps) => {
const [showToolTip, setShowToolTip] = useState(false)
- const { t } = useTranslation()
- const language = useLanguage()
const questionRef = useRef(null)
return (
@@ -36,9 +32,7 @@ export const PayStringHeader = ({ accountId }: PayStringHeaderProps) => {
{showToolTip && questionRef.current && (
(
+ { hook, provider }: NameProps,
+ initialState?: T,
+) => {
+ const Context = createContext(initialState)
+
+ const useContextFactory = (): T => {
+ const context = useContext(Context)
+
+ if (context === undefined) {
+ throw new Error(`${hook} must be used in a child of ${provider}`)
+ }
+ return context
+ }
+
+ return [Context, useContextFactory] as const
+}
diff --git a/src/containers/shared/components/Streams/StreamsContext.tsx b/src/containers/shared/components/Streams/StreamsContext.tsx
new file mode 100644
index 000000000..c83ecfb0d
--- /dev/null
+++ b/src/containers/shared/components/Streams/StreamsContext.tsx
@@ -0,0 +1,13 @@
+import { contextFactory } from '../../../helpers/contextFactory'
+import { Ledger, Metrics } from './types'
+
+const [StreamsContext, useStreams] = contextFactory<{
+ metrics: Metrics
+ ledgers: Record
+ validators: Record
+}>({
+ hook: 'useStreams',
+ provider: 'StreamsProvider',
+})
+
+export { StreamsContext, useStreams }
diff --git a/src/containers/shared/components/Streams/StreamsProvider.tsx b/src/containers/shared/components/Streams/StreamsProvider.tsx
new file mode 100644
index 000000000..325c09ca5
--- /dev/null
+++ b/src/containers/shared/components/Streams/StreamsProvider.tsx
@@ -0,0 +1,322 @@
+import { FC, useContext, useEffect, useRef, useState } from 'react'
+import axios from 'axios'
+import type { LedgerStream, ValidationStream } from 'xrpl'
+import SocketContext from '../../SocketContext'
+import { getLedger } from '../../../../rippled/lib/rippled'
+import { convertRippleDate } from '../../../../rippled/lib/convertRippleDate'
+import { summarizeLedger } from '../../../../rippled/lib/summarizeLedger'
+import Log from '../../log'
+import { getNegativeUNL, getQuorum } from '../../../../rippled'
+import { XRP_BASE } from '../../transactionUtils'
+import { StreamsContext } from './StreamsContext'
+import { Ledger } from './types'
+
+const THROTTLE = 200
+
+// TODO: use useQuery
+const fetchMetrics = () =>
+ axios
+ .get('/api/v1/metrics')
+ .then((result) => result.data)
+ .catch((e) => Log.error(e))
+
+// TODO: use useQuery
+const fetchNegativeUNL = async (rippledSocket) =>
+ getNegativeUNL(rippledSocket)
+ .then((data) => {
+ if (data === undefined) throw new Error('undefined nUNL')
+
+ return data
+ })
+ .catch((e) => {
+ Log.error(e)
+ return []
+ })
+
+// TODO: use useQuery
+const fetchQuorum = async (rippledSocket) =>
+ getQuorum(rippledSocket)
+ .then((data) => {
+ if (data === undefined) throw new Error('undefined quorum')
+ return data
+ })
+ .catch((e) => Log.error(e))
+
+export const StreamsProvider: FC = ({ children }) => {
+ const [ledgers, setLedgers] = useState>([])
+ const ledgersRef = useRef>(ledgers)
+ const firstLedgerRef = useRef(0)
+ const [validators, setValidators] = useState>({})
+ const validationQueue = useRef([])
+ const socket = useContext(SocketContext)
+
+ // metrics
+ const [loadFee, setLoadFee] = useState('--')
+ const [txnSec, setTxnSec] = useState('--')
+ const [txnLedger, setTxnLedger] = useState('--')
+ const [ledgerInterval, setLedgerInterval] = useState('--')
+ const [avgFee, setAvgFee] = useState('--')
+ const [quorum, setQuorum] = useState('--')
+ const [nUnl, setNUnl] = useState([])
+
+ function addLedger(index: number | string) {
+ // Only add new ledgers that are newer than the last one added.
+ if (!firstLedgerRef.current) {
+ firstLedgerRef.current = Number(index)
+ }
+ if (firstLedgerRef.current > Number(index)) {
+ return
+ }
+
+ // TODO: only keep 20
+ if (!(index in ledgers)) {
+ setLedgers((previousLedgers) => ({
+ [index]: {
+ index: Number(index),
+ seen: Date.now(),
+ hashes: [],
+ transactions: undefined,
+ txCount: undefined,
+ },
+ ...previousLedgers,
+ }))
+ }
+ }
+
+ // TODO: use useQuery
+ function updateMetricsFromServer() {
+ fetchMetrics().then((serverMetrics) => {
+ setTxnSec(serverMetrics.txn_sec)
+ setTxnLedger(serverMetrics.txn_ledger)
+ setAvgFee(serverMetrics.avg_fee)
+ setLedgerInterval(serverMetrics.ledger_interval)
+ })
+ }
+
+ function updateMetrics() {
+ const ledgerChain = Object.values(ledgers)
+ .sort((a, b) => a.index - b.index)
+ .slice(-100)
+
+ let time = 0
+ let fees = 0
+ let timeCount = 0
+ let txCount = 0
+ let txWithFeesCount = 0
+ let ledgerCount = 0
+
+ ledgerChain.forEach((d, i) => {
+ const next = ledgerChain[i + 1]
+ if (next && next.seen && d.seen) {
+ time += next.seen - d.seen
+ timeCount += 1
+ }
+
+ if (d.totalFees) {
+ fees += d.totalFees
+ txWithFeesCount += d.txCount ?? 0
+ }
+ if (d.txCount) {
+ txCount += d.txCount
+ }
+ ledgerCount += 1
+ })
+
+ setTxnSec(time ? ((txCount / time) * 1000).toFixed(2) : '--')
+ setTxnLedger(ledgerCount ? (txCount / ledgerCount).toFixed(2) : '--')
+ setLedgerInterval(timeCount ? (time / timeCount / 1000).toFixed(3) : '--')
+ setAvgFee(txWithFeesCount ? (fees / txWithFeesCount).toPrecision(4) : '--')
+ }
+
+ function updateQuorum() {
+ fetchQuorum(socket).then((newQuorum) => {
+ setQuorum(newQuorum)
+ })
+ }
+
+ function updateNegativeUNL() {
+ fetchNegativeUNL(socket).then((newNUnl) => {
+ setNUnl(newNUnl)
+ })
+ }
+
+ function onLedger(data: LedgerStream) {
+ if (!ledgersRef.current[data.ledger_index]) {
+ // The ledger closed, but we did not have an existing entry likely because the page just loaded and its
+ // validations came in before we connected to the websocket.
+ addLedger(data.ledger_index)
+ }
+
+ if (process.env.VITE_ENVIRONMENT !== 'custom') {
+ // In custom mode we populate metrics from ledgers loaded into memory
+ updateMetricsFromServer()
+ } else {
+ // Make call to metrics tracked on the backend
+ updateMetrics()
+ }
+
+ setLoadFee((data.fee_base / XRP_BASE).toString())
+
+ // After each flag ledger we should check the UNL and quorum which are correlated and can only update every flag ledger.
+ if (data.ledger_index % 256 === 0 || quorum === '--') {
+ updateNegativeUNL()
+ updateQuorum()
+ }
+
+ // TODO: Set fields before getting full ledger info
+ // set validated hash
+ // set closetime
+
+ getLedger(socket, { ledger_hash: data.ledger_hash }).then(
+ populateFromLedgerResponse,
+ )
+ }
+
+ const populateFromLedgerResponse = (ledger: Promise) => {
+ const ledgerSummary = summarizeLedger(ledger)
+ setLedgers((previousLedgers) => {
+ const ledger = Object.assign(
+ previousLedgers[ledgerSummary.ledger_index] ?? {},
+ {
+ txCount: ledgerSummary.transactions.length,
+ closeTime: convertRippleDate(ledgerSummary.ledger_time),
+ transactions: ledgerSummary.transactions,
+ totalFees: ledgerSummary.total_fees, // fix type
+ },
+ )
+ const matchingHashIndex = ledger?.hashes.findIndex(
+ (hash) => hash.hash === ledgerSummary.ledger_hash,
+ )
+ const matchingHash = ledger?.hashes[matchingHashIndex]
+ if (matchingHash) {
+ matchingHash.validated = true
+ }
+ if (ledger && matchingHash) {
+ ledger.hashes[matchingHashIndex] = {
+ ...matchingHash,
+ }
+ }
+
+ // eslint-disable-next-line no-param-reassign
+ previousLedgers[ledgerSummary.ledger_index] = ledger
+
+ return { ...previousLedgers }
+ })
+ }
+
+ const onValidation = (data: ValidationStream) => {
+ if (!ledgersRef.current[Number(data.ledger_index)]) {
+ addLedger(data.ledger_index)
+ }
+ if (firstLedgerRef.current <= Number(data.ledger_index)) {
+ validationQueue.current.push(data)
+ }
+ }
+
+ // Process validations in chunks to make re-renders more manageable.
+ function processValidationQueue() {
+ setTimeout(processValidationQueue, THROTTLE)
+
+ if (validationQueue.current.length < 1) {
+ return
+ }
+ // copy the queue and clear it, so we aren't adding more while processing
+ const queue = [...validationQueue.current]
+ validationQueue.current = []
+ setLedgers((previousLedgers) => {
+ queue.forEach((validation) => {
+ const ledger = previousLedgers[Number(validation.ledger_index)]
+ const matchingHashIndex = ledger?.hashes.findIndex(
+ (hash) => hash.hash === validation.ledger_hash,
+ )
+ let matchingHash = ledger?.hashes[matchingHashIndex]
+ if (!matchingHash) {
+ matchingHash = {
+ hash: validation.ledger_hash,
+ validated: false,
+ validations: [],
+ time: convertRippleDate(validation.signing_time),
+ cookie: validation.cookie,
+ }
+ ledger.hashes.push(matchingHash)
+ }
+ matchingHash.validations = [...matchingHash.validations, validation]
+ if (ledger) {
+ ledger.hashes = [...(ledger?.hashes || [])]
+ ledger.hashes[matchingHashIndex] = {
+ ...matchingHash,
+ }
+ }
+ })
+ return { ...previousLedgers }
+ })
+ setValidators((previousValidators) => {
+ const newValidators: any = { ...previousValidators }
+ queue.forEach((validation) => {
+ newValidators[validation.validation_public_key] = {
+ ...previousValidators[validation.validation_public_key],
+ cookie: validation.cookie,
+ ledger_index: Number(validation.ledger_index),
+ ledger_hash: validation.ledger_hash,
+ pubkey: validation.validation_public_key,
+ partial: !validation.full,
+ time: convertRippleDate(validation.signing_time),
+ last: Date.now(),
+ }
+ })
+ return newValidators
+ })
+ }
+
+ useEffect(() => {
+ const interval = setTimeout(processValidationQueue, THROTTLE)
+
+ if (socket) {
+ socket.send({
+ command: 'subscribe',
+ streams: ['ledger', 'validations'],
+ })
+ socket.on('ledger', onLedger as any)
+ socket.on('validation', onValidation as any)
+
+ // Load in the most recent validated ledger to prevent the page from being empty until the next validations come in.
+ getLedger(socket, { ledger_index: 'validated' }).then(
+ populateFromLedgerResponse,
+ )
+ }
+
+ return () => {
+ clearTimeout(interval)
+ if (socket) {
+ socket.send({
+ command: 'unsubscribe',
+ streams: ['ledger', 'validations'],
+ })
+ socket.off('ledger', onLedger)
+ socket.off('validation', onValidation)
+ }
+ }
+ }, [socket])
+
+ useEffect(() => {
+ ledgersRef.current = ledgers
+ }, [ledgers])
+
+ const value = {
+ ledgers,
+ validators,
+ metrics: {
+ load_fee: loadFee,
+ txn_sec: txnSec,
+ txn_ledger: txnLedger,
+ ledger_interval: ledgerInterval,
+ avg_fee: avgFee,
+ quorum,
+ nUnl,
+ },
+ }
+
+ return (
+ {children}
+ )
+}
diff --git a/src/containers/shared/components/Streams/types.ts b/src/containers/shared/components/Streams/types.ts
new file mode 100644
index 000000000..f71634d41
--- /dev/null
+++ b/src/containers/shared/components/Streams/types.ts
@@ -0,0 +1,30 @@
+import { ValidationStream } from 'xrpl'
+
+export interface LedgerHash {
+ hash: string
+ validated: boolean
+ validations: ValidationStream[]
+ time: number
+ cookie?: string
+}
+
+export interface Ledger {
+ transactions: any[]
+ index: number
+ hashes: LedgerHash[]
+ seen: number
+ txCount?: number
+ closeTime: number
+ totalFees: number
+}
+
+export interface Metrics {
+ load_fee: string
+ txn_sec: string
+ txn_ledger: string
+ ledger_interval: string
+ avg_fee: string
+ quorum: string
+ nUnl: string[]
+ base_fee?: string
+}
diff --git a/src/containers/shared/components/Tooltip.jsx b/src/containers/shared/components/Tooltip.jsx
deleted file mode 100644
index 4d65d2209..000000000
--- a/src/containers/shared/components/Tooltip.jsx
+++ /dev/null
@@ -1,151 +0,0 @@
-import { Component } from 'react'
-import PropTypes from 'prop-types'
-import successIcon from '../images/success.png'
-import { localizeDate } from '../utils'
-import '../css/tooltip.scss'
-import PayStringToolTip from '../images/paystring_tooltip.svg'
-import { TxStatus } from './TxStatus'
-import { TxLabel } from './TxLabel'
-
-const PADDING_Y = 20
-const DATE_OPTIONS = {
- hour: 'numeric',
- minute: 'numeric',
- second: 'numeric',
- hour12: true,
-}
-
-class Tooltip extends Component {
- constructor(props) {
- super(props)
- this.state = {}
- }
-
- static getDerivedStateFromProps(nextProps) {
- return nextProps.data || { mode: null }
- }
-
- renderNegativeUnlTooltip() {
- const { nUnl } = this.state
- const list = nUnl.map((key) => {
- const short = key.substr(0, 8)
- return {`${short}...`}
- })
-
- return list
- }
-
- renderValidatorTooltip() {
- const { language } = this.props
- const { v = {}, pubkey, time } = this.state
- const key = v.master_key || pubkey
-
- return (
- <>
- {v.domain}
- {key}
- {localizeDate(time, language, DATE_OPTIONS)}
- {v.unl && (
-
- {v.unl}
-

-
- )}
- >
- )
- }
-
- renderTxTooltip() {
- const { type, result, account } = this.state
- return (
- <>
-
-
-
- {account}
- >
- )
- }
-
- renderMissingValidators() {
- const { missing } = this.state
- const { t } = this.props
- const list = missing.map((d) => (
-
- {d.domain || d.master_key}
-
- ))
-
- return (
- <>
- {t('missing')}:
- {list}
- >
- )
- }
-
- renderNFTId() {
- const { tokenId } = this.state
- return {tokenId}
- }
-
- renderPayStringToolTip() {
- const { t } = this.props
-
- return (
- <>
-
- {t('paystring_explainer_blurb')}
- >
- )
- }
-
- render() {
- const { mode, x, y } = this.state
- const style = { top: y + PADDING_Y, left: x }
- let content = null
- let className = 'tooltip'
- if (mode === 'validator') {
- content = this.renderValidatorTooltip()
- } else if (mode === 'tx') {
- content = this.renderTxTooltip()
- } else if (mode === 'nUnl') {
- content = this.renderNegativeUnlTooltip()
- } else if (mode === 'missing') {
- style.background = 'rgba(120,0,0,.9)'
- content = this.renderMissingValidators()
- } else if (mode === 'paystring') {
- className += ' paystring'
- content = this.renderPayStringToolTip()
- } else if (mode === 'nftId') {
- content = this.renderNFTId()
- }
-
- return content ? (
- this.setState({ mode: null })}
- onKeyUp={() => this.setState({ mode: null })}
- >
- {content}
-
- ) : null
- }
-}
-
-Tooltip.propTypes = {
- t: PropTypes.func,
- language: PropTypes.string,
- data: PropTypes.shape({}),
-}
-
-Tooltip.defaultProps = {
- t: (d) => d,
- language: undefined,
- data: null,
-}
-
-export default Tooltip
diff --git a/src/containers/shared/components/Tooltip/Tooltip.tsx b/src/containers/shared/components/Tooltip/Tooltip.tsx
new file mode 100644
index 000000000..340cda0db
--- /dev/null
+++ b/src/containers/shared/components/Tooltip/Tooltip.tsx
@@ -0,0 +1,123 @@
+import { CSSProperties } from 'react'
+import { useTranslation } from 'react-i18next'
+import successIcon from '../../images/success.png'
+import { localizeDate } from '../../utils'
+import '../../css/tooltip.scss'
+import PayStringToolTip from '../../images/paystring_tooltip.svg'
+import { TxStatus } from '../TxStatus'
+import { TxLabel } from '../TxLabel'
+import { useLanguage } from '../../hooks'
+import { convertRippleDate } from '../../../../rippled/lib/convertRippleDate'
+
+const PADDING_Y = 20
+const DATE_OPTIONS = {
+ hour: 'numeric',
+ minute: 'numeric',
+ second: 'numeric',
+ hour12: true,
+}
+
+export interface TooltipInstance {
+ data?: any
+ mode: string
+ x: number
+ y: number
+}
+
+export const Tooltip = ({ tooltip }: { tooltip?: TooltipInstance }) => {
+ const { t } = useTranslation()
+ const language = useLanguage()
+
+ if (!tooltip) {
+ // eslint-disable-next-line react/jsx-no-useless-fragment
+ return <>>
+ }
+
+ const { data } = tooltip
+
+ const renderNegativeUnlTooltip = () =>
+ data.nUnl.map((key) => {
+ const short = key.substr(0, 8)
+ return {`${short}...`}
+ })
+
+ const renderValidatorTooltip = () => {
+ // eslint-disable-next-line camelcase
+ const { v = {}, pubkey, signing_time } = data
+ const key = v.master_key || pubkey
+
+ return (
+ <>
+ {v.domain}
+ {key}
+
+ {localizeDate(
+ convertRippleDate(signing_time),
+ language,
+ DATE_OPTIONS,
+ )}
+
+ {v.unl && (
+
+ {v.unl}
+

+
+ )}
+ >
+ )
+ }
+
+ const renderTxTooltip = () => {
+ const { type, result, account } = data
+ return (
+ <>
+
+
+
+ {account}
+ >
+ )
+ }
+
+ const renderMissingValidators = () => (
+ <>
+ {t('missing')}:
+ {data.missing.map((d) => (
+
+ {d.domain || d.master_key}
+
+ ))}
+ >
+ )
+
+ const renderNFTId = () => {data.tokenId}
+
+ const renderPayStringToolTip = () => (
+ <>
+
+ {t('paystring_explainer_blurb')}
+ >
+ )
+
+ const { x, y, mode } = tooltip
+ const style: CSSProperties = { top: y + PADDING_Y, left: x }
+ const modeMap = {
+ validator: renderValidatorTooltip,
+ tx: renderTxTooltip,
+ nUnl: renderNegativeUnlTooltip,
+ missing: renderMissingValidators,
+ paystring: renderPayStringToolTip,
+ nftId: renderNFTId,
+ }
+
+ return modeMap[mode] ? (
+
+ {modeMap[mode]()}
+
+ ) : null
+}
diff --git a/src/containers/shared/components/Tooltip/index.ts b/src/containers/shared/components/Tooltip/index.ts
new file mode 100644
index 000000000..a7dae4c91
--- /dev/null
+++ b/src/containers/shared/components/Tooltip/index.ts
@@ -0,0 +1,2 @@
+export * from './Tooltip'
+export * from './useTooltip'
diff --git a/src/containers/shared/components/Tooltip/useTooltip.tsx b/src/containers/shared/components/Tooltip/useTooltip.tsx
new file mode 100644
index 000000000..a386564e2
--- /dev/null
+++ b/src/containers/shared/components/Tooltip/useTooltip.tsx
@@ -0,0 +1,69 @@
+import {
+ createContext,
+ Dispatch,
+ FC,
+ MouseEvent,
+ SetStateAction,
+ useContext,
+ useMemo,
+ useState,
+} from 'react'
+
+export interface TooltipContextType {
+ tooltip?: any
+ setTooltip: Dispatch>
+ hideTooltip: () => void
+ showTooltip: (
+ mode: string,
+ event: MouseEvent | MouseEvent,
+ data: any,
+ ) => void
+}
+
+export const TooltipContext = createContext({
+ tooltip: undefined,
+ setTooltip: (tt: SetStateAction) => tt,
+ hideTooltip: () => {},
+ showTooltip: () => {},
+})
+
+export const TooltipProvider: FC = ({ children }) => {
+ const [tooltip, setTooltip] = useState()
+ const hideTooltip = () => setTooltip(undefined)
+ const showTooltip = (
+ mode: string,
+ event: MouseEvent,
+ data: any,
+ ) => {
+ setTooltip({
+ data,
+ mode,
+ x:
+ event.currentTarget instanceof HTMLElement
+ ? event.currentTarget.offsetLeft
+ : event.nativeEvent.offsetX,
+ y:
+ event.currentTarget instanceof HTMLElement
+ ? event.currentTarget.offsetTop
+ : event.nativeEvent.offsetY,
+ })
+ }
+
+ const tooltipValues = useMemo(
+ () => ({
+ tooltip,
+ setTooltip,
+ hideTooltip,
+ showTooltip,
+ }),
+ [tooltip],
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const useTooltip = (): TooltipContextType => useContext(TooltipContext)
diff --git a/src/containers/shared/components/VHSValidators/VHSValidatorsContext.tsx b/src/containers/shared/components/VHSValidators/VHSValidatorsContext.tsx
new file mode 100644
index 000000000..28621beb3
--- /dev/null
+++ b/src/containers/shared/components/VHSValidators/VHSValidatorsContext.tsx
@@ -0,0 +1,10 @@
+import { contextFactory } from '../../../helpers/contextFactory'
+import { VHSValidatorsHookResult } from './types'
+
+const [VHSValidatorsContext, useVHSValidators] =
+ contextFactory({
+ hook: 'useVHSValidators',
+ provider: 'VHSValidatorsProvider',
+ })
+
+export { VHSValidatorsContext, useVHSValidators }
diff --git a/src/containers/shared/components/VHSValidators/VHSValidatorsProvider.tsx b/src/containers/shared/components/VHSValidators/VHSValidatorsProvider.tsx
new file mode 100644
index 000000000..1c9c00d03
--- /dev/null
+++ b/src/containers/shared/components/VHSValidators/VHSValidatorsProvider.tsx
@@ -0,0 +1,62 @@
+import { FC, useContext } from 'react'
+import { useQuery } from 'react-query'
+import axios from 'axios'
+import { VHSValidatorsContext } from './VHSValidatorsContext'
+import { FETCH_INTERVAL_ERROR_MILLIS, FETCH_INTERVAL_MILLIS } from '../../utils'
+import { ValidatorResponse } from '../../vhsTypes'
+import Log from '../../log'
+import NetworkContext from '../../NetworkContext'
+import { VHSValidatorsHookResult } from './types'
+
+export const VHSValidatorsProvider: FC = ({ children }) => {
+ const network = useContext(NetworkContext)
+
+ const { data: value } = useQuery(
+ ['fetchValidatorsData'],
+ () => fetchVHSData(),
+ {
+ refetchInterval: 0,
+ refetchOnMount: true,
+ enabled: process.env.VITE_ENVIRONMENT !== 'custom' || !!network,
+ initialData: {
+ unl: undefined,
+ validators: undefined,
+ },
+ },
+ )
+
+ function fetchVHSData(): Promise {
+ const url = `${process.env.VITE_DATA_URL}/validators/${network}`
+
+ return axios
+ .get(url)
+ .then((resp) => resp.data.validators)
+ .then((validators) => {
+ const newValidatorList: Record = {}
+ validators.forEach((v: ValidatorResponse) => {
+ newValidatorList[v.signing_key] = v
+ })
+
+ return {
+ validators: newValidatorList,
+ unl: validators
+ .filter((d: ValidatorResponse) => Boolean(d.unl))
+ .map((d: ValidatorResponse) => d.signing_key),
+ }
+ })
+ .catch((e) => {
+ Log.error(e)
+
+ return {
+ unl: undefined,
+ validators: undefined,
+ }
+ })
+ }
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/containers/shared/components/VHSValidators/types.ts b/src/containers/shared/components/VHSValidators/types.ts
new file mode 100644
index 000000000..d6f231144
--- /dev/null
+++ b/src/containers/shared/components/VHSValidators/types.ts
@@ -0,0 +1,6 @@
+import { ValidatorResponse } from '../../vhsTypes'
+
+export interface VHSValidatorsHookResult {
+ validators?: Record
+ unl?: string[]
+}
diff --git a/src/containers/shared/css/tooltip.scss b/src/containers/shared/css/tooltip.scss
index 032bea9e8..a1c6d5e4b 100644
--- a/src/containers/shared/css/tooltip.scss
+++ b/src/containers/shared/css/tooltip.scss
@@ -13,7 +13,7 @@
img {
height: 16px;
- margin: 0px 5px;
+ margin: 0 5px;
vertical-align: middle;
}
@@ -26,7 +26,7 @@
max-width: 100px;
color: $black-10;
font-size: 10px;
- letter-spacing: 0px;
+ letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -49,7 +49,7 @@
font-size: 10px;
}
- &.paystring {
+ &.tooltip-paystring {
width: 274px;
font-size: 12px;
@@ -58,4 +58,8 @@
margin-bottom: 8px;
}
}
+
+ &.tooltip-missing {
+ background: rgb(120 0 0 / 90%);
+ }
}
diff --git a/src/containers/shared/hooks/usePreviousWithPausing.tsx b/src/containers/shared/hooks/usePreviousWithPausing.tsx
new file mode 100644
index 000000000..b912a2775
--- /dev/null
+++ b/src/containers/shared/hooks/usePreviousWithPausing.tsx
@@ -0,0 +1,21 @@
+import { useLayoutEffect, useState } from 'react'
+
+/**
+ * A hook that prevents a value from being updated until it is unpaused.
+ * @param value - The value that is applied when paused is false
+ * @param paused - Should the value be updated
+ */
+export function usePreviousWithPausing(
+ value: T,
+ paused: boolean,
+): T | undefined {
+ const [val, setVal] = useState()
+
+ useLayoutEffect(() => {
+ if (!paused) {
+ setVal(value)
+ }
+ }, [paused, value]) // this code will run when the value of 'value' changes
+
+ return val // in the end, return the current ref value.
+}