packages/react-components/src/DemocracyLocks.tsx
// Copyright 2017-2024 @polkadot/react-components authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveDemocracyLock } from '@polkadot/api-derive/types';
import type { Balance } from '@polkadot/types/interfaces';
import type { BN } from '@polkadot/util';
import React, { useEffect, useState } from 'react';
import { useBestNumber } from '@polkadot/react-hooks';
import { BlockToTime, FormatBalance } from '@polkadot/react-query';
import { BN_ZERO, bnMax, formatBalance, formatNumber } from '@polkadot/util';
import Icon from './Icon.js';
import { styled } from './styled.js';
import Tooltip from './Tooltip.js';
import { useTranslation } from './translate.js';
interface Props {
className?: string;
value?: Partial<DeriveDemocracyLock>[];
}
interface Entry {
details: React.ReactNode;
headers: React.ReactNode[];
isCountdown: boolean;
isFinished: boolean;
}
interface State {
maxBalance: BN;
sorted: Entry[];
}
let id = 0;
// group by header & details
// - all unlockable together
// - all ongoing together
// - unlocks are displayed individually
function groupLocks (t: (key: string, options?: { replace: Record<string, unknown> }) => string, bestNumber: BN, locks: Partial<DeriveDemocracyLock>[] = []): State {
return {
maxBalance: bnMax(...locks.map(({ balance }) => balance).filter((b): b is Balance => !!b)),
sorted: locks
.map((info): [Partial<DeriveDemocracyLock>, BN] => [info, info.unlockAt && info.unlockAt.gt(bestNumber) ? info.unlockAt.sub(bestNumber) : BN_ZERO])
.sort((a, b) => (a[0].referendumId || BN_ZERO).cmp(b[0].referendumId || BN_ZERO))
.sort((a, b) => a[1].cmp(b[1]))
.sort((a, b) => a[0].isFinished === b[0].isFinished ? 0 : (a[0].isFinished ? -1 : 1))
.reduce((sorted: Entry[], [{ balance, isDelegated, isFinished = false, referendumId, vote }, blocks]): Entry[] => {
const isCountdown = blocks.gt(BN_ZERO);
const header = referendumId && vote
? <div>#{referendumId.toString()} {formatBalance(balance, { forceUnit: '-' })} {vote.conviction?.toString()}{isDelegated && '/d'}</div>
: <div>{t('Prior locked voting')}</div>;
const prev = sorted.length ? sorted[sorted.length - 1] : null;
if (!prev || (isCountdown || (isFinished !== prev.isFinished))) {
sorted.push({
details: (
<div className='faded'>
{isCountdown
? (
<BlockToTime
label={`${t('{{blocks}} blocks', { replace: { blocks: formatNumber(blocks) } })}, `}
value={blocks}
/>
)
: isFinished
? t('lock expired')
: t('ongoing referendum')
}
</div>
),
headers: [header],
isCountdown,
isFinished
});
} else {
prev.headers.push(header);
}
return sorted;
}, [])
};
}
function DemocracyLocks ({ className = '', value }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const bestNumber = useBestNumber();
const [trigger] = useState(() => `${Date.now()}-democracy-locks-${++id}`);
const [{ maxBalance, sorted }, setState] = useState<State>({ maxBalance: BN_ZERO, sorted: [] });
useEffect((): void => {
bestNumber && setState((state): State => {
const newState = groupLocks(t, bestNumber, value);
// only update when the structure of new is different
// - it has a new overall breakdown with sections
// - one of the sections has a different number of headers
return state.sorted.length !== newState.sorted.length || state.sorted.some((s, i) => s.headers.length !== newState.sorted[i].headers.length)
? newState
: state;
});
}, [bestNumber, t, value]);
if (!sorted.length) {
return null;
}
return (
<StyledDiv className={className}>
<FormatBalance
labelPost={
<Icon
icon='clock'
tooltip={trigger}
/>
}
value={maxBalance}
/>
<Tooltip trigger={trigger}>
{sorted.map(({ details, headers }, index): React.ReactNode => (
<div
className='row'
key={index}
>
{headers.map((header, index) => (
<div key={index}>{header}</div>
))}
<div className='faded'>{details}</div>
</div>
))}
</Tooltip>
</StyledDiv>
);
}
const StyledDiv = styled.div`
white-space: nowrap;
.ui--FormatBalance {
display: inline-block;
}
`;
export default React.memo(DemocracyLocks);