mirror of
https://github.com/pezkuwichain/pezkuwi-apps.git
synced 2026-06-13 15:11:05 +00:00
feat: initial Pezkuwi Apps rebrand from polkadot-apps
Rebranded terminology: - Polkadot → Pezkuwi - Kusama → Dicle - Westend → Zagros - Rococo → PezkuwiChain - Substrate → Bizinikiwi - parachain → teyrchain Custom logos with Kurdistan brand colors (#e6007a → #86e62a): - bizinikiwi-hexagon.svg - sora-bizinikiwi.svg - hezscanner.svg - heztreasury.svg - pezkuwiscan.svg - pezkuwistats.svg - pezkuwiassembly.svg - pezkuwiholic.svg
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletReferenda, TrackDescription, TrackInfoExt } from '../../types.js';
|
||||
import type { VoteResultItem } from './types.js';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { MarkWarning, styled, Table } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../../translate.js';
|
||||
import { getTrackInfo } from '../../util.js';
|
||||
|
||||
interface Props {
|
||||
allowEmpty?: boolean;
|
||||
className?: string;
|
||||
palletReferenda: PalletReferenda;
|
||||
trackId: number;
|
||||
tracks: TrackDescription[];
|
||||
value?: VoteResultItem[] | null | false;
|
||||
}
|
||||
|
||||
function Activity ({ allowEmpty, className, palletReferenda, tracks, value }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api, specName } = useApi();
|
||||
|
||||
const infos = useMemo(
|
||||
() => value && value.map((v): [VoteResultItem, TrackInfoExt | undefined] =>
|
||||
[v, getTrackInfo(api, specName, palletReferenda, tracks, v.classId.toNumber())]
|
||||
),
|
||||
[api, palletReferenda, specName, tracks, value]
|
||||
);
|
||||
|
||||
if (!infos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledDiv className={className}>
|
||||
{infos.length
|
||||
? (
|
||||
<Table isInline>
|
||||
{infos.map(([{ casting, classId, delegating }, info], index) => (
|
||||
<tr key={index}>
|
||||
<td className='all'>
|
||||
{info?.trackName || classId.toString()}
|
||||
</td>
|
||||
<td className='together'>
|
||||
{
|
||||
(delegating &&
|
||||
t('delegating')) ||
|
||||
(casting &&
|
||||
`${casting.length} ${casting.length === 1 ? t('vote') : t('votes')}`)
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</Table>
|
||||
)
|
||||
: <MarkWarning content={t('This account has no voting/delegating activity in the chain state')} />
|
||||
}
|
||||
{!allowEmpty && infos.some(([{ delegating }]) => delegating) && (
|
||||
<MarkWarning content={t('This account has some delegations in itself')} />
|
||||
)}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
.ui--Table {
|
||||
font-size: var(--font-percent-small);
|
||||
opacity: var(--opacity-light);
|
||||
padding-left: 2rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Activity);
|
||||
@@ -0,0 +1,292 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BatchOptions } from '@pezkuwi/react-hooks/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { PalletReferenda, PalletVote, TrackDescription } from '../../types.js';
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { Button, ConvictionDropdown, InputAddress, Modal, Toggle, ToggleGroup, TxButton, VoteValue } from '@pezkuwi/react-components';
|
||||
import { useAccounts, useApi, useStepper, useToggle, useTxBatch } from '@pezkuwi/react-hooks';
|
||||
import { isFunction } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../../translate.js';
|
||||
import TrackDropdown from '../Submit/TrackDropdown.js';
|
||||
import Activity from './Activity.js';
|
||||
import useActivityAccount from './useActivityAccount.js';
|
||||
import useActivityFellows from './useActivityFellows.js';
|
||||
import useActivityNominators from './useActivityNominators.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
palletReferenda: PalletReferenda;
|
||||
palletVote: PalletVote;
|
||||
tracks: TrackDescription[];
|
||||
}
|
||||
|
||||
interface Option {
|
||||
key: string;
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const BATCH_OPTS: BatchOptions = { type: 'force' };
|
||||
|
||||
function Delegate ({ className, palletReferenda, palletVote, tracks }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const { hasAccounts } = useAccounts();
|
||||
const [isOpen, toggleOpen] = useToggle();
|
||||
const [isAllTracks, toggleAllTracks] = useToggle();
|
||||
const [step, nextStep, prevStep] = useStepper();
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
const [toAccount, setToAccount] = useState<string | null>(null);
|
||||
const [trackId, setTrackId] = useState<number>(0);
|
||||
const [balance, setBalance] = useState<BN | undefined>();
|
||||
const [conviction, setConviction] = useState(1);
|
||||
const activityFell = useActivityFellows(palletVote);
|
||||
const activityVals = useActivityNominators(palletVote);
|
||||
const activityFrom = useActivityAccount(palletVote, accountId);
|
||||
const activityTo = useActivityAccount(palletVote, toAccount);
|
||||
const [accType, setAccType] = useState({ index: 0, type: 'address' });
|
||||
|
||||
const includeTracks = useMemo(
|
||||
() => activityFrom && tracks
|
||||
.filter((t) => !activityFrom.some((a) => t.id.eq(a.classId)))
|
||||
.map(({ id }) => id),
|
||||
[activityFrom, tracks]
|
||||
);
|
||||
|
||||
const allFell = useMemo(
|
||||
// We also filter the fellows by activity - since there are a number
|
||||
// we just want to skip those that does not have any activity
|
||||
() => activityFell &&
|
||||
Object
|
||||
.entries(activityFell)
|
||||
.map(([key, act]) => (act.length > 0) && ({ key, name: key, value: key }))
|
||||
.filter((a): a is Option => !!a),
|
||||
[activityFell]
|
||||
);
|
||||
|
||||
const allVals = useMemo(
|
||||
() => activityVals &&
|
||||
Object
|
||||
.entries(activityVals)
|
||||
.map(([key]) => ({ key, name: key, value: key }))
|
||||
.filter((a): a is Option => !!a),
|
||||
[activityVals]
|
||||
);
|
||||
|
||||
const typeOpts = useMemo(
|
||||
() => [
|
||||
{ text: t('Addresses'), value: 'address' },
|
||||
isFunction(api.query.staking?.nominators) &&
|
||||
{ isDisabled: !allVals?.length, text: t('Validators'), value: 'validators' },
|
||||
isFunction(api.query.fellowshipCollective?.members) &&
|
||||
{ isDisabled: !allFell?.length, text: t('Fellows'), value: 'fellows' }
|
||||
],
|
||||
[allFell, allVals, api, t]
|
||||
);
|
||||
|
||||
const onChangeType = useCallback(
|
||||
(index: number, type: string | number) =>
|
||||
setAccType({ index, type: type.toString() }),
|
||||
[]
|
||||
);
|
||||
|
||||
const batchInner = useMemo(
|
||||
() => balance && conviction >= 0 && toAccount && includeTracks
|
||||
? (isAllTracks ? includeTracks : [trackId]).map((trackId) =>
|
||||
api.tx[palletVote as 'convictionVoting'].delegate(trackId, toAccount, conviction, balance)
|
||||
)
|
||||
: null,
|
||||
[api, balance, conviction, includeTracks, isAllTracks, palletVote, toAccount, trackId]
|
||||
);
|
||||
|
||||
const extrinsics = useTxBatch(batchInner, BATCH_OPTS);
|
||||
|
||||
// NOTE The activityFrom & activityTo checks only checks that the hook has received
|
||||
// values, not that any values are contained. If we do a length check, that would mean
|
||||
// we could only delegate to accounts with activity. Instead, we just check that we
|
||||
// have the results from the on-chain data received via useActivity*
|
||||
const isStep1Valid = !!(accountId && activityFrom && includeTracks && (includeTracks.length > 0));
|
||||
const isStep2Valid = !!(toAccount && activityTo);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<Modal
|
||||
className={className}
|
||||
header={t('Delegate votes {{step}}/{{numSteps}}', { replace: { numSteps: 2, step } })}
|
||||
onClose={toggleOpen}
|
||||
size='large'
|
||||
>
|
||||
{(step === 1) && (
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('Delegate from this account to another. All votes made on the target would count as a delegated vote for this account.')}>
|
||||
<InputAddress
|
||||
label={t('delegate from account')}
|
||||
onChange={setAccountId}
|
||||
type='account'
|
||||
withLabel
|
||||
/>
|
||||
<Activity
|
||||
allowEmpty
|
||||
palletReferenda={palletReferenda}
|
||||
trackId={-1}
|
||||
tracks={tracks}
|
||||
value={activityFrom}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns
|
||||
align='right'
|
||||
hint={t('Either delegate your votes for a single track as selected or delegate for all available tracks.')}
|
||||
>
|
||||
<Toggle
|
||||
label={t('apply delegation to all tracks')}
|
||||
onChange={toggleAllTracks}
|
||||
value={isAllTracks}
|
||||
/>
|
||||
{!isAllTracks && includeTracks && (includeTracks.length > 0) && (
|
||||
<TrackDropdown
|
||||
include={includeTracks}
|
||||
onChange={setTrackId}
|
||||
palletReferenda={palletReferenda}
|
||||
tracks={tracks}
|
||||
/>
|
||||
)}
|
||||
</Modal.Columns>
|
||||
<Modal.Columns
|
||||
hint={
|
||||
<>
|
||||
<p>{t('The balance associated with the vote will be locked as per the conviction specified and will not be available for transfer during this period.')}</p>
|
||||
<p>{t('Conviction locks do overlap and are not additive, meaning that funds locked during a previous vote can be locked again.')}</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<VoteValue
|
||||
accountId={accountId}
|
||||
autoFocus
|
||||
label={t('delegated vote value')}
|
||||
onChange={setBalance}
|
||||
/>
|
||||
<ConvictionDropdown
|
||||
label={t('conviction')}
|
||||
onChange={setConviction}
|
||||
value={conviction}
|
||||
voteLockingPeriod={api.consts[palletVote as 'convictionVoting'].voteLockingPeriod}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
)}
|
||||
{(step === 2) && (
|
||||
<Modal.Content>
|
||||
{(typeOpts.length > 1) && (
|
||||
<Modal.Columns
|
||||
align='center'
|
||||
hint={t('Select from a list of pre-propulated accounts (based on your account activity) or supply your own')}
|
||||
>
|
||||
<ToggleGroup
|
||||
onChange={onChangeType}
|
||||
options={typeOpts}
|
||||
value={accType.index}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
)}
|
||||
<Modal.Columns hint={t('The account that you wish to delegate to')}>
|
||||
{accType.type === 'address'
|
||||
? (
|
||||
<InputAddress
|
||||
key='address'
|
||||
label={t('delegate to address')}
|
||||
onChange={setToAccount}
|
||||
type='allPlus'
|
||||
/>
|
||||
)
|
||||
: accType.type === 'validators'
|
||||
? (
|
||||
<InputAddress
|
||||
defaultValue={allVals?.[0].value}
|
||||
key='validators'
|
||||
label={t('delegate to validator')}
|
||||
onChange={setToAccount}
|
||||
options={allVals}
|
||||
type='allPlus'
|
||||
/>
|
||||
)
|
||||
: accType.type === 'fellows'
|
||||
? (
|
||||
<InputAddress
|
||||
defaultValue={allFell?.[0].value}
|
||||
key='fellows'
|
||||
label={t('delegate to fellow')}
|
||||
onChange={setToAccount}
|
||||
options={allFell}
|
||||
type='allPlus'
|
||||
/>
|
||||
)
|
||||
: null
|
||||
}
|
||||
<Activity
|
||||
palletReferenda={palletReferenda}
|
||||
trackId={isAllTracks ? -1 : trackId}
|
||||
tracks={tracks}
|
||||
value={
|
||||
accType.type === 'fellows'
|
||||
? activityFell && !!toAccount && activityFell[toAccount]
|
||||
: accType.type === 'validators'
|
||||
? activityVals && !!toAccount && activityVals[toAccount]
|
||||
: activityTo
|
||||
}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
)}
|
||||
<Modal.Actions>
|
||||
{(step !== 1) && (
|
||||
<Button
|
||||
icon='step-backward'
|
||||
label={t('Prev')}
|
||||
onClick={prevStep}
|
||||
/>
|
||||
)}
|
||||
{(step !== 2) && (
|
||||
<Button
|
||||
activeOnEnter
|
||||
icon='step-forward'
|
||||
isDisabled={
|
||||
step === 1
|
||||
? !isStep1Valid
|
||||
: !isStep2Valid
|
||||
}
|
||||
label={t('Next')}
|
||||
onClick={nextStep}
|
||||
/>
|
||||
)}
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
extrinsic={extrinsics}
|
||||
icon='code-merge'
|
||||
isDisabled={
|
||||
!isStep1Valid ||
|
||||
!isStep2Valid ||
|
||||
step !== 2
|
||||
}
|
||||
label={t('Delegate')}
|
||||
onStart={toggleOpen}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)}
|
||||
<Button
|
||||
icon='code-merge'
|
||||
isDisabled={!hasAccounts}
|
||||
label={t('Delegate')}
|
||||
onClick={toggleOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Delegate);
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletConvictionVotingVoteVoting } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
export interface LockResultItem {
|
||||
classId: BN;
|
||||
}
|
||||
|
||||
export type LockResult = Record<string, LockResultItem[]>;
|
||||
|
||||
export interface VoteResultCasting {
|
||||
refId: BN;
|
||||
}
|
||||
|
||||
export interface VoteResultDelegating {
|
||||
conviction: PalletConvictionVotingVoteVoting['asDelegating']['conviction']['type'];
|
||||
targetId: string;
|
||||
}
|
||||
|
||||
export interface VoteResultItem extends LockResultItem {
|
||||
casting?: VoteResultCasting[];
|
||||
delegating?: VoteResultDelegating;
|
||||
}
|
||||
|
||||
export type VoteResult = Record<string, VoteResultItem[]>;
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletVote } from '../../types.js';
|
||||
import type { VoteResult } from './types.js';
|
||||
|
||||
import { createNamedHook } from '@pezkuwi/react-hooks';
|
||||
|
||||
import useSuperIds from './useSuperIds.js';
|
||||
import useVotingFor from './useVotingFor.js';
|
||||
|
||||
function useActivityImpl (palletVote: PalletVote, accountIds?: string[] | null): VoteResult | null | undefined {
|
||||
const identities = useSuperIds(accountIds);
|
||||
|
||||
return useVotingFor(palletVote, identities);
|
||||
}
|
||||
|
||||
export default createNamedHook('useActivity', useActivityImpl);
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletVote } from '../../types.js';
|
||||
import type { VoteResultItem } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook } from '@pezkuwi/react-hooks';
|
||||
|
||||
import useVotingFor from './useVotingFor.js';
|
||||
|
||||
function useActivityAccountImpl (palletVote: PalletVote, accountId?: string | null): VoteResultItem[] | null | undefined {
|
||||
const params = useMemo(
|
||||
() => (accountId && [accountId]) || null,
|
||||
[accountId]
|
||||
);
|
||||
const votingFor = useVotingFor(palletVote, params);
|
||||
|
||||
// for a single account (which we assume is user-specified), we
|
||||
// do not lookup the actual parent identity, use as-is
|
||||
return votingFor && accountId
|
||||
? votingFor[accountId] || []
|
||||
: null;
|
||||
}
|
||||
|
||||
export default createNamedHook('useActivityAccount', useActivityAccountImpl);
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletVote } from '../../types.js';
|
||||
import type { VoteResult } from './types.js';
|
||||
|
||||
import { createNamedHook } from '@pezkuwi/react-hooks';
|
||||
|
||||
import useActivity from './useActivity.js';
|
||||
import useFellows from './useFellows.js';
|
||||
|
||||
function useActivityFellowsImpl (palletVote: PalletVote): VoteResult | null | undefined {
|
||||
const fellows = useFellows();
|
||||
|
||||
return useActivity(palletVote, fellows);
|
||||
}
|
||||
|
||||
export default createNamedHook('useActivityFellows', useActivityFellowsImpl);
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletVote } from '../../types.js';
|
||||
import type { VoteResult } from './types.js';
|
||||
|
||||
import { createNamedHook } from '@pezkuwi/react-hooks';
|
||||
|
||||
import useActivity from './useActivity.js';
|
||||
import useNominators from './useNominators.js';
|
||||
|
||||
function useActivityNominatorsImpl (palletVote: PalletVote): VoteResult | null | undefined {
|
||||
const nominators = useNominators();
|
||||
|
||||
return useActivity(palletVote, nominators);
|
||||
}
|
||||
|
||||
export default createNamedHook('useActivityNominators', useActivityNominatorsImpl);
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { StorageKey } from '@pezkuwi/types';
|
||||
import type { AccountId } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useMapKeys } from '@pezkuwi/react-hooks';
|
||||
import { isFunction } from '@pezkuwi/util';
|
||||
|
||||
const MEMBERS_OPT = {
|
||||
transform: (keys: StorageKey<[AccountId]>[]): string[] =>
|
||||
keys.map(({ args: [id] }) => id.toString())
|
||||
};
|
||||
|
||||
function useFellowsImpl (): string[] | null | undefined {
|
||||
const { api } = useApi();
|
||||
const members = useMapKeys(api.query.fellowshipCollective?.members, [], MEMBERS_OPT);
|
||||
|
||||
return useMemo(
|
||||
() => isFunction(api.query.fellowshipCollective?.members)
|
||||
? members
|
||||
: [],
|
||||
[api, members]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useFellows', useFellowsImpl);
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Option } from '@pezkuwi/types';
|
||||
import type { PalletStakingNominations } from '@pezkuwi/types/lookup';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useAccounts, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { isFunction } from '@pezkuwi/util';
|
||||
|
||||
const NOMINATORS_OPT = {
|
||||
transform: (optNominators: Option<PalletStakingNominations>[]): string[] =>
|
||||
optNominators.reduce<string[]>((all, o) =>
|
||||
o.isSome
|
||||
? all.concat(
|
||||
o.unwrap().targets
|
||||
.map((w) => w.toString())
|
||||
.filter((w) => !all.includes(w))
|
||||
)
|
||||
: all, []
|
||||
)
|
||||
};
|
||||
|
||||
// A list of all validators that any of our accounts nominate
|
||||
// (deduped across accounts)
|
||||
function useNominatorsImpl (): string[] | null | undefined {
|
||||
const { api } = useApi();
|
||||
const { allAccounts } = useAccounts();
|
||||
|
||||
const nomineesParam = useMemo(
|
||||
() => [allAccounts],
|
||||
[allAccounts]
|
||||
);
|
||||
|
||||
const nominees = useCall(!!allAccounts.length && nomineesParam && api.query.staking?.nominators?.multi, nomineesParam, NOMINATORS_OPT);
|
||||
|
||||
return useMemo(
|
||||
() => isFunction(api.query.staking?.nominators) && allAccounts.length
|
||||
? nominees
|
||||
: [],
|
||||
[allAccounts, api, nominees]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useNominators', useNominatorsImpl);
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Option } from '@pezkuwi/types';
|
||||
import type { AccountId } from '@pezkuwi/types/interfaces';
|
||||
import type { ITuple } from '@pezkuwi/types/types';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { isFunction } from '@pezkuwi/util';
|
||||
|
||||
const SUPEROF_OPT = {
|
||||
transform: ([[ids], optSupers]: [[string[]], Option<ITuple<[AccountId]>>[]]): string[] =>
|
||||
optSupers
|
||||
.map((opt, index) =>
|
||||
// if we have a super, use that, otherwise we default to
|
||||
// the actual passed-in identity (which is top-level)
|
||||
opt.isSome
|
||||
? opt.unwrap()[0].toString()
|
||||
: ids[index]
|
||||
)
|
||||
.reduce((all: string[], who): string[] => {
|
||||
// deupe all entries since we may have multiple nominees
|
||||
if (!all.includes(who)) {
|
||||
all.push(who);
|
||||
}
|
||||
|
||||
return all;
|
||||
}, []),
|
||||
withParamsTransform: true
|
||||
};
|
||||
|
||||
function useSuperIdsImpl (accountIds?: string[] | null): string[] | null | undefined {
|
||||
const { apiIdentity } = useApi();
|
||||
|
||||
// for the supplied accounts, retrieve the de-dupes parent identity
|
||||
const identityParam = useMemo(
|
||||
() => accountIds && [accountIds],
|
||||
[accountIds]
|
||||
);
|
||||
|
||||
const identities = useCall(identityParam && !!identityParam[0].length && apiIdentity.query.identity?.superOf?.multi, identityParam, SUPEROF_OPT);
|
||||
|
||||
return useMemo(
|
||||
() => identityParam
|
||||
? identityParam[0].length
|
||||
? isFunction(apiIdentity.query.identity?.superOf)
|
||||
? identities
|
||||
: accountIds
|
||||
: []
|
||||
: null,
|
||||
[apiIdentity, accountIds, identities, identityParam]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useSuperIds', useSuperIdsImpl);
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletConvictionVotingVoteVoting } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { PalletVote } from '../../types.js';
|
||||
import type { LockResult, VoteResult, VoteResultItem } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { isFunction } from '@pezkuwi/util';
|
||||
|
||||
import useVotingLocks from './useVotingLocks.js';
|
||||
|
||||
type ForParam = [accountId: string, classId: BN];
|
||||
|
||||
const FOR_OPT = {
|
||||
transform: ([[ids], votes]: [[ForParam[]], PalletConvictionVotingVoteVoting[]]): VoteResult =>
|
||||
ids.sort((a, b) => a[1].cmp(b[1])).reduce<VoteResult>((all, [accountId, classId], index) => {
|
||||
if (!all[accountId]) {
|
||||
all[accountId] = [];
|
||||
}
|
||||
|
||||
let casting: VoteResultItem['casting'] | undefined;
|
||||
let delegating: VoteResultItem['delegating'] | undefined;
|
||||
|
||||
if (votes[index].isCasting) {
|
||||
casting = votes[index].asCasting.votes.map(([refId]) => ({ refId }));
|
||||
} else if (votes[index].isDelegating) {
|
||||
const { conviction, target } = votes[index].asDelegating;
|
||||
|
||||
delegating = { conviction: conviction.type, targetId: target.toString() };
|
||||
} else {
|
||||
// failsafe log... just in-case
|
||||
console.error(`Unable to handle PalletConvictionVotingVoteVoting type ${votes[index].type}`);
|
||||
}
|
||||
|
||||
all[accountId].push({ casting, classId, delegating });
|
||||
|
||||
return all;
|
||||
}, {}),
|
||||
withParamsTransform: true
|
||||
};
|
||||
|
||||
function getParams (locks?: LockResult | null): ForParam[] | null | undefined {
|
||||
return locks && Object.entries(locks).reduce<ForParam[]>((all, [accountId, items]) => {
|
||||
return items.reduce<ForParam[]>((all, { classId }) => {
|
||||
all.push(([accountId, classId]));
|
||||
|
||||
return all;
|
||||
}, all);
|
||||
}, []);
|
||||
}
|
||||
|
||||
function combineResult (locks: LockResult, votes: VoteResult): VoteResult {
|
||||
return Object.keys(locks).reduce<VoteResult>((all, accountId) => {
|
||||
if (!all[accountId]) {
|
||||
// when it appears in the original keys, but not here
|
||||
// we just add an empty value to keep track of this one
|
||||
all[accountId] = [];
|
||||
}
|
||||
|
||||
return all;
|
||||
}, votes);
|
||||
}
|
||||
|
||||
function useVotingForImpl (palletVote: PalletVote, accountIds?: string[] | null): VoteResult | null | undefined {
|
||||
const { api } = useApi();
|
||||
const locks = useVotingLocks(palletVote, accountIds);
|
||||
|
||||
const forParam = useMemo(
|
||||
() => [getParams(locks)],
|
||||
[locks]
|
||||
);
|
||||
|
||||
const votes = useCall(forParam?.[0] && api.query[palletVote]?.votingFor?.multi, forParam, FOR_OPT);
|
||||
|
||||
return useMemo(
|
||||
() => locks && forParam
|
||||
? forParam[0]
|
||||
? isFunction(api.query[palletVote]?.votingFor)
|
||||
? votes && combineResult(locks, votes)
|
||||
: locks
|
||||
: {}
|
||||
: null,
|
||||
[api, locks, forParam, palletVote, votes]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useVotingFor', useVotingForImpl);
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { INumber } from '@pezkuwi/types/types';
|
||||
import type { PalletVote } from '../../types.js';
|
||||
import type { LockResult } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { isFunction } from '@pezkuwi/util';
|
||||
|
||||
const LOCKS_OPT = {
|
||||
transform: ([[ids], locks]: [[string[]], [INumber, INumber][][]]): LockResult =>
|
||||
ids.reduce<LockResult>((all, accountId, index) => ({
|
||||
...all,
|
||||
[accountId]: locks[index].map(([classId, value]) => ({
|
||||
balance: value.toBn(),
|
||||
classId: classId.toBn()
|
||||
}))
|
||||
}), {}),
|
||||
withParamsTransform: true
|
||||
};
|
||||
|
||||
function useVotingLocksImpl (palletVote: PalletVote, accountIds?: string[] | null): LockResult | null | undefined {
|
||||
const { api } = useApi();
|
||||
|
||||
const locksParam = useMemo(
|
||||
() => accountIds
|
||||
? accountIds.length
|
||||
? [accountIds]
|
||||
: []
|
||||
: undefined,
|
||||
[accountIds]
|
||||
);
|
||||
|
||||
const locks = useCall(locksParam?.[0] && api.query[palletVote]?.classLocksFor?.multi, locksParam, LOCKS_OPT);
|
||||
|
||||
return useMemo(
|
||||
() => locksParam
|
||||
? locksParam[0]
|
||||
? isFunction(api.query[palletVote]?.classLocksFor)
|
||||
? locks
|
||||
: locksParam[0].reduce<LockResult>((all, accountId) => ({ ...all, [accountId]: [] }), {})
|
||||
: {}
|
||||
: null,
|
||||
[api, locks, locksParam, palletVote]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useVotingLocks', useVotingLocksImpl);
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletReferendaTrackDetails } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { PalletReferenda } from '../../types.js';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Button, InputAddress, InputBalance, InputNumber, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { Available } from '@pezkuwi/react-query';
|
||||
|
||||
import { useTranslation } from '../../translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
id: BN;
|
||||
palletReferenda: PalletReferenda;
|
||||
track: PalletReferendaTrackDetails;
|
||||
}
|
||||
|
||||
function Deposit ({ className = '', id, palletReferenda, track }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const [isOpen, toggleOpen] = useToggle();
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<Modal
|
||||
className={className}
|
||||
header={t('Place decision deposit')}
|
||||
onClose={toggleOpen}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The deposit will be registered from this account and the balance lock will be applied here.')}>
|
||||
<InputAddress
|
||||
label={t('deposit from account')}
|
||||
labelExtra={
|
||||
<Available
|
||||
label={<span className='label'>{t('transferable')}</span>}
|
||||
params={accountId}
|
||||
/>
|
||||
}
|
||||
onChange={setAccountId}
|
||||
type='account'
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The referendum this deposit would apply to.')}>
|
||||
<InputNumber
|
||||
defaultValue={id}
|
||||
isDisabled
|
||||
label={t('referendum id')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The deposit for this proposal will be locked for the referendum duration.')}>
|
||||
<InputBalance
|
||||
defaultValue={track.decisionDeposit}
|
||||
isDisabled
|
||||
label={t('decision deposit')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
icon='plus'
|
||||
label={t('Place deposit')}
|
||||
onStart={toggleOpen}
|
||||
params={[id]}
|
||||
tx={api.tx[palletReferenda as 'referenda'].placeDecisionDeposit}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)}
|
||||
<Button
|
||||
icon='plus'
|
||||
label={t('Decision deposit')}
|
||||
onClick={toggleOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Deposit);
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { PalletReferenda } from '../../types.js';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Button, InputAddress, InputNumber, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useToggle } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../../translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
id: BN;
|
||||
palletReferenda: PalletReferenda;
|
||||
}
|
||||
|
||||
function Refund ({ className = '', id, palletReferenda }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const [isOpen, toggleOpen] = useToggle();
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<Modal
|
||||
className={className}
|
||||
header={t('Refund decision deposit')}
|
||||
onClose={toggleOpen}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The transaction will be submitted from this account.')}>
|
||||
<InputAddress
|
||||
label={t('refund from account')}
|
||||
onChange={setAccountId}
|
||||
type='account'
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The referendum this deposit would apply to.')}>
|
||||
<InputNumber
|
||||
defaultValue={id}
|
||||
isDisabled
|
||||
label={t('referendum id')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
icon='minus'
|
||||
label={t('Refund deposit')}
|
||||
onStart={toggleOpen}
|
||||
params={[id]}
|
||||
tx={api.tx[palletReferenda as 'referenda'].refundDecisionDeposit}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)}
|
||||
<Button
|
||||
icon='minus'
|
||||
label={t('Refund deposit')}
|
||||
onClick={toggleOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Refund);
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletReferendaDeposit, PalletReferendaTrackDetails } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { PalletReferenda } from '../../types.js';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { AddressMini, styled } from '@pezkuwi/react-components';
|
||||
|
||||
import Place from './Place.js';
|
||||
import Refund from './Refund.js';
|
||||
|
||||
interface Props {
|
||||
canDeposit?: boolean;
|
||||
canRefund?: boolean;
|
||||
className?: string;
|
||||
decision: PalletReferendaDeposit | null;
|
||||
id: BN;
|
||||
noMedia?: boolean;
|
||||
palletReferenda: PalletReferenda;
|
||||
submit: PalletReferendaDeposit | null;
|
||||
track?: PalletReferendaTrackDetails;
|
||||
}
|
||||
|
||||
function Deposits ({ canDeposit, canRefund, className = '', decision, id, noMedia, palletReferenda, submit, track }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<StyledTd className={`${className} address ${noMedia ? '' : 'media--1000-noPad'}`}>
|
||||
{submit && (
|
||||
<AddressMini
|
||||
balance={submit.amount}
|
||||
className={noMedia ? '' : 'media--1000'}
|
||||
value={submit.who}
|
||||
withBalance
|
||||
/>
|
||||
)}
|
||||
{decision
|
||||
? (
|
||||
<>
|
||||
<AddressMini
|
||||
balance={decision.amount}
|
||||
className={noMedia ? '' : 'media--1000'}
|
||||
value={decision.who}
|
||||
withBalance
|
||||
/>
|
||||
{canRefund && (
|
||||
<div className={noMedia ? '' : 'media--1000'}>
|
||||
<Refund
|
||||
id={id}
|
||||
palletReferenda={palletReferenda}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: canDeposit && track && (
|
||||
<div className={noMedia ? '' : 'media--1000'}>
|
||||
<Place
|
||||
id={id}
|
||||
palletReferenda={palletReferenda}
|
||||
track={track}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</StyledTd>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledTd = styled.td`
|
||||
.ui--AddressMini+.ui--Button {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Deposits);
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { PalletReferenda, PalletVote, ReferendaGroup, TrackDescription } from '../types.js';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { ExpandButton, Table } from '@pezkuwi/react-components';
|
||||
import { useApi, useToggle } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import { getTrackInfo } from '../util.js';
|
||||
import Referendum from './Referendum.js';
|
||||
|
||||
interface Props extends ReferendaGroup {
|
||||
activeIssuance?: BN;
|
||||
className?: string;
|
||||
isMember: boolean;
|
||||
members?: string[];
|
||||
palletReferenda: PalletReferenda;
|
||||
palletVote: PalletVote;
|
||||
ranks?: BN[];
|
||||
tracks: TrackDescription[];
|
||||
}
|
||||
|
||||
function Group ({ activeIssuance, className, isMember, members, palletReferenda, palletVote, ranks, referenda, trackId, trackName, tracks }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api, specName } = useApi();
|
||||
const [isExpanded, toggleExpanded] = useToggle();
|
||||
|
||||
const trackInfo = useMemo(
|
||||
() => getTrackInfo(api, specName, palletReferenda, tracks, trackId?.toNumber()),
|
||||
[api, specName, palletReferenda, tracks, trackId]
|
||||
);
|
||||
|
||||
const [headerButton, headerChildren] = useMemo(
|
||||
() => [
|
||||
false && trackInfo && (
|
||||
<ExpandButton
|
||||
expanded={isExpanded}
|
||||
onClick={toggleExpanded}
|
||||
/>
|
||||
),
|
||||
isExpanded && trackInfo && (
|
||||
<tr>
|
||||
<th colSpan={8} />
|
||||
</tr>
|
||||
)
|
||||
],
|
||||
[isExpanded, toggleExpanded, trackInfo]
|
||||
);
|
||||
|
||||
const [header, key] = useMemo(
|
||||
(): [([React.ReactNode?, string?, number?] | null)[], string] => [
|
||||
[
|
||||
[trackName ? <>{trackName}<div className='sub'>{trackInfo?.text}</div></> : t('referenda'), 'start', 8],
|
||||
[headerButton]
|
||||
],
|
||||
trackName
|
||||
? `track:${trackName}`
|
||||
: 'untracked'
|
||||
],
|
||||
[headerButton, t, trackInfo, trackName]
|
||||
);
|
||||
|
||||
return (
|
||||
<Table
|
||||
className={className}
|
||||
empty={referenda && t('No active referenda')}
|
||||
header={header}
|
||||
headerChildren={headerChildren}
|
||||
isSplit={!trackId}
|
||||
key={key}
|
||||
>
|
||||
{referenda?.map((r) => (
|
||||
<Referendum
|
||||
activeIssuance={activeIssuance}
|
||||
isMember={isMember}
|
||||
key={r.key}
|
||||
members={members}
|
||||
palletReferenda={palletReferenda}
|
||||
palletVote={palletVote}
|
||||
ranks={ranks}
|
||||
trackInfo={trackInfo}
|
||||
value={r}
|
||||
/>
|
||||
))}
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Group);
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useBestNumberRelay, useStakingAsyncApis } from '@pezkuwi/react-hooks';
|
||||
import { BlockToTime } from '@pezkuwi/react-query';
|
||||
import { formatNumber } from '@pezkuwi/util';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
label: string;
|
||||
when: BN | null;
|
||||
}
|
||||
|
||||
function RefEnd ({ className = '', label, when }: Props): React.ReactElement<Props> {
|
||||
const bestNumber = useBestNumberRelay();
|
||||
const { isStakingAsync, rcApi } = useStakingAsyncApis();
|
||||
|
||||
return (
|
||||
<td className={`${className} number`}>
|
||||
{bestNumber && when && (
|
||||
<>
|
||||
<div>{label}</div>
|
||||
{/* Remaining period should be decided based on Relay chain */}
|
||||
{when.gt(bestNumber) && (
|
||||
<BlockToTime
|
||||
api={isStakingAsync ? rcApi : undefined}
|
||||
value={when.sub(bestNumber)}
|
||||
/>
|
||||
)}
|
||||
<div>#{formatNumber(when)}</div>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(RefEnd);
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ReferendumProps as Props } from '../types.js';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import RefEnd from './RefEnd.js';
|
||||
|
||||
function RefOther ({ value: { info } }: Props): React.ReactElement<Props> {
|
||||
const when = useMemo(
|
||||
() => info.isKilled
|
||||
? info.asKilled
|
||||
: null,
|
||||
[info]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
className='all no-pad'
|
||||
colSpan={5}
|
||||
/>
|
||||
<RefEnd
|
||||
label={info.type}
|
||||
when={when}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(RefOther);
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { Hash } from '@pezkuwi/types/interfaces';
|
||||
import type { PalletConvictionVotingTally, PalletRankedCollectiveTally, PalletReferendaDeposit, PalletReferendaReferendumStatusConvictionVotingTally, PalletReferendaReferendumStatusRankedCollectiveTally, PalletReferendaTrackDetails } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
import type { Referendum, ReferendumProps as Props } from '../types.js';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { Progress } from '@pezkuwi/react-components';
|
||||
import { useApi, usePreimage } from '@pezkuwi/react-hooks';
|
||||
import { getPreimageHash } from '@pezkuwi/react-hooks/usePreimage';
|
||||
import { CallExpander } from '@pezkuwi/react-params';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Deposits from './Deposits/index.js';
|
||||
import Vote from './Vote/index.js';
|
||||
import RefEnd from './RefEnd.js';
|
||||
import { unwrapDeposit } from './util.js';
|
||||
import Votes from './Votes.js';
|
||||
|
||||
interface Expanded {
|
||||
decisionDeposit: PalletReferendaDeposit | null;
|
||||
periods: {
|
||||
periodEnd: BN | null;
|
||||
prepareEnd: BN | null;
|
||||
decideEnd: BN | null;
|
||||
confirmEnd: BN | null;
|
||||
};
|
||||
ongoing: PalletReferendaReferendumStatusConvictionVotingTally | PalletReferendaReferendumStatusRankedCollectiveTally;
|
||||
proposalHash?: HexString;
|
||||
submissionDeposit: PalletReferendaDeposit | null;
|
||||
tally: PalletConvictionVotingTally | PalletRankedCollectiveTally;
|
||||
tallyTotal: BN;
|
||||
}
|
||||
|
||||
function expandOngoing (api: ApiPromise, info: Referendum['info'], track?: PalletReferendaTrackDetails): Expanded {
|
||||
const ongoing = info.asOngoing;
|
||||
const proposalHash = getPreimageHash(api, ongoing.proposal || (ongoing as unknown as { proposalHash: Hash }).proposalHash).proposalHash;
|
||||
let prepareEnd: BN | null = null;
|
||||
let decideEnd: BN | null = null;
|
||||
let confirmEnd: BN | null = null;
|
||||
|
||||
if (track) {
|
||||
const { deciding, submitted } = ongoing;
|
||||
|
||||
if (deciding.isSome) {
|
||||
const { confirming, since } = deciding.unwrap();
|
||||
|
||||
if (confirming.isSome) {
|
||||
// we are confirming with the specific end block
|
||||
confirmEnd = confirming.unwrap();
|
||||
} else {
|
||||
// we are still deciding, start + length
|
||||
decideEnd = since.add(track.decisionPeriod);
|
||||
}
|
||||
} else {
|
||||
// we are still preparing, start + length
|
||||
prepareEnd = submitted.add(track.preparePeriod);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
decisionDeposit: unwrapDeposit(ongoing.decisionDeposit),
|
||||
ongoing,
|
||||
periods: {
|
||||
confirmEnd,
|
||||
decideEnd,
|
||||
periodEnd: confirmEnd || decideEnd || prepareEnd,
|
||||
prepareEnd
|
||||
},
|
||||
proposalHash,
|
||||
submissionDeposit: unwrapDeposit(ongoing.submissionDeposit),
|
||||
tally: ongoing.tally,
|
||||
tallyTotal: ongoing.tally.ayes.add(ongoing.tally.nays)
|
||||
};
|
||||
}
|
||||
|
||||
function Ongoing ({ isMember, members, palletReferenda, palletVote, ranks, trackInfo, value: { id, info, isConvictionVote, track } }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
|
||||
const { decisionDeposit, ongoing, periods: { confirmEnd, decideEnd, periodEnd }, submissionDeposit, tally, tallyTotal } = useMemo(
|
||||
() => expandOngoing(api, info, track),
|
||||
[api, info, track]
|
||||
);
|
||||
|
||||
const preimage = usePreimage(ongoing.proposal || (ongoing as unknown as { proposalHash: Hash }).proposalHash);
|
||||
|
||||
return (
|
||||
<>
|
||||
<td className='all'>
|
||||
{preimage?.proposal
|
||||
? (
|
||||
<CallExpander
|
||||
labelHash={t('preimage')}
|
||||
value={preimage.proposal}
|
||||
withHash
|
||||
/>
|
||||
)
|
||||
: <div className='shortHash'>{preimage?.proposalHash}</div>
|
||||
}
|
||||
</td>
|
||||
<Deposits
|
||||
canDeposit
|
||||
decision={decisionDeposit}
|
||||
id={id}
|
||||
palletReferenda={palletReferenda}
|
||||
submit={submissionDeposit}
|
||||
track={track}
|
||||
/>
|
||||
<RefEnd
|
||||
label={
|
||||
confirmEnd
|
||||
? t('Confirming')
|
||||
: decideEnd
|
||||
? t('Deciding')
|
||||
: t('Preparing')
|
||||
}
|
||||
when={periodEnd}
|
||||
/>
|
||||
<Votes
|
||||
id={id}
|
||||
isConvictionVote={isConvictionVote}
|
||||
palletVote={palletVote}
|
||||
tally={tally}
|
||||
/>
|
||||
<td className='middle chart media--1300-noPad'>
|
||||
<Progress
|
||||
className='media--1300'
|
||||
total={tallyTotal}
|
||||
value={tally.ayes}
|
||||
/>
|
||||
</td>
|
||||
<td className='actions button'>
|
||||
<Vote
|
||||
id={id}
|
||||
isConvictionVote={isConvictionVote}
|
||||
isMember={isMember}
|
||||
members={members}
|
||||
palletVote={palletVote}
|
||||
preimage={preimage}
|
||||
ranks={ranks}
|
||||
trackInfo={trackInfo}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Ongoing);
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletReferendaDeposit } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { Referendum, ReferendumProps as Props } from '../types.js';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import Deposits from './Deposits/index.js';
|
||||
import RefEnd from './RefEnd.js';
|
||||
import { unwrapDeposit } from './util.js';
|
||||
|
||||
interface Expanded {
|
||||
decision: PalletReferendaDeposit | null;
|
||||
submit: PalletReferendaDeposit | null;
|
||||
when: BN | null;
|
||||
}
|
||||
|
||||
function expandTuple (info: Referendum['info']): Expanded {
|
||||
const data = info.isApproved
|
||||
? info.asApproved
|
||||
: info.isRejected
|
||||
? info.asRejected
|
||||
: info.isCancelled
|
||||
? info.asCancelled
|
||||
: info.isTimedOut
|
||||
? info.asTimedOut
|
||||
: null;
|
||||
|
||||
return data
|
||||
? {
|
||||
decision: unwrapDeposit(data[2]),
|
||||
submit: unwrapDeposit(data[1]),
|
||||
when: data[0]
|
||||
}
|
||||
: {
|
||||
decision: null,
|
||||
submit: null,
|
||||
when: null
|
||||
};
|
||||
}
|
||||
|
||||
function Tuple ({ palletReferenda, value: { id, info, track } }: Props): React.ReactElement<Props> {
|
||||
const { decision, submit, when } = useMemo(
|
||||
() => expandTuple(info),
|
||||
[info]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
className='no-pad'
|
||||
colSpan={4}
|
||||
/>
|
||||
<Deposits
|
||||
canRefund
|
||||
className='all'
|
||||
decision={decision}
|
||||
id={id}
|
||||
noMedia
|
||||
palletReferenda={palletReferenda}
|
||||
submit={submit}
|
||||
track={track}
|
||||
/>
|
||||
<RefEnd
|
||||
label={info.type}
|
||||
when={when}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Tuple);
|
||||
@@ -0,0 +1,494 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ChartOptions, ChartTypeRegistry, TooltipItem } from 'chart.js';
|
||||
import type { PalletConvictionVotingTally, PalletRankedCollectiveTally, PalletReferendaReferendumInfoConvictionVotingTally, PalletReferendaReferendumInfoRankedCollectiveTally, PalletReferendaTrackDetails } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { CurveGraph, ReferendumProps as Props } from '../types.js';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { Chart, Columar, LinkExternal, styled, Table } from '@pezkuwi/react-components';
|
||||
import { useBestNumberRelay, useBlockInterval, useStakingAsyncApis, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { calcBlockTime } from '@pezkuwi/react-hooks/useBlockTime';
|
||||
import { BN_MILLION, BN_THOUSAND, bnMax, bnToBn, formatNumber, objectSpread } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Killed from './RefKilled.js';
|
||||
import Ongoing from './RefOngoing.js';
|
||||
import Tuple from './RefTuple.js';
|
||||
|
||||
const COMPONENTS: Record<string, React.ComponentType<Props>> = {
|
||||
Killed,
|
||||
Ongoing
|
||||
};
|
||||
|
||||
const VAL_COLORS = ['#ff8c00', '#9c3333', '#339c33'];
|
||||
const BOX_COLORS = {
|
||||
conf: 'rgba(255, 140, 0, 0.1)',
|
||||
enac: 'rgba(0, 0, 140, 0.1)',
|
||||
fail: 'rgba(140, 0, 0, 0.02)',
|
||||
pass: 'rgba(0, 140, 0, 0.02)',
|
||||
past: 'rgba(140, 140, 140, 0.2)'
|
||||
};
|
||||
const PT_CUR = 0;
|
||||
const PT_NEG = 1;
|
||||
const PT_POS = 2;
|
||||
|
||||
const OPTIONS: ChartOptions = {
|
||||
aspectRatio: 2.25,
|
||||
maintainAspectRatio: true,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interface ChartResult {
|
||||
progress: {
|
||||
percent: number;
|
||||
value: BN;
|
||||
total: BN;
|
||||
};
|
||||
labels: string[];
|
||||
values: number[][];
|
||||
}
|
||||
|
||||
interface ChartResultExt extends ChartResult {
|
||||
changeX: number;
|
||||
currentY: number;
|
||||
endConfirm: BN | null;
|
||||
points: BN[];
|
||||
since: BN;
|
||||
}
|
||||
|
||||
interface ChartProps extends ChartResult {
|
||||
colors: string[];
|
||||
options: typeof OPTIONS;
|
||||
}
|
||||
|
||||
function createTitleCallback (t: (key: string, options?: { replace: Record<string, unknown> }) => string, bestNumber: BN, blockInterval: BN, extraFn: (blockNumber: BN) => string): (items: TooltipItem<keyof ChartTypeRegistry>[]) => string | string[] {
|
||||
return ([{ label }]: TooltipItem<keyof ChartTypeRegistry>[]): string | string[] => {
|
||||
try {
|
||||
const blockNumber = bnToBn(label.replace(/,/g, ''));
|
||||
const extraTitle = extraFn(blockNumber);
|
||||
|
||||
if (blockNumber.gt(bestNumber)) {
|
||||
const blocks = blockNumber.sub(bestNumber);
|
||||
const when = new Date(Date.now() + blocks.mul(blockInterval).toNumber()).toLocaleString();
|
||||
const calc = calcBlockTime(blockInterval, blocks, t);
|
||||
const result = [`#${label}`, t('{{when}} (est.)', { replace: { when } }), calc[1]];
|
||||
|
||||
if (extraTitle) {
|
||||
result.push(extraTitle);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (extraTitle) {
|
||||
return [`#${label}`, extraTitle];
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return `#${label}`;
|
||||
};
|
||||
}
|
||||
|
||||
function getChartResult (totalEligible: BN, isConvictionVote: boolean, info: PalletReferendaReferendumInfoConvictionVotingTally | PalletReferendaReferendumInfoRankedCollectiveTally, track: PalletReferendaTrackDetails, trackGraph: CurveGraph): ChartResultExt[] | null {
|
||||
if (totalEligible && isConvictionVote && info.isOngoing) {
|
||||
const ongoing = info.asOngoing;
|
||||
|
||||
if (ongoing.deciding.isSome) {
|
||||
const { approval, support, x } = trackGraph;
|
||||
const { deciding, tally } = ongoing;
|
||||
const { confirming, since } = deciding.unwrap();
|
||||
const endConfirm = confirming.unwrapOr(null);
|
||||
const currentSupport = isConvictionVote
|
||||
? (tally as PalletConvictionVotingTally).support
|
||||
: (tally as PalletRankedCollectiveTally).bareAyes;
|
||||
const labels: string[] = [];
|
||||
const values: number[][][] = [[[], [], []], [[], [], []]];
|
||||
const supc = totalEligible.isZero()
|
||||
? 0
|
||||
: currentSupport.mul(BN_THOUSAND).div(totalEligible).toNumber() / 10;
|
||||
const appc = tally.ayes.isZero()
|
||||
? 0
|
||||
: tally.ayes.mul(BN_THOUSAND).div(tally.ayes.add(tally.nays)).toNumber() / 10;
|
||||
let appx = -1;
|
||||
let supx = -1;
|
||||
const points: BN[] = [];
|
||||
|
||||
for (let i = 0; i < approval.length; i++) {
|
||||
labels.push(formatNumber(since.add(x[i])));
|
||||
points.push(x[i]);
|
||||
|
||||
const appr = approval[i].div(BN_MILLION).toNumber() / 10;
|
||||
const appn = appc < appr;
|
||||
|
||||
values[0][PT_CUR][i] = appr;
|
||||
values[0][appn ? PT_NEG : PT_POS][i] = appc;
|
||||
appx = (appn || appx !== -1) ? appx : i;
|
||||
|
||||
const supr = support[i].div(BN_MILLION).toNumber() / 10;
|
||||
const supn = supc < supr;
|
||||
|
||||
values[1][PT_CUR][i] = supr;
|
||||
values[1][supn ? PT_NEG : PT_POS][i] = supc;
|
||||
supx = (supn || supx !== -1) ? supx : i;
|
||||
}
|
||||
|
||||
// Bringing it to a higher precision.
|
||||
// Otherwise, graphs with short periods (on dev chains) are invalid.
|
||||
const stepWithPrecision = x[x.length - 1].sub(x[0]).muln(100).divn(x.length);
|
||||
const lastIndex = x.length - 1;
|
||||
const lastBlock = endConfirm?.add(track.minEnactmentPeriod);
|
||||
|
||||
// if the confirmation end is later than shown on our graph, we extend it
|
||||
if (lastBlock?.gt(since.add(x[lastIndex]))) {
|
||||
let currentBlockWithPrecision = x[lastIndex].add(since).muln(100).add(stepWithPrecision);
|
||||
let currentBlock = currentBlockWithPrecision.divn(100);
|
||||
|
||||
do {
|
||||
labels.push(formatNumber(currentBlock));
|
||||
points.push(currentBlock.sub(since));
|
||||
|
||||
// adjust approvals (no curve adjustment)
|
||||
// values[0][0].push(values[0][0][lastIndex]);
|
||||
values[0][1].push(values[0][1][lastIndex]);
|
||||
values[0][2].push(values[0][2][lastIndex]);
|
||||
|
||||
// // adjust support
|
||||
// values[1][0].push(values[1][0][lastIndex]);
|
||||
values[1][1].push(values[1][1][lastIndex]);
|
||||
values[1][2].push(values[1][2][lastIndex]);
|
||||
|
||||
currentBlockWithPrecision = currentBlockWithPrecision.add(stepWithPrecision);
|
||||
currentBlock = currentBlockWithPrecision.divn(100);
|
||||
} while (currentBlock.lt(lastBlock));
|
||||
}
|
||||
|
||||
return [
|
||||
{ changeX: appx, currentY: appc, endConfirm, labels, points, progress: { percent: appc, total: ongoing.tally.ayes.add(ongoing.tally.nays), value: ongoing.tally.ayes }, since, values: values[0] },
|
||||
{ changeX: supx, currentY: supc, endConfirm, labels, points, progress: { percent: supc, total: totalEligible, value: currentSupport }, since, values: values[1] }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getChartProps (bestNumber: BN, blockInterval: BN, chartProps: ChartResultExt[], refId: BN, track: PalletReferendaTrackDetails, t: (key: string, options?: { replace: Record<string, unknown> }) => string): ChartProps[] {
|
||||
const changeXMax = chartProps.reduce((max, { changeX }) =>
|
||||
max === -1 || changeX === -1
|
||||
? -1
|
||||
: Math.max(max, changeX),
|
||||
0);
|
||||
|
||||
return chartProps.map(({ changeX, currentY, endConfirm, labels, points, progress, since, values }, index): ChartProps => {
|
||||
const maxX = labels.length;
|
||||
const maxY = index === 0
|
||||
? 100
|
||||
: 50;
|
||||
|
||||
const blockToX = (value: BN) =>
|
||||
Math.max(0, Math.min(maxX, maxX * (
|
||||
value.sub(since).toNumber() / points[points.length - 1].toNumber()
|
||||
)));
|
||||
|
||||
const swapX = changeX === -1
|
||||
? -1
|
||||
: maxX * (changeX / points.length);
|
||||
const enactX = changeXMax !== -1 && bnMax(bestNumber, points[changeXMax].add(since));
|
||||
const confirmX = endConfirm
|
||||
? [endConfirm.sub(track.confirmPeriod), endConfirm, endConfirm.add(track.minEnactmentPeriod)]
|
||||
: enactX
|
||||
? [enactX, enactX.add(track.confirmPeriod), enactX.add(track.confirmPeriod).add(track.minEnactmentPeriod)]
|
||||
: null;
|
||||
const title = createTitleCallback(t, bestNumber, blockInterval, (blockNumber) =>
|
||||
confirmX && blockNumber.gte(confirmX[0])
|
||||
? blockNumber.lte(confirmX[1])
|
||||
? t('Confirmation period')
|
||||
: blockNumber.lte(confirmX[2])
|
||||
? t('Enactment period')
|
||||
: ''
|
||||
: ''
|
||||
);
|
||||
|
||||
return {
|
||||
colors: VAL_COLORS,
|
||||
labels,
|
||||
options: objectSpread({
|
||||
plugins: {
|
||||
annotation: {
|
||||
annotations: objectSpread(
|
||||
{
|
||||
past: {
|
||||
backgroundColor: BOX_COLORS.past,
|
||||
borderWidth: 0,
|
||||
type: 'box',
|
||||
xMax: blockToX(bestNumber),
|
||||
xMin: 0,
|
||||
yMax: maxY,
|
||||
yMin: 0
|
||||
}
|
||||
},
|
||||
confirmX
|
||||
? {
|
||||
conf: {
|
||||
backgroundColor: BOX_COLORS.conf,
|
||||
borderWidth: 0,
|
||||
type: 'box',
|
||||
xMax: blockToX(confirmX[1]),
|
||||
xMin: blockToX(confirmX[0]),
|
||||
yMax: maxY,
|
||||
yMin: 0
|
||||
},
|
||||
enac: {
|
||||
backgroundColor: BOX_COLORS.enac,
|
||||
borderWidth: 0,
|
||||
type: 'box',
|
||||
xMax: blockToX(confirmX[2]),
|
||||
xMin: blockToX(confirmX[1]),
|
||||
yMax: maxY,
|
||||
yMin: 0
|
||||
}
|
||||
}
|
||||
: {},
|
||||
{
|
||||
fail: {
|
||||
backgroundColor: BOX_COLORS.fail,
|
||||
borderWidth: 0,
|
||||
type: 'box',
|
||||
xMax: swapX === -1
|
||||
? maxX
|
||||
: swapX,
|
||||
xMin: 0,
|
||||
yMax: currentY,
|
||||
yMin: 0
|
||||
}
|
||||
},
|
||||
swapX !== -1
|
||||
? {
|
||||
pass: {
|
||||
backgroundColor: BOX_COLORS.pass,
|
||||
borderWidth: 0,
|
||||
type: 'box',
|
||||
xMax: maxX,
|
||||
xMin: swapX,
|
||||
yMax: currentY,
|
||||
yMin: 0
|
||||
}
|
||||
}
|
||||
: {}
|
||||
)
|
||||
},
|
||||
crosshair: {
|
||||
sync: {
|
||||
group: refId.toNumber()
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}, OPTIONS),
|
||||
progress,
|
||||
values
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function extractInfo (info: PalletReferendaReferendumInfoConvictionVotingTally | PalletReferendaReferendumInfoRankedCollectiveTally, track?: PalletReferendaTrackDetails): { confirmEnd: BN | null, enactAt: { at: boolean, blocks: BN, end: BN | null } | null, nextAlarm: null | BN, submittedIn: null | BN } {
|
||||
let confirmEnd: BN | null = null;
|
||||
let enactAt: { at: boolean, blocks: BN, end: BN | null } | null = null;
|
||||
let nextAlarm: BN | null = null;
|
||||
let submittedIn: BN | null = null;
|
||||
|
||||
if (info.isOngoing) {
|
||||
const { alarm, deciding, enactment, submitted } = info.asOngoing;
|
||||
|
||||
enactAt = {
|
||||
at: enactment.isAt,
|
||||
blocks: enactment.isAt
|
||||
? enactment.asAt
|
||||
: enactment.asAfter,
|
||||
end: null
|
||||
};
|
||||
nextAlarm = alarm.unwrapOr([null])[0];
|
||||
submittedIn = submitted;
|
||||
|
||||
if (deciding.isSome) {
|
||||
const { confirming } = deciding.unwrap();
|
||||
|
||||
if (confirming.isSome) {
|
||||
// we are confirming with the specific end block
|
||||
confirmEnd = confirming.unwrap();
|
||||
|
||||
if (track) {
|
||||
// add our track data
|
||||
const fastEnd = confirmEnd.add(track.minEnactmentPeriod);
|
||||
|
||||
enactAt.end = enactment.isAt
|
||||
? bnMax(fastEnd, enactment.asAt)
|
||||
: fastEnd.add(enactment.asAfter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { confirmEnd, enactAt, nextAlarm, submittedIn };
|
||||
}
|
||||
|
||||
function Referendum (props: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { isStakingAsync, rcApi } = useStakingAsyncApis();
|
||||
const bestNumber = useBestNumberRelay();
|
||||
const blockInterval = useBlockInterval(isStakingAsync ? rcApi : undefined);
|
||||
const { activeIssuance, className = '', palletReferenda, value: { id, info, isConvictionVote, track, trackGraph } } = props;
|
||||
const [isExpanded, toggleExpanded] = useToggle(false);
|
||||
|
||||
const Component = useMemo(
|
||||
() => COMPONENTS[info.type] || Tuple,
|
||||
[info]
|
||||
);
|
||||
|
||||
const chartResult = useMemo(
|
||||
() => activeIssuance && track && trackGraph &&
|
||||
getChartResult(activeIssuance, isConvictionVote, info, track, trackGraph),
|
||||
[activeIssuance, info, isConvictionVote, track, trackGraph]
|
||||
);
|
||||
|
||||
const chartProps = useMemo(
|
||||
() => bestNumber && chartResult && isExpanded && track &&
|
||||
getChartProps(bestNumber, blockInterval, chartResult, id, track, t),
|
||||
[bestNumber, blockInterval, chartResult, id, isExpanded, t, track]
|
||||
);
|
||||
|
||||
const { confirmEnd, enactAt, nextAlarm, submittedIn } = useMemo(
|
||||
() => extractInfo(info, track),
|
||||
[info, track]
|
||||
);
|
||||
|
||||
const chartLegend = useMemo(
|
||||
() => [
|
||||
[
|
||||
t('minimum approval'),
|
||||
t('current approval (failing)'),
|
||||
t('current approval (passing)')
|
||||
],
|
||||
[
|
||||
t('minimum support'),
|
||||
t('current support (failing)'),
|
||||
t('current support (passing)')
|
||||
]
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledTr className={`${className} isExpanded isFirst ${isExpanded ? '' : 'isLast'}`}>
|
||||
<Table.Column.Id value={id} />
|
||||
<Component {...props} />
|
||||
<Table.Column.Expand
|
||||
isExpanded={isExpanded}
|
||||
toggle={toggleExpanded}
|
||||
/>
|
||||
</StyledTr>
|
||||
<StyledTr className={`${className} ${isExpanded ? 'isExpanded isLast' : 'isCollapsed'}`}>
|
||||
<td />
|
||||
<td
|
||||
className='columar'
|
||||
colSpan={6}
|
||||
>
|
||||
{chartProps && (
|
||||
<Columar>
|
||||
<Columar.Column>
|
||||
<Chart.Line
|
||||
legends={chartLegend[0]}
|
||||
title={t('approval / {{percent}}%', { replace: { percent: chartProps[0].progress.percent.toFixed(1) } })}
|
||||
{...chartProps[0]}
|
||||
/>
|
||||
</Columar.Column>
|
||||
<Columar.Column>
|
||||
<Chart.Line
|
||||
legends={chartLegend[1]}
|
||||
title={t('support / {{percent}}%', { replace: { percent: chartProps[1].progress.percent.toFixed(1) } })}
|
||||
{...chartProps[1]}
|
||||
/>
|
||||
</Columar.Column>
|
||||
</Columar>
|
||||
)}
|
||||
<Columar size='tiny'>
|
||||
<Columar.Column>
|
||||
{submittedIn && (
|
||||
<>
|
||||
<h5>{t('Submitted at')}</h5>
|
||||
#{formatNumber(submittedIn)}
|
||||
</>
|
||||
)}
|
||||
{nextAlarm && (
|
||||
<>
|
||||
<h5>{t('Next alarm')}</h5>
|
||||
#{formatNumber(nextAlarm)}
|
||||
</>
|
||||
)}
|
||||
</Columar.Column>
|
||||
<Columar.Column>
|
||||
{enactAt && (
|
||||
<>
|
||||
<h5>{enactAt.at ? t('Enact at') : t('Enact after')}</h5>
|
||||
{enactAt.at && '#'}{t('{{blocks}} blocks', { replace: { blocks: formatNumber(enactAt.blocks) } })}
|
||||
</>
|
||||
)}
|
||||
{confirmEnd && (
|
||||
<>
|
||||
<h5>{t('Confirm end')}</h5>
|
||||
#{formatNumber(confirmEnd)}
|
||||
</>
|
||||
)}
|
||||
{enactAt?.end && (
|
||||
<>
|
||||
<h5>{t('Enact end')}</h5>
|
||||
#{formatNumber(enactAt.end)}
|
||||
</>
|
||||
)}
|
||||
</Columar.Column>
|
||||
</Columar>
|
||||
<Columar
|
||||
is100
|
||||
size='tiny'
|
||||
>
|
||||
<Columar.Column>
|
||||
<LinkExternal
|
||||
data={id}
|
||||
type={palletReferenda}
|
||||
withTitle
|
||||
/>
|
||||
</Columar.Column>
|
||||
</Columar>
|
||||
</td>
|
||||
<td />
|
||||
</StyledTr>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledTr = styled.tr`
|
||||
.shortHash {
|
||||
max-width: var(--width-shorthash);
|
||||
min-width: 3em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: var(--width-shorthash);
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Referendum);
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { PalletReferenda, TrackDescription } from '../../types.js';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Dropdown, styled } from '@pezkuwi/react-components';
|
||||
|
||||
import { useTranslation } from '../../translate.js';
|
||||
import useTrackOptions from './useTrackOptions.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
exclude?: (BN | number)[];
|
||||
include?: (BN | number)[];
|
||||
onChange: (trackId: number) => void;
|
||||
palletReferenda: PalletReferenda;
|
||||
tracks: TrackDescription[];
|
||||
}
|
||||
|
||||
function TrackDropdown ({ className, exclude, include, onChange, palletReferenda, tracks }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const trackOpts = useTrackOptions(palletReferenda, tracks, include, exclude);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
className={className}
|
||||
defaultValue={trackOpts[0].value}
|
||||
label={t('submission track')}
|
||||
onChange={onChange}
|
||||
options={trackOpts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(styled(TrackDropdown)`
|
||||
.trackOption {
|
||||
.faded {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-normal);
|
||||
margin-top: 0.125rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -0,0 +1,307 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { RawParam } from '@pezkuwi/react-params/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
import type { PalletReferenda, TrackDescription } from '../../types.js';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Button, Dropdown, Input, InputAddress, InputBalance, InputNumber, Modal, styled, ToggleGroup, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useBestNumber, usePreimage, useToggle } from '@pezkuwi/react-hooks';
|
||||
import Params from '@pezkuwi/react-params';
|
||||
import { Available } from '@pezkuwi/react-query';
|
||||
import { getTypeDef } from '@pezkuwi/types/create';
|
||||
import { BN_HUNDRED, BN_ONE, BN_THOUSAND, BN_ZERO, isHex } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../../translate.js';
|
||||
import { getTrackInfo } from '../../util.js';
|
||||
import TrackDropdown from './TrackDropdown.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
isMember: boolean;
|
||||
members?: string[];
|
||||
palletReferenda: PalletReferenda;
|
||||
tracks: TrackDescription[];
|
||||
}
|
||||
|
||||
interface HashState {
|
||||
imageHash?: HexString | null;
|
||||
isImageHashValid: boolean;
|
||||
}
|
||||
|
||||
interface ImageState {
|
||||
imageLen: BN;
|
||||
imageLenDefault?: BN;
|
||||
isImageLenValid: boolean;
|
||||
}
|
||||
|
||||
function Submit ({ className = '', isMember, members, palletReferenda, tracks }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api, specName } = useApi();
|
||||
const bestNumber = useBestNumber();
|
||||
const [isOpen, toggleOpen] = useToggle();
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
const [trackId, setTrack] = useState<number | undefined>(undefined);
|
||||
const [origin, setOrigin] = useState<RawParam['value']>(null);
|
||||
const [{ imageHash, isImageHashValid }, setImageHash] = useState<HashState>({ imageHash: null, isImageHashValid: false });
|
||||
const [{ imageLen, imageLenDefault, isImageLenValid }, setImageLen] = useState<ImageState>({ imageLen: BN_ZERO, isImageLenValid: false });
|
||||
const [enactIndex, setEnactIndex] = useState(0);
|
||||
const [afterBlocks, setAfterBlocks] = useState<BN | undefined>(BN_HUNDRED);
|
||||
const [atBlock, setAtBlock] = useState<BN | undefined>(BN_ONE);
|
||||
const [initialAt, setInitialAt] = useState<BN | undefined>();
|
||||
const preimage = usePreimage(imageHash);
|
||||
|
||||
useEffect((): void => {
|
||||
bestNumber && setInitialAt((prev) =>
|
||||
prev || bestNumber.add(BN_THOUSAND)
|
||||
);
|
||||
}, [bestNumber]);
|
||||
|
||||
useEffect((): void => {
|
||||
preimage?.proposalLength && setImageLen((prev) => ({
|
||||
imageLen: prev.imageLen,
|
||||
imageLenDefault: preimage.proposalLength,
|
||||
isImageLenValid: prev.isImageLenValid
|
||||
}));
|
||||
}, [preimage]);
|
||||
|
||||
const trackInfo = useMemo(
|
||||
() => getTrackInfo(api, specName, palletReferenda, tracks, trackId),
|
||||
[api, palletReferenda, specName, trackId, tracks]
|
||||
);
|
||||
|
||||
const isInvalidAt = useMemo(
|
||||
() => !bestNumber || (
|
||||
enactIndex === 0
|
||||
? afterBlocks?.lt(BN_ONE)
|
||||
: atBlock?.lt(bestNumber)
|
||||
),
|
||||
[afterBlocks, atBlock, bestNumber, enactIndex]
|
||||
);
|
||||
|
||||
const originType = useMemo(
|
||||
() => [{
|
||||
name: 'origin',
|
||||
type: getTypeDef(api.tx[palletReferenda as 'referenda'].submit.meta.args[0].type.toString())
|
||||
}],
|
||||
[api, palletReferenda]
|
||||
);
|
||||
|
||||
const originOptions = useMemo(
|
||||
() => trackInfo && Array.isArray(trackInfo.origin)
|
||||
? trackInfo.origin.map((records, index) => ({
|
||||
text: Object.values(records)[0],
|
||||
value: index + 1
|
||||
}))
|
||||
: null,
|
||||
[trackInfo]
|
||||
);
|
||||
|
||||
const selectedOrigin = useMemo(
|
||||
() => !trackInfo?.origin || Array.isArray(trackInfo?.origin)
|
||||
? origin
|
||||
: trackInfo.origin,
|
||||
[origin, trackInfo]
|
||||
);
|
||||
|
||||
const enactOpts = useMemo(
|
||||
() => [
|
||||
{ text: t('After delay'), value: 'after' },
|
||||
{ text: t('At block'), value: 'at' }
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const _onChangeOriginMulti = useCallback(
|
||||
(value: number) => setOrigin(
|
||||
trackInfo && Array.isArray(trackInfo.origin)
|
||||
? trackInfo.origin[value - 1]
|
||||
: null
|
||||
),
|
||||
[trackInfo]
|
||||
);
|
||||
|
||||
const _onChangeOrigin = useCallback(
|
||||
([{ isValid, value }]: RawParam[]) =>
|
||||
setOrigin(isValid ? value : null),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onChangeImageHash = useCallback(
|
||||
(h?: string) =>
|
||||
setImageHash({
|
||||
imageHash: h as HexString,
|
||||
isImageHashValid: isHex(h, 256)
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onChangeImageLen = useCallback(
|
||||
(value?: BN): void => {
|
||||
value && setImageLen((prev) => ({
|
||||
imageLen: value,
|
||||
imageLenDefault: prev.imageLenDefault,
|
||||
isImageLenValid: !value.isZero()
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<StyledModal
|
||||
className={className}
|
||||
header={t('Submit proposal')}
|
||||
onClose={toggleOpen}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The proposal will be registered from this account and the balance lock will be applied here.')}>
|
||||
<InputAddress
|
||||
filter={members}
|
||||
label={t('propose from account')}
|
||||
labelExtra={
|
||||
<Available
|
||||
label={<span className='label'>{t('transferable')}</span>}
|
||||
params={accountId}
|
||||
/>
|
||||
}
|
||||
onChange={setAccountId}
|
||||
type='account'
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The origin (and by extension track) that you wish to submit for, each has a different period, different root and acceptance criteria.')}>
|
||||
<TrackDropdown
|
||||
onChange={setTrack}
|
||||
palletReferenda={palletReferenda}
|
||||
tracks={tracks}
|
||||
/>
|
||||
{!trackInfo?.origin && (
|
||||
<Params
|
||||
className='originSelect'
|
||||
onChange={_onChangeOrigin}
|
||||
params={originType}
|
||||
/>
|
||||
)}
|
||||
{originOptions && (
|
||||
<Dropdown
|
||||
defaultValue={originOptions[0].value}
|
||||
label={t('track origin')}
|
||||
onChange={_onChangeOriginMulti}
|
||||
options={originOptions}
|
||||
/>
|
||||
)}
|
||||
</Modal.Columns>
|
||||
<Modal.Columns
|
||||
hint={
|
||||
<>
|
||||
<p>{t('The hash of the preimage for the proposal as previously submitted or intended.')}</p>
|
||||
<p>{t('The length value will be auto-populated from the on-chain value if it is found.')}</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
isError={!isImageHashValid}
|
||||
label={t('preimage hash')}
|
||||
onChange={_onChangeImageHash}
|
||||
value={imageHash || ''}
|
||||
/>
|
||||
<InputNumber
|
||||
defaultValue={imageLenDefault}
|
||||
isDisabled={!!preimage?.proposalLength && !preimage?.proposalLength.isZero() && isImageHashValid && isImageLenValid}
|
||||
isError={!isImageLenValid}
|
||||
key='inputLength'
|
||||
label={t('preimage length')}
|
||||
onChange={_onChangeImageLen}
|
||||
value={imageLen}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns
|
||||
align='center'
|
||||
hint={t('The moment of enactment, either at a specific block, or after a specific number of blocks.')}
|
||||
>
|
||||
<ToggleGroup
|
||||
onChange={setEnactIndex}
|
||||
options={enactOpts}
|
||||
value={enactIndex}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
{enactIndex === 0
|
||||
? (
|
||||
<Modal.Columns hint={t('The number of blocks to delay enactment after proposal approval.')}>
|
||||
<InputNumber
|
||||
defaultValue={BN_HUNDRED}
|
||||
isError={isInvalidAt}
|
||||
label={t('after number of blocks')}
|
||||
onChange={setAfterBlocks}
|
||||
value={afterBlocks}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
)
|
||||
: (
|
||||
<Modal.Columns hint={t('A specific block to enact the proposal at.')}>
|
||||
<InputNumber
|
||||
defaultValue={initialAt}
|
||||
isError={isInvalidAt}
|
||||
label={t('at specific block')}
|
||||
onChange={setAtBlock}
|
||||
value={atBlock}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
)
|
||||
}
|
||||
<Modal.Columns hint={t('The deposit for this proposal will be locked for the referendum duration.')}>
|
||||
<InputBalance
|
||||
defaultValue={api.consts[palletReferenda as 'referenda'].submissionDeposit}
|
||||
isDisabled
|
||||
label={t('submission deposit')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
icon='plus'
|
||||
isDisabled={!selectedOrigin || !isImageHashValid || !isImageLenValid || !accountId || isInvalidAt || !preimage?.proposalHash}
|
||||
label={t('Submit proposal')}
|
||||
onStart={toggleOpen}
|
||||
params={[
|
||||
selectedOrigin,
|
||||
{
|
||||
Lookup: preimage
|
||||
? { hash: preimage.proposalHash, len: imageLen }
|
||||
: { hash: imageHash, len: imageLen }
|
||||
},
|
||||
enactIndex === 0
|
||||
? { After: afterBlocks }
|
||||
: { At: atBlock }
|
||||
]}
|
||||
tx={api.tx[palletReferenda as 'referenda'].submit}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</StyledModal>
|
||||
)}
|
||||
<Button
|
||||
icon='plus'
|
||||
isDisabled={!isMember}
|
||||
label={t('Submit proposal')}
|
||||
onClick={toggleOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledModal = styled(Modal)`
|
||||
.originSelect, .timeSelect {
|
||||
> .ui--Params-Content {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Submit);
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
export interface TrackOption {
|
||||
text: React.ReactNode;
|
||||
value: number;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { TrackDescription } from '../../types.js';
|
||||
import type { TrackOption } from './types.js';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi } from '@pezkuwi/react-hooks';
|
||||
import { bnToBn } from '@pezkuwi/util';
|
||||
|
||||
import { getTrackInfo, getTrackName } from '../../util.js';
|
||||
|
||||
function getTrackOptions (api: ApiPromise, specName: string, palletReferenda: string, tracks: TrackDescription[], include?: (BN | number)[], exclude?: (BN | number)[]): TrackOption[] {
|
||||
const includeBn = include?.map((v) => bnToBn(v));
|
||||
const excludeBn = exclude?.map((v) => bnToBn(v));
|
||||
|
||||
return tracks
|
||||
.filter(({ id }) =>
|
||||
(
|
||||
!includeBn ||
|
||||
includeBn.some((v) => v.eq(id))
|
||||
) && (
|
||||
!excludeBn ||
|
||||
!excludeBn.some((v) => v.eq(id))
|
||||
)
|
||||
)
|
||||
.map(({ id, info }): TrackOption => {
|
||||
const trackInfo = getTrackInfo(api, specName, palletReferenda, tracks, id.toNumber());
|
||||
const trackName = getTrackName(id, info);
|
||||
|
||||
return {
|
||||
text: trackInfo?.text
|
||||
? (
|
||||
<div className='trackOption'>
|
||||
<div className='normal'>{trackName}</div>
|
||||
<div className='faded'>{trackInfo.text}</div>
|
||||
</div>
|
||||
)
|
||||
: trackName,
|
||||
value: id.toNumber()
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function useTrackOptionsImpl (palletReferenda: string, tracks: TrackDescription[], include?: (BN | number)[], exclude?: (BN | number)[]): TrackOption[] {
|
||||
const { api, specName } = useApi();
|
||||
|
||||
return useMemo(
|
||||
() => getTrackOptions(api, specName, palletReferenda, tracks, include, exclude),
|
||||
[api, exclude, include, palletReferenda, specName, tracks]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useTrackOptions', useTrackOptionsImpl);
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { Summary as SummaryType } from '../types.js';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { FormatBalance } from '@pezkuwi/react-query';
|
||||
import { formatNumber, isFunction } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
issuanceActive?: BN;
|
||||
issuanceInactive?: BN;
|
||||
issuanceTotal?: BN;
|
||||
summary: SummaryType;
|
||||
withIssuance?: boolean;
|
||||
}
|
||||
|
||||
function Summary ({ className, issuanceActive, issuanceInactive, issuanceTotal, summary: { refActive, refCount }, withIssuance }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
|
||||
return (
|
||||
<SummaryBox className={className}>
|
||||
<section>
|
||||
<CardSummary label={t('active')}>
|
||||
{refActive === undefined
|
||||
? <span className='--tmp'>99</span>
|
||||
: formatNumber(refActive)
|
||||
}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('total')}>
|
||||
{refCount === undefined
|
||||
? <span className='--tmp'>99</span>
|
||||
: formatNumber(refCount)
|
||||
}
|
||||
</CardSummary>
|
||||
</section>
|
||||
{withIssuance && (
|
||||
<section>
|
||||
<CardSummary label={t('total issuance')}>
|
||||
<FormatBalance
|
||||
className={issuanceTotal ? '' : '--tmp'}
|
||||
value={issuanceTotal || 1}
|
||||
withSi
|
||||
/>
|
||||
</CardSummary>
|
||||
{isFunction(api.query.balances.inactiveIssuance) && (
|
||||
<>
|
||||
<CardSummary
|
||||
className='media--1000'
|
||||
label={t('inactive issuance')}
|
||||
>
|
||||
<FormatBalance
|
||||
className={issuanceInactive ? '' : '--tmp'}
|
||||
value={issuanceInactive || 1}
|
||||
withSi
|
||||
/>
|
||||
</CardSummary>
|
||||
<CardSummary
|
||||
className='media--800'
|
||||
label={t('active issuance')}
|
||||
>
|
||||
<FormatBalance
|
||||
className={issuanceActive ? '' : '--tmp'}
|
||||
value={issuanceActive || 1}
|
||||
withSi
|
||||
/>
|
||||
</CardSummary>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</SummaryBox>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Summary);
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-democracy authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { VoteTypeProps as Props } from '../types.js';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { Modal, VoteValue } from '@pezkuwi/react-components';
|
||||
|
||||
import { useTranslation } from '../../translate.js';
|
||||
|
||||
function VoteAbstain ({ accountId, id, onChange }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [balanceAbstain, setBalanceAbstain] = useState<BN | undefined>();
|
||||
const [balanceAye, setBalanceAye] = useState<BN | undefined>();
|
||||
const [balanceNay, setBalanceNay] = useState<BN | undefined>();
|
||||
|
||||
useEffect((): void => {
|
||||
onChange([id, {
|
||||
SplitAbstain: {
|
||||
abstain: balanceAbstain,
|
||||
aye: balanceAye,
|
||||
nay: balanceNay
|
||||
}
|
||||
}]);
|
||||
}, [balanceAbstain, balanceAye, balanceNay, id, onChange]);
|
||||
|
||||
return (
|
||||
<Modal.Columns hint={t('The value of the balance that is to be split to the abstain, aye and nay parts of the vote')}>
|
||||
<VoteValue
|
||||
accountId={accountId}
|
||||
autoFocus
|
||||
label={t('abstain vote value')}
|
||||
onChange={setBalanceAbstain}
|
||||
/>
|
||||
<VoteValue
|
||||
accountId={accountId}
|
||||
autoFocus
|
||||
label={t('aye vote value')}
|
||||
noDefault
|
||||
onChange={setBalanceAye}
|
||||
/>
|
||||
<VoteValue
|
||||
accountId={accountId}
|
||||
label={t('nay vote value')}
|
||||
noDefault
|
||||
onChange={setBalanceNay}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(VoteAbstain);
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-democracy authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { VoteTypeProps as Props } from '../types.js';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { Modal, VoteValue } from '@pezkuwi/react-components';
|
||||
|
||||
import { useTranslation } from '../../translate.js';
|
||||
|
||||
function VoteSplit ({ accountId, id, onChange }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [balanceAye, setBalanceAye] = useState<BN | undefined>();
|
||||
const [balanceNay, setBalanceNay] = useState<BN | undefined>();
|
||||
|
||||
useEffect((): void => {
|
||||
onChange([id, {
|
||||
Split: {
|
||||
aye: balanceAye,
|
||||
nay: balanceNay
|
||||
}
|
||||
}]);
|
||||
}, [balanceAye, balanceNay, id, onChange]);
|
||||
|
||||
return (
|
||||
<Modal.Columns hint={t('The value of the balance that is to be split to the aye and nay parts of the vote')}>
|
||||
<VoteValue
|
||||
accountId={accountId}
|
||||
autoFocus
|
||||
label={t('aye vote value')}
|
||||
onChange={setBalanceAye}
|
||||
/>
|
||||
<VoteValue
|
||||
accountId={accountId}
|
||||
label={t('nay vote value')}
|
||||
noDefault
|
||||
onChange={setBalanceNay}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(VoteSplit);
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-democracy authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { VoteTypeProps } from '../types.js';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { ConvictionDropdown, Modal, VoteValue } from '@pezkuwi/react-components';
|
||||
|
||||
import { useTranslation } from '../../translate.js';
|
||||
|
||||
interface Props extends VoteTypeProps {
|
||||
voteLockingPeriod: BN;
|
||||
}
|
||||
|
||||
function VoteStandard ({ accountId, id, isAye, onChange, voteLockingPeriod }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [balance, setBalance] = useState<BN | undefined>();
|
||||
const [conviction, setConviction] = useState(1);
|
||||
|
||||
useEffect((): void => {
|
||||
onChange([id, {
|
||||
Standard: {
|
||||
balance,
|
||||
vote: {
|
||||
aye: isAye,
|
||||
conviction
|
||||
}
|
||||
}
|
||||
}]);
|
||||
}, [balance, conviction, id, isAye, onChange]);
|
||||
|
||||
return (
|
||||
<Modal.Columns
|
||||
hint={
|
||||
<>
|
||||
<p>{t('The balance associated with the vote will be locked as per the conviction specified and will not be available for transfer during this period.')}</p>
|
||||
<p>{t('Conviction locks do overlap and are not additive, meaning that funds locked during a previous vote can be locked again.')}</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<VoteValue
|
||||
accountId={accountId}
|
||||
autoFocus
|
||||
isReferenda={true}
|
||||
label={
|
||||
isAye
|
||||
? t('aye vote value')
|
||||
: t('nay vote value')
|
||||
}
|
||||
onChange={setBalance}
|
||||
/>
|
||||
<ConvictionDropdown
|
||||
label={t('conviction')}
|
||||
onChange={setConviction}
|
||||
value={conviction}
|
||||
voteLockingPeriod={voteLockingPeriod}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(VoteStandard);
|
||||
@@ -0,0 +1,231 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-democracy authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { Preimage } from '@pezkuwi/react-hooks/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { PalletVote, TrackInfo } from '../../types.js';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { Button, Modal, styled, ToggleGroup, TxButton, VoteAccount } from '@pezkuwi/react-components';
|
||||
import { useAccounts, useApi, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { ProposedAction } from '@pezkuwi/react-params';
|
||||
|
||||
import { useTranslation } from '../../translate.js';
|
||||
import VoteAbstain from './VoteAbstain.js';
|
||||
import VoteSplit from './VoteSplit.js';
|
||||
import VoteStandard from './VoteStandard.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
id: BN;
|
||||
isConvictionVote: boolean;
|
||||
isMember: boolean;
|
||||
members?: string[];
|
||||
palletVote: PalletVote;
|
||||
preimage?: Preimage;
|
||||
ranks?: BN[];
|
||||
trackInfo?: TrackInfo;
|
||||
}
|
||||
|
||||
function filterMembers (allAccounts: string[], members?: string[], ranks?: BN[], trackInfo?: TrackInfo): string[] | undefined {
|
||||
if (members) {
|
||||
const accounts = members.filter((a) => allAccounts.includes(a));
|
||||
|
||||
if (ranks && trackInfo?.compare) {
|
||||
const cmp = trackInfo.compare;
|
||||
|
||||
return accounts.filter((_, i) => cmp(ranks[i]));
|
||||
}
|
||||
|
||||
return accounts;
|
||||
}
|
||||
|
||||
return members;
|
||||
}
|
||||
|
||||
function createVoteOpts (api: ApiPromise, t: (key: string, options?: { replace: Record<string, unknown> }) => string): { text: string, value: string }[] {
|
||||
let hasAbstain = false;
|
||||
|
||||
try {
|
||||
hasAbstain = !!api.createType('PalletConvictionVotingVoteAccountVote', { SplitAbstain: { abstain: 1 } }).isSplitAbstain;
|
||||
} catch {
|
||||
hasAbstain = false;
|
||||
}
|
||||
|
||||
return hasAbstain
|
||||
? [
|
||||
{ text: t('Aye'), value: 'aye' },
|
||||
{ text: t('Nay'), value: 'nay' },
|
||||
{ text: t('Split'), value: 'split' },
|
||||
{ text: t('Abstain'), value: 'abstain' }
|
||||
]
|
||||
: [
|
||||
{ text: t('Aye'), value: 'aye' },
|
||||
{ text: t('Nay'), value: 'nay' },
|
||||
{ text: t('Split'), value: 'split' }
|
||||
];
|
||||
}
|
||||
|
||||
function Voting ({ className, id, isConvictionVote, isMember, members, palletVote, preimage, ranks, trackInfo }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const { allAccounts, hasAccounts } = useAccounts();
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
const [isOpen, toggleOpen] = useToggle();
|
||||
const [voteTypeIndex, setVoteTypeIndex] = useState(0);
|
||||
const [params, setParams] = useState<unknown[] | undefined>();
|
||||
|
||||
// Create the options for the vote toggle using the type of vote and also inspecting
|
||||
// the actual support of the on-chain runtime (e.g. Abstentions)
|
||||
const voteTypeOpts = useMemo(
|
||||
() => createVoteOpts(api, t),
|
||||
[api, t]
|
||||
);
|
||||
|
||||
const filteredMembers = useMemo(
|
||||
() => filterMembers(allAccounts, members, ranks, trackInfo),
|
||||
[allAccounts, members, ranks, trackInfo]
|
||||
);
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() => !isMember || (
|
||||
members && filteredMembers
|
||||
? !filteredMembers.length
|
||||
: false
|
||||
),
|
||||
[filteredMembers, isMember, members]
|
||||
);
|
||||
|
||||
if (!hasAccounts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<StyledModal
|
||||
className={className}
|
||||
header={t('Vote on referendum')}
|
||||
onClose={toggleOpen}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
{preimage && (
|
||||
<Modal.Columns hint={t('If this proposal is passed, the changes will be applied via dispatch and the deposit returned.')}>
|
||||
<ProposedAction
|
||||
idNumber={id}
|
||||
proposal={preimage.proposal}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
)}
|
||||
<Modal.Columns hint={t('The vote will be recorded for this account. If another account delegated to this one, the delegated votes will also be counted.')}>
|
||||
<VoteAccount
|
||||
filter={filteredMembers}
|
||||
onChange={setAccountId}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
{isConvictionVote && (
|
||||
<>
|
||||
<Modal.Columns
|
||||
className='centerVoteType'
|
||||
hint={t('The type of vote that you wish to cast on the referendum.')}
|
||||
>
|
||||
<ToggleGroup
|
||||
onChange={setVoteTypeIndex}
|
||||
options={voteTypeOpts}
|
||||
value={voteTypeIndex}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
{voteTypeIndex === 0
|
||||
? (
|
||||
<VoteStandard
|
||||
accountId={accountId}
|
||||
id={id}
|
||||
isAye
|
||||
onChange={setParams}
|
||||
voteLockingPeriod={api.consts[palletVote as 'convictionVoting'].voteLockingPeriod}
|
||||
/>
|
||||
)
|
||||
: voteTypeIndex === 1
|
||||
? (
|
||||
<VoteStandard
|
||||
accountId={accountId}
|
||||
id={id}
|
||||
onChange={setParams}
|
||||
voteLockingPeriod={api.consts[palletVote as 'convictionVoting'].voteLockingPeriod}
|
||||
/>
|
||||
)
|
||||
: voteTypeIndex === 2
|
||||
? (
|
||||
<VoteSplit
|
||||
accountId={accountId}
|
||||
id={id}
|
||||
onChange={setParams}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<VoteAbstain
|
||||
accountId={accountId}
|
||||
id={id}
|
||||
onChange={setParams}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
{isConvictionVote
|
||||
? (
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
icon='check-to-slot'
|
||||
label={t('Vote')}
|
||||
onStart={toggleOpen}
|
||||
params={params}
|
||||
tx={api.tx[palletVote].vote}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
icon='ban'
|
||||
label={t('Vote Nay')}
|
||||
onStart={toggleOpen}
|
||||
params={[id, false]}
|
||||
tx={api.tx[palletVote].vote}
|
||||
/>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
icon='check'
|
||||
label={t('Vote Aye')}
|
||||
onStart={toggleOpen}
|
||||
params={[id, true]}
|
||||
tx={api.tx[palletVote].vote}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Modal.Actions>
|
||||
</StyledModal>
|
||||
)}
|
||||
<Button
|
||||
icon='check-to-slot'
|
||||
isDisabled={isDisabled}
|
||||
label={t('Vote')}
|
||||
onClick={toggleOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledModal = styled(Modal)`
|
||||
.ui--Modal-Columns.centerVoteType > div:first-child {
|
||||
text-align: center;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Voting);
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletConvictionVotingTally, PalletRankedCollectiveTally, PalletRankedCollectiveVoteRecord } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { PalletVote } from '../types.js';
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import { AddressMini, Expander } from '@pezkuwi/react-components';
|
||||
import { FormatBalance } from '@pezkuwi/react-query';
|
||||
import { formatNumber } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import useVotes from './useVotes.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
id: BN;
|
||||
isConvictionVote: boolean;
|
||||
palletVote: PalletVote;
|
||||
tally: PalletConvictionVotingTally | PalletRankedCollectiveTally;
|
||||
}
|
||||
|
||||
function renderMini (list?: [string, BN][]): React.ReactNode[] | undefined {
|
||||
return list
|
||||
?.sort(([, a], [, b]) => b.cmp(a))
|
||||
.map(([a]) => (
|
||||
<AddressMini
|
||||
key={a}
|
||||
value={a}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
function extractVotes (votes: Record<string, PalletRankedCollectiveVoteRecord> = {}): [[string, BN][]?, [string, BN][]?] {
|
||||
const ayes: [string, BN][] = [];
|
||||
const nays: [string, BN][] = [];
|
||||
const entries = Object.entries(votes);
|
||||
|
||||
for (let i = 0, count = entries.length; i < count; i++) {
|
||||
const [accountId, vote] = entries[i];
|
||||
|
||||
if (vote.isAye) {
|
||||
ayes.push([accountId, vote.asAye]);
|
||||
} else {
|
||||
nays.push([accountId, vote.asNay]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
ayes.length ? ayes : undefined,
|
||||
nays.length ? nays : undefined
|
||||
];
|
||||
}
|
||||
|
||||
function Votes ({ className = '', id, isConvictionVote, palletVote, tally }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const votes = useVotes(palletVote, id, isConvictionVote);
|
||||
|
||||
const [ayes, nays] = useMemo(
|
||||
() => extractVotes(votes),
|
||||
[votes]
|
||||
);
|
||||
|
||||
const renderAyes = useCallback(
|
||||
() => renderMini(ayes),
|
||||
[ayes]
|
||||
);
|
||||
|
||||
const renderNays = useCallback(
|
||||
() => renderMini(nays),
|
||||
[nays]
|
||||
);
|
||||
|
||||
return (
|
||||
<td className={`${className} expand media--1200-noPad`}>
|
||||
<Expander
|
||||
className='media--1200'
|
||||
renderChildren={ayes && renderAyes}
|
||||
summary={
|
||||
isConvictionVote
|
||||
? (
|
||||
<>
|
||||
{t('Aye')}
|
||||
<div><FormatBalance value={tally.ayes} /></div>
|
||||
</>
|
||||
)
|
||||
: t('Aye {{count}}', { replace: { count: formatNumber(tally.ayes) } })
|
||||
}
|
||||
/>
|
||||
<Expander
|
||||
className='media--1200'
|
||||
renderChildren={nays && renderNays}
|
||||
summary={
|
||||
isConvictionVote
|
||||
? (
|
||||
<>
|
||||
{t('Nay')}
|
||||
<div><FormatBalance value={tally.nays} /></div>
|
||||
</>
|
||||
)
|
||||
: t('Nay {{count}}', { replace: { count: formatNumber(tally.nays) } })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Votes);
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { PalletReferenda, PalletVote, ReferendaGroup } from '../types.js';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import AddPreimage from '@pezkuwi/app-preimages/Preimages/Add';
|
||||
import { Button, Dropdown, styled } from '@pezkuwi/react-components';
|
||||
import { useAccounts, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import useReferenda from '../useReferenda.js';
|
||||
import useSummary from '../useSummary.js';
|
||||
import Delegate from './Delegate/index.js';
|
||||
import Submit from './Submit/index.js';
|
||||
import Group from './Group.js';
|
||||
import Summary from './Summary.js';
|
||||
|
||||
export { useCounterNamed as useCounter } from '../useCounter.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
isConvictionVote?: boolean;
|
||||
members?: string[];
|
||||
palletReferenda: PalletReferenda;
|
||||
palletVote: PalletVote;
|
||||
ranks?: BN[];
|
||||
}
|
||||
|
||||
function Referenda ({ className, isConvictionVote, members, palletReferenda, palletVote, ranks }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const totalIssuance = useCall<BN | undefined>(api.query.balances.totalIssuance);
|
||||
const inactiveIssuance = useCall<BN | undefined>(api.query.balances.inactiveIssuance);
|
||||
const { allAccounts } = useAccounts();
|
||||
const [grouped, tracks] = useReferenda(palletReferenda);
|
||||
const summary = useSummary(palletReferenda, grouped);
|
||||
const [trackSelected, setTrackSelected] = useState(-1);
|
||||
|
||||
const activeIssuance = useMemo(
|
||||
() => totalIssuance?.sub(inactiveIssuance || BN_ZERO),
|
||||
[inactiveIssuance, totalIssuance]
|
||||
);
|
||||
|
||||
const trackOpts = useMemo(
|
||||
() => [{ text: t('All active/available tracks'), value: -1 }].concat(
|
||||
grouped
|
||||
.map(({ trackId, trackName }) => ({
|
||||
text: trackName,
|
||||
value: trackId ? trackId.toNumber() : -1
|
||||
}))
|
||||
.filter((v): v is { text: string, value: number } => !!v.text)
|
||||
),
|
||||
[grouped, t]
|
||||
);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => (
|
||||
trackSelected === -1
|
||||
? grouped
|
||||
: grouped.filter(({ trackId }) => !!trackId && trackId.eqn(trackSelected))
|
||||
) || [{ referenda: [] }],
|
||||
[grouped, trackSelected]
|
||||
);
|
||||
|
||||
const isMember = useMemo(
|
||||
() => !members || allAccounts.some((a) => members.includes(a)),
|
||||
[allAccounts, members]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledDiv className={className}>
|
||||
<Summary
|
||||
issuanceActive={activeIssuance}
|
||||
issuanceInactive={inactiveIssuance}
|
||||
issuanceTotal={totalIssuance}
|
||||
summary={summary}
|
||||
withIssuance={isConvictionVote}
|
||||
/>
|
||||
<Button.Group>
|
||||
<Dropdown
|
||||
className='topDropdown media--800'
|
||||
label={t('selected track')}
|
||||
onChange={setTrackSelected}
|
||||
options={trackOpts}
|
||||
value={trackSelected}
|
||||
/>
|
||||
{isConvictionVote && (
|
||||
<Delegate
|
||||
palletReferenda={palletReferenda}
|
||||
palletVote={palletVote}
|
||||
tracks={tracks}
|
||||
/>
|
||||
)}
|
||||
<AddPreimage />
|
||||
<Submit
|
||||
isMember={isMember}
|
||||
members={members}
|
||||
palletReferenda={palletReferenda}
|
||||
tracks={tracks}
|
||||
/>
|
||||
</Button.Group>
|
||||
{filtered.map(({ key, referenda, trackId, trackName }: ReferendaGroup) => (
|
||||
<Group
|
||||
activeIssuance={activeIssuance}
|
||||
isMember={isMember}
|
||||
key={key}
|
||||
members={members}
|
||||
palletReferenda={palletReferenda}
|
||||
palletVote={palletVote}
|
||||
ranks={ranks}
|
||||
referenda={referenda}
|
||||
trackId={trackId}
|
||||
trackName={trackName}
|
||||
tracks={tracks}
|
||||
/>
|
||||
))}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
.ui--Dropdown.topDropdown {
|
||||
min-width: 25rem;
|
||||
padding-left: 0;
|
||||
|
||||
> label {
|
||||
left: 1.55rem !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Referenda);
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-democracy authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
export interface VoteTypeProps {
|
||||
accountId: string | null;
|
||||
id: BN | number;
|
||||
isAye?: boolean;
|
||||
onChange: (params: unknown[]) => void;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-preimages authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { Changes } from '@pezkuwi/react-hooks/useEventChanges';
|
||||
import type { Option, StorageKey, u32 } from '@pezkuwi/types';
|
||||
import type { AccountId, EventRecord } from '@pezkuwi/types/interfaces';
|
||||
import type { PalletRankedCollectiveVoteRecord } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { PalletVote } from '../types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall, useEventChanges, useMapKeys } from '@pezkuwi/react-hooks';
|
||||
|
||||
const OPT_ACCOUNTID = {
|
||||
transform: (keys: StorageKey<[u32, AccountId]>[]): AccountId[] =>
|
||||
keys.map(({ args: [, accountId] }) => accountId)
|
||||
};
|
||||
|
||||
const OPT_VOTES = {
|
||||
transform: ([[params], votes]: [[[[BN, AccountId][]]], Option<PalletRankedCollectiveVoteRecord>[]]): Record<string, PalletRankedCollectiveVoteRecord> =>
|
||||
params.reduce<Record<string, PalletRankedCollectiveVoteRecord>>((all, [, a], i) => {
|
||||
if (votes[i] && votes[i].isSome) {
|
||||
all[a.toString()] = votes[i].unwrap();
|
||||
}
|
||||
|
||||
return all;
|
||||
}, {}),
|
||||
withParamsTransform: true
|
||||
};
|
||||
|
||||
function filterEvents (records: EventRecord[], _: ApiPromise, id?: BN): Changes<AccountId> {
|
||||
const added: AccountId[] = [];
|
||||
|
||||
records.forEach(({ event: { data: [who, pollId] } }): void => {
|
||||
if (id && pollId.eq(id)) {
|
||||
added.push(who as AccountId);
|
||||
}
|
||||
});
|
||||
|
||||
return { added };
|
||||
}
|
||||
|
||||
function useVotesImpl (palletVote: PalletVote, id: BN, isConvictionVote: boolean): Record<string, PalletRankedCollectiveVoteRecord> | undefined {
|
||||
const { api } = useApi();
|
||||
|
||||
// After v1.4.0 runtime upgrade, Relay chains i.e. Dicle and Pezkuwi, or other teyrchains chains, replaced `voting` method with `votingFor`.
|
||||
// Adding a safety check here so that app doesn't break
|
||||
const query = useMemo(() => api.query[palletVote].voting ?? api.query[palletVote].votingFor, [api.query, palletVote]);
|
||||
|
||||
const startAccounts = useMapKeys(isConvictionVote === false && query, [id], OPT_ACCOUNTID);
|
||||
const allAccounts = useEventChanges([
|
||||
api.events[palletVote].Voted
|
||||
], filterEvents, startAccounts, id);
|
||||
|
||||
const params = useMemo(
|
||||
() => allAccounts?.map((a) => [id, a]),
|
||||
[allAccounts, id]
|
||||
);
|
||||
|
||||
return useCall(params && query?.multi, [params], OPT_VOTES);
|
||||
}
|
||||
|
||||
export default createNamedHook('useVotes', useVotesImpl);
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletReferendaDeposit } from '@pezkuwi/types/lookup';
|
||||
import type { Referendum } from '../types.js';
|
||||
|
||||
import { Option } from '@pezkuwi/types';
|
||||
|
||||
export function unwrapDeposit (value: PalletReferendaDeposit | Option<PalletReferendaDeposit>): PalletReferendaDeposit | null {
|
||||
return value instanceof Option
|
||||
? value.unwrapOr(null)
|
||||
: value;
|
||||
}
|
||||
|
||||
export function getNumDeciding (referenda?: Referendum[]): number {
|
||||
if (!referenda) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return referenda.filter(({ info }) =>
|
||||
info.isOngoing &&
|
||||
info.asOngoing.deciding.isSome
|
||||
).length;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { Tabs } from '@pezkuwi/react-components';
|
||||
|
||||
import Referenda from './Referenda/index.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
export { default as useCounter } from './useCounter.js';
|
||||
|
||||
interface Props {
|
||||
basePath: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function App ({ basePath, className }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tabsRef = useRef([
|
||||
{
|
||||
isRoot: true,
|
||||
name: 'overview',
|
||||
text: t('Overview')
|
||||
}
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className={className}>
|
||||
<Tabs
|
||||
basePath={basePath}
|
||||
items={tabsRef.current}
|
||||
/>
|
||||
<Referenda
|
||||
isConvictionVote
|
||||
palletReferenda='referenda'
|
||||
palletVote='convictionVoting'
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(App);
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useTranslation as useTranslationBase } from 'react-i18next';
|
||||
|
||||
export function useTranslation (): { t: (key: string, options?: { replace: Record<string, unknown> }) => string } {
|
||||
return useTranslationBase('app-referenda');
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletReferendaReferendumInfoConvictionVotingTally, PalletReferendaReferendumInfoRankedCollectiveTally, PalletReferendaTrackDetails } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
export type PalletReferenda = 'referenda' | 'rankedPolls' | 'fellowshipReferenda'| 'ambassadorReferenda';
|
||||
|
||||
export type PalletVote = 'convictionVoting' | 'rankedCollective' | 'fellowshipCollective' | 'ambassadorCollective';
|
||||
|
||||
export interface ReferendaGroup {
|
||||
key: string;
|
||||
track?: PalletReferendaTrackDetails;
|
||||
trackGraph?: CurveGraph;
|
||||
trackId?: BN;
|
||||
trackName?: string;
|
||||
referenda?: Referendum[];
|
||||
}
|
||||
|
||||
export interface ReferendaGroupKnown extends ReferendaGroup {
|
||||
referenda: Referendum[];
|
||||
}
|
||||
|
||||
export interface Referendum {
|
||||
decidingEnd?: BN;
|
||||
id: BN;
|
||||
info: PalletReferendaReferendumInfoConvictionVotingTally | PalletReferendaReferendumInfoRankedCollectiveTally;
|
||||
isConvictionVote: boolean;
|
||||
key: string;
|
||||
track?: PalletReferendaTrackDetails;
|
||||
trackId?: BN;
|
||||
trackGraph?: CurveGraph;
|
||||
}
|
||||
|
||||
export interface ReferendumProps {
|
||||
className?: string;
|
||||
activeIssuance?: BN;
|
||||
isMember: boolean;
|
||||
members?: string[];
|
||||
onExpand?: () => void;
|
||||
palletReferenda: PalletReferenda;
|
||||
palletVote: PalletVote;
|
||||
ranks?: BN[];
|
||||
trackInfo?: TrackInfo;
|
||||
value: Referendum;
|
||||
}
|
||||
|
||||
export interface Summary {
|
||||
deciding?: BN;
|
||||
refActive?: number;
|
||||
refCount?: BN;
|
||||
}
|
||||
|
||||
export interface CurveGraph {
|
||||
approval: BN[];
|
||||
support: BN[];
|
||||
x: BN[];
|
||||
}
|
||||
|
||||
export interface TrackDescription {
|
||||
graph: CurveGraph;
|
||||
id: BN;
|
||||
info: PalletReferendaTrackDetails;
|
||||
}
|
||||
|
||||
export interface TrackInfo {
|
||||
compare?: (input: BN) => boolean;
|
||||
origin: Record<string, string> | Record<string, string>[];
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface TrackInfoExt extends TrackInfo {
|
||||
track: TrackDescription;
|
||||
trackName: string;
|
||||
}
|
||||
|
||||
export interface Lock {
|
||||
classId: BN;
|
||||
endBlock: BN;
|
||||
locked: string;
|
||||
refId: BN;
|
||||
total: BN;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { Option } from '@pezkuwi/types';
|
||||
import type { PalletConvictionVotingVoteCasting, PalletConvictionVotingVoteVoting, PalletReferendaReferendumInfoConvictionVotingTally } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { Lock, PalletReferenda, PalletVote } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { CONVICTIONS } from '@pezkuwi/react-components/ConvictionDropdown';
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { BN_MAX_INTEGER } from '@pezkuwi/util';
|
||||
|
||||
const OPT_CLASS = {
|
||||
transform: (locks: [BN, BN][]): BN[] =>
|
||||
locks.map(([classId]) => classId)
|
||||
};
|
||||
|
||||
const OPT_VOTES = {
|
||||
transform: ([[params], votes]: [[[string, BN][]], PalletConvictionVotingVoteVoting[]]): [classId: BN, refIds: BN[], casting: PalletConvictionVotingVoteCasting][] =>
|
||||
votes
|
||||
.map((v, index): null | [BN, BN[], PalletConvictionVotingVoteCasting] => {
|
||||
if (!v.isCasting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const casting = v.asCasting;
|
||||
|
||||
return [
|
||||
params[index][1],
|
||||
casting.votes.map(([refId]) => refId),
|
||||
casting
|
||||
];
|
||||
})
|
||||
.filter((v): v is [BN, BN[], PalletConvictionVotingVoteCasting] => !!v),
|
||||
withParamsTransform: true
|
||||
};
|
||||
|
||||
const OPT_REFS = {
|
||||
transform: ([[params], optTally]: [[BN[]], Option<PalletReferendaReferendumInfoConvictionVotingTally>[]]): [BN, PalletReferendaReferendumInfoConvictionVotingTally][] =>
|
||||
optTally
|
||||
.map((v, index): null | [BN, PalletReferendaReferendumInfoConvictionVotingTally] =>
|
||||
v.isSome
|
||||
? [params[index], v.unwrap()]
|
||||
: null
|
||||
)
|
||||
.filter((v): v is [BN, PalletReferendaReferendumInfoConvictionVotingTally] => !!v),
|
||||
withParamsTransform: true
|
||||
};
|
||||
|
||||
function getVoteParams (accountId: string, lockClasses?: BN[]): [[accountId: string, classId: BN][]] | undefined {
|
||||
if (lockClasses) {
|
||||
return [lockClasses.map((classId) => [accountId, classId])];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getRefParams (votes?: [classId: BN, refIds: BN[], casting: PalletConvictionVotingVoteCasting][]): [BN[]] | undefined {
|
||||
if (votes?.length) {
|
||||
const refIds = votes.reduce<BN[]>((all, [, refIds]) => all.concat(refIds), []);
|
||||
|
||||
if (refIds.length) {
|
||||
return [refIds];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getLocks (api: ApiPromise, palletVote: PalletVote, votes: [classId: BN, refIds: BN[], casting: PalletConvictionVotingVoteCasting][], referenda: [BN, PalletReferendaReferendumInfoConvictionVotingTally][]): Lock[] {
|
||||
const lockPeriod = api.consts[palletVote].voteLockingPeriod as BN;
|
||||
const locks: Lock[] = [];
|
||||
|
||||
for (let i = 0, voteCount = votes.length; i < voteCount; i++) {
|
||||
const [classId,, casting] = votes[i];
|
||||
|
||||
for (let i = 0, castCount = casting.votes.length; i < castCount; i++) {
|
||||
const [refId, accountVote] = casting.votes[i];
|
||||
const refInfo = referenda.find(([id]) => id.eq(refId));
|
||||
|
||||
if (refInfo) {
|
||||
const [, tally] = refInfo;
|
||||
let total: BN | undefined;
|
||||
let endBlock: BN| undefined;
|
||||
let convictionIndex = 0;
|
||||
let locked = 'None';
|
||||
const durationIndex = 1;
|
||||
|
||||
if (accountVote.isStandard) {
|
||||
const { balance, vote } = accountVote.asStandard;
|
||||
|
||||
total = balance;
|
||||
|
||||
if ((tally.isApproved && vote.isAye) || (tally.isRejected && vote.isNay)) {
|
||||
convictionIndex = vote.conviction.index;
|
||||
locked = vote.conviction.type;
|
||||
}
|
||||
} else if (accountVote.isSplit) {
|
||||
const { aye, nay } = accountVote.asSplit;
|
||||
|
||||
total = aye.add(nay);
|
||||
} else if (accountVote.isSplitAbstain) {
|
||||
const { abstain, aye, nay } = accountVote.asSplitAbstain;
|
||||
|
||||
total = aye.add(nay).add(abstain);
|
||||
} else {
|
||||
console.error(`Unable to handle ${accountVote.type}`);
|
||||
}
|
||||
|
||||
if (tally.isOngoing) {
|
||||
endBlock = BN_MAX_INTEGER;
|
||||
} else if (tally.isKilled) {
|
||||
endBlock = tally.asKilled;
|
||||
} else if (tally.isCancelled || tally.isTimedOut) {
|
||||
endBlock = tally.isCancelled
|
||||
? tally.asCancelled[0]
|
||||
: tally.asTimedOut[0];
|
||||
} else if (tally.isApproved || tally.isRejected) {
|
||||
endBlock = lockPeriod
|
||||
.muln(convictionIndex ? CONVICTIONS[convictionIndex - 1][durationIndex] : 0)
|
||||
.add(
|
||||
tally.isApproved
|
||||
? tally.asApproved[0]
|
||||
: tally.asRejected[0]
|
||||
);
|
||||
} else {
|
||||
console.error(`Unable to handle ${tally.type}`);
|
||||
}
|
||||
|
||||
if (total && endBlock) {
|
||||
locks.push({ classId, endBlock, locked, refId, total });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return locks;
|
||||
}
|
||||
|
||||
function useAccountLocksImpl (palletReferenda: PalletReferenda, palletVote: PalletVote, accountId: string): Lock[] | undefined {
|
||||
const { api } = useApi();
|
||||
|
||||
// retrieve the locks for the account (all classes) via the accountId
|
||||
const lockParams = useMemo(
|
||||
() => [accountId],
|
||||
[accountId]
|
||||
);
|
||||
|
||||
const lockClasses = useCall<BN[] | undefined>(lockParams && api.query[palletVote]?.classLocksFor, lockParams, OPT_CLASS);
|
||||
|
||||
// retrieve the specific votes casted over the classes & accountId
|
||||
const voteParams = useMemo(
|
||||
() => getVoteParams(accountId, lockClasses),
|
||||
[accountId, lockClasses]
|
||||
);
|
||||
|
||||
const votes = useCall<[BN, BN[], PalletConvictionVotingVoteCasting][] | undefined>(voteParams && api.query[palletVote]?.votingFor.multi, voteParams, OPT_VOTES);
|
||||
|
||||
// retrieve the referendums that were voted on
|
||||
const refParams = useMemo(
|
||||
() => getRefParams(votes),
|
||||
[votes]
|
||||
);
|
||||
|
||||
const referenda = useCall(refParams && api.query[palletReferenda]?.referendumInfoFor.multi, refParams, OPT_REFS);
|
||||
|
||||
// combine the referenda outcomes and the votes into locks
|
||||
return useMemo(
|
||||
() => votes && referenda && getLocks(api, palletVote, votes, referenda),
|
||||
[api, palletVote, referenda, votes]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useAccountLocks', useAccountLocksImpl);
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletReferenda } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook } from '@pezkuwi/react-hooks';
|
||||
|
||||
import useReferenda from './useReferenda.js';
|
||||
|
||||
export function useCounterNamed (palletReferenda: PalletReferenda): number {
|
||||
const [grouped] = useReferenda(palletReferenda);
|
||||
|
||||
return useMemo(
|
||||
() => grouped
|
||||
? grouped.reduce((total, { referenda }) =>
|
||||
total + (
|
||||
referenda
|
||||
? referenda.filter(({ info }) => info.isOngoing).length
|
||||
: 0
|
||||
), 0)
|
||||
: 0,
|
||||
[grouped]
|
||||
);
|
||||
}
|
||||
|
||||
function useCounterImpl (): number {
|
||||
return useCounterNamed('referenda');
|
||||
}
|
||||
|
||||
export default createNamedHook('useCounter', useCounterImpl);
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Option } from '@pezkuwi/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { PalletReferenda, ReferendaGroup, ReferendaGroupKnown, Referendum, TrackDescription } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
|
||||
import useReferendaIds from './useReferendaIds.js';
|
||||
import useTracks from './useTracks.js';
|
||||
import { calcDecidingEnd, getTrackName, isConvictionVote } from './util.js';
|
||||
|
||||
function sortOngoing (a: Referendum, b: Referendum): number {
|
||||
const ao = a.info.asOngoing;
|
||||
const bo = b.info.asOngoing;
|
||||
|
||||
return ao.track.cmp(bo.track) || (
|
||||
ao.deciding.isSome === bo.deciding.isSome
|
||||
? ao.deciding.isSome
|
||||
? a.info.asOngoing.deciding.unwrap().since.cmp(
|
||||
b.info.asOngoing.deciding.unwrap().since
|
||||
)
|
||||
: 0
|
||||
: ao.deciding.isSome
|
||||
? -1
|
||||
: 1
|
||||
);
|
||||
}
|
||||
|
||||
function sortReferenda (a: Referendum, b: Referendum): number {
|
||||
return (
|
||||
a.info.isOngoing === b.info.isOngoing
|
||||
? a.info.isOngoing
|
||||
? sortOngoing(a, b)
|
||||
: 0
|
||||
: a.info.isOngoing
|
||||
? -1
|
||||
: 1
|
||||
) || b.id.cmp(a.id);
|
||||
}
|
||||
|
||||
function sortGroups (a: ReferendaGroupKnown, b: ReferendaGroupKnown): number {
|
||||
return a.trackId && b.trackId
|
||||
? a.trackId.cmp(b.trackId)
|
||||
: a.trackId
|
||||
? -1
|
||||
: 1;
|
||||
}
|
||||
|
||||
const OPT_MULTI = {
|
||||
transform: ([[ids], all]: [[BN[]], Option<Referendum['info']>[]]) =>
|
||||
ids.map((id, i) => {
|
||||
const infoOpt = all[i];
|
||||
const info = infoOpt?.isSome ? infoOpt.unwrap() : undefined;
|
||||
|
||||
return {
|
||||
id,
|
||||
info,
|
||||
isConvictionVote: info ? isConvictionVote(info) : false,
|
||||
key: id.toString()
|
||||
};
|
||||
}).filter((r) => r.info !== undefined),
|
||||
withParamsTransform: true
|
||||
};
|
||||
|
||||
function group (tracks: TrackDescription[], totalIssuance?: BN, referenda?: Referendum[]): ReferendaGroup[] {
|
||||
if (!referenda || !totalIssuance) {
|
||||
// return an empty group when we have no referenda
|
||||
return [{ key: 'empty' }];
|
||||
} else if (!tracks) {
|
||||
// if we have no tracks, we just return the referenda sorted
|
||||
return [{ key: 'referenda', referenda: referenda.sort(sortReferenda) }];
|
||||
}
|
||||
|
||||
const grouped: ReferendaGroupKnown[] = [];
|
||||
const other: ReferendaGroupKnown = { key: 'referenda', referenda: [] };
|
||||
|
||||
// sort the referenda by track inside groups
|
||||
for (let i = 0, count = referenda.length; i < count; i++) {
|
||||
const ref = referenda[i];
|
||||
|
||||
if (!ref.info || !ref.info.isOngoing) {
|
||||
// info is undefined or not ongoing — can't get track
|
||||
other.referenda.push(ref);
|
||||
continue;
|
||||
}
|
||||
|
||||
const trackInfo = tracks.find(({ id }) =>
|
||||
id?.eq && id.eq(ref.info.asOngoing.track)
|
||||
);
|
||||
|
||||
if (trackInfo) {
|
||||
ref.trackGraph = trackInfo.graph;
|
||||
ref.trackId = trackInfo.id;
|
||||
ref.track = trackInfo.info;
|
||||
|
||||
if (ref.isConvictionVote) {
|
||||
const { deciding, tally } = ref.info.asOngoing;
|
||||
|
||||
if (deciding.isSome) {
|
||||
const { since } = deciding.unwrap();
|
||||
|
||||
ref.decidingEnd = calcDecidingEnd(totalIssuance, tally, trackInfo.info, since);
|
||||
}
|
||||
}
|
||||
|
||||
const group = grouped.find(({ track }) => ref.track === track);
|
||||
|
||||
if (!group) {
|
||||
// we don't have a group as of yet, create one
|
||||
grouped.push({
|
||||
key: `track:${ref.trackId.toString()}`,
|
||||
referenda: [ref],
|
||||
track: ref.track,
|
||||
trackGraph: ref.trackGraph,
|
||||
trackId: ref.trackId,
|
||||
trackName: getTrackName(ref.trackId, ref.track)
|
||||
});
|
||||
} else {
|
||||
// existing group, just add the referendum
|
||||
group.referenda.push(ref);
|
||||
}
|
||||
} else {
|
||||
// if we have no track, we just add it to "other"
|
||||
other.referenda.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
// if we do have items in "other", we add it (or if none, then empty other)
|
||||
if ((other.referenda && other.referenda.length !== 0) || !grouped.length) {
|
||||
grouped.push(other);
|
||||
}
|
||||
|
||||
// sort referenda per group
|
||||
for (let i = 0, count = grouped.length; i < count; i++) {
|
||||
grouped[i].referenda.sort(sortReferenda);
|
||||
}
|
||||
|
||||
// sort all groups
|
||||
return grouped.sort(sortGroups);
|
||||
}
|
||||
|
||||
function useReferendaImpl (palletReferenda: PalletReferenda): [ReferendaGroup[], TrackDescription[]] {
|
||||
const { api, isApiReady } = useApi();
|
||||
const totalIssuance = useCall<BN>(isApiReady && api.query.balances.totalIssuance);
|
||||
const ids = useReferendaIds(palletReferenda);
|
||||
const tracks = useTracks(palletReferenda);
|
||||
const referenda = useCall(isApiReady && ids && ids.length !== 0 && api.query[palletReferenda as 'referenda'].referendumInfoFor.multi, [ids], OPT_MULTI);
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
(ids && ids.length === 0)
|
||||
? [{ key: 'referenda', referenda: [] }]
|
||||
: group(tracks, totalIssuance, (referenda as Referendum[])),
|
||||
tracks
|
||||
],
|
||||
[ids, referenda, totalIssuance, tracks]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useReferenda', useReferendaImpl);
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Changes } from '@pezkuwi/react-hooks/useEventChanges';
|
||||
import type { StorageKey, u32 } from '@pezkuwi/types';
|
||||
import type { EventRecord } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { PalletReferenda } from './types.js';
|
||||
|
||||
import { createNamedHook, useApi, useEventChanges, useMapKeys } from '@pezkuwi/react-hooks';
|
||||
|
||||
const OPT_ID = {
|
||||
transform: (keys: StorageKey<[u32]>[]): u32[] =>
|
||||
keys.map(({ args: [id] }) => id)
|
||||
};
|
||||
|
||||
function filter (records: EventRecord[]): Changes<u32> {
|
||||
const added: u32[] = [];
|
||||
const removed: u32[] = [];
|
||||
|
||||
records.forEach(({ event: { data: [id], method } }): void => {
|
||||
if (method === 'Submitted') {
|
||||
added.push(id as u32);
|
||||
} else {
|
||||
removed.push(id as u32);
|
||||
}
|
||||
});
|
||||
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
function useReferendaIdsImpl (palletReferenda: PalletReferenda): BN[] | undefined {
|
||||
const { api } = useApi();
|
||||
const startValue = useMapKeys(api.query[palletReferenda].referendumInfoFor, [], OPT_ID);
|
||||
|
||||
return useEventChanges([
|
||||
api.events[palletReferenda].Submitted
|
||||
], filter, startValue);
|
||||
}
|
||||
|
||||
export default createNamedHook('useReferendaIds', useReferendaIdsImpl);
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { u32 } from '@pezkuwi/types';
|
||||
import type { PalletReferenda, ReferendaGroup, Summary } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
|
||||
function calcActive (grouped: ReferendaGroup[] = []): number {
|
||||
return grouped.reduce((total, { referenda = [] }) =>
|
||||
total + referenda.filter((r) =>
|
||||
r.info.isOngoing
|
||||
).length,
|
||||
0);
|
||||
}
|
||||
|
||||
function useSummaryImpl (palletReferenda: PalletReferenda, grouped?: ReferendaGroup[] | undefined): Summary {
|
||||
const { api } = useApi();
|
||||
const refCount = useCall<u32>(api.query[palletReferenda].referendumCount);
|
||||
const refActive = useMemo(
|
||||
() => calcActive(grouped),
|
||||
[grouped]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({ refActive, refCount }),
|
||||
[refActive, refCount]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useSummary', useSummaryImpl);
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletReferendaTrackDetails } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { PalletReferenda, TrackDescription } from './types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi } from '@pezkuwi/react-hooks';
|
||||
import { BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
import { calcCurves } from './util.js';
|
||||
|
||||
const zeroGraph = { approval: [BN_ZERO], support: [BN_ZERO], x: [BN_ZERO] };
|
||||
|
||||
function expandTracks (tracks: [BN, PalletReferendaTrackDetails][]): TrackDescription[] {
|
||||
return tracks.map(([id, info]) => ({
|
||||
graph: info.decisionDeposit && info.minApproval && info.minSupport ? calcCurves(info) : zeroGraph,
|
||||
id,
|
||||
info
|
||||
}));
|
||||
}
|
||||
|
||||
function useTracksImpl (palletReferenda: PalletReferenda): TrackDescription[] {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMemo(
|
||||
() => expandTracks(api.consts[palletReferenda as 'referenda'].tracks),
|
||||
[api, palletReferenda]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useTracks', useTracksImpl);
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
|
||||
|
||||
import type { PalletReferendaCurve } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import { BN_BILLION, BN_ZERO, bnToBn } from '@pezkuwi/util';
|
||||
|
||||
import { curveDelay, curveThreshold } from './util.js';
|
||||
|
||||
function curveLinear (ceil: BN | string | number, floor: BN | string | number, length: BN | string | number): PalletReferendaCurve {
|
||||
return {
|
||||
asLinearDecreasing: {
|
||||
ceil: bnToBn(ceil),
|
||||
floor: bnToBn(floor),
|
||||
length: bnToBn(length)
|
||||
},
|
||||
isLinearDecreasing: true,
|
||||
isReciprocal: false,
|
||||
isSteppedDecreasing: false
|
||||
} as PalletReferendaCurve;
|
||||
}
|
||||
|
||||
function curveReciprocal (factor: BN | string | number, xOffset: BN | string | number, yOffset: BN | string | number): PalletReferendaCurve {
|
||||
return {
|
||||
asReciprocal: {
|
||||
factor: bnToBn(factor),
|
||||
xOffset: bnToBn(xOffset),
|
||||
yOffset: bnToBn(yOffset)
|
||||
},
|
||||
isLinearDecreasing: false,
|
||||
isReciprocal: true,
|
||||
isSteppedDecreasing: false
|
||||
} as PalletReferendaCurve;
|
||||
}
|
||||
|
||||
// We don't currently have curves on Dicle for this, so needs a check
|
||||
// function curveStepped (begin: BN | string | number, end: BN | string | number, period: BN | string | number, step: BN | string | number): PalletReferendaCurve {
|
||||
// return {
|
||||
// asSteppedDecreasing: {
|
||||
// begin: bnToBn(begin),
|
||||
// end: bnToBn(end),
|
||||
// period: bnToBn(period),
|
||||
// step: bnToBn(step)
|
||||
// },
|
||||
// isLinearDecreasing: false,
|
||||
// isReciprocal: false,
|
||||
// isSteppedDecreasing: true
|
||||
// } as PalletReferendaCurve;
|
||||
// }
|
||||
|
||||
function percentValue (percent: number): BN {
|
||||
return BN_BILLION.muln(percent).divn(100);
|
||||
}
|
||||
|
||||
describe('curveThreshold', (): void => {
|
||||
describe('LinearDecreasing', (): void => {
|
||||
// linear support curve from root track on Dicle
|
||||
const rootCurve = curveLinear(BN_BILLION.divn(2), BN_ZERO, BN_BILLION);
|
||||
|
||||
it('has a correct starting point', (): void => {
|
||||
expect(
|
||||
curveThreshold(rootCurve, bnToBn(0), bnToBn(672)).toString()
|
||||
).toEqual(percentValue(50).toString());
|
||||
});
|
||||
|
||||
it('has the correct midpoint', (): void => {
|
||||
expect(
|
||||
curveThreshold(rootCurve, bnToBn(672 / 2), bnToBn(672)).toString()
|
||||
).toEqual(percentValue(25).toString());
|
||||
});
|
||||
|
||||
it('has a correct ending point', (): void => {
|
||||
expect(
|
||||
curveThreshold(rootCurve, bnToBn(672), bnToBn(672)).toString()
|
||||
).toEqual(percentValue(0).toString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reciprocal', (): void => {
|
||||
// linear approval curve from root track on Dicle
|
||||
const rootCurve = curveReciprocal(222_222_224, 333_333_335, 333_333_332);
|
||||
|
||||
it('has a correct starting point', (): void => {
|
||||
expect(
|
||||
curveThreshold(rootCurve, bnToBn(0), bnToBn(672)).toString()
|
||||
).toEqual(percentValue(100).toString());
|
||||
});
|
||||
|
||||
it('has the correct midpoints', (): void => {
|
||||
expect(
|
||||
curveThreshold(rootCurve, bnToBn(12), bnToBn(672)).toString()
|
||||
).toEqual('966101697'); // 96.61%
|
||||
expect(
|
||||
curveThreshold(rootCurve, bnToBn(336), bnToBn(672)).toString()
|
||||
).toEqual(percentValue(60).toString());
|
||||
});
|
||||
|
||||
it('has a correct ending point', (): void => {
|
||||
expect(
|
||||
curveThreshold(rootCurve, bnToBn(672), bnToBn(672)).toString()
|
||||
).toEqual('499999999');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('curveDelay', (): void => {
|
||||
describe('LinearDecreasing', (): void => {
|
||||
// linear support curve from root track on Dicle
|
||||
const rootCurve = curveLinear(BN_BILLION.divn(2), BN_ZERO, BN_BILLION);
|
||||
|
||||
it('has the correct result for 25%', (): void => {
|
||||
expect(
|
||||
curveDelay(rootCurve, bnToBn(25), bnToBn(100)).toString()
|
||||
).toEqual('500000000'); // 50% through
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reciprocal', (): void => {
|
||||
// linear approval curve from root track on Dicle
|
||||
const rootCurve = curveReciprocal(222_222_224, 333_333_335, 333_333_332);
|
||||
|
||||
it('has the correct result for 75%', (): void => {
|
||||
expect(
|
||||
curveDelay(rootCurve, bnToBn(75), bnToBn(100)).toString()
|
||||
).toEqual('200000000'); // 20% through
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-referenda authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { PalletConvictionVotingTally, PalletRankedCollectiveTally, PalletReferendaCurve, PalletReferendaReferendumInfoConvictionVotingTally, PalletReferendaReferendumInfoRankedCollectiveTally, PalletReferendaTrackDetails } from '@pezkuwi/types/lookup';
|
||||
import type { CurveGraph, TrackDescription, TrackInfoExt } from './types.js';
|
||||
|
||||
import { getGovernanceTracks } from '@pezkuwi/apps-config';
|
||||
import { BN, BN_BILLION, BN_ONE, BN_ZERO, bnMax, bnMin, formatNumber, objectSpread, stringPascalCase } from '@pezkuwi/util';
|
||||
|
||||
const CURVE_LENGTH = 500;
|
||||
|
||||
export function getTrackName (trackId: BN, { name }: PalletReferendaTrackDetails): string {
|
||||
return `${
|
||||
formatNumber(trackId)
|
||||
} / ${
|
||||
name
|
||||
.replace(/_/g, ' ')
|
||||
.split(' ')
|
||||
.map(stringPascalCase)
|
||||
.join(' ')
|
||||
}`;
|
||||
}
|
||||
|
||||
export function getTrackInfo (api: ApiPromise, specName: string, palletReferenda: string, tracks: TrackDescription[], trackId?: number): TrackInfoExt | undefined {
|
||||
let info: TrackInfoExt | undefined;
|
||||
|
||||
if (tracks && trackId !== undefined && trackId !== -1) {
|
||||
const originMap = getGovernanceTracks(api, specName, palletReferenda);
|
||||
const track = tracks.find(({ id }) => id.eqn(trackId));
|
||||
|
||||
if (track && originMap) {
|
||||
const trackName = track.info.name.toString();
|
||||
const base = originMap.find(({ id, name }) =>
|
||||
id === trackId &&
|
||||
name === trackName
|
||||
);
|
||||
|
||||
if (base) {
|
||||
info = objectSpread<TrackInfoExt>({
|
||||
track,
|
||||
trackName: getTrackName(track.id, track.info)
|
||||
}, base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
export function isConvictionTally (tally: PalletRankedCollectiveTally | PalletConvictionVotingTally): tally is PalletConvictionVotingTally {
|
||||
return !!(tally as PalletConvictionVotingTally).support && !(tally as PalletRankedCollectiveTally).bareAyes;
|
||||
}
|
||||
|
||||
export function isConvictionVote (info: PalletReferendaReferendumInfoConvictionVotingTally | PalletReferendaReferendumInfoRankedCollectiveTally): info is PalletReferendaReferendumInfoConvictionVotingTally {
|
||||
return info.isOngoing && isConvictionTally(info.asOngoing.tally);
|
||||
}
|
||||
|
||||
export function curveThreshold (curve: PalletReferendaCurve, input: BN, div: BN): BN {
|
||||
// if divisor is zero, we return the max
|
||||
if (div.isZero()) {
|
||||
return BN_BILLION;
|
||||
}
|
||||
|
||||
const x = input.mul(BN_BILLION).div(div);
|
||||
|
||||
if (curve.isLinearDecreasing) {
|
||||
const { ceil, floor, length } = curve.asLinearDecreasing;
|
||||
|
||||
// if divisor is zero, we return the max
|
||||
if (length.isZero()) {
|
||||
return BN_BILLION;
|
||||
}
|
||||
|
||||
// *ceil - (x.min(*length).saturating_div(*length, Down) * (*ceil - *floor))
|
||||
// NOTE: We first multiply, then divide (since we work with fractions)
|
||||
return ceil.sub(
|
||||
bnMin(x, length)
|
||||
.mul(ceil.sub(floor))
|
||||
.div(length)
|
||||
);
|
||||
} else if (curve.isSteppedDecreasing) {
|
||||
const { begin, end, period, step } = curve.asSteppedDecreasing;
|
||||
|
||||
// (*begin - (step.int_mul(x.int_div(*period))).min(*begin)).max(*end)
|
||||
return bnMax(
|
||||
end,
|
||||
begin.sub(
|
||||
bnMin(
|
||||
begin,
|
||||
step
|
||||
.mul(x)
|
||||
.div(period)
|
||||
)
|
||||
)
|
||||
);
|
||||
} else if (curve.isReciprocal) {
|
||||
const { factor, xOffset, yOffset } = curve.asReciprocal;
|
||||
const div = x.add(xOffset);
|
||||
|
||||
if (div.isZero()) {
|
||||
return BN_BILLION;
|
||||
}
|
||||
|
||||
// factor
|
||||
// .checked_rounding_div(FixedI64::from(x) + *x_offset, Low)
|
||||
// .map(|yp| (yp + *y_offset).into_clamped_perthing())
|
||||
// .unwrap_or_else(Perbill::one)
|
||||
return bnMin(
|
||||
BN_BILLION,
|
||||
factor
|
||||
.mul(BN_BILLION)
|
||||
.div(div)
|
||||
.add(yOffset)
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown curve found ${curve.type}`);
|
||||
}
|
||||
|
||||
export function curveDelay (curve: PalletReferendaCurve, input: BN, div: BN): BN {
|
||||
try {
|
||||
// if divisor is zero, we return the max
|
||||
if (div.isZero()) {
|
||||
return BN_BILLION;
|
||||
}
|
||||
|
||||
const y = input.mul(BN_BILLION).div(div);
|
||||
|
||||
if (curve.isLinearDecreasing) {
|
||||
const { ceil, floor, length } = curve.asLinearDecreasing;
|
||||
|
||||
// if y < *floor {
|
||||
// Perbill::one()
|
||||
// } else if y > *ceil {
|
||||
// Perbill::zero()
|
||||
// } else {
|
||||
// (*ceil - y).saturating_div(*ceil - *floor, Up).saturating_mul(*length)
|
||||
// }
|
||||
return y.lt(floor)
|
||||
? BN_BILLION
|
||||
: y.gt(ceil)
|
||||
? BN_ZERO
|
||||
: bnMin(
|
||||
BN_BILLION,
|
||||
bnMax(
|
||||
BN_ZERO,
|
||||
ceil
|
||||
.sub(y)
|
||||
.mul(length)
|
||||
.div(ceil.sub(floor))
|
||||
)
|
||||
);
|
||||
} else if (curve.isSteppedDecreasing) {
|
||||
const { begin, end, period, step } = curve.asSteppedDecreasing;
|
||||
|
||||
// if y < *end {
|
||||
// Perbill::one()
|
||||
// } else {
|
||||
// period.int_mul((*begin - y.min(*begin) + step.less_epsilon()).int_div(*step))
|
||||
// }
|
||||
return y.lt(end)
|
||||
? BN_BILLION
|
||||
: bnMin(
|
||||
BN_BILLION,
|
||||
bnMax(
|
||||
BN_ZERO,
|
||||
period
|
||||
.mul(
|
||||
begin
|
||||
.sub(bnMin(y, begin))
|
||||
.add(
|
||||
step.isZero()
|
||||
? step
|
||||
: step.sub(BN_ONE)
|
||||
)
|
||||
)
|
||||
.div(step)
|
||||
)
|
||||
);
|
||||
} else if (curve.asReciprocal) {
|
||||
const { factor, xOffset, yOffset } = curve.asReciprocal;
|
||||
const div = y.sub(yOffset);
|
||||
|
||||
if (div.isZero()) {
|
||||
return BN_BILLION;
|
||||
}
|
||||
|
||||
// let y = FixedI64::from(y);
|
||||
// let maybe_term = factor.checked_rounding_div(y - *y_offset, High);
|
||||
// maybe_term
|
||||
// .and_then(|term| (term - *x_offset).try_into_perthing().ok())
|
||||
// .unwrap_or_else(Perbill::one)
|
||||
return bnMin(
|
||||
BN_BILLION,
|
||||
bnMax(
|
||||
BN_ZERO,
|
||||
factor
|
||||
.mul(BN_BILLION)
|
||||
.div(div)
|
||||
.sub(xOffset)
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed on curve ${curve.type}:`, curve.inner.toHuman());
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown curve found ${curve.type}`);
|
||||
}
|
||||
|
||||
export function calcDecidingEnd (totalEligible: BN, tally: PalletRankedCollectiveTally | PalletConvictionVotingTally, { decisionPeriod, minApproval, minSupport }: PalletReferendaTrackDetails, since: BN): BN | undefined {
|
||||
const support = isConvictionTally(tally)
|
||||
? tally.support
|
||||
: tally.bareAyes;
|
||||
|
||||
return since.add(
|
||||
decisionPeriod
|
||||
.mul(
|
||||
bnMax(
|
||||
curveDelay(minApproval, tally.ayes, tally.ayes.add(tally.nays)),
|
||||
curveDelay(minSupport, support, totalEligible)
|
||||
)
|
||||
)
|
||||
.div(BN_BILLION)
|
||||
);
|
||||
}
|
||||
|
||||
export function calcCurves ({ decisionPeriod, minApproval, minSupport }: PalletReferendaTrackDetails): CurveGraph {
|
||||
const approval = new Array<BN>(CURVE_LENGTH);
|
||||
const support = new Array<BN>(CURVE_LENGTH);
|
||||
const x = new Array<BN>(CURVE_LENGTH);
|
||||
const last = CURVE_LENGTH - 1;
|
||||
// Bringing it to a higher precision before dividing by curve length.
|
||||
// Otherwise, graphs with short periods (on dev chains) are invalid.
|
||||
const stepWithPrecision = decisionPeriod.muln(100).divn(CURVE_LENGTH);
|
||||
let currentWithPrecision = new BN(0);
|
||||
|
||||
for (let i = 0; i < last; i++) {
|
||||
const current = currentWithPrecision.divn(100);
|
||||
|
||||
approval[i] = curveThreshold(minApproval, current, decisionPeriod);
|
||||
support[i] = curveThreshold(minSupport, current, decisionPeriod);
|
||||
x[i] = current;
|
||||
|
||||
currentWithPrecision = currentWithPrecision.add(stepWithPrecision);
|
||||
}
|
||||
|
||||
// since we may be lossy with the step, we explicitly calc the final point at 100%
|
||||
approval[last] = curveThreshold(minApproval, decisionPeriod, decisionPeriod);
|
||||
support[last] = curveThreshold(minSupport, decisionPeriod, decisionPeriod);
|
||||
x[last] = decisionPeriod;
|
||||
|
||||
return { approval, support, x };
|
||||
}
|
||||
Reference in New Issue
Block a user