feat: manage read-only accounts

This commit is contained in:
Victor Oliva
2025-08-29 17:36:04 +08:00
parent f0dae5c17a
commit ecdaa592e0
9 changed files with 394 additions and 230 deletions

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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
}

View File

@@ -58,8 +58,9 @@ const AccountCard: FC<{
}
const sourceColors: Record<AccountSource, string> = {
extension: "var(--color-orange-800)",
walletconnect: "var(--color-blue-800)",
extension: "var(--color-lime-700)",
walletconnect: "var(--color-blue-700)",
readonly: "var(--color-orange-700)",
}
const getExtensionName = (id: string) => knownExtensions[id]?.name ?? id
@@ -155,7 +156,7 @@ const Balances: FC<{
return (
<div className="flex flex-row">
<div className="flex flex-col sm:flex-row justify-evenly grow">
<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>

View File

@@ -0,0 +1,83 @@
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 { readOnlyAddresses$, 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 readOnlyAddresses = useStateObservable(readOnlyAddresses$)
return (
<div className="space-y-4 overflow-hidden">
<form
onSubmit={(evt) => {
evt.preventDefault()
if (!addressInput) return
setAddresses([
...readOnlyAddresses.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>
{readOnlyAddresses.length ? (
<div>
<h3 className="font-medium text-muted-foreground">Added addresses</h3>
<ul className="space-y-3">
{readOnlyAddresses.map((addr) => (
<li key={addr} className="flex gap-2 items-center">
<Button
variant="outline"
className="text-destructive border-destructive"
type="button"
onClick={() =>
setAddresses(readOnlyAddresses.filter((v) => addr !== v))
}
>
<Trash2 />
</Button>
<AccountIdDisplay value={addr} />
</li>
))}
</ul>
</div>
) : null}
</div>
)
}

View File

@@ -1,16 +1,15 @@
import { Spinner } from "@/components/Icons"
import { Button } from "@/components/ui/button"
import {
availableExtensions$,
onToggleExtension,
selectedExtensions$,
} from "@/state/extension-accounts.state"
// import { walletConnectStatus$ } from
import { cn } from "@/utils"
import { state, useStateObservable } from "@react-rxjs/core"
import { CircleQuestionMark, Eye } from "lucide-react"
import { FC, MouseEvent, PropsWithChildren } from "react"
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(() =>
@@ -58,11 +57,7 @@ export const Providers = () => {
</li>
))}
{/* <SourceButton label="Address">
<div>
<Eye className="size-10" />
</div>
</SourceButton> */}
<AddressProvider />
{/* <SourceButton label="Ledger" disabled>
<img
src={import.meta.env.BASE_URL + "providers/ledger.webp"}
@@ -134,26 +129,3 @@ const ExtensionButton: FC<{
</SourceButton>
)
}
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>
)

View File

@@ -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>
)

View File

@@ -68,8 +68,10 @@ export const selectedAccount$ = state(
const groupedAccounts$ = allAccounts$.pipeState(
map((v) =>
groupBy(v, (acc) =>
acc.type === "extension" ? acc.extensionId : acc.type,
groupBy(
// Accept only signer accounts for submitting transactions
v.filter((v) => "signer" in v),
(acc) => (acc.type === "extension" ? acc.extensionId : acc.type),
),
),
)

View File

@@ -1,13 +1,15 @@
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$ } from "./extension-accounts.state"
export type AccountSource = "extension" | "walletconnect"
export type AccountSource = "extension" | "walletconnect" | "readonly"
export const accountSourceTypeToName: Record<AccountSource, string> = {
extension: "Extension",
walletconnect: "Wallet Connect",
readonly: "Read-only",
}
const lazyWalletConnectAccounts$ = defer(() =>
@@ -69,10 +71,30 @@ export const walletConnectAccounts$ = state(
[],
)
export type Account = ExtensionAccount | WalletConnectAccount
export const accounts$ = state(
combineLatest([extensionAccounts$, walletConnectAccounts$]).pipe(
map((v): Account[] => v.flat()),
export interface ReadOnlyAccount extends BaseAccount {
type: "readonly"
}
export const [readOnlyAddresses$, setAddresses] = createLocalStorageState(
"read-only-addr",
[] as string[],
)
export const readOnlyAccounts$ = readOnlyAddresses$.pipe(
map((v) =>
v.map(
(address): ReadOnlyAccount => ({
type: "readonly",
accountId: address,
}),
),
),
)
export type Account = ExtensionAccount | WalletConnectAccount | ReadOnlyAccount
export const accounts$ = state(
combineLatest([
extensionAccounts$,
walletConnectAccounts$,
readOnlyAccounts$,
]).pipe(map((v): Account[] => v.flat())),
[],
)