chore: simplify AppKit API providers

This commit is contained in:
Danish Arora 2025-10-23 12:16:25 +05:30
parent 1bd93854ec
commit 858c1bcc35
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
18 changed files with 788 additions and 355 deletions

View File

@ -9,42 +9,19 @@ import {
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Bitcoin, Coins } from 'lucide-react';
import {
useAppKit,
useAppKitAccount,
useDisconnect,
useAppKitState,
} from '@reown/appkit/react';
import { useAuth, useAppKitWallet } from '@opchan/react';
interface WalletDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConnect: () => void;
}
export function WalletConnectionDialog({
open,
onOpenChange,
onConnect,
}: WalletDialogProps) {
// Always call hooks to follow React rules
const { initialized } = useAppKitState();
const appKit = useAppKit();
const { disconnect } = useDisconnect();
// Get account info for different chains
const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' });
const ethereumAccount = useAppKitAccount({ namespace: 'eip155' });
// Determine which account is connected
const isBitcoinConnected = bitcoinAccount.isConnected;
const isEthereumConnected = ethereumAccount.isConnected;
const isConnected = isBitcoinConnected || isEthereumConnected;
// Get the active account info
const activeAccount = isBitcoinConnected ? bitcoinAccount : ethereumAccount;
const activeAddress = activeAccount.address;
const activeChain = isBitcoinConnected ? 'Bitcoin' : 'Ethereum';
const { connect, disconnect } = useAuth();
const wallet = useAppKitWallet();
const handleDisconnect = async () => {
await disconnect();
@ -52,35 +29,23 @@ export function WalletConnectionDialog({
};
const handleBitcoinConnect = () => {
if (!initialized || !appKit) {
console.error('AppKit not initialized');
if (!wallet.isInitialized) {
console.error('Wallet not initialized');
return;
}
appKit.open({
view: 'Connect',
namespace: 'bip122',
});
onConnect();
onOpenChange(false);
connect('bitcoin');
};
const handleEthereumConnect = () => {
if (!initialized || !appKit) {
console.error('AppKit not initialized');
if (!wallet.isInitialized) {
console.error('Wallet not initialized');
return;
}
appKit.open({
view: 'Connect',
namespace: 'eip155',
});
onConnect();
onOpenChange(false);
connect('ethereum');
};
// Show loading state if AppKit is not initialized
if (!initialized) {
// Show loading state if wallet is not initialized
if (!wallet.isInitialized) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md border-neutral-800 bg-black text-white">
@ -98,6 +63,10 @@ export function WalletConnectionDialog({
);
}
const isConnected = wallet.isConnected;
const activeChain = wallet.walletType === 'bitcoin' ? 'Bitcoin' : 'Ethereum';
const activeAddress = wallet.address;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md border-neutral-800 bg-black text-white">

View File

@ -2,23 +2,14 @@ import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';
import { Buffer } from 'buffer';
import { OpchanWithAppKit } from './providers/OpchanWithAppKit';
import { WagmiProvider } from 'wagmi';
import { AppKitProvider } from '@reown/appkit/react';
import { appkitConfig, config } from '@opchan/core';
import { OpChanProvider } from '@opchan/react';
if (!(window as Window & typeof globalThis).Buffer) {
(window as Window & typeof globalThis).Buffer = Buffer;
}
createRoot(document.getElementById('root')!).render(
<WagmiProvider config={config}>
<AppKitProvider {...appkitConfig}>
<OpchanWithAppKit
config={{ ordiscanApiKey: '6bb07766-d98c-4ddd-93fb-6a0e94d629dd' }}
>
<OpChanProvider config={{ ordiscanApiKey: '6bb07766-d98c-4ddd-93fb-6a0e94d629dd' }}>
<App />
</OpchanWithAppKit>
</AppKitProvider>
</WagmiProvider>
</OpChanProvider>
);

View File

@ -1,78 +0,0 @@
import * as React from 'react';
import {
OpChanProvider,
type WalletAdapter,
type WalletAdapterAccount,
} from '@opchan/react';
import { useAppKitAccount, modal } from '@reown/appkit/react';
import { AppKit } from '@reown/appkit';
import type { OpChanClientConfig } from '@opchan/core';
import { walletManager } from '@opchan/core';
interface Props {
config: OpChanClientConfig;
children: React.ReactNode;
}
export const OpchanWithAppKit: React.FC<Props> = ({ config, children }) => {
const btc = useAppKitAccount({ namespace: 'bip122' });
const eth = useAppKitAccount({ namespace: 'eip155' });
const listenersRef = React.useRef(
new Set<(a: WalletAdapterAccount | null) => void>()
);
const getCurrent = React.useCallback((): WalletAdapterAccount | null => {
if (btc.isConnected && btc.address)
return { address: btc.address, walletType: 'bitcoin' };
if (eth.isConnected && eth.address)
return { address: eth.address, walletType: 'ethereum' };
return null;
}, [btc.isConnected, btc.address, eth.isConnected, eth.address]);
const adapter = React.useMemo<WalletAdapter>(
() => ({
getAccount: () => getCurrent(),
onChange: cb => {
listenersRef.current.add(cb);
return () => {
listenersRef.current.delete(cb);
};
},
}),
[getCurrent]
);
// Notify listeners when AppKit account changes
React.useEffect(() => {
const account = getCurrent();
listenersRef.current.forEach(cb => {
try {
cb(account);
} catch {
/* ignore */
}
});
}, [getCurrent]);
React.useEffect(() => {
if (!modal) return;
try {
const hasBtc = btc.isConnected && !!btc.address;
const hasEth = eth.isConnected && !!eth.address;
if (hasBtc || hasEth) {
walletManager.create(modal as AppKit, btc, eth);
} else if (walletManager.hasInstance()) {
walletManager.clear();
}
} catch (err) {
console.warn('WalletManager initialization error', err);
}
}, [btc, btc.isConnected, btc.address, eth, eth.isConnected, eth.address]);
return (
<OpChanProvider config={config} walletAdapter={adapter}>
{children}
</OpChanProvider>
);
};

View File

@ -5,7 +5,7 @@ import { ForumActions } from '../lib/forum/ForumActions';
import { RelevanceCalculator } from '../lib/forum/RelevanceCalculator';
import { UserIdentityService } from '../lib/services/UserIdentityService';
import { DelegationManager, delegationManager } from '../lib/delegation';
import { walletManager } from '../lib/wallet';
import WalletManager from '../lib/wallet';
import { MessageService } from '../lib/services/MessageService';
export interface OpChanClientConfig {
@ -22,19 +22,19 @@ export class OpChanClient {
readonly messageService: MessageService;
readonly userIdentityService: UserIdentityService;
readonly delegation: DelegationManager = delegationManager;
readonly wallet = walletManager;
readonly wallet = WalletManager
constructor(config: OpChanClientConfig) {
this.config = config;
const env: EnvironmentConfig = {
apiKeys: {
ordiscan: config.ordiscanApiKey,
},
};
environment.configure(env);
this.messageService = new MessageService(this.delegation);
this.userIdentityService = new UserIdentityService(this.messageService);

View File

@ -42,9 +42,10 @@ export { default as messageManager } from './lib/waku';
export * from './lib/waku/network';
// Export wallet functionality
export { WalletManager, walletManager } from './lib/wallet';
export { WalletManager } from './lib/wallet';
export * from './lib/wallet/config';
export * from './lib/wallet/types';
export { type WalletAdapter, setWalletAdapter, getWalletAdapter } from './lib/wallet/adapter';
// Primary client API
export { OpChanClient, type OpChanClientConfig } from './client/OpChanClient';

View File

@ -7,7 +7,8 @@ import {
} from '../../types/waku';
import { MessageService } from './MessageService';
import { localDatabase } from '../database/LocalDatabase';
import { walletManager, WalletManager } from '../wallet';
import { WalletManager } from '../wallet';
import { getWalletAdapter } from '../wallet/adapter';
export interface UserIdentity {
address: string;
@ -235,9 +236,9 @@ export class UserIdentityService {
this.resolveOrdinalDetails(address),
]);
const isWalletConnected = WalletManager.hasInstance()
? walletManager.getInstance().isConnected()
: false;
const isWalletConnected = (getWalletAdapter()?.isConnected?.() ?? (WalletManager.hasInstance()
? WalletManager.getInstance().isConnected()
: false));
let verificationStatus: EVerificationStatus;
if (ensName || ordinalDetails) {
verificationStatus = EVerificationStatus.ENS_ORDINAL_VERIFIED;

View File

@ -0,0 +1,19 @@
export interface WalletAdapter {
getAddress(): string | null;
getWalletType(): 'bitcoin' | 'ethereum' | null;
isConnected(): boolean;
signMessage(message: string): Promise<string>;
}
let currentAdapter: WalletAdapter | null = null;
export function setWalletAdapter(adapter: WalletAdapter | null): void {
currentAdapter = adapter;
}
export function getWalletAdapter(): WalletAdapter | null {
return currentAdapter;
}

View File

@ -25,7 +25,7 @@ export const wagmiAdapter = new WagmiAdapter({
// Export the Wagmi config for the provider
export const config = wagmiAdapter.wagmiConfig;
const bitcoinAdapter = new BitcoinAdapter({
export const bitcoinAdapter = new BitcoinAdapter({
projectId,
});

View File

@ -6,8 +6,8 @@ import {
verifyMessage as verifyEthereumMessage,
} from '@wagmi/core';
import { ChainNamespace } from '@reown/appkit-common';
import { config } from './config';
import { Provider } from '@reown/appkit-controllers';
import { config, wagmiAdapter, bitcoinAdapter } from './config';
import { Provider, ProviderController } from '@reown/appkit-controllers';
import { WalletInfo, ActiveWallet } from './types';
import { Inscription } from 'ordiscan';
export class WalletManager {
@ -196,15 +196,15 @@ export class WalletManager {
*/
async signMessage(message: string): Promise<string> {
try {
// Access the adapter through the appKit instance
const adapter = this.appKit.chainAdapters?.[this.namespace];
// Select adapter based on active namespace using configured instances
const adapter = this.namespace === 'eip155' ? wagmiAdapter : this.namespace === 'bip122' ? bitcoinAdapter : undefined;
if (!adapter) {
throw new Error(`No adapter found for namespace: ${this.namespace}`);
throw new Error(`No adapter instance configured for namespace: ${this.namespace}`);
}
// Get the provider for the current connection
const provider = this.appKit.getProvider(this.namespace);
// Get the provider for the current connection via ProviderController
const provider = ProviderController.getProvider(this.namespace);
if (!provider) {
throw new Error(`No provider found for namespace: ${this.namespace}`);
@ -218,7 +218,7 @@ export class WalletManager {
const result = await adapter.signMessage({
message,
address: this.activeAccount.address,
provider: provider as Provider,
provider: provider as unknown as Provider,
});
return result.signature;
@ -267,15 +267,7 @@ export class WalletManager {
}
}
// Convenience exports for singleton access
export const walletManager = {
create: WalletManager.create,
getInstance: WalletManager.getInstance,
hasInstance: WalletManager.hasInstance,
clear: WalletManager.clear,
resolveENS: WalletManager.resolveENS,
verifySignature: WalletManager.verifySignature,
};
export default WalletManager;
export * from './types';
export * from './config';

View File

@ -10,53 +10,16 @@ npm i @opchan/react @opchan/core react react-dom
### Quickstart
#### Basic Usage
#### With Reown AppKit (Recommended)
```tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { OpChanProvider } from '@opchan/react';
import type { OpChanClientConfig } from '@opchan/core';
const config: OpChanClientConfig = {
ordiscanApiKey: 'YOUR_ORDISCAN_API_KEY',
};
// Optional: bridge your wallet to OpChan
const walletAdapter = {
getAccount() {
// Return { address, walletType: 'bitcoin' | 'ethereum' } or null
return null;
},
onChange(cb) {
// Subscribe to wallet changes; return an unsubscribe function
return () => {};
},
};
function App() {
return (
<OpChanProvider config={config} walletAdapter={walletAdapter}>
{/* your app */}
</OpChanProvider>
);
}
createRoot(document.getElementById('root')!).render(<App />);
```
#### (Suggested) With Reown AppKit Integration
Using Reown AppKit for wallet management:
OpChan integrates with Reown AppKit for wallet management. The provider must be nested inside `WagmiProvider` and `AppKitProvider`:
```tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { WagmiProvider } from 'wagmi';
import { AppKitProvider } from '@reown/appkit/react';
import { OpchanWithAppKit } from './providers/OpchanWithAppKit';
// Define your own config for networks, or use our by default (supports Bitcoin and Ethereum)
import { OpChanProvider } from '@opchan/react';
import { config, appkitConfig } from '@opchan/core';
const opchanConfig = { ordiscanApiKey: 'YOUR_ORDISCAN_API_KEY' };
@ -65,9 +28,9 @@ function App() {
return (
<WagmiProvider config={config}>
<AppKitProvider {...appkitConfig}>
<OpchanWithAppKit config={opchanConfig}>
<OpChanProvider config={opchanConfig}>
{/* your app */}
</OpchanWithAppKit>
</OpChanProvider>
</AppKitProvider>
</WagmiProvider>
);
@ -113,13 +76,14 @@ export function Connect() {
<button onClick={() => disconnect()}>Disconnect</button>
</>
) : (
<button
onClick={() =>
connect({ address: '0xabc...1234', walletType: 'ethereum' })
}
>
Connect
</button>
<>
<button onClick={() => connect('ethereum')}>
Connect Ethereum
</button>
<button onClick={() => connect('bitcoin')}>
Connect Bitcoin
</button>
</>
)}
</div>
);
@ -129,22 +93,16 @@ export function Connect() {
### API
- **Providers**
- **`OpChanProvider`**: High-level provider that constructs an `OpChanClient` and wires persistence/events.
- Props:
- `config: OpChanClientConfig` — core client configuration.
- `walletAdapter?: WalletAdapter` — optional bridge to your wallet system.
- `children: React.ReactNode`.
- Types:
- `WalletAdapterAccount`: `{ address: string; walletType: 'bitcoin' | 'ethereum' }`.
- `WalletAdapter`:
- `getAccount(): WalletAdapterAccount | null`
- `onChange(cb: (a: WalletAdapterAccount | null) => void): () => void`
- **`OpchanWithAppKit`**: Convenience wrapper around `OpChanProvider` that integrates with Reown AppKit.
- **`OpChanProvider`**: High-level provider that constructs an `OpChanClient` and integrates with AppKit.
- Props:
- `config: OpChanClientConfig` — core client configuration.
- `children: React.ReactNode`.
- Automatically bridges AppKit wallet connections to OpChan's wallet adapter interface.
- Requires `WagmiProvider` and `AppKitProvider` from Reown AppKit as parent providers.
- Requirements: Must be nested inside `WagmiProvider` and `AppKitProvider` from Reown AppKit.
- Internally provides `AppKitWalletProvider` for wallet state management.
- **`AppKitWalletProvider`**: Wallet context provider (automatically included in `OpChanProvider`).
- Provides wallet state and controls from AppKit.
- **`ClientProvider`**: Low-level provider if you construct `OpChanClient` yourself.
- Props: `{ client: OpChanClient; children: React.ReactNode }`.
@ -153,9 +111,13 @@ export function Connect() {
- **`useAuth()`** → session & identity actions
- Data: `currentUser`, `verificationStatus`, `isAuthenticated`, `delegationInfo`.
- Actions: `connect({ address, walletType })`, `disconnect()`, `verifyOwnership()`,
- Actions: `connect(walletType: 'bitcoin' | 'ethereum')`, `disconnect()`, `verifyOwnership()`,
`delegate(duration)`, `delegationStatus()`, `clearDelegation()`,
`updateProfile({ callSign?, displayPreference? })`.
- **`useAppKitWallet()`** → AppKit wallet state (low-level)
- Data: `address`, `walletType`, `isConnected`, `isInitialized`.
- Actions: `connect(walletType)`, `disconnect()`.
- **`useContent()`** → forum data & actions
- Data: `cells`, `posts`, `comments`, `bookmarks`, `postsByCell`, `commentsByPost`,

View File

@ -0,0 +1,393 @@
## OpChan React SDK (packages/react) — Building Forum UIs
This guide shows how to build a forums-based app using the React SDK. It covers project wiring, wallet connection/disconnection, key delegation, waiting for Waku/network readiness, loading content, posting/commenting, voting, moderation, bookmarks, and user display utilities.
The examples assume you install and use the `@opchan/react` and `@opchan/core` packages.
---
### 1) Install and basic setup
```bash
npm i @opchan/react @opchan/core
```
Create an app-level provider using `OpChanProvider`. You must pass a minimal client config (e.g., Ordiscan API key if you have one). OpChanProvider must be nested inside `WagmiProvider` and `AppKitProvider` from Reown AppKit.
```tsx
import React from 'react';
import { OpChanProvider } from '@opchan/react';
import { WagmiProvider } from 'wagmi';
import { AppKitProvider } from '@reown/appkit/react';
import { appkitConfig, config } from '@opchan/core';
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<AppKitProvider {...appkitConfig}>
<OpChanProvider config={{ ordiscanApiKey: 'YOUR_API_KEY' }}>
{children}
</OpChanProvider>
</AppKitProvider>
</WagmiProvider>
);
}
```
OpChanProvider automatically integrates with AppKit for wallet management.
---
### 2) High-level hook
Use `useForum()` for a single entry point to the main hooks:
```tsx
import { useForum } from '@opchan/react';
function Example() {
const { user, content, permissions, network } = useForum();
// user: auth + delegation
// content: cells/posts/comments/bookmarks + actions
// permissions: derived booleans + reasons
// network: Waku readiness + refresh
return null;
}
```
---
### 3) Wallets — connect/disconnect & verification
API: `useAuth()`
```tsx
import { useAuth } from '@opchan/react';
function WalletControls() {
const { currentUser, verificationStatus, connect, disconnect, verifyOwnership } = useAuth();
return (
<div>
{currentUser ? (
<>
<div>Connected: {currentUser.displayName}</div>
<button onClick={() => disconnect()}>Disconnect</button>
<button onClick={() => verifyOwnership()}>Verify ENS/Ordinal</button>
<div>Status: {verificationStatus}</div>
</>
) : (
<>
<button onClick={() => connect('bitcoin')}>
Connect Bitcoin
</button>
<button onClick={() => connect('ethereum')}>
Connect Ethereum
</button>
</>
)}
</div>
);
}
```
Notes:
- `connect(walletType)` opens the AppKit modal for the specified wallet type (bitcoin/ethereum). Upon successful connection, OpChan automatically syncs the wallet state and creates a user session.
- `verifyOwnership()` refreshes identity and sets `EVerificationStatus` appropriately (checks ENS or Bitcoin Ordinals).
---
### 4) Key delegation — create, check, clear
API: `useAuth()``delegate(duration)`, `delegationStatus()`, `clearDelegation()`; also `delegationInfo` in-session.
```tsx
import { useAuth } from '@opchan/react';
function DelegationControls() {
const { delegate, delegationInfo, delegationStatus, clearDelegation } = useAuth();
return (
<div>
<div>
Delegated: {String(delegationInfo.isValid)}
{delegationInfo.expiresAt && `, expires ${delegationInfo.expiresAt.toLocaleString()}`}
</div>
<button onClick={() => delegate('7days')}>Delegate 7 days</button>
<button onClick={() => delegate('30days')}>Delegate 30 days</button>
<button onClick={() => delegationStatus().then(console.log)}>Check</button>
<button onClick={() => clearDelegation()}>Clear</button>
</div>
);
}
```
Behavior:
- The library generates a browser keypair and requests a wallet signature authorizing it until a selected expiry.
- All messages (cells/posts/comments/votes/moderation/profile updates) are signed with the delegated browser key and verified via the proof.
---
### 5) Network (Waku) — readiness and manual refresh
API: `useNetwork()`
```tsx
import { useNetwork } from '@opchan/react';
function NetworkStatus() {
const { isConnected, statusMessage, refresh } = useNetwork();
return (
<div>
<span>{isConnected ? 'Connected' : 'Connecting...'}</span>
<span> • {statusMessage}</span>
<button onClick={() => refresh()}>Refresh</button>
</div>
);
}
```
Notes:
- The store wires Waku health events to `network.isConnected` and `statusMessage`.
- `refresh()` triggers a lightweight cache refresh; live updates come from the Waku subscription.
---
### 6) Loading content — cells, posts, comments, bookmarks
API: `useContent()`
```tsx
import { useContent } from '@opchan/react';
function Feed() {
const { cells, posts, comments, postsByCell, commentsByPost, bookmarks, lastSync } = useContent();
return (
<div>
<div>Cells: {cells.length}</div>
<div>Posts: {posts.length}</div>
<div>Comments: {comments.length}</div>
<div>Bookmarks: {bookmarks.length}</div>
<div>Last sync: {lastSync ? new Date(lastSync).toLocaleTimeString() : '—'}</div>
</div>
);
}
```
Derived helpers:
- `postsByCell[cellId]: Post[]`
- `commentsByPost[postId]: Comment[]` (sorted oldest→newest)
- `cellsWithStats`: adds `postCount`, `activeUsers`, `recentActivity` for UI
- `userVerificationStatus[address]`: identity verification snapshot for weighting/relevance
- `pending.isPending(id)`: show optimistic “syncing…” indicators
---
### 7) Creating content — cells, posts, comments
All actions reflect to the local cache immediately and then propagate over the network.
```tsx
import { useContent } from '@opchan/react';
function Composer({ cellId, postId }: { cellId?: string; postId?: string }) {
const { createCell, createPost, createComment } = useContent();
const onCreateCell = async () => {
await createCell({ name: 'My Cell', description: 'Description', icon: '' });
};
const onCreatePost = async () => {
if (!cellId) return;
await createPost({ cellId, title: 'Hello', content: 'World' });
};
const onCreateComment = async () => {
if (!postId) return;
await createComment({ postId, content: 'Nice post!' });
};
return (
<div>
<button onClick={onCreateCell}>Create Cell</button>
<button onClick={onCreatePost} disabled={!cellId}>Create Post</button>
<button onClick={onCreateComment} disabled={!postId}>Create Comment</button>
</div>
);
}
```
Permissions: see `usePermissions()` below to gate UI.
---
### 8) Voting
```tsx
import { useContent } from '@opchan/react';
function VoteButtons({ targetId }: { targetId: string }) {
const { vote, pending } = useContent();
const isSyncing = pending.isPending(targetId);
return (
<div>
<button onClick={() => vote({ targetId, isUpvote: true })}>Upvote</button>
<button onClick={() => vote({ targetId, isUpvote: false })}>Downvote</button>
{isSyncing && <span> syncing…</span>}
</div>
);
}
```
---
### 9) Moderation (cell owner)
```tsx
import { useContent, usePermissions } from '@opchan/react';
function Moderation({ cellId, postId, commentId, userAddress }: { cellId: string; postId?: string; commentId?: string; userAddress?: string }) {
const { moderate } = useContent();
const { canModerate } = usePermissions();
if (!canModerate(cellId)) return null;
return (
<div>
{postId && (
<>
<button onClick={() => moderate.post(cellId, postId, 'reason')}>Moderate post</button>
<button onClick={() => moderate.unpost(cellId, postId, 'reason')}>Unmoderate post</button>
</>
)}
{commentId && (
<>
<button onClick={() => moderate.comment(cellId, commentId, 'reason')}>Moderate comment</button>
<button onClick={() => moderate.uncomment(cellId, commentId, 'reason')}>Unmoderate comment</button>
</>
)}
{userAddress && (
<>
<button onClick={() => moderate.user(cellId, userAddress, 'reason')}>Moderate user</button>
<button onClick={() => moderate.unuser(cellId, userAddress, 'reason')}>Unmoderate user</button>
</>
)}
</div>
);
}
```
---
### 10) Bookmarks
```tsx
import { useContent } from '@opchan/react';
function BookmarkControls({ post, comment }: { post?: any; comment?: any }) {
const { bookmarks, togglePostBookmark, toggleCommentBookmark, removeBookmark, clearAllBookmarks } = useContent();
return (
<div>
<div>Total: {bookmarks.length}</div>
{post && <button onClick={() => togglePostBookmark(post, post.cellId)}>Toggle post bookmark</button>}
{comment && <button onClick={() => toggleCommentBookmark(comment, comment.postId)}>Toggle comment bookmark</button>}
<button onClick={() => bookmarks[0] && removeBookmark(bookmarks[0].id)}>Remove first</button>
<button onClick={() => clearAllBookmarks()}>Clear all</button>
</div>
);
}
```
---
### 11) Permissions helper
API: `usePermissions()` exposes booleans and user-friendly reasons for gating UI.
```tsx
import { usePermissions } from '@opchan/react';
function ActionGates({ cellId }: { cellId: string }) {
const p = usePermissions();
return (
<ul>
<li>Can create cell: {String(p.canCreateCell)} ({p.reasons.createCell})</li>
<li>Can post: {String(p.canPost)} ({p.reasons.post})</li>
<li>Can comment: {String(p.canComment)} ({p.reasons.comment})</li>
<li>Can vote: {String(p.canVote)} ({p.reasons.vote})</li>
<li>Can moderate: {String(p.canModerate(cellId))} ({p.reasons.moderate(cellId)})</li>
</ul>
);
}
```
---
### 12) User display/identity helpers
API: `useUserDisplay(address)` returns resolved user identity for UI labels.
```tsx
import { useUserDisplay } from '@opchan/react';
function AuthorName({ address }: { address: string }) {
const { displayName, ensName, ordinalDetails, isLoading } = useUserDisplay(address);
if (isLoading) return <span>Loading…</span>;
return <span title={ensName || ordinalDetails?.ordinalId}>{displayName}</span>;
}
```
---
### 13) End-to-end page skeleton
```tsx
import React from 'react';
import { OpChanProvider, useForum } from '@opchan/react';
function Home() {
const { user, content, permissions, network } = useForum();
return (
<div>
<div>Network: {network.isConnected ? 'Ready' : 'Connecting…'}</div>
{user.currentUser ? (
<div>Welcome {user.currentUser.displayName}</div>
) : (
<button onClick={() => user.connect({ address: '0xabc...', walletType: 'ethereum' })}>Connect</button>
)}
{permissions.canPost && content.cells[0] && (
<button onClick={() => content.createPost({ cellId: content.cells[0].id, title: 'Hi', content: 'Hello world' })}>
New Post
</button>
)}
<ul>
{content.posts.map(p => (
<li key={p.id}>{p.title}</li>
))}
</ul>
</div>
);
}
export default function App() {
return (
<OpChanProvider config={{ ordiscanApiKey: '' }}>
<Home />
</OpChanProvider>
);
}
```
---
### 14) Notes and best practices
- Always gate actions with `usePermissions()` to provide clear UX reasons.
- Use `pending.isPending(id)` to show optimistic “syncing…” states for content you just created or voted on.
- The store is hydrated from IndexedDB on load; Waku live messages keep it in sync.
- Identity (ENS/Ordinals/Call Sign) is resolved and cached; calling `verifyOwnership()` or updating the profile will refresh it.

View File

@ -1,6 +1,6 @@
{
"name": "@opchan/react",
"version": "1.0.2",
"version": "1.1.0",
"private": false,
"description": "React contexts and hooks for OpChan built on @opchan/core",
"main": "dist/index.js",

View File

@ -5,8 +5,10 @@ export {
useClient,
} from './v1/context/ClientContext';
export { useAppKitWallet } from './v1/hooks/useAppKitWallet';
export { OpChanProvider } from './v1/provider/OpChanProvider';
export type { WalletAdapter, WalletAdapterAccount } from './v1/provider/OpChanProvider';
export type { OpChanProviderProps } from './v1/provider/OpChanProvider';
export { useAuth } from './v1/hooks/useAuth';
export { useContent } from './v1/hooks/useContent';

View File

@ -0,0 +1,53 @@
import React, { Component, ReactNode } from 'react';
interface AppKitErrorBoundaryState {
hasError: boolean;
error?: Error;
}
interface AppKitErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
export class AppKitErrorBoundary extends Component<
AppKitErrorBoundaryProps,
AppKitErrorBoundaryState
> {
constructor(props: AppKitErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): AppKitErrorBoundaryState {
// Check if this is an AppKit initialization error
if (error.message.includes('createAppKit') ||
error.message.includes('useAppKitState') ||
error.message.includes('AppKit')) {
return { hasError: true, error };
}
return { hasError: false };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.warn('[AppKitErrorBoundary] Caught AppKit error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
// Render fallback UI or the children with error handling
return this.props.fallback || (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-md">
<h3 className="text-sm font-medium text-yellow-800">
Wallet Connection Initializing...
</h3>
<p className="mt-1 text-sm text-yellow-700">
The wallet system is still loading. Please wait a moment.
</p>
</div>
);
}
return this.props.children;
}
}

View File

@ -0,0 +1,100 @@
import { useCallback, useMemo } from 'react';
import {
useAppKit,
useAppKitAccount,
useDisconnect,
useAppKitState,
} from '@reown/appkit/react';
export interface AppKitWalletState {
address: string | null;
walletType: 'bitcoin' | 'ethereum' | null;
isConnected: boolean;
isInitialized: boolean;
}
export interface AppKitWalletControls {
connect: (walletType: 'bitcoin' | 'ethereum') => void;
disconnect: () => Promise<void>;
}
export type AppKitWalletValue = AppKitWalletState & AppKitWalletControls;
export function useAppKitWallet(): AppKitWalletValue {
// Add error boundary for AppKit hooks
let initialized = false;
let appKit: ReturnType<typeof useAppKit> | null = null;
let appKitDisconnect: (() => Promise<void>) | null = null;
let bitcoinAccount: ReturnType<typeof useAppKitAccount> | null = null;
let ethereumAccount: ReturnType<typeof useAppKitAccount> | null = null;
try {
const appKitState = useAppKitState();
initialized = appKitState?.initialized || false;
appKit = useAppKit();
const disconnectHook = useDisconnect();
appKitDisconnect = disconnectHook?.disconnect;
bitcoinAccount = useAppKitAccount({ namespace: 'bip122' });
ethereumAccount = useAppKitAccount({ namespace: 'eip155' });
} catch (error) {
console.warn('[useAppKitWallet] AppKit hooks not available yet:', error);
// Return default values if AppKit is not initialized
return {
address: null,
walletType: null,
isConnected: false,
isInitialized: false,
connect: () => console.warn('AppKit not initialized'),
disconnect: async () => console.warn('AppKit not initialized'),
};
}
// Determine wallet state
const isBitcoinConnected = bitcoinAccount?.isConnected || false;
const isEthereumConnected = ethereumAccount?.isConnected || false;
const isConnected = isBitcoinConnected || isEthereumConnected;
const walletType = useMemo<'bitcoin' | 'ethereum' | null>(() => {
if (isBitcoinConnected) return 'bitcoin';
if (isEthereumConnected) return 'ethereum';
return null;
}, [isBitcoinConnected, isEthereumConnected]);
const address = useMemo<string | null>(() => {
if (isBitcoinConnected && bitcoinAccount?.address) return bitcoinAccount.address;
if (isEthereumConnected && ethereumAccount?.address) return ethereumAccount.address;
return null;
}, [isBitcoinConnected, bitcoinAccount?.address, isEthereumConnected, ethereumAccount?.address]);
const connect = useCallback((targetWalletType: 'bitcoin' | 'ethereum') => {
if (!initialized || !appKit) {
console.error('AppKit not initialized');
return;
}
const namespace = targetWalletType === 'bitcoin' ? 'bip122' : 'eip155';
appKit.open({
view: 'Connect',
namespace,
});
}, [initialized, appKit]);
const disconnect = useCallback(async () => {
if (appKitDisconnect) {
await appKitDisconnect();
}
}, [appKitDisconnect]);
return {
address,
walletType,
isConnected,
isInitialized: initialized,
connect,
disconnect,
};
}

View File

@ -1,73 +1,94 @@
import React from 'react';
import { useClient } from '../context/ClientContext';
import { useAppKitWallet } from '../hooks/useAppKitWallet';
import { useOpchanStore, setOpchanState } from '../store/opchanStore';
import {
User,
EVerificationStatus,
DelegationDuration,
EDisplayPreference,
walletManager,
WalletManager,
} from '@opchan/core';
import type { DelegationFullStatus } from '@opchan/core';
export interface ConnectInput {
address: string;
walletType: 'bitcoin' | 'ethereum';
}
export function useAuth() {
const client = useClient();
const wallet = useAppKitWallet();
const currentUser = useOpchanStore(s => s.session.currentUser);
const verificationStatus = useOpchanStore(s => s.session.verificationStatus);
const delegation = useOpchanStore(s => s.session.delegation);
// Sync AppKit wallet state to OpChan session
React.useEffect(() => {
const syncWallet = async () => {
if (wallet.isConnected && wallet.address && wallet.walletType) {
// Wallet connected - create/update user session
const baseUser: User = {
address: wallet.address,
walletType: wallet.walletType,
displayName: wallet.address.slice(0, 6) + '...' + wallet.address.slice(-4),
displayPreference: EDisplayPreference.WALLET_ADDRESS,
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
lastChecked: Date.now(),
};
const connect = React.useCallback(async (input: ConnectInput): Promise<boolean> => {
const baseUser: User = {
address: input.address,
walletType: input.walletType,
displayName: input.address.slice(0, 6) + '...' + input.address.slice(-4),
displayPreference: EDisplayPreference.WALLET_ADDRESS,
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
lastChecked: Date.now(),
try {
await client.database.storeUser(baseUser);
// Prime identity service so display name/ens are cached
const identity = await client.userIdentityService.getIdentity(baseUser.address);
if (identity) {
setOpchanState(prev => ({
...prev,
session: {
...prev.session,
currentUser: {
...baseUser,
...identity,
},
verificationStatus: identity.verificationStatus,
},
}));
} else {
setOpchanState(prev => ({
...prev,
session: {
...prev.session,
currentUser: baseUser,
verificationStatus: baseUser.verificationStatus,
},
}));
}
} catch (e) {
console.error('Failed to sync wallet to session', e);
}
} else if (!wallet.isConnected && currentUser) {
// Wallet disconnected - clear session
try {
await client.database.clearUser();
} catch (e) {
console.error('Failed to clear user on disconnect', e);
}
setOpchanState(prev => ({
...prev,
session: {
currentUser: null,
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
delegation: null,
},
}));
}
};
try {
await client.database.storeUser(baseUser);
// Prime identity service so display name/ens are cached
const identity = await client.userIdentityService.getIdentity(baseUser.address);
if (!identity) return false;
setOpchanState(prev => ({
...prev,
session: {
...prev.session,
currentUser: {
...baseUser,
...identity,
},
},
}));
return true;
} catch (e) {
console.error('connect failed', e);
return false;
}
}, [client]);
syncWallet();
}, [wallet.isConnected, wallet.address, wallet.walletType, client, currentUser]);
const connect = React.useCallback((walletType: 'bitcoin' | 'ethereum'): void => {
wallet.connect(walletType);
}, [wallet]);
const disconnect = React.useCallback(async (): Promise<void> => {
try {
await client.database.clearUser();
} finally {
setOpchanState(prev => ({
...prev,
session: {
currentUser: null,
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
delegation: null,
},
}));
}
}, [client]);
await wallet.disconnect();
}, [wallet]);
const verifyOwnership = React.useCallback(async (): Promise<boolean> => {
console.log('verifyOwnership')
@ -111,7 +132,7 @@ export function useAuth() {
const user = currentUser;
if (!user) return false;
try {
const signer = ((message: string) => walletManager.getInstance().signMessage(message));
const signer = ((message: string) => WalletManager.getInstance().signMessage(message));
const ok = await client.delegation.delegate(
user.address,
user.walletType,

View File

@ -1,91 +1,39 @@
import React from 'react';
import { OpChanClient, type OpChanClientConfig } from '@opchan/core';
import { ClientProvider } from '../context/ClientContext';
import { StoreWiring } from './StoreWiring';
import { setOpchanState } from '../store/opchanStore';
import { EVerificationStatus } from '@opchan/core';
import type { EDisplayPreference, User } from '@opchan/core';
import React from "react";
import { OpChanClient, type OpChanClientConfig } from "@opchan/core";
import { ClientProvider } from "../context/ClientContext";
import { StoreWiring } from "./StoreWiring";
import { WalletAdapterInitializer } from "./WalletAdapterInitializer";
import { AppKitProvider } from "@reown/appkit/react";
import { WagmiProvider } from "wagmi";
import { appkitConfig, config as wagmiConfig } from "@opchan/core";
import { AppKitErrorBoundary } from "../components/AppKitErrorBoundary";
export interface WalletAdapterAccount {
address: string;
walletType: 'bitcoin' | 'ethereum';
}
export interface WalletAdapter {
getAccount(): WalletAdapterAccount | null;
onChange(callback: (account: WalletAdapterAccount | null) => void): () => void;
}
export interface NewOpChanProviderProps {
export interface OpChanProviderProps {
config: OpChanClientConfig;
walletAdapter?: WalletAdapter;
children: React.ReactNode;
}
/**
* New provider that constructs the OpChanClient and sets up DI.
* Event wiring and store hydration will be handled in a separate effect layer.
* OpChan provider that constructs the OpChanClient and provides wallet context.
* Must be nested inside WagmiProvider and AppKitProvider.
*/
export const OpChanProvider: React.FC<NewOpChanProviderProps> = ({ config, walletAdapter, children }) => {
export const OpChanProvider: React.FC<OpChanProviderProps> = ({
config,
children,
}) => {
const [client] = React.useState(() => new OpChanClient(config));
// Bridge wallet adapter to session state
React.useEffect(() => {
if (!walletAdapter) return;
const syncFromAdapter = async (account: WalletAdapterAccount | null) => {
if (account) {
// Persist base user and update session
const baseUser: User = {
address: account.address,
walletType: account.walletType,
displayPreference: 'wallet-address' as EDisplayPreference,
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
displayName: account.address.slice(0, 6) + '...' + account.address.slice(-4),
lastChecked: Date.now(),
};
try {
await client.database.storeUser(baseUser);
} catch (err) {
console.warn('OpChanProvider: failed to persist base user', err);
}
setOpchanState(prev => ({
...prev,
session: {
currentUser: baseUser,
verificationStatus: baseUser.verificationStatus,
delegation: prev.session.delegation,
},
}));
} else {
// Clear session on disconnect
try { await client.database.clearUser(); } catch (err) {
console.warn('OpChanProvider: failed to clear user on disconnect', err);
}
setOpchanState(prev => ({
...prev,
session: {
currentUser: null,
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
delegation: null,
},
}));
}
};
// Initial sync
syncFromAdapter(walletAdapter.getAccount());
// Subscribe
const off = walletAdapter.onChange(syncFromAdapter);
return () => { try { off(); } catch { /* noop */ } };
}, [walletAdapter, client]);
return (
<ClientProvider client={client}>
<StoreWiring />
{children}
</ClientProvider>
<WagmiProvider config={wagmiConfig}>
<AppKitErrorBoundary>
<AppKitProvider {...appkitConfig}>
<ClientProvider client={client}>
<WalletAdapterInitializer />
<StoreWiring />
{children}
</ClientProvider>
</AppKitProvider>
</AppKitErrorBoundary>
</WagmiProvider>
);
};

View File

@ -0,0 +1,59 @@
import React from 'react';
import { useAppKit, useAppKitAccount, useAppKitState } from '@reown/appkit/react';
import type { UseAppKitAccountReturn } from '@reown/appkit/react';
import { WalletManager } from '@opchan/core';
import type { AppKit } from '@reown/appkit';
export const WalletAdapterInitializer: React.FC = () => {
// Add error boundary for AppKit hooks
let initialized = false;
let appKit: ReturnType<typeof useAppKit> | null = null;
let btc: UseAppKitAccountReturn | null = null;
let eth: UseAppKitAccountReturn | null = null;
try {
const appKitState = useAppKitState();
initialized = appKitState?.initialized || false;
appKit = useAppKit();
btc = useAppKitAccount({ namespace: 'bip122' });
eth = useAppKitAccount({ namespace: 'eip155' });
} catch (error) {
console.warn('[WalletAdapterInitializer] AppKit hooks not available yet:', error);
// Return early if AppKit is not initialized
return null;
}
React.useEffect(() => {
// Only proceed if AppKit is properly initialized
if (!initialized || !appKit || !btc || !eth) {
return;
}
const isBtc = btc.isConnected && !!btc.address;
const isEth = eth.isConnected && !!eth.address;
const anyConnected = isBtc || isEth;
if (anyConnected) {
// Initialize WalletManager for signing flows
try {
WalletManager.create(
appKit as unknown as AppKit,
btc as unknown as UseAppKitAccountReturn,
eth as unknown as UseAppKitAccountReturn,
);
} catch (e) {
console.warn('[WalletAdapterInitializer] WalletManager.create failed', e);
}
return () => {
try { WalletManager.clear(); } catch (e) { console.warn('[WalletAdapterInitializer] WalletManager.clear failed', e); }
};
}
// No connection: clear manager
try { WalletManager.clear(); } catch (e) { console.warn('[WalletAdapterInitializer] WalletManager.clear failed', e); }
}, [initialized, appKit, btc?.isConnected, btc?.address, eth?.isConnected, eth?.address]);
return null;
};