Merge pull request #96 from polkadot-api/vo/polkahub
feat: migrate accounts to polkahub
@@ -14,45 +14,43 @@
|
||||
"semi": false
|
||||
},
|
||||
"dependencies": {
|
||||
"@acala-network/chopsticks-core": "^1.2.3",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@acala-network/chopsticks-core": "^1.2.4",
|
||||
"@ledgerhq/hw-transport-webusb": "^6.29.13",
|
||||
"@polkadot-api/descriptors": "file:.papi/descriptors",
|
||||
"@polkadot-api/json-rpc-provider-proxy": "^0.2.5",
|
||||
"@polkadot-api/metadata-builders": "^0.13.6",
|
||||
"@polkadot-api/observable-client": "^0.15.2",
|
||||
"@polkadot-api/react-builder": "0.3.8",
|
||||
"@polkadot-api/substrate-bindings": "^0.16.4",
|
||||
"@polkadot-api/tx-utils": "^0.1.7",
|
||||
"@polkadot-api/metadata-builders": "^0.13.7",
|
||||
"@polkadot-api/observable-client": "^0.16.0",
|
||||
"@polkadot-api/react-builder": "0.3.9",
|
||||
"@polkadot-api/react-components": "^0.4.1",
|
||||
"@polkadot-api/substrate-bindings": "^0.16.5",
|
||||
"@polkadot-api/tx-utils": "^0.1.8",
|
||||
"@polkadot-api/utils": "^0.2.0",
|
||||
"@polkahub/ui-components": "0.2.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-rxjs/core": "^0.10.8",
|
||||
"@react-rxjs/utils": "^0.9.7",
|
||||
"@walletconnect/modal": "^2.7.0",
|
||||
"@walletconnect/universal-provider": "^2.22.3",
|
||||
"@walletconnect/utils": "^2.22.3",
|
||||
"blo": "^2.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"lucide-react": "^0.545.0",
|
||||
"polkadot-api": "^1.20.0",
|
||||
"lucide-react": "^0.553.0",
|
||||
"polkadot-api": "^1.20.1",
|
||||
"polkahub": "0.2.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-portal": "^4.3.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-router-dom": "^7.9.5",
|
||||
"react-svg": "^16.3.0",
|
||||
"react-virtuoso": "^4.14.1",
|
||||
"react18-json-view": "^0.2.9",
|
||||
@@ -70,7 +68,6 @@
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@types/react-portal": "^4.0.7",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"@walletconnect/types": "^2.22.3",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-plugin-react-hooks": "7.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.23",
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1,11 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 350" width="24" height="24" >
|
||||
<rect width="350" height="350" fill="#0888f0" />
|
||||
<path
|
||||
fill="#fff"
|
||||
d="m229.916 160.179 20.601-20.474c-46.561-46.274-104.416-46.274-150.977 0l20.601 20.474c35.411-35.193 74.388-35.193 109.799 0h-.024Z"
|
||||
></path>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="m223.045 207.88-48.044-47.748-48.045 47.748-48.045-47.748-20.577 20.45 68.622 68.222 48.045-47.748 48.044 47.748 68.622-68.222-20.577-20.45-48.045 47.748Z"
|
||||
></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 516 B |
@@ -9,6 +9,7 @@ import { RpcCalls } from "./pages/RpcCalls"
|
||||
import { RuntimeCalls } from "./pages/RuntimeCalls"
|
||||
import { Storage } from "./pages/Storage"
|
||||
import { Transactions } from "./pages/Transactions"
|
||||
import { VaultTxModal } from "polkahub"
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -30,6 +31,7 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
<Transactions />
|
||||
<VaultTxModal />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { AccountIdInput } from "@/components/AccountIdInput"
|
||||
import { EditAccountId, NOTIN } from "@polkadot-api/react-builder"
|
||||
import { AddressInput } from "polkahub"
|
||||
|
||||
export const CAccountId: EditAccountId = ({ value, onValueChanged }) => (
|
||||
<AccountIdInput
|
||||
<AddressInput
|
||||
className="w-64"
|
||||
triggerClassName="h-9 bg-input"
|
||||
value={value === NOTIN ? null : value}
|
||||
onValueChanged={(v) => onValueChanged(v === null ? NOTIN : v)}
|
||||
onChange={(v) => onValueChanged(v === null ? NOTIN : v)}
|
||||
disableClear
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { accountDetail$, accounts$ } from "@/state/extension-accounts.state"
|
||||
import { accountDetail$, addrToAccount$ } from "@/state/polkahub"
|
||||
import { cn } from "@/utils/cn"
|
||||
import { EditEthAccount, NOTIN } from "@polkadot-api/react-builder"
|
||||
import { ethAccount, HexString } from "@polkadot-api/substrate-bindings"
|
||||
@@ -25,17 +25,15 @@ import { map, take } from "rxjs"
|
||||
|
||||
const hintedAccounts$ = state(
|
||||
combineKeys(
|
||||
accounts$.pipe(
|
||||
addrToAccount$.pipe(
|
||||
map((accounts) =>
|
||||
[...accounts.entries()]
|
||||
.filter(([, { address }]) => address.startsWith("0x"))
|
||||
.map(([key]) => key),
|
||||
Object.keys(accounts).filter((address) => address.startsWith("0x")),
|
||||
),
|
||||
),
|
||||
(account) =>
|
||||
accounts$.pipe(
|
||||
addrToAccount$.pipe(
|
||||
map((v) => {
|
||||
const details = v.get(account)!
|
||||
const details = v[account]!
|
||||
return {
|
||||
address: details.address,
|
||||
name: details.name,
|
||||
@@ -103,7 +101,6 @@ export const CEthAccount: EditEthAccount = ({ value, onValueChanged }) => {
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="flex w-64 justify-between overflow-hidden px-2 border border-border bg-input"
|
||||
forceSvgSize={false}
|
||||
>
|
||||
{value !== NOTIN ? (
|
||||
<EthAccountDisplay value={value} className="overflow-hidden" />
|
||||
@@ -177,7 +174,6 @@ const AccountOption: FC<{
|
||||
value={account + "_" + name}
|
||||
onSelect={onSelect}
|
||||
className="flex flex-row items-center gap-2 p-1"
|
||||
forceSvgSize={false}
|
||||
>
|
||||
<EthAccountDisplay value={account} className="overflow-hidden" />
|
||||
<Check
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { PolkadotIdenticon } from "@/components/PolkadotIdenticon"
|
||||
import { accountDetail$, getPublicKey } from "@/state/extension-accounts.state"
|
||||
import { identity$, isVerified } from "@/state/identity.state"
|
||||
import { accountDetail$ } from "@/state/polkahub"
|
||||
import { EthIdenticon, PolkadotIdenticon } from "@polkadot-api/react-components"
|
||||
import { useStateObservable } from "@react-rxjs/core"
|
||||
import { CheckCircle } from "lucide-react"
|
||||
import { SS58String } from "polkadot-api"
|
||||
import { getSs58AddressInfo } from "polkadot-api"
|
||||
import { AccountAddress } from "polkahub"
|
||||
import { FC } from "react"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export const AccountIdDisplay: FC<{
|
||||
value: SS58String
|
||||
value: AccountAddress
|
||||
className?: string
|
||||
}> = ({ value, className }) => {
|
||||
const details = useStateObservable(accountDetail$(value))
|
||||
@@ -18,11 +19,15 @@ export const AccountIdDisplay: FC<{
|
||||
|
||||
return (
|
||||
<div className={twMerge("flex items-center gap-2", className)}>
|
||||
<PolkadotIdenticon
|
||||
className="shrink-0"
|
||||
publicKey={getPublicKey(value)}
|
||||
size={28}
|
||||
/>
|
||||
{value.startsWith("0x") ? (
|
||||
<EthIdenticon address={value} size={28} className="rounded" />
|
||||
) : (
|
||||
<PolkadotIdenticon
|
||||
className="shrink-0"
|
||||
publicKey={getPublicKey(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">
|
||||
@@ -42,3 +47,7 @@ export const AccountIdDisplay: FC<{
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const getPublicKey = (address: string) => {
|
||||
const info = getSs58AddressInfo(address)
|
||||
return info.isValid ? info.publicKey : null
|
||||
}
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import { AccountIdDisplay } from "@/components/AccountIdDisplay"
|
||||
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 { groupBy } from "@/lib/groupBy"
|
||||
import { accounts$ } from "@/state/accounts.state"
|
||||
import { getPublicKey } from "@/state/extension-accounts.state"
|
||||
import { identity$, isVerified } from "@/state/identity.state"
|
||||
import { cn } from "@/utils/cn"
|
||||
import {
|
||||
getSs58AddressInfo,
|
||||
SS58String,
|
||||
} from "@polkadot-api/substrate-bindings"
|
||||
import { toHex } from "@polkadot-api/utils"
|
||||
import { state, useStateObservable } from "@react-rxjs/core"
|
||||
import { combineKeys } from "@react-rxjs/utils"
|
||||
import { Check, ChevronsUpDown } from "lucide-react"
|
||||
import { FC, useState } from "react"
|
||||
import { filter, map, switchMap, take } from "rxjs"
|
||||
|
||||
const acconutMap$ = accounts$.pipeState(
|
||||
map((accounts) => groupBy(accounts, (acc) => acc.accountId)),
|
||||
map((groups) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(groups)
|
||||
.filter(([accountId]) => !accountId.startsWith("0x"))
|
||||
.map(([accountId, [groupRep]]) => [accountId, groupRep]),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const hintedAccounts$ = state(
|
||||
combineKeys(
|
||||
acconutMap$.pipe(map((groups) => Object.keys(groups))),
|
||||
(address) =>
|
||||
acconutMap$.pipe(
|
||||
map((v) => v[address]),
|
||||
filter((v) => !!v),
|
||||
take(1),
|
||||
switchMap((details) =>
|
||||
identity$(details.accountId).pipe(
|
||||
map((identity) => ({
|
||||
address: details.accountId,
|
||||
name: identity?.displayName ?? details.name,
|
||||
isVerified: isVerified(identity),
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
).pipe(map((v) => new Map(v))),
|
||||
new Map<
|
||||
string,
|
||||
{
|
||||
address: string
|
||||
name: string | undefined
|
||||
isVerified: boolean | undefined
|
||||
}
|
||||
>(),
|
||||
)
|
||||
|
||||
export const AccountIdInput: FC<{
|
||||
value: SS58String | null
|
||||
onValueChanged: (value: SS58String | null) => void
|
||||
className?: string
|
||||
}> = ({ value, onValueChanged, className }) => {
|
||||
const accounts = useStateObservable(hintedAccounts$)
|
||||
|
||||
const [query, setQuery] = useState("")
|
||||
const queryInfo = getSs58AddressInfo(query)
|
||||
const [open, _setOpen] = useState(false)
|
||||
|
||||
const setOpen = (value: boolean) => {
|
||||
_setOpen(value)
|
||||
setQuery("")
|
||||
}
|
||||
|
||||
const valueIsNew =
|
||||
value !== null &&
|
||||
!accounts.has(toHex(getPublicKey(value) ?? new Uint8Array()))
|
||||
|
||||
const accountList = Array.from(accounts.entries())
|
||||
if (value !== null) {
|
||||
accountList.sort(([, a], [, b]) =>
|
||||
a.address === value ? -1 : b.address === value ? 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={cn(
|
||||
"flex w-64 justify-between overflow-hidden px-2 border border-border bg-input",
|
||||
className,
|
||||
)}
|
||||
forceSvgSize={false}
|
||||
>
|
||||
{value !== null ? (
|
||||
<AccountIdDisplay 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 or insert…"
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<div className="text-foreground/50">
|
||||
The value is not a valid Account ID
|
||||
</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)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{queryInfo.isValid && (
|
||||
<AccountOption
|
||||
account={query}
|
||||
selected={value === query}
|
||||
onSelect={() => {
|
||||
onValueChanged(query)
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
const accountDetail$ = state(
|
||||
(accountId: SS58String) => acconutMap$.pipe(map((v) => v[accountId] ?? null)),
|
||||
null,
|
||||
)
|
||||
|
||||
const AccountOption: FC<{
|
||||
account: string
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
}> = ({ account, selected, onSelect }) => {
|
||||
const details = useStateObservable(accountDetail$(account))
|
||||
const identity = useStateObservable(identity$(account))
|
||||
|
||||
const name = identity?.displayName ?? details?.name
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
value={account + "_" + name}
|
||||
onSelect={onSelect}
|
||||
className="flex flex-row items-center gap-2 p-1"
|
||||
forceSvgSize={false}
|
||||
>
|
||||
<AccountIdDisplay value={account} className="overflow-hidden" />
|
||||
<Check
|
||||
size={12}
|
||||
className={cn(
|
||||
"ml-auto shrink-0",
|
||||
selected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
)
|
||||
}
|
||||
@@ -78,6 +78,7 @@ export function CommandPopover({
|
||||
onClick={() => setOpen(true)}
|
||||
placeholder={placeholder}
|
||||
className="border-none focus:ring-0"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{open && (
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { accountDetail$ } from "@/state/extension-accounts.state"
|
||||
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"
|
||||
import { EthIdenticon } from "./EthIdenticon"
|
||||
|
||||
export const EthAccountDisplay: FC<{
|
||||
value: HexString
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { HexString } from "polkadot-api"
|
||||
import { FC } from "react"
|
||||
import { blo } from "blo"
|
||||
|
||||
export const EthIdenticon: FC<{ address: HexString; size?: number }> = ({
|
||||
address,
|
||||
size,
|
||||
}) => {
|
||||
return <img className="rounded-full" src={blo(address as "0x", size)} />
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
// Based from https://github.com/paritytech/oo7/blob/251ba2b7c45503b68eab4320c270b5afa9bccb60/packages/polkadot-identicon/src/index.jsx
|
||||
|
||||
import { blake2b } from "@noble/hashes/blake2.js"
|
||||
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 blake2512 = (input: Uint8Array) => blake2b(input, { dkLen: 64 })
|
||||
const zero = blake2512(
|
||||
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(blake2512(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 },
|
||||
]
|
||||
}
|
||||
@@ -1,71 +1 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"border bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
forceSvgSize?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
forceSvgSize = true,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
buttonVariants({ variant, size, className }),
|
||||
forceSvgSize &&
|
||||
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button } from "@polkahub/ui-components"
|
||||
|
||||
@@ -1,157 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
interface CommandDialogProps extends DialogProps {}
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> & {
|
||||
forceSvgSize?: boolean
|
||||
}
|
||||
>(({ className, forceSvgSize = true, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
|
||||
forceSvgSize &&
|
||||
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
} from "@polkahub/ui-components"
|
||||
|
||||
@@ -1,120 +1,20 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-top-[20%] data-[state=open]:slide-in-from-top-[20%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogTrigger,
|
||||
} from "@polkahub/ui-components"
|
||||
|
||||
export function DialogBody({
|
||||
children,
|
||||
id,
|
||||
className,
|
||||
}: React.PropsWithChildren<{ id?: string; className?: string }>) {
|
||||
return (
|
||||
<div className={cn("overflow-auto px-6 py-2 last:pb-6", className)} id={id}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,5 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border border-neutral-200 bg-white p-4 text-neutral-950 shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
export {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@polkahub/ui-components"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import 'tailwindcss';
|
||||
@import "polkahub";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -73,7 +74,7 @@
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0 / 50%);
|
||||
--input: oklch(0.922 0 0 / 70%);
|
||||
--ring: oklch(0.708 0 0 / 50%);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
@@ -106,8 +107,8 @@
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 8%);
|
||||
--input: oklch(1 0 0 / 5%);
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--input: oklch(1 0 0 / 10%);
|
||||
--ring: oklch(0.556 0 0 / 50%);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Buffer } from "buffer"
|
||||
globalThis.Buffer = Buffer
|
||||
import { dynamicBuilder$ } from "@/state/chains/chain.state"
|
||||
import { RemoveSubscribe, Subscribe } from "@react-rxjs/core"
|
||||
import { PolkaHubProvider } from "polkahub"
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { BrowserRouter } from "react-router-dom"
|
||||
@@ -10,6 +13,7 @@ import "./index.css"
|
||||
import { explorer$ } from "./pages/Explorer"
|
||||
import { storage$ } from "./pages/Storage/storage.state.ts"
|
||||
import { transactions$ } from "./pages/Transactions"
|
||||
import { polkaHub } from "./state/polkahub.ts"
|
||||
import { ThemeProvider } from "./ThemeProvider.tsx"
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
@@ -19,11 +23,13 @@ createRoot(document.getElementById("root")!).render(
|
||||
<RemoveSubscribe>
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<App />
|
||||
</TooltipProvider>
|
||||
</BrowserRouter>
|
||||
<PolkaHubProvider polkaHub={polkaHub}>
|
||||
<BrowserRouter>
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<App />
|
||||
</TooltipProvider>
|
||||
</BrowserRouter>
|
||||
</PolkaHubProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>
|
||||
</RemoveSubscribe>
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { AccountIdDisplay } from "@/components/AccountIdDisplay"
|
||||
import { TokenAmount } from "@/components/TokenAmount"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Account,
|
||||
accounts$,
|
||||
AccountSource,
|
||||
accountSourceTypeToName,
|
||||
} from "@/state/accounts.state"
|
||||
import { chainProperties$ } from "@/state/chain-props.state"
|
||||
import { client$ } from "@/state/chains/chain.state"
|
||||
import { MultiAddress, polkadot_people } from "@polkadot-api/descriptors"
|
||||
@@ -18,17 +12,54 @@ import {
|
||||
} from "@react-rxjs/core"
|
||||
import { Send } from "lucide-react"
|
||||
import { CompatibilityLevel, SS58String } from "polkadot-api"
|
||||
import {
|
||||
Account,
|
||||
ledgerProviderId,
|
||||
PjsWalletAccount,
|
||||
polkadotVaultProviderId,
|
||||
readOnlyProviderId,
|
||||
useAvailableAccounts,
|
||||
walletConnectProviderId,
|
||||
} from "polkahub"
|
||||
import { FC } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { catchError, combineLatest, map, switchMap } from "rxjs"
|
||||
import { knownExtensions } from "./Providers"
|
||||
|
||||
const knownGroupsNames: Record<string, string> = {
|
||||
"pjs-wallet": "Browser Extensions",
|
||||
[ledgerProviderId]: "Ledger",
|
||||
[readOnlyProviderId]: "Read Only",
|
||||
[polkadotVaultProviderId]: "Polkadot Vault",
|
||||
[walletConnectProviderId]: "Wallet Connect",
|
||||
}
|
||||
const knownGroupsColors: Record<string, string> = {
|
||||
"pjs-wallet": "var(--color-lime-700)",
|
||||
[readOnlyProviderId]: "var(--color-orange-700)",
|
||||
[walletConnectProviderId]: "var(--color-blue-700)",
|
||||
}
|
||||
|
||||
export const AccountList = () => {
|
||||
const accounts = useStateObservable(accounts$)
|
||||
const accountGroups = useAvailableAccounts()
|
||||
|
||||
return (
|
||||
<div className="bg-card p-4 rounded-xl space-y-4">
|
||||
<h3 className="text-xl font-bold">Accounts</h3>
|
||||
{Object.entries(accountGroups)
|
||||
.filter(([_, accounts]) => accounts.length > 0)
|
||||
.map(([id, accounts]) => (
|
||||
<AccountGroup key={id} id={id} accounts={accounts} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AccountGroup: FC<{ id: string; accounts: Account[] }> = ({
|
||||
id,
|
||||
accounts,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<h4>{getGroupName(id)}</h4>
|
||||
<ul className="space-y-4">
|
||||
{accounts.map((account, i) => (
|
||||
<AccountCard key={i} account={account} />
|
||||
@@ -45,24 +76,25 @@ const AccountCard: FC<{
|
||||
<li className="shadow border rounded-xl p-4 space-y-4">
|
||||
<div className="flex justify-between items-start overflow-hidden">
|
||||
<AccountIdDisplay
|
||||
value={account.accountId}
|
||||
value={account.address}
|
||||
className="shrink-1 overflow-hidden"
|
||||
/>
|
||||
<SourceTag account={account} />
|
||||
</div>
|
||||
{account.accountId.startsWith("0x") ? null : (
|
||||
<Balances accountId={account.accountId} />
|
||||
{account.address.startsWith("0x") ? null : (
|
||||
<Balances accountId={account.address} />
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const sourceColors: Record<AccountSource, string> = {
|
||||
extension: "var(--color-lime-700)",
|
||||
walletconnect: "var(--color-blue-700)",
|
||||
readonly: "var(--color-orange-700)",
|
||||
export const knownExtensions: Record<string, string> = {
|
||||
"polkadot-js": "Polkadot JS",
|
||||
"nova-wallet": "Nova Wallet",
|
||||
talisman: "Talisman",
|
||||
"subwallet-js": "Subwallet",
|
||||
}
|
||||
const getExtensionName = (id: string) => knownExtensions[id]?.name ?? id
|
||||
const getExtensionName = (id: string) => knownExtensions[id] ?? id
|
||||
const getGroupName = (id: string) => knownGroupsNames[id] ?? id
|
||||
|
||||
const SourceTag: FC<{
|
||||
account: Account
|
||||
@@ -70,14 +102,18 @@ const SourceTag: FC<{
|
||||
return (
|
||||
<div
|
||||
className="rounded px-2 shrink-0"
|
||||
style={{
|
||||
color: sourceColors[account.type],
|
||||
backgroundColor: `color-mix(in srgb, ${sourceColors[account.type]}, transparent 90%)`,
|
||||
}}
|
||||
style={
|
||||
account.provider in knownGroupsColors
|
||||
? {
|
||||
color: knownGroupsColors[account.provider],
|
||||
backgroundColor: `color-mix(in srgb, ${knownGroupsColors[account.provider]}, transparent 90%)`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{account.type === "extension"
|
||||
? getExtensionName(account.extensionId)
|
||||
: accountSourceTypeToName[account.type]}
|
||||
{account.provider === "pjs-wallet"
|
||||
? getExtensionName((account as PjsWalletAccount).extensionId)
|
||||
: getGroupName(account.provider)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { AccountIdDisplay } from "@/components/AccountIdDisplay"
|
||||
import { AccountIdInput } from "@/components/AccountIdInput"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { readOnlyAccounts$, setAddresses } from "@/state/accounts.state"
|
||||
import { useStateObservable } from "@react-rxjs/core"
|
||||
import { Eye, Trash2 } from "lucide-react"
|
||||
import { SS58String } from "polkadot-api"
|
||||
import { useState } from "react"
|
||||
import { SourceButton } from "./SourceButton"
|
||||
|
||||
export const AddressProvider = () => (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<SourceButton label="Address">
|
||||
<div>
|
||||
<Eye className="size-10" />
|
||||
</div>
|
||||
</SourceButton>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<ManageAddresses />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
const ManageAddresses = () => {
|
||||
const [addressInput, setAddressInput] = useState<SS58String | null>(null)
|
||||
const accounts = useStateObservable(readOnlyAccounts$)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 overflow-hidden">
|
||||
<form
|
||||
onSubmit={(evt) => {
|
||||
evt.preventDefault()
|
||||
if (!addressInput) return
|
||||
|
||||
setAddresses([
|
||||
...accounts
|
||||
.map((v) => v.accountId)
|
||||
.filter((v) => v !== addressInput),
|
||||
addressInput,
|
||||
])
|
||||
setAddressInput(null)
|
||||
}}
|
||||
>
|
||||
<h3 className="font-medium text-muted-foreground">
|
||||
Add read-only address
|
||||
</h3>
|
||||
<div className="flex gap-2 items-center">
|
||||
<AccountIdInput
|
||||
className="grow"
|
||||
value={addressInput}
|
||||
onValueChanged={setAddressInput}
|
||||
/>
|
||||
<Button variant="secondary" disabled={!addressInput}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{accounts.length ? (
|
||||
<div>
|
||||
<h3 className="font-medium text-muted-foreground">Added addresses</h3>
|
||||
<ul className="space-y-3">
|
||||
{accounts.map((account) => (
|
||||
<li key={account.accountId} className="flex gap-2 items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-destructive border-destructive"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setAddresses(
|
||||
accounts
|
||||
.map((v) => v.accountId)
|
||||
.filter((v) => account.accountId !== v),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
<AccountIdDisplay value={account.accountId} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,131 +1,83 @@
|
||||
import { Spinner } from "@/components/Icons"
|
||||
import {
|
||||
availableExtensions$,
|
||||
onToggleExtension,
|
||||
selectedExtensions$,
|
||||
} from "@/state/extension-accounts.state"
|
||||
import { state, useStateObservable } from "@react-rxjs/core"
|
||||
import { CircleQuestionMark } from "lucide-react"
|
||||
import { FC } from "react"
|
||||
import { defer, from, switchMap } from "rxjs"
|
||||
import { AddressProvider } from "./AddressProvider"
|
||||
import { SourceButton } from "./SourceButton"
|
||||
Button,
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@polkahub/ui-components"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
import {
|
||||
ManageLedger,
|
||||
ManageReadOnly,
|
||||
ManageVault,
|
||||
ModalContext,
|
||||
PjsWalletButtons,
|
||||
usePolkaHubModalState,
|
||||
WalletConnectButton,
|
||||
} from "polkahub"
|
||||
import { FC, PropsWithChildren } from "react"
|
||||
|
||||
const lazyWalletConnectStatus$ = state(
|
||||
defer(() =>
|
||||
from(import("@/state/walletconnect.state")).pipe(
|
||||
switchMap(({ walletConnectStatus$ }) => walletConnectStatus$),
|
||||
),
|
||||
),
|
||||
{
|
||||
type: "disconnected",
|
||||
},
|
||||
)
|
||||
|
||||
export const knownExtensions: Record<string, { name: string; logo: string }> = {
|
||||
"polkadot-js": {
|
||||
name: "Polkadot JS",
|
||||
logo: import.meta.env.BASE_URL + "providers/polkadotjs.webp",
|
||||
},
|
||||
"nova-wallet": {
|
||||
name: "Nova Wallet",
|
||||
logo: import.meta.env.BASE_URL + "providers/novawallet.webp",
|
||||
},
|
||||
talisman: {
|
||||
name: "Talisman",
|
||||
logo: import.meta.env.BASE_URL + "providers/talisman.webp",
|
||||
},
|
||||
"subwallet-js": {
|
||||
name: "Subwallet",
|
||||
logo: import.meta.env.BASE_URL + "providers/subwallet.webp",
|
||||
},
|
||||
}
|
||||
|
||||
export const Providers = () => {
|
||||
const availableExtensions = useStateObservable(availableExtensions$).sort(
|
||||
(a, b) => (b in knownExtensions ? 1 : 0) - (a in knownExtensions ? 1 : 0),
|
||||
)
|
||||
const walletConnectStatus = useStateObservable(lazyWalletConnectStatus$)
|
||||
const ManageToDialog: FC<PropsWithChildren> = ({ children }) => {
|
||||
const { contentStack, contextValue, setContentStack } =
|
||||
usePolkaHubModalState()
|
||||
const activeContent = contentStack.length
|
||||
? contentStack[contentStack.length - 1]
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="bg-card p-4 rounded-xl">
|
||||
<h3 className="text-xl font-bold">Account Providers</h3>
|
||||
<ul className="flex gap-2 flex-wrap items-center justify-center">
|
||||
{availableExtensions.map((id) => (
|
||||
<li key={id}>
|
||||
<ExtensionButton id={id} />
|
||||
</li>
|
||||
))}
|
||||
|
||||
<AddressProvider />
|
||||
{/* <SourceButton label="Ledger" disabled>
|
||||
<img
|
||||
src={import.meta.env.BASE_URL + "providers/ledger.webp"}
|
||||
alt="Ledger"
|
||||
className="h-10 rounded"
|
||||
/>
|
||||
</SourceButton> */}
|
||||
{/* <SourceButton label="Vault" disabled>
|
||||
<img
|
||||
src={import.meta.env.BASE_URL + "providers/vault.webp"}
|
||||
alt="Vault"
|
||||
className="h-10 rounded"
|
||||
/>
|
||||
</SourceButton> */}
|
||||
<SourceButton
|
||||
label="Wallet Connect"
|
||||
isSelected={walletConnectStatus.type === "connected"}
|
||||
onClick={async () => {
|
||||
const { toggleWalletConnect } = await import(
|
||||
"@/state/walletconnect.state"
|
||||
)
|
||||
toggleWalletConnect()
|
||||
<>
|
||||
<ModalContext value={contextValue}>{children}</ModalContext>
|
||||
<Dialog
|
||||
open={activeContent != null}
|
||||
onOpenChange={() => setContentStack([])}
|
||||
>
|
||||
<DialogContent
|
||||
onEscapeKeyDown={(evt) => {
|
||||
if (
|
||||
evt.target instanceof HTMLElement &&
|
||||
(evt.target.tagName === "INPUT" ||
|
||||
evt.target.attributes.getNamedItem("cmdk-list"))
|
||||
) {
|
||||
evt.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{walletConnectStatus.type === "connecting" ? (
|
||||
<Spinner className="size-4 m-3 text-sky-500" />
|
||||
) : (
|
||||
<img
|
||||
src={import.meta.env.BASE_URL + "providers/walletConnect.svg"}
|
||||
alt="Wallet Connect"
|
||||
className="h-10 rounded"
|
||||
/>
|
||||
)}
|
||||
</SourceButton>
|
||||
<DialogHeader className="flex-row items-center">
|
||||
{contentStack.length > 1 ? (
|
||||
<Button
|
||||
className="has-[>svg]:px-1"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => contextValue.popContent()}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
) : null}
|
||||
<DialogTitle>{activeContent?.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<ModalContext value={contextValue}>
|
||||
{activeContent?.element}
|
||||
</ModalContext>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const Providers = () => (
|
||||
<div className="bg-card p-4 rounded-xl space-y-2">
|
||||
<h3 className="text-xl font-bold">Account Providers</h3>
|
||||
<PjsWalletButtons />
|
||||
<ManageToDialog>
|
||||
<ul className="flex gap-2 flex-wrap items-center justify-center">
|
||||
<ManageReadOnly />
|
||||
<ManageLedger />
|
||||
<ManageVault />
|
||||
<WalletConnectButton />
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ExtensionButton: FC<{
|
||||
id: string
|
||||
}> = ({ id }) => {
|
||||
const knownExtension = knownExtensions[id]
|
||||
const connectedExtensions = Array.from(
|
||||
useStateObservable(selectedExtensions$).keys(),
|
||||
)
|
||||
const isSelected = connectedExtensions.includes(id)
|
||||
|
||||
return (
|
||||
<SourceButton
|
||||
isSelected={isSelected}
|
||||
label={knownExtension?.name ?? id}
|
||||
onClick={() => onToggleExtension(id)}
|
||||
>
|
||||
{knownExtension ? (
|
||||
<img
|
||||
src={knownExtension.logo}
|
||||
alt={knownExtension.name}
|
||||
className="h-10 rounded"
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<CircleQuestionMark
|
||||
className="size-10 text-muted-foreground"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SourceButton>
|
||||
)
|
||||
}
|
||||
</ManageToDialog>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/utils"
|
||||
import { FC, MouseEvent, PropsWithChildren } from "react"
|
||||
|
||||
export const SourceButton: FC<
|
||||
PropsWithChildren<{
|
||||
label: string
|
||||
isSelected?: boolean
|
||||
className?: string
|
||||
onClick?: (evt: MouseEvent) => void
|
||||
disabled?: boolean
|
||||
}>
|
||||
> = ({ label, isSelected, onClick, className, children, disabled }) => (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn("h-auto min-w-40", isSelected ? "bg-accent" : "")}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
forceSvgSize={false}
|
||||
>
|
||||
{children}
|
||||
<div className="text-left">
|
||||
<span className={cn("font-bold", className)}>{label}</span>
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
@@ -1,127 +0,0 @@
|
||||
import { AccountIdDisplay } from "@/components/AccountIdDisplay"
|
||||
import { EthAccountDisplay } from "@/components/EthAccountDisplay"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { groupBy } from "@/lib/groupBy"
|
||||
import { knownExtensions } from "@/pages/Accounts/Providers"
|
||||
import {
|
||||
Account,
|
||||
accounts$,
|
||||
AccountSource,
|
||||
accountSourceTypeToName,
|
||||
} from "@/state/accounts.state"
|
||||
import { selectedExtensions$ } from "@/state/extension-accounts.state"
|
||||
import { walletConnectStatus$ } from "@/state/walletconnect.state"
|
||||
import { state, useStateObservable, withDefault } from "@react-rxjs/core"
|
||||
import { createSignal } from "@react-rxjs/utils"
|
||||
import React from "react"
|
||||
import {
|
||||
combineLatest,
|
||||
defer,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
merge,
|
||||
of,
|
||||
tap,
|
||||
} from "rxjs"
|
||||
|
||||
const [valueSelected$, selectValue] = createSignal<string>()
|
||||
|
||||
const LS_KEY = "selected-signer"
|
||||
const selectedValue$ = state(
|
||||
merge(
|
||||
defer(() => of(localStorage.getItem(LS_KEY))),
|
||||
valueSelected$.pipe(tap((v) => localStorage.setItem(LS_KEY, v))),
|
||||
),
|
||||
null,
|
||||
)
|
||||
|
||||
const allAccounts$ = accounts$.pipeState(
|
||||
map((v) =>
|
||||
v.map((acc) => ({
|
||||
...acc,
|
||||
id: `${acc.accountId}-${acc.type === "extension" ? acc.extensionId : acc.type}`,
|
||||
})),
|
||||
),
|
||||
withDefault([]),
|
||||
)
|
||||
|
||||
export const selectedAccount$ = state(
|
||||
combineLatest([selectedValue$, allAccounts$]).pipe(
|
||||
map(([selectedAccount, accounts]): Account | null => {
|
||||
if (!selectedAccount) return null
|
||||
const account = accounts.find((account) => account.id === selectedAccount)
|
||||
|
||||
return account ?? null
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
null,
|
||||
)
|
||||
|
||||
const groupedAccounts$ = allAccounts$.pipeState(
|
||||
map((v) =>
|
||||
groupBy(
|
||||
// Accept only signer accounts for submitting transactions
|
||||
v.filter((v) => v.signer != null),
|
||||
(acc) => (acc.type === "extension" ? acc.extensionId : acc.type),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const groupIdToName = (groupId: string) => {
|
||||
if (groupId in knownExtensions) {
|
||||
return knownExtensions[groupId].name
|
||||
}
|
||||
return accountSourceTypeToName[groupId as AccountSource] ?? groupId
|
||||
}
|
||||
|
||||
export const AccountProvider: React.FC = () => {
|
||||
const value = useStateObservable(selectedValue$)
|
||||
const extensions = useStateObservable(selectedExtensions$)
|
||||
const walletConnect = useStateObservable(walletConnectStatus$)
|
||||
const allAccounts = useStateObservable(allAccounts$)
|
||||
const groupedAccounts = useStateObservable(groupedAccounts$)
|
||||
|
||||
const activeExtensions = [...extensions.values()].filter((v) => !!v)
|
||||
const valueExists = value && allAccounts.some((acc) => acc.id === value)
|
||||
|
||||
if (!activeExtensions.length && walletConnect.type !== "connected")
|
||||
return null
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={valueExists ? (value ?? "") : ""}
|
||||
onValueChange={selectValue}
|
||||
>
|
||||
<SelectTrigger className="h-auto border-foreground/30">
|
||||
<SelectValue placeholder="Select an account" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(groupedAccounts).map(([groupId, accounts]) => (
|
||||
<SelectGroup key={groupId}>
|
||||
<SelectLabel className="flex gap-1">
|
||||
{groupIdToName(groupId)}
|
||||
</SelectLabel>
|
||||
{accounts.map((account) => (
|
||||
<SelectItem key={account.id} value={account.id}>
|
||||
{account.accountId.startsWith("0x") ? (
|
||||
<EthAccountDisplay value={account.accountId} />
|
||||
) : (
|
||||
<AccountIdDisplay value={account.accountId} />
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -2,14 +2,14 @@ import { Label } from "@/components/ui/label"
|
||||
import { TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
availableExtensions$,
|
||||
onToggleExtension,
|
||||
selectedExtensions$,
|
||||
} from "@/state/extension-accounts.state"
|
||||
toggleExtension,
|
||||
} from "@/state/polkahub"
|
||||
import { useStateObservable } from "@react-rxjs/core"
|
||||
|
||||
export const ExtensionProvider: React.FC = () => {
|
||||
const availableExtensions = useStateObservable(availableExtensions$)
|
||||
const selectedExtensions = useStateObservable(selectedExtensions$)
|
||||
const selectedExtensions = new Set(useStateObservable(selectedExtensions$))
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -18,7 +18,7 @@ export const ExtensionProvider: React.FC = () => {
|
||||
{availableExtensions.map((extensionName) => (
|
||||
<TabsTrigger
|
||||
className="mx-1"
|
||||
onClick={() => onToggleExtension(extensionName)}
|
||||
onClick={() => toggleExtension(extensionName)}
|
||||
active={selectedExtensions.has(extensionName)}
|
||||
key={extensionName}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ActionButton } from "@/components/ActionButton"
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
@@ -30,17 +32,21 @@ export const ExtrinsicModal: React.FC<{
|
||||
)
|
||||
evt.preventDefault()
|
||||
}}
|
||||
className="flex flex-col overflow-hidden"
|
||||
className="flex flex-col overflow-hidden max-w-lg sm:max-w-lg md:max-w-lg"
|
||||
>
|
||||
<DialogTitle>Create TX</DialogTitle>
|
||||
<Suspense fallback="Loading…">
|
||||
<SendTransactionForm
|
||||
callData={
|
||||
callData instanceof Uint8Array ? toHex(callData) : callData!
|
||||
}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create TX</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody className="flex flex-col gap-2">
|
||||
<Suspense fallback="Loading…">
|
||||
<SendTransactionForm
|
||||
callData={
|
||||
callData instanceof Uint8Array ? toHex(callData) : callData!
|
||||
}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { ActionButton } from "@/components/ActionButton"
|
||||
import { Spinner } from "@/components/Icons"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { trackSignedTx, trackUnsignedTx } from "@/pages/Transactions"
|
||||
import { unsafeApi$ } from "@/state/chains/chain.state"
|
||||
import { selectedAccount$ } from "@/state/polkahub"
|
||||
import { compactNumber } from "@polkadot-api/substrate-bindings"
|
||||
import { fromHex, mergeUint8, toHex } from "@polkadot-api/utils"
|
||||
import { AccountPicker } from "@polkahub/ui-components"
|
||||
import { useStateObservable } from "@react-rxjs/core"
|
||||
import { Binary } from "polkadot-api"
|
||||
import {
|
||||
AddressIdentity,
|
||||
useAvailableAccounts,
|
||||
useSelectedAccount,
|
||||
} from "polkahub"
|
||||
import { FC, useState } from "react"
|
||||
import { firstValueFrom } from "rxjs"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { AccountProvider, selectedAccount$ } from "./AccountProvider"
|
||||
import { ExtensionProvider } from "./ExtensionProvider"
|
||||
|
||||
const SignAndSubmit: FC<{ callData: string; onClose: () => void }> = ({
|
||||
@@ -77,7 +84,7 @@ export default function SubmitTxF(props: {
|
||||
return (
|
||||
<>
|
||||
<ExtensionProvider />
|
||||
<AccountProvider />
|
||||
<SelectAccount />
|
||||
<div className="flex flex-col gap-2">
|
||||
<SignAndSubmit {...props} />
|
||||
<SubmitUnsigned {...props} />
|
||||
@@ -85,3 +92,47 @@ export default function SubmitTxF(props: {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const groupLabels: Record<string, string> = {
|
||||
ledger: "Ledger",
|
||||
readonly: "Read Only",
|
||||
"polkadot-vault": "Vault",
|
||||
walletconnect: "Wallet Connect",
|
||||
}
|
||||
|
||||
const SelectAccount: FC<{
|
||||
className?: string
|
||||
}> = ({ className }) => {
|
||||
const availableAccounts = useAvailableAccounts()
|
||||
const [account, setAccount] = useSelectedAccount()
|
||||
|
||||
const groups = Object.entries(availableAccounts)
|
||||
.map(
|
||||
([group, accounts]) =>
|
||||
[group, accounts.filter((acc) => acc.signer)] as const,
|
||||
)
|
||||
.filter(([, accounts]) => accounts.length > 0)
|
||||
.map(([key, accounts]) => ({
|
||||
name: groupLabels[key] ?? key,
|
||||
accounts,
|
||||
}))
|
||||
|
||||
if (!groups.length && !account) return null
|
||||
|
||||
return (
|
||||
<AccountPicker
|
||||
value={account}
|
||||
onChange={setAccount}
|
||||
groups={groups}
|
||||
className={cn(className, "max-w-auto w-full")}
|
||||
renderAddress={(account) => (
|
||||
<AddressIdentity
|
||||
addr={account.address}
|
||||
name={account?.name}
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
disableClear
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
@@ -119,7 +120,7 @@ const NetworkSwitchDialogContent: FC<{
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
className="sm:max-w-[425px] min-h-[450px] max-h-full flex flex-col"
|
||||
className="sm:max-w-[425px] min-h-[450px] max-h-full flex flex-col w-auto"
|
||||
onEscapeKeyDown={(evt) => {
|
||||
if (
|
||||
evt.target instanceof HTMLElement &&
|
||||
@@ -133,131 +134,137 @@ const NetworkSwitchDialogContent: FC<{
|
||||
<DialogHeader>
|
||||
<DialogTitle>Switch Network</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="h-full grow flex flex-col">
|
||||
<CommandPopover
|
||||
placeholder="Search or enter a custom URI"
|
||||
value={enteredText}
|
||||
onValueChange={setEnteredText}
|
||||
selectedValue={selectedNetwork.id}
|
||||
>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<div className="text-foreground/50">No networks found.</div>
|
||||
</CommandEmpty>
|
||||
<ScrollArea className="h-[260px]">
|
||||
{networkCategories.map((category) => (
|
||||
<CommandGroup key={category.name} heading={category.name}>
|
||||
{category.networks.map((network) => (
|
||||
<CommandItem
|
||||
key={network.id}
|
||||
onSelect={() => handleNetworkSelect(network)}
|
||||
value={
|
||||
network.display.includes(category.name)
|
||||
? network.display
|
||||
: `${category.name} ${network.display}`
|
||||
}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
selectedNetwork.id === network.id
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{network.display}
|
||||
</CommandItem>
|
||||
))}
|
||||
{category.name === "Custom" && isValidUri(enteredText) ? (
|
||||
<CommandItem
|
||||
value={enteredText}
|
||||
onSelect={() => {
|
||||
handleNetworkSelect({
|
||||
id: "custom-network",
|
||||
lightclient: false,
|
||||
endpoints: { custom: enteredText },
|
||||
display: enteredText,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
selectedNetwork.id === "custom-network"
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{enteredText}
|
||||
</CommandItem>
|
||||
) : null}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</CommandPopover>
|
||||
<div className="h-[50vh] flex flex-col gap-2">
|
||||
{selectedNetwork ? (
|
||||
<div className="grow-1 overflow-hidden flex flex-col">
|
||||
<p className="py-2">Network: {selectedNetwork.display}</p>
|
||||
<div className="overflow-auto">
|
||||
<RadioGroup value={selectedRpc} onValueChange={setSelectedRpc}>
|
||||
{selectedNetwork.lightclient ? (
|
||||
<ConnectionOption
|
||||
value="light-client"
|
||||
isSelected={selectedRpc === "light-client"}
|
||||
name="Smoldot"
|
||||
type="light"
|
||||
/>
|
||||
) : null}
|
||||
{Object.entries(selectedNetwork.endpoints).map(
|
||||
([rpcName, url]) => (
|
||||
<ConnectionOption
|
||||
value={url}
|
||||
isSelected={selectedRpc === url}
|
||||
name={rpcName}
|
||||
type="rpc"
|
||||
url={url}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedRpc && selectedRpc !== "light-client" && (
|
||||
<div className="mt-4 p-3 border rounded-md bg-muted/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Chopsticks size={20} />
|
||||
<Label
|
||||
htmlFor="use-chopsticks"
|
||||
className="font-medium cursor-pointer"
|
||||
<DialogBody className="flex flex-col overflow-hidden gap-2">
|
||||
<div className="h-full grow flex flex-col">
|
||||
<CommandPopover
|
||||
placeholder="Search or enter a custom URI"
|
||||
value={enteredText}
|
||||
onValueChange={setEnteredText}
|
||||
selectedValue={selectedNetwork.id}
|
||||
>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<div className="text-foreground/50">No networks found.</div>
|
||||
</CommandEmpty>
|
||||
<ScrollArea className="h-[260px]">
|
||||
{networkCategories.map((category) => (
|
||||
<CommandGroup key={category.name} heading={category.name}>
|
||||
{category.networks.map((network) => (
|
||||
<CommandItem
|
||||
key={network.id}
|
||||
onSelect={() => handleNetworkSelect(network)}
|
||||
value={
|
||||
network.display.includes(category.name)
|
||||
? network.display
|
||||
: `${category.name} ${network.display}`
|
||||
}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
selectedNetwork.id === network.id
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{network.display}
|
||||
</CommandItem>
|
||||
))}
|
||||
{category.name === "Custom" && isValidUri(enteredText) ? (
|
||||
<CommandItem
|
||||
value={enteredText}
|
||||
onSelect={() => {
|
||||
handleNetworkSelect({
|
||||
id: "custom-network",
|
||||
lightclient: false,
|
||||
endpoints: { custom: enteredText },
|
||||
display: enteredText,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
selectedNetwork.id === "custom-network"
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{enteredText}
|
||||
</CommandItem>
|
||||
) : null}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</CommandPopover>
|
||||
<div className="h-[50vh] flex flex-col gap-2">
|
||||
{selectedNetwork ? (
|
||||
<div className="grow-1 overflow-hidden flex flex-col">
|
||||
<p className="py-2">Network: {selectedNetwork.display}</p>
|
||||
<div className="overflow-auto">
|
||||
<RadioGroup
|
||||
value={selectedRpc}
|
||||
onValueChange={setSelectedRpc}
|
||||
>
|
||||
Fork with Chopsticks
|
||||
</Label>
|
||||
{selectedNetwork.lightclient ? (
|
||||
<ConnectionOption
|
||||
value="light-client"
|
||||
isSelected={selectedRpc === "light-client"}
|
||||
name="Smoldot"
|
||||
type="light"
|
||||
/>
|
||||
) : null}
|
||||
{Object.entries(selectedNetwork.endpoints).map(
|
||||
([rpcName, url]) => (
|
||||
<ConnectionOption
|
||||
key={rpcName}
|
||||
value={url}
|
||||
isSelected={selectedRpc === url}
|
||||
name={rpcName}
|
||||
type="rpc"
|
||||
url={url}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<SliderToggle
|
||||
id="use-chopsticks"
|
||||
isToggled={withChopsticks}
|
||||
toggle={() => setWithChopsticks(!withChopsticks)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">
|
||||
Create a local fork of this chain
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
{selectedRpc && selectedRpc !== "light-client" && (
|
||||
<div className="mt-4 p-3 border rounded-md bg-muted/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Chopsticks size={20} />
|
||||
<Label
|
||||
htmlFor="use-chopsticks"
|
||||
className="font-medium cursor-pointer"
|
||||
>
|
||||
Fork with Chopsticks
|
||||
</Label>
|
||||
</div>
|
||||
<SliderToggle
|
||||
id="use-chopsticks"
|
||||
isToggled={withChopsticks}
|
||||
toggle={() => setWithChopsticks(!withChopsticks)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">
|
||||
Create a local fork of this chain
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={
|
||||
!selectedNetwork ||
|
||||
!hasChanged ||
|
||||
(selectedNetwork.id === "custom-network" && !selectedRpc)
|
||||
}
|
||||
>
|
||||
Confirm Selection
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={
|
||||
!selectedNetwork ||
|
||||
!hasChanged ||
|
||||
(selectedNetwork.id === "custom-network" && !selectedRpc)
|
||||
}
|
||||
>
|
||||
Confirm Selection
|
||||
</Button>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function Transactions() {
|
||||
return !nTxs ? null : (
|
||||
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button className="fixed bottom-4 right-4 bg-polkadot/90 text-polkadot-foreground px-4 py-2 rounded-full shadow-lg hover:bg-primary hover:font-bold focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-opacity-50 flex items-center space-x-2">
|
||||
<button className="fixed bottom-4 right-4 bg-polkadot/90 text-polkadot-foreground px-4 py-2 rounded-full shadow-lg hover:bg-polkadot hover:font-bold focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-opacity-50 flex items-center space-x-2">
|
||||
{nOngoing > 0 && (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { createLocalStorageState } from "@/lib/externalState"
|
||||
import { state } from "@react-rxjs/core"
|
||||
import type { HexString, PolkadotSigner, SS58String } from "polkadot-api"
|
||||
import type { InjectedPolkadotAccount } from "polkadot-api/pjs-signer"
|
||||
import { combineLatest, defer, from, map, switchMap } from "rxjs"
|
||||
import { accountsByExtension$, getPublicKey } from "./extension-accounts.state"
|
||||
import { getPolkadotSigner } from "polkadot-api/signer"
|
||||
import { canSetStorage$ } from "./chains/chain.state"
|
||||
|
||||
export type AccountSource = "extension" | "walletconnect" | "readonly"
|
||||
export const accountSourceTypeToName: Record<AccountSource, string> = {
|
||||
extension: "Extension",
|
||||
walletconnect: "Wallet Connect",
|
||||
readonly: "Read-only",
|
||||
}
|
||||
|
||||
const lazyWalletConnectAccounts$ = defer(() =>
|
||||
from(import("@/state/walletconnect.state")).pipe(
|
||||
switchMap(({ walletConnectAccounts$ }) => walletConnectAccounts$),
|
||||
),
|
||||
)
|
||||
|
||||
interface BaseAccount {
|
||||
type: AccountSource
|
||||
accountId: SS58String | HexString
|
||||
name?: string
|
||||
}
|
||||
|
||||
interface SignerAccount extends BaseAccount {
|
||||
signer: PolkadotSigner
|
||||
}
|
||||
|
||||
export interface ExtensionAccount extends SignerAccount {
|
||||
type: "extension"
|
||||
extensionId: string
|
||||
injected: InjectedPolkadotAccount
|
||||
}
|
||||
export const extensionAccounts$ = state(
|
||||
accountsByExtension$.pipe(
|
||||
map((extensionAccounts) =>
|
||||
Array.from(extensionAccounts.entries()).flatMap(([extension, accounts]) =>
|
||||
accounts.map(
|
||||
(account): ExtensionAccount => ({
|
||||
type: "extension",
|
||||
accountId: account.address,
|
||||
name: account.name,
|
||||
extensionId: extension,
|
||||
injected: account,
|
||||
signer: account.polkadotSigner,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
export interface WalletConnectAccount extends SignerAccount {
|
||||
type: "walletconnect"
|
||||
}
|
||||
export const walletConnectAccounts$ = state(
|
||||
lazyWalletConnectAccounts$.pipe(
|
||||
map((accounts) =>
|
||||
Object.entries(accounts).map(
|
||||
([accountId, signer]): WalletConnectAccount => ({
|
||||
type: "walletconnect",
|
||||
accountId,
|
||||
signer,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
export interface ReadOnlyAccount extends BaseAccount {
|
||||
type: "readonly"
|
||||
// Chopsticks can fake a signer
|
||||
signer?: PolkadotSigner
|
||||
}
|
||||
const [readOnlyAddresses$, setAddresses] = createLocalStorageState(
|
||||
"read-only-addr",
|
||||
[] as string[],
|
||||
)
|
||||
export { setAddresses }
|
||||
export const readOnlyAccounts$ = state(
|
||||
combineLatest([defer(readOnlyAddresses$), canSetStorage$]).pipe(
|
||||
map(([v, isChopsticks]) =>
|
||||
v.map(
|
||||
(address): ReadOnlyAccount => ({
|
||||
type: "readonly",
|
||||
accountId: address,
|
||||
signer: isChopsticks
|
||||
? getPolkadotSigner(getPublicKey(address)!, "Sr25519", () => {
|
||||
// From https://wiki.acala.network/build/sdks/homa
|
||||
const signature = new Uint8Array(64)
|
||||
signature.fill(0xcd)
|
||||
signature.set([0xde, 0xad, 0xbe, 0xef])
|
||||
return signature
|
||||
})
|
||||
: undefined,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
export type Account = ExtensionAccount | WalletConnectAccount | ReadOnlyAccount
|
||||
export const accounts$ = state(
|
||||
combineLatest([
|
||||
extensionAccounts$,
|
||||
walletConnectAccounts$,
|
||||
readOnlyAccounts$,
|
||||
]).pipe(map((v): Account[] => v.flat())),
|
||||
[],
|
||||
)
|
||||
@@ -1,214 +0,0 @@
|
||||
import { state, withDefault } from "@react-rxjs/core"
|
||||
import { combineKeys, createKeyedSignal } from "@react-rxjs/utils"
|
||||
import { Binary, getSs58AddressInfo } from "polkadot-api"
|
||||
import {
|
||||
connectInjectedExtension,
|
||||
getInjectedExtensions,
|
||||
InjectedExtension,
|
||||
InjectedPolkadotAccount,
|
||||
} from "polkadot-api/pjs-signer"
|
||||
import {
|
||||
catchError,
|
||||
concat,
|
||||
defer,
|
||||
filter,
|
||||
interval,
|
||||
map,
|
||||
NEVER,
|
||||
Observable,
|
||||
of,
|
||||
retry,
|
||||
scan,
|
||||
startWith,
|
||||
switchMap,
|
||||
take,
|
||||
tap,
|
||||
timer,
|
||||
} from "rxjs"
|
||||
|
||||
export const availableExtensions$ = state(
|
||||
concat(
|
||||
timer(0, 100).pipe(
|
||||
map(getInjectedExtensions),
|
||||
filter((v) => v.length > 0),
|
||||
take(1),
|
||||
),
|
||||
interval(2000).pipe(map(getInjectedExtensions)),
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
const [toggleExtension$, onToggleExtension] = createKeyedSignal<string>()
|
||||
export { onToggleExtension }
|
||||
|
||||
export const enum ConnectStatus {
|
||||
Connecting,
|
||||
Disconnected,
|
||||
Connected,
|
||||
}
|
||||
export type ExtensionState =
|
||||
| {
|
||||
type: ConnectStatus.Disconnected
|
||||
}
|
||||
| { type: ConnectStatus.Connecting }
|
||||
| { type: ConnectStatus.Connected; value: InjectedExtension }
|
||||
|
||||
const SELECTED_EXTENSIONS_KEY = "selected-extensions"
|
||||
const getPreselectedExtensions = () => {
|
||||
try {
|
||||
const res = JSON.parse(localStorage.getItem(SELECTED_EXTENSIONS_KEY)!)
|
||||
if (Array.isArray(res)) return res
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (_) {}
|
||||
return null
|
||||
}
|
||||
const extensionIsPreselected = (extension: string) =>
|
||||
getPreselectedExtensions()?.includes(extension) ?? false
|
||||
const setPreselectedExtension = (extension: string, selected: boolean) => {
|
||||
const preselectedExtensions = getPreselectedExtensions() ?? []
|
||||
const result = selected
|
||||
? [...new Set([...preselectedExtensions, extension])]
|
||||
: preselectedExtensions.filter((v) => v !== extension)
|
||||
localStorage.setItem(SELECTED_EXTENSIONS_KEY, JSON.stringify(result))
|
||||
}
|
||||
|
||||
const extension$ = (name: string) => {
|
||||
const connect$ = defer(() => connectInjectedExtension(name)).pipe(
|
||||
// PolkadotJS rejects the promise straight away instead of waiting for user input
|
||||
retry({
|
||||
delay(error) {
|
||||
if (error?.message.includes("pending authorization request")) {
|
||||
return timer(1000)
|
||||
}
|
||||
throw error
|
||||
},
|
||||
}),
|
||||
map((extension) => ({
|
||||
type: ConnectStatus.Connected as const,
|
||||
extension,
|
||||
})),
|
||||
catchError((ex) => {
|
||||
console.error(ex)
|
||||
return of({ type: ConnectStatus.Disconnected as const })
|
||||
}),
|
||||
startWith({ type: ConnectStatus.Connecting as const }),
|
||||
)
|
||||
|
||||
const connectWithCleanup$ = defer(() => {
|
||||
let disconnected = false
|
||||
let extension: InjectedExtension | null = null
|
||||
return concat(connect$, NEVER).pipe(
|
||||
tap({
|
||||
next(value) {
|
||||
if (value.type === ConnectStatus.Connected) {
|
||||
if (disconnected) {
|
||||
value.extension.disconnect()
|
||||
} else {
|
||||
extension = value.extension
|
||||
}
|
||||
}
|
||||
},
|
||||
unsubscribe() {
|
||||
if (extension) {
|
||||
extension.disconnect()
|
||||
} else {
|
||||
disconnected = true
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const initialSelected = extensionIsPreselected(name)
|
||||
return toggleExtension$(name).pipe(
|
||||
scan((acc) => !acc, initialSelected),
|
||||
startWith(initialSelected),
|
||||
switchMap((selected) =>
|
||||
selected
|
||||
? connectWithCleanup$
|
||||
: of({
|
||||
type: ConnectStatus.Disconnected as const,
|
||||
}),
|
||||
),
|
||||
tap((v) => {
|
||||
if (v.type === ConnectStatus.Connected) {
|
||||
setPreselectedExtension(name, true)
|
||||
} else if (v.type === ConnectStatus.Disconnected) {
|
||||
setPreselectedExtension(name, false)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const extensions$ = state(combineKeys(availableExtensions$, extension$))
|
||||
|
||||
export const selectedExtensions$ = extensions$.pipeState(
|
||||
map(
|
||||
(extensions) =>
|
||||
new Map(
|
||||
[...extensions.entries()]
|
||||
.filter(([, v]) => v.type !== ConnectStatus.Disconnected)
|
||||
.map(([k, v]) => [
|
||||
k,
|
||||
v.type === ConnectStatus.Connected ? v.extension : null,
|
||||
]),
|
||||
),
|
||||
),
|
||||
withDefault(new Map<string, InjectedExtension | null>()),
|
||||
)
|
||||
|
||||
const extensionAccounts$ = state(
|
||||
(name: string) =>
|
||||
extension$(name).pipe(
|
||||
switchMap((x) => {
|
||||
if (x.type !== ConnectStatus.Connected) return of([])
|
||||
return new Observable<InjectedPolkadotAccount[]>((observer) =>
|
||||
x.extension.subscribe((accounts) => {
|
||||
observer.next(accounts)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
export const accountsByExtension$ = state(
|
||||
combineKeys(availableExtensions$, extensionAccounts$),
|
||||
new Map<string, InjectedPolkadotAccount[]>(),
|
||||
)
|
||||
|
||||
export const getPublicKey = (address: string) => {
|
||||
const info = getSs58AddressInfo(address)
|
||||
return info.isValid ? info.publicKey : null
|
||||
}
|
||||
export const getAccountMapKey = (address: string) => {
|
||||
const pk = getPublicKey(address)
|
||||
return pk ? Binary.fromBytes(pk).asHex() : address
|
||||
}
|
||||
|
||||
export const accounts$ = state(
|
||||
accountsByExtension$.pipe(
|
||||
map(
|
||||
(extensionAccounts) =>
|
||||
new Map(
|
||||
Array.from(extensionAccounts.entries()).flatMap(
|
||||
([extension, accounts]) =>
|
||||
accounts.map((account) => [
|
||||
getAccountMapKey(account.address),
|
||||
{
|
||||
...account,
|
||||
extension,
|
||||
},
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
new Map<string, InjectedPolkadotAccount & { extension: string }>(),
|
||||
)
|
||||
|
||||
export const accountDetail$ = state(
|
||||
(account: string) =>
|
||||
accounts$.pipe(map((v) => v.get(getAccountMapKey(account)) ?? null)),
|
||||
null,
|
||||
)
|
||||
@@ -21,7 +21,7 @@ export const isVerified = (identity: Identity | null) =>
|
||||
identity?.judgments.some((j) => j.judgement === "Reasonable")
|
||||
|
||||
const cache = localStorageSubject<Record<string, Identity>>(
|
||||
"identity-cache",
|
||||
"console-identity-cache",
|
||||
JSON,
|
||||
{},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { polkadot_people } from "@polkadot-api/descriptors"
|
||||
import { liftSuspense, state, SUSPENSE, withDefault } from "@react-rxjs/core"
|
||||
import { AccountId, SS58String } from "polkadot-api"
|
||||
import {
|
||||
Account,
|
||||
createLedgerProvider,
|
||||
createPjsWalletProvider,
|
||||
createPolkadotVaultProvider,
|
||||
createPolkaHub,
|
||||
createReadOnlyProvider,
|
||||
createSelectedAccountPlugin,
|
||||
createWalletConnectProvider,
|
||||
knownChains,
|
||||
Plugin,
|
||||
} from "polkahub"
|
||||
import {
|
||||
combineLatest,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
pipe,
|
||||
switchMap,
|
||||
} from "rxjs"
|
||||
import { chainProperties$ } from "./chain-props.state"
|
||||
import { canSetStorage$, client$ } from "./chains/chain.state"
|
||||
import { identity$, isVerified } from "./identity.state"
|
||||
|
||||
const removeSuspense = <T>() =>
|
||||
pipe(
|
||||
liftSuspense<T>(),
|
||||
filter<T | SUSPENSE, T>((v): v is T => v !== SUSPENSE),
|
||||
)
|
||||
|
||||
const selectedAccountPlugin = createSelectedAccountPlugin()
|
||||
const pjsWalletProvider = createPjsWalletProvider()
|
||||
const polkadotVaultProvider = createPolkadotVaultProvider()
|
||||
const readOnlyProvider$ = canSetStorage$.pipe(
|
||||
map((fakeSigner) =>
|
||||
createReadOnlyProvider({
|
||||
fakeSigner,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const ledgerAccountProvider = createLedgerProvider(
|
||||
async () => {
|
||||
const module = await import("@ledgerhq/hw-transport-webusb")
|
||||
return module.default.create()
|
||||
},
|
||||
() =>
|
||||
firstValueFrom(
|
||||
chainProperties$.pipe(
|
||||
removeSuspense(),
|
||||
filter((v) => v != null),
|
||||
map(({ tokenDecimals, tokenSymbol }) => ({
|
||||
decimals: tokenDecimals!,
|
||||
tokenSymbol: tokenSymbol!,
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
const walletConnectProvider = createWalletConnectProvider(
|
||||
import.meta.env.VITE_REOWN_PROJECT_ID,
|
||||
[
|
||||
knownChains.polkadot,
|
||||
knownChains.polkadotAh,
|
||||
knownChains.kusama,
|
||||
knownChains.kusamaAh,
|
||||
knownChains.paseo,
|
||||
knownChains.paseoAh,
|
||||
],
|
||||
)
|
||||
|
||||
export const polkaHub = createPolkaHub(
|
||||
combineLatest([
|
||||
[selectedAccountPlugin],
|
||||
[pjsWalletProvider],
|
||||
[polkadotVaultProvider],
|
||||
readOnlyProvider$,
|
||||
[ledgerAccountProvider],
|
||||
[walletConnectProvider],
|
||||
]).pipe(map((v: (Plugin<any> | null)[]) => v.filter((v) => v != null))),
|
||||
{
|
||||
getIdentity: (address) =>
|
||||
identity$(address).pipe(
|
||||
removeSuspense(),
|
||||
map((res) =>
|
||||
res
|
||||
? {
|
||||
name: res.displayName,
|
||||
verified: isVerified(res) ?? false,
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
getBalance: (address) =>
|
||||
client$.pipe(
|
||||
removeSuspense(),
|
||||
switchMap((client) =>
|
||||
combineLatest([
|
||||
chainProperties$,
|
||||
client
|
||||
.getTypedApi(polkadot_people)
|
||||
.query.System.Account.getValue(address),
|
||||
]),
|
||||
),
|
||||
map(([chainProps, balance]) => {
|
||||
if (chainProps?.tokenDecimals == null) return null
|
||||
|
||||
return {
|
||||
value: balance.data.reserved + balance.data.free,
|
||||
decimals: chainProps.tokenDecimals,
|
||||
symbol: chainProps.tokenSymbol,
|
||||
}
|
||||
}),
|
||||
),
|
||||
ss58Format: chainProperties$.pipe(
|
||||
removeSuspense(),
|
||||
map((v) => v?.ss58Format ?? 42),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
export const addrToAccount$ = polkaHub.availableAccounts$.pipeState(
|
||||
map((groups) =>
|
||||
Object.fromEntries(
|
||||
Object.values(groups).flatMap((group) =>
|
||||
group.map((account) => [account.address, account]),
|
||||
),
|
||||
),
|
||||
),
|
||||
withDefault({} as Record<string, Account>),
|
||||
)
|
||||
|
||||
export const accountDetail$ = state(
|
||||
(addr: SS58String) =>
|
||||
addrToAccount$.pipe(map((addrToAccount) => addrToAccount[addr] ?? null)),
|
||||
null,
|
||||
)
|
||||
|
||||
export const getPublicKey = AccountId().enc
|
||||
|
||||
export const selectedAccount$ =
|
||||
selectedAccountPlugin.selectedAccount$.pipeState(withDefault(null))
|
||||
|
||||
export const availableExtensions$ = pjsWalletProvider.availableExtensions$
|
||||
export const selectedExtensions$ = pjsWalletProvider.connectedExtensions$
|
||||
export const toggleExtension = async (id: string) => {
|
||||
const extensions = await firstValueFrom(
|
||||
pjsWalletProvider.connectedExtensions$,
|
||||
)
|
||||
pjsWalletProvider.setConnectedExtensions(
|
||||
extensions.includes(id)
|
||||
? extensions.filter((v) => v != id)
|
||||
: [...extensions, id],
|
||||
)
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
import { localStorageSubject } from "@/utils/localStorageSubject"
|
||||
import { state, withDefault } from "@react-rxjs/core"
|
||||
import { createSignal } from "@react-rxjs/utils"
|
||||
import { WalletConnectModal } from "@walletconnect/modal"
|
||||
import { SessionTypes } from "@walletconnect/types"
|
||||
import UniversalProvider from "@walletconnect/universal-provider"
|
||||
import { getSdkError } from "@walletconnect/utils"
|
||||
import {
|
||||
getPolkadotSignerFromPjs,
|
||||
PolkadotSigner,
|
||||
} from "polkadot-api/pjs-signer"
|
||||
import {
|
||||
catchError,
|
||||
defer,
|
||||
EMPTY,
|
||||
filter,
|
||||
finalize,
|
||||
firstValueFrom,
|
||||
from,
|
||||
fromEventPattern,
|
||||
ignoreElements,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
scan,
|
||||
startWith,
|
||||
switchMap,
|
||||
take,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from "rxjs"
|
||||
|
||||
// https://docs.reown.com/advanced/multichain/polkadot/dapp-integration-guide
|
||||
const chains = [
|
||||
"polkadot:91b171bb158e2d3848fa23a9f1c25182", // Polkadot
|
||||
"polkadot:e143f23803ac50e8f6f8e62695d1ce9e", // Westend
|
||||
]
|
||||
const projectId = import.meta.env.VITE_REOWN_PROJECT_ID
|
||||
const walletConnectModal = new WalletConnectModal({
|
||||
projectId,
|
||||
chains,
|
||||
})
|
||||
|
||||
const provider$ = state(
|
||||
defer(() =>
|
||||
UniversalProvider.init({
|
||||
projectId,
|
||||
relayUrl: "wss://relay.walletconnect.com",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
interface InitializedSession {
|
||||
uri?: string
|
||||
approval: () => Promise<SessionTypes.Struct>
|
||||
}
|
||||
const initializeSession$ = () =>
|
||||
provider$.pipe(
|
||||
take(1),
|
||||
switchMap(
|
||||
(provider): Promise<InitializedSession> =>
|
||||
provider.client.connect({
|
||||
requiredNamespaces: {
|
||||
polkadot: {
|
||||
methods: ["polkadot_signTransaction", "polkadot_signMessage"],
|
||||
chains,
|
||||
events: ["chainChanged", "accountsChanged"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const sessionSubject = localStorageSubject<SessionTypes.Struct>(
|
||||
"wallet-connect",
|
||||
JSON,
|
||||
)
|
||||
|
||||
type WalletConnectStatus =
|
||||
| {
|
||||
type: "disconnected"
|
||||
}
|
||||
| {
|
||||
type: "connecting"
|
||||
}
|
||||
| {
|
||||
type: "connected"
|
||||
session: SessionTypes.Struct
|
||||
}
|
||||
|
||||
const connect$ = defer(initializeSession$).pipe(
|
||||
switchMap(({ uri, approval }) => {
|
||||
if (!uri) return approval()
|
||||
|
||||
walletConnectModal.openModal({ uri })
|
||||
const modal$ = fromEventPattern<{ open: boolean }>(
|
||||
(handler) => walletConnectModal.subscribeModal(handler),
|
||||
(_, fn) => fn(),
|
||||
)
|
||||
const closed$ = modal$.pipe(
|
||||
tap((v) => console.log("modal event", v)),
|
||||
filter(({ open }) => !open),
|
||||
)
|
||||
|
||||
return from(approval()).pipe(
|
||||
takeUntil(closed$),
|
||||
finalize(() => walletConnectModal.closeModal()),
|
||||
)
|
||||
}),
|
||||
map((session): WalletConnectStatus => ({ type: "connected", session })),
|
||||
catchError((err) => {
|
||||
console.log("connect WalletConnect error", err)
|
||||
return of(EMPTY) as any as Observable<WalletConnectStatus>
|
||||
}),
|
||||
startWith({ type: "connecting" } satisfies WalletConnectStatus),
|
||||
)
|
||||
const disconnect$ = provider$.pipe(
|
||||
take(1),
|
||||
switchMap((provider) =>
|
||||
provider.session
|
||||
? provider.client.disconnect({
|
||||
topic: provider.session.topic,
|
||||
reason: getSdkError("USER_DISCONNECTED"),
|
||||
})
|
||||
: EMPTY,
|
||||
),
|
||||
ignoreElements(),
|
||||
startWith({
|
||||
type: "disconnected",
|
||||
} satisfies WalletConnectStatus),
|
||||
)
|
||||
|
||||
export const [toggleConnect$, toggleWalletConnect] = createSignal<void>()
|
||||
|
||||
export const walletConnectStatus$ = state(
|
||||
sessionSubject.stream$.pipe(
|
||||
take(1),
|
||||
switchMap((session): Observable<WalletConnectStatus> => {
|
||||
const connectState$ = toggleConnect$.pipe(
|
||||
scan((acc) => !acc, !!session),
|
||||
switchMap((connect) =>
|
||||
connect
|
||||
? connect$.pipe(
|
||||
tapOnLast((v) => {
|
||||
// hack! if connect$ didn't actually complete, toggle it off
|
||||
if (v?.type !== "connected") {
|
||||
toggleWalletConnect()
|
||||
}
|
||||
}),
|
||||
)
|
||||
: disconnect$,
|
||||
),
|
||||
tap((v) => {
|
||||
if (v.type === "connected") {
|
||||
sessionSubject.setValue(v.session)
|
||||
} else {
|
||||
sessionSubject.clear()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return connectState$.pipe(
|
||||
startWith(
|
||||
(session
|
||||
? {
|
||||
type: "connected",
|
||||
session,
|
||||
}
|
||||
: {
|
||||
type: "disconnected",
|
||||
}) satisfies WalletConnectStatus,
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
{
|
||||
type: "disconnected",
|
||||
},
|
||||
)
|
||||
|
||||
const getAccounts = (session: SessionTypes.Struct) =>
|
||||
Object.values(session.namespaces)
|
||||
.map((namespace) => namespace.accounts)
|
||||
.flat()
|
||||
.map((wcAccount) => wcAccount.split(":")[2])
|
||||
|
||||
const getSigner = (session: SessionTypes.Struct, address: string) =>
|
||||
getPolkadotSignerFromPjs(
|
||||
address,
|
||||
async (transactionPayload) => {
|
||||
const provider = await firstValueFrom(provider$)
|
||||
|
||||
console.log("Topic to check chainId below", session.topic)
|
||||
|
||||
return provider.client.request({
|
||||
topic: session.topic,
|
||||
chainId: `polkadot:${transactionPayload.genesisHash.substring(2, 34)}`,
|
||||
request: {
|
||||
method: "polkadot_signTransaction",
|
||||
params: {
|
||||
address,
|
||||
transactionPayload,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
async ({ address, data }) => {
|
||||
const provider = await firstValueFrom(provider$)
|
||||
|
||||
// const chainId = provider.session.topic.split(":")[1];
|
||||
const chainId = session.topic.split(":")[1]
|
||||
|
||||
return provider.client.request({
|
||||
topic: session.topic,
|
||||
chainId: `polkadot:${chainId}`,
|
||||
request: {
|
||||
method: "polkadot_signMessage",
|
||||
params: {
|
||||
address,
|
||||
message: data,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const getSignersFromSession = (
|
||||
session: SessionTypes.Struct,
|
||||
): Record<string, PolkadotSigner> => {
|
||||
const accounts = getAccounts(session)
|
||||
return Object.fromEntries(
|
||||
accounts.map((address) => [address, getSigner(session, address)]),
|
||||
)
|
||||
}
|
||||
|
||||
export const walletConnectAccounts$ = walletConnectStatus$.pipeState(
|
||||
map((status) =>
|
||||
status.type === "connected" ? getSignersFromSession(status.session) : {},
|
||||
),
|
||||
withDefault({} as Record<string, PolkadotSigner>),
|
||||
)
|
||||
|
||||
const tapOnLast =
|
||||
<T>(onLast: (value: T | null) => void) =>
|
||||
(source$: Observable<T>) =>
|
||||
defer(() => {
|
||||
let value: T | null = null
|
||||
return source$.pipe(
|
||||
tap({
|
||||
next(v) {
|
||||
value = v
|
||||
},
|
||||
complete() {
|
||||
onLast(value)
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||