feat: library (#26)

* chore: move to /app

* chore: setup workspace

* chore: move lib

* wip

* fix: build and memory leak

* fix: app content hydration for message manager

* fix: non-ens wallets engagement, syncing hydration

* chore: improvements

* chore: IdentityContext

* chore: time range for sds store query to 1 month

* chore: remove client prop

* remove env logs

* wip

* FIX HYDRATION

* fix: message signing

* chore: rename providers

* fix: hydration interface

* state consistentcy

* fix: ens

* chore: minimal docs

* chore: update readme

* local build
This commit is contained in:
Danish Arora 2025-09-25 21:52:40 +05:30 committed by GitHub
parent c91164dbde
commit cca6299eb5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
217 changed files with 7295 additions and 9969 deletions

72
.gitignore vendored
View File

@ -1,47 +1,43 @@
.cursorrules
.giga/
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
furps-comparison.md
# Build outputs
dist/
build/
*.tsbuildinfo
.tsbuildinfo
README-task-master.md
.cursor
scripts
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Coverage
coverage/
.nyc_output/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# TypeScript cache
*.tsbuildinfo
# Added by Claude Task Master
dev-debug.log
# Dependency directories
node_modules/
# Environment variables
.env
.vscode
# OS specific
# Task files
tasks.json
tasks/
IMPLEMENTATION_PLAN.md
.giga
.cursor
.cursorrules

172
README.md
View File

@ -1,138 +1,72 @@
# OpChan
# Opchan
A decentralized forum application built as a Proof of Concept for a Waku-powered discussion platform. OpChan enables users to create "cells" (discussion boards), make posts, and engage in threaded conversations using Bitcoin Ordinal verification and the Waku protocol for decentralized messaging.
A TypeScript browser library workspace.
## Quick Start
## Structure
### Prerequisites
This is an npm workspace containing:
- Node.js 18+ and npm
- [Phantom Wallet](https://phantom.app/) browser extension
- Bitcoin Ordinals (required for posting, optional for reading)
- `@opchan/core` - Core browser library package
## Development
### Installation
1. **Clone the repository**
```bash
git clone https://github.com/waku-org/OpChan.git
cd OpChan
```
2. **Install dependencies**
```bash
npm install
```
3. **Setup environment variables**
```bash
cp .env.example .env
```
Edit `.env` to configure development settings:
```env
# Set to 'true' to bypass verification in development
VITE_OPCHAN_MOCK_ORDINAL_CHECK=false
```
4. **Start development server**
```bash
npm run dev
```
### Project Structure
```bash
npm install
```
src/
├── components/ # React components
│ ├── ui/ # shadcn/ui component library
│ ├── ActivityFeed.tsx
│ ├── CellPage.tsx
│ ├── Dashboard.tsx
│ └── ...
├── contexts/ # React Context providers
│ ├── AuthContext.tsx # Wallet & authentication
│ ├── ForumContext.tsx # Forum data & state
│ └── forum/ # Forum logic modules
├── lib/ # Core libraries
│ ├── identity/ # Wallet & cryptographic operations
│ ├── waku/ # Waku protocol integration
│ └── utils.ts
├── pages/ # Route components
└── types/ # TypeScript definitions
### Building
Build all packages:
```bash
npm run build
```
Build specific package:
```bash
npm run build --workspace=@opchan/core
```
### Development Mode
Watch mode for development:
```bash
npm run dev --workspace=@opchan/core
```
### Testing
```bash
npm test
```
### Linting
```bash
npm run lint
```
## Usage
### Getting Started
```typescript
import { Opchan } from '@opchan/core';
1. **Connect Wallet**: Click "Connect Wallet" and approve the Phantom wallet connection
2. **Verify Ordinals**: The app will check if your wallet contains Logos Operator Bitcoin Ordinals
3. **Browse Cells**: View existing discussion boards on the dashboard
4. **Create Content**: Create new cells, posts, or comments (requires Ordinals)
5. **Moderate**: Cell creators can moderate their boards
const opchan = new Opchan({
debug: true,
version: '1.0.0'
});
### Authentication Flow
console.log(opchan.getVersion()); // "1.0.0"
opchan.log('Hello from Opchan!'); // [Opchan] Hello from Opchan!
```
OpChan uses a two-tier authentication system:
## Packages
1. **Wallet Connection**: Initial connection to Phantom wallet
2. **Key Delegation**: Optional browser key generation for improved UX
- Reduces wallet signature prompts
- Configurable duration: 1 week or 30 days
- Can be regenerated anytime
### @opchan/core
### Network & Performance
The core browser library providing the main functionality.
- **Waku Network**: Connects to multiple bootstrap nodes for resilience
- **Message Caching**: Local caching with IndexedDB (planned)
- **Time-bounded Queries**: 24-hour query windows to prevent database overload
- **Pagination**: 50 messages per query with fallback limits
## License
## Contributing
### Development Setup
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/amazing-feature`
3. Make your changes following the existing code style
4. Test your changes thoroughly
5. Commit your changes: `git commit -m 'Add amazing feature'`
6. Push to the branch: `git push origin feature/amazing-feature`
7. Open a Pull Request
## TODOs
- [x] replace mock wallet connection/disconnection
- supports Phantom
- [x] replace mock Ordinal verification (API)
- [ ] figure out using actual icons for cells
- [ ] store message cache in indexedDB -- make app local-first (update from/to Waku when available)
- [ ] moderation
- [ ] admins can "moderate" comments/posts
## Architecture
OpChan implements a decentralized architecture with these key components:
- **Waku Protocol**: Handles peer-to-peer messaging and content distribution
- **Bitcoin Ordinals**: Provides decentralized identity verification
- **Key Delegation**: Improves UX while maintaining security
- **Content Addressing**: Messages are cryptographically signed and verifiable
- **Moderation Layer**: Cell-based moderation without global censorship
## Support
For questions, issues, or contributions:
- Open an issue on GitHub for bugs or feature requests
- Check existing issues before creating new ones
- Provide detailed information for bug reports
- Include steps to reproduce issues
---
**Note**: This is a Proof of Concept implementation. Use at your own risk in production environments.
MIT

47
app/.gitignore vendored Normal file
View File

@ -0,0 +1,47 @@
.cursorrules
.giga/
furps-comparison.md
README-task-master.md
.cursor
scripts
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Added by Claude Task Master
dev-debug.log
# Dependency directories
node_modules/
# Environment variables
.env
.vscode
# OS specific
# Task files
tasks.json
tasks/
IMPLEMENTATION_PLAN.md

138
app/README.md Normal file
View File

@ -0,0 +1,138 @@
# OpChan
A decentralized forum application built as a Proof of Concept for a Waku-powered discussion platform. OpChan enables users to create "cells" (discussion boards), make posts, and engage in threaded conversations using Bitcoin Ordinal verification and the Waku protocol for decentralized messaging.
## Quick Start
### Prerequisites
- Node.js 18+ and npm
- [Phantom Wallet](https://phantom.app/) browser extension
- Bitcoin Ordinals (required for posting, optional for reading)
### Installation
1. **Clone the repository**
```bash
git clone https://github.com/waku-org/OpChan.git
cd OpChan
```
2. **Install dependencies**
```bash
npm install
```
3. **Setup environment variables**
```bash
cp .env.example .env
```
Edit `.env` to configure development settings:
```env
# Set to 'true' to bypass verification in development
VITE_OPCHAN_MOCK_ORDINAL_CHECK=false
```
4. **Start development server**
```bash
npm run dev
```
### Project Structure
```
src/
├── components/ # React components
│ ├── ui/ # shadcn/ui component library
│ ├── ActivityFeed.tsx
│ ├── CellPage.tsx
│ ├── Dashboard.tsx
│ └── ...
├── contexts/ # React Context providers
│ ├── AuthContext.tsx # Wallet & authentication
│ ├── ForumContext.tsx # Forum data & state
│ └── forum/ # Forum logic modules
├── lib/ # Core libraries
│ ├── identity/ # Wallet & cryptographic operations
│ ├── waku/ # Waku protocol integration
│ └── utils.ts
├── pages/ # Route components
└── types/ # TypeScript definitions
```
## Usage
### Getting Started
1. **Connect Wallet**: Click "Connect Wallet" and approve the Phantom wallet connection
2. **Verify Ordinals**: The app will check if your wallet contains Logos Operator Bitcoin Ordinals
3. **Browse Cells**: View existing discussion boards on the dashboard
4. **Create Content**: Create new cells, posts, or comments (requires Ordinals)
5. **Moderate**: Cell creators can moderate their boards
### Authentication Flow
OpChan uses a two-tier authentication system:
1. **Wallet Connection**: Initial connection to Phantom wallet
2. **Key Delegation**: Optional browser key generation for improved UX
- Reduces wallet signature prompts
- Configurable duration: 1 week or 30 days
- Can be regenerated anytime
### Network & Performance
- **Waku Network**: Connects to multiple bootstrap nodes for resilience
- **Message Caching**: Local caching with IndexedDB (planned)
- **Time-bounded Queries**: 24-hour query windows to prevent database overload
- **Pagination**: 50 messages per query with fallback limits
## Contributing
### Development Setup
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/amazing-feature`
3. Make your changes following the existing code style
4. Test your changes thoroughly
5. Commit your changes: `git commit -m 'Add amazing feature'`
6. Push to the branch: `git push origin feature/amazing-feature`
7. Open a Pull Request
## TODOs
- [x] replace mock wallet connection/disconnection
- supports Phantom
- [x] replace mock Ordinal verification (API)
- [ ] figure out using actual icons for cells
- [ ] store message cache in indexedDB -- make app local-first (update from/to Waku when available)
- [ ] moderation
- [ ] admins can "moderate" comments/posts
## Architecture
OpChan implements a decentralized architecture with these key components:
- **Waku Protocol**: Handles peer-to-peer messaging and content distribution
- **Bitcoin Ordinals**: Provides decentralized identity verification
- **Key Delegation**: Improves UX while maintaining security
- **Content Addressing**: Messages are cryptographically signed and verifiable
- **Moderation Layer**: Cell-based moderation without global censorship
## Support
For questions, issues, or contributions:
- Open an issue on GitHub for bugs or feature requests
- Check existing issues before creating new ones
- Provide detailed information for bug reports
- Include steps to reproduce issues
---
**Note**: This is a Proof of Concept implementation. Use at your own risk in production environments.

View File

@ -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,

110
app/package.json Normal file
View File

@ -0,0 +1,110 @@
{
"name": "opchan",
"private": true,
"version": "1.0.0",
"description": "A decentralized forum built on Waku.",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"check": "tsc --project tsconfig.app.json --noEmit && tsc --project tsconfig.node.json --noEmit && eslint . --fix && prettier --write .",
"fix": "prettier --write . && eslint . --fix",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@noble/ed25519": "^2.2.3",
"@noble/hashes": "^1.8.0",
"@opchan/core": "file:../packages/core",
"@opchan/react": "file:../packages/react",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.0",
"@radix-ui/react-context-menu": "^2.2.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@reown/appkit": "^1.7.17",
"@reown/appkit-adapter-bitcoin": "^1.7.17",
"@reown/appkit-adapter-wagmi": "^1.7.17",
"@reown/appkit-wallet-button": "^1.7.17",
"@tanstack/react-query": "^5.84.1",
"buffer": "^6.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.3.0",
"input-otp": "^1.2.4",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"re-resizable": "6.11.2",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.3",
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.1.0",
"vaul": "^0.9.3",
"viem": "^2.37.6",
"wagmi": "^2.17.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@tailwindcss/typography": "^0.5.16",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.5.5",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react-swc": "^3.11.0",
"@vitest/ui": "^3.2.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"jsdom": "^26.1.0",
"postcss": "^8.4.47",
"prettier": "^3.6.2",
"tailwindcss": "^3.4.11",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^5.4.1",
"vitest": "^3.2.4"
}
}

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

39
app/src/App.tsx Normal file
View File

@ -0,0 +1,39 @@
import { Toaster } from '@/components/ui/toaster';
import { Toaster as Sonner } from '@/components/ui/sonner';
import { TooltipProvider } from '@/components/ui/tooltip';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import CellPage from './pages/CellPage';
import PostPage from './pages/PostPage';
import NotFound from './pages/NotFound';
import Dashboard from './pages/Dashboard';
import Index from './pages/Index';
import ProfilePage from './pages/ProfilePage';
import BookmarksPage from './pages/BookmarksPage';
import DebugPage from './pages/DebugPage';
// Create a client
const queryClient = new QueryClient();
const App = () => (
<QueryClientProvider client={queryClient}>
<Router>
<TooltipProvider>
<Toaster />
<Sonner />
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/cells" element={<Index />} />
<Route path="/cell/:cellId" element={<CellPage />} />
<Route path="/post/:postId" element={<PostPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="/debug" element={<DebugPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</TooltipProvider>
</Router>
</QueryClientProvider>
);
export default App;

View File

@ -1,6 +1,6 @@
import { useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useForumData, useForumActions, usePermissions } from '@/hooks';
import { useContent, usePermissions } from '@/hooks';
import {
Layout,
MessageSquare,
@ -24,9 +24,9 @@ import {
import { CypherImage } from './ui/CypherImage';
import { RelevanceIndicator } from './ui/relevance-indicator';
import { ModerationToggle } from './ui/moderation-toggle';
import { sortCells, SortOption } from '@/lib/utils/sorting';
import { Cell } from '@/types/forum';
import { usePending } from '@/hooks/usePending';
import { sortCells, SortOption } from '@/utils/sorting';
import type { Cell } from '@opchan/core';
import { useForum } from '@/hooks';
import { ShareButton } from './ui/ShareButton';
// Empty State Component
@ -76,7 +76,8 @@ const EmptyState: React.FC<{ canCreateCell: boolean }> = ({
// Separate component to properly use hooks
const CellItem: React.FC<{ cell: Cell }> = ({ cell }) => {
const pending = usePending(cell.id);
const { content } = useForum();
const isPending = content.pending.isPending(cell.id);
return (
<Link to={`/cell/${cell.id}`} className="group block board-card">
@ -103,7 +104,7 @@ const CellItem: React.FC<{ cell: Cell }> = ({ cell }) => {
/>
)}
</div>
{pending.isPending && (
{isPending && (
<div className="mb-2">
<span className="px-2 py-0.5 rounded-sm bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 text-xs">
syncing
@ -139,8 +140,8 @@ const CellItem: React.FC<{ cell: Cell }> = ({ cell }) => {
};
const CellList = () => {
const { cellsWithStats, isInitialLoading } = useForumData();
const { refreshData } = useForumActions();
const { cellsWithStats } = useContent();
const content = useContent();
const { canCreateCell } = usePermissions();
const [sortOption, setSortOption] = useState<SortOption>('relevance');
@ -149,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" />
@ -220,13 +221,13 @@ const CellList = () => {
<Button
variant="outline"
size="icon"
onClick={refreshData}
disabled={isInitialLoading}
onClick={content.refresh}
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>

View File

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

View File

@ -3,7 +3,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Loader2 } from 'lucide-react';
import { useForumActions, usePermissions } from '@/hooks';
import { useContent, usePermissions } from '@/hooks';
import {
Form,
FormControl,
@ -23,7 +23,7 @@ import {
DialogTrigger,
} from '@/components/ui/dialog';
import { useToast } from '@/hooks/use-toast';
import { urlLoads } from '@/lib/utils/urlLoads';
import { urlLoads } from '@/utils';
const formSchema = z.object({
title: z
@ -57,7 +57,8 @@ export function CreateCellDialog({
open: externalOpen,
onOpenChange,
}: CreateCellDialogProps = {}) {
const { createCell, isCreatingCell } = useForumActions();
const { createCell } = useContent();
const isCreatingCell = false;
const { canCreateCell } = usePermissions();
const { toast } = useToast();
const [internalOpen, setInternalOpen] = React.useState(false);
@ -78,18 +79,17 @@ export function CreateCellDialog({
if (!canCreateCell) {
toast({
title: 'Permission Denied',
description: 'You need to verify Ordinal ownership to create cells.',
description: 'Only verified ENS or Logos Ordinal owners can create cells.',
variant: 'destructive',
});
return;
}
// ✅ All validation handled in hook
const cell = await createCell(
values.title,
values.description,
values.icon
);
const cell = await createCell({
name: values.title,
description: values.description,
icon: values.icon,
});
if (cell) {
form.reset();
setOpen(false);

View File

@ -4,44 +4,30 @@ 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 { EVerificationStatus } from '@/types/identity';
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 +37,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 +61,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`}
@ -93,11 +79,11 @@ const FeedSidebar: React.FC = () => {
)}
{verificationStatus === EVerificationStatus.WALLET_CONNECTED && (
<div className="text-xs text-muted-foreground">
<CheckCircle className="w-3 h-3 inline mr-1" />
Connected. You can post, comment, and vote.
</div>
)}
<div className="text-xs text-muted-foreground">
<CheckCircle className="w-3 h-3 inline mr-1" />
Connected. You can post, comment, and vote.
</div>
)}
</CardContent>
</Card>
)}

View File

@ -1,11 +1,8 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useAuth, useWakuHealthStatus } from '@/hooks';
import { useAuth as useAuthContext } from '@/contexts/useAuth';
import { EVerificationStatus } from '@/types/identity';
import { useForum } from '@/contexts/useForum';
import { localDatabase } from '@/lib/database/LocalDatabase';
import { DelegationFullStatus } from '@/lib/delegation';
import { useAuth, useForum, useNetwork, useUIState } from '@/hooks';
import { EVerificationStatus } from '@opchan/core';
import { localDatabase } from '@opchan/core';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@ -48,74 +45,35 @@ 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 { verificationStatus } = useAuth();
const { getDelegationStatus } = useAuthContext();
const [delegationInfo, setDelegationInfo] =
useState<DelegationFullStatus | null>(null);
const wakuHealth = useWakuHealthStatus();
const location = useLocation();
const { toast } = useToast();
const forum = useForum();
const { currentUser, delegationInfo } = useAuth();
const {statusMessage} = useNetwork();
const location = useLocation()
const { toast } = useToast();
const { content } = useForum();
// Use AppKit hooks for multi-chain support
const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' });
const ethereumAccount = useAppKitAccount({ namespace: 'eip155' });
const { disconnect } = useDisconnect();
// Determine which account is connected
const isBitcoinConnected = bitcoinAccount.isConnected;
const isEthereumConnected = ethereumAccount.isConnected;
const isConnected = isBitcoinConnected || isEthereumConnected;
const address = isConnected
? isBitcoinConnected
? bitcoinAccount.address
: ethereumAccount.address
: undefined;
const isConnected = bitcoinAccount.isConnected || ethereumAccount.isConnected;
const [walletWizardOpen, setWalletWizardOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
// ✅ Get display name from enhanced hook
const { displayName } = useUserDisplay(address || '');
// Load delegation status
React.useEffect(() => {
getDelegationStatus().then(setDelegationInfo).catch(console.error);
}, [getDelegationStatus]);
// Use LocalDatabase to persist wizard state across navigation
const getHasShownWizard = async (): Promise<boolean> => {
try {
const value = await localDatabase.loadUIState('hasShownWalletWizard');
return value === true;
} catch {
return false;
}
};
const setHasShownWizard = async (value: boolean): Promise<void> => {
try {
await localDatabase.storeUIState('hasShownWalletWizard', value);
} catch (e) {
console.error('Failed to store wizard state', e);
}
};
// Use centralized UI state instead of direct LocalDatabase access
const [hasShownWizard, setHasShownWizard] = useUIState('hasShownWalletWizard', false);
// Auto-open wizard when wallet connects for the first time
React.useEffect(() => {
if (isConnected) {
getHasShownWizard().then(hasShown => {
if (!hasShown) {
setWalletWizardOpen(true);
setHasShownWizard(true).catch(console.error);
}
});
if (isConnected && !hasShownWizard) {
setWalletWizardOpen(true);
setHasShownWizard(true);
}
}, [isConnected]);
}, [isConnected, hasShownWizard, setHasShownWizard]);
const handleConnect = async () => {
setWalletWizardOpen(true);
@ -127,7 +85,7 @@ const Header = () => {
const handleDisconnect = async () => {
await disconnect();
await setHasShownWizard(false); // Reset so wizard can show again on next connection
setHasShownWizard(false); // Reset so wizard can show again on next connection
toast({
title: 'Wallet Disconnected',
description: 'Your wallet has been disconnected successfully.',
@ -151,18 +109,23 @@ const Header = () => {
}
};
useEffect(() => {
console.log('currentUser', currentUser)
}, [currentUser])
const getStatusIcon = () => {
if (!isConnected) return <CircleSlash className="w-4 h-4" />;
if (
verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED &&
currentUser?.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED &&
delegationInfo?.isValid
) {
return <CheckCircle className="w-4 h-4" />;
} else if (verificationStatus === EVerificationStatus.WALLET_CONNECTED) {
} else if (currentUser?.verificationStatus === EVerificationStatus.WALLET_CONNECTED) {
return <AlertTriangle className="w-4 h-4" />;
} else if (
verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED
currentUser?.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED
) {
return <Key className="w-4 h-4" />;
} else {
@ -192,13 +155,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>
{forum.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(forum.lastSync).toLocaleTimeString([], {
{new Date(content.lastSync).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
@ -222,11 +185,11 @@ const Header = () => {
<Badge
variant="outline"
className={`font-mono text-xs border-0 ${
verificationStatus ===
currentUser?.verificationStatus ===
EVerificationStatus.ENS_ORDINAL_VERIFIED &&
delegationInfo?.isValid
? 'bg-green-500/20 text-green-400 border-green-500/30'
: verificationStatus ===
: 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'
@ -234,11 +197,11 @@ const Header = () => {
>
{getStatusIcon()}
<span className="ml-1">
{verificationStatus === EVerificationStatus.WALLET_UNCONNECTED
{currentUser?.verificationStatus === EVerificationStatus.WALLET_UNCONNECTED
? 'CONNECT'
: delegationInfo?.isValid
? 'READY'
: verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED
: currentUser?.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED
? 'EXPIRED'
: 'DELEGATE'}
</span>
@ -252,7 +215,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>
@ -260,15 +223,6 @@ const Header = () => {
align="end"
className="w-56 bg-black/95 border-cyber-muted/30"
>
<div className="px-3 py-2 border-b border-cyber-muted/30">
<div className="text-sm font-medium text-white">
{displayName}
</div>
<div className="text-xs text-cyber-neutral">
{address?.slice(0, 8)}...{address?.slice(-4)}
</div>
</div>
<DropdownMenuItem asChild>
<Link
to="/profile"
@ -470,10 +424,10 @@ const Header = () => {
<div className="px-4 py-3 border-t border-cyber-muted/20">
<div className="flex items-center space-x-2 text-xs text-cyber-neutral">
<WakuHealthDot />
<span>{wakuHealth.statusMessage}</span>
{forum.lastSync && (
<span>{statusMessage}</span>
{content.lastSync && (
<span className="ml-auto">
{new Date(forum.lastSync).toLocaleTimeString([], {
{new Date(content.lastSync).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}

View File

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

View File

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

View File

@ -1,19 +1,13 @@
import React, { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import {
useCell,
useCellPosts,
useForumActions,
usePermissions,
useUserVotes,
useAuth,
useForumData,
} from '@/hooks';
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';
import { Skeleton } from '@/components/ui/skeleton';
import { LinkRenderer } from '@/components/ui/link-renderer';
import { RelevanceIndicator } from '@/components/ui/relevance-indicator';
import { ShareButton } from '@/components/ui/ShareButton';
import {
ArrowLeft,
@ -38,27 +32,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 {
createPost,
votePost,
moderatePost,
unmoderatePost,
moderateUser,
refreshData,
isCreatingPost,
isVoting,
} = useForumActions();
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">
@ -117,7 +101,7 @@ const PostList = () => {
if (!newPostContent.trim()) return;
// ✅ All validation handled in hook
const post = await createPost(cellId, newPostTitle, newPostContent);
const post = await createPost({ cellId, title: newPostTitle, content: newPostContent });
if (post) {
setNewPostTitle('');
setNewPostContent('');
@ -127,7 +111,8 @@ const PostList = () => {
// Handle keyboard shortcuts
const handleKeyDown = (e: React.KeyboardEvent) => {
// Enter inserts newline by default. Send on Ctrl+Enter or Shift+Enter.
const isSendCombo = (e.ctrlKey || e.metaKey || e.shiftKey) && e.key === 'Enter';
const isSendCombo =
(e.ctrlKey || e.metaKey || e.shiftKey) && e.key === 'Enter';
if (isSendCombo) {
e.preventDefault();
if (!isCreatingPost && newPostContent.trim() && newPostTitle.trim()) {
@ -137,30 +122,40 @@ const PostList = () => {
};
const handleVotePost = async (postId: string, isUpvote: boolean) => {
// ✅ Permission checking handled in hook
await votePost(postId, isUpvote);
await vote({ targetId: postId, isUpvote });
};
const getPostVoteType = (postId: string) => {
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 =
window.prompt('Enter a reason for moderation (optional):') || undefined;
if (!cell) return;
// ✅ All validation handled in hook
await moderatePost(cell.id, postId, reason);
await moderate.post(cell.id, postId, reason);
};
const handleUnmoderate = async (postId: string) => {
const reason =
window.prompt('Optional note for unmoderation?') || undefined;
if (!cell) return;
await unmoderatePost(cell.id, postId, reason);
await moderate.unpost(cell.id, postId, reason);
};
const handleModerateUser = async (userAddress: string) => {
@ -168,7 +163,7 @@ const PostList = () => {
window.prompt('Reason for moderating this user? (optional)') || undefined;
if (!cell) return;
// ✅ All validation handled in hook
await moderateUser(cell.id, userAddress, reason);
await moderate.user(cell.id, userAddress, reason);
};
return (
@ -195,13 +190,11 @@ const PostList = () => {
<Button
variant="outline"
size="icon"
onClick={refreshData}
disabled={cellPosts.isLoading}
onClick={refresh}
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>
@ -232,7 +225,9 @@ const PostList = () => {
/>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground mt-1 mb-2">
<span>Press Enter for newline Ctrl+Enter or Shift+Enter to post</span>
<span>
Press Enter for newline Ctrl+Enter or Shift+Enter to post
</span>
<span />
</div>
<div className="flex justify-end">
@ -251,11 +246,10 @@ const PostList = () => {
</div>
)}
{!canPost && !currentUser && (
<div className="section-spacing content-card-sm text-center">
<p className="text-sm mb-3">
Connect wallet and verify Ordinal ownership to post
Connect your wallet to post
</p>
<Button asChild size="sm">
<Link to="/">Connect Wallet</Link>
@ -271,11 +265,11 @@ const PostList = () => {
<p className="empty-state-description">
{canPost
? 'Be the first to post in this cell!'
: 'Connect your wallet and verify Ordinal ownership to start a thread.'}
: 'Connect your wallet to start a thread.'}
</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">
@ -284,7 +278,7 @@ const PostList = () => {
onClick={() => handleVotePost(post.id, true)}
disabled={!canVote || isVoting}
title={
canVote ? 'Upvote' : 'Connect wallet and verify to vote'
canVote ? 'Upvote' : 'Connect your wallet to vote'
}
>
<ArrowUp className="w-4 h-4" />
@ -297,7 +291,7 @@ const PostList = () => {
onClick={() => handleVotePost(post.id, false)}
disabled={!canVote || isVoting}
title={
canVote ? 'Downvote' : 'Connect wallet and verify to vote'
canVote ? 'Downvote' : 'Connect your wallet to vote'
}
>
<ArrowDown className="w-4 h-4" />
@ -329,6 +323,17 @@ const PostList = () => {
<MessageSquare className="inline w-3 h-3 mr-1" />
{commentsByPost[post.id]?.length || 0} comments
</span>
{typeof post.relevanceScore === 'number' && (
<>
<span></span>
<RelevanceIndicator
score={post.relevanceScore}
details={post.relevanceDetails}
type="post"
showTooltip={true}
/>
</>
)}
<ShareButton
url={`${window.location.origin}/post/${post.id}`}
title={post.title}

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
type CypherImageProps = {
src?: string;

View File

@ -1,6 +1,6 @@
import { Button } from '@/components/ui/button';
import { Share2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
import { useToast } from '../ui/use-toast';
interface ShareButtonProps {

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const Accordion = AccordionPrimitive.Root;

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
import { buttonVariants } from '@/components/ui/button-variants';
const AlertDialog = AlertDialogPrimitive.Root;

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',

View File

@ -1,6 +1,7 @@
import { Badge } from '@/components/ui/badge';
import { Shield, Crown, Hash } from 'lucide-react';
import { useUserDisplay } from '@/hooks';
import { useUserDisplay } from '@opchan/react';
import { useEffect } from 'react';
interface AuthorDisplayProps {
address: string;
@ -13,8 +14,11 @@ export function AuthorDisplay({
className = '',
showBadge = true,
}: AuthorDisplayProps) {
const { displayName, callSign, ensName, ordinalDetails } =
useUserDisplay(address);
const { ensName, ordinalDetails, callSign, displayName } = useUserDisplay(address);
useEffect(()=> {
console.log({ensName, ordinalDetails, callSign, displayName, address})
}, [address, ensName, ordinalDetails, callSign, displayName])
// Only show a badge if the author has ENS, Ordinal, or Call Sign
const shouldShowBadge = showBadge && (ensName || ordinalDetails || callSign);

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',

View File

@ -1,6 +1,6 @@
import { Button } from '@/components/ui/button';
import { Bookmark, BookmarkCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
interface BookmarkButtonProps {
isBookmarked: boolean;

View File

@ -8,9 +8,9 @@ import {
Trash2,
ExternalLink,
} from 'lucide-react';
import { Bookmark, BookmarkType } from '@/types/forum';
import { useUserDisplay } from '@/hooks';
import { cn } from '@/lib/utils';
import { Bookmark, BookmarkType } from '@opchan/core';
import { useUserDisplay } from '@opchan/react';
import { cn } from '../../utils'
import { formatDistanceToNow } from 'date-fns';
import { useNavigate } from 'react-router-dom';

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const Breadcrumb = React.forwardRef<
HTMLElement,

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
import { buttonVariants } from './button-variants';
export interface ButtonProps

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker } from 'react-day-picker';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
import { buttonVariants } from '@/components/ui/button-variants';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;

View File

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

View File

@ -1,6 +1,6 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const Card = React.forwardRef<
HTMLDivElement,

View File

@ -4,7 +4,7 @@ import useEmblaCarousel, {
} from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
import { Button } from '@/components/ui/button';
type CarouselApi = UseEmblaCarouselType[1];

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import * as RechartsPrimitive from 'recharts';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const;

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,

View File

@ -3,7 +3,7 @@ import { type DialogProps } from '@radix-ui/react-dialog';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
import { Dialog, DialogContent } from '@/components/ui/dialog';
const Command = React.forwardRef<

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const ContextMenu = ContextMenuPrimitive.Root;

View File

@ -1,9 +1,8 @@
import React, { useState, useEffect } from 'react';
import React from 'react';
import { Button } from './button';
import { useAuth, useAuthActions } from '@/hooks';
import { useAuth as useAuthContext } from '@/contexts/useAuth';
import { useAuth } from '@opchan/react';
import { CheckCircle, AlertCircle, Trash2 } from 'lucide-react';
import { DelegationDuration, DelegationFullStatus } from '@/lib/delegation';
import { DelegationDuration } from '@opchan/core';
interface DelegationStepProps {
onComplete: () => void;
@ -18,16 +17,7 @@ export function DelegationStep({
isLoading,
setIsLoading,
}: DelegationStepProps) {
const { currentUser, isAuthenticating } = useAuth();
const { getDelegationStatus } = useAuthContext();
const [delegationInfo, setDelegationInfo] =
useState<DelegationFullStatus | null>(null);
const { delegateKey, clearDelegation } = useAuthActions();
// Load delegation status
useEffect(() => {
getDelegationStatus().then(setDelegationInfo).catch(console.error);
}, [getDelegationStatus]);
const { currentUser, delegationInfo, delegate, clearDelegation } = useAuth();
const [selectedDuration, setSelectedDuration] =
React.useState<DelegationDuration>('7days');
@ -44,11 +34,11 @@ export function DelegationStep({
setDelegationResult(null);
try {
const success = await delegateKey(selectedDuration);
const success = await delegate(selectedDuration);
if (success) {
const expiryDate = currentUser.delegationExpiry
? new Date(currentUser.delegationExpiry).toLocaleString()
const expiryDate = delegationInfo?.expiresAt
? delegationInfo.expiresAt.toLocaleString()
: `${selectedDuration === '7days' ? '1 week' : '30 days'} from now`;
setDelegationResult({
@ -204,7 +194,7 @@ export function DelegationStep({
{/* User Address */}
{currentUser && (
<div className="text-xs text-neutral-400">
<div className="font-mono break-all">{currentUser.address}</div>
<div className="font-mono break-all">{currentUser.displayName}</div>
</div>
)}
@ -213,11 +203,7 @@ export function DelegationStep({
<div className="flex justify-end">
<Button
onClick={async () => {
const ok = await clearDelegation();
if (ok) {
// Refresh status so UI immediately reflects cleared state
getDelegationStatus().then(setDelegationInfo).catch(console.error);
}
await clearDelegation();
}}
variant="outline"
size="sm"
@ -245,7 +231,7 @@ export function DelegationStep({
<Button
onClick={handleDelegate}
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
disabled={isLoading || isAuthenticating}
disabled={isLoading}
>
{isLoading ? 'Delegating...' : 'Delegate Key'}
</Button>

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const Dialog = DialogPrimitive.Root;

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import { Drawer as DrawerPrimitive } from 'vaul';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const Drawer = ({
shouldScaleBackground = true,

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const DropdownMenu = DropdownMenuPrimitive.Root;

View File

@ -10,7 +10,7 @@ import {
useFormContext,
} from 'react-hook-form';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
import { Label } from '@/components/ui/label';
const Form = FormProvider;

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const HoverCard = HoverCardPrimitive.Root;

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import { OTPInput, OTPInputContext } from 'input-otp';
import { Dot } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,

View File

@ -1,6 +1,6 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import * as MenubarPrimitive from '@radix-ui/react-menubar';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const MenubarMenu = MenubarPrimitive.Menu;

View File

@ -1,14 +1,15 @@
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Eye, EyeOff } from 'lucide-react';
import { useModeration } from '@/contexts/ModerationContext';
import { usePermissions } from '@/hooks/core/usePermissions';
import { useForumData } from '@/hooks/core/useForumData';
import 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));

View File

@ -3,7 +3,7 @@ import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
import { cva } from 'class-variance-authority';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
import { ButtonProps } from '@/components/ui/button';
import { buttonVariants } from '@/components/ui/button-variants';

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const Popover = PopoverPrimitive.Root;

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,

View File

@ -23,7 +23,7 @@ import {
MessageSquare,
ThumbsUp,
} from 'lucide-react';
import { RelevanceScoreDetails } from '@/types/forum';
import { RelevanceScoreDetails } from '@opchan/core';
interface RelevanceIndicatorProps {
score: number;

View File

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

View File

@ -1,7 +1,7 @@
import { GripVertical } from 'lucide-react';
import * as ResizablePrimitive from 'react-resizable-panels';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const ResizablePanelGroup = ({
className,

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const Select = SelectPrimitive.Root;

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,

View File

@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const Sheet = SheetPrimitive.Root;

View File

@ -4,7 +4,7 @@ import { VariantProps, cva } from 'class-variance-authority';
import { PanelLeft } from 'lucide-react';
import { useIsMobile } from '@/hooks/use-mobile';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';

View File

@ -1,4 +1,4 @@
import { cn } from '@/lib/utils';
import { cn } from '../../utils';
function Skeleton({
className,

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '@/lib/utils';
import { cn } from '../../utils';
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,

View File

@ -1,6 +1,6 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const Table = React.forwardRef<
HTMLTableElement,

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const Tabs = TabsPrimitive.Root;

View File

@ -1,6 +1,6 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const Textarea = React.forwardRef<
HTMLTextAreaElement,

View File

@ -3,7 +3,7 @@ import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const ToastProvider = ToastPrimitives.Provider;

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
import { type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
import { toggleVariants } from '@/components/ui/toggle-variants';
const ToggleGroupContext = React.createContext<

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import * as TogglePrimitive from '@radix-ui/react-toggle';
import { type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
import { toggleVariants } from './toggle-variants';
const Toggle = React.forwardRef<

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
import { cn } from '../../utils'
const TooltipProvider = TooltipPrimitive.Provider;

View File

@ -8,10 +8,9 @@ import {
Loader2,
AlertCircle,
} from 'lucide-react';
import { useAuth, useAuthActions } from '@/hooks';
import { EVerificationStatus } from '@/types/identity';
import { useAppKitAccount } from '@reown/appkit/react';
import { OrdinalDetails, EnsDetails } from '@/types/identity';
import { useAuth } from '@/hooks';
import { EVerificationStatus } from '@opchan/core';
import { OrdinalDetails, EnsDetails } from '@opchan/core';
interface VerificationStepProps {
onComplete: () => void;
@ -26,20 +25,7 @@ export function VerificationStep({
isLoading,
setIsLoading,
}: VerificationStepProps) {
const { currentUser, verificationStatus, isAuthenticating } = useAuth();
const { verifyWallet } = useAuthActions();
// Get account info to determine wallet type
const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' });
const ethereumAccount = useAppKitAccount({ namespace: 'eip155' });
const isBitcoinConnected = bitcoinAccount.isConnected;
const isEthereumConnected = ethereumAccount.isConnected;
const walletType = isBitcoinConnected
? 'bitcoin'
: isEthereumConnected
? 'ethereum'
: undefined;
const { currentUser, verifyOwnership } = useAuth();
const [verificationResult, setVerificationResult] = React.useState<{
success: boolean;
@ -53,24 +39,17 @@ export function VerificationStep({
verificationResult?.success &&
verificationResult.message.includes('Checking ownership')
) {
// Check if actual ownership was verified
// Treat centralized verification status as source of truth
const isOwnerVerified =
verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED;
const hasOwnership =
walletType === 'bitcoin'
? isOwnerVerified && !!currentUser?.ordinalDetails
: isOwnerVerified && !!currentUser?.ensDetails;
const hasOwnership = currentUser?.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED;
if (hasOwnership) {
setVerificationResult({
success: true,
message:
walletType === 'bitcoin'
currentUser?.walletType === 'bitcoin'
? 'Ordinal ownership verified successfully!'
: 'ENS ownership verified successfully!',
details:
walletType === 'bitcoin'
currentUser?.walletType === 'bitcoin'
? currentUser?.ordinalDetails
: currentUser?.ensDetails,
});
@ -78,49 +57,58 @@ export function VerificationStep({
setVerificationResult({
success: false,
message:
walletType === 'bitcoin'
currentUser?.walletType === 'bitcoin'
? 'No Ordinal ownership found. You can still participate in the forum with your connected wallet!'
: 'No ENS ownership found. You can still participate in the forum with your connected wallet!',
});
}
}
}, [currentUser, verificationResult, walletType, verificationStatus]);
}, [currentUser, verificationResult]);
const handleVerify = async () => {
if (!currentUser) return;
console.log('🔘 Verify button clicked, currentUser:', currentUser);
if (!currentUser) {
console.log('❌ No currentUser in handleVerify');
return;
}
console.log('🔄 Setting loading state and calling verifyWallet...');
setIsLoading(true);
setVerificationResult(null);
try {
const success = await verifyWallet();
if (success) {
console.log('📞 Calling verifyWallet()...');
await verifyOwnership();
if (currentUser?.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED) {
// For now, just show success - the actual ownership check will be done
// by the useEffect when the user state updates
console.log('✅ Verification successful, setting result');
setVerificationResult({
success: true,
message:
walletType === 'bitcoin'
currentUser?.walletType === 'bitcoin'
? 'Verification process completed. Checking ownership...'
: 'Verification process completed. Checking ownership...',
details: undefined,
});
} else {
console.log('❌ Verification failed, setting failure result');
setVerificationResult({
success: false,
message:
walletType === 'bitcoin'
currentUser?.walletType === 'bitcoin'
? 'No Ordinal ownership found. You can still participate in the forum with your connected wallet!'
: 'No ENS ownership found. You can still participate in the forum with your connected wallet!',
});
}
} catch (error) {
console.error('💥 Error in handleVerify:', error);
setVerificationResult({
success: false,
message: `Verification failed. Please try again: ${error}`,
});
} finally {
console.log('🔄 Setting loading to false');
setIsLoading(false);
}
};
@ -130,19 +118,19 @@ export function VerificationStep({
};
const getVerificationType = () => {
return walletType === 'bitcoin' ? 'Bitcoin Ordinal' : 'Ethereum ENS';
return currentUser?.walletType === 'bitcoin' ? 'Bitcoin Ordinal' : 'Ethereum ENS';
};
const getVerificationIcon = () => {
return walletType === 'bitcoin' ? Bitcoin : Coins;
return currentUser?.walletType === 'bitcoin' ? Bitcoin : Coins;
};
const getVerificationColor = () => {
return walletType === 'bitcoin' ? 'text-orange-500' : 'text-blue-500';
return currentUser?.walletType === 'bitcoin' ? 'text-orange-500' : 'text-blue-500';
};
const getVerificationDescription = () => {
if (walletType === 'bitcoin') {
if (currentUser?.walletType === 'bitcoin') {
return "Verify your Bitcoin Ordinal ownership to unlock premium features. If you don't own any Ordinals, you can still participate in the forum with your connected wallet.";
} else {
return "Verify your Ethereum ENS ownership to unlock premium features. If you don't own any ENS, you can still participate in the forum with your connected wallet.";
@ -184,7 +172,7 @@ export function VerificationStep({
</p>
{verificationResult.details && (
<div className="text-xs text-neutral-400">
{walletType === 'bitcoin' ? (
{currentUser?.walletType === 'bitcoin' ? (
<p>
Ordinal ID:{' '}
{typeof verificationResult.details === 'object' &&
@ -221,7 +209,7 @@ export function VerificationStep({
}
// Show verification status
if (verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED) {
if (currentUser?.verificationStatus === EVerificationStatus.ENS_ORDINAL_VERIFIED) {
return (
<div className="flex flex-col h-full">
<div className="flex-1 space-y-4">
@ -237,8 +225,8 @@ export function VerificationStep({
</p>
{currentUser && (
<div className="text-xs text-neutral-400">
{walletType === 'bitcoin' && <p>Ordinal ID: Verified</p>}
{walletType === 'ethereum' && <p>ENS Name: Verified</p>}
{currentUser?.walletType === 'bitcoin' && <p>Ordinal ID: Verified</p>}
{currentUser?.walletType === 'ethereum' && <p>ENS Name: Verified</p>}
</div>
)}
</div>
@ -284,7 +272,7 @@ export function VerificationStep({
</span>
</div>
<ul className="text-xs text-neutral-400 space-y-1">
{walletType === 'bitcoin' ? (
{currentUser?.walletType === 'bitcoin' ? (
<>
<li> We'll check your wallet for Bitcoin Ordinal ownership</li>
<li> If found, you'll get full posting and voting access</li>
@ -309,10 +297,10 @@ export function VerificationStep({
<div className="mt-auto space-y-3">
<Button
onClick={handleVerify}
disabled={isLoading || isAuthenticating}
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
{isLoading || isAuthenticating ? (
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
@ -326,7 +314,7 @@ export function VerificationStep({
onClick={onBack}
variant="outline"
className="w-full border-neutral-600 text-neutral-400 hover:bg-neutral-800"
disabled={isLoading || isAuthenticating}
disabled={isLoading}
>
Back
</Button>

View File

@ -0,0 +1,78 @@
import { Wifi, WifiOff, CheckCircle } from 'lucide-react';
import { useNetwork } from '@opchan/react';
import { cn } from '../../utils'
interface WakuHealthIndicatorProps {
className?: string;
showText?: boolean;
size?: 'sm' | 'md' | 'lg';
}
export function WakuHealthIndicator({
className,
showText = true,
size = 'md',
}: WakuHealthIndicatorProps) {
const {isConnected, statusMessage} = useNetwork();
const getIcon = () => {
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" />;
}
};
const getSizeClasses = () => {
switch (size) {
case 'sm':
return 'w-4 h-4';
case 'lg':
return 'w-6 h-6';
default:
return 'w-5 h-5';
}
};
return (
<div className={cn('flex items-center gap-2', className)}>
<div className={getSizeClasses()}>{getIcon()}</div>
{showText && (
<span
className={cn(
'text-sm font-medium',
isConnected === true && 'text-green-400',
isConnected === false && 'text-red-400',
isConnected === null && 'text-gray-400'
)}
>
{statusMessage}
</span>
)}
</div>
);
}
/**
* Simple dot indicator for Waku health status
* Useful for compact displays like headers or status bars
*/
export function WakuHealthDot({ className }: { className?: string }) {
const { isConnected } = useNetwork();
const statusColor = isConnected === true ? 'green' : isConnected === false ? 'red' : 'gray';
return (
<div
className={cn(
'w-2 h-2 rounded-full',
isConnected === true && 'bg-green-500',
isConnected === false && 'bg-red-500',
isConnected === null && 'bg-gray-500',
className
)}
title={`Waku network: ${statusColor === 'green' ? 'Connected' : statusColor === 'red' ? 'Disconnected' : 'Loading'}`}
/>
);
}

View File

@ -194,7 +194,7 @@ export function WalletConnectionDialog({
</p>
<p className="text-sm text-neutral-300 mb-2">Address:</p>
<p className="text-xs font-mono text-neutral-400 break-all">
{activeAddress}
{activeAddress ? `${activeAddress.slice(0, 6)}...${activeAddress.slice(-4)}` : ''}
</p>
</div>

View File

@ -9,8 +9,7 @@ import {
import { Button } from '@/components/ui/button';
import { CheckCircle, Circle, Loader2 } from 'lucide-react';
import { useAuth } from '@/hooks';
import { useDelegation } from '@/hooks/useDelegation';
import { EVerificationStatus } from '@/types/identity';
import { EVerificationStatus } from '@opchan/core';
import { WalletConnectionStep } from './wallet-connection-step';
import { VerificationStep } from './verification-step';
import { DelegationStep } from './delegation-step';
@ -30,8 +29,8 @@ export function WalletWizard({
}: WalletWizardProps) {
const [currentStep, setCurrentStep] = React.useState<WizardStep>(1);
const [isLoading, setIsLoading] = React.useState(false);
const { isAuthenticated, verificationStatus } = useAuth();
const { delegationStatus } = useDelegation();
const [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(() => {
@ -41,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);
@ -65,7 +75,7 @@ export function WalletWizard({
case 2:
return verificationStatus !== EVerificationStatus.WALLET_UNCONNECTED;
case 3:
return delegationStatus.isValid;
return delegationStatus;
default:
return false;
}

11
app/src/hooks/index.ts Normal file
View File

@ -0,0 +1,11 @@
export {
useAuth ,
useForum ,
useNetwork,
usePermissions,
useContent,
useUIState,
useUserDisplay,
} from '@opchan/react';

Some files were not shown because too many files have changed in this diff Show More