fix: build and memory leak

This commit is contained in:
Danish Arora 2025-09-18 22:04:40 +05:30
parent cc29a30bd9
commit 9911c9c55e
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
31 changed files with 817 additions and 1599 deletions

View File

@ -1,6 +1,6 @@
import { useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useForumData, useForumActions, usePermissions } from '@/hooks';
import { useForumData, usePermissions } from '@/hooks';
import {
Layout,
MessageSquare,
@ -141,7 +141,7 @@ const CellItem: React.FC<{ cell: Cell }> = ({ cell }) => {
const CellList = () => {
const { cellsWithStats, isInitialLoading } = useForumData();
const { refreshData } = useForumActions();
const { content } = useForum();
const { canCreateCell } = usePermissions();
const [sortOption, setSortOption] = useState<SortOption>('relevance');
@ -221,7 +221,7 @@ const CellList = () => {
<Button
variant="outline"
size="icon"
onClick={refreshData}
onClick={content.refresh}
disabled={isInitialLoading}
title="Refresh data"
className="px-3 border-cyber-muted/30 text-cyber-neutral hover:bg-cyber-muted/30"

View File

@ -3,7 +3,8 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Loader2 } from 'lucide-react';
import { useForumActions, usePermissions } from '@/hooks';
import { usePermissions } from '@/hooks';
import { useForum } from '@opchan/react';
import {
Form,
FormControl,
@ -57,7 +58,9 @@ export function CreateCellDialog({
open: externalOpen,
onOpenChange,
}: CreateCellDialogProps = {}) {
const { createCell, isCreatingCell } = useForumActions();
const forum = useForum();
const {createCell} = forum.content;
const isCreatingCell = false;
const { canCreateCell } = usePermissions();
const { toast } = useToast();
const [internalOpen, setInternalOpen] = React.useState(false);
@ -84,12 +87,11 @@ export function CreateCellDialog({
return;
}
// ✅ All validation handled in hook
const cell = await createCell(
values.title,
values.description,
values.icon
);
const cell = await createCell({
name: values.title,
description: values.description,
icon: values.icon,
});
if (cell) {
form.reset();
setOpen(false);

View File

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

View File

@ -1,14 +1,7 @@
import React, { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import {
useCell,
useCellPosts,
useForumActions,
usePermissions,
useUserVotes,
useAuth,
useForumData,
} from '@/hooks';
import { useCell, useCellPosts, usePermissions, useUserVotes, useAuth, useForumData } from '@/hooks';
import { useForum } from '@opchan/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
@ -40,16 +33,10 @@ const PostList = () => {
// ✅ Use reactive hooks for data and actions
const cell = useCell(cellId);
const cellPosts = useCellPosts(cellId, { sortBy: 'relevance' });
const {
createPost,
votePost,
moderatePost,
unmoderatePost,
moderateUser,
refreshData,
isCreatingPost,
isVoting,
} = useForumActions();
const forum = useForum();
const { createPost, vote, moderate, refresh } = forum.content;
const isCreatingPost = false;
const isVoting = false;
const { canPost, canVote, canModerate } = usePermissions();
const userVotes = useUserVotes();
const { currentUser } = useAuth();
@ -117,7 +104,7 @@ const PostList = () => {
if (!newPostContent.trim()) return;
// ✅ All validation handled in hook
const post = await createPost(cellId, newPostTitle, newPostContent);
const post = await createPost({ cellId, title: newPostTitle, content: newPostContent });
if (post) {
setNewPostTitle('');
setNewPostContent('');
@ -139,7 +126,7 @@ const PostList = () => {
const handleVotePost = async (postId: string, isUpvote: boolean) => {
// ✅ Permission checking handled in hook
await votePost(postId, isUpvote);
await vote({ targetId: postId, isUpvote });
};
const getPostVoteType = (postId: string) => {
@ -154,14 +141,14 @@ const PostList = () => {
window.prompt('Enter a reason for moderation (optional):') || undefined;
if (!cell) return;
// ✅ All validation handled in hook
await moderatePost(cell.id, postId, reason);
await moderate.post(cell.id, postId, reason);
};
const handleUnmoderate = async (postId: string) => {
const reason =
window.prompt('Optional note for unmoderation?') || undefined;
if (!cell) return;
await unmoderatePost(cell.id, postId, reason);
await moderate.unpost(cell.id, postId, reason);
};
const handleModerateUser = async (userAddress: string) => {
@ -169,7 +156,7 @@ const PostList = () => {
window.prompt('Reason for moderating this user? (optional)') || undefined;
if (!cell) return;
// ✅ All validation handled in hook
await moderateUser(cell.id, userAddress, reason);
await moderate.user(cell.id, userAddress, reason);
};
return (
@ -196,7 +183,7 @@ const PostList = () => {
<Button
variant="outline"
size="icon"
onClick={refreshData}
onClick={refresh}
disabled={cellPosts.isLoading}
title="Refresh data"
>

View File

@ -3,7 +3,8 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Loader2, User, Hash } from 'lucide-react';
import { useAuth, useUserActions, useForumActions } from '@/hooks';
import { useAuth } from '@/hooks';
import { useForum } from '@opchan/react';
import {
Form,
FormControl,
@ -55,8 +56,9 @@ export function CallSignSetupDialog({
onOpenChange,
}: CallSignSetupDialogProps = {}) {
const { currentUser } = useAuth();
const { updateProfile } = useUserActions();
const { refreshData } = useForumActions();
const forum = useForum();
const { updateProfile } = forum.user;
const { refresh } = forum.content;
const { toast } = useToast();
const [internalOpen, setInternalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
@ -93,7 +95,7 @@ export function CallSignSetupDialog({
if (success) {
// Refresh forum data to update user display
await refreshData();
await refresh();
setOpen(false);
form.reset();
}

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Button } from './button';
import { useAuth, useAuthActions } from '@opchan/react';
import { useAuth } from '@opchan/react';
import { CheckCircle, AlertCircle, Trash2 } from 'lucide-react';
import { DelegationDuration, DelegationFullStatus } from '@opchan/core';
@ -20,7 +20,7 @@ export function DelegationStep({
const { currentUser, isAuthenticating, getDelegationStatus } = useAuth();
const [delegationInfo, setDelegationInfo] =
useState<DelegationFullStatus | null>(null);
const { delegateKey, clearDelegation } = useAuthActions();
const { delegateKey, clearDelegation } = useAuth();
// Load delegation status
useEffect(() => {

View File

@ -8,7 +8,7 @@ import {
Loader2,
AlertCircle,
} from 'lucide-react';
import { useAuth, useAuthActions } from '@/hooks';
import { useAuth } from '@/hooks';
import { EVerificationStatus } from '@opchan/core';
import { useAppKitAccount } from '@reown/appkit/react';
import { OrdinalDetails, EnsDetails } from '@opchan/core';
@ -26,8 +26,7 @@ export function VerificationStep({
isLoading,
setIsLoading,
}: VerificationStepProps) {
const { currentUser, verificationStatus, isAuthenticating } = useAuth();
const { verifyWallet } = useAuthActions();
const { currentUser, verificationStatus, isAuthenticating, verifyOwnership } = useAuth();
// Get account info to determine wallet type
const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' });
@ -99,7 +98,7 @@ export function VerificationStep({
try {
console.log('📞 Calling verifyWallet()...');
const success = await verifyWallet();
const success = await verifyOwnership();
console.log('📊 verifyWallet returned:', success);
if (success) {

View File

@ -1,5 +1,5 @@
import { Wifi, WifiOff, AlertTriangle, CheckCircle } from 'lucide-react';
import { useWakuHealthStatus } from '@opchan/react';
import { useNetworkStatus } from '@opchan/react';
import { cn } from '@opchan/core';
interface WakuHealthIndicatorProps {
@ -13,8 +13,12 @@ export function WakuHealthIndicator({
showText = true,
size = 'md',
}: WakuHealthIndicatorProps) {
const { connectionStatus, statusColor, statusMessage } =
useWakuHealthStatus();
const network = useNetworkStatus();
const connectionStatus = network.health.isConnected
? 'connected'
: 'disconnected';
const statusColor = network.getHealthColor();
const statusMessage = network.getStatusMessage();
const getIcon = () => {
switch (connectionStatus) {
@ -66,7 +70,8 @@ export function WakuHealthIndicator({
* Useful for compact displays like headers or status bars
*/
export function WakuHealthDot({ className }: { className?: string }) {
const { statusColor } = useWakuHealthStatus();
const { getHealthColor } = useNetworkStatus();
const statusColor = getHealthColor();
return (
<div

View File

@ -9,8 +9,7 @@ import {
import { Button } from '@/components/ui/button';
import { CheckCircle, Circle, Loader2 } from 'lucide-react';
import { useAuth } from '@/hooks';
import { useDelegation } from '@opchan/react';
import { EVerificationStatus } from '@opchan/core';
import { EVerificationStatus, DelegationFullStatus } from '@opchan/core';
import { WalletConnectionStep } from './wallet-connection-step';
import { VerificationStep } from './verification-step';
import { DelegationStep } from './delegation-step';
@ -30,8 +29,12 @@ export function WalletWizard({
}: WalletWizardProps) {
const [currentStep, setCurrentStep] = React.useState<WizardStep>(1);
const [isLoading, setIsLoading] = React.useState(false);
const { isAuthenticated, verificationStatus } = useAuth();
const { delegationStatus } = useDelegation();
const { isAuthenticated, verificationStatus, getDelegationStatus } = useAuth();
const [delegationStatus, setDelegationStatus] = React.useState<DelegationFullStatus | null>(null);
React.useEffect(() => {
getDelegationStatus().then(setDelegationStatus).catch(console.error);
}, [getDelegationStatus]);
// Reset wizard when opened - always start at step 1 for simplicity
React.useEffect(() => {
@ -65,7 +68,7 @@ export function WalletWizard({
case 2:
return verificationStatus !== EVerificationStatus.WALLET_UNCONNECTED;
case 3:
return delegationStatus.isValid;
return delegationStatus?.isValid ?? false;
default:
return false;
}

View File

@ -1,4 +1,4 @@
// Core hooks - Re-exported from @opchan/react
export {
useForumData,
useAuth,
@ -8,7 +8,6 @@ export {
useCommentBookmark,
} from '@opchan/react';
// Core types - Re-exported from @opchan/react
export type {
ForumData,
CellWithStats,
@ -20,11 +19,9 @@ export type {
UserDisplayInfo,
} from '@opchan/react';
// Derived hooks - Re-exported from @opchan/react
export { useCell, usePost } from '@opchan/react';
export type { CellData, PostData } from '@opchan/react';
// Derived hooks - Re-exported from @opchan/react
export { useCellPosts, usePostComments, useUserVotes } from '@opchan/react';
export type {
CellPostsOptions,
@ -34,26 +31,11 @@ export type {
UserVoteData,
} from '@opchan/react';
// Action hooks - Re-exported from @opchan/react
export { useForumActions, useUserActions, useAuthActions } from '@opchan/react';
export type {
ForumActionStates,
ForumActions,
UserActionStates,
UserActions,
AuthActionStates,
AuthActions,
} from '@opchan/react';
// Utility hooks - Re-exported from @opchan/react
export {
usePermissions,
useNetworkStatus,
useForumSelectors,
useDelegation,
useMessageSigning,
usePending,
usePendingVote,
useWallet,
} from '@opchan/react';
export type {
@ -64,17 +46,7 @@ export type {
ForumSelectors,
} from '@opchan/react';
// Legacy hooks (for backward compatibility - will be removed)
// export { useForum } from '@/contexts/useForum'; // Use useForumData instead
// export { useAuth as useLegacyAuth } from '@/contexts/useAuth'; // Use enhanced useAuth instead
// Re-export existing hooks that don't need changes (UI-specific)
export { useIsMobile as useMobile } from './use-mobile';
export { useToast } from './use-toast';
// Waku health hooks - Re-exported from @opchan/react
export {
useWakuHealth,
useWakuReady,
useWakuHealthStatus,
} from '@opchan/react';

View File

@ -12,14 +12,15 @@ import {
import PostCard from '@/components/PostCard';
import FeedSidebar from '@/components/FeedSidebar';
import { ModerationToggle } from '@/components/ui/moderation-toggle';
import { useForumData, useAuth, useForumActions } from '@/hooks';
import { useForumData, useAuth } from '@/hooks';
import { useForum } from '@opchan/react';
import { EVerificationStatus } from '@opchan/core';
import { sortPosts, SortOption } from '@opchan/core';
const FeedPage: React.FC = () => {
const forumData = useForumData();
const { verificationStatus } = useAuth();
const { refreshData } = useForumActions();
const { content } = useForum();
const [sortOption, setSortOption] = useState<SortOption>('relevance');
const {
@ -136,7 +137,7 @@ const FeedPage: React.FC = () => {
<Button
variant="outline"
size="sm"
onClick={refreshData}
onClick={content.refresh}
disabled={isRefreshing}
className="flex items-center space-x-2"
>

View File

@ -1,13 +1,11 @@
import Header from '@/components/Header';
import CellList from '@/components/CellList';
import { useForumActions } from '@/hooks';
import { Button } from '@/components/ui/button';
import { Wifi } from 'lucide-react';
import { useForum } from '@opchan/react';
const Index = () => {
const {network} = useForum()
const { refreshData } = useForumActions();
const { network, content } = useForum();
return (
<div className="page-container">
@ -17,7 +15,7 @@ const Index = () => {
{!network.isConnected && (
<div className="fixed bottom-4 right-4">
<Button
onClick={refreshData}
onClick={content.refresh}
variant="destructive"
className="flex items-center gap-2 shadow-lg animate-pulse"
>

View File

@ -1,8 +1,7 @@
import { useState, useEffect } from 'react';
import { useUserActions, useForumActions } from '@/hooks';
import { useForum } from '@opchan/react';
import { useAuth } from '@opchan/react';
import { useUserDisplay } from '@/hooks';
import { useDelegation } from '@opchan/react';
import { DelegationFullStatus } from '@opchan/core';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -36,13 +35,13 @@ import { EDisplayPreference, EVerificationStatus } from '@opchan/core';
import { useToast } from '@/hooks/use-toast';
export default function ProfilePage() {
const { updateProfile } = useUserActions();
const { refreshData } = useForumActions();
const forum = useForum();
const { updateProfile } = forum.user;
const { refresh } = forum.content;
const { toast } = useToast();
// Get current user from auth context for the address
const { currentUser, getDelegationStatus } = useAuth();
const { delegationStatus } = useDelegation();
const [delegationInfo, setDelegationInfo] =
useState<DelegationFullStatus | null>(null);
const address = currentUser?.address;
@ -171,7 +170,7 @@ export default function ProfilePage() {
});
if (success) {
await refreshData();
await refresh();
setIsEditing(false);
toast({
title: 'Profile Updated',
@ -494,7 +493,7 @@ export default function ProfilePage() {
<Shield className="h-5 w-5 text-cyber-accent" />
Security
</div>
{(delegationStatus.hasDelegation ||
{(forum.user.delegation.hasDelegation ||
delegationInfo?.hasDelegation) && (
<Button
variant="outline"
@ -503,7 +502,7 @@ export default function ProfilePage() {
className="border-cyber-muted/30 text-cyber-neutral hover:bg-cyber-muted/30"
>
<Settings className="w-4 h-4 mr-2" />
{delegationStatus.isValid || delegationInfo?.isValid
{forum.user.delegation.isValid || delegationInfo?.isValid
? 'Renew'
: 'Setup'}
</Button>
@ -519,24 +518,24 @@ export default function ProfilePage() {
</span>
<Badge
variant={
delegationStatus.isValid || delegationInfo?.isValid
forum.user.delegation.isValid || delegationInfo?.isValid
? 'default'
: 'secondary'
}
className={
delegationStatus.isValid || delegationInfo?.isValid
forum.user.delegation.isValid || delegationInfo?.isValid
? 'bg-green-500/20 text-green-400 border-green-500/30'
: 'bg-red-500/20 text-red-400 border-red-500/30'
}
>
{delegationStatus.isValid || delegationInfo?.isValid
{forum.user.delegation.isValid || delegationInfo?.isValid
? 'Active'
: 'Inactive'}
</Badge>
</div>
{/* Expiry Date */}
{(delegationStatus.expiresAt ||
{(forum.user.delegation.expiresAt ||
currentUser.delegationExpiry) && (
<div className="space-y-1">
<span className="text-xs text-cyber-neutral">
@ -544,7 +543,7 @@ export default function ProfilePage() {
</span>
<div className="text-sm font-mono text-cyber-light">
{(
delegationStatus.expiresAt ||
forum.user.delegation.expiresAt ||
new Date(currentUser.delegationExpiry!)
).toLocaleDateString()}
</div>
@ -559,13 +558,13 @@ export default function ProfilePage() {
<Badge
variant="outline"
className={
delegationStatus.isValid ||
forum.user.delegation.isValid ||
currentUser.delegationSignature === 'valid'
? 'text-green-400 border-green-500/30 bg-green-500/10'
: 'text-red-400 border-red-500/30 bg-red-500/10'
}
>
{delegationStatus.isValid ||
{forum.user.delegation.isValid ||
currentUser.delegationSignature === 'valid'
? 'Valid'
: 'Not signed'}
@ -580,18 +579,18 @@ export default function ProfilePage() {
</Label>
<div className="flex items-center gap-2">
<div className="flex-1 font-mono text-xs bg-cyber-dark/50 border border-cyber-muted/30 px-2 py-1 rounded text-cyber-light">
{delegationStatus.publicKey || currentUser.browserPubKey
? `${(delegationStatus.publicKey || currentUser.browserPubKey!).slice(0, 12)}...${(delegationStatus.publicKey || currentUser.browserPubKey!).slice(-8)}`
{forum.user.delegation.publicKey || currentUser.browserPubKey
? `${(forum.user.delegation.publicKey || currentUser.browserPubKey!).slice(0, 12)}...${(forum.user.delegation.publicKey || currentUser.browserPubKey!).slice(-8)}`
: 'Not delegated'}
</div>
{(delegationStatus.publicKey ||
{(forum.user.delegation.publicKey ||
currentUser.browserPubKey) && (
<Button
variant="outline"
size="sm"
onClick={() =>
copyToClipboard(
delegationStatus.publicKey ||
forum.user.delegation.publicKey ||
currentUser.browserPubKey!,
'Public Key'
)
@ -605,8 +604,8 @@ export default function ProfilePage() {
</div>
{/* Warning for expired delegation */}
{(!delegationStatus.isValid &&
delegationStatus.hasDelegation) ||
{(!forum.user.delegation.isValid &&
forum.user.delegation.hasDelegation) ||
(!delegationInfo?.isValid &&
delegationInfo?.hasDelegation && (
<div className="p-3 bg-orange-500/10 border border-orange-500/30 rounded-md">

View File

@ -43,82 +43,42 @@ export const AuthProvider: React.FC<{
// 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;
// Centralize identity resolution in core service
const identity = await client.userIdentityService.getUserIdentityFresh(currentUser.address);
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 newVerificationStatus = identity?.verificationStatus ?? EVerificationStatus.WALLET_CONNECTED;
const updatedUser = {
...currentUser,
verificationStatus: newVerificationStatus,
ensDetails: ensName ? { ensName } : undefined,
ordinalDetails,
};
ensDetails: identity?.ensName ? { ensName: identity.ensName } : undefined,
ordinalDetails: identity?.ordinalDetails,
} as User;
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,
ensName: identity?.ensName || undefined,
ordinalDetails: identity?.ordinalDetails,
verificationStatus: newVerificationStatus,
lastUpdated: Date.now(),
});
console.log('✅ Verification completed successfully, hasOwnership:', hasOwnership);
return hasOwnership;
return newVerificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED;
} catch (error) {
console.error('❌ Verification failed:', error);
// Fall back to wallet connected status
const updatedUser = { ...currentUser, verificationStatus: EVerificationStatus.WALLET_CONNECTED };
const updatedUser = { ...currentUser, verificationStatus: EVerificationStatus.WALLET_CONNECTED } as User;
setCurrentUser(updatedUser);
await localDatabase.storeUser(updatedUser);
return false;
}
}, [currentUser]);
}, [client.userIdentityService, currentUser]);
// Hydrate user from LocalDatabase on mount
useEffect(() => {

View File

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

View File

@ -1,5 +1,5 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { localDatabase, messageManager, ForumActions, OpChanClient, getDataFromCache } from '@opchan/core';
import { localDatabase, 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';
@ -63,47 +63,58 @@ export const ForumProvider: React.FC<{
}
}, [updateFromCache]);
// 1) Initial cache hydrate only decoupled from network subscriptions
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();
}, [updateFromCache]);
// 2) Network wiring subscribe once to the client's message manager
useEffect(() => {
let unsubHealth: (() => void) | null = null;
let unsubMsg: (() => void) | null = null;
// Check initial health status from the provided client to ensure a single core instance
const initialHealth = client.messageManager.currentHealth;
const initialReady = client.messageManager.isReady;
console.log('🔌 ForumContext initial state:', { initialReady, initialHealth });
setIsNetworkConnected(!!initialReady);
unsubHealth = client.messageManager.onHealthChange((ready: boolean, health: any) => {
console.log('🔌 ForumContext health change:', { ready, health });
setIsNetworkConnected(!!ready);
});
unsubMsg = client.messageManager.onMessageReceived(async () => {
await updateFromCache();
});
return () => {
try { unsubHealth && unsubHealth(); } catch {}
try { unsubMsg && unsubMsg(); } catch {}
};
}, [updateFromCache]);
}, [client, updateFromCache]);
// 3) Visibility change: re-check connection immediately when tab becomes active
useEffect(() => {
const handleVisibility = () => {
if (document.visibilityState === 'visible') {
const ready = client.messageManager.isReady;
setIsNetworkConnected(!!ready);
console.debug('🔌 ForumContext visibility check, ready:', ready);
}
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, [client]);
const ctx: ForumContextValue = useMemo(() => ({
cells,

View File

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

View File

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

View File

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

View File

@ -1,240 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { Bookmark, BookmarkType, Post, Comment, BookmarkService } from '@opchan/core';
import { useAuth } from '../../contexts/AuthContext';
export function useBookmarks() {
const { currentUser } = useAuth();
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadBookmarks = useCallback(async () => {
if (!currentUser?.address) return;
setLoading(true);
setError(null);
try {
const userBookmarks = await BookmarkService.getUserBookmarks(
currentUser.address
);
setBookmarks(userBookmarks);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load bookmarks');
} finally {
setLoading(false);
}
}, [currentUser?.address]);
useEffect(() => {
if (currentUser?.address) {
loadBookmarks();
} else {
setBookmarks([]);
}
}, [currentUser?.address, loadBookmarks]);
const bookmarkPost = useCallback(
async (post: Post, cellId?: string) => {
if (!currentUser?.address) return false;
try {
const isBookmarked = await BookmarkService.togglePostBookmark(
post,
currentUser.address,
cellId
);
await loadBookmarks();
return isBookmarked;
} catch (err) {
setError(
err instanceof Error ? err.message : 'Failed to bookmark post'
);
return false;
}
},
[currentUser?.address, loadBookmarks]
);
const bookmarkComment = useCallback(
async (comment: Comment, postId?: string) => {
if (!currentUser?.address) return false;
try {
const isBookmarked = await BookmarkService.toggleCommentBookmark(
comment,
currentUser.address,
postId
);
await loadBookmarks();
return isBookmarked;
} catch (err) {
setError(
err instanceof Error ? err.message : 'Failed to bookmark comment'
);
return false;
}
},
[currentUser?.address, loadBookmarks]
);
const removeBookmark = useCallback(
async (bookmarkId: string) => {
try {
const bookmark = BookmarkService.getBookmark(bookmarkId);
if (bookmark) {
await BookmarkService.removeBookmark(
bookmark.type,
bookmark.targetId
);
await loadBookmarks();
}
} catch (err) {
setError(
err instanceof Error ? err.message : 'Failed to remove bookmark'
);
}
},
[loadBookmarks]
);
const isPostBookmarked = useCallback(
(postId: string) => {
if (!currentUser?.address) return false;
return BookmarkService.isPostBookmarked(currentUser.address, postId);
},
[currentUser?.address]
);
const isCommentBookmarked = useCallback(
(commentId: string) => {
if (!currentUser?.address) return false;
return BookmarkService.isCommentBookmarked(
currentUser.address,
commentId
);
},
[currentUser?.address]
);
const getBookmarksByType = useCallback(
(type: BookmarkType) => {
return bookmarks.filter(bookmark => bookmark.type === type);
},
[bookmarks]
);
const clearAllBookmarks = useCallback(async () => {
if (!currentUser?.address) return;
try {
await BookmarkService.clearUserBookmarks(currentUser.address);
setBookmarks([]);
} catch (err) {
setError(
err instanceof Error ? err.message : 'Failed to clear bookmarks'
);
}
}, [currentUser?.address]);
return {
bookmarks,
loading,
error,
bookmarkPost,
bookmarkComment,
removeBookmark,
isPostBookmarked,
isCommentBookmarked,
getBookmarksByType,
clearAllBookmarks,
refreshBookmarks: loadBookmarks,
};
}
export function usePostBookmark(post: Post | null, cellId?: string) {
const { currentUser } = useAuth();
const [isBookmarked, setIsBookmarked] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (currentUser?.address && post?.id) {
const bookmarked = BookmarkService.isPostBookmarked(
currentUser.address,
post.id
);
setIsBookmarked(bookmarked);
} else {
setIsBookmarked(false);
}
}, [currentUser?.address, post?.id]);
const toggleBookmark = useCallback(async () => {
if (!currentUser?.address || !post) return false;
setLoading(true);
try {
const newBookmarkStatus = await BookmarkService.togglePostBookmark(
post,
currentUser.address,
cellId
);
setIsBookmarked(newBookmarkStatus);
return newBookmarkStatus;
} catch (err) {
console.error('Failed to toggle post bookmark:', err);
return false;
} finally {
setLoading(false);
}
}, [currentUser?.address, post, cellId]);
return {
isBookmarked,
loading,
toggleBookmark,
};
}
export function useCommentBookmark(comment: Comment, postId?: string) {
const { currentUser } = useAuth();
const [isBookmarked, setIsBookmarked] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (currentUser?.address) {
const bookmarked = BookmarkService.isCommentBookmarked(
currentUser.address,
comment.id
);
setIsBookmarked(bookmarked);
} else {
setIsBookmarked(false);
}
}, [currentUser?.address, comment.id]);
const toggleBookmark = useCallback(async () => {
if (!currentUser?.address) return false;
setLoading(true);
try {
const newBookmarkStatus = await BookmarkService.toggleCommentBookmark(
comment,
currentUser.address,
postId
);
setIsBookmarked(newBookmarkStatus);
return newBookmarkStatus;
} catch (err) {
console.error('Failed to toggle comment bookmark:', err);
return false;
} finally {
setLoading(false);
}
}, [currentUser?.address, comment, postId]);
return {
isBookmarked,
loading,
toggleBookmark,
};
}

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useForum } from '../../contexts/ForumContext';
import { useAuth } from '../../contexts/AuthContext';
import { EDisplayPreference, EVerificationStatus, UserIdentityService } from '@opchan/core';
import { EDisplayPreference, EVerificationStatus } from '@opchan/core';
import { useState, useEffect, useMemo } from 'react';
import { useForumData } from './useForumData';
import { useClient } from '../../contexts/ClientContext';
export interface UserDisplayInfo {
displayName: string;
@ -14,9 +14,12 @@ export interface UserDisplayInfo {
error: string | null;
}
/**
* User display hook with caching and reactive updates
*/
export function useUserDisplay(address: string): UserDisplayInfo {
const { userVerificationStatus } = useForum();
const { currentUser } = useAuth();
const client = useClient();
const { userVerificationStatus } = useForumData();
const [displayInfo, setDisplayInfo] = useState<UserDisplayInfo>({
displayName: `${address.slice(0, 6)}...${address.slice(-4)}`,
callSign: null,
@ -28,21 +31,9 @@ export function useUserDisplay(address: string): UserDisplayInfo {
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();
// Get verification status from forum context for reactive updates
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,
@ -50,7 +41,22 @@ export function useUserDisplay(address: string): UserDisplayInfo {
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
}
);
}, [userVerificationStatus, address, isCurrentUser, currentUser]);
}, [userVerificationStatus, address]);
// Set up refresh listener for user identity changes
useEffect(() => {
if (!client.userIdentityService || !address) return;
const unsubscribe = client.userIdentityService.addRefreshListener(
updatedAddress => {
if (updatedAddress === address) {
setRefreshTrigger(prev => prev + 1);
}
}
);
return unsubscribe;
}, [client.userIdentityService, address]);
useEffect(() => {
const getUserDisplayInfo = async () => {
@ -63,49 +69,63 @@ export function useUserDisplay(address: string): UserDisplayInfo {
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);
if (!client.userIdentityService) {
console.log(
'useEnhancedUserDisplay: No service available, using fallback',
{ address }
);
setDisplayInfo({
displayName,
callSign: identity?.callSign || null,
ensName: identity?.ensName || verificationInfo.ensName || null,
ordinalDetails: identity?.ordinalDetails ?
`${identity.ordinalDetails.ordinalId}` : null,
verificationLevel: identity?.verificationStatus ||
displayName: `${address.slice(0, 6)}...${address.slice(-4)}`,
callSign: null,
ensName: verificationInfo.ensName || null,
ordinalDetails: null,
verificationLevel:
verificationInfo.verificationStatus ||
EVerificationStatus.WALLET_UNCONNECTED,
displayPreference: identity?.displayPreference || null,
displayPreference: null,
isLoading: false,
error: null,
});
return;
}
try {
const identity = await client.userIdentityService.getUserIdentity(address);
if (identity) {
const displayName = client.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: client.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('useUserDisplay: Failed to get user display info:', error);
console.error(
'useEnhancedUserDisplay: Failed to get user display info:',
error
);
setDisplayInfo({
displayName: `${address.slice(0, 6)}...${address.slice(-4)}`,
callSign: null,
@ -116,18 +136,28 @@ export function useUserDisplay(address: string): UserDisplayInfo {
isLoading: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
isLoadingRef.current = false;
}
};
getUserDisplayInfo();
}, [address, client.userIdentityService, verificationInfo, refreshTrigger]);
// Cleanup function to reset loading ref
return () => {
isLoadingRef.current = false;
};
}, [address, refreshTrigger, verificationInfo.verificationStatus]);
// 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;
}

View File

@ -1,33 +1,24 @@
// Public hooks surface: aggregator and focused derived hooks
// Aggregator hook
// Aggregator hook (main API)
export { useForumApi } from './useForum';
// Core hooks
// Core hooks (complex logic)
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
// Derived hooks (data slicing utilities)
export { useCell } from './derived/useCell';
export { usePost } from './derived/usePost';
export { useCellPosts } from './derived/useCellPosts';
export { usePostComments } from './derived/usePostComments';
export { useUserVotes } from './derived/useUserVotes';
// Utility hooks
export { useWakuHealth, useWakuReady, useWakuHealthStatus } from './utilities/useWakuHealth';
export { useDelegation } from './utilities/useDelegation';
export { useMessageSigning } from './utilities/useMessageSigning';
export { usePending, usePendingVote } from './utilities/usePending';
// Utility hooks (remaining complex logic)
export { useWallet } from './utilities/useWallet';
export { useNetworkStatus } from './utilities/useNetworkStatus';
export { useForumSelectors } from './utilities/useForumSelectors';
export { useBookmarks, usePostBookmark, useCommentBookmark } from './utilities/useBookmarks';
// Export types
export type {
@ -45,20 +36,7 @@ export type {
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';
// Removed types from deleted action hooks - functionality now in useForumApi
export type { CellData } from './derived/useCell';
export type { PostData } from './derived/usePost';

View File

@ -1,22 +1,18 @@
import { useMemo } from 'react';
import { useMemo, useCallback, useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useForum as useForumContext } from '../contexts/ForumContext';
import { useClient } from '../contexts/ClientContext';
import { usePermissions } from './core/usePermissions';
import { useForumData } from './core/useForumData';
import { useNetworkStatus } from './utilities/useNetworkStatus';
import { 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,
User,
DelegationDuration,
EDisplayPreference,
EVerificationStatus,
@ -171,6 +167,7 @@ export interface UseForumApi {
}
export function useForumApi(): UseForumApi {
const client = useClient();
const { currentUser, verificationStatus, connectWallet, disconnectWallet, verifyOwnership } = useAuth();
const {
refreshData,
@ -179,14 +176,126 @@ export function useForumApi(): UseForumApi {
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);
// Bookmarks state (moved from useBookmarks)
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
// Delegation functionality (moved from useDelegation)
const [delegationStatus, setDelegationStatus] = useState({
hasDelegation: false,
isValid: false,
timeRemaining: 0,
expiresAt: undefined as Date | undefined,
publicKey: undefined as string | undefined,
});
// Update delegation status
useEffect(() => {
const updateStatus = async () => {
if (currentUser) {
const status = await client.delegation.getStatus(currentUser.address, currentUser.walletType);
setDelegationStatus({
hasDelegation: !!status,
isValid: status?.isValid || false,
timeRemaining: status?.timeRemaining || 0,
expiresAt: status?.proof?.expiryTimestamp ? new Date(status.proof.expiryTimestamp) : undefined,
publicKey: status?.publicKey,
});
}
};
updateStatus();
}, [client.delegation, currentUser]);
// Load bookmarks for current user
useEffect(() => {
const load = async () => {
if (!currentUser?.address) {
setBookmarks([]);
return;
}
try {
const list = await client.database.getUserBookmarks(currentUser.address);
setBookmarks(list);
} catch (e) {
console.error('Failed to load bookmarks', e);
}
};
load();
}, [client.database, currentUser?.address]);
const createDelegation = useCallback(async (duration?: DelegationDuration): Promise<boolean> => {
if (!currentUser) return false;
try {
// Use the delegate method from DelegationManager
const signFunction = async (message: string) => {
// This would need to be implemented based on your wallet signing approach
// For now, return empty string - this needs proper wallet integration
return '';
};
const success = await client.delegation.delegate(
currentUser.address,
currentUser.walletType,
duration,
signFunction
);
if (success) {
// Update status after successful delegation
const status = await client.delegation.getStatus(currentUser.address, currentUser.walletType);
setDelegationStatus({
hasDelegation: !!status,
isValid: status?.isValid || false,
timeRemaining: status?.timeRemaining || 0,
expiresAt: status?.proof?.expiryTimestamp ? new Date(status.proof.expiryTimestamp) : undefined,
publicKey: status?.publicKey,
});
}
return success;
} catch (error) {
console.error('Failed to create delegation:', error);
return false;
}
}, [client.delegation, currentUser]);
const clearDelegation = useCallback(async (): Promise<void> => {
// Clear delegation storage using the database directly
await client.database.clearDelegation();
setDelegationStatus({
hasDelegation: false,
isValid: false,
timeRemaining: 0,
expiresAt: undefined,
publicKey: undefined,
});
}, [client.database]);
// Message signing functionality (moved from useMessageSigning)
const signMessage = useCallback(async (message: OpchanMessage): Promise<void> => {
if (!currentUser) {
console.warn('No current user. Cannot sign message.');
return;
}
const status = await client.delegation.getStatus(currentUser.address, currentUser.walletType);
if (!status?.isValid) {
console.warn('No valid delegation found. Cannot sign message.');
return;
}
await client.messageManager.sendMessage(message);
}, [client.delegation, client.messageManager, currentUser]);
const verifyMessage = useCallback(async (message: OpchanMessage): Promise<boolean> => {
try {
// Use message service to verify message
return await client.messageService.verifyMessage(message);
} catch (error) {
console.error('Failed to verify message:', error);
return false;
}
}, [client.messageService]);
type MaybeOrdinal = { ordinalId?: unknown } | null | undefined;
const toOrdinal = (value: MaybeOrdinal): { ordinalId: string } | null => {
if (value && typeof value === 'object' && typeof (value as { ordinalId?: unknown }).ordinalId === 'string') {
@ -220,7 +329,36 @@ export function useForumApi(): UseForumApi {
delegateKey: async (duration?: DelegationDuration) => createDelegation(duration),
clearDelegation: async () => { await clearDelegation(); },
updateProfile: async (updates: { callSign?: string; displayPreference?: EDisplayPreference }) => {
return userActions.updateProfile(updates);
if (!currentUser) {
throw new Error('User identity service is not available.');
}
try {
// Update user identity in database
await client.database.upsertUserIdentity(currentUser.address, {
...(updates.callSign !== undefined ? { callSign: updates.callSign } : {}),
...(updates.displayPreference !== undefined ? { displayPreference: updates.displayPreference } : {}),
lastUpdated: Date.now(),
});
// Update user lightweight record for displayPreference if present
if (updates.displayPreference !== undefined) {
const updatedUser: User = {
address: currentUser.address,
walletType: currentUser.walletType!,
verificationStatus: currentUser.verificationStatus,
displayPreference: updates.displayPreference,
callSign: currentUser.callSign ?? undefined,
ensDetails: currentUser.ensDetails ?? undefined,
ordinalDetails: (currentUser as unknown as { ordinalDetails?: { ordinalId: string; ordinalDetails: string } | null }).ordinalDetails ?? undefined,
lastChecked: Date.now(),
};
await client.database.storeUser(updatedUser);
}
return true;
} catch (error) {
console.error('Failed to update profile:', error);
return false;
}
},
signMessage,
verifyMessage,
@ -241,71 +379,198 @@ export function useForumApi(): UseForumApi {
comments: forumData.filteredComments,
},
createCell: async (input: { name: string; description: string; icon?: string }) => {
return forumActions.createCell(input.name, input.description, input.icon);
if (!permissions.canCreateCell) {
throw new Error(permissions.createCellReason);
}
if (!input.name.trim() || !input.description.trim()) {
throw new Error('Please provide both a name and description for the cell.');
}
try {
const result = await client.forumActions.createCell(
{
name: input.name,
description: input.description,
icon: input.icon,
currentUser,
isAuthenticated: !!currentUser,
},
async () => {} // updateStateFromCache handled by ForumProvider
);
return result.data || null;
} catch {
throw new Error('Failed to create cell. Please try again.');
}
},
createPost: async (input: { cellId: string; title: string; content: string }) => {
return forumActions.createPost(input.cellId, input.title, input.content);
if (!permissions.canPost) {
throw new Error('You need to verify Ordinal ownership to create posts.');
}
if (!input.title.trim() || !input.content.trim()) {
throw new Error('Please provide both a title and content for the post.');
}
try {
const result = await client.forumActions.createPost(
{
cellId: input.cellId,
title: input.title,
content: input.content,
currentUser,
isAuthenticated: !!currentUser,
},
async () => {}
);
return result.data || null;
} catch {
throw new Error('Failed to create post. Please try again.');
}
},
createComment: async (input: { postId: string; content: string }) => {
return forumActions.createComment(input.postId, input.content);
if (!permissions.canComment) {
throw new Error('You need to connect your wallet to create comments.');
}
if (!input.content.trim()) {
throw new Error('Please provide content for the comment.');
}
try {
const result = await client.forumActions.createComment(
{
postId: input.postId,
content: input.content,
currentUser,
isAuthenticated: !!currentUser,
},
async () => {}
);
return result.data || null;
} catch {
throw new Error('Failed to create comment. Please try again.');
}
},
vote: async (input: { targetId: string; isUpvote: boolean }) => {
// useForumActions.vote handles both posts and comments by id
if (!permissions.canVote) {
throw new Error(permissions.voteReason);
}
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);
// Use the unified vote method from ForumActions
const result = await client.forumActions.vote(
{
targetId: input.targetId,
isUpvote: input.isUpvote,
currentUser,
isAuthenticated: !!currentUser,
},
async () => {}
);
return result.success;
} catch {
return false;
}
},
moderate: {
post: async (cellId: string, postId: string, reason?: string) => {
try { return await forumActions.moderatePost(cellId, postId, reason); } catch { return false; }
try {
const result = await client.forumActions.moderatePost(
{ cellId, postId, reason, currentUser, isAuthenticated: !!currentUser, cellOwner: currentUser?.address || '' },
async () => {}
);
return result.success;
} catch { return false; }
},
unpost: async (cellId: string, postId: string, reason?: string) => {
try { return await forumActions.unmoderatePost(cellId, postId, reason); } catch { return false; }
try {
const result = await client.forumActions.unmoderatePost(
{ cellId, postId, reason, currentUser, isAuthenticated: !!currentUser, cellOwner: currentUser?.address || '' },
async () => {}
);
return result.success;
} catch { return false; }
},
comment: async (cellId: string, commentId: string, reason?: string) => {
try { return await forumActions.moderateComment(cellId, commentId, reason); } catch { return false; }
try {
const result = await client.forumActions.moderateComment(
{ cellId, commentId, reason, currentUser, isAuthenticated: !!currentUser, cellOwner: currentUser?.address || '' },
async () => {}
);
return result.success;
} catch { return false; }
},
uncomment: async (cellId: string, commentId: string, reason?: string) => {
try { return await forumActions.unmoderateComment(cellId, commentId, reason); } catch { return false; }
try {
const result = await client.forumActions.unmoderateComment(
{ cellId, commentId, reason, currentUser, isAuthenticated: !!currentUser, cellOwner: currentUser?.address || '' },
async () => {}
);
return result.success;
} catch { return false; }
},
user: async (cellId: string, userAddress: string, reason?: string) => {
try { return await forumActions.moderateUser(cellId, userAddress, reason); } catch { return false; }
try {
const result = await client.forumActions.moderateUser(
{ cellId, userAddress, reason, currentUser, isAuthenticated: !!currentUser, cellOwner: currentUser?.address || '' },
async () => {}
);
return result.success;
} catch { return false; }
},
unuser: async (cellId: string, userAddress: string, reason?: string) => {
try { return await forumActions.unmoderateUser(cellId, userAddress, reason); } catch { return false; }
try {
const result = await client.forumActions.unmoderateUser(
{ cellId, userAddress, reason, currentUser, isAuthenticated: !!currentUser, cellOwner: currentUser?.address || '' },
async () => {}
);
return result.success;
} catch { return false; }
},
},
togglePostBookmark: async (post: Post, cellId?: string) => bookmarkPost(post, cellId),
toggleCommentBookmark: async (comment: Comment, postId?: string) => bookmarkComment(comment, postId),
togglePostBookmark: async (post: Post, cellId?: string) => {
try {
if (!currentUser?.address) return false;
const { BookmarkService } = await import('@opchan/core');
const added = await BookmarkService.togglePostBookmark(post, currentUser.address, cellId);
// Update local state snapshot from DB cache for immediate UI feedback
const updated = await client.database.getUserBookmarks(currentUser.address);
setBookmarks(updated);
return added;
} catch (e) {
console.error('togglePostBookmark failed', e);
return false;
}
},
toggleCommentBookmark: async (comment: Comment, postId?: string) => {
try {
if (!currentUser?.address) return false;
const { BookmarkService } = await import('@opchan/core');
const added = await BookmarkService.toggleCommentBookmark(comment, currentUser.address, postId);
const updated = await client.database.getUserBookmarks(currentUser.address);
setBookmarks(updated);
return added;
} catch (e) {
console.error('toggleCommentBookmark failed', e);
return false;
}
},
refresh: async () => { await refreshData(); },
pending: {
isPending: (id?: string) => {
return id ? localDatabase.isPending(id) : false;
return id ? client.database.isPending(id) : false;
},
isVotePending: (targetId?: string) => {
if (!targetId || !currentUser?.address) return false;
return Object.values(localDatabase.cache.votes).some(v => {
return Object.values(client.database.cache.votes).some(v => {
return (
v.targetId === targetId &&
v.author === currentUser.address &&
localDatabase.isPending(v.id)
client.database.isPending(v.id)
);
});
},
onChange: (cb: () => void) => {
return localDatabase.onPendingChange(cb);
return client.database.onPendingChange(cb);
},
},
};
}, [forumData, bookmarks, forumActions, bookmarkPost, bookmarkComment, refreshData, currentUser?.address]);
}, [forumData, bookmarks, refreshData, currentUser, permissions, client]);
const permissionsSlice = useMemo(() => {
return {
@ -361,3 +626,4 @@ export function useForumApi(): UseForumApi {
}

View File

@ -0,0 +1,130 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { useClient } from '../../contexts/ClientContext';
import { Bookmark, BookmarkType, Post, Comment } from '@opchan/core';
import { BookmarkService } from '@opchan/core';
export interface UseBookmarksReturn {
bookmarks: Bookmark[];
loading: boolean;
error: string | null;
getBookmarksByType: (type: BookmarkType) => Bookmark[];
removeBookmark: (bookmark: Bookmark) => Promise<void>;
clearAllBookmarks: () => Promise<void>;
togglePostBookmark: (post: Post, cellId?: string) => Promise<boolean>;
toggleCommentBookmark: (comment: Comment, postId?: string) => Promise<boolean>;
}
export function useBookmarks(): UseBookmarksReturn {
const { currentUser } = useAuth();
const client = useClient();
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (!currentUser?.address) {
setBookmarks([]);
setLoading(false);
return;
}
try {
setLoading(true);
const list = await client.database.getUserBookmarks(currentUser.address);
setBookmarks(list);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load bookmarks');
} finally {
setLoading(false);
}
}, [client.database, currentUser?.address]);
useEffect(() => {
refresh();
}, [refresh]);
const getBookmarksByType = useCallback(
(type: BookmarkType): Bookmark[] =>
bookmarks.filter(b => b.type === type),
[bookmarks]
);
const removeBookmark = useCallback(
async (bookmark: Bookmark): Promise<void> => {
await BookmarkService.removeBookmark(bookmark.type, bookmark.targetId);
await refresh();
},
[refresh]
);
const clearAllBookmarks = useCallback(async (): Promise<void> => {
if (!currentUser?.address) return;
await BookmarkService.clearUserBookmarks(currentUser.address);
await refresh();
}, [currentUser?.address, refresh]);
const togglePostBookmark = useCallback(
async (post: Post, cellId?: string): Promise<boolean> => {
if (!currentUser?.address) return false;
const added = await BookmarkService.togglePostBookmark(
post,
currentUser.address,
cellId
);
await refresh();
return added;
},
[currentUser?.address, refresh]
);
const toggleCommentBookmark = useCallback(
async (comment: Comment, postId?: string): Promise<boolean> => {
if (!currentUser?.address) return false;
const added = await BookmarkService.toggleCommentBookmark(
comment,
currentUser.address,
postId
);
await refresh();
return added;
},
[currentUser?.address, refresh]
);
return useMemo(
() => ({
bookmarks,
loading,
error,
getBookmarksByType,
removeBookmark,
clearAllBookmarks,
togglePostBookmark,
toggleCommentBookmark,
}),
[
bookmarks,
loading,
error,
getBookmarksByType,
removeBookmark,
clearAllBookmarks,
togglePostBookmark,
toggleCommentBookmark,
]
);
}
// Optional convenience hooks to match historic API surface
export function usePostBookmark() {
const { togglePostBookmark } = useBookmarks();
return { togglePostBookmark };
}
export function useCommentBookmark() {
const { toggleCommentBookmark } = useBookmarks();
return { toggleCommentBookmark };
}

View File

@ -1,82 +0,0 @@
import { useCallback, useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { DelegationDuration } from '@opchan/core';
export const useDelegation = () => {
const {
delegateKey: contextDelegateKey,
getDelegationStatus: contextGetDelegationStatus,
clearDelegation: contextClearDelegation,
isAuthenticating,
} = useAuth();
const createDelegation = useCallback(
async (duration?: DelegationDuration): Promise<boolean> => {
return contextDelegateKey(duration);
},
[contextDelegateKey]
);
const clearDelegation = useCallback(async (): Promise<void> => {
await contextClearDelegation();
}, [contextClearDelegation]);
const [delegationStatus, setDelegationStatus] = useState<{
hasDelegation: boolean;
isValid: boolean;
timeRemaining?: number;
expiresAt?: Date;
publicKey?: string;
address?: string;
walletType?: 'bitcoin' | 'ethereum';
}>({
hasDelegation: false,
isValid: false,
});
// Load delegation status
useEffect(() => {
contextGetDelegationStatus()
.then(status => {
setDelegationStatus({
hasDelegation: status.hasDelegation,
isValid: status.isValid,
timeRemaining: status.timeRemaining,
expiresAt: status.timeRemaining
? new Date(Date.now() + status.timeRemaining)
: undefined,
publicKey: status.publicKey,
address: status.address,
walletType: status.walletType,
});
})
.catch(console.error);
}, [contextGetDelegationStatus]);
const formatTimeRemaining = useCallback((timeMs: number): string => {
const hours = Math.floor(timeMs / (1000 * 60 * 60));
const minutes = Math.floor((timeMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days} day${days === 1 ? '' : 's'}`;
} else if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
}, []);
return {
// Delegation status
delegationStatus,
isCreatingDelegation: isAuthenticating,
// Delegation actions
createDelegation,
clearDelegation,
// Utilities
formatTimeRemaining,
};
};

View File

@ -1,44 +0,0 @@
import { useCallback } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { OpchanMessage } from '@opchan/core';
export const useMessageSigning = () => {
const {
signMessage: contextSignMessage,
verifyMessage: contextVerifyMessage,
getDelegationStatus,
} = useAuth();
const signMessage = useCallback(
async (message: OpchanMessage): Promise<void> => {
// Check if we have a valid delegation before attempting to sign
const delegationStatus = await getDelegationStatus();
if (!delegationStatus.isValid) {
console.warn('No valid delegation found. Cannot sign message.');
return;
}
await contextSignMessage(message);
},
[contextSignMessage, getDelegationStatus]
);
const verifyMessage = useCallback(
async (message: OpchanMessage): Promise<boolean> => {
return await contextVerifyMessage(message);
},
[contextVerifyMessage]
);
const canSignMessages = useCallback(async (): Promise<boolean> => {
const delegationStatus = await getDelegationStatus();
return delegationStatus.isValid;
}, [getDelegationStatus]);
return {
// Message signing
signMessage,
verifyMessage,
canSignMessages,
};
};

View File

@ -1,6 +1,7 @@
import { useMemo, useState, useEffect } from 'react';
import { useForum } from '../../contexts/ForumContext';
import { useAuth } from '../../contexts/AuthContext';
import { useClient } from '../../contexts/ClientContext';
import { DelegationFullStatus } from '@opchan/core';
export interface NetworkHealth {
@ -58,11 +59,38 @@ export interface NetworkStatusData {
export function useNetworkStatus(): NetworkStatusData {
const { isNetworkConnected, isInitialLoading, isRefreshing, error } =
useForum();
const client = useClient();
const { isAuthenticated, currentUser, getDelegationStatus } = useAuth();
const [delegationInfo, setDelegationInfo] =
useState<DelegationFullStatus | null>(null);
// Track Waku ready state directly from the client to react to changes
const [wakuReady, setWakuReady] = useState<boolean>(
Boolean((client)?.messageManager?.isReady)
);
useEffect(() => {
try {
// Prime from current state so UI updates immediately without navigation
try {
const nowReady = Boolean(client?.messageManager?.isReady);
setWakuReady(nowReady);
console.debug('[useNetworkStatus] primed wakuReady from client', { nowReady });
} catch {}
const off = client?.messageManager?.onHealthChange?.(
(ready: boolean) => {
console.debug('[useNetworkStatus] onHealthChange -> wakuReady', { ready });
setWakuReady(Boolean(ready));
}
);
return () => {
try { off && off(); } catch {}
};
} catch {}
}, [client]);
// Load delegation status
useEffect(() => {
getDelegationStatus().then(setDelegationInfo).catch(console.error);
@ -72,7 +100,10 @@ export function useNetworkStatus(): NetworkStatusData {
const health = useMemo((): NetworkHealth => {
const issues: string[] = [];
if (!isNetworkConnected) {
const fallbackConnected = Boolean(wakuReady);
const effectiveConnected = isNetworkConnected || wakuReady;
if (!effectiveConnected) {
issues.push('Waku network disconnected');
}
@ -88,14 +119,26 @@ export function useNetworkStatus(): NetworkStatusData {
const lastSync = Date.now(); // This would come from actual sync tracking
const syncAge = lastSync ? formatTimeAgo(lastSync) : null;
// Debug: surface the raw inputs to health computation
console.debug('[useNetworkStatus] health', {
forumIsNetworkConnected: isNetworkConnected,
fallbackConnected,
effectiveConnected,
isInitialLoading,
isRefreshing,
error,
delegationValid: delegationInfo?.isValid,
issues,
});
return {
isConnected: isNetworkConnected,
isConnected: effectiveConnected,
isHealthy,
lastSync,
syncAge,
issues,
};
}, [isNetworkConnected, error, isAuthenticated, delegationInfo?.isValid]);
}, [client, isNetworkConnected, wakuReady, error, isAuthenticated, delegationInfo?.isValid]);
// Sync status
const sync = useMemo((): SyncStatus => {
@ -114,11 +157,12 @@ export function useNetworkStatus(): NetworkStatusData {
// Connection status
const connections = useMemo((): ConnectionStatus => {
const effectiveConnected = health.isConnected;
return {
waku: {
connected: isNetworkConnected,
peers: isNetworkConnected ? 3 : 0, // Mock peer count
status: isNetworkConnected ? 'connected' : 'disconnected',
connected: effectiveConnected,
peers: effectiveConnected ? 3 : 0, // Mock peer count
status: effectiveConnected ? 'connected' : 'disconnected',
},
wallet: {
connected: isAuthenticated,
@ -131,19 +175,28 @@ export function useNetworkStatus(): NetworkStatusData {
status: delegationInfo?.isValid ? 'active' : 'expired',
},
};
}, [isNetworkConnected, isAuthenticated, currentUser, delegationInfo]);
}, [health.isConnected, isAuthenticated, currentUser, delegationInfo]);
// Status assessment
const canRefresh = !isRefreshing && !isInitialLoading;
const canSync = isNetworkConnected && !isRefreshing;
const canSync = health.isConnected && !isRefreshing;
const needsAttention = !health.isHealthy || !delegationInfo?.isValid;
// Helper methods
const getStatusMessage = useMemo(() => {
return (): string => {
console.debug('[useNetworkStatus] statusMessage inputs', {
isInitialLoading,
isRefreshing,
isNetworkConnected,
error,
issues: health.issues,
});
if (isInitialLoading) return 'Loading forum data...';
if (isRefreshing) return 'Refreshing data...';
if (!isNetworkConnected) return 'Network disconnected';
const fallbackConnected = Boolean(wakuReady);
const effectiveConnected = isNetworkConnected || fallbackConnected;
if (!effectiveConnected) return 'Network disconnected';
if (error) return `Error: ${error}`;
if (health.issues.length > 0) return health.issues[0] || 'Unknown issue';
return 'All systems operational';
@ -152,18 +205,30 @@ export function useNetworkStatus(): NetworkStatusData {
isInitialLoading,
isRefreshing,
isNetworkConnected,
client,
wakuReady,
error,
health.issues,
]);
const getHealthColor = useMemo(() => {
return (): 'green' | 'yellow' | 'red' => {
if (!isNetworkConnected || error) return 'red';
console.debug('[useNetworkStatus] healthColor inputs', {
isNetworkConnected,
error,
issues: health.issues,
delegationValid: delegationInfo?.isValid,
});
const fallbackConnected = Boolean(wakuReady);
const effectiveConnected = isNetworkConnected || fallbackConnected;
if (!effectiveConnected || error) return 'red';
if (health.issues.length > 0 || !delegationInfo?.isValid) return 'yellow';
return 'green';
};
}, [
isNetworkConnected,
client,
wakuReady,
error,
health.issues.length,
delegationInfo?.isValid,

View File

@ -1,46 +0,0 @@
import { useEffect, useState } from 'react';
import { localDatabase } from '@opchan/core';
import { useAuth } from '../../contexts/AuthContext';
export function usePending(id: string | undefined) {
const [isPending, setIsPending] = useState<boolean>(
id ? localDatabase.isPending(id) : false
);
useEffect(() => {
if (!id) return;
setIsPending(localDatabase.isPending(id));
const unsubscribe = localDatabase.onPendingChange(() => {
setIsPending(localDatabase.isPending(id));
});
return unsubscribe;
}, [id]);
return { isPending };
}
export function usePendingVote(targetId: string | undefined) {
const { currentUser } = useAuth();
const [isPending, setIsPending] = useState<boolean>(false);
useEffect(() => {
const compute = () => {
if (!targetId || !currentUser?.address) return setIsPending(false);
// Find a vote authored by current user for this target that is pending
const pending = Object.values(localDatabase.cache.votes).some(v => {
return (
v.targetId === targetId &&
v.author === currentUser.address &&
localDatabase.isPending(v.id)
);
});
setIsPending(pending);
};
compute();
const unsub = localDatabase.onPendingChange(compute);
return unsub;
}, [targetId, currentUser?.address]);
return { isPending };
}

View File

@ -1,112 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { HealthStatus } from '@waku/sdk';
import { messageManager } from '@opchan/core';
export interface WakuHealthState {
isReady: boolean;
health: HealthStatus;
isInitialized: boolean;
connectionStatus: 'connecting' | 'connected' | 'disconnected' | 'error';
}
export function useWakuHealth(): WakuHealthState {
const [isReady, setIsReady] = useState(false);
const [health, setHealth] = useState<HealthStatus>(HealthStatus.Unhealthy);
const [isInitialized, setIsInitialized] = useState(false);
const [connectionStatus, setConnectionStatus] = useState<
'connecting' | 'connected' | 'disconnected' | 'error'
>('connecting');
const updateHealth = useCallback(
(ready: boolean, currentHealth: HealthStatus) => {
setIsReady(ready);
setHealth(currentHealth);
if (ready) {
setConnectionStatus('connected');
} else if (currentHealth === HealthStatus.Unhealthy) {
setConnectionStatus('disconnected');
} else {
setConnectionStatus('connecting');
}
},
[]
);
useEffect(() => {
try {
const currentHealth = messageManager.currentHealth ?? HealthStatus.Unhealthy;
const currentReady = messageManager.isReady;
setIsInitialized(true);
updateHealth(currentReady, currentHealth);
const unsubscribe = messageManager.onHealthChange(updateHealth);
return unsubscribe;
} catch (error) {
console.error('Failed to initialize Waku health monitoring:', error);
setConnectionStatus('error');
setIsInitialized(false);
return undefined;
}
}, [updateHealth]);
return {
isReady,
health,
isInitialized,
connectionStatus,
};
}
export function useWakuReady(): boolean {
const { isReady } = useWakuHealth();
return isReady;
}
export function useWakuHealthStatus() {
const { isReady, health, connectionStatus } = useWakuHealth();
const getHealthDescription = useCallback(() => {
switch (health) {
case HealthStatus.SufficientlyHealthy:
return 'Network is healthy and fully operational';
case HealthStatus.MinimallyHealthy:
return 'Network is minimally healthy and functional';
case HealthStatus.Unhealthy:
return 'Network is unhealthy or disconnected';
default:
return 'Network status unknown';
}
}, [health]);
const getStatusColor = useCallback(() => {
if (isReady) return 'green';
if (health === HealthStatus.Unhealthy) return 'red';
return 'yellow';
}, [isReady, health]);
const getStatusMessage = useCallback(() => {
switch (connectionStatus) {
case 'connecting':
return 'Connecting to Waku network...';
case 'connected':
return 'Connected to Waku network';
case 'disconnected':
return 'Disconnected from Waku network';
case 'error':
return 'Error connecting to Waku network';
default:
return 'Unknown connection status';
}
}, [connectionStatus]);
return {
isReady,
health,
connectionStatus,
description: getHealthDescription(),
statusColor: getStatusColor(),
statusMessage: getStatusMessage(),
};
}

View File

@ -1,5 +1,6 @@
// Providers only (context hooks are internal)
export * from './provider/OpChanProvider';
export { ClientProvider, useClient } from './contexts/ClientContext';
export { AuthProvider, useAuth } from './contexts/AuthContext';
export { ForumProvider, useForum as useForumContext } from './contexts/ForumContext';
export { ModerationProvider, useModeration } from './contexts/ModerationContext';

View File

@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { OpChanClient } from '@opchan/core';
import { localDatabase, messageManager } from '@opchan/core';
import { ClientProvider } from '../contexts/ClientContext';
import { AuthProvider } from '../contexts/AuthContext';
import { ForumProvider } from '../contexts/ForumContext';
import { ModerationProvider } from '../contexts/ModerationContext';
@ -49,11 +50,13 @@ export const OpChanProvider: React.FC<OpChanProviderProps> = ({
const providers = useMemo(() => {
if (!isReady || !clientRef.current) return null;
return (
<AuthProvider client={clientRef.current}>
<ModerationProvider>
<ForumProvider client={clientRef.current}>{children}</ForumProvider>
</ModerationProvider>
</AuthProvider>
<ClientProvider client={clientRef.current}>
<AuthProvider client={clientRef.current}>
<ModerationProvider>
<ForumProvider client={clientRef.current}>{children}</ForumProvider>
</ModerationProvider>
</AuthProvider>
</ClientProvider>
);
}, [isReady, children]);