diff --git a/src/accounts.state.ts b/src/accounts.state.ts index 0d07509..1aff685 100644 --- a/src/accounts.state.ts +++ b/src/accounts.state.ts @@ -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() -export { onTogleExtension } +const [toggleExtension$, onToggleExtension] = createKeyedSignal() +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 => { - 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((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()), ) -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((observer) => + x.extension.subscribe((accounts) => { + observer.next(accounts) + }), + ) + }), + ), + [], +) + +export const accountsByExtension$ = state( + combineKeys(availableExtensions$, extensionAccounts$), + new Map(), +) + +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(), +) + +export const accountDetail$ = state( + (account: string) => + accounts$.pipe(map((v) => v.get(getAccountMapKey(account)) ?? null)), + null, ) -// accountsByExntesion$.subscribe() diff --git a/src/codec-components/EditCodec/CAccountId.tsx b/src/codec-components/EditCodec/CAccountId.tsx index 8d46a40..e5c7342 100644 --- a/src/codec-components/EditCodec/CAccountId.tsx +++ b/src/codec-components/EditCodec/CAccountId.tsx @@ -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( diff --git a/src/codec-components/common/accounts.state.ts b/src/codec-components/common/accounts.state.ts deleted file mode 100644 index 2fb795b..0000000 --- a/src/codec-components/common/accounts.state.ts +++ /dev/null @@ -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( - 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(), -) - -export const accountDetail$ = state( - (account: string) => - accounts$.pipe(map((v) => v.get(getAccountMapKey(account)) ?? null)), - null, -) diff --git a/src/components/AccountIdDisplay.tsx b/src/components/AccountIdDisplay.tsx index 5240748..cfff979 100644 --- a/src/components/AccountIdDisplay.tsx +++ b/src/components/AccountIdDisplay.tsx @@ -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" diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index e870a09..fffad2e 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -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, diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 05f63cc..fe61286 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -28,7 +28,8 @@ export const TabsTrigger: React.FC = ({ {...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, )} > diff --git a/src/pages/Extrinsics/SubmitTx/AccountProvider.tsx b/src/pages/Extrinsics/SubmitTx/AccountProvider.tsx index e600cc2..43ca5bf 100644 --- a/src/pages/Extrinsics/SubmitTx/AccountProvider.tsx +++ b/src/pages/Extrinsics/SubmitTx/AccountProvider.tsx @@ -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 ( @@ -43,58 +38,51 @@ export const Accounts: React.FC<{ extension: InjectedExtension }> = ({ ) } -const SignerCtx: React.FC> = ({ - account, - children, -}) => { - const extensions = useSelectedExtensions() - const [injectedPolkadotAccount, setInjectedPolkadotAccount] = - useState(null) +const [valueSelected$, selectValue] = createSignal() - 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 && ( - - {children} - - ) - ) -} - -export const AccountProvider: React.FC = ({ children }) => { - const [selectedAccount, setSelectedAcount] = useState(null) - const extensions = useSelectedExtensions() - - return ( - <> - - {children} - + ) } diff --git a/src/pages/Extrinsics/SubmitTx/ExtensionProvider.tsx b/src/pages/Extrinsics/SubmitTx/ExtensionProvider.tsx index 6a6a836..7ef359d 100644 --- a/src/pages/Extrinsics/SubmitTx/ExtensionProvider.tsx +++ b/src/pages/Extrinsics/SubmitTx/ExtensionProvider.tsx @@ -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() - 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 = ({ - 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
No extension provider detected
return ( <> - + {availableExtensions.map((extensionName) => ( { - extensionsStore.onToggleExtension(extensionName) - }} + onClick={() => onToggleExtension(extensionName)} active={selectedExtensions.has(extensionName)} key={extensionName} > @@ -87,9 +29,6 @@ export const ExtensionProvider: React.FC = ({ ))} - - {selectedExtensions.size ? children : null} - ) } diff --git a/src/pages/Extrinsics/SubmitTx/SubmitTx.tsx b/src/pages/Extrinsics/SubmitTx/SubmitTx.tsx index 9648d86..81501fe 100644 --- a/src/pages/Extrinsics/SubmitTx/SubmitTx.tsx +++ b/src/pages/Extrinsics/SubmitTx/SubmitTx.tsx @@ -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 ( { @@ -51,11 +54,11 @@ const SignAndSubmit = () => { const SelectAccountFromExtension = () => { return ( - - - - - + <> + + + + ) } diff --git a/src/pages/Extrinsics/SubmitTx/accountCtx.tsx b/src/pages/Extrinsics/SubmitTx/accountCtx.tsx deleted file mode 100644 index 015ef5a..0000000 --- a/src/pages/Extrinsics/SubmitTx/accountCtx.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { type InjectedPolkadotAccount } from "polkadot-api/pjs-signer" -import { createContext, useContext } from "react" - -export const SelectedAccountCtx = createContext( - null, -) -export const useSelectedAccount = () => useContext(SelectedAccountCtx)! diff --git a/src/pages/Extrinsics/SubmitTx/extensionCtx.tsx b/src/pages/Extrinsics/SubmitTx/extensionCtx.tsx deleted file mode 100644 index 6d49e37..0000000 --- a/src/pages/Extrinsics/SubmitTx/extensionCtx.tsx +++ /dev/null @@ -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([]) -export const useSelectedExtensions = () => useContext(extensionCtx)