diff --git a/.papi/descriptors/package.json b/.papi/descriptors/package.json index 61ccbaa..f40f0aa 100644 --- a/.papi/descriptors/package.json +++ b/.papi/descriptors/package.json @@ -1,5 +1,5 @@ { - "version": "0.1.0-autogenerated.9019692316970856612", + "version": "0.1.0-autogenerated.1693958661580142564", "name": "@polkadot-api/descriptors", "files": [ "dist" diff --git a/.papi/metadata/dotPeople.scale b/.papi/metadata/dotPeople.scale new file mode 100644 index 0000000..add64f3 Binary files /dev/null and b/.papi/metadata/dotPeople.scale differ diff --git a/.papi/metadata/ksmPeople.scale b/.papi/metadata/ksmPeople.scale new file mode 100644 index 0000000..15d8dad Binary files /dev/null and b/.papi/metadata/ksmPeople.scale differ diff --git a/.papi/metadata/westend.scale b/.papi/metadata/westend.scale index 3bc25db..8badb6f 100644 Binary files a/.papi/metadata/westend.scale and b/.papi/metadata/westend.scale differ diff --git a/.papi/metadata/westendPeople.scale b/.papi/metadata/westendPeople.scale new file mode 100644 index 0000000..035562d Binary files /dev/null and b/.papi/metadata/westendPeople.scale differ diff --git a/.papi/polkadot-api.json b/.papi/polkadot-api.json index ce0bae8..dd4020e 100644 --- a/.papi/polkadot-api.json +++ b/.papi/polkadot-api.json @@ -17,6 +17,18 @@ "westend": { "chain": "westend2", "metadata": ".papi/metadata/westend.scale" + }, + "dotPeople": { + "chain": "polkadot_people", + "metadata": ".papi/metadata/dotPeople.scale" + }, + "ksmPeople": { + "chain": "ksmcc3_people", + "metadata": ".papi/metadata/ksmPeople.scale" + }, + "westendPeople": { + "chain": "westend2_people", + "metadata": ".papi/metadata/westendPeople.scale" } } -} +} \ No newline at end of file diff --git a/src/assets/peopleNetworks.json b/src/assets/peopleNetworks.json new file mode 100644 index 0000000..3d8b0c6 --- /dev/null +++ b/src/assets/peopleNetworks.json @@ -0,0 +1,74 @@ +{ + "polkadotPeople": { + "nodes": [ + { + "url": "wss://sys.ibp.network/people-polkadot", + "name": "IBP1" + }, + { + "url": "wss://sys.dotters.network/people-polkadot", + "name": "IBP2" + }, + { + "url": "wss://rpc-people-polkadot.luckyfriday.io", + "name": "LuckyFriday" + }, + { + "url": "wss://polkadot-people-rpc.polkadot.io", + "name": "Parity" + }, + { + "url": "wss://people-polkadot.public.curie.radiumblock.co/ws", + "name": "RadiumBlock" + } + ] + }, + "kusamaPeople": { + "nodes": [ + { + "url": "wss://people-kusama-rpc.dwellir.com", + "name": "Dwellir" + }, + { + "url": "wss://sys.ibp.network/people-kusama", + "name": "IBP1" + }, + { + "url": "wss://sys.dotters.network/people-kusama", + "name": "IBP2" + }, + { + "url": "wss://rpc-people-kusama.luckyfriday.io", + "name": "LuckyFriday" + }, + { + "url": "wss://kusama-people-rpc.polkadot.io", + "name": "Parity" + }, + { + "url": "wss://ksm-rpc.stakeworld.io/people", + "name": "Stakeworld" + } + ] + }, + "westendPeople": { + "nodes": [ + { + "url": "wss://people-westend-rpc.dwellir.com", + "name": "Dwellir" + }, + { + "url": "wss://sys.ibp.network/people-westend", + "name": "IBP1" + }, + { + "url": "wss://sys.dotters.network/people-westend", + "name": "IBP2" + }, + { + "url": "wss://westend-people-rpc.polkadot.io", + "name": "Parity" + } + ] + } +} diff --git a/src/components/DelegateCard.tsx b/src/components/DelegateCard.tsx index ab85c75..c58d05b 100644 --- a/src/components/DelegateCard.tsx +++ b/src/components/DelegateCard.tsx @@ -3,15 +3,17 @@ import { Card } from '@/components/ui/card' import { useLocation, useNavigate } from 'react-router-dom' import { Delegate } from '@/contexts/DelegatesContext' import { ContentReveal } from './ui/content-reveal' +import { cn } from '@/lib/utils' import { sanitizeString } from '@/lib/utils' import { useNetwork } from '@/contexts/NetworkContext' -import { cn } from '@/lib/utils' import { toast } from 'sonner' import { LinkIcon } from 'lucide-react' + import Markdown from 'react-markdown' import { H, H2, H3, Hr, P } from './ui/md' import { AnchorLink } from './ui/anchorLink' import { useCallback, useMemo } from 'react' +import { IdentityInfo } from './IdentityInfo' interface Props { delegate: Delegate @@ -63,16 +65,18 @@ export const DelegateCard = ({ return ( <Card className={cn('flex flex-col p-4', className)}> - <div className="flex flex-col gap-4 md:flex-row"> + <div className="flex columns-3"> <DelegateAvatar /> - <div className="w-full"> - <div className="flex items-center gap-1 py-2 text-xl font-semibold"> - {name} - {hasShareButton && ( - <Button variant="ghost" onClick={onCopy} size="icon"> - <LinkIcon className="h-4 w-4 text-accent-foreground" /> - </Button> - )} + <div className="w-full p-2"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-1 py-2 text-xl font-semibold"> + <IdentityInfo address={address} name={name} /> + {hasShareButton && ( + <Button variant="ghost" onClick={onCopy} size="icon"> + <LinkIcon className="h-4 w-4 text-accent-foreground" /> + </Button> + )} + </div> </div> <div className="text-accent-foreground"> <div className="break-words text-lg">{shortDescription}</div> diff --git a/src/components/IdentityInfo.tsx b/src/components/IdentityInfo.tsx new file mode 100644 index 0000000..c95f7ce --- /dev/null +++ b/src/components/IdentityInfo.tsx @@ -0,0 +1,68 @@ +import { BsTwitterX } from 'react-icons/bs' +import { IoCheckmarkCircle, IoMail } from 'react-icons/io5' +import { TbWorld } from 'react-icons/tb' +import { Button } from './ui/button' +import { useIdentity } from '@/hooks/useIdentity' +import { TbCircleDashedMinus } from 'react-icons/tb' + +interface Props { + name: string + address: string +} + +export const IdentityInfo = ({ name, address }: Props) => { + const identity = useIdentity(address) + + return ( + <> + {identity?.display ? ( + <div className="flex items-center"> + {identity?.display} + {identity?.judgement ? ( + <IoCheckmarkCircle className="ml-4 text-green-500" /> + ) : ( + <TbCircleDashedMinus className="ml-4 text-gray-500" /> + )} + </div> + ) : ( + name + )} + {identity && ( + <div + className={`flex items-center justify-around text-${identity?.judgement ? 'green' : 'gray'}-500`} + > + {identity?.web && ( + <Button + variant="ghost" + onClick={() => window.open(identity?.web as string, '_blank')} + size="icon" + > + <TbWorld /> + </Button> + )} + {identity?.twitter && ( + <Button + variant="ghost" + onClick={() => + window.open( + `https://twitter.com/@${identity?.twitter}`, + '_blank', + ) + } + size="icon" + > + <BsTwitterX /> + </Button> + )} + {identity?.email && ( + <Button variant="ghost" size="icon"> + <a href={`mailto:${identity?.email}`}> + <IoMail /> + </a> + </Button> + )} + </div> + )} + </> + ) +} diff --git a/src/components/ui/address-display.tsx b/src/components/ui/address-display.tsx index a98c52c..5c4db08 100644 --- a/src/components/ui/address-display.tsx +++ b/src/components/ui/address-display.tsx @@ -1,3 +1,4 @@ +import { useIdentity } from '@/hooks/useIdentity' import { cn } from '@/lib/utils' import { Polkicon } from '@polkadot-ui/react' @@ -8,11 +9,13 @@ type Props = { } export const AddressDisplay = ({ address, size, className = '' }: Props) => { + const identity = useIdentity(address) + return ( <div className={cn('flex items-center gap-2', className)}> <Polkicon address={address} size={size} copy outerColor="transparent" /> <span className="text-gray-500"> - {address.slice(0, 6) + '...' + address.slice(-6)} + {identity?.display || address.slice(0, 6) + '...' + address.slice(-6)} </span> </div> ) diff --git a/src/contexts/AccountsContext.tsx b/src/contexts/AccountsContext.tsx index 890c2e0..22713d1 100644 --- a/src/contexts/AccountsContext.tsx +++ b/src/contexts/AccountsContext.tsx @@ -21,6 +21,10 @@ export interface IAccountContext { selectAccount: (account: InjectedPolkadotAccount | undefined) => void } +type AccountAddressType = { + address: string +} + const AccountContext = createContext<IAccountContext | undefined>(undefined) const AccountContextProvider = ({ children }: AccountContextProps) => { @@ -50,7 +54,7 @@ const AccountContextProvider = ({ children }: AccountContextProps) => { useEffect(() => { if (localStorageAccount) { const account = accounts.find( - (account) => account.address === localStorageAccount, + ({ address }: AccountAddressType) => address === localStorageAccount, ) if (account) { selectAccount(account) diff --git a/src/contexts/NetworkContext.tsx b/src/contexts/NetworkContext.tsx index 990b8f4..4b18b87 100644 --- a/src/contexts/NetworkContext.tsx +++ b/src/contexts/NetworkContext.tsx @@ -6,7 +6,15 @@ import { useEffect, useState, } from 'react' -import { dot, fastWestend, ksm, westend } from '@polkadot-api/descriptors' +import { + dot, + dotPeople, + fastWestend, + ksm, + ksmPeople, + westend, + westendPeople, +} from '@polkadot-api/descriptors' import { ChainDefinition, PolkadotClient, @@ -22,6 +30,7 @@ import { startFromWorker } from 'polkadot-api/smoldot/from-worker' import { getChainInformation } from '@/lib/utils' import { AssetType } from '@/lib/types' import networks from '@/assets/networks.json' +import peopleNetworks from '@/assets/peopleNetworks.json' import { DEFAULT_NETWORK, SELECTED_NETWORK_KEY } from '@/lib/constants' import { useLocalStorage } from 'usehooks-ts' import { useSearchParams } from 'react-router-dom' @@ -30,11 +39,16 @@ type NetworkContextProps = { children: React.ReactNode | React.ReactNode[] } export type NetworksFromConfig = keyof typeof networks +export type SupportedPeopleNetworkNames = keyof typeof peopleNetworks + export type SupportedNetworkNames = | 'polkadot-lc' | 'kusama-lc' | NetworksFromConfig export type ApiType = TypedApi<typeof dot | typeof ksm> +export type PeopleApiType = TypedApi< + typeof dotPeople | typeof ksmPeople | typeof westendPeople +> export const descriptorName: Record<SupportedNetworkNames, ChainDefinition> = { polkadot: dot, @@ -44,6 +58,14 @@ export const descriptorName: Record<SupportedNetworkNames, ChainDefinition> = { westend: westend, 'fast-westend': fastWestend, } +export const descriptorPeopleName: Record< + SupportedPeopleNetworkNames, + ChainDefinition +> = { + polkadotPeople: dotPeople, + kusamaPeople: ksmPeople, + westendPeople: westendPeople, +} export type TrackList = Record<number, string> @@ -51,9 +73,12 @@ export interface INetworkContext { lightClientLoaded: boolean isLight: boolean selectNetwork: (network: string, shouldResetAccountAddress?: boolean) => void - client: PolkadotClient | undefined - api: TypedApi<typeof dot | typeof ksm> | undefined + client?: PolkadotClient + api?: TypedApi<typeof dot | typeof ksm> + peopleApi?: PeopleApiType + peopleClient?: PolkadotClient network?: SupportedNetworkNames + peopleNetwork?: SupportedPeopleNetworkNames assetInfo: AssetType trackList: TrackList } @@ -63,6 +88,12 @@ export const isSupportedNetwork = ( ): network is SupportedNetworkNames => !!descriptorName[network as SupportedNetworkNames] +const extractPeopleFromNetwork = (network: SupportedNetworkNames) => + network + .replace('-lc', '') + .replace('fast-', '') + .concat('People') as SupportedPeopleNetworkNames + const NetworkContext = createContext<INetworkContext | undefined>(undefined) const NetworkContextProvider = ({ children }: NetworkContextProps) => { @@ -74,11 +105,17 @@ const NetworkContextProvider = ({ children }: NetworkContextProps) => { const [lightClientLoaded, setLightClientLoaded] = useState<boolean>(false) const [isLight, setIsLight] = useState<boolean>(false) const [client, setClient] = useState<PolkadotClient>() + const [peopleClient, setPeopleClient] = useState<PolkadotClient>() const [api, setApi] = useState<ApiType>() + const [peopleApi, setPeopleApi] = useState<PeopleApiType>() const [trackList, setTrackList] = useState<TrackList>({}) const [assetInfo, setAssetInfo] = useState<AssetType>({} as AssetType) const [network, setNetwork] = useState<SupportedNetworkNames | undefined>() + const [peopleNetwork, setPeopleNetwork] = + useState<SupportedPeopleNetworkNames>( + extractPeopleFromNetwork(DEFAULT_NETWORK), + ) const [searchParams, setSearchParams] = useSearchParams({ network: '' }) const selectNetwork = useCallback( @@ -86,10 +123,13 @@ const NetworkContextProvider = ({ children }: NetworkContextProps) => { if (!isSupportedNetwork(network)) { console.error('This network is not supported', network) selectNetwork(DEFAULT_NETWORK) + setPeopleNetwork(extractPeopleFromNetwork(DEFAULT_NETWORK)) return } setNetwork(network) + setPeopleNetwork(extractPeopleFromNetwork(network)) + setSearchParams((prev) => { prev.set('network', network) return prev @@ -105,10 +145,12 @@ const NetworkContextProvider = ({ children }: NetworkContextProps) => { // in this order we prefer the network in query string // or the local storage or the default - const selected = - queryStringNetwork || localStorageNetwork || DEFAULT_NETWORK + const selected = (queryStringNetwork || + localStorageNetwork || + DEFAULT_NETWORK) as SupportedNetworkNames selectNetwork(selected) + setPeopleNetwork(extractPeopleFromNetwork(selected)) } }, [localStorageNetwork, network, searchParams, selectNetwork]) @@ -116,6 +158,7 @@ const NetworkContextProvider = ({ children }: NetworkContextProps) => { if (!network) return let client: PolkadotClient + let peopleClient: PolkadotClient if (network === 'polkadot-lc' || network === 'kusama-lc') { const relay = network === 'polkadot-lc' ? 'polkadot' : 'kusama' @@ -125,31 +168,61 @@ const NetworkContextProvider = ({ children }: NetworkContextProps) => { setIsLight(true) const smoldot = startFromWorker(new SmWorker()) let relayChain: Promise<Chain> + let peopleChain: Promise<Chain> if (relay === 'polkadot') { relayChain = import('polkadot-api/chains/polkadot').then( ({ chainSpec }) => smoldot.addChain({ chainSpec }), ) + + peopleChain = Promise.all([ + relayChain, + import('polkadot-api/chains/polkadot_people'), + ]).then(([relayChain, { chainSpec }]) => + smoldot.addChain({ chainSpec, potentialRelayChains: [relayChain] }), + ) } else { relayChain = import('polkadot-api/chains/ksmcc3').then( ({ chainSpec }) => smoldot.addChain({ chainSpec }), ) + + peopleChain = Promise.all([ + relayChain, + import('polkadot-api/chains/ksmcc3_people'), + ]).then(([relayChain, { chainSpec }]) => + smoldot.addChain({ chainSpec, potentialRelayChains: [relayChain] }), + ) } client = createClient(getSmProvider(relayChain)) + peopleClient = createClient(getSmProvider(peopleChain)) } else { const { assetInfo, wsEndpoint } = getChainInformation(network) setAssetInfo(assetInfo) setIsLight(false) client = createClient(getWsProvider(wsEndpoint)) + let wss: string = '' + if (network === 'polkadot') { + wss = 'wss://sys.ibp.network/people-polkadot' + } else if (network === 'kusama') { + wss = 'wss://sys.ibp.network/people-kusama' + } else { + wss = 'wss://sys.ibp.network/people-westend' + } + peopleClient = createClient(getWsProvider(wss)) } const descriptor = descriptorName[network] const typedApi = client.getTypedApi(descriptor) + const descriptorPeople = descriptorPeopleName[peopleNetwork] + const typedPeopleApi = peopleClient.getTypedApi(descriptorPeople) + setClient(client) + setPeopleClient(peopleClient) setApi(typedApi) - }, [network]) + setPeopleApi(typedPeopleApi) + }, [network, peopleNetwork]) useEffect(() => { if (isLight) { @@ -185,6 +258,8 @@ const NetworkContextProvider = ({ children }: NetworkContextProps) => { selectNetwork, client, api, + peopleClient, + peopleApi, assetInfo, trackList, }} diff --git a/src/hooks/useIdentity.tsx b/src/hooks/useIdentity.tsx new file mode 100644 index 0000000..50cbc74 --- /dev/null +++ b/src/hooks/useIdentity.tsx @@ -0,0 +1,53 @@ +import { useNetwork } from '@/contexts/NetworkContext' +import { AccountInfoIF, acceptedJudgement } from '@/lib/utils' +import { DotPeopleQueries, IdentityJudgement } from '@polkadot-api/descriptors' +import { Binary } from 'polkadot-api' +import { useEffect, useState } from 'react' + +const getJudgements = (judgements: [number, IdentityJudgement][]) => + judgements.some(([, j]) => acceptedJudgement.includes(j.type)) + +const dataToString = (value: number | string | Binary | undefined) => + typeof value === 'object' ? value.asText() : (value ?? '') + +const mapRawIdentity = ( + rawIdentity?: DotPeopleQueries['Identity']['IdentityOf']['Value'], +) => { + if (!rawIdentity) return + const { + judgements, + info: { display, email, legal, matrix, twitter, web }, + } = rawIdentity[0] + + const display_id = dataToString(display.value) + return { + display: display_id, + web: dataToString(web.value), + email: dataToString(email.value), + legal: dataToString(legal.value), + matrix: dataToString(matrix.value), + twitter: dataToString(twitter.value), + judgement: getJudgements(judgements), + } +} + +export const useIdentity = (address: string | undefined) => { + const [identity, setIdentity] = useState<AccountInfoIF | undefined>() + + const { peopleApi } = useNetwork() + + useEffect(() => { + if (!address || !peopleApi) return + + peopleApi.query.Identity.IdentityOf.getValue(address) + .then((id) => { + setIdentity({ + address, + ...mapRawIdentity(id), + }) + }) + .catch(console.error) + }, [address, peopleApi]) + + return identity +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 6c1477b..0164138 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -110,3 +110,17 @@ export const shuffleArray = (arrayToShuffle: unknown[]) => { return array } + +// PEOPLE CHAIN RELATED +export type AccountInfoIF = { + address: string | number + display?: string | number + legal?: string | number + matrix?: string | number + email?: string | number + twitter?: string | number + web?: string | number + judgement?: boolean +} + +export const acceptedJudgement = ['Reasonable', 'FeePaid', 'KnownGood']