feat: make account display and inputs copyable

This commit is contained in:
Victor Oliva
2026-05-13 14:13:36 +02:00
parent 404287be46
commit 5db31bd94d
10 changed files with 1333 additions and 1481 deletions

View File

@@ -14,18 +14,18 @@
"semi": false
},
"dependencies": {
"@acala-network/chopsticks-core": "^1.3.1",
"@ledgerhq/hw-transport-webusb": "^6.34.1",
"@acala-network/chopsticks-core": "^1.4.0",
"@ledgerhq/hw-transport-webusb": "^6.34.2",
"@monaco-editor/react": "^4.7.0",
"@polkadot-api/descriptors": "file:.papi/descriptors",
"@polkadot-api/json-rpc-provider-proxy": "^0.4.0",
"@polkadot-api/metadata-builders": "^0.14.1",
"@polkadot-api/observable-client": "^0.18.4",
"@polkadot-api/react-builder": "0.5.1",
"@polkadot-api/metadata-builders": "^0.14.2",
"@polkadot-api/observable-client": "^0.18.5",
"@polkadot-api/react-builder": "0.5.2",
"@polkadot-api/react-components": "^0.4.2",
"@polkadot-api/substrate-bindings": "^0.20.1",
"@polkadot-api/tx-utils": "^0.3.1",
"@polkahub/ui-components": "^0.6.0",
"@polkadot-api/substrate-bindings": "^0.20.2",
"@polkadot-api/tx-utils": "^0.3.2",
"@polkahub/ui-components": "^0.7.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
@@ -44,38 +44,38 @@
"clsx": "^2.1.1",
"idb-keyval": "^6.2.2",
"lucide-react": "^1.14.0",
"polkadot-api": "^2.1.0",
"polkahub": "^0.6.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"polkadot-api": "^2.1.3",
"polkahub": "^0.7.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-portal": "^4.3.0",
"react-router-dom": "^7.14.2",
"react-router-dom": "^7.15.0",
"react-svg": "^17.2.4",
"react-virtuoso": "^4.18.6",
"react-virtuoso": "^4.18.7",
"react18-json-view": "^0.2.10",
"rxjs": "^7.8.2",
"save-as": "^0.1.8",
"tailwind-merge": "^3.5.0",
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^14.0.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.2.4",
"@types/node": "^25.6.0",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^25.7.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-portal": "^4.0.7",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"globals": "^17.6.0",
"prettier": "^3.8.3",
"tailwindcss": "^4.2.4",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.1",
"vite": "^8.0.10"
"typescript-eslint": "^8.59.3",
"vite": "^8.0.12"
},
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
"pnpm": {

2431
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,6 @@ import { ArrayDisplay } from "./ListComponents"
import { StructDisplay } from "./StructDisplay"
import {
BoolDisplay,
EthAccountDisplay,
NoneDisplay,
NumberDisplay,
ResultDisplay,
@@ -22,7 +21,7 @@ export const ViewValue: FC<{
const info = getSs58AddressInfo(value)
if (info.isValid) return <AccountIdDisplay value={value} />
if (value.startsWith("0x") && value.length === 42)
return <EthAccountDisplay value={value} />
return <AccountIdDisplay value={value} />
return <StrDisplay value={value} />
}
case "boolean":

View File

@@ -1,4 +1,3 @@
import { HexString } from "polkadot-api"
import { FC } from "react"
import { ViewValue } from "./ViewValue"
@@ -6,10 +5,6 @@ export const BoolDisplay: FC<{ value: boolean }> = ({ value }) => {
return <div className="flex gap-4">{value ? "Yes" : "No"}</div>
}
export const EthAccountDisplay: FC<{ value: HexString }> = ({ value }) => (
<span>{value}</span>
)
export const NoneDisplay: FC = () => (
<span className="text-foreground/60">None</span>
)

View File

@@ -1,12 +1,17 @@
import { CopyText } from "@/components/Copy"
import { EditAccountId, NOTIN } from "@polkadot-api/react-builder"
import { AddressInput } from "polkahub"
export const CAccountId: EditAccountId = ({ value, onValueChanged }) => (
<AddressInput
className="w-64"
triggerClassName="h-9 bg-input"
value={value === NOTIN ? null : value}
onChange={(v) => onValueChanged(v === null ? NOTIN : v)}
disableClear
/>
<div className="flex items-center gap-2">
<AddressInput
className="w-64"
triggerClassName="h-9 bg-input"
value={value === NOTIN ? null : value}
onChange={(v) => onValueChanged(v === null ? NOTIN : v)}
format="ss58"
disableClear
/>
<CopyText text={value === NOTIN ? "" : value} disabled={value === NOTIN} />
</div>
)

View File

@@ -1,188 +1,17 @@
import { EthAccountDisplay } from "@/components/EthAccountDisplay"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { accountDetail$, addrToAccount$ } from "@/state/polkahub"
import { cn } from "@/utils/cn"
import { CopyText } from "@/components/Copy"
import { EditEthAccount, NOTIN } from "@polkadot-api/react-builder"
import { ethAccount, HexString } from "@polkadot-api/substrate-bindings"
import { state, useStateObservable } from "@react-rxjs/core"
import { combineKeys } from "@react-rxjs/utils"
import { Check, ChevronsUpDown } from "lucide-react"
import { FC, useMemo, useState } from "react"
import { map, take } from "rxjs"
import { AddressInput } from "polkahub"
const hintedAccounts$ = state(
combineKeys(
addrToAccount$.pipe(
map((accounts) =>
Object.keys(accounts).filter((address) => address.startsWith("0x")),
),
),
(account) =>
addrToAccount$.pipe(
map((v) => {
const details = v[account]!
return {
address: details.address,
name: details.name,
}
}),
take(1),
),
).pipe(
map(
(v) => new Map([...v].map(([key, value]) => [key.toLowerCase(), value])),
),
),
new Map<
string,
{
address: string
name: string | undefined
}
>(),
export const CEthAccount: EditEthAccount = ({ value, onValueChanged }) => (
<div className="flex items-center gap-2">
<AddressInput
className="w-64"
triggerClassName="h-9 bg-input"
value={value === NOTIN ? null : value}
onChange={(v) => onValueChanged(v === null ? NOTIN : v)}
format="eth"
disableClear
/>
<CopyText text={value === NOTIN ? "" : value} disabled={value === NOTIN} />
</div>
)
const isEthAddressValid = (address: HexString) => {
try {
ethAccount.dec(address)
} catch {
return false
}
return true
}
export const CEthAccount: EditEthAccount = ({ value, onValueChanged }) => {
const accounts = useStateObservable(hintedAccounts$)
const [query, setQuery] = useState("")
const isValid = useMemo(() => isEthAddressValid(query), [query])
const [open, _setOpen] = useState(false)
const setOpen = (value: boolean) => {
_setOpen(value)
setQuery("")
}
const valueIsNew =
value !== NOTIN &&
!accounts.has(isEthAddressValid(value) ? value.toLowerCase() : "")
const accountList = Array.from(accounts.entries())
if (value !== NOTIN) {
accountList.sort(([a], [b]) =>
a === b ? -1 : b === value.toLowerCase() ? 1 : 0,
)
}
const onTriggerKeyDown = (evt: React.KeyboardEvent) => {
if (evt.key.length === 1) {
setOpen(true)
}
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild onKeyDown={onTriggerKeyDown}>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="flex w-64 justify-between overflow-hidden px-2 border border-border bg-input"
>
{value !== NOTIN ? (
<EthAccountDisplay value={value} className="overflow-hidden" />
) : (
<span className="opacity-80">Select</span>
)}
<ChevronsUpDown size={14} className="opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0">
<Command>
<CommandInput
placeholder="Filter…"
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>
<div className="text-foreground/50">
The value is not a valid Ethereum Address
</div>
</CommandEmpty>
<CommandGroup>
{valueIsNew && (
<AccountOption
account={value}
selected={true}
onSelect={() => setOpen(false)}
/>
)}
{accountList.map(([key, account]) => (
<AccountOption
key={key}
account={account.address}
selected={value === account.address}
onSelect={() => {
onValueChanged(account.address)
setOpen(false)
}}
/>
))}
{isValid && (
<AccountOption
account={query}
selected={value === query}
onSelect={() => {
onValueChanged(query)
setOpen(false)
}}
/>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
const AccountOption: FC<{
account: string
selected: boolean
onSelect: () => void
}> = ({ account, selected, onSelect }) => {
const details = useStateObservable(accountDetail$(account))
const name = details?.name
return (
<CommandItem
value={account + "_" + name}
onSelect={onSelect}
className="flex flex-row items-center gap-2 p-1"
>
<EthAccountDisplay value={account} className="overflow-hidden" />
<Check
size={12}
className={cn(
"ml-auto shrink-0",
selected ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
)
}

View File

@@ -7,26 +7,36 @@ import { getSs58AddressInfo } from "polkadot-api"
import { AccountAddress } from "polkahub"
import { FC } from "react"
import { twMerge } from "tailwind-merge"
import { CopyText } from "./Copy"
export const AccountIdDisplay: FC<{
value: AccountAddress
className?: string
}> = ({ value, className }) => {
nonInteractive?: boolean
}> = ({ value, className, nonInteractive }) => {
const details = useStateObservable(accountDetail$(value))
const identity = useStateObservable(identity$(value))
const name = identity?.displayName ?? details?.name
const icon = value.startsWith("0x") ? (
<EthIdenticon address={value} size={28} className="rounded" />
) : (
<PolkadotIdenticon
className="shrink-0"
publicKey={getPublicKey(value)}
size={28}
/>
)
return (
<div className={twMerge("flex items-center gap-2", className)}>
{value.startsWith("0x") ? (
<EthIdenticon address={value} size={28} className="rounded" />
{nonInteractive ? (
icon
) : (
<PolkadotIdenticon
className="shrink-0"
publicKey={getPublicKey(value)}
size={28}
/>
<CopyText text={value} size={28}>
{icon}
</CopyText>
)}
<div className="flex flex-col justify-center text-foreground leading-tight overflow-hidden">
{name && (

View File

@@ -1,42 +1,33 @@
import { CopyText as GenericCopyText } from "@polkadot-api/react-components"
import { CheckCircle, Copy } from "lucide-react"
import { useEffect, useState } from "react"
import { PropsWithChildren } from "react"
import { twMerge } from "tailwind-merge"
import { CopyBinaryIcon } from "./Icons"
export const CopyText: React.FC<{
text: string
disabled?: boolean
className?: string
binary?: boolean
}> = ({ text, className, binary = false, disabled = false }) => {
const [copied, setCopied] = useState(false)
const copy = async (evt: React.MouseEvent) => {
if (disabled) return
evt.stopPropagation()
await navigator.clipboard.writeText(text)
setCopied(true)
}
useEffect(() => {
if (copied) {
setTimeout(() => setCopied(false), 1000)
}
}, [copied])
export const CopyText: React.FC<
PropsWithChildren<{
text: string
size?: number
disabled?: boolean
className?: string
binary?: boolean
}>
> = ({ text, className, children, binary, disabled, size = 16 }) => {
return (
<button
aria-label={binary ? "copy binary" : "copy"}
disabled={disabled || copied}
className={twMerge(className, disabled ? "opacity-50" : "")}
onClick={copy}
>
{copied ? (
<CheckCircle size={16} className="text-green-500 dark:text-green-300" />
) : binary ? (
<CopyBinaryIcon size={16} />
) : (
<Copy size={16} />
<GenericCopyText
className={twMerge(
className,
disabled ? "opacity-50 pointer-events-none" : "",
)}
</button>
text={text}
copiedIndicator={
<CheckCircle
size={size}
className="text-green-500 dark:text-green-300"
/>
}
>
{children ?? (binary ? <CopyBinaryIcon size={16} /> : <Copy size={16} />)}
</GenericCopyText>
)
}

View File

@@ -1,24 +0,0 @@
import { accountDetail$ } from "@/state/polkahub"
import { EthIdenticon } from "@polkadot-api/react-components"
import { useStateObservable } from "@react-rxjs/core"
import { HexString } from "polkadot-api"
import { FC } from "react"
import { twMerge } from "tailwind-merge"
export const EthAccountDisplay: FC<{
value: HexString
className?: string
}> = ({ value, className }) => {
const { name } = useStateObservable(accountDetail$(value)) ?? {}
return (
<div className={twMerge("flex items-center gap-2", className)}>
<EthIdenticon address={value} size={28} />
<div className="flex flex-col justify-center text-foreground leading-tight overflow-hidden">
{name && <span className="inline-flex items-center gap-1">{name}</span>}
<span className="text-foreground/50 text-ellipsis overflow-hidden">
{value}
</span>
</div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { polkadot_people } from "@polkadot-api/descriptors"
import { liftSuspense, state, SUSPENSE, withDefault } from "@react-rxjs/core"
import { AccountId, SS58String } from "polkadot-api"
import { AccountId, HexString, SS58String } from "polkadot-api"
import {
Account,
createLedgerProvider,
@@ -192,7 +192,7 @@ export const addrToAccount$ = polkaHub.availableAccounts$.pipeState(
)
export const accountDetail$ = state(
(addr: SS58String) =>
(addr: SS58String | HexString) =>
addrToAccount$.pipe(map((addrToAccount) => addrToAccount[addr] ?? null)),
null,
)