From 858c1bcc356c793d81939659ac3cbe89945f20db Mon Sep 17 00:00:00 2001 From: Danish Arora Date: Thu, 23 Oct 2025 12:16:25 +0530 Subject: [PATCH] chore: simplify AppKit API providers --- app/src/components/ui/wallet-dialog.tsx | 61 +-- app/src/main.tsx | 15 +- app/src/providers/OpchanWithAppKit.tsx | 78 ---- packages/core/src/client/OpChanClient.ts | 6 +- packages/core/src/index.ts | 3 +- .../src/lib/services/UserIdentityService.ts | 9 +- packages/core/src/lib/wallet/adapter.ts | 19 + packages/core/src/lib/wallet/config.ts | 2 +- packages/core/src/lib/wallet/index.ts | 26 +- packages/react/README.md | 88 ++-- packages/react/docs/getting-started.md | 393 ++++++++++++++++++ packages/react/package.json | 2 +- packages/react/src/index.ts | 4 +- .../src/v1/components/AppKitErrorBoundary.tsx | 53 +++ .../react/src/v1/hooks/useAppKitWallet.ts | 100 +++++ packages/react/src/v1/hooks/useAuth.ts | 119 +++--- .../react/src/v1/provider/OpChanProvider.tsx | 106 ++--- .../v1/provider/WalletAdapterInitializer.tsx | 59 +++ 18 files changed, 788 insertions(+), 355 deletions(-) delete mode 100644 app/src/providers/OpchanWithAppKit.tsx create mode 100644 packages/core/src/lib/wallet/adapter.ts create mode 100644 packages/react/docs/getting-started.md create mode 100644 packages/react/src/v1/components/AppKitErrorBoundary.tsx create mode 100644 packages/react/src/v1/hooks/useAppKitWallet.ts create mode 100644 packages/react/src/v1/provider/WalletAdapterInitializer.tsx diff --git a/app/src/components/ui/wallet-dialog.tsx b/app/src/components/ui/wallet-dialog.tsx index f8a67e9..8b84bd3 100644 --- a/app/src/components/ui/wallet-dialog.tsx +++ b/app/src/components/ui/wallet-dialog.tsx @@ -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 ( @@ -98,6 +63,10 @@ export function WalletConnectionDialog({ ); } + const isConnected = wallet.isConnected; + const activeChain = wallet.walletType === 'bitcoin' ? 'Bitcoin' : 'Ethereum'; + const activeAddress = wallet.address; + return ( diff --git a/app/src/main.tsx b/app/src/main.tsx index 51ba29f..d496783 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -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( - - - + - - - + ); diff --git a/app/src/providers/OpchanWithAppKit.tsx b/app/src/providers/OpchanWithAppKit.tsx deleted file mode 100644 index 1b5d269..0000000 --- a/app/src/providers/OpchanWithAppKit.tsx +++ /dev/null @@ -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 = ({ 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( - () => ({ - 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 ( - - {children} - - ); -}; diff --git a/packages/core/src/client/OpChanClient.ts b/packages/core/src/client/OpChanClient.ts index 795a298..dc89513 100644 --- a/packages/core/src/client/OpChanClient.ts +++ b/packages/core/src/client/OpChanClient.ts @@ -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); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ff405fb..328d682 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/lib/services/UserIdentityService.ts b/packages/core/src/lib/services/UserIdentityService.ts index eab0451..aa8b2ac 100644 --- a/packages/core/src/lib/services/UserIdentityService.ts +++ b/packages/core/src/lib/services/UserIdentityService.ts @@ -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; diff --git a/packages/core/src/lib/wallet/adapter.ts b/packages/core/src/lib/wallet/adapter.ts new file mode 100644 index 0000000..f4537fd --- /dev/null +++ b/packages/core/src/lib/wallet/adapter.ts @@ -0,0 +1,19 @@ +export interface WalletAdapter { + getAddress(): string | null; + getWalletType(): 'bitcoin' | 'ethereum' | null; + isConnected(): boolean; + signMessage(message: string): Promise; +} + +let currentAdapter: WalletAdapter | null = null; + +export function setWalletAdapter(adapter: WalletAdapter | null): void { + currentAdapter = adapter; +} + +export function getWalletAdapter(): WalletAdapter | null { + return currentAdapter; +} + + + diff --git a/packages/core/src/lib/wallet/config.ts b/packages/core/src/lib/wallet/config.ts index 65bcfc0..5c453e6 100644 --- a/packages/core/src/lib/wallet/config.ts +++ b/packages/core/src/lib/wallet/config.ts @@ -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, }); diff --git a/packages/core/src/lib/wallet/index.ts b/packages/core/src/lib/wallet/index.ts index 7241d96..669311c 100644 --- a/packages/core/src/lib/wallet/index.ts +++ b/packages/core/src/lib/wallet/index.ts @@ -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 { 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'; diff --git a/packages/react/README.md b/packages/react/README.md index 4b64747..f225b4f 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -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 ( - - {/* your app */} - - ); -} - -createRoot(document.getElementById('root')!).render(); -``` - -#### (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 ( - + {/* your app */} - + ); @@ -113,13 +76,14 @@ export function Connect() { ) : ( - + <> + + + )} ); @@ -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`, diff --git a/packages/react/docs/getting-started.md b/packages/react/docs/getting-started.md new file mode 100644 index 0000000..8861e0a --- /dev/null +++ b/packages/react/docs/getting-started.md @@ -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 ( + + + + {children} + + + + ); +} +``` + +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 ( +
+ {currentUser ? ( + <> +
Connected: {currentUser.displayName}
+ + +
Status: {verificationStatus}
+ + ) : ( + <> + + + + )} +
+ ); +} +``` + +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 ( +
+
+ Delegated: {String(delegationInfo.isValid)} + {delegationInfo.expiresAt && `, expires ${delegationInfo.expiresAt.toLocaleString()}`} +
+ + + + +
+ ); +} +``` + +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 ( +
+ {isConnected ? 'Connected' : 'Connecting...'} + • {statusMessage} + +
+ ); +} +``` + +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 ( +
+
Cells: {cells.length}
+
Posts: {posts.length}
+
Comments: {comments.length}
+
Bookmarks: {bookmarks.length}
+
Last sync: {lastSync ? new Date(lastSync).toLocaleTimeString() : '—'}
+
+ ); +} +``` + +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 ( +
+ + + +
+ ); +} +``` + +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 ( +
+ + + {isSyncing && syncing…} +
+ ); +} +``` + +--- + +### 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 ( +
+ {postId && ( + <> + + + + )} + {commentId && ( + <> + + + + )} + {userAddress && ( + <> + + + + )} +
+ ); +} +``` + +--- + +### 10) Bookmarks + +```tsx +import { useContent } from '@opchan/react'; + +function BookmarkControls({ post, comment }: { post?: any; comment?: any }) { + const { bookmarks, togglePostBookmark, toggleCommentBookmark, removeBookmark, clearAllBookmarks } = useContent(); + + return ( +
+
Total: {bookmarks.length}
+ {post && } + {comment && } + + +
+ ); +} +``` + +--- + +### 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 ( +
    +
  • Can create cell: {String(p.canCreateCell)} ({p.reasons.createCell})
  • +
  • Can post: {String(p.canPost)} ({p.reasons.post})
  • +
  • Can comment: {String(p.canComment)} ({p.reasons.comment})
  • +
  • Can vote: {String(p.canVote)} ({p.reasons.vote})
  • +
  • Can moderate: {String(p.canModerate(cellId))} ({p.reasons.moderate(cellId)})
  • +
+ ); +} +``` + +--- + +### 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 Loading…; + return {displayName}; +} +``` + +--- + +### 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 ( +
+
Network: {network.isConnected ? 'Ready' : 'Connecting…'}
+ {user.currentUser ? ( +
Welcome {user.currentUser.displayName}
+ ) : ( + + )} + {permissions.canPost && content.cells[0] && ( + + )} +
    + {content.posts.map(p => ( +
  • {p.title}
  • + ))} +
