From 8439f169027edf4b168255f11ce6450cf63d6233 Mon Sep 17 00:00:00 2001 From: Victor Oliva Date: Wed, 23 Oct 2024 09:29:00 +0200 Subject: [PATCH] feat: improve AccountId input --- package.json | 1 + pnpm-lock.yaml | 16 ++ src/components/PolkadotIdenticon.tsx | 153 +++++++++++++ src/pages/Extrinsics/EditCodec/CAccountId.tsx | 207 ++++++++++++++++-- 4 files changed, 357 insertions(+), 20 deletions(-) create mode 100644 src/components/PolkadotIdenticon.tsx diff --git a/package.json b/package.json index 32e8302..ef43942 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@headlessui/react": "^2.1.10", "@polkadot-api/known-chains": "^0.5.4", "@polkadot-api/metadata-builders": "^0.8.2", + "@polkadot-api/pjs-signer": "^0.5.0", "@polkadot-api/polkadot-sdk-compat": "^2.3.1", "@polkadot-api/sm-provider": "^0.1.3", "@polkadot-api/smoldot": "^0.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 895a587..5e5e1a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ dependencies: '@polkadot-api/metadata-builders': specifier: ^0.8.2 version: 0.8.2 + '@polkadot-api/pjs-signer': + specifier: ^0.5.0 + version: 0.5.0 '@polkadot-api/polkadot-sdk-compat': specifier: ^2.3.1 version: 2.3.1 @@ -783,12 +786,25 @@ packages: '@polkadot-api/utils': 0.1.2 dev: false + /@polkadot-api/pjs-signer@0.5.0: + resolution: {integrity: sha512-rvQIEG5lbtZ2/ix7QFYTy+2ZxZ3Gi9pzhXEUFpMTmHvYqAGj0RrK94v544bzdV8XBAotgGV6jqIdIArd0p+Tog==} + dependencies: + '@polkadot-api/metadata-builders': 0.8.2 + '@polkadot-api/polkadot-signer': 0.1.6 + '@polkadot-api/substrate-bindings': 0.9.2 + '@polkadot-api/utils': 0.1.2 + dev: false + /@polkadot-api/polkadot-sdk-compat@2.3.1: resolution: {integrity: sha512-rb8IWmPRhKWD9NG4zh2n4q0HlEAvq+Cv1CbD+8YxH0XAqIIiFA+ch5JeDCIxQYngkn/43B0Gs7Gtzh18yv2yoA==} dependencies: '@polkadot-api/json-rpc-provider': 0.0.4 dev: false + /@polkadot-api/polkadot-signer@0.1.6: + resolution: {integrity: sha512-X7ghAa4r7doETtjAPTb50IpfGtrBmy3BJM5WCfNKa1saK04VFY9w+vDn+hwEcM4p0PcDHt66Ts74hzvHq54d9A==} + dev: false + /@polkadot-api/sm-provider@0.1.3(smoldot@2.0.30): resolution: {integrity: sha512-/Sj6fPlUtu7BVEOeIxjflrO28G564fxM3JDjQZcv1V3hrAb22/p6I/toWej0zSZ6mEAXLjCIEWje14b/lyZmkw==} peerDependencies: diff --git a/src/components/PolkadotIdenticon.tsx b/src/components/PolkadotIdenticon.tsx new file mode 100644 index 0000000..a783294 --- /dev/null +++ b/src/components/PolkadotIdenticon.tsx @@ -0,0 +1,153 @@ +// Based from https://github.com/paritytech/oo7/blob/251ba2b7c45503b68eab4320c270b5afa9bccb60/packages/polkadot-identicon/src/index.jsx + +import { Blake2256 } from "@polkadot-api/substrate-bindings" +import { FC, SVGAttributes } from "react" + +export const PolkadotIdenticon: FC< + Omit, "viewBox"> & { + sixPoint?: boolean + publicKey: Uint8Array | null + size?: number + } +> = ({ sixPoint, publicKey, ...props }) => { + const svgProps = { + ...props, + width: props.size ?? props.width ?? SIZE, + height: props.size ?? props.height ?? SIZE, + viewBox: `0 0 ${SIZE} ${SIZE}`, + } + if (!publicKey) { + return ( + + + + ) + } + + const colors = getColors(publicKey) + const circles = getPositions(sixPoint) + + return ( + + + {circles.map(({ x, y }, i) => ( + + ))} + + ) +} + +const SIZE = 64 +const CENTER = SIZE / 2 + +const z = (SIZE / 64) * 5 +type Schema = { freq: number; colors: number[] } +const schema: Record = { + target: { + freq: 1, + colors: [0, 28, 0, 0, 28, 0, 0, 28, 0, 0, 28, 0, 0, 28, 0, 0, 28, 0, 1], + }, + cube: { + freq: 20, + colors: [0, 1, 3, 2, 4, 3, 0, 1, 3, 2, 4, 3, 0, 1, 3, 2, 4, 3, 5], + }, + quazar: { + freq: 16, + colors: [1, 2, 3, 1, 2, 4, 5, 5, 4, 1, 2, 3, 1, 2, 4, 5, 5, 4, 0], + }, + flower: { + freq: 32, + colors: [0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 3], + }, + cyclic: { + freq: 32, + colors: [0, 1, 2, 3, 4, 5, 0, 1, 2, 3, 4, 5, 0, 1, 2, 3, 4, 5, 6], + }, + vmirror: { + freq: 128, + colors: [0, 1, 2, 3, 4, 5, 3, 4, 2, 0, 1, 6, 7, 8, 9, 7, 8, 6, 10], + }, + hmirror: { + freq: 128, + colors: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 8, 6, 7, 5, 3, 4, 2, 11], + }, +} +const totalFreq = Object.keys(schema) + .map((k) => schema[k].freq) + .reduce((a, b) => a + b) + +const findScheme = (d: number) => { + let cum = 0 + for (const scheme of Object.values(schema)) { + cum += scheme.freq + if (d < cum) { + return scheme + } + } + throw "Unreachable" +} +const zero = Blake2256( + new Uint8Array([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, + ]), +) + +const getColors = (publicKey: Uint8Array) => { + const id = Array.from(Blake2256(publicKey)).map( + (x, i) => (x + 256 - zero[i]) % 256, + ) + + const sat = (Math.floor((id[29] * 70) / 256 + 26) % 80) + 30 + const d = Math.floor((id[30] + id[31] * 256) % totalFreq) + const scheme = findScheme(d) + const palette = Array.from(id).map((x, i) => { + const b = (x + (i % 28) * 58) % 256 + if (b == 0) { + return "#444" + } + if (b == 255) { + return "transparent" + } + const h = Math.floor(((b % 64) * 360) / 64) + const l = [53, 15, 35, 75][Math.floor(b / 64)] + return `hsl(${h}, ${sat}%, ${l}%)` + }) + + const rot = (id[28] % 6) * 3 + + return scheme.colors.map( + (_, i) => palette[scheme.colors[i < 18 ? (i + rot) % 18 : 18]], + ) +} + +const getPositions = (sixPoint = false) => { + const r = sixPoint ? (SIZE / 2 / 8) * 5 : (SIZE / 2 / 4) * 3 + const rroot3o2 = (r * Math.sqrt(3)) / 2 + const ro2 = r / 2 + const rroot3o4 = (r * Math.sqrt(3)) / 4 + const ro4 = r / 4 + const r3o4 = (r * 3) / 4 + + return [ + { x: CENTER, y: CENTER - r }, + { x: CENTER, y: CENTER - ro2 }, + { x: CENTER - rroot3o4, y: CENTER - r3o4 }, + { x: CENTER - rroot3o2, y: CENTER - ro2 }, + { x: CENTER - rroot3o4, y: CENTER - ro4 }, + { x: CENTER - rroot3o2, y: CENTER }, + { x: CENTER - rroot3o2, y: CENTER + ro2 }, + { x: CENTER - rroot3o4, y: CENTER + ro4 }, + { x: CENTER - rroot3o4, y: CENTER + r3o4 }, + { x: CENTER, y: CENTER + r }, + { x: CENTER, y: CENTER + ro2 }, + { x: CENTER + rroot3o4, y: CENTER + r3o4 }, + { x: CENTER + rroot3o2, y: CENTER + ro2 }, + { x: CENTER + rroot3o4, y: CENTER + ro4 }, + { x: CENTER + rroot3o2, y: CENTER }, + { x: CENTER + rroot3o2, y: CENTER - ro2 }, + { x: CENTER + rroot3o4, y: CENTER - ro4 }, + { x: CENTER + rroot3o4, y: CENTER - r3o4 }, + { x: CENTER, y: CENTER }, + ] +} diff --git a/src/pages/Extrinsics/EditCodec/CAccountId.tsx b/src/pages/Extrinsics/EditCodec/CAccountId.tsx index 42d749e..6623548 100644 --- a/src/pages/Extrinsics/EditCodec/CAccountId.tsx +++ b/src/pages/Extrinsics/EditCodec/CAccountId.tsx @@ -1,10 +1,40 @@ -import { - SS58String, - getSs58AddressInfo, -} from "@polkadot-api/substrate-bindings" -import { EditAccountId, NOTIN } from "@codec-components" -import { TextInputField } from "./codec-components" +import { PolkadotIdenticon } from "@/components/PolkadotIdenticon" import { useSynchronizeInput } from "@/components/useSynchroniseInput" +import { EditAccountId, NOTIN } from "@codec-components" +import { + Combobox, + ComboboxButton, + ComboboxInput, + ComboboxOption, + ComboboxOptions, +} from "@headlessui/react" +import { + connectInjectedExtension, + getInjectedExtensions, + InjectedPolkadotAccount, +} from "@polkadot-api/pjs-signer" +import { + getSs58AddressInfo, + SS58String, +} from "@polkadot-api/substrate-bindings" +import { state, useStateObservable } from "@react-rxjs/core" +import { combineKeys } from "@react-rxjs/utils" +import { FC, useState } from "react" +import { + concat, + filter, + finalize, + from, + fromEventPattern, + interval, + map, + retry, + startWith, + switchMap, + take, + timer, +} from "rxjs" +import { twMerge } from "tailwind-merge" export const CAccountId: EditAccountId = ({ value, onValueChanged }) => { const [localInput, setLocalInput] = useSynchronizeInput( @@ -12,30 +42,167 @@ export const CAccountId: EditAccountId = ({ value, onValueChanged }) => { onValueChanged, parseValue, ) + const accounts = useStateObservable(accounts$) + + const [query, setQuery] = useState("") + const queryInfo = getSs58AddressInfo(query) + const filteredAccounts = + query === "" + ? Array.from(accounts.values()) + : Array.from(accounts.values()).filter( + (account) => + account.address.toLowerCase().includes(query.toLowerCase()) || + (account.name && + account.name.toLowerCase().includes(query.toLowerCase())), + ) - const invalid = getValidationError(localInput) return ( - + setLocalInput(v ?? "")} + onClose={() => setQuery("")} + immediate + > + + {localInput && ( + + )} + + accounts.get(address)?.name ?? address + } + onChange={(event) => setQuery(event.target.value)} + /> + + + {filteredAccounts.length > 0 ? ( + filteredAccounts.map((account) => ( + + )) + ) : queryInfo.isValid ? ( + + ) : ( +
+ The value is not a valid Account ID +
+ )} +
+
) } +const AccountOption: FC<{ account: string; publicKey: Uint8Array | null }> = ({ + account, + publicKey, +}) => { + const details = useStateObservable(accountDetail$(account)) + + return ( + + +
+ {details?.name && {details.name}} + + {account} + +
+
+ ) +} + +const getPublicKey = (address: string) => { + const info = getSs58AddressInfo(address) + return info.isValid ? info.publicKey : null +} + const getValidationError = (value: string) => { if (value === "") return null - const info = getSs58AddressInfo(value) - return info.isValid && - (info.publicKey.length === 32 || info.publicKey.length === 32) - ? null - : "Invalid address" + const { isValid } = getSs58AddressInfo(value) + return isValid ? null : "Invalid address" } const parseValue = (value: string): SS58String | NOTIN => { if (value.trim() === "") return NOTIN if (getValidationError(value)) return NOTIN return value } + +const extensions$ = state( + concat( + timer(0, 100).pipe( + map(getInjectedExtensions), + filter((v) => v.length > 0), + take(1), + ), + interval(2000).pipe(map(getInjectedExtensions)), + ), + [], +) +const extensionAccounts$ = combineKeys(extensions$, (extension) => + from(connectInjectedExtension(extension)).pipe( + switchMap((extension) => + fromEventPattern( + extension.subscribe, + (_, fn) => fn(), + ).pipe( + startWith(extension.getAccounts()), + finalize(extension.disconnect), + ), + ), + retry({ + delay: 100, + }), + ), +) +const accounts$ = state( + extensionAccounts$.pipe( + map( + (extensionAccounts) => + new Map( + Array.from(extensionAccounts.entries()).flatMap( + ([extension, accounts]) => + accounts.map((account) => [ + account.address, + { + ...account, + extension, + }, + ]), + ), + ), + ), + ), + new Map(), +) + +const accountDetail$ = state( + (account: string) => accounts$.pipe(map((v) => v.get(account) ?? null)), + null, +)