This commit is contained in:
Danish Arora 2025-09-18 17:02:11 +05:30
parent b897dca588
commit cc29a30bd9
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
79 changed files with 10529 additions and 21475 deletions

6
.gitignore vendored
View File

@ -36,4 +36,8 @@ coverage/
.nyc_output/
# TypeScript cache
*.tsbuildinfo
*.tsbuildinfo
.giga
.cursor
.cursorrules

17424
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"name": "opchan",
"private": true,
"version": "0.1.0",
"version": "1.0.0",
"description": "A decentralized forum built on Waku.",
"type": "module",
"scripts": {
@ -15,7 +15,8 @@
"test:ui": "vitest --ui"
},
"dependencies": {
"@opchan/core": "workspace:*",
"@opchan/react": "file:../packages/react",
"@opchan/core": "1.0.0",
"@hookform/resolvers": "^3.9.0",
"@noble/ed25519": "^2.2.3",
"@noble/hashes": "^1.8.0",
@ -78,8 +79,8 @@
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.1.0",
"vaul": "^0.9.3",
"viem": "^2.37.1",
"wagmi": "^2.16.1",
"viem": "^2.37.6",
"wagmi": "^2.17.0",
"zod": "^3.23.8"
},
"devDependencies": {

View File

@ -16,9 +16,6 @@ import { Toaster as Sonner } from '@/components/ui/sonner';
import { TooltipProvider } from '@/components/ui/tooltip';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AuthProvider } from '@/contexts/AuthContext';
import { ForumProvider } from '@/contexts/ForumContext';
import { ModerationProvider } from '@/contexts/ModerationContext';
import CellPage from './pages/CellPage';
import PostPage from './pages/PostPage';
import NotFound from './pages/NotFound';
@ -26,41 +23,28 @@ import Dashboard from './pages/Dashboard';
import Index from './pages/Index';
import ProfilePage from './pages/ProfilePage';
import BookmarksPage from './pages/BookmarksPage';
import { appkitConfig, config } from '@opchan/core';
import { WagmiProvider } from 'wagmi';
import { AppKitProvider } from '@reown/appkit/react';
// Create a client
const queryClient = new QueryClient();
const App = () => (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<AppKitProvider {...appkitConfig}>
<Router>
<AuthProvider>
<ForumProvider>
<ModerationProvider>
<TooltipProvider>
<Toaster />
<Sonner />
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/cells" element={<Index />} />
<Route path="/cell/:cellId" element={<CellPage />} />
<Route path="/post/:postId" element={<PostPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</TooltipProvider>
</ModerationProvider>
</ForumProvider>
</AuthProvider>
</Router>
</AppKitProvider>
</QueryClientProvider>
</WagmiProvider>
<QueryClientProvider client={queryClient}>
<Router>
<TooltipProvider>
<Toaster />
<Sonner />
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/cells" element={<Index />} />
<Route path="/cell/:cellId" element={<CellPage />} />
<Route path="/post/:postId" element={<PostPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</TooltipProvider>
</Router>
</QueryClientProvider>
);
export default App;

View File

@ -26,7 +26,7 @@ import { RelevanceIndicator } from './ui/relevance-indicator';
import { ModerationToggle } from './ui/moderation-toggle';
import { sortCells, SortOption } from '@opchan/core';
import { Cell } from '@opchan/core';
import { usePending } from '@/hooks/usePending';
import { useForum } from '@opchan/react';
import { ShareButton } from './ui/ShareButton';
// Empty State Component
@ -76,7 +76,8 @@ const EmptyState: React.FC<{ canCreateCell: boolean }> = ({
// Separate component to properly use hooks
const CellItem: React.FC<{ cell: Cell }> = ({ cell }) => {
const pending = usePending(cell.id);
const { content } = useForum();
const isPending = content.pending.isPending(cell.id);
return (
<Link to={`/cell/${cell.id}`} className="group block board-card">
@ -103,7 +104,7 @@ const CellItem: React.FC<{ cell: Cell }> = ({ cell }) => {
/>
)}
</div>
{pending.isPending && (
{isPending && (
<div className="mb-2">
<span className="px-2 py-0.5 rounded-sm bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 text-xs">
syncing

View File

@ -2,17 +2,11 @@ import React from 'react';
import { ArrowUp, ArrowDown, Clock, Shield, UserX } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { Comment } from '@opchan/core';
import {
useForumActions,
usePermissions,
useUserVotes,
useCommentBookmark,
} from '@/hooks';
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 { usePending, usePendingVote } from '@/hooks/usePending';
import { useForum } from '@opchan/react';
import {
Tooltip,
TooltipContent,
@ -32,7 +26,8 @@ interface CommentCardProps {
// Extracted child component to respect Rules of Hooks
const PendingBadge: React.FC<{ id: string }> = ({ id }) => {
const { isPending } = usePending(id);
const { content } = useForum();
const isPending = content.pending.isPending(id);
if (!isPending) return null;
return (
<>
@ -53,27 +48,40 @@ const CommentCard: React.FC<CommentCardProps> = ({
onUnmoderateComment,
onModerateUser,
}) => {
const { voteComment, isVoting } = useForumActions();
const { canVote } = usePermissions();
const userVotes = useUserVotes();
const {
isBookmarked,
loading: bookmarkLoading,
toggleBookmark,
} = useCommentBookmark(comment, postId);
const forum = useForum();
const { content, permissions } = forum;
const commentVotePending = usePendingVote(comment.id);
// Check if bookmarked
const isBookmarked = content.bookmarks.some(
b => b.targetId === comment.id && b.type === 'comment'
);
const [bookmarkLoading, setBookmarkLoading] = React.useState(false);
// Use library pending API
const commentVotePending = content.pending.isVotePending(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 handleVoteComment = async (isUpvote: boolean) => {
await voteComment(comment.id, isUpvote);
await content.vote({ targetId: comment.id, isUpvote });
};
const handleBookmark = async () => {
await toggleBookmark();
};
const getCommentVoteType = () => {
return userVotes.getCommentVoteType(comment.id);
setBookmarkLoading(true);
try {
await content.toggleCommentBookmark(comment, postId);
} finally {
setBookmarkLoading(false);
}
};
return (
@ -82,12 +90,12 @@ const CommentCard: React.FC<CommentCardProps> = ({
<div className="flex flex-col items-center">
<button
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${
getCommentVoteType() === 'upvote' ? 'text-cyber-accent' : ''
userUpvoted ? 'text-cyber-accent' : ''
}`}
onClick={() => handleVoteComment(true)}
disabled={!canVote || isVoting}
disabled={!permissions.canVote}
title={
canVote ? 'Upvote comment' : 'Connect wallet and verify to vote'
permissions.canVote ? 'Upvote comment' : permissions.reasons.vote
}
>
<ArrowUp className="w-3 h-3" />
@ -95,17 +103,19 @@ const CommentCard: React.FC<CommentCardProps> = ({
<span className="text-sm font-bold">{comment.voteScore}</span>
<button
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${
getCommentVoteType() === 'downvote' ? 'text-cyber-accent' : ''
userDownvoted ? 'text-cyber-accent' : ''
}`}
onClick={() => handleVoteComment(false)}
disabled={!canVote || isVoting}
disabled={!permissions.canVote}
title={
canVote ? 'Downvote comment' : 'Connect wallet and verify to vote'
permissions.canVote
? 'Downvote comment'
: permissions.reasons.vote
}
>
<ArrowDown className="w-3 h-3" />
</button>
{commentVotePending.isPending && (
{commentVotePending && (
<span className="mt-1 text-[10px] text-yellow-500">syncing</span>
)}
</div>

View File

@ -93,11 +93,11 @@ const FeedSidebar: React.FC = () => {
)}
{verificationStatus === EVerificationStatus.WALLET_CONNECTED && (
<div className="text-xs text-muted-foreground">
<CheckCircle className="w-3 h-3 inline mr-1" />
Connected. You can post, comment, and vote.
</div>
)}
<div className="text-xs text-muted-foreground">
<CheckCircle className="w-3 h-3 inline mr-1" />
Connected. You can post, comment, and vote.
</div>
)}
</CardContent>
</Card>
)}

View File

@ -1,11 +1,9 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useAuth, useWakuHealthStatus } from '@/hooks';
import { useAuth as useAuthContext } from '@/contexts/useAuth';
import { useForum } from '@opchan/react';
import { EVerificationStatus } from '@opchan/core';
import { useForum } from '@/contexts/useForum';
import { localDatabase } from '@opchan/core';
import { DelegationFullStatus } from '@opchan/core';
// Removed unused import
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@ -52,14 +50,10 @@ import { useUserDisplay } from '@/hooks';
import { WakuHealthDot } from '@/components/ui/waku-health-indicator';
const Header = () => {
const { verificationStatus } = useAuth();
const { getDelegationStatus } = useAuthContext();
const [delegationInfo, setDelegationInfo] =
useState<DelegationFullStatus | null>(null);
const wakuHealth = useWakuHealthStatus();
const forum = useForum();
const { user, network } = forum;
const location = useLocation();
const { toast } = useToast();
const forum = useForum();
// Use AppKit hooks for multi-chain support
const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' });
@ -70,22 +64,27 @@ const Header = () => {
const isBitcoinConnected = bitcoinAccount.isConnected;
const isEthereumConnected = ethereumAccount.isConnected;
const isConnected = isBitcoinConnected || isEthereumConnected;
const address = isConnected
? isBitcoinConnected
? bitcoinAccount.address
: ethereumAccount.address
: undefined;
// Use currentUser address (which has ENS details) instead of raw AppKit address
const address =
user.address ||
(isConnected
? isBitcoinConnected
? bitcoinAccount.address
: ethereumAccount.address
: undefined);
const [walletWizardOpen, setWalletWizardOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
// ✅ Get display name from enhanced hook
const { displayName } = useUserDisplay(address || '');
// ✅ Use UserIdentityService via useUserDisplay hook for centralized display logic
const { displayName, ensName, verificationLevel } = useUserDisplay(
address || ''
);
// Load delegation status
React.useEffect(() => {
getDelegationStatus().then(setDelegationInfo).catch(console.error);
}, [getDelegationStatus]);
// ✅ Removed console.log to prevent infinite loop spam
// Delegation info is available directly from user.delegation
// Use LocalDatabase to persist wizard state across navigation
const getHasShownWizard = async (): Promise<boolean> => {
@ -126,6 +125,7 @@ const Header = () => {
};
const handleDisconnect = async () => {
await user.disconnect();
await disconnect();
await setHasShownWizard(false); // Reset so wizard can show again on next connection
toast({
@ -154,15 +154,18 @@ const Header = () => {
const getStatusIcon = () => {
if (!isConnected) return <CircleSlash className="w-4 h-4" />;
// Use verification status from user slice
if (
verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED &&
delegationInfo?.isValid
user.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED &&
user.delegation.isValid
) {
return <CheckCircle className="w-4 h-4" />;
} else if (verificationStatus === EVerificationStatus.WALLET_CONNECTED) {
} else if (
user.verificationStatus === EVerificationStatus.WALLET_CONNECTED
) {
return <AlertTriangle className="w-4 h-4" />;
} else if (
verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED
user.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED
) {
return <Key className="w-4 h-4" />;
} else {
@ -192,13 +195,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}
{network.statusMessage}
</span>
{forum.lastSync && (
{network.isConnected && (
<div className="flex items-center space-x-1 text-xs text-cyber-neutral/70">
<Clock className="w-3 h-3" />
<span>
{new Date(forum.lastSync).toLocaleTimeString([], {
{new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
@ -222,11 +225,11 @@ const Header = () => {
<Badge
variant="outline"
className={`font-mono text-xs border-0 ${
verificationStatus ===
user.verificationStatus ===
EVerificationStatus.ENS_ORDINAL_VERIFIED &&
delegationInfo?.isValid
user.delegation.isValid
? 'bg-green-500/20 text-green-400 border-green-500/30'
: verificationStatus ===
: user.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'
@ -234,11 +237,13 @@ const Header = () => {
>
{getStatusIcon()}
<span className="ml-1">
{verificationStatus === EVerificationStatus.WALLET_UNCONNECTED
{user.verificationStatus ===
EVerificationStatus.WALLET_UNCONNECTED
? 'CONNECT'
: delegationInfo?.isValid
: user.delegation.isValid
? 'READY'
: verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED
: user.verificationStatus ===
EVerificationStatus.ENS_ORDINAL_VERIFIED
? 'EXPIRED'
: 'DELEGATE'}
</span>
@ -470,10 +475,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>
{forum.lastSync && (
<span>{network.statusMessage}</span>
{network.isConnected && (
<span className="ml-auto">
{new Date(forum.lastSync).toLocaleTimeString([], {
{new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}

View File

@ -3,18 +3,12 @@ import { Link } from 'react-router-dom';
import { ArrowUp, ArrowDown, MessageSquare } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { Post } from '@opchan/core';
import {
useForumActions,
usePermissions,
useUserVotes,
useForumData,
usePostBookmark,
} from '@/hooks';
// Removed unused imports
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 { usePending, usePendingVote } from '@/hooks/usePending';
import { useForum } from '@opchan/react';
import { ShareButton } from '@/components/ui/ShareButton';
interface PostCardProps {
@ -23,32 +17,36 @@ interface PostCardProps {
}
const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
const { cellsWithStats } = useForumData();
const { votePost, isVoting } = useForumActions();
const { canVote } = usePermissions();
const userVotes = useUserVotes();
const {
isBookmarked,
loading: bookmarkLoading,
toggleBookmark,
} = usePostBookmark(post, post.cellId);
const forum = useForum();
const { content, permissions } = forum;
// Get pre-computed cell data
const cell = cellsWithStats.find(c => c.id === post.cellId);
// Get cell data from content
const cell = content.cells.find(c => c.id === post.cellId);
const cellName = cell?.name || 'unknown';
// Use pre-computed vote data (assuming post comes from useForumData)
// Use pre-computed vote data
const score =
'voteScore' in post
? (post.voteScore as number)
: post.upvotes.length - post.downvotes.length;
const { isPending } = usePending(post.id);
const votePending = usePendingVote(post.id);
// ✅ Get user vote status from hook
const userVoteType = userVotes.getPostVoteType(post.id);
const userUpvoted = userVoteType === 'upvote';
const userDownvoted = userVoteType === 'downvote';
// 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 =
(post as unknown as { userUpvoted?: boolean }).userUpvoted || false;
const userDownvoted =
(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 [bookmarkLoading, setBookmarkLoading] = React.useState(false);
// Remove duplicate vote status logic
// ✅ Content truncation (simple presentation logic is OK)
const contentPreview =
@ -58,8 +56,7 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
const handleVote = async (e: React.MouseEvent, isUpvote: boolean) => {
e.preventDefault();
// ✅ All validation and permission checking handled in hook
await votePost(post.id, isUpvote);
await content.vote({ targetId: post.id, isUpvote });
};
const handleBookmark = async (e?: React.MouseEvent) => {
@ -67,7 +64,12 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
e.preventDefault();
e.stopPropagation();
}
await toggleBookmark();
setBookmarkLoading(true);
try {
await content.togglePostBookmark(post, post.cellId);
} finally {
setBookmarkLoading(false);
}
};
return (
@ -82,8 +84,8 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
: 'text-cyber-neutral hover:text-cyber-accent'
}`}
onClick={e => handleVote(e, true)}
disabled={!canVote || isVoting}
title={canVote ? 'Upvote' : 'Connect wallet and verify to vote'}
disabled={!permissions.canVote}
title={permissions.canVote ? 'Upvote' : permissions.reasons.vote}
>
<ArrowUp className="w-5 h-5" />
</button>
@ -107,12 +109,12 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
: 'text-cyber-neutral hover:text-blue-400'
}`}
onClick={e => handleVote(e, false)}
disabled={!canVote || isVoting}
title={canVote ? 'Downvote' : 'Connect wallet and verify to vote'}
disabled={!permissions.canVote}
title={permissions.canVote ? 'Downvote' : permissions.reasons.vote}
>
<ArrowDown className="w-5 h-5" />
</button>
{votePending.isPending && (
{votePending && (
<span className="mt-1 text-[10px] text-yellow-400">syncing</span>
)}
</div>

View File

@ -1,13 +1,6 @@
import React, { useState } from 'react';
import { Link, useParams, useNavigate } from 'react-router-dom';
import {
usePost,
usePostComments,
useForumActions,
usePermissions,
useUserVotes,
usePostBookmark,
} from '@/hooks';
import { usePost, usePostComments } from '@/hooks';
import { Button } from '@/components/ui/button';
//
// import ResizableTextarea from '@/components/ui/resizable-textarea';
@ -28,36 +21,30 @@ import { AuthorDisplay } from './ui/author-display';
import { BookmarkButton } from './ui/bookmark-button';
import { MarkdownRenderer } from './ui/markdown-renderer';
import CommentCard from './CommentCard';
import { usePending, usePendingVote } from '@/hooks/usePending';
import { useForum } from '@opchan/react';
import { ShareButton } from './ui/ShareButton';
const PostDetail = () => {
const { postId } = useParams<{ postId: string }>();
const navigate = useNavigate();
// ✅ Use reactive hooks for data and actions
// Use aggregated forum API
const forum = useForum();
const { content, permissions } = forum;
// Get post and comments using focused hooks
const post = usePost(postId);
const comments = usePostComments(postId);
const {
createComment,
votePost,
moderateComment,
unmoderateComment,
moderateUser,
isCreatingComment,
isVoting,
} = useForumActions();
const { canVote, canComment, canModerate } = usePermissions();
const userVotes = useUserVotes();
const {
isBookmarked,
loading: bookmarkLoading,
toggleBookmark,
} = usePostBookmark(post, post?.cellId);
// ✅ Move ALL hook calls to the top, before any conditional logic
const postPending = usePending(post?.id);
const postVotePending = usePendingVote(post?.id);
// Use library pending API
const postPending = content.pending.isPending(post?.id);
const postVotePending = content.pending.isVotePending(post?.id);
// Check if bookmarked
const isBookmarked = content.bookmarks.some(
b => b.targetId === post?.id && b.type === 'post'
);
const [bookmarkLoading, setBookmarkLoading] = React.useState(false);
const [newComment, setNewComment] = useState('');
@ -97,8 +84,8 @@ const PostDetail = () => {
e.preventDefault();
if (!newComment.trim()) return;
// ✅ All validation handled in hook
const result = await createComment(postId, newComment);
// Use aggregated content API
const result = await content.createComment({ postId, content: newComment });
if (result) {
setNewComment('');
}
@ -107,18 +94,18 @@ const PostDetail = () => {
// Handle keyboard shortcuts
const handleKeyDown = (e: React.KeyboardEvent) => {
// Enter inserts newline by default. Send on Ctrl+Enter or Shift+Enter.
const isSendCombo = (e.ctrlKey || e.metaKey || e.shiftKey) && e.key === 'Enter';
const isSendCombo =
(e.ctrlKey || e.metaKey || e.shiftKey) && e.key === 'Enter';
if (isSendCombo) {
e.preventDefault();
if (!isCreatingComment && newComment.trim()) {
if (newComment.trim()) {
handleCreateComment(e as React.FormEvent);
}
}
};
const handleVotePost = async (isUpvote: boolean) => {
// ✅ Permission checking handled in hook
await votePost(post.id, isUpvote);
await content.vote({ targetId: post.id, isUpvote });
};
const handleBookmark = async (e?: React.MouseEvent) => {
@ -126,35 +113,39 @@ const PostDetail = () => {
e.preventDefault();
e.stopPropagation();
}
await toggleBookmark();
setBookmarkLoading(true);
try {
await content.togglePostBookmark(post, post.cellId);
} finally {
setBookmarkLoading(false);
}
};
// ✅ Get vote status from hooks
const postVoteType = userVotes.getPostVoteType(post.id);
const isPostUpvoted = postVoteType === 'upvote';
const isPostDownvoted = postVoteType === 'downvote';
// 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;
const handleModerateComment = async (commentId: string) => {
const reason =
window.prompt('Enter a reason for moderation (optional):') || undefined;
if (!cell) return;
// ✅ All validation handled in hook
await moderateComment(cell.id, commentId, reason);
await content.moderate.comment(cell.id, commentId, reason);
};
const handleUnmoderateComment = async (commentId: string) => {
const reason =
window.prompt('Optional note for unmoderation?') || undefined;
if (!cell) return;
await unmoderateComment(cell.id, commentId, reason);
await content.moderate.uncomment(cell.id, commentId, reason);
};
const handleModerateUser = async (userAddress: string) => {
const reason =
window.prompt('Reason for moderating this user? (optional)') || undefined;
if (!cell) return;
// ✅ All validation handled in hook
await moderateUser(cell.id, userAddress, reason);
await content.moderate.user(cell.id, userAddress, reason);
};
return (
@ -178,9 +169,9 @@ const PostDetail = () => {
isPostUpvoted ? 'text-primary' : ''
}`}
onClick={() => handleVotePost(true)}
disabled={!canVote || isVoting}
disabled={!permissions.canVote}
title={
canVote ? 'Upvote post' : 'Connect wallet and verify to vote'
permissions.canVote ? 'Upvote post' : permissions.reasons.vote
}
>
<ArrowUp className="w-4 h-4" />
@ -191,16 +182,16 @@ const PostDetail = () => {
isPostDownvoted ? 'text-primary' : ''
}`}
onClick={() => handleVotePost(false)}
disabled={!canVote || isVoting}
disabled={!permissions.canVote}
title={
canVote
permissions.canVote
? 'Downvote post'
: 'Connect wallet and verify to vote'
: permissions.reasons.vote
}
>
<ArrowDown className="w-4 h-4" />
</button>
{postVotePending.isPending && (
{postVotePending && (
<span className="mt-1 text-[10px] text-yellow-500">
syncing
</span>
@ -238,7 +229,7 @@ const PostDetail = () => {
/>
</>
)}
{postPending.isPending && (
{postPending && (
<>
<span></span>
<span className="px-2 py-0.5 rounded-sm bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
@ -273,7 +264,7 @@ const PostDetail = () => {
</div>
{/* Comment Form */}
{canComment && (
{permissions.canComment && (
<div className="mb-8">
<form onSubmit={handleCreateComment} onKeyDown={handleKeyDown}>
<h2 className="text-sm font-bold mb-2 flex items-center gap-1">
@ -284,7 +275,7 @@ const PostDetail = () => {
placeholder="What are your thoughts?"
value={newComment}
onChange={setNewComment}
disabled={isCreatingComment}
disabled={false}
minHeight={100}
initialHeight={140}
maxHeight={600}
@ -292,27 +283,18 @@ const PostDetail = () => {
<div className="flex justify-end">
<Button
type="submit"
disabled={!canComment || isCreatingComment}
disabled={!permissions.canComment}
className="bg-cyber-accent hover:bg-cyber-accent/80"
>
{isCreatingComment ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Posting...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Post Comment
</>
)}
<Send className="w-4 h-4 mr-2" />
Post Comment
</Button>
</div>
</form>
</div>
)}
{!canComment && (
{!permissions.canComment && (
<div className="mb-6 p-4 border border-cyber-muted rounded-sm bg-cyber-muted/20 text-center">
<p className="text-sm mb-3">
Connect wallet and verify Ordinal ownership to comment
@ -335,7 +317,7 @@ const PostDetail = () => {
<MessageCircle className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<h3 className="text-lg font-bold mb-2">No comments yet</h3>
<p className="text-muted-foreground">
{canComment
{permissions.canComment
? 'Be the first to share your thoughts!'
: 'Connect your wallet to join the conversation.'}
</p>
@ -347,7 +329,7 @@ const PostDetail = () => {
comment={comment}
postId={postId}
cellId={cell?.id}
canModerate={canModerate(cell?.id || '')}
canModerate={permissions.canModerate(cell?.id || '')}
onModerateComment={handleModerateComment}
onUnmoderateComment={handleUnmoderateComment}
onModerateUser={handleModerateUser}

View File

@ -127,7 +127,8 @@ const PostList = () => {
// Handle keyboard shortcuts
const handleKeyDown = (e: React.KeyboardEvent) => {
// Enter inserts newline by default. Send on Ctrl+Enter or Shift+Enter.
const isSendCombo = (e.ctrlKey || e.metaKey || e.shiftKey) && e.key === 'Enter';
const isSendCombo =
(e.ctrlKey || e.metaKey || e.shiftKey) && e.key === 'Enter';
if (isSendCombo) {
e.preventDefault();
if (!isCreatingPost && newPostContent.trim() && newPostTitle.trim()) {
@ -232,7 +233,9 @@ const PostList = () => {
/>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground mt-1 mb-2">
<span>Press Enter for newline Ctrl+Enter or Shift+Enter to post</span>
<span>
Press Enter for newline Ctrl+Enter or Shift+Enter to post
</span>
<span />
</div>
<div className="flex justify-end">
@ -251,7 +254,6 @@ const PostList = () => {
</div>
)}
{!canPost && !currentUser && (
<div className="section-spacing content-card-sm text-center">
<p className="text-sm mb-3">

View File

@ -1,7 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Button } from './button';
import { useAuth, useAuthActions } from '@/hooks';
import { useAuth as useAuthContext } from '@/contexts/useAuth';
import { useAuth, useAuthActions } from '@opchan/react';
import { CheckCircle, AlertCircle, Trash2 } from 'lucide-react';
import { DelegationDuration, DelegationFullStatus } from '@opchan/core';
@ -18,8 +17,7 @@ export function DelegationStep({
isLoading,
setIsLoading,
}: DelegationStepProps) {
const { currentUser, isAuthenticating } = useAuth();
const { getDelegationStatus } = useAuthContext();
const { currentUser, isAuthenticating, getDelegationStatus } = useAuth();
const [delegationInfo, setDelegationInfo] =
useState<DelegationFullStatus | null>(null);
const { delegateKey, clearDelegation } = useAuthActions();
@ -216,7 +214,9 @@ export function DelegationStep({
const ok = await clearDelegation();
if (ok) {
// Refresh status so UI immediately reflects cleared state
getDelegationStatus().then(setDelegationInfo).catch(console.error);
getDelegationStatus()
.then(setDelegationInfo)
.catch(console.error);
}
}}
variant="outline"

View File

@ -62,7 +62,10 @@ export const MarkdownInput: React.FC<MarkdownInputProps> = ({
<TabsContent value="preview">
<div className="p-3 border rounded-sm bg-card">
<MarkdownRenderer content={value} className="prose prose-invert max-w-none" />
<MarkdownRenderer
content={value}
className="prose prose-invert max-w-none"
/>
</div>
</TabsContent>
</Tabs>
@ -71,5 +74,3 @@ export const MarkdownInput: React.FC<MarkdownInputProps> = ({
};
export default MarkdownInput;

View File

@ -11,9 +11,12 @@ interface MarkdownRendererProps {
/**
* Renders sanitized Markdown with GFM support.
*/
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className }) => {
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
className,
}) => {
// Extend sanitize schema to allow common markdown elements (headings, lists, code, tables, etc.)
const schema: any = {
const schema: typeof defaultSchema = {
...defaultSchema,
tagNames: [
...(defaultSchema.tagNames || []),
@ -57,15 +60,15 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, cla
['alt'],
['title'],
],
code: [
...(defaultSchema.attributes?.code || []),
['className'],
],
code: [...(defaultSchema.attributes?.code || []), ['className']],
},
};
return (
<div className={className}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[[rehypeSanitize, schema]]}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeSanitize, schema]]}
>
{content || ''}
</ReactMarkdown>
</div>
@ -73,5 +76,3 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, cla
};
export default MarkdownRenderer;

View File

@ -1,9 +1,8 @@
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Eye, EyeOff } from 'lucide-react';
import { useModeration } from '@/contexts/ModerationContext';
import { usePermissions } from '@/hooks/core/usePermissions';
import { useForumData } from '@/hooks/core/useForumData';
import { useModeration } from '@opchan/react';
import { usePermissions, useForumData } from '@opchan/react';
export function ModerationToggle() {
const { showModerated, toggleShowModerated } = useModeration();

View File

@ -4,11 +4,12 @@ import { Resizable } from 're-resizable';
import { cn } from '@opchan/core';
import { Textarea } from '@/components/ui/textarea';
type ResizableTextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
initialHeight?: number;
minHeight?: number;
maxHeight?: number;
};
type ResizableTextareaProps =
React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
initialHeight?: number;
minHeight?: number;
maxHeight?: number;
};
export const ResizableTextarea = React.forwardRef<
HTMLTextAreaElement,
@ -44,7 +45,9 @@ export const ResizableTextarea = React.forwardRef<
minHeight={minHeight}
maxHeight={maxHeight}
onResizeStop={(_event, _dir, _elementRef, delta) => {
setHeight(current => Math.max(minHeight, Math.min(maxHeight, current + delta.height)));
setHeight(current =>
Math.max(minHeight, Math.min(maxHeight, current + delta.height))
);
}}
handleComponent={{
bottom: (
@ -71,5 +74,3 @@ export const ResizableTextarea = React.forwardRef<
ResizableTextarea.displayName = 'ResizableTextarea';
export default ResizableTextarea;

View File

@ -87,17 +87,25 @@ export function VerificationStep({
}, [currentUser, verificationResult, walletType, verificationStatus]);
const handleVerify = async () => {
if (!currentUser) return;
console.log('🔘 Verify button clicked, currentUser:', currentUser);
if (!currentUser) {
console.log('❌ No currentUser in handleVerify');
return;
}
console.log('🔄 Setting loading state and calling verifyWallet...');
setIsLoading(true);
setVerificationResult(null);
try {
console.log('📞 Calling verifyWallet()...');
const success = await verifyWallet();
console.log('📊 verifyWallet returned:', success);
if (success) {
// For now, just show success - the actual ownership check will be done
// by the useEffect when the user state updates
console.log('✅ Verification successful, setting result');
setVerificationResult({
success: true,
message:
@ -107,6 +115,7 @@ export function VerificationStep({
details: undefined,
});
} else {
console.log('❌ Verification failed, setting failure result');
setVerificationResult({
success: false,
message:
@ -116,11 +125,13 @@ export function VerificationStep({
});
}
} catch (error) {
console.error('💥 Error in handleVerify:', error);
setVerificationResult({
success: false,
message: `Verification failed. Please try again: ${error}`,
});
} finally {
console.log('🔄 Setting loading to false');
setIsLoading(false);
}
};

View File

@ -1,5 +1,5 @@
import { Wifi, WifiOff, AlertTriangle, CheckCircle } from 'lucide-react';
import { useWakuHealthStatus } from '@/hooks/useWakuHealth';
import { useWakuHealthStatus } from '@opchan/react';
import { cn } from '@opchan/core';
interface WakuHealthIndicatorProps {

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 { useDelegation } from '@/hooks/useDelegation';
import { useDelegation } from '@opchan/react';
import { EVerificationStatus } from '@opchan/core';
import { WalletConnectionStep } from './wallet-connection-step';
import { VerificationStep } from './verification-step';

View File

@ -1,570 +0,0 @@
import React, { createContext, useState, useEffect, useMemo } from 'react';
import { useToast } from '@/components/ui/use-toast';
import { OpchanMessage } from '@opchan/core';
import {
User,
EVerificationStatus,
EDisplayPreference,
} from '@opchan/core';
import { WalletManager } from '@opchan/core';
import {
DelegationManager,
DelegationDuration,
DelegationFullStatus,
} from '@opchan/core';
import { localDatabase } from '@opchan/core';
import { useAppKitAccount, useDisconnect, modal } from '@reown/appkit/react';
import { MessageService } from '@opchan/core';
import { UserIdentityService } from '@opchan/core';
import { reconnect } from '@wagmi/core';
import { config as wagmiConfig } from '@opchan/core';
interface AuthContextType {
currentUser: User | null;
isAuthenticating: boolean;
isAuthenticated: boolean;
verificationStatus: EVerificationStatus;
connectWallet: () => Promise<boolean>;
disconnectWallet: () => void;
verifyOwnership: () => Promise<boolean>;
delegateKey: (duration?: DelegationDuration) => Promise<boolean>;
getDelegationStatus: () => Promise<DelegationFullStatus>;
clearDelegation: () => Promise<void>;
signMessage: (message: OpchanMessage) => Promise<OpchanMessage | null>;
verifyMessage: (message: OpchanMessage) => Promise<boolean>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export { AuthContext };
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [isAuthenticating, setIsAuthenticating] = useState(false);
const [verificationStatus, setVerificationStatus] =
useState<EVerificationStatus>(EVerificationStatus.WALLET_UNCONNECTED);
const { toast } = useToast();
// Use AppKit hooks for multi-chain support
const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' });
const ethereumAccount = useAppKitAccount({ namespace: 'eip155' });
// Determine which account is connected
const isBitcoinConnected = bitcoinAccount.isConnected;
const isEthereumConnected = ethereumAccount.isConnected;
const isConnected = isBitcoinConnected || isEthereumConnected;
// Get the active account info
const activeAccount = isBitcoinConnected ? bitcoinAccount : ethereumAccount;
const address = activeAccount.address;
// Create manager instances
const delegationManager = useMemo(() => new DelegationManager(), []);
const messageService = useMemo(
() => new MessageService(delegationManager),
[delegationManager]
);
const userIdentityService = useMemo(
() => new UserIdentityService(messageService),
[messageService]
);
// Create wallet manager when we have all dependencies
useEffect(() => {
if (modal && (bitcoinAccount.isConnected || ethereumAccount.isConnected)) {
try {
WalletManager.create(modal, bitcoinAccount, ethereumAccount);
} catch (error) {
console.warn('Failed to create WalletManager:', error);
WalletManager.clear();
}
} else {
WalletManager.clear();
}
}, [bitcoinAccount, ethereumAccount]);
// Try to auto-reconnect EVM wallets on app mount
useEffect(() => {
void reconnect(wagmiConfig)
}, []);
// Attempt reconnect when network comes back online
useEffect(() => {
const handleOnline = () => {
void reconnect(wagmiConfig)
};
window.addEventListener('online', handleOnline);
return () => window.removeEventListener('online', handleOnline);
}, []);
// Attempt reconnect when tab becomes visible again
useEffect(() => {
const handleVisibility = () => {
if (document.visibilityState === 'visible') {
reconnect(wagmiConfig).catch(() => {});
}
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, []);
// Helper functions for user persistence
const loadStoredUser = async (): Promise<User | null> => {
try {
return await localDatabase.loadUser();
} catch (e) {
console.error('Failed to load stored user data', e);
return null;
}
};
const saveUser = async (user: User): Promise<void> => {
try {
await localDatabase.storeUser(user);
} catch (e) {
console.error('Failed to save user data', e);
}
};
// Helper function for ownership verification (via UserIdentityService)
const verifyUserOwnership = async (user: User): Promise<User> => {
try {
// Force fresh resolution to ensure API call happens during verification
const identity = await userIdentityService.getUserIdentityFresh(
user.address
);
if (!identity) {
return {
...user,
ensDetails: undefined,
ordinalDetails: undefined,
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
lastChecked: Date.now(),
};
}
return {
...user,
ensDetails: identity.ensName
? { ensName: identity.ensName }
: undefined,
ordinalDetails: identity.ordinalDetails,
verificationStatus: identity.verificationStatus,
lastChecked: Date.now(),
};
} catch (error) {
console.error(
'Error verifying ownership via UserIdentityService:',
error
);
return {
...user,
ensDetails: undefined,
ordinalDetails: undefined,
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
lastChecked: Date.now(),
};
}
};
// Helper function for key delegation
const createUserDelegation = async (
user: User,
duration: DelegationDuration = '7days'
): Promise<boolean> => {
try {
if (!WalletManager.hasInstance()) {
throw new Error(
'Wallet not connected. Please ensure your wallet is connected.'
);
}
const walletManager = WalletManager.getInstance();
// Verify wallet type matches
if (walletManager.getWalletType() !== user.walletType) {
throw new Error(
`Expected ${user.walletType} wallet, but ${walletManager.getWalletType()} is connected.`
);
}
// Use the simplified delegation method
return await delegationManager.delegate(
user.address,
user.walletType,
duration,
message => walletManager.signMessage(message)
);
} catch (error) {
console.error(
`Error creating key delegation for ${user.walletType}:`,
error
);
return false;
}
};
// Sync with AppKit wallet state
useEffect(() => {
if (isConnected && address) {
// Check if we have a stored user for this address
loadStoredUser().then(async storedUser => {
if (storedUser && storedUser.address === address) {
// Use stored user data
setCurrentUser(storedUser);
setVerificationStatus(getVerificationStatus(storedUser));
} else {
// Create new user from AppKit wallet
const newUser: User = {
address,
walletType: isBitcoinConnected ? 'bitcoin' : 'ethereum',
verificationStatus: EVerificationStatus.WALLET_CONNECTED, // Connected wallets get basic verification by default
displayPreference: EDisplayPreference.WALLET_ADDRESS,
lastChecked: Date.now(),
};
// For Ethereum wallets, try to check ENS ownership immediately
if (isEthereumConnected) {
try {
const walletManager = WalletManager.getInstance();
walletManager
.getWalletInfo()
.then(async walletInfo => {
if (walletInfo?.ensName) {
const updatedUser = {
...newUser,
ensDetails: { ensName: walletInfo.ensName },
verificationStatus:
EVerificationStatus.ENS_ORDINAL_VERIFIED,
};
setCurrentUser(updatedUser);
setVerificationStatus(
EVerificationStatus.ENS_ORDINAL_VERIFIED
);
await saveUser(updatedUser);
await localDatabase.upsertUserIdentity(
updatedUser.address,
{
ensName: walletInfo.ensName,
verificationStatus:
EVerificationStatus.ENS_ORDINAL_VERIFIED,
lastUpdated: Date.now(),
}
);
} else {
setCurrentUser(newUser);
setVerificationStatus(EVerificationStatus.WALLET_CONNECTED);
await saveUser(newUser);
await localDatabase.upsertUserIdentity(newUser.address, {
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
lastUpdated: Date.now(),
});
}
})
.catch(async () => {
// Fallback to basic verification if ENS check fails
setCurrentUser(newUser);
setVerificationStatus(EVerificationStatus.WALLET_CONNECTED);
await saveUser(newUser);
await localDatabase.upsertUserIdentity(newUser.address, {
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
lastUpdated: Date.now(),
});
});
} catch {
// WalletManager not ready, fallback to basic verification
setCurrentUser(newUser);
setVerificationStatus(EVerificationStatus.WALLET_CONNECTED);
await saveUser(newUser);
await localDatabase.upsertUserIdentity(newUser.address, {
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
lastUpdated: Date.now(),
});
}
} else {
setCurrentUser(newUser);
setVerificationStatus(EVerificationStatus.WALLET_CONNECTED);
await saveUser(newUser);
await localDatabase.upsertUserIdentity(newUser.address, {
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
lastUpdated: Date.now(),
});
}
const chainName = isBitcoinConnected ? 'Bitcoin' : 'Ethereum';
// Note: We can't use useUserDisplay hook here since this is not a React component
// This is just for toast messages, so simple truncation is acceptable
const displayName = `${address.slice(0, 6)}...${address.slice(-4)}`;
toast({
title: 'Wallet Connected',
description: `Connected to ${chainName} with ${displayName}`,
});
const verificationType = isBitcoinConnected
? 'Ordinal ownership'
: 'ENS ownership';
toast({
title: 'Action Required',
description: `You can participate in the forum now! Verify your ${verificationType} for premium features and delegate a signing key for better UX.`,
});
}
});
} else {
// Wallet disconnected
setCurrentUser(null);
setVerificationStatus(EVerificationStatus.WALLET_UNCONNECTED);
}
}, [isConnected, address, isBitcoinConnected, isEthereumConnected, toast]);
const { disconnect } = useDisconnect();
const connectWallet = async (): Promise<boolean> => {
try {
if (modal) {
await modal.open();
return true;
}
return false;
} catch (error) {
console.error('Error connecting wallet:', error);
return false;
}
};
const disconnectWallet = (): void => {
disconnect();
};
const getVerificationStatus = (user: User): EVerificationStatus => {
if (user.walletType === 'bitcoin') {
return user.ordinalDetails
? EVerificationStatus.ENS_ORDINAL_VERIFIED
: EVerificationStatus.WALLET_CONNECTED;
} else if (user.walletType === 'ethereum') {
return user.ensDetails
? EVerificationStatus.ENS_ORDINAL_VERIFIED
: EVerificationStatus.WALLET_CONNECTED;
}
return EVerificationStatus.WALLET_UNCONNECTED;
};
const verifyOwnership = async (): Promise<boolean> => {
if (!currentUser || !currentUser.address) {
toast({
title: 'Not Connected',
description: 'Please connect your wallet first.',
variant: 'destructive',
});
return false;
}
setIsAuthenticating(true);
setVerificationStatus(EVerificationStatus.WALLET_CONNECTED); // Temporary state during verification
try {
const verificationType =
currentUser.walletType === 'bitcoin' ? 'Ordinal' : 'ENS';
toast({
title: `Verifying ${verificationType}`,
description: `Checking your wallet for ${verificationType} ownership...`,
});
const updatedUser = await verifyUserOwnership(currentUser);
setCurrentUser(updatedUser);
await saveUser(updatedUser);
// Persist centralized identity (ENS/verification) for display everywhere
await localDatabase.upsertUserIdentity(updatedUser.address, {
ensName: updatedUser.ensDetails?.ensName,
ordinalDetails: updatedUser.ordinalDetails,
verificationStatus: updatedUser.verificationStatus,
lastUpdated: Date.now(),
});
// Update verification status
setVerificationStatus(getVerificationStatus(updatedUser));
if (updatedUser.walletType === 'bitcoin' && updatedUser.ordinalDetails) {
toast({
title: 'Ordinal Verified',
description:
'You now have premium access with higher relevance bonuses. We recommend delegating a key for better UX.',
});
} else if (
updatedUser.walletType === 'ethereum' &&
updatedUser.ensDetails
) {
toast({
title: 'ENS Verified',
description:
'You now have premium access with higher relevance bonuses. We recommend delegating a key for better UX.',
});
} else {
const verificationType =
updatedUser.walletType === 'bitcoin'
? 'Ordinal Operators'
: 'ENS domain';
toast({
title: 'Basic Access Granted',
description: `No ${verificationType} found, but you can still participate in the forum with your connected wallet.`,
variant: 'default',
});
}
return Boolean(
(updatedUser.walletType === 'bitcoin' && updatedUser.ordinalDetails) ||
(updatedUser.walletType === 'ethereum' && updatedUser.ensDetails)
);
} catch (error) {
console.error('Error verifying ownership:', error);
setVerificationStatus(EVerificationStatus.WALLET_UNCONNECTED);
let errorMessage = 'Failed to verify ownership. Please try again.';
if (error instanceof Error) {
errorMessage = error.message;
}
toast({
title: 'Verification Error',
description: errorMessage,
variant: 'destructive',
});
return false;
} finally {
setIsAuthenticating(false);
}
};
const delegateKey = async (
duration: DelegationDuration = '7days'
): Promise<boolean> => {
if (!currentUser) {
toast({
title: 'No User Found',
description: 'Please connect your wallet first.',
variant: 'destructive',
});
return false;
}
setIsAuthenticating(true);
try {
const durationText = duration === '7days' ? '1 week' : '30 days';
toast({
title: 'Starting Key Delegation',
description: `This will let you post, comment, and vote without approving each action for ${durationText}.`,
});
const success = await createUserDelegation(currentUser, duration);
if (!success) {
throw new Error('Failed to create key delegation');
}
// Update user with delegation info
const delegationStatus = await delegationManager.getStatus(
currentUser.address,
currentUser.walletType
);
const updatedUser = {
...currentUser,
browserPubKey: delegationStatus.publicKey || undefined,
delegationSignature: delegationStatus.isValid ? 'valid' : undefined,
delegationExpiry: delegationStatus.timeRemaining
? Date.now() + delegationStatus.timeRemaining
: undefined,
};
setCurrentUser(updatedUser);
await saveUser(updatedUser);
// Format date for user-friendly display
const expiryDate = new Date(updatedUser.delegationExpiry!);
const formattedExpiry = expiryDate.toLocaleString();
toast({
title: 'Key Delegation Successful',
description: `You can now interact with the forum without additional wallet approvals until ${formattedExpiry}.`,
});
return true;
} catch (error) {
console.error('Error delegating key:', error);
let errorMessage = 'Failed to delegate key. Please try again.';
if (error instanceof Error) {
errorMessage = error.message;
}
toast({
title: 'Delegation Error',
description: errorMessage,
variant: 'destructive',
});
return false;
} finally {
setIsAuthenticating(false);
}
};
const getDelegationStatus = async (): Promise<DelegationFullStatus> => {
return await delegationManager.getStatus(
currentUser?.address,
currentUser?.walletType
);
};
const clearDelegation = async (): Promise<void> => {
await delegationManager.clear();
// Update the current user to remove delegation info
if (currentUser) {
const updatedUser = {
...currentUser,
delegationExpiry: undefined,
browserPubKey: undefined,
delegationSignature: undefined,
};
setCurrentUser(updatedUser);
await saveUser(updatedUser);
}
toast({
title: 'Delegation Cleared',
description:
"Your delegated signing key has been removed. You'll need to delegate a new key to continue posting and voting.",
});
};
const messageSigning = {
signMessage: async (
message: OpchanMessage
): Promise<OpchanMessage | null> => {
return await delegationManager.signMessage(message);
},
verifyMessage: async (message: OpchanMessage): Promise<boolean> => {
return await delegationManager.verify(message);
},
};
const value: AuthContextType = {
currentUser,
isAuthenticating,
isAuthenticated: Boolean(currentUser && isConnected),
verificationStatus,
connectWallet,
disconnectWallet,
verifyOwnership,
delegateKey,
getDelegationStatus,
clearDelegation,
signMessage: messageSigning.signMessage,
verifyMessage: messageSigning.verifyMessage,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

View File

@ -1,777 +0,0 @@
import React, {
createContext,
useState,
useEffect,
useCallback,
useMemo,
useRef,
} from 'react';
import { Cell, Post, Comment } from '@opchan/core';
import {
User,
EVerificationStatus,
EDisplayPreference,
} from '@opchan/core';
import { useToast } from '@/components/ui/use-toast';
import { ForumActions } from '@opchan/core';
import { monitorNetworkHealth, initializeNetwork } from '@opchan/core';
import { messageManager } from '@opchan/core';
import { getDataFromCache } from '@opchan/core';
import { RelevanceCalculator } from '@opchan/core';
import { UserVerificationStatus } from '@opchan/core';
import { DelegationManager } from '@opchan/core';
import { UserIdentityService } from '@opchan/core';
import { MessageService } from '@opchan/core';
import { useAuth } from '@/contexts/useAuth';
import { localDatabase } from '@opchan/core';
interface ForumContextType {
cells: Cell[];
posts: Post[];
comments: Comment[];
// User verification status for display
userVerificationStatus: UserVerificationStatus;
// User identity service for profile management
userIdentityService: UserIdentityService | null;
// Granular loading states
isInitialLoading: boolean;
// Sync state
lastSync: number | null;
isSyncing: boolean;
isPostingCell: boolean;
isPostingPost: boolean;
isPostingComment: boolean;
isVoting: boolean;
isRefreshing: boolean;
// Network status
isNetworkConnected: boolean;
error: string | null;
getCellById: (id: string) => Cell | undefined;
getPostsByCell: (cellId: string) => Post[];
getCommentsByPost: (postId: string) => Comment[];
createPost: (
cellId: string,
title: string,
content: string
) => Promise<Post | null>;
createComment: (postId: string, content: string) => Promise<Comment | null>;
votePost: (postId: string, isUpvote: boolean) => Promise<boolean>;
voteComment: (commentId: string, isUpvote: boolean) => Promise<boolean>;
createCell: (
name: string,
description: string,
icon?: string
) => Promise<Cell | null>;
refreshData: () => Promise<void>;
moderatePost: (
cellId: string,
postId: string,
reason: string | undefined,
cellOwner: string
) => Promise<boolean>;
unmoderatePost: (
cellId: string,
postId: string,
reason: string | undefined,
cellOwner: string
) => Promise<boolean>;
moderateComment: (
cellId: string,
commentId: string,
reason: string | undefined,
cellOwner: string
) => Promise<boolean>;
unmoderateComment: (
cellId: string,
commentId: string,
reason: string | undefined,
cellOwner: string
) => Promise<boolean>;
moderateUser: (
cellId: string,
userAddress: string,
reason: string | undefined,
cellOwner: string
) => Promise<boolean>;
unmoderateUser: (
cellId: string,
userAddress: string,
reason: string | undefined,
cellOwner: string
) => Promise<boolean>;
}
const ForumContext = createContext<ForumContextType | undefined>(undefined);
export { ForumContext };
export function ForumProvider({ children }: { children: React.ReactNode }) {
const [cells, setCells] = useState<Cell[]>([]);
const [posts, setPosts] = useState<Post[]>([]);
const [comments, setComments] = useState<Comment[]>([]);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [{ lastSync, isSyncing }, setSyncState] = useState({
lastSync: null as number | null,
isSyncing: false,
});
const [isPostingCell, setIsPostingCell] = useState(false);
const [isPostingPost, setIsPostingPost] = useState(false);
const [isPostingComment, setIsPostingComment] = useState(false);
const [isVoting, setIsVoting] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isNetworkConnected, setIsNetworkConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const [userVerificationStatus, setUserVerificationStatus] =
useState<UserVerificationStatus>({});
const { toast } = useToast();
const { currentUser, isAuthenticated } = useAuth();
const delegationManager = useMemo(() => new DelegationManager(), []);
const messageService = useMemo(
() => new MessageService(delegationManager),
[delegationManager]
);
const userIdentityService = useMemo(
() => new UserIdentityService(messageService),
[messageService]
);
const forumActions = useMemo(
() => new ForumActions(delegationManager),
[delegationManager]
);
// Transform message cache data to the expected types
const updateStateFromCache = useCallback(async () => {
// Build user verification status for relevance calculation
const relevanceCalculator = new RelevanceCalculator();
const allUsers: User[] = [];
// Collect all unique users from posts, comments, and votes
const userAddresses = new Set<string>();
// Add users from posts
Object.values(messageManager.messageCache.posts).forEach(post => {
userAddresses.add(post.author);
});
// Add users from comments
Object.values(messageManager.messageCache.comments).forEach(comment => {
userAddresses.add(comment.author);
});
// Add users from votes
Object.values(messageManager.messageCache.votes).forEach(vote => {
userAddresses.add(vote.author);
});
// Create user objects for verification status using UserIdentityService
const userIdentityPromises = Array.from(userAddresses).map(
async address => {
// Check if this address matches the current user's address
if (currentUser && currentUser.address === address) {
// Use the current user's actual verification status
return {
address,
walletType: currentUser.walletType,
verificationStatus: currentUser.verificationStatus,
displayPreference: currentUser.displayPreference,
ensDetails: currentUser.ensDetails,
ordinalDetails: currentUser.ordinalDetails,
lastChecked: currentUser.lastChecked,
};
} else {
// Use UserIdentityService to get identity information
const identity = await userIdentityService.getUserIdentity(address);
if (identity) {
return {
address,
walletType: address.startsWith('0x')
? ('ethereum' as const)
: ('bitcoin' as const),
verificationStatus: identity.verificationStatus,
displayPreference: identity.displayPreference,
ensDetails: identity.ensName
? { ensName: identity.ensName }
: undefined,
ordinalDetails: identity.ordinalDetails,
lastChecked: identity.lastUpdated,
};
} else {
// Fallback to generic user object
return {
address,
walletType: address.startsWith('0x')
? ('ethereum' as const)
: ('bitcoin' as const),
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
displayPreference: EDisplayPreference.WALLET_ADDRESS,
};
}
}
}
);
const resolvedUsers = await Promise.all(userIdentityPromises);
allUsers.push(...resolvedUsers);
const initialStatus =
relevanceCalculator.buildUserVerificationStatus(allUsers);
// Transform data with relevance calculation
const { cells, posts, comments } = await getDataFromCache(
undefined,
initialStatus
);
setCells(cells);
setPosts(posts);
setComments(comments);
setUserVerificationStatus(initialStatus);
// Sync state from LocalDatabase
setSyncState(localDatabase.getSyncState());
}, [currentUser, userIdentityService]);
const handleRefreshData = async () => {
setIsRefreshing(true);
try {
// SDS handles message syncing automatically, just update UI
await updateStateFromCache();
const { lastSync, isSyncing } = localDatabase.getSyncState();
toast({
title: 'Data Refreshed',
description: lastSync
? `Your view has been updated. Last sync: ${new Date(
lastSync
).toLocaleTimeString()}${isSyncing ? ' (syncing...)' : ''}`
: 'Your view has been updated.',
});
} catch (error) {
console.error('Error refreshing data:', error);
toast({
title: 'Refresh Failed',
description: 'Could not update the view. Please try again.',
variant: 'destructive',
});
} finally {
setIsRefreshing(false);
}
};
// Monitor network connection status
useEffect(() => {
const { unsubscribe } = monitorNetworkHealth(setIsNetworkConnected, toast);
return unsubscribe;
}, [toast]);
const hasInitializedRef = useRef(false);
useEffect(() => {
if (hasInitializedRef.current) return;
hasInitializedRef.current = true;
const loadData = async () => {
setIsInitialLoading(true);
// Open Local DB and seed Waku cache on warm start before network init
try {
await localDatabase.open();
// Seed messageManager's in-memory cache from LocalDatabase for instant UI
const seeded = localDatabase.cache;
Object.assign(messageManager.messageCache.cells, seeded.cells);
Object.assign(messageManager.messageCache.posts, seeded.posts);
Object.assign(messageManager.messageCache.comments, seeded.comments);
Object.assign(messageManager.messageCache.votes, seeded.votes);
Object.assign(
messageManager.messageCache.moderations,
seeded.moderations
);
Object.assign(
messageManager.messageCache.userIdentities,
seeded.userIdentities
);
// Determine if we have any cached content
const hasSeedData =
Object.keys(seeded.cells).length > 0 ||
Object.keys(seeded.posts).length > 0 ||
Object.keys(seeded.comments).length > 0 ||
Object.keys(seeded.votes).length > 0;
// Render from local cache immediately (warm start) or empty (cold)
await updateStateFromCache();
// Initialize network and let incoming messages update LocalDatabase/Cache
await initializeNetwork(toast, setError);
if (hasSeedData) {
setIsInitialLoading(false);
} else {
// Wait for Waku network to be healthy instead of first message
const unsubscribeHealth = messageManager.onHealthChange(isReady => {
if (isReady) {
setIsInitialLoading(false);
unsubscribeHealth();
}
});
}
} catch (e) {
console.warn('LocalDatabase warm-start failed, continuing cold:', e);
// Initialize network even if local DB failed, keep loader until Waku is healthy
await initializeNetwork(toast, setError);
const unsubscribeHealth = messageManager.onHealthChange(isReady => {
if (isReady) {
setIsInitialLoading(false);
unsubscribeHealth();
}
});
}
};
loadData();
// Set up periodic queries
// Setup periodic queries would go here
// const { cleanup } = setupPeriodicQueries(updateStateFromCache);
return () => {}; // Return empty cleanup function
}, [toast, updateStateFromCache]);
// Subscribe to incoming messages to update UI in real-time
useEffect(() => {
const unsubscribe = messageManager.onMessageReceived(() => {
localDatabase.setSyncing(true);
updateStateFromCache().finally(() => localDatabase.setSyncing(false));
});
return unsubscribe;
}, [updateStateFromCache]);
// Simple reactive updates: check for new data periodically when connected
useEffect(() => {
if (!isNetworkConnected) return;
const interval = setInterval(() => {
// Only update if we're connected and ready
if (messageManager.isReady) {
localDatabase.setSyncing(true);
updateStateFromCache().finally(() => localDatabase.setSyncing(false));
}
}, 15000); // 15 seconds - much less frequent than before
return () => clearInterval(interval);
}, [isNetworkConnected, updateStateFromCache]);
const getCellById = (id: string): Cell | undefined => {
return cells.find(cell => cell.id === id);
};
const getPostsByCell = (cellId: string): Post[] => {
return posts
.filter(post => post.cellId === cellId)
.sort((a, b) => b.timestamp - a.timestamp);
};
const getCommentsByPost = (postId: string): Comment[] => {
return comments
.filter(comment => comment.postId === postId)
.sort((a, b) => a.timestamp - b.timestamp);
};
const handleCreatePost = async (
cellId: string,
title: string,
content: string
): Promise<Post | null> => {
setIsPostingPost(true);
toast({
title: 'Creating post',
description: 'Sending your post to the network...',
});
const result = await forumActions.createPost(
{ cellId, title, content, currentUser, isAuthenticated },
updateStateFromCache
);
setIsPostingPost(false);
if (result.success) {
toast({
title: 'Post Created',
description: 'Your post has been published successfully.',
});
return result.data || null;
} else {
toast({
title: 'Post Failed',
description: result.error || 'Failed to create post. Please try again.',
variant: 'destructive',
});
return null;
}
};
const handleCreateComment = async (
postId: string,
content: string
): Promise<Comment | null> => {
setIsPostingComment(true);
toast({
title: 'Posting comment',
description: 'Sending your comment to the network...',
});
const result = await forumActions.createComment(
{ postId, content, currentUser, isAuthenticated },
updateStateFromCache
);
setIsPostingComment(false);
if (result.success) {
toast({
title: 'Comment Added',
description: 'Your comment has been published.',
});
return result.data || null;
} else {
toast({
title: 'Comment Failed',
description: result.error || 'Failed to add comment. Please try again.',
variant: 'destructive',
});
return null;
}
};
const handleVotePost = async (
postId: string,
isUpvote: boolean
): Promise<boolean> => {
setIsVoting(true);
const voteType = isUpvote ? 'upvote' : 'downvote';
toast({
title: `Sending ${voteType}`,
description: 'Recording your vote on the network...',
});
const result = await forumActions.vote(
{ targetId: postId, isUpvote, currentUser, isAuthenticated },
updateStateFromCache
);
setIsVoting(false);
if (result.success) {
toast({
title: 'Vote Recorded',
description: `Your ${voteType} has been registered.`,
});
return result.data || false;
} else {
toast({
title: 'Vote Failed',
description:
result.error || 'Failed to register your vote. Please try again.',
variant: 'destructive',
});
return false;
}
};
const handleVoteComment = async (
commentId: string,
isUpvote: boolean
): Promise<boolean> => {
setIsVoting(true);
const voteType = isUpvote ? 'upvote' : 'downvote';
toast({
title: `Sending ${voteType}`,
description: 'Recording your vote on the network...',
});
const result = await forumActions.vote(
{ targetId: commentId, isUpvote, currentUser, isAuthenticated },
updateStateFromCache
);
setIsVoting(false);
if (result.success) {
toast({
title: 'Vote Recorded',
description: `Your ${voteType} has been registered.`,
});
return result.data || false;
} else {
toast({
title: 'Vote Failed',
description:
result.error || 'Failed to register your vote. Please try again.',
variant: 'destructive',
});
return false;
}
};
const handleCreateCell = async (
name: string,
description: string,
icon?: string
): Promise<Cell | null> => {
setIsPostingCell(true);
toast({
title: 'Creating cell',
description: 'Sending your cell to the network...',
});
const result = await forumActions.createCell(
{ name, description, icon, currentUser, isAuthenticated },
updateStateFromCache
);
setIsPostingCell(false);
if (result.success) {
toast({
title: 'Cell Created',
description: 'Your cell has been published.',
});
return result.data || null;
} else {
toast({
title: 'Cell Failed',
description: result.error || 'Failed to create cell. Please try again.',
variant: 'destructive',
});
return null;
}
};
const handleModeratePost = async (
cellId: string,
postId: string,
reason: string | undefined,
cellOwner: string
) => {
toast({
title: 'Moderating Post',
description: 'Sending moderation message to the network...',
});
const result = await forumActions.moderatePost(
{ cellId, postId, reason, currentUser, isAuthenticated, cellOwner },
updateStateFromCache
);
if (result.success) {
toast({
title: 'Post Moderated',
description: 'The post has been marked as moderated.',
});
return result.data || false;
} else {
toast({
title: 'Moderation Failed',
description:
result.error || 'Failed to moderate post. Please try again.',
variant: 'destructive',
});
return false;
}
};
const handleUnmoderatePost = async (
cellId: string,
postId: string,
reason: string | undefined,
cellOwner: string
) => {
toast({
title: 'Unmoderating Post',
description: 'Sending unmoderation message to the network...',
});
const result = await forumActions.unmoderatePost(
{ cellId, postId, reason, currentUser, isAuthenticated, cellOwner },
updateStateFromCache
);
if (result.success) {
toast({
title: 'Post Unmoderated',
description: 'The post is now visible again.',
});
return result.data || false;
} else {
toast({
title: 'Unmoderation Failed',
description:
result.error || 'Failed to unmoderate post. Please try again.',
variant: 'destructive',
});
return false;
}
};
const handleModerateComment = async (
cellId: string,
commentId: string,
reason: string | undefined,
cellOwner: string
) => {
toast({
title: 'Moderating Comment',
description: 'Sending moderation message to the network...',
});
const result = await forumActions.moderateComment(
{ cellId, commentId, reason, currentUser, isAuthenticated, cellOwner },
updateStateFromCache
);
if (result.success) {
toast({
title: 'Comment Moderated',
description: 'The comment has been marked as moderated.',
});
return result.data || false;
} else {
toast({
title: 'Moderation Failed',
description:
result.error || 'Failed to moderate comment. Please try again.',
variant: 'destructive',
});
return false;
}
};
const handleUnmoderateComment = async (
cellId: string,
commentId: string,
reason: string | undefined,
cellOwner: string
) => {
toast({
title: 'Unmoderating Comment',
description: 'Sending unmoderation message to the network...',
});
const result = await forumActions.unmoderateComment(
{ cellId, commentId, reason, currentUser, isAuthenticated, cellOwner },
updateStateFromCache
);
if (result.success) {
toast({
title: 'Comment Unmoderated',
description: 'The comment is now visible again.',
});
return result.data || false;
} else {
toast({
title: 'Unmoderation Failed',
description:
result.error || 'Failed to unmoderate comment. Please try again.',
variant: 'destructive',
});
return false;
}
};
const handleModerateUser = async (
cellId: string,
userAddress: string,
reason: string | undefined,
cellOwner: string
) => {
const result = await forumActions.moderateUser(
{ cellId, userAddress, reason, currentUser, isAuthenticated, cellOwner },
updateStateFromCache
);
if (result.success) {
toast({
title: 'User Moderated',
description: `User ${userAddress} has been moderated in this cell.`,
});
return result.data || false;
} else {
toast({
title: 'Moderation Failed',
description:
result.error || 'Failed to moderate user. Please try again.',
variant: 'destructive',
});
return false;
}
};
const handleUnmoderateUser = async (
cellId: string,
userAddress: string,
reason: string | undefined,
cellOwner: string
) => {
const result = await forumActions.unmoderateUser(
{ cellId, userAddress, reason, currentUser, isAuthenticated, cellOwner },
updateStateFromCache
);
if (result.success) {
toast({
title: 'User Unmoderated',
description: `User ${userAddress} has been unmoderated in this cell.`,
});
return result.data || false;
} else {
toast({
title: 'Unmoderation Failed',
description:
result.error || 'Failed to unmoderate user. Please try again.',
variant: 'destructive',
});
return false;
}
};
return (
<ForumContext.Provider
value={{
cells,
posts,
comments,
userVerificationStatus,
userIdentityService,
isInitialLoading,
lastSync,
isSyncing,
isPostingCell,
isPostingPost,
isPostingComment,
isVoting,
isRefreshing,
isNetworkConnected,
error,
getCellById,
getPostsByCell,
getCommentsByPost,
createPost: handleCreatePost,
createComment: handleCreateComment,
votePost: handleVotePost,
voteComment: handleVoteComment,
createCell: handleCreateCell,
refreshData: handleRefreshData,
moderatePost: handleModeratePost,
unmoderatePost: handleUnmoderatePost,
moderateComment: handleModerateComment,
unmoderateComment: handleUnmoderateComment,
moderateUser: handleModerateUser,
unmoderateUser: handleUnmoderateUser,
}}
>
{children}
</ForumContext.Provider>
);
}

View File

@ -1,83 +0,0 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { localDatabase } from '@opchan/core';
interface ModerationContextType {
showModerated: boolean;
setShowModerated: (show: boolean) => void;
toggleShowModerated: () => void;
}
const ModerationContext = createContext<ModerationContextType | undefined>(
undefined
);
export function ModerationProvider({
children,
}: {
children: React.ReactNode;
}) {
const [showModerated, setShowModerated] = useState<boolean>(false);
const [isInitialized, setIsInitialized] = useState<boolean>(false);
// Load initial state from IndexedDB
useEffect(() => {
const loadModerationPreference = async () => {
try {
const saved = await localDatabase.loadUIState('show-moderated');
setShowModerated(saved === true);
} catch (error) {
console.warn(
'Failed to load moderation preference from IndexedDB:',
error
);
setShowModerated(false);
} finally {
setIsInitialized(true);
}
};
loadModerationPreference();
}, []);
// Save to IndexedDB whenever the value changes (but only after initialization)
useEffect(() => {
if (!isInitialized) return;
const saveModerationPreference = async () => {
try {
await localDatabase.storeUIState('show-moderated', showModerated);
} catch (error) {
console.warn(
'Failed to save moderation preference to IndexedDB:',
error
);
}
};
saveModerationPreference();
}, [showModerated, isInitialized]);
const toggleShowModerated = () => {
setShowModerated(prev => !prev);
};
return (
<ModerationContext.Provider
value={{
showModerated,
setShowModerated,
toggleShowModerated,
}}
>
{children}
</ModerationContext.Provider>
);
}
export function useModeration() {
const context = useContext(ModerationContext);
if (context === undefined) {
throw new Error('useModeration must be used within a ModerationProvider');
}
return context;
}

View File

@ -1,10 +0,0 @@
import { useContext } from 'react';
import { AuthContext } from './AuthContext';
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@ -1,10 +0,0 @@
import { useContext } from 'react';
import { ForumContext } from './ForumContext';
export const useForum = () => {
const context = useContext(ForumContext);
if (context === undefined) {
throw new Error('useForum must be used within a ForumProvider');
}
return context;
};

View File

@ -1,323 +0,0 @@
import { useCallback, useState } from 'react';
import { useAuth } from '@/hooks/core/useAuth';
import { DelegationDuration } from '@opchan/core';
import { useToast } from '@/components/ui/use-toast';
import { useAuth as useAuthContext } from '@/contexts/useAuth';
import { EVerificationStatus } from '@opchan/core';
export interface AuthActionStates {
isConnecting: boolean;
isVerifying: boolean;
isDelegating: boolean;
isDisconnecting: boolean;
}
export interface AuthActions extends AuthActionStates {
// Connection actions
connectWallet: () => Promise<boolean>;
disconnectWallet: () => Promise<boolean>;
// Verification actions
verifyWallet: () => Promise<boolean>;
// Delegation actions
delegateKey: (duration: DelegationDuration) => Promise<boolean>;
clearDelegation: () => Promise<boolean>;
renewDelegation: (duration: DelegationDuration) => Promise<boolean>;
// Utility actions
checkVerificationStatus: () => Promise<void>;
}
/**
* Hook for authentication and verification actions
*/
export function useAuthActions(): AuthActions {
const { isAuthenticated, isAuthenticating, verificationStatus } = useAuth();
const {
verifyOwnership,
delegateKey: delegateKeyFromContext,
getDelegationStatus,
clearDelegation: clearDelegationFromContext,
} = useAuthContext();
const { toast } = useToast();
const [isConnecting, setIsConnecting] = useState(false);
const [isVerifying, setIsVerifying] = useState(false);
const [isDelegating, setIsDelegating] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
// Connect wallet
const connectWallet = useCallback(async (): Promise<boolean> => {
if (isAuthenticated) {
toast({
title: 'Already Connected',
description: 'Your wallet is already connected.',
});
return true;
}
setIsConnecting(true);
try {
// This would trigger the wallet connection flow
// The actual implementation would depend on the wallet system
// For now, we'll assume it's handled by the auth context
toast({
title: 'Connecting...',
description: 'Please approve the connection in your wallet.',
});
// Wait for authentication to complete
// This is a simplified implementation
await new Promise(resolve => setTimeout(resolve, 2000));
if (isAuthenticated) {
toast({
title: 'Wallet Connected',
description: 'Your wallet has been connected successfully.',
});
return true;
} else {
toast({
title: 'Connection Failed',
description: 'Failed to connect wallet. Please try again.',
variant: 'destructive',
});
return false;
}
} catch (error) {
console.error('Failed to connect wallet:', error);
toast({
title: 'Connection Error',
description: 'An error occurred while connecting your wallet.',
variant: 'destructive',
});
return false;
} finally {
setIsConnecting(false);
}
}, [isAuthenticated, toast]);
// Disconnect wallet
const disconnectWallet = useCallback(async (): Promise<boolean> => {
if (!isAuthenticated) {
toast({
title: 'Not Connected',
description: 'No wallet is currently connected.',
});
return true;
}
setIsDisconnecting(true);
try {
// This would trigger the wallet disconnection
// The actual implementation would depend on the wallet system
toast({
title: 'Wallet Disconnected',
description: 'Your wallet has been disconnected.',
});
return true;
} catch (error) {
console.error('Failed to disconnect wallet:', error);
toast({
title: 'Disconnection Error',
description: 'An error occurred while disconnecting your wallet.',
variant: 'destructive',
});
return false;
} finally {
setIsDisconnecting(false);
}
}, [isAuthenticated, toast]);
// Verify wallet
const verifyWallet = useCallback(async (): Promise<boolean> => {
if (!isAuthenticated) {
toast({
title: 'Wallet Not Connected',
description: 'Please connect your wallet first.',
variant: 'destructive',
});
return false;
}
if (verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED) {
toast({
title: 'Already Verified',
description: 'Your wallet is already verified.',
});
return true;
}
setIsVerifying(true);
try {
// Call the real verification function from AuthContext
const success = await verifyOwnership();
if (success) {
toast({
title: 'Verification Complete',
description: 'Your wallet has been verified successfully.',
});
} else {
toast({
title: 'Verification Failed',
description: 'Failed to verify wallet ownership. Please try again.',
variant: 'destructive',
});
}
return success;
} catch (error) {
console.error('Failed to verify wallet:', error);
toast({
title: 'Verification Failed',
description: 'Failed to verify wallet. Please try again.',
variant: 'destructive',
});
return false;
} finally {
setIsVerifying(false);
}
}, [isAuthenticated, verificationStatus, verifyOwnership, toast]);
// Delegate key
const delegateKey = useCallback(
async (duration: DelegationDuration): Promise<boolean> => {
if (!isAuthenticated) {
toast({
title: 'Wallet Not Connected',
description: 'Please connect your wallet first.',
variant: 'destructive',
});
return false;
}
if (verificationStatus === EVerificationStatus.WALLET_UNCONNECTED) {
toast({
title: 'Verification Required',
description: 'Please verify your wallet before delegating keys.',
variant: 'destructive',
});
return false;
}
setIsDelegating(true);
try {
// Call the real delegation function from AuthContext
const success = await delegateKeyFromContext(duration);
if (success) {
const durationLabel = duration === '7days' ? '1 week' : '30 days';
toast({
title: 'Key Delegated',
description: `Your signing key has been delegated for ${durationLabel}.`,
});
} else {
toast({
title: 'Delegation Failed',
description: 'Failed to delegate signing key. Please try again.',
variant: 'destructive',
});
}
return success;
} catch (error) {
console.error('Failed to delegate key:', error);
toast({
title: 'Delegation Failed',
description: 'Failed to delegate signing key. Please try again.',
variant: 'destructive',
});
return false;
} finally {
setIsDelegating(false);
}
},
[isAuthenticated, verificationStatus, delegateKeyFromContext, toast]
);
// Clear delegation
const clearDelegation = useCallback(async (): Promise<boolean> => {
const delegationInfo = await getDelegationStatus();
if (!delegationInfo.isValid) {
toast({
title: 'No Active Delegation',
description: 'There is no active key delegation to clear.',
});
return true;
}
try {
// Use the real clear implementation from AuthContext (includes toast)
await clearDelegationFromContext();
return true;
} catch (error) {
console.error('Failed to clear delegation:', error);
toast({
title: 'Clear Failed',
description: 'Failed to clear delegation. Please try again.',
variant: 'destructive',
});
return false;
}
}, [getDelegationStatus, clearDelegationFromContext, toast]);
// Renew delegation
const renewDelegation = useCallback(
async (duration: DelegationDuration): Promise<boolean> => {
// Clear existing delegation first, then create new one
const cleared = await clearDelegation();
if (!cleared) return false;
return delegateKey(duration);
},
[clearDelegation, delegateKey]
);
// Check verification status
const checkVerificationStatus = useCallback(async (): Promise<void> => {
if (!isAuthenticated) return;
try {
// This would check the current verification status
// The actual implementation would query the verification service
toast({
title: 'Status Updated',
description: 'Verification status has been refreshed.',
});
} catch (error) {
console.error('Failed to check verification status:', error);
toast({
title: 'Status Check Failed',
description: 'Failed to refresh verification status.',
variant: 'destructive',
});
}
}, [isAuthenticated, toast]);
return {
// States
isConnecting,
isVerifying: isVerifying || isAuthenticating,
isDelegating,
isDisconnecting,
// Actions
connectWallet,
disconnectWallet,
verifyWallet,
delegateKey,
clearDelegation,
renewDelegation,
checkVerificationStatus,
};
}

View File

@ -1,646 +0,0 @@
import { useCallback } from 'react';
import { useForum } from '@/contexts/useForum';
import { useAuth } from '@/hooks/core/useAuth';
import { usePermissions } from '@/hooks/core/usePermissions';
import { Cell, Post, Comment } from '@opchan/core';
import { useToast } from '@/components/ui/use-toast';
export interface ForumActionStates {
isCreatingCell: boolean;
isCreatingPost: boolean;
isCreatingComment: boolean;
isVoting: boolean;
isModerating: boolean;
}
export interface ForumActions extends ForumActionStates {
// Cell actions
createCell: (
name: string,
description: string,
icon?: string
) => Promise<Cell | null>;
// Post actions
createPost: (
cellId: string,
title: string,
content: string
) => Promise<Post | null>;
votePost: (postId: string, isUpvote: boolean) => Promise<boolean>;
moderatePost: (
cellId: string,
postId: string,
reason?: string
) => Promise<boolean>;
unmoderatePost: (
cellId: string,
postId: string,
reason?: string
) => Promise<boolean>;
// Comment actions
createComment: (postId: string, content: string) => Promise<Comment | null>;
voteComment: (commentId: string, isUpvote: boolean) => Promise<boolean>;
moderateComment: (
cellId: string,
commentId: string,
reason?: string
) => Promise<boolean>;
unmoderateComment: (
cellId: string,
commentId: string,
reason?: string
) => Promise<boolean>;
// User moderation
moderateUser: (
cellId: string,
userAddress: string,
reason?: string
) => Promise<boolean>;
unmoderateUser: (
cellId: string,
userAddress: string,
reason?: string
) => Promise<boolean>;
// Data refresh
refreshData: () => Promise<void>;
}
/**
* Hook for forum actions with loading states and error handling
*/
export function useForumActions(): ForumActions {
const {
createCell: baseCreateCell,
createPost: baseCreatePost,
createComment: baseCreateComment,
votePost: baseVotePost,
voteComment: baseVoteComment,
moderatePost: baseModeratePost,
unmoderatePost: baseUnmoderatePost,
moderateComment: baseModerateComment,
unmoderateComment: baseUnmoderateComment,
moderateUser: baseModerateUser,
unmoderateUser: baseUnmoderateUser,
refreshData: baseRefreshData,
isPostingCell,
isPostingPost,
isPostingComment,
isVoting,
getCellById,
} = useForum();
const { currentUser } = useAuth();
const permissions = usePermissions();
const { toast } = useToast();
// Cell creation
const createCell = useCallback(
async (
name: string,
description: string,
icon?: string
): Promise<Cell | null> => {
if (!permissions.canCreateCell) {
toast({
title: 'Permission Denied',
description: permissions.createCellReason,
variant: 'destructive',
});
return null;
}
if (!name.trim() || !description.trim()) {
toast({
title: 'Invalid Input',
description:
'Please provide both a name and description for the cell.',
variant: 'destructive',
});
return null;
}
try {
const result = await baseCreateCell(name, description, icon);
if (result) {
toast({
title: 'Cell Created',
description: `Successfully created "${name}" cell.`,
});
}
return result;
} catch {
toast({
title: 'Creation Failed',
description: 'Failed to create cell. Please try again.',
variant: 'destructive',
});
return null;
}
},
[permissions.canCreateCell, baseCreateCell, toast]
);
// Post creation
const createPost = useCallback(
async (
cellId: string,
title: string,
content: string
): Promise<Post | null> => {
if (!permissions.canPost) {
toast({
title: 'Permission Denied',
description: 'You need to verify Ordinal ownership to create posts.',
variant: 'destructive',
});
return null;
}
if (!title.trim() || !content.trim()) {
toast({
title: 'Invalid Input',
description: 'Please provide both a title and content for the post.',
variant: 'destructive',
});
return null;
}
try {
const result = await baseCreatePost(cellId, title, content);
if (result) {
toast({
title: 'Post Created',
description: `Successfully created "${title}".`,
});
}
return result;
} catch {
toast({
title: 'Creation Failed',
description: 'Failed to create post. Please try again.',
variant: 'destructive',
});
return null;
}
},
[permissions.canPost, baseCreatePost, toast]
);
// Comment creation
const createComment = useCallback(
async (postId: string, content: string): Promise<Comment | null> => {
if (!permissions.canComment) {
toast({
title: 'Permission Denied',
description: permissions.commentReason,
variant: 'destructive',
});
return null;
}
if (!content.trim()) {
toast({
title: 'Invalid Input',
description: 'Please provide content for the comment.',
variant: 'destructive',
});
return null;
}
try {
const result = await baseCreateComment(postId, content);
if (result) {
toast({
title: 'Comment Created',
description: 'Successfully posted your comment.',
});
}
return result;
} catch {
toast({
title: 'Creation Failed',
description: 'Failed to create comment. Please try again.',
variant: 'destructive',
});
return null;
}
},
[permissions.canComment, baseCreateComment, toast]
);
// Post voting
const votePost = useCallback(
async (postId: string, isUpvote: boolean): Promise<boolean> => {
if (!permissions.canVote) {
toast({
title: 'Permission Denied',
description: permissions.voteReason,
variant: 'destructive',
});
return false;
}
try {
const result = await baseVotePost(postId, isUpvote);
if (result) {
toast({
title: 'Vote Recorded',
description: `Your ${isUpvote ? 'upvote' : 'downvote'} has been registered.`,
});
}
return result;
} catch {
toast({
title: 'Vote Failed',
description: 'Failed to record your vote. Please try again.',
variant: 'destructive',
});
return false;
}
},
[permissions.canVote, baseVotePost, toast]
);
// Comment voting
const voteComment = useCallback(
async (commentId: string, isUpvote: boolean): Promise<boolean> => {
if (!permissions.canVote) {
toast({
title: 'Permission Denied',
description: permissions.voteReason,
variant: 'destructive',
});
return false;
}
try {
const result = await baseVoteComment(commentId, isUpvote);
if (result) {
toast({
title: 'Vote Recorded',
description: `Your ${isUpvote ? 'upvote' : 'downvote'} has been registered.`,
});
}
return result;
} catch {
toast({
title: 'Vote Failed',
description: 'Failed to record your vote. Please try again.',
variant: 'destructive',
});
return false;
}
},
[permissions.canVote, baseVoteComment, toast]
);
// Post moderation
const moderatePost = useCallback(
async (
cellId: string,
postId: string,
reason?: string
): Promise<boolean> => {
const cell = getCellById(cellId);
const canModerate =
permissions.canModerate(cellId) &&
cell &&
currentUser?.address === cell.author;
if (!canModerate) {
toast({
title: 'Permission Denied',
description: 'You must be the cell owner to moderate content.',
variant: 'destructive',
});
return false;
}
try {
const result = await baseModeratePost(
cellId,
postId,
reason,
cell.author
);
if (result) {
toast({
title: 'Post Moderated',
description: 'The post has been moderated successfully.',
});
}
return result;
} catch {
toast({
title: 'Moderation Failed',
description: 'Failed to moderate post. Please try again.',
variant: 'destructive',
});
return false;
}
},
[permissions, currentUser, getCellById, baseModeratePost, toast]
);
// Post unmoderation
const unmoderatePost = useCallback(
async (
cellId: string,
postId: string,
reason?: string
): Promise<boolean> => {
const cell = getCellById(cellId);
const canModerate =
permissions.canModerate(cellId) &&
cell &&
currentUser?.address === cell.author;
if (!canModerate) {
toast({
title: 'Permission Denied',
description: 'You must be the cell owner to unmoderate content.',
variant: 'destructive',
});
return false;
}
try {
const result = await baseUnmoderatePost(
cellId,
postId,
reason,
cell.author
);
if (result) {
toast({
title: 'Post Unmoderated',
description: 'The post is now visible again.',
});
}
return result;
} catch {
toast({
title: 'Unmoderation Failed',
description: 'Failed to unmoderate post. Please try again.',
variant: 'destructive',
});
return false;
}
},
[permissions, currentUser, getCellById, baseUnmoderatePost, toast]
);
// Comment moderation
const moderateComment = useCallback(
async (
cellId: string,
commentId: string,
reason?: string
): Promise<boolean> => {
const cell = getCellById(cellId);
const canModerate =
permissions.canModerate(cellId) &&
cell &&
currentUser?.address === cell.author;
if (!canModerate) {
toast({
title: 'Permission Denied',
description: 'You must be the cell owner to moderate content.',
variant: 'destructive',
});
return false;
}
try {
const result = await baseModerateComment(
cellId,
commentId,
reason,
cell.author
);
if (result) {
toast({
title: 'Comment Moderated',
description: 'The comment has been moderated successfully.',
});
}
return result;
} catch {
toast({
title: 'Moderation Failed',
description: 'Failed to moderate comment. Please try again.',
variant: 'destructive',
});
return false;
}
},
[permissions, currentUser, getCellById, baseModerateComment, toast]
);
// Comment unmoderation
const unmoderateComment = useCallback(
async (
cellId: string,
commentId: string,
reason?: string
): Promise<boolean> => {
const cell = getCellById(cellId);
const canModerate =
permissions.canModerate(cellId) &&
cell &&
currentUser?.address === cell.author;
if (!canModerate) {
toast({
title: 'Permission Denied',
description: 'You must be the cell owner to unmoderate content.',
variant: 'destructive',
});
return false;
}
try {
const result = await baseUnmoderateComment(
cellId,
commentId,
reason,
cell.author
);
if (result) {
toast({
title: 'Comment Unmoderated',
description: 'The comment is now visible again.',
});
}
return result;
} catch {
toast({
title: 'Unmoderation Failed',
description: 'Failed to unmoderate comment. Please try again.',
variant: 'destructive',
});
return false;
}
},
[permissions, currentUser, getCellById, baseUnmoderateComment, toast]
);
// User moderation
const moderateUser = useCallback(
async (
cellId: string,
userAddress: string,
reason?: string
): Promise<boolean> => {
const cell = getCellById(cellId);
const canModerate =
permissions.canModerate(cellId) &&
cell &&
currentUser?.address === cell.author;
if (!canModerate) {
toast({
title: 'Permission Denied',
description: 'You must be the cell owner to moderate users.',
variant: 'destructive',
});
return false;
}
if (userAddress === currentUser?.address) {
toast({
title: 'Invalid Action',
description: 'You cannot moderate yourself.',
variant: 'destructive',
});
return false;
}
try {
const result = await baseModerateUser(
cellId,
userAddress,
reason,
cell.author
);
if (result) {
toast({
title: 'User Moderated',
description: 'The user has been moderated successfully.',
});
}
return result;
} catch {
toast({
title: 'Moderation Failed',
description: 'Failed to moderate user. Please try again.',
variant: 'destructive',
});
return false;
}
},
[permissions, currentUser, getCellById, baseModerateUser, toast]
);
// User unmoderation
const unmoderateUser = useCallback(
async (
cellId: string,
userAddress: string,
reason?: string
): Promise<boolean> => {
const cell = getCellById(cellId);
const canModerate =
permissions.canModerate(cellId) &&
cell &&
currentUser?.address === cell.author;
if (!canModerate) {
toast({
title: 'Permission Denied',
description: 'You must be the cell owner to unmoderate users.',
variant: 'destructive',
});
return false;
}
if (userAddress === currentUser?.address) {
toast({
title: 'Invalid Action',
description: 'You cannot unmoderate yourself.',
variant: 'destructive',
});
return false;
}
try {
const result = await baseUnmoderateUser(
cellId,
userAddress,
reason,
cell.author
);
if (result) {
toast({
title: 'User Unmoderated',
description: 'The user is now unmoderated in this cell.',
});
}
return result;
} catch {
toast({
title: 'Unmoderation Failed',
description: 'Failed to unmoderate user. Please try again.',
variant: 'destructive',
});
return false;
}
},
[permissions, currentUser, getCellById, baseUnmoderateUser, toast]
);
// Data refresh
const refreshData = useCallback(async (): Promise<void> => {
try {
await baseRefreshData();
toast({
title: 'Data Refreshed',
description: 'Forum data has been updated.',
});
} catch {
toast({
title: 'Refresh Failed',
description: 'Failed to refresh data. Please try again.',
variant: 'destructive',
});
}
}, [baseRefreshData, toast]);
return {
// States
isCreatingCell: isPostingCell,
isCreatingPost: isPostingPost,
isCreatingComment: isPostingComment,
isVoting,
isModerating: false, // This would need to be added to the context
// Actions
createCell,
createPost,
createComment,
votePost,
voteComment,
moderatePost,
unmoderatePost,
moderateComment,
unmoderateComment,
moderateUser,
unmoderateUser,
refreshData,
};
}

View File

@ -1,283 +0,0 @@
import { useCallback, useState } from 'react';
import { useForum } from '@/contexts/useForum';
import { useAuth } from '@/hooks/core/useAuth';
import { usePermissions } from '@/hooks/core/usePermissions';
import { EDisplayPreference } from '@opchan/core';
import { useToast } from '@/components/ui/use-toast';
export interface UserActionStates {
isUpdatingProfile: boolean;
isUpdatingCallSign: boolean;
isUpdatingDisplayPreference: boolean;
}
export interface UserActions extends UserActionStates {
updateCallSign: (callSign: string) => Promise<boolean>;
updateDisplayPreference: (preference: EDisplayPreference) => Promise<boolean>;
updateProfile: (updates: {
callSign?: string;
displayPreference?: EDisplayPreference;
}) => Promise<boolean>;
clearCallSign: () => Promise<boolean>;
}
/**
* Hook for user profile and identity actions
*/
export function useUserActions(): UserActions {
const { userIdentityService } = useForum();
const { currentUser } = useAuth();
const permissions = usePermissions();
const { toast } = useToast();
const [isUpdatingProfile, setIsUpdatingProfile] = useState(false);
const [isUpdatingCallSign, setIsUpdatingCallSign] = useState(false);
const [isUpdatingDisplayPreference, setIsUpdatingDisplayPreference] =
useState(false);
// Update call sign
const updateCallSign = useCallback(
async (callSign: string): Promise<boolean> => {
if (!permissions.canUpdateProfile) {
toast({
title: 'Permission Denied',
description:
'You need to connect your wallet to update your profile.',
variant: 'destructive',
});
return false;
}
if (!userIdentityService || !currentUser) {
toast({
title: 'Service Unavailable',
description: 'User identity service is not available.',
variant: 'destructive',
});
return false;
}
if (!callSign.trim()) {
toast({
title: 'Invalid Input',
description: 'Call sign cannot be empty.',
variant: 'destructive',
});
return false;
}
// Basic validation for call sign
if (callSign.length < 3 || callSign.length > 20) {
toast({
title: 'Invalid Call Sign',
description: 'Call sign must be between 3 and 20 characters.',
variant: 'destructive',
});
return false;
}
if (!/^[a-zA-Z0-9_-]+$/.test(callSign)) {
toast({
title: 'Invalid Call Sign',
description:
'Call sign can only contain letters, numbers, underscores, and hyphens.',
variant: 'destructive',
});
return false;
}
setIsUpdatingCallSign(true);
try {
const success = await userIdentityService.updateUserProfile(
currentUser.address,
callSign,
currentUser.displayPreference
);
if (success) {
toast({
title: 'Call Sign Updated',
description: `Your call sign has been set to "${callSign}".`,
});
return true;
} else {
toast({
title: 'Update Failed',
description: 'Failed to update call sign. Please try again.',
variant: 'destructive',
});
return false;
}
} catch (error) {
console.error('Failed to update call sign:', error);
toast({
title: 'Update Failed',
description: 'An error occurred while updating your call sign.',
variant: 'destructive',
});
return false;
} finally {
setIsUpdatingCallSign(false);
}
},
[permissions.canUpdateProfile, userIdentityService, currentUser, toast]
);
// Update display preference
const updateDisplayPreference = useCallback(
async (preference: EDisplayPreference): Promise<boolean> => {
if (!permissions.canUpdateProfile) {
toast({
title: 'Permission Denied',
description:
'You need to connect your wallet to update your profile.',
variant: 'destructive',
});
return false;
}
if (!userIdentityService || !currentUser) {
toast({
title: 'Service Unavailable',
description: 'User identity service is not available.',
variant: 'destructive',
});
return false;
}
setIsUpdatingDisplayPreference(true);
try {
const success = await userIdentityService.updateUserProfile(
currentUser.address,
currentUser.callSign || '',
preference
);
if (success) {
const preferenceLabel =
preference === EDisplayPreference.CALL_SIGN
? 'Call Sign'
: 'Wallet Address';
toast({
title: 'Display Preference Updated',
description: `Your display preference has been set to "${preferenceLabel}".`,
});
return true;
} else {
toast({
title: 'Update Failed',
description:
'Failed to update display preference. Please try again.',
variant: 'destructive',
});
return false;
}
} catch (error) {
console.error('Failed to update display preference:', error);
toast({
title: 'Update Failed',
description:
'An error occurred while updating your display preference.',
variant: 'destructive',
});
return false;
} finally {
setIsUpdatingDisplayPreference(false);
}
},
[permissions.canUpdateProfile, userIdentityService, currentUser, toast]
);
// Update profile (multiple fields at once)
const updateProfile = useCallback(
async (updates: {
callSign?: string;
displayPreference?: EDisplayPreference;
}): Promise<boolean> => {
if (!permissions.canUpdateProfile) {
toast({
title: 'Permission Denied',
description:
'You need to connect your wallet to update your profile.',
variant: 'destructive',
});
return false;
}
if (!userIdentityService || !currentUser) {
toast({
title: 'Service Unavailable',
description: 'User identity service is not available.',
variant: 'destructive',
});
return false;
}
setIsUpdatingProfile(true);
try {
let success = true;
if (
updates.callSign !== undefined ||
updates.displayPreference !== undefined
) {
const callSignToSend = updates.callSign;
const preferenceToSend =
updates.displayPreference ?? currentUser.displayPreference;
success = await userIdentityService.updateUserProfile(
currentUser.address,
callSignToSend,
preferenceToSend
);
}
if (success) {
toast({
title: 'Profile Updated',
description: 'Your profile has been updated successfully.',
});
return true;
} else {
toast({
title: 'Update Failed',
description: 'Some profile updates failed. Please try again.',
variant: 'destructive',
});
return false;
}
} catch (error) {
console.error('Failed to update profile:', error);
toast({
title: 'Update Failed',
description: 'An error occurred while updating your profile.',
variant: 'destructive',
});
return false;
} finally {
setIsUpdatingProfile(false);
}
},
[permissions.canUpdateProfile, userIdentityService, currentUser, toast]
);
// Clear call sign
const clearCallSign = useCallback(async (): Promise<boolean> => {
return updateCallSign('');
}, [updateCallSign]);
return {
// States
isUpdatingProfile,
isUpdatingCallSign,
isUpdatingDisplayPreference,
// Actions
updateCallSign,
updateDisplayPreference,
updateProfile,
clearCallSign,
};
}

View File

@ -1,54 +0,0 @@
import { useAuth as useBaseAuth } from '@/contexts/useAuth';
import { useForum } from '@/contexts/useForum';
import { User, EVerificationStatus } from '@opchan/core';
export interface AuthState {
currentUser: User | null;
isAuthenticated: boolean;
isAuthenticating: boolean;
verificationStatus: EVerificationStatus;
// Helper functions
getDisplayName: () => string;
getVerificationBadge: () => string | null;
}
/**
* Unified authentication hook that provides core auth state and helpers
*/
export function useAuth(): AuthState {
const { currentUser, isAuthenticated, isAuthenticating, verificationStatus } =
useBaseAuth();
const { userIdentityService } = useForum();
// Helper functions
const getDisplayName = (): string => {
if (!currentUser) return 'Anonymous';
// Centralized display logic; fallback to truncated address if service unavailable
if (userIdentityService) {
return userIdentityService.getDisplayName(currentUser.address);
}
const addr = currentUser.address;
return `${addr.slice(0, 6)}...${addr.slice(-4)}`;
};
const getVerificationBadge = (): string | null => {
switch (verificationStatus) {
case EVerificationStatus.ENS_ORDINAL_VERIFIED:
return '🔑'; // ENS/Ordinal owner
case EVerificationStatus.WALLET_CONNECTED:
return '✅'; // Wallet connected
default:
return null;
}
};
return {
currentUser,
isAuthenticated,
isAuthenticating,
verificationStatus,
getDisplayName,
getVerificationBadge,
};
}

View File

@ -1,164 +0,0 @@
import { useState, useEffect, useMemo } from 'react';
import { useForum } from '@/contexts/useForum';
import { EDisplayPreference, EVerificationStatus } from '@opchan/core';
export interface UserDisplayInfo {
displayName: string;
callSign: string | null;
ensName: string | null;
ordinalDetails: string | null;
verificationLevel: EVerificationStatus;
displayPreference: EDisplayPreference | null;
isLoading: boolean;
error: string | null;
}
/**
* Enhanced user display hook with caching and reactive updates
*/
export function useEnhancedUserDisplay(address: string): UserDisplayInfo {
const { userIdentityService, userVerificationStatus } = useForum();
const [displayInfo, setDisplayInfo] = useState<UserDisplayInfo>({
displayName: `${address.slice(0, 6)}...${address.slice(-4)}`,
callSign: null,
ensName: null,
ordinalDetails: null,
verificationLevel: EVerificationStatus.WALLET_UNCONNECTED,
displayPreference: null,
isLoading: true,
error: null,
});
const [refreshTrigger, setRefreshTrigger] = useState(0);
// Get verification status from forum context for reactive updates
const verificationInfo = useMemo(() => {
return (
userVerificationStatus[address] || {
isVerified: false,
ensName: null,
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
}
);
}, [userVerificationStatus, address]);
// Set up refresh listener for user identity changes
useEffect(() => {
if (!userIdentityService || !address) return;
const unsubscribe = userIdentityService.addRefreshListener(
updatedAddress => {
if (updatedAddress === address) {
setRefreshTrigger(prev => prev + 1);
}
}
);
return unsubscribe;
}, [userIdentityService, address]);
useEffect(() => {
const getUserDisplayInfo = async () => {
if (!address) {
setDisplayInfo(prev => ({
...prev,
isLoading: false,
error: 'No address provided',
}));
return;
}
if (!userIdentityService) {
console.log(
'useEnhancedUserDisplay: No service available, using fallback',
{ address }
);
setDisplayInfo({
displayName: `${address.slice(0, 6)}...${address.slice(-4)}`,
callSign: null,
ensName: verificationInfo.ensName || null,
ordinalDetails: null,
verificationLevel:
verificationInfo.verificationStatus ||
EVerificationStatus.WALLET_UNCONNECTED,
displayPreference: null,
isLoading: false,
error: null,
});
return;
}
try {
const identity = await userIdentityService.getUserIdentity(address);
if (identity) {
const displayName = userIdentityService.getDisplayName(address);
setDisplayInfo({
displayName,
callSign: identity.callSign || null,
ensName: identity.ensName || null,
ordinalDetails: identity.ordinalDetails
? identity.ordinalDetails.ordinalDetails
: null,
verificationLevel: identity.verificationStatus,
displayPreference: identity.displayPreference || null,
isLoading: false,
error: null,
});
} else {
setDisplayInfo({
displayName: userIdentityService.getDisplayName(address),
callSign: null,
ensName: verificationInfo.ensName || null,
ordinalDetails: null,
verificationLevel:
verificationInfo.verificationStatus ||
EVerificationStatus.WALLET_UNCONNECTED,
displayPreference: null,
isLoading: false,
error: null,
});
}
} catch (error) {
console.error(
'useEnhancedUserDisplay: Failed to get user display info:',
error
);
setDisplayInfo({
displayName: `${address.slice(0, 6)}...${address.slice(-4)}`,
callSign: null,
ensName: null,
ordinalDetails: null,
verificationLevel: EVerificationStatus.WALLET_UNCONNECTED,
displayPreference: null,
isLoading: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
};
getUserDisplayInfo();
}, [address, userIdentityService, verificationInfo, refreshTrigger]);
// Update display info when verification status changes reactively
useEffect(() => {
if (!displayInfo.isLoading && verificationInfo) {
setDisplayInfo(prev => ({
...prev,
ensName: verificationInfo.ensName || prev.ensName,
verificationLevel:
verificationInfo.verificationStatus || prev.verificationLevel,
}));
}
}, [
verificationInfo.ensName,
verificationInfo.verificationStatus,
displayInfo.isLoading,
verificationInfo,
]);
return displayInfo;
}
// Export as the main useUserDisplay hook
export { useEnhancedUserDisplay as useUserDisplay };

View File

@ -1,3 +0,0 @@
// Re-export the enhanced user display hook as the main useUserDisplay
export { useEnhancedUserDisplay as useUserDisplay } from './useEnhancedUserDisplay';
export type { UserDisplayInfo } from './useEnhancedUserDisplay';

View File

@ -1,91 +1,80 @@
// Core hooks - Main exports
export { useForumData } from './core/useForumData';
export { useAuth } from './core/useAuth';
export { useUserDisplay } from './core/useUserDisplay';
// Core hooks - Re-exported from @opchan/react
export {
useForumData,
useAuth,
useUserDisplay,
useBookmarks,
usePostBookmark,
useCommentBookmark,
} from './core/useBookmarks';
} from '@opchan/react';
// Core types
// Core types - Re-exported from @opchan/react
export type {
ForumData,
CellWithStats,
PostWithVoteStatus,
CommentWithVoteStatus,
} from './core/useForumData';
export type { AuthState } from './core/useAuth';
export type {
Permission,
PermissionReasons,
PermissionResult,
} from './core/usePermissions';
UserDisplayInfo,
} from '@opchan/react';
export type { UserDisplayInfo } from './core/useEnhancedUserDisplay';
// Derived hooks - Re-exported from @opchan/react
export { useCell, usePost } from '@opchan/react';
export type { CellData, PostData } from '@opchan/react';
// Derived hooks
export { useCell } from './derived/useCell';
export type { CellData } from './derived/useCell';
export { usePost } from './derived/usePost';
export type { PostData } from './derived/usePost';
export { useCellPosts } from './derived/useCellPosts';
export type { CellPostsOptions, CellPostsData } from './derived/useCellPosts';
export { usePostComments } from './derived/usePostComments';
// Derived hooks - Re-exported from @opchan/react
export { useCellPosts, usePostComments, useUserVotes } from '@opchan/react';
export type {
CellPostsOptions,
CellPostsData,
PostCommentsOptions,
PostCommentsData,
} from './derived/usePostComments';
UserVoteData,
} from '@opchan/react';
export { useUserVotes } from './derived/useUserVotes';
export type { UserVoteData } from './derived/useUserVotes';
// Action hooks
export { useForumActions } from './actions/useForumActions';
// Action hooks - Re-exported from @opchan/react
export { useForumActions, useUserActions, useAuthActions } from '@opchan/react';
export type {
ForumActionStates,
ForumActions,
} from './actions/useForumActions';
UserActionStates,
UserActions,
AuthActionStates,
AuthActions,
} from '@opchan/react';
export { useUserActions } from './actions/useUserActions';
export type { UserActionStates, UserActions } from './actions/useUserActions';
export { useAuthActions } from './actions/useAuthActions';
export type { AuthActionStates, AuthActions } from './actions/useAuthActions';
// Utility hooks
export { usePermissions } from './core/usePermissions';
export { useNetworkStatus } from './utilities/useNetworkStatus';
// Utility hooks - Re-exported from @opchan/react
export {
usePermissions,
useNetworkStatus,
useForumSelectors,
useDelegation,
useMessageSigning,
usePending,
usePendingVote,
useWallet,
} from '@opchan/react';
export type {
NetworkHealth,
SyncStatus,
ConnectionStatus,
NetworkStatusData,
} from './utilities/useNetworkStatus';
export {
useWakuHealth,
useWakuReady,
useWakuHealthStatus,
} from './useWakuHealth';
export type { WakuHealthState } from './useWakuHealth';
export { useForumSelectors } from './utilities/selectors';
export type { ForumSelectors } from './utilities/selectors';
ForumSelectors,
} from '@opchan/react';
// Legacy hooks (for backward compatibility - will be removed)
// export { useForum } from '@/contexts/useForum'; // Use useForumData instead
// export { useAuth as useLegacyAuth } from '@/contexts/useAuth'; // Use enhanced useAuth instead
// Re-export existing hooks that don't need changes
// Re-export existing hooks that don't need changes (UI-specific)
export { useIsMobile as useMobile } from './use-mobile';
export { useToast } from './use-toast';
// export { useCache } from './useCache'; // Removed - functionality moved to useForumData
export { useDelegation } from './useDelegation';
export { useMessageSigning } from './useMessageSigning';
export { useWallet } from './useWallet';
// Waku health hooks - Re-exported from @opchan/react
export {
useWakuHealth,
useWakuReady,
useWakuHealthStatus,
} from '@opchan/react';

View File

@ -2,20 +2,24 @@ import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';
import { Buffer } from 'buffer';
import { environment } from '@opchan/core';
import { OpChanProvider } from '@opchan/react';
import { WagmiProvider } from 'wagmi';
import { AppKitProvider } from '@reown/appkit/react';
import { appkitConfig, config } from '@opchan/core';
// Configure the core library environment
environment.configure({
isDevelopment: import.meta.env.DEV,
isProduction: import.meta.env.PROD,
apiKeys: {
ordiscan: import.meta.env.VITE_ORDISCAN_API,
},
});
// Ensure Buffer is available in the browser (needed by some wallet libs)
if (!(window as Window & typeof globalThis).Buffer) {
(window as Window & typeof globalThis).Buffer = Buffer;
}
createRoot(document.getElementById('root')!).render(<App />);
createRoot(document.getElementById('root')!).render(
<WagmiProvider config={config}>
<AppKitProvider {...appkitConfig}>
<OpChanProvider
ordiscanApiKey={'6bb07766-d98c-4ddd-93fb-6a0e94d629dd'}
debug={import.meta.env.DEV}
>
<App />
</OpChanProvider>
</AppKitProvider>
</WagmiProvider>
);

View File

@ -24,7 +24,7 @@ import {
FileText,
MessageSquare,
} from 'lucide-react';
import { useAuth } from '@/contexts/useAuth';
import { useAuth } from '@opchan/react';
const BookmarksPage = () => {
const { currentUser } = useAuth();

View File

@ -1,6 +1,5 @@
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import messageManager from '@opchan/core';
import { MessageType } from '@opchan/core';
import { messageManager, MessageType } from '@opchan/core';
import type { OpchanMessage } from '@opchan/core';
interface ReceivedMessage {
@ -14,11 +13,13 @@ export default function DebugPage() {
useEffect(() => {
// Subscribe to inbound messages from reliable channel
unsubscribeRef.current = messageManager.onMessageReceived(msg => {
setMessages(prev =>
[{ receivedAt: Date.now(), message: msg }, ...prev].slice(0, 500)
);
});
unsubscribeRef.current = messageManager.onMessageReceived(
(msg: OpchanMessage) => {
setMessages(prev =>
[{ receivedAt: Date.now(), message: msg }, ...prev].slice(0, 500)
);
}
);
return () => {
unsubscribeRef.current?.();

View File

@ -1,11 +1,12 @@
import Header from '@/components/Header';
import CellList from '@/components/CellList';
import { useNetworkStatus, useForumActions } from '@/hooks';
import { useForumActions } from '@/hooks';
import { Button } from '@/components/ui/button';
import { Wifi } from 'lucide-react';
import { useForum } from '@opchan/react';
const Index = () => {
const { health } = useNetworkStatus();
const {network} = useForum()
const { refreshData } = useForumActions();
return (
@ -13,7 +14,7 @@ const Index = () => {
<Header />
<main className="page-content relative">
<CellList />
{!health.isConnected && (
{!network.isConnected && (
<div className="fixed bottom-4 right-4">
<Button
onClick={refreshData}

View File

@ -1,8 +1,8 @@
import { useState, useEffect } from 'react';
import { useAuth, useUserActions, useForumActions } from '@/hooks';
import { useAuth as useAuthContext } from '@/contexts/useAuth';
import { useUserActions, useForumActions } from '@/hooks';
import { useAuth } from '@opchan/react';
import { useUserDisplay } from '@/hooks';
import { useDelegation } from '@/hooks/useDelegation';
import { useDelegation } from '@opchan/react';
import { DelegationFullStatus } from '@opchan/core';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -41,8 +41,7 @@ export default function ProfilePage() {
const { toast } = useToast();
// Get current user from auth context for the address
const { currentUser, verificationStatus } = useAuth();
const { getDelegationStatus } = useAuthContext();
const { currentUser, getDelegationStatus } = useAuth();
const { delegationStatus } = useDelegation();
const [delegationInfo, setDelegationInfo] =
useState<DelegationFullStatus | null>(null);
@ -56,6 +55,20 @@ export default function ProfilePage() {
// Get comprehensive user information from the unified hook
const userInfo = useUserDisplay(address || '');
// Debug current user ENS info
console.log('📋 Profile page debug:', {
address,
currentUser: currentUser
? {
address: currentUser.address,
callSign: currentUser.callSign,
ensDetails: currentUser.ensDetails,
verificationStatus: currentUser.verificationStatus,
}
: null,
userInfo,
});
const [isEditing, setIsEditing] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [callSign, setCallSign] = useState('');
@ -190,7 +203,8 @@ export default function ProfilePage() {
};
const getVerificationIcon = () => {
switch (verificationStatus) {
// Use verification level from UserIdentityService (central database store)
switch (userInfo.verificationLevel) {
case EVerificationStatus.ENS_ORDINAL_VERIFIED:
return <CheckCircle className="h-4 w-4 text-green-500" />;
case EVerificationStatus.WALLET_CONNECTED:
@ -203,7 +217,8 @@ export default function ProfilePage() {
};
const getVerificationText = () => {
switch (verificationStatus) {
// Use verification level from UserIdentityService (central database store)
switch (userInfo.verificationLevel) {
case EVerificationStatus.ENS_ORDINAL_VERIFIED:
return 'Owns ENS or Ordinal';
case EVerificationStatus.WALLET_CONNECTED:
@ -216,7 +231,8 @@ export default function ProfilePage() {
};
const getVerificationColor = () => {
switch (verificationStatus) {
// Use verification level from UserIdentityService (central database store)
switch (userInfo.verificationLevel) {
case EVerificationStatus.ENS_ORDINAL_VERIFIED:
return 'bg-green-100 text-green-800 border-green-200';
case EVerificationStatus.WALLET_CONNECTED:
@ -277,9 +293,32 @@ export default function ProfilePage() {
{userInfo.displayName}
</div>
<div className="text-sm text-cyber-neutral">
{userInfo.ordinalDetails || currentUser.ordinalDetails?.ordinalDetails
? `Ordinal: ${userInfo.ordinalDetails || currentUser.ordinalDetails?.ordinalDetails}`
: currentUser.ensDetails?.ensName || 'No ENS name'}
{/* Show ENS name if available */}
{(userInfo.ensName ||
currentUser?.ensDetails?.ensName) && (
<div>
ENS:{' '}
{userInfo.ensName ||
currentUser?.ensDetails?.ensName}
</div>
)}
{/* Show Ordinal details if available */}
{(userInfo.ordinalDetails ||
currentUser?.ordinalDetails?.ordinalDetails) && (
<div>
Ordinal:{' '}
{userInfo.ordinalDetails ||
currentUser?.ordinalDetails?.ordinalDetails}
</div>
)}
{/* Show fallback if neither ENS nor Ordinal */}
{!(
userInfo.ensName || currentUser?.ensDetails?.ensName
) &&
!(
userInfo.ordinalDetails ||
currentUser?.ordinalDetails?.ordinalDetails
) && <div>No ENS or Ordinal verification</div>}
</div>
<div className="flex items-center gap-2 mt-2">
{getVerificationIcon()}

View File

@ -16,7 +16,7 @@ export default defineConfig(() => ({
},
},
optimizeDeps: {
include: ['buffer'],
include: ['buffer', '@opchan/core', '@opchan/hooks'],
},
build: {
target: 'es2022',

8362
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,8 @@
"description": "Browser-based Forum Library over Waku",
"private": true,
"workspaces": [
"packages/*"
"packages/*",
"app"
],
"scripts": {
"build": "npm run build --workspaces",

View File

@ -5,14 +5,22 @@
"main": "dist/index.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.esm.js",
"require": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "npm run build:cjs && npm run build:esm && npm run build:types",
"build": "npm run clean && npm run build:cjs && cp dist/cjs/index.js dist/index.js && npm run build:esm && npm run build:types",
"build:cjs": "tsc --module commonjs --outDir dist/cjs",
"build:esm": "tsc --module esnext --outDir dist/esm && cp -r dist/esm/* dist/ && mv dist/index.js dist/index.esm.js",
"build:types": "tsc --declaration --emitDeclarationOnly --outDir dist",
"build:esm": "tsc --module esnext --outDir dist/esm && cp dist/esm/index.js dist/index.esm.js && cp -r dist/esm/types dist/ && cp -r dist/esm/lib dist/ && cp -r dist/esm/client dist/",
"build:types": "tsc --declaration --emitDeclarationOnly --outDir dist && cp dist/cjs/index.d.ts dist/index.d.ts",
"dev": "tsc --watch",
"test": "vitest",
"lint": "eslint src --ext .ts",
@ -38,7 +46,7 @@
"ordiscan": "^1.3.0",
"tailwind-merge": "^2.5.2",
"uuid": "^11.1.0",
"wagmi": "^2.16.1"
"wagmi": "^2.17.0"
},
"devDependencies": {
"@types/node": "^22.5.5",

View File

@ -0,0 +1,47 @@
import { environment, type EnvironmentConfig } from '../lib/utils/environment';
import messageManager, { DefaultMessageManager } from '../lib/waku';
import { LocalDatabase, localDatabase } from '../lib/database/LocalDatabase';
import { ForumActions } from '../lib/forum/ForumActions';
import { RelevanceCalculator } from '../lib/forum/RelevanceCalculator';
import { UserIdentityService } from '../lib/services/UserIdentityService';
import { DelegationManager, delegationManager } from '../lib/delegation';
import { walletManager } from '../lib/wallet';
import { MessageService } from '../lib/services/MessageService';
export interface OpChanClientConfig {
ordiscanApiKey: string;
debug?: boolean;
isDevelopment?: boolean;
isProduction?: boolean;
}
export class OpChanClient {
readonly config: OpChanClientConfig;
// Exposed subsystems
readonly messageManager: DefaultMessageManager = messageManager;
readonly database: LocalDatabase = localDatabase;
readonly forumActions = new ForumActions();
readonly relevance = new RelevanceCalculator();
readonly messageService: MessageService;
readonly userIdentityService: UserIdentityService;
readonly delegation: DelegationManager = delegationManager;
readonly wallet = walletManager;
constructor(config: OpChanClientConfig) {
this.config = config;
const env: EnvironmentConfig = {
isDevelopment: config.isDevelopment ?? config.debug ?? false,
isProduction: config.isProduction ?? !config.debug,
apiKeys: {
ordiscan: config.ordiscanApiKey,
},
};
environment.configure(env);
this.messageService = new MessageService(this.delegation);
this.userIdentityService = new UserIdentityService(this.messageService);
}
}

View File

@ -19,6 +19,7 @@ export {
DelegationCrypto
} from './lib/delegation';
export * from './lib/delegation/types';
export type { DelegationFullStatus } from './lib/delegation';
// Export forum functionality
export { ForumActions } from './lib/forum/ForumActions';
@ -45,4 +46,7 @@ export * from './lib/waku/network';
// Export wallet functionality
export { WalletManager, walletManager } from './lib/wallet';
export * from './lib/wallet/config';
export * from './lib/wallet/types';
export * from './lib/wallet/types';
// Primary client API
export { OpChanClient, type OpChanClientConfig } from './client/OpChanClient';

View File

@ -8,6 +8,7 @@ import {
EModerationAction,
} from '../../types/waku';
import messageManager from '../waku';
import { localDatabase } from '../database/LocalDatabase';
import { RelevanceCalculator } from './RelevanceCalculator';
import { UserVerificationStatus } from '../../types/forum';
// Validation is enforced at ingestion time by LocalDatabase. Transformers assume
@ -68,7 +69,7 @@ export const transformPost = async (
): Promise<Post | null> => {
// Message validity already enforced upstream
const votes = Object.values(messageManager.messageCache.votes).filter(
const votes = Object.values(localDatabase.cache.votes).filter(
(vote): vote is VoteMessage => vote && vote.targetId === postMessage.id
);
// Votes in cache are already validated; just map
@ -80,13 +81,13 @@ export const transformPost = async (
(vote): vote is VoteMessage => vote !== null && vote.value === -1
);
const modMsg = messageManager.messageCache.moderations[postMessage.id];
const modMsg = localDatabase.cache.moderations[postMessage.id];
const isPostModerated =
!!modMsg &&
modMsg.targetType === 'post' &&
modMsg.action === EModerationAction.MODERATE;
const userModMsg = Object.values(
messageManager.messageCache.moderations
localDatabase.cache.moderations
).find(
(m): m is ModerateMessage =>
m &&
@ -137,7 +138,7 @@ export const transformPost = async (
// Get comments for this post
const comments = await Promise.all(
Object.values(messageManager.messageCache.comments)
Object.values(localDatabase.cache.comments)
.filter((comment): comment is CommentMessage => comment !== null)
.map(comment =>
transformComment(comment, undefined, userVerificationStatus)
@ -190,7 +191,7 @@ export const transformComment = async (
userVerificationStatus?: UserVerificationStatus
): Promise<Comment | null> => {
// Message validity already enforced upstream
const votes = Object.values(messageManager.messageCache.votes).filter(
const votes = Object.values(localDatabase.cache.votes).filter(
(vote): vote is VoteMessage => vote && vote.targetId === commentMessage.id
);
// Votes in cache are already validated
@ -202,17 +203,17 @@ export const transformComment = async (
(vote): vote is VoteMessage => vote !== null && vote.value === -1
);
const modMsg = messageManager.messageCache.moderations[commentMessage.id];
const modMsg = localDatabase.cache.moderations[commentMessage.id];
const isCommentModerated =
!!modMsg &&
modMsg.targetType === 'comment' &&
modMsg.action === EModerationAction.MODERATE;
// Find the post to get the correct cell ID
const parentPost = Object.values(messageManager.messageCache.posts).find(
const parentPost = Object.values(localDatabase.cache.posts).find(
(post): post is PostMessage => post && post.id === commentMessage.postId
);
const userModMsg = Object.values(
messageManager.messageCache.moderations
localDatabase.cache.moderations
).find(
(m): m is ModerateMessage =>
m &&
@ -288,10 +289,10 @@ export const getDataFromCache = async (
_verifyMessage?: unknown, // Deprecated parameter, kept for compatibility
userVerificationStatus?: UserVerificationStatus
): Promise<{ cells: Cell[]; posts: Post[]; comments: Comment[] }> => {
// First transform posts and comments to get relevance scores
// Use LocalDatabase cache for immediate hydration, avoiding messageManager race conditions
// All validation is now handled internally by the transform functions
const posts = await Promise.all(
Object.values(messageManager.messageCache.posts)
Object.values(localDatabase.cache.posts)
.filter((post): post is PostMessage => post !== null)
.map(post =>
transformPost(post, undefined, userVerificationStatus)
@ -299,7 +300,7 @@ export const getDataFromCache = async (
).then(posts => posts.filter((post): post is Post => post !== null));
const comments = await Promise.all(
Object.values(messageManager.messageCache.comments)
Object.values(localDatabase.cache.comments)
.filter((c): c is CommentMessage => c !== null)
.map(c =>
transformComment(c, undefined, userVerificationStatus)
@ -310,7 +311,7 @@ export const getDataFromCache = async (
// Then transform cells with posts for relevance calculation
const cells = await Promise.all(
Object.values(messageManager.messageCache.cells)
Object.values(localDatabase.cache.cells)
.filter((cell): cell is CellMessage => cell !== null)
.map(cell =>
transformCell(cell, undefined, userVerificationStatus, posts)

View File

@ -1,6 +1,5 @@
import { Ordiscan, Inscription } from 'ordiscan';
import { environment } from '../utils/environment';
const API_KEY = environment.ordiscanApiKey;
class Ordinals {
private static instance: Ordinals | null = null;
@ -14,10 +13,11 @@ class Ordinals {
static getInstance(): Ordinals {
if (!Ordinals.instance) {
if (!API_KEY) {
const apiKey = environment.ordiscanApiKey;
if (!apiKey) {
throw new Error('Ordiscan API key is not configured. Please set up the environment.');
}
Ordinals.instance = new Ordinals(new Ordiscan(API_KEY));
Ordinals.instance = new Ordinals(new Ordiscan(apiKey));
}
return Ordinals.instance;
}
@ -48,4 +48,9 @@ class Ordinals {
}
}
export const ordinals = Ordinals.getInstance();
export const ordinals = {
getInstance: () => Ordinals.getInstance(),
getOrdinalDetails: async (address: string) => {
return Ordinals.getInstance().getOrdinalDetails(address);
}
};

View File

@ -28,15 +28,37 @@ export class UserIdentityService {
private messageService: MessageService;
private userIdentityCache: UserIdentityCache = {};
private refreshListeners: Set<(address: string) => void> = new Set();
private ensResolutionCache: Map<string, Promise<string | null>> = new Map();
private debounceTimers: Map<string, NodeJS.Timeout> = new Map();
constructor(messageService: MessageService) {
this.messageService = messageService;
}
/**
* Get user identity from cache or resolve from sources
* Get user identity from cache or resolve from sources with debouncing
*/
async getUserIdentity(address: string): Promise<UserIdentity | null> {
// Debounce rapid calls to the same address
if (this.debounceTimers.has(address)) {
clearTimeout(this.debounceTimers.get(address)!);
}
return new Promise((resolve) => {
const timer = setTimeout(async () => {
this.debounceTimers.delete(address);
const result = await this.getUserIdentityInternal(address);
resolve(result);
}, 100); // 100ms debounce
this.debounceTimers.set(address, timer);
});
}
/**
* Internal method to get user identity without debouncing
*/
private async getUserIdentityInternal(address: string): Promise<UserIdentity | null> {
// Check internal cache first
if (this.userIdentityCache[address]) {
const cached = this.userIdentityCache[address];
@ -48,6 +70,16 @@ export class UserIdentityService {
const ensName = await this.resolveENSName(address);
if (ensName) {
cached.ensName = ensName;
// Update verification status if ENS is found
if (cached.verificationStatus !== EVerificationStatus.ENS_ORDINAL_VERIFIED) {
cached.verificationStatus = EVerificationStatus.ENS_ORDINAL_VERIFIED;
// Persist the updated verification status to LocalDatabase
await localDatabase.upsertUserIdentity(address, {
ensName,
verificationStatus: EVerificationStatus.ENS_ORDINAL_VERIFIED,
lastUpdated: Date.now(),
});
}
}
}
return {
@ -91,6 +123,17 @@ export class UserIdentityService {
if (ensName) {
result.ensName = ensName;
this.userIdentityCache[address].ensName = ensName;
// Update verification status if ENS is found
if (result.verificationStatus !== EVerificationStatus.ENS_ORDINAL_VERIFIED) {
result.verificationStatus = EVerificationStatus.ENS_ORDINAL_VERIFIED;
this.userIdentityCache[address].verificationStatus = EVerificationStatus.ENS_ORDINAL_VERIFIED;
// Persist the updated verification status to LocalDatabase
await localDatabase.upsertUserIdentity(address, {
ensName,
verificationStatus: EVerificationStatus.ENS_ORDINAL_VERIFIED,
lastUpdated: Date.now(),
});
}
}
}
return result;
@ -132,6 +175,17 @@ export class UserIdentityService {
if (ensName) {
result.ensName = ensName;
this.userIdentityCache[address].ensName = ensName;
// Update verification status if ENS is found
if (result.verificationStatus !== EVerificationStatus.ENS_ORDINAL_VERIFIED) {
result.verificationStatus = EVerificationStatus.ENS_ORDINAL_VERIFIED;
this.userIdentityCache[address].verificationStatus = EVerificationStatus.ENS_ORDINAL_VERIFIED;
// Persist the updated verification status to LocalDatabase
await localDatabase.upsertUserIdentity(address, {
ensName,
verificationStatus: EVerificationStatus.ENS_ORDINAL_VERIFIED,
lastUpdated: Date.now(),
});
}
}
}
return result;
@ -152,6 +206,16 @@ export class UserIdentityService {
lastUpdated: identity.lastUpdated,
verificationStatus: identity.verificationStatus,
};
// Persist the resolved identity to LocalDatabase for future use
await localDatabase.upsertUserIdentity(address, {
ensName: identity.ensName,
ordinalDetails: identity.ordinalDetails,
callSign: identity.callSign,
displayPreference: identity.displayPreference,
verificationStatus: identity.verificationStatus,
lastUpdated: identity.lastUpdated,
});
}
return identity;
@ -176,6 +240,16 @@ export class UserIdentityService {
lastUpdated: identity.lastUpdated,
verificationStatus: identity.verificationStatus,
};
// Persist the fresh identity to LocalDatabase
await localDatabase.upsertUserIdentity(address, {
ensName: identity.ensName,
ordinalDetails: identity.ordinalDetails,
callSign: identity.callSign,
displayPreference: identity.displayPreference,
verificationStatus: identity.verificationStatus,
lastUpdated: identity.lastUpdated,
});
}
return identity;
}
@ -324,13 +398,40 @@ export class UserIdentityService {
}
/**
* Resolve ENS name from Ethereum address
* Resolve ENS name from Ethereum address with caching to prevent multiple calls
*/
private async resolveENSName(address: string): Promise<string | null> {
if (!address.startsWith('0x')) {
return null; // Not an Ethereum address
}
// Check if we already have a pending resolution for this address
if (this.ensResolutionCache.has(address)) {
return this.ensResolutionCache.get(address)!;
}
// Check if we already have this resolved in the cache and it's recent
const cached = this.userIdentityCache[address];
if (cached?.ensName && cached.lastUpdated > Date.now() - 300000) { // 5 minutes cache
return cached.ensName;
}
// Create and cache the promise
const resolutionPromise = this.doResolveENSName(address);
this.ensResolutionCache.set(address, resolutionPromise);
// Clean up the cache after resolution (successful or failed)
resolutionPromise.finally(() => {
// Remove from cache after 60 seconds to allow for re-resolution if needed
setTimeout(() => {
this.ensResolutionCache.delete(address);
}, 60000);
});
return resolutionPromise;
}
private async doResolveENSName(address: string): Promise<string | null> {
try {
// Import the ENS resolver from wagmi
const { getEnsName } = await import('@wagmi/core');
@ -445,6 +546,10 @@ export class UserIdentityService {
*/
clearUserIdentityCache(): void {
this.userIdentityCache = {};
this.ensResolutionCache.clear();
// Clear all debounce timers
this.debounceTimers.forEach(timer => clearTimeout(timer));
this.debounceTimers.clear();
}
/**

View File

@ -123,9 +123,11 @@ class MessageManager {
}
// Create a default instance that can be used synchronously but initialized asynchronously
class DefaultMessageManager {
export class DefaultMessageManager {
private _instance: MessageManager | null = null;
private _initPromise: Promise<MessageManager> | null = null;
private _pendingHealthSubscriptions: HealthChangeCallback[] = [];
private _pendingMessageSubscriptions: ((message: any) => void)[] = [];
// Initialize the manager asynchronously
async initialize(): Promise<void> {
@ -133,6 +135,18 @@ class DefaultMessageManager {
this._initPromise = MessageManager.create();
}
this._instance = await this._initPromise;
// Establish all pending health subscriptions
this._pendingHealthSubscriptions.forEach(callback => {
this._instance!.onHealthChange(callback);
});
this._pendingHealthSubscriptions = [];
// Establish all pending message subscriptions
this._pendingMessageSubscriptions.forEach(callback => {
this._instance!.onMessageReceived(callback);
});
this._pendingMessageSubscriptions = [];
}
// Get the messageCache (most common usage)
@ -170,16 +184,32 @@ class DefaultMessageManager {
onHealthChange(callback: any) {
if (!this._instance) {
// Return a no-op function until initialized
return () => {};
// Queue the callback for when we're initialized
this._pendingHealthSubscriptions.push(callback);
// Return a function that removes from the pending queue
return () => {
const index = this._pendingHealthSubscriptions.indexOf(callback);
if (index !== -1) {
this._pendingHealthSubscriptions.splice(index, 1);
}
};
}
return this._instance.onHealthChange(callback);
}
onMessageReceived(callback: any) {
if (!this._instance) {
// Return a no-op function until initialized
return () => {};
// Queue the callback for when we're initialized
this._pendingMessageSubscriptions.push(callback);
// Return a function that removes from the pending queue
return () => {
const index = this._pendingMessageSubscriptions.indexOf(callback);
if (index !== -1) {
this._pendingMessageSubscriptions.splice(index, 1);
}
};
}
return this._instance.onMessageReceived(callback);
}

View File

@ -0,0 +1,42 @@
{
"name": "@opchan/react",
"version": "1.0.0",
"private": false,
"description": "React contexts and hooks for OpChan built on @opchan/core",
"main": "dist/index.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.esm.js",
"require": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "npm run clean && npm run build:esm && npm run build:types",
"build:esm": "tsc -p tsconfig.build.json && cp dist/index.js dist/index.esm.js",
"build:types": "tsc -p tsconfig.types.json",
"clean": "rm -rf dist",
"dev": "tsc -w -p tsconfig.build.json",
"lint": "eslint src --ext .ts,.tsx"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"dependencies": {
"@opchan/core": "file:../core"
},
"devDependencies": {
"typescript": "^5.5.3",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22"
}
}

View File

@ -0,0 +1,324 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { User, EVerificationStatus, OpChanClient, EDisplayPreference } from '@opchan/core';
import { walletManager, delegationManager, messageManager, LocalDatabase, localDatabase, WalletManager } from '@opchan/core';
import { DelegationDuration } from '@opchan/core';
import { useAppKitAccount } from '@reown/appkit/react';
export interface AuthContextValue {
currentUser: User | 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: typeof messageManager.sendMessage;
verifyMessage: (message: unknown) => Promise<boolean>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export const AuthProvider: React.FC<{
client: OpChanClient;
children: React.ReactNode
}> = ({ client, children }) => {
const [currentUser, setCurrentUser] = useState<User | 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';
// ✅ 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> => {
console.log('🔍 verifyOwnership called, currentUser:', currentUser);
if (!currentUser) {
console.log('❌ No currentUser, returning false');
return false;
}
try {
console.log('🚀 Starting verification for', currentUser.walletType, 'wallet:', currentUser.address);
// Actually check for ENS/Ordinal ownership using core services
const { WalletManager } = await import('@opchan/core');
let hasOwnership = false;
let ensName: string | undefined;
let ordinalDetails: { ordinalId: string; ordinalDetails: string } | undefined;
if (currentUser.walletType === 'ethereum') {
console.log('🔗 Checking ENS ownership for Ethereum address:', currentUser.address);
// Check ENS ownership
const resolvedEns = await WalletManager.resolveENS(currentUser.address);
console.log('📝 ENS resolution result:', resolvedEns);
ensName = resolvedEns || undefined;
hasOwnership = !!ensName;
console.log('✅ ENS hasOwnership:', hasOwnership);
} else if (currentUser.walletType === 'bitcoin') {
console.log('🪙 Checking Ordinal ownership for Bitcoin address:', currentUser.address);
// Check Ordinal ownership
const ordinals = await WalletManager.resolveOperatorOrdinals(currentUser.address);
console.log('📝 Ordinals resolution result:', ordinals);
hasOwnership = !!ordinals && ordinals.length > 0;
if (hasOwnership && ordinals) {
const inscription = ordinals[0];
const detail = inscription.parent_inscription_id || 'Operator badge present';
ordinalDetails = {
ordinalId: inscription.inscription_id,
ordinalDetails: String(detail),
};
}
console.log('✅ Ordinals hasOwnership:', hasOwnership);
}
const newVerificationStatus = hasOwnership
? EVerificationStatus.ENS_ORDINAL_VERIFIED
: EVerificationStatus.WALLET_CONNECTED;
console.log('📊 Setting verification status to:', newVerificationStatus);
const updatedUser = {
...currentUser,
verificationStatus: newVerificationStatus,
ensDetails: ensName ? { ensName } : undefined,
ordinalDetails,
};
setCurrentUser(updatedUser);
await localDatabase.storeUser(updatedUser);
// Also update the user identities cache so UserIdentityService can access ENS details
await localDatabase.upsertUserIdentity(currentUser.address, {
ensName: ensName || undefined,
ordinalDetails,
verificationStatus: newVerificationStatus,
lastUpdated: Date.now(),
});
console.log('✅ Verification completed successfully, hasOwnership:', hasOwnership);
return hasOwnership;
} catch (error) {
console.error('❌ Verification failed:', error);
// Fall back to wallet connected status
const updatedUser = { ...currentUser, verificationStatus: EVerificationStatus.WALLET_CONNECTED };
setCurrentUser(updatedUser);
await localDatabase.storeUser(updatedUser);
return false;
}
}, [currentUser]);
// Hydrate user from LocalDatabase on mount
useEffect(() => {
let mounted = true;
const load = async () => {
try {
const user = await localDatabase.loadUser();
if (mounted && user) {
setCurrentUser(user);
// 🔄 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;
};
}, []); // 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(),
};
setCurrentUser(user);
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]); // Remove currentUser and verifyOwnership dependencies
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(),
};
setCurrentUser(user);
await localDatabase.storeUser(user);
return true;
} catch (e) {
console.error('connectWallet failed', e);
return false;
} finally {
setIsAuthenticating(false);
}
}, [currentUser?.displayPreference, isWalletConnected, connectedAddress, walletType]);
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: messageManager.sendMessage.bind(messageManager),
verifyMessage,
};
}, [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

@ -0,0 +1,133 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { localDatabase, messageManager, ForumActions, OpChanClient, getDataFromCache } from '@opchan/core';
import { transformCell, transformPost, transformComment } from '@opchan/core';
import { useAuth } from './AuthContext';
import { Cell, Post, Comment, UserVerificationStatus } 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<{
client: OpChanClient;
children: React.ReactNode
}> = ({ client, children }) => {
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(() => new ForumActions(), []);
const updateFromCache = useCallback(async () => {
try {
const data = await getDataFromCache(undefined, userVerificationStatus);
setCells(data.cells);
setPosts(data.posts);
setComments(data.comments);
} catch (e) {
console.error('Failed to read cache', e);
}
}, [userVerificationStatus]);
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]);
useEffect(() => {
let unsubHealth: (() => void) | null = null;
let unsubMsg: (() => void) | null = null;
const init = async () => {
try {
// Ensure LocalDatabase is opened before hydrating
if (!localDatabase.getSyncState) {
console.log('📥 Opening LocalDatabase for ForumProvider...');
await localDatabase.open();
}
await updateFromCache();
setIsInitialLoading(false);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to initialize');
setIsInitialLoading(false);
}
// Check initial health status
const initialHealth = messageManager.currentHealth;
const initialReady = messageManager.isReady;
console.log('🔌 ForumContext initial state:', { initialReady, initialHealth });
setIsNetworkConnected(!!initialReady);
unsubHealth = messageManager.onHealthChange((ready: boolean, health: any) => {
console.log('🔌 ForumContext health change:', { ready, health });
setIsNetworkConnected(!!ready);
});
unsubMsg = messageManager.onMessageReceived(async () => {
await updateFromCache();
});
};
init();
return () => {
try { unsubHealth && unsubHealth(); } catch {}
try { unsubMsg && unsubMsg(); } catch {}
};
}, [updateFromCache]);
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

@ -0,0 +1,29 @@
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

@ -0,0 +1,160 @@
import { useCallback, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { DelegationDuration, EVerificationStatus } from '@opchan/core';
export interface AuthActionStates {
isConnecting: boolean;
isVerifying: boolean;
isDelegating: boolean;
isDisconnecting: boolean;
}
export interface AuthActions extends AuthActionStates {
connectWallet: () => Promise<boolean>;
disconnectWallet: () => Promise<boolean>;
verifyWallet: () => Promise<boolean>;
delegateKey: (duration: DelegationDuration) => Promise<boolean>;
clearDelegation: () => Promise<boolean>;
renewDelegation: (duration: DelegationDuration) => Promise<boolean>;
checkVerificationStatus: () => Promise<void>;
}
export function useAuthActions(): AuthActions {
const {
isAuthenticated,
isAuthenticating,
verificationStatus,
connectWallet: baseConnectWallet,
disconnectWallet: baseDisconnectWallet,
verifyOwnership,
delegateKey: baseDelegateKey,
getDelegationStatus,
clearDelegation: baseClearDelegation,
} = useAuth();
const [isConnecting, setIsConnecting] = useState(false);
const [isVerifying, setIsVerifying] = useState(false);
const [isDelegating, setIsDelegating] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
const connectWallet = useCallback(async (): Promise<boolean> => {
if (isAuthenticated) return true;
setIsConnecting(true);
try {
const result = await baseConnectWallet();
return result;
} catch (error) {
console.error('Failed to connect wallet:', error);
return false;
} finally {
setIsConnecting(false);
}
}, [isAuthenticated, baseConnectWallet]);
const disconnectWallet = useCallback(async (): Promise<boolean> => {
if (!isAuthenticated) return true;
setIsDisconnecting(true);
try {
baseDisconnectWallet();
return true;
} catch (error) {
console.error('Failed to disconnect wallet:', error);
return false;
} finally {
setIsDisconnecting(false);
}
}, [isAuthenticated, baseDisconnectWallet]);
const verifyWallet = useCallback(async (): Promise<boolean> => {
console.log('🎯 verifyWallet called, isAuthenticated:', isAuthenticated, 'verificationStatus:', verificationStatus);
if (!isAuthenticated) {
console.log('❌ Not authenticated, returning false');
return false;
}
if (verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED) {
console.log('✅ Already verified, returning true');
return true;
}
console.log('🔄 Setting isVerifying to true and calling verifyOwnership...');
setIsVerifying(true);
try {
const success = await verifyOwnership();
console.log('📊 verifyOwnership result:', success);
return success;
} catch (error) {
console.error('❌ Failed to verify wallet:', error);
return false;
} finally {
console.log('🔄 Setting isVerifying to false');
setIsVerifying(false);
}
}, [isAuthenticated, verificationStatus, verifyOwnership]);
const delegateKey = useCallback(
async (duration: DelegationDuration): Promise<boolean> => {
if (!isAuthenticated) return false;
if (verificationStatus === EVerificationStatus.WALLET_UNCONNECTED) {
return false;
}
setIsDelegating(true);
try {
const success = await baseDelegateKey(duration);
return success;
} catch (error) {
console.error('Failed to delegate key:', error);
return false;
} finally {
setIsDelegating(false);
}
},
[isAuthenticated, verificationStatus, baseDelegateKey]
);
const clearDelegation = useCallback(async (): Promise<boolean> => {
const delegationInfo = await getDelegationStatus();
if (!delegationInfo.isValid) return true;
try {
await baseClearDelegation();
return true;
} catch (error) {
console.error('Failed to clear delegation:', error);
return false;
}
}, [getDelegationStatus, baseClearDelegation]);
const renewDelegation = useCallback(
async (duration: DelegationDuration): Promise<boolean> => {
const cleared = await clearDelegation();
if (!cleared) return false;
return delegateKey(duration);
},
[clearDelegation, delegateKey]
);
const checkVerificationStatus = useCallback(async (): Promise<void> => {
if (!isAuthenticated) return;
// This would refresh verification status - simplified for now
}, [isAuthenticated]);
return {
isConnecting,
isVerifying: isVerifying || isAuthenticating,
isDelegating,
isDisconnecting,
connectWallet,
disconnectWallet,
verifyWallet,
delegateKey,
clearDelegation,
renewDelegation,
checkVerificationStatus,
};
}

View File

@ -0,0 +1,368 @@
import { useCallback } from 'react';
import { useForum } from '../../contexts/ForumContext';
import { useAuth } from '../../contexts/AuthContext';
import { usePermissions } from '../core/usePermissions';
import { Cell, Post, Comment } from '@opchan/core';
export interface ForumActionStates {
isCreatingCell: boolean;
isCreatingPost: boolean;
isCreatingComment: boolean;
isVoting: boolean;
isModerating: boolean;
}
export interface ForumActions extends ForumActionStates {
createCell: (
name: string,
description: string,
icon?: string
) => Promise<Cell | null>;
createPost: (
cellId: string,
title: string,
content: string
) => Promise<Post | null>;
votePost: (postId: string, isUpvote: boolean) => Promise<boolean>;
moderatePost: (
cellId: string,
postId: string,
reason?: string
) => Promise<boolean>;
unmoderatePost: (
cellId: string,
postId: string,
reason?: string
) => Promise<boolean>;
createComment: (postId: string, content: string) => Promise<Comment | null>;
voteComment: (commentId: string, isUpvote: boolean) => Promise<boolean>;
moderateComment: (
cellId: string,
commentId: string,
reason?: string
) => Promise<boolean>;
unmoderateComment: (
cellId: string,
commentId: string,
reason?: string
) => Promise<boolean>;
moderateUser: (
cellId: string,
userAddress: string,
reason?: string
) => Promise<boolean>;
unmoderateUser: (
cellId: string,
userAddress: string,
reason?: string
) => Promise<boolean>;
refreshData: () => Promise<void>;
}
export function useForumActions(): ForumActions {
const { actions, refreshData } = useForum();
const { currentUser } = useAuth();
const permissions = usePermissions();
const createCell = useCallback(
async (
name: string,
description: string,
icon?: string
): Promise<Cell | null> => {
if (!permissions.canCreateCell) {
throw new Error(permissions.createCellReason);
}
if (!name.trim() || !description.trim()) {
throw new Error('Please provide both a name and description for the cell.');
}
try {
const result = await actions.createCell(
{
name,
description,
icon,
currentUser,
isAuthenticated: !!currentUser,
},
async () => {} // updateStateFromCache handled by ForumProvider
);
return result.data || null;
} catch {
throw new Error('Failed to create cell. Please try again.');
}
},
[permissions.canCreateCell, permissions.createCellReason, actions, currentUser]
);
const createPost = useCallback(
async (
cellId: string,
title: string,
content: string
): Promise<Post | null> => {
if (!permissions.canPost) {
throw new Error('You need to verify Ordinal ownership to create posts.');
}
if (!title.trim() || !content.trim()) {
throw new Error('Please provide both a title and content for the post.');
}
try {
const result = await actions.createPost(
{
cellId,
title,
content,
currentUser,
isAuthenticated: !!currentUser,
},
async () => {}
);
return result.data || null;
} catch {
throw new Error('Failed to create post. Please try again.');
}
},
[permissions.canPost, actions, currentUser]
);
const createComment = useCallback(
async (postId: string, content: string): Promise<Comment | null> => {
if (!permissions.canComment) {
throw new Error(permissions.commentReason);
}
if (!content.trim()) {
throw new Error('Please provide content for the comment.');
}
try {
const result = await actions.createComment(
{
postId,
content,
currentUser,
isAuthenticated: !!currentUser,
},
async () => {}
);
return result.data || null;
} catch {
throw new Error('Failed to create comment. Please try again.');
}
},
[permissions.canComment, permissions.commentReason, actions, currentUser]
);
const votePost = useCallback(
async (postId: string, isUpvote: boolean): Promise<boolean> => {
if (!permissions.canVote) {
throw new Error(permissions.voteReason);
}
try {
const result = await actions.vote(
{
targetId: postId,
isUpvote,
currentUser,
isAuthenticated: !!currentUser,
},
async () => {}
);
return result.success;
} catch {
throw new Error('Failed to record your vote. Please try again.');
}
},
[permissions.canVote, permissions.voteReason, actions, currentUser]
);
const voteComment = useCallback(
async (commentId: string, isUpvote: boolean): Promise<boolean> => {
if (!permissions.canVote) {
throw new Error(permissions.voteReason);
}
try {
const result = await actions.vote(
{
targetId: commentId,
isUpvote,
currentUser,
isAuthenticated: !!currentUser,
},
async () => {}
);
return result.success;
} catch {
throw new Error('Failed to record your vote. Please try again.');
}
},
[permissions.canVote, permissions.voteReason, actions, currentUser]
);
// For now, return simple implementations - moderation actions would need cell owner checks
const moderatePost = useCallback(
async (cellId: string, postId: string, reason?: string): Promise<boolean> => {
try {
const result = await actions.moderatePost(
{
cellId,
postId,
reason,
currentUser,
isAuthenticated: !!currentUser,
cellOwner: currentUser?.address || '',
},
async () => {}
);
return result.success;
} catch {
return false;
}
},
[actions, currentUser]
);
const unmoderatePost = useCallback(
async (cellId: string, postId: string, reason?: string): Promise<boolean> => {
try {
const result = await actions.unmoderatePost(
{
cellId,
postId,
reason,
currentUser,
isAuthenticated: !!currentUser,
cellOwner: currentUser?.address || '',
},
async () => {}
);
return result.success;
} catch {
return false;
}
},
[actions, currentUser]
);
const moderateComment = useCallback(
async (cellId: string, commentId: string, reason?: string): Promise<boolean> => {
try {
const result = await actions.moderateComment(
{
cellId,
commentId,
reason,
currentUser,
isAuthenticated: !!currentUser,
cellOwner: currentUser?.address || '',
},
async () => {}
);
return result.success;
} catch {
return false;
}
},
[actions, currentUser]
);
const unmoderateComment = useCallback(
async (cellId: string, commentId: string, reason?: string): Promise<boolean> => {
try {
const result = await actions.unmoderateComment(
{
cellId,
commentId,
reason,
currentUser,
isAuthenticated: !!currentUser,
cellOwner: currentUser?.address || '',
},
async () => {}
);
return result.success;
} catch {
return false;
}
},
[actions, currentUser]
);
const moderateUser = useCallback(
async (cellId: string, userAddress: string, reason?: string): Promise<boolean> => {
try {
const result = await actions.moderateUser(
{
cellId,
userAddress,
reason,
currentUser,
isAuthenticated: !!currentUser,
cellOwner: currentUser?.address || '',
},
async () => {}
);
return result.success;
} catch {
return false;
}
},
[actions, currentUser]
);
const unmoderateUser = useCallback(
async (cellId: string, userAddress: string, reason?: string): Promise<boolean> => {
try {
const result = await actions.unmoderateUser(
{
cellId,
userAddress,
reason,
currentUser,
isAuthenticated: !!currentUser,
cellOwner: currentUser?.address || '',
},
async () => {}
);
return result.success;
} catch {
return false;
}
},
[actions, currentUser]
);
return {
// States - simplified for now
isCreatingCell: false,
isCreatingPost: false,
isCreatingComment: false,
isVoting: false,
isModerating: false,
// Actions
createCell,
createPost,
createComment,
votePost,
voteComment,
moderatePost,
unmoderatePost,
moderateComment,
unmoderateComment,
moderateUser,
unmoderateUser,
refreshData,
};
}

View File

@ -0,0 +1,171 @@
import { useCallback, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { usePermissions } from '../core/usePermissions';
import { EDisplayPreference, localDatabase } from '@opchan/core';
export interface UserActionStates {
isUpdatingProfile: boolean;
isUpdatingCallSign: boolean;
isUpdatingDisplayPreference: boolean;
}
export interface UserActions extends UserActionStates {
updateCallSign: (callSign: string) => Promise<boolean>;
updateDisplayPreference: (preference: EDisplayPreference) => Promise<boolean>;
updateProfile: (updates: {
callSign?: string;
displayPreference?: EDisplayPreference;
}) => Promise<boolean>;
clearCallSign: () => Promise<boolean>;
}
export function useUserActions(): UserActions {
const { currentUser } = useAuth();
const permissions = usePermissions();
const [isUpdatingProfile, setIsUpdatingProfile] = useState(false);
const [isUpdatingCallSign, setIsUpdatingCallSign] = useState(false);
const [isUpdatingDisplayPreference, setIsUpdatingDisplayPreference] =
useState(false);
const updateCallSign = useCallback(
async (callSign: string): Promise<boolean> => {
if (!permissions.canUpdateProfile) {
throw new Error('You need to connect your wallet to update your profile.');
}
if (!currentUser) {
throw new Error('User identity service is not available.');
}
if (!callSign.trim()) {
throw new Error('Call sign cannot be empty.');
}
if (callSign.length < 3 || callSign.length > 20) {
throw new Error('Call sign must be between 3 and 20 characters.');
}
if (!/^[a-zA-Z0-9_-]+$/.test(callSign)) {
throw new Error(
'Call sign can only contain letters, numbers, underscores, and hyphens.'
);
}
setIsUpdatingCallSign(true);
try {
await localDatabase.upsertUserIdentity(currentUser.address, {
callSign,
lastUpdated: Date.now(),
});
return true;
} catch (error) {
console.error('Failed to update call sign:', error);
throw new Error('An error occurred while updating your call sign.');
} finally {
setIsUpdatingCallSign(false);
}
},
[permissions.canUpdateProfile, currentUser]
);
const updateDisplayPreference = useCallback(
async (preference: EDisplayPreference): Promise<boolean> => {
if (!permissions.canUpdateProfile) {
throw new Error('You need to connect your wallet to update your profile.');
}
if (!currentUser) {
throw new Error('User identity service is not available.');
}
setIsUpdatingDisplayPreference(true);
try {
// Persist to central identity store
await localDatabase.upsertUserIdentity(currentUser.address, {
displayPreference: preference,
lastUpdated: Date.now(),
});
// Also persist on the lightweight user record if present
await localDatabase.storeUser({
...currentUser,
displayPreference: preference,
lastChecked: Date.now(),
});
return true;
} catch (error) {
console.error('Failed to update display preference:', error);
throw new Error('An error occurred while updating your display preference.');
} finally {
setIsUpdatingDisplayPreference(false);
}
},
[permissions.canUpdateProfile, currentUser]
);
const updateProfile = useCallback(
async (updates: {
callSign?: string;
displayPreference?: EDisplayPreference;
}): Promise<boolean> => {
if (!permissions.canUpdateProfile) {
throw new Error('You need to connect your wallet to update your profile.');
}
if (!currentUser) {
throw new Error('User identity service is not available.');
}
setIsUpdatingProfile(true);
try {
// Write a consolidated identity update to IndexedDB
await localDatabase.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) {
await localDatabase.storeUser({
...currentUser,
displayPreference: updates.displayPreference,
lastChecked: Date.now(),
} as any);
}
// Also call granular updaters for validation side-effects
if (updates.callSign !== undefined) {
await updateCallSign(updates.callSign);
}
if (updates.displayPreference !== undefined) {
await updateDisplayPreference(updates.displayPreference);
}
return true;
} catch (error) {
console.error('Failed to update profile:', error);
throw new Error('An error occurred while updating your profile.');
} finally {
setIsUpdatingProfile(false);
}
},
[permissions.canUpdateProfile, currentUser, updateCallSign, updateDisplayPreference]
);
const clearCallSign = useCallback(async (): Promise<boolean> => {
return updateCallSign('');
}, [updateCallSign]);
return {
isUpdatingProfile,
isUpdatingCallSign,
isUpdatingDisplayPreference,
updateCallSign,
updateDisplayPreference,
updateProfile,
clearCallSign,
};
}

View File

@ -1,12 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { Bookmark, BookmarkType, Post, Comment } from '@opchan/core';
import { BookmarkService } from '@opchan/core';
import { useAuth } from '@/contexts/useAuth';
import { Bookmark, BookmarkType, Post, Comment, BookmarkService } from '@opchan/core';
import { useAuth } from '../../contexts/AuthContext';
/**
* Hook for managing bookmarks
* Provides bookmark state and operations for the current user
*/
export function useBookmarks() {
const { currentUser } = useAuth();
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
@ -30,7 +25,6 @@ export function useBookmarks() {
}
}, [currentUser?.address]);
// Load user bookmarks when user changes
useEffect(() => {
if (currentUser?.address) {
loadBookmarks();
@ -49,7 +43,7 @@ export function useBookmarks() {
currentUser.address,
cellId
);
await loadBookmarks(); // Refresh the list
await loadBookmarks();
return isBookmarked;
} catch (err) {
setError(
@ -71,7 +65,7 @@ export function useBookmarks() {
currentUser.address,
postId
);
await loadBookmarks(); // Refresh the list
await loadBookmarks();
return isBookmarked;
} catch (err) {
setError(
@ -92,7 +86,7 @@ export function useBookmarks() {
bookmark.type,
bookmark.targetId
);
await loadBookmarks(); // Refresh the list
await loadBookmarks();
}
} catch (err) {
setError(
@ -157,16 +151,11 @@ export function useBookmarks() {
};
}
/**
* Hook for bookmarking a specific post
* Provides bookmark state and toggle function for a single post
*/
export function usePostBookmark(post: Post | null, cellId?: string) {
const { currentUser } = useAuth();
const [isBookmarked, setIsBookmarked] = useState(false);
const [loading, setLoading] = useState(false);
// Check initial bookmark status
useEffect(() => {
if (currentUser?.address && post?.id) {
const bookmarked = BookmarkService.isPostBookmarked(
@ -206,16 +195,11 @@ export function usePostBookmark(post: Post | null, cellId?: string) {
};
}
/**
* Hook for bookmarking a specific comment
* Provides bookmark state and toggle function for a single comment
*/
export function useCommentBookmark(comment: Comment, postId?: string) {
const { currentUser } = useAuth();
const [isBookmarked, setIsBookmarked] = useState(false);
const [loading, setLoading] = useState(false);
// Check initial bookmark status
useEffect(() => {
if (currentUser?.address) {
const bookmarked = BookmarkService.isCommentBookmarked(

View File

@ -1,9 +1,14 @@
import { useMemo } from 'react';
import { useForum } from '@/contexts/useForum';
import { useAuth } from '@/contexts/useAuth';
import { useModeration } from '@/contexts/ModerationContext';
import { Cell, Post, Comment, UserVerificationStatus } from '@opchan/core';
import { EVerificationStatus } from '@opchan/core';
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;
@ -62,10 +67,6 @@ export interface ForumData {
userCreatedComments: Set<string>;
}
/**
* Main forum data hook with reactive updates and computed properties
* This is the primary data source for all forum-related information
*/
export function useForumData(): ForumData {
const {
cells,
@ -81,12 +82,11 @@ export function useForumData(): ForumData {
const { currentUser } = useAuth();
const { showModerated } = useModeration();
// Compute cells with statistics
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 // 7 days
post => Date.now() - post.timestamp < 7 * 24 * 60 * 60 * 1000
);
const uniqueAuthors = new Set(cellPosts.map(post => post.author));
@ -100,46 +100,35 @@ export function useForumData(): ForumData {
});
}, [cells, posts]);
// Helper function to check if user can vote
const canUserVote = useMemo(() => {
if (!currentUser) return false;
return (
currentUser.verificationStatus ===
EVerificationStatus.ENS_ORDINAL_VERIFIED ||
currentUser.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED ||
currentUser.verificationStatus === EVerificationStatus.WALLET_CONNECTED ||
Boolean(currentUser.ensDetails) ||
Boolean(currentUser.ordinalDetails)
);
}, [currentUser]);
// Helper function to check if user can moderate in a cell
const canUserModerate = useMemo(() => {
const moderationMap: Record<string, boolean> = {};
if (!currentUser) return moderationMap;
cells.forEach(cell => {
moderationMap[cell.id] = currentUser.address === cell.signature;
moderationMap[cell.id] = currentUser.address === (cell as unknown as { signature?: string }).signature;
});
return moderationMap;
}, [currentUser, cells]);
// Compute posts with vote status
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,
@ -151,25 +140,20 @@ export function useForumData(): ForumData {
});
}, [posts, currentUser, canUserVote, canUserModerate]);
// Compute comments with vote status
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;
// Find the post to determine cell for moderation
const parentPost = posts.find(post => post.id === comment.postId);
const canModerate = parentPost
? canUserModerate[parentPost.cellId] || false
: false;
return {
...comment,
userUpvoted,
@ -181,150 +165,99 @@ export function useForumData(): ForumData {
});
}, [comments, currentUser, canUserVote, canUserModerate, posts]);
// Organize posts by cell
const postsByCell = useMemo((): Record<string, PostWithVoteStatus[]> => {
const organized: Record<string, PostWithVoteStatus[]> = {};
postsWithVoteStatus.forEach(post => {
if (!organized[post.cellId]) {
organized[post.cellId] = [];
}
const cellPosts = organized[post.cellId];
if (cellPosts) {
cellPosts.push(post);
}
if (!organized[post.cellId]) organized[post.cellId] = [];
organized[post.cellId]!.push(post);
});
// Sort posts within each cell by relevance score or timestamp
Object.keys(organized).forEach(cellId => {
const cellPosts = organized[cellId];
if (cellPosts) {
cellPosts.sort((a, b) => {
if (
a.relevanceScore !== undefined &&
b.relevanceScore !== undefined
) {
return b.relevanceScore - a.relevanceScore;
}
return b.timestamp - a.timestamp;
});
}
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]);
// Organize comments by post
const commentsByPost = useMemo((): Record<
string,
CommentWithVoteStatus[]
> => {
const commentsByPost = useMemo((): Record<string, CommentWithVoteStatus[]> => {
const organized: Record<string, CommentWithVoteStatus[]> = {};
commentsWithVoteStatus.forEach(comment => {
if (!organized[comment.postId]) {
organized[comment.postId] = [];
}
const postComments = organized[comment.postId];
if (postComments) {
postComments.push(comment);
}
if (!organized[comment.postId]) organized[comment.postId] = [];
organized[comment.postId]!.push(comment);
});
// Sort comments within each post by timestamp (oldest first)
Object.keys(organized).forEach(postId => {
const postComments = organized[postId];
if (postComments) {
postComments.sort((a, b) => a.timestamp - b.timestamp);
}
const list = organized[postId]!;
list.sort((a, b) => a.timestamp - b.timestamp);
});
return organized;
}, [commentsWithVoteStatus]);
// User-specific data sets
const userVotedPosts = useMemo(() => {
const votedPosts = new Set<string>();
if (!currentUser) return votedPosts;
const voted = new Set<string>();
if (!currentUser) return voted;
postsWithVoteStatus.forEach(post => {
if (post.userUpvoted || post.userDownvoted) {
votedPosts.add(post.id);
}
if (post.userUpvoted || post.userDownvoted) voted.add(post.id);
});
return votedPosts;
return voted;
}, [postsWithVoteStatus, currentUser]);
const userVotedComments = useMemo(() => {
const votedComments = new Set<string>();
if (!currentUser) return votedComments;
const voted = new Set<string>();
if (!currentUser) return voted;
commentsWithVoteStatus.forEach(comment => {
if (comment.userUpvoted || comment.userDownvoted) {
votedComments.add(comment.id);
}
if (comment.userUpvoted || comment.userDownvoted) voted.add(comment.id);
});
return votedComments;
return voted;
}, [commentsWithVoteStatus, currentUser]);
const userCreatedPosts = useMemo(() => {
const createdPosts = new Set<string>();
if (!currentUser) return createdPosts;
const created = new Set<string>();
if (!currentUser) return created;
posts.forEach(post => {
if (post.author === currentUser.address) {
createdPosts.add(post.id);
}
if (post.author === currentUser.address) created.add(post.id);
});
return createdPosts;
return created;
}, [posts, currentUser]);
const userCreatedComments = useMemo(() => {
const createdComments = new Set<string>();
if (!currentUser) return createdComments;
const created = new Set<string>();
if (!currentUser) return created;
comments.forEach(comment => {
if (comment.author === currentUser.address) {
createdComments.add(comment.id);
}
if (comment.author === currentUser.address) created.add(comment.id);
});
return createdComments;
return created;
}, [comments, currentUser]);
// Filtered data based on moderation settings
const filteredPosts = useMemo(() => {
return showModerated
? postsWithVoteStatus
: postsWithVoteStatus.filter(post => !post.moderated);
: postsWithVoteStatus.filter(p => !p.moderated);
}, [postsWithVoteStatus, showModerated]);
const filteredComments = useMemo(() => {
if (showModerated) return commentsWithVoteStatus;
// Hide moderated comments AND comments whose parent post is moderated
const moderatedPostIds = new Set(
postsWithVoteStatus.filter(p => p.moderated).map(p => p.id)
);
return commentsWithVoteStatus.filter(
comment => !comment.moderated && !moderatedPostIds.has(comment.postId)
c => !c.moderated && !moderatedPostIds.has(c.postId)
);
}, [commentsWithVoteStatus, postsWithVoteStatus, showModerated]);
// Filtered cells with stats based on filtered posts
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 // 7 days
post => Date.now() - post.timestamp < 7 * 24 * 60 * 60 * 1000
);
const uniqueAuthors = new Set(cellPosts.map(post => post.author));
return {
...cell,
postCount: cellPosts.length,
@ -334,55 +267,38 @@ export function useForumData(): ForumData {
});
}, [cells, filteredPosts]);
// Filtered comments organized by post
const filteredCommentsByPost = useMemo((): Record<
string,
CommentWithVoteStatus[]
> => {
const filteredCommentsByPost = useMemo((): Record<string, CommentWithVoteStatus[]> => {
const organized: Record<string, CommentWithVoteStatus[]> = {};
filteredComments.forEach(comment => {
if (!organized[comment.postId]) {
organized[comment.postId] = [];
}
if (!organized[comment.postId]) organized[comment.postId] = [];
organized[comment.postId]!.push(comment);
});
return organized;
}, [filteredComments]);
return {
// Raw data
cells,
posts,
comments,
userVerificationStatus,
// Loading states
isInitialLoading,
isRefreshing,
isNetworkConnected,
error,
// Computed data
cellsWithStats,
postsWithVoteStatus,
commentsWithVoteStatus,
// Filtered data based on moderation settings
filteredPosts,
filteredComments,
filteredCellsWithStats,
filteredCommentsByPost,
// Organized data
postsByCell,
commentsByPost,
// User-specific data
userVotedPosts,
userVotedComments,
userCreatedPosts,
userCreatedComments,
};
}

View File

@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { useAuth } from './useAuth';
import { useAuth } from '../../contexts/AuthContext';
import { useForumData } from './useForumData';
import { EVerificationStatus } from '@opchan/core';
@ -26,9 +26,6 @@ export interface PermissionResult {
reason: string;
}
/**
* Unified permission system with single source of truth for all permission logic
*/
export function usePermissions(): Permission &
PermissionReasons & {
checkPermission: (
@ -39,7 +36,6 @@ export function usePermissions(): Permission &
const { currentUser, verificationStatus } = useAuth();
const { cellsWithStats } = useForumData();
// Single source of truth for all permission logic
const permissions = useMemo((): Permission => {
const isWalletConnected =
verificationStatus === EVerificationStatus.WALLET_CONNECTED;
@ -50,10 +46,9 @@ export function usePermissions(): Permission &
canPost: isWalletConnected || isVerified,
canComment: isWalletConnected || isVerified,
canVote: isWalletConnected || isVerified,
canCreateCell: isVerified, // Only ENS/Ordinal owners
canCreateCell: isVerified,
canModerate: (cellId: string) => {
if (!currentUser || !cellId) return false;
// Check if user is the creator of the cell
const cell = cellsWithStats.find(c => c.id === cellId);
return cell ? cell.author === currentUser.address : false;
},
@ -62,7 +57,6 @@ export function usePermissions(): Permission &
};
}, [currentUser, verificationStatus, cellsWithStats]);
// Single source of truth for permission reasons
const reasons = useMemo((): PermissionReasons => {
if (!currentUser) {
return {
@ -94,9 +88,8 @@ export function usePermissions(): Permission &
: 'Only cell creators can moderate';
},
};
}, [currentUser, verificationStatus, permissions]);
}, [currentUser, permissions]);
// Unified permission checker
const checkPermission = useMemo(() => {
return (action: keyof Permission, cellId?: string): PermissionResult => {
let allowed = false;
@ -107,41 +100,34 @@ export function usePermissions(): Permission &
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';

View File

@ -0,0 +1,133 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useForum } from '../../contexts/ForumContext';
import { useAuth } from '../../contexts/AuthContext';
import { EDisplayPreference, EVerificationStatus, UserIdentityService } from '@opchan/core';
export interface UserDisplayInfo {
displayName: string;
callSign: string | null;
ensName: string | null;
ordinalDetails: string | null;
verificationLevel: EVerificationStatus;
displayPreference: EDisplayPreference | null;
isLoading: boolean;
error: string | null;
}
export function useUserDisplay(address: string): UserDisplayInfo {
const { userVerificationStatus } = useForum();
const { currentUser } = useAuth();
const [displayInfo, setDisplayInfo] = useState<UserDisplayInfo>({
displayName: `${address.slice(0, 6)}...${address.slice(-4)}`,
callSign: null,
ensName: null,
ordinalDetails: null,
verificationLevel: EVerificationStatus.WALLET_UNCONNECTED,
displayPreference: null,
isLoading: true,
error: null,
});
const [refreshTrigger, setRefreshTrigger] = useState(0);
const isLoadingRef = useRef(false);
// Check if this is the current user to get their direct ENS details
const isCurrentUser = currentUser && currentUser.address.toLowerCase() === address.toLowerCase();
const verificationInfo = useMemo(() => {
if (isCurrentUser && currentUser) {
// Use current user's direct ENS details
return {
isVerified: currentUser.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED,
ensName: currentUser.ensDetails?.ensName || null,
verificationStatus: currentUser.verificationStatus,
};
}
return (
userVerificationStatus[address] || {
isVerified: false,
ensName: null,
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
}
);
}, [userVerificationStatus, address, isCurrentUser, currentUser]);
useEffect(() => {
const getUserDisplayInfo = async () => {
if (!address) {
setDisplayInfo(prev => ({
...prev,
isLoading: false,
error: 'No address provided',
}));
return;
}
// Prevent multiple simultaneous calls
if (isLoadingRef.current) {
return;
}
isLoadingRef.current = true;
try {
// Use UserIdentityService to get proper identity and display name from central store
const { UserIdentityService } = await import('@opchan/core');
const userIdentityService = new UserIdentityService(null as any); // MessageService not needed for display
// For current user, ensure their ENS details are in the database first
if (isCurrentUser && currentUser?.ensDetails?.ensName) {
const { localDatabase } = await import('@opchan/core');
await localDatabase.upsertUserIdentity(address, {
ensName: currentUser.ensDetails.ensName,
verificationStatus: currentUser.verificationStatus,
lastUpdated: Date.now(),
});
}
// Get user identity which includes ENS name, callSign, etc. from central store
const identity = await userIdentityService.getUserIdentity(address);
// Use the service's getDisplayName method which has the correct priority logic
const displayName = userIdentityService.getDisplayName(address);
setDisplayInfo({
displayName,
callSign: identity?.callSign || null,
ensName: identity?.ensName || verificationInfo.ensName || null,
ordinalDetails: identity?.ordinalDetails ?
`${identity.ordinalDetails.ordinalId}` : null,
verificationLevel: identity?.verificationStatus ||
verificationInfo.verificationStatus ||
EVerificationStatus.WALLET_UNCONNECTED,
displayPreference: identity?.displayPreference || null,
isLoading: false,
error: null,
});
} catch (error) {
console.error('useUserDisplay: Failed to get user display info:', error);
setDisplayInfo({
displayName: `${address.slice(0, 6)}...${address.slice(-4)}`,
callSign: null,
ensName: null,
ordinalDetails: null,
verificationLevel: EVerificationStatus.WALLET_UNCONNECTED,
displayPreference: null,
isLoading: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
isLoadingRef.current = false;
}
};
getUserDisplayInfo();
// Cleanup function to reset loading ref
return () => {
isLoadingRef.current = false;
};
}, [address, refreshTrigger, verificationInfo.verificationStatus]);
return displayInfo;
}

View File

@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useForumData, CellWithStats } from '@/hooks/core/useForumData';
import { useAuth } from '@/hooks/core/useAuth';
import { useForumData, CellWithStats } from '../core/useForumData';
import { useAuth } from '../../contexts/AuthContext';
import { EVerificationStatus } from '@opchan/core';
export interface CellData extends CellWithStats {
@ -18,9 +18,6 @@ export interface CellData extends CellWithStats {
canPost: boolean;
}
/**
* Hook for getting a specific cell with its posts and permissions
*/
export function useCell(cellId: string | undefined): CellData | null {
const { cellsWithStats, postsByCell, commentsByPost } = useForumData();
const { currentUser } = useAuth();
@ -33,7 +30,6 @@ export function useCell(cellId: string | undefined): CellData | null {
const cellPosts = postsByCell[cellId] || [];
// Transform posts to include comment count
const posts = cellPosts.map(post => ({
id: post.id,
title: post.title,
@ -44,9 +40,8 @@ export function useCell(cellId: string | undefined): CellData | null {
commentCount: (commentsByPost[post.id] || []).length,
}));
// Check user permissions
const isUserAdmin = currentUser
? currentUser.address === cell.signature
? currentUser.address === (cell as unknown as { signature?: string }).signature
: false;
const canModerate = isUserAdmin;
const canPost = currentUser

View File

@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { useForumData, PostWithVoteStatus } from '@/hooks/core/useForumData';
import { useAuth } from '@/hooks/core/useAuth';
import { useModeration } from '@/contexts/ModerationContext';
import { useForumData, PostWithVoteStatus } from '../core/useForumData';
import { useAuth } from '../../contexts/AuthContext';
import { useModeration } from '../../contexts/ModerationContext';
export interface CellPostsOptions {
includeModerated?: boolean;
@ -16,9 +16,6 @@ export interface CellPostsData {
isLoading: boolean;
}
/**
* Hook for getting posts for a specific cell with filtering and sorting
*/
export function useCellPosts(
cellId: string | undefined,
options: CellPostsOptions = {}
@ -49,7 +46,7 @@ export function useCellPosts(
if (!includeModerated) {
const cell = cellsWithStats.find(c => c.id === cellId);
const isUserAdmin =
currentUser && cell && currentUser.address === cell.signature;
Boolean(currentUser && cell && currentUser.address === (cell as unknown as { signature?: string }).signature);
if (!isUserAdmin) {
posts = posts.filter(post => !post.moderated);

View File

@ -3,8 +3,8 @@ import {
useForumData,
PostWithVoteStatus,
CommentWithVoteStatus,
} from '@/hooks/core/useForumData';
import { useAuth } from '@/hooks/core/useAuth';
} from '../core/useForumData';
import { useAuth } from '../../contexts/AuthContext';
export interface PostData extends PostWithVoteStatus {
cell: {
@ -17,9 +17,6 @@ export interface PostData extends PostWithVoteStatus {
isUserAuthor: boolean;
}
/**
* Hook for getting a specific post with its comments and metadata
*/
export function usePost(postId: string | undefined): PostData | null {
const { postsWithVoteStatus, commentsByPost, cellsWithStats } =
useForumData();

View File

@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { useForumData, CommentWithVoteStatus } from '@/hooks/core/useForumData';
import { useAuth } from '@/hooks/core/useAuth';
import { useModeration } from '@/contexts/ModerationContext';
import { useForumData, CommentWithVoteStatus } from '../core/useForumData';
import { useAuth } from '../../contexts/AuthContext';
import { useModeration } from '../../contexts/ModerationContext';
export interface PostCommentsOptions {
includeModerated?: boolean;
@ -16,9 +16,6 @@ export interface PostCommentsData {
isLoading: boolean;
}
/**
* Hook for getting comments for a specific post with filtering and sorting
*/
export function usePostComments(
postId: string | undefined,
options: PostCommentsOptions = {}
@ -55,7 +52,7 @@ export function usePostComments(
const post = postsWithVoteStatus.find(p => p.id === postId);
const cell = post ? cellsWithStats.find(c => c.id === post.cellId) : null;
const isUserAdmin =
currentUser && cell && currentUser.address === cell.signature;
Boolean(currentUser && cell && currentUser.address === (cell as unknown as { signature?: string }).signature);
if (!isUserAdmin) {
comments = comments.filter(comment => !comment.moderated);

View File

@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useForumData } from '@/hooks/core/useForumData';
import { useAuth } from '@/hooks/core/useAuth';
import { useForumData } from '../core/useForumData';
import { useAuth } from '../../contexts/AuthContext';
export interface UserVoteData {
// Vote status for specific items
@ -22,9 +22,6 @@ export interface UserVoteData {
upvoteRatio: number;
}
/**
* Hook for getting user's voting status and history
*/
export function useUserVotes(userAddress?: string): UserVoteData {
const { postsWithVoteStatus, commentsWithVoteStatus } = useForumData();
const { currentUser } = useAuth();

View File

@ -0,0 +1,80 @@
// Public hooks surface: aggregator and focused derived hooks
// Aggregator hook
export { useForumApi } from './useForum';
// Core hooks
export { useForumData } from './core/useForumData';
export { usePermissions } from './core/usePermissions';
export { useUserDisplay } from './core/useUserDisplay';
export { useBookmarks, usePostBookmark, useCommentBookmark } from './core/useBookmarks';
// Action hooks
export { useForumActions } from './actions/useForumActions';
export { useAuthActions } from './actions/useAuthActions';
export { useUserActions } from './actions/useUserActions';
// Derived hooks
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
export { useWakuHealth, useWakuReady, useWakuHealthStatus } from './utilities/useWakuHealth';
export { useDelegation } from './utilities/useDelegation';
export { useMessageSigning } from './utilities/useMessageSigning';
export { usePending, usePendingVote } from './utilities/usePending';
export { useWallet } from './utilities/useWallet';
export { useNetworkStatus } from './utilities/useNetworkStatus';
export { useForumSelectors } from './utilities/useForumSelectors';
// 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';
export type {
ForumActionStates,
ForumActions,
} from './actions/useForumActions';
export type {
AuthActionStates,
AuthActions,
} from './actions/useAuthActions';
export type {
UserActionStates,
UserActions,
} from './actions/useUserActions';
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

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

View File

@ -0,0 +1,363 @@
import { useMemo } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useForum as useForumContext } from '../contexts/ForumContext';
import { usePermissions } from './core/usePermissions';
import { useForumData } from './core/useForumData';
import { useNetworkStatus } from './utilities/useNetworkStatus';
import { useBookmarks } from './core/useBookmarks';
import { useForumActions } from './actions/useForumActions';
import { useUserActions } from './actions/useUserActions';
import { useDelegation } from './utilities/useDelegation';
import { useMessageSigning } from './utilities/useMessageSigning';
import { useForumSelectors } from './utilities/useForumSelectors';
import { localDatabase } from '@opchan/core';
import type {
Cell,
Comment,
Post,
Bookmark,
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 { currentUser, verificationStatus, connectWallet, disconnectWallet, verifyOwnership } = useAuth();
const {
refreshData,
} = useForumContext();
const forumData: ForumData = useForumData();
const permissions = usePermissions();
const network = useNetworkStatus();
const { bookmarks, bookmarkPost, bookmarkComment } = useBookmarks();
const forumActions = useForumActions();
const userActions = useUserActions();
const { delegationStatus, createDelegation, clearDelegation } = useDelegation();
const { signMessage, verifyMessage } = useMessageSigning();
const selectors = useForumSelectors(forumData);
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 }) => {
return userActions.updateProfile(updates);
},
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 }) => {
return forumActions.createCell(input.name, input.description, input.icon);
},
createPost: async (input: { cellId: string; title: string; content: string }) => {
return forumActions.createPost(input.cellId, input.title, input.content);
},
createComment: async (input: { postId: string; content: string }) => {
return forumActions.createComment(input.postId, input.content);
},
vote: async (input: { targetId: string; isUpvote: boolean }) => {
// useForumActions.vote handles both posts and comments by id
if (!input.targetId) return false;
// Try post vote first, then comment vote if needed
try {
const ok = await forumActions.votePost(input.targetId, input.isUpvote);
if (ok) return true;
} catch {}
try {
return await forumActions.voteComment(input.targetId, input.isUpvote);
} catch {
return false;
}
},
moderate: {
post: async (cellId: string, postId: string, reason?: string) => {
try { return await forumActions.moderatePost(cellId, postId, reason); } catch { return false; }
},
unpost: async (cellId: string, postId: string, reason?: string) => {
try { return await forumActions.unmoderatePost(cellId, postId, reason); } catch { return false; }
},
comment: async (cellId: string, commentId: string, reason?: string) => {
try { return await forumActions.moderateComment(cellId, commentId, reason); } catch { return false; }
},
uncomment: async (cellId: string, commentId: string, reason?: string) => {
try { return await forumActions.unmoderateComment(cellId, commentId, reason); } catch { return false; }
},
user: async (cellId: string, userAddress: string, reason?: string) => {
try { return await forumActions.moderateUser(cellId, userAddress, reason); } catch { return false; }
},
unuser: async (cellId: string, userAddress: string, reason?: string) => {
try { return await forumActions.unmoderateUser(cellId, userAddress, reason); } catch { return false; }
},
},
togglePostBookmark: async (post: Post, cellId?: string) => bookmarkPost(post, cellId),
toggleCommentBookmark: async (comment: Comment, postId?: string) => bookmarkComment(comment, postId),
refresh: async () => { await refreshData(); },
pending: {
isPending: (id?: string) => {
return id ? localDatabase.isPending(id) : false;
},
isVotePending: (targetId?: string) => {
if (!targetId || !currentUser?.address) return false;
return Object.values(localDatabase.cache.votes).some(v => {
return (
v.targetId === targetId &&
v.author === currentUser.address &&
localDatabase.isPending(v.id)
);
});
},
onChange: (cb: () => void) => {
return localDatabase.onPendingChange(cb);
},
},
};
}, [forumData, bookmarks, forumActions, bookmarkPost, bookmarkComment, refreshData, currentUser?.address]);
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

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

View File

@ -1,20 +1,14 @@
import { useCallback, useContext, useState, useEffect } from 'react';
import { AuthContext } from '@/contexts/AuthContext';
import { useCallback, useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { DelegationDuration } from '@opchan/core';
export const useDelegation = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useDelegation must be used within an AuthProvider');
}
const {
delegateKey: contextDelegateKey,
getDelegationStatus: contextGetDelegationStatus,
clearDelegation: contextClearDelegation,
isAuthenticating,
} = context;
} = useAuth();
const createDelegation = useCallback(
async (duration?: DelegationDuration): Promise<boolean> => {
@ -23,8 +17,8 @@ export const useDelegation = () => {
[contextDelegateKey]
);
const clearDelegation = useCallback((): void => {
contextClearDelegation();
const clearDelegation = useCallback(async (): Promise<void> => {
await contextClearDelegation();
}, [contextClearDelegation]);
const [delegationStatus, setDelegationStatus] = useState<{

View File

@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { ForumData } from '@/hooks/core/useForumData';
import { ForumData } from '../core/useForumData';
import { Cell, Post, Comment } from '@opchan/core';
import { EVerificationStatus } from '@opchan/core';
@ -108,7 +108,7 @@ export function useForumSelectors(forumData: ForumData): ForumSelectors {
const selectCellsByOwner = useMemo(() => {
return (ownerAddress: string): Cell[] => {
return cells.filter(cell => cell.signature === ownerAddress);
return cells.filter(cell => (cell as unknown as { signature?: string }).signature === ownerAddress);
};
}, [cells]);
@ -127,25 +127,24 @@ export function useForumSelectors(forumData: ForumData): ForumSelectors {
const selectPostsByVoteScore = useMemo(() => {
return (minScore: number = 0): Post[] => {
return posts.filter(post => post.voteScore >= minScore);
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[] => {
// 7 days default
const cutoff = Date.now() - timeframe;
return posts
.filter(post => post.timestamp > cutoff)
.sort((a, b) => {
// Sort by relevance score if available, otherwise by vote score
if (
a.relevanceScore !== undefined &&
b.relevanceScore !== undefined
) {
return b.relevanceScore - a.relevanceScore;
}
return b.voteScore - a.voteScore;
return ((b as unknown as { voteScore?: number }).voteScore || 0) -
((a as unknown as { voteScore?: number }).voteScore || 0);
});
};
}, [posts]);
@ -223,7 +222,6 @@ export function useForumSelectors(forumData: ForumData): ForumSelectors {
const selectActiveUsers = useMemo(() => {
return (timeframe: number = 7 * 24 * 60 * 60 * 1000): string[] => {
// 7 days default
const cutoff = Date.now() - timeframe;
const activeUsers = new Set<string>();

View File

@ -1,30 +1,24 @@
import { useCallback, useContext } from 'react';
import { AuthContext } from '@/contexts/AuthContext';
import { useCallback } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { OpchanMessage } from '@opchan/core';
export const useMessageSigning = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useMessageSigning must be used within an AuthProvider');
}
const {
signMessage: contextSignMessage,
verifyMessage: contextVerifyMessage,
getDelegationStatus,
} = context;
} = useAuth();
const signMessage = useCallback(
async (message: OpchanMessage): Promise<OpchanMessage | null> => {
async (message: OpchanMessage): Promise<void> => {
// Check if we have a valid delegation before attempting to sign
const delegationStatus = await getDelegationStatus();
if (!delegationStatus.isValid) {
console.warn('No valid delegation found. Cannot sign message.');
return null;
return;
}
return contextSignMessage(message);
await contextSignMessage(message);
},
[contextSignMessage, getDelegationStatus]
);

View File

@ -1,7 +1,6 @@
import { useMemo, useState, useEffect } from 'react';
import { useForum } from '@/contexts/useForum';
import { useAuth } from '@/hooks/core/useAuth';
import { useAuth as useAuthContext } from '@/contexts/useAuth';
import { useForum } from '../../contexts/ForumContext';
import { useAuth } from '../../contexts/AuthContext';
import { DelegationFullStatus } from '@opchan/core';
export interface NetworkHealth {
@ -56,15 +55,11 @@ export interface NetworkStatusData {
getRecommendedActions: () => string[];
}
/**
* Hook for monitoring network status and connection health
*/
export function useNetworkStatus(): NetworkStatusData {
const { isNetworkConnected, isInitialLoading, isRefreshing, error } =
useForum();
const { isAuthenticated, currentUser } = useAuth();
const { getDelegationStatus } = useAuthContext();
const { isAuthenticated, currentUser, getDelegationStatus } = useAuth();
const [delegationInfo, setDelegationInfo] =
useState<DelegationFullStatus | null>(null);

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { localDatabase } from '@opchan/core';
import { useAuth } from '@/contexts/useAuth';
import { useAuth } from '../../contexts/AuthContext';
export function usePending(id: string | undefined) {
const [isPending, setIsPending] = useState<boolean>(

View File

@ -9,10 +9,6 @@ export interface WakuHealthState {
connectionStatus: 'connecting' | 'connected' | 'disconnected' | 'error';
}
/**
* Hook for monitoring Waku network health and connection status
* Provides real-time updates on network state and health
*/
export function useWakuHealth(): WakuHealthState {
const [isReady, setIsReady] = useState(false);
const [health, setHealth] = useState<HealthStatus>(HealthStatus.Unhealthy);
@ -26,7 +22,6 @@ export function useWakuHealth(): WakuHealthState {
setIsReady(ready);
setHealth(currentHealth);
// Update connection status based on health
if (ready) {
setConnectionStatus('connected');
} else if (currentHealth === HealthStatus.Unhealthy) {
@ -39,17 +34,14 @@ export function useWakuHealth(): WakuHealthState {
);
useEffect(() => {
// Check if messageManager is initialized
try {
const currentHealth = messageManager.currentHealth;
const currentHealth = messageManager.currentHealth ?? HealthStatus.Unhealthy;
const currentReady = messageManager.isReady;
setIsInitialized(true);
updateHealth(currentReady, currentHealth);
// Subscribe to health changes
const unsubscribe = messageManager.onHealthChange(updateHealth);
return unsubscribe;
} catch (error) {
console.error('Failed to initialize Waku health monitoring:', error);
@ -67,18 +59,11 @@ export function useWakuHealth(): WakuHealthState {
};
}
/**
* Hook that provides a simple boolean indicating if Waku is ready for use
* Useful for conditional rendering and loading states
*/
export function useWakuReady(): boolean {
const { isReady } = useWakuHealth();
return isReady;
}
/**
* Hook that provides health status with human-readable descriptions
*/
export function useWakuHealthStatus() {
const { isReady, health, connectionStatus } = useWakuHealth();

View File

@ -1,21 +1,15 @@
import { useCallback, useContext } from 'react';
import { AuthContext } from '@/contexts/AuthContext';
import { useCallback } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { modal } from '@reown/appkit/react';
export const useWallet = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useWallet must be used within an AuthProvider');
}
const {
currentUser,
isAuthenticated,
verificationStatus,
connectWallet: contextConnectWallet,
disconnectWallet: contextDisconnectWallet,
} = context;
} = useAuth();
const connect = useCallback(async (): Promise<boolean> => {
return contextConnectWallet();

View File

@ -0,0 +1,25 @@
// Providers only (context hooks are internal)
export * from './provider/OpChanProvider';
export { AuthProvider, useAuth } from './contexts/AuthContext';
export { ForumProvider, useForum as useForumContext } from './contexts/ForumContext';
export { ModerationProvider, useModeration } from './contexts/ModerationContext';
// Public hooks and types
export * from './hooks';
export { useForumApi as useForum } from './hooks/useForum';
// Re-export core types for convenience
export type {
User,
EVerificationStatus,
EDisplayPreference,
DelegationDuration,
Cell,
Post,
Comment,
Bookmark,
BookmarkType,
RelevanceScoreDetails,
} from '@opchan/core';

View File

@ -0,0 +1,63 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { OpChanClient } from '@opchan/core';
import { localDatabase, messageManager } from '@opchan/core';
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,
debug: !!debug,
isDevelopment: !!debug,
});
clientRef.current = client;
// Open local DB early for warm cache
await localDatabase.open().catch(console.error);
if (!cancelled) setIsReady(true);
};
init();
return () => {
cancelled = true;
};
}, [ordiscanApiKey, debug]);
const providers = useMemo(() => {
if (!isReady || !clientRef.current) return null;
return (
<AuthProvider client={clientRef.current}>
<ModerationProvider>
<ForumProvider client={clientRef.current}>{children}</ForumProvider>
</ModerationProvider>
</AuthProvider>
);
}, [isReady, children]);
return providers || null;
};

View File

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"module": "esnext",
"jsx": "react-jsx",
"declaration": false,
"emitDeclarationOnly": false,
"declarationMap": false,
"skipLibCheck": true
},
"include": ["src/**/*"]
}

View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"jsx": "react-jsx",
"skipLibCheck": true,
"noEmit": true
},
"include": ["src/**/*"]
}

View File

@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"module": "commonjs",
"jsx": "react-jsx",
"declaration": true,
"emitDeclarationOnly": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}

View File

@ -13,7 +13,7 @@
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx",
"removeComments": true,
"noEmitOnError": true,
"incremental": true,