diff --git a/app/eslint.config.js b/app/eslint.config.js index c5b5079..c6f4edf 100644 --- a/app/eslint.config.js +++ b/app/eslint.config.js @@ -4,6 +4,8 @@ import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; import tseslint from 'typescript-eslint'; +const tsconfigRootDir = new URL('.', import.meta.url).pathname; + export default tseslint.config( { ignores: ['dist'] }, { @@ -12,6 +14,9 @@ export default tseslint.config( languageOptions: { ecmaVersion: 2020, globals: globals.browser, + parserOptions: { + tsconfigRootDir, + }, }, plugins: { 'react-hooks': reactHooks, diff --git a/app/src/components/CellList.tsx b/app/src/components/CellList.tsx index 809d116..d3892d5 100644 --- a/app/src/components/CellList.tsx +++ b/app/src/components/CellList.tsx @@ -1,6 +1,6 @@ import { useState, useMemo } from 'react'; import { Link } from 'react-router-dom'; -import { useForumData, usePermissions } from '@/hooks'; +import { useContent, usePermissions } from '@/hooks'; import { Layout, MessageSquare, @@ -26,7 +26,7 @@ import { RelevanceIndicator } from './ui/relevance-indicator'; import { ModerationToggle } from './ui/moderation-toggle'; import { sortCells, SortOption } from '@/utils/sorting'; import type { Cell } from '@opchan/core'; -import { useForum } from '@opchan/react'; +import { useForum } from '@/hooks'; import { ShareButton } from './ui/ShareButton'; // Empty State Component @@ -140,8 +140,8 @@ const CellItem: React.FC<{ cell: Cell }> = ({ cell }) => { }; const CellList = () => { - const { cellsWithStats, isInitialLoading } = useForumData(); - const { content } = useForum(); + const { cellsWithStats } = useContent(); + const content = useContent(); const { canCreateCell } = usePermissions(); const [sortOption, setSortOption] = useState('relevance'); @@ -150,7 +150,7 @@ const CellList = () => { return sortCells(cellsWithStats, sortOption); }, [cellsWithStats, sortOption]); - if (isInitialLoading) { + if (!cellsWithStats.length) { return (
@@ -222,12 +222,12 @@ const CellList = () => { variant="outline" size="icon" onClick={content.refresh} - disabled={isInitialLoading} + disabled={false} title="Refresh data" className="px-3 border-cyber-muted/30 text-cyber-neutral hover:bg-cyber-muted/30" > diff --git a/app/src/components/CommentCard.tsx b/app/src/components/CommentCard.tsx index d687675..ed100cc 100644 --- a/app/src/components/CommentCard.tsx +++ b/app/src/components/CommentCard.tsx @@ -1,12 +1,12 @@ import React from 'react'; import { ArrowUp, ArrowDown, Clock, Shield, UserX } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; -import { Comment } from '@opchan/core'; +import type { CommentMessage } from '@opchan/core'; import { Button } from '@/components/ui/button'; import { BookmarkButton } from '@/components/ui/bookmark-button'; import { AuthorDisplay } from '@/components/ui/author-display'; import { MarkdownRenderer } from '@/components/ui/markdown-renderer'; -import { useForum } from '@opchan/react'; +import { useContent, useForum, usePermissions } from '@/hooks'; import { Tooltip, TooltipContent, @@ -15,7 +15,7 @@ import { import { ShareButton } from '@/components/ui/ShareButton'; interface CommentCardProps { - comment: Comment; + comment: CommentMessage; postId: string; cellId?: string; canModerate: boolean; @@ -48,8 +48,8 @@ const CommentCard: React.FC = ({ onUnmoderateComment, onModerateUser, }) => { - const forum = useForum(); - const { content, permissions } = forum; + const content = useContent(); + const permissions = usePermissions(); // Check if bookmarked const isBookmarked = content.bookmarks.some( @@ -58,18 +58,13 @@ const CommentCard: React.FC = ({ const [bookmarkLoading, setBookmarkLoading] = React.useState(false); // Use library pending API - const commentVotePending = content.pending.isVotePending(comment.id); + const commentVotePending = content.pending.isPending(comment.id); // Get user vote status from filtered comment data - const filteredComment = content.filtered.comments.find( - c => c.id === comment.id - ); - const userUpvoted = filteredComment - ? (filteredComment as unknown as { userUpvoted?: boolean }).userUpvoted - : false; - const userDownvoted = filteredComment - ? (filteredComment as unknown as { userDownvoted?: boolean }).userDownvoted - : false; + const userUpvoted = Boolean((comment as unknown as { userUpvoted?: boolean }).userUpvoted); + const userDownvoted = Boolean((comment as unknown as { userDownvoted?: boolean }).userDownvoted); + const score = (comment as unknown as { voteScore?: number }).voteScore ?? 0; + const isModerated = Boolean((comment as unknown as { moderated?: boolean }).moderated); const handleVoteComment = async (isUpvote: boolean) => { await content.vote({ targetId: comment.id, isUpvote }); @@ -100,7 +95,7 @@ const CommentCard: React.FC = ({ > - {comment.voteScore} + {score}
- {canModerate && !comment.moderated && ( + {canModerate && !isModerated && (
-
{displayName}
+
{currentUser?.displayName}
{ - const { currentUser, getDelegationStatus } = useAuth(); + const { currentUser, delegationStatus } = useAuth(); const [delegationInfo, setDelegationInfo] = useState(null); - const network = useNetworkStatus(); - const wakuHealth = { - statusMessage: network.getStatusMessage(), - }; + const {statusMessage} = useNetwork(); + const location = useLocation() const { toast } = useToast(); - const forumContext = useForumContext(); + const { content } = useForum(); const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' }); const ethereumAccount = useAppKitAccount({ namespace: 'eip155' }); @@ -67,10 +64,6 @@ const Header = () => { const isConnected = bitcoinAccount.isConnected || ethereumAccount.isConnected; - useEffect(()=> { - console.log('currentUser', currentUser); - }, []) - // Use currentUser address (which has ENS details) instead of raw AppKit address @@ -83,12 +76,17 @@ const Header = () => { const [walletWizardOpen, setWalletWizardOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const { displayName, verificationLevel } = useUserDisplay(address || ''); + React.useEffect(() => { - getDelegationStatus().then(setDelegationInfo).catch(console.error); - }, [getDelegationStatus]); + delegationStatus().then(setDelegationInfo).catch(console.error); + }, [delegationStatus]); + + useEffect(() => { + console.log({currentUser}) + + }, [currentUser]) // Use LocalDatabase to persist wizard state across navigation const getHasShownWizard = async (): Promise => { @@ -158,14 +156,14 @@ const Header = () => { if (!isConnected) return ; if ( - verificationLevel === EVerificationStatus.ENS_ORDINAL_VERIFIED && + currentUser?.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED && delegationInfo?.isValid ) { return ; - } else if (verificationLevel === EVerificationStatus.WALLET_CONNECTED) { + } else if (currentUser?.verificationStatus === EVerificationStatus.WALLET_CONNECTED) { return ; } else if ( - verificationLevel === EVerificationStatus.ENS_ORDINAL_VERIFIED + currentUser?.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED ) { return ; } else { @@ -195,13 +193,13 @@ const Header = () => {
- {wakuHealth.statusMessage} + {statusMessage} - {forumContext.lastSync && ( + {content.lastSync && (
- {new Date(forumContext.lastSync).toLocaleTimeString([], { + {new Date(content.lastSync).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', })} @@ -225,11 +223,11 @@ const Header = () => { { > {getStatusIcon()} - {verificationLevel === EVerificationStatus.WALLET_UNCONNECTED + {currentUser?.verificationStatus === EVerificationStatus.WALLET_UNCONNECTED ? 'CONNECT' : delegationInfo?.isValid ? 'READY' - : verificationLevel === EVerificationStatus.ENS_ORDINAL_VERIFIED + : currentUser?.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED ? 'EXPIRED' : 'DELEGATE'} @@ -255,7 +253,7 @@ const Header = () => { size="sm" className="flex items-center space-x-2 text-white hover:bg-cyber-muted/30" > -
{displayName}
+
{currentUser?.displayName}
@@ -265,7 +263,7 @@ const Header = () => { >
- {displayName} + {currentUser?.displayName}
{address?.slice(0, 8)}...{address?.slice(-4)} @@ -473,10 +471,10 @@ const Header = () => {
- {wakuHealth.statusMessage} - {forumContext.lastSync && ( + {statusMessage} + {content.lastSync && ( - {new Date(forumContext.lastSync).toLocaleTimeString([], { + {new Date(content.lastSync).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', })} diff --git a/app/src/components/PostCard.tsx b/app/src/components/PostCard.tsx index a20e1c2..cffd9c9 100644 --- a/app/src/components/PostCard.tsx +++ b/app/src/components/PostCard.tsx @@ -2,37 +2,44 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { ArrowUp, ArrowDown, MessageSquare } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; -import type { Post } from '@opchan/core'; -// Removed unused imports +import type { Post, PostMessage } from '@opchan/core'; import { RelevanceIndicator } from '@/components/ui/relevance-indicator'; import { AuthorDisplay } from '@/components/ui/author-display'; import { BookmarkButton } from '@/components/ui/bookmark-button'; import { LinkRenderer } from '@/components/ui/link-renderer'; -import { useForum } from '@opchan/react'; +import { useContent, usePermissions } from '@/hooks'; import { ShareButton } from '@/components/ui/ShareButton'; interface PostCardProps { - post: Post; + post: Post | PostMessage; commentCount?: number; } const PostCard: React.FC = ({ post, commentCount = 0 }) => { - const forum = useForum(); - const { content, permissions } = forum; + const content = useContent(); + const permissions = usePermissions(); // Get cell data from content - const cell = content.cells.find(c => c.id === post.cellId); + const cell = content.cells.find((c) => c.id === post.cellId); const cellName = cell?.name || 'unknown'; - // Use pre-computed vote data - const score = - 'voteScore' in post - ? (post.voteScore as number) - : post.upvotes.length - post.downvotes.length; + // Use pre-computed vote data or safely compute from arrays when available + const computedVoteScore = + 'voteScore' in post && typeof (post as Post).voteScore === 'number' + ? (post as Post).voteScore + : undefined; + const upvoteCount = + 'upvotes' in post && Array.isArray((post as Post).upvotes) + ? (post as Post).upvotes.length + : 0; + const downvoteCount = + 'downvotes' in post && Array.isArray((post as Post).downvotes) + ? (post as Post).downvotes.length + : 0; + const score = computedVoteScore ?? upvoteCount - downvoteCount; // Use library pending API const isPending = content.pending.isPending(post.id); - const votePending = content.pending.isVotePending(post.id); // Get user vote status from post data const userUpvoted = @@ -41,18 +48,17 @@ const PostCard: React.FC = ({ post, commentCount = 0 }) => { (post as unknown as { userDownvoted?: boolean }).userDownvoted || false; // Check if bookmarked - const isBookmarked = content.bookmarks.some( - b => b.targetId === post.id && b.type === 'post' - ); + const isBookmarked = content.bookmarks.some((b) => b.targetId === post.id && b.type === 'post'); const [bookmarkLoading, setBookmarkLoading] = React.useState(false); // Remove duplicate vote status logic // ✅ Content truncation (simple presentation logic is OK) + const contentText = typeof post.content === 'string' ? post.content : String(post.content ?? ''); const contentPreview = - post.content.length > 200 - ? post.content.substring(0, 200) + '...' - : post.content; + contentText.length > 200 + ? contentText.substring(0, 200) + '...' + : contentText; const handleVote = async (e: React.MouseEvent, isUpvote: boolean) => { e.preventDefault(); @@ -114,7 +120,7 @@ const PostCard: React.FC = ({ post, commentCount = 0 }) => { > - {votePending && ( + {isPending && ( syncing… )}
@@ -140,12 +146,12 @@ const PostCard: React.FC = ({ post, commentCount = 0 }) => { addSuffix: true, })} - {post.relevanceScore !== undefined && ( + {('relevanceScore' in post) && typeof (post as Post).relevanceScore === 'number' && ( <> { @@ -29,21 +26,19 @@ const PostDetail = () => { const navigate = useNavigate(); // Use aggregated forum API - const forum = useForum(); - const { content, permissions } = forum; + const content = useContent(); + const permissions = usePermissions(); // Get post and comments using focused hooks - const post = usePost(postId); - const comments = usePostComments(postId); + const post = content.posts.find((p) => p.id === postId); + const visibleComments = postId ? content.commentsByPost[postId] ?? [] : []; // Use library pending API const postPending = content.pending.isPending(post?.id); - const postVotePending = content.pending.isVotePending(post?.id); + const postVotePending = content.pending.isPending(post?.id); // Check if bookmarked - const isBookmarked = content.bookmarks.some( - b => b.targetId === post?.id && b.type === 'post' - ); + const isBookmarked = content.bookmarks.some((b) => b.targetId === post?.id && b.type === 'post'); const [bookmarkLoading, setBookmarkLoading] = React.useState(false); const [newComment, setNewComment] = useState(''); @@ -51,7 +46,7 @@ const PostDetail = () => { if (!postId) return
Invalid post ID
; // ✅ Loading state handled by hook - if (comments.isLoading) { + if (postPending) { return (
@@ -77,8 +72,7 @@ const PostDetail = () => { } // ✅ All data comes pre-computed from hooks - const { cell } = post; - const visibleComments = comments.comments; // Already filtered by hook + const cell = content.cells.find((c: ForumCell) => c.id === post?.cellId); const handleCreateComment = async (e: React.FormEvent) => { e.preventDefault(); @@ -121,11 +115,11 @@ const PostDetail = () => { } }; - // Get vote status from post data - const isPostUpvoted = - (post as unknown as { userUpvoted?: boolean }).userUpvoted || false; - const isPostDownvoted = - (post as unknown as { userDownvoted?: boolean }).userDownvoted || false; + // Get vote status from post data (enhanced posts only) + const enhanced = post as unknown as { userUpvoted?: boolean; userDownvoted?: boolean; voteScore?: number }; + const isPostUpvoted = Boolean(enhanced.userUpvoted); + const isPostDownvoted = Boolean(enhanced.userDownvoted); + const score = typeof enhanced.voteScore === 'number' ? enhanced.voteScore : 0; const handleModerateComment = async (commentId: string) => { const reason = @@ -176,7 +170,7 @@ const PostDetail = () => { > - {post.voteScore} + {score}
) : ( - visibleComments.map(comment => ( + visibleComments.map((comment) => ( { const { cellId } = useParams<{ cellId: string }>(); // ✅ Use reactive hooks for data and actions - const cell = useCell(cellId); - const cellPosts = useCellPosts(cellId, { sortBy: 'relevance' }); - const forum = useForum(); - const { createPost, vote, moderate, refresh } = forum.content; + const { createPost, vote, moderate, refresh, commentsByPost, cells, posts } = useContent(); + const cell = cells.find((c: ForumCell) => c.id === cellId); const isCreatingPost = false; const isVoting = false; const { canPost, canVote, canModerate } = usePermissions(); - const userVotes = useUserVotes(); const { currentUser } = useAuth(); - const { commentsByPost } = useForumData(); const [newPostTitle, setNewPostTitle] = useState(''); const [newPostContent, setNewPostContent] = useState(''); - if (!cellId || cellPosts.isLoading) { + if (!cellId) { return (
@@ -125,16 +121,26 @@ const PostList = () => { }; const handleVotePost = async (postId: string, isUpvote: boolean) => { - // ✅ Permission checking handled in hook await vote({ targetId: postId, isUpvote }); }; const getPostVoteType = (postId: string) => { - return userVotes.getPostVoteType(postId); + if (!currentUser) return null; + const p = posts.find((p: ForumPost) => p.id === postId); + if (!p) return null; + const up = p.upvotes.some((v: VoteMessage) => v.author === currentUser.address); + const down = p.downvotes.some((v: VoteMessage) => v.author === currentUser.address); + return up ? 'upvote' : down ? 'downvote' : null; }; // ✅ Posts already filtered by hook based on user permissions - const visiblePosts = cellPosts.posts; + const visiblePosts = posts + .filter((p: ForumPost) => p.cellId === cellId) + .sort((a: ForumPost, b: ForumPost) => { + const ar = a.relevanceScore ?? 0; + const br = b.relevanceScore ?? 0; + return br - ar || b.timestamp - a.timestamp; + }); const handleModerate = async (postId: string) => { const reason = @@ -184,12 +190,10 @@ const PostList = () => { variant="outline" size="icon" onClick={refresh} - disabled={cellPosts.isLoading} + disabled={false} title="Refresh data" > - +

{cell.description}

@@ -264,7 +268,7 @@ const PostList = () => {

) : ( - visiblePosts.map(post => ( + visiblePosts.map((post: ForumPost) => (
diff --git a/app/src/components/ui/author-display.tsx b/app/src/components/ui/author-display.tsx index 04b0bf7..75cd87d 100644 --- a/app/src/components/ui/author-display.tsx +++ b/app/src/components/ui/author-display.tsx @@ -1,6 +1,6 @@ import { Badge } from '@/components/ui/badge'; import { Shield, Crown, Hash } from 'lucide-react'; -import { useUserDisplay } from '@/hooks'; +import { useUserDisplay } from '@opchan/react'; interface AuthorDisplayProps { address: string; diff --git a/app/src/components/ui/bookmark-card.tsx b/app/src/components/ui/bookmark-card.tsx index 77f8580..21dee46 100644 --- a/app/src/components/ui/bookmark-card.tsx +++ b/app/src/components/ui/bookmark-card.tsx @@ -9,7 +9,7 @@ import { ExternalLink, } from 'lucide-react'; import { Bookmark, BookmarkType } from '@opchan/core'; -import { useUserDisplay } from '@/hooks'; +import { useUserDisplay } from '@opchan/react'; import { cn } from '../../utils' import { formatDistanceToNow } from 'date-fns'; import { useNavigate } from 'react-router-dom'; diff --git a/app/src/components/ui/moderation-toggle.tsx b/app/src/components/ui/moderation-toggle.tsx index defc793..ac98d87 100644 --- a/app/src/components/ui/moderation-toggle.tsx +++ b/app/src/components/ui/moderation-toggle.tsx @@ -1,13 +1,15 @@ import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Eye, EyeOff } from 'lucide-react'; -import { useModeration } from '@opchan/react'; -import { usePermissions, useForumData } from '@opchan/react'; +import React from 'react'; +import { usePermissions, useContent, useUIState } from '@/hooks'; export function ModerationToggle() { - const { showModerated, toggleShowModerated } = useModeration(); const { canModerate } = usePermissions(); - const { cellsWithStats } = useForumData(); + const { cellsWithStats } = useContent(); + + const [showModerated, setShowModerated] = useUIState('showModerated', false); + const toggleShowModerated = React.useCallback((value: boolean) => setShowModerated(value), [setShowModerated]); // Check if user is admin of any cell const isAdminOfAnyCell = cellsWithStats.some(cell => canModerate(cell.id)); diff --git a/app/src/components/ui/waku-health-indicator.tsx b/app/src/components/ui/waku-health-indicator.tsx index 2c67de0..343a9cf 100644 --- a/app/src/components/ui/waku-health-indicator.tsx +++ b/app/src/components/ui/waku-health-indicator.tsx @@ -1,5 +1,5 @@ import { Wifi, WifiOff, CheckCircle } from 'lucide-react'; -import { useNetworkStatus } from '@opchan/react'; +import { useNetwork } from '@opchan/react'; import { cn } from '../../utils' interface WakuHealthIndicatorProps { @@ -13,21 +13,15 @@ export function WakuHealthIndicator({ showText = true, size = 'md', }: WakuHealthIndicatorProps) { - const network = useNetworkStatus(); - const connectionStatus = network.health.isConnected - ? 'connected' - : 'disconnected'; - const statusColor = network.getHealthColor(); - const statusMessage = network.getStatusMessage(); + const {isConnected, statusMessage} = useNetwork(); const getIcon = () => { - switch (connectionStatus) { - case 'connected': - return ; - case 'disconnected': - return ; - default: - return ; + if (isConnected === true) { + return ; + } else if (isConnected === false) { + return ; + } else { + return ; } }; @@ -49,9 +43,9 @@ export function WakuHealthIndicator({ {statusMessage} @@ -66,19 +60,19 @@ export function WakuHealthIndicator({ * Useful for compact displays like headers or status bars */ export function WakuHealthDot({ className }: { className?: string }) { - const { getHealthColor } = useNetworkStatus(); - const statusColor = getHealthColor(); + const { isConnected } = useNetwork(); + const statusColor = isConnected === true ? 'green' : isConnected === false ? 'red' : 'gray'; return (
); } diff --git a/app/src/components/ui/wallet-wizard.tsx b/app/src/components/ui/wallet-wizard.tsx index 985ef84..778cc17 100644 --- a/app/src/components/ui/wallet-wizard.tsx +++ b/app/src/components/ui/wallet-wizard.tsx @@ -9,7 +9,7 @@ import { import { Button } from '@/components/ui/button'; import { CheckCircle, Circle, Loader2 } from 'lucide-react'; import { useAuth } from '@/hooks'; -import { EVerificationStatus, DelegationFullStatus } from '@opchan/core'; +import { EVerificationStatus } from '@opchan/core'; import { WalletConnectionStep } from './wallet-connection-step'; import { VerificationStep } from './verification-step'; import { DelegationStep } from './delegation-step'; @@ -29,12 +29,8 @@ export function WalletWizard({ }: WalletWizardProps) { const [currentStep, setCurrentStep] = React.useState(1); const [isLoading, setIsLoading] = React.useState(false); - const { isAuthenticated, verificationStatus, getDelegationStatus } = useAuth(); - const [delegationStatus, setDelegationStatus] = React.useState(null); - - React.useEffect(() => { - getDelegationStatus().then(setDelegationStatus).catch(console.error); - }, [getDelegationStatus]); + const [delegationStatus, setDelegationStatus] = React.useState(false); + const { isAuthenticated, verificationStatus, delegationStatus: getDelegationStatus } = useAuth(); // Reset wizard when opened - always start at step 1 for simplicity React.useEffect(() => { @@ -44,6 +40,17 @@ export function WalletWizard({ } }, [open]); + // Load delegation status when component mounts or when user changes + React.useEffect(() => { + if (isAuthenticated) { + getDelegationStatus().then(status => { + setDelegationStatus(status.isValid); + }).catch(console.error); + } else { + setDelegationStatus(false); + } + }, [isAuthenticated, getDelegationStatus]); + const handleStepComplete = (step: WizardStep) => { if (step < 3) { setCurrentStep((step + 1) as WizardStep); @@ -68,7 +75,7 @@ export function WalletWizard({ case 2: return verificationStatus !== EVerificationStatus.WALLET_UNCONNECTED; case 3: - return delegationStatus?.isValid ?? false; + return delegationStatus; default: return false; } diff --git a/app/src/hooks/index.ts b/app/src/hooks/index.ts index 9768731..6c57bdb 100644 --- a/app/src/hooks/index.ts +++ b/app/src/hooks/index.ts @@ -1,52 +1,10 @@ export { - useForumData, - useAuth, - useUserDisplay, - useBookmarks, - usePostBookmark, - useCommentBookmark, -} from '@opchan/react'; - -export type { - ForumData, - CellWithStats, - PostWithVoteStatus, - CommentWithVoteStatus, - Permission, - PermissionReasons, - PermissionResult, - UserDisplayInfo, -} from '@opchan/react'; - -export { useCell, usePost } from '@opchan/react'; -export type { CellData, PostData } from '@opchan/react'; - -export { useCellPosts, usePostComments, useUserVotes } from '@opchan/react'; -export type { - CellPostsOptions, - CellPostsData, - PostCommentsOptions, - PostCommentsData, - UserVoteData, -} from '@opchan/react'; - - -export { + useAuth , + useForum , + useNetwork, usePermissions, - useNetworkStatus, - useForumSelectors, - useWallet, -} from '@opchan/react'; -export type { - NetworkHealth, - SyncStatus, - ConnectionStatus, - NetworkStatusData, - ForumSelectors, + useContent, + useUIState, } from '@opchan/react'; - -export { useIsMobile as useMobile } from './use-mobile'; -export { useToast } from './use-toast'; - diff --git a/app/src/main.tsx b/app/src/main.tsx index c672c96..ce6d4c9 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -2,7 +2,7 @@ import { createRoot } from 'react-dom/client'; import App from './App.tsx'; import './index.css'; import { Buffer } from 'buffer'; -import { OpChanProvider } from '@opchan/react'; +import { OpchanWithAppKit } from './providers/OpchanWithAppKit'; import { WagmiProvider } from 'wagmi'; import { AppKitProvider } from '@reown/appkit/react'; import { appkitConfig, config } from '@opchan/core'; @@ -14,12 +14,9 @@ if (!(window as Window & typeof globalThis).Buffer) { createRoot(document.getElementById('root')!).render( - + - + ); diff --git a/app/src/pages/BookmarksPage.tsx b/app/src/pages/BookmarksPage.tsx index 5c5c7f1..48fabcd 100644 --- a/app/src/pages/BookmarksPage.tsx +++ b/app/src/pages/BookmarksPage.tsx @@ -16,7 +16,6 @@ import { AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; -import { useBookmarks } from '@/hooks'; import { Bookmark, BookmarkType } from '@opchan/core'; import { Trash2, @@ -24,19 +23,12 @@ import { FileText, MessageSquare, } from 'lucide-react'; -import { useAuth } from '@opchan/react'; +import { useAuth, useContent } from '@/hooks'; const BookmarksPage = () => { const { currentUser } = useAuth(); const navigate = useNavigate(); - const { - bookmarks, - loading, - error, - removeBookmark, - getBookmarksByType, - clearAllBookmarks, - } = useBookmarks(); + const { bookmarks, removeBookmark, clearAllBookmarks } = useContent(); const [activeTab, setActiveTab] = useState<'all' | 'posts' | 'comments'>( 'all' @@ -61,8 +53,8 @@ const BookmarksPage = () => { ); } - const postBookmarks = getBookmarksByType(BookmarkType.POST); - const commentBookmarks = getBookmarksByType(BookmarkType.COMMENT); + const postBookmarks = bookmarks.filter(bookmark => bookmark.type === BookmarkType.POST); + const commentBookmarks = bookmarks.filter(bookmark => bookmark.type === BookmarkType.COMMENT); const getFilteredBookmarks = () => { switch (activeTab) { @@ -87,36 +79,6 @@ const BookmarksPage = () => { await clearAllBookmarks(); }; - if (loading) { - return ( -
-
-
-
-
-

Loading bookmarks...

-
-
-
- ); - } - - if (error) { - return ( -
-
-
-
-

- Error Loading Bookmarks -

-

{error}

- -
-
-
- ); - } return (
diff --git a/app/src/pages/FeedPage.tsx b/app/src/pages/FeedPage.tsx index 9c5d2db..32357ce 100644 --- a/app/src/pages/FeedPage.tsx +++ b/app/src/pages/FeedPage.tsx @@ -12,36 +12,23 @@ import { import PostCard from '@/components/PostCard'; import FeedSidebar from '@/components/FeedSidebar'; import { ModerationToggle } from '@/components/ui/moderation-toggle'; -import { useForumData, useAuth } from '@/hooks'; -import { useForum } from '@opchan/react'; +import { useAuth, useContent } from '@/hooks'; import { EVerificationStatus } from '@opchan/core'; import { sortPosts, SortOption } from '@/utils/sorting'; const FeedPage: React.FC = () => { - const forumData = useForumData(); + const content = useContent(); const { verificationStatus } = useAuth(); - const { content } = useForum(); const [sortOption, setSortOption] = useState('relevance'); - const { - filteredPosts, - filteredCommentsByPost, - isInitialLoading, - isRefreshing, - } = forumData; - // ✅ Use pre-computed filtered data - const allPosts = useMemo(() => { - return sortPosts(filteredPosts, sortOption); - }, [filteredPosts, sortOption]); + // Build sorted posts from content slices + const allPosts = useMemo(() => sortPosts([...content.posts], sortOption), [content.posts, sortOption]); // ✅ Get comment count from filtered organized data - const getCommentCount = (postId: string) => { - const comments = filteredCommentsByPost[postId] || []; - return comments.length; - }; + const getCommentCount = (postId: string) => (content.commentsByPost[postId] || []).length; // Loading skeleton - if (isInitialLoading) { + if (!content.posts.length && !content.comments.length && !content.cells.length) { return (
@@ -137,12 +124,10 @@ const FeedPage: React.FC = () => { variant="outline" size="sm" onClick={content.refresh} - disabled={isRefreshing} + disabled={false} className="flex items-center space-x-2" > - + Refresh
diff --git a/app/src/pages/Index.tsx b/app/src/pages/Index.tsx index ccf0bb0..6975661 100644 --- a/app/src/pages/Index.tsx +++ b/app/src/pages/Index.tsx @@ -2,7 +2,7 @@ import Header from '@/components/Header'; import CellList from '@/components/CellList'; import { Button } from '@/components/ui/button'; import { Wifi } from 'lucide-react'; -import { useForum } from '@opchan/react'; +import { useForum } from '@/hooks'; const Index = () => { const { network, content } = useForum(); diff --git a/app/src/pages/ProfilePage.tsx b/app/src/pages/ProfilePage.tsx index 7d42917..75a904f 100644 --- a/app/src/pages/ProfilePage.tsx +++ b/app/src/pages/ProfilePage.tsx @@ -1,8 +1,6 @@ -import { useState, useEffect } from 'react'; +import { useState} from 'react'; import { useForum } from '@opchan/react'; import { useAuth } from '@opchan/react'; -import { useUserDisplay } from '@/hooks'; -import { DelegationFullStatus } from '@opchan/core'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -42,19 +40,9 @@ export default function ProfilePage() { const { toast } = useToast(); // Get current user from auth context for the address - const { currentUser, getDelegationStatus } = useAuth(); - const [delegationInfo, setDelegationInfo] = - useState(null); + const { currentUser, delegation } = useAuth(); const address = currentUser?.address; - // Load delegation status - useEffect(() => { - getDelegationStatus().then(setDelegationInfo).catch(console.error); - }, [getDelegationStatus]); - - // Get comprehensive user information from the unified hook - const userInfo = useUserDisplay(address || ''); - // Debug current user ENS info console.log('📋 Profile page debug:', { address, @@ -65,8 +53,7 @@ export default function ProfilePage() { ensDetails: currentUser.ensDetails, verificationStatus: currentUser.verificationStatus, } - : null, - userInfo, + : null }); const [isEditing, setIsEditing] = useState(false); @@ -77,21 +64,6 @@ export default function ProfilePage() { ); const [walletWizardOpen, setWalletWizardOpen] = useState(false); - // Initialize and update local state when user data changes - useEffect(() => { - if (currentUser) { - // Use the same data source as the display (userInfo) for consistency - const currentCallSign = userInfo.callSign || currentUser.callSign || ''; - const currentDisplayPreference = - userInfo.displayPreference || - currentUser.displayPreference || - EDisplayPreference.WALLET_ADDRESS; - - setCallSign(currentCallSign); - setDisplayPreference(currentDisplayPreference); - } - }, [currentUser, userInfo.callSign, userInfo.displayPreference]); - // Copy to clipboard function const copyToClipboard = async (text: string, label: string) => { try { @@ -191,9 +163,9 @@ export default function ProfilePage() { const handleCancel = () => { // Reset to the same data source as display for consistency - const currentCallSign = userInfo.callSign || currentUser.callSign || ''; + const currentCallSign = currentUser.callSign || currentUser.callSign || ''; const currentDisplayPreference = - userInfo.displayPreference || + currentUser.displayPreference || currentUser.displayPreference || EDisplayPreference.WALLET_ADDRESS; @@ -204,7 +176,7 @@ export default function ProfilePage() { const getVerificationIcon = () => { // Use verification level from UserIdentityService (central database store) - switch (userInfo.verificationLevel) { + switch (currentUser.verificationStatus) { case EVerificationStatus.ENS_ORDINAL_VERIFIED: return ; case EVerificationStatus.WALLET_CONNECTED: @@ -218,7 +190,7 @@ export default function ProfilePage() { const getVerificationText = () => { // Use verification level from UserIdentityService (central database store) - switch (userInfo.verificationLevel) { + switch (currentUser.verificationStatus) { case EVerificationStatus.ENS_ORDINAL_VERIFIED: return 'Owns ENS or Ordinal'; case EVerificationStatus.WALLET_CONNECTED: @@ -232,7 +204,7 @@ export default function ProfilePage() { const getVerificationColor = () => { // Use verification level from UserIdentityService (central database store) - switch (userInfo.verificationLevel) { + switch (currentUser.verificationStatus) { case EVerificationStatus.ENS_ORDINAL_VERIFIED: return 'bg-green-100 text-green-800 border-green-200'; case EVerificationStatus.WALLET_CONNECTED: @@ -290,36 +262,30 @@ export default function ProfilePage() {
- {userInfo.displayName} + {currentUser.displayName}
{/* Show ENS name if available */} - {(userInfo.ensName || - currentUser?.ensDetails?.ensName) && ( + {(currentUser.ensDetails?.ensName ) && (
ENS:{' '} - {userInfo.ensName || - currentUser?.ensDetails?.ensName} + {currentUser.ensDetails?.ensName}
)} {/* Show Ordinal details if available */} - {(userInfo.ordinalDetails || - currentUser?.ordinalDetails?.ordinalDetails) && ( + {(currentUser.ordinalDetails ) && (
Ordinal:{' '} - {userInfo.ordinalDetails || - currentUser?.ordinalDetails?.ordinalDetails} + {currentUser.ordinalDetails.ordinalDetails}
)} {/* Show fallback if neither ENS nor Ordinal */} {!( - userInfo.ensName || currentUser?.ensDetails?.ensName + currentUser.ensDetails?.ensName ) && !( - userInfo.ordinalDetails || - currentUser?.ordinalDetails?.ordinalDetails + currentUser.ordinalDetails?.ordinalDetails ) &&
No ENS or Ordinal verification
} -
{getVerificationIcon()} @@ -329,6 +295,7 @@ export default function ProfilePage() {
+
{/* Wallet Section */}
@@ -398,7 +365,7 @@ export default function ProfilePage() { /> ) : (
- {userInfo.callSign || + {currentUser.callSign || currentUser.callSign || 'Not set'}
@@ -444,7 +411,7 @@ export default function ProfilePage() { ) : (
- {(userInfo.displayPreference || + {(currentUser.displayPreference || displayPreference) === EDisplayPreference.CALL_SIGN ? 'Call Sign (when available)' @@ -494,8 +461,7 @@ export default function ProfilePage() { Security
- {(forum.user.delegation.hasDelegation || - delegationInfo?.hasDelegation) && ( + {delegation.hasDelegation && ( )} @@ -518,35 +482,25 @@ export default function ProfilePage() { Delegation - {forum.user.delegation.isValid || delegationInfo?.isValid - ? 'Active' - : 'Inactive'} + {delegation.isValid ? 'Active' : 'Inactive'}
{/* Expiry Date */} - {(forum.user.delegation.expiresAt || - currentUser.delegationExpiry) && ( + {delegation.expiresAt && (
Valid until
- {( - forum.user.delegation.expiresAt || - new Date(currentUser.delegationExpiry!) - ).toLocaleDateString()} + {delegation.expiresAt.toLocaleDateString()}
)} @@ -559,16 +513,12 @@ export default function ProfilePage() { - {forum.user.delegation.isValid || - currentUser.delegationSignature === 'valid' - ? 'Valid' - : 'Not signed'} + {delegation.isValid ? 'Valid' : 'Not signed'}
@@ -580,19 +530,17 @@ export default function ProfilePage() {
- {forum.user.delegation.publicKey || currentUser.browserPubKey - ? `${(forum.user.delegation.publicKey || currentUser.browserPubKey!).slice(0, 12)}...${(forum.user.delegation.publicKey || currentUser.browserPubKey!).slice(-8)}` + {delegation.publicKey + ? `${delegation.publicKey.slice(0, 12)}...${delegation.publicKey.slice(-8)}` : 'Not delegated'}
- {(forum.user.delegation.publicKey || - currentUser.browserPubKey) && ( + {delegation.publicKey && (
{/* Warning for expired delegation */} - {(!forum.user.delegation.isValid && - forum.user.delegation.hasDelegation) || - (!delegationInfo?.isValid && - delegationInfo?.hasDelegation && ( + {(!delegation.isValid && delegation.hasDelegation) && (
@@ -618,7 +563,7 @@ export default function ProfilePage() {
- ))} + )}
diff --git a/app/src/providers/OpchanWithAppKit.tsx b/app/src/providers/OpchanWithAppKit.tsx new file mode 100644 index 0000000..fafc55a --- /dev/null +++ b/app/src/providers/OpchanWithAppKit.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { OpChanProvider, type WalletAdapter, type WalletAdapterAccount } from '@opchan/react'; +import { useAppKitAccount } from '@reown/appkit/react'; +import type { OpChanClientConfig } 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 (e) { /* ignore */ } + }); + }, [getCurrent]); + + return ( + + {children} + + ); +}; + + diff --git a/packages/core/src/lib/database/LocalDatabase.ts b/packages/core/src/lib/database/LocalDatabase.ts index f2533c5..ee2685c 100644 --- a/packages/core/src/lib/database/LocalDatabase.ts +++ b/packages/core/src/lib/database/LocalDatabase.ts @@ -14,7 +14,7 @@ import { } from '../../types/waku'; import { OpchanMessage } from '../../types/forum'; import { MessageValidator } from '../utils/MessageValidator'; -import { EVerificationStatus, User } from '../../types/identity'; +import { EDisplayPreference, EVerificationStatus, User } from '../../types/identity'; import { DelegationInfo } from '../delegation/types'; import { openLocalDB, STORE, StoreName } from '../database/schema'; import { Bookmark, BookmarkCache } from '../../types/forum'; @@ -648,21 +648,17 @@ export class LocalDatabase { address: string, record: Partial & { lastUpdated?: number } ): Promise { + // Retrieve existing identity or initialize with proper defaults const existing: UserIdentityCache[string] = this.cache.userIdentities[address] || - ({ + { ensName: undefined, ordinalDetails: undefined, callSign: undefined, - displayPreference: EVerificationStatus.WALLET_UNCONNECTED - ? (undefined as never) - : (undefined as never), - // We'll set displayPreference when we receive a profile update; leave as - // WALLET_ADDRESS by default for correctness. - // Casting below ensures the object satisfies the interface at compile time. + displayPreference: EDisplayPreference.WALLET_ADDRESS, lastUpdated: 0, verificationStatus: EVerificationStatus.WALLET_UNCONNECTED, - } as unknown as UserIdentityCache[string]); + }; const merged: UserIdentityCache[string] = { ...existing, @@ -671,7 +667,7 @@ export class LocalDatabase { existing.lastUpdated ?? 0, record.lastUpdated ?? Date.now() ), - } as UserIdentityCache[string]; + }; this.cache.userIdentities[address] = merged; this.put(STORE.USER_IDENTITIES, { address, ...merged }); diff --git a/packages/core/src/types/identity.ts b/packages/core/src/types/identity.ts index d3f30cc..5341ff7 100644 --- a/packages/core/src/types/identity.ts +++ b/packages/core/src/types/identity.ts @@ -5,9 +5,9 @@ export type User = { ordinalDetails?: OrdinalDetails; ensDetails?: EnsDetails; - //TODO: implement call sign & display preference setup callSign?: string; displayPreference: EDisplayPreference; + displayName: string; verificationStatus: EVerificationStatus; diff --git a/packages/react/eslint.config.js b/packages/react/eslint.config.js new file mode 100644 index 0000000..900b08f --- /dev/null +++ b/packages/react/eslint.config.js @@ -0,0 +1,27 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['src/**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + parserOptions: { + tsconfigRootDir: new URL('.', import.meta.url).pathname, + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, + } +); + + + + diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index cb74b9f..a7f68a3 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,20 +1,19 @@ -// New v1 exports are namespaced to avoid breaking the app while we migrate. -// Old API remains available under ./old/index exports. -export * from './old/index'; export { - OpChanProvider as OpChanProviderV1, - useClient as useClientV1, + OpChanProvider as ClientProvider , + useClient , } from './v1/context/ClientContext'; -export { OpChanProvider as NewOpChanProvider } from './v1/provider/OpChanProvider'; +export { OpChanProvider } from './v1/provider/OpChanProvider'; +export type { WalletAdapter, WalletAdapterAccount } from './v1/provider/OpChanProvider'; -export { useAuth as useAuthV1 } from './v1/hooks/useAuth'; -export { useContent as useContentV1 } from './v1/hooks/useContent'; -export { usePermissions as usePermissionsV1 } from './v1/hooks/usePermissions'; -export { useNetwork as useNetworkV1 } from './v1/hooks/useNetwork'; -export { useUserDisplay as useUserDisplayV1 } from './v1/hooks/useUserDisplay'; -export { useForum as useForumV1 } from './v1/hooks/useForum'; +export { useAuth } from './v1/hooks/useAuth'; +export { useContent } from './v1/hooks/useContent'; +export { usePermissions } from './v1/hooks/usePermissions'; +export { useNetwork } from './v1/hooks/useNetwork'; +export { useForum } from './v1/hooks/useForum'; +export { useUIState } from './v1/hooks/useUIState'; +export { useUserDisplay } from './v1/hooks/useUserDisplay'; diff --git a/packages/react/src/old/contexts/AuthContext.tsx b/packages/react/src/old/contexts/AuthContext.tsx deleted file mode 100644 index a6969c8..0000000 --- a/packages/react/src/old/contexts/AuthContext.tsx +++ /dev/null @@ -1,379 +0,0 @@ -import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { User, EVerificationStatus, EDisplayPreference } from '@opchan/core'; -import { delegationManager, localDatabase } from '@opchan/core'; -import { DelegationDuration } from '@opchan/core'; -import { useAppKitAccount } from '@reown/appkit/react'; -import { useClient } from './ClientContext'; - -// Extend the base User with convenient, display-focused fields -export type CurrentUser = User & { - displayName: string; - ensName?: string; - ordinalDetailsText?: string; -}; - -export interface AuthContextValue { - currentUser: CurrentUser | null; - isAuthenticated: boolean; - isAuthenticating: boolean; - verificationStatus: EVerificationStatus; - - connectWallet: () => Promise; - disconnectWallet: () => void; - verifyOwnership: () => Promise; - - delegateKey: (duration?: DelegationDuration) => Promise; - getDelegationStatus: () => ReturnType; - clearDelegation: () => Promise; - - signMessage: (message: unknown, statusCallback?: { - onSent?: (messageId: string) => void; - onAcknowledged?: (messageId: string) => void; - onError?: (messageId: string, error: string) => void; - }) => Promise; - verifyMessage: (message: unknown) => Promise; -} - -const AuthContext = createContext(null); - -export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const client = useClient(); - const [currentUser, setCurrentUser] = useState(null); - const [isAuthenticating, setIsAuthenticating] = useState(false); - - // Get wallet connection status from AppKit - const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' }); - const ethereumAccount = useAppKitAccount({ namespace: 'eip155' }); - - const isWalletConnected = bitcoinAccount.isConnected || ethereumAccount.isConnected; - const connectedAddress = bitcoinAccount.address || ethereumAccount.address; - const walletType = bitcoinAccount.isConnected ? 'bitcoin' : 'ethereum'; - - // Helper: enrich a base User with identity-derived display fields - const enrichUserWithIdentity = useCallback(async (baseUser: User): Promise => { - const address = baseUser.address; - // Resolve identity (debounced) and read display name from service - const identity = await client.userIdentityService.getUserIdentity(address); - const displayName = client.userIdentityService.getDisplayName(address); - - const ensName = identity?.ensName ?? baseUser.ensDetails?.ensName; - const ordinalDetailsText = identity?.ordinalDetails?.ordinalDetails ?? baseUser.ordinalDetails?.ordinalDetails; - const callSign = identity?.callSign ?? baseUser.callSign; - const displayPreference = identity?.displayPreference ?? baseUser.displayPreference; - const verificationStatus = identity?.verificationStatus ?? baseUser.verificationStatus; - - return { - ...baseUser, - callSign, - displayPreference, - verificationStatus, - displayName, - ensName, - ordinalDetailsText, - } as CurrentUser; - }, [client]); - - // ✅ Removed console.log to prevent infinite loop spam - - // Define verifyOwnership function early so it can be used in useEffect dependencies - const verifyOwnership = useCallback(async (): Promise => { - if (!currentUser) { - return false; - } - - try { - // Centralize identity resolution in core service - const identity = await client.userIdentityService.getUserIdentityFresh(currentUser.address); - - const newVerificationStatus = identity?.verificationStatus ?? EVerificationStatus.WALLET_CONNECTED; - - const updatedUser: User = { - ...currentUser, - verificationStatus: newVerificationStatus, - ensDetails: identity?.ensName ? { ensName: identity.ensName } : undefined, - ordinalDetails: identity?.ordinalDetails, - } as User; - - const enriched = await enrichUserWithIdentity(updatedUser); - setCurrentUser(enriched); - await localDatabase.storeUser(updatedUser); - - await localDatabase.upsertUserIdentity(currentUser.address, { - ensName: identity?.ensName || undefined, - ordinalDetails: identity?.ordinalDetails, - verificationStatus: newVerificationStatus, - lastUpdated: Date.now(), - }); - - return newVerificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED; - } catch (error) { - console.error('❌ Verification failed:', error); - const updatedUser = { ...currentUser, verificationStatus: EVerificationStatus.WALLET_CONNECTED } as User; - const enriched = await enrichUserWithIdentity(updatedUser); - setCurrentUser(enriched); - await localDatabase.storeUser(updatedUser); - return false; - } - }, [client, currentUser, enrichUserWithIdentity]); - - // Hydrate user from LocalDatabase on mount - useEffect(() => { - let mounted = true; - const load = async () => { - try { - const user = await localDatabase.loadUser(); - if (mounted && user) { - const enriched = await enrichUserWithIdentity(user); - setCurrentUser(enriched); - - // 🔄 Sync verification status with UserIdentityService - await localDatabase.upsertUserIdentity(user.address, { - ensName: user.ensDetails?.ensName, - ordinalDetails: user.ordinalDetails, - verificationStatus: user.verificationStatus, - lastUpdated: Date.now(), - }); - - // 🔄 Check if verification status needs updating on load - // If user has ENS details but verification status is outdated, auto-verify - if (user.ensDetails?.ensName && user.verificationStatus !== EVerificationStatus.ENS_ORDINAL_VERIFIED) { - try { - await verifyOwnership(); - } catch (error) { - console.error('❌ Auto-verification on load failed:', error); - } - } - } - } catch (e) { - console.error('❌ Failed to load user from database:', e); - } - }; - load(); - return () => { - mounted = false; - }; - }, [enrichUserWithIdentity]); // Remove verifyOwnership dependency to prevent infinite loops - - // Auto-connect when wallet is detected - useEffect(() => { - const autoConnect = async () => { - if (isWalletConnected && connectedAddress && !currentUser) { - setIsAuthenticating(true); - try { - // Check if we have stored user data for this address - const storedUser = await localDatabase.loadUser(); - - const user: User = storedUser && storedUser.address === connectedAddress ? { - // Preserve existing user data including verification status - ...storedUser, - walletType: walletType as 'bitcoin' | 'ethereum', - lastChecked: Date.now(), - } : { - // Create new user with basic connection status - address: connectedAddress, - walletType: walletType as 'bitcoin' | 'ethereum', - displayPreference: EDisplayPreference.WALLET_ADDRESS, - verificationStatus: EVerificationStatus.WALLET_CONNECTED, - lastChecked: Date.now(), - }; - - const enriched = await enrichUserWithIdentity(user); - setCurrentUser(enriched); - await localDatabase.storeUser(user); - - // Also store identity info so UserIdentityService can access it - await localDatabase.upsertUserIdentity(connectedAddress, { - verificationStatus: user.verificationStatus, - lastUpdated: Date.now(), - }); - - // 🔥 AUTOMATIC VERIFICATION: Check if user needs verification - // Only auto-verify if they don't already have ENS_ORDINAL_VERIFIED status - if (user.verificationStatus !== EVerificationStatus.ENS_ORDINAL_VERIFIED) { - try { - await verifyOwnership(); - } catch (error) { - console.error('❌ Auto-verification failed:', error); - // Don't fail the connection if verification fails - } - } - } catch (error) { - console.error('❌ Auto-connect failed:', error); - } finally { - setIsAuthenticating(false); - } - } else if (!isWalletConnected && currentUser) { - setCurrentUser(null); - await localDatabase.clearUser(); - } - }; - - autoConnect(); - }, [isWalletConnected, connectedAddress, walletType, enrichUserWithIdentity]); // Remove currentUser and verifyOwnership dependencies - - // Ensure verificationStatus reflects a connected wallet even if a user was preloaded - useEffect(() => { - const syncConnectedStatus = async () => { - if (!isWalletConnected || !connectedAddress || !currentUser) return; - - const needsAddressSync = - currentUser.address !== connectedAddress || - currentUser.walletType !== (walletType as 'bitcoin' | 'ethereum'); - - const needsStatusUpgrade = - currentUser.verificationStatus === EVerificationStatus.WALLET_UNCONNECTED; - - if (needsAddressSync || needsStatusUpgrade) { - const nextStatus = - currentUser.verificationStatus === - EVerificationStatus.ENS_ORDINAL_VERIFIED - ? EVerificationStatus.ENS_ORDINAL_VERIFIED - : EVerificationStatus.WALLET_CONNECTED; - - const updatedUser: User = { - ...currentUser, - address: connectedAddress, - walletType: walletType as 'bitcoin' | 'ethereum', - verificationStatus: nextStatus, - lastChecked: Date.now(), - } as User; - - const enriched = await enrichUserWithIdentity(updatedUser); - setCurrentUser(enriched); - await localDatabase.storeUser(updatedUser); - await localDatabase.upsertUserIdentity(connectedAddress, { - ensName: updatedUser.ensDetails?.ensName, - ordinalDetails: updatedUser.ordinalDetails, - verificationStatus: nextStatus, - lastUpdated: Date.now(), - }); - } - }; - - syncConnectedStatus(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isWalletConnected, connectedAddress, walletType, currentUser, enrichUserWithIdentity]); - - // Keep currentUser in sync with identity updates (e.g., profile changes) - useEffect(() => { - if (!currentUser) return; - const off = client.userIdentityService.addRefreshListener(async (addr) => { - if (addr !== currentUser.address) return; - const enriched = await enrichUserWithIdentity(currentUser as User); - setCurrentUser(enriched); - }); - return () => { - try { off && off(); } catch {} - }; - }, [client, currentUser, enrichUserWithIdentity]); - - const connectWallet = useCallback(async (): Promise => { - if (!isWalletConnected || !connectedAddress) return false; - - try { - setIsAuthenticating(true); - - const user: User = { - address: connectedAddress, - walletType: walletType as 'bitcoin' | 'ethereum', - displayPreference: currentUser?.displayPreference ?? EDisplayPreference.WALLET_ADDRESS, - verificationStatus: EVerificationStatus.WALLET_CONNECTED, - lastChecked: Date.now(), - }; - const enriched = await enrichUserWithIdentity(user); - setCurrentUser(enriched); - await localDatabase.storeUser(user); - return true; - } catch (e) { - console.error('connectWallet failed', e); - return false; - } finally { - setIsAuthenticating(false); - } - }, [currentUser?.displayPreference, isWalletConnected, connectedAddress, walletType, enrichUserWithIdentity]); - - const disconnectWallet = useCallback(() => { - setCurrentUser(null); - localDatabase.clearUser().catch(console.error); - }, []); - - const delegateKey = useCallback(async (duration?: DelegationDuration): Promise => { - if (!currentUser) return false; - - console.log('🔑 Starting delegation process...', { currentUser, duration }); - - try { - const ok = await delegationManager.delegate( - currentUser.address, - currentUser.walletType, - duration ?? '7days', - async (msg: string) => { - console.log('🖋️ Signing delegation message...', msg); - - if (currentUser.walletType === 'ethereum') { - // For Ethereum wallets, we need to import and use signMessage dynamically - // This avoids the context issue by importing at runtime - const { signMessage } = await import('wagmi/actions'); - const { config } = await import('@opchan/core'); - return await signMessage(config, { message: msg }); - } else { - // For Bitcoin wallets, we need to use AppKit's Bitcoin adapter - // For now, throw an error as Bitcoin signing needs special handling - throw new Error('Bitcoin delegation signing not implemented yet. Please use Ethereum wallet.'); - } - } - ); - - console.log('📝 Delegation result:', ok); - return ok; - } catch (e) { - console.error('❌ delegateKey failed:', e); - return false; - } - }, [currentUser]); - - const getDelegationStatus = useCallback(async () => { - return delegationManager.getStatus(currentUser?.address, currentUser?.walletType); - }, [currentUser?.address, currentUser?.walletType]); - - const clearDelegation = useCallback(async () => { - await delegationManager.clear(); - }, []); - - const verifyMessage = useCallback(async (message: unknown) => { - try { - return delegationManager.verify(message as never); - } catch { - return false; - } - }, []); - - const ctx: AuthContextValue = useMemo(() => { - return { - currentUser, - isAuthenticated: !!currentUser, - isAuthenticating, - verificationStatus: currentUser?.verificationStatus ?? EVerificationStatus.WALLET_UNCONNECTED, - connectWallet, - disconnectWallet, - verifyOwnership, - delegateKey, - getDelegationStatus, - clearDelegation, - signMessage: client.messageManager.sendMessage.bind(client.messageManager), - verifyMessage, - }; - }, [client, currentUser, isAuthenticating, connectWallet, disconnectWallet, verifyOwnership, delegateKey, getDelegationStatus, clearDelegation, verifyMessage]); - - return {children}; -}; - -export function useAuth() { - const ctx = useContext(AuthContext); - if (!ctx) throw new Error('useAuth must be used within OpChanProvider'); - return ctx; -} - -export { AuthContext }; - - diff --git a/packages/react/src/old/contexts/ClientContext.tsx b/packages/react/src/old/contexts/ClientContext.tsx deleted file mode 100644 index 3af191a..0000000 --- a/packages/react/src/old/contexts/ClientContext.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React, { createContext, useContext } from 'react'; -import { OpChanClient } from '@opchan/core'; - -export interface ClientContextValue { - client: OpChanClient; -} - -const ClientContext = createContext(null); - -export const ClientProvider: React.FC<{ - client: OpChanClient; - children: React.ReactNode; -}> = ({ client, children }) => { - return ( - - {children} - - ); -}; - -export function useClient(): OpChanClient { - const context = useContext(ClientContext); - if (!context) { - throw new Error('useClient must be used within OpChanProvider'); - } - return context.client; -} - -export { ClientContext }; diff --git a/packages/react/src/old/contexts/ForumContext.tsx b/packages/react/src/old/contexts/ForumContext.tsx deleted file mode 100644 index 7703335..0000000 --- a/packages/react/src/old/contexts/ForumContext.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { localDatabase, getDataFromCache } from '@opchan/core'; -import { useAuth } from './AuthContext'; -import { Cell, Post, Comment, UserVerificationStatus, EVerificationStatus } from '@opchan/core'; -import { useClient } from './ClientContext'; -import type { ForumActions } from '@opchan/core'; - -export interface ForumContextValue { - cells: Cell[]; - posts: Post[]; - comments: Comment[]; - userVerificationStatus: UserVerificationStatus; - - isInitialLoading: boolean; - isRefreshing: boolean; - isNetworkConnected: boolean; - lastSync: number | null; - error: string | null; - - refreshData: () => Promise; - - // Actions - actions: ForumActions; -} - -const ForumContext = createContext(null); - -export const ForumProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const client = useClient(); - const { currentUser } = useAuth(); - const [cells, setCells] = useState([]); - const [posts, setPosts] = useState([]); - const [comments, setComments] = useState([]); - const [userVerificationStatus, setUserVerificationStatus] = useState({}); - const [isInitialLoading, setIsInitialLoading] = useState(true); - const [isRefreshing, setIsRefreshing] = useState(false); - const [isNetworkConnected, setIsNetworkConnected] = useState(false); - const [error, setError] = useState(null); - - const actions = useMemo(() => client.forumActions, [client]); - - const updateFromCache = useCallback(async () => { - try { - // Rebuild verification status map from centralized user identity cache - const nextVerificationStatus: UserVerificationStatus = {}; - try { - const identities = localDatabase.cache.userIdentities || {}; - Object.entries(identities).forEach(([address, record]) => { - const hasENS = Boolean((record as { ensName?: unknown }).ensName); - const hasOrdinal = Boolean((record as { ordinalDetails?: unknown }).ordinalDetails); - const verificationStatus = (record as { verificationStatus?: EVerificationStatus }).verificationStatus; - const isVerified = - hasENS || - hasOrdinal || - verificationStatus === EVerificationStatus.WALLET_CONNECTED || - verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED; - nextVerificationStatus[address] = { - isVerified, - hasENS, - hasOrdinal, - ensName: (record as { ensName?: string }).ensName, - verificationStatus, - }; - }); - } catch {} - - setUserVerificationStatus(nextVerificationStatus); - - const data = await getDataFromCache(undefined, nextVerificationStatus); - setCells(data.cells); - setPosts(data.posts); - setComments(data.comments); - } catch (e) { - console.error('Failed to read cache', e); - } - }, []); - - const refreshData = useCallback(async () => { - setIsRefreshing(true); - try { - await updateFromCache(); - setError(null); - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to refresh'); - } finally { - setIsRefreshing(false); - } - }, [updateFromCache]); - - // 1) Initial cache hydrate only – decoupled from network subscriptions - useEffect(() => { - const init = async () => { - try { - await updateFromCache(); - setIsInitialLoading(false); - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to initialize'); - setIsInitialLoading(false); - } - }; - init(); - }, [updateFromCache]); - - // 2) Network wiring – subscribe once to the client's message manager - useEffect(() => { - let unsubHealth: (() => void) | null = null; - let unsubMsg: (() => void) | null = null; - - // Check initial health status from the provided client to ensure a single core instance - const initialHealth = client.messageManager.currentHealth; - const initialReady = client.messageManager.isReady; - console.log('🔌 ForumContext initial state:', { initialReady, initialHealth }); - setIsNetworkConnected(!!initialReady); - - unsubHealth = client.messageManager.onHealthChange(async (ready: boolean, health: any) => { - console.log('🔌 ForumContext health change:', { ready, health }); - setIsNetworkConnected(!!ready); - if (ready) { - try { await updateFromCache(); } catch {} - } - }); - - unsubMsg = client.messageManager.onMessageReceived(async () => { - await updateFromCache(); - }); - - return () => { - try { unsubHealth && unsubHealth(); } catch {} - try { unsubMsg && unsubMsg(); } catch {} - }; - }, [client, updateFromCache]); - - // 2b) Pending state wiring – rehydrate when local pending queue changes - useEffect(() => { - const off = localDatabase.onPendingChange(async () => { - try { await updateFromCache(); } catch {} - }); - return () => { try { off && off(); } catch {} }; - }, [updateFromCache]); - - // 3) Visibility change: re-check connection immediately when tab becomes active - useEffect(() => { - const handleVisibility = async () => { - if (document.visibilityState === 'visible') { - const ready = client.messageManager.isReady; - setIsNetworkConnected(!!ready); - console.debug('🔌 ForumContext visibility check, ready:', ready); - if (ready) { - try { await updateFromCache(); } catch {} - } - } - }; - document.addEventListener('visibilitychange', handleVisibility); - return () => document.removeEventListener('visibilitychange', handleVisibility); - }, [client]); - - const ctx: ForumContextValue = useMemo(() => ({ - cells, - posts, - comments, - userVerificationStatus, - isInitialLoading, - isRefreshing, - isNetworkConnected, - lastSync: localDatabase.getSyncState().lastSync, - error, - refreshData, - actions, - }), [cells, posts, comments, userVerificationStatus, isInitialLoading, isRefreshing, isNetworkConnected, error, refreshData, actions]); - - return {children}; -}; - -export function useForum() { - const ctx = useContext(ForumContext); - if (!ctx) throw new Error('useForum must be used within OpChanProvider'); - return ctx; -} - -export { ForumContext }; - - diff --git a/packages/react/src/old/contexts/ModerationContext.tsx b/packages/react/src/old/contexts/ModerationContext.tsx deleted file mode 100644 index d299588..0000000 --- a/packages/react/src/old/contexts/ModerationContext.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React, { createContext, useContext, useMemo, useState } from 'react'; - -export interface ModerationContextValue { - showModerated: boolean; - toggleShowModerated: () => void; -} - -const ModerationContext = createContext(null); - -export const ModerationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [showModerated, setShowModerated] = useState(false); - - const ctx = useMemo(() => ({ - showModerated, - toggleShowModerated: () => setShowModerated(v => !v), - }), [showModerated]); - - return {children}; -}; - -export function useModeration() { - const ctx = useContext(ModerationContext); - if (!ctx) throw new Error('useModeration must be used within OpChanProvider'); - return ctx; -} - -export { ModerationContext }; - - diff --git a/packages/react/src/old/hooks/core/useForumData.ts b/packages/react/src/old/hooks/core/useForumData.ts deleted file mode 100644 index cc4da6d..0000000 --- a/packages/react/src/old/hooks/core/useForumData.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { useMemo } from 'react'; -import { useForum } from '../../contexts/ForumContext'; -import { useAuth } from '../../contexts/AuthContext'; -import { useModeration } from '../../contexts/ModerationContext'; -import { - Cell, - Post, - Comment, - UserVerificationStatus, - EVerificationStatus, -} from '@opchan/core'; - -export interface CellWithStats extends Cell { - postCount: number; - activeUsers: number; - recentActivity: number; -} - -export interface PostWithVoteStatus extends Post { - userUpvoted: boolean; - userDownvoted: boolean; - voteScore: number; - canVote: boolean; - canModerate: boolean; -} - -export interface CommentWithVoteStatus extends Comment { - userUpvoted: boolean; - userDownvoted: boolean; - voteScore: number; - canVote: boolean; - canModerate: boolean; -} - -export interface ForumData { - // Raw data - cells: Cell[]; - posts: Post[]; - comments: Comment[]; - userVerificationStatus: UserVerificationStatus; - - // Loading states - isInitialLoading: boolean; - isRefreshing: boolean; - isNetworkConnected: boolean; - error: string | null; - - // Computed data with reactive updates - cellsWithStats: CellWithStats[]; - postsWithVoteStatus: PostWithVoteStatus[]; - commentsWithVoteStatus: CommentWithVoteStatus[]; - - // Filtered data based on moderation settings - filteredPosts: PostWithVoteStatus[]; - filteredComments: CommentWithVoteStatus[]; - filteredCellsWithStats: CellWithStats[]; - filteredCommentsByPost: Record; - - // Organized data - postsByCell: Record; - commentsByPost: Record; - - // User-specific data - userVotedPosts: Set; - userVotedComments: Set; - userCreatedPosts: Set; - userCreatedComments: Set; -} - -export function useForumData(): ForumData { - const { - cells, - posts, - comments, - userVerificationStatus, - isInitialLoading, - isRefreshing, - isNetworkConnected, - error, - } = useForum(); - - const { currentUser } = useAuth(); - const { showModerated } = useModeration(); - - const cellsWithStats = useMemo((): CellWithStats[] => { - return cells.map(cell => { - const cellPosts = posts.filter(post => post.cellId === cell.id); - const recentPosts = cellPosts.filter( - post => Date.now() - post.timestamp < 7 * 24 * 60 * 60 * 1000 - ); - - const uniqueAuthors = new Set(cellPosts.map(post => post.author)); - - return { - ...cell, - postCount: cellPosts.length, - activeUsers: uniqueAuthors.size, - recentActivity: recentPosts.length, - }; - }); - }, [cells, posts]); - - const canUserVote = useMemo(() => { - if (!currentUser) return false; - return ( - currentUser.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED || - currentUser.verificationStatus === EVerificationStatus.WALLET_CONNECTED || - Boolean(currentUser.ensDetails) || - Boolean(currentUser.ordinalDetails) - ); - }, [currentUser]); - - const canUserModerate = useMemo(() => { - const moderationMap: Record = {}; - if (!currentUser) return moderationMap; - cells.forEach(cell => { - moderationMap[cell.id] = currentUser.address === (cell as unknown as { signature?: string }).signature; - }); - return moderationMap; - }, [currentUser, cells]); - - const postsWithVoteStatus = useMemo((): PostWithVoteStatus[] => { - return posts.map(post => { - const userUpvoted = currentUser - ? post.upvotes.some(vote => vote.author === currentUser.address) - : false; - const userDownvoted = currentUser - ? post.downvotes.some(vote => vote.author === currentUser.address) - : false; - const voteScore = post.upvotes.length - post.downvotes.length; - const canModerate = canUserModerate[post.cellId] || false; - return { - ...post, - userUpvoted, - userDownvoted, - voteScore, - canVote: canUserVote, - canModerate, - }; - }); - }, [posts, currentUser, canUserVote, canUserModerate]); - - const commentsWithVoteStatus = useMemo((): CommentWithVoteStatus[] => { - return comments.map(comment => { - const userUpvoted = currentUser - ? comment.upvotes.some(vote => vote.author === currentUser.address) - : false; - const userDownvoted = currentUser - ? comment.downvotes.some(vote => vote.author === currentUser.address) - : false; - const voteScore = comment.upvotes.length - comment.downvotes.length; - - const parentPost = posts.find(post => post.id === comment.postId); - const canModerate = parentPost - ? canUserModerate[parentPost.cellId] || false - : false; - return { - ...comment, - userUpvoted, - userDownvoted, - voteScore, - canVote: canUserVote, - canModerate, - }; - }); - }, [comments, currentUser, canUserVote, canUserModerate, posts]); - - const postsByCell = useMemo((): Record => { - const organized: Record = {}; - postsWithVoteStatus.forEach(post => { - if (!organized[post.cellId]) organized[post.cellId] = []; - organized[post.cellId]!.push(post); - }); - Object.keys(organized).forEach(cellId => { - const list = organized[cellId]!; - list.sort((a, b) => { - if ( - a.relevanceScore !== undefined && - b.relevanceScore !== undefined - ) { - return b.relevanceScore - a.relevanceScore; - } - return b.timestamp - a.timestamp; - }); - }); - return organized; - }, [postsWithVoteStatus]); - - const commentsByPost = useMemo((): Record => { - const organized: Record = {}; - commentsWithVoteStatus.forEach(comment => { - if (!organized[comment.postId]) organized[comment.postId] = []; - organized[comment.postId]!.push(comment); - }); - Object.keys(organized).forEach(postId => { - const list = organized[postId]!; - list.sort((a, b) => a.timestamp - b.timestamp); - }); - return organized; - }, [commentsWithVoteStatus]); - - const userVotedPosts = useMemo(() => { - const voted = new Set(); - if (!currentUser) return voted; - postsWithVoteStatus.forEach(post => { - if (post.userUpvoted || post.userDownvoted) voted.add(post.id); - }); - return voted; - }, [postsWithVoteStatus, currentUser]); - - const userVotedComments = useMemo(() => { - const voted = new Set(); - if (!currentUser) return voted; - commentsWithVoteStatus.forEach(comment => { - if (comment.userUpvoted || comment.userDownvoted) voted.add(comment.id); - }); - return voted; - }, [commentsWithVoteStatus, currentUser]); - - const userCreatedPosts = useMemo(() => { - const created = new Set(); - if (!currentUser) return created; - posts.forEach(post => { - if (post.author === currentUser.address) created.add(post.id); - }); - return created; - }, [posts, currentUser]); - - const userCreatedComments = useMemo(() => { - const created = new Set(); - if (!currentUser) return created; - comments.forEach(comment => { - if (comment.author === currentUser.address) created.add(comment.id); - }); - return created; - }, [comments, currentUser]); - - const filteredPosts = useMemo(() => { - return showModerated - ? postsWithVoteStatus - : postsWithVoteStatus.filter(p => !p.moderated); - }, [postsWithVoteStatus, showModerated]); - - const filteredComments = useMemo(() => { - if (showModerated) return commentsWithVoteStatus; - const moderatedPostIds = new Set( - postsWithVoteStatus.filter(p => p.moderated).map(p => p.id) - ); - return commentsWithVoteStatus.filter( - c => !c.moderated && !moderatedPostIds.has(c.postId) - ); - }, [commentsWithVoteStatus, postsWithVoteStatus, showModerated]); - - const filteredCellsWithStats = useMemo((): CellWithStats[] => { - return cells.map(cell => { - const cellPosts = filteredPosts.filter(post => post.cellId === cell.id); - const recentPosts = cellPosts.filter( - post => Date.now() - post.timestamp < 7 * 24 * 60 * 60 * 1000 - ); - const uniqueAuthors = new Set(cellPosts.map(post => post.author)); - return { - ...cell, - postCount: cellPosts.length, - activeUsers: uniqueAuthors.size, - recentActivity: recentPosts.length, - }; - }); - }, [cells, filteredPosts]); - - const filteredCommentsByPost = useMemo((): Record => { - const organized: Record = {}; - filteredComments.forEach(comment => { - if (!organized[comment.postId]) organized[comment.postId] = []; - organized[comment.postId]!.push(comment); - }); - return organized; - }, [filteredComments]); - - return { - cells, - posts, - comments, - userVerificationStatus, - isInitialLoading, - isRefreshing, - isNetworkConnected, - error, - cellsWithStats, - postsWithVoteStatus, - commentsWithVoteStatus, - filteredPosts, - filteredComments, - filteredCellsWithStats, - filteredCommentsByPost, - postsByCell, - commentsByPost, - userVotedPosts, - userVotedComments, - userCreatedPosts, - userCreatedComments, - }; -} - - diff --git a/packages/react/src/old/hooks/core/usePermissions.ts b/packages/react/src/old/hooks/core/usePermissions.ts deleted file mode 100644 index cf21305..0000000 --- a/packages/react/src/old/hooks/core/usePermissions.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { useMemo } from 'react'; -import { useAuth } from '../../contexts/AuthContext'; -import { useForumData } from './useForumData'; -import { EVerificationStatus } from '@opchan/core'; - -export interface Permission { - canPost: boolean; - canComment: boolean; - canVote: boolean; - canCreateCell: boolean; - canModerate: (cellId: string) => boolean; - canDelegate: boolean; - canUpdateProfile: boolean; -} - -export interface PermissionReasons { - voteReason: string; - postReason: string; - commentReason: string; - createCellReason: string; - moderateReason: (cellId: string) => string; -} - -export interface PermissionResult { - allowed: boolean; - reason: string; -} - -export function usePermissions(): Permission & - PermissionReasons & { - checkPermission: ( - action: keyof Permission, - cellId?: string - ) => PermissionResult; - } { - const { currentUser, verificationStatus } = useAuth(); - const { cellsWithStats } = useForumData(); - - const permissions = useMemo((): Permission => { - const isWalletConnected = - verificationStatus === EVerificationStatus.WALLET_CONNECTED; - const isVerified = - verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED; - - return { - canPost: isWalletConnected || isVerified, - canComment: isWalletConnected || isVerified, - canVote: isWalletConnected || isVerified, - canCreateCell: isVerified, - canModerate: (cellId: string) => { - if (!currentUser || !cellId) return false; - const cell = cellsWithStats.find(c => c.id === cellId); - return cell ? cell.author === currentUser.address : false; - }, - canDelegate: isWalletConnected || isVerified, - canUpdateProfile: Boolean(currentUser), - }; - }, [currentUser, verificationStatus, cellsWithStats]); - - const reasons = useMemo((): PermissionReasons => { - if (!currentUser) { - return { - voteReason: 'Connect your wallet to vote', - postReason: 'Connect your wallet to post', - commentReason: 'Connect your wallet to comment', - createCellReason: 'Connect your wallet to create cells', - moderateReason: () => 'Connect your wallet to moderate', - }; - } - - return { - voteReason: permissions.canVote - ? 'You can vote' - : 'Connect your wallet to vote', - postReason: permissions.canPost - ? 'You can post' - : 'Connect your wallet to post', - commentReason: permissions.canComment - ? 'You can comment' - : 'Connect your wallet to comment', - createCellReason: permissions.canCreateCell - ? 'You can create cells' - : 'Verify ENS or Logos ordinal to create cells', - moderateReason: (cellId: string) => { - if (!cellId) return 'Cell ID required'; - return permissions.canModerate(cellId) - ? 'You can moderate this cell' - : 'Only cell creators can moderate'; - }, - }; - }, [currentUser, permissions]); - - const checkPermission = useMemo(() => { - return (action: keyof Permission, cellId?: string): PermissionResult => { - let allowed = false; - let reason = ''; - - switch (action) { - case 'canVote': - allowed = permissions.canVote; - reason = reasons.voteReason; - break; - case 'canPost': - allowed = permissions.canPost; - reason = reasons.postReason; - break; - case 'canComment': - allowed = permissions.canComment; - reason = reasons.commentReason; - break; - case 'canCreateCell': - allowed = permissions.canCreateCell; - reason = reasons.createCellReason; - break; - case 'canModerate': - allowed = cellId ? permissions.canModerate(cellId) : false; - reason = cellId ? reasons.moderateReason(cellId) : 'Cell ID required'; - break; - case 'canDelegate': - allowed = permissions.canDelegate; - reason = allowed - ? 'You can delegate keys' - : 'Connect your wallet to delegate keys'; - break; - case 'canUpdateProfile': - allowed = permissions.canUpdateProfile; - reason = allowed - ? 'You can update your profile' - : 'Connect your wallet to update profile'; - break; - default: - allowed = false; - reason = 'Unknown permission'; - } - - return { allowed, reason }; - }; - }, [permissions, reasons]); - - return { - ...permissions, - ...reasons, - checkPermission, - }; -} diff --git a/packages/react/src/old/hooks/derived/useCell.ts b/packages/react/src/old/hooks/derived/useCell.ts deleted file mode 100644 index 02975b3..0000000 --- a/packages/react/src/old/hooks/derived/useCell.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useMemo } from 'react'; -import { useForumData, CellWithStats } from '../core/useForumData'; -import { useAuth } from '../../contexts/AuthContext'; -import { EVerificationStatus } from '@opchan/core'; - -export interface CellData extends CellWithStats { - posts: Array<{ - id: string; - title: string; - content: string; - author: string; - timestamp: number; - voteScore: number; - commentCount: number; - }>; - isUserAdmin: boolean; - canModerate: boolean; - canPost: boolean; -} - -export function useCell(cellId: string | undefined): CellData | null { - const { cellsWithStats, postsByCell, commentsByPost } = useForumData(); - const { currentUser } = useAuth(); - - return useMemo(() => { - if (!cellId) return null; - - const cell = cellsWithStats.find(c => c.id === cellId); - if (!cell) return null; - - const cellPosts = postsByCell[cellId] || []; - - const posts = cellPosts.map(post => ({ - id: post.id, - title: post.title, - content: post.content, - author: post.author, - timestamp: post.timestamp, - voteScore: post.voteScore, - commentCount: (commentsByPost[post.id] || []).length, - })); - - const isUserAdmin = currentUser - ? currentUser.address === (cell as unknown as { signature?: string }).signature - : false; - const canModerate = isUserAdmin; - const canPost = currentUser - ? currentUser.verificationStatus === - EVerificationStatus.ENS_ORDINAL_VERIFIED || - currentUser.verificationStatus === - EVerificationStatus.WALLET_CONNECTED || - Boolean(currentUser.ensDetails) || - Boolean(currentUser.ordinalDetails) - : false; - - return { - ...cell, - posts, - isUserAdmin, - canModerate, - canPost, - }; - }, [cellId, cellsWithStats, postsByCell, commentsByPost, currentUser]); -} diff --git a/packages/react/src/old/hooks/derived/useCellPosts.ts b/packages/react/src/old/hooks/derived/useCellPosts.ts deleted file mode 100644 index 4f638ab..0000000 --- a/packages/react/src/old/hooks/derived/useCellPosts.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { useMemo } from 'react'; -import { useForumData, PostWithVoteStatus } from '../core/useForumData'; -import { useAuth } from '../../contexts/AuthContext'; -import { useModeration } from '../../contexts/ModerationContext'; - -export interface CellPostsOptions { - includeModerated?: boolean; - sortBy?: 'relevance' | 'timestamp' | 'votes'; - limit?: number; -} - -export interface CellPostsData { - posts: PostWithVoteStatus[]; - totalCount: number; - hasMore: boolean; - isLoading: boolean; -} - -export function useCellPosts( - cellId: string | undefined, - options: CellPostsOptions = {} -): CellPostsData { - const { postsByCell, isInitialLoading, cellsWithStats } = useForumData(); - const { currentUser } = useAuth(); - const { showModerated } = useModeration(); - - const { - includeModerated = showModerated, - sortBy = 'relevance', - limit, - } = options; - - return useMemo(() => { - if (!cellId) { - return { - posts: [], - totalCount: 0, - hasMore: false, - isLoading: isInitialLoading, - }; - } - - let posts = postsByCell[cellId] || []; - - // Filter moderated posts unless user is admin - if (!includeModerated) { - const cell = cellsWithStats.find(c => c.id === cellId); - const isUserAdmin = - Boolean(currentUser && cell && currentUser.address === (cell as unknown as { signature?: string }).signature); - - if (!isUserAdmin) { - posts = posts.filter(post => !post.moderated); - } - } - - // Sort posts - const sortedPosts = [...posts].sort((a, b) => { - switch (sortBy) { - case 'relevance': - if ( - a.relevanceScore !== undefined && - b.relevanceScore !== undefined - ) { - return b.relevanceScore - a.relevanceScore; - } - return b.timestamp - a.timestamp; - - case 'votes': - return b.voteScore - a.voteScore; - - case 'timestamp': - default: - return b.timestamp - a.timestamp; - } - }); - - // Apply limit if specified - const limitedPosts = limit ? sortedPosts.slice(0, limit) : sortedPosts; - const hasMore = limit ? sortedPosts.length > limit : false; - - return { - posts: limitedPosts, - totalCount: sortedPosts.length, - hasMore, - isLoading: isInitialLoading, - }; - }, [ - cellId, - postsByCell, - isInitialLoading, - currentUser, - cellsWithStats, - includeModerated, - sortBy, - limit, - ]); -} diff --git a/packages/react/src/old/hooks/derived/usePost.ts b/packages/react/src/old/hooks/derived/usePost.ts deleted file mode 100644 index 468ecbf..0000000 --- a/packages/react/src/old/hooks/derived/usePost.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useMemo } from 'react'; -import { - useForumData, - PostWithVoteStatus, - CommentWithVoteStatus, -} from '../core/useForumData'; -import { useAuth } from '../../contexts/AuthContext'; - -export interface PostData extends PostWithVoteStatus { - cell: { - id: string; - name: string; - description: string; - } | null; - comments: CommentWithVoteStatus[]; - commentCount: number; - isUserAuthor: boolean; -} - -export function usePost(postId: string | undefined): PostData | null { - const { postsWithVoteStatus, commentsByPost, cellsWithStats } = - useForumData(); - const { currentUser } = useAuth(); - - return useMemo(() => { - if (!postId) return null; - - const post = postsWithVoteStatus.find(p => p.id === postId); - if (!post) return null; - - const cell = cellsWithStats.find(c => c.id === post.cellId) || null; - const comments = commentsByPost[postId] || []; - const commentCount = comments.length; - const isUserAuthor = currentUser - ? currentUser.address === post.author - : false; - - return { - ...post, - cell: cell - ? { - id: cell.id, - name: cell.name, - description: cell.description, - } - : null, - comments, - commentCount, - isUserAuthor, - }; - }, [ - postId, - postsWithVoteStatus, - commentsByPost, - cellsWithStats, - currentUser, - ]); -} diff --git a/packages/react/src/old/hooks/derived/usePostComments.ts b/packages/react/src/old/hooks/derived/usePostComments.ts deleted file mode 100644 index 20e6bc6..0000000 --- a/packages/react/src/old/hooks/derived/usePostComments.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { useMemo } from 'react'; -import { useForumData, CommentWithVoteStatus } from '../core/useForumData'; -import { useAuth } from '../../contexts/AuthContext'; -import { useModeration } from '../../contexts/ModerationContext'; - -export interface PostCommentsOptions { - includeModerated?: boolean; - sortBy?: 'timestamp' | 'votes'; - limit?: number; -} - -export interface PostCommentsData { - comments: CommentWithVoteStatus[]; - totalCount: number; - hasMore: boolean; - isLoading: boolean; -} - -export function usePostComments( - postId: string | undefined, - options: PostCommentsOptions = {} -): PostCommentsData { - const { - commentsByPost, - isInitialLoading, - postsWithVoteStatus, - cellsWithStats, - } = useForumData(); - const { currentUser } = useAuth(); - const { showModerated } = useModeration(); - - const { - includeModerated = showModerated, - sortBy = 'timestamp', - limit, - } = options; - - return useMemo(() => { - if (!postId) { - return { - comments: [], - totalCount: 0, - hasMore: false, - isLoading: isInitialLoading, - }; - } - - let comments = commentsByPost[postId] || []; - - // Filter moderated comments unless user is admin - if (!includeModerated) { - const post = postsWithVoteStatus.find(p => p.id === postId); - const cell = post ? cellsWithStats.find(c => c.id === post.cellId) : null; - const isUserAdmin = - Boolean(currentUser && cell && currentUser.address === (cell as unknown as { signature?: string }).signature); - - if (!isUserAdmin) { - comments = comments.filter(comment => !comment.moderated); - } - } - - // Sort comments - const sortedComments = [...comments].sort((a, b) => { - switch (sortBy) { - case 'votes': - return b.voteScore - a.voteScore; - - case 'timestamp': - default: - return a.timestamp - b.timestamp; // Oldest first for comments - } - }); - - // Apply limit if specified - const limitedComments = limit - ? sortedComments.slice(0, limit) - : sortedComments; - const hasMore = limit ? sortedComments.length > limit : false; - - return { - comments: limitedComments, - totalCount: sortedComments.length, - hasMore, - isLoading: isInitialLoading, - }; - }, [ - postId, - commentsByPost, - isInitialLoading, - currentUser, - postsWithVoteStatus, - cellsWithStats, - includeModerated, - sortBy, - limit, - ]); -} diff --git a/packages/react/src/old/hooks/derived/useUserVotes.ts b/packages/react/src/old/hooks/derived/useUserVotes.ts deleted file mode 100644 index 8a08ed9..0000000 --- a/packages/react/src/old/hooks/derived/useUserVotes.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { useMemo } from 'react'; -import { useForumData } from '../core/useForumData'; -import { useAuth } from '../../contexts/AuthContext'; - -export interface UserVoteData { - // Vote status for specific items - hasVotedOnPost: (postId: string) => boolean; - hasVotedOnComment: (commentId: string) => boolean; - getPostVoteType: (postId: string) => 'upvote' | 'downvote' | null; - getCommentVoteType: (commentId: string) => 'upvote' | 'downvote' | null; - - // User's voting history - votedPosts: Set; - votedComments: Set; - upvotedPosts: Set; - downvotedPosts: Set; - upvotedComments: Set; - downvotedComments: Set; - - // Statistics - totalVotes: number; - upvoteRatio: number; -} - -export function useUserVotes(userAddress?: string): UserVoteData { - const { postsWithVoteStatus, commentsWithVoteStatus } = useForumData(); - const { currentUser } = useAuth(); - - const targetAddress = userAddress || currentUser?.address; - - return useMemo(() => { - if (!targetAddress) { - return { - hasVotedOnPost: () => false, - hasVotedOnComment: () => false, - getPostVoteType: () => null, - getCommentVoteType: () => null, - votedPosts: new Set(), - votedComments: new Set(), - upvotedPosts: new Set(), - downvotedPosts: new Set(), - upvotedComments: new Set(), - downvotedComments: new Set(), - totalVotes: 0, - upvoteRatio: 0, - }; - } - - // Build vote sets - const votedPosts = new Set(); - const votedComments = new Set(); - const upvotedPosts = new Set(); - const downvotedPosts = new Set(); - const upvotedComments = new Set(); - const downvotedComments = new Set(); - - // Analyze post votes - postsWithVoteStatus.forEach(post => { - const hasUpvoted = post.upvotes.some( - vote => vote.author === targetAddress - ); - const hasDownvoted = post.downvotes.some( - vote => vote.author === targetAddress - ); - - if (hasUpvoted) { - votedPosts.add(post.id); - upvotedPosts.add(post.id); - } - if (hasDownvoted) { - votedPosts.add(post.id); - downvotedPosts.add(post.id); - } - }); - - // Analyze comment votes - commentsWithVoteStatus.forEach(comment => { - const hasUpvoted = comment.upvotes.some( - vote => vote.author === targetAddress - ); - const hasDownvoted = comment.downvotes.some( - vote => vote.author === targetAddress - ); - - if (hasUpvoted) { - votedComments.add(comment.id); - upvotedComments.add(comment.id); - } - if (hasDownvoted) { - votedComments.add(comment.id); - downvotedComments.add(comment.id); - } - }); - - // Calculate statistics - const totalVotes = votedPosts.size + votedComments.size; - const totalUpvotes = upvotedPosts.size + upvotedComments.size; - const upvoteRatio = totalVotes > 0 ? totalUpvotes / totalVotes : 0; - - // Helper functions - const hasVotedOnPost = (postId: string): boolean => { - return votedPosts.has(postId); - }; - - const hasVotedOnComment = (commentId: string): boolean => { - return votedComments.has(commentId); - }; - - const getPostVoteType = (postId: string): 'upvote' | 'downvote' | null => { - if (upvotedPosts.has(postId)) return 'upvote'; - if (downvotedPosts.has(postId)) return 'downvote'; - return null; - }; - - const getCommentVoteType = ( - commentId: string - ): 'upvote' | 'downvote' | null => { - if (upvotedComments.has(commentId)) return 'upvote'; - if (downvotedComments.has(commentId)) return 'downvote'; - return null; - }; - - return { - hasVotedOnPost, - hasVotedOnComment, - getPostVoteType, - getCommentVoteType, - votedPosts, - votedComments, - upvotedPosts, - downvotedPosts, - upvotedComments, - downvotedComments, - totalVotes, - upvoteRatio, - }; - }, [postsWithVoteStatus, commentsWithVoteStatus, targetAddress]); -} diff --git a/packages/react/src/old/hooks/index.ts b/packages/react/src/old/hooks/index.ts deleted file mode 100644 index 7b1e0ba..0000000 --- a/packages/react/src/old/hooks/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Public hooks surface: aggregator and focused derived hooks -// Aggregator hook (main API) -export { useForumApi } from './useForum'; - -// Core hooks (complex logic) -export { useForumData } from './core/useForumData'; -export { usePermissions } from './core/usePermissions'; -export { useUserDisplay } from './core/useUserDisplay'; - -// Derived hooks (data slicing utilities) -export { useCell } from './derived/useCell'; -export { usePost } from './derived/usePost'; -export { useCellPosts } from './derived/useCellPosts'; -export { usePostComments } from './derived/usePostComments'; -export { useUserVotes } from './derived/useUserVotes'; - -// Utility hooks (remaining complex logic) -export { useWallet } from './utilities/useWallet'; -export { useNetworkStatus } from './utilities/useNetworkStatus'; -export { useForumSelectors } from './utilities/useForumSelectors'; -export { useBookmarks, usePostBookmark, useCommentBookmark } from './utilities/useBookmarks'; - -// Export types -export type { - ForumData, - CellWithStats, - PostWithVoteStatus, - CommentWithVoteStatus, -} from './core/useForumData'; - -export type { - Permission, - PermissionReasons, - PermissionResult, -} from './core/usePermissions'; - -export type { UserDisplayInfo } from './core/useUserDisplay'; - -// Removed types from deleted action hooks - functionality now in useForumApi - -export type { CellData } from './derived/useCell'; -export type { PostData } from './derived/usePost'; -export type { CellPostsOptions, CellPostsData } from './derived/useCellPosts'; -export type { PostCommentsOptions, PostCommentsData } from './derived/usePostComments'; -export type { UserVoteData } from './derived/useUserVotes'; - -// Utility types -export type { - NetworkHealth, - SyncStatus, - ConnectionStatus, - NetworkStatusData, -} from './utilities/useNetworkStatus'; -export type { ForumSelectors } from './utilities/useForumSelectors'; - -// Remove duplicate re-exports - - diff --git a/packages/react/src/old/hooks/useAuth.ts b/packages/react/src/old/hooks/useAuth.ts deleted file mode 100644 index 045293e..0000000 --- a/packages/react/src/old/hooks/useAuth.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { useAuth } from '../contexts/AuthContext'; - - diff --git a/packages/react/src/old/hooks/useForum.ts b/packages/react/src/old/hooks/useForum.ts deleted file mode 100644 index 5cdd6a8..0000000 --- a/packages/react/src/old/hooks/useForum.ts +++ /dev/null @@ -1,629 +0,0 @@ -import { useMemo, useCallback, useState, useEffect } from 'react'; -import { useAuth } from '../contexts/AuthContext'; -import { useForum as useForumContext } from '../contexts/ForumContext'; -import { useClient } from '../contexts/ClientContext'; -import { usePermissions } from './core/usePermissions'; -import { useForumData } from './core/useForumData'; -import { useNetworkStatus } from './utilities/useNetworkStatus'; -import { useForumSelectors } from './utilities/useForumSelectors'; - -import type { - Cell, - Comment, - Post, - Bookmark, - User, - DelegationDuration, - EDisplayPreference, - EVerificationStatus, - OpchanMessage, -} from '@opchan/core'; -import type { - CellWithStats, - CommentWithVoteStatus, - ForumData, - PostWithVoteStatus, -} from './core/useForumData'; -import type { Permission } from './core/usePermissions'; - -export interface UseForumApi { - user: { - isConnected: boolean; - address?: string; - walletType?: 'bitcoin' | 'ethereum'; - ensName?: string | null; - ordinalDetails?: { ordinalId: string } | null; - verificationStatus: EVerificationStatus; - delegation: { - hasDelegation: boolean; - isValid: boolean; - timeRemaining?: number; - expiresAt?: Date; - publicKey?: string; - }; - profile: { - callSign: string | null; - displayPreference: EDisplayPreference | null; - }; - connect: () => Promise; - disconnect: () => Promise; - verifyOwnership: () => Promise; - delegateKey: (duration?: DelegationDuration) => Promise; - clearDelegation: () => Promise; - updateProfile: (updates: { - callSign?: string; - displayPreference?: EDisplayPreference; - }) => Promise; - signMessage: (msg: OpchanMessage) => Promise; - verifyMessage: (msg: OpchanMessage) => Promise; - }; - content: { - cells: Cell[]; - posts: Post[]; - comments: Comment[]; - bookmarks: Bookmark[]; - postsByCell: Record; - commentsByPost: Record; - filtered: { - cells: CellWithStats[]; - posts: PostWithVoteStatus[]; - comments: CommentWithVoteStatus[]; - }; - createCell: (input: { - name: string; - description: string; - icon?: string; - }) => Promise; - createPost: (input: { - cellId: string; - title: string; - content: string; - }) => Promise; - createComment: (input: { postId: string; content: string }) => Promise< - Comment | null - >; - vote: (input: { targetId: string; isUpvote: boolean }) => Promise; - moderate: { - post: ( - cellId: string, - postId: string, - reason?: string - ) => Promise; - unpost: ( - cellId: string, - postId: string, - reason?: string - ) => Promise; - comment: ( - cellId: string, - commentId: string, - reason?: string - ) => Promise; - uncomment: ( - cellId: string, - commentId: string, - reason?: string - ) => Promise; - user: ( - cellId: string, - userAddress: string, - reason?: string - ) => Promise; - unuser: ( - cellId: string, - userAddress: string, - reason?: string - ) => Promise; - }; - togglePostBookmark: (post: Post, cellId?: string) => Promise; - toggleCommentBookmark: ( - comment: Comment, - postId?: string - ) => Promise; - refresh: () => Promise; - pending: { - isPending: (id?: string) => boolean; - isVotePending: (targetId?: string) => boolean; - onChange: (cb: () => void) => () => void; - }; - }; - permissions: { - canPost: boolean; - canComment: boolean; - canVote: boolean; - canCreateCell: boolean; - canDelegate: boolean; - canModerate: (cellId: string) => boolean; - reasons: { - vote: string; - post: string; - comment: string; - createCell: string; - moderate: (cellId: string) => string; - }; - check: ( - action: - | 'canPost' - | 'canComment' - | 'canVote' - | 'canCreateCell' - | 'canDelegate' - | 'canModerate', - cellId?: string - ) => { allowed: boolean; reason: string }; - }; - network: { - isConnected: boolean; - statusColor: 'green' | 'yellow' | 'red'; - statusMessage: string; - issues: string[]; - canRefresh: boolean; - canSync: boolean; - needsAttention: boolean; - refresh: () => Promise; - recommendedActions: string[]; - }; - selectors: ReturnType; -} - -export function useForumApi(): UseForumApi { - const client = useClient(); - const { currentUser, verificationStatus, connectWallet, disconnectWallet, verifyOwnership } = useAuth(); - const { - refreshData, - } = useForumContext(); - - const forumData: ForumData = useForumData(); - const permissions = usePermissions(); - const network = useNetworkStatus(); - const selectors = useForumSelectors(forumData); - - // Bookmarks state (moved from useBookmarks) - const [bookmarks, setBookmarks] = useState([]); - - // Delegation functionality (moved from useDelegation) - const [delegationStatus, setDelegationStatus] = useState({ - hasDelegation: false, - isValid: false, - timeRemaining: 0, - expiresAt: undefined as Date | undefined, - publicKey: undefined as string | undefined, - }); - - // Update delegation status - useEffect(() => { - const updateStatus = async () => { - if (currentUser) { - const status = await client.delegation.getStatus(currentUser.address, currentUser.walletType); - setDelegationStatus({ - hasDelegation: !!status, - isValid: status?.isValid || false, - timeRemaining: status?.timeRemaining || 0, - expiresAt: status?.proof?.expiryTimestamp ? new Date(status.proof.expiryTimestamp) : undefined, - publicKey: status?.publicKey, - }); - } - }; - updateStatus(); - }, [client.delegation, currentUser]); - - // Load bookmarks for current user - useEffect(() => { - const load = async () => { - if (!currentUser?.address) { - setBookmarks([]); - return; - } - try { - const list = await client.database.getUserBookmarks(currentUser.address); - setBookmarks(list); - } catch (e) { - console.error('Failed to load bookmarks', e); - } - }; - load(); - }, [client.database, currentUser?.address]); - - const createDelegation = useCallback(async (duration?: DelegationDuration): Promise => { - if (!currentUser) return false; - try { - // Use the delegate method from DelegationManager - const signFunction = async (message: string) => { - // This would need to be implemented based on your wallet signing approach - // For now, return empty string - this needs proper wallet integration - return ''; - }; - - const success = await client.delegation.delegate( - currentUser.address, - currentUser.walletType, - duration, - signFunction - ); - - if (success) { - // Update status after successful delegation - const status = await client.delegation.getStatus(currentUser.address, currentUser.walletType); - setDelegationStatus({ - hasDelegation: !!status, - isValid: status?.isValid || false, - timeRemaining: status?.timeRemaining || 0, - expiresAt: status?.proof?.expiryTimestamp ? new Date(status.proof.expiryTimestamp) : undefined, - publicKey: status?.publicKey, - }); - } - - return success; - } catch (error) { - console.error('Failed to create delegation:', error); - return false; - } - }, [client.delegation, currentUser]); - - const clearDelegation = useCallback(async (): Promise => { - // Clear delegation storage using the database directly - await client.database.clearDelegation(); - setDelegationStatus({ - hasDelegation: false, - isValid: false, - timeRemaining: 0, - expiresAt: undefined, - publicKey: undefined, - }); - }, [client.database]); - - // Message signing functionality (moved from useMessageSigning) - const signMessage = useCallback(async (message: OpchanMessage): Promise => { - if (!currentUser) { - console.warn('No current user. Cannot sign message.'); - return; - } - const status = await client.delegation.getStatus(currentUser.address, currentUser.walletType); - if (!status?.isValid) { - console.warn('No valid delegation found. Cannot sign message.'); - return; - } - await client.messageManager.sendMessage(message); - }, [client.delegation, client.messageManager, currentUser]); - - const verifyMessage = useCallback(async (message: OpchanMessage): Promise => { - try { - // Use message service to verify message - return await client.messageService.verifyMessage(message); - } catch (error) { - console.error('Failed to verify message:', error); - return false; - } - }, [client.messageService]); - - type MaybeOrdinal = { ordinalId?: unknown } | null | undefined; - const toOrdinal = (value: MaybeOrdinal): { ordinalId: string } | null => { - if (value && typeof value === 'object' && typeof (value as { ordinalId?: unknown }).ordinalId === 'string') { - return { ordinalId: (value as { ordinalId: string }).ordinalId }; - } - return null; - }; - - const user = useMemo(() => { - return { - isConnected: Boolean(currentUser), - address: currentUser?.address, - walletType: currentUser?.walletType, - ensName: currentUser?.ensDetails?.ensName ?? null, - ordinalDetails: toOrdinal((currentUser as unknown as { ordinalDetails?: MaybeOrdinal } | null | undefined)?.ordinalDetails), - verificationStatus: verificationStatus, - delegation: { - hasDelegation: delegationStatus.hasDelegation, - isValid: delegationStatus.isValid, - timeRemaining: delegationStatus.timeRemaining, - expiresAt: delegationStatus.expiresAt, - publicKey: delegationStatus.publicKey, - }, - profile: { - callSign: currentUser?.callSign ?? null, - displayPreference: currentUser?.displayPreference ?? null, - }, - connect: async () => connectWallet(), - disconnect: async () => { disconnectWallet(); }, - verifyOwnership: async () => verifyOwnership(), - delegateKey: async (duration?: DelegationDuration) => createDelegation(duration), - clearDelegation: async () => { await clearDelegation(); }, - updateProfile: async (updates: { callSign?: string; displayPreference?: EDisplayPreference }) => { - if (!currentUser) { - throw new Error('User identity service is not available.'); - } - try { - // Update user identity in database - await client.database.upsertUserIdentity(currentUser.address, { - ...(updates.callSign !== undefined ? { callSign: updates.callSign } : {}), - ...(updates.displayPreference !== undefined ? { displayPreference: updates.displayPreference } : {}), - lastUpdated: Date.now(), - }); - - // Update user lightweight record for displayPreference if present - if (updates.displayPreference !== undefined) { - const updatedUser: User = { - address: currentUser.address, - walletType: currentUser.walletType!, - verificationStatus: currentUser.verificationStatus, - displayPreference: updates.displayPreference, - callSign: currentUser.callSign ?? undefined, - ensDetails: currentUser.ensDetails ?? undefined, - ordinalDetails: (currentUser as unknown as { ordinalDetails?: { ordinalId: string; ordinalDetails: string } | null }).ordinalDetails ?? undefined, - lastChecked: Date.now(), - }; - await client.database.storeUser(updatedUser); - } - return true; - } catch (error) { - console.error('Failed to update profile:', error); - return false; - } - }, - signMessage, - verifyMessage, - }; - }, [currentUser, verificationStatus, delegationStatus, connectWallet, disconnectWallet, verifyOwnership, createDelegation, clearDelegation, signMessage, verifyMessage]); - - const content = useMemo(() => { - return { - cells: forumData.cells, - posts: forumData.posts, - comments: forumData.comments, - bookmarks, - postsByCell: forumData.postsByCell, - commentsByPost: forumData.commentsByPost, - filtered: { - cells: forumData.filteredCellsWithStats, - posts: forumData.filteredPosts, - comments: forumData.filteredComments, - }, - createCell: async (input: { name: string; description: string; icon?: string }) => { - if (!permissions.canCreateCell) { - throw new Error(permissions.createCellReason); - } - if (!input.name.trim() || !input.description.trim()) { - throw new Error('Please provide both a name and description for the cell.'); - } - try { - const result = await client.forumActions.createCell( - { - name: input.name, - description: input.description, - icon: input.icon, - currentUser, - isAuthenticated: !!currentUser, - }, - async () => { await refreshData(); } - ); - return result.data || null; - } catch { - throw new Error('Failed to create cell. Please try again.'); - } - }, - createPost: async (input: { cellId: string; title: string; content: string }) => { - if (!permissions.canPost) { - throw new Error('Connect your wallet to create posts.'); - } - if (!input.title.trim() || !input.content.trim()) { - throw new Error('Please provide both a title and content for the post.'); - } - try { - const result = await client.forumActions.createPost( - { - cellId: input.cellId, - title: input.title, - content: input.content, - currentUser, - isAuthenticated: !!currentUser, - }, - async () => { await refreshData(); } - ); - return result.data || null; - } catch { - throw new Error('Failed to create post. Please try again.'); - } - }, - createComment: async (input: { postId: string; content: string }) => { - if (!permissions.canComment) { - throw new Error('You need to connect your wallet to create comments.'); - } - if (!input.content.trim()) { - throw new Error('Please provide content for the comment.'); - } - try { - const result = await client.forumActions.createComment( - { - postId: input.postId, - content: input.content, - currentUser, - isAuthenticated: !!currentUser, - }, - async () => { await refreshData(); } - ); - return result.data || null; - } catch { - throw new Error('Failed to create comment. Please try again.'); - } - }, - vote: async (input: { targetId: string; isUpvote: boolean }) => { - if (!permissions.canVote) { - throw new Error(permissions.voteReason); - } - if (!input.targetId) return false; - try { - // Use the unified vote method from ForumActions - const result = await client.forumActions.vote( - { - targetId: input.targetId, - isUpvote: input.isUpvote, - currentUser, - isAuthenticated: !!currentUser, - }, - async () => { await refreshData(); } - ); - return result.success; - } catch { - return false; - } - }, - moderate: { - post: async (cellId: string, postId: string, reason?: string) => { - try { - const result = await client.forumActions.moderatePost( - { cellId, postId, reason, currentUser, isAuthenticated: !!currentUser, cellOwner: currentUser?.address || '' }, - async () => { await refreshData(); } - ); - return result.success; - } catch { return false; } - }, - unpost: async (cellId: string, postId: string, reason?: string) => { - try { - const result = await client.forumActions.unmoderatePost( - { cellId, postId, reason, currentUser, isAuthenticated: !!currentUser, cellOwner: currentUser?.address || '' }, - async () => { await refreshData(); } - ); - return result.success; - } catch { return false; } - }, - comment: async (cellId: string, commentId: string, reason?: string) => { - try { - const result = await client.forumActions.moderateComment( - { cellId, commentId, reason, currentUser, isAuthenticated: !!currentUser, cellOwner: currentUser?.address || '' }, - async () => { await refreshData(); } - ); - return result.success; - } catch { return false; } - }, - uncomment: async (cellId: string, commentId: string, reason?: string) => { - try { - const result = await client.forumActions.unmoderateComment( - { cellId, commentId, reason, currentUser, isAuthenticated: !!currentUser, cellOwner: currentUser?.address || '' }, - async () => { await refreshData(); } - ); - return result.success; - } catch { return false; } - }, - user: async (cellId: string, userAddress: string, reason?: string) => { - try { - const result = await client.forumActions.moderateUser( - { cellId, userAddress, reason, currentUser, isAuthenticated: !!currentUser, cellOwner: currentUser?.address || '' }, - async () => { await refreshData(); } - ); - return result.success; - } catch { return false; } - }, - unuser: async (cellId: string, userAddress: string, reason?: string) => { - try { - const result = await client.forumActions.unmoderateUser( - { cellId, userAddress, reason, currentUser, isAuthenticated: !!currentUser, cellOwner: currentUser?.address || '' }, - async () => { await refreshData(); } - ); - return result.success; - } catch { return false; } - }, - }, - togglePostBookmark: async (post: Post, cellId?: string) => { - try { - if (!currentUser?.address) return false; - const { BookmarkService } = await import('@opchan/core'); - const added = await BookmarkService.togglePostBookmark(post, currentUser.address, cellId); - // Update local state snapshot from DB cache for immediate UI feedback - const updated = await client.database.getUserBookmarks(currentUser.address); - setBookmarks(updated); - return added; - } catch (e) { - console.error('togglePostBookmark failed', e); - return false; - } - }, - toggleCommentBookmark: async (comment: Comment, postId?: string) => { - try { - if (!currentUser?.address) return false; - const { BookmarkService } = await import('@opchan/core'); - const added = await BookmarkService.toggleCommentBookmark(comment, currentUser.address, postId); - const updated = await client.database.getUserBookmarks(currentUser.address); - setBookmarks(updated); - return added; - } catch (e) { - console.error('toggleCommentBookmark failed', e); - return false; - } - }, - refresh: async () => { await refreshData(); }, - pending: { - isPending: (id?: string) => { - return id ? client.database.isPending(id) : false; - }, - isVotePending: (targetId?: string) => { - if (!targetId || !currentUser?.address) return false; - return Object.values(client.database.cache.votes).some(v => { - return ( - v.targetId === targetId && - v.author === currentUser.address && - client.database.isPending(v.id) - ); - }); - }, - onChange: (cb: () => void) => { - return client.database.onPendingChange(cb); - }, - }, - }; - }, [forumData, bookmarks, refreshData, currentUser, permissions, client]); - - const permissionsSlice = useMemo(() => { - return { - canPost: permissions.canPost, - canComment: permissions.canComment, - canVote: permissions.canVote, - canCreateCell: permissions.canCreateCell, - canDelegate: permissions.canDelegate, - canModerate: permissions.canModerate, - reasons: { - vote: permissions.voteReason, - post: permissions.postReason, - comment: permissions.commentReason, - createCell: permissions.createCellReason, - moderate: permissions.moderateReason, - }, - check: (action: keyof Permission, cellId?: string) => { - return permissions.checkPermission(action, cellId); - }, - }; - }, [permissions]); - - const networkSlice = useMemo(() => { - return { - isConnected: network.health.isConnected, - statusColor: network.getHealthColor(), - statusMessage: network.getStatusMessage(), - issues: network.health.issues, - canRefresh: network.canRefresh, - canSync: network.canSync, - needsAttention: network.needsAttention, - refresh: async () => { await forumData && content.refresh(); }, - recommendedActions: network.getRecommendedActions(), - }; - }, [ - network.health.isConnected, - network.health.isHealthy, - network.health.issues, - network.canRefresh, - network.canSync, - network.needsAttention, - forumData, - content - ]); - - return useMemo(() => ({ - user, - content, - permissions: permissionsSlice, - network: networkSlice, - selectors, - }), [user, content, permissionsSlice, networkSlice, selectors]); -} - - - diff --git a/packages/react/src/old/hooks/useModeration.ts b/packages/react/src/old/hooks/useModeration.ts deleted file mode 100644 index d29a7c0..0000000 --- a/packages/react/src/old/hooks/useModeration.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { useModeration } from '../contexts/ModerationContext'; - - diff --git a/packages/react/src/old/hooks/utilities/useBookmarks.ts b/packages/react/src/old/hooks/utilities/useBookmarks.ts deleted file mode 100644 index d161aac..0000000 --- a/packages/react/src/old/hooks/utilities/useBookmarks.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useAuth } from '../../contexts/AuthContext'; -import { useClient } from '../../contexts/ClientContext'; -import { Bookmark, BookmarkType, Post, Comment } from '@opchan/core'; -import { BookmarkService } from '@opchan/core'; - -export interface UseBookmarksReturn { - bookmarks: Bookmark[]; - loading: boolean; - error: string | null; - getBookmarksByType: (type: BookmarkType) => Bookmark[]; - removeBookmark: (bookmark: Bookmark) => Promise; - clearAllBookmarks: () => Promise; - togglePostBookmark: (post: Post, cellId?: string) => Promise; - toggleCommentBookmark: (comment: Comment, postId?: string) => Promise; -} - -export function useBookmarks(): UseBookmarksReturn { - const { currentUser } = useAuth(); - const client = useClient(); - const [bookmarks, setBookmarks] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const refresh = useCallback(async () => { - if (!currentUser?.address) { - setBookmarks([]); - setLoading(false); - return; - } - try { - setLoading(true); - const list = await client.database.getUserBookmarks(currentUser.address); - setBookmarks(list); - setError(null); - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to load bookmarks'); - } finally { - setLoading(false); - } - }, [client.database, currentUser?.address]); - - useEffect(() => { - refresh(); - }, [refresh]); - - const getBookmarksByType = useCallback( - (type: BookmarkType): Bookmark[] => - bookmarks.filter(b => b.type === type), - [bookmarks] - ); - - const removeBookmark = useCallback( - async (bookmark: Bookmark): Promise => { - await BookmarkService.removeBookmark(bookmark.type, bookmark.targetId); - await refresh(); - }, - [refresh] - ); - - const clearAllBookmarks = useCallback(async (): Promise => { - if (!currentUser?.address) return; - await BookmarkService.clearUserBookmarks(currentUser.address); - await refresh(); - }, [currentUser?.address, refresh]); - - const togglePostBookmark = useCallback( - async (post: Post, cellId?: string): Promise => { - if (!currentUser?.address) return false; - const added = await BookmarkService.togglePostBookmark( - post, - currentUser.address, - cellId - ); - await refresh(); - return added; - }, - [currentUser?.address, refresh] - ); - - const toggleCommentBookmark = useCallback( - async (comment: Comment, postId?: string): Promise => { - if (!currentUser?.address) return false; - const added = await BookmarkService.toggleCommentBookmark( - comment, - currentUser.address, - postId - ); - await refresh(); - return added; - }, - [currentUser?.address, refresh] - ); - - return useMemo( - () => ({ - bookmarks, - loading, - error, - getBookmarksByType, - removeBookmark, - clearAllBookmarks, - togglePostBookmark, - toggleCommentBookmark, - }), - [ - bookmarks, - loading, - error, - getBookmarksByType, - removeBookmark, - clearAllBookmarks, - togglePostBookmark, - toggleCommentBookmark, - ] - ); -} - -// Optional convenience hooks to match historic API surface -export function usePostBookmark() { - const { togglePostBookmark } = useBookmarks(); - return { togglePostBookmark }; -} - -export function useCommentBookmark() { - const { toggleCommentBookmark } = useBookmarks(); - return { toggleCommentBookmark }; -} - - diff --git a/packages/react/src/old/hooks/utilities/useForumSelectors.ts b/packages/react/src/old/hooks/utilities/useForumSelectors.ts deleted file mode 100644 index 0e58fbd..0000000 --- a/packages/react/src/old/hooks/utilities/useForumSelectors.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { useMemo } from 'react'; -import { ForumData } from '../core/useForumData'; -import { Cell, Post, Comment } from '@opchan/core'; -import { EVerificationStatus } from '@opchan/core'; - -// Selector types for different data slices -export type CellSelector = (cells: Cell[]) => T; -export type PostSelector = (posts: Post[]) => T; -export type CommentSelector = (comments: Comment[]) => T; - -// Common selector patterns -export interface ForumSelectors { - // Cell selectors - selectCellsByActivity: () => Cell[]; - selectCellsByMemberCount: () => Cell[]; - selectCellsByRelevance: () => Cell[]; - selectCellById: (id: string) => Cell | null; - selectCellsByOwner: (ownerAddress: string) => Cell[]; - - // Post selectors - selectPostsByCell: (cellId: string) => Post[]; - selectPostsByAuthor: (authorAddress: string) => Post[]; - selectPostsByVoteScore: (minScore?: number) => Post[]; - selectTrendingPosts: (timeframe?: number) => Post[]; - selectRecentPosts: (limit?: number) => Post[]; - selectPostById: (id: string) => Post | null; - - // Comment selectors - selectCommentsByPost: (postId: string) => Comment[]; - selectCommentsByAuthor: (authorAddress: string) => Comment[]; - selectRecentComments: (limit?: number) => Comment[]; - selectCommentById: (id: string) => Comment | null; - - // User-specific selectors - selectUserPosts: (userAddress: string) => Post[]; - selectUserComments: (userAddress: string) => Comment[]; - selectUserActivity: (userAddress: string) => { - posts: Post[]; - comments: Comment[]; - }; - selectVerifiedUsers: () => string[]; - selectActiveUsers: (timeframe?: number) => string[]; - - // Search and filter selectors - searchPosts: (query: string) => Post[]; - searchComments: (query: string) => Comment[]; - searchCells: (query: string) => Cell[]; - filterByVerification: ( - items: (Post | Comment)[], - level: EVerificationStatus - ) => (Post | Comment)[]; - - // Aggregation selectors - selectStats: () => { - totalCells: number; - totalPosts: number; - totalComments: number; - totalUsers: number; - verifiedUsers: number; - }; -} - -/** - * Hook providing optimized selectors for forum data - */ -export function useForumSelectors(forumData: ForumData): ForumSelectors { - const { - cells, - postsWithVoteStatus: posts, - commentsWithVoteStatus: comments, - userVerificationStatus, - } = forumData; - - // Cell selectors - const selectCellsByActivity = useMemo(() => { - return (): Cell[] => { - return [...cells].sort((a, b) => { - const aActivity = - 'recentActivity' in b ? (b.recentActivity as number) : 0; - const bActivity = - 'recentActivity' in a ? (a.recentActivity as number) : 0; - return aActivity - bActivity; - }); - }; - }, [cells]); - - const selectCellsByMemberCount = useMemo(() => { - return (): Cell[] => { - return [...cells].sort( - (a, b) => (b.activeMemberCount || 0) - (a.activeMemberCount || 0) - ); - }; - }, [cells]); - - const selectCellsByRelevance = useMemo(() => { - return (): Cell[] => { - return [...cells].sort( - (a, b) => (b.relevanceScore || 0) - (a.relevanceScore || 0) - ); - }; - }, [cells]); - - const selectCellById = useMemo(() => { - return (id: string): Cell | null => { - return cells.find(cell => cell.id === id) || null; - }; - }, [cells]); - - const selectCellsByOwner = useMemo(() => { - return (ownerAddress: string): Cell[] => { - return cells.filter(cell => (cell as unknown as { signature?: string }).signature === ownerAddress); - }; - }, [cells]); - - // Post selectors - const selectPostsByCell = useMemo(() => { - return (cellId: string): Post[] => { - return posts.filter(post => post.cellId === cellId); - }; - }, [posts]); - - const selectPostsByAuthor = useMemo(() => { - return (authorAddress: string): Post[] => { - return posts.filter(post => post.author === authorAddress); - }; - }, [posts]); - - const selectPostsByVoteScore = useMemo(() => { - return (minScore: number = 0): Post[] => { - return posts.filter(post => (post as unknown as { voteScore?: number }).voteScore! >= minScore); - }; - }, [posts]); - - const selectTrendingPosts = useMemo(() => { - return (timeframe: number = 7 * 24 * 60 * 60 * 1000): Post[] => { - const cutoff = Date.now() - timeframe; - return posts - .filter(post => post.timestamp > cutoff) - .sort((a, b) => { - if ( - a.relevanceScore !== undefined && - b.relevanceScore !== undefined - ) { - return b.relevanceScore - a.relevanceScore; - } - return ((b as unknown as { voteScore?: number }).voteScore || 0) - - ((a as unknown as { voteScore?: number }).voteScore || 0); - }); - }; - }, [posts]); - - const selectRecentPosts = useMemo(() => { - return (limit: number = 10): Post[] => { - return [...posts] - .sort((a, b) => b.timestamp - a.timestamp) - .slice(0, limit); - }; - }, [posts]); - - const selectPostById = useMemo(() => { - return (id: string): Post | null => { - return posts.find(post => post.id === id) || null; - }; - }, [posts]); - - // Comment selectors - const selectCommentsByPost = useMemo(() => { - return (postId: string): Comment[] => { - return comments.filter(comment => comment.postId === postId); - }; - }, [comments]); - - const selectCommentsByAuthor = useMemo(() => { - return (authorAddress: string): Comment[] => { - return comments.filter(comment => comment.author === authorAddress); - }; - }, [comments]); - - const selectRecentComments = useMemo(() => { - return (limit: number = 10): Comment[] => { - return [...comments] - .sort((a, b) => b.timestamp - a.timestamp) - .slice(0, limit); - }; - }, [comments]); - - const selectCommentById = useMemo(() => { - return (id: string): Comment | null => { - return comments.find(comment => comment.id === id) || null; - }; - }, [comments]); - - // User-specific selectors - const selectUserPosts = useMemo(() => { - return (userAddress: string): Post[] => { - return posts.filter(post => post.author === userAddress); - }; - }, [posts]); - - const selectUserComments = useMemo(() => { - return (userAddress: string): Comment[] => { - return comments.filter(comment => comment.author === userAddress); - }; - }, [comments]); - - const selectUserActivity = useMemo(() => { - return (userAddress: string) => { - return { - posts: posts.filter(post => post.author === userAddress), - comments: comments.filter(comment => comment.author === userAddress), - }; - }; - }, [posts, comments]); - - const selectVerifiedUsers = useMemo(() => { - return (): string[] => { - return Object.entries(userVerificationStatus) - .filter(([_, status]) => status.isVerified) - .map(([address]) => address); - }; - }, [userVerificationStatus]); - - const selectActiveUsers = useMemo(() => { - return (timeframe: number = 7 * 24 * 60 * 60 * 1000): string[] => { - const cutoff = Date.now() - timeframe; - const activeUsers = new Set(); - - posts.forEach(post => { - if (post.timestamp > cutoff) { - activeUsers.add(post.author); - } - }); - - comments.forEach(comment => { - if (comment.timestamp > cutoff) { - activeUsers.add(comment.author); - } - }); - - return Array.from(activeUsers); - }; - }, [posts, comments]); - - // Search selectors - const searchPosts = useMemo(() => { - return (query: string): Post[] => { - const lowerQuery = query.toLowerCase(); - return posts.filter( - post => - post.title.toLowerCase().includes(lowerQuery) || - post.content.toLowerCase().includes(lowerQuery) - ); - }; - }, [posts]); - - const searchComments = useMemo(() => { - return (query: string): Comment[] => { - const lowerQuery = query.toLowerCase(); - return comments.filter(comment => - comment.content.toLowerCase().includes(lowerQuery) - ); - }; - }, [comments]); - - const searchCells = useMemo(() => { - return (query: string): Cell[] => { - const lowerQuery = query.toLowerCase(); - return cells.filter( - cell => - cell.name.toLowerCase().includes(lowerQuery) || - cell.description.toLowerCase().includes(lowerQuery) - ); - }; - }, [cells]); - - const filterByVerification = useMemo(() => { - return ( - items: (Post | Comment)[], - level: EVerificationStatus - ): (Post | Comment)[] => { - return items.filter(item => { - const userStatus = userVerificationStatus[item.author]; - return userStatus?.verificationStatus === level; - }); - }; - }, [userVerificationStatus]); - - // Aggregation selectors - const selectStats = useMemo(() => { - return () => { - const uniqueUsers = new Set([ - ...posts.map(post => post.author), - ...comments.map(comment => comment.author), - ]); - - const verifiedUsers = Object.values(userVerificationStatus).filter( - status => status.isVerified - ).length; - - return { - totalCells: cells.length, - totalPosts: posts.length, - totalComments: comments.length, - totalUsers: uniqueUsers.size, - verifiedUsers, - }; - }; - }, [cells, posts, comments, userVerificationStatus]); - - return { - selectCellsByActivity, - selectCellsByMemberCount, - selectCellsByRelevance, - selectCellById, - selectCellsByOwner, - selectPostsByCell, - selectPostsByAuthor, - selectPostsByVoteScore, - selectTrendingPosts, - selectRecentPosts, - selectPostById, - selectCommentsByPost, - selectCommentsByAuthor, - selectRecentComments, - selectCommentById, - selectUserPosts, - selectUserComments, - selectUserActivity, - selectVerifiedUsers, - selectActiveUsers, - searchPosts, - searchComments, - searchCells, - filterByVerification, - selectStats, - }; -} diff --git a/packages/react/src/old/hooks/utilities/useNetworkStatus.ts b/packages/react/src/old/hooks/utilities/useNetworkStatus.ts deleted file mode 100644 index cdfffbc..0000000 --- a/packages/react/src/old/hooks/utilities/useNetworkStatus.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { useMemo, useState, useEffect } from 'react'; -import { useForum } from '../../contexts/ForumContext'; -import { useAuth } from '../../contexts/AuthContext'; -import { useClient } from '../../contexts/ClientContext'; -import { DelegationFullStatus } from '@opchan/core'; - -export interface NetworkHealth { - isConnected: boolean; - isHealthy: boolean; - lastSync: number | null; - syncAge: string | null; - issues: string[]; -} - -export interface SyncStatus { - isInitialLoading: boolean; - isRefreshing: boolean; - isSyncing: boolean; - lastRefresh: number | null; - nextRefresh: number | null; - autoRefreshEnabled: boolean; -} - -export interface ConnectionStatus { - waku: { - connected: boolean; - peers: number; - status: 'connected' | 'connecting' | 'disconnected' | 'error'; - }; - wallet: { - connected: boolean; - network: string | null; - status: 'connected' | 'connecting' | 'disconnected' | 'error'; - }; - delegation: { - active: boolean; - expires: number | null; - status: 'active' | 'expired' | 'none'; - }; -} - -export interface NetworkStatusData { - // Overall status - health: NetworkHealth; - sync: SyncStatus; - connections: ConnectionStatus; - - // Actions - canRefresh: boolean; - canSync: boolean; - needsAttention: boolean; - - // Helper methods - getStatusMessage: () => string; - getHealthColor: () => 'green' | 'yellow' | 'red'; - getRecommendedActions: () => string[]; -} - -export function useNetworkStatus(): NetworkStatusData { - const { isNetworkConnected, isInitialLoading, isRefreshing, error } = - useForum(); - const client = useClient(); - - const { isAuthenticated, currentUser, getDelegationStatus } = useAuth(); - const [delegationInfo, setDelegationInfo] = - useState(null); - - // Track Waku ready state directly from the client to react to changes - const [wakuReady, setWakuReady] = useState( - Boolean((client)?.messageManager?.isReady) - ); - - useEffect(() => { - try { - // Prime from current state so UI updates immediately without navigation - try { - const nowReady = Boolean(client?.messageManager?.isReady); - setWakuReady(nowReady); - } catch {} - - const off = client?.messageManager?.onHealthChange?.( - (ready: boolean) => { - setWakuReady(Boolean(ready)); - } - ); - return () => { - try { off && off(); } catch {} - }; - } catch {} - }, [client]); - - // Load delegation status - useEffect(() => { - getDelegationStatus().then(setDelegationInfo).catch(console.error); - }, [getDelegationStatus]); - - // Network health assessment - const health = useMemo((): NetworkHealth => { - const issues: string[] = []; - - const fallbackConnected = Boolean(wakuReady); - const effectiveConnected = isNetworkConnected || wakuReady; - - if (!effectiveConnected) { - issues.push('Waku network disconnected'); - } - - if (error) { - issues.push(`Forum error: ${error}`); - } - - if (isAuthenticated && !delegationInfo?.isValid) { - issues.push('Key delegation expired'); - } - - const isHealthy = issues.length === 0; - const lastSync = Date.now(); // This would come from actual sync tracking - const syncAge = lastSync ? formatTimeAgo(lastSync) : null; - - return { - isConnected: effectiveConnected, - isHealthy, - lastSync, - syncAge, - issues, - }; - }, [client, isNetworkConnected, wakuReady, error, isAuthenticated, delegationInfo?.isValid]); - - // Sync status - const sync = useMemo((): SyncStatus => { - const lastRefresh = Date.now() - 30000; // Mock: 30 seconds ago - const nextRefresh = lastRefresh + 60000; // Mock: every minute - - return { - isInitialLoading, - isRefreshing, - isSyncing: isInitialLoading || isRefreshing, - lastRefresh, - nextRefresh, - autoRefreshEnabled: true, // This would be configurable - }; - }, [isInitialLoading, isRefreshing]); - - // Connection status - const connections = useMemo((): ConnectionStatus => { - const effectiveConnected = health.isConnected; - return { - waku: { - connected: effectiveConnected, - peers: effectiveConnected ? 3 : 0, // Mock peer count - status: effectiveConnected ? 'connected' : 'disconnected', - }, - wallet: { - connected: isAuthenticated, - network: currentUser?.walletType === 'bitcoin' ? 'Bitcoin' : 'Ethereum', - status: isAuthenticated ? 'connected' : 'disconnected', - }, - delegation: { - active: delegationInfo?.isValid || false, - expires: delegationInfo?.timeRemaining || null, - status: delegationInfo?.isValid ? 'active' : 'expired', - }, - }; - }, [health.isConnected, isAuthenticated, currentUser, delegationInfo]); - - // Status assessment - const canRefresh = !isRefreshing && !isInitialLoading; - const canSync = health.isConnected && !isRefreshing; - const needsAttention = !health.isHealthy || !delegationInfo?.isValid; - - // Helper methods - const getStatusMessage = useMemo(() => { - return (): string => { - if (isInitialLoading) return 'Loading forum data...'; - if (isRefreshing) return 'Refreshing data...'; - const fallbackConnected = Boolean(wakuReady); - const effectiveConnected = isNetworkConnected || fallbackConnected; - if (!effectiveConnected) return 'Network disconnected'; - if (error) return `Error: ${error}`; - if (health.issues.length > 0) return health.issues[0] || 'Unknown issue'; - return 'All systems operational'; - }; - }, [ - isInitialLoading, - isRefreshing, - isNetworkConnected, - client, - wakuReady, - error, - health.issues, - ]); - - const getHealthColor = useMemo(() => { - return (): 'green' | 'yellow' | 'red' => { - const fallbackConnected = Boolean(wakuReady); - const effectiveConnected = isNetworkConnected || fallbackConnected; - if (!effectiveConnected || error) return 'red'; - if (health.issues.length > 0 || !delegationInfo?.isValid) return 'yellow'; - return 'green'; - }; - }, [ - isNetworkConnected, - client, - wakuReady, - error, - health.issues.length, - delegationInfo?.isValid, - ]); - - const getRecommendedActions = useMemo(() => { - return (): string[] => { - const actions: string[] = []; - - if (!isNetworkConnected) { - actions.push('Check your internet connection'); - actions.push('Try refreshing the page'); - } - - if (!isAuthenticated) { - actions.push('Connect your wallet'); - } - - if (!delegationInfo?.isValid) { - actions.push('Renew key delegation'); - } - - if ( - delegationInfo?.isValid && - delegationInfo?.timeRemaining && - delegationInfo.timeRemaining < 3600 - ) { - actions.push('Consider renewing key delegation soon'); - } - - if (error) { - actions.push('Try refreshing forum data'); - } - - if (actions.length === 0) { - actions.push('All systems are working normally'); - } - - return actions; - }; - }, [isNetworkConnected, isAuthenticated, delegationInfo, error]); - - return { - health, - sync, - connections, - canRefresh, - canSync, - needsAttention, - getStatusMessage, - getHealthColor, - getRecommendedActions, - }; -} - -// Helper function to format time ago -function formatTimeAgo(timestamp: number): string { - const now = Date.now(); - const diff = now - timestamp; - - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (days > 0) return `${days}d ago`; - if (hours > 0) return `${hours}h ago`; - if (minutes > 0) return `${minutes}m ago`; - return `${seconds}s ago`; -} diff --git a/packages/react/src/old/hooks/utilities/useWallet.ts b/packages/react/src/old/hooks/utilities/useWallet.ts deleted file mode 100644 index 3334d17..0000000 --- a/packages/react/src/old/hooks/utilities/useWallet.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useCallback } from 'react'; -import { useAuth } from '../../contexts/AuthContext'; -import { modal } from '@reown/appkit/react'; - -export const useWallet = () => { - const { - currentUser, - isAuthenticated, - verificationStatus, - connectWallet: contextConnectWallet, - disconnectWallet: contextDisconnectWallet, - } = useAuth(); - - const connect = useCallback(async (): Promise => { - return contextConnectWallet(); - }, [contextConnectWallet]); - - const disconnect = useCallback((): void => { - contextDisconnectWallet(); - }, [contextDisconnectWallet]); - - const openModal = useCallback(async (): Promise => { - if (modal) { - await modal.open(); - } - }, []); - - const closeModal = useCallback((): void => { - if (modal) { - modal.close(); - } - }, []); - - return { - // Wallet state - isConnected: isAuthenticated, - address: currentUser?.address, - walletType: currentUser?.walletType, - verificationStatus, - currentUser, - - // Wallet actions - connect, - disconnect, - openModal, - closeModal, - }; -}; diff --git a/packages/react/src/old/index.ts b/packages/react/src/old/index.ts deleted file mode 100644 index f24da62..0000000 --- a/packages/react/src/old/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from './provider/OpChanProvider'; -export { ClientProvider, useClient } from './contexts/ClientContext'; -export { AuthProvider, useAuth } from './contexts/AuthContext'; -export { ForumProvider, useForum as useForumContext } from './contexts/ForumContext'; -export { ModerationProvider, useModeration } from './contexts/ModerationContext'; - -export * from './hooks'; -export { useForumApi as useForum } from './hooks/useForum'; - diff --git a/packages/react/src/old/provider/OpChanProvider.tsx b/packages/react/src/old/provider/OpChanProvider.tsx deleted file mode 100644 index 0129631..0000000 --- a/packages/react/src/old/provider/OpChanProvider.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { OpChanClient } from '@opchan/core'; -import { localDatabase } from '@opchan/core'; -import { ClientProvider } from '../contexts/ClientContext'; -import { AuthProvider } from '../contexts/AuthContext'; -import { ForumProvider } from '../contexts/ForumContext'; -import { ModerationProvider } from '../contexts/ModerationContext'; - -export interface OpChanProviderProps { - ordiscanApiKey: string; - debug?: boolean; - children: React.ReactNode; -} - -export const OpChanProvider: React.FC = ({ - ordiscanApiKey, - debug, - children, -}) => { - const [isReady, setIsReady] = useState(false); - const clientRef = useRef(null); - - useEffect(() => { - let cancelled = false; - - const init = async () => { - if (typeof window === 'undefined') return; // SSR guard - - // Configure environment and create client - const client = new OpChanClient({ - ordiscanApiKey, - }); - clientRef.current = client; - - // Open local DB early for warm cache - await localDatabase.open().catch(console.error); - - try { - await client.messageManager.initialize(); - } catch (e) { - console.error('Failed to initialize message manager:', e); - } - - - if (!cancelled) setIsReady(true); - }; - - init(); - return () => { - cancelled = true; - }; - }, [ordiscanApiKey, debug]); - - const providers = useMemo(() => { - if (!isReady || !clientRef.current) return null; - return ( - - - - {children} - - - - ); - }, [isReady, children]); - - return providers || null; -}; - - diff --git a/packages/react/src/v1/context/ClientContext.tsx b/packages/react/src/v1/context/ClientContext.tsx new file mode 100644 index 0000000..31ee24a --- /dev/null +++ b/packages/react/src/v1/context/ClientContext.tsx @@ -0,0 +1,28 @@ +import React, { createContext, useContext } from 'react'; +import type { OpChanClient } from '@opchan/core'; + +interface ClientContextValue { + client: OpChanClient; +} + +const ClientContext = createContext(null); + +type ProviderProps = { client: OpChanClient; children: React.ReactNode }; + +export function OpChanProvider({ client, children }: ProviderProps) { + return ( + + {children} + + ); +} + +export function useClient(): OpChanClient { + const ctx = useContext(ClientContext); + if (!ctx) throw new Error('useClient must be used within OpChanProvider'); + return ctx.client; +} + + + + diff --git a/packages/react/src/v1/hooks/useAuth.ts b/packages/react/src/v1/hooks/useAuth.ts new file mode 100644 index 0000000..b3b3777 --- /dev/null +++ b/packages/react/src/v1/hooks/useAuth.ts @@ -0,0 +1,195 @@ +import React from 'react'; +import { useClient } from '../context/ClientContext'; +import { useOpchanStore, setOpchanState } from '../store/opchanStore'; +import { + User, + EVerificationStatus, + DelegationDuration, + EDisplayPreference, +} from '@opchan/core'; +import type { DelegationFullStatus } from '@opchan/core'; + +export interface ConnectInput { + address: string; + walletType: 'bitcoin' | 'ethereum'; +} + +export function useAuth() { + const client = useClient(); + const currentUser = useOpchanStore(s => s.session.currentUser); + const verificationStatus = useOpchanStore(s => s.session.verificationStatus); + const delegation = useOpchanStore(s => s.session.delegation); + + const connect = React.useCallback(async (input: ConnectInput): Promise => { + const baseUser: User = { + address: input.address, + walletType: input.walletType, + displayName: input.address, + 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 + await client.userIdentityService.getUserIdentity(baseUser.address); + + setOpchanState(prev => ({ + ...prev, + session: { + ...prev.session, + currentUser: baseUser, + verificationStatus: baseUser.verificationStatus, + delegation: prev.session.delegation, + }, + })); + return true; + } catch (e) { + console.error('connect failed', e); + return false; + } + }, [client]); + + 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]); + + const verifyOwnership = React.useCallback(async (): Promise => { + const user = currentUser; + if (!user) return false; + try { + const identity = await client.userIdentityService.getUserIdentityFresh(user.address); + const nextStatus = identity?.verificationStatus ?? EVerificationStatus.WALLET_CONNECTED; + + const updated: User = { + ...user, + verificationStatus: nextStatus, + displayName: identity?.displayPreference === EDisplayPreference.CALL_SIGN ? identity.callSign! : identity!.ensName!, + ensDetails: identity?.ensName ? { ensName: identity.ensName } : undefined, + ordinalDetails: identity?.ordinalDetails, + }; + + await client.database.storeUser(updated); + await client.database.upsertUserIdentity(user.address, { + ensName: identity?.ensName || undefined, + ordinalDetails: identity?.ordinalDetails, + verificationStatus: nextStatus, + lastUpdated: Date.now(), + }); + + setOpchanState(prev => ({ + ...prev, + session: { ...prev.session, currentUser: updated, verificationStatus: nextStatus }, + })); + return nextStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED; + } catch (e) { + console.error('verifyOwnership failed', e); + return false; + } + }, [client, currentUser]); + + const delegate = React.useCallback(async ( + signFunction: (message: string) => Promise, + duration: DelegationDuration = '7days', + ): Promise => { + const user = currentUser; + if (!user) return false; + try { + const ok = await client.delegation.delegate( + user.address, + user.walletType, + duration, + signFunction, + ); + + const status = await client.delegation.getStatus(user.address, user.walletType); + setOpchanState(prev => ({ + ...prev, + session: { ...prev.session, delegation: status }, + })); + return ok; + } catch (e) { + console.error('delegate failed', e); + return false; + } + }, [client, currentUser]); + + const delegationStatus = React.useCallback(async () => { + const user = currentUser; + if (!user) return { hasDelegation: false, isValid: false } as const; + return client.delegation.getStatus(user.address, user.walletType); + }, [client, currentUser]); + + const clearDelegation = React.useCallback(async () => { + await client.delegation.clear(); + setOpchanState(prev => ({ + ...prev, + session: { ...prev.session, delegation: null }, + })); + }, [client]); + + const updateProfile = React.useCallback(async (updates: { callSign?: string; displayPreference?: EDisplayPreference }): Promise => { + const user = currentUser; + if (!user) return false; + try { + const ok = await client.userIdentityService.updateUserProfile( + user.address, + updates.callSign, + updates.displayPreference ?? user.displayPreference, + ); + if (!ok) return false; + + await client.userIdentityService.refreshUserIdentity(user.address); + const fresh = await client.userIdentityService.getUserIdentity(user.address); + const updated: User = { + ...user, + callSign: fresh?.callSign ?? user.callSign, + displayPreference: fresh?.displayPreference ?? user.displayPreference, + }; + await client.database.storeUser(updated); + setOpchanState(prev => ({ ...prev, session: { ...prev.session, currentUser: updated } })); + return true; + } catch (e) { + console.error('updateProfile failed', e); + return false; + } + }, [client, currentUser]); + + return { + currentUser, + verificationStatus, + isAuthenticated: currentUser !== null, + // Provide a stable, non-null delegation object for UI safety + delegation: ((): DelegationFullStatus & { expiresAt?: Date } => { + const base: DelegationFullStatus = + delegation ?? ({ hasDelegation: false, isValid: false } as const); + const expiresAt = base?.proof?.expiryTimestamp + ? new Date(base.proof.expiryTimestamp) + : undefined; + return { ...base, expiresAt }; + })(), + connect, + disconnect, + verifyOwnership, + delegate, + delegationStatus, + clearDelegation, + updateProfile, + } as const; +} + + + + diff --git a/packages/react/src/v1/hooks/useContent.ts b/packages/react/src/v1/hooks/useContent.ts new file mode 100644 index 0000000..cc669b4 --- /dev/null +++ b/packages/react/src/v1/hooks/useContent.ts @@ -0,0 +1,305 @@ +import React from 'react'; +import { useClient } from '../context/ClientContext'; +import { useOpchanStore, setOpchanState } from '../store/opchanStore'; +import { + PostMessage, + CommentMessage, + Post, + Comment, + Cell, + EVerificationStatus, + UserVerificationStatus, + BookmarkType, +} from '@opchan/core'; +import { BookmarkService } from '@opchan/core'; + +function reflectCache(client: ReturnType): void { + const cache = client.database.cache; + setOpchanState(prev => ({ + ...prev, + content: { + ...prev.content, + cells: Object.values(cache.cells), + posts: Object.values(cache.posts), + comments: Object.values(cache.comments), + bookmarks: Object.values(cache.bookmarks), + lastSync: client.database.getSyncState().lastSync, + pendingIds: prev.content.pendingIds, + pendingVotes: prev.content.pendingVotes, + }, + })); +} + +export function useContent() { + const client = useClient(); + const content = useOpchanStore(s => s.content); + const session = useOpchanStore(s => s.session); + + // Re-render on pending changes from LocalDatabase so isPending reflects current state + const [, forceRender] = React.useReducer((x: number) => x + 1, 0); + React.useEffect(() => { + const off = client.database.onPendingChange(() => { + forceRender(); + }); + return () => { + try { + off(); + } catch (err) { + console.error('Error cleaning up pending change listener:', err); + } + }; + }, [client]); + + // Derived maps + const postsByCell = React.useMemo(() => { + const map: Record = {}; + for (const p of content.posts) { + (map[p.cellId] ||= []).push(p); + } + return map; + }, [content.posts]); + + const commentsByPost = React.useMemo(() => { + const map: Record = {}; + for (const c of content.comments) { + (map[c.postId] ||= []).push(c); + } + return map; + }, [content.comments]); + + // Derived: user verification status from identity cache + const userVerificationStatus: UserVerificationStatus = React.useMemo(() => { + const identities = client.database.cache.userIdentities; + const result: UserVerificationStatus = {}; + for (const [address, rec] of Object.entries(identities)) { + const hasEns = Boolean(rec.ensName); + const hasOrdinal = Boolean(rec.ordinalDetails); + const isVerified = rec.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED; + result[address] = { + isVerified, + hasENS: hasEns, + hasOrdinal, + ensName: rec.ensName, + verificationStatus: rec.verificationStatus, + }; + } + return result; + }, [client.database.cache.userIdentities]); + + // Derived: cells with stats for sidebar/trending + const cellsWithStats = React.useMemo(() => { + const byCell: Record; recentActivity: number }> = {}; + const now = Date.now(); + const recentWindowMs = 7 * 24 * 60 * 60 * 1000; // 7 days + for (const p of content.posts) { + const entry = (byCell[p.cellId] ||= { postCount: 0, activeUsers: new Set(), recentActivity: 0 }); + entry.postCount += 1; + entry.activeUsers.add(p.author); + if (now - p.timestamp <= recentWindowMs) entry.recentActivity += 1; + } + for (const c of content.comments) { + // find post for cell reference + const post = content.posts.find(pp => pp.id === c.postId); + if (!post) continue; + const entry = (byCell[post.cellId] ||= { postCount: 0, activeUsers: new Set(), recentActivity: 0 }); + entry.activeUsers.add(c.author); + if (now - c.timestamp <= recentWindowMs) entry.recentActivity += 1; + } + return content.cells.map(cell => { + const stats = byCell[cell.id] || { postCount: 0, activeUsers: new Set(), recentActivity: 0 }; + return { + ...cell, + postCount: stats.postCount, + activeUsers: stats.activeUsers.size, + recentActivity: stats.recentActivity, + } as Cell & { postCount: number; activeUsers: number; recentActivity: number }; + }); + }, [content.cells, content.posts, content.comments]); + + // Actions + const createCell = React.useCallback(async (input: { name: string; description: string; icon?: string }): Promise => { + const currentUser = session.currentUser; + const isAuthenticated = Boolean(currentUser); + const result = await client.forumActions.createCell( + { ...input, currentUser, isAuthenticated }, + () => reflectCache(client) + ); + reflectCache(client); + return result.data ?? null; + }, [client, session.currentUser]); + + const createPost = React.useCallback(async (input: { cellId: string; title: string; content: string }): Promise => { + const currentUser = session.currentUser; + const isAuthenticated = Boolean(currentUser); + const result = await client.forumActions.createPost( + { ...input, currentUser, isAuthenticated }, + () => reflectCache(client) + ); + reflectCache(client); + return result.data ?? null; + }, [client, session.currentUser]); + + const createComment = React.useCallback(async (input: { postId: string; content: string }): Promise => { + const currentUser = session.currentUser; + const isAuthenticated = Boolean(currentUser); + const result = await client.forumActions.createComment( + { ...input, currentUser, isAuthenticated }, + () => reflectCache(client) + ); + reflectCache(client); + return result.data ?? null; + }, [client, session.currentUser]); + + const vote = React.useCallback(async (input: { targetId: string; isUpvote: boolean }): Promise => { + const currentUser = session.currentUser; + const isAuthenticated = Boolean(currentUser); + const result = await client.forumActions.vote( + { ...input, currentUser, isAuthenticated }, + () => reflectCache(client) + ); + reflectCache(client); + return result.data ?? false; + }, [client, session.currentUser]); + + const moderate = React.useMemo(() => ({ + post: async (cellId: string, postId: string, reason?: string) => { + const currentUser = session.currentUser; + const isAuthenticated = Boolean(currentUser); + const cell = content.cells.find(c => c.id === cellId); + const res = await client.forumActions.moderatePost( + { cellId, postId, reason, currentUser, isAuthenticated, cellOwner: cell?.author ?? '' }, + () => reflectCache(client) + ); + reflectCache(client); + return res.data ?? false; + }, + unpost: async (cellId: string, postId: string, reason?: string) => { + const currentUser = session.currentUser; + const isAuthenticated = Boolean(currentUser); + const cell = content.cells.find(c => c.id === cellId); + const res = await client.forumActions.unmoderatePost( + { cellId, postId, reason, currentUser, isAuthenticated, cellOwner: cell?.author ?? '' }, + () => reflectCache(client) + ); + reflectCache(client); + return res.data ?? false; + }, + comment: async (cellId: string, commentId: string, reason?: string) => { + const currentUser = session.currentUser; + const isAuthenticated = Boolean(currentUser); + const cell = content.cells.find(c => c.id === cellId); + const res = await client.forumActions.moderateComment( + { cellId, commentId, reason, currentUser, isAuthenticated, cellOwner: cell?.author ?? '' }, + () => reflectCache(client) + ); + reflectCache(client); + return res.data ?? false; + }, + uncomment: async (cellId: string, commentId: string, reason?: string) => { + const currentUser = session.currentUser; + const isAuthenticated = Boolean(currentUser); + const cell = content.cells.find(c => c.id === cellId); + const res = await client.forumActions.unmoderateComment( + { cellId, commentId, reason, currentUser, isAuthenticated, cellOwner: cell?.author ?? '' }, + () => reflectCache(client) + ); + reflectCache(client); + return res.data ?? false; + }, + user: async (cellId: string, userAddress: string, reason?: string) => { + const currentUser = session.currentUser; + const isAuthenticated = Boolean(currentUser); + const cell = content.cells.find(c => c.id === cellId); + const res = await client.forumActions.moderateUser( + { cellId, userAddress, reason, currentUser, isAuthenticated, cellOwner: cell?.author ?? '' }, + () => reflectCache(client) + ); + reflectCache(client); + return res.data ?? false; + }, + unuser: async (cellId: string, userAddress: string, reason?: string) => { + const currentUser = session.currentUser; + const isAuthenticated = Boolean(currentUser); + const cell = content.cells.find(c => c.id === cellId); + const res = await client.forumActions.unmoderateUser( + { cellId, userAddress, reason, currentUser, isAuthenticated, cellOwner: cell?.author ?? '' }, + () => reflectCache(client) + ); + reflectCache(client); + return res.data ?? false; + }, + }), [client, session.currentUser, content.cells]); + + const togglePostBookmark = React.useCallback(async (post: Post | PostMessage, cellId?: string): Promise => { + const address = session.currentUser?.address; + if (!address) return false; + const added = await BookmarkService.togglePostBookmark(post as Post, address, cellId); + const updated = await client.database.getUserBookmarks(address); + setOpchanState(prev => ({ ...prev, content: { ...prev.content, bookmarks: updated } })); + return added; + }, [client, session.currentUser?.address]); + + const toggleCommentBookmark = React.useCallback(async (comment: Comment | CommentMessage, postId?: string): Promise => { + const address = session.currentUser?.address; + if (!address) return false; + const added = await BookmarkService.toggleCommentBookmark(comment as Comment, address, postId); + const updated = await client.database.getUserBookmarks(address); + setOpchanState(prev => ({ ...prev, content: { ...prev.content, bookmarks: updated } })); + return added; + }, [client, session.currentUser?.address]); + + const removeBookmark = React.useCallback(async (bookmarkId: string): Promise => { + const address = session.currentUser?.address; + if (!address) return; + const [typeStr, targetId] = bookmarkId.split(':'); + const type = typeStr === 'post' ? BookmarkType.POST : BookmarkType.COMMENT; + await BookmarkService.removeBookmark(type, targetId); + const updated = await client.database.getUserBookmarks(address); + setOpchanState(prev => ({ ...prev, content: { ...prev.content, bookmarks: updated } })); + }, [client, session.currentUser?.address]); + + const clearAllBookmarks = React.useCallback(async (): Promise => { + const address = session.currentUser?.address; + if (!address) return; + await BookmarkService.clearUserBookmarks(address); + const updated = await client.database.getUserBookmarks(address); + setOpchanState(prev => ({ ...prev, content: { ...prev.content, bookmarks: updated } })); + }, [client, session.currentUser?.address]); + + const refresh = React.useCallback(async () => { + // Minimal refresh: re-reflect cache; network refresh is via useNetwork + reflectCache(client); + }, [client]); + + return { + // data + cells: content.cells, + posts: content.posts, + comments: content.comments, + bookmarks: content.bookmarks, + postsByCell, + commentsByPost, + cellsWithStats, + userVerificationStatus, + pending: { + isPending: (id?: string) => (id ? client.database.isPending(id) : false), + onChange: (cb: () => void) => client.database.onPendingChange(cb), + }, + lastSync: content.lastSync, + // actions + createCell, + createPost, + createComment, + vote, + moderate, + togglePostBookmark, + toggleCommentBookmark, + removeBookmark, + clearAllBookmarks, + refresh, + } as const; +} + + + + diff --git a/packages/react/src/v1/hooks/useForum.ts b/packages/react/src/v1/hooks/useForum.ts new file mode 100644 index 0000000..65e760b --- /dev/null +++ b/packages/react/src/v1/hooks/useForum.ts @@ -0,0 +1,16 @@ +import { useAuth } from './useAuth'; +import { useContent } from './useContent'; +import { usePermissions } from './usePermissions'; +import { useNetwork } from './useNetwork'; + +export function useForum() { + const user = useAuth(); + const content = useContent(); + const permissions = usePermissions(); + const network = useNetwork(); + return { user, content, permissions, network } as const; +} + + + + diff --git a/packages/react/src/v1/hooks/useNetwork.ts b/packages/react/src/v1/hooks/useNetwork.ts new file mode 100644 index 0000000..82ce639 --- /dev/null +++ b/packages/react/src/v1/hooks/useNetwork.ts @@ -0,0 +1,29 @@ +import { useOpchanStore } from '../store/opchanStore'; +import { useClient } from '../context/ClientContext'; + +export function useNetwork() { + const client = useClient(); + const network = useOpchanStore(s => s.network); + + const refresh = async () => { + try { + // trigger a database refresh using core helper + const { refreshData } = await import('@opchan/core'); + await refreshData(client.messageManager.isReady, () => {}, () => {}, () => {}); + } catch (e) { + console.error('Network refresh failed', e); + } + }; + + return { + isConnected: network.isConnected, + statusMessage: network.statusMessage, + issues: network.issues, + canRefresh: true, + refresh, + } as const; +} + + + + diff --git a/packages/react/src/v1/hooks/usePermissions.ts b/packages/react/src/v1/hooks/usePermissions.ts new file mode 100644 index 0000000..50448dd --- /dev/null +++ b/packages/react/src/v1/hooks/usePermissions.ts @@ -0,0 +1,69 @@ +import { useOpchanStore } from '../store/opchanStore'; +import { EVerificationStatus } from '@opchan/core'; + +export function usePermissions() { + const { session, content } = useOpchanStore(s => ({ session: s.session, content: s.content })); + const currentUser = session.currentUser; + + const isVerified = session.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED; + const isConnected = session.verificationStatus !== EVerificationStatus.WALLET_UNCONNECTED; + + const canCreateCell = isVerified || isConnected; + const canPost = isConnected; + const canComment = isConnected; + const canVote = isConnected; + + const canModerate = (cellId: string): boolean => { + if (!currentUser) return false; + const cell = content.cells.find(c => c.id === cellId); + return cell ? cell.author === currentUser.address : false; + }; + + const reasons = { + post: canPost ? '' : 'Connect your wallet to post', + comment: canComment ? '' : 'Connect your wallet to comment', + vote: canVote ? '' : 'Connect your wallet to vote', + createCell: canCreateCell ? '' : 'Verification required to create a cell', + moderate: (cellId: string) => (canModerate(cellId) ? '' : 'Only cell owner can moderate'), + } as const; + + const check = ( + action: + | 'canPost' + | 'canComment' + | 'canVote' + | 'canCreateCell' + | 'canModerate', + cellId?: string + ): { allowed: boolean; reason: string } => { + switch (action) { + case 'canPost': + return { allowed: canPost, reason: reasons.post }; + case 'canComment': + return { allowed: canComment, reason: reasons.comment }; + case 'canVote': + return { allowed: canVote, reason: reasons.vote }; + case 'canCreateCell': + return { allowed: canCreateCell, reason: reasons.createCell }; + case 'canModerate': + return { allowed: cellId ? canModerate(cellId) : false, reason: cellId ? reasons.moderate(cellId) : 'Cell required' }; + default: + return { allowed: false, reason: 'Unknown action' }; + } + }; + + return { + canPost, + canComment, + canVote, + canCreateCell, + canDelegate: isVerified || isConnected, + canModerate, + reasons, + check, + } as const; +} + + + + diff --git a/packages/react/src/v1/hooks/useUIState.ts b/packages/react/src/v1/hooks/useUIState.ts new file mode 100644 index 0000000..49503c4 --- /dev/null +++ b/packages/react/src/v1/hooks/useUIState.ts @@ -0,0 +1,39 @@ +import React from 'react'; +import { useClient } from '../context/ClientContext'; + +export function useUIState(key: string, defaultValue: T): [T, (value: T) => void, { loading: boolean; error?: string }] { + const client = useClient(); + const [state, setState] = React.useState(defaultValue); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(undefined); + + React.useEffect(() => { + let mounted = true; + (async () => { + try { + const value = await client.database.loadUIState(key); + if (mounted) { + if (value !== undefined) setState(value as T); + setLoading(false); + } + } catch (e) { + if (mounted) { + setError((e as Error).message); + setLoading(false); + } + } + })(); + return () => { + mounted = false; + }; + }, [client, key]); + + const set = React.useCallback((value: T) => { + setState(value); + client.database.storeUIState(key, value).catch(() => {}); + }, [client, key]); + + return [state, set, { loading, error }]; +} + + diff --git a/packages/react/src/old/hooks/core/useUserDisplay.ts b/packages/react/src/v1/hooks/useUserDisplay.ts similarity index 50% rename from packages/react/src/old/hooks/core/useUserDisplay.ts rename to packages/react/src/v1/hooks/useUserDisplay.ts index bd94427..68166ea 100644 --- a/packages/react/src/old/hooks/core/useUserDisplay.ts +++ b/packages/react/src/v1/hooks/useUserDisplay.ts @@ -1,6 +1,6 @@ +import React from 'react'; +import { useClient } from '../context/ClientContext'; import { EDisplayPreference, EVerificationStatus } from '@opchan/core'; -import { useEffect, useState } from 'react'; -import { useClient } from '../../contexts/ClientContext'; export interface UserDisplayInfo { displayName: string; @@ -15,11 +15,12 @@ export interface UserDisplayInfo { /** * User display hook with caching and reactive updates + * Takes an address and resolves display details for it */ export function useUserDisplay(address: string): UserDisplayInfo { const client = useClient(); - const getDisplayName = (addr: string) => client.userIdentityService.getDisplayName(addr); - const [displayInfo, setDisplayInfo] = useState({ + + const [displayInfo, setDisplayInfo] = React.useState({ displayName: `${address.slice(0, 6)}...${address.slice(-4)}`, callSign: null, ensName: null, @@ -29,22 +30,29 @@ export function useUserDisplay(address: string): UserDisplayInfo { isLoading: true, error: null, }); - // Subscribe to identity service refresh events directly - useEffect(() => { + + const getDisplayName = React.useCallback((addr: string) => { + return client.userIdentityService.getDisplayName(addr); + }, [client]); + + // Initial load and refresh listener + React.useEffect(() => { + if (!address) return; + let cancelled = false; - const prime = async () => { - if (!address) return; + + const loadUserDisplay = async () => { try { const identity = await client.userIdentityService.getUserIdentity(address); + if (cancelled) return; + if (identity) { setDisplayInfo({ displayName: getDisplayName(address), callSign: identity.callSign || null, ensName: identity.ensName || null, - ordinalDetails: identity.ordinalDetails - ? identity.ordinalDetails.ordinalDetails - : null, + ordinalDetails: identity.ordinalDetails?.ordinalDetails || null, verificationLevel: identity.verificationStatus, displayPreference: identity.displayPreference || null, isLoading: false, @@ -59,6 +67,8 @@ export function useUserDisplay(address: string): UserDisplayInfo { })); } } catch (error) { + if (cancelled) return; + setDisplayInfo(prev => ({ ...prev, isLoading: false, @@ -66,29 +76,48 @@ export function useUserDisplay(address: string): UserDisplayInfo { })); } }; - prime(); - return () => { cancelled = true; }; - }, [address, client, getDisplayName]); - useEffect(() => { - if (!address) return; - const off = client.userIdentityService.addRefreshListener(async (changed) => { - if (changed !== address) return; - const identity = await client.userIdentityService.getUserIdentity(address); - if (!identity) return; - setDisplayInfo(prev => ({ - ...prev, - displayName: getDisplayName(address), - callSign: identity.callSign || null, - ensName: identity.ensName || null, - ordinalDetails: identity.ordinalDetails ? identity.ordinalDetails.ordinalDetails : null, - verificationLevel: identity.verificationStatus, - isLoading: false, - error: null, - })); + loadUserDisplay(); + + // Subscribe to identity service refresh events + const unsubscribe = client.userIdentityService.addRefreshListener(async (changedAddress) => { + if (changedAddress !== address || cancelled) return; + + try { + const identity = await client.userIdentityService.getUserIdentity(address); + if (!identity || cancelled) return; + + setDisplayInfo(prev => ({ + ...prev, + displayName: getDisplayName(address), + callSign: identity.callSign || null, + ensName: identity.ensName || null, + ordinalDetails: identity.ordinalDetails?.ordinalDetails || null, + verificationLevel: identity.verificationStatus, + displayPreference: identity.displayPreference || null, + isLoading: false, + error: null, + })); + } catch (error) { + if (cancelled) return; + + setDisplayInfo(prev => ({ + ...prev, + isLoading: false, + error: error instanceof Error ? error.message : 'Unknown error', + })); + } }); - return () => { try { off && off(); } catch {} }; + + return () => { + cancelled = true; + try { + unsubscribe(); + } catch { + // Ignore unsubscribe errors + } + }; }, [address, client, getDisplayName]); return displayInfo; -} +} \ No newline at end of file diff --git a/packages/react/src/v1/provider/OpChanProvider.tsx b/packages/react/src/v1/provider/OpChanProvider.tsx new file mode 100644 index 0000000..94ee739 --- /dev/null +++ b/packages/react/src/v1/provider/OpChanProvider.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { OpChanClient, type OpChanClientConfig } from '@opchan/core'; +import { OpChanProvider as 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'; + +export interface WalletAdapterAccount { + address: string; + walletType: 'bitcoin' | 'ethereum'; +} + +export interface WalletAdapter { + getAccount(): WalletAdapterAccount | null; + onChange(callback: (account: WalletAdapterAccount | null) => void): () => void; +} + +export interface NewOpChanProviderProps { + 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. + */ +export const OpChanProvider: React.FC = ({ config, walletAdapter, 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, + 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} + + ); +}; + + diff --git a/packages/react/src/v1/provider/StoreWiring.tsx b/packages/react/src/v1/provider/StoreWiring.tsx new file mode 100644 index 0000000..c00402f --- /dev/null +++ b/packages/react/src/v1/provider/StoreWiring.tsx @@ -0,0 +1,172 @@ +import React from 'react'; +import { useClient } from '../context/ClientContext'; +import { setOpchanState, getOpchanState } from '../store/opchanStore'; +import type { OpchanMessage, User } from '@opchan/core'; +import { EVerificationStatus, EDisplayPreference } from '@opchan/core'; + +export const StoreWiring: React.FC = () => { + const client = useClient(); + + // Initial hydrate from LocalDatabase + React.useEffect(() => { + let unsubHealth: (() => void) | null = null; + let unsubMessages: (() => void) | null = null; + let unsubIdentity: (() => void) | null = null; + + const hydrate = async () => { + try { + await client.database.open(); + const cache = client.database.cache; + + // Reflect content cache + setOpchanState(prev => ({ + ...prev, + content: { + ...prev.content, + cells: Object.values(cache.cells), + posts: Object.values(cache.posts), + comments: Object.values(cache.comments), + bookmarks: Object.values(cache.bookmarks), + lastSync: client.database.getSyncState().lastSync, + pendingIds: new Set(), + pendingVotes: new Set(), + }, + })); + + // Hydrate session (user + delegation) from LocalDatabase + try { + const loadedUser = await client.database.loadUser(); + const delegationStatus = await client.delegation.getStatus( + loadedUser?.address, + loadedUser?.walletType, + ); + + // If we have a loaded user, enrich it with latest identity for display fields + let enrichedUser: User | null = loadedUser ?? null; + if (loadedUser) { + try { + const identity = await client.userIdentityService.getUserIdentity(loadedUser.address); + if (identity) { + const displayName = identity.displayPreference === EDisplayPreference.CALL_SIGN + ? (identity.callSign || loadedUser.displayName) + : (identity.ensName || loadedUser.displayName); + enrichedUser = { + ...loadedUser, + callSign: identity.callSign ?? loadedUser.callSign, + displayPreference: identity.displayPreference ?? loadedUser.displayPreference, + displayName, + ensDetails: identity.ensName ? { ensName: identity.ensName } : loadedUser.ensDetails, + ordinalDetails: identity.ordinalDetails ?? loadedUser.ordinalDetails, + verificationStatus: identity.verificationStatus ?? loadedUser.verificationStatus, + }; + try { await client.database.storeUser(enrichedUser); } catch { /* ignore persist error */ } + } + } catch { /* ignore identity enrich error */ } + } + + setOpchanState(prev => ({ + ...prev, + session: { + currentUser: enrichedUser, + verificationStatus: + enrichedUser?.verificationStatus ?? EVerificationStatus.WALLET_UNCONNECTED, + delegation: delegationStatus ?? null, + }, + })); + } catch (sessionErr) { + console.error('Initial session hydrate failed', sessionErr); + } + } catch (e) { + console.error('Initial hydrate failed', e); + } + }; + + const wire = () => { + unsubHealth = client.messageManager.onHealthChange((isReady: boolean) => { + setOpchanState(prev => ({ + ...prev, + network: { + ...prev.network, + isConnected: isReady, + statusMessage: isReady ? 'connected' : 'connecting…', + issues: isReady ? [] : prev.network.issues, + }, + })); + }); + + unsubMessages = client.messageManager.onMessageReceived(async (message: OpchanMessage) => { + // Persist, then reflect cache in store + try { + await client.database.updateCache(message); + const cache = client.database.cache; + setOpchanState(prev => ({ + ...prev, + content: { + ...prev.content, + cells: Object.values(cache.cells), + posts: Object.values(cache.posts), + comments: Object.values(cache.comments), + bookmarks: Object.values(cache.bookmarks), + lastSync: Date.now(), + pendingIds: prev.content.pendingIds, + pendingVotes: prev.content.pendingVotes, + }, + })); + } catch (e) { + console.error('Failed to apply incoming message', e); + } + }); + + // Reactively update session.currentUser when identity refreshes for the active user + unsubIdentity = client.userIdentityService.addRefreshListener(async (address: string) => { + try { + const { session } = getOpchanState(); + const active = session.currentUser; + if (!active || active.address !== address) return; + + const identity = await client.userIdentityService.getUserIdentity(address); + if (!identity) return; + + const displayName = identity.displayPreference === EDisplayPreference.CALL_SIGN + ? (identity.callSign || active.displayName) + : (identity.ensName || active.displayName); + + const updated: User = { + ...active, + callSign: identity.callSign ?? active.callSign, + displayPreference: identity.displayPreference ?? active.displayPreference, + displayName, + ensDetails: identity.ensName ? { ensName: identity.ensName } : active.ensDetails, + ordinalDetails: identity.ordinalDetails ?? active.ordinalDetails, + verificationStatus: identity.verificationStatus ?? active.verificationStatus, + }; + + try { await client.database.storeUser(updated); } catch { /* ignore persist error */ } + + setOpchanState(prev => ({ + ...prev, + session: { + ...prev.session, + currentUser: updated, + verificationStatus: updated.verificationStatus, + }, + })); + } catch (err) { + console.warn('Identity refresh wiring failed', err); + } + }); + }; + + hydrate().then(wire); + + return () => { + unsubHealth?.(); + unsubMessages?.(); + unsubIdentity?.(); + }; + }, [client]); + + return null; +}; + + diff --git a/packages/react/src/v1/store/opchanStore.ts b/packages/react/src/v1/store/opchanStore.ts new file mode 100644 index 0000000..6516d04 --- /dev/null +++ b/packages/react/src/v1/store/opchanStore.ts @@ -0,0 +1,128 @@ +import React, { useSyncExternalStore } from 'react'; +import type { + CellMessage, + PostMessage, + CommentMessage, + Bookmark, + User, + EVerificationStatus, + DelegationFullStatus, +} from '@opchan/core'; + +type Listener = () => void; + +export interface SessionSlice { + currentUser: User | null; + verificationStatus: EVerificationStatus; + delegation: DelegationFullStatus | null; +} + +export interface ContentSlice { + cells: CellMessage[]; + posts: PostMessage[]; + comments: CommentMessage[]; + bookmarks: Bookmark[]; + lastSync: number | null; + pendingIds: Set; + pendingVotes: Set; +} + +export interface IdentitySlice { + // minimal identity cache; full logic lives in UserIdentityService + displayNameByAddress: Record; +} + +export interface NetworkSlice { + isConnected: boolean; + statusMessage: string; + issues: string[]; +} + +export interface OpchanState { + session: SessionSlice; + content: ContentSlice; + identity: IdentitySlice; + network: NetworkSlice; +} + +const defaultState: OpchanState = { + session: { + currentUser: null, + verificationStatus: 'wallet-unconnected' as EVerificationStatus, + delegation: null, + }, + content: { + cells: [], + posts: [], + comments: [], + bookmarks: [], + lastSync: null, + pendingIds: new Set(), + pendingVotes: new Set(), + }, + identity: { + displayNameByAddress: {}, + }, + network: { + isConnected: false, + statusMessage: 'connecting…', + issues: [], + }, +}; + +class OpchanStoreImpl { + private state: OpchanState = defaultState; + private listeners: Set = new Set(); + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + getSnapshot(): OpchanState { + return this.state; + } + + private notify(): void { + for (const l of this.listeners) l(); + } + + setState(mutator: (prev: OpchanState) => OpchanState): void { + const next = mutator(this.state); + if (next !== this.state) { + this.state = next; + this.notify(); + } + } +} + +export const opchanStore = new OpchanStoreImpl(); + +export function useOpchanStore(selector: (s: OpchanState) => T, isEqual?: (a: T, b: T) => boolean): T { + // Subscribe to the raw store snapshot to keep getSnapshot referentially stable + const state = useSyncExternalStore( + (cb) => opchanStore.subscribe(cb), + () => opchanStore.getSnapshot(), + () => opchanStore.getSnapshot(), + ); + + const compare = isEqual ?? ((a: T, b: T) => a === b); + const selected = React.useMemo(() => selector(state), [state, selector]); + + // Cache the last selected value using the provided equality to avoid churn + const cachedRef = React.useRef(selected); + if (!compare(cachedRef.current, selected)) { + cachedRef.current = selected; + } + return cachedRef.current; +} + +export function getOpchanState(): OpchanState { + return opchanStore.getSnapshot(); +} + +export function setOpchanState(mutator: (prev: OpchanState) => OpchanState): void { + opchanStore.setState(mutator); +} + +