Add a form to edit wallet rules (#1345)

This commit is contained in:
Niranjan Ramadas 2023-02-07 10:18:22 -06:00 committed by GitHub
parent b781a011d8
commit b4c99d8ea7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
93 changed files with 4368 additions and 74 deletions

View File

@ -71,7 +71,8 @@ export const createProposal = async (
proposalIndex: number,
instructionsData: InstructionDataWithHoldUpTime[],
isDraft: boolean,
client?: VotingClient
client?: VotingClient,
callbacks?: Parameters<typeof sendTransactionsV3>[0]['callbacks']
): Promise<PublicKey> => {
const instructions: TransactionInstruction[] = []
@ -227,6 +228,7 @@ export const createProposal = async (
})
await sendTransactionsV3({
callbacks,
connection,
wallet,
transactionInstructions: txes,
@ -279,6 +281,7 @@ export const createProposal = async (
})
await sendTransactionsV3({
callbacks,
connection,
wallet,
transactionInstructions: txes,

View File

@ -12,6 +12,7 @@ import { VoteTipping } from '@solana/spl-governance'
import cx from 'classnames'
import React, { useState } from 'react'
import { BigNumber } from 'bignumber.js'
import { useRouter } from 'next/router'
import { formatNumber } from '@utils/formatNumber'
import { ntext } from '@utils/ntext'
@ -21,6 +22,7 @@ import useRealm from '@hooks/useRealm'
import Tooltip from '@components/Tooltip'
import { DISABLED_VOTER_WEIGHT } from '@tools/constants'
import Address from '@components/Address'
import useQueryContext from '@hooks/useQueryContext'
import Section from '../../../Section'
import TokenIcon from '../../../../icons/TokenIcon'
@ -66,6 +68,17 @@ export function durationStr(duration: number, short = false) {
return count + (short ? 's' : ' ' + ntext(count, 'second'))
}
function votingLengthText(time: number) {
const hours = time / UNIX_HOUR
const days = Math.floor(hours / 24)
const remainingHours = (time - days * UNIX_DAY) / UNIX_HOUR
return (
durationStr(days * UNIX_DAY) +
(remainingHours ? ` ${durationStr(remainingHours * UNIX_HOUR)}` : '')
)
}
interface Props {
className?: string
wallet: Wallet
@ -73,7 +86,9 @@ interface Props {
export default function Rules(props: Props) {
const [editRulesOpen, setEditRulesOpen] = useState(false)
const { ownVoterWeight } = useRealm()
const { ownVoterWeight, symbol } = useRealm()
const router = useRouter()
const { fmtUrlWithCluster } = useQueryContext()
const programVersion = useProgramVersion()
@ -123,7 +138,15 @@ export default function Rules(props: Props) {
'disabled:opacity-50'
)}
disabled={!canEditRules}
onClick={() => setEditRulesOpen(true)}
onClick={() => {
if (props.wallet.governanceAccount) {
router.push(
fmtUrlWithCluster(
`/realm/${symbol}/governance/${props.wallet.governanceAccount.pubkey.toBase58()}/edit`
)
)
}
}}
>
<PencilIcon className="h-4 w-4 stroke-primary-light" />
<div>Edit Rules</div>
@ -138,8 +161,17 @@ export default function Rules(props: Props) {
<div className="grid grid-cols-2 gap-8">
<Section
icon={<CalendarIcon />}
name="Max Voting Time"
value={durationStr(props.wallet.rules.common.maxVotingTime)}
name="Unrestricted Voting Time"
value={votingLengthText(
props.wallet.rules.common.maxVotingTime
)}
/>
<Section
icon={<CalendarIcon />}
name="Voting Cool-Off Time"
value={durationStr(
props.wallet.rules.common.votingCoolOffSeconds
)}
/>
<Section
icon={<ClockIcon />}

View File

@ -17,6 +17,7 @@ export function getRulesFromAccount(
rules.common = {
maxVotingTime: govConfig.maxVotingTime,
minInstructionHoldupTime: govConfig.minInstructionHoldUpTime,
votingCoolOffSeconds: govConfig.votingCoolOffTime,
}
}

View File

@ -5,7 +5,10 @@
"tsconfigRootDir": ".",
"sourceType": "module"
},
"plugins": ["@typescript-eslint/eslint-plugin", "import"],
"plugins": [
"@typescript-eslint/eslint-plugin",
"import"
],
"extends": [
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended"
@ -15,7 +18,10 @@
"node": true,
"jest": true
},
"ignorePatterns": [".eslintrc.js", "migrations"],
"ignorePatterns": [
".eslintrc.js",
"migrations"
],
"rules": {
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/explicit-function-return-type": "off",
@ -27,7 +33,9 @@
"error",
{
"newlines-between": "always-and-inside-groups",
"pathGroupsExcludedImportTypes": ["builtin"],
"pathGroupsExcludedImportTypes": [
"builtin"
],
"pathGroups": [
{
"pattern": "@hub/**/**",
@ -52,6 +60,10 @@
{
"pattern": "@verify-wallet/**/**",
"group": "parent"
},
{
"pattern": "@hooks/**/**",
"group": "parent"
}
],
"alphabetize": {

View File

@ -1,6 +1,7 @@
import Head from 'next/head';
import { useRouter } from 'next/router';
import Script from 'next/script';
import React from 'react';
import React, { useEffect } from 'react';
import { GlobalHeader } from '@hub/components/GlobalHeader';
import { MinimalHeader } from '@hub/components/GlobalHeader/MinimalHeader';
@ -56,9 +57,31 @@ interface Props {
}
export function App(props: Props) {
const router = useRouter();
const isDarkMode = router.pathname.startsWith('/realm/[id]/governance');
useEffect(() => {
if (isDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [isDarkMode]);
return (
<RootProvider>
<Head>
{isDarkMode && (
<style
dangerouslySetInnerHTML={{
__html: `
html {
background-color: #171717;
}
`,
}}
/>
)}
<link
rel="apple-touch-icon"
sizes="57x57"

View File

@ -57,6 +57,7 @@ export function AuthorAvatar(props: Props) {
'rounded-full',
'border',
'border-neutral-400',
'dark:border-neutral-600',
props.className,
)}
src={props.author.civicInfo.avatarUrl}
@ -69,6 +70,7 @@ export function AuthorAvatar(props: Props) {
'rounded-full',
'border',
'border-neutral-400',
'dark:border-neutral-600',
props.className,
)}
src={props.author.twitterInfo.avatarUrl}

View File

@ -1,5 +1,3 @@
import { PublicKey } from '@solana/web3.js';
import { Realm } from '../gql';
import { SmallCard } from '@hub/components/DiscoverPage/SmallCard';
import { NFT as NFTIcon } from '@hub/components/icons/NFT';

View File

@ -1,4 +1,3 @@
import { formatDistanceToNowStrict } from 'date-fns';
import Link from 'next/link';
import { useRouter } from 'next/router';

View File

@ -31,6 +31,7 @@ function buildFormStateFromData(data: gql.DiscoverPage) {
function removeExtraneousFields<T extends { __typename?: string }>(
item: T,
): Omit<T, '__typename'> {
// eslint-disable-next-line
const { __typename, ...rest } = item;
return rest;
}
@ -106,8 +107,6 @@ export function EditDiscoverPage(props: Props) {
}
}, [result]);
console.log(result);
return (
<article className={props.className}>
{pipe(
@ -283,6 +282,7 @@ export function EditDiscoverPage(props: Props) {
(r) => r.publicKey,
),
spotlight: (formState?.spotlight || []).map((s) => {
// eslint-disable-next-line
const { realm, ...rest } = s;
return removeExtraneousFields({
...rest,

View File

@ -25,6 +25,7 @@ function trimFAQ(
return (!!item.answer && !isEmpty(item.answer)) || !!item.question;
})
.map((item) => {
// eslint-disable-next-line
const { __typename, clippedAnswer, ...rest } = item;
return rest;
});

View File

@ -2,10 +2,6 @@ import ImageIcon from '@carbon/icons-react/lib/Image';
import { produce } from 'immer';
import { FieldDescription } from '../common/FieldDescription';
import { FieldHeader } from '../common/FieldHeader';
import { FieldIconPreview } from '../common/FieldIconPreview';
import { SecondaryRed } from '@hub/components/controls/Button';
import { Input } from '@hub/components/controls/Input';
import { Item } from './Item';
@ -28,6 +24,7 @@ function trimGallery(
return !!g.url;
})
.map((g) => {
// eslint-disable-next-line
const { __typename, ...rest } = g;
return rest;
});

View File

@ -30,6 +30,7 @@ function trimAbout(
return !isEmpty(a.content) || !!a.heading;
})
.map((a) => {
// eslint-disable-next-line
const { __typename, ...rest } = a;
return rest;
});
@ -52,6 +53,7 @@ function trimResources(
return !!r.url || !!r.title;
})
.map((r) => {
// eslint-disable-next-line
const { __typename, ...rest } = r;
return {
...rest,

View File

@ -74,6 +74,7 @@ function trimRoadmap(roadmap: {
return !!item.date || !!item.resource || !!item.status || !!item.title;
})
.map((item) => {
// eslint-disable-next-line
const { __typename, ...rest } = item;
return rest;
});

View File

@ -37,6 +37,7 @@ function trimTeam(
);
})
.map((t) => {
// eslint-disable-next-line
const { __typename, ...rest } = t;
return rest;
});

View File

@ -35,6 +35,7 @@ function removeClippedAnswer<
O extends { clippedAnswer?: any; [key: string]: any }
>(item: O): Omit<O, 'clippedAnswer'> {
if ('clippedAnswer' in item) {
// eslint-disable-next-line
const { clippedAnswer, ...rest } = item;
return rest;
}
@ -46,6 +47,7 @@ function removeTypename<O extends { __typename?: string; [key: string]: any }>(
item: O,
): Omit<O, '__typename'> {
if ('__typename' in item) {
// eslint-disable-next-line
const { __typename, ...rest } = item;
return rest;
}
@ -57,6 +59,7 @@ function removeTwitterFollowerCount<
O extends { twitterFollowerCount?: any; [key: string]: any }
>(item: O): Omit<O, 'twitterFollowerCount'> {
if ('twitterFollowerCount' in item) {
// eslint-disable-next-line
const { twitterFollowerCount, ...rest } = item;
return rest;
}

View File

@ -0,0 +1,66 @@
import { SectionBlock } from '../SectionBlock';
import { ValueBlock } from '../ValueBlock';
import { Input } from '@hub/components/controls/Input';
import cx from '@hub/lib/cx';
import { formatNumber } from '@hub/lib/formatNumber';
import { FormProps } from '@hub/types/FormProps';
interface Props
extends FormProps<{
depositExemptProposalCount: number;
minInstructionHoldupDays: number;
}> {
className?: string;
programVersion: number;
}
export function AdvancedOptions(props: Props) {
return (
<SectionBlock className={cx(props.className, 'space-y-8')}>
<ValueBlock
title="Deposit Exempt Proposal Count"
description="The amount of proposals a member can create without a deposit."
>
<div className="relative">
<Input
className="w-full pr-24"
placeholder="# of proposals"
value={formatNumber(props.depositExemptProposalCount, undefined, {
maximumFractionDigits: 0,
})}
onChange={(e) => {
const text = e.currentTarget.value.replaceAll(/[^\d.-]/g, '');
const val = parseInt(text || '0', 10);
props.onDepositExemptProposalCountChange?.(val);
}}
/>
<div className="absolute top-1/2 right-4 text-neutral-500 -translate-y-1/2">
Proposals
</div>
</div>
</ValueBlock>
<ValueBlock
title="Minimum Instruction Holdup Time"
description="The minimum time which must pass before proposal instructions can be executed."
>
<div className="relative">
<Input
className="w-full pr-24"
placeholder="# of days"
value={formatNumber(props.minInstructionHoldupDays, undefined, {
maximumFractionDigits: 0,
})}
onChange={(e) => {
const text = e.currentTarget.value.replaceAll(/[^\d.-]/g, '');
const val = parseInt(text || '0', 10);
props.onMinInstructionHoldupDaysChange?.(val);
}}
/>
<div className="absolute top-1/2 right-4 text-neutral-500 -translate-y-1/2">
Days
</div>
</div>
</ValueBlock>
</SectionBlock>
);
}

View File

@ -0,0 +1,362 @@
import ChevronDownIcon from '@carbon/icons-react/lib/ChevronDown';
import UserMultipleIcon from '@carbon/icons-react/lib/UserMultiple';
import { BigNumber } from 'bignumber.js';
import { produce } from 'immer';
import { useState } from 'react';
import { SectionBlock } from '../SectionBlock';
import { SectionHeader } from '../SectionHeader';
import { SliderValue } from '../SliderValue';
import { CommunityRules, CouncilRules } from '../types';
import { ValueBlock } from '../ValueBlock';
import { VoteTippingSelector } from '../VoteTippingSelector';
import { ButtonToggle } from '@hub/components/controls/ButtonToggle';
import { Input } from '@hub/components/controls/Input';
import { Slider } from '@hub/components/controls/Slider';
import cx from '@hub/lib/cx';
import { formatNumber } from '@hub/lib/formatNumber';
import { FormProps } from '@hub/types/FormProps';
interface Props
extends FormProps<{
communityRules: CommunityRules;
}> {
currentCommunityRules: CommunityRules;
currentCouncilRules?: CouncilRules;
className?: string;
programVersion: number;
}
export function CommunityDetails(props: Props) {
const communityPowerPercent = props.communityRules.votingPowerToCreateProposals
.dividedBy(props.communityRules.totalSupply)
.multipliedBy(100);
const [additionalOptionsExpanded, setAdditionalOptionsExpanded] = useState(
false,
);
return (
<SectionBlock className={props.className}>
<SectionHeader
className="mb-8"
icon={<UserMultipleIcon />}
text="Community Details"
/>
<div className="space-y-8">
{!!props.currentCouncilRules &&
props.currentCouncilRules.canCreateProposal && (
<ValueBlock
title="Do you want to allow community members to create proposals?"
description="If disabled, the community members can no longer create proposals."
>
<ButtonToggle
className="h-14"
value={props.communityRules.canCreateProposal}
onChange={(value) => {
const newRules = produce(props.communityRules, (data) => {
data.canCreateProposal = value;
});
props.onCommunityRulesChange?.(newRules);
}}
/>
</ValueBlock>
)}
{props.communityRules.canCreateProposal && (
<ValueBlock
title="What is the minimum amount of community governance power required to create a proposal?"
description="A user must have this many community governance power in order to create a proposal."
>
<div className="relative">
<Input
className="w-full pr-24"
placeholder="amount of governance power"
value={formatNumber(
props.communityRules.votingPowerToCreateProposals,
undefined,
{
maximumFractionDigits: 0,
},
)}
onChange={(e) => {
const text = e.currentTarget.value.replaceAll(/[^\d.-]/g, '');
const value = text ? new BigNumber(text) : new BigNumber(0);
const newRules = produce(props.communityRules, (data) => {
data.votingPowerToCreateProposals = value;
});
props.onCommunityRulesChange?.(newRules);
}}
/>
<div className="absolute top-1/2 right-4 text-neutral-500 -translate-y-1/2">
Tokens
</div>
</div>
<div className="flex items-center justify-end">
{props.communityRules.totalSupply.isGreaterThan(0) && (
<div className="mt-1 text-xs text-neutral-500">
{communityPowerPercent.isGreaterThan(0)
? communityPowerPercent.isLessThan(0.01)
? '<0.01'
: formatNumber(communityPowerPercent, undefined, {
maximumFractionDigits: 2,
minimumFractionDigits: 0,
})
: 0}
% of token supply
</div>
)}
</div>
</ValueBlock>
)}
{!!props.currentCouncilRules && props.currentCouncilRules.canVote && (
<ValueBlock
title="Do you want to allow community members to vote?"
description="If disabled, the community members can no longer vote on proposals."
>
<ButtonToggle
className="h-14"
value={props.communityRules.canVote}
onChange={(value) => {
const newRules = produce(props.communityRules, (data) => {
data.canVote = value;
});
props.onCommunityRulesChange?.(newRules);
}}
/>
</ValueBlock>
)}
{props.communityRules.canVote && (
<>
<ValueBlock
title="Community Approval Quorum"
description="The percentage of Yes votes required to pass a proposal"
>
<div className="grid grid-cols-[100px,1fr] gap-x-2 items-center">
<SliderValue
min={1}
max={100}
value={props.communityRules.quorumPercent}
units="%"
onChange={(value) => {
const newRules = produce(props.communityRules, (data) => {
data.quorumPercent = value;
});
props.onCommunityRulesChange?.(newRules);
}}
/>
<Slider
min={1}
max={100}
trackColor="bg-sky-400"
value={props.communityRules.quorumPercent}
onChange={(value) => {
const newRules = produce(props.communityRules, (data) => {
data.quorumPercent = value;
});
props.onCommunityRulesChange?.(newRules);
}}
onRenderValue={(val) => `${val}%`}
/>
</div>
</ValueBlock>
<ValueBlock
title="Community Vote Tipping"
description="Decide when voting should end"
>
<VoteTippingSelector
className="w-full"
value={props.communityRules.voteTipping}
onChange={(value) => {
const newRules = produce(props.communityRules, (data) => {
data.voteTipping = value;
});
props.onCommunityRulesChange?.(newRules);
}}
/>
</ValueBlock>
</>
)}
{!!props.currentCouncilRules && props.currentCouncilRules.canVote && (
<ValueBlock
title="Do you want your community to have veto power over council proposals?"
description="Your community can veto a council-approved proposal."
>
<ButtonToggle
className="h-14"
value={props.communityRules.canVeto}
onChange={(value) => {
const newRules = produce(props.communityRules, (data) => {
data.canVeto = value;
});
props.onCommunityRulesChange?.(newRules);
}}
/>
</ValueBlock>
)}
{!!props.currentCouncilRules &&
props.currentCouncilRules.canVote &&
props.communityRules.canVeto && (
<ValueBlock
title="Community Veto Voting Quorum"
description={
<>
The percentage of <span className="font-bold">No</span> votes
required to veto a council proposal
</>
}
>
<div className="grid grid-cols-[100px,1fr] gap-x-2 items-center">
<SliderValue
min={1}
max={100}
value={props.communityRules.vetoQuorumPercent}
units="%"
onChange={(value) => {
const newRules = produce(props.communityRules, (data) => {
data.vetoQuorumPercent = value;
});
props.onCommunityRulesChange?.(newRules);
}}
/>
<Slider
min={1}
max={100}
trackColor="bg-sky-400"
value={props.communityRules.vetoQuorumPercent}
onChange={(value) => {
const newRules = produce(props.communityRules, (data) => {
data.vetoQuorumPercent = value;
});
props.onCommunityRulesChange?.(newRules);
}}
onRenderValue={(val) => `${val}%`}
/>
</div>
</ValueBlock>
)}
{!!props.currentCouncilRules &&
(!props.currentCouncilRules.canVote ||
!props.currentCouncilRules.canCreateProposal) && (
<button
className="flex items-center text-sm text-neutral-500"
onClick={() => setAdditionalOptionsExpanded((cur) => !cur)}
>
Additional options{' '}
<ChevronDownIcon
className={cx(
'fill-current',
'h-4',
'transition-transform',
'w-4',
additionalOptionsExpanded && '-rotate-180',
)}
/>
</button>
)}
{additionalOptionsExpanded && (
<>
{!!props.currentCouncilRules &&
!props.currentCouncilRules.canCreateProposal && (
<ValueBlock
title="Do you want to allow community members to create proposals?"
description="If disabled, the community members can no longer create proposals."
>
<ButtonToggle
className="h-14"
value={props.communityRules.canCreateProposal}
onChange={(value) => {
const newRules = produce(props.communityRules, (data) => {
data.canCreateProposal = value;
});
props.onCommunityRulesChange?.(newRules);
}}
/>
</ValueBlock>
)}
{!!props.currentCouncilRules && !props.currentCouncilRules.canVote && (
<ValueBlock
title="Do you want to allow community members to vote?"
description="If disabled, the community members can no longer vote on proposals."
>
<ButtonToggle
className="h-14"
value={props.communityRules.canVote}
onChange={(value) => {
const newRules = produce(props.communityRules, (data) => {
data.canVote = value;
});
props.onCommunityRulesChange?.(newRules);
}}
/>
</ValueBlock>
)}
{!!props.currentCouncilRules && !props.currentCouncilRules.canVote && (
<ValueBlock
title="Do you want your community to have veto power over council proposals?"
description="Your community can veto a council-approved proposal."
>
<ButtonToggle
className="h-14"
value={props.communityRules.canVeto}
onChange={(value) => {
const newRules = produce(props.communityRules, (data) => {
data.canVeto = value;
});
props.onCommunityRulesChange?.(newRules);
}}
/>
</ValueBlock>
)}
{!!props.currentCouncilRules &&
!props.currentCouncilRules.canVote &&
props.communityRules.canVeto && (
<ValueBlock
title="Community Veto Voting Quorum"
description={
<>
The percentage of <span className="font-bold">No</span>{' '}
votes required to veto a council proposal
</>
}
>
<div className="grid grid-cols-[100px,1fr] gap-x-2 items-center">
<SliderValue
min={1}
max={100}
value={props.communityRules.vetoQuorumPercent}
units="%"
onChange={(value) => {
const newRules = produce(
props.communityRules,
(data) => {
data.vetoQuorumPercent = value;
},
);
props.onCommunityRulesChange?.(newRules);
}}
/>
<Slider
min={1}
max={100}
trackColor="bg-sky-400"
value={props.communityRules.vetoQuorumPercent}
onChange={(value) => {
const newRules = produce(
props.communityRules,
(data) => {
data.vetoQuorumPercent = value;
},
);
props.onCommunityRulesChange?.(newRules);
}}
onRenderValue={(val) => `${val}%`}
/>
</div>
</ValueBlock>
)}
</>
)}
</div>
</SectionBlock>
);
}

View File

@ -0,0 +1,406 @@
import BuildingIcon from '@carbon/icons-react/lib/Building';
import ChevronDownIcon from '@carbon/icons-react/lib/ChevronDown';
import { BigNumber } from 'bignumber.js';
import { produce } from 'immer';
import { useState } from 'react';
import { SectionBlock } from '../SectionBlock';
import { SectionHeader } from '../SectionHeader';
import { SliderValue } from '../SliderValue';
import { CommunityRules, CouncilRules } from '../types';
import { ValueBlock } from '../ValueBlock';
import { VoteTippingSelector } from '../VoteTippingSelector';
import { ButtonToggle } from '@hub/components/controls/ButtonToggle';
import { Input } from '@hub/components/controls/Input';
import { Slider } from '@hub/components/controls/Slider';
import cx from '@hub/lib/cx';
import { formatNumber } from '@hub/lib/formatNumber';
import { FormProps } from '@hub/types/FormProps';
interface Props
extends FormProps<{
councilRules: NonNullable<CouncilRules>;
}> {
className?: string;
currentCommunityRules: CommunityRules;
currentCouncilRules: NonNullable<CouncilRules>;
programVersion: number;
}
export function CouncilDetails(props: Props) {
const councilPowerPercent = props.councilRules.votingPowerToCreateProposals
.dividedBy(props.councilRules.totalSupply)
.multipliedBy(100);
const [additionalOptionsExpanded, setAdditionalOptionsExpanded] = useState(
false,
);
return (
<SectionBlock className={props.className}>
<SectionHeader
className="mb-8"
icon={<BuildingIcon />}
text="Council Details"
/>
<div className="space-y-8">
{props.currentCommunityRules.canCreateProposal && (
<ValueBlock
title="Do you want to allow council members to create proposals?"
description="If disabled, the council members can no longer create proposals."
>
<ButtonToggle
className="h-14"
value={props.councilRules.canCreateProposal}
onChange={(value) => {
const newRules = produce(props.councilRules, (data) => {
data.canCreateProposal = value;
});
props.onCouncilRulesChange?.(newRules);
}}
/>
</ValueBlock>
)}
{props.currentCommunityRules.canCreateProposal &&
props.councilRules.canCreateProposal && (
<ValueBlock
title="What is the minimum amount of council governance power required to create a proposal?"
description="A user must have this many council governance power in order to create a proposal."
>
<div className="relative">
<Input
className="w-full pr-24"
placeholder="amount of governance power"
value={formatNumber(
props.councilRules.votingPowerToCreateProposals,
undefined,
{
maximumFractionDigits: 0,
},
)}
onChange={(e) => {
const text = e.currentTarget.value.replaceAll(
/[^\d.-]/g,
'',
);
const value = text ? new BigNumber(text) : new BigNumber(0);
const newRules = produce(props.councilRules, (data) => {
data.votingPowerToCreateProposals = value;
});
props.onCouncilRulesChange?.(newRules);
}}
/>
<div className="absolute top-1/2 right-4 text-neutral-500 -translate-y-1/2">
Tokens
</div>
</div>
<div className="flex items-center justify-end">
{props.councilRules.totalSupply.isGreaterThan(0) && (
<div className="mt-1 text-xs text-neutral-500">
{councilPowerPercent.isGreaterThan(0)
? councilPowerPercent.isLessThan(0.01)
? '<0.01'
: formatNumber(councilPowerPercent, undefined, {
maximumFractionDigits: 2,
minimumFractionDigits: 0,
})
: 0}
% of token supply
</div>
)}
</div>
</ValueBlock>
)}
{props.currentCommunityRules.canVote && (
<ValueBlock
title="Do you want to allow council members to vote?"
description="If disabled, the council members can no longer vote on proposals."
>
<ButtonToggle
className="h-14"
value={props.councilRules.canVote}
onChange={(value) => {
const newRules = produce(props.councilRules, (data) => {
data.canVote = value;
});
props.onCouncilRulesChange?.(newRules);
}}
/>
</ValueBlock>
)}
{props.councilRules.canVote && (
<>
<ValueBlock
title="Council Approval Quorum"
description="The percentage of Yes votes required to pass a proposal"
>
<div className="grid grid-cols-[100px,1fr] gap-x-2 items-center">
<SliderValue
min={1}
max={100}
value={props.councilRules.quorumPercent}
units="%"
onChange={(value) => {
const newRules = produce(props.councilRules, (data) => {
data.quorumPercent = value;
});
props.onCouncilRulesChange?.(newRules);
}}
/>
<Slider
min={1}
max={100}
trackColor="bg-sky-400"
value={props.councilRules.quorumPercent}
onChange={(value) => {
const newRules = produce(props.councilRules, (data) => {
data.quorumPercent = value;
});
props.onCouncilRulesChange?.(newRules);
}}
onRenderValue={(val) => `${val}%`}
/>
</div>
</ValueBlock>
<ValueBlock
title="Council Vote Tipping"
description="Decide when voting should end"
>
<VoteTippingSelector
className="w-full"
value={props.councilRules.voteTipping}
onChange={(value) => {
const newRules = produce(props.councilRules, (data) => {
data.voteTipping = value;
});
props.onCouncilRulesChange?.(newRules);
}}
/>
</ValueBlock>
</>
)}
{props.currentCommunityRules.canVote && (
<ValueBlock
title="Do you want your council to have veto power over community proposals?"
description="Your council can veto a community-approved proposal."
>
<ButtonToggle
className="h-14"
value={props.councilRules.canVeto}
onChange={(value) => {
const newRules = produce(props.councilRules, (data) => {
data.canVeto = value;
});
props.onCouncilRulesChange?.(newRules);
}}
/>
</ValueBlock>
)}
{props.currentCommunityRules.canVote && props.councilRules.canVeto && (
<ValueBlock
title="Council Veto Voting Quorum"
description={
<>
The percentage of <span className="font-bold">No</span> votes
required to veto a community proposal
</>
}
>
<div className="grid grid-cols-[100px,1fr] gap-x-2 items-center">
<SliderValue
min={1}
max={100}
value={props.councilRules.vetoQuorumPercent}
units="%"
onChange={(value) => {
const newRules = produce(props.councilRules, (data) => {
data.vetoQuorumPercent = value;
});
props.onCouncilRulesChange?.(newRules);
}}
/>
<Slider
min={1}
max={100}
trackColor="bg-sky-400"
value={props.councilRules.vetoQuorumPercent}
onChange={(value) => {
const newRules = produce(props.councilRules, (data) => {
data.vetoQuorumPercent = value;
});
props.onCouncilRulesChange?.(newRules);
}}
onRenderValue={(val) => `${val}%`}
/>
</div>
</ValueBlock>
)}
{(!props.currentCommunityRules.canCreateProposal ||
!props.currentCommunityRules.canVote) && (
<button
className="flex items-center text-sm text-neutral-500"
onClick={() => setAdditionalOptionsExpanded((cur) => !cur)}
>
Additional options{' '}
<ChevronDownIcon
className={cx(
'fill-current',
'h-4',
'transition-transform',
'w-4',
additionalOptionsExpanded && '-rotate-180',
)}
/>
</button>
)}
{additionalOptionsExpanded && (
<>
{!props.currentCommunityRules.canCreateProposal && (
<ValueBlock
title="Do you want to allow council members to create proposals?"
description="If disabled, the council members can no longer create proposals."
>
<ButtonToggle
className="h-14"
value={props.councilRules.canCreateProposal}
onChange={(value) => {
const newRules = produce(props.councilRules, (data) => {
data.canCreateProposal = value;
});
props.onCouncilRulesChange?.(newRules);
}}
/>
</ValueBlock>
)}
{!props.currentCommunityRules.canCreateProposal &&
props.councilRules.canCreateProposal && (
<ValueBlock
title="What is the minimum amount of council governance power required to create a proposal?"
description="A user must have this many council governance power in order to create a proposal."
>
<div className="relative">
<Input
className="w-full pr-24"
placeholder="amount of governance power"
value={formatNumber(
props.councilRules.votingPowerToCreateProposals,
undefined,
{
maximumFractionDigits: 0,
},
)}
onChange={(e) => {
const text = e.currentTarget.value.replaceAll(
/[^\d.-]/g,
'',
);
const value = text
? new BigNumber(text)
: new BigNumber(0);
const newRules = produce(props.councilRules, (data) => {
data.votingPowerToCreateProposals = value;
});
props.onCouncilRulesChange?.(newRules);
}}
/>
<div className="absolute top-1/2 right-4 text-neutral-500 -translate-y-1/2">
Tokens
</div>
</div>
<div className="flex items-center justify-end">
{props.councilRules.totalSupply.isGreaterThan(0) && (
<div className="mt-1 text-xs text-neutral-500">
{councilPowerPercent.isGreaterThan(0)
? councilPowerPercent.isLessThan(0.01)
? '<0.01'
: formatNumber(councilPowerPercent, undefined, {
maximumFractionDigits: 2,
minimumFractionDigits: 0,
})
: 0}
% of token supply
</div>
)}
</div>
</ValueBlock>
)}
{!props.currentCommunityRules.canVote && (
<ValueBlock
title="Do you want to allow council members to vote?"
description="If disabled, the council members can no longer vote on proposals."
>
<ButtonToggle
className="h-14"
value={props.councilRules.canVote}
onChange={(value) => {
const newRules = produce(props.councilRules, (data) => {
data.canVote = value;
});
props.onCouncilRulesChange?.(newRules);
}}
/>
</ValueBlock>
)}
{!props.currentCommunityRules.canVote && (
<ValueBlock
title="Do you want your council to have veto power over community proposals?"
description="Your council can veto a community-approved proposal."
>
<ButtonToggle
className="h-14"
value={props.councilRules.canVeto}
onChange={(value) => {
const newRules = produce(props.councilRules, (data) => {
data.canVeto = value;
});
props.onCouncilRulesChange?.(newRules);
}}
/>
</ValueBlock>
)}
{!props.currentCommunityRules.canVote &&
props.councilRules.canVeto && (
<ValueBlock
title="Council Veto Voting Quorum"
description={
<>
The percentage of <span className="font-bold">No</span>{' '}
votes required to veto a community proposal
</>
}
>
<div className="grid grid-cols-[100px,1fr] gap-x-2 items-center">
<SliderValue
min={1}
max={100}
value={props.councilRules.vetoQuorumPercent}
units="%"
onChange={(value) => {
const newRules = produce(props.councilRules, (data) => {
data.vetoQuorumPercent = value;
});
props.onCouncilRulesChange?.(newRules);
}}
/>
<Slider
min={1}
max={100}
trackColor="bg-sky-400"
value={props.councilRules.vetoQuorumPercent}
onChange={(value) => {
const newRules = produce(props.councilRules, (data) => {
data.vetoQuorumPercent = value;
});
props.onCouncilRulesChange?.(newRules);
}}
onRenderValue={(val) => `${val}%`}
/>
</div>
</ValueBlock>
)}
</>
)}
</div>
</SectionBlock>
);
}

View File

@ -0,0 +1,163 @@
import ChevronDownIcon from '@carbon/icons-react/lib/ChevronDown';
import type { PublicKey } from '@solana/web3.js';
import { useState } from 'react';
import { AdvancedOptions } from '../AdvancedOptions';
import { CommunityDetails } from '../CommunityDetails';
import { CouncilDetails } from '../CouncilDetails';
import { CommunityRules, CouncilRules } from '../types';
import { VotingDuration } from '../VotingDuration';
import { WalletDescription } from '../WalletDescription';
import cx from '@hub/lib/cx';
import { FormProps } from '@hub/types/FormProps';
interface Props
extends FormProps<{
communityRules: CommunityRules;
councilRules: CouncilRules;
coolOffHours: number;
depositExemptProposalCount: number;
maxVoteDays: number;
minInstructionHoldupDays: number;
}> {
className?: string;
currentCommunityRules: CommunityRules;
currentCouncilRules: CouncilRules;
governanceAddress: PublicKey;
programVersion: number;
walletAddress: PublicKey;
}
export function Form(props: Props) {
const [showCouncilOptions, setShowCouncilOptions] = useState(
props.currentCouncilRules?.canVote || false,
);
const [showCommunityOptions, setShowCommunityOptions] = useState(
props.currentCommunityRules.canVote,
);
const [showAdvanceOptions, setShowAdvanceOptions] = useState(false);
const showCommunityFirst = props.currentCommunityRules.canVote;
return (
<article className={props.className}>
<WalletDescription
className="mb-3"
governanceAddress={props.governanceAddress}
walletAddress={props.walletAddress}
/>
<h1 className="text-5xl font-medium m-0 mb-4 dark:text-white ">
What changes would you like to make to this wallet?
</h1>
<p className="m-0 mb-16 dark:text-neutral-300">
Submitting updates to a wallets rules will create a proposal for the
DAO to vote on. If approved, the updates will be ready to be executed.
</p>
<div>
<VotingDuration
className="mb-8"
coolOffHours={props.coolOffHours}
maxVoteDays={props.maxVoteDays}
programVersion={props.programVersion}
onCoolOffHoursChange={props.onCoolOffHoursChange}
onMaxVoteDaysChange={props.onMaxVoteDaysChange}
/>
{showCommunityOptions && showCommunityFirst && (
<CommunityDetails
className="mb-8"
communityRules={props.communityRules}
currentCommunityRules={props.currentCommunityRules}
currentCouncilRules={props.currentCouncilRules}
programVersion={props.programVersion}
onCommunityRulesChange={props.onCommunityRulesChange}
/>
)}
{!props.currentCouncilRules?.canVote && props.currentCouncilRules && (
<button
className="flex items-center text-sm text-neutral-500 mt-16 mb-2.5"
onClick={() => setShowCouncilOptions((cur) => !cur)}
>
Council Options{' '}
<ChevronDownIcon
className={cx(
'fill-current',
'h-4',
'transition-transform',
'w-4',
showCouncilOptions && '-rotate-180',
)}
/>
</button>
)}
{showCouncilOptions &&
props.councilRules &&
props.currentCouncilRules && (
<CouncilDetails
className="mb-8"
councilRules={props.councilRules}
currentCouncilRules={props.currentCouncilRules}
currentCommunityRules={props.currentCommunityRules}
programVersion={props.programVersion}
onCouncilRulesChange={props.onCouncilRulesChange}
/>
)}
{!props.currentCommunityRules.canVote && !showCommunityFirst && (
<button
className="flex items-center text-sm text-neutral-500 mt-16 mb-2.5"
onClick={() => setShowCommunityOptions((cur) => !cur)}
>
Community Options{' '}
<ChevronDownIcon
className={cx(
'fill-current',
'h-4',
'transition-transform',
'w-4',
showCommunityOptions && '-rotate-180',
)}
/>
</button>
)}
{showCommunityOptions && !showCommunityFirst && (
<CommunityDetails
communityRules={props.communityRules}
currentCommunityRules={props.currentCommunityRules}
currentCouncilRules={props.currentCouncilRules}
programVersion={props.programVersion}
onCommunityRulesChange={props.onCommunityRulesChange}
/>
)}
</div>
<div className="mt-16">
<button
className="flex items-center text-sm text-neutral-500"
onClick={() => setShowAdvanceOptions((cur) => !cur)}
>
Advanced Options{' '}
<ChevronDownIcon
className={cx(
'fill-current',
'h-4',
'transition-transform',
'w-4',
showAdvanceOptions && '-rotate-180',
)}
/>
</button>
{showAdvanceOptions && (
<AdvancedOptions
className="mt-2.5"
depositExemptProposalCount={props.depositExemptProposalCount}
minInstructionHoldupDays={props.minInstructionHoldupDays}
programVersion={props.programVersion}
onDepositExemptProposalCountChange={
props.onDepositExemptProposalCountChange
}
onMinInstructionHoldupDaysChange={
props.onMinInstructionHoldupDaysChange
}
/>
)}
</div>
</article>
);
}

View File

@ -0,0 +1,52 @@
import type { PublicKey } from '@solana/web3.js';
import { SectionBlock } from '../SectionBlock';
import { ValueBlock } from '../ValueBlock';
import { Input } from '@hub/components/controls/Input';
import { Textarea } from '@hub/components/controls/Textarea';
import cx from '@hub/lib/cx';
import { FormProps } from '@hub/types/FormProps';
interface Props
extends FormProps<{
proposalDescription: string;
proposalTitle: string;
}> {
className?: string;
governanceAddress: PublicKey;
walletAddress: PublicKey;
}
export function ProposalDetails(props: Props) {
return (
<SectionBlock className={cx(props.className, 'space-y-8')}>
<ValueBlock
title="Proposal Title"
description="Consider using the suggested propsal title."
>
<div className="relative">
<Input
className="w-full pr-12"
value={props.proposalTitle}
onChange={(e) => {
const text = e.currentTarget.value;
props.onProposalTitleChange?.(text);
}}
/>
</div>
</ValueBlock>
<ValueBlock
title="Proposal Description"
description="This will help voters understand more details about your proposed changes."
>
<Textarea
className="h-28 w-full"
value={props.proposalDescription}
onChange={(e) => {
props.onProposalDescriptionChange?.(e.currentTarget.value);
}}
/>
</ValueBlock>
</SectionBlock>
);
}

View File

@ -0,0 +1,170 @@
import BuildingIcon from '@carbon/icons-react/lib/Building';
import ChevronDownIcon from '@carbon/icons-react/lib/ChevronDown';
import UserMultipleIcon from '@carbon/icons-react/lib/UserMultiple';
import WalletIcon from '@carbon/icons-react/lib/Wallet';
import WarningFilledIcon from '@carbon/icons-react/lib/WarningFilled';
import { useState } from 'react';
import { SectionBlock } from '../SectionBlock';
import { SectionHeader } from '../SectionHeader';
import { SummaryItem } from '../SummaryItem';
import { CommunityRules, CouncilRules } from '../types';
import { ValueBlock } from '../ValueBlock';
import { getLabel } from '../VoteTippingSelector';
import { ButtonToggle } from '@hub/components/controls/ButtonToggle';
import cx from '@hub/lib/cx';
import { ntext } from '@hub/lib/ntext';
import { FormProps } from '@hub/types/FormProps';
interface Props
extends FormProps<{
proposalVoteType: 'council' | 'community';
}> {
className?: string;
currentCommunityRules: CommunityRules;
currentCouncilRules: CouncilRules;
currentBaseVoteDays: number;
currentCoolOffHours: number;
currentMinInstructionHoldupDays: number;
}
export function ProposalVoteType(props: Props) {
const [showRules, setShowRules] = useState(false);
const rules =
props.proposalVoteType === 'council' && props.currentCouncilRules
? props.currentCouncilRules
: props.currentCommunityRules;
const unrestrictedVotingHours = 24 * props.currentBaseVoteDays;
const unrestrictedVotingDays = Math.floor(unrestrictedVotingHours / 24);
const unrestrictedVotingRemainingHours =
unrestrictedVotingHours - unrestrictedVotingDays * 24;
return (
<SectionBlock className={props.className}>
<SectionHeader
className="mb-8"
icon={<WalletIcon />}
text="Membership Voting"
/>
{!!props.currentCouncilRules &&
props.currentCouncilRules.canVote &&
props.currentCommunityRules.canVote ? (
<ValueBlock
title="Who should vote on this proposal?"
description="Community or council?"
>
<ButtonToggle
disableValueTrue={!props.currentCommunityRules.canVote}
disableValueFalse={!props.currentCouncilRules}
value={props.proposalVoteType === 'community'}
valueTrueText="Community"
valueFalseText="Council"
onChange={(value) => {
if (value === false && !!props.currentCouncilRules) {
props.onProposalVoteTypeChange?.('council');
} else {
props.onProposalVoteTypeChange?.('community');
}
}}
/>
</ValueBlock>
) : props.proposalVoteType === 'council' ? (
<div className="text-white text-lg">
The proposal will be voted on by council members
</div>
) : (
<div className="text-white text-lg">
The proposal will be voted on by community members
</div>
)}
{showRules && (
<div className="mt-8">
<div className="flex items-center">
{props.proposalVoteType === 'community' ? (
<UserMultipleIcon className="h-4 fill-neutral-500 mr-3 w-4" />
) : (
<BuildingIcon className="h-4 fill-neutral-500 mr-3 w-4" />
)}
<div className="text-neutral-500">
Current{' '}
{props.proposalVoteType === 'community' ? 'Community' : 'Council'}{' '}
Rules
</div>
</div>
<div className="gap-x-4 gap-y-8 grid grid-cols-2 mt-8">
<SummaryItem
label="Unrestricted Voting Time"
value={
`${unrestrictedVotingDays} ${ntext(
unrestrictedVotingDays,
'day',
)}` +
(unrestrictedVotingRemainingHours
? ` ${unrestrictedVotingRemainingHours} ${ntext(
unrestrictedVotingRemainingHours,
'hour',
)}`
: '')
}
/>
<SummaryItem
label="Voting Cool-off Hours"
value={`${props.currentCoolOffHours} ${ntext(
props.currentCoolOffHours,
'hour',
)}`}
/>
<SummaryItem
label="Min Instruction Holdup Time"
value={`${props.currentMinInstructionHoldupDays} ${ntext(
props.currentMinInstructionHoldupDays,
'day',
)}`}
/>
<SummaryItem
label="Approval Quorum"
value={`${rules.quorumPercent}%`}
/>
<SummaryItem
label="Vote Tipping"
value={getLabel(rules.voteTipping)}
/>
{props.proposalVoteType === 'council' &&
props.currentCommunityRules.canVeto && (
<SummaryItem
label="Community Veto Quorum"
value={`${props.currentCommunityRules.vetoQuorumPercent}%`}
/>
)}
{props.proposalVoteType === 'community' &&
props.currentCouncilRules &&
props.currentCouncilRules.canVeto && (
<SummaryItem
label="Council Veto Quorum"
value={`${props.currentCommunityRules.vetoQuorumPercent}%`}
/>
)}
</div>
</div>
)}
<button
className="flex items-center mt-10 text-sm text-neutral-500"
onClick={() => setShowRules((cur) => !cur)}
>
{showRules ? 'Hide' : 'Show'} Voting Rules{' '}
<ChevronDownIcon
className={cx(
'h-4',
'ml-1.5',
'transition-transform',
'w-4',
showRules && '-rotate-180',
)}
/>
</button>
</SectionBlock>
);
}

View File

@ -0,0 +1,16 @@
import cx from '@hub/lib/cx';
interface Props {
className?: string;
children: React.ReactNode;
}
export function SectionBlock(props: Props) {
return (
<section
className={cx(props.className, 'p-8', 'rounded', 'dark:bg-black/40')}
>
{props.children}
</section>
);
}

View File

@ -0,0 +1,28 @@
import { cloneElement } from 'react';
import cx from '@hub/lib/cx';
interface Props {
className?: string;
icon: JSX.Element;
text: string;
}
export function SectionHeader(props: Props) {
return (
<header
className={cx(
props.className,
'flex',
'items-center',
'space-x-2',
'text-neutral-500',
)}
>
{cloneElement(props.icon, {
className: cx(props.icon.props.className, 'fill-current', 'h-4', 'w-4'),
})}
<div className="text-xl font-medium">{props.text}</div>
</header>
);
}

View File

@ -0,0 +1,54 @@
import { useEffect, useState } from 'react';
import { Input } from '@hub/components/controls/Input';
import cx from '@hub/lib/cx';
interface Props {
className?: string;
min: number;
max: number;
value: number;
units: React.ReactNode;
onChange?(value: number): void;
}
export function SliderValue(props: Props) {
const [value, setValue] = useState(String(props.value));
useEffect(() => {
setValue(String(props.value));
}, [props.value]);
return (
<div className={cx('relative', props.className)}>
<Input
className="block w-full"
value={value}
onChange={(e) => {
setValue(e.currentTarget.value);
}}
onBlur={(e) => {
const text = e.currentTarget.value.replaceAll(
/.*?(([0-9]*\.)?[0-9]+).*/g,
'$1',
);
const parsed = parseFloat(text);
const value = Number.isNaN(parsed) ? props.min : parsed;
props.onChange?.(Math.max(props.min, value));
}}
/>
<div
className={cx(
'absolute',
'top-1/2',
'right-4',
'-translate-y-1/2',
'text-center',
'dark:text-neutral-500',
)}
>
{props.units}
</div>
</div>
);
}

View File

@ -0,0 +1,95 @@
import BotIcon from '@carbon/icons-react/lib/Bot';
import type { PublicKey } from '@solana/web3.js';
import { ProposalDetails } from '../ProposalDetails';
import { ProposalVoteType } from '../ProposalVoteType';
import { CommunityRules, CouncilRules } from '../types';
import { UpdatesList } from '../UpdatesList';
import { WalletDescription } from '../WalletDescription';
import { FormProps } from '@hub/types/FormProps';
interface Props
extends FormProps<{
proposalDescription: string;
proposalTitle: string;
proposalVoteType: 'council' | 'community';
}> {
className?: string;
communityRules: CommunityRules;
coolOffHours: number;
councilRules: CouncilRules;
currentCommunityRules: CommunityRules;
currentCoolOffHours: number;
currentCouncilRules: CouncilRules;
currentDepositExemptProposalCount: number;
currentBaseVoteDays: number;
currentMinInstructionHoldupDays: number;
depositExemptProposalCount: number;
governanceAddress: PublicKey;
baseVoteDays: number;
minInstructionHoldupDays: number;
walletAddress: PublicKey;
}
export function Summary(props: Props) {
return (
<article className={props.className}>
<WalletDescription
className="mb-3"
governanceAddress={props.governanceAddress}
walletAddress={props.walletAddress}
/>
<h1 className="text-5xl font-medium m-0 mb-4 dark:text-white ">
Your proposal is almost ready. Does everything look correct?
</h1>
<p className="m-0 mb-16 dark:text-neutral-300">
Before submitting, ensure your description is correct and rules updates
are accurate.
</p>
<ProposalDetails
proposalDescription={props.proposalDescription}
proposalTitle={props.proposalTitle}
governanceAddress={props.governanceAddress}
walletAddress={props.walletAddress}
onProposalDescriptionChange={props.onProposalDescriptionChange}
onProposalTitleChange={props.onProposalTitleChange}
/>
<ProposalVoteType
className="mt-8"
currentCommunityRules={props.currentCommunityRules}
currentCouncilRules={props.currentCouncilRules}
currentBaseVoteDays={props.currentBaseVoteDays}
currentCoolOffHours={props.currentCoolOffHours}
currentMinInstructionHoldupDays={props.currentMinInstructionHoldupDays}
proposalVoteType={props.proposalVoteType}
onProposalVoteTypeChange={props.onProposalVoteTypeChange}
/>
<div className="mt-14">
<div className="text-lg font-bold dark:text-white">
Proposed Rules Updates
</div>
<div className="flex items-center mt-3 dark:text-emerald-400">
<BotIcon className="h-3 fill-current mr-1 w-4" />
<div className="text-xs">This section is automatically generated</div>
</div>
</div>
<UpdatesList
className="mt-4"
communityRules={props.communityRules}
coolOffHours={props.coolOffHours}
councilRules={props.councilRules}
currentCommunityRules={props.currentCommunityRules}
currentCoolOffHours={props.currentCoolOffHours}
currentCouncilRules={props.currentCouncilRules}
currentDepositExemptProposalCount={
props.currentDepositExemptProposalCount
}
currentBaseVoteDays={props.currentBaseVoteDays}
currentMinInstructionHoldupDays={props.currentMinInstructionHoldupDays}
depositExemptProposalCount={props.depositExemptProposalCount}
baseVoteDays={props.baseVoteDays}
minInstructionHoldupDays={props.minInstructionHoldupDays}
/>
</article>
);
}

View File

@ -0,0 +1,23 @@
import cx from '@hub/lib/cx';
interface Props {
className?: string;
label: React.ReactNode;
value: React.ReactNode;
}
export function SummaryItem(props: Props) {
return (
<div
className={cx(
props.className,
'border-l',
'pl-4',
'dark:border-neutral-700',
)}
>
<div className="text-sm dark:text-neutral-500">{props.label}</div>
<div className="mt-2 text-xl dark:text-white">{props.value}</div>
</div>
);
}

View File

@ -0,0 +1,575 @@
import BuildingIcon from '@carbon/icons-react/lib/Building';
import ChemistryIcon from '@carbon/icons-react/lib/Chemistry';
import TimeIcon from '@carbon/icons-react/lib/Time';
import UserMultipleIcon from '@carbon/icons-react/lib/UserMultiple';
import { PublicKey } from '@solana/web3.js';
import { BigNumber } from 'bignumber.js';
import { SectionBlock } from '../SectionBlock';
import { SectionHeader } from '../SectionHeader';
import { SummaryItem } from '../SummaryItem';
import { CommunityRules, CouncilRules } from '../types';
import { getLabel } from '../VoteTippingSelector';
import cx from '@hub/lib/cx';
import { formatNumber } from '@hub/lib/formatNumber';
import { ntext } from '@hub/lib/ntext';
function diff<T extends { [key: string]: unknown }>(existing: T, changed: T) {
const diffs = {} as {
[K in keyof T]: [T[K] | null, T[K] | null];
};
for (const key of Object.keys(existing) as (keyof T)[]) {
const existingValue = existing[key];
const changedValue = changed[key];
if (
existingValue instanceof PublicKey &&
changedValue instanceof PublicKey
) {
continue;
} else if (
BigNumber.isBigNumber(existingValue) &&
BigNumber.isBigNumber(changedValue)
) {
if (!existingValue.isEqualTo(changedValue)) {
diffs[key] = [existingValue, changedValue];
}
} else {
if (existingValue !== changedValue) {
diffs[key] = [existingValue, changedValue];
}
}
}
return diffs;
}
function unrestrictedVotingTimeText(days: number) {
const hours = days * 24;
const votingDays = Math.floor(hours / 24);
const remainingHours = hours - votingDays * 24;
return (
`${votingDays} ${ntext(votingDays, 'day')}` +
(remainingHours
? ` ${remainingHours} ${ntext(remainingHours, 'hour')}`
: '')
);
}
interface Props {
className?: string;
communityRules: CommunityRules;
coolOffHours: number;
councilRules: CouncilRules;
currentCommunityRules: CommunityRules;
currentCoolOffHours: number;
currentCouncilRules: CouncilRules;
currentDepositExemptProposalCount: number;
currentBaseVoteDays: number;
currentMinInstructionHoldupDays: number;
depositExemptProposalCount: number;
baseVoteDays: number;
minInstructionHoldupDays: number;
}
export function UpdatesList(props: Props) {
const currentVotingDuration = {
coolOffHours: props.currentCoolOffHours,
baseVoteDays: props.currentBaseVoteDays,
};
const currentAdvancedSettings = {
depositExemptProposalCount: props.currentDepositExemptProposalCount,
minInstructionHoldupDays: props.currentMinInstructionHoldupDays,
};
const newVotingDuration = {
coolOffHours: props.coolOffHours,
baseVoteDays: props.baseVoteDays,
};
const newAdvancedSettings = {
depositExemptProposalCount: props.depositExemptProposalCount,
minInstructionHoldupDays: props.minInstructionHoldupDays,
};
const votingDurationDiff = diff(currentVotingDuration, newVotingDuration);
const communityDetailsDiff = diff(
props.currentCommunityRules,
props.communityRules,
);
const councilDetailsDiff = diff(
(props.currentCouncilRules || {}) as NonNullable<CouncilRules>,
(props.councilRules || {}) as NonNullable<CouncilRules>,
);
const advancedSettingsDiff = diff(
currentAdvancedSettings,
newAdvancedSettings,
);
if (
Object.keys(votingDurationDiff).length === 0 &&
Object.keys(communityDetailsDiff).length === 0 &&
Object.keys(councilDetailsDiff).length === 0 &&
Object.keys(advancedSettingsDiff).length === 0
) {
return (
<SectionBlock
className={cx(
props.className,
'grid',
'place-items-center',
'w-full',
'h-52',
)}
>
<div className="text-lg dark:text-white">
There are no proposed changes
</div>
</SectionBlock>
);
}
return (
<SectionBlock className={cx('space-y-16', props.className)}>
{!!Object.keys(votingDurationDiff).length && (
<div>
<SectionHeader
className="mb-8"
icon={<TimeIcon />}
text="Voting Duration"
/>
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
{!!votingDurationDiff.baseVoteDays?.length && (
<SummaryItem
label="Unrestricted Voting Time"
value={
<div className="flex items-baseline">
{votingDurationDiff.baseVoteDays[1] ? (
<div>
{unrestrictedVotingTimeText(
votingDurationDiff.baseVoteDays[1],
)}
</div>
) : (
<div>Disabled</div>
)}
{votingDurationDiff.baseVoteDays[0] ? (
<div className="ml-3 text-base text-neutral-500 line-through">
{unrestrictedVotingTimeText(
votingDurationDiff.baseVoteDays[0],
)}
</div>
) : (
<div>Disabled</div>
)}
</div>
}
/>
)}
{!!votingDurationDiff.coolOffHours?.length && (
<SummaryItem
label="Cool-Off Voting Time"
value={
<div className="flex items-baseline">
{typeof votingDurationDiff.coolOffHours[1] === 'number' ? (
<div>
{votingDurationDiff.coolOffHours[1]}{' '}
{ntext(votingDurationDiff.coolOffHours[1], 'hour')}
</div>
) : (
<div>Disabled</div>
)}
{typeof votingDurationDiff.coolOffHours[0] === 'number' ? (
<div className="ml-3 text-base text-neutral-500 line-through">
{votingDurationDiff.coolOffHours[0]}{' '}
{ntext(votingDurationDiff.coolOffHours[0], 'hour')}
</div>
) : (
<div className="ml-3 text-base text-neutral-500 line-through">
Disabled
</div>
)}
</div>
}
/>
)}
</div>
</div>
)}
{!!Object.keys(communityDetailsDiff).length && (
<div>
<SectionHeader
className="mb-8"
icon={<UserMultipleIcon />}
text="Community Details"
/>
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
{!!communityDetailsDiff.canCreateProposal?.length && (
<SummaryItem
label="Allow community members to create proposals"
value={
<div className="flex items-baseline">
<div>
{communityDetailsDiff.canCreateProposal[1] ? 'Yes' : 'No'}
</div>
<div className="ml-3 text-base text-neutral-500 line-through">
{communityDetailsDiff.canCreateProposal[0] ? 'Yes' : 'No'}
</div>
</div>
}
/>
)}
{!!communityDetailsDiff.votingPowerToCreateProposals?.length &&
props.communityRules.canCreateProposal && (
<SummaryItem
label="Minimum amount of community tokens required to create a proposal"
value={
<div>
{communityDetailsDiff.votingPowerToCreateProposals[1] ? (
<div>
{formatNumber(
communityDetailsDiff
.votingPowerToCreateProposals[1],
undefined,
{ maximumFractionDigits: 0 },
)}{' '}
{ntext(
communityDetailsDiff.votingPowerToCreateProposals[1].toNumber(),
'token',
)}
</div>
) : (
<div>Disabled</div>
)}
{!props.currentCommunityRules.canCreateProposal ||
!communityDetailsDiff.votingPowerToCreateProposals[0] ? (
<div className="text-base text-neutral-500 line-through">
Disabled
</div>
) : (
<div className="text-base text-neutral-500 line-through">
{formatNumber(
communityDetailsDiff
.votingPowerToCreateProposals[0],
undefined,
{ maximumFractionDigits: 0 },
)}{' '}
{ntext(
communityDetailsDiff.votingPowerToCreateProposals[0].toNumber(),
'token',
)}
</div>
)}
</div>
}
/>
)}
{!!communityDetailsDiff.canVote?.length && (
<SummaryItem
label="Community Members Can Vote?"
value={
<div className="flex items-baseline">
<div>{communityDetailsDiff.canVote[1] ? 'Yes' : 'No'}</div>
<div className="ml-3 text-base text-neutral-500 line-through">
{communityDetailsDiff.canVote[0] ? 'Yes' : 'No'}
</div>
</div>
}
/>
)}
{!!communityDetailsDiff.quorumPercent?.length && (
<SummaryItem
label="Community Voting Quorum"
value={
<div className="flex items-baseline">
{communityDetailsDiff.quorumPercent[1] ? (
<div>{communityDetailsDiff.quorumPercent[1]}%</div>
) : (
<div>Disabled</div>
)}
{communityDetailsDiff.quorumPercent[0] ? (
<div className="ml-3 text-base text-neutral-500 line-through">
{communityDetailsDiff.quorumPercent[0]}%
</div>
) : (
<div className="ml-3 text-base text-neutral-500 line-through">
Disabled
</div>
)}
</div>
}
/>
)}
{!!communityDetailsDiff.voteTipping?.length && (
<SummaryItem
label="Community Vote Tipping"
value={
<div className="flex items-baseline">
{communityDetailsDiff.voteTipping[1] ? (
<div>{getLabel(communityDetailsDiff.voteTipping[1])}</div>
) : (
<div>Disabled</div>
)}
{communityDetailsDiff.voteTipping[0] ? (
<div className="ml-3 text-base text-neutral-500 line-through">
{getLabel(communityDetailsDiff.voteTipping[0])}
</div>
) : (
<div className="ml-3 text-base text-neutral-500 line-through">
Disabled
</div>
)}
</div>
}
/>
)}
{!!communityDetailsDiff.canVeto?.length && (
<SummaryItem
label="Community Veto Power over Council Proposals?"
value={
<div className="flex items-baseline">
<div>{communityDetailsDiff.canVeto[1] ? 'Yes' : 'No'}</div>
<div className="ml-3 text-base text-neutral-500 line-through">
{communityDetailsDiff.canVeto[0] ? 'Yes' : 'No'}
</div>
</div>
}
/>
)}
{props.communityRules.canVeto &&
!!communityDetailsDiff.vetoQuorumPercent?.length && (
<SummaryItem
label="Community Veto Voting Quorum"
value={
<div className="flex items-baseline">
<div>
{communityDetailsDiff.vetoQuorumPercent[1] || 0}%
</div>
<div className="ml-3 text-base text-neutral-500 line-through">
{communityDetailsDiff.vetoQuorumPercent[0] || 0}%
</div>
</div>
}
/>
)}
</div>
</div>
)}
{!!Object.keys(councilDetailsDiff).length && (
<div>
<SectionHeader
className="mb-8"
icon={<BuildingIcon />}
text="Council Details"
/>
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
{!!councilDetailsDiff.canCreateProposal && (
<SummaryItem
label="Allow council members to create proposals"
value={
<div className="flex items-baseline">
<div>
{councilDetailsDiff.canCreateProposal[1] ? 'Yes' : 'No'}
</div>
<div className="ml-3 text-base text-neutral-500 line-through">
{councilDetailsDiff.canCreateProposal[0] ? 'Yes' : 'No'}
</div>
</div>
}
/>
)}
{!!councilDetailsDiff.votingPowerToCreateProposals?.length &&
props.councilRules?.canCreateProposal && (
<SummaryItem
label="Minimum amount of council tokens required to create a proposal"
value={
<div>
{councilDetailsDiff.votingPowerToCreateProposals[1] ? (
<div>
{formatNumber(
councilDetailsDiff.votingPowerToCreateProposals[1],
undefined,
{ maximumFractionDigits: 0 },
)}{' '}
{ntext(
councilDetailsDiff.votingPowerToCreateProposals[1].toNumber(),
'token',
)}
</div>
) : (
<div>Disabled</div>
)}
{!props.currentCouncilRules?.canCreateProposal ||
!councilDetailsDiff.votingPowerToCreateProposals[0] ? (
<div className="text-base text-neutral-500 line-through">
Disabled
</div>
) : (
<div className="text-base text-neutral-500 line-through">
{formatNumber(
councilDetailsDiff.votingPowerToCreateProposals[0],
undefined,
{ maximumFractionDigits: 0 },
)}{' '}
{ntext(
councilDetailsDiff.votingPowerToCreateProposals[0].toNumber(),
'token',
)}
</div>
)}
</div>
}
/>
)}
{!!councilDetailsDiff.canVote?.length && (
<SummaryItem
label="Council Members Can Vote?"
value={
<div className="flex items-baseline">
<div>{councilDetailsDiff.canVote[1] ? 'Yes' : 'No'}</div>
<div className="ml-3 text-base text-neutral-500 line-through">
{councilDetailsDiff.canVote[0] ? 'Yes' : 'No'}
</div>
</div>
}
/>
)}
{!!councilDetailsDiff.quorumPercent?.length && (
<SummaryItem
label="Council Voting Quorum"
value={
<div className="flex items-baseline">
{councilDetailsDiff.quorumPercent[1] ? (
<div>{councilDetailsDiff.quorumPercent[1]}%</div>
) : (
<div>Disabled</div>
)}
{councilDetailsDiff.quorumPercent[0] ? (
<div className="ml-3 text-base text-neutral-500 line-through">
{councilDetailsDiff.quorumPercent[0]}%
</div>
) : (
<div className="ml-3 text-base text-neutral-500 line-through">
Disabled
</div>
)}
</div>
}
/>
)}
{!!councilDetailsDiff.voteTipping?.length && (
<SummaryItem
label="Council Vote Tipping"
value={
<div className="flex items-baseline">
{councilDetailsDiff.voteTipping[1] ? (
<div>{getLabel(councilDetailsDiff.voteTipping[1])}</div>
) : (
<div>Disabled</div>
)}
{councilDetailsDiff.voteTipping[0] ? (
<div className="ml-3 text-base text-neutral-500 line-through">
{getLabel(councilDetailsDiff.voteTipping[0])}
</div>
) : (
<div className="ml-3 text-base text-neutral-500 line-through">
Disabled
</div>
)}
</div>
}
/>
)}
{!!councilDetailsDiff.canVeto?.length && (
<SummaryItem
label="Council Veto Power over Community Proposals?"
value={
<div className="flex items-baseline">
<div>{councilDetailsDiff.canVeto[1] ? 'Yes' : 'No'}</div>
<div className="ml-3 text-base text-neutral-500 line-through">
{councilDetailsDiff.canVeto[0] ? 'Yes' : 'No'}
</div>
</div>
}
/>
)}
{props.councilRules?.canVeto &&
!!councilDetailsDiff.vetoQuorumPercent?.length && (
<SummaryItem
label="Council Veto Voting Quorum"
value={
<div className="flex items-baseline">
<div>{councilDetailsDiff.vetoQuorumPercent[1] || 0}%</div>
<div className="ml-3 text-base text-neutral-500 line-through">
{councilDetailsDiff.vetoQuorumPercent[0] || 0}%
</div>
</div>
}
/>
)}
</div>
</div>
)}
{!!Object.keys(advancedSettingsDiff).length && (
<div className="space-y-8">
<SectionHeader
className="mb-8"
icon={<ChemistryIcon />}
text="Advanced Options"
/>
{!!advancedSettingsDiff.depositExemptProposalCount?.length && (
<SummaryItem
label="The amount of proposals a member can create without a deposit."
value={
<div className="flex items-baseline">
<div>
{advancedSettingsDiff.depositExemptProposalCount[1]}
</div>
<div className="ml-3 text-base text-neutral-500 line-through">
{advancedSettingsDiff.depositExemptProposalCount[0]}
</div>
</div>
}
/>
)}
{!!advancedSettingsDiff.minInstructionHoldupDays?.length && (
<SummaryItem
label="Minimum Instruction Holdup Time"
value={
<div className="flex items-baseline">
{advancedSettingsDiff.minInstructionHoldupDays[1] ? (
<div>
{advancedSettingsDiff.minInstructionHoldupDays[1]}{' '}
{ntext(
advancedSettingsDiff.minInstructionHoldupDays[1],
'day',
)}
</div>
) : (
<div>Disabled</div>
)}
{advancedSettingsDiff.minInstructionHoldupDays[0] ? (
<div className="ml-3 text-base text-neutral-500 line-through">
{advancedSettingsDiff.minInstructionHoldupDays[0]}{' '}
{ntext(
advancedSettingsDiff.minInstructionHoldupDays[0],
'day',
)}
</div>
) : (
<div className="ml-3 text-base text-neutral-500 line-through">
Disabled
</div>
)}
</div>
}
/>
)}
</div>
)}
</SectionBlock>
);
}

View File

@ -0,0 +1,19 @@
import { ValueDescription } from '../ValueDescription';
import { ValueLabel } from '../ValueLabel';
interface Props {
className?: string;
title: string;
description: React.ReactNode;
children: React.ReactNode;
}
export function ValueBlock(props: Props) {
return (
<div className={props.className}>
<ValueLabel className="mb-1" text={props.title} />
<ValueDescription className="mb-4" text={props.description} />
<div>{props.children}</div>
</div>
);
}

View File

@ -0,0 +1,14 @@
import cx from '@hub/lib/cx';
interface Props {
className?: string;
text: React.ReactNode;
}
export function ValueDescription(props: Props) {
return (
<div className={cx(props.className, 'text-sm', 'dark:text-neutral-300')}>
{props.text}
</div>
);
}

View File

@ -0,0 +1,14 @@
import cx from '@hub/lib/cx';
interface Props {
className?: string;
text: string;
}
export function ValueLabel(props: Props) {
return (
<div className={cx(props.className, 'font-bold', 'dark:text-neutral-50')}>
{props.text}
</div>
);
}

View File

@ -0,0 +1,119 @@
import CheckmarkIcon from '@carbon/icons-react/lib/Checkmark';
import ChevronDownIcon from '@carbon/icons-react/lib/ChevronDown';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useEffect, useRef, useState } from 'react';
import cx from '@hub/lib/cx';
import { GovernanceVoteTipping } from '@hub/types/GovernanceVoteTipping';
export function getLabel(value: GovernanceVoteTipping): string {
switch (value) {
case GovernanceVoteTipping.Disabled:
return 'Disabled';
case GovernanceVoteTipping.Early:
return 'Early';
case GovernanceVoteTipping.Strict:
return 'Strict';
}
}
function getDescription(value: GovernanceVoteTipping): string {
switch (value) {
case GovernanceVoteTipping.Disabled:
return 'A proposal passes only when the total voting duration elapses';
case GovernanceVoteTipping.Early:
return 'A proposal passes when quorum is reached';
case GovernanceVoteTipping.Strict:
return 'A proposal passes as soon as it cannot mathematically be defeated';
}
}
const itemStyles = cx(
'border',
'cursor-pointer',
'gap-x-4',
'grid-cols-[80px,1fr,20px]',
'grid',
'h-14',
'items-center',
'px-4',
'rounded-md',
'text-left',
'transition-colors',
'dark:bg-neutral-800',
'dark:border-neutral-700',
'dark:hover:bg-neutral-700',
);
const labelStyles = cx('font-700', 'dark:text-neutral-50');
const descriptionStyles = cx('dark:text-neutral-400');
const iconStyles = cx('fill-neutral-500', 'h-5', 'transition-transform', 'w-4');
interface Props {
className?: string;
value: GovernanceVoteTipping;
onChange?(value: GovernanceVoteTipping): void;
}
export function VoteTippingSelector(props: Props) {
const [open, setOpen] = useState(false);
const [width, setWidth] = useState(0);
const trigger = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (trigger.current) {
setWidth(trigger.current.clientWidth);
} else {
setWidth(0);
}
}, [trigger, open]);
return (
<DropdownMenu.Root open={open} onOpenChange={setOpen}>
<div>
<DropdownMenu.Trigger
className={cx(itemStyles, props.className)}
ref={trigger}
>
<div className={labelStyles}>{getLabel(props.value)}</div>
<div className={descriptionStyles}>{getDescription(props.value)}</div>
<ChevronDownIcon className={cx(iconStyles, open && '-rotate-180')} />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="space-y-0.5"
sideOffset={2}
style={{ width }}
>
{[
GovernanceVoteTipping.Disabled,
GovernanceVoteTipping.Early,
GovernanceVoteTipping.Strict,
]
.filter((voteTippingType) => voteTippingType !== props.value)
.map((voteTippingType) => (
<DropdownMenu.Item
className={cx(
itemStyles,
'w-full',
'focus:outline-none',
'dark:focus:bg-neutral-700',
)}
key={voteTippingType}
onClick={() => props.onChange?.(voteTippingType)}
>
<div className={labelStyles}>{getLabel(voteTippingType)}</div>
<div className={descriptionStyles}>
{getDescription(voteTippingType)}
</div>
{voteTippingType === props.value && (
<CheckmarkIcon className={iconStyles} />
)}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</div>
</DropdownMenu.Root>
);
}

View File

@ -0,0 +1,161 @@
import TimeIcon from '@carbon/icons-react/lib/Time';
import { SectionBlock } from '../SectionBlock';
import { SectionHeader } from '../SectionHeader';
import { SliderValue } from '../SliderValue';
import { SummaryItem } from '../SummaryItem';
import { ValueBlock } from '../ValueBlock';
import { Slider } from '@hub/components/controls/Slider';
import { formatNumber } from '@hub/lib/formatNumber';
import { ntext } from '@hub/lib/ntext';
import { FormProps } from '@hub/types/FormProps';
interface Props
extends FormProps<{
coolOffHours: number;
maxVoteDays: number;
}> {
className?: string;
programVersion: number;
}
export function VotingDuration(props: Props) {
const unrestrictedVotingHours = 24 * props.maxVoteDays - props.coolOffHours;
const unrestrictedVotingDays = Math.floor(unrestrictedVotingHours / 24);
const unrestrictedVotingRemainingHours =
unrestrictedVotingHours - unrestrictedVotingDays * 24;
return (
<SectionBlock className={props.className}>
<SectionHeader
className="mb-8"
icon={<TimeIcon />}
text="Voting Duration"
/>
<ValueBlock
description="The lifespan of your proposal, start to finish. This includes unrestricted and cool-off voting times."
title="Total Voting Duration"
>
<div className="grid grid-cols-[100px,1fr] gap-x-2 items-center">
<SliderValue
min={1}
max={7}
value={props.maxVoteDays}
units={ntext(props.maxVoteDays, 'Day')}
onChange={props.onMaxVoteDaysChange}
/>
<Slider
min={1}
max={7}
value={props.maxVoteDays}
onChange={props.onMaxVoteDaysChange}
/>
</div>
</ValueBlock>
{props.programVersion >= 3 && (
<ValueBlock
className="mt-8"
description={
<>
After an unrestricted voting time, cool-off voting time limits
members to voting <span className="font-bold">No</span>,
withdrawing their vote, or vetoing a proposal. A member cannot
vote to approve a proposal during the cool-off time.
</>
}
title="Cool-Off Voting Time"
>
<div className="grid grid-cols-[100px,1fr] gap-x-2 items-center">
<SliderValue
min={0}
max={24}
value={props.coolOffHours}
units={ntext(props.coolOffHours, 'Hour')}
onChange={props.onCoolOffHoursChange}
/>
<Slider
min={0}
max={24}
trackColor="bg-orange-400"
value={props.coolOffHours}
onChange={props.onCoolOffHoursChange}
/>
</div>
<div className="flex items-center mt-3 text-xs">
<div className="dark:text-neutral-500">
Unrestricted Voting Time:
</div>
<div className="ml-2 dark:text-neutral-50">
<span className="font-bold">
{formatNumber(unrestrictedVotingDays, undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})}
</span>{' '}
{ntext(unrestrictedVotingDays, 'day')}
</div>
{!!unrestrictedVotingRemainingHours && (
<div className="ml-2 dark:text-neutral-50">
<span className="font-bold">
{formatNumber(unrestrictedVotingRemainingHours, undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})}
</span>{' '}
{ntext(unrestrictedVotingRemainingHours, 'hour')}
</div>
)}
</div>
</ValueBlock>
)}
{props.programVersion >= 3 && (
<div className="mt-12">
<div className="font-bold text-neutral-500">Summary</div>
<div className="grid grid-cols-3 gap-x-4 mt-4 pb-4">
<SummaryItem
label="Unrestricted Voting Time"
value={
<div className="flex items-center">
<div>
{formatNumber(unrestrictedVotingDays, undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})}{' '}
{ntext(unrestrictedVotingDays, 'day')}
</div>
{!!unrestrictedVotingRemainingHours && (
<div className="ml-3">
{formatNumber(
unrestrictedVotingRemainingHours,
undefined,
{
minimumFractionDigits: 0,
maximumFractionDigits: 2,
},
)}{' '}
{ntext(unrestrictedVotingRemainingHours, 'hour')}
</div>
)}
</div>
}
/>
<SummaryItem
label="Cool-Off Voting Time"
value={`${formatNumber(props.coolOffHours, undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})} ${ntext(props.coolOffHours, 'hour')}`}
/>
<SummaryItem
label="Total Voting Duration"
value={`${formatNumber(props.maxVoteDays, undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})} ${ntext(props.maxVoteDays, 'day')}`}
/>
</div>
</div>
)}
</SectionBlock>
);
}

View File

@ -0,0 +1,43 @@
import CopyIcon from '@carbon/icons-react/lib/Copy';
import WalletIcon from '@carbon/icons-react/lib/Wallet';
import type { PublicKey } from '@solana/web3.js';
import { getAccountName } from '@components/instructions/tools';
import { CopyAddressButton } from '@hub/components/controls/CopyAddressButton';
import { abbreviateAddress } from '@hub/lib/abbreviateAddress';
import cx from '@hub/lib/cx';
interface Props {
className?: string;
governanceAddress: PublicKey;
walletAddress: PublicKey;
}
export function WalletDescription(props: Props) {
const name =
getAccountName(props.walletAddress) ||
getAccountName(props.governanceAddress);
const address = abbreviateAddress(props.walletAddress);
return (
<header className={cx('flex', 'items-center', props.className)}>
<div className="flex items-center">
<WalletIcon className="h-4 mr-2 w-4 dark:fill-white" />
<div className="dark:text-white">{name || `Wallet ${address}`}</div>
</div>
<div className="flex items-center mt-1 ml-2">
<a
className="text-xs dark:text-neutral-500 hover:underline"
href={`https://explorer.solana.com/address/${props.governanceAddress}`}
target="_blank"
rel="noreferrer"
>
{address}
</a>
<CopyAddressButton address={props.walletAddress} className="group ml-2">
<CopyIcon className="h-3 transition-colors w-3 dark:fill-neutral-500 dark:group-hover:fill-neutral-300" />
</CopyAddressButton>
</div>
</header>
);
}

View File

@ -0,0 +1,3 @@
import { BigNumber } from 'bignumber.js';
export const MAX_NUM = new BigNumber('18446744073709551615');

View File

@ -0,0 +1,119 @@
import {
createSetGovernanceConfig,
GovernanceConfig,
VoteThresholdType,
VoteTipping,
} from '@solana/spl-governance';
import type { PublicKey } from '@solana/web3.js';
import BN from 'bn.js';
import { GovernanceVoteTipping } from '@hub/types/GovernanceVoteTipping';
import { MAX_NUM } from './constants';
import { Rules } from './types';
function hoursToSeconds(hours: number) {
return hours * 60 * 60;
}
function daysToSeconds(days: number) {
return hoursToSeconds(days * 24);
}
function convertVoteTipping(tipping: GovernanceVoteTipping): VoteTipping {
switch (tipping) {
case GovernanceVoteTipping.Disabled:
return VoteTipping.Disabled;
case GovernanceVoteTipping.Early:
return VoteTipping.Early;
case GovernanceVoteTipping.Strict:
return VoteTipping.Strict;
}
}
export function createTransaction(
programId: PublicKey,
programVersion: number,
governance: PublicKey,
rules: Rules,
) {
const communityRules = rules.communityTokenRules;
const councilRules = rules.councilTokenRules;
const minCommunityTokensToCreateProposal = new BN(
(communityRules.canCreateProposal
? communityRules.votingPowerToCreateProposals.shiftedBy(
communityRules.tokenMintDecimals.toNumber(),
)
: MAX_NUM
).toString(),
);
const minCouncilTokensToCreateProposal = new BN(
(councilRules && councilRules.canCreateProposal
? councilRules.votingPowerToCreateProposals.shiftedBy(
councilRules.tokenMintDecimals.toNumber(),
)
: MAX_NUM
).toString(),
);
const newConfig = new GovernanceConfig({
minCommunityTokensToCreateProposal,
minCouncilTokensToCreateProposal,
communityVoteThreshold: communityRules.canVote
? {
type: VoteThresholdType.YesVotePercentage,
value: communityRules.quorumPercent,
}
: {
type: VoteThresholdType.Disabled,
value: undefined,
},
minInstructionHoldUpTime: daysToSeconds(rules.minInstructionHoldupDays),
maxVotingTime:
daysToSeconds(rules.maxVoteDays) - hoursToSeconds(rules.coolOffHours),
communityVoteTipping: convertVoteTipping(communityRules.voteTipping),
councilVoteThreshold: councilRules?.canVote
? {
type: VoteThresholdType.YesVotePercentage,
value: councilRules.quorumPercent,
}
: {
type: VoteThresholdType.Disabled,
value: undefined,
},
councilVetoVoteThreshold: councilRules?.canVeto
? {
type: VoteThresholdType.YesVotePercentage,
value: councilRules.vetoQuorumPercent,
}
: {
type: VoteThresholdType.Disabled,
value: undefined,
},
communityVetoVoteThreshold: communityRules.canVeto
? {
type: VoteThresholdType.YesVotePercentage,
value: communityRules.vetoQuorumPercent,
}
: {
type: VoteThresholdType.Disabled,
value: undefined,
},
councilVoteTipping: councilRules
? convertVoteTipping(councilRules.voteTipping)
: VoteTipping.Disabled,
votingCoolOffTime: hoursToSeconds(rules.coolOffHours),
depositExemptProposalCount: rules.depositExemptProposalCount,
});
const instruction = createSetGovernanceConfig(
programId,
programVersion,
governance,
newConfig,
);
return instruction;
}

View File

@ -0,0 +1,92 @@
import * as IT from 'io-ts';
import { gql } from 'urql';
import { BigNumber } from '@hub/types/decoders/BigNumber';
import { GovernanceTokenType } from '@hub/types/decoders/GovernanceTokenType';
import { GovernanceVoteTipping } from '@hub/types/decoders/GovernanceVoteTipping';
import { PublicKey } from '@hub/types/decoders/PublicKey';
export const getGovernanceRules = gql`
query($realmUrlId: String!, $governancePublicKey: PublicKey!) {
me {
publicKey
}
realmByUrlId(urlId: $realmUrlId) {
programPublicKey
publicKey
governance(governance: $governancePublicKey) {
communityTokenRules {
canCreateProposal
canVeto
canVote
quorumPercent
tokenMintAddress
tokenMintDecimals
tokenType
totalSupply
vetoQuorumPercent
voteTipping
votingPowerToCreateProposals
}
coolOffHours
councilTokenRules {
canCreateProposal
canVeto
canVote
quorumPercent
tokenMintAddress
tokenMintDecimals
tokenType
totalSupply
vetoQuorumPercent
voteTipping
votingPowerToCreateProposals
}
depositExemptProposalCount
governanceAddress
maxVoteDays
minInstructionHoldupDays
version
walletAddress
}
}
}
`;
const Rules = IT.type({
canCreateProposal: IT.boolean,
canVeto: IT.boolean,
canVote: IT.boolean,
quorumPercent: IT.number,
tokenMintAddress: PublicKey,
tokenMintDecimals: BigNumber,
tokenType: GovernanceTokenType,
totalSupply: BigNumber,
vetoQuorumPercent: IT.number,
voteTipping: GovernanceVoteTipping,
votingPowerToCreateProposals: BigNumber,
});
export const getGovernanceRulesResp = IT.type({
me: IT.union([
IT.null,
IT.type({
publicKey: PublicKey,
}),
]),
realmByUrlId: IT.type({
programPublicKey: PublicKey,
publicKey: PublicKey,
governance: IT.type({
communityTokenRules: Rules,
coolOffHours: IT.number,
councilTokenRules: IT.union([IT.null, Rules]),
depositExemptProposalCount: IT.number,
governanceAddress: PublicKey,
maxVoteDays: IT.number,
minInstructionHoldupDays: IT.number,
version: IT.number,
walletAddress: PublicKey,
}),
}),
});

View File

@ -0,0 +1,384 @@
import CheckmarkIcon from '@carbon/icons-react/lib/Checkmark';
import ChevronLeftIcon from '@carbon/icons-react/lib/ChevronLeft';
import EditIcon from '@carbon/icons-react/lib/Edit';
import { PublicKey } from '@solana/web3.js';
import { BigNumber } from 'bignumber.js';
import { hoursToSeconds, secondsToHours } from 'date-fns';
import { pipe } from 'fp-ts/function';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { getAccountName } from '@components/instructions/tools';
import { Primary, Secondary } from '@hub/components/controls/Button';
import { Connect } from '@hub/components/GlobalHeader/User/Connect';
import { ProposalCreationProgress } from '@hub/components/ProposalCreationProgress';
import { useCluster, ClusterType } from '@hub/hooks/useCluster';
import { useProposal } from '@hub/hooks/useProposal';
import { useQuery } from '@hub/hooks/useQuery';
import { useToast, ToastType } from '@hub/hooks/useToast';
import cx from '@hub/lib/cx';
import { GovernanceTokenType } from '@hub/types/GovernanceTokenType';
import { GovernanceVoteTipping } from '@hub/types/GovernanceVoteTipping';
import * as RE from '@hub/types/Result';
import { createTransaction } from './createTransaction';
import { Form } from './Form';
import * as gql from './gql';
import { Summary } from './Summary';
import { CommunityRules, CouncilRules } from './types';
enum Step {
Form,
Summary,
}
function stepNum(step: Step): number {
switch (step) {
case Step.Form:
return 1;
case Step.Summary:
return 2;
}
}
function stepName(step: Step): string {
switch (step) {
case Step.Form:
return 'Edit Wallet Rules';
case Step.Summary:
return 'Create Proposal';
}
}
interface Props {
className?: string;
realmUrlId: string;
governanceAddress: PublicKey;
}
export function EditWalletRules(props: Props) {
const [cluster] = useCluster();
const { createProposal, progress } = useProposal();
const { publish } = useToast();
const [result] = useQuery(gql.getGovernanceRulesResp, {
query: gql.getGovernanceRules,
variables: {
realmUrlId: props.realmUrlId,
governancePublicKey: props.governanceAddress.toBase58(),
},
});
const router = useRouter();
const [step, setStep] = useState(Step.Form);
const [proposalVoteType, setProposalVoteType] = useState<
'community' | 'council'
>('community');
const [proposalDescription, setProposalDescription] = useState('');
const [proposalTitle, setProposalTitle] = useState('');
const [communityRules, setCommunityRules] = useState<CommunityRules>({
canCreateProposal: true,
canVeto: false,
canVote: false,
quorumPercent: 1,
// this isn't a valid value, but it's just to satisfy the types for the
// default initialized value
tokenMintAddress: props.governanceAddress,
tokenMintDecimals: new BigNumber(0),
tokenType: GovernanceTokenType.Community,
totalSupply: new BigNumber(1),
vetoQuorumPercent: 100,
voteTipping: GovernanceVoteTipping.Disabled,
votingPowerToCreateProposals: new BigNumber(1),
});
const [councilRules, setCouncilRules] = useState<CouncilRules>(null);
const [coolOffHours, setCoolOffHours] = useState(0);
const [depositExemptProposalCount, setDepositExemptProposalCount] = useState(
0,
);
const [baseVoteDays, setBaseVoteDays] = useState(3);
const [maxVoteDays, setMaxVoteDays] = useState(3);
const [minInstructionHoldupDays, setMinInstructionHoldupDays] = useState(0);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (typeof window !== 'undefined') {
window.scrollTo({ top: 0 });
}
}, [step]);
useEffect(() => {
if (RE.isOk(result)) {
const data = result.data.realmByUrlId.governance;
setCommunityRules(data.communityTokenRules);
setCoolOffHours(data.coolOffHours);
setCouncilRules(data.councilTokenRules);
setDepositExemptProposalCount(data.depositExemptProposalCount);
// maxVotingDays is actually misnamed on-chain. It should be `baseVotingDays`
const baseVotingSeconds = hoursToSeconds(24 * data.maxVoteDays);
const coolOffSeconds = hoursToSeconds(data.coolOffHours);
const maxVotingSeconds = baseVotingSeconds + coolOffSeconds;
setBaseVoteDays(data.maxVoteDays);
setMaxVoteDays(maxVotingSeconds / 60 / 60 / 24);
setMinInstructionHoldupDays(data.minInstructionHoldupDays);
if (!data.councilTokenRules) {
setProposalVoteType('community');
} else if (!data.communityTokenRules.canVote) {
setProposalVoteType('council');
}
const walletName =
getAccountName(data.walletAddress) ||
getAccountName(data.governanceAddress) ||
data.walletAddress.toBase58();
const title = `Update Wallet Rules for “${walletName}`;
setProposalTitle(title);
}
}, [result._tag]);
return pipe(
result,
RE.match(
() => <div />,
() => <div />,
({ me, realmByUrlId: { governance, programPublicKey, publicKey } }) => {
const walletName =
getAccountName(governance.walletAddress) ||
getAccountName(governance.governanceAddress) ||
governance.walletAddress.toBase58();
if (!me) {
return (
<div className={cx(props.className, 'dark:bg-neutral-900')}>
<Head>
<title>Edit Wallet Rules - {walletName}</title>
<meta
property="og:title"
content={`Edit Wallet Rules - ${governance.walletAddress.toBase58()}`}
key="title"
/>
</Head>
<div className="w-full max-w-3xl pt-14 mx-auto grid place-items-center">
<div className="my-16 py-8 px-16 dark:bg-black/40 rounded flex flex-col items-center">
<div className="text-white mb-2 text-center">
Please sign in to edit wallet rules
<br />
for "{walletName}"
</div>
<Connect />
</div>
</div>
</div>
);
}
return (
<div className={cx(props.className, 'dark:bg-neutral-900')}>
<ProposalCreationProgress progress={progress} />
<div className="w-full max-w-3xl pt-14 mx-auto">
<Head>
<title>Edit Wallet Rules - {walletName}</title>
<meta
property="og:title"
content={`Edit Wallet Rules - ${governance.walletAddress.toBase58()}`}
key="title"
/>
</Head>
<div className="flex items-center mt-4">
<div className="text-sm dark:text-neutral-500">
Step {stepNum(step)} of 2
</div>
<div className="text-sm dark:text-white ml-2">
{stepName(step)}
</div>
</div>
<div className="py-16">
{step === Step.Form && (
<>
<Form
className="mb-16"
communityRules={communityRules}
coolOffHours={coolOffHours}
councilRules={councilRules}
currentCommunityRules={governance.communityTokenRules}
currentCouncilRules={governance.councilTokenRules}
depositExemptProposalCount={depositExemptProposalCount}
governanceAddress={governance.governanceAddress}
maxVoteDays={maxVoteDays}
minInstructionHoldupDays={minInstructionHoldupDays}
programVersion={governance.version}
walletAddress={governance.walletAddress}
onCommunityRulesChange={setCommunityRules}
onCoolOffHoursChange={(coolOffHours) => {
setCoolOffHours(coolOffHours);
const maxVotingSeconds = hoursToSeconds(
maxVoteDays * 24,
);
const coolOffSeconds = hoursToSeconds(coolOffHours);
const baseVotingSeconds =
maxVotingSeconds - coolOffSeconds;
setBaseVoteDays(secondsToHours(baseVotingSeconds) / 24);
}}
onCouncilRulesChange={setCouncilRules}
onDepositExemptProposalCountChange={
setDepositExemptProposalCount
}
onMaxVoteDaysChange={(votingDays) => {
setMaxVoteDays(votingDays);
const maxVotingSeconds = hoursToSeconds(
24 * votingDays,
);
const coolOffSeconds = hoursToSeconds(coolOffHours);
const baseVotingSeconds =
maxVotingSeconds - coolOffSeconds;
setBaseVoteDays(secondsToHours(baseVotingSeconds) / 24);
}}
onMinInstructionHoldupDaysChange={
setMinInstructionHoldupDays
}
/>
<footer className="flex items-center justify-between">
<button
className="flex items-center text-sm text-neutral-500"
onClick={() => router.back()}
>
<ChevronLeftIcon className="h-4 fill-current w-4" />
Go Back
</button>
<Secondary
className="h-14 w-44"
onClick={() => setStep(Step.Summary)}
>
Continue
</Secondary>
</footer>
</>
)}
{step === Step.Summary && (
<>
<Summary
className="mb-16"
communityRules={communityRules}
coolOffHours={coolOffHours}
councilRules={councilRules}
currentCommunityRules={governance.communityTokenRules}
currentCoolOffHours={governance.coolOffHours}
currentCouncilRules={governance.councilTokenRules}
currentDepositExemptProposalCount={
governance.depositExemptProposalCount
}
currentBaseVoteDays={governance.maxVoteDays}
currentMinInstructionHoldupDays={
governance.minInstructionHoldupDays
}
depositExemptProposalCount={depositExemptProposalCount}
governanceAddress={governance.governanceAddress}
baseVoteDays={baseVoteDays}
minInstructionHoldupDays={minInstructionHoldupDays}
proposalDescription={proposalDescription}
proposalTitle={proposalTitle}
proposalVoteType={proposalVoteType}
walletAddress={governance.walletAddress}
onProposalDescriptionChange={setProposalDescription}
onProposalTitleChange={setProposalTitle}
onProposalVoteTypeChange={setProposalVoteType}
/>
<footer className="flex items-center justify-end">
<button
className="flex items-center text-sm text-neutral-500"
onClick={() => setStep(Step.Form)}
>
<EditIcon className="h-4 fill-current mr-1 w-4" />
Edit Rules
</button>
<Primary
className="ml-16 h-14 w-44"
pending={submitting}
onClick={async () => {
setSubmitting(true);
const transaction = createTransaction(
programPublicKey,
governance.version,
governance.governanceAddress,
{
coolOffHours,
depositExemptProposalCount,
maxVoteDays,
minInstructionHoldupDays,
communityTokenRules: communityRules,
councilTokenRules: councilRules,
governanceAddress: governance.governanceAddress,
version: governance.version,
walletAddress: governance.walletAddress,
},
);
const governingTokenMintPublicKey =
proposalVoteType === 'council' &&
governance.councilTokenRules
? governance.councilTokenRules.tokenMintAddress
: governance.communityTokenRules.tokenMintAddress;
try {
const proposalAddress = await createProposal({
governingTokenMintPublicKey,
programPublicKey,
proposalDescription,
proposalTitle,
governancePublicKey: governance.governanceAddress,
instructions: [transaction],
isDraft: false,
realmPublicKey: publicKey,
councilTokenMintPublicKey:
governance.councilTokenRules
?.tokenMintAddress || undefined,
communityTokenMintPublicKey:
governance.communityTokenRules.tokenMintAddress,
});
if (proposalAddress) {
router.push(
`/dao/${
props.realmUrlId
}/proposal/${proposalAddress.toBase58()}` +
(cluster.type === ClusterType.Devnet
? '?cluster=devnet'
: ''),
);
}
} catch (e) {
publish({
type: ToastType.Error,
title: 'Could not create proposal.',
message: String(e),
});
}
setSubmitting(false);
}}
>
<CheckmarkIcon className="h-4 fill-current mr-1 w-4" />
Create Proposal
</Primary>
</footer>
</>
)}
</div>
</div>
</div>
);
},
),
);
}

View File

@ -0,0 +1,9 @@
import { TypeOf } from 'io-ts';
import * as gql from './gql';
export type Rules = TypeOf<
typeof gql.getGovernanceRulesResp
>['realmByUrlId']['governance'];
export type CommunityRules = Rules['communityTokenRules'];
export type CouncilRules = Rules['councilTokenRules'];

View File

@ -35,6 +35,8 @@ export function Links(props: Props) {
<Link passHref href={link.href} key={i}>
<NavigationMenu.Link
className={cx(
'dark:text-neutral-400',
'dark:hover:text-neutral-200',
'block',
'group',
'py-2',

View File

@ -49,6 +49,7 @@ export function LinksDropdown(props: Props) {
'text-sm',
'transition-colors',
'group-hover:text-neutral-900',
'dark:group-hover:text-neutral-200',
)}
>
<Select.Value asChild>
@ -63,6 +64,7 @@ export function LinksDropdown(props: Props) {
'transition-colors',
'w-3',
'group-hover:fill-neutral-900',
'dark:group-hover:fill-neutral-200',
)}
/>
</Select.Icon>
@ -71,6 +73,7 @@ export function LinksDropdown(props: Props) {
<Select.Portal>
<Select.Content
className={cx(
'dark:bg-neutral-900',
'bg-white',
'rounded',
'overflow-hidden',
@ -96,6 +99,9 @@ export function LinksDropdown(props: Props) {
'text-neutral-900',
'hover:bg-neutral-200',
'focus:bg-neutral-200',
'dark:text-neutral-400',
'dark:hover:bg-neutral-700',
'dark:focus:bg-neutral-700',
)}
key={i}
>

View File

@ -24,6 +24,7 @@ export function MinimalHeader(props: Props) {
'flex',
'items-center',
'justify-center',
'dark:bg-neutral-800',
)}
style={{
paddingRight: 'var(--removed-body-scroll-bar-size)',
@ -43,7 +44,10 @@ export function MinimalHeader(props: Props) {
)}
>
<div className={cx('flex', 'items-center')}>
<Logo compressed={!showExpandedUserDropdown} />
<Logo
className="text-[#201F27] dark:text-neutral-50"
compressed={!showExpandedUserDropdown}
/>
</div>
<div className="flex items-center">
<User compressed={!showExpandedUserDropdown} />

View File

@ -52,7 +52,6 @@ export function Connect(props: Props) {
<NavigationMenu.Item>
<button
className={cx(
props.className,
'cursor-pointer',
'flex',
'items-center',
@ -66,6 +65,11 @@ export function Connect(props: Props) {
'transition-colors',
'active:bg-black/20',
'hover:bg-black/10',
'dark:text-neutral-400',
'dark:hover:text-neutral-200',
'dark:active:bg-neutral-800',
'dark:hover:bg-neutral-700',
props.className,
)}
onClick={async () => {
try {

View File

@ -16,8 +16,6 @@ import React, { useEffect, useMemo, useState } from 'react';
import { REALMS_PUBLIC_KEY, themeVariables } from './Notifications.constants';
import { solanaWalletToDialectWallet } from './solanaWalletToDialectWallet';
type ThemeType = 'light' | 'dark' | undefined;
interface Props {
className?: string;
}

View File

@ -4,13 +4,15 @@ import {
} from '@dialectlabs/react-ui';
import { PublicKey } from '@solana/web3.js';
import cx from '@hub/lib/cx';
export const REALMS_PUBLIC_KEY = new PublicKey(
'BUxZD6aECR5B5MopyvvYqJxwSKDBhx2jSSo1U32en6mj',
);
export const themeVariables: IncomingThemeVariables = {
dark: {
bellButton: `${defaultVariables.dark.bellButton} bg-transparent !shadow-none text-neutral-700 h-10 rounded-full w-10 hover:bg-bkg-3`,
bellButton: `${defaultVariables.dark.bellButton} bg-transparent !shadow-none text-neutral-300 h-10 rounded-full w-10 hover:bg-bkg-3`,
iconButton: `${defaultVariables.dark.iconButton} hover:opacity-100 bg-transparent`,
adornmentButton: `${defaultVariables.dark.adornmentButton} bg-sky-500 hover:!bg-sky-400 active:bg-sky-500 rounded transition-colors`,
buttonLoading: `${defaultVariables.dark.buttonLoading} rounded-full min-h-[40px]`,
@ -32,7 +34,21 @@ export const themeVariables: IncomingThemeVariables = {
secondaryDangerButton: `${defaultVariables.dark.secondaryDangerButton} rounded-full`,
},
light: {
bellButton: `${defaultVariables.light.bellButton} w-10 h-10 border-none bg-transparent shadow-none text-neutral-600 active:bg-neutral-300 hover:bg-neutral-200`,
bellButton: cx(
defaultVariables.light.bellButton,
'bg-transparent',
'border-none',
'h-10',
'shadow-none',
'text-neutral-600',
'w-10',
'active:bg-neutral-300',
'hover:bg-neutral-200',
'dark:text-neutral-400',
'dark:hover:text-neutral-200',
'dark:active:bg-neutral-600',
'dark:hover:bg-neutral-700',
),
iconButton: `${defaultVariables.light.iconButton} hover:opacity-100 bg-transparent`,
buttonLoading: `${defaultVariables.light.buttonLoading} rounded-full min-h-[40px]`,
adornmentButton: `${defaultVariables.light.adornmentButton} bg-sky-500 hover:!bg-sky-400 active:bg-sky-500 rounded transition-colors`,
@ -44,7 +60,11 @@ export const themeVariables: IncomingThemeVariables = {
toggleBackgroundActive: 'bg-sky-500',
},
textStyles: {
input: `${defaultVariables.light.textStyles.input} text-neutral-900 placeholder:text-fgd-3`,
input: cx(
defaultVariables.light.textStyles.input,
'text-neutral-900',
'placeholder:text-fgd-3',
),
body: `${defaultVariables.light.textStyles.body} text-neutral-900`,
small: `${defaultVariables.light.textStyles.small} text-neutral-900`,
xsmall: `${defaultVariables.light.textStyles.xsmall} text-neutral-900`,

View File

@ -14,7 +14,7 @@ export function solanaWalletToDialectWallet(
}
return {
publicKey: wallet.publicKey!,
publicKey: wallet.publicKey,
signMessage: wallet.signMessage,
signTransaction: wallet.signTransaction,
signAllTransactions: wallet.signAllTransactions,

View File

@ -25,6 +25,12 @@ export function DropdownButton(props: Props) {
'w-full',
'active:bg-neutral-300',
'hover:bg-neutral-200',
'dark:fill-neutral-400',
'dark:text-neutral-400',
'dark:active:bg-neutral-600',
'dark:hover:bg-neutral-700',
'dark:hover:text-neutral-200',
'dark:hover:fill-neutral-200',
)}
/>
</NavigationMenu.Item>

View File

@ -46,6 +46,10 @@ export function UserDropdown(props: Props) {
'transition-colors',
'active:bg-neutral-300',
'hover:bg-neutral-200',
'dark:text-neutral-400',
'dark:hover:bg-neutral-700',
'dark:active:bg-neutral-600',
'dark:hover:text-neutral-200',
!props.compressed && 'w-48',
)}
>
@ -55,7 +59,7 @@ export function UserDropdown(props: Props) {
<div className="truncate flex-shrink">{username}</div>
)}
</div>
<ChevronDownIcon className="h-4 w-4 fill-neutral-900 flex-shrink-0" />
<ChevronDownIcon className="h-4 w-4 fill-neutral-900 flex-shrink-0 dark:fill-neutral-400" />
</NavigationMenu.Trigger>
<NavigationMenu.Content
className={cx(
@ -65,6 +69,7 @@ export function UserDropdown(props: Props) {
'overflow-hidden',
'rounded',
'w-48',
'dark:bg-neutral-900',
!!props.compressed && 'right-3',
)}
>

View File

@ -34,6 +34,7 @@ export function GlobalHeader(props: Props) {
<NavigationMenu.Root
className={cx(
props.className,
'dark:bg-neutral-800',
'bg-white',
'flex',
'items-center',
@ -57,7 +58,10 @@ export function GlobalHeader(props: Props) {
)}
>
<div className={cx('flex', 'items-center')}>
<Logo compressed={!showExpandedUserDropdown} />
<Logo
className="text-[#201F27] dark:text-neutral-50"
compressed={!showExpandedUserDropdown}
/>
{showDesktopRealmSelector && (
<NavigationMenu.Item asChild>
<RealmSearchNavigation className="ml-4" />

View File

@ -1,4 +1,3 @@
import * as Separator from '@radix-ui/react-separator';
import type { PublicKey } from '@solana/web3.js';
import { pipe } from 'fp-ts/function';
import React from 'react';

View File

@ -1,6 +1,5 @@
import ChevronRightIcon from '@carbon/icons-react/lib/ChevronRight';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { abbreviateNumber } from '@hub/lib/abbreviateNumber';
@ -23,7 +22,6 @@ interface BountyData {
export function Bounties(props: Props) {
const [bounties, setBounties] = useState([]);
const [loading, setLoading] = useState(true);
const router = useRouter();
useEffect(() => {
const fetchData = async () => {

View File

@ -1,4 +1,3 @@
import * as Separator from '@radix-ui/react-separator';
import { pipe } from 'fp-ts/function';
import React from 'react';

View File

@ -41,6 +41,7 @@ export function NewPostEditor(props: Props) {
const [, createPost] = useMutation(gql.createPostResp, gql.createPost);
const [error, setError] = useState<CombinedError | null>(null);
const [realm, setRealm] = useState(props.realm);
// eslint-disable-next-line
const [crosspostTo, setCrosspostTo] = useState<
{ name: string; publicKey: PublicKey; iconUrl?: null | string }[]
>([]);

View File

@ -0,0 +1,96 @@
import * as Progress from '@radix-ui/react-progress';
import React from 'react';
import {
CreationProgress,
CreationProgressState,
} from '@hub/hooks/useProposalCreationProgress';
import cx from '@hub/lib/cx';
function Modal(props: { className?: string; children?: React.ReactNode }) {
return (
<div
className={cx(
props.className,
'fixed',
'top-0',
'left-0',
'right-0',
'bottom-0',
'z-50',
'bg-black/40',
)}
>
<div
className={cx(
'-translate-x-1/2',
'-translate-y-1/2',
'absolute',
'bg-neutral-800',
'left-1/2',
'max-w-3xl',
'p-8',
'rounded',
'shadow-2xl',
'top-1/2',
'w-full',
)}
>
{props.children}
</div>
</div>
);
}
interface Props {
className?: string;
progress: CreationProgress;
}
export function ProposalCreationProgress(props: Props) {
if (props.progress.state === CreationProgressState.Processing) {
const progressValue =
((props.progress.transactionsCompleted + 1) /
props.progress.totalTransactions) *
100;
return (
<Modal className={props.className}>
<div className="text-white">Creating proposal</div>
<div className="mt-4">
<Progress.Root
className="h-10 rounded overflow-hidden w-full bg-neutral-200 relative"
value={progressValue}
>
<Progress.Indicator
className={cx(
'absolute',
'animate-move-stripes',
'duration-700',
'top-0',
'bottom-0',
'transition-all',
'w-full',
)}
style={{
background:
'repeating-linear-gradient(-67.5deg, #bae6fd, #bae6fd 20px, #7dd3fc 20px, #7dd3fc 40px)',
right: `${100 - progressValue}%`,
}}
/>
</Progress.Root>
</div>
<div className="mt-2 text-sm text-neutral-500">
Processing transaction{' '}
<span className="font-bold">
{props.progress.transactionsCompleted + 1}
</span>{' '}
of{' '}
<span className="font-bold">{props.progress.totalTransactions}</span>
</div>
</Modal>
);
}
return null;
}

View File

@ -16,6 +16,7 @@ export function RealmIcon(props: Props) {
'rounded-full',
'border',
'border-neutral-400',
'dark:border-neutral-600',
props.className,
)}
src={props.iconUrl}
@ -28,6 +29,7 @@ export function RealmIcon(props: Props) {
'rounded-full',
'border',
'border-neutral-400',
'dark:border-neutral-600',
props.className,
)}
src={ecosystemIcon.src}

View File

@ -129,8 +129,12 @@ export const RealmSearchNavigation = forwardRef<HTMLInputElement, Props>(
'placeholder:transition-colors',
'focus:placeholder:text-neutral-300',
'focus:outline-none',
'dark:bg-neutral-900',
'dark:border-neutral-700',
'dark:placeholder:text-neutral-400',
'dark:focus:placeholder:text-neutral-200',
)}
placeholder="Communities"
placeholder="Organizations"
ref={inputRef}
value={text}
onChange={(e) => setText(e.currentTarget.value)}
@ -145,10 +149,12 @@ export const RealmSearchNavigation = forwardRef<HTMLInputElement, Props>(
'left-4',
'top-1/2',
'w-4',
'dark:fill-neutral-400',
)}
/>
<button
className={cx(
'dark:text-neutral-400',
'-translate-y-1/2',
'absolute',
'focus:opacity-100',
@ -186,6 +192,7 @@ export const RealmSearchNavigation = forwardRef<HTMLInputElement, Props>(
align="start"
sideOffset={4}
className={cx(
'dark:bg-neutral-900',
'drop-shadow-lg',
'bg-white',
'overflow-hidden',
@ -215,11 +222,13 @@ export const RealmSearchNavigation = forwardRef<HTMLInputElement, Props>(
'gap-x-2',
'grid-cols-[24px,1fr]',
'grid',
'group',
'items-center',
'p-2',
'transition-colors',
'w-full',
'hover:bg-neutral-200',
'dark:hover:bg-neutral-700',
)}
onClick={() => {
setText('');
@ -231,7 +240,15 @@ export const RealmSearchNavigation = forwardRef<HTMLInputElement, Props>(
iconUrl={option.iconUrl}
name={option.name}
/>
<div className="text-sm text-neutral-900">
<div
className={cx(
'text-sm',
'text-neutral-900',
'transition-colors',
'dark:text-neutral-400',
'dark:group-hover:text-neutral-200',
)}
>
{option.name}
</div>
</a>
@ -252,11 +269,13 @@ export const RealmSearchNavigation = forwardRef<HTMLInputElement, Props>(
'gap-x-2',
'grid-cols-[24px,1fr]',
'grid',
'group',
'items-center',
'p-2',
'transition-colors',
'w-full',
'hover:bg-neutral-200',
'dark:hover:bg-neutral-700',
)}
onClick={() => {
setText('');
@ -268,7 +287,15 @@ export const RealmSearchNavigation = forwardRef<HTMLInputElement, Props>(
iconUrl={option.iconUrl}
name={option.name}
/>
<div className="text-sm text-neutral-900">
<div
className={cx(
'text-sm',
'text-neutral-900',
'transition-colors',
'dark:text-neutral-400',
'dark:group-hover:text-neutral-200',
)}
>
{option.name}
</div>
</a>

View File

@ -9,6 +9,7 @@ interface Props {
onExpand?(): void;
}
// eslint-disable-next-line
export function ImageNode(props: Props) {
return <div />;
}

View File

@ -12,24 +12,24 @@ export function RealmsLogo(props: Props) {
>
<path
d="M45.578 21.142C44.408 21.142 43.4 20.404 41.942 17.722L41.564 17.02C43.742 16.894 45.02 15.49 45.02 13.528C45.02 11.314 43.508 10 40.898 10H35.75V22.51H37.28V17.038H39.854L40.592 18.388C42.302 21.628 43.49 22.546 45.524 22.546C45.668 22.546 45.794 22.528 45.92 22.51V21.088C45.83 21.124 45.722 21.142 45.578 21.142ZM40.898 11.35C42.608 11.35 43.436 12.124 43.436 13.528C43.436 14.896 42.626 15.688 40.898 15.688H37.28V11.35H40.898Z"
fill="#201F27"
fill="currentColor"
/>
<path
d="M54.4684 19.522C53.8744 20.71 53.0644 21.43 51.6784 21.43C49.9684 21.43 48.9244 20.314 48.8344 18.532H55.5124C55.5304 18.424 55.5304 18.208 55.5304 18.046C55.5304 15.418 54.0184 13.654 51.5344 13.654C49.0864 13.654 47.2684 15.508 47.2684 18.19C47.2684 20.854 49.1404 22.726 51.6784 22.726C53.7304 22.726 54.9364 21.592 55.6204 20.242L54.4684 19.522ZM51.5164 14.932C53.0284 14.932 53.8564 15.778 53.9824 17.326H48.8704C49.0684 15.832 50.0764 14.932 51.5164 14.932Z"
fill="#201F27"
fill="currentColor"
/>
<path
d="M60.979 22.726C61.987 22.726 62.959 22.258 63.589 21.394V22.51H65.083V17.092C65.083 14.86 63.643 13.654 61.573 13.654C59.593 13.654 58.171 14.788 57.721 16.624L59.071 17.002C59.395 15.652 60.151 14.95 61.555 14.95C62.833 14.95 63.589 15.778 63.589 17.146V18.28C63.031 17.758 62.149 17.254 60.979 17.254C59.053 17.254 57.667 18.478 57.667 19.99C57.667 21.682 59.053 22.726 60.979 22.726ZM61.123 21.484C59.989 21.484 59.251 20.872 59.251 19.99C59.251 19.126 59.989 18.514 61.123 18.514C62.167 18.514 63.049 19.09 63.589 19.72V20.044C63.013 20.872 62.167 21.484 61.123 21.484Z"
fill="#201F27"
fill="currentColor"
/>
<path d="M69.6409 22.51V10H68.1469V22.51H69.6409Z" fill="#201F27" />
<path d="M69.6409 22.51V10H68.1469V22.51H69.6409Z" fill="currentColor" />
<path
d="M82.8761 13.654C81.5981 13.654 80.4821 14.068 79.7261 15.202C79.2401 14.14 78.2501 13.654 76.9721 13.654C75.8201 13.654 74.8481 14.122 74.2541 14.95V13.87H72.7601V22.51H74.2541V17.29C74.2541 15.778 75.2981 14.95 76.6301 14.95C77.9621 14.95 78.6641 15.778 78.6641 17.308V22.51H80.1581V17.308C80.1581 15.778 81.2021 14.95 82.5341 14.95C83.8661 14.95 84.5861 15.778 84.5861 17.308V22.51H86.0801V17.164C86.0801 15.004 84.9281 13.654 82.8761 13.654Z"
fill="#201F27"
fill="currentColor"
/>
<path
d="M92.4092 22.726C94.3892 22.726 95.8832 21.79 95.8832 20.08C95.8832 18.298 94.5152 17.83 93.0752 17.578L92.0492 17.398C90.8432 17.2 90.3932 16.894 90.3932 16.12C90.3932 15.346 91.0952 14.896 92.2832 14.896C93.6692 14.896 94.2992 15.688 94.4792 16.516L95.8292 16.156C95.5772 14.734 94.4612 13.654 92.3372 13.654C90.2312 13.654 88.9352 14.626 88.9352 16.138C88.9352 17.992 90.3392 18.46 91.8152 18.73L92.8232 18.892C93.9212 19.09 94.3352 19.36 94.3352 20.098C94.3352 21.034 93.5252 21.484 92.4452 21.484C91.0772 21.484 90.0332 20.962 89.8892 19.414L88.5032 19.666C88.5392 21.772 90.3392 22.726 92.4092 22.726Z"
fill="#201F27"
fill="currentColor"
/>
<path
d="M21.6658 28.1373C21.5204 28.1932 21.4034 28.0154 21.5064 27.8985C24.3031 24.7263 25.9999 20.5612 25.9999 15.9997C25.9999 11.4383 24.3031 7.27314 21.5064 4.10096C21.4034 3.98409 21.5203 3.80623 21.6658 3.86217C26.5405 5.7373 29.9999 10.4645 29.9999 15.9997C29.9999 21.5349 26.5405 26.2622 21.6658 28.1373Z"

View File

@ -30,11 +30,15 @@ export const Primary = forwardRef<HTMLButtonElement, Props>(function Primary(
'text-white',
'tracking-normal',
'transition-colors',
'dark:bg-sky-400',
'dark:text-neutral-900',
rest.className,
!pending && 'active:bg-sky-500',
!pending && 'dark:active:bg-sky-400',
'disabled:bg-zinc-300',
'disabled:cursor-not-allowed',
!pending && 'hover:bg-sky-400',
!pending && 'dark:hover:bg-sky-300',
pending && 'cursor-not-allowed',
)}
onClick={(e) => {

View File

@ -0,0 +1,68 @@
import { forwardRef } from 'react';
import { LoadingDots } from '@hub/components/LoadingDots';
import cx from '@hub/lib/cx';
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
pending?: boolean;
}
export const PrimaryAlt = forwardRef<HTMLButtonElement, Props>(
function PrimaryAlt(props, ref) {
const { pending, ...rest } = props;
return (
<button
{...rest}
ref={ref}
className={cx(
'bg-sky-500',
'flex',
'group',
'h-10',
'items-center',
'justify-center',
'p-3',
'relative',
'rounded',
'text-white',
'tracking-normal',
'transition-colors',
'dark:bg-neutral-50',
'dark:text-black',
rest.className,
!pending && 'active:bg-sky-500',
'disabled:bg-zinc-300',
'disabled:cursor-not-allowed',
!pending && 'hover:bg-sky-400',
pending && 'cursor-not-allowed',
!pending && 'dark:active:bg-neutral-300',
!pending && 'dark:hover:bg-neutral-200',
)}
onClick={(e) => {
if (!pending && !rest.disabled) {
rest.onClick?.(e);
}
}}
>
<div
className={cx(
'flex',
'items-center',
'justify-center',
'text-current',
'text-sm',
'transition-all',
'group-disabled:text-neutral-400',
pending ? 'opacity-0' : 'opacity-100',
)}
>
{rest.children}
</div>
{pending && (
<LoadingDots className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
)}
</button>
);
},
);

View File

@ -29,11 +29,15 @@ export const Secondary = forwardRef<HTMLButtonElement, Props>(
'text-sky-500',
'tracking-normal',
'transition-colors',
'dark:border-sky-400',
'dark:text-sky-400',
rest.className,
'disabled:border-zinc-300',
'disabled:cursor-not-allowed',
!pending && 'hover:bg-sky-100',
!pending && 'active:bg-sky-200',
!pending && 'hover:bg-sky-400/10',
!pending && 'active:bg-sky-400/20',
pending && 'cursor-not-allowed',
)}
onClick={(e) => {

View File

@ -0,0 +1,73 @@
import { forwardRef } from 'react';
import { LoadingDots } from '@hub/components/LoadingDots';
import cx from '@hub/lib/cx';
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
pending?: boolean;
}
export const SecondaryAlt = forwardRef<HTMLButtonElement, Props>(
function SecondaryAlt(props, ref) {
const { pending, ...rest } = props;
return (
<button
{...rest}
ref={ref}
className={cx(
'border-sky-500',
'border',
'flex',
'group',
'h-10',
'items-center',
'justify-center',
'p-3',
'relative',
'rounded',
'text-sky-500',
'tracking-normal',
'transition-colors',
'dark:border-neutral-50',
'dark:text-neutral-50',
rest.className,
'disabled:border-zinc-300',
'disabled:cursor-not-allowed',
!pending && 'hover:bg-sky-100',
!pending && 'active:bg-sky-200',
pending && 'cursor-not-allowed',
!pending && 'dark:hover:bg-neutral-50/20',
!pending && 'disabled:dark:hover:bg-transparent',
!pending && 'dark:active:border-neutral-300',
!pending && 'dark:active:text-neutral-300',
!pending && 'dark:hover:border-neutral-200',
!pending && 'dark:hover:text-neutral-200',
)}
onClick={(e) => {
if (!pending && !rest.disabled) {
rest.onClick?.(e);
}
}}
>
<div
className={cx(
'flex',
'items-center',
'justify-center',
'text-current',
'text-sm',
'transition-all',
'group-disabled:text-zinc-300',
pending ? 'opacity-0' : 'opacity-100',
)}
>
{rest.children}
</div>
{pending && (
<LoadingDots className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
)}
</button>
);
},
);

View File

@ -1,4 +1,6 @@
export { Primary } from './Primary';
export { PrimaryAlt } from './PrimaryAlt';
export { Secondary } from './Secondary';
export { SecondaryAlt } from './SecondaryAlt';
export { SecondaryRed } from './SecondaryRed';
export { Tertiary } from './Tertiary';

View File

@ -0,0 +1,65 @@
import RadioButtonIcon from '@carbon/icons-react/lib/RadioButton';
import RadioButtonCheckedIcon from '@carbon/icons-react/lib/RadioButtonChecked';
import * as Button from '@hub/components/controls/Button';
import cx from '@hub/lib/cx';
interface Props {
className?: string;
value: boolean;
valueFalseText: string;
valueTrueText: string;
disableValueTrue?: boolean;
disableValueFalse?: boolean;
onChange?(value: boolean): void;
}
export function ButtonToggle(props: Props) {
return (
<div className={cx(props.className, 'grid', 'grid-cols-2', 'gap-x-2.5')}>
{props.value === true ? (
<Button.PrimaryAlt
className="h-full"
disabled={props.disableValueTrue}
onClick={() => props.onChange?.(true)}
>
<RadioButtonCheckedIcon className="h-4 mr-1 w-4" />
{props.valueTrueText}
</Button.PrimaryAlt>
) : (
<Button.SecondaryAlt
className="h-full opacity-60"
disabled={props.disableValueTrue}
onClick={() => props.onChange?.(true)}
>
<RadioButtonIcon className="h-4 mr-1 w-4" />
{props.valueTrueText}
</Button.SecondaryAlt>
)}
{props.value === false ? (
<Button.PrimaryAlt
className="h-full"
disabled={props.disableValueFalse}
onClick={() => props.onChange?.(false)}
>
<RadioButtonCheckedIcon className="h-4 mr-1 w-4" />
{props.valueFalseText}
</Button.PrimaryAlt>
) : (
<Button.SecondaryAlt
className="h-full opacity-60"
disabled={props.disableValueFalse}
onClick={() => props.onChange?.(false)}
>
<RadioButtonIcon className="h-4 mr-1 w-4" />
{props.valueFalseText}
</Button.SecondaryAlt>
)}
</div>
);
}
ButtonToggle.defaultProps = {
valueFalseText: 'No',
valueTrueText: 'Yes',
};

View File

@ -0,0 +1,38 @@
import type { PublicKey } from '@solana/web3.js';
import { useToast, ToastType } from '@hub/hooks/useToast';
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
address: string | PublicKey;
}
export function CopyAddressButton(props: Props) {
const { address, ...rest } = props;
const { publish } = useToast();
return (
<button
{...rest}
onClick={async (e) => {
try {
const text =
typeof address === 'string' ? address : address.toBase58();
await navigator.clipboard.writeText(text);
publish({
type: ToastType.Success,
title: 'Copied!',
message: 'Address copied to clipboard',
});
} catch {
publish({
type: ToastType.Error,
title: 'Could not copy address',
message: 'There was an error when trying to copy this address',
});
}
rest.onClick?.(e);
}}
/>
);
}

View File

@ -20,6 +20,12 @@ export function Input(props: Props) {
'hover:border-zinc-400',
'focus:border-sky-500',
'placeholder:text-neutral-400',
'dark:bg-neutral-800',
'dark:border-neutral-700',
'dark:placeholder:text-neutral-600',
'dark:text-neutral-50',
'disabled:opacity-50',
'disabled:pointer-events-none',
className,
)}
{...rest}

View File

@ -0,0 +1,81 @@
import * as SliderRoot from '@radix-ui/react-slider';
import { clamp } from 'ramda';
import cx from '@hub/lib/cx';
interface Props {
className?: string;
trackColor?: string;
max: number;
min: number;
step: number;
value: number;
onRenderValue(value: number): React.ReactNode;
onChange?(value: number): void;
}
export function Slider(props: Props) {
return (
<div
className={cx(
props.className,
'gap-x-1.5',
'grid-cols-[max-content,1fr,max-content]',
'grid',
'h-14',
'items-center',
'p-4',
'rounded-md',
'w-full',
'dark:bg-neutral-800',
)}
>
<div className="text-center min-w-[32px] dark:text-neutral-300">
{props.onRenderValue(props.min)}
</div>
<SliderRoot.Root
max={props.max}
min={props.min}
step={props.step}
value={[clamp(props.min, props.max, props.value)]}
onValueChange={(values) => props.onChange?.(values[0])}
onValueCommit={(values) => props.onChange?.(values[0])}
>
<SliderRoot.Track className="block h-2 relative rounded-sm dark:bg-black">
<SliderRoot.Range
className={cx(
'absolute',
'block',
'h-2',
'rounded-sm',
'bg-neutral-300',
props.trackColor,
)}
/>
<SliderRoot.Thumb
className={cx(
'-translate-y-1/2',
'block',
'cursor-pointer',
'h-6',
'mt-1',
'rounded-full',
'transition-colors',
'w-6',
'dark:bg-white',
'dark:hover:bg-neutral-300',
)}
/>
</SliderRoot.Track>
</SliderRoot.Root>
<div className="text-center min-w-[32px] dark:text-neutral-300">
{props.onRenderValue(Math.max(props.max, props.value))}
</div>
</div>
);
}
Slider.defaultProps = {
step: 1,
onRenderValue: (value) => String(value),
} as Props;

View File

@ -21,6 +21,10 @@ export function Textarea(props: Props) {
'hover:border-zinc-400',
'focus:border-sky-500',
'placeholder:text-neutral-400',
'dark:bg-neutral-800',
'dark:border-neutral-700',
'dark:placeholder:text-neutral-600',
'dark:text-neutral-50',
className,
)}
{...rest}

View File

@ -15,6 +15,7 @@ interface Options {
const cache = (() => {
if (typeof window === 'undefined') {
return {
// eslint-disable-next-line
get: <V>(key: string, options?: Options): V | null => null,
set: <V>(key: string, value: V) => value,
};

7
hub/hooks/useProposal.ts Normal file
View File

@ -0,0 +1,7 @@
import { useContext } from 'react';
import { context } from '@hub/providers/Proposal';
export function useProposal() {
return useContext(context);
}

View File

@ -0,0 +1,101 @@
import { useCallback, useState } from 'react';
export enum CreationProgressState {
Complete = 'Complete',
Error = 'Error',
Processing = 'Processing',
Ready = 'Ready',
}
export interface CreationComplete {
state: CreationProgressState.Complete;
transactionsCompleted: number;
}
export interface CreationError {
state: CreationProgressState.Error;
error: Error;
}
export interface CreationProcessing {
state: CreationProgressState.Processing;
totalTransactions: number;
transactionsCompleted: number;
transactionsRemaining: number;
}
export interface CreationReady {
state: CreationProgressState.Ready;
}
export type CreationProgress =
| CreationComplete
| CreationError
| CreationProcessing
| CreationReady;
export function useProposalCreationProgress() {
const [progress, setProgress] = useState<CreationProgress>({
state: CreationProgressState.Ready,
});
const afterBatchSign = useCallback(
(signedTransactionCount: number) => {
setProgress({
state: CreationProgressState.Processing,
totalTransactions: signedTransactionCount,
transactionsCompleted: 0,
transactionsRemaining: signedTransactionCount,
});
},
[setProgress],
);
const afterAllTxConfirmed = useCallback(() => {
setProgress((cur) => ({
state: CreationProgressState.Complete,
transactionsCompleted:
cur.state === CreationProgressState.Processing
? cur.totalTransactions
: 0,
}));
}, [setProgress]);
const afterEveryTxConfirmation = useCallback(() => {
setProgress((cur) => ({
state: CreationProgressState.Processing,
totalTransactions:
cur.state === CreationProgressState.Processing
? cur.totalTransactions
: 0,
transactionsCompleted:
cur.state === CreationProgressState.Processing
? cur.transactionsCompleted + 1
: 0,
transactionsRemaining:
cur.state === CreationProgressState.Processing
? cur.transactionsRemaining - 1
: 0,
}));
}, [setProgress]);
const onError = useCallback(
(error: any) => {
setProgress({
state: CreationProgressState.Error,
error: error instanceof Error ? error : new Error(error),
});
},
[setProgress],
);
return {
progress,
callbacks: {
afterBatchSign,
afterAllTxConfirmed,
afterEveryTxConfirmation,
onError,
},
};
}

View File

@ -3,8 +3,18 @@ import { useContext } from 'react';
import { context } from '@hub/providers/Wallet';
export function useWallet() {
const { connect, publicKey, signMessage, signTransation } = useContext(
context,
);
return { connect, publicKey, signMessage, signTransation };
const {
connect,
publicKey,
signMessage,
signTransaction,
signAllTransactions,
} = useContext(context);
return {
connect,
publicKey,
signMessage,
signTransaction,
signAllTransactions,
};
}

View File

@ -1,6 +1,7 @@
import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
import { clusterApiUrl, Connection } from '@solana/web3.js';
import { createContext, useState } from 'react';
import { useRouter } from 'next/router';
import { createContext, useEffect, useState } from 'react';
const DEVNET_RPC_ENDPOINT =
process.env.DEVNET_RPC || 'https://api.dao.devnet.solana.com/';
@ -68,7 +69,21 @@ interface Props {
}
export function ClusterProvider(props: Props) {
const [type, setType] = useState(ClusterType.Mainnet);
const router = useRouter();
const { cluster: urlCluster } = router.query;
const [type, setType] = useState(
typeof urlCluster === 'string' && urlCluster === 'devnet'
? ClusterType.Devnet
: ClusterType.Mainnet,
);
useEffect(() => {
if (typeof urlCluster === 'string' && urlCluster === 'devnet') {
setType(ClusterType.Devnet);
} else {
setType(ClusterType.Mainnet);
}
}, [urlCluster]);
const cluster =
type === ClusterType.Devnet

View File

@ -1,36 +1,67 @@
import { makeOperation } from '@urql/core';
import { authExchange } from '@urql/exchange-auth';
export const auth = authExchange<{ token?: string }>({
export const auth = authExchange<{ token?: string; cluster?: string }>({
addAuthToOperation: ({ authState, operation }) => {
const token = authState?.token || localStorage.getItem('user');
if (!token) {
return operation;
let enchancedOperation = operation;
if (authState?.cluster === 'devnet') {
const fetchOptions =
typeof enchancedOperation.context.fetchOptions === 'function'
? enchancedOperation.context.fetchOptions()
: enchancedOperation.context.fetchOptions || {};
enchancedOperation = makeOperation(
enchancedOperation.kind,
enchancedOperation,
{
...enchancedOperation.context,
fetchOptions: {
...fetchOptions,
headers: {
...fetchOptions.headers,
'x-environment': 'devnet',
},
},
},
);
}
const fetchOptions =
typeof operation.context.fetchOptions === 'function'
? operation.context.fetchOptions()
: operation.context.fetchOptions || {};
if (token) {
const fetchOptions =
typeof enchancedOperation.context.fetchOptions === 'function'
? enchancedOperation.context.fetchOptions()
: enchancedOperation.context.fetchOptions || {};
return makeOperation(operation.kind, operation, {
...operation.context,
fetchOptions: {
...fetchOptions,
headers: {
...fetchOptions.headers,
Authorization: `Bearer ${token}`,
enchancedOperation = makeOperation(
enchancedOperation.kind,
enchancedOperation,
{
...enchancedOperation.context,
fetchOptions: {
...fetchOptions,
headers: {
...fetchOptions.headers,
Authorization: `Bearer ${token}`,
},
},
},
},
});
);
}
return enchancedOperation;
},
getAuth: async ({ authState }) => {
if (!authState) {
const token = localStorage.getItem('user');
const token = localStorage.getItem('user') || undefined;
const cluster = location.search.includes('cluster=devnet')
? 'devnet'
: undefined;
if (token) {
return { token };
if (token || cluster) {
return { cluster, token };
}
}

View File

@ -54,6 +54,7 @@ export const graphcache = async (
DiscoverPage: (page) => String(page.version as number),
DiscoverPageSpotlightItem: (item) => item.publicKey as string,
DiscoverPageSpotlightItemStat: () => null,
GovernanceRules: (rules) => rules.governanceAddress as string,
Realm: (realm) => realm.publicKey as string,
RealmAboutSection: () => null,
RealmDocumentation: () => null,
@ -83,6 +84,7 @@ export const graphcache = async (
RealmProposalUserVote: () => null,
RealmProposalVoteBreakdown: () => null,
RealmTreasury: (treasury) => treasury.belongsTo as string,
TokenBasedGovernanceRules: () => null,
User: (user) => user.publicKey as string,
},
updates: {

View File

@ -0,0 +1,263 @@
import { Wallet } from '@project-serum/anchor';
import {
serializeInstructionToBase64,
getGovernance,
getRealm,
getTokenOwnerRecordForRealm,
getRealmConfigAddress,
GoverningTokenType,
GoverningTokenConfig,
RpcContext,
GovernanceAccountParser,
RealmConfigAccount,
ProgramAccount,
} from '@solana/spl-governance';
import type {
Connection,
PublicKey,
TransactionInstruction,
Transaction,
} from '@solana/web3.js';
import {
InstructionDataWithHoldUpTime,
createProposal as _createProposal,
} from 'actions/createProposal';
import { tryGetNftRegistrar } from 'VoteStakeRegistry/sdk/api';
import {
vsrPluginsPks,
nftPluginsPks,
gatewayPluginsPks,
switchboardPluginsPks,
pythPluginsPks,
} from '@hooks/useVotingPlugins';
import { getRegistrarPDA as getPluginRegistrarPDA } from '@utils/plugin/accounts';
import { getNfts } from '@utils/tokens';
import { NFTWithMeta, VotingClient } from '@utils/uiTypes/VotePlugin';
import { fetchPlugins } from './fetchPlugins';
interface Args {
callbacks?: Parameters<typeof _createProposal>[11];
cluster?: string;
connection: Connection;
councilTokenMintPublicKey?: PublicKey;
communityTokenMintPublicKey?: PublicKey;
governancePublicKey: PublicKey;
governingTokenMintPublicKey: PublicKey;
instructions: TransactionInstruction[];
isDraft: boolean;
programPublicKey: PublicKey;
proposalDescription: string;
proposalTitle: string;
realmPublicKey: PublicKey;
requestingUserPublicKey: PublicKey;
signTransaction(transaction: Transaction): Promise<Transaction>;
signAllTransactions(transactions: Transaction[]): Promise<Transaction[]>;
}
export async function createProposal(args: Args) {
const [
governance,
realm,
tokenOwnerRecord,
realmConfigPublicKey,
// eslint-disable-next-line
councilTokenOwnerRecord,
// eslint-disable-next-line
communityTokenOwnerRecord,
] = await Promise.all([
getGovernance(args.connection, args.governancePublicKey),
getRealm(args.connection, args.realmPublicKey),
getTokenOwnerRecordForRealm(
args.connection,
args.programPublicKey,
args.realmPublicKey,
args.governingTokenMintPublicKey,
args.requestingUserPublicKey,
),
getRealmConfigAddress(args.programPublicKey, args.realmPublicKey),
args.councilTokenMintPublicKey
? getTokenOwnerRecordForRealm(
args.connection,
args.programPublicKey,
args.realmPublicKey,
args.councilTokenMintPublicKey,
args.requestingUserPublicKey,
).catch(() => undefined)
: undefined,
args.communityTokenMintPublicKey
? getTokenOwnerRecordForRealm(
args.connection,
args.programPublicKey,
args.realmPublicKey,
args.communityTokenMintPublicKey,
args.requestingUserPublicKey,
).catch(() => undefined)
: undefined,
]);
const realmConfigAccountInfo = await args.connection.getAccountInfo(
realmConfigPublicKey,
);
const realmConfig: ProgramAccount<RealmConfigAccount> = realmConfigAccountInfo
? GovernanceAccountParser(RealmConfigAccount)(
realmConfigPublicKey,
realmConfigAccountInfo,
)
: {
pubkey: realmConfigPublicKey,
owner: args.programPublicKey,
account: new RealmConfigAccount({
realm: args.realmPublicKey,
communityTokenConfig: new GoverningTokenConfig({
voterWeightAddin: undefined,
maxVoterWeightAddin: undefined,
tokenType: GoverningTokenType.Liquid,
reserved: new Uint8Array(),
}),
councilTokenConfig: new GoverningTokenConfig({
voterWeightAddin: undefined,
maxVoterWeightAddin: undefined,
tokenType: GoverningTokenType.Liquid,
reserved: new Uint8Array(),
}),
reserved: new Uint8Array(),
}),
};
const serializedInstructions = args.instructions.map(
serializeInstructionToBase64,
);
const instructionData = serializedInstructions.map(
(serializedInstruction) =>
new InstructionDataWithHoldUpTime({
governance,
instruction: {
governance,
serializedInstruction,
isValid: true,
},
}),
);
const proposalIndex = governance.account.proposalCount;
const votingPlugins = await fetchPlugins(
args.connection,
args.programPublicKey,
{
publicKey: args.requestingUserPublicKey,
signTransaction: args.signTransaction,
signAllTransactions: args.signAllTransactions,
} as Wallet,
args.cluster === 'devnet',
);
const pluginPublicKey =
realmConfig.account.communityTokenConfig.voterWeightAddin;
let votingClient: VotingClient | undefined = undefined;
let votingNfts: NFTWithMeta[] = [];
if (pluginPublicKey) {
const pluginPublicKeyStr = pluginPublicKey.toBase58();
let client: VotingClient['client'] = undefined;
// Check for plugins in a particular order. I'm not sure why, but I
// borrowed this from /hooks/useVotingPlugins.ts
if (vsrPluginsPks.includes(pluginPublicKeyStr) && votingPlugins.vsrClient) {
client = votingPlugins.vsrClient;
}
if (nftPluginsPks.includes(pluginPublicKeyStr) && votingPlugins.nftClient) {
client = votingPlugins.nftClient;
if (client && args.communityTokenMintPublicKey) {
const programId = client.program.programId;
const registrarPDA = (
await getPluginRegistrarPDA(
args.realmPublicKey,
args.communityTokenMintPublicKey,
programId,
)
).registrar;
const registrar: any = await tryGetNftRegistrar(registrarPDA, client);
const collections: string[] =
registrar?.collectionConfigs.map((x: any) =>
x.collection.toBase58(),
) || [];
const nfts = await getNfts(args.requestingUserPublicKey, {
cluster: args.cluster,
} as any);
votingNfts = nfts.filter(
(nft) =>
nft.collection &&
nft.collection.mintAddress &&
(nft.collection.verified ||
typeof nft.collection.verified === 'undefined') &&
collections.includes(nft.collection.mintAddress) &&
nft.collection.creators?.filter((x) => x.verified).length > 0,
);
}
}
if (
switchboardPluginsPks.includes(pluginPublicKeyStr) &&
votingPlugins.switchboardClient
) {
client = votingPlugins.switchboardClient;
}
if (
gatewayPluginsPks.includes(pluginPublicKeyStr) &&
votingPlugins.gatewayClient
) {
client = votingPlugins.gatewayClient;
}
if (
pythPluginsPks.includes(pluginPublicKeyStr) &&
votingPlugins.pythClient
) {
client = votingPlugins.pythClient;
}
if (client) {
votingClient = new VotingClient({
realm,
client,
walletPk: args.requestingUserPublicKey,
});
votingClient._setCurrentVoterNfts(votingNfts);
}
}
return _createProposal(
{
connection: args.connection,
wallet: {
publicKey: args.requestingUserPublicKey,
signTransaction: args.signTransaction,
signAllTransactions: args.signAllTransactions,
},
programId: args.programPublicKey,
walletPubkey: args.requestingUserPublicKey,
} as RpcContext,
realm,
args.governancePublicKey,
tokenOwnerRecord,
args.proposalTitle,
args.proposalDescription,
args.governingTokenMintPublicKey,
proposalIndex,
instructionData,
args.isDraft,
votingClient,
args.callbacks,
);
}

View File

@ -0,0 +1,43 @@
import { AnchorProvider, Wallet } from '@project-serum/anchor';
import {
NftVoterClient,
GatewayClient,
} from '@solana/governance-program-library';
import { Connection, PublicKey } from '@solana/web3.js';
import { PythClient } from 'pyth-staking-api';
import { VsrClient } from 'VoteStakeRegistry/sdk/client';
import { SwitchboardQueueVoterClient } from '../../../SwitchboardVotePlugin/SwitchboardQueueVoterClient';
export async function fetchPlugins(
connection: Connection,
programPublicKey: PublicKey,
wallet: Wallet,
isDevnet?: boolean,
) {
const defaultOptions = AnchorProvider.defaultOptions();
const anchorProvider = new AnchorProvider(connection, wallet, defaultOptions);
const [
vsrClient,
nftClient,
gatewayClient,
switchboardClient,
pythClient,
] = await Promise.all([
VsrClient.connect(anchorProvider, programPublicKey, isDevnet),
NftVoterClient.connect(anchorProvider, isDevnet),
GatewayClient.connect(anchorProvider, isDevnet),
SwitchboardQueueVoterClient.connect(anchorProvider, isDevnet),
PythClient.connect(anchorProvider, connection.rpcEndpoint),
]);
return {
vsrClient,
nftClient,
gatewayClient,
switchboardClient,
pythClient,
};
}

View File

@ -0,0 +1,94 @@
import { createContext } from 'react';
import { ClusterType, useCluster } from '@hub/hooks/useCluster';
import {
useProposalCreationProgress,
CreationProgress,
CreationProgressState,
} from '@hub/hooks/useProposalCreationProgress';
import { useToast, ToastType } from '@hub/hooks/useToast';
import { useWallet } from '@hub/hooks/useWallet';
import { createProposal } from './createProposal';
type CreateProposalsArgs = Omit<
Parameters<typeof createProposal>[0],
| 'callbacks'
| 'connection'
| 'cluster'
| 'signTransaction'
| 'signAllTransactions'
| 'requestingUserPublicKey'
>;
interface Value {
createProposal(
args: CreateProposalsArgs,
): Promise<Awaited<ReturnType<typeof createProposal>> | null>;
progress: CreationProgress;
}
export const DEFAULT: Value = {
createProposal: async () => {
throw new Error('Not implemented');
},
progress: { state: CreationProgressState.Ready },
};
export const context = createContext(DEFAULT);
interface Props {
children?: React.ReactNode;
}
export function ProposalProvider(props: Props) {
const [cluster] = useCluster();
const { connect, signTransaction, signAllTransactions } = useWallet();
const { publish } = useToast();
const { callbacks, progress } = useProposalCreationProgress();
return (
<context.Provider
value={{
progress,
createProposal: async (args) => {
try {
const publicKey = await connect();
if (!publicKey) {
throw new Error('User must be signed in');
}
return createProposal({
...args,
callbacks,
cluster:
cluster.type === ClusterType.Devnet
? 'devnet'
: cluster.type === ClusterType.Mainnet
? 'mainnet'
: 'localnet',
connection: cluster.connection,
requestingUserPublicKey: publicKey,
signAllTransactions,
signTransaction,
});
} catch (e) {
const message =
e instanceof Error ? e.message : 'Something went wrong';
publish({
message,
type: ToastType.Error,
title: 'Couuld not create a proposal',
});
return null;
}
},
}}
>
{props.children}
</context.Provider>
);
}

View File

@ -6,6 +6,7 @@ import cx from '@hub/lib/cx';
import { ClusterProvider } from './Cluster';
import { GraphQLProvider } from './GraphQL';
import { JWTProvider } from './JWT';
import { ProposalProvider } from './Proposal';
import { ToastProvider } from './Toast';
import { UserPrefsProvider } from './UserPrefs';
import { WalletProvider } from './Wallet';
@ -33,7 +34,9 @@ export function RootProvider(props: Props) {
<WalletProvider>
<GraphQLProvider>
<UserPrefsProvider>
<Tooltip.Provider>{props.children}</Tooltip.Provider>
<ProposalProvider>
<Tooltip.Provider>{props.children}</Tooltip.Provider>
</ProposalProvider>
</UserPrefsProvider>
</GraphQLProvider>
</WalletProvider>

View File

@ -82,6 +82,9 @@ function ToastItem(props: ToastModel & { onOpenChange(open: boolean): void }) {
'text-left',
'translate-x-full',
'transition-all',
'dark:bg-neutral-900',
'dark:border',
'dark:border-neutral-600',
'hover:scale-105',
show ? 'opacity-100' : 'opacity-0',
show ? 'h-auto' : 'h-0',
@ -95,13 +98,18 @@ function ToastItem(props: ToastModel & { onOpenChange(open: boolean): void }) {
})}
</div>
<Toast.Title
className={cx('text-neutral-900', 'text-sm', 'font-bold')}
className={cx(
'text-neutral-900',
'text-sm',
'font-bold',
'dark:text-white',
)}
>
{props.title || defaultTitle(props)}
</Toast.Title>
</div>
<div className="pl-6">
<Toast.Description className="text-sm text-zinc-500">
<Toast.Description className="text-sm text-neutral-500">
{props.message}
</Toast.Description>
</div>

View File

@ -9,7 +9,8 @@ interface Value {
connect(): Promise<PublicKey>;
publicKey?: PublicKey;
signMessage: NonNullable<WalletContextState['signMessage']>;
signTransation: NonNullable<WalletContextState['signTransaction']>;
signTransaction: NonNullable<WalletContextState['signTransaction']>;
signAllTransactions: NonNullable<WalletContextState['signAllTransactions']>;
}
export const DEFAULT: Value = {
@ -20,7 +21,10 @@ export const DEFAULT: Value = {
signMessage: async () => {
throw new Error('Not implemented');
},
signTransation: async () => {
signTransaction: async () => {
throw new Error('Not implemented');
},
signAllTransactions: async () => {
throw new Error('Not implemented');
},
};
@ -44,10 +48,14 @@ function WalletProviderInner(props: Props) {
const { signMessage } = await getAdapter();
return signMessage(message);
},
signTransation: async (transaction) => {
signTransaction: async (transaction) => {
const { signTransaction } = await getAdapter();
return signTransaction(transaction);
},
signAllTransactions: async (transactions) => {
const { signAllTransactions } = await getAdapter();
return signAllTransactions(transactions);
},
}}
>
{props.children}

View File

@ -22,6 +22,7 @@ interface Wallet {
publicKey: PublicKey;
signMessage: NonNullable<WalletContextState['signMessage']>;
signTransaction: NonNullable<WalletContextState['signTransaction']>;
signAllTransactions: NonNullable<WalletContextState['signAllTransactions']>;
wallet: BaseWallet;
}
@ -47,7 +48,14 @@ interface Props {
}
function WalletSelectorInner(props: Props) {
const { wallets, signMessage, signTransaction, select, wallet } = useWallet();
const {
wallets,
signMessage,
signTransaction,
signAllTransactions,
select,
wallet,
} = useWallet();
const [selectorOpen, setSelectorOpen] = useState(false);
const [publicKey, setPublicKey] = useState<PublicKey | null>(null);
const [shouldConnect, setShouldConnect] = useState(false);
@ -100,6 +108,11 @@ function WalletSelectorInner(props: Props) {
signMessage,
signTransaction,
wallet,
signAllTransactions:
signAllTransactions ||
(() => {
throw new Error('signAllTransactions not available');
}),
});
}
}, [signMessage, signTransaction, publicKey, wallet]);

View File

@ -0,0 +1,5 @@
export type FormCallbacks<V extends object> = {
[K in keyof V as `on${Capitalize<K extends string ? K : never>}Change`]+?: (
value: V[K],
) => void;
};

3
hub/types/FormProps.ts Normal file
View File

@ -0,0 +1,3 @@
import { FormCallbacks } from './FormCallbacks';
export type FormProps<P extends object> = P & FormCallbacks<P>;

View File

@ -0,0 +1,4 @@
export enum GovernanceTokenType {
Council = 'Council',
Community = 'Community',
}

View File

@ -0,0 +1,5 @@
export enum GovernanceVoteTipping {
Disabled = 'Disabled',
Early = 'Early',
Strict = 'Strict',
}

View File

@ -0,0 +1,15 @@
import * as IT from 'io-ts';
import { GovernanceTokenType as _GovernanceTokenType } from '../GovernanceTokenType';
export const GovernanceTokenTypeCouncil = IT.literal(
_GovernanceTokenType.Council,
);
export const GovernanceTokenTypeCommunity = IT.literal(
_GovernanceTokenType.Community,
);
export const GovernanceTokenType = IT.union([
GovernanceTokenTypeCouncil,
GovernanceTokenTypeCommunity,
]);

View File

@ -0,0 +1,19 @@
import * as IT from 'io-ts';
import { GovernanceVoteTipping as _GovernanceVoteTipping } from '../GovernanceVoteTipping';
export const GovernanceVoteTippingDisabled = IT.literal(
_GovernanceVoteTipping.Disabled,
);
export const GovernanceVoteTippingEarly = IT.literal(
_GovernanceVoteTipping.Early,
);
export const GovernanceVoteTippingStrict = IT.literal(
_GovernanceVoteTipping.Strict,
);
export const GovernanceVoteTipping = IT.union([
GovernanceVoteTippingDisabled,
GovernanceVoteTippingEarly,
GovernanceVoteTippingStrict,
]);

View File

@ -10,6 +10,7 @@ import { Asset, Token } from './Asset'
export interface CommonRules {
maxVotingTime: number
minInstructionHoldupTime: number
votingCoolOffSeconds: number
}
export interface Rules {

View File

@ -75,6 +75,7 @@
"@radix-ui/react-radio-group": "1.1.0",
"@radix-ui/react-select": "1.0.0",
"@radix-ui/react-separator": "1.0.0",
"@radix-ui/react-slider": "1.1.0",
"@radix-ui/react-tabs": "1.0.0",
"@radix-ui/react-toast": "1.0.0",
"@radix-ui/react-toolbar": "1.0.0",

View File

@ -22,7 +22,8 @@ export default function App({ Component, pageProps, router }: AppProps) {
if (
router.pathname.startsWith('/verify-wallet') ||
router.pathname.startsWith('/matchday/verify-wallet')
router.pathname.startsWith('/matchday/verify-wallet') ||
router.pathname.startsWith('/realm/[id]/governance')
) {
return (
<HubApp minimal>

View File

@ -0,0 +1,43 @@
import Head from 'next/head'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { PublicKey } from '@solana/web3.js'
import { EditWalletRules } from '@hub/components/EditWalletRules'
import { ECOSYSTEM_PAGE } from '@hub/lib/constants'
export default function EditWallet() {
const router = useRouter()
const { id, governanceId } = router.query
if (typeof governanceId !== 'string') {
throw new Error('Not a valid wallet address')
}
const governanceAddress = new PublicKey(governanceId)
useEffect(() => {
if (id === ECOSYSTEM_PAGE.toBase58()) {
router.replace('/ecosystem')
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- TODO please fix, it can cause difficult bugs. You might wanna check out https://bobbyhadz.com/blog/react-hooks-exhaustive-deps for info. -@asktree
}, [id])
if (id === ECOSYSTEM_PAGE.toBase58()) {
return <div />
}
return (
<div>
<Head>
<title>Edit Wallet Rules</title>
<meta property="og:title" content="Edit Wallet" key="title" />
</Head>
<EditWalletRules
className="min-h-screen"
realmUrlId={id as string}
governanceAddress={governanceAddress}
/>
</div>
)
}

View File

@ -1,6 +1,7 @@
const lineClamp = require('@tailwindcss/line-clamp')
module.exports = {
darkMode: 'class',
content: [
'./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',

View File

@ -3593,6 +3593,24 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.0"
"@radix-ui/react-slider@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slider/-/react-slider-1.1.0.tgz#b3fdaca27619150e9e6067ad9f979a4535f68d5e"
integrity sha512-5H/QB4xD3GF9UfoSCVLBx2JjlXamMcmTyL6gr4kkd/MiAGaYB0W7Exi4MQa0tJApBFJe+KmS5InKCI56p2kmjA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/number" "1.0.0"
"@radix-ui/primitive" "1.0.0"
"@radix-ui/react-collection" "1.0.1"
"@radix-ui/react-compose-refs" "1.0.0"
"@radix-ui/react-context" "1.0.0"
"@radix-ui/react-direction" "1.0.0"
"@radix-ui/react-primitive" "1.0.1"
"@radix-ui/react-use-controllable-state" "1.0.0"
"@radix-ui/react-use-layout-effect" "1.0.0"
"@radix-ui/react-use-previous" "1.0.0"
"@radix-ui/react-use-size" "1.0.0"
"@radix-ui/react-slot@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.0.tgz#7fa805b99891dea1e862d8f8fbe07f4d6d0fd698"