diff --git a/.changeset/wicked-bananas-check.md b/.changeset/wicked-bananas-check.md new file mode 100644 index 00000000..60d37d28 --- /dev/null +++ b/.changeset/wicked-bananas-check.md @@ -0,0 +1,5 @@ +--- +'@wallet-standard/react-core': major +--- + +Replaced the feature context providers with two hooks: `useConnect()` and `useDisconnect()` diff --git a/packages/react/core/README.md b/packages/react/core/README.md index b447e8a0..7a04962c 100644 --- a/packages/react/core/README.md +++ b/packages/react/core/README.md @@ -7,3 +7,45 @@ This package provides React hooks for using Wallet Standard wallets, accounts, a ### `useWallets()` Vends an array of `UiWallet` objects; one for every registered Wallet Standard `Wallet`. + +### `useConnect(uiWallet)` + +Given a `UiWallet`, this hook returns a function you can call to ask the wallet to authorize the current domain to use new accounts. Calling the returned method will return a promise for the array of account authorized by the wallet. + +```tsx +function ConnectButton({ onAccountsConnected, wallet }) { + const [isConnecting, connect] = useConnect(wallet); + return ( + + ); +} +``` + +Be aware that calls to `connect` operate on a single promise that is global to the entire app. If you call `connect` multiple times or from multiple places in your app, the exact same promise will be vended to all callers. This reflects the fact that a wallet can only field one authorization request at a time. + +### `useDisconnect(uiWallet)` + +Given a `UiWallet`, this hook returns a function you can call to ask the wallet to deauthorize accounts authorized for the current domain, or at least to remove them from the wallet's `accounts` array for the time being, depending on the wallet's implementation of `standard:disconnect`. Call the returned method will return a promise that resolves once the disconnection is complete. + +```tsx +function DisconnectButton({ onDisconnected, wallet }) { + const [isDisconnecting, disconnect] = useDisconnect(wallet); + return ( + + ); +} +``` diff --git a/packages/react/core/src/WalletStandardProvider.tsx b/packages/react/core/src/WalletStandardProvider.tsx index e0c5eaf3..22a0d5eb 100644 --- a/packages/react/core/src/WalletStandardProvider.tsx +++ b/packages/react/core/src/WalletStandardProvider.tsx @@ -1,12 +1,5 @@ import type { FC, ReactNode } from 'react'; import React from 'react'; -import { - ConnectProvider, - DisconnectProvider, - SignAndSendTransactionProvider, - SignMessageProvider, - SignTransactionProvider, -} from './features/index.js'; import { WalletAccountProvider } from './WalletAccountProvider.js'; import { WalletProvider } from './WalletProvider.js'; @@ -17,20 +10,10 @@ export interface WalletStandardProviderProps { } /** TODO: docs */ -export const WalletStandardProvider: FC = ({ children, onError }) => { +export const WalletStandardProvider: FC = ({ children }) => { return ( - - - - - - {children} - - - - - + {children} ); }; diff --git a/packages/react/core/src/__tests__/useConnect-test.ts b/packages/react/core/src/__tests__/useConnect-test.ts new file mode 100644 index 00000000..0b279e7e --- /dev/null +++ b/packages/react/core/src/__tests__/useConnect-test.ts @@ -0,0 +1,130 @@ +import type { Wallet, WalletVersion } from '@wallet-standard/base'; +import { StandardConnect } from '@wallet-standard/features'; +import type { UiWallet } from '@wallet-standard/ui'; +import { getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED } from '@wallet-standard/ui-registry'; + +import { useConnect } from '../features/useConnect.js'; +import { renderHook } from '../test-renderer.js'; + +jest.mock('@wallet-standard/ui-registry'); + +describe('useConnect', () => { + let mockConnect: jest.Mock; + let mockUiWallet: UiWallet; + beforeEach(() => { + mockConnect = jest.fn().mockResolvedValue({ accounts: [] }); + const mockWallet: Wallet = { + accounts: [], + chains: [], + features: { + [StandardConnect]: { + connect: mockConnect, + }, + }, + icon: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAIBAAA=', + name: 'Mock Wallet', + version: '1.0.0' as WalletVersion, + }; + jest.mocked(getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED).mockReturnValue(mockWallet); + // Suppresses console output when an `ErrorBoundary` is hit. + // See https://stackoverflow.com/a/72632884/802047 + jest.spyOn(console, 'error').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + }); + describe('the `isConnecting` property', () => { + it('is `false` before calling `connect()`', () => { + const { result } = renderHook(() => useConnect(mockUiWallet)); + if (result.__type === 'error' || !result.current) { + throw result.current; + } + const [isConnecting, _] = result.current; + expect(isConnecting).toBe(false); + }); + it('is `false` after the connection resolves', async () => { + expect.assertions(1); + const { result } = renderHook(() => useConnect(mockUiWallet)); + if (result.__type === 'error' || !result.current) { + throw result.current; + } + const [_A, connect] = result.current; + await connect(); + const [isConnecting, _B] = result.current; + expect(isConnecting).toBe(false); + }); + it('is `true` after calling `connect()`', () => { + const { result } = renderHook(() => useConnect(mockUiWallet)); + if (result.__type === 'error' || !result.current) { + throw result.current; + } + const [_, connect] = result.current; + connect(); + const [isConnecting] = result.current; + expect(isConnecting).toBe(true); + }); + it('is `true` on hooks that did not trigger the connect', () => { + const { result: resultA } = renderHook(() => useConnect(mockUiWallet)); + const { result: resultB } = renderHook(() => useConnect(mockUiWallet)); + if (resultA.__type === 'error' || !resultA.current) { + throw resultA.current; + } + if (resultB.__type === 'error' || !resultB.current) { + throw resultB.current; + } + const [_, connectA] = resultA.current; + connectA(); + const [isConnectingB] = resultB.current; + expect(isConnectingB).toBe(true); + }); + }); + describe('the `connect` property', () => { + it("calls the wallet's connect implementation when called ", () => { + const { result } = renderHook(() => useConnect(mockUiWallet)); + if (result.__type === 'error' || !result.current) { + throw result.current; + } else { + const [_, connect] = result.current; + connect(); + expect(mockConnect).toHaveBeenCalled(); + } + }); + it("calls the wallet's connect implementation once despite multiple calls", () => { + const { result } = renderHook(() => useConnect(mockUiWallet)); + if (result.__type === 'error' || !result.current) { + throw result.current; + } else { + const [_, connect] = result.current; + connect(); + connect(); + expect(mockConnect).toHaveBeenCalledTimes(1); + } + }); + it("calls the wallet's connect implementation once despite calls from different hooks", () => { + const { result: resultA } = renderHook(() => useConnect(mockUiWallet)); + const { result: resultB } = renderHook(() => useConnect(mockUiWallet)); + if (resultA.__type === 'error' || !resultA.current) { + throw resultA.current; + } else if (resultB.__type === 'error' || !resultB.current) { + throw resultB.current; + } else { + const [_A, connectA] = resultA.current; + const [_B, connectB] = resultB.current; + connectA(); + connectB(); + expect(mockConnect).toHaveBeenCalledTimes(1); + } + }); + it("calls the wallet's connect implementation anew after the first connection resolves", async () => { + expect.assertions(1); + const { result } = renderHook(() => useConnect(mockUiWallet)); + if (result.__type === 'error' || !result.current) { + throw result.current; + } else { + const [_, connect] = result.current; + connect(); + await jest.runAllTimersAsync(); + connect(); + expect(mockConnect).toHaveBeenCalledTimes(2); + } + }); + }); +}); diff --git a/packages/react/core/src/__tests__/useDisconnect-test.ts b/packages/react/core/src/__tests__/useDisconnect-test.ts new file mode 100644 index 00000000..fddb072d --- /dev/null +++ b/packages/react/core/src/__tests__/useDisconnect-test.ts @@ -0,0 +1,130 @@ +import type { Wallet, WalletVersion } from '@wallet-standard/base'; +import { StandardDisconnect } from '@wallet-standard/features'; +import type { UiWallet } from '@wallet-standard/ui'; +import { getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED } from '@wallet-standard/ui-registry'; + +import { useDisconnect } from '../features/useDisconnect.js'; +import { renderHook } from '../test-renderer.js'; + +jest.mock('@wallet-standard/ui-registry'); + +describe('useDisconnect', () => { + let mockDisconnect: jest.Mock; + let mockUiWallet: UiWallet; + beforeEach(() => { + mockDisconnect = jest.fn().mockResolvedValue({ accounts: [] }); + const mockWallet: Wallet = { + accounts: [], + chains: [], + features: { + [StandardDisconnect]: { + disconnect: mockDisconnect, + }, + }, + icon: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAIBAAA=', + name: 'Mock Wallet', + version: '1.0.0' as WalletVersion, + }; + jest.mocked(getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED).mockReturnValue(mockWallet); + // Suppresses console output when an `ErrorBoundary` is hit. + // See https://stackoverflow.com/a/72632884/802047 + jest.spyOn(console, 'error').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + }); + describe('the `isDisconnecting` property', () => { + it('is `false` before calling `disconnect()`', () => { + const { result } = renderHook(() => useDisconnect(mockUiWallet)); + if (result.__type === 'error' || !result.current) { + throw result.current; + } + const [isDisconnecting, _] = result.current; + expect(isDisconnecting).toBe(false); + }); + it('is `false` after the disconnection resolves', async () => { + expect.assertions(1); + const { result } = renderHook(() => useDisconnect(mockUiWallet)); + if (result.__type === 'error' || !result.current) { + throw result.current; + } + const [_A, disconnectA] = result.current; + await disconnectA(); + const [isDisconnecting, _B] = result.current; + expect(isDisconnecting).toBe(false); + }); + it('is `true` after calling `disconnect()`', () => { + const { result } = renderHook(() => useDisconnect(mockUiWallet)); + if (result.__type === 'error' || !result.current) { + throw result.current; + } + const [_, disconnect] = result.current; + disconnect(); + const [isDisconnecting] = result.current; + expect(isDisconnecting).toBe(true); + }); + it('is `true` on hooks that did not trigger the disconnect', () => { + const { result: resultA } = renderHook(() => useDisconnect(mockUiWallet)); + const { result: resultB } = renderHook(() => useDisconnect(mockUiWallet)); + if (resultA.__type === 'error' || !resultA.current) { + throw resultA.current; + } + if (resultB.__type === 'error' || !resultB.current) { + throw resultB.current; + } + const [_, disconnectA] = resultA.current; + disconnectA(); + const [isDisconnectingB] = resultB.current; + expect(isDisconnectingB).toBe(true); + }); + }); + describe('the `disconnect` property', () => { + it("calls the wallet's disconnect implementation when called ", () => { + const { result } = renderHook(() => useDisconnect(mockUiWallet)); + if (result.__type === 'error' || !result.current) { + throw result.current; + } else { + const [_, disconnect] = result.current; + disconnect(); + expect(mockDisconnect).toHaveBeenCalled(); + } + }); + it("calls the wallet's disconnect implementation once despite multiple calls", () => { + const { result } = renderHook(() => useDisconnect(mockUiWallet)); + if (result.__type === 'error' || !result.current) { + throw result.current; + } else { + const [_, disconnect] = result.current; + disconnect(); + disconnect(); + expect(mockDisconnect).toHaveBeenCalledTimes(1); + } + }); + it("calls the wallet's disconnect implementation once despite calls from different hooks", () => { + const { result: resultA } = renderHook(() => useDisconnect(mockUiWallet)); + const { result: resultB } = renderHook(() => useDisconnect(mockUiWallet)); + if (resultA.__type === 'error' || !resultA.current) { + throw resultA.current; + } else if (resultB.__type === 'error' || !resultB.current) { + throw resultB.current; + } else { + const [_A, disconnectA] = resultA.current; + const [_B, disconnectB] = resultB.current; + disconnectA(); + disconnectB(); + expect(mockDisconnect).toHaveBeenCalledTimes(1); + } + }); + it("calls the wallet's disconnect implementation anew after the first disconnection resolves", async () => { + expect.assertions(1); + const { result } = renderHook(() => useDisconnect(mockUiWallet)); + if (result.__type === 'error' || !result.current) { + throw result.current; + } else { + const [_, disconnect] = result.current; + disconnect(); + await jest.runAllTimersAsync(); + disconnect(); + expect(mockDisconnect).toHaveBeenCalledTimes(2); + } + }); + }); +}); diff --git a/packages/react/core/src/__tests__/useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT-test.ts b/packages/react/core/src/__tests__/useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT-test.ts index db27c4fb..84e8732e 100644 --- a/packages/react/core/src/__tests__/useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT-test.ts +++ b/packages/react/core/src/__tests__/useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT-test.ts @@ -21,8 +21,6 @@ describe('useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT', () => { on: mockOn, register: mockRegister, }); - // Suppresses console output when an `ErrorBoundary` is hit. - // See https://stackoverflow.com/a/72632884/802047 }); it('returns a list of registered wallets', () => { const expectedWallets = [] as readonly Wallet[]; diff --git a/packages/react/core/src/features/connect/ConnectProvider.tsx b/packages/react/core/src/features/connect/ConnectProvider.tsx deleted file mode 100644 index a11d9da3..00000000 --- a/packages/react/core/src/features/connect/ConnectProvider.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import type { Wallet } from '@wallet-standard/base'; -import type { ConnectFeature, ConnectMethod } from '@wallet-standard/features'; -import type { FC, ReactNode } from 'react'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { useWallet } from '../../useWallet.js'; -import { ConnectContext } from './useConnect.js'; - -/** TODO: docs */ -export interface ConnectProviderProps { - children: NonNullable; - onError?: (error: Error) => void; -} - -/** TODO: docs */ -export function hasConnectFeature(features: Wallet['features']): features is ConnectFeature { - return 'standard:connect' in features; -} - -/** TODO: docs */ -export const ConnectProvider: FC = ({ children, onError }) => { - const { features } = useWallet(); - - // Handle errors, logging them by default. - const handleError = useCallback( - (error: Error) => { - (onError || console.error)(error); - return error; - }, - [onError] - ); - - // Connect to the wallet. - const [waiting, setWaiting] = useState(false); - const promise = useRef>(); - const connect = useMemo( - () => - hasConnectFeature(features) - ? async (input) => { - // FIXME: if called first with silent=true, promise.current will be set but waiting will be false - - // If already waiting, wait for that promise to resolve. - if (promise.current) { - try { - await promise.current; - } catch (error: any) { - // Error will already have been handled below. - } - } - - const { silent } = input || {}; - if (!silent) { - setWaiting(true); - } - try { - promise.current = features['standard:connect'].connect(input); - return await promise.current; - } catch (error: any) { - throw handleError(error); - } finally { - if (!silent) { - setWaiting(false); - } - promise.current = undefined; - } - } - : undefined, - [features, promise, handleError] - ); - - return ( - - {children} - - ); -}; diff --git a/packages/react/core/src/features/connect/index.ts b/packages/react/core/src/features/connect/index.ts deleted file mode 100644 index 8148b972..00000000 --- a/packages/react/core/src/features/connect/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './ConnectProvider.js'; -export * from './useConnect.js'; diff --git a/packages/react/core/src/features/connect/useConnect.ts b/packages/react/core/src/features/connect/useConnect.ts deleted file mode 100644 index 0809ceb5..00000000 --- a/packages/react/core/src/features/connect/useConnect.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { ConnectMethod } from '@wallet-standard/features'; -import { createContext, useContext } from 'react'; -import { createDefaultContext } from '../../context.js'; - -/** TODO: docs */ -export interface ConnectContextState { - waiting: boolean; - connect: ConnectMethod | undefined; -} - -const DEFAULT_CONNECT_STATE: Readonly = { - waiting: false, - connect: undefined, -} as const; - -const DEFAULT_CONNECT_CONTEXT = createDefaultContext('Connect', DEFAULT_CONNECT_STATE); - -/** TODO: docs */ -export const ConnectContext = createContext(DEFAULT_CONNECT_CONTEXT); - -/** TODO: docs */ -export function useConnect(): ConnectContextState { - return useContext(ConnectContext); -} diff --git a/packages/react/core/src/features/disconnect/DisconnectProvider.tsx b/packages/react/core/src/features/disconnect/DisconnectProvider.tsx deleted file mode 100644 index d5ea9c0c..00000000 --- a/packages/react/core/src/features/disconnect/DisconnectProvider.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import type { Wallet } from '@wallet-standard/base'; -import type { DisconnectFeature, DisconnectMethod } from '@wallet-standard/features'; -import type { FC, ReactNode } from 'react'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { useWallet } from '../../useWallet.js'; -import { DisconnectContext } from './useDisconnect.js'; - -/** TODO: docs */ -export interface DisconnectProviderProps { - children: NonNullable; - onError?: (error: Error) => void; -} - -/** TODO: docs */ -export function hasDisconnectFeature(features: Wallet['features']): features is DisconnectFeature { - return 'standard:disconnect' in features; -} - -/** TODO: docs */ -export const DisconnectProvider: FC = ({ children, onError }) => { - const { features } = useWallet(); - - // Handle errors, logging them by default. - const handleError = useCallback( - (error: Error) => { - (onError || console.error)(error); - return error; - }, - [onError] - ); - - // Disconnect from the wallet. - const [waiting, setWaiting] = useState(false); - const promise = useRef>(); - const disconnect = useMemo( - () => - hasDisconnectFeature(features) - ? async () => { - // If already waiting, wait for that promise to resolve. - if (promise.current) { - try { - await promise.current; - } catch (error: any) { - // Error will already have been handled below. - } - } - - setWaiting(true); - try { - promise.current = features['standard:disconnect'].disconnect(); - return await promise.current; - } catch (error: any) { - throw handleError(error); - } finally { - setWaiting(false); - promise.current = undefined; - } - } - : undefined, - [features, promise, handleError] - ); - - return ( - - {children} - - ); -}; diff --git a/packages/react/core/src/features/disconnect/index.ts b/packages/react/core/src/features/disconnect/index.ts deleted file mode 100644 index 3b52d541..00000000 --- a/packages/react/core/src/features/disconnect/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './DisconnectProvider.js'; -export * from './useDisconnect.js'; diff --git a/packages/react/core/src/features/disconnect/useDisconnect.ts b/packages/react/core/src/features/disconnect/useDisconnect.ts deleted file mode 100644 index e5f9bec5..00000000 --- a/packages/react/core/src/features/disconnect/useDisconnect.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { DisconnectMethod } from '@wallet-standard/features'; -import { createContext, useContext } from 'react'; -import { createDefaultContext } from '../../context.js'; - -/** TODO: docs */ -export interface DisconnectContextState { - waiting: boolean; - disconnect: DisconnectMethod | undefined; -} - -const DEFAULT_DISCONNECT_STATE: Readonly = { - waiting: false, - disconnect: undefined, -} as const; - -const DEFAULT_DISCONNECT_CONTEXT = createDefaultContext('Disconnect', DEFAULT_DISCONNECT_STATE); - -/** TODO: docs */ -export const DisconnectContext = createContext(DEFAULT_DISCONNECT_CONTEXT); - -/** TODO: docs */ -export function useDisconnect(): DisconnectContextState { - return useContext(DisconnectContext); -} diff --git a/packages/react/core/src/features/events.ts b/packages/react/core/src/features/events.ts new file mode 100644 index 00000000..4b33dbec --- /dev/null +++ b/packages/react/core/src/features/events.ts @@ -0,0 +1,7 @@ +import type { Wallet, WalletWithFeatures } from '@wallet-standard/base'; +import type { StandardEventsFeature } from '@wallet-standard/features'; +import { StandardEvents } from '@wallet-standard/features'; + +export function walletHasStandardEventsFeature(wallet: Wallet): wallet is WalletWithFeatures { + return StandardEvents in wallet.features; +} diff --git a/packages/react/core/src/features/index.ts b/packages/react/core/src/features/index.ts index 42c05024..69b7b52b 100644 --- a/packages/react/core/src/features/index.ts +++ b/packages/react/core/src/features/index.ts @@ -1,5 +1,2 @@ -export * from './connect/index.js'; -export * from './disconnect/index.js'; -export * from './signAndSendTransaction/index.js'; -export * from './signMessage/index.js'; -export * from './signTransaction/index.js'; +export * from './useConnect.js'; +export * from './useDisconnect.js'; diff --git a/packages/react/core/src/features/signAndSendTransaction/SignAndSendTransactionProvider.tsx b/packages/react/core/src/features/signAndSendTransaction/SignAndSendTransactionProvider.tsx deleted file mode 100644 index 5db0fc47..00000000 --- a/packages/react/core/src/features/signAndSendTransaction/SignAndSendTransactionProvider.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import type { Wallet } from '@wallet-standard/base'; -import type { - SignAndSendTransactionFeature, - SignAndSendTransactionMethod, -} from '@wallet-standard/experimental-features'; -import type { FC, ReactNode } from 'react'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { useWallet } from '../../useWallet.js'; -import { SignAndSendTransactionContext } from './useSignAndSendTransaction.js'; - -/** TODO: docs */ -export interface SignAndSendTransactionProviderProps { - children: NonNullable; - onError?: (error: Error) => void; -} - -/** TODO: docs */ -export function hasSignAndSendTransactionFeature( - features: Wallet['features'] -): features is SignAndSendTransactionFeature { - return 'experimental:signAndSendTransaction' in features; -} - -/** TODO: docs */ -export const SignAndSendTransactionProvider: FC = ({ children, onError }) => { - const { features } = useWallet(); - - // Handle errors, logging them by default. - const handleError = useCallback( - (error: Error) => { - (onError || console.error)(error); - return error; - }, - [onError] - ); - - // Sign transactions with the wallet. - const [waiting, setWaiting] = useState(false); - const promise = useRef>(); - const signAndSendTransaction = useMemo( - () => - hasSignAndSendTransactionFeature(features) - ? async (...inputs) => { - // If already waiting, wait for that promise to resolve. - if (promise.current) { - try { - await promise.current; - } catch (error: any) { - // Error will already have been handled below. - } - } - - setWaiting(true); - try { - promise.current = features['experimental:signAndSendTransaction'].signAndSendTransaction( - ...inputs - ); - return await promise.current; - } catch (error: any) { - throw handleError(error); - } finally { - setWaiting(false); - promise.current = undefined; - } - } - : undefined, - [features, promise, handleError] - ); - - return ( - - {children} - - ); -}; diff --git a/packages/react/core/src/features/signAndSendTransaction/index.ts b/packages/react/core/src/features/signAndSendTransaction/index.ts deleted file mode 100644 index f1ad145a..00000000 --- a/packages/react/core/src/features/signAndSendTransaction/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './SignAndSendTransactionProvider.js'; -export * from './useSignAndSendTransaction.js'; diff --git a/packages/react/core/src/features/signAndSendTransaction/useSignAndSendTransaction.ts b/packages/react/core/src/features/signAndSendTransaction/useSignAndSendTransaction.ts deleted file mode 100644 index 26510940..00000000 --- a/packages/react/core/src/features/signAndSendTransaction/useSignAndSendTransaction.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { SignAndSendTransactionMethod } from '@wallet-standard/experimental-features'; -import { createContext, useContext } from 'react'; -import { createDefaultContext } from '../../context.js'; - -/** TODO: docs */ -export interface SignAndSendTransactionContextState { - waiting: boolean; - signAndSendTransaction: SignAndSendTransactionMethod | undefined; -} - -const DEFAULT_SIGN_AND_SEND_TRANSACTION_STATE: Readonly = { - waiting: false, - signAndSendTransaction: undefined, -} as const; - -const DEFAULT_SIGN_AND_SEND_TRANSACTION_CONTEXT = createDefaultContext( - 'SignAndSendTransaction', - DEFAULT_SIGN_AND_SEND_TRANSACTION_STATE -); - -/** TODO: docs */ -export const SignAndSendTransactionContext = createContext(DEFAULT_SIGN_AND_SEND_TRANSACTION_CONTEXT); - -/** TODO: docs */ -export function useSignAndSendTransaction(): SignAndSendTransactionContextState { - return useContext(SignAndSendTransactionContext); -} diff --git a/packages/react/core/src/features/signMessage/SignMessageProvider.tsx b/packages/react/core/src/features/signMessage/SignMessageProvider.tsx deleted file mode 100644 index acae7b53..00000000 --- a/packages/react/core/src/features/signMessage/SignMessageProvider.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import type { Wallet } from '@wallet-standard/base'; -import type { SignMessageFeature, SignMessageMethod } from '@wallet-standard/experimental-features'; -import type { FC, ReactNode } from 'react'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { useWallet } from '../../useWallet.js'; -import { SignMessageContext } from './useSignMessage.js'; - -/** TODO: docs */ -export interface SignMessageProviderProps { - children: NonNullable; - onError?: (error: Error) => void; -} - -/** TODO: docs */ -export function hasSignMessageFeature(features: Wallet['features']): features is SignMessageFeature { - return 'experimental:signMessage' in features; -} - -/** TODO: docs */ -export const SignMessageProvider: FC = ({ children, onError }) => { - const { features } = useWallet(); - - // Handle errors, logging them by default. - const handleError = useCallback( - (error: Error) => { - (onError || console.error)(error); - return error; - }, - [onError] - ); - - // Sign messages with the wallet. - const [waiting, setWaiting] = useState(false); - const promise = useRef>(); - const signMessage = useMemo( - () => - hasSignMessageFeature(features) - ? async (...inputs) => { - // If already waiting, wait for that promise to resolve. - if (promise.current) { - try { - await promise.current; - } catch (error: any) { - // Error will already have been handled below. - } - } - - setWaiting(true); - try { - promise.current = features['experimental:signMessage'].signMessage(...inputs); - return await promise.current; - } catch (error: any) { - throw handleError(error); - } finally { - setWaiting(false); - promise.current = undefined; - } - } - : undefined, - [features, promise, handleError] - ); - - return ( - - {children} - - ); -}; diff --git a/packages/react/core/src/features/signMessage/index.ts b/packages/react/core/src/features/signMessage/index.ts deleted file mode 100644 index 7996c450..00000000 --- a/packages/react/core/src/features/signMessage/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './SignMessageProvider.js'; -export * from './useSignMessage.js'; diff --git a/packages/react/core/src/features/signMessage/useSignMessage.ts b/packages/react/core/src/features/signMessage/useSignMessage.ts deleted file mode 100644 index 9a900781..00000000 --- a/packages/react/core/src/features/signMessage/useSignMessage.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { SignMessageMethod } from '@wallet-standard/experimental-features'; -import { createContext, useContext } from 'react'; -import { createDefaultContext } from '../../context.js'; - -/** TODO: docs */ -export interface SignMessageContextState { - waiting: boolean; - signMessage: SignMessageMethod | undefined; -} - -const DEFAULT_SIGN_MESSAGE_STATE: Readonly = { - waiting: false, - signMessage: undefined, -} as const; - -const DEFAULT_SIGN_MESSAGE_CONTEXT = createDefaultContext('SignMessage', DEFAULT_SIGN_MESSAGE_STATE); - -/** TODO: docs */ -export const SignMessageContext = createContext(DEFAULT_SIGN_MESSAGE_CONTEXT); - -/** TODO: docs */ -export function useSignMessage(): SignMessageContextState { - return useContext(SignMessageContext); -} diff --git a/packages/react/core/src/features/signTransaction/SignTransactionProvider.tsx b/packages/react/core/src/features/signTransaction/SignTransactionProvider.tsx deleted file mode 100644 index 1d115119..00000000 --- a/packages/react/core/src/features/signTransaction/SignTransactionProvider.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import type { Wallet } from '@wallet-standard/base'; -import type { SignTransactionFeature, SignTransactionMethod } from '@wallet-standard/experimental-features'; -import type { FC, ReactNode } from 'react'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { useWallet } from '../../useWallet.js'; -import { SignTransactionContext } from './useSignTransaction.js'; - -/** TODO: docs */ -export interface SignTransactionProviderProps { - children: NonNullable; - onError?: (error: Error) => void; -} - -/** TODO: docs */ -export function hasSignTransactionFeature(features: Wallet['features']): features is SignTransactionFeature { - return 'experimental:signTransaction' in features; -} - -/** TODO: docs */ -export const SignTransactionProvider: FC = ({ children, onError }) => { - const { features } = useWallet(); - - // Handle errors, logging them by default. - const handleError = useCallback( - (error: Error) => { - (onError || console.error)(error); - return error; - }, - [onError] - ); - - // Sign transactions with the wallet. - const [waiting, setWaiting] = useState(false); - const promise = useRef>(); - const signTransaction = useMemo( - () => - hasSignTransactionFeature(features) - ? async (...inputs) => { - // If already waiting, wait for that promise to resolve. - if (promise.current) { - try { - await promise.current; - } catch (error: any) { - // Error will already have been handled below. - } - } - - setWaiting(true); - try { - promise.current = features['experimental:signTransaction'].signTransaction(...inputs); - return await promise.current; - } catch (error: any) { - throw handleError(error); - } finally { - setWaiting(false); - promise.current = undefined; - } - } - : undefined, - [features, promise, handleError] - ); - - return ( - - {children} - - ); -}; diff --git a/packages/react/core/src/features/signTransaction/index.ts b/packages/react/core/src/features/signTransaction/index.ts deleted file mode 100644 index afab8faa..00000000 --- a/packages/react/core/src/features/signTransaction/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './SignTransactionProvider.js'; -export * from './useSignTransaction.js'; diff --git a/packages/react/core/src/features/signTransaction/useSignTransaction.ts b/packages/react/core/src/features/signTransaction/useSignTransaction.ts deleted file mode 100644 index cb5d49c9..00000000 --- a/packages/react/core/src/features/signTransaction/useSignTransaction.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { SignTransactionMethod } from '@wallet-standard/experimental-features'; -import { createContext, useContext } from 'react'; -import { createDefaultContext } from '../../context.js'; - -/** TODO: docs */ -export interface SignTransactionContextState { - waiting: boolean; - signTransaction: SignTransactionMethod | undefined; -} - -const DEFAULT_SIGN_TRANSACTION_STATE: Readonly = { - waiting: false, - signTransaction: undefined, -} as const; - -const DEFAULT_SIGN_TRANSACTION_CONTEXT = createDefaultContext('SignTransaction', DEFAULT_SIGN_TRANSACTION_STATE); - -/** TODO: docs */ -export const SignTransactionContext = createContext(DEFAULT_SIGN_TRANSACTION_CONTEXT); - -/** TODO: docs */ -export function useSignTransaction(): SignTransactionContextState { - return useContext(SignTransactionContext); -} diff --git a/packages/react/core/src/features/useConnect.ts b/packages/react/core/src/features/useConnect.ts new file mode 100644 index 00000000..19add354 --- /dev/null +++ b/packages/react/core/src/features/useConnect.ts @@ -0,0 +1,66 @@ +import type { StandardConnectFeature, StandardConnectInput, StandardConnectMethod } from '@wallet-standard/features'; +import { StandardConnect } from '@wallet-standard/features'; +import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui'; +import { getWalletFeature } from '@wallet-standard/ui'; +import { + getOrCreateUiWalletAccountForStandardWalletAccount_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, + getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, +} from '@wallet-standard/ui-registry'; +import { useCallback } from 'react'; + +import { useWeakRef } from '../useWeakRef.js'; + +type TupleSplit = N extends T['length'] + ? [T, []] + : [T extends [...infer U, ...infer R] ? [...U, ...TupleSplit[0]] : never, TupleSplit[1]]; + +type SkipFirst = T extends [] ? [] : TupleSplit[1]; + +/** + * Returns a function you can call to ask the wallet to authorize the current domain to use new + * accounts. + */ +export function useConnect( + uiWallet: TWallet +): [ + isConnecting: boolean, + connect: ( + input?: Omit[0]>, 'silent'>, + ...rest: SkipFirst, 1> + ) => Promise, +] { + const connectFeature = getWalletFeature( + uiWallet, + StandardConnect + ) as StandardConnectFeature[typeof StandardConnect]; + const wallet = getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(uiWallet); + const connectionPromise = useWeakRef>(wallet); + const connect = useCallback( + async function connect( + input?: Omit[0]>, 'silent'>, + ...rest: SkipFirst, 1> + ) { + if (connectionPromise.current) { + return connectionPromise.current; + } + const newConnectionPromise = connectFeature + .connect(input, ...rest) + .then(({ accounts }) => { + return accounts.map( + getOrCreateUiWalletAccountForStandardWalletAccount_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.bind( + null, + wallet + ) + ) as readonly UiWalletAccount[]; + }) + .finally(() => { + connectionPromise.current = undefined; + }); + connectionPromise.current = newConnectionPromise; + return await newConnectionPromise; + }, + [connectFeature, connectionPromise, wallet] + ); + const isConnecting = !!connectionPromise.current; + return [isConnecting, connect]; +} diff --git a/packages/react/core/src/features/useDisconnect.ts b/packages/react/core/src/features/useDisconnect.ts new file mode 100644 index 00000000..7e1e736f --- /dev/null +++ b/packages/react/core/src/features/useDisconnect.ts @@ -0,0 +1,39 @@ +import type { StandardDisconnectFeature, StandardDisconnectMethod } from '@wallet-standard/features'; +import { StandardDisconnect } from '@wallet-standard/features'; +import type { UiWallet } from '@wallet-standard/ui'; +import { getWalletFeature } from '@wallet-standard/ui'; +import { getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED } from '@wallet-standard/ui-registry'; +import { useCallback } from 'react'; + +import { useWeakRef } from '../useWeakRef.js'; + +/** + * Returns a function you can call to ask the wallet to deauthorize accounts authorized for the + * current domain, or at least to remove them from the wallet's `accounts` array for the time being, + * depending on the wallet's implementation of `standard:disconnect`. + */ +export function useDisconnect( + uiWallet: TWallet +): [isDisconnecting: boolean, disconnect: (...inputs: Parameters) => Promise] { + const disconnectFeature = getWalletFeature( + uiWallet, + StandardDisconnect + ) as StandardDisconnectFeature[typeof StandardDisconnect]; + const wallet = getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(uiWallet); + const disconnectionPromise = useWeakRef | undefined>(wallet); + const disconnect = useCallback( + async function disconnect(...inputs: Parameters) { + if (disconnectionPromise.current) { + return disconnectionPromise.current; + } + const newDisconnectionPromise = disconnectFeature.disconnect(...inputs).finally(() => { + disconnectionPromise.current = undefined; + }); + disconnectionPromise.current = newDisconnectionPromise; + return await newDisconnectionPromise; + }, + [disconnectFeature, disconnectionPromise] + ); + const isDisconnecting = !!disconnectionPromise.current; + return [isDisconnecting, disconnect]; +} diff --git a/packages/react/core/src/features/version.ts b/packages/react/core/src/features/version.ts new file mode 100644 index 00000000..42aaf466 --- /dev/null +++ b/packages/react/core/src/features/version.ts @@ -0,0 +1,3 @@ +import type { WalletVersion } from '@wallet-standard/base'; + +export const FEATURE_HOOKS_SUPPORTED_WALLET_VERSION = '1.0.0' as WalletVersion; diff --git a/packages/react/core/src/useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT.ts b/packages/react/core/src/useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT.ts index 3b7cfcd6..4b265cae 100644 --- a/packages/react/core/src/useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT.ts +++ b/packages/react/core/src/useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT.ts @@ -1,9 +1,9 @@ import { getWallets } from '@wallet-standard/app'; -import type { Wallet, WalletWithFeatures } from '@wallet-standard/base'; -import { StandardEvents, type StandardEventsFeature } from '@wallet-standard/features'; +import type { Wallet } from '@wallet-standard/base'; +import { StandardEvents } from '@wallet-standard/features'; import { useCallback, useRef, useSyncExternalStore } from 'react'; -import { hasEventsFeature } from './WalletProvider.js'; +import { walletHasStandardEventsFeature } from './features/events.js'; import { useStable } from './useStable.js'; const NO_WALLETS: readonly Wallet[] = []; @@ -12,10 +12,6 @@ function getServerSnapshot(): readonly Wallet[] { return NO_WALLETS; } -function walletHasStandardEventsFeature(wallet: Wallet): wallet is WalletWithFeatures { - return hasEventsFeature(wallet.features); -} - /** TODO: docs */ export function useWallets_INTERNAL_ONLY_NOT_FOR_EXPORT(): readonly Wallet[] { const { get, on } = useStable(getWallets); diff --git a/packages/ui/features/src/__tests__/getWalletFeature-test.ts b/packages/ui/features/src/__tests__/getWalletFeature-test.ts index 1393e1d3..21e6555b 100644 --- a/packages/ui/features/src/__tests__/getWalletFeature-test.ts +++ b/packages/ui/features/src/__tests__/getWalletFeature-test.ts @@ -1,5 +1,8 @@ import type { Wallet, WalletVersion } from '@wallet-standard/base'; -import { WalletStandardError } from '@wallet-standard/errors'; +import { + WALLET_STANDARD_ERROR__FEATURES__WALLET_FEATURE_UNIMPLEMENTED, + WalletStandardError, +} from '@wallet-standard/errors'; import type { UiWalletHandle } from '@wallet-standard/ui-core'; import { getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED } from '@wallet-standard/ui-registry'; @@ -36,7 +39,14 @@ describe('getWalletFeature', () => { it('throws if the handle provided does not support the feature requested', () => { expect(() => { getWalletFeature(mockWalletHandle, 'feature:b'); - }).toThrow(WalletStandardError); + }).toThrow( + new WalletStandardError(WALLET_STANDARD_ERROR__FEATURES__WALLET_FEATURE_UNIMPLEMENTED, { + featureName: 'feature:b', + supportedChains: ['solana:mainnet'], + supportedFeatures: ['feature:a'], + walletName: 'Mock Wallet', + }) + ); }); it('returns the feature of the underlying wallet', () => { jest.mocked(getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED).mockReturnValue(mockWallet);