Skip to content

Commit e72cfc1

Browse files
authored
Merge pull request #7 from dialectlabs/feature/improve-react-api
Feature/improve react api
2 parents f97aeeb + 9561fdb commit e72cfc1

File tree

9 files changed

+172
-13
lines changed

9 files changed

+172
-13
lines changed

bun.lockb

206 KB
Binary file not shown.

package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
"require": "./dist/ext/twitter.cjs",
2222
"types": "./dist/ext/twitter.d.ts"
2323
},
24+
"./react": {
25+
"import": "./dist/react/index.js",
26+
"require": "./dist/react/index.cjs",
27+
"types": "./dist/react/index.d.ts"
28+
},
2429
".": {
2530
"import": "./dist/index.js",
2631
"require": "./dist/index.cjs",
@@ -50,6 +55,8 @@
5055
"typescript": "^5.0.0"
5156
},
5257
"peerDependencies": {
58+
"@solana/wallet-adapter-react": "^0.15.0",
59+
"@solana/wallet-adapter-react-ui": "^0.9.0",
5360
"@solana/web3.js": "^1.91.0",
5461
"react": ">=18",
5562
"react-dom": ">=18"

src/api/Action.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export class Action {
1414
private constructor(
1515
private readonly _url: string,
1616
private readonly _data: ActionsSpecGetResponse,
17-
private readonly _adapter: ActionAdapter,
17+
private _adapter?: ActionAdapter,
1818
) {
1919
// if no links present, fallback to original solana pay spec
2020
if (!_data.links?.actions) {
@@ -61,14 +61,22 @@ export class Action {
6161
}
6262

6363
public get adapter() {
64+
if (!this._adapter) {
65+
throw new Error('No adapter provided');
66+
}
67+
6468
return this._adapter;
6569
}
6670

71+
public setAdapter(adapter: ActionAdapter) {
72+
this._adapter = adapter;
73+
}
74+
6775
public resetActions() {
6876
this._actions.forEach((action) => action.reset());
6977
}
7078

71-
static async fetch(apiUrl: string, adapter: ActionAdapter) {
79+
static async fetch(apiUrl: string, adapter?: ActionAdapter) {
7280
const proxyUrl = proxify(apiUrl);
7381
const response = await fetch(proxyUrl, {
7482
headers: {

src/api/ActionConfig.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,17 @@ export class ActionConfig implements ActionAdapter {
3030
private connection: Connection;
3131

3232
constructor(
33-
rpcUrl: string,
33+
rpcUrlOrConnection: string | Connection,
3434
private adapter: IncomingActionConfig['adapter'],
3535
) {
36-
if (!rpcUrl) {
37-
throw new Error('rpcUrl is required');
36+
if (!rpcUrlOrConnection) {
37+
throw new Error('rpcUrl or connection is required');
3838
}
3939

40-
this.connection = new Connection(rpcUrl, 'confirmed');
40+
this.connection =
41+
typeof rpcUrlOrConnection === 'string'
42+
? new Connection(rpcUrlOrConnection, 'confirmed')
43+
: rpcUrlOrConnection;
4144
}
4245

4346
async connect(context: ActionContext) {

src/react/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
'use client';
2+
export { useAction } from './useAction';
3+
export { useActionAdapter } from './useActionAdapter';
4+
export { useActionsRegistryInterval } from './useActionRegistryInterval';

src/react/useAction.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use client';
2+
import type { Connection } from '@solana/web3.js';
3+
import { useEffect, useState } from 'react';
4+
import { Action } from '../api';
5+
import { useActionAdapter } from './useActionAdapter.ts';
6+
import { useActionsRegistryInterval } from './useActionRegistryInterval.ts';
7+
8+
interface UseActionOptions {
9+
rpcUrlOrConnection: string | Connection;
10+
refreshInterval?: number;
11+
}
12+
13+
export function useAction(
14+
actionUrl: string,
15+
{ rpcUrlOrConnection, refreshInterval }: UseActionOptions,
16+
) {
17+
const { isRegistryLoaded } = useActionsRegistryInterval({ refreshInterval });
18+
const { adapter } = useActionAdapter(rpcUrlOrConnection);
19+
const [action, setAction] = useState<Action | null>(null);
20+
21+
useEffect(() => {
22+
setAction(null);
23+
if (!isRegistryLoaded) {
24+
return;
25+
}
26+
Action.fetch(actionUrl)
27+
.then(setAction)
28+
.catch((e) => {
29+
console.error('Failed to fetch action', e);
30+
setAction(null);
31+
});
32+
}, [actionUrl, isRegistryLoaded]);
33+
34+
useEffect(() => {
35+
action?.setAdapter(adapter);
36+
}, [action, adapter]);
37+
38+
return { action };
39+
}

src/react/useActionAdapter.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
'use client';
2+
import { useWallet } from '@solana/wallet-adapter-react';
3+
import { useWalletModal } from '@solana/wallet-adapter-react-ui';
4+
import { Connection, VersionedTransaction } from '@solana/web3.js';
5+
import { useMemo } from 'react';
6+
import { ActionConfig } from '../api';
7+
8+
/**
9+
* Hook to create an action adapter using solana's wallet adapter.
10+
*
11+
* Be sure to call `action.setAdapter` with the to update the adapter, every time the instance updates.
12+
*
13+
* @param rpcUrlOrConnection
14+
* @see {Action}
15+
*/
16+
export function useActionAdapter(rpcUrlOrConnection: string | Connection) {
17+
const wallet = useWallet();
18+
const walletModal = useWalletModal();
19+
20+
const finalConnection = useMemo(() => {
21+
return typeof rpcUrlOrConnection === 'string'
22+
? new Connection(rpcUrlOrConnection, 'confirmed')
23+
: rpcUrlOrConnection;
24+
}, [rpcUrlOrConnection]);
25+
26+
const adapter = useMemo(() => {
27+
return new ActionConfig(finalConnection, {
28+
connect: async () => {
29+
try {
30+
await wallet.connect();
31+
} catch {
32+
walletModal.setVisible(true);
33+
return null;
34+
}
35+
36+
return wallet.publicKey?.toBase58() ?? null;
37+
},
38+
signTransaction: async (txData: string) => {
39+
try {
40+
const tx = await wallet.sendTransaction(
41+
VersionedTransaction.deserialize(Buffer.from(txData, 'base64')),
42+
finalConnection,
43+
);
44+
return { signature: tx };
45+
} catch {
46+
return { error: 'Signing failed.' };
47+
}
48+
},
49+
});
50+
}, [finalConnection, wallet, walletModal]);
51+
52+
return { adapter };
53+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use client';
2+
import { useEffect, useState } from 'react';
3+
import { ActionsRegistry } from '../api';
4+
5+
const TEN_MINUTES_MS = 10 * 60 * 1000;
6+
7+
export function useActionsRegistryInterval({
8+
refreshInterval = TEN_MINUTES_MS,
9+
}: {
10+
refreshInterval?: number;
11+
} = {}) {
12+
const [isRegistryLoaded, setRegistryLoaded] = useState(false);
13+
const [isLoading, setLoading] = useState(false);
14+
15+
useEffect(() => {
16+
const refresh = async () => {
17+
await ActionsRegistry.getInstance().init();
18+
setRegistryLoaded(true);
19+
};
20+
21+
if (!isRegistryLoaded && !isLoading) {
22+
setLoading(true);
23+
refresh().then(() => setLoading(false));
24+
}
25+
26+
const interval = setInterval(refresh, refreshInterval);
27+
28+
return () => {
29+
clearInterval(interval);
30+
};
31+
}, [isRegistryLoaded, refreshInterval]);
32+
33+
return { isRegistryLoaded };
34+
}

tsup.config.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
1-
import { defineConfig } from 'tsup';
1+
import { defineConfig, type Options } from 'tsup';
22

3-
export default defineConfig({
4-
entry: ['src/index.ts', 'src/index.css', 'src/ext/twitter.tsx'],
3+
const commonCfg: Partial<Options> = {
54
splitting: true,
65
sourcemap: false,
76
clean: true,
8-
dts: {
9-
entry: ['src/index.ts', 'src/ext/twitter.tsx'],
10-
},
117
format: ['cjs', 'esm'],
128
target: ['esnext'],
13-
});
9+
};
10+
11+
export default defineConfig([
12+
{
13+
...commonCfg,
14+
entry: [
15+
'src/index.ts',
16+
'src/index.css',
17+
'src/ext/twitter.tsx',
18+
'src/react/index.ts',
19+
],
20+
dts: {
21+
entry: ['src/index.ts', 'src/ext/twitter.tsx', 'src/react/index.ts'],
22+
},
23+
},
24+
]);

0 commit comments

Comments
 (0)