OpChan/packages/core/docs/sample-app.md
2025-12-12 15:08:52 -05:00

835 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# @opchan/core Sample Application
Complete, production-ready forum application demonstrating all features of the OpChan Core SDK.
---
## Complete Forum Application
A full-featured, production-ready decentralized forum demonstrating all features of the OpChan Core SDK including authentication, content management, moderation, identity resolution, bookmarks, and real-time updates.
### Features Demonstrated
-**Client Initialization** - Setup and configuration
-**Anonymous & Wallet Authentication** - Dual authentication modes
-**Session Persistence** - Restore sessions across page loads
-**Content Creation** - Posts, comments, and cells
-**Voting System** - Upvote/downvote functionality
-**Identity Resolution** - ENS lookup and call signs
-**Real-Time Updates** - Live message synchronization
-**Relevance Scoring** - Content ranking algorithm
-**Moderation** - Cell owner moderation tools
-**Bookmarks** - Save and manage favorite content
-**Network Monitoring** - Connection health tracking
-**Pending States** - Optimistic UI with sync indicators
### Application Structure
```typescript
import {
OpChanClient,
EVerificationStatus,
EDisplayPreference,
BookmarkService,
transformPost,
type User,
type Post,
type Comment,
type Cell
} from '@opchan/core';
import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';
import { signMessage } from 'viem/accounts';
class CompleteForum {
// Core components
private client: OpChanClient;
private bookmarkService: BookmarkService;
private currentUser: User | null = null;
// Event handlers
private unsubscribers: (() => void)[] = [];
constructor() {
this.client = new OpChanClient({
wakuConfig: {
contentTopic: '/opchan/1/messages/proto',
reliableChannelId: 'opchan-messages'
},
reownProjectId: process.env.REOWN_PROJECT_ID
});
this.bookmarkService = new BookmarkService();
}
// ============================================================================
// INITIALIZATION
// ============================================================================
async initialize() {
console.log('🚀 Initializing OpChan Forum...\n');
// 1. Open database (hydrates from IndexedDB)
await this.client.database.open();
console.log('✅ Database opened');
// 2. Set up ENS resolution
const publicClient = createPublicClient({
chain: mainnet,
transport: http()
});
this.client.userIdentityService.setPublicClient(publicClient);
console.log('✅ ENS resolution configured');
// 3. Set up event listeners
this.setupListeners();
console.log('✅ Event listeners configured');
// 4. Restore or create session
await this.restoreSession();
// 5. Initial render
await this.render();
console.log('\n✅ Forum initialized successfully!\n');
}
private setupListeners() {
// Message listener - handles all incoming messages
const msgUnsub = this.client.messageManager.onMessageReceived(
async (message) => {
const wasNew = await this.client.database.applyMessage(message);
if (wasNew) {
console.log(`📨 New ${message.type} received`);
await this.render();
}
}
);
this.unsubscribers.push(msgUnsub);
// Network health listener
const healthUnsub = this.client.messageManager.onHealthChange(
(isHealthy) => {
console.log(isHealthy ? '🟢 Network: Connected' : '🔴 Network: Offline');
}
);
this.unsubscribers.push(healthUnsub);
// Identity updates listener
const identityUnsub = this.client.userIdentityService.subscribe(
(address, identity) => {
if (identity) {
console.log(`👤 Identity updated: ${identity.displayName}`);
}
}
);
this.unsubscribers.push(identityUnsub);
// Pending state listener
const pendingUnsub = this.client.database.onPendingChange(() => {
this.updatePendingIndicators();
});
this.unsubscribers.push(pendingUnsub);
}
// ============================================================================
// AUTHENTICATION
// ============================================================================
private async restoreSession() {
// Try to load stored user
const storedUser = await this.client.database.loadUser();
if (storedUser) {
// Validate delegation
const status = await this.client.delegation.getStatus(storedUser.address);
if (status.isValid) {
this.currentUser = storedUser;
console.log(`👤 Restored session: ${storedUser.displayName}`);
} else {
console.log('⚠️ Delegation expired, starting new session');
await this.startAnonymousSession();
}
} else {
await this.startAnonymousSession();
}
}
async startAnonymousSession() {
const sessionId = await this.client.delegation.delegateAnonymous('7days');
this.currentUser = {
address: sessionId,
displayName: 'Anonymous',
displayPreference: EDisplayPreference.WALLET_ADDRESS,
verificationStatus: EVerificationStatus.ANONYMOUS
};
await this.client.database.storeUser(this.currentUser);
console.log('👤 Started anonymous session');
}
async connectWallet(walletAddress: `0x${string}`) {
console.log('\n🔐 Connecting wallet...');
// Create delegation
const success = await this.client.delegation.delegate(
walletAddress,
'7days',
async (message) => {
// Sign with wallet (this would use your wallet provider)
console.log('📝 Please sign the authorization message...');
return await signMessage({ message, account: walletAddress });
}
);
if (!success) {
console.error('❌ Failed to create delegation');
return;
}
// Get identity
const identity = await this.client.userIdentityService.getIdentity(
walletAddress,
{ fresh: true }
);
if (!identity) {
console.error('❌ Failed to resolve identity');
return;
}
this.currentUser = {
address: walletAddress,
displayName: identity.displayName,
displayPreference: identity.displayPreference,
verificationStatus: identity.verificationStatus,
ensName: identity.ensName,
ensAvatar: identity.ensAvatar,
callSign: identity.callSign
};
await this.client.database.storeUser(this.currentUser);
console.log(`✅ Wallet connected: ${identity.displayName}`);
console.log(` Verification: ${identity.verificationStatus}\n`);
}
async disconnect() {
await this.client.delegation.clear();
await this.client.database.clearUser();
this.currentUser = null;
console.log('👋 Disconnected');
}
async setCallSign(callSign: string) {
if (!this.currentUser) return;
const result = await this.client.userIdentityService.updateProfile(
this.currentUser.address,
{
callSign,
displayPreference: EDisplayPreference.CALL_SIGN
}
);
if (result.ok) {
this.currentUser.callSign = callSign;
this.currentUser.displayName = callSign;
this.currentUser.displayPreference = EDisplayPreference.CALL_SIGN;
await this.client.database.storeUser(this.currentUser);
console.log(`✅ Call sign set: ${callSign}`);
} else {
console.error('❌ Failed to set call sign:', result.error);
}
}
// ============================================================================
// CONTENT CREATION
// ============================================================================
async createCell(name: string, description: string, icon?: string) {
if (!this.currentUser) {
console.error('❌ Not authenticated');
return null;
}
const result = await this.client.forumActions.createCell(
{
name,
description,
icon,
currentUser: this.currentUser,
isAuthenticated: true
},
() => this.render()
);
if (result.success) {
console.log(`✅ Cell created: ${name}`);
this.client.database.markPending(result.data!.id);
return result.data;
} else {
console.error(`❌ Failed to create cell: ${result.error}`);
return null;
}
}
async createPost(cellId: string, title: string, content: string) {
if (!this.currentUser) {
console.error('❌ Not authenticated');
return null;
}
const result = await this.client.forumActions.createPost(
{
cellId,
title,
content,
currentUser: this.currentUser,
isAuthenticated: true
},
() => this.render()
);
if (result.success) {
console.log(`✅ Post created: ${title}`);
this.client.database.markPending(result.data!.id);
return result.data;
} else {
console.error(`❌ Failed to create post: ${result.error}`);
return null;
}
}
async createComment(postId: string, content: string) {
if (!this.currentUser) {
console.error('❌ Not authenticated');
return null;
}
const result = await this.client.forumActions.createComment(
{
postId,
content,
currentUser: this.currentUser,
isAuthenticated: true
},
() => this.render()
);
if (result.success) {
console.log('✅ Comment created');
this.client.database.markPending(result.data!.id);
return result.data;
} else {
console.error(`❌ Failed to create comment: ${result.error}`);
return null;
}
}
// ============================================================================
// VOTING
// ============================================================================
async vote(targetId: string, isUpvote: boolean) {
if (!this.currentUser) {
console.error('❌ Not authenticated');
return;
}
const result = await this.client.forumActions.vote(
{
targetId,
isUpvote,
currentUser: this.currentUser,
isAuthenticated: true
},
() => this.render()
);
if (result.success) {
console.log(`✅ ${isUpvote ? 'Upvoted' : 'Downvoted'}`);
} else {
console.error(`❌ Failed to vote: ${result.error}`);
}
}
// ============================================================================
// MODERATION
// ============================================================================
canModerate(cellId: string): boolean {
if (!this.currentUser) return false;
const cell = this.client.database.cache.cells[cellId];
return cell?.author === this.currentUser.address;
}
async moderatePost(cellId: string, postId: string, reason: string) {
if (!this.currentUser || !this.canModerate(cellId)) {
console.error('❌ Not authorized to moderate');
return;
}
const cell = this.client.database.cache.cells[cellId];
const result = await this.client.forumActions.moderatePost(
{
cellId,
postId,
reason,
currentUser: this.currentUser,
isAuthenticated: true,
cellOwner: cell.author
},
() => this.render()
);
if (result.success) {
console.log('✅ Post moderated');
} else {
console.error(`❌ Failed to moderate: ${result.error}`);
}
}
async unmoderatePost(cellId: string, postId: string) {
if (!this.currentUser || !this.canModerate(cellId)) {
console.error('❌ Not authorized');
return;
}
const cell = this.client.database.cache.cells[cellId];
const result = await this.client.forumActions.unmoderatePost(
{
cellId,
postId,
currentUser: this.currentUser,
isAuthenticated: true,
cellOwner: cell.author
},
() => this.render()
);
if (result.success) {
console.log('✅ Post unmoderated');
} else {
console.error(`❌ Failed to unmoderate: ${result.error}`);
}
}
// ============================================================================
// BOOKMARKS
// ============================================================================
async bookmarkPost(postId: string) {
if (!this.currentUser) return;
const post = this.client.database.cache.posts[postId];
if (!post) {
console.error('❌ Post not found');
return;
}
await this.bookmarkService.addPostBookmark(
post,
this.currentUser.address,
post.cellId
);
console.log(`✅ Bookmarked: ${post.title}`);
}
async removeBookmark(bookmarkId: string) {
await this.bookmarkService.removeBookmark(bookmarkId);
console.log('✅ Bookmark removed');
}
isBookmarked(type: 'post' | 'comment', targetId: string): boolean {
if (!this.currentUser) return false;
return this.client.database.isBookmarked(
this.currentUser.address,
type,
targetId
);
}
// ============================================================================
// DATA ACCESS
// ============================================================================
async getSortedPosts(cellId?: string): Promise<Post[]> {
let posts = Object.values(this.client.database.cache.posts);
if (cellId) {
posts = posts.filter(p => p.cellId === cellId);
}
// Transform and score posts
const transformedPosts = await Promise.all(
posts.map(async p => {
const transformed = await transformPost(p);
if (!transformed) return null;
// Calculate relevance
const votes = Object.values(this.client.database.cache.votes)
.filter(v => v.targetId === p.id);
const comments = Object.values(this.client.database.cache.comments)
.filter(c => c.postId === p.id);
const userVerificationStatus = {};
for (const [addr, identity] of Object.entries(
this.client.database.cache.userIdentities
)) {
userVerificationStatus[addr] = {
isVerified: identity.verificationStatus === 'ens-verified',
hasENS: !!identity.ensName,
ensName: identity.ensName
};
}
const scoreDetails = this.client.relevance.calculatePostScore(
p,
votes,
comments,
userVerificationStatus,
this.client.database.cache.moderations
);
transformed.relevanceScore = scoreDetails.finalScore;
transformed.relevanceDetails = scoreDetails;
return transformed;
})
);
return transformedPosts
.filter((p): p is Post => p !== null)
.sort((a, b) => (b.relevanceScore || 0) - (a.relevanceScore || 0));
}
getCells(): Cell[] {
return Object.values(this.client.database.cache.cells);
}
getComments(postId: string): Comment[] {
return Object.values(this.client.database.cache.comments)
.filter(c => c.postId === postId)
.sort((a, b) => a.timestamp - b.timestamp);
}
async getMyBookmarks() {
if (!this.currentUser) return [];
return await this.client.database.getUserBookmarks(this.currentUser.address);
}
// ============================================================================
// UI RENDERING
// ============================================================================
private async render() {
console.clear();
console.log('═══════════════════════════════════════════════════════════');
console.log(' OPCHAN FORUM ');
console.log('═══════════════════════════════════════════════════════════\n');
// Network status
const isHealthy = this.client.messageManager.isReady;
console.log(`Network: ${isHealthy ? '🟢 Connected' : '🔴 Offline'}`);
// User status
if (this.currentUser) {
console.log(`User: ${this.currentUser.displayName} (${this.currentUser.verificationStatus})`);
} else {
console.log('User: Not authenticated');
}
console.log('\n───────────────────────────────────────────────────────────\n');
// Cells
const cells = this.getCells();
console.log(`📁 CELLS (${cells.length})\n`);
cells.forEach(cell => {
const posts = Object.values(this.client.database.cache.posts)
.filter(p => p.cellId === cell.id);
console.log(` ${cell.icon || '📁'} ${cell.name} (${posts.length} posts)`);
console.log(` ${cell.description}`);
if (this.canModerate(cell.id)) {
console.log(` 👮 You can moderate this cell`);
}
console.log();
});
// Posts (top 10 by relevance)
const posts = await this.getSortedPosts();
console.log(`\n📝 TOP POSTS (${posts.length} total)\n`);
posts.slice(0, 10).forEach((post, index) => {
const isPending = this.client.database.isPending(post.id);
const score = post.relevanceScore?.toFixed(0) || '0';
const upvotes = post.upvotes?.length || 0;
const downvotes = post.downvotes?.length || 0;
const comments = this.getComments(post.id).length;
const isBookmarked = this.isBookmarked('post', post.id);
console.log(`${index + 1}. [Score: ${score}] ${post.title}${isPending ? ' ⏳' : ''}`);
console.log(` by ${post.author.slice(0, 8)}... | Cell: ${post.cellId}`);
console.log(` ⬆️ ${upvotes} ⬇️ ${downvotes} 💬 ${comments}${isBookmarked ? ' 🔖' : ''}`);
if (post.relevanceDetails) {
const details = post.relevanceDetails;
console.log(` 📊 Base: ${details.baseScore} + Engagement: ${details.engagementScore.toFixed(0)} × Decay: ${(details.timeDecayMultiplier * 100).toFixed(0)}%`);
}
console.log(` ${post.content.slice(0, 80)}${post.content.length > 80 ? '...' : ''}`);
console.log();
});
// Bookmarks
if (this.currentUser) {
const bookmarks = await this.getMyBookmarks();
if (bookmarks.length > 0) {
console.log(`\n🔖 MY BOOKMARKS (${bookmarks.length})\n`);
bookmarks.slice(0, 5).forEach(b => {
console.log(` ${b.title || b.id}`);
});
console.log();
}
}
console.log('═══════════════════════════════════════════════════════════\n');
}
private updatePendingIndicators() {
// In a real UI, this would update visual indicators
// For console, we just re-render
this.render();
}
// ============================================================================
// CLEANUP
// ============================================================================
cleanup() {
this.unsubscribers.forEach(unsub => unsub());
console.log('👋 Forum cleaned up');
}
}
// ============================================================================
// USAGE EXAMPLE
// ============================================================================
async function main() {
const forum = new CompleteForum();
try {
// Initialize
await forum.initialize();
// Wait a moment for content to load
await new Promise(resolve => setTimeout(resolve, 2000));
// Example: Set a call sign for anonymous user
await forum.setCallSign('alice');
// Example: Create a post
await forum.createPost(
'general',
'Hello OpChan!',
'This is a comprehensive example of the OpChan forum application.'
);
// Example: Vote on a post (would need actual post ID)
// await forum.vote('post-id', true);
// Example: Bookmark a post
// await forum.bookmarkPost('post-id');
// Keep running and listening for updates
console.log('\n✅ Forum is running. Press Ctrl+C to exit.\n');
// In a real application, this would be kept alive by your UI framework
// For this example, we'll just wait
await new Promise(() => {}); // Wait forever
} catch (error) {
console.error('❌ Error:', error);
} finally {
forum.cleanup();
}
}
// Run the application
main();
```
---
## Key Features Explained
### 1. **Robust Initialization**
The application properly initializes all components in order:
- Opens database and hydrates from IndexedDB
- Configures ENS resolution
- Sets up event listeners
- Restores or creates user session
### 2. **Dual Authentication**
Supports both anonymous and wallet-connected users:
- Anonymous users get a UUID session ID
- Wallet users connect with ENS verification
- Sessions persist across page reloads
- Delegation expiry is checked on restoration
### 3. **Real-Time Synchronization**
All events are properly wired:
- Incoming messages trigger UI updates
- Network health changes are logged
- Identity updates propagate automatically
- Pending states tracked for optimistic UI
### 4. **Complete Content Management**
Full CRUD operations:
- Create cells (ENS-verified users only)
- Create posts and comments (all authenticated users)
- Vote on content
- Moderation tools for cell owners
### 5. **Advanced Features**
Production-ready functionality:
- Relevance scoring with detailed breakdown
- Bookmark management
- Identity resolution with ENS
- Call sign support
- Pending state indicators
### 6. **Proper Error Handling**
All operations include error handling:
- Permission checks before actions
- Validation of delegation status
- User-friendly error messages
- Graceful degradation
### 7. **Clean Architecture**
Well-organized code structure:
- Logical section separation
- Event-driven design
- Proper cleanup on exit
- Reusable methods
---
## Running the Application
### Prerequisites
```bash
npm install @opchan/core viem
```
### Environment Variables
```bash
export REOWN_PROJECT_ID="your-project-id"
```
### Run
```typescript
// Save as forum.ts
// Run with: npx tsx forum.ts
```
### Integration with UI
This application can be easily adapted for:
- **React**: Convert methods to hooks, use state management
- **Vue**: Use reactive refs and computed properties
- **Svelte**: Use stores and reactive statements
- **Web Components**: Create custom elements
- **CLI**: Interactive command-line interface
---
## Extending the Application
### Add Wallet Support
```typescript
import { useWalletClient } from 'wagmi';
async connectWithWagmi() {
const { data: walletClient } = useWalletClient();
if (!walletClient) return;
await this.connectWallet(walletClient.account.address);
}
```
### Add Search Functionality
```typescript
searchPosts(query: string): Post[] {
const posts = Object.values(this.client.database.cache.posts);
return posts.filter(p =>
p.title.toLowerCase().includes(query.toLowerCase()) ||
p.content.toLowerCase().includes(query.toLowerCase())
);
}
```
### Add Notifications
```typescript
private setupListeners() {
this.client.messageManager.onMessageReceived(async (message) => {
const wasNew = await this.client.database.applyMessage(message);
if (wasNew && message.type === 'comment') {
this.notify(`New comment on your post!`);
}
});
}
```
---
## Production Considerations
### Performance
- Cache is in-memory for fast reads
- IndexedDB persistence for reliability
- Lazy loading of identities
- Debounced identity lookups
### Security
- All messages cryptographically signed
- Delegation proofs verified
- Permission checks enforced
- Wallet signatures required for delegation
### Scalability
- Event-driven architecture
- Efficient data structures
- Minimal re-renders
- Optimistic UI updates
### Reliability
- Session persistence
- Delegation expiry handling
- Network disconnect handling
- Error recovery
---
**This is a production-ready template demonstrating all features of the OpChan Core SDK. Use it as a foundation for building your decentralized forum application.**