mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-05 22:33:07 +00:00
wip
This commit is contained in:
parent
b897dca588
commit
cc29a30bd9
4
.gitignore
vendored
4
.gitignore
vendored
@ -37,3 +37,7 @@ coverage/
|
|||||||
|
|
||||||
# TypeScript cache
|
# 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",
|
"name": "opchan",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"description": "A decentralized forum built on Waku.",
|
"description": "A decentralized forum built on Waku.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -15,7 +15,8 @@
|
|||||||
"test:ui": "vitest --ui"
|
"test:ui": "vitest --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opchan/core": "workspace:*",
|
"@opchan/react": "file:../packages/react",
|
||||||
|
"@opchan/core": "1.0.0",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@noble/ed25519": "^2.2.3",
|
"@noble/ed25519": "^2.2.3",
|
||||||
"@noble/hashes": "^1.8.0",
|
"@noble/hashes": "^1.8.0",
|
||||||
@ -78,8 +79,8 @@
|
|||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"vaul": "^0.9.3",
|
"vaul": "^0.9.3",
|
||||||
"viem": "^2.37.1",
|
"viem": "^2.37.6",
|
||||||
"wagmi": "^2.16.1",
|
"wagmi": "^2.17.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -16,9 +16,6 @@ import { Toaster as Sonner } from '@/components/ui/sonner';
|
|||||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
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 CellPage from './pages/CellPage';
|
||||||
import PostPage from './pages/PostPage';
|
import PostPage from './pages/PostPage';
|
||||||
import NotFound from './pages/NotFound';
|
import NotFound from './pages/NotFound';
|
||||||
@ -26,41 +23,28 @@ import Dashboard from './pages/Dashboard';
|
|||||||
import Index from './pages/Index';
|
import Index from './pages/Index';
|
||||||
import ProfilePage from './pages/ProfilePage';
|
import ProfilePage from './pages/ProfilePage';
|
||||||
import BookmarksPage from './pages/BookmarksPage';
|
import BookmarksPage from './pages/BookmarksPage';
|
||||||
import { appkitConfig, config } from '@opchan/core';
|
|
||||||
import { WagmiProvider } from 'wagmi';
|
|
||||||
import { AppKitProvider } from '@reown/appkit/react';
|
|
||||||
|
|
||||||
// Create a client
|
// Create a client
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
<WagmiProvider config={config}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<Router>
|
||||||
<AppKitProvider {...appkitConfig}>
|
<TooltipProvider>
|
||||||
<Router>
|
<Toaster />
|
||||||
<AuthProvider>
|
<Sonner />
|
||||||
<ForumProvider>
|
<Routes>
|
||||||
<ModerationProvider>
|
<Route path="/" element={<Dashboard />} />
|
||||||
<TooltipProvider>
|
<Route path="/cells" element={<Index />} />
|
||||||
<Toaster />
|
<Route path="/cell/:cellId" element={<CellPage />} />
|
||||||
<Sonner />
|
<Route path="/post/:postId" element={<PostPage />} />
|
||||||
<Routes>
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||||
<Route path="/cells" element={<Index />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
<Route path="/cell/:cellId" element={<CellPage />} />
|
</Routes>
|
||||||
<Route path="/post/:postId" element={<PostPage />} />
|
</TooltipProvider>
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
</Router>
|
||||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
</QueryClientProvider>
|
||||||
<Route path="*" element={<NotFound />} />
|
|
||||||
</Routes>
|
|
||||||
</TooltipProvider>
|
|
||||||
</ModerationProvider>
|
|
||||||
</ForumProvider>
|
|
||||||
</AuthProvider>
|
|
||||||
</Router>
|
|
||||||
</AppKitProvider>
|
|
||||||
</QueryClientProvider>
|
|
||||||
</WagmiProvider>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@ -26,7 +26,7 @@ import { RelevanceIndicator } from './ui/relevance-indicator';
|
|||||||
import { ModerationToggle } from './ui/moderation-toggle';
|
import { ModerationToggle } from './ui/moderation-toggle';
|
||||||
import { sortCells, SortOption } from '@opchan/core';
|
import { sortCells, SortOption } from '@opchan/core';
|
||||||
import { Cell } from '@opchan/core';
|
import { Cell } from '@opchan/core';
|
||||||
import { usePending } from '@/hooks/usePending';
|
import { useForum } from '@opchan/react';
|
||||||
import { ShareButton } from './ui/ShareButton';
|
import { ShareButton } from './ui/ShareButton';
|
||||||
|
|
||||||
// Empty State Component
|
// Empty State Component
|
||||||
@ -76,7 +76,8 @@ const EmptyState: React.FC<{ canCreateCell: boolean }> = ({
|
|||||||
|
|
||||||
// Separate component to properly use hooks
|
// Separate component to properly use hooks
|
||||||
const CellItem: React.FC<{ cell: Cell }> = ({ cell }) => {
|
const CellItem: React.FC<{ cell: Cell }> = ({ cell }) => {
|
||||||
const pending = usePending(cell.id);
|
const { content } = useForum();
|
||||||
|
const isPending = content.pending.isPending(cell.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={`/cell/${cell.id}`} className="group block board-card">
|
<Link to={`/cell/${cell.id}`} className="group block board-card">
|
||||||
@ -103,7 +104,7 @@ const CellItem: React.FC<{ cell: Cell }> = ({ cell }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{pending.isPending && (
|
{isPending && (
|
||||||
<div className="mb-2">
|
<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">
|
<span className="px-2 py-0.5 rounded-sm bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 text-xs">
|
||||||
syncing…
|
syncing…
|
||||||
|
|||||||
@ -2,17 +2,11 @@ import React from 'react';
|
|||||||
import { ArrowUp, ArrowDown, Clock, Shield, UserX } from 'lucide-react';
|
import { ArrowUp, ArrowDown, Clock, Shield, UserX } from 'lucide-react';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { Comment } from '@opchan/core';
|
import { Comment } from '@opchan/core';
|
||||||
import {
|
|
||||||
useForumActions,
|
|
||||||
usePermissions,
|
|
||||||
useUserVotes,
|
|
||||||
useCommentBookmark,
|
|
||||||
} from '@/hooks';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { BookmarkButton } from '@/components/ui/bookmark-button';
|
import { BookmarkButton } from '@/components/ui/bookmark-button';
|
||||||
import { AuthorDisplay } from '@/components/ui/author-display';
|
import { AuthorDisplay } from '@/components/ui/author-display';
|
||||||
import { MarkdownRenderer } from '@/components/ui/markdown-renderer';
|
import { MarkdownRenderer } from '@/components/ui/markdown-renderer';
|
||||||
import { usePending, usePendingVote } from '@/hooks/usePending';
|
import { useForum } from '@opchan/react';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@ -32,7 +26,8 @@ interface CommentCardProps {
|
|||||||
|
|
||||||
// Extracted child component to respect Rules of Hooks
|
// Extracted child component to respect Rules of Hooks
|
||||||
const PendingBadge: React.FC<{ id: string }> = ({ id }) => {
|
const PendingBadge: React.FC<{ id: string }> = ({ id }) => {
|
||||||
const { isPending } = usePending(id);
|
const { content } = useForum();
|
||||||
|
const isPending = content.pending.isPending(id);
|
||||||
if (!isPending) return null;
|
if (!isPending) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -53,27 +48,40 @@ const CommentCard: React.FC<CommentCardProps> = ({
|
|||||||
onUnmoderateComment,
|
onUnmoderateComment,
|
||||||
onModerateUser,
|
onModerateUser,
|
||||||
}) => {
|
}) => {
|
||||||
const { voteComment, isVoting } = useForumActions();
|
const forum = useForum();
|
||||||
const { canVote } = usePermissions();
|
const { content, permissions } = forum;
|
||||||
const userVotes = useUserVotes();
|
|
||||||
const {
|
|
||||||
isBookmarked,
|
|
||||||
loading: bookmarkLoading,
|
|
||||||
toggleBookmark,
|
|
||||||
} = useCommentBookmark(comment, postId);
|
|
||||||
|
|
||||||
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) => {
|
const handleVoteComment = async (isUpvote: boolean) => {
|
||||||
await voteComment(comment.id, isUpvote);
|
await content.vote({ targetId: comment.id, isUpvote });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBookmark = async () => {
|
const handleBookmark = async () => {
|
||||||
await toggleBookmark();
|
setBookmarkLoading(true);
|
||||||
};
|
try {
|
||||||
|
await content.toggleCommentBookmark(comment, postId);
|
||||||
const getCommentVoteType = () => {
|
} finally {
|
||||||
return userVotes.getCommentVoteType(comment.id);
|
setBookmarkLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -82,12 +90,12 @@ const CommentCard: React.FC<CommentCardProps> = ({
|
|||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<button
|
<button
|
||||||
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${
|
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${
|
||||||
getCommentVoteType() === 'upvote' ? 'text-cyber-accent' : ''
|
userUpvoted ? 'text-cyber-accent' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleVoteComment(true)}
|
onClick={() => handleVoteComment(true)}
|
||||||
disabled={!canVote || isVoting}
|
disabled={!permissions.canVote}
|
||||||
title={
|
title={
|
||||||
canVote ? 'Upvote comment' : 'Connect wallet and verify to vote'
|
permissions.canVote ? 'Upvote comment' : permissions.reasons.vote
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ArrowUp className="w-3 h-3" />
|
<ArrowUp className="w-3 h-3" />
|
||||||
@ -95,17 +103,19 @@ const CommentCard: React.FC<CommentCardProps> = ({
|
|||||||
<span className="text-sm font-bold">{comment.voteScore}</span>
|
<span className="text-sm font-bold">{comment.voteScore}</span>
|
||||||
<button
|
<button
|
||||||
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${
|
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${
|
||||||
getCommentVoteType() === 'downvote' ? 'text-cyber-accent' : ''
|
userDownvoted ? 'text-cyber-accent' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleVoteComment(false)}
|
onClick={() => handleVoteComment(false)}
|
||||||
disabled={!canVote || isVoting}
|
disabled={!permissions.canVote}
|
||||||
title={
|
title={
|
||||||
canVote ? 'Downvote comment' : 'Connect wallet and verify to vote'
|
permissions.canVote
|
||||||
|
? 'Downvote comment'
|
||||||
|
: permissions.reasons.vote
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ArrowDown className="w-3 h-3" />
|
<ArrowDown className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
{commentVotePending.isPending && (
|
{commentVotePending && (
|
||||||
<span className="mt-1 text-[10px] text-yellow-500">syncing…</span>
|
<span className="mt-1 text-[10px] text-yellow-500">syncing…</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -93,11 +93,11 @@ const FeedSidebar: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{verificationStatus === EVerificationStatus.WALLET_CONNECTED && (
|
{verificationStatus === EVerificationStatus.WALLET_CONNECTED && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
<CheckCircle className="w-3 h-3 inline mr-1" />
|
<CheckCircle className="w-3 h-3 inline mr-1" />
|
||||||
Connected. You can post, comment, and vote.
|
Connected. You can post, comment, and vote.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { useAuth, useWakuHealthStatus } from '@/hooks';
|
import { useForum } from '@opchan/react';
|
||||||
import { useAuth as useAuthContext } from '@/contexts/useAuth';
|
|
||||||
import { EVerificationStatus } from '@opchan/core';
|
import { EVerificationStatus } from '@opchan/core';
|
||||||
import { useForum } from '@/contexts/useForum';
|
|
||||||
import { localDatabase } from '@opchan/core';
|
import { localDatabase } from '@opchan/core';
|
||||||
import { DelegationFullStatus } from '@opchan/core';
|
// Removed unused import
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
@ -52,14 +50,10 @@ import { useUserDisplay } from '@/hooks';
|
|||||||
import { WakuHealthDot } from '@/components/ui/waku-health-indicator';
|
import { WakuHealthDot } from '@/components/ui/waku-health-indicator';
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
const { verificationStatus } = useAuth();
|
const forum = useForum();
|
||||||
const { getDelegationStatus } = useAuthContext();
|
const { user, network } = forum;
|
||||||
const [delegationInfo, setDelegationInfo] =
|
|
||||||
useState<DelegationFullStatus | null>(null);
|
|
||||||
const wakuHealth = useWakuHealthStatus();
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const forum = useForum();
|
|
||||||
|
|
||||||
// Use AppKit hooks for multi-chain support
|
// Use AppKit hooks for multi-chain support
|
||||||
const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' });
|
const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' });
|
||||||
@ -70,22 +64,27 @@ const Header = () => {
|
|||||||
const isBitcoinConnected = bitcoinAccount.isConnected;
|
const isBitcoinConnected = bitcoinAccount.isConnected;
|
||||||
const isEthereumConnected = ethereumAccount.isConnected;
|
const isEthereumConnected = ethereumAccount.isConnected;
|
||||||
const isConnected = isBitcoinConnected || isEthereumConnected;
|
const isConnected = isBitcoinConnected || isEthereumConnected;
|
||||||
const address = isConnected
|
|
||||||
? isBitcoinConnected
|
// Use currentUser address (which has ENS details) instead of raw AppKit address
|
||||||
? bitcoinAccount.address
|
const address =
|
||||||
: ethereumAccount.address
|
user.address ||
|
||||||
: undefined;
|
(isConnected
|
||||||
|
? isBitcoinConnected
|
||||||
|
? bitcoinAccount.address
|
||||||
|
: ethereumAccount.address
|
||||||
|
: undefined);
|
||||||
|
|
||||||
const [walletWizardOpen, setWalletWizardOpen] = useState(false);
|
const [walletWizardOpen, setWalletWizardOpen] = useState(false);
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
// ✅ Get display name from enhanced hook
|
// ✅ Use UserIdentityService via useUserDisplay hook for centralized display logic
|
||||||
const { displayName } = useUserDisplay(address || '');
|
const { displayName, ensName, verificationLevel } = useUserDisplay(
|
||||||
|
address || ''
|
||||||
|
);
|
||||||
|
|
||||||
// Load delegation status
|
// ✅ Removed console.log to prevent infinite loop spam
|
||||||
React.useEffect(() => {
|
|
||||||
getDelegationStatus().then(setDelegationInfo).catch(console.error);
|
// Delegation info is available directly from user.delegation
|
||||||
}, [getDelegationStatus]);
|
|
||||||
|
|
||||||
// Use LocalDatabase to persist wizard state across navigation
|
// Use LocalDatabase to persist wizard state across navigation
|
||||||
const getHasShownWizard = async (): Promise<boolean> => {
|
const getHasShownWizard = async (): Promise<boolean> => {
|
||||||
@ -126,6 +125,7 @@ const Header = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDisconnect = async () => {
|
const handleDisconnect = async () => {
|
||||||
|
await user.disconnect();
|
||||||
await disconnect();
|
await disconnect();
|
||||||
await setHasShownWizard(false); // Reset so wizard can show again on next connection
|
await setHasShownWizard(false); // Reset so wizard can show again on next connection
|
||||||
toast({
|
toast({
|
||||||
@ -154,15 +154,18 @@ const Header = () => {
|
|||||||
const getStatusIcon = () => {
|
const getStatusIcon = () => {
|
||||||
if (!isConnected) return <CircleSlash className="w-4 h-4" />;
|
if (!isConnected) return <CircleSlash className="w-4 h-4" />;
|
||||||
|
|
||||||
|
// Use verification status from user slice
|
||||||
if (
|
if (
|
||||||
verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED &&
|
user.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED &&
|
||||||
delegationInfo?.isValid
|
user.delegation.isValid
|
||||||
) {
|
) {
|
||||||
return <CheckCircle className="w-4 h-4" />;
|
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" />;
|
return <AlertTriangle className="w-4 h-4" />;
|
||||||
} else if (
|
} else if (
|
||||||
verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED
|
user.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED
|
||||||
) {
|
) {
|
||||||
return <Key className="w-4 h-4" />;
|
return <Key className="w-4 h-4" />;
|
||||||
} else {
|
} 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">
|
<div className="flex items-center space-x-2 px-3 py-1 bg-cyber-muted/20 rounded-full border border-cyber-muted/30">
|
||||||
<WakuHealthDot />
|
<WakuHealthDot />
|
||||||
<span className="text-xs font-mono text-cyber-neutral">
|
<span className="text-xs font-mono text-cyber-neutral">
|
||||||
{wakuHealth.statusMessage}
|
{network.statusMessage}
|
||||||
</span>
|
</span>
|
||||||
{forum.lastSync && (
|
{network.isConnected && (
|
||||||
<div className="flex items-center space-x-1 text-xs text-cyber-neutral/70">
|
<div className="flex items-center space-x-1 text-xs text-cyber-neutral/70">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
<span>
|
<span>
|
||||||
{new Date(forum.lastSync).toLocaleTimeString([], {
|
{new Date().toLocaleTimeString([], {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
})}
|
})}
|
||||||
@ -222,11 +225,11 @@ const Header = () => {
|
|||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`font-mono text-xs border-0 ${
|
className={`font-mono text-xs border-0 ${
|
||||||
verificationStatus ===
|
user.verificationStatus ===
|
||||||
EVerificationStatus.ENS_ORDINAL_VERIFIED &&
|
EVerificationStatus.ENS_ORDINAL_VERIFIED &&
|
||||||
delegationInfo?.isValid
|
user.delegation.isValid
|
||||||
? 'bg-green-500/20 text-green-400 border-green-500/30'
|
? 'bg-green-500/20 text-green-400 border-green-500/30'
|
||||||
: verificationStatus ===
|
: user.verificationStatus ===
|
||||||
EVerificationStatus.ENS_ORDINAL_VERIFIED
|
EVerificationStatus.ENS_ORDINAL_VERIFIED
|
||||||
? 'bg-orange-500/20 text-orange-400 border-orange-500/30'
|
? 'bg-orange-500/20 text-orange-400 border-orange-500/30'
|
||||||
: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'
|
: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'
|
||||||
@ -234,11 +237,13 @@ const Header = () => {
|
|||||||
>
|
>
|
||||||
{getStatusIcon()}
|
{getStatusIcon()}
|
||||||
<span className="ml-1">
|
<span className="ml-1">
|
||||||
{verificationStatus === EVerificationStatus.WALLET_UNCONNECTED
|
{user.verificationStatus ===
|
||||||
|
EVerificationStatus.WALLET_UNCONNECTED
|
||||||
? 'CONNECT'
|
? 'CONNECT'
|
||||||
: delegationInfo?.isValid
|
: user.delegation.isValid
|
||||||
? 'READY'
|
? 'READY'
|
||||||
: verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED
|
: user.verificationStatus ===
|
||||||
|
EVerificationStatus.ENS_ORDINAL_VERIFIED
|
||||||
? 'EXPIRED'
|
? 'EXPIRED'
|
||||||
: 'DELEGATE'}
|
: 'DELEGATE'}
|
||||||
</span>
|
</span>
|
||||||
@ -470,10 +475,10 @@ const Header = () => {
|
|||||||
<div className="px-4 py-3 border-t border-cyber-muted/20">
|
<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">
|
<div className="flex items-center space-x-2 text-xs text-cyber-neutral">
|
||||||
<WakuHealthDot />
|
<WakuHealthDot />
|
||||||
<span>{wakuHealth.statusMessage}</span>
|
<span>{network.statusMessage}</span>
|
||||||
{forum.lastSync && (
|
{network.isConnected && (
|
||||||
<span className="ml-auto">
|
<span className="ml-auto">
|
||||||
{new Date(forum.lastSync).toLocaleTimeString([], {
|
{new Date().toLocaleTimeString([], {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -3,18 +3,12 @@ import { Link } from 'react-router-dom';
|
|||||||
import { ArrowUp, ArrowDown, MessageSquare } from 'lucide-react';
|
import { ArrowUp, ArrowDown, MessageSquare } from 'lucide-react';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { Post } from '@opchan/core';
|
import { Post } from '@opchan/core';
|
||||||
import {
|
// Removed unused imports
|
||||||
useForumActions,
|
|
||||||
usePermissions,
|
|
||||||
useUserVotes,
|
|
||||||
useForumData,
|
|
||||||
usePostBookmark,
|
|
||||||
} from '@/hooks';
|
|
||||||
import { RelevanceIndicator } from '@/components/ui/relevance-indicator';
|
import { RelevanceIndicator } from '@/components/ui/relevance-indicator';
|
||||||
import { AuthorDisplay } from '@/components/ui/author-display';
|
import { AuthorDisplay } from '@/components/ui/author-display';
|
||||||
import { BookmarkButton } from '@/components/ui/bookmark-button';
|
import { BookmarkButton } from '@/components/ui/bookmark-button';
|
||||||
import { LinkRenderer } from '@/components/ui/link-renderer';
|
import { LinkRenderer } from '@/components/ui/link-renderer';
|
||||||
import { usePending, usePendingVote } from '@/hooks/usePending';
|
import { useForum } from '@opchan/react';
|
||||||
import { ShareButton } from '@/components/ui/ShareButton';
|
import { ShareButton } from '@/components/ui/ShareButton';
|
||||||
|
|
||||||
interface PostCardProps {
|
interface PostCardProps {
|
||||||
@ -23,32 +17,36 @@ interface PostCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
|
const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
|
||||||
const { cellsWithStats } = useForumData();
|
const forum = useForum();
|
||||||
const { votePost, isVoting } = useForumActions();
|
const { content, permissions } = forum;
|
||||||
const { canVote } = usePermissions();
|
|
||||||
const userVotes = useUserVotes();
|
|
||||||
const {
|
|
||||||
isBookmarked,
|
|
||||||
loading: bookmarkLoading,
|
|
||||||
toggleBookmark,
|
|
||||||
} = usePostBookmark(post, post.cellId);
|
|
||||||
|
|
||||||
// ✅ Get pre-computed cell data
|
// Get cell data from content
|
||||||
const cell = cellsWithStats.find(c => c.id === post.cellId);
|
const cell = content.cells.find(c => c.id === post.cellId);
|
||||||
const cellName = cell?.name || 'unknown';
|
const cellName = cell?.name || 'unknown';
|
||||||
|
|
||||||
// ✅ Use pre-computed vote data (assuming post comes from useForumData)
|
// Use pre-computed vote data
|
||||||
const score =
|
const score =
|
||||||
'voteScore' in post
|
'voteScore' in post
|
||||||
? (post.voteScore as number)
|
? (post.voteScore as number)
|
||||||
: post.upvotes.length - post.downvotes.length;
|
: post.upvotes.length - post.downvotes.length;
|
||||||
const { isPending } = usePending(post.id);
|
|
||||||
const votePending = usePendingVote(post.id);
|
|
||||||
|
|
||||||
// ✅ Get user vote status from hook
|
// Use library pending API
|
||||||
const userVoteType = userVotes.getPostVoteType(post.id);
|
const isPending = content.pending.isPending(post.id);
|
||||||
const userUpvoted = userVoteType === 'upvote';
|
const votePending = content.pending.isVotePending(post.id);
|
||||||
const userDownvoted = userVoteType === 'downvote';
|
|
||||||
|
// 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)
|
// ✅ Content truncation (simple presentation logic is OK)
|
||||||
const contentPreview =
|
const contentPreview =
|
||||||
@ -58,8 +56,7 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
|
|||||||
|
|
||||||
const handleVote = async (e: React.MouseEvent, isUpvote: boolean) => {
|
const handleVote = async (e: React.MouseEvent, isUpvote: boolean) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// ✅ All validation and permission checking handled in hook
|
await content.vote({ targetId: post.id, isUpvote });
|
||||||
await votePost(post.id, isUpvote);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBookmark = async (e?: React.MouseEvent) => {
|
const handleBookmark = async (e?: React.MouseEvent) => {
|
||||||
@ -67,7 +64,12 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
await toggleBookmark();
|
setBookmarkLoading(true);
|
||||||
|
try {
|
||||||
|
await content.togglePostBookmark(post, post.cellId);
|
||||||
|
} finally {
|
||||||
|
setBookmarkLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -82,8 +84,8 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
|
|||||||
: 'text-cyber-neutral hover:text-cyber-accent'
|
: 'text-cyber-neutral hover:text-cyber-accent'
|
||||||
}`}
|
}`}
|
||||||
onClick={e => handleVote(e, true)}
|
onClick={e => handleVote(e, true)}
|
||||||
disabled={!canVote || isVoting}
|
disabled={!permissions.canVote}
|
||||||
title={canVote ? 'Upvote' : 'Connect wallet and verify to vote'}
|
title={permissions.canVote ? 'Upvote' : permissions.reasons.vote}
|
||||||
>
|
>
|
||||||
<ArrowUp className="w-5 h-5" />
|
<ArrowUp className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
@ -107,12 +109,12 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
|
|||||||
: 'text-cyber-neutral hover:text-blue-400'
|
: 'text-cyber-neutral hover:text-blue-400'
|
||||||
}`}
|
}`}
|
||||||
onClick={e => handleVote(e, false)}
|
onClick={e => handleVote(e, false)}
|
||||||
disabled={!canVote || isVoting}
|
disabled={!permissions.canVote}
|
||||||
title={canVote ? 'Downvote' : 'Connect wallet and verify to vote'}
|
title={permissions.canVote ? 'Downvote' : permissions.reasons.vote}
|
||||||
>
|
>
|
||||||
<ArrowDown className="w-5 h-5" />
|
<ArrowDown className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
{votePending.isPending && (
|
{votePending && (
|
||||||
<span className="mt-1 text-[10px] text-yellow-400">syncing…</span>
|
<span className="mt-1 text-[10px] text-yellow-400">syncing…</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,13 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link, useParams, useNavigate } from 'react-router-dom';
|
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import { usePost, usePostComments } from '@/hooks';
|
||||||
usePost,
|
|
||||||
usePostComments,
|
|
||||||
useForumActions,
|
|
||||||
usePermissions,
|
|
||||||
useUserVotes,
|
|
||||||
usePostBookmark,
|
|
||||||
} from '@/hooks';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
//
|
//
|
||||||
// import ResizableTextarea from '@/components/ui/resizable-textarea';
|
// import ResizableTextarea from '@/components/ui/resizable-textarea';
|
||||||
@ -28,36 +21,30 @@ import { AuthorDisplay } from './ui/author-display';
|
|||||||
import { BookmarkButton } from './ui/bookmark-button';
|
import { BookmarkButton } from './ui/bookmark-button';
|
||||||
import { MarkdownRenderer } from './ui/markdown-renderer';
|
import { MarkdownRenderer } from './ui/markdown-renderer';
|
||||||
import CommentCard from './CommentCard';
|
import CommentCard from './CommentCard';
|
||||||
import { usePending, usePendingVote } from '@/hooks/usePending';
|
import { useForum } from '@opchan/react';
|
||||||
import { ShareButton } from './ui/ShareButton';
|
import { ShareButton } from './ui/ShareButton';
|
||||||
|
|
||||||
const PostDetail = () => {
|
const PostDetail = () => {
|
||||||
const { postId } = useParams<{ postId: string }>();
|
const { postId } = useParams<{ postId: string }>();
|
||||||
const navigate = useNavigate();
|
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 post = usePost(postId);
|
||||||
const comments = usePostComments(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
|
// Use library pending API
|
||||||
const postPending = usePending(post?.id);
|
const postPending = content.pending.isPending(post?.id);
|
||||||
const postVotePending = usePendingVote(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('');
|
const [newComment, setNewComment] = useState('');
|
||||||
|
|
||||||
@ -97,8 +84,8 @@ const PostDetail = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newComment.trim()) return;
|
if (!newComment.trim()) return;
|
||||||
|
|
||||||
// ✅ All validation handled in hook
|
// Use aggregated content API
|
||||||
const result = await createComment(postId, newComment);
|
const result = await content.createComment({ postId, content: newComment });
|
||||||
if (result) {
|
if (result) {
|
||||||
setNewComment('');
|
setNewComment('');
|
||||||
}
|
}
|
||||||
@ -107,18 +94,18 @@ const PostDetail = () => {
|
|||||||
// Handle keyboard shortcuts
|
// Handle keyboard shortcuts
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
// Enter inserts newline by default. Send on Ctrl+Enter or Shift+Enter.
|
// 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) {
|
if (isSendCombo) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!isCreatingComment && newComment.trim()) {
|
if (newComment.trim()) {
|
||||||
handleCreateComment(e as React.FormEvent);
|
handleCreateComment(e as React.FormEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVotePost = async (isUpvote: boolean) => {
|
const handleVotePost = async (isUpvote: boolean) => {
|
||||||
// ✅ Permission checking handled in hook
|
await content.vote({ targetId: post.id, isUpvote });
|
||||||
await votePost(post.id, isUpvote);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBookmark = async (e?: React.MouseEvent) => {
|
const handleBookmark = async (e?: React.MouseEvent) => {
|
||||||
@ -126,35 +113,39 @@ const PostDetail = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
await toggleBookmark();
|
setBookmarkLoading(true);
|
||||||
|
try {
|
||||||
|
await content.togglePostBookmark(post, post.cellId);
|
||||||
|
} finally {
|
||||||
|
setBookmarkLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ✅ Get vote status from hooks
|
// Get vote status from post data
|
||||||
const postVoteType = userVotes.getPostVoteType(post.id);
|
const isPostUpvoted =
|
||||||
const isPostUpvoted = postVoteType === 'upvote';
|
(post as unknown as { userUpvoted?: boolean }).userUpvoted || false;
|
||||||
const isPostDownvoted = postVoteType === 'downvote';
|
const isPostDownvoted =
|
||||||
|
(post as unknown as { userDownvoted?: boolean }).userDownvoted || false;
|
||||||
|
|
||||||
const handleModerateComment = async (commentId: string) => {
|
const handleModerateComment = async (commentId: string) => {
|
||||||
const reason =
|
const reason =
|
||||||
window.prompt('Enter a reason for moderation (optional):') || undefined;
|
window.prompt('Enter a reason for moderation (optional):') || undefined;
|
||||||
if (!cell) return;
|
if (!cell) return;
|
||||||
// ✅ All validation handled in hook
|
await content.moderate.comment(cell.id, commentId, reason);
|
||||||
await moderateComment(cell.id, commentId, reason);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUnmoderateComment = async (commentId: string) => {
|
const handleUnmoderateComment = async (commentId: string) => {
|
||||||
const reason =
|
const reason =
|
||||||
window.prompt('Optional note for unmoderation?') || undefined;
|
window.prompt('Optional note for unmoderation?') || undefined;
|
||||||
if (!cell) return;
|
if (!cell) return;
|
||||||
await unmoderateComment(cell.id, commentId, reason);
|
await content.moderate.uncomment(cell.id, commentId, reason);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModerateUser = async (userAddress: string) => {
|
const handleModerateUser = async (userAddress: string) => {
|
||||||
const reason =
|
const reason =
|
||||||
window.prompt('Reason for moderating this user? (optional)') || undefined;
|
window.prompt('Reason for moderating this user? (optional)') || undefined;
|
||||||
if (!cell) return;
|
if (!cell) return;
|
||||||
// ✅ All validation handled in hook
|
await content.moderate.user(cell.id, userAddress, reason);
|
||||||
await moderateUser(cell.id, userAddress, reason);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -178,9 +169,9 @@ const PostDetail = () => {
|
|||||||
isPostUpvoted ? 'text-primary' : ''
|
isPostUpvoted ? 'text-primary' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleVotePost(true)}
|
onClick={() => handleVotePost(true)}
|
||||||
disabled={!canVote || isVoting}
|
disabled={!permissions.canVote}
|
||||||
title={
|
title={
|
||||||
canVote ? 'Upvote post' : 'Connect wallet and verify to vote'
|
permissions.canVote ? 'Upvote post' : permissions.reasons.vote
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ArrowUp className="w-4 h-4" />
|
<ArrowUp className="w-4 h-4" />
|
||||||
@ -191,16 +182,16 @@ const PostDetail = () => {
|
|||||||
isPostDownvoted ? 'text-primary' : ''
|
isPostDownvoted ? 'text-primary' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleVotePost(false)}
|
onClick={() => handleVotePost(false)}
|
||||||
disabled={!canVote || isVoting}
|
disabled={!permissions.canVote}
|
||||||
title={
|
title={
|
||||||
canVote
|
permissions.canVote
|
||||||
? 'Downvote post'
|
? 'Downvote post'
|
||||||
: 'Connect wallet and verify to vote'
|
: permissions.reasons.vote
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ArrowDown className="w-4 h-4" />
|
<ArrowDown className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
{postVotePending.isPending && (
|
{postVotePending && (
|
||||||
<span className="mt-1 text-[10px] text-yellow-500">
|
<span className="mt-1 text-[10px] text-yellow-500">
|
||||||
syncing…
|
syncing…
|
||||||
</span>
|
</span>
|
||||||
@ -238,7 +229,7 @@ const PostDetail = () => {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{postPending.isPending && (
|
{postPending && (
|
||||||
<>
|
<>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span className="px-2 py-0.5 rounded-sm bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Comment Form */}
|
{/* Comment Form */}
|
||||||
{canComment && (
|
{permissions.canComment && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<form onSubmit={handleCreateComment} onKeyDown={handleKeyDown}>
|
<form onSubmit={handleCreateComment} onKeyDown={handleKeyDown}>
|
||||||
<h2 className="text-sm font-bold mb-2 flex items-center gap-1">
|
<h2 className="text-sm font-bold mb-2 flex items-center gap-1">
|
||||||
@ -284,7 +275,7 @@ const PostDetail = () => {
|
|||||||
placeholder="What are your thoughts?"
|
placeholder="What are your thoughts?"
|
||||||
value={newComment}
|
value={newComment}
|
||||||
onChange={setNewComment}
|
onChange={setNewComment}
|
||||||
disabled={isCreatingComment}
|
disabled={false}
|
||||||
minHeight={100}
|
minHeight={100}
|
||||||
initialHeight={140}
|
initialHeight={140}
|
||||||
maxHeight={600}
|
maxHeight={600}
|
||||||
@ -292,27 +283,18 @@ const PostDetail = () => {
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!canComment || isCreatingComment}
|
disabled={!permissions.canComment}
|
||||||
className="bg-cyber-accent hover:bg-cyber-accent/80"
|
className="bg-cyber-accent hover:bg-cyber-accent/80"
|
||||||
>
|
>
|
||||||
{isCreatingComment ? (
|
<Send className="w-4 h-4 mr-2" />
|
||||||
<>
|
Post Comment
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Posting...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Send className="w-4 h-4 mr-2" />
|
|
||||||
Post Comment
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!canComment && (
|
{!permissions.canComment && (
|
||||||
<div className="mb-6 p-4 border border-cyber-muted rounded-sm bg-cyber-muted/20 text-center">
|
<div className="mb-6 p-4 border border-cyber-muted rounded-sm bg-cyber-muted/20 text-center">
|
||||||
<p className="text-sm mb-3">
|
<p className="text-sm mb-3">
|
||||||
Connect wallet and verify Ordinal ownership to comment
|
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" />
|
<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>
|
<h3 className="text-lg font-bold mb-2">No comments yet</h3>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{canComment
|
{permissions.canComment
|
||||||
? 'Be the first to share your thoughts!'
|
? 'Be the first to share your thoughts!'
|
||||||
: 'Connect your wallet to join the conversation.'}
|
: 'Connect your wallet to join the conversation.'}
|
||||||
</p>
|
</p>
|
||||||
@ -347,7 +329,7 @@ const PostDetail = () => {
|
|||||||
comment={comment}
|
comment={comment}
|
||||||
postId={postId}
|
postId={postId}
|
||||||
cellId={cell?.id}
|
cellId={cell?.id}
|
||||||
canModerate={canModerate(cell?.id || '')}
|
canModerate={permissions.canModerate(cell?.id || '')}
|
||||||
onModerateComment={handleModerateComment}
|
onModerateComment={handleModerateComment}
|
||||||
onUnmoderateComment={handleUnmoderateComment}
|
onUnmoderateComment={handleUnmoderateComment}
|
||||||
onModerateUser={handleModerateUser}
|
onModerateUser={handleModerateUser}
|
||||||
|
|||||||
@ -127,7 +127,8 @@ const PostList = () => {
|
|||||||
// Handle keyboard shortcuts
|
// Handle keyboard shortcuts
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
// Enter inserts newline by default. Send on Ctrl+Enter or Shift+Enter.
|
// 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) {
|
if (isSendCombo) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!isCreatingPost && newPostContent.trim() && newPostTitle.trim()) {
|
if (!isCreatingPost && newPostContent.trim() && newPostTitle.trim()) {
|
||||||
@ -232,7 +233,9 @@ const PostList = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground mt-1 mb-2">
|
<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 />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
@ -251,7 +254,6 @@ const PostList = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{!canPost && !currentUser && (
|
{!canPost && !currentUser && (
|
||||||
<div className="section-spacing content-card-sm text-center">
|
<div className="section-spacing content-card-sm text-center">
|
||||||
<p className="text-sm mb-3">
|
<p className="text-sm mb-3">
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Button } from './button';
|
import { Button } from './button';
|
||||||
import { useAuth, useAuthActions } from '@/hooks';
|
import { useAuth, useAuthActions } from '@opchan/react';
|
||||||
import { useAuth as useAuthContext } from '@/contexts/useAuth';
|
|
||||||
import { CheckCircle, AlertCircle, Trash2 } from 'lucide-react';
|
import { CheckCircle, AlertCircle, Trash2 } from 'lucide-react';
|
||||||
import { DelegationDuration, DelegationFullStatus } from '@opchan/core';
|
import { DelegationDuration, DelegationFullStatus } from '@opchan/core';
|
||||||
|
|
||||||
@ -18,8 +17,7 @@ export function DelegationStep({
|
|||||||
isLoading,
|
isLoading,
|
||||||
setIsLoading,
|
setIsLoading,
|
||||||
}: DelegationStepProps) {
|
}: DelegationStepProps) {
|
||||||
const { currentUser, isAuthenticating } = useAuth();
|
const { currentUser, isAuthenticating, getDelegationStatus } = useAuth();
|
||||||
const { getDelegationStatus } = useAuthContext();
|
|
||||||
const [delegationInfo, setDelegationInfo] =
|
const [delegationInfo, setDelegationInfo] =
|
||||||
useState<DelegationFullStatus | null>(null);
|
useState<DelegationFullStatus | null>(null);
|
||||||
const { delegateKey, clearDelegation } = useAuthActions();
|
const { delegateKey, clearDelegation } = useAuthActions();
|
||||||
@ -216,7 +214,9 @@ export function DelegationStep({
|
|||||||
const ok = await clearDelegation();
|
const ok = await clearDelegation();
|
||||||
if (ok) {
|
if (ok) {
|
||||||
// Refresh status so UI immediately reflects cleared state
|
// Refresh status so UI immediately reflects cleared state
|
||||||
getDelegationStatus().then(setDelegationInfo).catch(console.error);
|
getDelegationStatus()
|
||||||
|
.then(setDelegationInfo)
|
||||||
|
.catch(console.error);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@ -62,7 +62,10 @@ export const MarkdownInput: React.FC<MarkdownInputProps> = ({
|
|||||||
|
|
||||||
<TabsContent value="preview">
|
<TabsContent value="preview">
|
||||||
<div className="p-3 border rounded-sm bg-card">
|
<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>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@ -71,5 +74,3 @@ export const MarkdownInput: React.FC<MarkdownInputProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default MarkdownInput;
|
export default MarkdownInput;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -11,9 +11,12 @@ interface MarkdownRendererProps {
|
|||||||
/**
|
/**
|
||||||
* Renders sanitized Markdown with GFM support.
|
* 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.)
|
// Extend sanitize schema to allow common markdown elements (headings, lists, code, tables, etc.)
|
||||||
const schema: any = {
|
const schema: typeof defaultSchema = {
|
||||||
...defaultSchema,
|
...defaultSchema,
|
||||||
tagNames: [
|
tagNames: [
|
||||||
...(defaultSchema.tagNames || []),
|
...(defaultSchema.tagNames || []),
|
||||||
@ -57,15 +60,15 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, cla
|
|||||||
['alt'],
|
['alt'],
|
||||||
['title'],
|
['title'],
|
||||||
],
|
],
|
||||||
code: [
|
code: [...(defaultSchema.attributes?.code || []), ['className']],
|
||||||
...(defaultSchema.attributes?.code || []),
|
|
||||||
['className'],
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[[rehypeSanitize, schema]]}>
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[[rehypeSanitize, schema]]}
|
||||||
|
>
|
||||||
{content || ''}
|
{content || ''}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
@ -73,5 +76,3 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, cla
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default MarkdownRenderer;
|
export default MarkdownRenderer;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
import { useModeration } from '@/contexts/ModerationContext';
|
import { useModeration } from '@opchan/react';
|
||||||
import { usePermissions } from '@/hooks/core/usePermissions';
|
import { usePermissions, useForumData } from '@opchan/react';
|
||||||
import { useForumData } from '@/hooks/core/useForumData';
|
|
||||||
|
|
||||||
export function ModerationToggle() {
|
export function ModerationToggle() {
|
||||||
const { showModerated, toggleShowModerated } = useModeration();
|
const { showModerated, toggleShowModerated } = useModeration();
|
||||||
|
|||||||
@ -4,11 +4,12 @@ import { Resizable } from 're-resizable';
|
|||||||
import { cn } from '@opchan/core';
|
import { cn } from '@opchan/core';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
type ResizableTextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
type ResizableTextareaProps =
|
||||||
initialHeight?: number;
|
React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||||
minHeight?: number;
|
initialHeight?: number;
|
||||||
maxHeight?: number;
|
minHeight?: number;
|
||||||
};
|
maxHeight?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export const ResizableTextarea = React.forwardRef<
|
export const ResizableTextarea = React.forwardRef<
|
||||||
HTMLTextAreaElement,
|
HTMLTextAreaElement,
|
||||||
@ -44,7 +45,9 @@ export const ResizableTextarea = React.forwardRef<
|
|||||||
minHeight={minHeight}
|
minHeight={minHeight}
|
||||||
maxHeight={maxHeight}
|
maxHeight={maxHeight}
|
||||||
onResizeStop={(_event, _dir, _elementRef, delta) => {
|
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={{
|
handleComponent={{
|
||||||
bottom: (
|
bottom: (
|
||||||
@ -71,5 +74,3 @@ export const ResizableTextarea = React.forwardRef<
|
|||||||
ResizableTextarea.displayName = 'ResizableTextarea';
|
ResizableTextarea.displayName = 'ResizableTextarea';
|
||||||
|
|
||||||
export default ResizableTextarea;
|
export default ResizableTextarea;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -87,17 +87,25 @@ export function VerificationStep({
|
|||||||
}, [currentUser, verificationResult, walletType, verificationStatus]);
|
}, [currentUser, verificationResult, walletType, verificationStatus]);
|
||||||
|
|
||||||
const handleVerify = async () => {
|
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);
|
setIsLoading(true);
|
||||||
setVerificationResult(null);
|
setVerificationResult(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('📞 Calling verifyWallet()...');
|
||||||
const success = await verifyWallet();
|
const success = await verifyWallet();
|
||||||
|
console.log('📊 verifyWallet returned:', success);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// For now, just show success - the actual ownership check will be done
|
// For now, just show success - the actual ownership check will be done
|
||||||
// by the useEffect when the user state updates
|
// by the useEffect when the user state updates
|
||||||
|
console.log('✅ Verification successful, setting result');
|
||||||
setVerificationResult({
|
setVerificationResult({
|
||||||
success: true,
|
success: true,
|
||||||
message:
|
message:
|
||||||
@ -107,6 +115,7 @@ export function VerificationStep({
|
|||||||
details: undefined,
|
details: undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
console.log('❌ Verification failed, setting failure result');
|
||||||
setVerificationResult({
|
setVerificationResult({
|
||||||
success: false,
|
success: false,
|
||||||
message:
|
message:
|
||||||
@ -116,11 +125,13 @@ export function VerificationStep({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('💥 Error in handleVerify:', error);
|
||||||
setVerificationResult({
|
setVerificationResult({
|
||||||
success: false,
|
success: false,
|
||||||
message: `Verification failed. Please try again: ${error}`,
|
message: `Verification failed. Please try again: ${error}`,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
console.log('🔄 Setting loading to false');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Wifi, WifiOff, AlertTriangle, CheckCircle } from 'lucide-react';
|
import { Wifi, WifiOff, AlertTriangle, CheckCircle } from 'lucide-react';
|
||||||
import { useWakuHealthStatus } from '@/hooks/useWakuHealth';
|
import { useWakuHealthStatus } from '@opchan/react';
|
||||||
import { cn } from '@opchan/core';
|
import { cn } from '@opchan/core';
|
||||||
|
|
||||||
interface WakuHealthIndicatorProps {
|
interface WakuHealthIndicatorProps {
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CheckCircle, Circle, Loader2 } from 'lucide-react';
|
import { CheckCircle, Circle, Loader2 } from 'lucide-react';
|
||||||
import { useAuth } from '@/hooks';
|
import { useAuth } from '@/hooks';
|
||||||
import { useDelegation } from '@/hooks/useDelegation';
|
import { useDelegation } from '@opchan/react';
|
||||||
import { EVerificationStatus } from '@opchan/core';
|
import { EVerificationStatus } from '@opchan/core';
|
||||||
import { WalletConnectionStep } from './wallet-connection-step';
|
import { WalletConnectionStep } from './wallet-connection-step';
|
||||||
import { VerificationStep } from './verification-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
|
// Core hooks - Re-exported from @opchan/react
|
||||||
export { useForumData } from './core/useForumData';
|
|
||||||
export { useAuth } from './core/useAuth';
|
|
||||||
export { useUserDisplay } from './core/useUserDisplay';
|
|
||||||
export {
|
export {
|
||||||
|
useForumData,
|
||||||
|
useAuth,
|
||||||
|
useUserDisplay,
|
||||||
useBookmarks,
|
useBookmarks,
|
||||||
usePostBookmark,
|
usePostBookmark,
|
||||||
useCommentBookmark,
|
useCommentBookmark,
|
||||||
} from './core/useBookmarks';
|
} from '@opchan/react';
|
||||||
|
|
||||||
// Core types
|
// Core types - Re-exported from @opchan/react
|
||||||
export type {
|
export type {
|
||||||
ForumData,
|
ForumData,
|
||||||
CellWithStats,
|
CellWithStats,
|
||||||
PostWithVoteStatus,
|
PostWithVoteStatus,
|
||||||
CommentWithVoteStatus,
|
CommentWithVoteStatus,
|
||||||
} from './core/useForumData';
|
|
||||||
|
|
||||||
export type { AuthState } from './core/useAuth';
|
|
||||||
export type {
|
|
||||||
Permission,
|
Permission,
|
||||||
PermissionReasons,
|
PermissionReasons,
|
||||||
PermissionResult,
|
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
|
// Derived hooks - Re-exported from @opchan/react
|
||||||
export { useCell } from './derived/useCell';
|
export { useCellPosts, usePostComments, useUserVotes } from '@opchan/react';
|
||||||
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';
|
|
||||||
export type {
|
export type {
|
||||||
|
CellPostsOptions,
|
||||||
|
CellPostsData,
|
||||||
PostCommentsOptions,
|
PostCommentsOptions,
|
||||||
PostCommentsData,
|
PostCommentsData,
|
||||||
} from './derived/usePostComments';
|
UserVoteData,
|
||||||
|
} from '@opchan/react';
|
||||||
|
|
||||||
export { useUserVotes } from './derived/useUserVotes';
|
// Action hooks - Re-exported from @opchan/react
|
||||||
export type { UserVoteData } from './derived/useUserVotes';
|
export { useForumActions, useUserActions, useAuthActions } from '@opchan/react';
|
||||||
|
|
||||||
// Action hooks
|
|
||||||
export { useForumActions } from './actions/useForumActions';
|
|
||||||
export type {
|
export type {
|
||||||
ForumActionStates,
|
ForumActionStates,
|
||||||
ForumActions,
|
ForumActions,
|
||||||
} from './actions/useForumActions';
|
UserActionStates,
|
||||||
|
UserActions,
|
||||||
|
AuthActionStates,
|
||||||
|
AuthActions,
|
||||||
|
} from '@opchan/react';
|
||||||
|
|
||||||
export { useUserActions } from './actions/useUserActions';
|
// Utility hooks - Re-exported from @opchan/react
|
||||||
export type { UserActionStates, UserActions } from './actions/useUserActions';
|
export {
|
||||||
|
usePermissions,
|
||||||
export { useAuthActions } from './actions/useAuthActions';
|
useNetworkStatus,
|
||||||
export type { AuthActionStates, AuthActions } from './actions/useAuthActions';
|
useForumSelectors,
|
||||||
|
useDelegation,
|
||||||
// Utility hooks
|
useMessageSigning,
|
||||||
export { usePermissions } from './core/usePermissions';
|
usePending,
|
||||||
|
usePendingVote,
|
||||||
export { useNetworkStatus } from './utilities/useNetworkStatus';
|
useWallet,
|
||||||
|
} from '@opchan/react';
|
||||||
export type {
|
export type {
|
||||||
NetworkHealth,
|
NetworkHealth,
|
||||||
SyncStatus,
|
SyncStatus,
|
||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
NetworkStatusData,
|
NetworkStatusData,
|
||||||
} from './utilities/useNetworkStatus';
|
ForumSelectors,
|
||||||
|
} from '@opchan/react';
|
||||||
export {
|
|
||||||
useWakuHealth,
|
|
||||||
useWakuReady,
|
|
||||||
useWakuHealthStatus,
|
|
||||||
} from './useWakuHealth';
|
|
||||||
export type { WakuHealthState } from './useWakuHealth';
|
|
||||||
|
|
||||||
export { useForumSelectors } from './utilities/selectors';
|
|
||||||
export type { ForumSelectors } from './utilities/selectors';
|
|
||||||
|
|
||||||
// Legacy hooks (for backward compatibility - will be removed)
|
// Legacy hooks (for backward compatibility - will be removed)
|
||||||
// export { useForum } from '@/contexts/useForum'; // Use useForumData instead
|
// export { useForum } from '@/contexts/useForum'; // Use useForumData instead
|
||||||
// export { useAuth as useLegacyAuth } from '@/contexts/useAuth'; // Use enhanced useAuth 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 { useIsMobile as useMobile } from './use-mobile';
|
||||||
export { useToast } from './use-toast';
|
export { useToast } from './use-toast';
|
||||||
// export { useCache } from './useCache'; // Removed - functionality moved to useForumData
|
|
||||||
export { useDelegation } from './useDelegation';
|
// Waku health hooks - Re-exported from @opchan/react
|
||||||
export { useMessageSigning } from './useMessageSigning';
|
export {
|
||||||
export { useWallet } from './useWallet';
|
useWakuHealth,
|
||||||
|
useWakuReady,
|
||||||
|
useWakuHealthStatus,
|
||||||
|
} from '@opchan/react';
|
||||||
|
|||||||
@ -2,20 +2,24 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import { Buffer } from 'buffer';
|
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) {
|
if (!(window as Window & typeof globalThis).Buffer) {
|
||||||
(window as Window & typeof globalThis).Buffer = 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,
|
FileText,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '@/contexts/useAuth';
|
import { useAuth } from '@opchan/react';
|
||||||
|
|
||||||
const BookmarksPage = () => {
|
const BookmarksPage = () => {
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import messageManager from '@opchan/core';
|
import { messageManager, MessageType } from '@opchan/core';
|
||||||
import { MessageType } from '@opchan/core';
|
|
||||||
import type { OpchanMessage } from '@opchan/core';
|
import type { OpchanMessage } from '@opchan/core';
|
||||||
|
|
||||||
interface ReceivedMessage {
|
interface ReceivedMessage {
|
||||||
@ -14,11 +13,13 @@ export default function DebugPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Subscribe to inbound messages from reliable channel
|
// Subscribe to inbound messages from reliable channel
|
||||||
unsubscribeRef.current = messageManager.onMessageReceived(msg => {
|
unsubscribeRef.current = messageManager.onMessageReceived(
|
||||||
setMessages(prev =>
|
(msg: OpchanMessage) => {
|
||||||
[{ receivedAt: Date.now(), message: msg }, ...prev].slice(0, 500)
|
setMessages(prev =>
|
||||||
);
|
[{ receivedAt: Date.now(), message: msg }, ...prev].slice(0, 500)
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribeRef.current?.();
|
unsubscribeRef.current?.();
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import Header from '@/components/Header';
|
import Header from '@/components/Header';
|
||||||
import CellList from '@/components/CellList';
|
import CellList from '@/components/CellList';
|
||||||
import { useNetworkStatus, useForumActions } from '@/hooks';
|
import { useForumActions } from '@/hooks';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Wifi } from 'lucide-react';
|
import { Wifi } from 'lucide-react';
|
||||||
|
import { useForum } from '@opchan/react';
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
const { health } = useNetworkStatus();
|
const {network} = useForum()
|
||||||
const { refreshData } = useForumActions();
|
const { refreshData } = useForumActions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -13,7 +14,7 @@ const Index = () => {
|
|||||||
<Header />
|
<Header />
|
||||||
<main className="page-content relative">
|
<main className="page-content relative">
|
||||||
<CellList />
|
<CellList />
|
||||||
{!health.isConnected && (
|
{!network.isConnected && (
|
||||||
<div className="fixed bottom-4 right-4">
|
<div className="fixed bottom-4 right-4">
|
||||||
<Button
|
<Button
|
||||||
onClick={refreshData}
|
onClick={refreshData}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useAuth, useUserActions, useForumActions } from '@/hooks';
|
import { useUserActions, useForumActions } from '@/hooks';
|
||||||
import { useAuth as useAuthContext } from '@/contexts/useAuth';
|
import { useAuth } from '@opchan/react';
|
||||||
import { useUserDisplay } from '@/hooks';
|
import { useUserDisplay } from '@/hooks';
|
||||||
import { useDelegation } from '@/hooks/useDelegation';
|
import { useDelegation } from '@opchan/react';
|
||||||
import { DelegationFullStatus } from '@opchan/core';
|
import { DelegationFullStatus } from '@opchan/core';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@ -41,8 +41,7 @@ export default function ProfilePage() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Get current user from auth context for the address
|
// Get current user from auth context for the address
|
||||||
const { currentUser, verificationStatus } = useAuth();
|
const { currentUser, getDelegationStatus } = useAuth();
|
||||||
const { getDelegationStatus } = useAuthContext();
|
|
||||||
const { delegationStatus } = useDelegation();
|
const { delegationStatus } = useDelegation();
|
||||||
const [delegationInfo, setDelegationInfo] =
|
const [delegationInfo, setDelegationInfo] =
|
||||||
useState<DelegationFullStatus | null>(null);
|
useState<DelegationFullStatus | null>(null);
|
||||||
@ -56,6 +55,20 @@ export default function ProfilePage() {
|
|||||||
// Get comprehensive user information from the unified hook
|
// Get comprehensive user information from the unified hook
|
||||||
const userInfo = useUserDisplay(address || '');
|
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 [isEditing, setIsEditing] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [callSign, setCallSign] = useState('');
|
const [callSign, setCallSign] = useState('');
|
||||||
@ -190,7 +203,8 @@ export default function ProfilePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getVerificationIcon = () => {
|
const getVerificationIcon = () => {
|
||||||
switch (verificationStatus) {
|
// Use verification level from UserIdentityService (central database store)
|
||||||
|
switch (userInfo.verificationLevel) {
|
||||||
case EVerificationStatus.ENS_ORDINAL_VERIFIED:
|
case EVerificationStatus.ENS_ORDINAL_VERIFIED:
|
||||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||||
case EVerificationStatus.WALLET_CONNECTED:
|
case EVerificationStatus.WALLET_CONNECTED:
|
||||||
@ -203,7 +217,8 @@ export default function ProfilePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getVerificationText = () => {
|
const getVerificationText = () => {
|
||||||
switch (verificationStatus) {
|
// Use verification level from UserIdentityService (central database store)
|
||||||
|
switch (userInfo.verificationLevel) {
|
||||||
case EVerificationStatus.ENS_ORDINAL_VERIFIED:
|
case EVerificationStatus.ENS_ORDINAL_VERIFIED:
|
||||||
return 'Owns ENS or Ordinal';
|
return 'Owns ENS or Ordinal';
|
||||||
case EVerificationStatus.WALLET_CONNECTED:
|
case EVerificationStatus.WALLET_CONNECTED:
|
||||||
@ -216,7 +231,8 @@ export default function ProfilePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getVerificationColor = () => {
|
const getVerificationColor = () => {
|
||||||
switch (verificationStatus) {
|
// Use verification level from UserIdentityService (central database store)
|
||||||
|
switch (userInfo.verificationLevel) {
|
||||||
case EVerificationStatus.ENS_ORDINAL_VERIFIED:
|
case EVerificationStatus.ENS_ORDINAL_VERIFIED:
|
||||||
return 'bg-green-100 text-green-800 border-green-200';
|
return 'bg-green-100 text-green-800 border-green-200';
|
||||||
case EVerificationStatus.WALLET_CONNECTED:
|
case EVerificationStatus.WALLET_CONNECTED:
|
||||||
@ -277,9 +293,32 @@ export default function ProfilePage() {
|
|||||||
{userInfo.displayName}
|
{userInfo.displayName}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-cyber-neutral">
|
<div className="text-sm text-cyber-neutral">
|
||||||
{userInfo.ordinalDetails || currentUser.ordinalDetails?.ordinalDetails
|
{/* Show ENS name if available */}
|
||||||
? `Ordinal: ${userInfo.ordinalDetails || currentUser.ordinalDetails?.ordinalDetails}`
|
{(userInfo.ensName ||
|
||||||
: currentUser.ensDetails?.ensName || 'No ENS name'}
|
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>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
{getVerificationIcon()}
|
{getVerificationIcon()}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export default defineConfig(() => ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ['buffer'],
|
include: ['buffer', '@opchan/core', '@opchan/hooks'],
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
target: 'es2022',
|
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",
|
"description": "Browser-based Forum Library over Waku",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*",
|
||||||
|
"app"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build --workspaces",
|
"build": "npm run build --workspaces",
|
||||||
|
|||||||
@ -5,14 +5,22 @@
|
|||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.esm.js",
|
"module": "dist/index.esm.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
"sideEffects": false,
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.esm.js",
|
||||||
|
"require": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"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: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: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",
|
"build:types": "tsc --declaration --emitDeclarationOnly --outDir dist && cp dist/cjs/index.d.ts dist/index.d.ts",
|
||||||
"dev": "tsc --watch",
|
"dev": "tsc --watch",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"lint": "eslint src --ext .ts",
|
"lint": "eslint src --ext .ts",
|
||||||
@ -38,7 +46,7 @@
|
|||||||
"ordiscan": "^1.3.0",
|
"ordiscan": "^1.3.0",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"wagmi": "^2.16.1"
|
"wagmi": "^2.17.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.5.5",
|
"@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
|
DelegationCrypto
|
||||||
} from './lib/delegation';
|
} from './lib/delegation';
|
||||||
export * from './lib/delegation/types';
|
export * from './lib/delegation/types';
|
||||||
|
export type { DelegationFullStatus } from './lib/delegation';
|
||||||
|
|
||||||
// Export forum functionality
|
// Export forum functionality
|
||||||
export { ForumActions } from './lib/forum/ForumActions';
|
export { ForumActions } from './lib/forum/ForumActions';
|
||||||
@ -46,3 +47,6 @@ export * from './lib/waku/network';
|
|||||||
export { WalletManager, walletManager } from './lib/wallet';
|
export { WalletManager, walletManager } from './lib/wallet';
|
||||||
export * from './lib/wallet/config';
|
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,
|
EModerationAction,
|
||||||
} from '../../types/waku';
|
} from '../../types/waku';
|
||||||
import messageManager from '../waku';
|
import messageManager from '../waku';
|
||||||
|
import { localDatabase } from '../database/LocalDatabase';
|
||||||
import { RelevanceCalculator } from './RelevanceCalculator';
|
import { RelevanceCalculator } from './RelevanceCalculator';
|
||||||
import { UserVerificationStatus } from '../../types/forum';
|
import { UserVerificationStatus } from '../../types/forum';
|
||||||
// Validation is enforced at ingestion time by LocalDatabase. Transformers assume
|
// Validation is enforced at ingestion time by LocalDatabase. Transformers assume
|
||||||
@ -68,7 +69,7 @@ export const transformPost = async (
|
|||||||
): Promise<Post | null> => {
|
): Promise<Post | null> => {
|
||||||
// Message validity already enforced upstream
|
// 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
|
(vote): vote is VoteMessage => vote && vote.targetId === postMessage.id
|
||||||
);
|
);
|
||||||
// Votes in cache are already validated; just map
|
// 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
|
(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 =
|
const isPostModerated =
|
||||||
!!modMsg &&
|
!!modMsg &&
|
||||||
modMsg.targetType === 'post' &&
|
modMsg.targetType === 'post' &&
|
||||||
modMsg.action === EModerationAction.MODERATE;
|
modMsg.action === EModerationAction.MODERATE;
|
||||||
const userModMsg = Object.values(
|
const userModMsg = Object.values(
|
||||||
messageManager.messageCache.moderations
|
localDatabase.cache.moderations
|
||||||
).find(
|
).find(
|
||||||
(m): m is ModerateMessage =>
|
(m): m is ModerateMessage =>
|
||||||
m &&
|
m &&
|
||||||
@ -137,7 +138,7 @@ export const transformPost = async (
|
|||||||
|
|
||||||
// Get comments for this post
|
// Get comments for this post
|
||||||
const comments = await Promise.all(
|
const comments = await Promise.all(
|
||||||
Object.values(messageManager.messageCache.comments)
|
Object.values(localDatabase.cache.comments)
|
||||||
.filter((comment): comment is CommentMessage => comment !== null)
|
.filter((comment): comment is CommentMessage => comment !== null)
|
||||||
.map(comment =>
|
.map(comment =>
|
||||||
transformComment(comment, undefined, userVerificationStatus)
|
transformComment(comment, undefined, userVerificationStatus)
|
||||||
@ -190,7 +191,7 @@ export const transformComment = async (
|
|||||||
userVerificationStatus?: UserVerificationStatus
|
userVerificationStatus?: UserVerificationStatus
|
||||||
): Promise<Comment | null> => {
|
): Promise<Comment | null> => {
|
||||||
// Message validity already enforced upstream
|
// 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
|
(vote): vote is VoteMessage => vote && vote.targetId === commentMessage.id
|
||||||
);
|
);
|
||||||
// Votes in cache are already validated
|
// Votes in cache are already validated
|
||||||
@ -202,17 +203,17 @@ export const transformComment = async (
|
|||||||
(vote): vote is VoteMessage => vote !== null && vote.value === -1
|
(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 =
|
const isCommentModerated =
|
||||||
!!modMsg &&
|
!!modMsg &&
|
||||||
modMsg.targetType === 'comment' &&
|
modMsg.targetType === 'comment' &&
|
||||||
modMsg.action === EModerationAction.MODERATE;
|
modMsg.action === EModerationAction.MODERATE;
|
||||||
// Find the post to get the correct cell ID
|
// 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
|
(post): post is PostMessage => post && post.id === commentMessage.postId
|
||||||
);
|
);
|
||||||
const userModMsg = Object.values(
|
const userModMsg = Object.values(
|
||||||
messageManager.messageCache.moderations
|
localDatabase.cache.moderations
|
||||||
).find(
|
).find(
|
||||||
(m): m is ModerateMessage =>
|
(m): m is ModerateMessage =>
|
||||||
m &&
|
m &&
|
||||||
@ -288,10 +289,10 @@ export const getDataFromCache = async (
|
|||||||
_verifyMessage?: unknown, // Deprecated parameter, kept for compatibility
|
_verifyMessage?: unknown, // Deprecated parameter, kept for compatibility
|
||||||
userVerificationStatus?: UserVerificationStatus
|
userVerificationStatus?: UserVerificationStatus
|
||||||
): Promise<{ cells: Cell[]; posts: Post[]; comments: Comment[] }> => {
|
): 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
|
// All validation is now handled internally by the transform functions
|
||||||
const posts = await Promise.all(
|
const posts = await Promise.all(
|
||||||
Object.values(messageManager.messageCache.posts)
|
Object.values(localDatabase.cache.posts)
|
||||||
.filter((post): post is PostMessage => post !== null)
|
.filter((post): post is PostMessage => post !== null)
|
||||||
.map(post =>
|
.map(post =>
|
||||||
transformPost(post, undefined, userVerificationStatus)
|
transformPost(post, undefined, userVerificationStatus)
|
||||||
@ -299,7 +300,7 @@ export const getDataFromCache = async (
|
|||||||
).then(posts => posts.filter((post): post is Post => post !== null));
|
).then(posts => posts.filter((post): post is Post => post !== null));
|
||||||
|
|
||||||
const comments = await Promise.all(
|
const comments = await Promise.all(
|
||||||
Object.values(messageManager.messageCache.comments)
|
Object.values(localDatabase.cache.comments)
|
||||||
.filter((c): c is CommentMessage => c !== null)
|
.filter((c): c is CommentMessage => c !== null)
|
||||||
.map(c =>
|
.map(c =>
|
||||||
transformComment(c, undefined, userVerificationStatus)
|
transformComment(c, undefined, userVerificationStatus)
|
||||||
@ -310,7 +311,7 @@ export const getDataFromCache = async (
|
|||||||
|
|
||||||
// Then transform cells with posts for relevance calculation
|
// Then transform cells with posts for relevance calculation
|
||||||
const cells = await Promise.all(
|
const cells = await Promise.all(
|
||||||
Object.values(messageManager.messageCache.cells)
|
Object.values(localDatabase.cache.cells)
|
||||||
.filter((cell): cell is CellMessage => cell !== null)
|
.filter((cell): cell is CellMessage => cell !== null)
|
||||||
.map(cell =>
|
.map(cell =>
|
||||||
transformCell(cell, undefined, userVerificationStatus, posts)
|
transformCell(cell, undefined, userVerificationStatus, posts)
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { Ordiscan, Inscription } from 'ordiscan';
|
import { Ordiscan, Inscription } from 'ordiscan';
|
||||||
import { environment } from '../utils/environment';
|
import { environment } from '../utils/environment';
|
||||||
const API_KEY = environment.ordiscanApiKey;
|
|
||||||
|
|
||||||
class Ordinals {
|
class Ordinals {
|
||||||
private static instance: Ordinals | null = null;
|
private static instance: Ordinals | null = null;
|
||||||
@ -14,10 +13,11 @@ class Ordinals {
|
|||||||
|
|
||||||
static getInstance(): Ordinals {
|
static getInstance(): Ordinals {
|
||||||
if (!Ordinals.instance) {
|
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.');
|
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;
|
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 messageService: MessageService;
|
||||||
private userIdentityCache: UserIdentityCache = {};
|
private userIdentityCache: UserIdentityCache = {};
|
||||||
private refreshListeners: Set<(address: string) => void> = new Set();
|
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) {
|
constructor(messageService: MessageService) {
|
||||||
this.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> {
|
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
|
// Check internal cache first
|
||||||
if (this.userIdentityCache[address]) {
|
if (this.userIdentityCache[address]) {
|
||||||
const cached = this.userIdentityCache[address];
|
const cached = this.userIdentityCache[address];
|
||||||
@ -48,6 +70,16 @@ export class UserIdentityService {
|
|||||||
const ensName = await this.resolveENSName(address);
|
const ensName = await this.resolveENSName(address);
|
||||||
if (ensName) {
|
if (ensName) {
|
||||||
cached.ensName = 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 {
|
return {
|
||||||
@ -91,6 +123,17 @@ export class UserIdentityService {
|
|||||||
if (ensName) {
|
if (ensName) {
|
||||||
result.ensName = ensName;
|
result.ensName = ensName;
|
||||||
this.userIdentityCache[address].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;
|
return result;
|
||||||
@ -132,6 +175,17 @@ export class UserIdentityService {
|
|||||||
if (ensName) {
|
if (ensName) {
|
||||||
result.ensName = ensName;
|
result.ensName = ensName;
|
||||||
this.userIdentityCache[address].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;
|
return result;
|
||||||
@ -152,6 +206,16 @@ export class UserIdentityService {
|
|||||||
lastUpdated: identity.lastUpdated,
|
lastUpdated: identity.lastUpdated,
|
||||||
verificationStatus: identity.verificationStatus,
|
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;
|
return identity;
|
||||||
@ -176,6 +240,16 @@ export class UserIdentityService {
|
|||||||
lastUpdated: identity.lastUpdated,
|
lastUpdated: identity.lastUpdated,
|
||||||
verificationStatus: identity.verificationStatus,
|
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;
|
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> {
|
private async resolveENSName(address: string): Promise<string | null> {
|
||||||
if (!address.startsWith('0x')) {
|
if (!address.startsWith('0x')) {
|
||||||
return null; // Not an Ethereum address
|
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 {
|
try {
|
||||||
// Import the ENS resolver from wagmi
|
// Import the ENS resolver from wagmi
|
||||||
const { getEnsName } = await import('@wagmi/core');
|
const { getEnsName } = await import('@wagmi/core');
|
||||||
@ -445,6 +546,10 @@ export class UserIdentityService {
|
|||||||
*/
|
*/
|
||||||
clearUserIdentityCache(): void {
|
clearUserIdentityCache(): void {
|
||||||
this.userIdentityCache = {};
|
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
|
// Create a default instance that can be used synchronously but initialized asynchronously
|
||||||
class DefaultMessageManager {
|
export class DefaultMessageManager {
|
||||||
private _instance: MessageManager | null = null;
|
private _instance: MessageManager | null = null;
|
||||||
private _initPromise: Promise<MessageManager> | null = null;
|
private _initPromise: Promise<MessageManager> | null = null;
|
||||||
|
private _pendingHealthSubscriptions: HealthChangeCallback[] = [];
|
||||||
|
private _pendingMessageSubscriptions: ((message: any) => void)[] = [];
|
||||||
|
|
||||||
// Initialize the manager asynchronously
|
// Initialize the manager asynchronously
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
@ -133,6 +135,18 @@ class DefaultMessageManager {
|
|||||||
this._initPromise = MessageManager.create();
|
this._initPromise = MessageManager.create();
|
||||||
}
|
}
|
||||||
this._instance = await this._initPromise;
|
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)
|
// Get the messageCache (most common usage)
|
||||||
@ -170,16 +184,32 @@ class DefaultMessageManager {
|
|||||||
|
|
||||||
onHealthChange(callback: any) {
|
onHealthChange(callback: any) {
|
||||||
if (!this._instance) {
|
if (!this._instance) {
|
||||||
// Return a no-op function until initialized
|
// Queue the callback for when we're initialized
|
||||||
return () => {};
|
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);
|
return this._instance.onHealthChange(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMessageReceived(callback: any) {
|
onMessageReceived(callback: any) {
|
||||||
if (!this._instance) {
|
if (!this._instance) {
|
||||||
// Return a no-op function until initialized
|
// Queue the callback for when we're initialized
|
||||||
return () => {};
|
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);
|
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 { useState, useEffect, useCallback } from 'react';
|
||||||
import { Bookmark, BookmarkType, Post, Comment } from '@opchan/core';
|
import { Bookmark, BookmarkType, Post, Comment, BookmarkService } from '@opchan/core';
|
||||||
import { BookmarkService } from '@opchan/core';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useAuth } from '@/contexts/useAuth';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for managing bookmarks
|
|
||||||
* Provides bookmark state and operations for the current user
|
|
||||||
*/
|
|
||||||
export function useBookmarks() {
|
export function useBookmarks() {
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
|
||||||
@ -30,7 +25,6 @@ export function useBookmarks() {
|
|||||||
}
|
}
|
||||||
}, [currentUser?.address]);
|
}, [currentUser?.address]);
|
||||||
|
|
||||||
// Load user bookmarks when user changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser?.address) {
|
if (currentUser?.address) {
|
||||||
loadBookmarks();
|
loadBookmarks();
|
||||||
@ -49,7 +43,7 @@ export function useBookmarks() {
|
|||||||
currentUser.address,
|
currentUser.address,
|
||||||
cellId
|
cellId
|
||||||
);
|
);
|
||||||
await loadBookmarks(); // Refresh the list
|
await loadBookmarks();
|
||||||
return isBookmarked;
|
return isBookmarked;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
@ -71,7 +65,7 @@ export function useBookmarks() {
|
|||||||
currentUser.address,
|
currentUser.address,
|
||||||
postId
|
postId
|
||||||
);
|
);
|
||||||
await loadBookmarks(); // Refresh the list
|
await loadBookmarks();
|
||||||
return isBookmarked;
|
return isBookmarked;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
@ -92,7 +86,7 @@ export function useBookmarks() {
|
|||||||
bookmark.type,
|
bookmark.type,
|
||||||
bookmark.targetId
|
bookmark.targetId
|
||||||
);
|
);
|
||||||
await loadBookmarks(); // Refresh the list
|
await loadBookmarks();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
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) {
|
export function usePostBookmark(post: Post | null, cellId?: string) {
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
const [isBookmarked, setIsBookmarked] = useState(false);
|
const [isBookmarked, setIsBookmarked] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
// Check initial bookmark status
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser?.address && post?.id) {
|
if (currentUser?.address && post?.id) {
|
||||||
const bookmarked = BookmarkService.isPostBookmarked(
|
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) {
|
export function useCommentBookmark(comment: Comment, postId?: string) {
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
const [isBookmarked, setIsBookmarked] = useState(false);
|
const [isBookmarked, setIsBookmarked] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
// Check initial bookmark status
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser?.address) {
|
if (currentUser?.address) {
|
||||||
const bookmarked = BookmarkService.isCommentBookmarked(
|
const bookmarked = BookmarkService.isCommentBookmarked(
|
||||||
@ -1,9 +1,14 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useForum } from '@/contexts/useForum';
|
import { useForum } from '../../contexts/ForumContext';
|
||||||
import { useAuth } from '@/contexts/useAuth';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useModeration } from '@/contexts/ModerationContext';
|
import { useModeration } from '../../contexts/ModerationContext';
|
||||||
import { Cell, Post, Comment, UserVerificationStatus } from '@opchan/core';
|
import {
|
||||||
import { EVerificationStatus } from '@opchan/core';
|
Cell,
|
||||||
|
Post,
|
||||||
|
Comment,
|
||||||
|
UserVerificationStatus,
|
||||||
|
EVerificationStatus,
|
||||||
|
} from '@opchan/core';
|
||||||
|
|
||||||
export interface CellWithStats extends Cell {
|
export interface CellWithStats extends Cell {
|
||||||
postCount: number;
|
postCount: number;
|
||||||
@ -62,10 +67,6 @@ export interface ForumData {
|
|||||||
userCreatedComments: Set<string>;
|
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 {
|
export function useForumData(): ForumData {
|
||||||
const {
|
const {
|
||||||
cells,
|
cells,
|
||||||
@ -81,12 +82,11 @@ export function useForumData(): ForumData {
|
|||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
const { showModerated } = useModeration();
|
const { showModerated } = useModeration();
|
||||||
|
|
||||||
// Compute cells with statistics
|
|
||||||
const cellsWithStats = useMemo((): CellWithStats[] => {
|
const cellsWithStats = useMemo((): CellWithStats[] => {
|
||||||
return cells.map(cell => {
|
return cells.map(cell => {
|
||||||
const cellPosts = posts.filter(post => post.cellId === cell.id);
|
const cellPosts = posts.filter(post => post.cellId === cell.id);
|
||||||
const recentPosts = cellPosts.filter(
|
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));
|
const uniqueAuthors = new Set(cellPosts.map(post => post.author));
|
||||||
@ -100,46 +100,35 @@ export function useForumData(): ForumData {
|
|||||||
});
|
});
|
||||||
}, [cells, posts]);
|
}, [cells, posts]);
|
||||||
|
|
||||||
// Helper function to check if user can vote
|
|
||||||
const canUserVote = useMemo(() => {
|
const canUserVote = useMemo(() => {
|
||||||
if (!currentUser) return false;
|
if (!currentUser) return false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
currentUser.verificationStatus ===
|
currentUser.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED ||
|
||||||
EVerificationStatus.ENS_ORDINAL_VERIFIED ||
|
|
||||||
currentUser.verificationStatus === EVerificationStatus.WALLET_CONNECTED ||
|
currentUser.verificationStatus === EVerificationStatus.WALLET_CONNECTED ||
|
||||||
Boolean(currentUser.ensDetails) ||
|
Boolean(currentUser.ensDetails) ||
|
||||||
Boolean(currentUser.ordinalDetails)
|
Boolean(currentUser.ordinalDetails)
|
||||||
);
|
);
|
||||||
}, [currentUser]);
|
}, [currentUser]);
|
||||||
|
|
||||||
// Helper function to check if user can moderate in a cell
|
|
||||||
const canUserModerate = useMemo(() => {
|
const canUserModerate = useMemo(() => {
|
||||||
const moderationMap: Record<string, boolean> = {};
|
const moderationMap: Record<string, boolean> = {};
|
||||||
|
|
||||||
if (!currentUser) return moderationMap;
|
if (!currentUser) return moderationMap;
|
||||||
|
|
||||||
cells.forEach(cell => {
|
cells.forEach(cell => {
|
||||||
moderationMap[cell.id] = currentUser.address === cell.signature;
|
moderationMap[cell.id] = currentUser.address === (cell as unknown as { signature?: string }).signature;
|
||||||
});
|
});
|
||||||
|
|
||||||
return moderationMap;
|
return moderationMap;
|
||||||
}, [currentUser, cells]);
|
}, [currentUser, cells]);
|
||||||
|
|
||||||
// Compute posts with vote status
|
|
||||||
const postsWithVoteStatus = useMemo((): PostWithVoteStatus[] => {
|
const postsWithVoteStatus = useMemo((): PostWithVoteStatus[] => {
|
||||||
return posts.map(post => {
|
return posts.map(post => {
|
||||||
const userUpvoted = currentUser
|
const userUpvoted = currentUser
|
||||||
? post.upvotes.some(vote => vote.author === currentUser.address)
|
? post.upvotes.some(vote => vote.author === currentUser.address)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const userDownvoted = currentUser
|
const userDownvoted = currentUser
|
||||||
? post.downvotes.some(vote => vote.author === currentUser.address)
|
? post.downvotes.some(vote => vote.author === currentUser.address)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const voteScore = post.upvotes.length - post.downvotes.length;
|
const voteScore = post.upvotes.length - post.downvotes.length;
|
||||||
const canModerate = canUserModerate[post.cellId] || false;
|
const canModerate = canUserModerate[post.cellId] || false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...post,
|
...post,
|
||||||
userUpvoted,
|
userUpvoted,
|
||||||
@ -151,25 +140,20 @@ export function useForumData(): ForumData {
|
|||||||
});
|
});
|
||||||
}, [posts, currentUser, canUserVote, canUserModerate]);
|
}, [posts, currentUser, canUserVote, canUserModerate]);
|
||||||
|
|
||||||
// Compute comments with vote status
|
|
||||||
const commentsWithVoteStatus = useMemo((): CommentWithVoteStatus[] => {
|
const commentsWithVoteStatus = useMemo((): CommentWithVoteStatus[] => {
|
||||||
return comments.map(comment => {
|
return comments.map(comment => {
|
||||||
const userUpvoted = currentUser
|
const userUpvoted = currentUser
|
||||||
? comment.upvotes.some(vote => vote.author === currentUser.address)
|
? comment.upvotes.some(vote => vote.author === currentUser.address)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const userDownvoted = currentUser
|
const userDownvoted = currentUser
|
||||||
? comment.downvotes.some(vote => vote.author === currentUser.address)
|
? comment.downvotes.some(vote => vote.author === currentUser.address)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const voteScore = comment.upvotes.length - comment.downvotes.length;
|
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 parentPost = posts.find(post => post.id === comment.postId);
|
||||||
const canModerate = parentPost
|
const canModerate = parentPost
|
||||||
? canUserModerate[parentPost.cellId] || false
|
? canUserModerate[parentPost.cellId] || false
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...comment,
|
...comment,
|
||||||
userUpvoted,
|
userUpvoted,
|
||||||
@ -181,150 +165,99 @@ export function useForumData(): ForumData {
|
|||||||
});
|
});
|
||||||
}, [comments, currentUser, canUserVote, canUserModerate, posts]);
|
}, [comments, currentUser, canUserVote, canUserModerate, posts]);
|
||||||
|
|
||||||
// Organize posts by cell
|
|
||||||
const postsByCell = useMemo((): Record<string, PostWithVoteStatus[]> => {
|
const postsByCell = useMemo((): Record<string, PostWithVoteStatus[]> => {
|
||||||
const organized: Record<string, PostWithVoteStatus[]> = {};
|
const organized: Record<string, PostWithVoteStatus[]> = {};
|
||||||
|
|
||||||
postsWithVoteStatus.forEach(post => {
|
postsWithVoteStatus.forEach(post => {
|
||||||
if (!organized[post.cellId]) {
|
if (!organized[post.cellId]) organized[post.cellId] = [];
|
||||||
organized[post.cellId] = [];
|
organized[post.cellId]!.push(post);
|
||||||
}
|
|
||||||
const cellPosts = organized[post.cellId];
|
|
||||||
if (cellPosts) {
|
|
||||||
cellPosts.push(post);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort posts within each cell by relevance score or timestamp
|
|
||||||
Object.keys(organized).forEach(cellId => {
|
Object.keys(organized).forEach(cellId => {
|
||||||
const cellPosts = organized[cellId];
|
const list = organized[cellId]!;
|
||||||
if (cellPosts) {
|
list.sort((a, b) => {
|
||||||
cellPosts.sort((a, b) => {
|
if (
|
||||||
if (
|
a.relevanceScore !== undefined &&
|
||||||
a.relevanceScore !== undefined &&
|
b.relevanceScore !== undefined
|
||||||
b.relevanceScore !== undefined
|
) {
|
||||||
) {
|
return b.relevanceScore - a.relevanceScore;
|
||||||
return b.relevanceScore - a.relevanceScore;
|
}
|
||||||
}
|
return b.timestamp - a.timestamp;
|
||||||
return b.timestamp - a.timestamp;
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return organized;
|
return organized;
|
||||||
}, [postsWithVoteStatus]);
|
}, [postsWithVoteStatus]);
|
||||||
|
|
||||||
// Organize comments by post
|
const commentsByPost = useMemo((): Record<string, CommentWithVoteStatus[]> => {
|
||||||
const commentsByPost = useMemo((): Record<
|
|
||||||
string,
|
|
||||||
CommentWithVoteStatus[]
|
|
||||||
> => {
|
|
||||||
const organized: Record<string, CommentWithVoteStatus[]> = {};
|
const organized: Record<string, CommentWithVoteStatus[]> = {};
|
||||||
|
|
||||||
commentsWithVoteStatus.forEach(comment => {
|
commentsWithVoteStatus.forEach(comment => {
|
||||||
if (!organized[comment.postId]) {
|
if (!organized[comment.postId]) organized[comment.postId] = [];
|
||||||
organized[comment.postId] = [];
|
organized[comment.postId]!.push(comment);
|
||||||
}
|
|
||||||
const postComments = organized[comment.postId];
|
|
||||||
if (postComments) {
|
|
||||||
postComments.push(comment);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort comments within each post by timestamp (oldest first)
|
|
||||||
Object.keys(organized).forEach(postId => {
|
Object.keys(organized).forEach(postId => {
|
||||||
const postComments = organized[postId];
|
const list = organized[postId]!;
|
||||||
if (postComments) {
|
list.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
postComments.sort((a, b) => a.timestamp - b.timestamp);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return organized;
|
return organized;
|
||||||
}, [commentsWithVoteStatus]);
|
}, [commentsWithVoteStatus]);
|
||||||
|
|
||||||
// User-specific data sets
|
|
||||||
const userVotedPosts = useMemo(() => {
|
const userVotedPosts = useMemo(() => {
|
||||||
const votedPosts = new Set<string>();
|
const voted = new Set<string>();
|
||||||
if (!currentUser) return votedPosts;
|
if (!currentUser) return voted;
|
||||||
|
|
||||||
postsWithVoteStatus.forEach(post => {
|
postsWithVoteStatus.forEach(post => {
|
||||||
if (post.userUpvoted || post.userDownvoted) {
|
if (post.userUpvoted || post.userDownvoted) voted.add(post.id);
|
||||||
votedPosts.add(post.id);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
return voted;
|
||||||
return votedPosts;
|
|
||||||
}, [postsWithVoteStatus, currentUser]);
|
}, [postsWithVoteStatus, currentUser]);
|
||||||
|
|
||||||
const userVotedComments = useMemo(() => {
|
const userVotedComments = useMemo(() => {
|
||||||
const votedComments = new Set<string>();
|
const voted = new Set<string>();
|
||||||
if (!currentUser) return votedComments;
|
if (!currentUser) return voted;
|
||||||
|
|
||||||
commentsWithVoteStatus.forEach(comment => {
|
commentsWithVoteStatus.forEach(comment => {
|
||||||
if (comment.userUpvoted || comment.userDownvoted) {
|
if (comment.userUpvoted || comment.userDownvoted) voted.add(comment.id);
|
||||||
votedComments.add(comment.id);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
return voted;
|
||||||
return votedComments;
|
|
||||||
}, [commentsWithVoteStatus, currentUser]);
|
}, [commentsWithVoteStatus, currentUser]);
|
||||||
|
|
||||||
const userCreatedPosts = useMemo(() => {
|
const userCreatedPosts = useMemo(() => {
|
||||||
const createdPosts = new Set<string>();
|
const created = new Set<string>();
|
||||||
if (!currentUser) return createdPosts;
|
if (!currentUser) return created;
|
||||||
|
|
||||||
posts.forEach(post => {
|
posts.forEach(post => {
|
||||||
if (post.author === currentUser.address) {
|
if (post.author === currentUser.address) created.add(post.id);
|
||||||
createdPosts.add(post.id);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
return created;
|
||||||
return createdPosts;
|
|
||||||
}, [posts, currentUser]);
|
}, [posts, currentUser]);
|
||||||
|
|
||||||
const userCreatedComments = useMemo(() => {
|
const userCreatedComments = useMemo(() => {
|
||||||
const createdComments = new Set<string>();
|
const created = new Set<string>();
|
||||||
if (!currentUser) return createdComments;
|
if (!currentUser) return created;
|
||||||
|
|
||||||
comments.forEach(comment => {
|
comments.forEach(comment => {
|
||||||
if (comment.author === currentUser.address) {
|
if (comment.author === currentUser.address) created.add(comment.id);
|
||||||
createdComments.add(comment.id);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
return created;
|
||||||
return createdComments;
|
|
||||||
}, [comments, currentUser]);
|
}, [comments, currentUser]);
|
||||||
|
|
||||||
// Filtered data based on moderation settings
|
|
||||||
const filteredPosts = useMemo(() => {
|
const filteredPosts = useMemo(() => {
|
||||||
return showModerated
|
return showModerated
|
||||||
? postsWithVoteStatus
|
? postsWithVoteStatus
|
||||||
: postsWithVoteStatus.filter(post => !post.moderated);
|
: postsWithVoteStatus.filter(p => !p.moderated);
|
||||||
}, [postsWithVoteStatus, showModerated]);
|
}, [postsWithVoteStatus, showModerated]);
|
||||||
|
|
||||||
const filteredComments = useMemo(() => {
|
const filteredComments = useMemo(() => {
|
||||||
if (showModerated) return commentsWithVoteStatus;
|
if (showModerated) return commentsWithVoteStatus;
|
||||||
|
|
||||||
// Hide moderated comments AND comments whose parent post is moderated
|
|
||||||
const moderatedPostIds = new Set(
|
const moderatedPostIds = new Set(
|
||||||
postsWithVoteStatus.filter(p => p.moderated).map(p => p.id)
|
postsWithVoteStatus.filter(p => p.moderated).map(p => p.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
return commentsWithVoteStatus.filter(
|
return commentsWithVoteStatus.filter(
|
||||||
comment => !comment.moderated && !moderatedPostIds.has(comment.postId)
|
c => !c.moderated && !moderatedPostIds.has(c.postId)
|
||||||
);
|
);
|
||||||
}, [commentsWithVoteStatus, postsWithVoteStatus, showModerated]);
|
}, [commentsWithVoteStatus, postsWithVoteStatus, showModerated]);
|
||||||
|
|
||||||
// Filtered cells with stats based on filtered posts
|
|
||||||
const filteredCellsWithStats = useMemo((): CellWithStats[] => {
|
const filteredCellsWithStats = useMemo((): CellWithStats[] => {
|
||||||
return cells.map(cell => {
|
return cells.map(cell => {
|
||||||
const cellPosts = filteredPosts.filter(post => post.cellId === cell.id);
|
const cellPosts = filteredPosts.filter(post => post.cellId === cell.id);
|
||||||
const recentPosts = cellPosts.filter(
|
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));
|
const uniqueAuthors = new Set(cellPosts.map(post => post.author));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...cell,
|
...cell,
|
||||||
postCount: cellPosts.length,
|
postCount: cellPosts.length,
|
||||||
@ -334,55 +267,38 @@ export function useForumData(): ForumData {
|
|||||||
});
|
});
|
||||||
}, [cells, filteredPosts]);
|
}, [cells, filteredPosts]);
|
||||||
|
|
||||||
// Filtered comments organized by post
|
const filteredCommentsByPost = useMemo((): Record<string, CommentWithVoteStatus[]> => {
|
||||||
const filteredCommentsByPost = useMemo((): Record<
|
|
||||||
string,
|
|
||||||
CommentWithVoteStatus[]
|
|
||||||
> => {
|
|
||||||
const organized: Record<string, CommentWithVoteStatus[]> = {};
|
const organized: Record<string, CommentWithVoteStatus[]> = {};
|
||||||
|
|
||||||
filteredComments.forEach(comment => {
|
filteredComments.forEach(comment => {
|
||||||
if (!organized[comment.postId]) {
|
if (!organized[comment.postId]) organized[comment.postId] = [];
|
||||||
organized[comment.postId] = [];
|
|
||||||
}
|
|
||||||
organized[comment.postId]!.push(comment);
|
organized[comment.postId]!.push(comment);
|
||||||
});
|
});
|
||||||
|
|
||||||
return organized;
|
return organized;
|
||||||
}, [filteredComments]);
|
}, [filteredComments]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Raw data
|
|
||||||
cells,
|
cells,
|
||||||
posts,
|
posts,
|
||||||
comments,
|
comments,
|
||||||
userVerificationStatus,
|
userVerificationStatus,
|
||||||
|
|
||||||
// Loading states
|
|
||||||
isInitialLoading,
|
isInitialLoading,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
isNetworkConnected,
|
isNetworkConnected,
|
||||||
error,
|
error,
|
||||||
|
|
||||||
// Computed data
|
|
||||||
cellsWithStats,
|
cellsWithStats,
|
||||||
postsWithVoteStatus,
|
postsWithVoteStatus,
|
||||||
commentsWithVoteStatus,
|
commentsWithVoteStatus,
|
||||||
|
|
||||||
// Filtered data based on moderation settings
|
|
||||||
filteredPosts,
|
filteredPosts,
|
||||||
filteredComments,
|
filteredComments,
|
||||||
filteredCellsWithStats,
|
filteredCellsWithStats,
|
||||||
filteredCommentsByPost,
|
filteredCommentsByPost,
|
||||||
|
|
||||||
// Organized data
|
|
||||||
postsByCell,
|
postsByCell,
|
||||||
commentsByPost,
|
commentsByPost,
|
||||||
|
|
||||||
// User-specific data
|
|
||||||
userVotedPosts,
|
userVotedPosts,
|
||||||
userVotedComments,
|
userVotedComments,
|
||||||
userCreatedPosts,
|
userCreatedPosts,
|
||||||
userCreatedComments,
|
userCreatedComments,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useAuth } from './useAuth';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useForumData } from './useForumData';
|
import { useForumData } from './useForumData';
|
||||||
import { EVerificationStatus } from '@opchan/core';
|
import { EVerificationStatus } from '@opchan/core';
|
||||||
|
|
||||||
@ -26,9 +26,6 @@ export interface PermissionResult {
|
|||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified permission system with single source of truth for all permission logic
|
|
||||||
*/
|
|
||||||
export function usePermissions(): Permission &
|
export function usePermissions(): Permission &
|
||||||
PermissionReasons & {
|
PermissionReasons & {
|
||||||
checkPermission: (
|
checkPermission: (
|
||||||
@ -39,7 +36,6 @@ export function usePermissions(): Permission &
|
|||||||
const { currentUser, verificationStatus } = useAuth();
|
const { currentUser, verificationStatus } = useAuth();
|
||||||
const { cellsWithStats } = useForumData();
|
const { cellsWithStats } = useForumData();
|
||||||
|
|
||||||
// Single source of truth for all permission logic
|
|
||||||
const permissions = useMemo((): Permission => {
|
const permissions = useMemo((): Permission => {
|
||||||
const isWalletConnected =
|
const isWalletConnected =
|
||||||
verificationStatus === EVerificationStatus.WALLET_CONNECTED;
|
verificationStatus === EVerificationStatus.WALLET_CONNECTED;
|
||||||
@ -50,10 +46,9 @@ export function usePermissions(): Permission &
|
|||||||
canPost: isWalletConnected || isVerified,
|
canPost: isWalletConnected || isVerified,
|
||||||
canComment: isWalletConnected || isVerified,
|
canComment: isWalletConnected || isVerified,
|
||||||
canVote: isWalletConnected || isVerified,
|
canVote: isWalletConnected || isVerified,
|
||||||
canCreateCell: isVerified, // Only ENS/Ordinal owners
|
canCreateCell: isVerified,
|
||||||
canModerate: (cellId: string) => {
|
canModerate: (cellId: string) => {
|
||||||
if (!currentUser || !cellId) return false;
|
if (!currentUser || !cellId) return false;
|
||||||
// Check if user is the creator of the cell
|
|
||||||
const cell = cellsWithStats.find(c => c.id === cellId);
|
const cell = cellsWithStats.find(c => c.id === cellId);
|
||||||
return cell ? cell.author === currentUser.address : false;
|
return cell ? cell.author === currentUser.address : false;
|
||||||
},
|
},
|
||||||
@ -62,7 +57,6 @@ export function usePermissions(): Permission &
|
|||||||
};
|
};
|
||||||
}, [currentUser, verificationStatus, cellsWithStats]);
|
}, [currentUser, verificationStatus, cellsWithStats]);
|
||||||
|
|
||||||
// Single source of truth for permission reasons
|
|
||||||
const reasons = useMemo((): PermissionReasons => {
|
const reasons = useMemo((): PermissionReasons => {
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return {
|
return {
|
||||||
@ -94,9 +88,8 @@ export function usePermissions(): Permission &
|
|||||||
: 'Only cell creators can moderate';
|
: 'Only cell creators can moderate';
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [currentUser, verificationStatus, permissions]);
|
}, [currentUser, permissions]);
|
||||||
|
|
||||||
// Unified permission checker
|
|
||||||
const checkPermission = useMemo(() => {
|
const checkPermission = useMemo(() => {
|
||||||
return (action: keyof Permission, cellId?: string): PermissionResult => {
|
return (action: keyof Permission, cellId?: string): PermissionResult => {
|
||||||
let allowed = false;
|
let allowed = false;
|
||||||
@ -107,41 +100,34 @@ export function usePermissions(): Permission &
|
|||||||
allowed = permissions.canVote;
|
allowed = permissions.canVote;
|
||||||
reason = reasons.voteReason;
|
reason = reasons.voteReason;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'canPost':
|
case 'canPost':
|
||||||
allowed = permissions.canPost;
|
allowed = permissions.canPost;
|
||||||
reason = reasons.postReason;
|
reason = reasons.postReason;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'canComment':
|
case 'canComment':
|
||||||
allowed = permissions.canComment;
|
allowed = permissions.canComment;
|
||||||
reason = reasons.commentReason;
|
reason = reasons.commentReason;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'canCreateCell':
|
case 'canCreateCell':
|
||||||
allowed = permissions.canCreateCell;
|
allowed = permissions.canCreateCell;
|
||||||
reason = reasons.createCellReason;
|
reason = reasons.createCellReason;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'canModerate':
|
case 'canModerate':
|
||||||
allowed = cellId ? permissions.canModerate(cellId) : false;
|
allowed = cellId ? permissions.canModerate(cellId) : false;
|
||||||
reason = cellId ? reasons.moderateReason(cellId) : 'Cell ID required';
|
reason = cellId ? reasons.moderateReason(cellId) : 'Cell ID required';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'canDelegate':
|
case 'canDelegate':
|
||||||
allowed = permissions.canDelegate;
|
allowed = permissions.canDelegate;
|
||||||
reason = allowed
|
reason = allowed
|
||||||
? 'You can delegate keys'
|
? 'You can delegate keys'
|
||||||
: 'Connect your wallet to delegate keys';
|
: 'Connect your wallet to delegate keys';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'canUpdateProfile':
|
case 'canUpdateProfile':
|
||||||
allowed = permissions.canUpdateProfile;
|
allowed = permissions.canUpdateProfile;
|
||||||
reason = allowed
|
reason = allowed
|
||||||
? 'You can update your profile'
|
? 'You can update your profile'
|
||||||
: 'Connect your wallet to update profile';
|
: 'Connect your wallet to update profile';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
allowed = false;
|
allowed = false;
|
||||||
reason = 'Unknown permission';
|
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 { useMemo } from 'react';
|
||||||
import { useForumData, CellWithStats } from '@/hooks/core/useForumData';
|
import { useForumData, CellWithStats } from '../core/useForumData';
|
||||||
import { useAuth } from '@/hooks/core/useAuth';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { EVerificationStatus } from '@opchan/core';
|
import { EVerificationStatus } from '@opchan/core';
|
||||||
|
|
||||||
export interface CellData extends CellWithStats {
|
export interface CellData extends CellWithStats {
|
||||||
@ -18,9 +18,6 @@ export interface CellData extends CellWithStats {
|
|||||||
canPost: boolean;
|
canPost: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for getting a specific cell with its posts and permissions
|
|
||||||
*/
|
|
||||||
export function useCell(cellId: string | undefined): CellData | null {
|
export function useCell(cellId: string | undefined): CellData | null {
|
||||||
const { cellsWithStats, postsByCell, commentsByPost } = useForumData();
|
const { cellsWithStats, postsByCell, commentsByPost } = useForumData();
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
@ -33,7 +30,6 @@ export function useCell(cellId: string | undefined): CellData | null {
|
|||||||
|
|
||||||
const cellPosts = postsByCell[cellId] || [];
|
const cellPosts = postsByCell[cellId] || [];
|
||||||
|
|
||||||
// Transform posts to include comment count
|
|
||||||
const posts = cellPosts.map(post => ({
|
const posts = cellPosts.map(post => ({
|
||||||
id: post.id,
|
id: post.id,
|
||||||
title: post.title,
|
title: post.title,
|
||||||
@ -44,9 +40,8 @@ export function useCell(cellId: string | undefined): CellData | null {
|
|||||||
commentCount: (commentsByPost[post.id] || []).length,
|
commentCount: (commentsByPost[post.id] || []).length,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Check user permissions
|
|
||||||
const isUserAdmin = currentUser
|
const isUserAdmin = currentUser
|
||||||
? currentUser.address === cell.signature
|
? currentUser.address === (cell as unknown as { signature?: string }).signature
|
||||||
: false;
|
: false;
|
||||||
const canModerate = isUserAdmin;
|
const canModerate = isUserAdmin;
|
||||||
const canPost = currentUser
|
const canPost = currentUser
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useForumData, PostWithVoteStatus } from '@/hooks/core/useForumData';
|
import { useForumData, PostWithVoteStatus } from '../core/useForumData';
|
||||||
import { useAuth } from '@/hooks/core/useAuth';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useModeration } from '@/contexts/ModerationContext';
|
import { useModeration } from '../../contexts/ModerationContext';
|
||||||
|
|
||||||
export interface CellPostsOptions {
|
export interface CellPostsOptions {
|
||||||
includeModerated?: boolean;
|
includeModerated?: boolean;
|
||||||
@ -16,9 +16,6 @@ export interface CellPostsData {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for getting posts for a specific cell with filtering and sorting
|
|
||||||
*/
|
|
||||||
export function useCellPosts(
|
export function useCellPosts(
|
||||||
cellId: string | undefined,
|
cellId: string | undefined,
|
||||||
options: CellPostsOptions = {}
|
options: CellPostsOptions = {}
|
||||||
@ -49,7 +46,7 @@ export function useCellPosts(
|
|||||||
if (!includeModerated) {
|
if (!includeModerated) {
|
||||||
const cell = cellsWithStats.find(c => c.id === cellId);
|
const cell = cellsWithStats.find(c => c.id === cellId);
|
||||||
const isUserAdmin =
|
const isUserAdmin =
|
||||||
currentUser && cell && currentUser.address === cell.signature;
|
Boolean(currentUser && cell && currentUser.address === (cell as unknown as { signature?: string }).signature);
|
||||||
|
|
||||||
if (!isUserAdmin) {
|
if (!isUserAdmin) {
|
||||||
posts = posts.filter(post => !post.moderated);
|
posts = posts.filter(post => !post.moderated);
|
||||||
@ -3,8 +3,8 @@ import {
|
|||||||
useForumData,
|
useForumData,
|
||||||
PostWithVoteStatus,
|
PostWithVoteStatus,
|
||||||
CommentWithVoteStatus,
|
CommentWithVoteStatus,
|
||||||
} from '@/hooks/core/useForumData';
|
} from '../core/useForumData';
|
||||||
import { useAuth } from '@/hooks/core/useAuth';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
export interface PostData extends PostWithVoteStatus {
|
export interface PostData extends PostWithVoteStatus {
|
||||||
cell: {
|
cell: {
|
||||||
@ -17,9 +17,6 @@ export interface PostData extends PostWithVoteStatus {
|
|||||||
isUserAuthor: boolean;
|
isUserAuthor: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for getting a specific post with its comments and metadata
|
|
||||||
*/
|
|
||||||
export function usePost(postId: string | undefined): PostData | null {
|
export function usePost(postId: string | undefined): PostData | null {
|
||||||
const { postsWithVoteStatus, commentsByPost, cellsWithStats } =
|
const { postsWithVoteStatus, commentsByPost, cellsWithStats } =
|
||||||
useForumData();
|
useForumData();
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useForumData, CommentWithVoteStatus } from '@/hooks/core/useForumData';
|
import { useForumData, CommentWithVoteStatus } from '../core/useForumData';
|
||||||
import { useAuth } from '@/hooks/core/useAuth';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useModeration } from '@/contexts/ModerationContext';
|
import { useModeration } from '../../contexts/ModerationContext';
|
||||||
|
|
||||||
export interface PostCommentsOptions {
|
export interface PostCommentsOptions {
|
||||||
includeModerated?: boolean;
|
includeModerated?: boolean;
|
||||||
@ -16,9 +16,6 @@ export interface PostCommentsData {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for getting comments for a specific post with filtering and sorting
|
|
||||||
*/
|
|
||||||
export function usePostComments(
|
export function usePostComments(
|
||||||
postId: string | undefined,
|
postId: string | undefined,
|
||||||
options: PostCommentsOptions = {}
|
options: PostCommentsOptions = {}
|
||||||
@ -55,7 +52,7 @@ export function usePostComments(
|
|||||||
const post = postsWithVoteStatus.find(p => p.id === postId);
|
const post = postsWithVoteStatus.find(p => p.id === postId);
|
||||||
const cell = post ? cellsWithStats.find(c => c.id === post.cellId) : null;
|
const cell = post ? cellsWithStats.find(c => c.id === post.cellId) : null;
|
||||||
const isUserAdmin =
|
const isUserAdmin =
|
||||||
currentUser && cell && currentUser.address === cell.signature;
|
Boolean(currentUser && cell && currentUser.address === (cell as unknown as { signature?: string }).signature);
|
||||||
|
|
||||||
if (!isUserAdmin) {
|
if (!isUserAdmin) {
|
||||||
comments = comments.filter(comment => !comment.moderated);
|
comments = comments.filter(comment => !comment.moderated);
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useForumData } from '@/hooks/core/useForumData';
|
import { useForumData } from '../core/useForumData';
|
||||||
import { useAuth } from '@/hooks/core/useAuth';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
export interface UserVoteData {
|
export interface UserVoteData {
|
||||||
// Vote status for specific items
|
// Vote status for specific items
|
||||||
@ -22,9 +22,6 @@ export interface UserVoteData {
|
|||||||
upvoteRatio: number;
|
upvoteRatio: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for getting user's voting status and history
|
|
||||||
*/
|
|
||||||
export function useUserVotes(userAddress?: string): UserVoteData {
|
export function useUserVotes(userAddress?: string): UserVoteData {
|
||||||
const { postsWithVoteStatus, commentsWithVoteStatus } = useForumData();
|
const { postsWithVoteStatus, commentsWithVoteStatus } = useForumData();
|
||||||
const { currentUser } = useAuth();
|
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 { useCallback, useState, useEffect } from 'react';
|
||||||
import { AuthContext } from '@/contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { DelegationDuration } from '@opchan/core';
|
import { DelegationDuration } from '@opchan/core';
|
||||||
|
|
||||||
export const useDelegation = () => {
|
export const useDelegation = () => {
|
||||||
const context = useContext(AuthContext);
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useDelegation must be used within an AuthProvider');
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
delegateKey: contextDelegateKey,
|
delegateKey: contextDelegateKey,
|
||||||
getDelegationStatus: contextGetDelegationStatus,
|
getDelegationStatus: contextGetDelegationStatus,
|
||||||
clearDelegation: contextClearDelegation,
|
clearDelegation: contextClearDelegation,
|
||||||
isAuthenticating,
|
isAuthenticating,
|
||||||
} = context;
|
} = useAuth();
|
||||||
|
|
||||||
const createDelegation = useCallback(
|
const createDelegation = useCallback(
|
||||||
async (duration?: DelegationDuration): Promise<boolean> => {
|
async (duration?: DelegationDuration): Promise<boolean> => {
|
||||||
@ -23,8 +17,8 @@ export const useDelegation = () => {
|
|||||||
[contextDelegateKey]
|
[contextDelegateKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearDelegation = useCallback((): void => {
|
const clearDelegation = useCallback(async (): Promise<void> => {
|
||||||
contextClearDelegation();
|
await contextClearDelegation();
|
||||||
}, [contextClearDelegation]);
|
}, [contextClearDelegation]);
|
||||||
|
|
||||||
const [delegationStatus, setDelegationStatus] = useState<{
|
const [delegationStatus, setDelegationStatus] = useState<{
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { ForumData } from '@/hooks/core/useForumData';
|
import { ForumData } from '../core/useForumData';
|
||||||
import { Cell, Post, Comment } from '@opchan/core';
|
import { Cell, Post, Comment } from '@opchan/core';
|
||||||
import { EVerificationStatus } from '@opchan/core';
|
import { EVerificationStatus } from '@opchan/core';
|
||||||
|
|
||||||
@ -108,7 +108,7 @@ export function useForumSelectors(forumData: ForumData): ForumSelectors {
|
|||||||
|
|
||||||
const selectCellsByOwner = useMemo(() => {
|
const selectCellsByOwner = useMemo(() => {
|
||||||
return (ownerAddress: string): Cell[] => {
|
return (ownerAddress: string): Cell[] => {
|
||||||
return cells.filter(cell => cell.signature === ownerAddress);
|
return cells.filter(cell => (cell as unknown as { signature?: string }).signature === ownerAddress);
|
||||||
};
|
};
|
||||||
}, [cells]);
|
}, [cells]);
|
||||||
|
|
||||||
@ -127,25 +127,24 @@ export function useForumSelectors(forumData: ForumData): ForumSelectors {
|
|||||||
|
|
||||||
const selectPostsByVoteScore = useMemo(() => {
|
const selectPostsByVoteScore = useMemo(() => {
|
||||||
return (minScore: number = 0): Post[] => {
|
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]);
|
}, [posts]);
|
||||||
|
|
||||||
const selectTrendingPosts = useMemo(() => {
|
const selectTrendingPosts = useMemo(() => {
|
||||||
return (timeframe: number = 7 * 24 * 60 * 60 * 1000): Post[] => {
|
return (timeframe: number = 7 * 24 * 60 * 60 * 1000): Post[] => {
|
||||||
// 7 days default
|
|
||||||
const cutoff = Date.now() - timeframe;
|
const cutoff = Date.now() - timeframe;
|
||||||
return posts
|
return posts
|
||||||
.filter(post => post.timestamp > cutoff)
|
.filter(post => post.timestamp > cutoff)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// Sort by relevance score if available, otherwise by vote score
|
|
||||||
if (
|
if (
|
||||||
a.relevanceScore !== undefined &&
|
a.relevanceScore !== undefined &&
|
||||||
b.relevanceScore !== undefined
|
b.relevanceScore !== undefined
|
||||||
) {
|
) {
|
||||||
return b.relevanceScore - a.relevanceScore;
|
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]);
|
}, [posts]);
|
||||||
@ -223,7 +222,6 @@ export function useForumSelectors(forumData: ForumData): ForumSelectors {
|
|||||||
|
|
||||||
const selectActiveUsers = useMemo(() => {
|
const selectActiveUsers = useMemo(() => {
|
||||||
return (timeframe: number = 7 * 24 * 60 * 60 * 1000): string[] => {
|
return (timeframe: number = 7 * 24 * 60 * 60 * 1000): string[] => {
|
||||||
// 7 days default
|
|
||||||
const cutoff = Date.now() - timeframe;
|
const cutoff = Date.now() - timeframe;
|
||||||
const activeUsers = new Set<string>();
|
const activeUsers = new Set<string>();
|
||||||
|
|
||||||
@ -1,30 +1,24 @@
|
|||||||
import { useCallback, useContext } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { AuthContext } from '@/contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { OpchanMessage } from '@opchan/core';
|
import { OpchanMessage } from '@opchan/core';
|
||||||
|
|
||||||
export const useMessageSigning = () => {
|
export const useMessageSigning = () => {
|
||||||
const context = useContext(AuthContext);
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useMessageSigning must be used within an AuthProvider');
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
signMessage: contextSignMessage,
|
signMessage: contextSignMessage,
|
||||||
verifyMessage: contextVerifyMessage,
|
verifyMessage: contextVerifyMessage,
|
||||||
getDelegationStatus,
|
getDelegationStatus,
|
||||||
} = context;
|
} = useAuth();
|
||||||
|
|
||||||
const signMessage = useCallback(
|
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
|
// Check if we have a valid delegation before attempting to sign
|
||||||
const delegationStatus = await getDelegationStatus();
|
const delegationStatus = await getDelegationStatus();
|
||||||
if (!delegationStatus.isValid) {
|
if (!delegationStatus.isValid) {
|
||||||
console.warn('No valid delegation found. Cannot sign message.');
|
console.warn('No valid delegation found. Cannot sign message.');
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return contextSignMessage(message);
|
await contextSignMessage(message);
|
||||||
},
|
},
|
||||||
[contextSignMessage, getDelegationStatus]
|
[contextSignMessage, getDelegationStatus]
|
||||||
);
|
);
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { useMemo, useState, useEffect } from 'react';
|
import { useMemo, useState, useEffect } from 'react';
|
||||||
import { useForum } from '@/contexts/useForum';
|
import { useForum } from '../../contexts/ForumContext';
|
||||||
import { useAuth } from '@/hooks/core/useAuth';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useAuth as useAuthContext } from '@/contexts/useAuth';
|
|
||||||
import { DelegationFullStatus } from '@opchan/core';
|
import { DelegationFullStatus } from '@opchan/core';
|
||||||
|
|
||||||
export interface NetworkHealth {
|
export interface NetworkHealth {
|
||||||
@ -56,15 +55,11 @@ export interface NetworkStatusData {
|
|||||||
getRecommendedActions: () => string[];
|
getRecommendedActions: () => string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for monitoring network status and connection health
|
|
||||||
*/
|
|
||||||
export function useNetworkStatus(): NetworkStatusData {
|
export function useNetworkStatus(): NetworkStatusData {
|
||||||
const { isNetworkConnected, isInitialLoading, isRefreshing, error } =
|
const { isNetworkConnected, isInitialLoading, isRefreshing, error } =
|
||||||
useForum();
|
useForum();
|
||||||
|
|
||||||
const { isAuthenticated, currentUser } = useAuth();
|
const { isAuthenticated, currentUser, getDelegationStatus } = useAuth();
|
||||||
const { getDelegationStatus } = useAuthContext();
|
|
||||||
const [delegationInfo, setDelegationInfo] =
|
const [delegationInfo, setDelegationInfo] =
|
||||||
useState<DelegationFullStatus | null>(null);
|
useState<DelegationFullStatus | null>(null);
|
||||||
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { localDatabase } from '@opchan/core';
|
import { localDatabase } from '@opchan/core';
|
||||||
import { useAuth } from '@/contexts/useAuth';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
export function usePending(id: string | undefined) {
|
export function usePending(id: string | undefined) {
|
||||||
const [isPending, setIsPending] = useState<boolean>(
|
const [isPending, setIsPending] = useState<boolean>(
|
||||||
@ -9,10 +9,6 @@ export interface WakuHealthState {
|
|||||||
connectionStatus: 'connecting' | 'connected' | 'disconnected' | 'error';
|
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 {
|
export function useWakuHealth(): WakuHealthState {
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
const [health, setHealth] = useState<HealthStatus>(HealthStatus.Unhealthy);
|
const [health, setHealth] = useState<HealthStatus>(HealthStatus.Unhealthy);
|
||||||
@ -26,7 +22,6 @@ export function useWakuHealth(): WakuHealthState {
|
|||||||
setIsReady(ready);
|
setIsReady(ready);
|
||||||
setHealth(currentHealth);
|
setHealth(currentHealth);
|
||||||
|
|
||||||
// Update connection status based on health
|
|
||||||
if (ready) {
|
if (ready) {
|
||||||
setConnectionStatus('connected');
|
setConnectionStatus('connected');
|
||||||
} else if (currentHealth === HealthStatus.Unhealthy) {
|
} else if (currentHealth === HealthStatus.Unhealthy) {
|
||||||
@ -39,17 +34,14 @@ export function useWakuHealth(): WakuHealthState {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if messageManager is initialized
|
|
||||||
try {
|
try {
|
||||||
const currentHealth = messageManager.currentHealth;
|
const currentHealth = messageManager.currentHealth ?? HealthStatus.Unhealthy;
|
||||||
const currentReady = messageManager.isReady;
|
const currentReady = messageManager.isReady;
|
||||||
|
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
updateHealth(currentReady, currentHealth);
|
updateHealth(currentReady, currentHealth);
|
||||||
|
|
||||||
// Subscribe to health changes
|
|
||||||
const unsubscribe = messageManager.onHealthChange(updateHealth);
|
const unsubscribe = messageManager.onHealthChange(updateHealth);
|
||||||
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize Waku health monitoring:', 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 {
|
export function useWakuReady(): boolean {
|
||||||
const { isReady } = useWakuHealth();
|
const { isReady } = useWakuHealth();
|
||||||
return isReady;
|
return isReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook that provides health status with human-readable descriptions
|
|
||||||
*/
|
|
||||||
export function useWakuHealthStatus() {
|
export function useWakuHealthStatus() {
|
||||||
const { isReady, health, connectionStatus } = useWakuHealth();
|
const { isReady, health, connectionStatus } = useWakuHealth();
|
||||||
|
|
||||||
@ -1,21 +1,15 @@
|
|||||||
import { useCallback, useContext } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { AuthContext } from '@/contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { modal } from '@reown/appkit/react';
|
import { modal } from '@reown/appkit/react';
|
||||||
|
|
||||||
export const useWallet = () => {
|
export const useWallet = () => {
|
||||||
const context = useContext(AuthContext);
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useWallet must be used within an AuthProvider');
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
currentUser,
|
currentUser,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
verificationStatus,
|
verificationStatus,
|
||||||
connectWallet: contextConnectWallet,
|
connectWallet: contextConnectWallet,
|
||||||
disconnectWallet: contextDisconnectWallet,
|
disconnectWallet: contextDisconnectWallet,
|
||||||
} = context;
|
} = useAuth();
|
||||||
|
|
||||||
const connect = useCallback(async (): Promise<boolean> => {
|
const connect = useCallback(async (): Promise<boolean> => {
|
||||||
return contextConnectWallet();
|
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,
|
"declarationMap": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"jsx": "react-jsx",
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"noEmitOnError": true,
|
"noEmitOnError": true,
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user