Merge pull request #78 from polkadot-api/vo/account-management
feat: account management, fake chopsticks signature
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "0.1.0-autogenerated.1071370278631255394",
|
||||
"version": "0.1.0-autogenerated.9612188710233485064",
|
||||
"name": "@polkadot-api/descriptors",
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
@@ -1,11 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 350 350" width="24" height="24" >
|
||||
<circle
|
||||
cx="175"
|
||||
cy="175"
|
||||
r="175"
|
||||
fill="#0888f0"
|
||||
transform="matrix(-1 0 0 1 350 0)"
|
||||
></circle>
|
||||
<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"
|
||||
|
Before Width: | Height: | Size: 596 B After Width: | Height: | Size: 516 B |
@@ -1,4 +1,5 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom"
|
||||
import { Accounts } from "./pages/Accounts/Accounts"
|
||||
import { Constants } from "./pages/Constants"
|
||||
import { Explorer } from "./pages/Explorer"
|
||||
import { Extrinsics } from "./pages/Extrinsics"
|
||||
@@ -23,6 +24,7 @@ export default function App() {
|
||||
<Route path="runtimeCalls/*" element={<RuntimeCalls />} />
|
||||
<Route path="rpcCalls/*" element={<RpcCalls />} />
|
||||
<Route path="metadata/*" element={<Metadata />} />
|
||||
<Route path="accounts/*" element={<Accounts />} />
|
||||
<Route path="*" element={<Navigate to="/explorer" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { state, useStateObservable } from "@react-rxjs/core"
|
||||
import { createSignal } from "@react-rxjs/utils"
|
||||
import {
|
||||
createContext,
|
||||
FC,
|
||||
@@ -6,7 +7,9 @@ import {
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
} from "react"
|
||||
import { fromEvent, map } from "rxjs"
|
||||
import { fromEvent, map, merge } from "rxjs"
|
||||
|
||||
export const [changeTheme$, changeTheme] = createSignal<"light" | "dark">()
|
||||
|
||||
const getCurrentMedia = (): "light" | "dark" =>
|
||||
window.matchMedia
|
||||
@@ -15,17 +18,30 @@ const getCurrentMedia = (): "light" | "dark" =>
|
||||
: "light"
|
||||
: "dark"
|
||||
|
||||
const defaultTheme = getCurrentMedia()
|
||||
const prefLSKey = "theme"
|
||||
changeTheme$.subscribe((theme) => {
|
||||
if (getCurrentMedia() === theme) {
|
||||
localStorage.removeItem(prefLSKey)
|
||||
} else {
|
||||
localStorage.setItem(prefLSKey, theme)
|
||||
}
|
||||
})
|
||||
|
||||
const defaultTheme =
|
||||
(localStorage.getItem(prefLSKey) as "light" | "dark") || getCurrentMedia()
|
||||
|
||||
const ThemeContext = createContext<"light" | "dark">(defaultTheme)
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext)
|
||||
|
||||
const theme$ = state(
|
||||
fromEvent<MediaQueryListEvent>(
|
||||
window.matchMedia("(prefers-color-scheme: dark)"),
|
||||
"change",
|
||||
).pipe(map((evt) => (evt.matches ? "dark" : "light"))),
|
||||
merge(
|
||||
fromEvent<MediaQueryListEvent>(
|
||||
window.matchMedia("(prefers-color-scheme: dark)"),
|
||||
"change",
|
||||
).pipe(map((evt) => (evt.matches ? "dark" : "light"))),
|
||||
changeTheme$,
|
||||
),
|
||||
defaultTheme,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,190 +1,9 @@
|
||||
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 {
|
||||
accountDetail$,
|
||||
accounts$,
|
||||
getPublicKey,
|
||||
} from "@/state/extension-accounts.state"
|
||||
import { identity$, isVerified } from "@/state/identity.state"
|
||||
import { cn } from "@/utils/cn"
|
||||
import { AccountIdInput } from "@/components/AccountIdInput"
|
||||
import { EditAccountId, NOTIN } from "@polkadot-api/react-builder"
|
||||
import { getSs58AddressInfo } 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 { map, switchMap, take } from "rxjs"
|
||||
|
||||
const hintedAccounts$ = state(
|
||||
combineKeys(
|
||||
accounts$.pipe(
|
||||
map((accounts) =>
|
||||
[...accounts.entries()]
|
||||
.filter(([, { address }]) => !address.startsWith("0x"))
|
||||
.map(([key]) => key),
|
||||
),
|
||||
),
|
||||
(address) =>
|
||||
accounts$.pipe(
|
||||
map((v) => v.get(address)!),
|
||||
take(1),
|
||||
switchMap((details) =>
|
||||
identity$(details.address).pipe(
|
||||
map((identity) => ({
|
||||
address: details.address,
|
||||
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 CAccountId: EditAccountId = ({ value, onValueChanged }) => (
|
||||
<AccountIdInput
|
||||
value={value === NOTIN ? null : value}
|
||||
onValueChanged={(v) => onValueChanged(v === null ? NOTIN : v)}
|
||||
/>
|
||||
)
|
||||
|
||||
export const CAccountId: EditAccountId = ({ value, onValueChanged }) => {
|
||||
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 !== NOTIN &&
|
||||
!accounts.has(toHex(getPublicKey(value) ?? new Uint8Array()))
|
||||
|
||||
const accountList = Array.from(accounts.entries())
|
||||
if (value !== NOTIN) {
|
||||
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="flex w-64 justify-between overflow-hidden px-2 border border-border bg-input"
|
||||
forceSvgSize={false}
|
||||
>
|
||||
{value !== NOTIN ? (
|
||||
<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…"
|
||||
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 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import chopsticksLogoLight from "./icons/chopsticks_light.svg"
|
||||
import enumSvg from "./icons/enum.svg"
|
||||
import focusSvg from "./icons/focus.svg"
|
||||
import switchBinarySvg from "./icons/switch_binary.svg"
|
||||
import walletConnectSvg from "./icons/walletConnect.svg"
|
||||
|
||||
type CustomIconProps = Omit<Props, "ref" | "src"> & { size?: number }
|
||||
const CustomIcon: FC<
|
||||
@@ -62,7 +61,6 @@ const themeIcon = (light: string, dark: string) => (props: CustomIconProps) => {
|
||||
export const Focus = customIcon(focusSvg)
|
||||
export const Enum = customIcon(enumSvg)
|
||||
export const BinaryEdit = customIcon(binarySvg)
|
||||
export const WalletConnect = customIcon(walletConnectSvg)
|
||||
export const SwitchBinary = customIcon(switchBinarySvg)
|
||||
export const Chopsticks = themeIcon(chopsticksLogoLight, chopsticksLogoDark)
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { chainProperties$ } from "@/state/chain-props.state"
|
||||
import { useStateObservable } from "@react-rxjs/core"
|
||||
import { FC } from "react"
|
||||
|
||||
export const TokenAmount: FC<{
|
||||
children: bigint
|
||||
className?: string
|
||||
}> = ({ children, className }) => {
|
||||
const properties = useStateObservable(chainProperties$)
|
||||
|
||||
if (!properties) return null
|
||||
|
||||
if (properties?.tokenDecimals == null) {
|
||||
return children.toLocaleString()
|
||||
}
|
||||
|
||||
const formattedValue = (
|
||||
Number(children) /
|
||||
10 ** properties.tokenDecimals
|
||||
).toLocaleString(undefined)
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("tabular-nums", className)}
|
||||
>{`${formattedValue} ${properties.tokenSymbol}`}</span>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { state } from "@react-rxjs/core"
|
||||
import { createSignal } from "@react-rxjs/utils"
|
||||
import { useState } from "react"
|
||||
import { map } from "rxjs"
|
||||
import { getHashParams, setHashParams } from "../hashParams"
|
||||
|
||||
interface Parser<T> {
|
||||
@@ -89,3 +92,31 @@ const sessionHashState: ExternalState = {
|
||||
},
|
||||
}
|
||||
export const useHashSessionState = createExternalStateHook(sessionHashState)
|
||||
|
||||
export const createLocalStorageState = <T>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
serializer: {
|
||||
stringify: (value: Exclude<T, null>) => string
|
||||
parse: (value: string) => T | null
|
||||
} = JSON,
|
||||
) => {
|
||||
const [valueChange$, setValue] = createSignal<T | null>()
|
||||
valueChange$.subscribe((v) =>
|
||||
v === null
|
||||
? localStorage.removeItem(key)
|
||||
: localStorage.setItem(key, serializer.stringify(v as Exclude<T, null>)),
|
||||
)
|
||||
|
||||
const state$ = state(
|
||||
() => valueChange$.pipe(map((v) => (v === null ? defaultValue : v))),
|
||||
() => {
|
||||
const initialValueStr = localStorage.getItem(key)
|
||||
return initialValueStr != null
|
||||
? (serializer.parse(initialValueStr) ?? defaultValue)
|
||||
: defaultValue
|
||||
},
|
||||
)
|
||||
|
||||
return [state$, setValue] as const
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
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"
|
||||
import {
|
||||
liftSuspense,
|
||||
sinkSuspense,
|
||||
state,
|
||||
useStateObservable,
|
||||
} from "@react-rxjs/core"
|
||||
import { Send } from "lucide-react"
|
||||
import { CompatibilityLevel, SS58String } from "polkadot-api"
|
||||
import { FC } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { catchError, combineLatest, map, switchMap } from "rxjs"
|
||||
import { knownExtensions } from "./Providers"
|
||||
|
||||
export const AccountList = () => {
|
||||
const accounts = useStateObservable(accounts$)
|
||||
|
||||
return (
|
||||
<div className="bg-card p-4 rounded-xl space-y-4">
|
||||
<h3 className="text-xl font-bold">Accounts</h3>
|
||||
<ul className="space-y-4">
|
||||
{accounts.map((account, i) => (
|
||||
<AccountCard key={i} account={account} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AccountCard: FC<{
|
||||
account: Account
|
||||
}> = ({ account }) => {
|
||||
return (
|
||||
<li className="shadow border rounded-xl p-4 space-y-4">
|
||||
<div className="flex justify-between items-start overflow-hidden">
|
||||
<AccountIdDisplay
|
||||
value={account.accountId}
|
||||
className="shrink-1 overflow-hidden"
|
||||
/>
|
||||
<SourceTag account={account} />
|
||||
</div>
|
||||
{account.accountId.startsWith("0x") ? null : (
|
||||
<Balances accountId={account.accountId} />
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const sourceColors: Record<AccountSource, string> = {
|
||||
extension: "var(--color-lime-700)",
|
||||
walletconnect: "var(--color-blue-700)",
|
||||
readonly: "var(--color-orange-700)",
|
||||
}
|
||||
const getExtensionName = (id: string) => knownExtensions[id]?.name ?? id
|
||||
|
||||
const SourceTag: FC<{
|
||||
account: Account
|
||||
}> = ({ account }) => {
|
||||
return (
|
||||
<div
|
||||
className="rounded px-2 shrink-0"
|
||||
style={{
|
||||
color: sourceColors[account.type],
|
||||
backgroundColor: `color-mix(in srgb, ${sourceColors[account.type]}, transparent 90%)`,
|
||||
}}
|
||||
>
|
||||
{account.type === "extension"
|
||||
? getExtensionName(account.extensionId)
|
||||
: accountSourceTypeToName[account.type]}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const typedApi$ = client$.pipeState(map((v) => v.getTypedApi(polkadot_people)))
|
||||
|
||||
const balance$ = state(
|
||||
(accountId: SS58String) =>
|
||||
typedApi$.pipe(
|
||||
switchMap((typedApi) =>
|
||||
typedApi.query.System.Account.watchValue(accountId),
|
||||
),
|
||||
map((account) => {
|
||||
const { reserved, free, frozen } = account.data
|
||||
const total = reserved + free
|
||||
|
||||
// TODO ED
|
||||
const untouchable = total == 0n ? 0n : maxBigInt(frozen - reserved, 0n)
|
||||
|
||||
return {
|
||||
total,
|
||||
spendable: free - untouchable,
|
||||
frozen,
|
||||
reserved,
|
||||
}
|
||||
}),
|
||||
liftSuspense(),
|
||||
catchError((ex) => {
|
||||
console.error(ex)
|
||||
return [null]
|
||||
}),
|
||||
sinkSuspense(),
|
||||
),
|
||||
null,
|
||||
)
|
||||
|
||||
const getTransferCallData$ = state(
|
||||
combineLatest([typedApi$, chainProperties$]).pipe(
|
||||
switchMap(async ([typedApi, chainProperties]) => {
|
||||
const tokenDecimals = chainProperties?.tokenDecimals
|
||||
|
||||
if (tokenDecimals == null) return null
|
||||
const token = await typedApi.compatibilityToken
|
||||
|
||||
if (
|
||||
!typedApi.tx.Balances.transfer_keep_alive.isCompatible(
|
||||
CompatibilityLevel.BackwardsCompatible,
|
||||
token,
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (dest: SS58String) => {
|
||||
const value = 10n ** BigInt(tokenDecimals)
|
||||
|
||||
return typedApi.tx.Balances.transfer_keep_alive({
|
||||
dest: MultiAddress.Id(dest),
|
||||
value,
|
||||
})
|
||||
.getEncodedData(token)
|
||||
.asHex()
|
||||
}
|
||||
}),
|
||||
),
|
||||
null,
|
||||
)
|
||||
|
||||
const Balances: FC<{
|
||||
accountId: SS58String
|
||||
}> = ({ accountId }) => {
|
||||
const balance = useStateObservable(balance$(accountId))
|
||||
const getTransferCallData = useStateObservable(getTransferCallData$)
|
||||
|
||||
if (!balance) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<div className="flex flex-col sm:flex-row justify-center gap-x-8 grow">
|
||||
<div className="flex flex-row sm:flex-col gap-2">
|
||||
<div className="text-muted-foreground">Balance</div>
|
||||
<div>
|
||||
<TokenAmount>{balance.total}</TokenAmount>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row sm:flex-col gap-2">
|
||||
<div className="text-muted-foreground">Transferable</div>
|
||||
<TokenAmount className="text-green-800 font-bold">
|
||||
{balance.spendable}
|
||||
</TokenAmount>
|
||||
</div>
|
||||
<div className="flex flex-row sm:flex-col gap-2">
|
||||
<div className="text-muted-foreground">Locked</div>
|
||||
<TokenAmount className="text-orange-800">
|
||||
{balance.total - balance.spendable}
|
||||
</TokenAmount>
|
||||
</div>
|
||||
</div>
|
||||
{getTransferCallData ? (
|
||||
<Button variant="secondary" asChild>
|
||||
<Link to={`/extrinsics#data=${getTransferCallData(accountId)}`}>
|
||||
<Send /> Transfer
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const maxBigInt = (a: bigint, b: bigint) => (a > b ? a : b)
|
||||
@@ -0,0 +1,11 @@
|
||||
import { AccountList } from "./AccountList"
|
||||
import { Providers } from "./Providers"
|
||||
|
||||
export const Accounts = () => {
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<Providers />
|
||||
<AccountList />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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"
|
||||
|
||||
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$)
|
||||
|
||||
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()
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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>
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Accounts"
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AccountIdDisplay } from "@/components/AccountIdDisplay"
|
||||
import { EthAccountDisplay } from "@/components/EthAccountDisplay"
|
||||
import { WalletConnect } from "@/components/Icons"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -10,18 +9,18 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { groupBy } from "@/lib/groupBy"
|
||||
import { knownExtensions } from "@/pages/Accounts/Providers"
|
||||
import {
|
||||
accountsByExtension$,
|
||||
extensionAccounts$,
|
||||
selectedExtensions$,
|
||||
} from "@/state/extension-accounts.state"
|
||||
import {
|
||||
walletConnectAccounts$,
|
||||
walletConnectStatus$,
|
||||
} from "@/state/walletconnect.state"
|
||||
import { state, useStateObservable } from "@react-rxjs/core"
|
||||
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 { InjectedExtension } from "polkadot-api/pjs-signer"
|
||||
import React from "react"
|
||||
import {
|
||||
combineLatest,
|
||||
@@ -33,53 +32,6 @@ import {
|
||||
tap,
|
||||
} from "rxjs"
|
||||
|
||||
const Accounts: React.FC<{ extension: InjectedExtension }> = ({
|
||||
extension,
|
||||
}) => {
|
||||
const accounts = useStateObservable(extensionAccounts$(extension.name))
|
||||
|
||||
return (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{extension.name}</SelectLabel>
|
||||
{accounts.map((account) => (
|
||||
<SelectItem
|
||||
key={account.address}
|
||||
value={account.address + "-" + extension.name}
|
||||
>
|
||||
{account.address.startsWith("0x") ? (
|
||||
<EthAccountDisplay value={account.address} />
|
||||
) : (
|
||||
<AccountIdDisplay value={account.address} />
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)
|
||||
}
|
||||
|
||||
const WalletConnectAccounts = () => {
|
||||
const accounts = useStateObservable(walletConnectAccounts$)
|
||||
|
||||
if (!Object.keys(accounts).length) return null
|
||||
|
||||
return (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="flex gap-1">
|
||||
<WalletConnect /> Wallet Connect
|
||||
</SelectLabel>
|
||||
{Object.keys(accounts).map((address) => (
|
||||
<SelectItem key={address} value={address + "-" + "wallet_connect"}>
|
||||
{address.startsWith("0x") ? (
|
||||
<EthAccountDisplay value={address} />
|
||||
) : (
|
||||
<AccountIdDisplay value={address} />
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)
|
||||
}
|
||||
|
||||
const [valueSelected$, selectValue] = createSignal<string>()
|
||||
|
||||
const LS_KEY = "selected-signer"
|
||||
@@ -91,54 +43,55 @@ const selectedValue$ = state(
|
||||
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$,
|
||||
accountsByExtension$,
|
||||
walletConnectAccounts$,
|
||||
]).pipe(
|
||||
map(([selectedAccount, accountsByExtension, walletConnectAccounts]) => {
|
||||
combineLatest([selectedValue$, allAccounts$]).pipe(
|
||||
map(([selectedAccount, accounts]): Account | null => {
|
||||
if (!selectedAccount) return null
|
||||
const [address, ...rest] = selectedAccount.split("-")
|
||||
const signer = rest.join("-")
|
||||
const account = accounts.find((account) => account.id === selectedAccount)
|
||||
|
||||
if (signer === "wallet_connect") {
|
||||
return address in walletConnectAccounts
|
||||
? {
|
||||
polkadotSigner: walletConnectAccounts[address],
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
const accounts = accountsByExtension.get(signer)
|
||||
if (!accounts) return null
|
||||
return accounts.find((account) => account.address === address) ?? null
|
||||
return account ?? null
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
null,
|
||||
)
|
||||
|
||||
const allAccounts$ = state(
|
||||
combineLatest([walletConnectAccounts$, accountsByExtension$]).pipe(
|
||||
map(([wcAccounts, accounts]) => [
|
||||
...Object.keys(wcAccounts).map((account) => `${account}-wallet_connect`),
|
||||
...[...accounts.entries()].flatMap(([extension, accounts]) =>
|
||||
accounts.map((account) => `${account.address}-${extension}`),
|
||||
),
|
||||
]),
|
||||
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.includes(value)
|
||||
const valueExists = value && allAccounts.some((acc) => acc.id === value)
|
||||
|
||||
if (!activeExtensions.length && walletConnect.type !== "connected")
|
||||
return null
|
||||
@@ -152,10 +105,22 @@ export const AccountProvider: React.FC = () => {
|
||||
<SelectValue placeholder="Select an account" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeExtensions.map((extension) => (
|
||||
<Accounts key={extension.name} extension={extension} />
|
||||
{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>
|
||||
))}
|
||||
<WalletConnectAccounts />
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Spinner, WalletConnect } from "@/components/Icons"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
@@ -6,16 +5,11 @@ import {
|
||||
onToggleExtension,
|
||||
selectedExtensions$,
|
||||
} from "@/state/extension-accounts.state"
|
||||
import {
|
||||
toggleWalletConnect,
|
||||
walletConnectStatus$,
|
||||
} from "@/state/walletconnect.state"
|
||||
import { useStateObservable } from "@react-rxjs/core"
|
||||
|
||||
export const ExtensionProvider: React.FC = () => {
|
||||
const availableExtensions = useStateObservable(availableExtensions$)
|
||||
const selectedExtensions = useStateObservable(selectedExtensions$)
|
||||
const walletConnectStatus = useStateObservable(walletConnectStatus$)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -31,18 +25,6 @@ export const ExtensionProvider: React.FC = () => {
|
||||
{extensionName}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
<TabsTrigger
|
||||
className="mx-1 flex gap-1"
|
||||
onClick={() => toggleWalletConnect()}
|
||||
active={walletConnectStatus.type === "connected"}
|
||||
>
|
||||
{walletConnectStatus.type === "connecting" ? (
|
||||
<Spinner size={16} className="text-sky-500" />
|
||||
) : (
|
||||
<WalletConnect />
|
||||
)}{" "}
|
||||
Wallet Connect
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -22,11 +22,13 @@ const SignAndSubmit: FC<{ callData: string; onClose: () => void }> = ({
|
||||
return (
|
||||
<ActionButton
|
||||
onClick={async () => {
|
||||
if (!account?.signer) return
|
||||
|
||||
setIsSigning(true)
|
||||
try {
|
||||
const unsafeApi = await firstValueFrom(unsafeApi$)
|
||||
const tx = await unsafeApi.txFromCallData(Binary.fromHex(callData))
|
||||
const signedExtrinsic = await tx.sign(account!.polkadotSigner)
|
||||
const signedExtrinsic = await tx.sign(account.signer)
|
||||
trackSignedTx(signedExtrinsic)
|
||||
onClose()
|
||||
} catch (ex) {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { FC, PropsWithChildren, useState } from "react"
|
||||
import { useLocation } from "react-router-dom"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { NetworkSwitcher } from "./Network/Network"
|
||||
import SliderToggle from "@/components/Toggle"
|
||||
import { changeTheme, useTheme } from "@/ThemeProvider"
|
||||
|
||||
const navigationItems = [
|
||||
{ path: "/explorer", label: "Explorer", important: true },
|
||||
@@ -13,8 +15,7 @@ const navigationItems = [
|
||||
{ path: "/extrinsics", label: "Extrinsics", important: true },
|
||||
{ path: "/constants", label: "Constants", important: false },
|
||||
{ path: "/runtimeCalls", label: "Runtime Calls", important: true },
|
||||
{ path: "/rpcCalls", label: "RPC Calls", important: false },
|
||||
{ path: "/metadata", label: "Metadata", important: false },
|
||||
{ path: "/accounts", label: "Accounts", important: false },
|
||||
]
|
||||
|
||||
export const Header = () => {
|
||||
@@ -61,7 +62,7 @@ export const Header = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden text-foreground hover:bg-accent"
|
||||
className="text-foreground hover:bg-accent"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
@@ -71,7 +72,7 @@ export const Header = () => {
|
||||
className="bg-background border-accent w-64 pt-10"
|
||||
>
|
||||
<NetworkSwitcher forSmallScreen />
|
||||
<nav className="flex flex-col gap-4">
|
||||
<nav className="flex flex-col gap-4 lg:hidden">
|
||||
{navigationItems
|
||||
.filter(({ important }) => !important)
|
||||
.map(({ path, label }) => (
|
||||
@@ -84,6 +85,23 @@ export const Header = () => {
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<NavLink to="/metadata" onClick={() => setIsOpen(false)}>
|
||||
Metadata
|
||||
</NavLink>
|
||||
<NavLink to="/rpcCalls" onClick={() => setIsOpen(false)}>
|
||||
RPC Calls
|
||||
</NavLink>
|
||||
<hr />
|
||||
<ThemeToggle />
|
||||
<div className="grow" />
|
||||
<div className="border-t p-2 text-right">
|
||||
<a
|
||||
href="https://github.com/polkadot-api/papi-console"
|
||||
target="_blank"
|
||||
>
|
||||
github
|
||||
</a>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
@@ -112,3 +130,17 @@ const NavLink: FC<
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const ThemeToggle = () => {
|
||||
const theme = useTheme()
|
||||
|
||||
return (
|
||||
<label className="flex items-center justify-between px-4">
|
||||
<div>Dark mode</div>
|
||||
<SliderToggle
|
||||
isToggled={theme === "dark"}
|
||||
toggle={() => changeTheme(theme === "dark" ? "light" : "dark")}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export const Lookup: FC = () => {
|
||||
<label>
|
||||
Id:{" "}
|
||||
<input
|
||||
className="text-sm border rounded p-2 border-polkadot-200 leading-tight text-white"
|
||||
className="text-sm border rounded p-2 border-polkadot-200 leading-tight dark:text-white"
|
||||
value={id}
|
||||
onChange={(evt) => {
|
||||
setSearch("")
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
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())),
|
||||
[],
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
import { withDefault } from "@react-rxjs/core"
|
||||
import { map, switchMap } from "rxjs"
|
||||
import { client$ } from "./chains/chain.state"
|
||||
|
||||
export const chainProperties$ = client$.pipeState(
|
||||
switchMap((v) => v.getChainSpecData()),
|
||||
map((v) => v.properties),
|
||||
map(
|
||||
(
|
||||
v,
|
||||
): {
|
||||
ss58Format?: number
|
||||
tokenDecimals?: number
|
||||
tokenSymbol?: string
|
||||
} => {
|
||||
if (v && typeof v === "object") {
|
||||
const { ss58Format, tokenDecimals, tokenSymbol } = v
|
||||
|
||||
return {
|
||||
ss58Format,
|
||||
tokenDecimals,
|
||||
tokenSymbol,
|
||||
}
|
||||
}
|
||||
return {}
|
||||
},
|
||||
),
|
||||
withDefault(null),
|
||||
)
|
||||
@@ -157,7 +157,7 @@ export const selectedExtensions$ = extensions$.pipeState(
|
||||
withDefault(new Map<string, InjectedExtension | null>()),
|
||||
)
|
||||
|
||||
export const extensionAccounts$ = state(
|
||||
const extensionAccounts$ = state(
|
||||
(name: string) =>
|
||||
extension$(name).pipe(
|
||||
switchMap((x) => {
|
||||
|
||||
@@ -3,4 +3,6 @@ import { Polkadot_peopleWhitelistEntry } from "@polkadot-api/descriptors"
|
||||
export const whitelist: Polkadot_peopleWhitelistEntry[] = [
|
||||
"query.Identity.IdentityOf",
|
||||
"query.Identity.SuperOf",
|
||||
"query.System.Account",
|
||||
"tx.Balances.transfer_keep_alive",
|
||||
]
|
||||
|
||||