unify state management for extension accounts

This commit is contained in:
Victor Oliva
2024-11-04 17:24:02 +01:00
parent 7537fb5d60
commit 2990cfbc6b
11 changed files with 244 additions and 344 deletions
+156 -60
View File
@@ -1,5 +1,6 @@
import { state } from "@react-rxjs/core"
import { state, withDefault } from "@react-rxjs/core"
import { combineKeys, createKeyedSignal } from "@react-rxjs/utils"
import { Binary, getSs58AddressInfo } from "polkadot-api"
import {
connectInjectedExtension,
getInjectedExtensions,
@@ -7,31 +8,37 @@ import {
InjectedPolkadotAccount,
} from "polkadot-api/pjs-signer"
import {
catchError,
concat,
defer,
EMPTY,
exhaustMap,
filter,
interval,
map,
merge,
mergeMap,
NEVER,
Observable,
of,
scan,
startWith,
switchMap,
take,
tap,
timer,
} from "rxjs"
const availableExtensions$ = state(
defer(() =>
timer(100, 500).pipe(
export const availableExtensions$ = state(
concat(
timer(0, 100).pipe(
map(getInjectedExtensions),
startWith(getInjectedExtensions()),
filter((v) => v.length > 0),
take(1),
),
interval(2000).pipe(map(getInjectedExtensions)),
),
[],
)
const [toggleExtension$, onTogleExtension] = createKeyedSignal<string>()
export { onTogleExtension }
const [toggleExtension$, onToggleExtension] = createKeyedSignal<string>()
export { onToggleExtension }
export const enum ConnectStatus {
Connecting,
@@ -45,61 +52,150 @@ export type ExtensionState =
| { type: ConnectStatus.Connecting }
| { type: ConnectStatus.Connected; value: InjectedExtension }
const extension$ = state(
(name: string): Observable<ExtensionState> => {
const storageKey = `extension-${name}`
const isInStorage = localStorage.hasOwnProperty(storageKey)
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 connect$ = defer(() =>
connectInjectedExtension(name)
.then((value) => ({
type: ConnectStatus.Connected as const,
value,
}))
.catch(() => ({ type: ConnectStatus.Disconnected as const })),
).pipe(startWith({ type: ConnectStatus.Connecting as const }))
const extension$ = (name: string) => {
const connect$ = defer(() => connectInjectedExtension(name)).pipe(
map((extension) => ({
type: ConnectStatus.Connected as const,
extension,
})),
catchError(() => of({ type: ConnectStatus.Disconnected as const })),
startWith({ type: ConnectStatus.Connecting as const }),
)
const init$ = isInStorage ? of() : EMPTY
let state: ExtensionState = { type: ConnectStatus.Disconnected }
return merge(
init$,
toggleExtension$(name).pipe(
exhaustMap(() =>
state.type === ConnectStatus.Disconnected
? connect$
: of({ type: ConnectStatus.Disconnected as const }),
),
tap(({ type }) => {
if (type === ConnectStatus.Disconnected)
localStorage.removeItem(storageKey)
else if (type === ConnectStatus.Connected)
localStorage.setItem(storageKey, "")
}),
),
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
}
},
}),
)
},
{ type: ConnectStatus.Disconnected as const },
)
})
const extensionAccounts$ = state((name: string) =>
extension$(name).pipe(
mergeMap((x) => {
if (x.type !== ConnectStatus.Connected) return EMPTY
return new Observable<InjectedPolkadotAccount[]>((observer) =>
x.value.subscribe((accounts) => {
observer.next(accounts)
}),
)
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>()),
)
export const extensions$ = combineKeys(availableExtensions$, extension$)
// extensions$.subscribe()
export const accountsByExntesion$ = combineKeys(
availableExtensions$,
extensionAccounts$,
export 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,
)
// accountsByExntesion$.subscribe()
@@ -1,3 +1,9 @@
import {
accountDetail$,
accounts$,
getAccountMapKey,
getPublicKey,
} from "@/accounts.state"
import { PolkadotIdenticon } from "@/components/PolkadotIdenticon"
import { useSynchronizeInput } from "@/components/useSynchroniseInput"
import { EditAccountId, NOTIN } from "@codec-components"
@@ -15,12 +21,6 @@ import {
import { useStateObservable } from "@react-rxjs/core"
import { FC, useState } from "react"
import { twMerge } from "tailwind-merge"
import {
accountDetail$,
accounts$,
getAccountMapKey,
getPublicKey,
} from "../common/accounts.state"
export const CAccountId: EditAccountId = ({ value, onValueChanged }) => {
const [localInput, setLocalInput] = useSynchronizeInput(
@@ -1,85 +0,0 @@
import { Binary, getSs58AddressInfo } from "@polkadot-api/substrate-bindings"
import { state } from "@react-rxjs/core"
import { combineKeys } from "@react-rxjs/utils"
import {
connectInjectedExtension,
getInjectedExtensions,
InjectedPolkadotAccount,
} from "polkadot-api/pjs-signer"
import {
concat,
filter,
finalize,
from,
fromEventPattern,
interval,
map,
retry,
startWith,
switchMap,
take,
timer,
} from "rxjs"
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
}
const extensions$ = state(
concat(
timer(0, 100).pipe(
map(getInjectedExtensions),
filter((v) => v.length > 0),
take(1),
),
interval(2000).pipe(map(getInjectedExtensions)),
),
[],
)
const extensionAccounts$ = combineKeys(extensions$, (extension) =>
from(connectInjectedExtension(extension)).pipe(
switchMap((extension) =>
fromEventPattern<InjectedPolkadotAccount[]>(
extension.subscribe,
(_, fn) => fn(),
).pipe(
startWith(extension.getAccounts()),
finalize(extension.disconnect),
),
),
retry({
delay: 100,
}),
),
)
export const accounts$ = state(
extensionAccounts$.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,
)
+1 -4
View File
@@ -1,7 +1,4 @@
import {
accountDetail$,
getPublicKey,
} from "@/codec-components/common/accounts.state"
import { accountDetail$, getPublicKey } from "@/accounts.state"
import { PolkadotIdenticon } from "@/components/PolkadotIdenticon"
import { useStateObservable } from "@react-rxjs/core"
import { SS58String } from "polkadot-api"
+1
View File
@@ -74,6 +74,7 @@ const SelectContent = React.forwardRef<
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
"bg-black",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
+2 -1
View File
@@ -28,7 +28,8 @@ export const TabsTrigger: React.FC<DivProps & { active?: boolean }> = ({
{...props}
className={cn(
"cursor-pointer inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
active ? "bg-background text-foreground shadow-sm" : "",
"border border-polkadot-500",
active ? "bg-polkadot-500 text-foreground shadow-sm" : "",
className,
)}
>
@@ -1,14 +1,8 @@
import {
InjectedExtension,
InjectedPolkadotAccount,
} from "polkadot-api/pjs-signer"
import React, {
PropsWithChildren,
useEffect,
useState,
useSyncExternalStore,
} from "react"
import { useSelectedExtensions } from "./extensionCtx"
accountsByExtension$,
extensionAccounts$,
selectedExtensions$,
} from "@/accounts.state"
import {
Select,
SelectContent,
@@ -18,15 +12,16 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { SelectedAccountCtx } from "./accountCtx"
import { state, useStateObservable } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
import { InjectedExtension } from "polkadot-api/pjs-signer"
import React from "react"
import { combineLatest, distinctUntilChanged, map, merge, of, tap } from "rxjs"
export const Accounts: React.FC<{ extension: InjectedExtension }> = ({
const Accounts: React.FC<{ extension: InjectedExtension }> = ({
extension,
}) => {
const accounts = useSyncExternalStore(
extension.subscribe,
extension.getAccounts,
)
const accounts = useStateObservable(extensionAccounts$(extension.name))
return (
<SelectGroup>
@@ -43,58 +38,51 @@ export const Accounts: React.FC<{ extension: InjectedExtension }> = ({
)
}
const SignerCtx: React.FC<PropsWithChildren<{ account: string | null }>> = ({
account,
children,
}) => {
const extensions = useSelectedExtensions()
const [injectedPolkadotAccount, setInjectedPolkadotAccount] =
useState<InjectedPolkadotAccount | null>(null)
const [valueSelected$, selectValue] = createSignal<string>()
useEffect(() => {
if (!account) {
setInjectedPolkadotAccount(null)
return
}
const LS_KEY = "selected-signer"
const selectedValue$ = state(
merge(
of(localStorage.getItem(LS_KEY)),
valueSelected$.pipe(tap((v) => localStorage.setItem(LS_KEY, v))),
),
null,
)
const separator = account.indexOf("-")
const address = account.slice(0, separator)
const extensionName = account.slice(separator + 1)
export const selectedAccount$ = state(
combineLatest([selectedValue$, accountsByExtension$]).pipe(
map(([selectedAccount, accountsByExtension]) => {
if (!selectedAccount) return null
const [address, ...rest] = selectedAccount.split("-")
const signer = rest.join("-")
setInjectedPolkadotAccount(
extensions
.find((x) => x.name === extensionName)
?.getAccounts()
.find((account) => account.address === address) ?? null,
)
}, [extensions, account])
const accounts = accountsByExtension.get(signer)
if (!accounts) return null
return accounts.find((account) => account.address === address) ?? null
}),
distinctUntilChanged(),
),
null,
)
export const AccountProvider: React.FC = () => {
const value = useStateObservable(selectedValue$)
const extensions = useStateObservable(selectedExtensions$)
const activeExtensions = [...extensions.values()].filter((v) => !!v)
if (!activeExtensions.length) return null
return (
injectedPolkadotAccount && (
<SelectedAccountCtx.Provider value={injectedPolkadotAccount}>
{children}
</SelectedAccountCtx.Provider>
)
)
}
export const AccountProvider: React.FC<PropsWithChildren> = ({ children }) => {
const [selectedAccount, setSelectedAcount] = useState<string | null>(null)
const extensions = useSelectedExtensions()
return (
<>
<Select onValueChange={setSelectedAcount}>
<SelectTrigger>
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{extensions.map((extension) => (
<Accounts key={extension.name} {...{ extension }} />
))}
</SelectContent>
</Select>
<SignerCtx account={selectedAccount}>{children}</SignerCtx>
</>
<Select value={value ?? ""} onValueChange={selectValue}>
<SelectTrigger>
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{activeExtensions.map((extension) => (
<Accounts key={extension.name} extension={extension} />
))}
</SelectContent>
</Select>
)
}
@@ -1,85 +1,27 @@
import {
InjectedExtension,
connectInjectedExtension,
} from "polkadot-api/pjs-signer"
import { PropsWithChildren, useSyncExternalStore } from "react"
import { extensionCtx, useAvailableExtensions } from "./extensionCtx"
import { TabsList, TabsTrigger } from "@/components/ui/tabs"
availableExtensions$,
onToggleExtension,
selectedExtensions$,
} from "@/accounts.state"
import { Label } from "@/components/ui/label"
import { TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useStateObservable } from "@react-rxjs/core"
const getExtensionsStore = () => {
let connectedExtensions = new Map<string, InjectedExtension>()
const getSnapshot = () => connectedExtensions
const listeners = new Set<() => void>()
const update = () => {
connectedExtensions = new Map(connectedExtensions)
listeners.forEach((cb) => {
cb()
})
}
const subscribe = (cb: () => void) => {
listeners.add(cb)
return () => {
listeners.delete(cb)
}
}
let isRunning = false
const onToggleExtension = (name: string) => {
if (isRunning) return
if (connectedExtensions.has(name)) {
connectedExtensions.delete(name)
return update()
}
isRunning = true
connectInjectedExtension(name)
.then(
(extension) => {
connectedExtensions.set(name, extension)
update()
},
() => {},
)
.finally(() => {
isRunning = false
})
}
return {
subscribe,
getSnapshot,
onToggleExtension,
}
}
const extensionsStore = getExtensionsStore()
extensionsStore.subscribe(Function.prototype as any)
export const ExtensionProvider: React.FC<PropsWithChildren> = ({
children,
}) => {
const availableExtensions = useAvailableExtensions()
const selectedExtensions = useSyncExternalStore(
extensionsStore.subscribe,
extensionsStore.getSnapshot,
)
export const ExtensionProvider: React.FC = () => {
const availableExtensions = useStateObservable(availableExtensions$)
const selectedExtensions = useStateObservable(selectedExtensions$)
if (availableExtensions.length === 0)
return <div>No extension provider detected</div>
return (
<>
<Label>Click on the extension name to (dis)connect it:</Label>
<Label>Click on the extension name to toggle it:</Label>
<TabsList>
{availableExtensions.map((extensionName) => (
<TabsTrigger
className="mx-1"
onClick={() => {
extensionsStore.onToggleExtension(extensionName)
}}
onClick={() => onToggleExtension(extensionName)}
active={selectedExtensions.has(extensionName)}
key={extensionName}
>
@@ -87,9 +29,6 @@ export const ExtensionProvider: React.FC<PropsWithChildren> = ({
</TabsTrigger>
))}
</TabsList>
<extensionCtx.Provider value={[...selectedExtensions.values()]}>
{selectedExtensions.size ? children : null}
</extensionCtx.Provider>
</>
)
}
+15 -12
View File
@@ -1,3 +1,4 @@
import { unsafeApi$ } from "@/chain.state"
import { ActionButton } from "@/components/ActionButton"
import {
Dialog,
@@ -5,23 +6,25 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { onNexTx } from "@/pages/Transactions"
import { toHex } from "@polkadot-api/utils"
import { useStateObservable } from "@react-rxjs/core"
import { Binary, HexString } from "polkadot-api"
import React, { createContext, useContext, useState } from "react"
import { ExtensionProvider } from "./ExtensionProvider"
import { AccountProvider } from "./AccountProvider"
import { useSelectedAccount } from "./accountCtx"
import { unsafeApi$ } from "@/chain.state"
import { noop } from "rxjs"
import { toHex } from "@polkadot-api/utils"
import { onNexTx } from "@/pages/Transactions"
import { AccountProvider, selectedAccount$ } from "./AccountProvider"
import { ExtensionProvider } from "./ExtensionProvider"
const CallDataCtx = createContext("")
const OnCloseCtx = createContext(noop)
const SignAndSubmit = () => {
const account = useSelectedAccount()
const account = useStateObservable(selectedAccount$)
const callData = useContext(CallDataCtx)
const close = useContext(OnCloseCtx)
if (!account) return null
return (
<ActionButton
onClick={() => {
@@ -51,11 +54,11 @@ const SignAndSubmit = () => {
const SelectAccountFromExtension = () => {
return (
<ExtensionProvider>
<AccountProvider>
<SignAndSubmit />
</AccountProvider>
</ExtensionProvider>
<>
<ExtensionProvider />
<AccountProvider />
<SignAndSubmit />
</>
)
}
@@ -1,7 +0,0 @@
import { type InjectedPolkadotAccount } from "polkadot-api/pjs-signer"
import { createContext, useContext } from "react"
export const SelectedAccountCtx = createContext<InjectedPolkadotAccount | null>(
null,
)
export const useSelectedAccount = () => useContext(SelectedAccountCtx)!
@@ -1,33 +0,0 @@
import {
InjectedExtension,
getInjectedExtensions,
} from "polkadot-api/pjs-signer"
import { createContext, useContext, useEffect, useMemo, useState } from "react"
const getJoinedInjectedExtensions = () => getInjectedExtensions().join(",")
export const useAvailableExtensions = (): string[] => {
const [extensions, setExtensions] = useState(getJoinedInjectedExtensions)
useEffect(() => {
let token: any
const updateExtensions = () => {
const jointedExtensions = getJoinedInjectedExtensions()
setExtensions(jointedExtensions)
token = setTimeout(updateExtensions, jointedExtensions ? 2_000 : 100)
}
updateExtensions()
return () => {
clearTimeout(token)
}
}, [])
return useMemo(
() => (extensions.length ? extensions.split(",") : []),
[extensions],
)
}
export const extensionCtx = createContext<InjectedExtension[]>([])
export const useSelectedExtensions = () => useContext(extensionCtx)