mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-02 12:53:10 +00:00
FIX HYDRATION
This commit is contained in:
parent
8d0f86fb2e
commit
0ad1cce551
@ -4,6 +4,8 @@ import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
const tsconfigRootDir = new URL('.', import.meta.url).pathname;
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
@ -12,6 +14,9 @@ export default tseslint.config(
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
tsconfigRootDir,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useForumData, usePermissions } from '@/hooks';
|
||||
import { useContent, usePermissions } from '@/hooks';
|
||||
import {
|
||||
Layout,
|
||||
MessageSquare,
|
||||
@ -26,7 +26,7 @@ import { RelevanceIndicator } from './ui/relevance-indicator';
|
||||
import { ModerationToggle } from './ui/moderation-toggle';
|
||||
import { sortCells, SortOption } from '@/utils/sorting';
|
||||
import type { Cell } from '@opchan/core';
|
||||
import { useForum } from '@opchan/react';
|
||||
import { useForum } from '@/hooks';
|
||||
import { ShareButton } from './ui/ShareButton';
|
||||
|
||||
// Empty State Component
|
||||
@ -140,8 +140,8 @@ const CellItem: React.FC<{ cell: Cell }> = ({ cell }) => {
|
||||
};
|
||||
|
||||
const CellList = () => {
|
||||
const { cellsWithStats, isInitialLoading } = useForumData();
|
||||
const { content } = useForum();
|
||||
const { cellsWithStats } = useContent();
|
||||
const content = useContent();
|
||||
const { canCreateCell } = usePermissions();
|
||||
const [sortOption, setSortOption] = useState<SortOption>('relevance');
|
||||
|
||||
@ -150,7 +150,7 @@ const CellList = () => {
|
||||
return sortCells(cellsWithStats, sortOption);
|
||||
}, [cellsWithStats, sortOption]);
|
||||
|
||||
if (isInitialLoading) {
|
||||
if (!cellsWithStats.length) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 pt-24 pb-16 text-center">
|
||||
<Loader2 className="w-8 h-8 mx-auto mb-4 animate-spin text-primary" />
|
||||
@ -222,12 +222,12 @@ const CellList = () => {
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={content.refresh}
|
||||
disabled={isInitialLoading}
|
||||
disabled={false}
|
||||
title="Refresh data"
|
||||
className="px-3 border-cyber-muted/30 text-cyber-neutral hover:bg-cyber-muted/30"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 ${isInitialLoading ? 'animate-spin' : ''}`}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import { ArrowUp, ArrowDown, Clock, Shield, UserX } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Comment } from '@opchan/core';
|
||||
import type { CommentMessage } from '@opchan/core';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { BookmarkButton } from '@/components/ui/bookmark-button';
|
||||
import { AuthorDisplay } from '@/components/ui/author-display';
|
||||
import { MarkdownRenderer } from '@/components/ui/markdown-renderer';
|
||||
import { useForum } from '@opchan/react';
|
||||
import { useContent, useForum, usePermissions } from '@/hooks';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@ -15,7 +15,7 @@ import {
|
||||
import { ShareButton } from '@/components/ui/ShareButton';
|
||||
|
||||
interface CommentCardProps {
|
||||
comment: Comment;
|
||||
comment: CommentMessage;
|
||||
postId: string;
|
||||
cellId?: string;
|
||||
canModerate: boolean;
|
||||
@ -48,8 +48,8 @@ const CommentCard: React.FC<CommentCardProps> = ({
|
||||
onUnmoderateComment,
|
||||
onModerateUser,
|
||||
}) => {
|
||||
const forum = useForum();
|
||||
const { content, permissions } = forum;
|
||||
const content = useContent();
|
||||
const permissions = usePermissions();
|
||||
|
||||
// Check if bookmarked
|
||||
const isBookmarked = content.bookmarks.some(
|
||||
@ -58,18 +58,13 @@ const CommentCard: React.FC<CommentCardProps> = ({
|
||||
const [bookmarkLoading, setBookmarkLoading] = React.useState(false);
|
||||
|
||||
// Use library pending API
|
||||
const commentVotePending = content.pending.isVotePending(comment.id);
|
||||
const commentVotePending = content.pending.isPending(comment.id);
|
||||
|
||||
// Get user vote status from filtered comment data
|
||||
const filteredComment = content.filtered.comments.find(
|
||||
c => c.id === comment.id
|
||||
);
|
||||
const userUpvoted = filteredComment
|
||||
? (filteredComment as unknown as { userUpvoted?: boolean }).userUpvoted
|
||||
: false;
|
||||
const userDownvoted = filteredComment
|
||||
? (filteredComment as unknown as { userDownvoted?: boolean }).userDownvoted
|
||||
: false;
|
||||
const userUpvoted = Boolean((comment as unknown as { userUpvoted?: boolean }).userUpvoted);
|
||||
const userDownvoted = Boolean((comment as unknown as { userDownvoted?: boolean }).userDownvoted);
|
||||
const score = (comment as unknown as { voteScore?: number }).voteScore ?? 0;
|
||||
const isModerated = Boolean((comment as unknown as { moderated?: boolean }).moderated);
|
||||
|
||||
const handleVoteComment = async (isUpvote: boolean) => {
|
||||
await content.vote({ targetId: comment.id, isUpvote });
|
||||
@ -100,7 +95,7 @@ const CommentCard: React.FC<CommentCardProps> = ({
|
||||
>
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
</button>
|
||||
<span className="text-sm font-bold">{comment.voteScore}</span>
|
||||
<span className="text-sm font-bold">{score}</span>
|
||||
<button
|
||||
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${
|
||||
userDownvoted ? 'text-cyber-accent' : ''
|
||||
@ -161,7 +156,7 @@ const CommentCard: React.FC<CommentCardProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{canModerate && !comment.moderated && (
|
||||
{canModerate && !isModerated && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@ -178,7 +173,7 @@ const CommentCard: React.FC<CommentCardProps> = ({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canModerate && comment.moderated && (
|
||||
{canModerate && isModerated && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@ -3,8 +3,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { usePermissions } from '@/hooks';
|
||||
import { useForum } from '@opchan/react';
|
||||
import { useContent, usePermissions } from '@/hooks';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@ -58,8 +57,7 @@ export function CreateCellDialog({
|
||||
open: externalOpen,
|
||||
onOpenChange,
|
||||
}: CreateCellDialogProps = {}) {
|
||||
const forum = useForum();
|
||||
const {createCell} = forum.content;
|
||||
const { createCell } = useContent();
|
||||
const isCreatingCell = false;
|
||||
const { canCreateCell } = usePermissions();
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -4,44 +4,31 @@ import { TrendingUp, Users, Eye, CheckCircle } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useForumData, useAuth } from '@/hooks';
|
||||
import { useAuth, useContent } from '@/hooks';
|
||||
import { EVerificationStatus } from '@opchan/core';
|
||||
import { CypherImage } from '@/components/ui/CypherImage';
|
||||
import { useUserDisplay } from '@/hooks';
|
||||
|
||||
const FeedSidebar: React.FC = () => {
|
||||
// ✅ Use reactive hooks for data
|
||||
const forumData = useForumData();
|
||||
const {cells, posts, comments, cellsWithStats, userVerificationStatus} = useContent();
|
||||
const { currentUser, verificationStatus } = useAuth();
|
||||
|
||||
// Get user display information using the hook
|
||||
const { displayName, ensName, ordinalDetails } = useUserDisplay(
|
||||
currentUser?.address || ''
|
||||
);
|
||||
|
||||
// ✅ Get stats from filtered data
|
||||
const {
|
||||
filteredPosts,
|
||||
filteredComments,
|
||||
filteredCellsWithStats,
|
||||
cells,
|
||||
userVerificationStatus,
|
||||
} = forumData;
|
||||
|
||||
const stats = {
|
||||
totalCells: cells.length,
|
||||
totalPosts: filteredPosts.length,
|
||||
totalComments: filteredComments.length,
|
||||
totalPosts: posts.length,
|
||||
totalComments: comments.length,
|
||||
totalUsers: new Set([
|
||||
...filteredPosts.map(post => post.author),
|
||||
...filteredComments.map(comment => comment.author),
|
||||
...posts.map(post => post.author),
|
||||
...comments.map(comment => comment.author),
|
||||
]).size,
|
||||
verifiedUsers: Object.values(userVerificationStatus).filter(
|
||||
status => status.isVerified
|
||||
).length,
|
||||
};
|
||||
// Use filtered cells with stats for trending cells
|
||||
const trendingCells = filteredCellsWithStats
|
||||
const trendingCells = cellsWithStats
|
||||
.sort((a, b) => b.recentActivity - a.recentActivity)
|
||||
.slice(0, 5);
|
||||
|
||||
@ -51,9 +38,9 @@ const FeedSidebar: React.FC = () => {
|
||||
return { text: 'Verified Owner', color: 'bg-green-500' };
|
||||
} else if (verificationStatus === EVerificationStatus.WALLET_CONNECTED) {
|
||||
return { text: 'Verified', color: 'bg-blue-500' };
|
||||
} else if (ensName) {
|
||||
} else if (currentUser?.ensDetails) {
|
||||
return { text: 'ENS User', color: 'bg-purple-500' };
|
||||
} else if (ordinalDetails) {
|
||||
} else if (currentUser?.ordinalDetails) {
|
||||
return { text: 'Ordinal User', color: 'bg-orange-500' };
|
||||
}
|
||||
return { text: 'Unverified', color: 'bg-gray-500' };
|
||||
@ -75,7 +62,7 @@ const FeedSidebar: React.FC = () => {
|
||||
<Users className="w-5 h-5 text-cyber-accent" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{displayName}</div>
|
||||
<div className="font-medium text-sm">{currentUser?.displayName}</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`${verificationBadge.color} text-white text-xs`}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useAuth, useForumContext, useNetworkStatus } from '@opchan/react';
|
||||
import { useAuth, useForum, useNetwork } from '@/hooks';
|
||||
import { EVerificationStatus } from '@opchan/core';
|
||||
import { localDatabase } from '@opchan/core';
|
||||
import { DelegationFullStatus } from '@opchan/core';
|
||||
@ -46,20 +46,17 @@ import { useToast } from '@/components/ui/use-toast';
|
||||
import { useAppKitAccount, useDisconnect } from '@reown/appkit/react';
|
||||
import { WalletWizard } from '@/components/ui/wallet-wizard';
|
||||
|
||||
import { useUserDisplay } from '@/hooks';
|
||||
import { WakuHealthDot } from '@/components/ui/waku-health-indicator';
|
||||
|
||||
const Header = () => {
|
||||
const { currentUser, getDelegationStatus } = useAuth();
|
||||
const { currentUser, delegationStatus } = useAuth();
|
||||
const [delegationInfo, setDelegationInfo] =
|
||||
useState<DelegationFullStatus | null>(null);
|
||||
const network = useNetworkStatus();
|
||||
const wakuHealth = {
|
||||
statusMessage: network.getStatusMessage(),
|
||||
};
|
||||
const {statusMessage} = useNetwork();
|
||||
|
||||
const location = useLocation()
|
||||
const { toast } = useToast();
|
||||
const forumContext = useForumContext();
|
||||
const { content } = useForum();
|
||||
|
||||
const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' });
|
||||
const ethereumAccount = useAppKitAccount({ namespace: 'eip155' });
|
||||
@ -67,10 +64,6 @@ const Header = () => {
|
||||
|
||||
const isConnected = bitcoinAccount.isConnected || ethereumAccount.isConnected;
|
||||
|
||||
useEffect(()=> {
|
||||
console.log('currentUser', currentUser);
|
||||
}, [])
|
||||
|
||||
|
||||
|
||||
// Use currentUser address (which has ENS details) instead of raw AppKit address
|
||||
@ -83,12 +76,17 @@ const Header = () => {
|
||||
const [walletWizardOpen, setWalletWizardOpen] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const { displayName, verificationLevel } = useUserDisplay(address || '');
|
||||
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
getDelegationStatus().then(setDelegationInfo).catch(console.error);
|
||||
}, [getDelegationStatus]);
|
||||
delegationStatus().then(setDelegationInfo).catch(console.error);
|
||||
}, [delegationStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log({currentUser})
|
||||
|
||||
}, [currentUser])
|
||||
|
||||
// Use LocalDatabase to persist wizard state across navigation
|
||||
const getHasShownWizard = async (): Promise<boolean> => {
|
||||
@ -158,14 +156,14 @@ const Header = () => {
|
||||
if (!isConnected) return <CircleSlash className="w-4 h-4" />;
|
||||
|
||||
if (
|
||||
verificationLevel === EVerificationStatus.ENS_ORDINAL_VERIFIED &&
|
||||
currentUser?.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED &&
|
||||
delegationInfo?.isValid
|
||||
) {
|
||||
return <CheckCircle className="w-4 h-4" />;
|
||||
} else if (verificationLevel === EVerificationStatus.WALLET_CONNECTED) {
|
||||
} else if (currentUser?.verificationStatus === EVerificationStatus.WALLET_CONNECTED) {
|
||||
return <AlertTriangle className="w-4 h-4" />;
|
||||
} else if (
|
||||
verificationLevel === EVerificationStatus.ENS_ORDINAL_VERIFIED
|
||||
currentUser?.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED
|
||||
) {
|
||||
return <Key className="w-4 h-4" />;
|
||||
} else {
|
||||
@ -195,13 +193,13 @@ const Header = () => {
|
||||
<div className="flex items-center space-x-2 px-3 py-1 bg-cyber-muted/20 rounded-full border border-cyber-muted/30">
|
||||
<WakuHealthDot />
|
||||
<span className="text-xs font-mono text-cyber-neutral">
|
||||
{wakuHealth.statusMessage}
|
||||
{statusMessage}
|
||||
</span>
|
||||
{forumContext.lastSync && (
|
||||
{content.lastSync && (
|
||||
<div className="flex items-center space-x-1 text-xs text-cyber-neutral/70">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>
|
||||
{new Date(forumContext.lastSync).toLocaleTimeString([], {
|
||||
{new Date(content.lastSync).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
@ -225,11 +223,11 @@ const Header = () => {
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`font-mono text-xs border-0 ${
|
||||
verificationLevel ===
|
||||
currentUser?.verificationStatus ===
|
||||
EVerificationStatus.ENS_ORDINAL_VERIFIED &&
|
||||
delegationInfo?.isValid
|
||||
? 'bg-green-500/20 text-green-400 border-green-500/30'
|
||||
: verificationLevel ===
|
||||
: currentUser?.verificationStatus ===
|
||||
EVerificationStatus.ENS_ORDINAL_VERIFIED
|
||||
? 'bg-orange-500/20 text-orange-400 border-orange-500/30'
|
||||
: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'
|
||||
@ -237,11 +235,11 @@ const Header = () => {
|
||||
>
|
||||
{getStatusIcon()}
|
||||
<span className="ml-1">
|
||||
{verificationLevel === EVerificationStatus.WALLET_UNCONNECTED
|
||||
{currentUser?.verificationStatus === EVerificationStatus.WALLET_UNCONNECTED
|
||||
? 'CONNECT'
|
||||
: delegationInfo?.isValid
|
||||
? 'READY'
|
||||
: verificationLevel === EVerificationStatus.ENS_ORDINAL_VERIFIED
|
||||
: currentUser?.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED
|
||||
? 'EXPIRED'
|
||||
: 'DELEGATE'}
|
||||
</span>
|
||||
@ -255,7 +253,7 @@ const Header = () => {
|
||||
size="sm"
|
||||
className="flex items-center space-x-2 text-white hover:bg-cyber-muted/30"
|
||||
>
|
||||
<div className="text-sm font-mono">{displayName}</div>
|
||||
<div className="text-sm font-mono">{currentUser?.displayName}</div>
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@ -265,7 +263,7 @@ const Header = () => {
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-cyber-muted/30">
|
||||
<div className="text-sm font-medium text-white">
|
||||
{displayName}
|
||||
{currentUser?.displayName}
|
||||
</div>
|
||||
<div className="text-xs text-cyber-neutral">
|
||||
{address?.slice(0, 8)}...{address?.slice(-4)}
|
||||
@ -473,10 +471,10 @@ const Header = () => {
|
||||
<div className="px-4 py-3 border-t border-cyber-muted/20">
|
||||
<div className="flex items-center space-x-2 text-xs text-cyber-neutral">
|
||||
<WakuHealthDot />
|
||||
<span>{wakuHealth.statusMessage}</span>
|
||||
{forumContext.lastSync && (
|
||||
<span>{statusMessage}</span>
|
||||
{content.lastSync && (
|
||||
<span className="ml-auto">
|
||||
{new Date(forumContext.lastSync).toLocaleTimeString([], {
|
||||
{new Date(content.lastSync).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
|
||||
@ -2,37 +2,44 @@ import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowUp, ArrowDown, MessageSquare } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import type { Post } from '@opchan/core';
|
||||
// Removed unused imports
|
||||
import type { Post, PostMessage } from '@opchan/core';
|
||||
import { RelevanceIndicator } from '@/components/ui/relevance-indicator';
|
||||
import { AuthorDisplay } from '@/components/ui/author-display';
|
||||
import { BookmarkButton } from '@/components/ui/bookmark-button';
|
||||
import { LinkRenderer } from '@/components/ui/link-renderer';
|
||||
import { useForum } from '@opchan/react';
|
||||
import { useContent, usePermissions } from '@/hooks';
|
||||
import { ShareButton } from '@/components/ui/ShareButton';
|
||||
|
||||
interface PostCardProps {
|
||||
post: Post;
|
||||
post: Post | PostMessage;
|
||||
commentCount?: number;
|
||||
}
|
||||
|
||||
const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
|
||||
const forum = useForum();
|
||||
const { content, permissions } = forum;
|
||||
const content = useContent();
|
||||
const permissions = usePermissions();
|
||||
|
||||
// Get cell data from content
|
||||
const cell = content.cells.find(c => c.id === post.cellId);
|
||||
const cell = content.cells.find((c) => c.id === post.cellId);
|
||||
const cellName = cell?.name || 'unknown';
|
||||
|
||||
// Use pre-computed vote data
|
||||
const score =
|
||||
'voteScore' in post
|
||||
? (post.voteScore as number)
|
||||
: post.upvotes.length - post.downvotes.length;
|
||||
// Use pre-computed vote data or safely compute from arrays when available
|
||||
const computedVoteScore =
|
||||
'voteScore' in post && typeof (post as Post).voteScore === 'number'
|
||||
? (post as Post).voteScore
|
||||
: undefined;
|
||||
const upvoteCount =
|
||||
'upvotes' in post && Array.isArray((post as Post).upvotes)
|
||||
? (post as Post).upvotes.length
|
||||
: 0;
|
||||
const downvoteCount =
|
||||
'downvotes' in post && Array.isArray((post as Post).downvotes)
|
||||
? (post as Post).downvotes.length
|
||||
: 0;
|
||||
const score = computedVoteScore ?? upvoteCount - downvoteCount;
|
||||
|
||||
// Use library pending API
|
||||
const isPending = content.pending.isPending(post.id);
|
||||
const votePending = content.pending.isVotePending(post.id);
|
||||
|
||||
// Get user vote status from post data
|
||||
const userUpvoted =
|
||||
@ -41,18 +48,17 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
|
||||
(post as unknown as { userDownvoted?: boolean }).userDownvoted || false;
|
||||
|
||||
// Check if bookmarked
|
||||
const isBookmarked = content.bookmarks.some(
|
||||
b => b.targetId === post.id && b.type === 'post'
|
||||
);
|
||||
const isBookmarked = content.bookmarks.some((b) => b.targetId === post.id && b.type === 'post');
|
||||
const [bookmarkLoading, setBookmarkLoading] = React.useState(false);
|
||||
|
||||
// Remove duplicate vote status logic
|
||||
|
||||
// ✅ Content truncation (simple presentation logic is OK)
|
||||
const contentText = typeof post.content === 'string' ? post.content : String(post.content ?? '');
|
||||
const contentPreview =
|
||||
post.content.length > 200
|
||||
? post.content.substring(0, 200) + '...'
|
||||
: post.content;
|
||||
contentText.length > 200
|
||||
? contentText.substring(0, 200) + '...'
|
||||
: contentText;
|
||||
|
||||
const handleVote = async (e: React.MouseEvent, isUpvote: boolean) => {
|
||||
e.preventDefault();
|
||||
@ -114,7 +120,7 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
|
||||
>
|
||||
<ArrowDown className="w-5 h-5" />
|
||||
</button>
|
||||
{votePending && (
|
||||
{isPending && (
|
||||
<span className="mt-1 text-[10px] text-yellow-400">syncing…</span>
|
||||
)}
|
||||
</div>
|
||||
@ -140,12 +146,12 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
{post.relevanceScore !== undefined && (
|
||||
{('relevanceScore' in post) && typeof (post as Post).relevanceScore === 'number' && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<RelevanceIndicator
|
||||
score={post.relevanceScore}
|
||||
details={post.relevanceDetails}
|
||||
score={(post as Post).relevanceScore as number}
|
||||
details={('relevanceDetails' in post ? (post as Post).relevanceDetails : undefined)}
|
||||
type="post"
|
||||
className="text-xs"
|
||||
showTooltip={true}
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||
import { usePost, usePostComments } from '@/hooks';
|
||||
import { Button } from '@/components/ui/button';
|
||||
//
|
||||
// import ResizableTextarea from '@/components/ui/resizable-textarea';
|
||||
import { MarkdownInput } from '@/components/ui/markdown-input';
|
||||
import {
|
||||
ArrowLeft,
|
||||
@ -16,12 +13,12 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
import { RelevanceIndicator } from './ui/relevance-indicator';
|
||||
import { AuthorDisplay } from './ui/author-display';
|
||||
import { BookmarkButton } from './ui/bookmark-button';
|
||||
import { MarkdownRenderer } from './ui/markdown-renderer';
|
||||
import CommentCard from './CommentCard';
|
||||
import { useForum } from '@opchan/react';
|
||||
import { useContent, usePermissions } from '@/hooks';
|
||||
import type { Cell as ForumCell } from '@opchan/core';
|
||||
import { ShareButton } from './ui/ShareButton';
|
||||
|
||||
const PostDetail = () => {
|
||||
@ -29,21 +26,19 @@ const PostDetail = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Use aggregated forum API
|
||||
const forum = useForum();
|
||||
const { content, permissions } = forum;
|
||||
const content = useContent();
|
||||
const permissions = usePermissions();
|
||||
|
||||
// Get post and comments using focused hooks
|
||||
const post = usePost(postId);
|
||||
const comments = usePostComments(postId);
|
||||
const post = content.posts.find((p) => p.id === postId);
|
||||
const visibleComments = postId ? content.commentsByPost[postId] ?? [] : [];
|
||||
|
||||
// Use library pending API
|
||||
const postPending = content.pending.isPending(post?.id);
|
||||
const postVotePending = content.pending.isVotePending(post?.id);
|
||||
const postVotePending = content.pending.isPending(post?.id);
|
||||
|
||||
// Check if bookmarked
|
||||
const isBookmarked = content.bookmarks.some(
|
||||
b => b.targetId === post?.id && b.type === 'post'
|
||||
);
|
||||
const isBookmarked = content.bookmarks.some((b) => b.targetId === post?.id && b.type === 'post');
|
||||
const [bookmarkLoading, setBookmarkLoading] = React.useState(false);
|
||||
|
||||
const [newComment, setNewComment] = useState('');
|
||||
@ -51,7 +46,7 @@ const PostDetail = () => {
|
||||
if (!postId) return <div>Invalid post ID</div>;
|
||||
|
||||
// ✅ Loading state handled by hook
|
||||
if (comments.isLoading) {
|
||||
if (postPending) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-16 text-center">
|
||||
<Loader2 className="w-8 h-8 mx-auto mb-4 animate-spin text-primary" />
|
||||
@ -77,8 +72,7 @@ const PostDetail = () => {
|
||||
}
|
||||
|
||||
// ✅ All data comes pre-computed from hooks
|
||||
const { cell } = post;
|
||||
const visibleComments = comments.comments; // Already filtered by hook
|
||||
const cell = content.cells.find((c: ForumCell) => c.id === post?.cellId);
|
||||
|
||||
const handleCreateComment = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@ -121,11 +115,11 @@ const PostDetail = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Get vote status from post data
|
||||
const isPostUpvoted =
|
||||
(post as unknown as { userUpvoted?: boolean }).userUpvoted || false;
|
||||
const isPostDownvoted =
|
||||
(post as unknown as { userDownvoted?: boolean }).userDownvoted || false;
|
||||
// Get vote status from post data (enhanced posts only)
|
||||
const enhanced = post as unknown as { userUpvoted?: boolean; userDownvoted?: boolean; voteScore?: number };
|
||||
const isPostUpvoted = Boolean(enhanced.userUpvoted);
|
||||
const isPostDownvoted = Boolean(enhanced.userDownvoted);
|
||||
const score = typeof enhanced.voteScore === 'number' ? enhanced.voteScore : 0;
|
||||
|
||||
const handleModerateComment = async (commentId: string) => {
|
||||
const reason =
|
||||
@ -176,7 +170,7 @@ const PostDetail = () => {
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm font-bold">{post.voteScore}</span>
|
||||
<span className="text-sm font-bold">{score}</span>
|
||||
<button
|
||||
className={`p-1 rounded-sm hover:bg-muted/50 ${
|
||||
isPostDownvoted ? 'text-primary' : ''
|
||||
@ -217,18 +211,7 @@ const PostDetail = () => {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
{post.relevanceScore !== undefined && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<RelevanceIndicator
|
||||
score={post.relevanceScore}
|
||||
details={post.relevanceDetails}
|
||||
type="post"
|
||||
className="text-sm"
|
||||
showTooltip={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Relevance details unavailable in raw PostMessage; skip indicator */}
|
||||
{postPending && (
|
||||
<>
|
||||
<span>•</span>
|
||||
@ -323,7 +306,7 @@ const PostDetail = () => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
visibleComments.map(comment => (
|
||||
visibleComments.map((comment) => (
|
||||
<CommentCard
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useCell, useCellPosts, usePermissions, useUserVotes, useAuth, useForumData } from '@/hooks';
|
||||
import { useForum } from '@opchan/react';
|
||||
import { usePermissions, useAuth, useContent } from '@/hooks';
|
||||
import type { Post as ForumPost, Cell as ForumCell, VoteMessage } from '@opchan/core';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@ -31,21 +31,17 @@ const PostList = () => {
|
||||
const { cellId } = useParams<{ cellId: string }>();
|
||||
|
||||
// ✅ Use reactive hooks for data and actions
|
||||
const cell = useCell(cellId);
|
||||
const cellPosts = useCellPosts(cellId, { sortBy: 'relevance' });
|
||||
const forum = useForum();
|
||||
const { createPost, vote, moderate, refresh } = forum.content;
|
||||
const { createPost, vote, moderate, refresh, commentsByPost, cells, posts } = useContent();
|
||||
const cell = cells.find((c: ForumCell) => c.id === cellId);
|
||||
const isCreatingPost = false;
|
||||
const isVoting = false;
|
||||
const { canPost, canVote, canModerate } = usePermissions();
|
||||
const userVotes = useUserVotes();
|
||||
const { currentUser } = useAuth();
|
||||
const { commentsByPost } = useForumData();
|
||||
|
||||
const [newPostTitle, setNewPostTitle] = useState('');
|
||||
const [newPostContent, setNewPostContent] = useState('');
|
||||
|
||||
if (!cellId || cellPosts.isLoading) {
|
||||
if (!cellId) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="mb-6">
|
||||
@ -125,16 +121,26 @@ const PostList = () => {
|
||||
};
|
||||
|
||||
const handleVotePost = async (postId: string, isUpvote: boolean) => {
|
||||
// ✅ Permission checking handled in hook
|
||||
await vote({ targetId: postId, isUpvote });
|
||||
};
|
||||
|
||||
const getPostVoteType = (postId: string) => {
|
||||
return userVotes.getPostVoteType(postId);
|
||||
if (!currentUser) return null;
|
||||
const p = posts.find((p: ForumPost) => p.id === postId);
|
||||
if (!p) return null;
|
||||
const up = p.upvotes.some((v: VoteMessage) => v.author === currentUser.address);
|
||||
const down = p.downvotes.some((v: VoteMessage) => v.author === currentUser.address);
|
||||
return up ? 'upvote' : down ? 'downvote' : null;
|
||||
};
|
||||
|
||||
// ✅ Posts already filtered by hook based on user permissions
|
||||
const visiblePosts = cellPosts.posts;
|
||||
const visiblePosts = posts
|
||||
.filter((p: ForumPost) => p.cellId === cellId)
|
||||
.sort((a: ForumPost, b: ForumPost) => {
|
||||
const ar = a.relevanceScore ?? 0;
|
||||
const br = b.relevanceScore ?? 0;
|
||||
return br - ar || b.timestamp - a.timestamp;
|
||||
});
|
||||
|
||||
const handleModerate = async (postId: string) => {
|
||||
const reason =
|
||||
@ -184,12 +190,10 @@ const PostList = () => {
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={refresh}
|
||||
disabled={cellPosts.isLoading}
|
||||
disabled={false}
|
||||
title="Refresh data"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 ${cellPosts.isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="page-subtitle">{cell.description}</p>
|
||||
@ -264,7 +268,7 @@ const PostList = () => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
visiblePosts.map(post => (
|
||||
visiblePosts.map((post: ForumPost) => (
|
||||
<div key={post.id} className="thread-card">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Shield, Crown, Hash } from 'lucide-react';
|
||||
import { useUserDisplay } from '@/hooks';
|
||||
import { useUserDisplay } from '@opchan/react';
|
||||
|
||||
interface AuthorDisplayProps {
|
||||
address: string;
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { Bookmark, BookmarkType } from '@opchan/core';
|
||||
import { useUserDisplay } from '@/hooks';
|
||||
import { useUserDisplay } from '@opchan/react';
|
||||
import { cn } from '../../utils'
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { useModeration } from '@opchan/react';
|
||||
import { usePermissions, useForumData } from '@opchan/react';
|
||||
import React from 'react';
|
||||
import { usePermissions, useContent, useUIState } from '@/hooks';
|
||||
|
||||
export function ModerationToggle() {
|
||||
const { showModerated, toggleShowModerated } = useModeration();
|
||||
const { canModerate } = usePermissions();
|
||||
const { cellsWithStats } = useForumData();
|
||||
const { cellsWithStats } = useContent();
|
||||
|
||||
const [showModerated, setShowModerated] = useUIState<boolean>('showModerated', false);
|
||||
const toggleShowModerated = React.useCallback((value: boolean) => setShowModerated(value), [setShowModerated]);
|
||||
|
||||
// Check if user is admin of any cell
|
||||
const isAdminOfAnyCell = cellsWithStats.some(cell => canModerate(cell.id));
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Wifi, WifiOff, CheckCircle } from 'lucide-react';
|
||||
import { useNetworkStatus } from '@opchan/react';
|
||||
import { useNetwork } from '@opchan/react';
|
||||
import { cn } from '../../utils'
|
||||
|
||||
interface WakuHealthIndicatorProps {
|
||||
@ -13,21 +13,15 @@ export function WakuHealthIndicator({
|
||||
showText = true,
|
||||
size = 'md',
|
||||
}: WakuHealthIndicatorProps) {
|
||||
const network = useNetworkStatus();
|
||||
const connectionStatus = network.health.isConnected
|
||||
? 'connected'
|
||||
: 'disconnected';
|
||||
const statusColor = network.getHealthColor();
|
||||
const statusMessage = network.getStatusMessage();
|
||||
const {isConnected, statusMessage} = useNetwork();
|
||||
|
||||
const getIcon = () => {
|
||||
switch (connectionStatus) {
|
||||
case 'connected':
|
||||
return <CheckCircle className="text-green-500" />;
|
||||
case 'disconnected':
|
||||
return <WifiOff className="text-red-500" />;
|
||||
default:
|
||||
return <Wifi className="text-gray-500" />;
|
||||
if (isConnected === true) {
|
||||
return <CheckCircle className="text-green-500" />;
|
||||
} else if (isConnected === false) {
|
||||
return <WifiOff className="text-red-500" />;
|
||||
} else {
|
||||
return <Wifi className="text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
@ -49,9 +43,9 @@ export function WakuHealthIndicator({
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
statusColor === 'green' && 'text-green-400',
|
||||
statusColor === 'yellow' && 'text-yellow-400',
|
||||
statusColor === 'red' && 'text-red-400'
|
||||
isConnected === true && 'text-green-400',
|
||||
isConnected === false && 'text-red-400',
|
||||
isConnected === null && 'text-gray-400'
|
||||
)}
|
||||
>
|
||||
{statusMessage}
|
||||
@ -66,19 +60,19 @@ export function WakuHealthIndicator({
|
||||
* Useful for compact displays like headers or status bars
|
||||
*/
|
||||
export function WakuHealthDot({ className }: { className?: string }) {
|
||||
const { getHealthColor } = useNetworkStatus();
|
||||
const statusColor = getHealthColor();
|
||||
const { isConnected } = useNetwork();
|
||||
const statusColor = isConnected === true ? 'green' : isConnected === false ? 'red' : 'gray';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
statusColor === 'green' && 'bg-green-500',
|
||||
statusColor === 'yellow' && 'bg-yellow-500 animate-pulse',
|
||||
statusColor === 'red' && 'bg-red-500',
|
||||
isConnected === true && 'bg-green-500',
|
||||
isConnected === false && 'bg-red-500',
|
||||
isConnected === null && 'bg-gray-500',
|
||||
className
|
||||
)}
|
||||
title={`Waku network: ${statusColor}`}
|
||||
title={`Waku network: ${statusColor === 'green' ? 'Connected' : statusColor === 'red' ? 'Disconnected' : 'Loading'}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CheckCircle, Circle, Loader2 } from 'lucide-react';
|
||||
import { useAuth } from '@/hooks';
|
||||
import { EVerificationStatus, DelegationFullStatus } from '@opchan/core';
|
||||
import { EVerificationStatus } from '@opchan/core';
|
||||
import { WalletConnectionStep } from './wallet-connection-step';
|
||||
import { VerificationStep } from './verification-step';
|
||||
import { DelegationStep } from './delegation-step';
|
||||
@ -29,12 +29,8 @@ export function WalletWizard({
|
||||
}: WalletWizardProps) {
|
||||
const [currentStep, setCurrentStep] = React.useState<WizardStep>(1);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const { isAuthenticated, verificationStatus, getDelegationStatus } = useAuth();
|
||||
const [delegationStatus, setDelegationStatus] = React.useState<DelegationFullStatus | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
getDelegationStatus().then(setDelegationStatus).catch(console.error);
|
||||
}, [getDelegationStatus]);
|
||||
const [delegationStatus, setDelegationStatus] = React.useState<boolean>(false);
|
||||
const { isAuthenticated, verificationStatus, delegationStatus: getDelegationStatus } = useAuth();
|
||||
|
||||
// Reset wizard when opened - always start at step 1 for simplicity
|
||||
React.useEffect(() => {
|
||||
@ -44,6 +40,17 @@ export function WalletWizard({
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Load delegation status when component mounts or when user changes
|
||||
React.useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
getDelegationStatus().then(status => {
|
||||
setDelegationStatus(status.isValid);
|
||||
}).catch(console.error);
|
||||
} else {
|
||||
setDelegationStatus(false);
|
||||
}
|
||||
}, [isAuthenticated, getDelegationStatus]);
|
||||
|
||||
const handleStepComplete = (step: WizardStep) => {
|
||||
if (step < 3) {
|
||||
setCurrentStep((step + 1) as WizardStep);
|
||||
@ -68,7 +75,7 @@ export function WalletWizard({
|
||||
case 2:
|
||||
return verificationStatus !== EVerificationStatus.WALLET_UNCONNECTED;
|
||||
case 3:
|
||||
return delegationStatus?.isValid ?? false;
|
||||
return delegationStatus;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -1,52 +1,10 @@
|
||||
|
||||
export {
|
||||
useForumData,
|
||||
useAuth,
|
||||
useUserDisplay,
|
||||
useBookmarks,
|
||||
usePostBookmark,
|
||||
useCommentBookmark,
|
||||
} from '@opchan/react';
|
||||
|
||||
export type {
|
||||
ForumData,
|
||||
CellWithStats,
|
||||
PostWithVoteStatus,
|
||||
CommentWithVoteStatus,
|
||||
Permission,
|
||||
PermissionReasons,
|
||||
PermissionResult,
|
||||
UserDisplayInfo,
|
||||
} from '@opchan/react';
|
||||
|
||||
export { useCell, usePost } from '@opchan/react';
|
||||
export type { CellData, PostData } from '@opchan/react';
|
||||
|
||||
export { useCellPosts, usePostComments, useUserVotes } from '@opchan/react';
|
||||
export type {
|
||||
CellPostsOptions,
|
||||
CellPostsData,
|
||||
PostCommentsOptions,
|
||||
PostCommentsData,
|
||||
UserVoteData,
|
||||
} from '@opchan/react';
|
||||
|
||||
|
||||
export {
|
||||
useAuth ,
|
||||
useForum ,
|
||||
useNetwork,
|
||||
usePermissions,
|
||||
useNetworkStatus,
|
||||
useForumSelectors,
|
||||
useWallet,
|
||||
} from '@opchan/react';
|
||||
export type {
|
||||
NetworkHealth,
|
||||
SyncStatus,
|
||||
ConnectionStatus,
|
||||
NetworkStatusData,
|
||||
ForumSelectors,
|
||||
useContent,
|
||||
useUIState,
|
||||
} from '@opchan/react';
|
||||
|
||||
|
||||
export { useIsMobile as useMobile } from './use-mobile';
|
||||
export { useToast } from './use-toast';
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
import { Buffer } from 'buffer';
|
||||
import { OpChanProvider } from '@opchan/react';
|
||||
import { OpchanWithAppKit } from './providers/OpchanWithAppKit';
|
||||
import { WagmiProvider } from 'wagmi';
|
||||
import { AppKitProvider } from '@reown/appkit/react';
|
||||
import { appkitConfig, config } from '@opchan/core';
|
||||
@ -14,12 +14,9 @@ if (!(window as Window & typeof globalThis).Buffer) {
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<WagmiProvider config={config}>
|
||||
<AppKitProvider {...appkitConfig}>
|
||||
<OpChanProvider
|
||||
ordiscanApiKey={'6bb07766-d98c-4ddd-93fb-6a0e94d629dd'}
|
||||
debug={import.meta.env.DEV}
|
||||
>
|
||||
<OpchanWithAppKit config={{ ordiscanApiKey: '6bb07766-d98c-4ddd-93fb-6a0e94d629dd' }}>
|
||||
<App />
|
||||
</OpChanProvider>
|
||||
</OpchanWithAppKit>
|
||||
</AppKitProvider>
|
||||
</WagmiProvider>
|
||||
);
|
||||
|
||||
@ -16,7 +16,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useBookmarks } from '@/hooks';
|
||||
import { Bookmark, BookmarkType } from '@opchan/core';
|
||||
import {
|
||||
Trash2,
|
||||
@ -24,19 +23,12 @@ import {
|
||||
FileText,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@opchan/react';
|
||||
import { useAuth, useContent } from '@/hooks';
|
||||
|
||||
const BookmarksPage = () => {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
bookmarks,
|
||||
loading,
|
||||
error,
|
||||
removeBookmark,
|
||||
getBookmarksByType,
|
||||
clearAllBookmarks,
|
||||
} = useBookmarks();
|
||||
const { bookmarks, removeBookmark, clearAllBookmarks } = useContent();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'posts' | 'comments'>(
|
||||
'all'
|
||||
@ -61,8 +53,8 @@ const BookmarksPage = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const postBookmarks = getBookmarksByType(BookmarkType.POST);
|
||||
const commentBookmarks = getBookmarksByType(BookmarkType.COMMENT);
|
||||
const postBookmarks = bookmarks.filter(bookmark => bookmark.type === BookmarkType.POST);
|
||||
const commentBookmarks = bookmarks.filter(bookmark => bookmark.type === BookmarkType.COMMENT);
|
||||
|
||||
const getFilteredBookmarks = () => {
|
||||
switch (activeTab) {
|
||||
@ -87,36 +79,6 @@ const BookmarksPage = () => {
|
||||
await clearAllBookmarks();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-cyber-dark text-white">
|
||||
<Header />
|
||||
<main className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-cyber-accent mx-auto mb-4" />
|
||||
<p className="text-cyber-neutral">Loading bookmarks...</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-cyber-dark text-white">
|
||||
<Header />
|
||||
<main className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-red-400 mb-4">
|
||||
Error Loading Bookmarks
|
||||
</h1>
|
||||
<p className="text-cyber-neutral mb-4">{error}</p>
|
||||
<Button onClick={() => window.location.reload()}>Try Again</Button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
|
||||
@ -12,36 +12,23 @@ import {
|
||||
import PostCard from '@/components/PostCard';
|
||||
import FeedSidebar from '@/components/FeedSidebar';
|
||||
import { ModerationToggle } from '@/components/ui/moderation-toggle';
|
||||
import { useForumData, useAuth } from '@/hooks';
|
||||
import { useForum } from '@opchan/react';
|
||||
import { useAuth, useContent } from '@/hooks';
|
||||
import { EVerificationStatus } from '@opchan/core';
|
||||
import { sortPosts, SortOption } from '@/utils/sorting';
|
||||
const FeedPage: React.FC = () => {
|
||||
const forumData = useForumData();
|
||||
const content = useContent();
|
||||
const { verificationStatus } = useAuth();
|
||||
const { content } = useForum();
|
||||
const [sortOption, setSortOption] = useState<SortOption>('relevance');
|
||||
|
||||
const {
|
||||
filteredPosts,
|
||||
filteredCommentsByPost,
|
||||
isInitialLoading,
|
||||
isRefreshing,
|
||||
} = forumData;
|
||||
|
||||
// ✅ Use pre-computed filtered data
|
||||
const allPosts = useMemo(() => {
|
||||
return sortPosts(filteredPosts, sortOption);
|
||||
}, [filteredPosts, sortOption]);
|
||||
// Build sorted posts from content slices
|
||||
const allPosts = useMemo(() => sortPosts([...content.posts], sortOption), [content.posts, sortOption]);
|
||||
|
||||
// ✅ Get comment count from filtered organized data
|
||||
const getCommentCount = (postId: string) => {
|
||||
const comments = filteredCommentsByPost[postId] || [];
|
||||
return comments.length;
|
||||
};
|
||||
const getCommentCount = (postId: string) => (content.commentsByPost[postId] || []).length;
|
||||
|
||||
// Loading skeleton
|
||||
if (isInitialLoading) {
|
||||
if (!content.posts.length && !content.comments.length && !content.cells.length) {
|
||||
return (
|
||||
<div className="min-h-screen bg-cyber-dark">
|
||||
<div className="container mx-auto px-4 py-6 max-w-6xl">
|
||||
@ -137,12 +124,10 @@ const FeedPage: React.FC = () => {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={content.refresh}
|
||||
disabled={isRefreshing}
|
||||
disabled={false}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>Refresh</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,7 @@ import Header from '@/components/Header';
|
||||
import CellList from '@/components/CellList';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Wifi } from 'lucide-react';
|
||||
import { useForum } from '@opchan/react';
|
||||
import { useForum } from '@/hooks';
|
||||
|
||||
const Index = () => {
|
||||
const { network, content } = useForum();
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState} from 'react';
|
||||
import { useForum } from '@opchan/react';
|
||||
import { useAuth } from '@opchan/react';
|
||||
import { useUserDisplay } from '@/hooks';
|
||||
import { DelegationFullStatus } from '@opchan/core';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@ -42,19 +40,9 @@ export default function ProfilePage() {
|
||||
const { toast } = useToast();
|
||||
|
||||
// Get current user from auth context for the address
|
||||
const { currentUser, getDelegationStatus } = useAuth();
|
||||
const [delegationInfo, setDelegationInfo] =
|
||||
useState<DelegationFullStatus | null>(null);
|
||||
const { currentUser, delegation } = useAuth();
|
||||
const address = currentUser?.address;
|
||||
|
||||
// Load delegation status
|
||||
useEffect(() => {
|
||||
getDelegationStatus().then(setDelegationInfo).catch(console.error);
|
||||
}, [getDelegationStatus]);
|
||||
|
||||
// Get comprehensive user information from the unified hook
|
||||
const userInfo = useUserDisplay(address || '');
|
||||
|
||||
// Debug current user ENS info
|
||||
console.log('📋 Profile page debug:', {
|
||||
address,
|
||||
@ -65,8 +53,7 @@ export default function ProfilePage() {
|
||||
ensDetails: currentUser.ensDetails,
|
||||
verificationStatus: currentUser.verificationStatus,
|
||||
}
|
||||
: null,
|
||||
userInfo,
|
||||
: null
|
||||
});
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@ -77,21 +64,6 @@ export default function ProfilePage() {
|
||||
);
|
||||
const [walletWizardOpen, setWalletWizardOpen] = useState(false);
|
||||
|
||||
// Initialize and update local state when user data changes
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
// Use the same data source as the display (userInfo) for consistency
|
||||
const currentCallSign = userInfo.callSign || currentUser.callSign || '';
|
||||
const currentDisplayPreference =
|
||||
userInfo.displayPreference ||
|
||||
currentUser.displayPreference ||
|
||||
EDisplayPreference.WALLET_ADDRESS;
|
||||
|
||||
setCallSign(currentCallSign);
|
||||
setDisplayPreference(currentDisplayPreference);
|
||||
}
|
||||
}, [currentUser, userInfo.callSign, userInfo.displayPreference]);
|
||||
|
||||
// Copy to clipboard function
|
||||
const copyToClipboard = async (text: string, label: string) => {
|
||||
try {
|
||||
@ -191,9 +163,9 @@ export default function ProfilePage() {
|
||||
|
||||
const handleCancel = () => {
|
||||
// Reset to the same data source as display for consistency
|
||||
const currentCallSign = userInfo.callSign || currentUser.callSign || '';
|
||||
const currentCallSign = currentUser.callSign || currentUser.callSign || '';
|
||||
const currentDisplayPreference =
|
||||
userInfo.displayPreference ||
|
||||
currentUser.displayPreference ||
|
||||
currentUser.displayPreference ||
|
||||
EDisplayPreference.WALLET_ADDRESS;
|
||||
|
||||
@ -204,7 +176,7 @@ export default function ProfilePage() {
|
||||
|
||||
const getVerificationIcon = () => {
|
||||
// Use verification level from UserIdentityService (central database store)
|
||||
switch (userInfo.verificationLevel) {
|
||||
switch (currentUser.verificationStatus) {
|
||||
case EVerificationStatus.ENS_ORDINAL_VERIFIED:
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
case EVerificationStatus.WALLET_CONNECTED:
|
||||
@ -218,7 +190,7 @@ export default function ProfilePage() {
|
||||
|
||||
const getVerificationText = () => {
|
||||
// Use verification level from UserIdentityService (central database store)
|
||||
switch (userInfo.verificationLevel) {
|
||||
switch (currentUser.verificationStatus) {
|
||||
case EVerificationStatus.ENS_ORDINAL_VERIFIED:
|
||||
return 'Owns ENS or Ordinal';
|
||||
case EVerificationStatus.WALLET_CONNECTED:
|
||||
@ -232,7 +204,7 @@ export default function ProfilePage() {
|
||||
|
||||
const getVerificationColor = () => {
|
||||
// Use verification level from UserIdentityService (central database store)
|
||||
switch (userInfo.verificationLevel) {
|
||||
switch (currentUser.verificationStatus) {
|
||||
case EVerificationStatus.ENS_ORDINAL_VERIFIED:
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case EVerificationStatus.WALLET_CONNECTED:
|
||||
@ -290,36 +262,30 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xl font-mono font-bold text-white">
|
||||
{userInfo.displayName}
|
||||
{currentUser.displayName}
|
||||
</div>
|
||||
<div className="text-sm text-cyber-neutral">
|
||||
{/* Show ENS name if available */}
|
||||
{(userInfo.ensName ||
|
||||
currentUser?.ensDetails?.ensName) && (
|
||||
{(currentUser.ensDetails?.ensName ) && (
|
||||
<div>
|
||||
ENS:{' '}
|
||||
{userInfo.ensName ||
|
||||
currentUser?.ensDetails?.ensName}
|
||||
{currentUser.ensDetails?.ensName}
|
||||
</div>
|
||||
)}
|
||||
{/* Show Ordinal details if available */}
|
||||
{(userInfo.ordinalDetails ||
|
||||
currentUser?.ordinalDetails?.ordinalDetails) && (
|
||||
{(currentUser.ordinalDetails ) && (
|
||||
<div>
|
||||
Ordinal:{' '}
|
||||
{userInfo.ordinalDetails ||
|
||||
currentUser?.ordinalDetails?.ordinalDetails}
|
||||
{currentUser.ordinalDetails.ordinalDetails}
|
||||
</div>
|
||||
)}
|
||||
{/* Show fallback if neither ENS nor Ordinal */}
|
||||
{!(
|
||||
userInfo.ensName || currentUser?.ensDetails?.ensName
|
||||
currentUser.ensDetails?.ensName
|
||||
) &&
|
||||
!(
|
||||
userInfo.ordinalDetails ||
|
||||
currentUser?.ordinalDetails?.ordinalDetails
|
||||
currentUser.ordinalDetails?.ordinalDetails
|
||||
) && <div>No ENS or Ordinal verification</div>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{getVerificationIcon()}
|
||||
<Badge className={getVerificationColor()}>
|
||||
@ -329,6 +295,7 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wallet Section */}
|
||||
<div className="space-y-3">
|
||||
@ -398,7 +365,7 @@ export default function ProfilePage() {
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm bg-cyber-dark/50 border border-cyber-muted/30 px-3 py-2 rounded-md text-cyber-light">
|
||||
{userInfo.callSign ||
|
||||
{currentUser.callSign ||
|
||||
currentUser.callSign ||
|
||||
'Not set'}
|
||||
</div>
|
||||
@ -444,7 +411,7 @@ export default function ProfilePage() {
|
||||
</Select>
|
||||
) : (
|
||||
<div className="text-sm bg-cyber-dark/50 border border-cyber-muted/30 px-3 py-2 rounded-md text-cyber-light">
|
||||
{(userInfo.displayPreference ||
|
||||
{(currentUser.displayPreference ||
|
||||
displayPreference) ===
|
||||
EDisplayPreference.CALL_SIGN
|
||||
? 'Call Sign (when available)'
|
||||
@ -494,8 +461,7 @@ export default function ProfilePage() {
|
||||
<Shield className="h-5 w-5 text-cyber-accent" />
|
||||
Security
|
||||
</div>
|
||||
{(forum.user.delegation.hasDelegation ||
|
||||
delegationInfo?.hasDelegation) && (
|
||||
{delegation.hasDelegation && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@ -503,9 +469,7 @@ export default function ProfilePage() {
|
||||
className="border-cyber-muted/30 text-cyber-neutral hover:bg-cyber-muted/30"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
{forum.user.delegation.isValid || delegationInfo?.isValid
|
||||
? 'Renew'
|
||||
: 'Setup'}
|
||||
{delegation.isValid ? 'Renew' : 'Setup'}
|
||||
</Button>
|
||||
)}
|
||||
</CardTitle>
|
||||
@ -518,35 +482,25 @@ export default function ProfilePage() {
|
||||
Delegation
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
forum.user.delegation.isValid || delegationInfo?.isValid
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
variant={delegation.isValid ? 'default' : 'secondary'}
|
||||
className={
|
||||
forum.user.delegation.isValid || delegationInfo?.isValid
|
||||
delegation.isValid
|
||||
? 'bg-green-500/20 text-green-400 border-green-500/30'
|
||||
: 'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
}
|
||||
>
|
||||
{forum.user.delegation.isValid || delegationInfo?.isValid
|
||||
? 'Active'
|
||||
: 'Inactive'}
|
||||
{delegation.isValid ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Expiry Date */}
|
||||
{(forum.user.delegation.expiresAt ||
|
||||
currentUser.delegationExpiry) && (
|
||||
{delegation.expiresAt && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-cyber-neutral">
|
||||
Valid until
|
||||
</span>
|
||||
<div className="text-sm font-mono text-cyber-light">
|
||||
{(
|
||||
forum.user.delegation.expiresAt ||
|
||||
new Date(currentUser.delegationExpiry!)
|
||||
).toLocaleDateString()}
|
||||
{delegation.expiresAt.toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -559,16 +513,12 @@ export default function ProfilePage() {
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
forum.user.delegation.isValid ||
|
||||
currentUser.delegationSignature === 'valid'
|
||||
delegation.isValid
|
||||
? 'text-green-400 border-green-500/30 bg-green-500/10'
|
||||
: 'text-red-400 border-red-500/30 bg-red-500/10'
|
||||
}
|
||||
>
|
||||
{forum.user.delegation.isValid ||
|
||||
currentUser.delegationSignature === 'valid'
|
||||
? 'Valid'
|
||||
: 'Not signed'}
|
||||
{delegation.isValid ? 'Valid' : 'Not signed'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@ -580,19 +530,17 @@ export default function ProfilePage() {
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 font-mono text-xs bg-cyber-dark/50 border border-cyber-muted/30 px-2 py-1 rounded text-cyber-light">
|
||||
{forum.user.delegation.publicKey || currentUser.browserPubKey
|
||||
? `${(forum.user.delegation.publicKey || currentUser.browserPubKey!).slice(0, 12)}...${(forum.user.delegation.publicKey || currentUser.browserPubKey!).slice(-8)}`
|
||||
{delegation.publicKey
|
||||
? `${delegation.publicKey.slice(0, 12)}...${delegation.publicKey.slice(-8)}`
|
||||
: 'Not delegated'}
|
||||
</div>
|
||||
{(forum.user.delegation.publicKey ||
|
||||
currentUser.browserPubKey) && (
|
||||
{delegation.publicKey && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
forum.user.delegation.publicKey ||
|
||||
currentUser.browserPubKey!,
|
||||
delegation.publicKey!,
|
||||
'Public Key'
|
||||
)
|
||||
}
|
||||
@ -605,10 +553,7 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
|
||||
{/* Warning for expired delegation */}
|
||||
{(!forum.user.delegation.isValid &&
|
||||
forum.user.delegation.hasDelegation) ||
|
||||
(!delegationInfo?.isValid &&
|
||||
delegationInfo?.hasDelegation && (
|
||||
{(!delegation.isValid && delegation.hasDelegation) && (
|
||||
<div className="p-3 bg-orange-500/10 border border-orange-500/30 rounded-md">
|
||||
<div className="flex items-center gap-2 text-orange-400">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
@ -618,7 +563,7 @@ export default function ProfilePage() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
43
app/src/providers/OpchanWithAppKit.tsx
Normal file
43
app/src/providers/OpchanWithAppKit.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import * as React from 'react';
|
||||
import { OpChanProvider, type WalletAdapter, type WalletAdapterAccount } from '@opchan/react';
|
||||
import { useAppKitAccount } from '@reown/appkit/react';
|
||||
import type { OpChanClientConfig } from '@opchan/core';
|
||||
|
||||
interface Props { config: OpChanClientConfig; children: React.ReactNode }
|
||||
|
||||
export const OpchanWithAppKit: React.FC<Props> = ({ config, children }) => {
|
||||
const btc = useAppKitAccount({ namespace: 'bip122' });
|
||||
const eth = useAppKitAccount({ namespace: 'eip155' });
|
||||
|
||||
const listenersRef = React.useRef(new Set<(a: WalletAdapterAccount | null) => void>());
|
||||
|
||||
const getCurrent = React.useCallback((): WalletAdapterAccount | null => {
|
||||
if (btc.isConnected && btc.address) return { address: btc.address, walletType: 'bitcoin' };
|
||||
if (eth.isConnected && eth.address) return { address: eth.address, walletType: 'ethereum' };
|
||||
return null;
|
||||
}, [btc.isConnected, btc.address, eth.isConnected, eth.address]);
|
||||
|
||||
const adapter = React.useMemo<WalletAdapter>(() => ({
|
||||
getAccount: () => getCurrent(),
|
||||
onChange: (cb) => {
|
||||
listenersRef.current.add(cb);
|
||||
return () => { listenersRef.current.delete(cb); };
|
||||
},
|
||||
}), [getCurrent]);
|
||||
|
||||
// Notify listeners when AppKit account changes
|
||||
React.useEffect(() => {
|
||||
const account = getCurrent();
|
||||
listenersRef.current.forEach(cb => {
|
||||
try { cb(account); } catch (e) { /* ignore */ }
|
||||
});
|
||||
}, [getCurrent]);
|
||||
|
||||
return (
|
||||
<OpChanProvider config={config} walletAdapter={adapter}>
|
||||
{children}
|
||||
</OpChanProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
} from '../../types/waku';
|
||||
import { OpchanMessage } from '../../types/forum';
|
||||
import { MessageValidator } from '../utils/MessageValidator';
|
||||
import { EVerificationStatus, User } from '../../types/identity';
|
||||
import { EDisplayPreference, EVerificationStatus, User } from '../../types/identity';
|
||||
import { DelegationInfo } from '../delegation/types';
|
||||
import { openLocalDB, STORE, StoreName } from '../database/schema';
|
||||
import { Bookmark, BookmarkCache } from '../../types/forum';
|
||||
@ -648,21 +648,17 @@ export class LocalDatabase {
|
||||
address: string,
|
||||
record: Partial<UserIdentityCache[string]> & { lastUpdated?: number }
|
||||
): Promise<void> {
|
||||
// Retrieve existing identity or initialize with proper defaults
|
||||
const existing: UserIdentityCache[string] =
|
||||
this.cache.userIdentities[address] ||
|
||||
({
|
||||
{
|
||||
ensName: undefined,
|
||||
ordinalDetails: undefined,
|
||||
callSign: undefined,
|
||||
displayPreference: EVerificationStatus.WALLET_UNCONNECTED
|
||||
? (undefined as never)
|
||||
: (undefined as never),
|
||||
// We'll set displayPreference when we receive a profile update; leave as
|
||||
// WALLET_ADDRESS by default for correctness.
|
||||
// Casting below ensures the object satisfies the interface at compile time.
|
||||
displayPreference: EDisplayPreference.WALLET_ADDRESS,
|
||||
lastUpdated: 0,
|
||||
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
|
||||
} as unknown as UserIdentityCache[string]);
|
||||
};
|
||||
|
||||
const merged: UserIdentityCache[string] = {
|
||||
...existing,
|
||||
@ -671,7 +667,7 @@ export class LocalDatabase {
|
||||
existing.lastUpdated ?? 0,
|
||||
record.lastUpdated ?? Date.now()
|
||||
),
|
||||
} as UserIdentityCache[string];
|
||||
};
|
||||
|
||||
this.cache.userIdentities[address] = merged;
|
||||
this.put(STORE.USER_IDENTITIES, { address, ...merged });
|
||||
|
||||
@ -5,9 +5,9 @@ export type User = {
|
||||
ordinalDetails?: OrdinalDetails;
|
||||
ensDetails?: EnsDetails;
|
||||
|
||||
//TODO: implement call sign & display preference setup
|
||||
callSign?: string;
|
||||
displayPreference: EDisplayPreference;
|
||||
displayName: string;
|
||||
|
||||
verificationStatus: EVerificationStatus;
|
||||
|
||||
|
||||
27
packages/react/eslint.config.js
Normal file
27
packages/react/eslint.config.js
Normal file
@ -0,0 +1,27 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
parserOptions: {
|
||||
tsconfigRootDir: new URL('.', import.meta.url).pathname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,20 +1,19 @@
|
||||
// New v1 exports are namespaced to avoid breaking the app while we migrate.
|
||||
// Old API remains available under ./old/index exports.
|
||||
|
||||
export * from './old/index';
|
||||
|
||||
export {
|
||||
OpChanProvider as OpChanProviderV1,
|
||||
useClient as useClientV1,
|
||||
OpChanProvider as ClientProvider ,
|
||||
useClient ,
|
||||
} from './v1/context/ClientContext';
|
||||
|
||||
export { OpChanProvider as NewOpChanProvider } from './v1/provider/OpChanProvider';
|
||||
export { OpChanProvider } from './v1/provider/OpChanProvider';
|
||||
export type { WalletAdapter, WalletAdapterAccount } from './v1/provider/OpChanProvider';
|
||||
|
||||
export { useAuth as useAuthV1 } from './v1/hooks/useAuth';
|
||||
export { useContent as useContentV1 } from './v1/hooks/useContent';
|
||||
export { usePermissions as usePermissionsV1 } from './v1/hooks/usePermissions';
|
||||
export { useNetwork as useNetworkV1 } from './v1/hooks/useNetwork';
|
||||
export { useUserDisplay as useUserDisplayV1 } from './v1/hooks/useUserDisplay';
|
||||
export { useForum as useForumV1 } from './v1/hooks/useForum';
|
||||
export { useAuth } from './v1/hooks/useAuth';
|
||||
export { useContent } from './v1/hooks/useContent';
|
||||
export { usePermissions } from './v1/hooks/usePermissions';
|
||||
export { useNetwork } from './v1/hooks/useNetwork';
|
||||
export { useForum } from './v1/hooks/useForum';
|
||||
export { useUIState } from './v1/hooks/useUIState';
|
||||
export { useUserDisplay } from './v1/hooks/useUserDisplay';
|
||||
|
||||
|
||||
|
||||
@ -1,379 +0,0 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { User, EVerificationStatus, EDisplayPreference } from '@opchan/core';
|
||||
import { delegationManager, localDatabase } from '@opchan/core';
|
||||
import { DelegationDuration } from '@opchan/core';
|
||||
import { useAppKitAccount } from '@reown/appkit/react';
|
||||
import { useClient } from './ClientContext';
|
||||
|
||||
// Extend the base User with convenient, display-focused fields
|
||||
export type CurrentUser = User & {
|
||||
displayName: string;
|
||||
ensName?: string;
|
||||
ordinalDetailsText?: string;
|
||||
};
|
||||
|
||||
export interface AuthContextValue {
|
||||
currentUser: CurrentUser | null;
|
||||
isAuthenticated: boolean;
|
||||
isAuthenticating: boolean;
|
||||
verificationStatus: EVerificationStatus;
|
||||
|
||||
connectWallet: () => Promise<boolean>;
|
||||
disconnectWallet: () => void;
|
||||
verifyOwnership: () => Promise<boolean>;
|
||||
|
||||
delegateKey: (duration?: DelegationDuration) => Promise<boolean>;
|
||||
getDelegationStatus: () => ReturnType<typeof delegationManager.getStatus>;
|
||||
clearDelegation: () => Promise<void>;
|
||||
|
||||
signMessage: (message: unknown, statusCallback?: {
|
||||
onSent?: (messageId: string) => void;
|
||||
onAcknowledged?: (messageId: string) => void;
|
||||
onError?: (messageId: string, error: string) => void;
|
||||
}) => Promise<void>;
|
||||
verifyMessage: (message: unknown) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const client = useClient();
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||
|
||||
// Get wallet connection status from AppKit
|
||||
const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' });
|
||||
const ethereumAccount = useAppKitAccount({ namespace: 'eip155' });
|
||||
|
||||
const isWalletConnected = bitcoinAccount.isConnected || ethereumAccount.isConnected;
|
||||
const connectedAddress = bitcoinAccount.address || ethereumAccount.address;
|
||||
const walletType = bitcoinAccount.isConnected ? 'bitcoin' : 'ethereum';
|
||||
|
||||
// Helper: enrich a base User with identity-derived display fields
|
||||
const enrichUserWithIdentity = useCallback(async (baseUser: User): Promise<CurrentUser> => {
|
||||
const address = baseUser.address;
|
||||
// Resolve identity (debounced) and read display name from service
|
||||
const identity = await client.userIdentityService.getUserIdentity(address);
|
||||
const displayName = client.userIdentityService.getDisplayName(address);
|
||||
|
||||
const ensName = identity?.ensName ?? baseUser.ensDetails?.ensName;
|
||||
const ordinalDetailsText = identity?.ordinalDetails?.ordinalDetails ?? baseUser.ordinalDetails?.ordinalDetails;
|
||||
const callSign = identity?.callSign ?? baseUser.callSign;
|
||||
const displayPreference = identity?.displayPreference ?? baseUser.displayPreference;
|
||||
const verificationStatus = identity?.verificationStatus ?? baseUser.verificationStatus;
|
||||
|
||||
return {
|
||||
...baseUser,
|
||||
callSign,
|
||||
displayPreference,
|
||||
verificationStatus,
|
||||
displayName,
|
||||
ensName,
|
||||
ordinalDetailsText,
|
||||
} as CurrentUser;
|
||||
}, [client]);
|
||||
|
||||
// ✅ Removed console.log to prevent infinite loop spam
|
||||
|
||||
// Define verifyOwnership function early so it can be used in useEffect dependencies
|
||||
const verifyOwnership = useCallback(async (): Promise<boolean> => {
|
||||
if (!currentUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Centralize identity resolution in core service
|
||||
const identity = await client.userIdentityService.getUserIdentityFresh(currentUser.address);
|
||||
|
||||
const newVerificationStatus = identity?.verificationStatus ?? EVerificationStatus.WALLET_CONNECTED;
|
||||
|
||||
const updatedUser: User = {
|
||||
...currentUser,
|
||||
verificationStatus: newVerificationStatus,
|
||||
ensDetails: identity?.ensName ? { ensName: identity.ensName } : undefined,
|
||||
ordinalDetails: identity?.ordinalDetails,
|
||||
} as User;
|
||||
|
||||
const enriched = await enrichUserWithIdentity(updatedUser);
|
||||
setCurrentUser(enriched);
|
||||
await localDatabase.storeUser(updatedUser);
|
||||
|
||||
await localDatabase.upsertUserIdentity(currentUser.address, {
|
||||
ensName: identity?.ensName || undefined,
|
||||
ordinalDetails: identity?.ordinalDetails,
|
||||
verificationStatus: newVerificationStatus,
|
||||
lastUpdated: Date.now(),
|
||||
});
|
||||
|
||||
return newVerificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED;
|
||||
} catch (error) {
|
||||
console.error('❌ Verification failed:', error);
|
||||
const updatedUser = { ...currentUser, verificationStatus: EVerificationStatus.WALLET_CONNECTED } as User;
|
||||
const enriched = await enrichUserWithIdentity(updatedUser);
|
||||
setCurrentUser(enriched);
|
||||
await localDatabase.storeUser(updatedUser);
|
||||
return false;
|
||||
}
|
||||
}, [client, currentUser, enrichUserWithIdentity]);
|
||||
|
||||
// Hydrate user from LocalDatabase on mount
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const user = await localDatabase.loadUser();
|
||||
if (mounted && user) {
|
||||
const enriched = await enrichUserWithIdentity(user);
|
||||
setCurrentUser(enriched);
|
||||
|
||||
// 🔄 Sync verification status with UserIdentityService
|
||||
await localDatabase.upsertUserIdentity(user.address, {
|
||||
ensName: user.ensDetails?.ensName,
|
||||
ordinalDetails: user.ordinalDetails,
|
||||
verificationStatus: user.verificationStatus,
|
||||
lastUpdated: Date.now(),
|
||||
});
|
||||
|
||||
// 🔄 Check if verification status needs updating on load
|
||||
// If user has ENS details but verification status is outdated, auto-verify
|
||||
if (user.ensDetails?.ensName && user.verificationStatus !== EVerificationStatus.ENS_ORDINAL_VERIFIED) {
|
||||
try {
|
||||
await verifyOwnership();
|
||||
} catch (error) {
|
||||
console.error('❌ Auto-verification on load failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ Failed to load user from database:', e);
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [enrichUserWithIdentity]); // Remove verifyOwnership dependency to prevent infinite loops
|
||||
|
||||
// Auto-connect when wallet is detected
|
||||
useEffect(() => {
|
||||
const autoConnect = async () => {
|
||||
if (isWalletConnected && connectedAddress && !currentUser) {
|
||||
setIsAuthenticating(true);
|
||||
try {
|
||||
// Check if we have stored user data for this address
|
||||
const storedUser = await localDatabase.loadUser();
|
||||
|
||||
const user: User = storedUser && storedUser.address === connectedAddress ? {
|
||||
// Preserve existing user data including verification status
|
||||
...storedUser,
|
||||
walletType: walletType as 'bitcoin' | 'ethereum',
|
||||
lastChecked: Date.now(),
|
||||
} : {
|
||||
// Create new user with basic connection status
|
||||
address: connectedAddress,
|
||||
walletType: walletType as 'bitcoin' | 'ethereum',
|
||||
displayPreference: EDisplayPreference.WALLET_ADDRESS,
|
||||
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
|
||||
lastChecked: Date.now(),
|
||||
};
|
||||
|
||||
const enriched = await enrichUserWithIdentity(user);
|
||||
setCurrentUser(enriched);
|
||||
await localDatabase.storeUser(user);
|
||||
|
||||
// Also store identity info so UserIdentityService can access it
|
||||
await localDatabase.upsertUserIdentity(connectedAddress, {
|
||||
verificationStatus: user.verificationStatus,
|
||||
lastUpdated: Date.now(),
|
||||
});
|
||||
|
||||
// 🔥 AUTOMATIC VERIFICATION: Check if user needs verification
|
||||
// Only auto-verify if they don't already have ENS_ORDINAL_VERIFIED status
|
||||
if (user.verificationStatus !== EVerificationStatus.ENS_ORDINAL_VERIFIED) {
|
||||
try {
|
||||
await verifyOwnership();
|
||||
} catch (error) {
|
||||
console.error('❌ Auto-verification failed:', error);
|
||||
// Don't fail the connection if verification fails
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Auto-connect failed:', error);
|
||||
} finally {
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
} else if (!isWalletConnected && currentUser) {
|
||||
setCurrentUser(null);
|
||||
await localDatabase.clearUser();
|
||||
}
|
||||
};
|
||||
|
||||
autoConnect();
|
||||
}, [isWalletConnected, connectedAddress, walletType, enrichUserWithIdentity]); // Remove currentUser and verifyOwnership dependencies
|
||||
|
||||
// Ensure verificationStatus reflects a connected wallet even if a user was preloaded
|
||||
useEffect(() => {
|
||||
const syncConnectedStatus = async () => {
|
||||
if (!isWalletConnected || !connectedAddress || !currentUser) return;
|
||||
|
||||
const needsAddressSync =
|
||||
currentUser.address !== connectedAddress ||
|
||||
currentUser.walletType !== (walletType as 'bitcoin' | 'ethereum');
|
||||
|
||||
const needsStatusUpgrade =
|
||||
currentUser.verificationStatus === EVerificationStatus.WALLET_UNCONNECTED;
|
||||
|
||||
if (needsAddressSync || needsStatusUpgrade) {
|
||||
const nextStatus =
|
||||
currentUser.verificationStatus ===
|
||||
EVerificationStatus.ENS_ORDINAL_VERIFIED
|
||||
? EVerificationStatus.ENS_ORDINAL_VERIFIED
|
||||
: EVerificationStatus.WALLET_CONNECTED;
|
||||
|
||||
const updatedUser: User = {
|
||||
...currentUser,
|
||||
address: connectedAddress,
|
||||
walletType: walletType as 'bitcoin' | 'ethereum',
|
||||
verificationStatus: nextStatus,
|
||||
lastChecked: Date.now(),
|
||||
} as User;
|
||||
|
||||
const enriched = await enrichUserWithIdentity(updatedUser);
|
||||
setCurrentUser(enriched);
|
||||
await localDatabase.storeUser(updatedUser);
|
||||
await localDatabase.upsertUserIdentity(connectedAddress, {
|
||||
ensName: updatedUser.ensDetails?.ensName,
|
||||
ordinalDetails: updatedUser.ordinalDetails,
|
||||
verificationStatus: nextStatus,
|
||||
lastUpdated: Date.now(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
syncConnectedStatus();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isWalletConnected, connectedAddress, walletType, currentUser, enrichUserWithIdentity]);
|
||||
|
||||
// Keep currentUser in sync with identity updates (e.g., profile changes)
|
||||
useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
const off = client.userIdentityService.addRefreshListener(async (addr) => {
|
||||
if (addr !== currentUser.address) return;
|
||||
const enriched = await enrichUserWithIdentity(currentUser as User);
|
||||
setCurrentUser(enriched);
|
||||
});
|
||||
return () => {
|
||||
try { off && off(); } catch {}
|
||||
};
|
||||
}, [client, currentUser, enrichUserWithIdentity]);
|
||||
|
||||
const connectWallet = useCallback(async (): Promise<boolean> => {
|
||||
if (!isWalletConnected || !connectedAddress) return false;
|
||||
|
||||
try {
|
||||
setIsAuthenticating(true);
|
||||
|
||||
const user: User = {
|
||||
address: connectedAddress,
|
||||
walletType: walletType as 'bitcoin' | 'ethereum',
|
||||
displayPreference: currentUser?.displayPreference ?? EDisplayPreference.WALLET_ADDRESS,
|
||||
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
|
||||
lastChecked: Date.now(),
|
||||
};
|
||||
const enriched = await enrichUserWithIdentity(user);
|
||||
setCurrentUser(enriched);
|
||||
await localDatabase.storeUser(user);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('connectWallet failed', e);
|
||||
return false;
|
||||
} finally {
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
}, [currentUser?.displayPreference, isWalletConnected, connectedAddress, walletType, enrichUserWithIdentity]);
|
||||
|
||||
const disconnectWallet = useCallback(() => {
|
||||
setCurrentUser(null);
|
||||
localDatabase.clearUser().catch(console.error);
|
||||
}, []);
|
||||
|
||||
const delegateKey = useCallback(async (duration?: DelegationDuration): Promise<boolean> => {
|
||||
if (!currentUser) return false;
|
||||
|
||||
console.log('🔑 Starting delegation process...', { currentUser, duration });
|
||||
|
||||
try {
|
||||
const ok = await delegationManager.delegate(
|
||||
currentUser.address,
|
||||
currentUser.walletType,
|
||||
duration ?? '7days',
|
||||
async (msg: string) => {
|
||||
console.log('🖋️ Signing delegation message...', msg);
|
||||
|
||||
if (currentUser.walletType === 'ethereum') {
|
||||
// For Ethereum wallets, we need to import and use signMessage dynamically
|
||||
// This avoids the context issue by importing at runtime
|
||||
const { signMessage } = await import('wagmi/actions');
|
||||
const { config } = await import('@opchan/core');
|
||||
return await signMessage(config, { message: msg });
|
||||
} else {
|
||||
// For Bitcoin wallets, we need to use AppKit's Bitcoin adapter
|
||||
// For now, throw an error as Bitcoin signing needs special handling
|
||||
throw new Error('Bitcoin delegation signing not implemented yet. Please use Ethereum wallet.');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log('📝 Delegation result:', ok);
|
||||
return ok;
|
||||
} catch (e) {
|
||||
console.error('❌ delegateKey failed:', e);
|
||||
return false;
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
const getDelegationStatus = useCallback(async () => {
|
||||
return delegationManager.getStatus(currentUser?.address, currentUser?.walletType);
|
||||
}, [currentUser?.address, currentUser?.walletType]);
|
||||
|
||||
const clearDelegation = useCallback(async () => {
|
||||
await delegationManager.clear();
|
||||
}, []);
|
||||
|
||||
const verifyMessage = useCallback(async (message: unknown) => {
|
||||
try {
|
||||
return delegationManager.verify(message as never);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const ctx: AuthContextValue = useMemo(() => {
|
||||
return {
|
||||
currentUser,
|
||||
isAuthenticated: !!currentUser,
|
||||
isAuthenticating,
|
||||
verificationStatus: currentUser?.verificationStatus ?? EVerificationStatus.WALLET_UNCONNECTED,
|
||||
connectWallet,
|
||||
disconnectWallet,
|
||||
verifyOwnership,
|
||||
delegateKey,
|
||||
getDelegationStatus,
|
||||
clearDelegation,
|
||||
signMessage: client.messageManager.sendMessage.bind(client.messageManager),
|
||||
verifyMessage,
|
||||
};
|
||||
}, [client, currentUser, isAuthenticating, connectWallet, disconnectWallet, verifyOwnership, delegateKey, getDelegationStatus, clearDelegation, verifyMessage]);
|
||||
|
||||
return <AuthContext.Provider value={ctx}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within OpChanProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export { AuthContext };
|
||||
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import { OpChanClient } from '@opchan/core';
|
||||
|
||||
export interface ClientContextValue {
|
||||
client: OpChanClient;
|
||||
}
|
||||
|
||||
const ClientContext = createContext<ClientContextValue | null>(null);
|
||||
|
||||
export const ClientProvider: React.FC<{
|
||||
client: OpChanClient;
|
||||
children: React.ReactNode;
|
||||
}> = ({ client, children }) => {
|
||||
return (
|
||||
<ClientContext.Provider value={{ client }}>
|
||||
{children}
|
||||
</ClientContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useClient(): OpChanClient {
|
||||
const context = useContext(ClientContext);
|
||||
if (!context) {
|
||||
throw new Error('useClient must be used within OpChanProvider');
|
||||
}
|
||||
return context.client;
|
||||
}
|
||||
|
||||
export { ClientContext };
|
||||
@ -1,182 +0,0 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { localDatabase, getDataFromCache } from '@opchan/core';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { Cell, Post, Comment, UserVerificationStatus, EVerificationStatus } from '@opchan/core';
|
||||
import { useClient } from './ClientContext';
|
||||
import type { ForumActions } from '@opchan/core';
|
||||
|
||||
export interface ForumContextValue {
|
||||
cells: Cell[];
|
||||
posts: Post[];
|
||||
comments: Comment[];
|
||||
userVerificationStatus: UserVerificationStatus;
|
||||
|
||||
isInitialLoading: boolean;
|
||||
isRefreshing: boolean;
|
||||
isNetworkConnected: boolean;
|
||||
lastSync: number | null;
|
||||
error: string | null;
|
||||
|
||||
refreshData: () => Promise<void>;
|
||||
|
||||
// Actions
|
||||
actions: ForumActions;
|
||||
}
|
||||
|
||||
const ForumContext = createContext<ForumContextValue | null>(null);
|
||||
|
||||
export const ForumProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const client = useClient();
|
||||
const { currentUser } = useAuth();
|
||||
const [cells, setCells] = useState<Cell[]>([]);
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [userVerificationStatus, setUserVerificationStatus] = useState<UserVerificationStatus>({});
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isNetworkConnected, setIsNetworkConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const actions = useMemo(() => client.forumActions, [client]);
|
||||
|
||||
const updateFromCache = useCallback(async () => {
|
||||
try {
|
||||
// Rebuild verification status map from centralized user identity cache
|
||||
const nextVerificationStatus: UserVerificationStatus = {};
|
||||
try {
|
||||
const identities = localDatabase.cache.userIdentities || {};
|
||||
Object.entries(identities).forEach(([address, record]) => {
|
||||
const hasENS = Boolean((record as { ensName?: unknown }).ensName);
|
||||
const hasOrdinal = Boolean((record as { ordinalDetails?: unknown }).ordinalDetails);
|
||||
const verificationStatus = (record as { verificationStatus?: EVerificationStatus }).verificationStatus;
|
||||
const isVerified =
|
||||
hasENS ||
|
||||
hasOrdinal ||
|
||||
verificationStatus === EVerificationStatus.WALLET_CONNECTED ||
|
||||
verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED;
|
||||
nextVerificationStatus[address] = {
|
||||
isVerified,
|
||||
hasENS,
|
||||
hasOrdinal,
|
||||
ensName: (record as { ensName?: string }).ensName,
|
||||
verificationStatus,
|
||||
};
|
||||
});
|
||||
} catch {}
|
||||
|
||||
setUserVerificationStatus(nextVerificationStatus);
|
||||
|
||||
const data = await getDataFromCache(undefined, nextVerificationStatus);
|
||||
setCells(data.cells);
|
||||
setPosts(data.posts);
|
||||
setComments(data.comments);
|
||||
} catch (e) {
|
||||
console.error('Failed to read cache', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshData = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await updateFromCache();
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to refresh');
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [updateFromCache]);
|
||||
|
||||
// 1) Initial cache hydrate only – decoupled from network subscriptions
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
await updateFromCache();
|
||||
setIsInitialLoading(false);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to initialize');
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, [updateFromCache]);
|
||||
|
||||
// 2) Network wiring – subscribe once to the client's message manager
|
||||
useEffect(() => {
|
||||
let unsubHealth: (() => void) | null = null;
|
||||
let unsubMsg: (() => void) | null = null;
|
||||
|
||||
// Check initial health status from the provided client to ensure a single core instance
|
||||
const initialHealth = client.messageManager.currentHealth;
|
||||
const initialReady = client.messageManager.isReady;
|
||||
console.log('🔌 ForumContext initial state:', { initialReady, initialHealth });
|
||||
setIsNetworkConnected(!!initialReady);
|
||||
|
||||
unsubHealth = client.messageManager.onHealthChange(async (ready: boolean, health: any) => {
|
||||
console.log('🔌 ForumContext health change:', { ready, health });
|
||||
setIsNetworkConnected(!!ready);
|
||||
if (ready) {
|
||||
try { await updateFromCache(); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
unsubMsg = client.messageManager.onMessageReceived(async () => {
|
||||
await updateFromCache();
|
||||
});
|
||||
|
||||
return () => {
|
||||
try { unsubHealth && unsubHealth(); } catch {}
|
||||
try { unsubMsg && unsubMsg(); } catch {}
|
||||
};
|
||||
}, [client, updateFromCache]);
|
||||
|
||||
// 2b) Pending state wiring – rehydrate when local pending queue changes
|
||||
useEffect(() => {
|
||||
const off = localDatabase.onPendingChange(async () => {
|
||||
try { await updateFromCache(); } catch {}
|
||||
});
|
||||
return () => { try { off && off(); } catch {} };
|
||||
}, [updateFromCache]);
|
||||
|
||||
// 3) Visibility change: re-check connection immediately when tab becomes active
|
||||
useEffect(() => {
|
||||
const handleVisibility = async () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
const ready = client.messageManager.isReady;
|
||||
setIsNetworkConnected(!!ready);
|
||||
console.debug('🔌 ForumContext visibility check, ready:', ready);
|
||||
if (ready) {
|
||||
try { await updateFromCache(); } catch {}
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
||||
}, [client]);
|
||||
|
||||
const ctx: ForumContextValue = useMemo(() => ({
|
||||
cells,
|
||||
posts,
|
||||
comments,
|
||||
userVerificationStatus,
|
||||
isInitialLoading,
|
||||
isRefreshing,
|
||||
isNetworkConnected,
|
||||
lastSync: localDatabase.getSyncState().lastSync,
|
||||
error,
|
||||
refreshData,
|
||||
actions,
|
||||
}), [cells, posts, comments, userVerificationStatus, isInitialLoading, isRefreshing, isNetworkConnected, error, refreshData, actions]);
|
||||
|
||||
return <ForumContext.Provider value={ctx}>{children}</ForumContext.Provider>;
|
||||
};
|
||||
|
||||
export function useForum() {
|
||||
const ctx = useContext(ForumContext);
|
||||
if (!ctx) throw new Error('useForum must be used within OpChanProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export { ForumContext };
|
||||
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
import React, { createContext, useContext, useMemo, useState } from 'react';
|
||||
|
||||
export interface ModerationContextValue {
|
||||
showModerated: boolean;
|
||||
toggleShowModerated: () => void;
|
||||
}
|
||||
|
||||
const ModerationContext = createContext<ModerationContextValue | null>(null);
|
||||
|
||||
export const ModerationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [showModerated, setShowModerated] = useState(false);
|
||||
|
||||
const ctx = useMemo(() => ({
|
||||
showModerated,
|
||||
toggleShowModerated: () => setShowModerated(v => !v),
|
||||
}), [showModerated]);
|
||||
|
||||
return <ModerationContext.Provider value={ctx}>{children}</ModerationContext.Provider>;
|
||||
};
|
||||
|
||||
export function useModeration() {
|
||||
const ctx = useContext(ModerationContext);
|
||||
if (!ctx) throw new Error('useModeration must be used within OpChanProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export { ModerationContext };
|
||||
|
||||
|
||||
@ -1,304 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useForum } from '../../contexts/ForumContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useModeration } from '../../contexts/ModerationContext';
|
||||
import {
|
||||
Cell,
|
||||
Post,
|
||||
Comment,
|
||||
UserVerificationStatus,
|
||||
EVerificationStatus,
|
||||
} from '@opchan/core';
|
||||
|
||||
export interface CellWithStats extends Cell {
|
||||
postCount: number;
|
||||
activeUsers: number;
|
||||
recentActivity: number;
|
||||
}
|
||||
|
||||
export interface PostWithVoteStatus extends Post {
|
||||
userUpvoted: boolean;
|
||||
userDownvoted: boolean;
|
||||
voteScore: number;
|
||||
canVote: boolean;
|
||||
canModerate: boolean;
|
||||
}
|
||||
|
||||
export interface CommentWithVoteStatus extends Comment {
|
||||
userUpvoted: boolean;
|
||||
userDownvoted: boolean;
|
||||
voteScore: number;
|
||||
canVote: boolean;
|
||||
canModerate: boolean;
|
||||
}
|
||||
|
||||
export interface ForumData {
|
||||
// Raw data
|
||||
cells: Cell[];
|
||||
posts: Post[];
|
||||
comments: Comment[];
|
||||
userVerificationStatus: UserVerificationStatus;
|
||||
|
||||
// Loading states
|
||||
isInitialLoading: boolean;
|
||||
isRefreshing: boolean;
|
||||
isNetworkConnected: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Computed data with reactive updates
|
||||
cellsWithStats: CellWithStats[];
|
||||
postsWithVoteStatus: PostWithVoteStatus[];
|
||||
commentsWithVoteStatus: CommentWithVoteStatus[];
|
||||
|
||||
// Filtered data based on moderation settings
|
||||
filteredPosts: PostWithVoteStatus[];
|
||||
filteredComments: CommentWithVoteStatus[];
|
||||
filteredCellsWithStats: CellWithStats[];
|
||||
filteredCommentsByPost: Record<string, CommentWithVoteStatus[]>;
|
||||
|
||||
// Organized data
|
||||
postsByCell: Record<string, PostWithVoteStatus[]>;
|
||||
commentsByPost: Record<string, CommentWithVoteStatus[]>;
|
||||
|
||||
// User-specific data
|
||||
userVotedPosts: Set<string>;
|
||||
userVotedComments: Set<string>;
|
||||
userCreatedPosts: Set<string>;
|
||||
userCreatedComments: Set<string>;
|
||||
}
|
||||
|
||||
export function useForumData(): ForumData {
|
||||
const {
|
||||
cells,
|
||||
posts,
|
||||
comments,
|
||||
userVerificationStatus,
|
||||
isInitialLoading,
|
||||
isRefreshing,
|
||||
isNetworkConnected,
|
||||
error,
|
||||
} = useForum();
|
||||
|
||||
const { currentUser } = useAuth();
|
||||
const { showModerated } = useModeration();
|
||||
|
||||
const cellsWithStats = useMemo((): CellWithStats[] => {
|
||||
return cells.map(cell => {
|
||||
const cellPosts = posts.filter(post => post.cellId === cell.id);
|
||||
const recentPosts = cellPosts.filter(
|
||||
post => Date.now() - post.timestamp < 7 * 24 * 60 * 60 * 1000
|
||||
);
|
||||
|
||||
const uniqueAuthors = new Set(cellPosts.map(post => post.author));
|
||||
|
||||
return {
|
||||
...cell,
|
||||
postCount: cellPosts.length,
|
||||
activeUsers: uniqueAuthors.size,
|
||||
recentActivity: recentPosts.length,
|
||||
};
|
||||
});
|
||||
}, [cells, posts]);
|
||||
|
||||
const canUserVote = useMemo(() => {
|
||||
if (!currentUser) return false;
|
||||
return (
|
||||
currentUser.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED ||
|
||||
currentUser.verificationStatus === EVerificationStatus.WALLET_CONNECTED ||
|
||||
Boolean(currentUser.ensDetails) ||
|
||||
Boolean(currentUser.ordinalDetails)
|
||||
);
|
||||
}, [currentUser]);
|
||||
|
||||
const canUserModerate = useMemo(() => {
|
||||
const moderationMap: Record<string, boolean> = {};
|
||||
if (!currentUser) return moderationMap;
|
||||
cells.forEach(cell => {
|
||||
moderationMap[cell.id] = currentUser.address === (cell as unknown as { signature?: string }).signature;
|
||||
});
|
||||
return moderationMap;
|
||||
}, [currentUser, cells]);
|
||||
|
||||
const postsWithVoteStatus = useMemo((): PostWithVoteStatus[] => {
|
||||
return posts.map(post => {
|
||||
const userUpvoted = currentUser
|
||||
? post.upvotes.some(vote => vote.author === currentUser.address)
|
||||
: false;
|
||||
const userDownvoted = currentUser
|
||||
? post.downvotes.some(vote => vote.author === currentUser.address)
|
||||
: false;
|
||||
const voteScore = post.upvotes.length - post.downvotes.length;
|
||||
const canModerate = canUserModerate[post.cellId] || false;
|
||||
return {
|
||||
...post,
|
||||
userUpvoted,
|
||||
userDownvoted,
|
||||
voteScore,
|
||||
canVote: canUserVote,
|
||||
canModerate,
|
||||
};
|
||||
});
|
||||
}, [posts, currentUser, canUserVote, canUserModerate]);
|
||||
|
||||
const commentsWithVoteStatus = useMemo((): CommentWithVoteStatus[] => {
|
||||
return comments.map(comment => {
|
||||
const userUpvoted = currentUser
|
||||
? comment.upvotes.some(vote => vote.author === currentUser.address)
|
||||
: false;
|
||||
const userDownvoted = currentUser
|
||||
? comment.downvotes.some(vote => vote.author === currentUser.address)
|
||||
: false;
|
||||
const voteScore = comment.upvotes.length - comment.downvotes.length;
|
||||
|
||||
const parentPost = posts.find(post => post.id === comment.postId);
|
||||
const canModerate = parentPost
|
||||
? canUserModerate[parentPost.cellId] || false
|
||||
: false;
|
||||
return {
|
||||
...comment,
|
||||
userUpvoted,
|
||||
userDownvoted,
|
||||
voteScore,
|
||||
canVote: canUserVote,
|
||||
canModerate,
|
||||
};
|
||||
});
|
||||
}, [comments, currentUser, canUserVote, canUserModerate, posts]);
|
||||
|
||||
const postsByCell = useMemo((): Record<string, PostWithVoteStatus[]> => {
|
||||
const organized: Record<string, PostWithVoteStatus[]> = {};
|
||||
postsWithVoteStatus.forEach(post => {
|
||||
if (!organized[post.cellId]) organized[post.cellId] = [];
|
||||
organized[post.cellId]!.push(post);
|
||||
});
|
||||
Object.keys(organized).forEach(cellId => {
|
||||
const list = organized[cellId]!;
|
||||
list.sort((a, b) => {
|
||||
if (
|
||||
a.relevanceScore !== undefined &&
|
||||
b.relevanceScore !== undefined
|
||||
) {
|
||||
return b.relevanceScore - a.relevanceScore;
|
||||
}
|
||||
return b.timestamp - a.timestamp;
|
||||
});
|
||||
});
|
||||
return organized;
|
||||
}, [postsWithVoteStatus]);
|
||||
|
||||
const commentsByPost = useMemo((): Record<string, CommentWithVoteStatus[]> => {
|
||||
const organized: Record<string, CommentWithVoteStatus[]> = {};
|
||||
commentsWithVoteStatus.forEach(comment => {
|
||||
if (!organized[comment.postId]) organized[comment.postId] = [];
|
||||
organized[comment.postId]!.push(comment);
|
||||
});
|
||||
Object.keys(organized).forEach(postId => {
|
||||
const list = organized[postId]!;
|
||||
list.sort((a, b) => a.timestamp - b.timestamp);
|
||||
});
|
||||
return organized;
|
||||
}, [commentsWithVoteStatus]);
|
||||
|
||||
const userVotedPosts = useMemo(() => {
|
||||
const voted = new Set<string>();
|
||||
if (!currentUser) return voted;
|
||||
postsWithVoteStatus.forEach(post => {
|
||||
if (post.userUpvoted || post.userDownvoted) voted.add(post.id);
|
||||
});
|
||||
return voted;
|
||||
}, [postsWithVoteStatus, currentUser]);
|
||||
|
||||
const userVotedComments = useMemo(() => {
|
||||
const voted = new Set<string>();
|
||||
if (!currentUser) return voted;
|
||||
commentsWithVoteStatus.forEach(comment => {
|
||||
if (comment.userUpvoted || comment.userDownvoted) voted.add(comment.id);
|
||||
});
|
||||
return voted;
|
||||
}, [commentsWithVoteStatus, currentUser]);
|
||||
|
||||
const userCreatedPosts = useMemo(() => {
|
||||
const created = new Set<string>();
|
||||
if (!currentUser) return created;
|
||||
posts.forEach(post => {
|
||||
if (post.author === currentUser.address) created.add(post.id);
|
||||
});
|
||||
return created;
|
||||
}, [posts, currentUser]);
|
||||
|
||||
const userCreatedComments = useMemo(() => {
|
||||
const created = new Set<string>();
|
||||
if (!currentUser) return created;
|
||||
comments.forEach(comment => {
|
||||
if (comment.author === currentUser.address) created.add(comment.id);
|
||||
});
|
||||
return created;
|
||||
}, [comments, currentUser]);
|
||||
|
||||
const filteredPosts = useMemo(() => {
|
||||
return showModerated
|
||||
? postsWithVoteStatus
|
||||
: postsWithVoteStatus.filter(p => !p.moderated);
|
||||
}, [postsWithVoteStatus, showModerated]);
|
||||
|
||||
const filteredComments = useMemo(() => {
|
||||
if (showModerated) return commentsWithVoteStatus;
|
||||
const moderatedPostIds = new Set(
|
||||
postsWithVoteStatus.filter(p => p.moderated).map(p => p.id)
|
||||
);
|
||||
return commentsWithVoteStatus.filter(
|
||||
c => !c.moderated && !moderatedPostIds.has(c.postId)
|
||||
);
|
||||
}, [commentsWithVoteStatus, postsWithVoteStatus, showModerated]);
|
||||
|
||||
const filteredCellsWithStats = useMemo((): CellWithStats[] => {
|
||||
return cells.map(cell => {
|
||||
const cellPosts = filteredPosts.filter(post => post.cellId === cell.id);
|
||||
const recentPosts = cellPosts.filter(
|
||||
post => Date.now() - post.timestamp < 7 * 24 * 60 * 60 * 1000
|
||||
);
|
||||
const uniqueAuthors = new Set(cellPosts.map(post => post.author));
|
||||
return {
|
||||
...cell,
|
||||
postCount: cellPosts.length,
|
||||
activeUsers: uniqueAuthors.size,
|
||||
recentActivity: recentPosts.length,
|
||||
};
|
||||
});
|
||||
}, [cells, filteredPosts]);
|
||||
|
||||
const filteredCommentsByPost = useMemo((): Record<string, CommentWithVoteStatus[]> => {
|
||||
const organized: Record<string, CommentWithVoteStatus[]> = {};
|
||||
filteredComments.forEach(comment => {
|
||||
if (!organized[comment.postId]) organized[comment.postId] = [];
|
||||
organized[comment.postId]!.push(comment);
|
||||
});
|
||||
return organized;
|
||||
}, [filteredComments]);
|
||||
|
||||
return {
|
||||
cells,
|
||||
posts,
|
||||
comments,
|
||||
userVerificationStatus,
|
||||
isInitialLoading,
|
||||
isRefreshing,
|
||||
isNetworkConnected,
|
||||
error,
|
||||
cellsWithStats,
|
||||
postsWithVoteStatus,
|
||||
commentsWithVoteStatus,
|
||||
filteredPosts,
|
||||
filteredComments,
|
||||
filteredCellsWithStats,
|
||||
filteredCommentsByPost,
|
||||
postsByCell,
|
||||
commentsByPost,
|
||||
userVotedPosts,
|
||||
userVotedComments,
|
||||
userCreatedPosts,
|
||||
userCreatedComments,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,145 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useForumData } from './useForumData';
|
||||
import { EVerificationStatus } from '@opchan/core';
|
||||
|
||||
export interface Permission {
|
||||
canPost: boolean;
|
||||
canComment: boolean;
|
||||
canVote: boolean;
|
||||
canCreateCell: boolean;
|
||||
canModerate: (cellId: string) => boolean;
|
||||
canDelegate: boolean;
|
||||
canUpdateProfile: boolean;
|
||||
}
|
||||
|
||||
export interface PermissionReasons {
|
||||
voteReason: string;
|
||||
postReason: string;
|
||||
commentReason: string;
|
||||
createCellReason: string;
|
||||
moderateReason: (cellId: string) => string;
|
||||
}
|
||||
|
||||
export interface PermissionResult {
|
||||
allowed: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function usePermissions(): Permission &
|
||||
PermissionReasons & {
|
||||
checkPermission: (
|
||||
action: keyof Permission,
|
||||
cellId?: string
|
||||
) => PermissionResult;
|
||||
} {
|
||||
const { currentUser, verificationStatus } = useAuth();
|
||||
const { cellsWithStats } = useForumData();
|
||||
|
||||
const permissions = useMemo((): Permission => {
|
||||
const isWalletConnected =
|
||||
verificationStatus === EVerificationStatus.WALLET_CONNECTED;
|
||||
const isVerified =
|
||||
verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED;
|
||||
|
||||
return {
|
||||
canPost: isWalletConnected || isVerified,
|
||||
canComment: isWalletConnected || isVerified,
|
||||
canVote: isWalletConnected || isVerified,
|
||||
canCreateCell: isVerified,
|
||||
canModerate: (cellId: string) => {
|
||||
if (!currentUser || !cellId) return false;
|
||||
const cell = cellsWithStats.find(c => c.id === cellId);
|
||||
return cell ? cell.author === currentUser.address : false;
|
||||
},
|
||||
canDelegate: isWalletConnected || isVerified,
|
||||
canUpdateProfile: Boolean(currentUser),
|
||||
};
|
||||
}, [currentUser, verificationStatus, cellsWithStats]);
|
||||
|
||||
const reasons = useMemo((): PermissionReasons => {
|
||||
if (!currentUser) {
|
||||
return {
|
||||
voteReason: 'Connect your wallet to vote',
|
||||
postReason: 'Connect your wallet to post',
|
||||
commentReason: 'Connect your wallet to comment',
|
||||
createCellReason: 'Connect your wallet to create cells',
|
||||
moderateReason: () => 'Connect your wallet to moderate',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
voteReason: permissions.canVote
|
||||
? 'You can vote'
|
||||
: 'Connect your wallet to vote',
|
||||
postReason: permissions.canPost
|
||||
? 'You can post'
|
||||
: 'Connect your wallet to post',
|
||||
commentReason: permissions.canComment
|
||||
? 'You can comment'
|
||||
: 'Connect your wallet to comment',
|
||||
createCellReason: permissions.canCreateCell
|
||||
? 'You can create cells'
|
||||
: 'Verify ENS or Logos ordinal to create cells',
|
||||
moderateReason: (cellId: string) => {
|
||||
if (!cellId) return 'Cell ID required';
|
||||
return permissions.canModerate(cellId)
|
||||
? 'You can moderate this cell'
|
||||
: 'Only cell creators can moderate';
|
||||
},
|
||||
};
|
||||
}, [currentUser, permissions]);
|
||||
|
||||
const checkPermission = useMemo(() => {
|
||||
return (action: keyof Permission, cellId?: string): PermissionResult => {
|
||||
let allowed = false;
|
||||
let reason = '';
|
||||
|
||||
switch (action) {
|
||||
case 'canVote':
|
||||
allowed = permissions.canVote;
|
||||
reason = reasons.voteReason;
|
||||
break;
|
||||
case 'canPost':
|
||||
allowed = permissions.canPost;
|
||||
reason = reasons.postReason;
|
||||
break;
|
||||
case 'canComment':
|
||||
allowed = permissions.canComment;
|
||||
reason = reasons.commentReason;
|
||||
break;
|
||||
case 'canCreateCell':
|
||||
allowed = permissions.canCreateCell;
|
||||
reason = reasons.createCellReason;
|
||||
break;
|
||||
case 'canModerate':
|
||||
allowed = cellId ? permissions.canModerate(cellId) : false;
|
||||
reason = cellId ? reasons.moderateReason(cellId) : 'Cell ID required';
|
||||
break;
|
||||
case 'canDelegate':
|
||||
allowed = permissions.canDelegate;
|
||||
reason = allowed
|
||||
? 'You can delegate keys'
|
||||
: 'Connect your wallet to delegate keys';
|
||||
break;
|
||||
case 'canUpdateProfile':
|
||||
allowed = permissions.canUpdateProfile;
|
||||
reason = allowed
|
||||
? 'You can update your profile'
|
||||
: 'Connect your wallet to update profile';
|
||||
break;
|
||||
default:
|
||||
allowed = false;
|
||||
reason = 'Unknown permission';
|
||||
}
|
||||
|
||||
return { allowed, reason };
|
||||
};
|
||||
}, [permissions, reasons]);
|
||||
|
||||
return {
|
||||
...permissions,
|
||||
...reasons,
|
||||
checkPermission,
|
||||
};
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useForumData, CellWithStats } from '../core/useForumData';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { EVerificationStatus } from '@opchan/core';
|
||||
|
||||
export interface CellData extends CellWithStats {
|
||||
posts: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
author: string;
|
||||
timestamp: number;
|
||||
voteScore: number;
|
||||
commentCount: number;
|
||||
}>;
|
||||
isUserAdmin: boolean;
|
||||
canModerate: boolean;
|
||||
canPost: boolean;
|
||||
}
|
||||
|
||||
export function useCell(cellId: string | undefined): CellData | null {
|
||||
const { cellsWithStats, postsByCell, commentsByPost } = useForumData();
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!cellId) return null;
|
||||
|
||||
const cell = cellsWithStats.find(c => c.id === cellId);
|
||||
if (!cell) return null;
|
||||
|
||||
const cellPosts = postsByCell[cellId] || [];
|
||||
|
||||
const posts = cellPosts.map(post => ({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
content: post.content,
|
||||
author: post.author,
|
||||
timestamp: post.timestamp,
|
||||
voteScore: post.voteScore,
|
||||
commentCount: (commentsByPost[post.id] || []).length,
|
||||
}));
|
||||
|
||||
const isUserAdmin = currentUser
|
||||
? currentUser.address === (cell as unknown as { signature?: string }).signature
|
||||
: false;
|
||||
const canModerate = isUserAdmin;
|
||||
const canPost = currentUser
|
||||
? currentUser.verificationStatus ===
|
||||
EVerificationStatus.ENS_ORDINAL_VERIFIED ||
|
||||
currentUser.verificationStatus ===
|
||||
EVerificationStatus.WALLET_CONNECTED ||
|
||||
Boolean(currentUser.ensDetails) ||
|
||||
Boolean(currentUser.ordinalDetails)
|
||||
: false;
|
||||
|
||||
return {
|
||||
...cell,
|
||||
posts,
|
||||
isUserAdmin,
|
||||
canModerate,
|
||||
canPost,
|
||||
};
|
||||
}, [cellId, cellsWithStats, postsByCell, commentsByPost, currentUser]);
|
||||
}
|
||||
@ -1,97 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useForumData, PostWithVoteStatus } from '../core/useForumData';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useModeration } from '../../contexts/ModerationContext';
|
||||
|
||||
export interface CellPostsOptions {
|
||||
includeModerated?: boolean;
|
||||
sortBy?: 'relevance' | 'timestamp' | 'votes';
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface CellPostsData {
|
||||
posts: PostWithVoteStatus[];
|
||||
totalCount: number;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useCellPosts(
|
||||
cellId: string | undefined,
|
||||
options: CellPostsOptions = {}
|
||||
): CellPostsData {
|
||||
const { postsByCell, isInitialLoading, cellsWithStats } = useForumData();
|
||||
const { currentUser } = useAuth();
|
||||
const { showModerated } = useModeration();
|
||||
|
||||
const {
|
||||
includeModerated = showModerated,
|
||||
sortBy = 'relevance',
|
||||
limit,
|
||||
} = options;
|
||||
|
||||
return useMemo(() => {
|
||||
if (!cellId) {
|
||||
return {
|
||||
posts: [],
|
||||
totalCount: 0,
|
||||
hasMore: false,
|
||||
isLoading: isInitialLoading,
|
||||
};
|
||||
}
|
||||
|
||||
let posts = postsByCell[cellId] || [];
|
||||
|
||||
// Filter moderated posts unless user is admin
|
||||
if (!includeModerated) {
|
||||
const cell = cellsWithStats.find(c => c.id === cellId);
|
||||
const isUserAdmin =
|
||||
Boolean(currentUser && cell && currentUser.address === (cell as unknown as { signature?: string }).signature);
|
||||
|
||||
if (!isUserAdmin) {
|
||||
posts = posts.filter(post => !post.moderated);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort posts
|
||||
const sortedPosts = [...posts].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'relevance':
|
||||
if (
|
||||
a.relevanceScore !== undefined &&
|
||||
b.relevanceScore !== undefined
|
||||
) {
|
||||
return b.relevanceScore - a.relevanceScore;
|
||||
}
|
||||
return b.timestamp - a.timestamp;
|
||||
|
||||
case 'votes':
|
||||
return b.voteScore - a.voteScore;
|
||||
|
||||
case 'timestamp':
|
||||
default:
|
||||
return b.timestamp - a.timestamp;
|
||||
}
|
||||
});
|
||||
|
||||
// Apply limit if specified
|
||||
const limitedPosts = limit ? sortedPosts.slice(0, limit) : sortedPosts;
|
||||
const hasMore = limit ? sortedPosts.length > limit : false;
|
||||
|
||||
return {
|
||||
posts: limitedPosts,
|
||||
totalCount: sortedPosts.length,
|
||||
hasMore,
|
||||
isLoading: isInitialLoading,
|
||||
};
|
||||
}, [
|
||||
cellId,
|
||||
postsByCell,
|
||||
isInitialLoading,
|
||||
currentUser,
|
||||
cellsWithStats,
|
||||
includeModerated,
|
||||
sortBy,
|
||||
limit,
|
||||
]);
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
useForumData,
|
||||
PostWithVoteStatus,
|
||||
CommentWithVoteStatus,
|
||||
} from '../core/useForumData';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
export interface PostData extends PostWithVoteStatus {
|
||||
cell: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
} | null;
|
||||
comments: CommentWithVoteStatus[];
|
||||
commentCount: number;
|
||||
isUserAuthor: boolean;
|
||||
}
|
||||
|
||||
export function usePost(postId: string | undefined): PostData | null {
|
||||
const { postsWithVoteStatus, commentsByPost, cellsWithStats } =
|
||||
useForumData();
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!postId) return null;
|
||||
|
||||
const post = postsWithVoteStatus.find(p => p.id === postId);
|
||||
if (!post) return null;
|
||||
|
||||
const cell = cellsWithStats.find(c => c.id === post.cellId) || null;
|
||||
const comments = commentsByPost[postId] || [];
|
||||
const commentCount = comments.length;
|
||||
const isUserAuthor = currentUser
|
||||
? currentUser.address === post.author
|
||||
: false;
|
||||
|
||||
return {
|
||||
...post,
|
||||
cell: cell
|
||||
? {
|
||||
id: cell.id,
|
||||
name: cell.name,
|
||||
description: cell.description,
|
||||
}
|
||||
: null,
|
||||
comments,
|
||||
commentCount,
|
||||
isUserAuthor,
|
||||
};
|
||||
}, [
|
||||
postId,
|
||||
postsWithVoteStatus,
|
||||
commentsByPost,
|
||||
cellsWithStats,
|
||||
currentUser,
|
||||
]);
|
||||
}
|
||||
@ -1,97 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useForumData, CommentWithVoteStatus } from '../core/useForumData';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useModeration } from '../../contexts/ModerationContext';
|
||||
|
||||
export interface PostCommentsOptions {
|
||||
includeModerated?: boolean;
|
||||
sortBy?: 'timestamp' | 'votes';
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface PostCommentsData {
|
||||
comments: CommentWithVoteStatus[];
|
||||
totalCount: number;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function usePostComments(
|
||||
postId: string | undefined,
|
||||
options: PostCommentsOptions = {}
|
||||
): PostCommentsData {
|
||||
const {
|
||||
commentsByPost,
|
||||
isInitialLoading,
|
||||
postsWithVoteStatus,
|
||||
cellsWithStats,
|
||||
} = useForumData();
|
||||
const { currentUser } = useAuth();
|
||||
const { showModerated } = useModeration();
|
||||
|
||||
const {
|
||||
includeModerated = showModerated,
|
||||
sortBy = 'timestamp',
|
||||
limit,
|
||||
} = options;
|
||||
|
||||
return useMemo(() => {
|
||||
if (!postId) {
|
||||
return {
|
||||
comments: [],
|
||||
totalCount: 0,
|
||||
hasMore: false,
|
||||
isLoading: isInitialLoading,
|
||||
};
|
||||
}
|
||||
|
||||
let comments = commentsByPost[postId] || [];
|
||||
|
||||
// Filter moderated comments unless user is admin
|
||||
if (!includeModerated) {
|
||||
const post = postsWithVoteStatus.find(p => p.id === postId);
|
||||
const cell = post ? cellsWithStats.find(c => c.id === post.cellId) : null;
|
||||
const isUserAdmin =
|
||||
Boolean(currentUser && cell && currentUser.address === (cell as unknown as { signature?: string }).signature);
|
||||
|
||||
if (!isUserAdmin) {
|
||||
comments = comments.filter(comment => !comment.moderated);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort comments
|
||||
const sortedComments = [...comments].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'votes':
|
||||
return b.voteScore - a.voteScore;
|
||||
|
||||
case 'timestamp':
|
||||
default:
|
||||
return a.timestamp - b.timestamp; // Oldest first for comments
|
||||
}
|
||||
});
|
||||
|
||||
// Apply limit if specified
|
||||
const limitedComments = limit
|
||||
? sortedComments.slice(0, limit)
|
||||
: sortedComments;
|
||||
const hasMore = limit ? sortedComments.length > limit : false;
|
||||
|
||||
return {
|
||||
comments: limitedComments,
|
||||
totalCount: sortedComments.length,
|
||||
hasMore,
|
||||
isLoading: isInitialLoading,
|
||||
};
|
||||
}, [
|
||||
postId,
|
||||
commentsByPost,
|
||||
isInitialLoading,
|
||||
currentUser,
|
||||
postsWithVoteStatus,
|
||||
cellsWithStats,
|
||||
includeModerated,
|
||||
sortBy,
|
||||
limit,
|
||||
]);
|
||||
}
|
||||
@ -1,138 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useForumData } from '../core/useForumData';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
export interface UserVoteData {
|
||||
// Vote status for specific items
|
||||
hasVotedOnPost: (postId: string) => boolean;
|
||||
hasVotedOnComment: (commentId: string) => boolean;
|
||||
getPostVoteType: (postId: string) => 'upvote' | 'downvote' | null;
|
||||
getCommentVoteType: (commentId: string) => 'upvote' | 'downvote' | null;
|
||||
|
||||
// User's voting history
|
||||
votedPosts: Set<string>;
|
||||
votedComments: Set<string>;
|
||||
upvotedPosts: Set<string>;
|
||||
downvotedPosts: Set<string>;
|
||||
upvotedComments: Set<string>;
|
||||
downvotedComments: Set<string>;
|
||||
|
||||
// Statistics
|
||||
totalVotes: number;
|
||||
upvoteRatio: number;
|
||||
}
|
||||
|
||||
export function useUserVotes(userAddress?: string): UserVoteData {
|
||||
const { postsWithVoteStatus, commentsWithVoteStatus } = useForumData();
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
const targetAddress = userAddress || currentUser?.address;
|
||||
|
||||
return useMemo(() => {
|
||||
if (!targetAddress) {
|
||||
return {
|
||||
hasVotedOnPost: () => false,
|
||||
hasVotedOnComment: () => false,
|
||||
getPostVoteType: () => null,
|
||||
getCommentVoteType: () => null,
|
||||
votedPosts: new Set(),
|
||||
votedComments: new Set(),
|
||||
upvotedPosts: new Set(),
|
||||
downvotedPosts: new Set(),
|
||||
upvotedComments: new Set(),
|
||||
downvotedComments: new Set(),
|
||||
totalVotes: 0,
|
||||
upvoteRatio: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Build vote sets
|
||||
const votedPosts = new Set<string>();
|
||||
const votedComments = new Set<string>();
|
||||
const upvotedPosts = new Set<string>();
|
||||
const downvotedPosts = new Set<string>();
|
||||
const upvotedComments = new Set<string>();
|
||||
const downvotedComments = new Set<string>();
|
||||
|
||||
// Analyze post votes
|
||||
postsWithVoteStatus.forEach(post => {
|
||||
const hasUpvoted = post.upvotes.some(
|
||||
vote => vote.author === targetAddress
|
||||
);
|
||||
const hasDownvoted = post.downvotes.some(
|
||||
vote => vote.author === targetAddress
|
||||
);
|
||||
|
||||
if (hasUpvoted) {
|
||||
votedPosts.add(post.id);
|
||||
upvotedPosts.add(post.id);
|
||||
}
|
||||
if (hasDownvoted) {
|
||||
votedPosts.add(post.id);
|
||||
downvotedPosts.add(post.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Analyze comment votes
|
||||
commentsWithVoteStatus.forEach(comment => {
|
||||
const hasUpvoted = comment.upvotes.some(
|
||||
vote => vote.author === targetAddress
|
||||
);
|
||||
const hasDownvoted = comment.downvotes.some(
|
||||
vote => vote.author === targetAddress
|
||||
);
|
||||
|
||||
if (hasUpvoted) {
|
||||
votedComments.add(comment.id);
|
||||
upvotedComments.add(comment.id);
|
||||
}
|
||||
if (hasDownvoted) {
|
||||
votedComments.add(comment.id);
|
||||
downvotedComments.add(comment.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate statistics
|
||||
const totalVotes = votedPosts.size + votedComments.size;
|
||||
const totalUpvotes = upvotedPosts.size + upvotedComments.size;
|
||||
const upvoteRatio = totalVotes > 0 ? totalUpvotes / totalVotes : 0;
|
||||
|
||||
// Helper functions
|
||||
const hasVotedOnPost = (postId: string): boolean => {
|
||||
return votedPosts.has(postId);
|
||||
};
|
||||
|
||||
const hasVotedOnComment = (commentId: string): boolean => {
|
||||
return votedComments.has(commentId);
|
||||
};
|
||||
|
||||
const getPostVoteType = (postId: string): 'upvote' | 'downvote' | null => {
|
||||
if (upvotedPosts.has(postId)) return 'upvote';
|
||||
if (downvotedPosts.has(postId)) return 'downvote';
|
||||
return null;
|
||||
};
|
||||
|
||||
const getCommentVoteType = (
|
||||
commentId: string
|
||||
): 'upvote' | 'downvote' | null => {
|
||||
if (upvotedComments.has(commentId)) return 'upvote';
|
||||
if (downvotedComments.has(commentId)) return 'downvote';
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
hasVotedOnPost,
|
||||
hasVotedOnComment,
|
||||
getPostVoteType,
|
||||
getCommentVoteType,
|
||||
votedPosts,
|
||||
votedComments,
|
||||
upvotedPosts,
|
||||
downvotedPosts,
|
||||
upvotedComments,
|
||||
downvotedComments,
|
||||
totalVotes,
|
||||
upvoteRatio,
|
||||
};
|
||||
}, [postsWithVoteStatus, commentsWithVoteStatus, targetAddress]);
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
// Public hooks surface: aggregator and focused derived hooks
|
||||
// Aggregator hook (main API)
|
||||
export { useForumApi } from './useForum';
|
||||
|
||||
// Core hooks (complex logic)
|
||||
export { useForumData } from './core/useForumData';
|
||||
export { usePermissions } from './core/usePermissions';
|
||||
export { useUserDisplay } from './core/useUserDisplay';
|
||||
|
||||
// Derived hooks (data slicing utilities)
|
||||
export { useCell } from './derived/useCell';
|
||||
export { usePost } from './derived/usePost';
|
||||
export { useCellPosts } from './derived/useCellPosts';
|
||||
export { usePostComments } from './derived/usePostComments';
|
||||
export { useUserVotes } from './derived/useUserVotes';
|
||||
|
||||
// Utility hooks (remaining complex logic)
|
||||
export { useWallet } from './utilities/useWallet';
|
||||
export { useNetworkStatus } from './utilities/useNetworkStatus';
|
||||
export { useForumSelectors } from './utilities/useForumSelectors';
|
||||
export { useBookmarks, usePostBookmark, useCommentBookmark } from './utilities/useBookmarks';
|
||||
|
||||
// Export types
|
||||
export type {
|
||||
ForumData,
|
||||
CellWithStats,
|
||||
PostWithVoteStatus,
|
||||
CommentWithVoteStatus,
|
||||
} from './core/useForumData';
|
||||
|
||||
export type {
|
||||
Permission,
|
||||
PermissionReasons,
|
||||
PermissionResult,
|
||||
} from './core/usePermissions';
|
||||
|
||||
export type { UserDisplayInfo } from './core/useUserDisplay';
|
||||
|
||||
// Removed types from deleted action hooks - functionality now in useForumApi
|
||||
|
||||
export type { CellData } from './derived/useCell';
|
||||
export type { PostData } from './derived/usePost';
|
||||
export type { CellPostsOptions, CellPostsData } from './derived/useCellPosts';
|
||||
export type { PostCommentsOptions, PostCommentsData } from './derived/usePostComments';
|
||||
export type { UserVoteData } from './derived/useUserVotes';
|
||||
|
||||
// Utility types
|
||||
export type {
|
||||
NetworkHealth,
|
||||
SyncStatus,
|
||||
ConnectionStatus,
|
||||
NetworkStatusData,
|
||||
} from './utilities/useNetworkStatus';
|
||||
export type { ForumSelectors } from './utilities/useForumSelectors';
|
||||
|
||||
// Remove duplicate re-exports
|
||||
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
export { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
|
||||
@ -1,629 +0,0 @@
|
||||
import { useMemo, useCallback, useState, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useForum as useForumContext } from '../contexts/ForumContext';
|
||||
import { useClient } from '../contexts/ClientContext';
|
||||
import { usePermissions } from './core/usePermissions';
|
||||
import { useForumData } from './core/useForumData';
|
||||
import { useNetworkStatus } from './utilities/useNetworkStatus';
|
||||
import { useForumSelectors } from './utilities/useForumSelectors';
|
||||
|
||||
import type {
|
||||
Cell,
|
||||
Comment,
|
||||
Post,
|
||||
Bookmark,
|
||||
User,
|
||||
DelegationDuration,
|
||||
EDisplayPreference,
|
||||
EVerificationStatus,
|
||||
OpchanMessage,
|
||||
} from '@opchan/core';
|
||||
import type {
|
||||
CellWithStats,
|
||||
CommentWithVoteStatus,
|
||||
ForumData,
|
||||
PostWithVoteStatus,
|
||||
} from './core/useForumData';
|
||||
import type { Permission } from './core/usePermissions';
|
||||
|
||||
export interface UseForumApi {
|
||||
user: {
|
||||
isConnected: boolean;
|
||||
address?: string;
|
||||
walletType?: 'bitcoin' | 'ethereum';
|
||||
ensName?: string | null;
|
||||
ordinalDetails?: { ordinalId: string } | null;
|
||||
verificationStatus: EVerificationStatus;
|
||||
delegation: {
|
||||
hasDelegation: boolean;
|
||||
isValid: boolean;
|
||||
timeRemaining?: number;
|
||||
expiresAt?: Date;
|
||||
publicKey?: string;
|
||||
};
|
||||
profile: {
|
||||
callSign: string | null;
|
||||
displayPreference: EDisplayPreference | null;
|
||||
};
|
||||
connect: () => Promise<boolean>;
|
||||
disconnect: () => Promise<void>;
|
||||
verifyOwnership: () => Promise<boolean>;
|
||||
delegateKey: (duration?: DelegationDuration) => Promise<boolean>;
|
||||
clearDelegation: () => Promise<void>;
|
||||
updateProfile: (updates: {
|
||||
callSign?: string;
|
||||
displayPreference?: EDisplayPreference;
|
||||
}) => Promise<boolean>;
|
||||
signMessage: (msg: OpchanMessage) => Promise<void>;
|
||||
verifyMessage: (msg: OpchanMessage) => Promise<boolean>;
|
||||
};
|
||||
content: {
|
||||
cells: Cell[];
|
||||
posts: Post[];
|
||||
comments: Comment[];
|
||||
bookmarks: Bookmark[];
|
||||
postsByCell: Record<string, PostWithVoteStatus[]>;
|
||||
commentsByPost: Record<string, CommentWithVoteStatus[]>;
|
||||
filtered: {
|
||||
cells: CellWithStats[];
|
||||
posts: PostWithVoteStatus[];
|
||||
comments: CommentWithVoteStatus[];
|
||||
};
|
||||
createCell: (input: {
|
||||
name: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
}) => Promise<Cell | null>;
|
||||
createPost: (input: {
|
||||
cellId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
}) => Promise<Post | null>;
|
||||
createComment: (input: { postId: string; content: string }) => Promise<
|
||||
Comment | null
|
||||
>;
|
||||
vote: (input: { targetId: string; isUpvote: boolean }) => Promise<boolean>;
|
||||
moderate: {
|
||||
post: (
|
||||
cellId: string,
|
||||
postId: string,
|
||||
reason?: string
|
||||
) => Promise<boolean>;
|
||||
unpost: (
|
||||
cellId: string,
|
||||
postId: string,
|
||||
reason?: string
|
||||
) => Promise<boolean>;
|
||||
comment: (
|
||||
cellId: string,
|
||||
commentId: string,
|
||||
reason?: string
|
||||
) => Promise<boolean>;
|
||||
uncomment: (
|
||||
cellId: string,
|
||||
commentId: string,
|
||||
reason?: string
|
||||
) => Promise<boolean>;
|
||||
user: (
|
||||
cellId: string,
|
||||
userAddress: string,
|
||||
reason?: string
|
||||
) => Promise<boolean>;
|
||||
unuser: (
|
||||
cellId: string,
|
||||
userAddress: string,
|
||||
reason?: string
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
togglePostBookmark: (post: Post, cellId?: string) => Promise<boolean>;
|
||||
toggleCommentBookmark: (
|
||||
comment: Comment,
|
||||
postId?: string
|
||||
) => Promise<boolean>;
|
||||
refresh: () => Promise<void>;
|
||||
pending: {
|
||||
isPending: (id?: string) => boolean;
|
||||
isVotePending: (targetId?: string) => boolean;
|
||||
onChange: (cb: () => void) => () => void;
|
||||
};
|
||||
};
|
||||
permissions: {
|
||||
canPost: boolean;
|
||||
canComment: boolean;
|
||||
canVote: boolean;
|
||||
canCreateCell: boolean;
|
||||
canDelegate: boolean;
|
||||
canModerate: (cellId: string) => boolean;
|
||||
reasons: {
|
||||
vote: string;
|
||||
post: string;
|
||||
comment: string;
|
||||
createCell: string;
|
||||
moderate: (cellId: string) => string;
|
||||
};
|
||||
check: (
|
||||
action:
|
||||
| 'canPost'
|
||||
| 'canComment'
|
||||
| 'canVote'
|
||||
| 'canCreateCell'
|
||||
| 'canDelegate'
|
||||
| 'canModerate',
|
||||
cellId?: string
|
||||
) => { allowed: boolean; reason: string };
|
||||
};
|
||||
network: {
|
||||
isConnected: boolean;
|
||||
statusColor: 'green' | 'yellow' | 'red';
|
||||
statusMessage: string;
|
||||
issues: string[];
|
||||
canRefresh: boolean;
|
||||
canSync: boolean;
|
||||
needsAttention: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
recommendedActions: string[];
|
||||
};
|
||||
selectors: ReturnType<typeof useForumSelectors>;
|
||||
}
|
||||
|
||||
export function useForumApi(): UseForumApi {
|
||||
const client = useClient();
|
||||
const { currentUser, verificationStatus, connectWallet, disconnectWallet, verifyOwnership } = useAuth();
|
||||
const {
|
||||
refreshData,
|
||||
} = useForumContext();
|
||||
|
||||
const forumData: ForumData = useForumData();
|
||||
const permissions = usePermissions();
|
||||
const network = useNetworkStatus();
|
||||
const selectors = useForumSelectors(forumData);
|
||||
|
||||
// Bookmarks state (moved from useBookmarks)
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
|
||||
|
||||
// Delegation functionality (moved from useDelegation)
|
||||
const [delegationStatus, setDelegationStatus] = useState({
|
||||
hasDelegation: false,
|
||||
isValid: false,
|
||||
timeRemaining: 0,
|
||||
expiresAt: undefined as Date | undefined,
|
||||
publicKey: undefined as string | undefined,
|
||||
});
|
||||
|
||||
// Update delegation status
|
||||
useEffect(() => {
|
||||
const updateStatus = async () => {
|
||||
if (currentUser) {
|
||||
const status = await client.delegation.getStatus(currentUser.address, currentUser.walletType);
|
||||
setDelegationStatus({
|
||||
hasDelegation: !!status,
|
||||
isValid: status?.isValid || false,
|
||||
timeRemaining: status?.timeRemaining || 0,
|
||||
expiresAt: status?.proof?.expiryTimestamp ? new Date(status.proof.expiryTimestamp) : undefined,
|
||||
publicKey: status?.publicKey,
|
||||
});
|
||||
}
|
||||
};
|
||||
updateStatus();
|
||||
}, [client.delegation, currentUser]);
|
||||
|
||||
// Load bookmarks for current user
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (!currentUser?.address) {
|
||||
setBookmarks([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const list = await client.database.getUserBookmarks(currentUser.address);
|
||||
setBookmarks(list);
|
||||
} catch (e) {
|
||||
console.error('Failed to load bookmarks', e);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [client.database, currentUser?.address]);
|
||||
|
||||
const createDelegation = useCallback(async (duration?: DelegationDuration): Promise<boolean> => {
|
||||
if (!currentUser) return false;
|
||||
try {
|
||||
// Use the delegate method from DelegationManager
|
||||
const signFunction = async (message: string) => {
|
||||
// This would need to be implemented based on your wallet signing approach
|
||||
// For now, return empty string - this needs proper wallet integration
|
||||
return '';
|
||||
};
|
||||
|
||||
const success = await client.delegation.delegate(
|
||||
currentUser.address,
|
||||
currentUser.walletType,
|
||||
duration,
|
||||
signFunction
|
||||
);
|
||||
|
||||
if (success) {
|
||||
// Update status after successful delegation
|
||||
const status = await client.delegation.getStatus(currentUser.address, currentUser.walletType);
|
||||
setDelegationStatus({
|
||||
hasDelegation: !!status,
|
||||
isValid: status?.isValid || false,
|
||||
timeRemaining: status?.timeRemaining || 0,
|
||||
expiresAt: status?.proof?.expiryTimestamp ? new Date(status.proof.expiryTimestamp) : undefined,
|
||||
publicKey: status?.publicKey,
|
||||
});
|
||||
}
|
||||
|
||||
return success;
|
||||
} catch (error) {
|
||||
console.error('Failed to create delegation:', error);
|
||||
return false;
|
||||
}
|
||||
}, [client.delegation, currentUser]);
|
||||
|
||||
const clearDelegation = useCallback(async (): Promise<void> => {
|
||||
// Clear delegation storage using the database directly
|
||||
await client.database.clearDelegation();
|
||||
setDelegationStatus({
|
||||
hasDelegation: false,
|
||||
isValid: false,
|
||||
timeRemaining: 0,
|
||||
expiresAt: undefined,
|
||||
publicKey: undefined,
|
||||
});
|
||||
}, [client.database]);
|
||||
|
||||
// Message signing functionality (moved from useMessageSigning)
|
||||
const signMessage = useCallback(async (message: OpchanMessage): Promise<void> => {
|
||||
if (!currentUser) {
|
||||
console.warn('No current user. Cannot sign message.');
|
||||
return;
|
||||
}
|
||||
const status = await client.delegation.getStatus(currentUser.address, currentUser.walletType);
|
||||
if (!status?.isValid) {
|
||||
console.warn('No valid delegation found. Cannot sign message.');
|
||||
return;
|
||||
}
|
||||
await client.messageManager.sendMessage(message);
|
||||
}, [client.delegation, client.messageManager, currentUser]);
|
||||
|
||||
const verifyMessage = useCallback(async (message: OpchanMessage): Promise<boolean> => {
|
||||
try {
|
||||
// Use message service to verify message
|
||||
return await client.messageService.verifyMessage(message);
|
||||
} catch (error) {
|
||||
console.error('Failed to verify message:', error);
|
||||
return false;
|
||||
}
|
||||
}, [client.messageService]);
|
||||
|
||||
type MaybeOrdinal = { ordinalId?: unknown } | null | undefined;
|
||||
const toOrdinal = (value: MaybeOrdinal): { ordinalId: string } | null => {
|
||||
if (value && typeof value === 'object' && typeof (value as { ordinalId?: unknown }).ordinalId === 'string') {
|
||||
return { ordinalId: (value as { ordinalId: string }).ordinalId };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const user = useMemo(() => {
|
||||
return {
|
||||
isConnected: Boolean(currentUser),
|
||||
address: currentUser?.address,
|
||||
walletType: currentUser?.walletType,
|
||||
ensName: currentUser?.ensDetails?.ensName ?? null,
|
||||
ordinalDetails: toOrdinal((currentUser as unknown as { ordinalDetails?: MaybeOrdinal } | null | undefined)?.ordinalDetails),
|
||||
verificationStatus: verificationStatus,
|
||||
delegation: {
|
||||
hasDelegation: delegationStatus.hasDelegation,
|
||||
isValid: delegationStatus.isValid,
|
||||
timeRemaining: delegationStatus.timeRemaining,
|
||||
expiresAt: delegationStatus.expiresAt,
|
||||
publicKey: delegationStatus.publicKey,
|
||||
},
|
||||
profile: {
|
||||
callSign: currentUser?.callSign ?? null,
|
||||
displayPreference: currentUser?.displayPreference ?? null,
|
||||
},
|
||||
connect: async () => connectWallet(),
|
||||
disconnect: async () => { disconnectWallet(); },
|
||||
verifyOwnership: async () => verifyOwnership(),
|
||||
delegateKey: async (duration?: DelegationDuration) => createDelegation(duration),
|
||||
clearDelegation: async () => { await clearDelegation(); },
|
||||
updateProfile: async (updates: { callSign?: string; displayPreference?: EDisplayPreference }) => {
|
||||
if (!currentUser) {
|
||||
throw new Error('User identity service is not available.');
|
||||
}
|
||||
try {
|
||||
// Update user identity in database
|
||||
await client.database.upsertUserIdentity(currentUser.address, {
|
||||
...(updates.callSign !== undefined ? { callSign: updates.callSign } : {}),
|
||||
...(updates.displayPreference !== undefined ? { displayPreference: updates.displayPreference } : {}),
|
||||
lastUpdated: Date.now(),
|
||||
});
|
||||
|
||||
// Update user lightweight record for displayPreference if present
|
||||
if (updates.displayPreference !== undefined) {
|
||||
const updatedUser: User = {
|
||||
address: currentUser.address,
|
||||
walletType: currentUser.walletType!,
|
||||
verificationStatus: currentUser.verificationStatus,
|
||||
displayPreference: updates.displayPreference,
|
||||
callSign: currentUser.callSign ?? undefined,
|
||||
ensDetails: currentUser.ensDetails ?? undefined,
|
||||
ordinalDetails: (currentUser as unknown as { ordinalDetails?: { ordinalId: string; ordinalDetails: string } | null }).ordinalDetails ?? undefined,
|
||||
lastChecked: Date.now(),
|
||||
};
|
||||
await client.database.storeUser(updatedUser);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update profile:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
signMessage,
|
||||
verifyMessage,
|
||||
};
|
||||
}, [currentUser, verificationStatus, delegationStatus, connectWallet, disconnectWallet, verifyOwnership, createDelegation, clearDelegation, signMessage, verifyMessage]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
return {
|
||||
cells: forumData.cells,
|
||||
posts: forumData.posts,
|
||||
comments: forumData.comments,
|
||||
bookmarks,
|
||||
postsByCell: forumData.postsByCell,
|
||||
commentsByPost: forumData.commentsByPost,
|
||||
filtered: {
|
||||
cells: forumData.filteredCellsWithStats,
|
||||
posts: forumData.filteredPosts,
|
||||
comments: forumData.filteredComments,
|
||||
},
|
||||
createCell: async (input: { name: string; description: string; icon?: string }) => {
|
||||
if (!permissions.canCreateCell) {
|
||||
throw new Error(permissions.createCellReason);
|
||||
}
|
||||
if (!input.name.trim() || !input.description.trim()) {
|
||||
throw new Error('Please provide both a name and description for the cell.');
|
||||
}
|
||||
try {
|
||||
const result = await client.forumActions.createCell(
|
||||
{
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
icon: input.icon,
|
||||
currentUser,
|
||||
isAuthenticated: !!currentUser,
|
||||
},
|
||||
async () => { await refreshData(); }
|
||||
);
|
||||
return result.data || null;
|
||||
} catch {
|
||||
throw new Error('Failed to create cell. Please try again.');
|
||||
}
|
||||
},
|
||||
createPost: async (input: { cellId: string; title: string; content: string }) => {
|
||||
if (!permissions.canPost) {
|
||||
throw new Error('Connect your wallet to create posts.');
|
||||
}
|
||||
if (!input.title.trim() || !input.content.trim()) {
|
||||
throw new Error('Please provide both a title and content for the post.');
|
||||
}
|
||||
try {
|
||||
const result = await client.forumActions.createPost(
|
||||
{
|
||||
cellId: input.cellId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
currentUser,
|
||||
isAuthenticated: !!currentUser,
|
||||
},
|
||||
async () => { await refreshData(); }
|
||||
);
|
||||
return result.data || null;
|
||||
} catch {
|
||||
throw new Error('Failed to create post. Please try again.');
|
||||
}
|
||||
},
|
||||
createComment: async (input: { postId: string; content: string }) => {
|
||||
if (!permissions.canComment) {
|
||||
throw new Error('You need to connect your wallet to create comments.');
|
||||
}
|
||||
if (!input.content.trim()) {
|
||||
throw new Error('Please provide content for the comment.');
|
||||
}
|
||||
try {
|
||||
const result = await client.forumActions.createComment(
|
||||
{
|
||||
postId: input.postId,
|
||||
content: input.content,
|
||||
currentUser,
|
||||
isAuthenticated: !!currentUser,
|
||||
},
|
||||
async () => { await refreshData(); }
|
||||
);
|
||||
return result.data || null;
|
||||
} catch {
|
||||
throw new Error('Failed to create comment. Please try again.');
|
||||
}
|
||||
},
|
||||
vote: async (input: { targetId: string; isUpvote: boolean }) => {
|
||||
if (!permissions.canVote) {
|
||||
throw new Error(permissions.voteReason);
|
||||
}
|
||||
if (!input.targetId) return false;
|
||||
try {
|
||||
// Use the unified vote method from ForumActions
|
||||
const result = await client.forumActions.vote(
|
||||
{
|
||||
targetId: input.targetId,
|
||||
isUpvote: input.isUpvote,
|
||||
currentUser,
|
||||
isAuthenticated: !!currentUser,
|
||||
},
|
||||
async () => { await refreshData(); }
|
||||
);
|
||||
return result.success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
moderate: {
|
||||
post: async (cellId: string, postId: string, reason?: string) => {
|
||||
try {
|
||||
const result = await client.forumActions.moderatePost(
|
||||
{ cellId, postId, reason, currentUser, isAuthenticated: !!currentUser, cellOwner: currentUser?.address || '' },
|
||||
async () => { await refreshData(); }
|
||||
);
|
||||
return result.success;
|
||||
} catch { return false; }
|
||||
},
|
||||
unpost: async (cellId: string, postId: string, reason?: string) => {
|
||||
try {
|
||||
const result = await client.forumActions.unmoderatePost(
|
||||
{ cellId, postId, reason, currentUser, isAuthenticated: !!currentUser, cellOwner: currentUser?.address || '' },
|
||||
async () => { await refreshData(); }
|
||||
);
|
||||
return result.success;
|
||||
} catch { return false; }
|
||||
},
|
||||
comment: async (cellId: string, commentId: string, reason?: string) => {
|
||||
try {
|
||||
const result = await client.forumActions.moderateComment(
|
||||
{ cellId, commentId, reason, currentUser, isAuthenticated: !!currentUser, cellOwner: currentUser?.address || '' },
|
||||
async () => { await refreshData(); }
|
||||
);
|
||||
return result.success;
|
||||
} catch { return false; }
|
||||
},
|
||||
uncomment: async (cellId: string, commentId: string, reason?: string) => {
|
||||
try {
|
||||
const result = await client.forumActions.unmoderateComment(
|
||||
{ cellId, commentId, reason, currentUser, isAuthenticated: !!currentUser, cellOwner: currentUser?.address || '' },
|
||||
async () => { await refreshData(); }
|
||||
);
|
||||
return result.success;
|
||||
} catch { return false; }
|
||||
},
|
||||
user: async (cellId: string, userAddress: string, reason?: string) => {
|
||||
try {
|
||||
const result = await client.forumActions.moderateUser(
|
||||
{ cellId, userAddress, reason, currentUser, isAuthenticated: !!currentUser, cellOwner: currentUser?.address || '' },
|
||||
async () => { await refreshData(); }
|
||||
);
|
||||
return result.success;
|
||||
} catch { return false; }
|
||||
},
|
||||
unuser: async (cellId: string, userAddress: string, reason?: string) => {
|
||||
try {
|
||||
const result = await client.forumActions.unmoderateUser(
|
||||
{ cellId, userAddress, reason, currentUser, isAuthenticated: !!currentUser, cellOwner: currentUser?.address || '' },
|
||||
async () => { await refreshData(); }
|
||||
);
|
||||
return result.success;
|
||||
} catch { return false; }
|
||||
},
|
||||
},
|
||||
togglePostBookmark: async (post: Post, cellId?: string) => {
|
||||
try {
|
||||
if (!currentUser?.address) return false;
|
||||
const { BookmarkService } = await import('@opchan/core');
|
||||
const added = await BookmarkService.togglePostBookmark(post, currentUser.address, cellId);
|
||||
// Update local state snapshot from DB cache for immediate UI feedback
|
||||
const updated = await client.database.getUserBookmarks(currentUser.address);
|
||||
setBookmarks(updated);
|
||||
return added;
|
||||
} catch (e) {
|
||||
console.error('togglePostBookmark failed', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
toggleCommentBookmark: async (comment: Comment, postId?: string) => {
|
||||
try {
|
||||
if (!currentUser?.address) return false;
|
||||
const { BookmarkService } = await import('@opchan/core');
|
||||
const added = await BookmarkService.toggleCommentBookmark(comment, currentUser.address, postId);
|
||||
const updated = await client.database.getUserBookmarks(currentUser.address);
|
||||
setBookmarks(updated);
|
||||
return added;
|
||||
} catch (e) {
|
||||
console.error('toggleCommentBookmark failed', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
refresh: async () => { await refreshData(); },
|
||||
pending: {
|
||||
isPending: (id?: string) => {
|
||||
return id ? client.database.isPending(id) : false;
|
||||
},
|
||||
isVotePending: (targetId?: string) => {
|
||||
if (!targetId || !currentUser?.address) return false;
|
||||
return Object.values(client.database.cache.votes).some(v => {
|
||||
return (
|
||||
v.targetId === targetId &&
|
||||
v.author === currentUser.address &&
|
||||
client.database.isPending(v.id)
|
||||
);
|
||||
});
|
||||
},
|
||||
onChange: (cb: () => void) => {
|
||||
return client.database.onPendingChange(cb);
|
||||
},
|
||||
},
|
||||
};
|
||||
}, [forumData, bookmarks, refreshData, currentUser, permissions, client]);
|
||||
|
||||
const permissionsSlice = useMemo(() => {
|
||||
return {
|
||||
canPost: permissions.canPost,
|
||||
canComment: permissions.canComment,
|
||||
canVote: permissions.canVote,
|
||||
canCreateCell: permissions.canCreateCell,
|
||||
canDelegate: permissions.canDelegate,
|
||||
canModerate: permissions.canModerate,
|
||||
reasons: {
|
||||
vote: permissions.voteReason,
|
||||
post: permissions.postReason,
|
||||
comment: permissions.commentReason,
|
||||
createCell: permissions.createCellReason,
|
||||
moderate: permissions.moderateReason,
|
||||
},
|
||||
check: (action: keyof Permission, cellId?: string) => {
|
||||
return permissions.checkPermission(action, cellId);
|
||||
},
|
||||
};
|
||||
}, [permissions]);
|
||||
|
||||
const networkSlice = useMemo(() => {
|
||||
return {
|
||||
isConnected: network.health.isConnected,
|
||||
statusColor: network.getHealthColor(),
|
||||
statusMessage: network.getStatusMessage(),
|
||||
issues: network.health.issues,
|
||||
canRefresh: network.canRefresh,
|
||||
canSync: network.canSync,
|
||||
needsAttention: network.needsAttention,
|
||||
refresh: async () => { await forumData && content.refresh(); },
|
||||
recommendedActions: network.getRecommendedActions(),
|
||||
};
|
||||
}, [
|
||||
network.health.isConnected,
|
||||
network.health.isHealthy,
|
||||
network.health.issues,
|
||||
network.canRefresh,
|
||||
network.canSync,
|
||||
network.needsAttention,
|
||||
forumData,
|
||||
content
|
||||
]);
|
||||
|
||||
return useMemo(() => ({
|
||||
user,
|
||||
content,
|
||||
permissions: permissionsSlice,
|
||||
network: networkSlice,
|
||||
selectors,
|
||||
}), [user, content, permissionsSlice, networkSlice, selectors]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
export { useModeration } from '../contexts/ModerationContext';
|
||||
|
||||
|
||||
@ -1,130 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useClient } from '../../contexts/ClientContext';
|
||||
import { Bookmark, BookmarkType, Post, Comment } from '@opchan/core';
|
||||
import { BookmarkService } from '@opchan/core';
|
||||
|
||||
export interface UseBookmarksReturn {
|
||||
bookmarks: Bookmark[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
getBookmarksByType: (type: BookmarkType) => Bookmark[];
|
||||
removeBookmark: (bookmark: Bookmark) => Promise<void>;
|
||||
clearAllBookmarks: () => Promise<void>;
|
||||
togglePostBookmark: (post: Post, cellId?: string) => Promise<boolean>;
|
||||
toggleCommentBookmark: (comment: Comment, postId?: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function useBookmarks(): UseBookmarksReturn {
|
||||
const { currentUser } = useAuth();
|
||||
const client = useClient();
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!currentUser?.address) {
|
||||
setBookmarks([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
const list = await client.database.getUserBookmarks(currentUser.address);
|
||||
setBookmarks(list);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load bookmarks');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client.database, currentUser?.address]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const getBookmarksByType = useCallback(
|
||||
(type: BookmarkType): Bookmark[] =>
|
||||
bookmarks.filter(b => b.type === type),
|
||||
[bookmarks]
|
||||
);
|
||||
|
||||
const removeBookmark = useCallback(
|
||||
async (bookmark: Bookmark): Promise<void> => {
|
||||
await BookmarkService.removeBookmark(bookmark.type, bookmark.targetId);
|
||||
await refresh();
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
const clearAllBookmarks = useCallback(async (): Promise<void> => {
|
||||
if (!currentUser?.address) return;
|
||||
await BookmarkService.clearUserBookmarks(currentUser.address);
|
||||
await refresh();
|
||||
}, [currentUser?.address, refresh]);
|
||||
|
||||
const togglePostBookmark = useCallback(
|
||||
async (post: Post, cellId?: string): Promise<boolean> => {
|
||||
if (!currentUser?.address) return false;
|
||||
const added = await BookmarkService.togglePostBookmark(
|
||||
post,
|
||||
currentUser.address,
|
||||
cellId
|
||||
);
|
||||
await refresh();
|
||||
return added;
|
||||
},
|
||||
[currentUser?.address, refresh]
|
||||
);
|
||||
|
||||
const toggleCommentBookmark = useCallback(
|
||||
async (comment: Comment, postId?: string): Promise<boolean> => {
|
||||
if (!currentUser?.address) return false;
|
||||
const added = await BookmarkService.toggleCommentBookmark(
|
||||
comment,
|
||||
currentUser.address,
|
||||
postId
|
||||
);
|
||||
await refresh();
|
||||
return added;
|
||||
},
|
||||
[currentUser?.address, refresh]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
bookmarks,
|
||||
loading,
|
||||
error,
|
||||
getBookmarksByType,
|
||||
removeBookmark,
|
||||
clearAllBookmarks,
|
||||
togglePostBookmark,
|
||||
toggleCommentBookmark,
|
||||
}),
|
||||
[
|
||||
bookmarks,
|
||||
loading,
|
||||
error,
|
||||
getBookmarksByType,
|
||||
removeBookmark,
|
||||
clearAllBookmarks,
|
||||
togglePostBookmark,
|
||||
toggleCommentBookmark,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Optional convenience hooks to match historic API surface
|
||||
export function usePostBookmark() {
|
||||
const { togglePostBookmark } = useBookmarks();
|
||||
return { togglePostBookmark };
|
||||
}
|
||||
|
||||
export function useCommentBookmark() {
|
||||
const { toggleCommentBookmark } = useBookmarks();
|
||||
return { toggleCommentBookmark };
|
||||
}
|
||||
|
||||
|
||||
@ -1,337 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ForumData } from '../core/useForumData';
|
||||
import { Cell, Post, Comment } from '@opchan/core';
|
||||
import { EVerificationStatus } from '@opchan/core';
|
||||
|
||||
// Selector types for different data slices
|
||||
export type CellSelector<T> = (cells: Cell[]) => T;
|
||||
export type PostSelector<T> = (posts: Post[]) => T;
|
||||
export type CommentSelector<T> = (comments: Comment[]) => T;
|
||||
|
||||
// Common selector patterns
|
||||
export interface ForumSelectors {
|
||||
// Cell selectors
|
||||
selectCellsByActivity: () => Cell[];
|
||||
selectCellsByMemberCount: () => Cell[];
|
||||
selectCellsByRelevance: () => Cell[];
|
||||
selectCellById: (id: string) => Cell | null;
|
||||
selectCellsByOwner: (ownerAddress: string) => Cell[];
|
||||
|
||||
// Post selectors
|
||||
selectPostsByCell: (cellId: string) => Post[];
|
||||
selectPostsByAuthor: (authorAddress: string) => Post[];
|
||||
selectPostsByVoteScore: (minScore?: number) => Post[];
|
||||
selectTrendingPosts: (timeframe?: number) => Post[];
|
||||
selectRecentPosts: (limit?: number) => Post[];
|
||||
selectPostById: (id: string) => Post | null;
|
||||
|
||||
// Comment selectors
|
||||
selectCommentsByPost: (postId: string) => Comment[];
|
||||
selectCommentsByAuthor: (authorAddress: string) => Comment[];
|
||||
selectRecentComments: (limit?: number) => Comment[];
|
||||
selectCommentById: (id: string) => Comment | null;
|
||||
|
||||
// User-specific selectors
|
||||
selectUserPosts: (userAddress: string) => Post[];
|
||||
selectUserComments: (userAddress: string) => Comment[];
|
||||
selectUserActivity: (userAddress: string) => {
|
||||
posts: Post[];
|
||||
comments: Comment[];
|
||||
};
|
||||
selectVerifiedUsers: () => string[];
|
||||
selectActiveUsers: (timeframe?: number) => string[];
|
||||
|
||||
// Search and filter selectors
|
||||
searchPosts: (query: string) => Post[];
|
||||
searchComments: (query: string) => Comment[];
|
||||
searchCells: (query: string) => Cell[];
|
||||
filterByVerification: (
|
||||
items: (Post | Comment)[],
|
||||
level: EVerificationStatus
|
||||
) => (Post | Comment)[];
|
||||
|
||||
// Aggregation selectors
|
||||
selectStats: () => {
|
||||
totalCells: number;
|
||||
totalPosts: number;
|
||||
totalComments: number;
|
||||
totalUsers: number;
|
||||
verifiedUsers: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook providing optimized selectors for forum data
|
||||
*/
|
||||
export function useForumSelectors(forumData: ForumData): ForumSelectors {
|
||||
const {
|
||||
cells,
|
||||
postsWithVoteStatus: posts,
|
||||
commentsWithVoteStatus: comments,
|
||||
userVerificationStatus,
|
||||
} = forumData;
|
||||
|
||||
// Cell selectors
|
||||
const selectCellsByActivity = useMemo(() => {
|
||||
return (): Cell[] => {
|
||||
return [...cells].sort((a, b) => {
|
||||
const aActivity =
|
||||
'recentActivity' in b ? (b.recentActivity as number) : 0;
|
||||
const bActivity =
|
||||
'recentActivity' in a ? (a.recentActivity as number) : 0;
|
||||
return aActivity - bActivity;
|
||||
});
|
||||
};
|
||||
}, [cells]);
|
||||
|
||||
const selectCellsByMemberCount = useMemo(() => {
|
||||
return (): Cell[] => {
|
||||
return [...cells].sort(
|
||||
(a, b) => (b.activeMemberCount || 0) - (a.activeMemberCount || 0)
|
||||
);
|
||||
};
|
||||
}, [cells]);
|
||||
|
||||
const selectCellsByRelevance = useMemo(() => {
|
||||
return (): Cell[] => {
|
||||
return [...cells].sort(
|
||||
(a, b) => (b.relevanceScore || 0) - (a.relevanceScore || 0)
|
||||
);
|
||||
};
|
||||
}, [cells]);
|
||||
|
||||
const selectCellById = useMemo(() => {
|
||||
return (id: string): Cell | null => {
|
||||
return cells.find(cell => cell.id === id) || null;
|
||||
};
|
||||
}, [cells]);
|
||||
|
||||
const selectCellsByOwner = useMemo(() => {
|
||||
return (ownerAddress: string): Cell[] => {
|
||||
return cells.filter(cell => (cell as unknown as { signature?: string }).signature === ownerAddress);
|
||||
};
|
||||
}, [cells]);
|
||||
|
||||
// Post selectors
|
||||
const selectPostsByCell = useMemo(() => {
|
||||
return (cellId: string): Post[] => {
|
||||
return posts.filter(post => post.cellId === cellId);
|
||||
};
|
||||
}, [posts]);
|
||||
|
||||
const selectPostsByAuthor = useMemo(() => {
|
||||
return (authorAddress: string): Post[] => {
|
||||
return posts.filter(post => post.author === authorAddress);
|
||||
};
|
||||
}, [posts]);
|
||||
|
||||
const selectPostsByVoteScore = useMemo(() => {
|
||||
return (minScore: number = 0): Post[] => {
|
||||
return posts.filter(post => (post as unknown as { voteScore?: number }).voteScore! >= minScore);
|
||||
};
|
||||
}, [posts]);
|
||||
|
||||
const selectTrendingPosts = useMemo(() => {
|
||||
return (timeframe: number = 7 * 24 * 60 * 60 * 1000): Post[] => {
|
||||
const cutoff = Date.now() - timeframe;
|
||||
return posts
|
||||
.filter(post => post.timestamp > cutoff)
|
||||
.sort((a, b) => {
|
||||
if (
|
||||
a.relevanceScore !== undefined &&
|
||||
b.relevanceScore !== undefined
|
||||
) {
|
||||
return b.relevanceScore - a.relevanceScore;
|
||||
}
|
||||
return ((b as unknown as { voteScore?: number }).voteScore || 0) -
|
||||
((a as unknown as { voteScore?: number }).voteScore || 0);
|
||||
});
|
||||
};
|
||||
}, [posts]);
|
||||
|
||||
const selectRecentPosts = useMemo(() => {
|
||||
return (limit: number = 10): Post[] => {
|
||||
return [...posts]
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
.slice(0, limit);
|
||||
};
|
||||
}, [posts]);
|
||||
|
||||
const selectPostById = useMemo(() => {
|
||||
return (id: string): Post | null => {
|
||||
return posts.find(post => post.id === id) || null;
|
||||
};
|
||||
}, [posts]);
|
||||
|
||||
// Comment selectors
|
||||
const selectCommentsByPost = useMemo(() => {
|
||||
return (postId: string): Comment[] => {
|
||||
return comments.filter(comment => comment.postId === postId);
|
||||
};
|
||||
}, [comments]);
|
||||
|
||||
const selectCommentsByAuthor = useMemo(() => {
|
||||
return (authorAddress: string): Comment[] => {
|
||||
return comments.filter(comment => comment.author === authorAddress);
|
||||
};
|
||||
}, [comments]);
|
||||
|
||||
const selectRecentComments = useMemo(() => {
|
||||
return (limit: number = 10): Comment[] => {
|
||||
return [...comments]
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
.slice(0, limit);
|
||||
};
|
||||
}, [comments]);
|
||||
|
||||
const selectCommentById = useMemo(() => {
|
||||
return (id: string): Comment | null => {
|
||||
return comments.find(comment => comment.id === id) || null;
|
||||
};
|
||||
}, [comments]);
|
||||
|
||||
// User-specific selectors
|
||||
const selectUserPosts = useMemo(() => {
|
||||
return (userAddress: string): Post[] => {
|
||||
return posts.filter(post => post.author === userAddress);
|
||||
};
|
||||
}, [posts]);
|
||||
|
||||
const selectUserComments = useMemo(() => {
|
||||
return (userAddress: string): Comment[] => {
|
||||
return comments.filter(comment => comment.author === userAddress);
|
||||
};
|
||||
}, [comments]);
|
||||
|
||||
const selectUserActivity = useMemo(() => {
|
||||
return (userAddress: string) => {
|
||||
return {
|
||||
posts: posts.filter(post => post.author === userAddress),
|
||||
comments: comments.filter(comment => comment.author === userAddress),
|
||||
};
|
||||
};
|
||||
}, [posts, comments]);
|
||||
|
||||
const selectVerifiedUsers = useMemo(() => {
|
||||
return (): string[] => {
|
||||
return Object.entries(userVerificationStatus)
|
||||
.filter(([_, status]) => status.isVerified)
|
||||
.map(([address]) => address);
|
||||
};
|
||||
}, [userVerificationStatus]);
|
||||
|
||||
const selectActiveUsers = useMemo(() => {
|
||||
return (timeframe: number = 7 * 24 * 60 * 60 * 1000): string[] => {
|
||||
const cutoff = Date.now() - timeframe;
|
||||
const activeUsers = new Set<string>();
|
||||
|
||||
posts.forEach(post => {
|
||||
if (post.timestamp > cutoff) {
|
||||
activeUsers.add(post.author);
|
||||
}
|
||||
});
|
||||
|
||||
comments.forEach(comment => {
|
||||
if (comment.timestamp > cutoff) {
|
||||
activeUsers.add(comment.author);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(activeUsers);
|
||||
};
|
||||
}, [posts, comments]);
|
||||
|
||||
// Search selectors
|
||||
const searchPosts = useMemo(() => {
|
||||
return (query: string): Post[] => {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return posts.filter(
|
||||
post =>
|
||||
post.title.toLowerCase().includes(lowerQuery) ||
|
||||
post.content.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
};
|
||||
}, [posts]);
|
||||
|
||||
const searchComments = useMemo(() => {
|
||||
return (query: string): Comment[] => {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return comments.filter(comment =>
|
||||
comment.content.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
};
|
||||
}, [comments]);
|
||||
|
||||
const searchCells = useMemo(() => {
|
||||
return (query: string): Cell[] => {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return cells.filter(
|
||||
cell =>
|
||||
cell.name.toLowerCase().includes(lowerQuery) ||
|
||||
cell.description.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
};
|
||||
}, [cells]);
|
||||
|
||||
const filterByVerification = useMemo(() => {
|
||||
return (
|
||||
items: (Post | Comment)[],
|
||||
level: EVerificationStatus
|
||||
): (Post | Comment)[] => {
|
||||
return items.filter(item => {
|
||||
const userStatus = userVerificationStatus[item.author];
|
||||
return userStatus?.verificationStatus === level;
|
||||
});
|
||||
};
|
||||
}, [userVerificationStatus]);
|
||||
|
||||
// Aggregation selectors
|
||||
const selectStats = useMemo(() => {
|
||||
return () => {
|
||||
const uniqueUsers = new Set([
|
||||
...posts.map(post => post.author),
|
||||
...comments.map(comment => comment.author),
|
||||
]);
|
||||
|
||||
const verifiedUsers = Object.values(userVerificationStatus).filter(
|
||||
status => status.isVerified
|
||||
).length;
|
||||
|
||||
return {
|
||||
totalCells: cells.length,
|
||||
totalPosts: posts.length,
|
||||
totalComments: comments.length,
|
||||
totalUsers: uniqueUsers.size,
|
||||
verifiedUsers,
|
||||
};
|
||||
};
|
||||
}, [cells, posts, comments, userVerificationStatus]);
|
||||
|
||||
return {
|
||||
selectCellsByActivity,
|
||||
selectCellsByMemberCount,
|
||||
selectCellsByRelevance,
|
||||
selectCellById,
|
||||
selectCellsByOwner,
|
||||
selectPostsByCell,
|
||||
selectPostsByAuthor,
|
||||
selectPostsByVoteScore,
|
||||
selectTrendingPosts,
|
||||
selectRecentPosts,
|
||||
selectPostById,
|
||||
selectCommentsByPost,
|
||||
selectCommentsByAuthor,
|
||||
selectRecentComments,
|
||||
selectCommentById,
|
||||
selectUserPosts,
|
||||
selectUserComments,
|
||||
selectUserActivity,
|
||||
selectVerifiedUsers,
|
||||
selectActiveUsers,
|
||||
searchPosts,
|
||||
searchComments,
|
||||
searchCells,
|
||||
filterByVerification,
|
||||
selectStats,
|
||||
};
|
||||
}
|
||||
@ -1,274 +0,0 @@
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useForum } from '../../contexts/ForumContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useClient } from '../../contexts/ClientContext';
|
||||
import { DelegationFullStatus } from '@opchan/core';
|
||||
|
||||
export interface NetworkHealth {
|
||||
isConnected: boolean;
|
||||
isHealthy: boolean;
|
||||
lastSync: number | null;
|
||||
syncAge: string | null;
|
||||
issues: string[];
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
isInitialLoading: boolean;
|
||||
isRefreshing: boolean;
|
||||
isSyncing: boolean;
|
||||
lastRefresh: number | null;
|
||||
nextRefresh: number | null;
|
||||
autoRefreshEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface ConnectionStatus {
|
||||
waku: {
|
||||
connected: boolean;
|
||||
peers: number;
|
||||
status: 'connected' | 'connecting' | 'disconnected' | 'error';
|
||||
};
|
||||
wallet: {
|
||||
connected: boolean;
|
||||
network: string | null;
|
||||
status: 'connected' | 'connecting' | 'disconnected' | 'error';
|
||||
};
|
||||
delegation: {
|
||||
active: boolean;
|
||||
expires: number | null;
|
||||
status: 'active' | 'expired' | 'none';
|
||||
};
|
||||
}
|
||||
|
||||
export interface NetworkStatusData {
|
||||
// Overall status
|
||||
health: NetworkHealth;
|
||||
sync: SyncStatus;
|
||||
connections: ConnectionStatus;
|
||||
|
||||
// Actions
|
||||
canRefresh: boolean;
|
||||
canSync: boolean;
|
||||
needsAttention: boolean;
|
||||
|
||||
// Helper methods
|
||||
getStatusMessage: () => string;
|
||||
getHealthColor: () => 'green' | 'yellow' | 'red';
|
||||
getRecommendedActions: () => string[];
|
||||
}
|
||||
|
||||
export function useNetworkStatus(): NetworkStatusData {
|
||||
const { isNetworkConnected, isInitialLoading, isRefreshing, error } =
|
||||
useForum();
|
||||
const client = useClient();
|
||||
|
||||
const { isAuthenticated, currentUser, getDelegationStatus } = useAuth();
|
||||
const [delegationInfo, setDelegationInfo] =
|
||||
useState<DelegationFullStatus | null>(null);
|
||||
|
||||
// Track Waku ready state directly from the client to react to changes
|
||||
const [wakuReady, setWakuReady] = useState<boolean>(
|
||||
Boolean((client)?.messageManager?.isReady)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
// Prime from current state so UI updates immediately without navigation
|
||||
try {
|
||||
const nowReady = Boolean(client?.messageManager?.isReady);
|
||||
setWakuReady(nowReady);
|
||||
} catch {}
|
||||
|
||||
const off = client?.messageManager?.onHealthChange?.(
|
||||
(ready: boolean) => {
|
||||
setWakuReady(Boolean(ready));
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
try { off && off(); } catch {}
|
||||
};
|
||||
} catch {}
|
||||
}, [client]);
|
||||
|
||||
// Load delegation status
|
||||
useEffect(() => {
|
||||
getDelegationStatus().then(setDelegationInfo).catch(console.error);
|
||||
}, [getDelegationStatus]);
|
||||
|
||||
// Network health assessment
|
||||
const health = useMemo((): NetworkHealth => {
|
||||
const issues: string[] = [];
|
||||
|
||||
const fallbackConnected = Boolean(wakuReady);
|
||||
const effectiveConnected = isNetworkConnected || wakuReady;
|
||||
|
||||
if (!effectiveConnected) {
|
||||
issues.push('Waku network disconnected');
|
||||
}
|
||||
|
||||
if (error) {
|
||||
issues.push(`Forum error: ${error}`);
|
||||
}
|
||||
|
||||
if (isAuthenticated && !delegationInfo?.isValid) {
|
||||
issues.push('Key delegation expired');
|
||||
}
|
||||
|
||||
const isHealthy = issues.length === 0;
|
||||
const lastSync = Date.now(); // This would come from actual sync tracking
|
||||
const syncAge = lastSync ? formatTimeAgo(lastSync) : null;
|
||||
|
||||
return {
|
||||
isConnected: effectiveConnected,
|
||||
isHealthy,
|
||||
lastSync,
|
||||
syncAge,
|
||||
issues,
|
||||
};
|
||||
}, [client, isNetworkConnected, wakuReady, error, isAuthenticated, delegationInfo?.isValid]);
|
||||
|
||||
// Sync status
|
||||
const sync = useMemo((): SyncStatus => {
|
||||
const lastRefresh = Date.now() - 30000; // Mock: 30 seconds ago
|
||||
const nextRefresh = lastRefresh + 60000; // Mock: every minute
|
||||
|
||||
return {
|
||||
isInitialLoading,
|
||||
isRefreshing,
|
||||
isSyncing: isInitialLoading || isRefreshing,
|
||||
lastRefresh,
|
||||
nextRefresh,
|
||||
autoRefreshEnabled: true, // This would be configurable
|
||||
};
|
||||
}, [isInitialLoading, isRefreshing]);
|
||||
|
||||
// Connection status
|
||||
const connections = useMemo((): ConnectionStatus => {
|
||||
const effectiveConnected = health.isConnected;
|
||||
return {
|
||||
waku: {
|
||||
connected: effectiveConnected,
|
||||
peers: effectiveConnected ? 3 : 0, // Mock peer count
|
||||
status: effectiveConnected ? 'connected' : 'disconnected',
|
||||
},
|
||||
wallet: {
|
||||
connected: isAuthenticated,
|
||||
network: currentUser?.walletType === 'bitcoin' ? 'Bitcoin' : 'Ethereum',
|
||||
status: isAuthenticated ? 'connected' : 'disconnected',
|
||||
},
|
||||
delegation: {
|
||||
active: delegationInfo?.isValid || false,
|
||||
expires: delegationInfo?.timeRemaining || null,
|
||||
status: delegationInfo?.isValid ? 'active' : 'expired',
|
||||
},
|
||||
};
|
||||
}, [health.isConnected, isAuthenticated, currentUser, delegationInfo]);
|
||||
|
||||
// Status assessment
|
||||
const canRefresh = !isRefreshing && !isInitialLoading;
|
||||
const canSync = health.isConnected && !isRefreshing;
|
||||
const needsAttention = !health.isHealthy || !delegationInfo?.isValid;
|
||||
|
||||
// Helper methods
|
||||
const getStatusMessage = useMemo(() => {
|
||||
return (): string => {
|
||||
if (isInitialLoading) return 'Loading forum data...';
|
||||
if (isRefreshing) return 'Refreshing data...';
|
||||
const fallbackConnected = Boolean(wakuReady);
|
||||
const effectiveConnected = isNetworkConnected || fallbackConnected;
|
||||
if (!effectiveConnected) return 'Network disconnected';
|
||||
if (error) return `Error: ${error}`;
|
||||
if (health.issues.length > 0) return health.issues[0] || 'Unknown issue';
|
||||
return 'All systems operational';
|
||||
};
|
||||
}, [
|
||||
isInitialLoading,
|
||||
isRefreshing,
|
||||
isNetworkConnected,
|
||||
client,
|
||||
wakuReady,
|
||||
error,
|
||||
health.issues,
|
||||
]);
|
||||
|
||||
const getHealthColor = useMemo(() => {
|
||||
return (): 'green' | 'yellow' | 'red' => {
|
||||
const fallbackConnected = Boolean(wakuReady);
|
||||
const effectiveConnected = isNetworkConnected || fallbackConnected;
|
||||
if (!effectiveConnected || error) return 'red';
|
||||
if (health.issues.length > 0 || !delegationInfo?.isValid) return 'yellow';
|
||||
return 'green';
|
||||
};
|
||||
}, [
|
||||
isNetworkConnected,
|
||||
client,
|
||||
wakuReady,
|
||||
error,
|
||||
health.issues.length,
|
||||
delegationInfo?.isValid,
|
||||
]);
|
||||
|
||||
const getRecommendedActions = useMemo(() => {
|
||||
return (): string[] => {
|
||||
const actions: string[] = [];
|
||||
|
||||
if (!isNetworkConnected) {
|
||||
actions.push('Check your internet connection');
|
||||
actions.push('Try refreshing the page');
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
actions.push('Connect your wallet');
|
||||
}
|
||||
|
||||
if (!delegationInfo?.isValid) {
|
||||
actions.push('Renew key delegation');
|
||||
}
|
||||
|
||||
if (
|
||||
delegationInfo?.isValid &&
|
||||
delegationInfo?.timeRemaining &&
|
||||
delegationInfo.timeRemaining < 3600
|
||||
) {
|
||||
actions.push('Consider renewing key delegation soon');
|
||||
}
|
||||
|
||||
if (error) {
|
||||
actions.push('Try refreshing forum data');
|
||||
}
|
||||
|
||||
if (actions.length === 0) {
|
||||
actions.push('All systems are working normally');
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
}, [isNetworkConnected, isAuthenticated, delegationInfo, error]);
|
||||
|
||||
return {
|
||||
health,
|
||||
sync,
|
||||
connections,
|
||||
canRefresh,
|
||||
canSync,
|
||||
needsAttention,
|
||||
getStatusMessage,
|
||||
getHealthColor,
|
||||
getRecommendedActions,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to format time ago
|
||||
function formatTimeAgo(timestamp: number): string {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return `${days}d ago`;
|
||||
if (hours > 0) return `${hours}h ago`;
|
||||
if (minutes > 0) return `${minutes}m ago`;
|
||||
return `${seconds}s ago`;
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { modal } from '@reown/appkit/react';
|
||||
|
||||
export const useWallet = () => {
|
||||
const {
|
||||
currentUser,
|
||||
isAuthenticated,
|
||||
verificationStatus,
|
||||
connectWallet: contextConnectWallet,
|
||||
disconnectWallet: contextDisconnectWallet,
|
||||
} = useAuth();
|
||||
|
||||
const connect = useCallback(async (): Promise<boolean> => {
|
||||
return contextConnectWallet();
|
||||
}, [contextConnectWallet]);
|
||||
|
||||
const disconnect = useCallback((): void => {
|
||||
contextDisconnectWallet();
|
||||
}, [contextDisconnectWallet]);
|
||||
|
||||
const openModal = useCallback(async (): Promise<void> => {
|
||||
if (modal) {
|
||||
await modal.open();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback((): void => {
|
||||
if (modal) {
|
||||
modal.close();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Wallet state
|
||||
isConnected: isAuthenticated,
|
||||
address: currentUser?.address,
|
||||
walletType: currentUser?.walletType,
|
||||
verificationStatus,
|
||||
currentUser,
|
||||
|
||||
// Wallet actions
|
||||
connect,
|
||||
disconnect,
|
||||
openModal,
|
||||
closeModal,
|
||||
};
|
||||
};
|
||||
@ -1,9 +0,0 @@
|
||||
export * from './provider/OpChanProvider';
|
||||
export { ClientProvider, useClient } from './contexts/ClientContext';
|
||||
export { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
export { ForumProvider, useForum as useForumContext } from './contexts/ForumContext';
|
||||
export { ModerationProvider, useModeration } from './contexts/ModerationContext';
|
||||
|
||||
export * from './hooks';
|
||||
export { useForumApi as useForum } from './hooks/useForum';
|
||||
|
||||
@ -1,70 +0,0 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { OpChanClient } from '@opchan/core';
|
||||
import { localDatabase } from '@opchan/core';
|
||||
import { ClientProvider } from '../contexts/ClientContext';
|
||||
import { AuthProvider } from '../contexts/AuthContext';
|
||||
import { ForumProvider } from '../contexts/ForumContext';
|
||||
import { ModerationProvider } from '../contexts/ModerationContext';
|
||||
|
||||
export interface OpChanProviderProps {
|
||||
ordiscanApiKey: string;
|
||||
debug?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const OpChanProvider: React.FC<OpChanProviderProps> = ({
|
||||
ordiscanApiKey,
|
||||
debug,
|
||||
children,
|
||||
}) => {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const clientRef = useRef<OpChanClient | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const init = async () => {
|
||||
if (typeof window === 'undefined') return; // SSR guard
|
||||
|
||||
// Configure environment and create client
|
||||
const client = new OpChanClient({
|
||||
ordiscanApiKey,
|
||||
});
|
||||
clientRef.current = client;
|
||||
|
||||
// Open local DB early for warm cache
|
||||
await localDatabase.open().catch(console.error);
|
||||
|
||||
try {
|
||||
await client.messageManager.initialize();
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize message manager:', e);
|
||||
}
|
||||
|
||||
|
||||
if (!cancelled) setIsReady(true);
|
||||
};
|
||||
|
||||
init();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [ordiscanApiKey, debug]);
|
||||
|
||||
const providers = useMemo(() => {
|
||||
if (!isReady || !clientRef.current) return null;
|
||||
return (
|
||||
<ClientProvider client={clientRef.current}>
|
||||
<AuthProvider>
|
||||
<ModerationProvider>
|
||||
<ForumProvider>{children}</ForumProvider>
|
||||
</ModerationProvider>
|
||||
</AuthProvider>
|
||||
</ClientProvider>
|
||||
);
|
||||
}, [isReady, children]);
|
||||
|
||||
return providers || null;
|
||||
};
|
||||
|
||||
|
||||
28
packages/react/src/v1/context/ClientContext.tsx
Normal file
28
packages/react/src/v1/context/ClientContext.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import type { OpChanClient } from '@opchan/core';
|
||||
|
||||
interface ClientContextValue {
|
||||
client: OpChanClient;
|
||||
}
|
||||
|
||||
const ClientContext = createContext<ClientContextValue | null>(null);
|
||||
|
||||
type ProviderProps = { client: OpChanClient; children: React.ReactNode };
|
||||
|
||||
export function OpChanProvider({ client, children }: ProviderProps) {
|
||||
return (
|
||||
<ClientContext.Provider value={{ client }}>
|
||||
{children}
|
||||
</ClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useClient(): OpChanClient {
|
||||
const ctx = useContext(ClientContext);
|
||||
if (!ctx) throw new Error('useClient must be used within OpChanProvider');
|
||||
return ctx.client;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
195
packages/react/src/v1/hooks/useAuth.ts
Normal file
195
packages/react/src/v1/hooks/useAuth.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import React from 'react';
|
||||
import { useClient } from '../context/ClientContext';
|
||||
import { useOpchanStore, setOpchanState } from '../store/opchanStore';
|
||||
import {
|
||||
User,
|
||||
EVerificationStatus,
|
||||
DelegationDuration,
|
||||
EDisplayPreference,
|
||||
} from '@opchan/core';
|
||||
import type { DelegationFullStatus } from '@opchan/core';
|
||||
|
||||
export interface ConnectInput {
|
||||
address: string;
|
||||
walletType: 'bitcoin' | 'ethereum';
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const client = useClient();
|
||||
const currentUser = useOpchanStore(s => s.session.currentUser);
|
||||
const verificationStatus = useOpchanStore(s => s.session.verificationStatus);
|
||||
const delegation = useOpchanStore(s => s.session.delegation);
|
||||
|
||||
const connect = React.useCallback(async (input: ConnectInput): Promise<boolean> => {
|
||||
const baseUser: User = {
|
||||
address: input.address,
|
||||
walletType: input.walletType,
|
||||
displayName: input.address,
|
||||
displayPreference: EDisplayPreference.WALLET_ADDRESS,
|
||||
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
|
||||
lastChecked: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
await client.database.storeUser(baseUser);
|
||||
// Prime identity service so display name/ens are cached
|
||||
await client.userIdentityService.getUserIdentity(baseUser.address);
|
||||
|
||||
setOpchanState(prev => ({
|
||||
...prev,
|
||||
session: {
|
||||
...prev.session,
|
||||
currentUser: baseUser,
|
||||
verificationStatus: baseUser.verificationStatus,
|
||||
delegation: prev.session.delegation,
|
||||
},
|
||||
}));
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('connect failed', e);
|
||||
return false;
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
const disconnect = React.useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
await client.database.clearUser();
|
||||
} finally {
|
||||
setOpchanState(prev => ({
|
||||
...prev,
|
||||
session: {
|
||||
currentUser: null,
|
||||
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
|
||||
delegation: null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
const verifyOwnership = React.useCallback(async (): Promise<boolean> => {
|
||||
const user = currentUser;
|
||||
if (!user) return false;
|
||||
try {
|
||||
const identity = await client.userIdentityService.getUserIdentityFresh(user.address);
|
||||
const nextStatus = identity?.verificationStatus ?? EVerificationStatus.WALLET_CONNECTED;
|
||||
|
||||
const updated: User = {
|
||||
...user,
|
||||
verificationStatus: nextStatus,
|
||||
displayName: identity?.displayPreference === EDisplayPreference.CALL_SIGN ? identity.callSign! : identity!.ensName!,
|
||||
ensDetails: identity?.ensName ? { ensName: identity.ensName } : undefined,
|
||||
ordinalDetails: identity?.ordinalDetails,
|
||||
};
|
||||
|
||||
await client.database.storeUser(updated);
|
||||
await client.database.upsertUserIdentity(user.address, {
|
||||
ensName: identity?.ensName || undefined,
|
||||
ordinalDetails: identity?.ordinalDetails,
|
||||
verificationStatus: nextStatus,
|
||||
lastUpdated: Date.now(),
|
||||
});
|
||||
|
||||
setOpchanState(prev => ({
|
||||
...prev,
|
||||
session: { ...prev.session, currentUser: updated, verificationStatus: nextStatus },
|
||||
}));
|
||||
return nextStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED;
|
||||
} catch (e) {
|
||||
console.error('verifyOwnership failed', e);
|
||||
return false;
|
||||
}
|
||||
}, [client, currentUser]);
|
||||
|
||||
const delegate = React.useCallback(async (
|
||||
signFunction: (message: string) => Promise<string>,
|
||||
duration: DelegationDuration = '7days',
|
||||
): Promise<boolean> => {
|
||||
const user = currentUser;
|
||||
if (!user) return false;
|
||||
try {
|
||||
const ok = await client.delegation.delegate(
|
||||
user.address,
|
||||
user.walletType,
|
||||
duration,
|
||||
signFunction,
|
||||
);
|
||||
|
||||
const status = await client.delegation.getStatus(user.address, user.walletType);
|
||||
setOpchanState(prev => ({
|
||||
...prev,
|
||||
session: { ...prev.session, delegation: status },
|
||||
}));
|
||||
return ok;
|
||||
} catch (e) {
|
||||
console.error('delegate failed', e);
|
||||
return false;
|
||||
}
|
||||
}, [client, currentUser]);
|
||||
|
||||
const delegationStatus = React.useCallback(async () => {
|
||||
const user = currentUser;
|
||||
if (!user) return { hasDelegation: false, isValid: false } as const;
|
||||
return client.delegation.getStatus(user.address, user.walletType);
|
||||
}, [client, currentUser]);
|
||||
|
||||
const clearDelegation = React.useCallback(async () => {
|
||||
await client.delegation.clear();
|
||||
setOpchanState(prev => ({
|
||||
...prev,
|
||||
session: { ...prev.session, delegation: null },
|
||||
}));
|
||||
}, [client]);
|
||||
|
||||
const updateProfile = React.useCallback(async (updates: { callSign?: string; displayPreference?: EDisplayPreference }): Promise<boolean> => {
|
||||
const user = currentUser;
|
||||
if (!user) return false;
|
||||
try {
|
||||
const ok = await client.userIdentityService.updateUserProfile(
|
||||
user.address,
|
||||
updates.callSign,
|
||||
updates.displayPreference ?? user.displayPreference,
|
||||
);
|
||||
if (!ok) return false;
|
||||
|
||||
await client.userIdentityService.refreshUserIdentity(user.address);
|
||||
const fresh = await client.userIdentityService.getUserIdentity(user.address);
|
||||
const updated: User = {
|
||||
...user,
|
||||
callSign: fresh?.callSign ?? user.callSign,
|
||||
displayPreference: fresh?.displayPreference ?? user.displayPreference,
|
||||
};
|
||||
await client.database.storeUser(updated);
|
||||
setOpchanState(prev => ({ ...prev, session: { ...prev.session, currentUser: updated } }));
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('updateProfile failed', e);
|
||||
return false;
|
||||
}
|
||||
}, [client, currentUser]);
|
||||
|
||||
return {
|
||||
currentUser,
|
||||
verificationStatus,
|
||||
isAuthenticated: currentUser !== null,
|
||||
// Provide a stable, non-null delegation object for UI safety
|
||||
delegation: ((): DelegationFullStatus & { expiresAt?: Date } => {
|
||||
const base: DelegationFullStatus =
|
||||
delegation ?? ({ hasDelegation: false, isValid: false } as const);
|
||||
const expiresAt = base?.proof?.expiryTimestamp
|
||||
? new Date(base.proof.expiryTimestamp)
|
||||
: undefined;
|
||||
return { ...base, expiresAt };
|
||||
})(),
|
||||
connect,
|
||||
disconnect,
|
||||
verifyOwnership,
|
||||
delegate,
|
||||
delegationStatus,
|
||||
clearDelegation,
|
||||
updateProfile,
|
||||
} as const;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
305
packages/react/src/v1/hooks/useContent.ts
Normal file
305
packages/react/src/v1/hooks/useContent.ts
Normal file
@ -0,0 +1,305 @@
|
||||
import React from 'react';
|
||||
import { useClient } from '../context/ClientContext';
|
||||
import { useOpchanStore, setOpchanState } from '../store/opchanStore';
|
||||
import {
|
||||
PostMessage,
|
||||
CommentMessage,
|
||||
Post,
|
||||
Comment,
|
||||
Cell,
|
||||
EVerificationStatus,
|
||||
UserVerificationStatus,
|
||||
BookmarkType,
|
||||
} from '@opchan/core';
|
||||
import { BookmarkService } from '@opchan/core';
|
||||
|
||||
function reflectCache(client: ReturnType<typeof useClient>): void {
|
||||
const cache = client.database.cache;
|
||||
setOpchanState(prev => ({
|
||||
...prev,
|
||||
content: {
|
||||
...prev.content,
|
||||
cells: Object.values(cache.cells),
|
||||
posts: Object.values(cache.posts),
|
||||
comments: Object.values(cache.comments),
|
||||
bookmarks: Object.values(cache.bookmarks),
|
||||
lastSync: client.database.getSyncState().lastSync,
|
||||
pendingIds: prev.content.pendingIds,
|
||||
pendingVotes: prev.content.pendingVotes,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function useContent() {
|
||||
const client = useClient();
|
||||
const content = useOpchanStore(s => s.content);
|
||||
const session = useOpchanStore(s => s.session);
|
||||
|
||||
// Re-render on pending changes from LocalDatabase so isPending reflects current state
|
||||
const [, forceRender] = React.useReducer((x: number) => x + 1, 0);
|
||||
React.useEffect(() => {
|
||||
const off = client.database.onPendingChange(() => {
|
||||
forceRender();
|
||||
});
|
||||
return () => {
|
||||
try {
|
||||
off();
|
||||
} catch (err) {
|
||||
console.error('Error cleaning up pending change listener:', err);
|
||||
}
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
// Derived maps
|
||||
const postsByCell = React.useMemo(() => {
|
||||
const map: Record<string, PostMessage[]> = {};
|
||||
for (const p of content.posts) {
|
||||
(map[p.cellId] ||= []).push(p);
|
||||
}
|
||||
return map;
|
||||
}, [content.posts]);
|
||||
|
||||
const commentsByPost = React.useMemo(() => {
|
||||
const map: Record<string, CommentMessage[]> = {};
|
||||
for (const c of content.comments) {
|
||||
(map[c.postId] ||= []).push(c);
|
||||
}
|
||||
return map;
|
||||
}, [content.comments]);
|
||||
|
||||
// Derived: user verification status from identity cache
|
||||
const userVerificationStatus: UserVerificationStatus = React.useMemo(() => {
|
||||
const identities = client.database.cache.userIdentities;
|
||||
const result: UserVerificationStatus = {};
|
||||
for (const [address, rec] of Object.entries(identities)) {
|
||||
const hasEns = Boolean(rec.ensName);
|
||||
const hasOrdinal = Boolean(rec.ordinalDetails);
|
||||
const isVerified = rec.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED;
|
||||
result[address] = {
|
||||
isVerified,
|
||||
hasENS: hasEns,
|
||||
hasOrdinal,
|
||||
ensName: rec.ensName,
|
||||
verificationStatus: rec.verificationStatus,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}, [client.database.cache.userIdentities]);
|
||||
|
||||
// Derived: cells with stats for sidebar/trending
|
||||
const cellsWithStats = React.useMemo(() => {
|
||||
const byCell: Record<string, { postCount: number; activeUsers: Set<string>; recentActivity: number }> = {};
|
||||
const now = Date.now();
|
||||
const recentWindowMs = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
for (const p of content.posts) {
|
||||
const entry = (byCell[p.cellId] ||= { postCount: 0, activeUsers: new Set<string>(), recentActivity: 0 });
|
||||
entry.postCount += 1;
|
||||
entry.activeUsers.add(p.author);
|
||||
if (now - p.timestamp <= recentWindowMs) entry.recentActivity += 1;
|
||||
}
|
||||
for (const c of content.comments) {
|
||||
// find post for cell reference
|
||||
const post = content.posts.find(pp => pp.id === c.postId);
|
||||
if (!post) continue;
|
||||
const entry = (byCell[post.cellId] ||= { postCount: 0, activeUsers: new Set<string>(), recentActivity: 0 });
|
||||
entry.activeUsers.add(c.author);
|
||||
if (now - c.timestamp <= recentWindowMs) entry.recentActivity += 1;
|
||||
}
|
||||
return content.cells.map(cell => {
|
||||
const stats = byCell[cell.id] || { postCount: 0, activeUsers: new Set<string>(), recentActivity: 0 };
|
||||
return {
|
||||
...cell,
|
||||
postCount: stats.postCount,
|
||||
activeUsers: stats.activeUsers.size,
|
||||
recentActivity: stats.recentActivity,
|
||||
} as Cell & { postCount: number; activeUsers: number; recentActivity: number };
|
||||
});
|
||||
}, [content.cells, content.posts, content.comments]);
|
||||
|
||||
// Actions
|
||||
const createCell = React.useCallback(async (input: { name: string; description: string; icon?: string }): Promise<Cell | null> => {
|
||||
const currentUser = session.currentUser;
|
||||
const isAuthenticated = Boolean(currentUser);
|
||||
const result = await client.forumActions.createCell(
|
||||
{ ...input, currentUser, isAuthenticated },
|
||||
() => reflectCache(client)
|
||||
);
|
||||
reflectCache(client);
|
||||
return result.data ?? null;
|
||||
}, [client, session.currentUser]);
|
||||
|
||||
const createPost = React.useCallback(async (input: { cellId: string; title: string; content: string }): Promise<Post | null> => {
|
||||
const currentUser = session.currentUser;
|
||||
const isAuthenticated = Boolean(currentUser);
|
||||
const result = await client.forumActions.createPost(
|
||||
{ ...input, currentUser, isAuthenticated },
|
||||
() => reflectCache(client)
|
||||
);
|
||||
reflectCache(client);
|
||||
return result.data ?? null;
|
||||
}, [client, session.currentUser]);
|
||||
|
||||
const createComment = React.useCallback(async (input: { postId: string; content: string }): Promise<Comment | null> => {
|
||||
const currentUser = session.currentUser;
|
||||
const isAuthenticated = Boolean(currentUser);
|
||||
const result = await client.forumActions.createComment(
|
||||
{ ...input, currentUser, isAuthenticated },
|
||||
() => reflectCache(client)
|
||||
);
|
||||
reflectCache(client);
|
||||
return result.data ?? null;
|
||||
}, [client, session.currentUser]);
|
||||
|
||||
const vote = React.useCallback(async (input: { targetId: string; isUpvote: boolean }): Promise<boolean> => {
|
||||
const currentUser = session.currentUser;
|
||||
const isAuthenticated = Boolean(currentUser);
|
||||
const result = await client.forumActions.vote(
|
||||
{ ...input, currentUser, isAuthenticated },
|
||||
() => reflectCache(client)
|
||||
);
|
||||
reflectCache(client);
|
||||
return result.data ?? false;
|
||||
}, [client, session.currentUser]);
|
||||
|
||||
const moderate = React.useMemo(() => ({
|
||||
post: async (cellId: string, postId: string, reason?: string) => {
|
||||
const currentUser = session.currentUser;
|
||||
const isAuthenticated = Boolean(currentUser);
|
||||
const cell = content.cells.find(c => c.id === cellId);
|
||||
const res = await client.forumActions.moderatePost(
|
||||
{ cellId, postId, reason, currentUser, isAuthenticated, cellOwner: cell?.author ?? '' },
|
||||
() => reflectCache(client)
|
||||
);
|
||||
reflectCache(client);
|
||||
return res.data ?? false;
|
||||
},
|
||||
unpost: async (cellId: string, postId: string, reason?: string) => {
|
||||
const currentUser = session.currentUser;
|
||||
const isAuthenticated = Boolean(currentUser);
|
||||
const cell = content.cells.find(c => c.id === cellId);
|
||||
const res = await client.forumActions.unmoderatePost(
|
||||
{ cellId, postId, reason, currentUser, isAuthenticated, cellOwner: cell?.author ?? '' },
|
||||
() => reflectCache(client)
|
||||
);
|
||||
reflectCache(client);
|
||||
return res.data ?? false;
|
||||
},
|
||||
comment: async (cellId: string, commentId: string, reason?: string) => {
|
||||
const currentUser = session.currentUser;
|
||||
const isAuthenticated = Boolean(currentUser);
|
||||
const cell = content.cells.find(c => c.id === cellId);
|
||||
const res = await client.forumActions.moderateComment(
|
||||
{ cellId, commentId, reason, currentUser, isAuthenticated, cellOwner: cell?.author ?? '' },
|
||||
() => reflectCache(client)
|
||||
);
|
||||
reflectCache(client);
|
||||
return res.data ?? false;
|
||||
},
|
||||
uncomment: async (cellId: string, commentId: string, reason?: string) => {
|
||||
const currentUser = session.currentUser;
|
||||
const isAuthenticated = Boolean(currentUser);
|
||||
const cell = content.cells.find(c => c.id === cellId);
|
||||
const res = await client.forumActions.unmoderateComment(
|
||||
{ cellId, commentId, reason, currentUser, isAuthenticated, cellOwner: cell?.author ?? '' },
|
||||
() => reflectCache(client)
|
||||
);
|
||||
reflectCache(client);
|
||||
return res.data ?? false;
|
||||
},
|
||||
user: async (cellId: string, userAddress: string, reason?: string) => {
|
||||
const currentUser = session.currentUser;
|
||||
const isAuthenticated = Boolean(currentUser);
|
||||
const cell = content.cells.find(c => c.id === cellId);
|
||||
const res = await client.forumActions.moderateUser(
|
||||
{ cellId, userAddress, reason, currentUser, isAuthenticated, cellOwner: cell?.author ?? '' },
|
||||
() => reflectCache(client)
|
||||
);
|
||||
reflectCache(client);
|
||||
return res.data ?? false;
|
||||
},
|
||||
unuser: async (cellId: string, userAddress: string, reason?: string) => {
|
||||
const currentUser = session.currentUser;
|
||||
const isAuthenticated = Boolean(currentUser);
|
||||
const cell = content.cells.find(c => c.id === cellId);
|
||||
const res = await client.forumActions.unmoderateUser(
|
||||
{ cellId, userAddress, reason, currentUser, isAuthenticated, cellOwner: cell?.author ?? '' },
|
||||
() => reflectCache(client)
|
||||
);
|
||||
reflectCache(client);
|
||||
return res.data ?? false;
|
||||
},
|
||||
}), [client, session.currentUser, content.cells]);
|
||||
|
||||
const togglePostBookmark = React.useCallback(async (post: Post | PostMessage, cellId?: string): Promise<boolean> => {
|
||||
const address = session.currentUser?.address;
|
||||
if (!address) return false;
|
||||
const added = await BookmarkService.togglePostBookmark(post as Post, address, cellId);
|
||||
const updated = await client.database.getUserBookmarks(address);
|
||||
setOpchanState(prev => ({ ...prev, content: { ...prev.content, bookmarks: updated } }));
|
||||
return added;
|
||||
}, [client, session.currentUser?.address]);
|
||||
|
||||
const toggleCommentBookmark = React.useCallback(async (comment: Comment | CommentMessage, postId?: string): Promise<boolean> => {
|
||||
const address = session.currentUser?.address;
|
||||
if (!address) return false;
|
||||
const added = await BookmarkService.toggleCommentBookmark(comment as Comment, address, postId);
|
||||
const updated = await client.database.getUserBookmarks(address);
|
||||
setOpchanState(prev => ({ ...prev, content: { ...prev.content, bookmarks: updated } }));
|
||||
return added;
|
||||
}, [client, session.currentUser?.address]);
|
||||
|
||||
const removeBookmark = React.useCallback(async (bookmarkId: string): Promise<void> => {
|
||||
const address = session.currentUser?.address;
|
||||
if (!address) return;
|
||||
const [typeStr, targetId] = bookmarkId.split(':');
|
||||
const type = typeStr === 'post' ? BookmarkType.POST : BookmarkType.COMMENT;
|
||||
await BookmarkService.removeBookmark(type, targetId);
|
||||
const updated = await client.database.getUserBookmarks(address);
|
||||
setOpchanState(prev => ({ ...prev, content: { ...prev.content, bookmarks: updated } }));
|
||||
}, [client, session.currentUser?.address]);
|
||||
|
||||
const clearAllBookmarks = React.useCallback(async (): Promise<void> => {
|
||||
const address = session.currentUser?.address;
|
||||
if (!address) return;
|
||||
await BookmarkService.clearUserBookmarks(address);
|
||||
const updated = await client.database.getUserBookmarks(address);
|
||||
setOpchanState(prev => ({ ...prev, content: { ...prev.content, bookmarks: updated } }));
|
||||
}, [client, session.currentUser?.address]);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
// Minimal refresh: re-reflect cache; network refresh is via useNetwork
|
||||
reflectCache(client);
|
||||
}, [client]);
|
||||
|
||||
return {
|
||||
// data
|
||||
cells: content.cells,
|
||||
posts: content.posts,
|
||||
comments: content.comments,
|
||||
bookmarks: content.bookmarks,
|
||||
postsByCell,
|
||||
commentsByPost,
|
||||
cellsWithStats,
|
||||
userVerificationStatus,
|
||||
pending: {
|
||||
isPending: (id?: string) => (id ? client.database.isPending(id) : false),
|
||||
onChange: (cb: () => void) => client.database.onPendingChange(cb),
|
||||
},
|
||||
lastSync: content.lastSync,
|
||||
// actions
|
||||
createCell,
|
||||
createPost,
|
||||
createComment,
|
||||
vote,
|
||||
moderate,
|
||||
togglePostBookmark,
|
||||
toggleCommentBookmark,
|
||||
removeBookmark,
|
||||
clearAllBookmarks,
|
||||
refresh,
|
||||
} as const;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
16
packages/react/src/v1/hooks/useForum.ts
Normal file
16
packages/react/src/v1/hooks/useForum.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { useAuth } from './useAuth';
|
||||
import { useContent } from './useContent';
|
||||
import { usePermissions } from './usePermissions';
|
||||
import { useNetwork } from './useNetwork';
|
||||
|
||||
export function useForum() {
|
||||
const user = useAuth();
|
||||
const content = useContent();
|
||||
const permissions = usePermissions();
|
||||
const network = useNetwork();
|
||||
return { user, content, permissions, network } as const;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
29
packages/react/src/v1/hooks/useNetwork.ts
Normal file
29
packages/react/src/v1/hooks/useNetwork.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { useOpchanStore } from '../store/opchanStore';
|
||||
import { useClient } from '../context/ClientContext';
|
||||
|
||||
export function useNetwork() {
|
||||
const client = useClient();
|
||||
const network = useOpchanStore(s => s.network);
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
// trigger a database refresh using core helper
|
||||
const { refreshData } = await import('@opchan/core');
|
||||
await refreshData(client.messageManager.isReady, () => {}, () => {}, () => {});
|
||||
} catch (e) {
|
||||
console.error('Network refresh failed', e);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isConnected: network.isConnected,
|
||||
statusMessage: network.statusMessage,
|
||||
issues: network.issues,
|
||||
canRefresh: true,
|
||||
refresh,
|
||||
} as const;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
69
packages/react/src/v1/hooks/usePermissions.ts
Normal file
69
packages/react/src/v1/hooks/usePermissions.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { useOpchanStore } from '../store/opchanStore';
|
||||
import { EVerificationStatus } from '@opchan/core';
|
||||
|
||||
export function usePermissions() {
|
||||
const { session, content } = useOpchanStore(s => ({ session: s.session, content: s.content }));
|
||||
const currentUser = session.currentUser;
|
||||
|
||||
const isVerified = session.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED;
|
||||
const isConnected = session.verificationStatus !== EVerificationStatus.WALLET_UNCONNECTED;
|
||||
|
||||
const canCreateCell = isVerified || isConnected;
|
||||
const canPost = isConnected;
|
||||
const canComment = isConnected;
|
||||
const canVote = isConnected;
|
||||
|
||||
const canModerate = (cellId: string): boolean => {
|
||||
if (!currentUser) return false;
|
||||
const cell = content.cells.find(c => c.id === cellId);
|
||||
return cell ? cell.author === currentUser.address : false;
|
||||
};
|
||||
|
||||
const reasons = {
|
||||
post: canPost ? '' : 'Connect your wallet to post',
|
||||
comment: canComment ? '' : 'Connect your wallet to comment',
|
||||
vote: canVote ? '' : 'Connect your wallet to vote',
|
||||
createCell: canCreateCell ? '' : 'Verification required to create a cell',
|
||||
moderate: (cellId: string) => (canModerate(cellId) ? '' : 'Only cell owner can moderate'),
|
||||
} as const;
|
||||
|
||||
const check = (
|
||||
action:
|
||||
| 'canPost'
|
||||
| 'canComment'
|
||||
| 'canVote'
|
||||
| 'canCreateCell'
|
||||
| 'canModerate',
|
||||
cellId?: string
|
||||
): { allowed: boolean; reason: string } => {
|
||||
switch (action) {
|
||||
case 'canPost':
|
||||
return { allowed: canPost, reason: reasons.post };
|
||||
case 'canComment':
|
||||
return { allowed: canComment, reason: reasons.comment };
|
||||
case 'canVote':
|
||||
return { allowed: canVote, reason: reasons.vote };
|
||||
case 'canCreateCell':
|
||||
return { allowed: canCreateCell, reason: reasons.createCell };
|
||||
case 'canModerate':
|
||||
return { allowed: cellId ? canModerate(cellId) : false, reason: cellId ? reasons.moderate(cellId) : 'Cell required' };
|
||||
default:
|
||||
return { allowed: false, reason: 'Unknown action' };
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
canPost,
|
||||
canComment,
|
||||
canVote,
|
||||
canCreateCell,
|
||||
canDelegate: isVerified || isConnected,
|
||||
canModerate,
|
||||
reasons,
|
||||
check,
|
||||
} as const;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
39
packages/react/src/v1/hooks/useUIState.ts
Normal file
39
packages/react/src/v1/hooks/useUIState.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { useClient } from '../context/ClientContext';
|
||||
|
||||
export function useUIState<T>(key: string, defaultValue: T): [T, (value: T) => void, { loading: boolean; error?: string }] {
|
||||
const client = useClient();
|
||||
const [state, setState] = React.useState<T>(defaultValue);
|
||||
const [loading, setLoading] = React.useState<boolean>(true);
|
||||
const [error, setError] = React.useState<string | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const value = await client.database.loadUIState(key);
|
||||
if (mounted) {
|
||||
if (value !== undefined) setState(value as T);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setError((e as Error).message);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [client, key]);
|
||||
|
||||
const set = React.useCallback((value: T) => {
|
||||
setState(value);
|
||||
client.database.storeUIState(key, value).catch(() => {});
|
||||
}, [client, key]);
|
||||
|
||||
return [state, set, { loading, error }];
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useClient } from '../context/ClientContext';
|
||||
import { EDisplayPreference, EVerificationStatus } from '@opchan/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useClient } from '../../contexts/ClientContext';
|
||||
|
||||
export interface UserDisplayInfo {
|
||||
displayName: string;
|
||||
@ -15,11 +15,12 @@ export interface UserDisplayInfo {
|
||||
|
||||
/**
|
||||
* User display hook with caching and reactive updates
|
||||
* Takes an address and resolves display details for it
|
||||
*/
|
||||
export function useUserDisplay(address: string): UserDisplayInfo {
|
||||
const client = useClient();
|
||||
const getDisplayName = (addr: string) => client.userIdentityService.getDisplayName(addr);
|
||||
const [displayInfo, setDisplayInfo] = useState<UserDisplayInfo>({
|
||||
|
||||
const [displayInfo, setDisplayInfo] = React.useState<UserDisplayInfo>({
|
||||
displayName: `${address.slice(0, 6)}...${address.slice(-4)}`,
|
||||
callSign: null,
|
||||
ensName: null,
|
||||
@ -29,22 +30,29 @@ export function useUserDisplay(address: string): UserDisplayInfo {
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
// Subscribe to identity service refresh events directly
|
||||
useEffect(() => {
|
||||
|
||||
const getDisplayName = React.useCallback((addr: string) => {
|
||||
return client.userIdentityService.getDisplayName(addr);
|
||||
}, [client]);
|
||||
|
||||
// Initial load and refresh listener
|
||||
React.useEffect(() => {
|
||||
if (!address) return;
|
||||
|
||||
let cancelled = false;
|
||||
const prime = async () => {
|
||||
if (!address) return;
|
||||
|
||||
const loadUserDisplay = async () => {
|
||||
try {
|
||||
const identity = await client.userIdentityService.getUserIdentity(address);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (identity) {
|
||||
setDisplayInfo({
|
||||
displayName: getDisplayName(address),
|
||||
callSign: identity.callSign || null,
|
||||
ensName: identity.ensName || null,
|
||||
ordinalDetails: identity.ordinalDetails
|
||||
? identity.ordinalDetails.ordinalDetails
|
||||
: null,
|
||||
ordinalDetails: identity.ordinalDetails?.ordinalDetails || null,
|
||||
verificationLevel: identity.verificationStatus,
|
||||
displayPreference: identity.displayPreference || null,
|
||||
isLoading: false,
|
||||
@ -59,6 +67,8 @@ export function useUserDisplay(address: string): UserDisplayInfo {
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
|
||||
setDisplayInfo(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
@ -66,29 +76,48 @@ export function useUserDisplay(address: string): UserDisplayInfo {
|
||||
}));
|
||||
}
|
||||
};
|
||||
prime();
|
||||
return () => { cancelled = true; };
|
||||
}, [address, client, getDisplayName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!address) return;
|
||||
const off = client.userIdentityService.addRefreshListener(async (changed) => {
|
||||
if (changed !== address) return;
|
||||
const identity = await client.userIdentityService.getUserIdentity(address);
|
||||
if (!identity) return;
|
||||
setDisplayInfo(prev => ({
|
||||
...prev,
|
||||
displayName: getDisplayName(address),
|
||||
callSign: identity.callSign || null,
|
||||
ensName: identity.ensName || null,
|
||||
ordinalDetails: identity.ordinalDetails ? identity.ordinalDetails.ordinalDetails : null,
|
||||
verificationLevel: identity.verificationStatus,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}));
|
||||
loadUserDisplay();
|
||||
|
||||
// Subscribe to identity service refresh events
|
||||
const unsubscribe = client.userIdentityService.addRefreshListener(async (changedAddress) => {
|
||||
if (changedAddress !== address || cancelled) return;
|
||||
|
||||
try {
|
||||
const identity = await client.userIdentityService.getUserIdentity(address);
|
||||
if (!identity || cancelled) return;
|
||||
|
||||
setDisplayInfo(prev => ({
|
||||
...prev,
|
||||
displayName: getDisplayName(address),
|
||||
callSign: identity.callSign || null,
|
||||
ensName: identity.ensName || null,
|
||||
ordinalDetails: identity.ordinalDetails?.ordinalDetails || null,
|
||||
verificationLevel: identity.verificationStatus,
|
||||
displayPreference: identity.displayPreference || null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
|
||||
setDisplayInfo(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}));
|
||||
}
|
||||
});
|
||||
return () => { try { off && off(); } catch {} };
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
try {
|
||||
unsubscribe();
|
||||
} catch {
|
||||
// Ignore unsubscribe errors
|
||||
}
|
||||
};
|
||||
}, [address, client, getDisplayName]);
|
||||
|
||||
return displayInfo;
|
||||
}
|
||||
}
|
||||
91
packages/react/src/v1/provider/OpChanProvider.tsx
Normal file
91
packages/react/src/v1/provider/OpChanProvider.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import { OpChanClient, type OpChanClientConfig } from '@opchan/core';
|
||||
import { OpChanProvider as ClientProvider } from '../context/ClientContext';
|
||||
import { StoreWiring } from './StoreWiring';
|
||||
import { setOpchanState } from '../store/opchanStore';
|
||||
import { EVerificationStatus } from '@opchan/core';
|
||||
import type { EDisplayPreference, User } from '@opchan/core';
|
||||
|
||||
export interface WalletAdapterAccount {
|
||||
address: string;
|
||||
walletType: 'bitcoin' | 'ethereum';
|
||||
}
|
||||
|
||||
export interface WalletAdapter {
|
||||
getAccount(): WalletAdapterAccount | null;
|
||||
onChange(callback: (account: WalletAdapterAccount | null) => void): () => void;
|
||||
}
|
||||
|
||||
export interface NewOpChanProviderProps {
|
||||
config: OpChanClientConfig;
|
||||
walletAdapter?: WalletAdapter;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* New provider that constructs the OpChanClient and sets up DI.
|
||||
* Event wiring and store hydration will be handled in a separate effect layer.
|
||||
*/
|
||||
export const OpChanProvider: React.FC<NewOpChanProviderProps> = ({ config, walletAdapter, children }) => {
|
||||
const [client] = React.useState(() => new OpChanClient(config));
|
||||
|
||||
// Bridge wallet adapter to session state
|
||||
React.useEffect(() => {
|
||||
if (!walletAdapter) return;
|
||||
|
||||
const syncFromAdapter = async (account: WalletAdapterAccount | null) => {
|
||||
if (account) {
|
||||
// Persist base user and update session
|
||||
const baseUser: User = {
|
||||
address: account.address,
|
||||
walletType: account.walletType,
|
||||
displayPreference: 'wallet-address' as EDisplayPreference,
|
||||
verificationStatus: EVerificationStatus.WALLET_CONNECTED,
|
||||
displayName: account.address,
|
||||
lastChecked: Date.now(),
|
||||
};
|
||||
try {
|
||||
await client.database.storeUser(baseUser);
|
||||
} catch (err) {
|
||||
console.warn('OpChanProvider: failed to persist base user', err);
|
||||
}
|
||||
setOpchanState(prev => ({
|
||||
...prev,
|
||||
session: {
|
||||
currentUser: baseUser,
|
||||
verificationStatus: baseUser.verificationStatus,
|
||||
delegation: prev.session.delegation,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
// Clear session on disconnect
|
||||
try { await client.database.clearUser(); } catch (err) {
|
||||
console.warn('OpChanProvider: failed to clear user on disconnect', err);
|
||||
}
|
||||
setOpchanState(prev => ({
|
||||
...prev,
|
||||
session: {
|
||||
currentUser: null,
|
||||
verificationStatus: EVerificationStatus.WALLET_UNCONNECTED,
|
||||
delegation: null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Initial sync
|
||||
syncFromAdapter(walletAdapter.getAccount());
|
||||
// Subscribe
|
||||
const off = walletAdapter.onChange(syncFromAdapter);
|
||||
return () => { try { off(); } catch { /* noop */ } };
|
||||
}, [walletAdapter, client]);
|
||||
|
||||
return (
|
||||
<ClientProvider client={client}>
|
||||
<StoreWiring />
|
||||
{children}
|
||||
</ClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
172
packages/react/src/v1/provider/StoreWiring.tsx
Normal file
172
packages/react/src/v1/provider/StoreWiring.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import React from 'react';
|
||||
import { useClient } from '../context/ClientContext';
|
||||
import { setOpchanState, getOpchanState } from '../store/opchanStore';
|
||||
import type { OpchanMessage, User } from '@opchan/core';
|
||||
import { EVerificationStatus, EDisplayPreference } from '@opchan/core';
|
||||
|
||||
export const StoreWiring: React.FC = () => {
|
||||
const client = useClient();
|
||||
|
||||
// Initial hydrate from LocalDatabase
|
||||
React.useEffect(() => {
|
||||
let unsubHealth: (() => void) | null = null;
|
||||
let unsubMessages: (() => void) | null = null;
|
||||
let unsubIdentity: (() => void) | null = null;
|
||||
|
||||
const hydrate = async () => {
|
||||
try {
|
||||
await client.database.open();
|
||||
const cache = client.database.cache;
|
||||
|
||||
// Reflect content cache
|
||||
setOpchanState(prev => ({
|
||||
...prev,
|
||||
content: {
|
||||
...prev.content,
|
||||
cells: Object.values(cache.cells),
|
||||
posts: Object.values(cache.posts),
|
||||
comments: Object.values(cache.comments),
|
||||
bookmarks: Object.values(cache.bookmarks),
|
||||
lastSync: client.database.getSyncState().lastSync,
|
||||
pendingIds: new Set<string>(),
|
||||
pendingVotes: new Set<string>(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Hydrate session (user + delegation) from LocalDatabase
|
||||
try {
|
||||
const loadedUser = await client.database.loadUser();
|
||||
const delegationStatus = await client.delegation.getStatus(
|
||||
loadedUser?.address,
|
||||
loadedUser?.walletType,
|
||||
);
|
||||
|
||||
// If we have a loaded user, enrich it with latest identity for display fields
|
||||
let enrichedUser: User | null = loadedUser ?? null;
|
||||
if (loadedUser) {
|
||||
try {
|
||||
const identity = await client.userIdentityService.getUserIdentity(loadedUser.address);
|
||||
if (identity) {
|
||||
const displayName = identity.displayPreference === EDisplayPreference.CALL_SIGN
|
||||
? (identity.callSign || loadedUser.displayName)
|
||||
: (identity.ensName || loadedUser.displayName);
|
||||
enrichedUser = {
|
||||
...loadedUser,
|
||||
callSign: identity.callSign ?? loadedUser.callSign,
|
||||
displayPreference: identity.displayPreference ?? loadedUser.displayPreference,
|
||||
displayName,
|
||||
ensDetails: identity.ensName ? { ensName: identity.ensName } : loadedUser.ensDetails,
|
||||
ordinalDetails: identity.ordinalDetails ?? loadedUser.ordinalDetails,
|
||||
verificationStatus: identity.verificationStatus ?? loadedUser.verificationStatus,
|
||||
};
|
||||
try { await client.database.storeUser(enrichedUser); } catch { /* ignore persist error */ }
|
||||
}
|
||||
} catch { /* ignore identity enrich error */ }
|
||||
}
|
||||
|
||||
setOpchanState(prev => ({
|
||||
...prev,
|
||||
session: {
|
||||
currentUser: enrichedUser,
|
||||
verificationStatus:
|
||||
enrichedUser?.verificationStatus ?? EVerificationStatus.WALLET_UNCONNECTED,
|
||||
delegation: delegationStatus ?? null,
|
||||
},
|
||||
}));
|
||||
} catch (sessionErr) {
|
||||
console.error('Initial session hydrate failed', sessionErr);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Initial hydrate failed', e);
|
||||
}
|
||||
};
|
||||
|
||||
const wire = () => {
|
||||
unsubHealth = client.messageManager.onHealthChange((isReady: boolean) => {
|
||||
setOpchanState(prev => ({
|
||||
...prev,
|
||||
network: {
|
||||
...prev.network,
|
||||
isConnected: isReady,
|
||||
statusMessage: isReady ? 'connected' : 'connecting…',
|
||||
issues: isReady ? [] : prev.network.issues,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
unsubMessages = client.messageManager.onMessageReceived(async (message: OpchanMessage) => {
|
||||
// Persist, then reflect cache in store
|
||||
try {
|
||||
await client.database.updateCache(message);
|
||||
const cache = client.database.cache;
|
||||
setOpchanState(prev => ({
|
||||
...prev,
|
||||
content: {
|
||||
...prev.content,
|
||||
cells: Object.values(cache.cells),
|
||||
posts: Object.values(cache.posts),
|
||||
comments: Object.values(cache.comments),
|
||||
bookmarks: Object.values(cache.bookmarks),
|
||||
lastSync: Date.now(),
|
||||
pendingIds: prev.content.pendingIds,
|
||||
pendingVotes: prev.content.pendingVotes,
|
||||
},
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to apply incoming message', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Reactively update session.currentUser when identity refreshes for the active user
|
||||
unsubIdentity = client.userIdentityService.addRefreshListener(async (address: string) => {
|
||||
try {
|
||||
const { session } = getOpchanState();
|
||||
const active = session.currentUser;
|
||||
if (!active || active.address !== address) return;
|
||||
|
||||
const identity = await client.userIdentityService.getUserIdentity(address);
|
||||
if (!identity) return;
|
||||
|
||||
const displayName = identity.displayPreference === EDisplayPreference.CALL_SIGN
|
||||
? (identity.callSign || active.displayName)
|
||||
: (identity.ensName || active.displayName);
|
||||
|
||||
const updated: User = {
|
||||
...active,
|
||||
callSign: identity.callSign ?? active.callSign,
|
||||
displayPreference: identity.displayPreference ?? active.displayPreference,
|
||||
displayName,
|
||||
ensDetails: identity.ensName ? { ensName: identity.ensName } : active.ensDetails,
|
||||
ordinalDetails: identity.ordinalDetails ?? active.ordinalDetails,
|
||||
verificationStatus: identity.verificationStatus ?? active.verificationStatus,
|
||||
};
|
||||
|
||||
try { await client.database.storeUser(updated); } catch { /* ignore persist error */ }
|
||||
|
||||
setOpchanState(prev => ({
|
||||
...prev,
|
||||
session: {
|
||||
...prev.session,
|
||||
currentUser: updated,
|
||||
verificationStatus: updated.verificationStatus,
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
console.warn('Identity refresh wiring failed', err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
hydrate().then(wire);
|
||||
|
||||
return () => {
|
||||
unsubHealth?.();
|
||||
unsubMessages?.();
|
||||
unsubIdentity?.();
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
128
packages/react/src/v1/store/opchanStore.ts
Normal file
128
packages/react/src/v1/store/opchanStore.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import React, { useSyncExternalStore } from 'react';
|
||||
import type {
|
||||
CellMessage,
|
||||
PostMessage,
|
||||
CommentMessage,
|
||||
Bookmark,
|
||||
User,
|
||||
EVerificationStatus,
|
||||
DelegationFullStatus,
|
||||
} from '@opchan/core';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export interface SessionSlice {
|
||||
currentUser: User | null;
|
||||
verificationStatus: EVerificationStatus;
|
||||
delegation: DelegationFullStatus | null;
|
||||
}
|
||||
|
||||
export interface ContentSlice {
|
||||
cells: CellMessage[];
|
||||
posts: PostMessage[];
|
||||
comments: CommentMessage[];
|
||||
bookmarks: Bookmark[];
|
||||
lastSync: number | null;
|
||||
pendingIds: Set<string>;
|
||||
pendingVotes: Set<string>;
|
||||
}
|
||||
|
||||
export interface IdentitySlice {
|
||||
// minimal identity cache; full logic lives in UserIdentityService
|
||||
displayNameByAddress: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface NetworkSlice {
|
||||
isConnected: boolean;
|
||||
statusMessage: string;
|
||||
issues: string[];
|
||||
}
|
||||
|
||||
export interface OpchanState {
|
||||
session: SessionSlice;
|
||||
content: ContentSlice;
|
||||
identity: IdentitySlice;
|
||||
network: NetworkSlice;
|
||||
}
|
||||
|
||||
const defaultState: OpchanState = {
|
||||
session: {
|
||||
currentUser: null,
|
||||
verificationStatus: 'wallet-unconnected' as EVerificationStatus,
|
||||
delegation: null,
|
||||
},
|
||||
content: {
|
||||
cells: [],
|
||||
posts: [],
|
||||
comments: [],
|
||||
bookmarks: [],
|
||||
lastSync: null,
|
||||
pendingIds: new Set<string>(),
|
||||
pendingVotes: new Set<string>(),
|
||||
},
|
||||
identity: {
|
||||
displayNameByAddress: {},
|
||||
},
|
||||
network: {
|
||||
isConnected: false,
|
||||
statusMessage: 'connecting…',
|
||||
issues: [],
|
||||
},
|
||||
};
|
||||
|
||||
class OpchanStoreImpl {
|
||||
private state: OpchanState = defaultState;
|
||||
private listeners: Set<Listener> = new Set();
|
||||
|
||||
subscribe(listener: Listener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
getSnapshot(): OpchanState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const l of this.listeners) l();
|
||||
}
|
||||
|
||||
setState(mutator: (prev: OpchanState) => OpchanState): void {
|
||||
const next = mutator(this.state);
|
||||
if (next !== this.state) {
|
||||
this.state = next;
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const opchanStore = new OpchanStoreImpl();
|
||||
|
||||
export function useOpchanStore<T>(selector: (s: OpchanState) => T, isEqual?: (a: T, b: T) => boolean): T {
|
||||
// Subscribe to the raw store snapshot to keep getSnapshot referentially stable
|
||||
const state = useSyncExternalStore(
|
||||
(cb) => opchanStore.subscribe(cb),
|
||||
() => opchanStore.getSnapshot(),
|
||||
() => opchanStore.getSnapshot(),
|
||||
);
|
||||
|
||||
const compare = isEqual ?? ((a: T, b: T) => a === b);
|
||||
const selected = React.useMemo(() => selector(state), [state, selector]);
|
||||
|
||||
// Cache the last selected value using the provided equality to avoid churn
|
||||
const cachedRef = React.useRef<T>(selected);
|
||||
if (!compare(cachedRef.current, selected)) {
|
||||
cachedRef.current = selected;
|
||||
}
|
||||
return cachedRef.current;
|
||||
}
|
||||
|
||||
export function getOpchanState(): OpchanState {
|
||||
return opchanStore.getSnapshot();
|
||||
}
|
||||
|
||||
export function setOpchanState(mutator: (prev: OpchanState) => OpchanState): void {
|
||||
opchanStore.setState(mutator);
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user