+
+ ); +} + +export default function App() { + return ( + + + + ); +} +``` + +--- + +### 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. + + diff --git a/packages/react/package.json b/packages/react/package.json index 8a8f8b5..be5a7b7 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -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", diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index a88c4fe..6622b90 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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'; diff --git a/packages/react/src/v1/components/AppKitErrorBoundary.tsx b/packages/react/src/v1/components/AppKitErrorBoundary.tsx new file mode 100644 index 0000000..fdd71b2 --- /dev/null +++ b/packages/react/src/v1/components/AppKitErrorBoundary.tsx @@ -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 || ( +
+

+ Wallet Connection Initializing... +

+

+ The wallet system is still loading. Please wait a moment. +

+
+ ); + } + + return this.props.children; + } +} diff --git a/packages/react/src/v1/hooks/useAppKitWallet.ts b/packages/react/src/v1/hooks/useAppKitWallet.ts new file mode 100644 index 0000000..f8a106a --- /dev/null +++ b/packages/react/src/v1/hooks/useAppKitWallet.ts @@ -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; +} + +export type AppKitWalletValue = AppKitWalletState & AppKitWalletControls; + +export function useAppKitWallet(): AppKitWalletValue { + // Add error boundary for AppKit hooks + let initialized = false; + let appKit: ReturnType | null = null; + let appKitDisconnect: (() => Promise) | null = null; + let bitcoinAccount: ReturnType | null = null; + let ethereumAccount: ReturnType | 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(() => { + 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, + }; +} + + + + diff --git a/packages/react/src/v1/hooks/useAuth.ts b/packages/react/src/v1/hooks/useAuth.ts index 57ca3cc..23192ee 100644 --- a/packages/react/src/v1/hooks/useAuth.ts +++ b/packages/react/src/v1/hooks/useAuth.ts @@ -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 => { - 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 => { - 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 => { 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, diff --git a/packages/react/src/v1/provider/OpChanProvider.tsx b/packages/react/src/v1/provider/OpChanProvider.tsx index a52f645..c018845 100644 --- a/packages/react/src/v1/provider/OpChanProvider.tsx +++ b/packages/react/src/v1/provider/OpChanProvider.tsx @@ -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 = ({ config, walletAdapter, children }) => { +export const OpChanProvider: React.FC = ({ + 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 ( - - - {children} - + + + + + + + {children} + + + + ); }; - - diff --git a/packages/react/src/v1/provider/WalletAdapterInitializer.tsx b/packages/react/src/v1/provider/WalletAdapterInitializer.tsx new file mode 100644 index 0000000..4dcaaef --- /dev/null +++ b/packages/react/src/v1/provider/WalletAdapterInitializer.tsx @@ -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 | 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; +}; + +