OpChan/packages/core/docs/sample-apps.md

24 KiB
Raw Permalink Blame History

@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

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

npm install @opchan/core viem

Environment Variables

export REOWN_PROJECT_ID="your-project-id"

Run

// 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

import { useWalletClient } from 'wagmi';

async connectWithWagmi() {
  const { data: walletClient } = useWalletClient();
  if (!walletClient) return;
  
  await this.connectWallet(walletClient.account.address);
}

Add Search Functionality

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

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.