feat: improve AccountId input

This commit is contained in:
Victor Oliva
2024-10-23 09:29:00 +02:00
parent 0b7f62dee1
commit 8439f16902
4 changed files with 357 additions and 20 deletions
+1
View File
@@ -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",
+16
View File
@@ -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:
+153
View File
@@ -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<SVGAttributes<SVGElement>, "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 (
<svg {...svgProps}>
<circle cx={SIZE / 2} cy={SIZE / 2} r={SIZE / 2} fill="#eee" />
</svg>
)
}
const colors = getColors(publicKey)
const circles = getPositions(sixPoint)
return (
<svg {...svgProps}>
<circle cx={SIZE / 2} cy={SIZE / 2} r={SIZE / 2} fill="#eee" />
{circles.map(({ x, y }, i) => (
<circle key={i} cx={x} cy={y} r={z} fill={colors[i]} />
))}
</svg>
)
}
const SIZE = 64
const CENTER = SIZE / 2
const z = (SIZE / 64) * 5
type Schema = { freq: number; colors: number[] }
const schema: Record<string, Schema> = {
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 },
]
}
+187 -20
View File
@@ -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 (
<TextInputField
value={localInput ?? ""}
className="min-w-96"
onChange={setLocalInput}
placeholder="AccountId"
warn={localInput === ""}
softError={invalid}
/>
<Combobox
value={localInput}
onChange={(v) => setLocalInput(v ?? "")}
onClose={() => setQuery("")}
immediate
>
<ComboboxButton
className={twMerge(
"text-sm border rounded p-2 border-polkadot-200 leading-tight text-white focus-within:outline",
"flex items-center gap-2 bg-polkadot-900",
value === NOTIN ? "border-orange-400" : null,
)}
>
{localInput && (
<PolkadotIdenticon
className="flex-shrink-0"
publicKey={getPublicKey(localInput)}
size={20}
/>
)}
<ComboboxInput
className="bg-transparent outline-none w-full"
aria-label="AccountId"
displayValue={(address: string) =>
accounts.get(address)?.name ?? address
}
onChange={(event) => setQuery(event.target.value)}
/>
</ComboboxButton>
<ComboboxOptions
anchor="bottom start"
className={twMerge(
"max-h-96 rounded border border-black p-2 bg-polkadot-800 [--anchor-gap:4px]",
)}
>
{filteredAccounts.length > 0 ? (
filteredAccounts.map((account) => (
<AccountOption
key={account.address}
account={account.address}
publicKey={getPublicKey(account.address)}
/>
))
) : queryInfo.isValid ? (
<AccountOption account={query} publicKey={queryInfo.publicKey} />
) : (
<div className="text-slate-400">
The value is not a valid Account ID
</div>
)}
</ComboboxOptions>
</Combobox>
)
}
const AccountOption: FC<{ account: string; publicKey: Uint8Array | null }> = ({
account,
publicKey,
}) => {
const details = useStateObservable(accountDetail$(account))
return (
<ComboboxOption
value={account}
className="data-[focus]:bg-polkadot-700 cursor-pointer flex flex-row items-center gap-2 p-1 rounded overflow-hidden"
>
<PolkadotIdenticon
className="flex-shrink-0"
publicKey={publicKey}
size={32}
/>
<div className="flex flex-col justify-center text-white leading-tight overflow-hidden">
{details?.name && <span>{details.name}</span>}
<span className="text-slate-400 text-ellipsis overflow-hidden">
{account}
</span>
</div>
</ComboboxOption>
)
}
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<InjectedPolkadotAccount[]>(
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<string, InjectedPolkadotAccount & { extension: string }>(),
)
const accountDetail$ = state(
(account: string) => accounts$.pipe(map((v) => v.get(account) ?? null)),
null,
)