mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-03 05:13:09 +00:00
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:
parent
c91164dbde
commit
cca6299eb5
72
.gitignore
vendored
72
.gitignore
vendored
@ -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
172
README.md
@ -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
47
app/.gitignore
vendored
Normal 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
138
app/README.md
Normal 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.
|
||||
@ -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
110
app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
39
app/src/App.tsx
Normal file
39
app/src/App.tsx
Normal 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;
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
@ -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);
|
||||
@ -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>
|
||||
)}
|
||||
@ -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',
|
||||
})}
|
||||
@ -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}
|
||||
@ -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}
|
||||
@ -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}
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '../../utils'
|
||||
|
||||
type CypherImageProps = {
|
||||
src?: string;
|
||||
@ -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 {
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -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',
|
||||
@ -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);
|
||||
@ -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>,
|
||||
@ -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',
|
||||
@ -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;
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
@ -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
|
||||
@ -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>;
|
||||
@ -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();
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '../../utils'
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@ -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];
|
||||
@ -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;
|
||||
@ -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>,
|
||||
@ -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<
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
|
||||
@ -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>,
|
||||
@ -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) => {
|
||||
@ -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'
|
||||
@ -62,7 +62,10 @@ export const MarkdownInput: React.FC<MarkdownInputProps> = ({
|
||||
|
||||
<TabsContent value="preview">
|
||||
<div className="p-3 border rounded-sm bg-card">
|
||||
<MarkdownRenderer content={value} className="prose prose-invert max-w-none" />
|
||||
<MarkdownRenderer
|
||||
content={value}
|
||||
className="prose prose-invert max-w-none"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
@ -71,5 +74,3 @@ export const MarkdownInput: React.FC<MarkdownInputProps> = ({
|
||||
};
|
||||
|
||||
export default MarkdownInput;
|
||||
|
||||
|
||||
@ -11,9 +11,12 @@ interface MarkdownRendererProps {
|
||||
/**
|
||||
* Renders sanitized Markdown with GFM support.
|
||||
*/
|
||||
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className }) => {
|
||||
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
content,
|
||||
className,
|
||||
}) => {
|
||||
// Extend sanitize schema to allow common markdown elements (headings, lists, code, tables, etc.)
|
||||
const schema: any = {
|
||||
const schema: typeof defaultSchema = {
|
||||
...defaultSchema,
|
||||
tagNames: [
|
||||
...(defaultSchema.tagNames || []),
|
||||
@ -57,15 +60,15 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, cla
|
||||
['alt'],
|
||||
['title'],
|
||||
],
|
||||
code: [
|
||||
...(defaultSchema.attributes?.code || []),
|
||||
['className'],
|
||||
],
|
||||
code: [...(defaultSchema.attributes?.code || []), ['className']],
|
||||
},
|
||||
};
|
||||
return (
|
||||
<div className={className}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[[rehypeSanitize, schema]]}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[[rehypeSanitize, schema]]}
|
||||
>
|
||||
{content || ''}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
@ -73,5 +76,3 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, cla
|
||||
};
|
||||
|
||||
export default MarkdownRenderer;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
@ -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>,
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>,
|
||||
@ -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>,
|
||||
@ -23,7 +23,7 @@ import {
|
||||
MessageSquare,
|
||||
ThumbsUp,
|
||||
} from 'lucide-react';
|
||||
import { RelevanceScoreDetails } from '@/types/forum';
|
||||
import { RelevanceScoreDetails } from '@opchan/core';
|
||||
|
||||
interface RelevanceIndicatorProps {
|
||||
score: number;
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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,
|
||||
@ -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>,
|
||||
@ -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;
|
||||
|
||||
@ -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>,
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
@ -1,4 +1,4 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '../../utils';
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
@ -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>,
|
||||
@ -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>,
|
||||
@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '../../utils'
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
@ -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;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '../../utils'
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
@ -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;
|
||||
|
||||
@ -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<
|
||||
@ -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<
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
78
app/src/components/ui/waku-health-indicator.tsx
Normal file
78
app/src/components/ui/waku-health-indicator.tsx
Normal 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'}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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
11
app/src/hooks/index.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user