Files
xrpl-dev-portal/@theme/components/Amendments.tsx
Mayukha Vadari 0b247281eb run prettier
2026-01-12 14:19:31 -05:00

431 lines
13 KiB
TypeScript

import * as React from 'react'
import { Link } from '@redocly/theme/components/Link/Link'
import { useThemeHooks } from '@redocly/theme/core/hooks'
type Amendment = {
name: string
rippled_version: string
tx_hash?: string
consensus?: string
date?: string
id: string
eta?: string
}
type AmendmentsResponse = {
amendments: Amendment[]
}
type AmendmentsCachePayload = {
timestamp: number
amendments: Amendment[]
}
// API data caching
const amendmentsEndpoint = 'https://vhs.prod.ripplex.io/v1/network/amendments/vote/main/'
const amendmentsCacheKey = 'xrpl.amendments.mainnet.cache'
const amendmentsTTL = 15 * 60 * 1000 // 15 minutes in milliseconds
function readAmendmentsCache(): Amendment[] | null {
if (typeof window === 'undefined') return null
try {
const raw = window.localStorage.getItem(amendmentsCacheKey)
if (!raw) return null
const parsed: AmendmentsCachePayload = JSON.parse(raw)
if (!parsed || !Array.isArray(parsed.amendments)) return null
const fresh = Date.now() - parsed.timestamp < amendmentsTTL
return fresh ? parsed.amendments : null
} catch {
return null
}
}
function writeAmendmentsCache(amendments: Amendment[]) {
if (typeof window === 'undefined') return
try {
const payload: AmendmentsCachePayload = { timestamp: Date.now(), amendments }
window.localStorage.setItem(amendmentsCacheKey, JSON.stringify(payload))
} catch {
// Ignore quota or serialization errors
}
}
// Sort amendments table by status, then chronologically, then alphabetically
function sortAmendments(list: Amendment[]): Amendment[] {
const getStatusPriority = (amendment: Amendment): number => {
if (amendment.consensus) return 0 // Open for Voting
if (amendment.eta) return 1 // Expected
if (amendment.tx_hash) return 2 // Enabled
return 3 // Fallback
}
const getChronoKey = (amendment: Amendment): number => {
const raw = amendment.date || amendment.eta
if (!raw) return 0
const t = Date.parse(raw)
return isNaN(t) ? 0 : t
}
return [...list].sort((a, b) => {
// 1. Status
const statusDiff = getStatusPriority(a) - getStatusPriority(b)
if (statusDiff !== 0) return statusDiff
// 2. Chronological (most recent first)
const chronoA = getChronoKey(a)
const chronoB = getChronoKey(b)
if (chronoA !== chronoB) return chronoB - chronoA
// 3. Alphabetical
return a.name.localeCompare(b.name)
})
}
// Generate amendments table with live mainnet data
export function AmendmentsTable() {
const [amendments, setAmendments] = React.useState<Amendment[]>([])
const [loading, setLoading] = React.useState(true)
const [error, setError] = React.useState<string | null>(null)
const { useTranslate } = useThemeHooks()
const { translate } = useTranslate()
React.useEffect(() => {
const fetchAmendments = async () => {
try {
setLoading(true)
// 1. Try cache first
const cached = readAmendmentsCache()
if (cached) {
setAmendments(sortAmendments(cached))
return // Use current cache (fresh)
}
// 2. Fetch new data if cache is stale
const response = await fetch(amendmentsEndpoint)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data: AmendmentsResponse = await response.json()
writeAmendmentsCache(data.amendments)
setAmendments(sortAmendments(data.amendments))
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch amendments')
} finally {
setLoading(false)
}
}
fetchAmendments()
}, [])
// Fancy schmancy loading icon
if (loading) {
return (
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '2rem' }}>
<div className="spinner-border text-primary" role="status">
<span className="sr-only">{translate('amendment.loading', 'Loading amendments...')}</span>
</div>
<div style={{ marginTop: '1rem' }}>{translate('amendment.loading', 'Loading amendments...')}</div>
</div>
)
}
if (error) {
return (
<div className="alert alert-danger" role="alert">
<strong>{translate('amendment.error', 'Error loading amendments:')}:</strong> {error}
</div>
)
}
// Render amendment table
return (
<div className="md-table-wrapper">
<table className="md">
<thead>
<tr>
<th align="left" data-label="Name">
{translate('amendment.table.name', 'Name')}
</th>
<th align="left" data-label="Introduced">
{translate('amendment.table.introduced', 'Introduced')}
</th>
<th align="left" data-label="Status">
{translate('amendment.table.status', 'Status')}
</th>
</tr>
</thead>
<tbody>
{amendments.map((amendment) => (
<tr key={amendment.id}>
<td align="left">
<Link to={`#${amendment.name.toLowerCase()}`}>{amendment.name}</Link>
</td>
<td align="left">{amendment.rippled_version}</td>
<td align="left">
<AmendmentBadge amendment={amendment} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function AmendmentBadge(props: { amendment: Amendment }) {
const [status, setStatus] = React.useState<string>('Loading...')
const [href, setHref] = React.useState<string | undefined>(undefined)
const [color, setColor] = React.useState<string>('blue')
const { useTranslate } = useThemeHooks()
const { translate } = useTranslate()
const enabledLabel = translate('amendment.status.enabled', 'Enabled')
const votingLabel = translate('amendment.status.openForVoting', 'Open for Voting')
const etaLabel = translate('amendment.status.eta', 'Expected')
React.useEffect(() => {
const amendment = props.amendment
// Check if amendment is enabled (has tx_hash)
if (amendment.tx_hash) {
const enabledDate = new Date(amendment.date).toISOString().split('T')[0]
setStatus(`${enabledLabel}: ${enabledDate}`)
setColor('green')
setHref(`https://livenet.xrpl.org/transactions/${amendment.tx_hash}`)
}
// Check if expected activation is provided (has eta field)
else if (amendment.eta) {
let etaDate = new Date(amendment.eta).toISOString().split('T')[0]
setStatus(`${etaLabel}: ${etaDate}`)
setColor('blue')
setHref(undefined)
}
// Check if amendment is currently being voted on (has consensus field)
else if (amendment.consensus) {
setStatus(`${votingLabel}: ${amendment.consensus}`)
setColor('80d0e0')
setHref(undefined) // No link for voting amendments
}
}, [props.amendment, enabledLabel, etaLabel, votingLabel])
// Split the status at the colon to create two-color badge
const parts = status.split(':')
const label = shieldsIoEscape(parts[0])
const message = shieldsIoEscape(parts.slice(1).join(':'))
const badgeUrl = `https://img.shields.io/badge/${label}-${message}-${color}`
if (href) {
return (
<Link to={href} target="_blank">
<img src={badgeUrl} alt={status} className="shield" />
</Link>
)
}
return <img src={badgeUrl} alt={status} className="shield" />
}
export function AmendmentDisclaimer(props: { name: string; compact: boolean; mode: string }) {
const [amendmentStatus, setStatus] = React.useState<Amendment | null>(null)
const [loading, setLoading] = React.useState(true)
const [error, setError] = React.useState<string | null>(null)
const { useTranslate } = useThemeHooks()
const { translate } = useTranslate()
const link = () => (
<Link to={`/resources/known-amendments#${props.name.toLowerCase()}`}>
{props.name}
{props.compact ? '' : ' amendment'}
</Link>
)
React.useEffect(() => {
const loadAmendment = async () => {
try {
setLoading(true)
// 1. Try cache first
const cached = readAmendmentsCache()
if (cached) {
const found = cached.find((a) => a.name === props.name)
if (found) {
setStatus(found)
return // amendment successfully found in cache
}
}
// 2. New API request for stale/missing cache.
// Also catches edge case of new amendment appearing
// on mainnet within cache TTL window.
const response = await fetch(amendmentsEndpoint)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data: AmendmentsResponse = await response.json()
writeAmendmentsCache(data.amendments)
const found = data.amendments.find((a) => a.name === props.name)
if (!found) {
throw new Error(`Couldn't find ${props.name} amendment in status table.`)
}
setStatus(found)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch amendments')
} finally {
setLoading(false)
}
}
loadAmendment()
}, [props.name])
if (loading) {
return (
<p>
<em>
{translate('component.amendment-status.requires.1', 'Requires the ')}
{link()}
{translate('component.amendment-status.requires.2', '.')}{' '}
<span className="spinner-border text-primary" role="status">
<span className="sr-only">{translate('amendment.loading_status', 'Loading...')}</span>
</span>
</em>
</p>
)
}
if (error) {
return (
<p>
<em>
{translate('component.amendment-status.requires.1', 'Requires the ')}
{link()}
{translate('component.amendment-status.requires.2', '.')}{' '}
<span className="alert alert-danger" style={{ display: 'block' }}>
<strong>{translate('amendment.error_status', 'Error loading amendment status')}:</strong> {error}
</span>
</em>
</p>
)
}
if (props.compact) {
return (
<>
{link()} <AmendmentBadge amendment={amendmentStatus} />
</>
)
}
if (props.mode === 'updated') {
return (
<p>
<em>
(
{amendmentStatus.date ? (
<>
{translate('component.amendment-status.updated.1', 'Updated by the ')}
{link()}
{translate('component.amendment-status.updated.2', '.')} <AmendmentBadge amendment={amendmentStatus} />
</>
) : (
<>
{translate('component.amendment-status.updates.1', 'The ')}
{link()}
{translate('component.amendment-status.updates.2', ' updates this.')} <AmendmentBadge amendment={amendmentStatus} />
</>
)}
)
</em>
</p>
)
}
return (
<p>
<em>
(
{amendmentStatus.date ? (
<>
{translate('component.amendment-status.added.1', 'Added by the ')}
{link()}
{translate('component.amendment-status.added.2', '.')} <AmendmentBadge amendment={amendmentStatus} />
</>
) : (
<>
{translate('component.amendment-status.requires.1', 'Requires the ')}
{link()}
{translate('component.amendment-status.requires.2', '.')} <AmendmentBadge amendment={amendmentStatus} />
</>
)}
)
</em>
</p>
)
}
function shieldsIoEscape(s: string) {
return s.trim().replace(/-/g, '--').replace(/_/g, '__').replace(/%/g, '%25')
}
export function Badge(props: { children: React.ReactNode; color: string; href: string }) {
const DEFAULT_COLORS = {
'open for voting': '80d0e0',
: '80d0e0', // ja: open for voting
expected: 'blue',
: 'blue', // ja: expected
enabled: 'green',
: 'green', // ja: enabled
obsolete: 'red',
'removed in': 'red',
: 'red', // ja: removed in
: 'red', // ja: obsolete
: 'red', // ja: withdrawn/removed/vetoed
'new in': 'blue',
: 'blue', // ja: new in
'updated in': 'blue',
: 'blue', // ja: updated in
'in development': 'lightgrey',
: 'lightgrey', // ja: in development
}
let childstrings = ''
React.Children.forEach(props.children, (child, index) => {
if (typeof child == 'string') {
childstrings += child
}
})
const parts = childstrings.split(':')
const left: string = shieldsIoEscape(parts[0])
const right: string = shieldsIoEscape(parts.slice(1).join(':'))
let color = props.color
if (!color) {
if (DEFAULT_COLORS.hasOwnProperty(left.toLowerCase())) {
color = DEFAULT_COLORS[left.toLowerCase()]
} else {
color = 'lightgrey'
}
}
let badge_url = `https://img.shields.io/badge/${left}-${right}-${color}.svg`
if (props.href) {
return (
<Link to={props.href}>
<img src={badge_url} alt={childstrings} className="shield" />
</Link>
)
} else {
return <img src={badge_url} alt={childstrings} className="shield" />
}
}