FIX HYDRATION

This commit is contained in:
Danish Arora 2025-09-24 13:45:00 +05:30
parent 8d0f86fb2e
commit 0ad1cce551
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
57 changed files with 1448 additions and 3539 deletions

View File

@ -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,

View File

@ -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<SortOption>('relevance');
@ -150,7 +150,7 @@ const CellList = () => {
return sortCells(cellsWithStats, sortOption);
}, [cellsWithStats, sortOption]);
if (isInitialLoading) {
if (!cellsWithStats.length) {
return (
<div className="container mx-auto px-4 pt-24 pb-16 text-center">
<Loader2 className="w-8 h-8 mx-auto mb-4 animate-spin text-primary" />
@ -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"
>
<RefreshCw
className={`w-4 h-4 ${isInitialLoading ? 'animate-spin' : ''}`}
className="w-4 h-4"
/>
</Button>

View File

@ -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<CommentCardProps> = ({
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<CommentCardProps> = ({
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<CommentCardProps> = ({
>
<ArrowUp className="w-3 h-3" />
</button>
<span className="text-sm font-bold">{comment.voteScore}</span>
<span className="text-sm font-bold">{score}</span>
<button
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${
userDownvoted ? 'text-cyber-accent' : ''
@ -161,7 +156,7 @@ const CommentCard: React.FC<CommentCardProps> = ({
</div>
<div className="flex items-center gap-2">
{canModerate && !comment.moderated && (
{canModerate && !isModerated && (
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -178,7 +173,7 @@ const CommentCard: React.FC<CommentCardProps> = ({
</TooltipContent>
</Tooltip>
)}
{canModerate && comment.moderated && (
{canModerate && isModerated && (
<Tooltip>
<TooltipTrigger asChild>
<Button

View File

@ -3,8 +3,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Loader2 } from 'lucide-react';
import { usePermissions } from '@/hooks';
import { useForum } from '@opchan/react';
import { useContent, usePermissions } from '@/hooks';
import {
Form,
FormControl,
@ -58,8 +57,7 @@ export function CreateCellDialog({
open: externalOpen,
onOpenChange,
}: CreateCellDialogProps = {}) {
const forum = useForum();
const {createCell} = forum.content;
const { createCell } = useContent();
const isCreatingCell = false;
const { canCreateCell } = usePermissions();
const { toast } = useToast();

View File

@ -4,44 +4,31 @@ import { TrendingUp, Users, Eye, CheckCircle } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useForumData, useAuth } from '@/hooks';
import { useAuth, useContent } from '@/hooks';
import { EVerificationStatus } from '@opchan/core';
import { CypherImage } from '@/components/ui/CypherImage';
import { useUserDisplay } from '@/hooks';
const FeedSidebar: React.FC = () => {
// ✅ Use reactive hooks for data
const forumData = useForumData();
const {cells, posts, comments, cellsWithStats, userVerificationStatus} = useContent();
const { currentUser, verificationStatus } = useAuth();
// Get user display information using the hook
const { displayName, ensName, ordinalDetails } = useUserDisplay(
currentUser?.address || ''
);
// ✅ Get stats from filtered data
const {
filteredPosts,
filteredComments,
filteredCellsWithStats,
cells,
userVerificationStatus,
} = forumData;
const stats = {
totalCells: cells.length,
totalPosts: filteredPosts.length,
totalComments: filteredComments.length,
totalPosts: posts.length,
totalComments: comments.length,
totalUsers: new Set([
...filteredPosts.map(post => post.author),
...filteredComments.map(comment => comment.author),
...posts.map(post => post.author),
...comments.map(comment => comment.author),
]).size,
verifiedUsers: Object.values(userVerificationStatus).filter(
status => status.isVerified
).length,
};
// Use filtered cells with stats for trending cells
const trendingCells = filteredCellsWithStats
const trendingCells = cellsWithStats
.sort((a, b) => b.recentActivity - a.recentActivity)
.slice(0, 5);
@ -51,9 +38,9 @@ const FeedSidebar: React.FC = () => {
return { text: 'Verified Owner', color: 'bg-green-500' };
} else if (verificationStatus === EVerificationStatus.WALLET_CONNECTED) {
return { text: 'Verified', color: 'bg-blue-500' };
} else if (ensName) {
} else if (currentUser?.ensDetails) {
return { text: 'ENS User', color: 'bg-purple-500' };
} else if (ordinalDetails) {
} else if (currentUser?.ordinalDetails) {
return { text: 'Ordinal User', color: 'bg-orange-500' };
}
return { text: 'Unverified', color: 'bg-gray-500' };
@ -75,7 +62,7 @@ const FeedSidebar: React.FC = () => {
<Users className="w-5 h-5 text-cyber-accent" />
</div>
<div className="flex-1">
<div className="font-medium text-sm">{displayName}</div>
<div className="font-medium text-sm">{currentUser?.displayName}</div>
<Badge
variant="secondary"
className={`${verificationBadge.color} text-white text-xs`}

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useAuth, useForumContext, useNetworkStatus } from '@opchan/react';
import { useAuth, useForum, useNetwork } from '@/hooks';
import { EVerificationStatus } from '@opchan/core';
import { localDatabase } from '@opchan/core';
import { DelegationFullStatus } from '@opchan/core';
@ -46,20 +46,17 @@ import { useToast } from '@/components/ui/use-toast';
import { useAppKitAccount, useDisconnect } from '@reown/appkit/react';
import { WalletWizard } from '@/components/ui/wallet-wizard';
import { useUserDisplay } from '@/hooks';
import { WakuHealthDot } from '@/components/ui/waku-health-indicator';
const Header = () => {
const { currentUser, getDelegationStatus } = useAuth();
const { currentUser, delegationStatus } = useAuth();
const [delegationInfo, setDelegationInfo] =
useState<DelegationFullStatus | null>(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<boolean> => {
@ -158,14 +156,14 @@ const Header = () => {
if (!isConnected) return <CircleSlash className="w-4 h-4" />;
if (
verificationLevel === EVerificationStatus.ENS_ORDINAL_VERIFIED &&
currentUser?.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED &&
delegationInfo?.isValid
) {
return <CheckCircle className="w-4 h-4" />;
} else if (verificationLevel === EVerificationStatus.WALLET_CONNECTED) {
} else if (currentUser?.verificationStatus === EVerificationStatus.WALLET_CONNECTED) {
return <AlertTriangle className="w-4 h-4" />;
} else if (
verificationLevel === EVerificationStatus.ENS_ORDINAL_VERIFIED
currentUser?.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED
) {
return <Key className="w-4 h-4" />;
} else {
@ -195,13 +193,13 @@ const Header = () => {
<div className="flex items-center space-x-2 px-3 py-1 bg-cyber-muted/20 rounded-full border border-cyber-muted/30">
<WakuHealthDot />
<span className="text-xs font-mono text-cyber-neutral">
{wakuHealth.statusMessage}
{statusMessage}
</span>
{forumContext.lastSync && (
{content.lastSync && (
<div className="flex items-center space-x-1 text-xs text-cyber-neutral/70">
<Clock className="w-3 h-3" />
<span>
{new Date(forumContext.lastSync).toLocaleTimeString([], {
{new Date(content.lastSync).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
@ -225,11 +223,11 @@ const Header = () => {
<Badge
variant="outline"
className={`font-mono text-xs border-0 ${
verificationLevel ===
currentUser?.verificationStatus ===
EVerificationStatus.ENS_ORDINAL_VERIFIED &&
delegationInfo?.isValid
? 'bg-green-500/20 text-green-400 border-green-500/30'
: verificationLevel ===
: currentUser?.verificationStatus ===
EVerificationStatus.ENS_ORDINAL_VERIFIED
? 'bg-orange-500/20 text-orange-400 border-orange-500/30'
: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'
@ -237,11 +235,11 @@ const Header = () => {
>
{getStatusIcon()}
<span className="ml-1">
{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'}
</span>
@ -255,7 +253,7 @@ const Header = () => {
size="sm"
className="flex items-center space-x-2 text-white hover:bg-cyber-muted/30"
>
<div className="text-sm font-mono">{displayName}</div>
<div className="text-sm font-mono">{currentUser?.displayName}</div>
<Settings className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
@ -265,7 +263,7 @@ const Header = () => {
>
<div className="px-3 py-2 border-b border-cyber-muted/30">
<div className="text-sm font-medium text-white">
{displayName}
{currentUser?.displayName}
</div>
<div className="text-xs text-cyber-neutral">
{address?.slice(0, 8)}...{address?.slice(-4)}
@ -473,10 +471,10 @@ const Header = () => {
<div className="px-4 py-3 border-t border-cyber-muted/20">
<div className="flex items-center space-x-2 text-xs text-cyber-neutral">
<WakuHealthDot />
<span>{wakuHealth.statusMessage}</span>
{forumContext.lastSync && (
<span>{statusMessage}</span>
{content.lastSync && (
<span className="ml-auto">
{new Date(forumContext.lastSync).toLocaleTimeString([], {
{new Date(content.lastSync).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}

View File

@ -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<PostCardProps> = ({ 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<PostCardProps> = ({ 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<PostCardProps> = ({ post, commentCount = 0 }) => {
>
<ArrowDown className="w-5 h-5" />
</button>
{votePending && (
{isPending && (
<span className="mt-1 text-[10px] text-yellow-400">syncing</span>
)}
</div>
@ -140,12 +146,12 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
addSuffix: true,
})}
</span>
{post.relevanceScore !== undefined && (
{('relevanceScore' in post) && typeof (post as Post).relevanceScore === 'number' && (
<>
<span></span>
<RelevanceIndicator
score={post.relevanceScore}
details={post.relevanceDetails}
score={(post as Post).relevanceScore as number}
details={('relevanceDetails' in post ? (post as Post).relevanceDetails : undefined)}
type="post"
className="text-xs"
showTooltip={true}

View File

@ -1,9 +1,6 @@
import React, { useState } from 'react';
import { Link, useParams, useNavigate } from 'react-router-dom';
import { usePost, usePostComments } from '@/hooks';
import { Button } from '@/components/ui/button';
//
// import ResizableTextarea from '@/components/ui/resizable-textarea';
import { MarkdownInput } from '@/components/ui/markdown-input';
import {
ArrowLeft,
@ -16,12 +13,12 @@ import {
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { RelevanceIndicator } from './ui/relevance-indicator';
import { AuthorDisplay } from './ui/author-display';
import { BookmarkButton } from './ui/bookmark-button';
import { MarkdownRenderer } from './ui/markdown-renderer';
import CommentCard from './CommentCard';
import { useForum } from '@opchan/react';
import { useContent, usePermissions } from '@/hooks';
import type { Cell as ForumCell } from '@opchan/core';
import { ShareButton } from './ui/ShareButton';
const PostDetail = () => {
@ -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 <div>Invalid post ID</div>;
// ✅ Loading state handled by hook
if (comments.isLoading) {
if (postPending) {
return (
<div className="container mx-auto px-4 py-16 text-center">
<Loader2 className="w-8 h-8 mx-auto mb-4 animate-spin text-primary" />
@ -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 = () => {
>
<ArrowUp className="w-4 h-4" />
</button>
<span className="text-sm font-bold">{post.voteScore}</span>
<span className="text-sm font-bold">{score}</span>
<button
className={`p-1 rounded-sm hover:bg-muted/50 ${
isPostDownvoted ? 'text-primary' : ''
@ -217,18 +211,7 @@ const PostDetail = () => {
addSuffix: true,
})}
</span>
{post.relevanceScore !== undefined && (
<>
<span></span>
<RelevanceIndicator
score={post.relevanceScore}
details={post.relevanceDetails}
type="post"
className="text-sm"
showTooltip={true}
/>
</>
)}
{/* Relevance details unavailable in raw PostMessage; skip indicator */}
{postPending && (
<>
<span></span>
@ -323,7 +306,7 @@ const PostDetail = () => {
</p>
</div>
) : (
visibleComments.map(comment => (
visibleComments.map((comment) => (
<CommentCard
key={comment.id}
comment={comment}

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { useCell, useCellPosts, usePermissions, useUserVotes, useAuth, useForumData } from '@/hooks';
import { useForum } from '@opchan/react';
import { usePermissions, useAuth, useContent } from '@/hooks';
import type { Post as ForumPost, Cell as ForumCell, VoteMessage } from '@opchan/core';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
@ -31,21 +31,17 @@ const PostList = () => {
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 (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="mb-6">
@ -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"
>
<RefreshCw
className={`w-4 h-4 ${cellPosts.isLoading ? 'animate-spin' : ''}`}
/>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
<p className="page-subtitle">{cell.description}</p>
@ -264,7 +268,7 @@ const PostList = () => {
</p>
</div>
) : (
visiblePosts.map(post => (
visiblePosts.map((post: ForumPost) => (
<div key={post.id} className="thread-card">
<div className="flex gap-4">
<div className="flex flex-col items-center">

View File

@ -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;

View File

@ -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';

View File

@ -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<boolean>('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));

View File

@ -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 <CheckCircle className="text-green-500" />;
case 'disconnected':
return <WifiOff className="text-red-500" />;
default:
return <Wifi className="text-gray-500" />;
if (isConnected === true) {
return <CheckCircle className="text-green-500" />;
} else if (isConnected === false) {
return <WifiOff className="text-red-500" />;
} else {
return <Wifi className="text-gray-500" />;
}
};
@ -49,9 +43,9 @@ export function WakuHealthIndicator({
<span
className={cn(
'text-sm font-medium',
statusColor === 'green' && 'text-green-400',
statusColor === 'yellow' && 'text-yellow-400',
statusColor === 'red' && 'text-red-400'
isConnected === true && 'text-green-400',
isConnected === false && 'text-red-400',
isConnected === null && 'text-gray-400'
)}
>
{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 (
<div
className={cn(
'w-2 h-2 rounded-full',
statusColor === 'green' && 'bg-green-500',
statusColor === 'yellow' && 'bg-yellow-500 animate-pulse',
statusColor === 'red' && 'bg-red-500',
isConnected === true && 'bg-green-500',
isConnected === false && 'bg-red-500',
isConnected === null && 'bg-gray-500',
className
)}
title={`Waku network: ${statusColor}`}
title={`Waku network: ${statusColor === 'green' ? 'Connected' : statusColor === 'red' ? 'Disconnected' : 'Loading'}`}
/>
);
}

View File

@ -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<WizardStep>(1);
const [isLoading, setIsLoading] = React.useState(false);
const { isAuthenticated, verificationStatus, getDelegationStatus } = useAuth();
const [delegationStatus, setDelegationStatus] = React.useState<DelegationFullStatus | null>(null);
React.useEffect(() => {
getDelegationStatus().then(setDelegationStatus).catch(console.error);
}, [getDelegationStatus]);
const [delegationStatus, setDelegationStatus] = React.useState<boolean>(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;
}

View File

@ -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';

View File

@ -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(
<WagmiProvider config={config}>
<AppKitProvider {...appkitConfig}>
<OpChanProvider
ordiscanApiKey={'6bb07766-d98c-4ddd-93fb-6a0e94d629dd'}
debug={import.meta.env.DEV}
>
<OpchanWithAppKit config={{ ordiscanApiKey: '6bb07766-d98c-4ddd-93fb-6a0e94d629dd' }}>
<App />
</OpChanProvider>
</OpchanWithAppKit>
</AppKitProvider>
</WagmiProvider>
);

View File

@ -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 (
<div className="min-h-screen flex flex-col bg-cyber-dark text-white">
<Header />
<main className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-cyber-accent mx-auto mb-4" />
<p className="text-cyber-neutral">Loading bookmarks...</p>
</div>
</main>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex flex-col bg-cyber-dark text-white">
<Header />
<main className="flex-1 flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-red-400 mb-4">
Error Loading Bookmarks
</h1>
<p className="text-cyber-neutral mb-4">{error}</p>
<Button onClick={() => window.location.reload()}>Try Again</Button>
</div>
</main>
</div>
);
}
return (
<div className="page-container">

View File

@ -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<SortOption>('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 (
<div className="min-h-screen bg-cyber-dark">
<div className="container mx-auto px-4 py-6 max-w-6xl">
@ -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"
>
<RefreshCw
className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`}
/>
<RefreshCw className="w-4 h-4" />
<span>Refresh</span>
</Button>
</div>

View File

@ -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();

View File

@ -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<DelegationFullStatus | null>(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 <CheckCircle className="h-4 w-4 text-green-500" />;
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() {
</div>
<div className="flex-1">
<div className="text-xl font-mono font-bold text-white">
{userInfo.displayName}
{currentUser.displayName}
</div>
<div className="text-sm text-cyber-neutral">
{/* Show ENS name if available */}
{(userInfo.ensName ||
currentUser?.ensDetails?.ensName) && (
{(currentUser.ensDetails?.ensName ) && (
<div>
ENS:{' '}
{userInfo.ensName ||
currentUser?.ensDetails?.ensName}
{currentUser.ensDetails?.ensName}
</div>
)}
{/* Show Ordinal details if available */}
{(userInfo.ordinalDetails ||
currentUser?.ordinalDetails?.ordinalDetails) && (
{(currentUser.ordinalDetails ) && (
<div>
Ordinal:{' '}
{userInfo.ordinalDetails ||
currentUser?.ordinalDetails?.ordinalDetails}
{currentUser.ordinalDetails.ordinalDetails}
</div>
)}
{/* Show fallback if neither ENS nor Ordinal */}
{!(
userInfo.ensName || currentUser?.ensDetails?.ensName
currentUser.ensDetails?.ensName
) &&
!(
userInfo.ordinalDetails ||
currentUser?.ordinalDetails?.ordinalDetails
currentUser.ordinalDetails?.ordinalDetails
) && <div>No ENS or Ordinal verification</div>}
</div>
<div className="flex items-center gap-2 mt-2">
{getVerificationIcon()}
<Badge className={getVerificationColor()}>
@ -329,6 +295,7 @@ export default function ProfilePage() {
</div>
</div>
</div>
</div>
{/* Wallet Section */}
<div className="space-y-3">
@ -398,7 +365,7 @@ export default function ProfilePage() {
/>
) : (
<div className="text-sm bg-cyber-dark/50 border border-cyber-muted/30 px-3 py-2 rounded-md text-cyber-light">
{userInfo.callSign ||
{currentUser.callSign ||
currentUser.callSign ||
'Not set'}
</div>
@ -444,7 +411,7 @@ export default function ProfilePage() {
</Select>
) : (
<div className="text-sm bg-cyber-dark/50 border border-cyber-muted/30 px-3 py-2 rounded-md text-cyber-light">
{(userInfo.displayPreference ||
{(currentUser.displayPreference ||
displayPreference) ===
EDisplayPreference.CALL_SIGN
? 'Call Sign (when available)'
@ -494,8 +461,7 @@ export default function ProfilePage() {
<Shield className="h-5 w-5 text-cyber-accent" />
Security
</div>
{(forum.user.delegation.hasDelegation ||
delegationInfo?.hasDelegation) && (
{delegation.hasDelegation && (
<Button
variant="outline"
size="sm"
@ -503,9 +469,7 @@ export default function ProfilePage() {
className="border-cyber-muted/30 text-cyber-neutral hover:bg-cyber-muted/30"
>
<Settings className="w-4 h-4 mr-2" />
{forum.user.delegation.isValid || delegationInfo?.isValid
? 'Renew'
: 'Setup'}
{delegation.isValid ? 'Renew' : 'Setup'}
</Button>
)}
</CardTitle>
@ -518,35 +482,25 @@ export default function ProfilePage() {
Delegation
</span>
<Badge
variant={
forum.user.delegation.isValid || delegationInfo?.isValid
? 'default'
: 'secondary'
}
variant={delegation.isValid ? 'default' : 'secondary'}
className={
forum.user.delegation.isValid || delegationInfo?.isValid
delegation.isValid
? 'bg-green-500/20 text-green-400 border-green-500/30'
: 'bg-red-500/20 text-red-400 border-red-500/30'
}
>
{forum.user.delegation.isValid || delegationInfo?.isValid
? 'Active'
: 'Inactive'}
{delegation.isValid ? 'Active' : 'Inactive'}
</Badge>
</div>
{/* Expiry Date */}
{(forum.user.delegation.expiresAt ||
currentUser.delegationExpiry) && (
{delegation.expiresAt && (
<div className="space-y-1">
<span className="text-xs text-cyber-neutral">
Valid until
</span>
<div className="text-sm font-mono text-cyber-light">
{(
forum.user.delegation.expiresAt ||
new Date(currentUser.delegationExpiry!)
).toLocaleDateString()}
{delegation.expiresAt.toLocaleDateString()}
</div>
</div>
)}
@ -559,16 +513,12 @@ export default function ProfilePage() {
<Badge
variant="outline"
className={
forum.user.delegation.isValid ||
currentUser.delegationSignature === 'valid'
delegation.isValid
? 'text-green-400 border-green-500/30 bg-green-500/10'
: 'text-red-400 border-red-500/30 bg-red-500/10'
}
>
{forum.user.delegation.isValid ||
currentUser.delegationSignature === 'valid'
? 'Valid'
: 'Not signed'}
{delegation.isValid ? 'Valid' : 'Not signed'}
</Badge>
</div>
</div>
@ -580,19 +530,17 @@ export default function ProfilePage() {
</Label>
<div className="flex items-center gap-2">
<div className="flex-1 font-mono text-xs bg-cyber-dark/50 border border-cyber-muted/30 px-2 py-1 rounded text-cyber-light">
{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'}
</div>
{(forum.user.delegation.publicKey ||
currentUser.browserPubKey) && (
{delegation.publicKey && (
<Button
variant="outline"
size="sm"
onClick={() =>
copyToClipboard(
forum.user.delegation.publicKey ||
currentUser.browserPubKey!,
delegation.publicKey!,
'Public Key'
)
}
@ -605,10 +553,7 @@ export default function ProfilePage() {
</div>
{/* Warning for expired delegation */}
{(!forum.user.delegation.isValid &&
forum.user.delegation.hasDelegation) ||
(!delegationInfo?.isValid &&
delegationInfo?.hasDelegation && (
{(!delegation.isValid && delegation.hasDelegation) && (
<div className="p-3 bg-orange-500/10 border border-orange-500/30 rounded-md">
<div className="flex items-center gap-2 text-orange-400">
<AlertTriangle className="w-4 h-4" />
@ -618,7 +563,7 @@ export default function ProfilePage() {
</span>
</div>
</div>
))}
)}
</CardContent>
</Card>
</div>

View File

@ -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<Props> = ({ config, children }) => {
const btc = useAppKitAccount({ namespace: 'bip122' });
const eth = useAppKitAccount({ namespace: 'eip155' });
const listenersRef = React.useRef(new Set<(a: WalletAdapterAccount | null) => void>());
const getCurrent = React.useCallback((): WalletAdapterAccount | null => {
if (btc.isConnected && btc.address) return { address: btc.address, walletType: 'bitcoin' };
if (eth.isConnected && eth.address) return { address: eth.address, walletType: 'ethereum' };
return null;
}, [btc.isConnected, btc.address, eth.isConnected, eth.address]);
const adapter = React.useMemo<WalletAdapter>(() => ({
getAccount: () => getCurrent(),
onChange: (cb) => {
listenersRef.current.add(cb);
return () => { listenersRef.current.delete(cb); };
},
}), [getCurrent]);
// Notify listeners when AppKit account changes
React.useEffect(() => {
const account = getCurrent();
listenersRef.current.forEach(cb => {
try { cb(account); } catch (e) { /* ignore */ }
});
}, [getCurrent]);
return (
<OpChanProvider config={config} walletAdapter={adapter}>
{children}
</OpChanProvider>
);
};

View File

@ -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<UserIdentityCache[string]> & { lastUpdated?: number }
): Promise<void> {
// 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 });

View File

@ -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;

View File

@ -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: '^_' },
],
},
}
);

View File

@ -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';

View File

@ -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<boolean>;
disconnectWallet: () => void;
verifyOwnership: () => Promise<boolean>;
delegateKey: (duration?: DelegationDuration) => Promise<boolean>;
getDelegationStatus: () => ReturnType<typeof delegationManager.getStatus>;
clearDelegation: () => Promise<void>;
signMessage: (message: unknown, statusCallback?: {
onSent?: (messageId: string) => void;
onAcknowledged?: (messageId: string) => void;
onError?: (messageId: string, error: string) => void;
}) => Promise<void>;
verifyMessage: (message: unknown) => Promise<boolean>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const client = useClient();
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(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<CurrentUser> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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 <AuthContext.Provider value={ctx}>{children}</AuthContext.Provider>;
};
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within OpChanProvider');
return ctx;
}
export { AuthContext };

View File

@ -1,29 +0,0 @@
import React, { createContext, useContext } from 'react';
import { OpChanClient } from '@opchan/core';
export interface ClientContextValue {
client: OpChanClient;
}
const ClientContext = createContext<ClientContextValue | null>(null);
export const ClientProvider: React.FC<{
client: OpChanClient;
children: React.ReactNode;
}> = ({ client, children }) => {
return (
<ClientContext.Provider value={{ client }}>
{children}
</ClientContext.Provider>
);
};
export function useClient(): OpChanClient {
const context = useContext(ClientContext);
if (!context) {
throw new Error('useClient must be used within OpChanProvider');
}
return context.client;
}
export { ClientContext };

View File

@ -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<void>;
// Actions
actions: ForumActions;
}
const ForumContext = createContext<ForumContextValue | null>(null);
export const ForumProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const client = useClient();
const { currentUser } = useAuth();
const [cells, setCells] = useState<Cell[]>([]);
const [posts, setPosts] = useState<Post[]>([]);
const [comments, setComments] = useState<Comment[]>([]);
const [userVerificationStatus, setUserVerificationStatus] = useState<UserVerificationStatus>({});
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isNetworkConnected, setIsNetworkConnected] = useState(false);
const [error, setError] = useState<string | null>(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 <ForumContext.Provider value={ctx}>{children}</ForumContext.Provider>;
};
export function useForum() {
const ctx = useContext(ForumContext);
if (!ctx) throw new Error('useForum must be used within OpChanProvider');
return ctx;
}
export { ForumContext };

View File

@ -1,29 +0,0 @@
import React, { createContext, useContext, useMemo, useState } from 'react';
export interface ModerationContextValue {
showModerated: boolean;
toggleShowModerated: () => void;
}
const ModerationContext = createContext<ModerationContextValue | null>(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 <ModerationContext.Provider value={ctx}>{children}</ModerationContext.Provider>;
};
export function useModeration() {
const ctx = useContext(ModerationContext);
if (!ctx) throw new Error('useModeration must be used within OpChanProvider');
return ctx;
}
export { ModerationContext };

View File

@ -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<string, CommentWithVoteStatus[]>;
// Organized data
postsByCell: Record<string, PostWithVoteStatus[]>;
commentsByPost: Record<string, CommentWithVoteStatus[]>;
// User-specific data
userVotedPosts: Set<string>;
userVotedComments: Set<string>;
userCreatedPosts: Set<string>;
userCreatedComments: Set<string>;
}
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<string, boolean> = {};
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<string, PostWithVoteStatus[]> => {
const organized: Record<string, PostWithVoteStatus[]> = {};
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<string, CommentWithVoteStatus[]> => {
const organized: Record<string, CommentWithVoteStatus[]> = {};
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<string>();
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<string>();
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<string>();
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<string>();
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<string, CommentWithVoteStatus[]> => {
const organized: Record<string, CommentWithVoteStatus[]> = {};
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,
};
}

View File

@ -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,
};
}

View File

@ -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]);
}

View File

@ -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,
]);
}

View File

@ -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,
]);
}

View File

@ -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,
]);
}

View File

@ -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<string>;
votedComments: Set<string>;
upvotedPosts: Set<string>;
downvotedPosts: Set<string>;
upvotedComments: Set<string>;
downvotedComments: Set<string>;
// 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<string>();
const votedComments = new Set<string>();
const upvotedPosts = new Set<string>();
const downvotedPosts = new Set<string>();
const upvotedComments = new Set<string>();
const downvotedComments = new Set<string>();
// 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]);
}

View File

@ -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

View File

@ -1,3 +0,0 @@
export { useAuth } from '../contexts/AuthContext';

View File

@ -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<boolean>;
disconnect: () => Promise<void>;
verifyOwnership: () => Promise<boolean>;
delegateKey: (duration?: DelegationDuration) => Promise<boolean>;
clearDelegation: () => Promise<void>;
updateProfile: (updates: {
callSign?: string;
displayPreference?: EDisplayPreference;
}) => Promise<boolean>;
signMessage: (msg: OpchanMessage) => Promise<void>;
verifyMessage: (msg: OpchanMessage) => Promise<boolean>;
};
content: {
cells: Cell[];
posts: Post[];
comments: Comment[];
bookmarks: Bookmark[];
postsByCell: Record<string, PostWithVoteStatus[]>;
commentsByPost: Record<string, CommentWithVoteStatus[]>;
filtered: {
cells: CellWithStats[];
posts: PostWithVoteStatus[];
comments: CommentWithVoteStatus[];
};
createCell: (input: {
name: string;
description: string;
icon?: string;
}) => Promise<Cell | null>;
createPost: (input: {
cellId: string;
title: string;
content: string;
}) => Promise<Post | null>;
createComment: (input: { postId: string; content: string }) => Promise<
Comment | null
>;
vote: (input: { targetId: string; isUpvote: boolean }) => Promise<boolean>;
moderate: {
post: (
cellId: string,
postId: string,
reason?: string
) => Promise<boolean>;
unpost: (
cellId: string,
postId: string,
reason?: string
) => Promise<boolean>;
comment: (
cellId: string,
commentId: string,
reason?: string
) => Promise<boolean>;
uncomment: (
cellId: string,
commentId: string,
reason?: string
) => Promise<boolean>;
user: (
cellId: string,
userAddress: string,
reason?: string
) => Promise<boolean>;
unuser: (
cellId: string,
userAddress: string,
reason?: string
) => Promise<boolean>;
};
togglePostBookmark: (post: Post, cellId?: string) => Promise<boolean>;
toggleCommentBookmark: (
comment: Comment,
postId?: string
) => Promise<boolean>;
refresh: () => Promise<void>;
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<void>;
recommendedActions: string[];
};
selectors: ReturnType<typeof useForumSelectors>;
}
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<Bookmark[]>([]);
// 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<boolean> => {
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<void> => {
// 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<void> => {
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<boolean> => {
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]);
}

View File

@ -1,3 +0,0 @@
export { useModeration } from '../contexts/ModerationContext';

View File

@ -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<void>;
clearAllBookmarks: () => Promise<void>;
togglePostBookmark: (post: Post, cellId?: string) => Promise<boolean>;
toggleCommentBookmark: (comment: Comment, postId?: string) => Promise<boolean>;
}
export function useBookmarks(): UseBookmarksReturn {
const { currentUser } = useAuth();
const client = useClient();
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(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<void> => {
await BookmarkService.removeBookmark(bookmark.type, bookmark.targetId);
await refresh();
},
[refresh]
);
const clearAllBookmarks = useCallback(async (): Promise<void> => {
if (!currentUser?.address) return;
await BookmarkService.clearUserBookmarks(currentUser.address);
await refresh();
}, [currentUser?.address, refresh]);
const togglePostBookmark = useCallback(
async (post: Post, cellId?: string): Promise<boolean> => {
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<boolean> => {
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 };
}

View File

@ -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<T> = (cells: Cell[]) => T;
export type PostSelector<T> = (posts: Post[]) => T;
export type CommentSelector<T> = (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<string>();
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,
};
}

View File

@ -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<DelegationFullStatus | null>(null);
// Track Waku ready state directly from the client to react to changes
const [wakuReady, setWakuReady] = useState<boolean>(
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`;
}

View File

@ -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<boolean> => {
return contextConnectWallet();
}, [contextConnectWallet]);
const disconnect = useCallback((): void => {
contextDisconnectWallet();
}, [contextDisconnectWallet]);
const openModal = useCallback(async (): Promise<void> => {
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,
};
};

View File

@ -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';

View File

@ -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<OpChanProviderProps> = ({
ordiscanApiKey,
debug,
children,
}) => {
const [isReady, setIsReady] = useState(false);
const clientRef = useRef<OpChanClient | null>(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 (
<ClientProvider client={clientRef.current}>
<AuthProvider>
<ModerationProvider>
<ForumProvider>{children}</ForumProvider>
</ModerationProvider>
</AuthProvider>
</ClientProvider>
);
}, [isReady, children]);
return providers || null;
};

View File

@ -0,0 +1,28 @@
import React, { createContext, useContext } from 'react';
import type { OpChanClient } from '@opchan/core';
interface ClientContextValue {
client: OpChanClient;
}
const ClientContext = createContext<ClientContextValue | null>(null);
type ProviderProps = { client: OpChanClient; children: React.ReactNode };
export function OpChanProvider({ client, children }: ProviderProps) {
return (
<ClientContext.Provider value={{ client }}>
{children}
</ClientContext.Provider>
);
}
export function useClient(): OpChanClient {
const ctx = useContext(ClientContext);
if (!ctx) throw new Error('useClient must be used within OpChanProvider');
return ctx.client;
}

View File

@ -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<boolean> => {
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<void> => {
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<boolean> => {
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<string>,
duration: DelegationDuration = '7days',
): Promise<boolean> => {
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<boolean> => {
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;
}

View File

@ -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<typeof useClient>): 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<string, PostMessage[]> = {};
for (const p of content.posts) {
(map[p.cellId] ||= []).push(p);
}
return map;
}, [content.posts]);
const commentsByPost = React.useMemo(() => {
const map: Record<string, CommentMessage[]> = {};
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<string, { postCount: number; activeUsers: Set<string>; 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<string>(), 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<string>(), 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<string>(), 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<Cell | null> => {
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<Post | null> => {
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<Comment | null> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<void> => {
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<void> => {
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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,39 @@
import React from 'react';
import { useClient } from '../context/ClientContext';
export function useUIState<T>(key: string, defaultValue: T): [T, (value: T) => void, { loading: boolean; error?: string }] {
const client = useClient();
const [state, setState] = React.useState<T>(defaultValue);
const [loading, setLoading] = React.useState<boolean>(true);
const [error, setError] = React.useState<string | undefined>(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 }];
}

View File

@ -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<UserDisplayInfo>({
const [displayInfo, setDisplayInfo] = React.useState<UserDisplayInfo>({
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;
}
}

View File

@ -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<NewOpChanProviderProps> = ({ 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 (
<ClientProvider client={client}>
<StoreWiring />
{children}
</ClientProvider>
);
};

View File

@ -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<string>(),
pendingVotes: new Set<string>(),
},
}));
// 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;
};

View File

@ -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<string>;
pendingVotes: Set<string>;
}
export interface IdentitySlice {
// minimal identity cache; full logic lives in UserIdentityService
displayNameByAddress: Record<string, string>;
}
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<string>(),
pendingVotes: new Set<string>(),
},
identity: {
displayNameByAddress: {},
},
network: {
isConnected: false,
statusMessage: 'connecting…',
issues: [],
},
};
class OpchanStoreImpl {
private state: OpchanState = defaultState;
private listeners: Set<Listener> = 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<T>(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<T>(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);
}