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([]) const [loading, setLoading] = React.useState(true) const [error, setError] = React.useState(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 (
{translate('amendment.loading', 'Loading amendments...')}
{translate('amendment.loading', 'Loading amendments...')}
) } if (error) { return (
{translate('amendment.error', 'Error loading amendments:')}: {error}
) } // Render amendment table return (
{amendments.map((amendment) => ( ))}
{translate('amendment.table.name', 'Name')} {translate('amendment.table.introduced', 'Introduced')} {translate('amendment.table.status', 'Status')}
{amendment.name} {amendment.rippled_version}
) } function AmendmentBadge(props: { amendment: Amendment }) { const [status, setStatus] = React.useState('Loading...') const [href, setHref] = React.useState(undefined) const [color, setColor] = React.useState('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 ( {status} ) } return {status} } export function AmendmentDisclaimer(props: { name: string; compact: boolean; mode: string }) { const [amendmentStatus, setStatus] = React.useState(null) const [loading, setLoading] = React.useState(true) const [error, setError] = React.useState(null) const { useTranslate } = useThemeHooks() const { translate } = useTranslate() const link = () => ( {props.name} {props.compact ? '' : ' amendment'} ) 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 (

{translate('component.amendment-status.requires.1', 'Requires the ')} {link()} {translate('component.amendment-status.requires.2', '.')}{' '} {translate('amendment.loading_status', 'Loading...')}

) } if (error) { return (

{translate('component.amendment-status.requires.1', 'Requires the ')} {link()} {translate('component.amendment-status.requires.2', '.')}{' '} {translate('amendment.error_status', 'Error loading amendment status')}: {error}

) } if (props.compact) { return ( <> {link()} ) } if (props.mode === 'updated') { return (

( {amendmentStatus.date ? ( <> {translate('component.amendment-status.updated.1', 'Updated by the ')} {link()} {translate('component.amendment-status.updated.2', '.')} ) : ( <> {translate('component.amendment-status.updates.1', 'The ')} {link()} {translate('component.amendment-status.updates.2', ' updates this.')} )} )

) } return (

( {amendmentStatus.date ? ( <> {translate('component.amendment-status.added.1', 'Added by the ')} {link()} {translate('component.amendment-status.added.2', '.')} ) : ( <> {translate('component.amendment-status.requires.1', 'Requires the ')} {link()} {translate('component.amendment-status.requires.2', '.')} )} )

) } 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 ( {childstrings} ) } else { return {childstrings} } }