mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-02 12:53:10 +00:00
wip
This commit is contained in:
parent
b897dca588
commit
cc29a30bd9
6
.gitignore
vendored
6
.gitignore
vendored
@ -36,4 +36,8 @@ coverage/
|
||||
.nyc_output/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
|
||||
.giga
|
||||
.cursor
|
||||
.cursorrules
|
||||
17424
app/package-lock.json
generated
17424
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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": {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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…
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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',
|
||||
})}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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 };
|
||||
@ -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';
|
||||
@ -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';
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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?.();
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -16,7 +16,7 @@ export default defineConfig(() => ({
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['buffer'],
|
||||
include: ['buffer', '@opchan/core', '@opchan/hooks'],
|
||||
},
|
||||
build: {
|
||||
target: 'es2022',
|
||||
|
||||
8362
package-lock.json
generated
8362
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -4,7 +4,8 @@
|
||||
"description": "Browser-based Forum Library over Waku",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
"packages/*",
|
||||
"app"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build --workspaces",
|
||||
|
||||
@ -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",
|
||||
|
||||
47
packages/core/src/client/OpChanClient.ts
Normal file
47
packages/core/src/client/OpChanClient.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
42
packages/react/package.json
Normal file
42
packages/react/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
324
packages/react/src/contexts/AuthContext.tsx
Normal file
324
packages/react/src/contexts/AuthContext.tsx
Normal 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 };
|
||||
|
||||
|
||||
133
packages/react/src/contexts/ForumContext.tsx
Normal file
133
packages/react/src/contexts/ForumContext.tsx
Normal 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 };
|
||||
|
||||
|
||||
29
packages/react/src/contexts/ModerationContext.tsx
Normal file
29
packages/react/src/contexts/ModerationContext.tsx
Normal 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 };
|
||||
|
||||
|
||||
160
packages/react/src/hooks/actions/useAuthActions.ts
Normal file
160
packages/react/src/hooks/actions/useAuthActions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
368
packages/react/src/hooks/actions/useForumActions.ts
Normal file
368
packages/react/src/hooks/actions/useForumActions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
171
packages/react/src/hooks/actions/useUserActions.ts
Normal file
171
packages/react/src/hooks/actions/useUserActions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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(
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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';
|
||||
133
packages/react/src/hooks/core/useUserDisplay.ts
Normal file
133
packages/react/src/hooks/core/useUserDisplay.ts
Normal 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;
|
||||
}
|
||||
@ -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
|
||||
@ -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);
|
||||
@ -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();
|
||||
@ -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);
|
||||
@ -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();
|
||||
80
packages/react/src/hooks/index.ts
Normal file
80
packages/react/src/hooks/index.ts
Normal 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
|
||||
|
||||
|
||||
3
packages/react/src/hooks/useAuth.ts
Normal file
3
packages/react/src/hooks/useAuth.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
|
||||
363
packages/react/src/hooks/useForum.ts
Normal file
363
packages/react/src/hooks/useForum.ts
Normal 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]);
|
||||
}
|
||||
|
||||
|
||||
3
packages/react/src/hooks/useModeration.ts
Normal file
3
packages/react/src/hooks/useModeration.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { useModeration } from '../contexts/ModerationContext';
|
||||
|
||||
|
||||
@ -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<{
|
||||
@ -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>();
|
||||
|
||||
@ -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]
|
||||
);
|
||||
@ -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);
|
||||
|
||||
@ -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>(
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
25
packages/react/src/index.ts
Normal file
25
packages/react/src/index.ts
Normal 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';
|
||||
|
||||
|
||||
63
packages/react/src/provider/OpChanProvider.tsx
Normal file
63
packages/react/src/provider/OpChanProvider.tsx
Normal 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;
|
||||
};
|
||||
|
||||
|
||||
16
packages/react/tsconfig.build.json
Normal file
16
packages/react/tsconfig.build.json
Normal 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/**/*"]
|
||||
}
|
||||
|
||||
|
||||
13
packages/react/tsconfig.json
Normal file
13
packages/react/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
|
||||
15
packages/react/tsconfig.types.json
Normal file
15
packages/react/tsconfig.types.json
Normal 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/**/*"]
|
||||
}
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "react-jsx",
|
||||
"removeComments": true,
|
||||
"noEmitOnError": true,
|
||||
"incremental": true,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user