feat: indexedDB + local first app

This commit is contained in:
Danish Arora 2025-09-04 13:27:47 +05:30
parent ab77654b81
commit 60fe855779
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
30 changed files with 1077 additions and 1157 deletions

View File

@ -1,138 +0,0 @@
# Codebase Cleanup Strategy
## ✅ **Current Status: Hook System Complete**
The reactive hook system has been successfully implemented across the entire frontend:
### **✅ Completed:**
- **13 New Hooks Created** covering all forum functionality
- **All Major Components Migrated** (PostCard, PostDetail, PostList, ActivityFeed, Header, CellList, FeedSidebar)
- **All Pages Migrated** (FeedPage, Index)
- **Business Logic Centralized** in hooks
- **Reactive Updates** implemented throughout
## 🧹 **Next Steps: Strategic Cleanup**
### **Priority 1: Fix Type Errors (Critical)**
```bash
# 72 TypeScript errors need resolution
npm run check
```
**Key Issues:**
1. **Hook Interface Mismatches** - Some hooks return incompatible types
2. **Missing Context Dependencies** - Some components still reference old context methods
3. **Unused Imports** - Many imports are no longer needed after migration
### **Priority 2: Optimize Context Layer**
The existing contexts (`ForumContext`, `AuthContext`) should be streamlined since hooks now handle most logic:
**ForumContext Optimization:**
- Remove business logic methods (now in hooks)
- Keep only core data fetching and state
- Simplify interface to support hook system
**AuthContext Optimization:**
- Remove complex verification logic (now in hooks)
- Keep only core authentication state
- Simplify delegation management
### **Priority 3: Remove Legacy Code**
**Files to Clean/Remove:**
- `src/hooks/useCache.tsx` (functionality moved to useForumData)
- Unused utility functions in contexts
- Redundant business logic in service classes
## 🎯 **Immediate Actions Needed**
### **1. Fix Critical Type Errors**
```typescript
// Fix useForumData return types
interface PostWithVoteStatus extends Post {
canVote: boolean; // Fix type mismatch
canModerate: boolean;
}
// Fix selector types
selectCellsByActivity: () => CellWithStats[]; // Use correct interface
```
### **2. Clean Component Imports**
```typescript
// Remove unused imports from migrated components
// Update import paths to use hook barrel exports
import { useForumData, useAuth } from '@/hooks';
```
### **3. Update Context Dependencies**
```typescript
// Update ForumContext to support hook system
// Remove redundant business logic
// Keep only core data management
```
## 📊 **Benefits Achieved**
### **Performance Improvements:**
- ✅ Selective re-renders (components only update when needed)
- ✅ Memoized computations (vote scores, user status)
- ✅ Efficient data access patterns
### **Code Quality:**
- ✅ Zero business logic in components
- ✅ Centralized permission checking
- ✅ Consistent error handling
- ✅ Type-safe interfaces
### **Developer Experience:**
- ✅ Predictable data flow
- ✅ Reusable hook patterns
- ✅ Easy testing (hooks can be tested independently)
- ✅ Clear separation of concerns
## 🚀 **Final Implementation Status**
**Hook System Coverage:**
- ✅ **Core Data:** `useForumData`, `useAuth`, `useUserDisplay`
- ✅ **Derived Data:** `useCell`, `usePost`, `useCellPosts`, `usePostComments`, `useUserVotes`
- ✅ **Actions:** `useForumActions`, `useUserActions`, `useAuthActions`
- ✅ **Utilities:** `usePermissions`, `useNetworkStatus`, `useForumSelectors`
**Component Migration:**
- ✅ **Main Components:** All migrated to use hooks
- ✅ **UI Components:** Wallet wizard, dialogs migrated
- ✅ **Pages:** All pages using new hook system
**Architecture Benefits:**
- ✅ **No Business Logic in Components** - All moved to hooks
- ✅ **Reactive Updates** - Automatic data synchronization
- ✅ **Performance Optimized** - Memoized computations
- ✅ **Type Safe** - Full TypeScript coverage
## 🔧 **Recommended Next Steps**
1. **Fix Type Errors** (30 minutes)
2. **Clean Unused Imports** (15 minutes)
3. **Optimize Contexts** (20 minutes)
4. **Test Reactive Updates** (15 minutes)
**Total Time Investment:** ~1.5 hours for complete cleanup
The hook system is **fully functional** and provides the reactive, centralized architecture you requested. The cleanup phase will polish the implementation and resolve remaining technical debt.

View File

@ -1,307 +0,0 @@
# Hook Migration Guide
## Overview
This guide explains how to migrate your existing components from direct context usage to the new reactive hook system. The new hooks eliminate business logic from components and provide better performance through selective re-renders.
## Migration Strategy
### Phase 1: Install New Hooks (✅ Complete)
- Core data hooks: `useForumData`, `useAuth`, `useUserDisplay`
- Derived hooks: `useCell`, `usePost`, `useCellPosts`, `usePostComments`, `useUserVotes`
- Action hooks: `useForumActions`, `useUserActions`, `useAuthActions`
- Utility hooks: `usePermissions`, `useNetworkStatus`, `useForumSelectors`
### Phase 2: Component Migration (Next Steps)
## Before and After Examples
### PostCard Component Migration
#### ❌ Before (Business Logic in Component)
```tsx
const PostCard: React.FC<PostCardProps> = ({ post }) => {
const { getCellById, votePost, isVoting } = useForum();
const { isAuthenticated, currentUser } = useAuth();
const cell = getCellById(post.cellId);
// ❌ Business logic in component
const score = post.upvotes.length - post.downvotes.length;
const userUpvoted = currentUser
? post.upvotes.some(vote => vote.author === currentUser.address)
: false;
const userDownvoted = currentUser
? post.downvotes.some(vote => vote.author === currentUser.address)
: false;
const handleVote = async (e: React.MouseEvent, isUpvote: boolean) => {
e.preventDefault();
if (!isAuthenticated) return;
await votePost(post.id, isUpvote);
};
return (
// JSX with manual calculations
);
};
```
#### ✅ After (Pure Presentation)
```tsx
const PostCard: React.FC<PostCardProps> = ({ post }) => {
// ✅ All data comes pre-computed from hooks
const { forumActions } = useForumActions();
const permissions = usePermissions();
const userVotes = useUserVotes();
// ✅ No business logic - just pure data access
const userVoteType = userVotes.getPostVoteType(post.id);
const canVote = permissions.canVote;
const handleVote = async (e: React.MouseEvent, isUpvote: boolean) => {
e.preventDefault();
// ✅ All validation and logic handled in hook
await forumActions.votePost(post.id, isUpvote);
};
return (
// ✅ JSX uses pre-computed data
<div>
<span>{post.voteScore}</span> {/* Already computed */}
<button
disabled={!canVote || forumActions.isVoting}
className={userVoteType === 'upvote' ? 'active' : ''}
>
Upvote
</button>
</div>
);
};
```
### PostDetail Component Migration
#### ❌ Before
```tsx
const PostDetail = () => {
const { posts, getCommentsByPost, votePost, voteComment } = useForum();
const { currentUser, verificationStatus } = useAuth();
// ❌ Manual data fetching and filtering
const post = posts.find(p => p.id === postId);
const postComments = getCommentsByPost(post.id);
const visibleComments = postComments.filter(comment => !comment.moderated);
// ❌ Permission checking in component
const canVote =
verificationStatus === 'verified-owner' ||
currentUser?.ensDetails ||
currentUser?.ordinalDetails;
// ❌ Vote status checking
const isPostUpvoted =
currentUser &&
post.upvotes.some(vote => vote.author === currentUser.address);
};
```
#### ✅ After
```tsx
const PostDetail = () => {
// ✅ Get pre-computed post data
const post = usePost(postId);
const comments = usePostComments(postId, { includeModerated: false });
const permissions = usePermissions();
const forumActions = useForumActions();
if (!post) return <div>Post not found</div>;
return (
<div>
<h1>{post.title}</h1>
<p>Score: {post.voteScore}</p> {/* Pre-computed */}
<button
disabled={!permissions.canVote}
className={post.userUpvoted ? 'active' : ''}
onClick={() => forumActions.votePost(post.id, true)}
>
Upvote ({post.upvotes.length})
</button>
{comments.comments.map(comment => (
<CommentCard key={comment.id} comment={comment} />
))}
</div>
);
};
```
## Migration Checklist
### For Each Component:
1. **Identify Current Context Usage**
- [ ] Replace `useForum()` with specific hooks
- [ ] Replace `useAuth()` with enhanced `useAuth()`
- [ ] Replace `useUserDisplay()` with enhanced version
2. **Extract Business Logic**
- [ ] Remove vote calculations → use `post.voteScore`, `post.userUpvoted`
- [ ] Remove permission checks → use `usePermissions()`
- [ ] Remove data filtering → use hook options
- [ ] Remove user display logic → use `useUserDisplay()`
3. **Use Appropriate Hooks**
- [ ] Single items: `useCell()`, `usePost()`
- [ ] Collections: `useCellPosts()`, `usePostComments()`
- [ ] Actions: `useForumActions()`, `useUserActions()`
- [ ] Utilities: `usePermissions()`, `useNetworkStatus()`
4. **Update Action Handlers**
- [ ] Replace direct context methods with hook actions
- [ ] Remove manual loading states (hooks provide them)
- [ ] Remove manual error handling (hooks handle it)
## Common Patterns
### Data Access
```tsx
// ❌ Before
const { posts, getCellById } = useForum();
const cellPosts = posts.filter(p => p.cellId === cellId);
const cell = getCellById(cellId);
// ✅ After
const cell = useCell(cellId);
const cellPosts = useCellPosts(cellId, { sortBy: 'relevance' });
```
### Permission Checking
```tsx
// ❌ Before
const canVote =
verificationStatus === 'verified-owner' && currentUser?.ordinalDetails;
// ✅ After
const { canVote, voteReason } = usePermissions();
```
### Vote Status
```tsx
// ❌ Before
const userUpvoted =
currentUser && post.upvotes.some(vote => vote.author === currentUser.address);
// ✅ After
const userVotes = useUserVotes();
const userUpvoted = userVotes.getPostVoteType(post.id) === 'upvote';
```
### Actions with Loading States
```tsx
// ❌ Before
const { votePost, isVoting } = useForum();
// ✅ After
const { votePost, isVoting } = useForumActions();
```
## Benefits After Migration
### Performance
- ✅ Selective re-renders (only affected components update)
- ✅ Memoized computations (vote scores, user status, etc.)
- ✅ Efficient data access patterns
### Developer Experience
- ✅ Type-safe hook interfaces
- ✅ Built-in loading states and error handling
- ✅ Consistent permission checking
- ✅ No business logic in components
### Maintainability
- ✅ Centralized business logic in hooks
- ✅ Reusable data transformations
- ✅ Easier testing (hooks can be tested independently)
- ✅ Clear separation of concerns
## Testing Strategy
### Hook Testing
```tsx
// Test hooks independently
import { renderHook } from '@testing-library/react';
import { useForumData } from '@/hooks';
test('useForumData returns computed vote scores', () => {
const { result } = renderHook(() => useForumData());
expect(result.current.postsWithVoteStatus[0].voteScore).toBeDefined();
});
```
### Component Testing
```tsx
// Components become easier to test (pure presentation)
import { render } from '@testing-library/react';
import PostCard from './PostCard';
test('PostCard displays vote score', () => {
const mockPost = { id: '1', voteScore: 5, userUpvoted: true };
render(<PostCard post={mockPost} />);
expect(screen.getByText('5')).toBeInTheDocument();
});
```
## Rollback Plan
If issues arise during migration:
1. **Immediate Rollback**: Import legacy hooks
```tsx
import { useForum as useLegacyForum } from '@/contexts/useForum';
```
2. **Gradual Migration**: Use both systems temporarily
```tsx
const legacyData = useLegacyForum();
const newData = useForumData();
const data = newData.isInitialLoading ? legacyData : newData;
```
3. **Component-by-Component**: Migrate one component at a time
## Next Steps
1. **Start with Simple Components**: Begin with components that have minimal business logic
2. **Test Thoroughly**: Ensure reactive updates work correctly
3. **Monitor Performance**: Verify improved render performance
4. **Update Documentation**: Keep component documentation current
5. **Remove Legacy Code**: After full migration, remove old context dependencies
## Example Migration Order
1. ✅ **PostCard** - Simple display component
2. ✅ **CommentCard** - Similar patterns to PostCard
3. **CellList** - Collection display
4. **PostDetail** - Complex component with multiple data sources
5. **PostList** - Full CRUD operations
6. **Header** - Authentication and user display
7. **ActivityFeed** - Complex data aggregation
This migration will transform your codebase into a clean, reactive system where components are purely presentational and all business logic is centralized in reusable hooks.

View File

@ -1,134 +0,0 @@
# ✅ Hook System Implementation Complete
## 🎯 **Mission Accomplished: Zero Business Logic in Components**
Your codebase has been successfully transformed into a reactive hook-based system where **all business logic is centralized in hooks** and **components are purely presentational**.
## 📁 **What Was Created**
### **Hook Architecture (13 New Hooks)**
```
src/hooks/
├── core/ # Foundation layer
│ ├── useForumData.ts # ✅ Main reactive forum data
│ ├── useEnhancedAuth.ts # ✅ Enhanced authentication
│ └── useEnhancedUserDisplay.ts # ✅ Enhanced user display
├── derived/ # Specialized data access
│ ├── useCell.ts # ✅ Single cell with permissions
│ ├── usePost.ts # ✅ Single post with comments
│ ├── useCellPosts.ts # ✅ Cell posts collection
│ ├── usePostComments.ts # ✅ Post comments collection
│ └── useUserVotes.ts # ✅ User voting data
├── actions/ # Business logic layer
│ ├── useForumActions.ts # ✅ Forum CRUD operations
│ ├── useUserActions.ts # ✅ User profile actions
│ └── useAuthActions.ts # ✅ Auth/verification actions
├── utilities/ # Helper layer
│ ├── usePermissions.ts # ✅ Permission checking
│ ├── useNetworkStatus.ts # ✅ Network monitoring
│ └── selectors.ts # ✅ Data selectors
└── index.ts # ✅ Centralized exports
```
### **Migrated Components (8 Major Components)**
- ✅ **PostCard** - Pure presentation, vote status from hooks
- ✅ **PostDetail** - No business logic, all data pre-computed
- ✅ **PostList** - Uses reactive cell/posts hooks
- ✅ **ActivityFeed** - Uses selectors for data transformation
- ✅ **Header** - Uses network status and auth hooks
- ✅ **CellList** - Uses forum data with statistics
- ✅ **FeedSidebar** - Uses selectors for trending data
- ✅ **UI Components** - Wizard dialogs use action hooks
### **Migrated Pages (2 Pages)**
- ✅ **FeedPage** - Uses forum data and selectors
- ✅ **Index** - Uses network status hooks
## 🔄 **Before vs After Transformation**
### ❌ **Before: Business Logic Everywhere**
```tsx
// Business logic scattered in components
const score = post.upvotes.length - post.downvotes.length;
const userUpvoted =
currentUser && post.upvotes.some(vote => vote.author === currentUser.address);
const canVote =
verificationStatus === 'verified-owner' && currentUser?.ordinalDetails;
// Manual permission checking
if (!isAuthenticated) return;
if (verificationStatus !== 'verified-owner') return;
```
### ✅ **After: Pure Presentation**
```tsx
// All data comes pre-computed from hooks
const { voteScore, userUpvoted, canVote } = post; // From useForumData()
const { votePost } = useForumActions(); // All validation included
const { canVote, voteReason } = usePermissions(); // Centralized permissions
// Simple action calls
await votePost(post.id, true); // Hook handles everything
```
## 🚀 **Key Achievements**
### **1. Reactive Data Flow**
- ✅ Components automatically re-render when data changes
- ✅ No manual state management in components
- ✅ Centralized data transformations
### **2. Performance Optimized**
- ✅ Memoized expensive computations (vote scores, user status)
- ✅ Selective re-renders (only affected components update)
- ✅ Efficient data access patterns
### **3. Developer Experience**
- ✅ Type-safe hook interfaces
- ✅ Built-in loading states and error handling
- ✅ Consistent permission checking
- ✅ Predictable data flow
### **4. Architecture Benefits**
- ✅ Clear separation of concerns
- ✅ Reusable business logic
- ✅ Easy to test (hooks can be unit tested)
- ✅ Maintainable codebase
## 📋 **Current Status**
### **✅ Fully Functional**
- All components using new hook system
- Reactive updates working
- Business logic centralized
- Performance optimized
### **🔧 Minor Cleanup Needed**
- Some TypeScript errors to resolve (mainly unused imports)
- Context optimization opportunities
- Legacy code removal
## 🎉 **Mission Complete**
**Your frontend now has:**
- ✅ **Zero business logic in components**
- ✅ **All data access through reactive hooks**
- ✅ **Automatic reactive updates**
- ✅ **Centralized permissions and validation**
- ✅ **Performance-optimized data flow**
The hook system provides exactly what you requested: **a reactive, centralized architecture where components are purely presentational and all business logic is handled by reusable hooks**.
**Ready for production use!** 🚀

179
TODO.md Normal file
View File

@ -0,0 +1,179 @@
# OpChan TODO - Missing Features & Improvements
This document outlines the features and improvements that still need to be implemented to fully satisfy the FURPS requirements for the Waku Forum.
## 🚨 High Priority (1-2 weeks)
### 1. Bookmarking System
- **Requirement**: "Users can bookmark posts and topics; local only"
- **Status**: ❌ Not implemented
- **Missing**:
- [ ] Local storage implementation for bookmarked posts/topics
- [ ] Bookmark UI components (bookmark button, bookmark list)
- [ ] Bookmark management interface
- [ ] Bookmark persistence across sessions
- **Impact**: Users cannot save content for later reference
- **Estimated Effort**: 2-3 days
### 2. Call Sign Setup & Display
- **Requirement**: "Users can setup a call sign; bitcoin identity operator unique name - remains - ordinal used as avatar"
- **Status**: ⚠️ Partially implemented
- **Missing**:
- [ ] Complete call sign setup UI integration
- [ ] Ordinal avatar display and integration
- [ ] User profile settings interface
- [ ] Call sign validation and uniqueness checks
- **Impact**: Users cannot customize their forum identity
- **Estimated Effort**: 3-4 days
### 3. Cell Icon System
- **Requirement**: "Cell can be created with a name, description, icon; icon size will be restricted"
- **Status**: ❌ Not implemented
- **Missing**:
- [ ] Icon upload/selection interface
- [ ] Icon size restrictions and validation
- [ ] Icon display in cell listings and details
- [ ] Icon storage and management
- **Impact**: Cells lack visual identity and branding
- **Estimated Effort**: 2-3 days
## 🔶 Medium Priority (2-3 weeks)
### 4. Enhanced Sorting Options
- **Requirement**: "Users can sort topics per new or top"
- **Status**: ⚠️ Basic implementation exists
- **Missing**:
- [ ] "Top" sorting by votes/relevance
- [ ] UI controls for sorting preferences
- [ ] Persistent sorting preferences
- [ ] Sort option indicators in UI
- **Impact**: Limited content discovery options
- **Estimated Effort**: 1-2 days
### 5. Active Member Count Display
- **Requirement**: "A user can see the number of active members per cell; deduced from retrievable activity"
- **Status**: ⚠️ Calculated in backend but not shown
- **Missing**:
- [ ] UI components to display active member counts
- [ ] Member count updates in real-time
- [ ] Member activity indicators
- **Impact**: Users cannot gauge cell activity levels
- **Estimated Effort**: 1 day
### 6. IndexedDB Integration
- **Requirement**: "store message cache in indexedDB -- make app local-first"
- **Status**: ❌ In-memory caching only
- **Missing**:
- [ ] IndexedDB schema design
- [ ] Message persistence layer
- [ ] Offline-first capabilities
- [ ] Cache synchronization logic
- **Impact**: No offline support, data lost on refresh
- **Estimated Effort**: 3-4 days
### 7. Enhanced Moderation UI
- **Requirement**: "Cell admin can mark posts and comments as moderated"
- **Status**: ⚠️ Backend logic exists, basic UI
- **Missing**:
- [ ] Rich moderation interface
- [ ] Moderation history and audit trail
- [ ] Bulk moderation actions
- [ ] Moderation reason templates
- [ ] Moderation statistics dashboard
- **Impact**: Limited moderation capabilities for cell admins
- **Estimated Effort**: 2-3 days
## 🔵 Low Priority (3-4 weeks)
### 8. Anonymous User Experience
- **Requirement**: "Anonymous users can upvote, comments and post"
- **Status**: ⚠️ Basic support but limited UX
- **Missing**:
- [ ] Better anonymous user flow
- [ ] Clear permission indicators
- [ ] Anonymous user onboarding
- [ ] Anonymous user limitations display
- **Impact**: Poor experience for non-authenticated users
- **Estimated Effort**: 2-3 days
### 9. Relevance Score Visibility
- **Requirement**: "The relevance index is used to push most relevant posts and comments on top"
- **Status**: ⚠️ Calculated but limited visibility
- **Missing**:
- [ ] Better relevance score indicators
- [ ] Relevance-based filtering options
- [ ] Relevance score explanations
- [ ] Relevance score trends
- **Impact**: Users don't understand content ranking
- **Estimated Effort**: 1-2 days
### 10. Mobile Responsiveness
- **Requirement**: "Users do not need any software beyond a browser to use the forum"
- **Status**: ❌ Basic responsive design
- **Missing**:
- [ ] Full mobile-optimized experience
- [ ] Touch-friendly interactions
- [ ] Mobile-specific navigation
- [ ] Responsive image handling
- **Impact**: Poor mobile user experience
- **Estimated Effort**: 3-4 days
## 🛠️ Technical Debt & Infrastructure
### 11. Performance Optimizations
- [ ] Implement virtual scrolling for large lists
- [ ] Add message pagination
- [ ] Optimize relevance calculations
- [ ] Implement lazy loading for images
### 12. Testing & Quality
- [ ] Add comprehensive unit tests
- [ ] Implement integration tests
- [ ] Add end-to-end testing
- [ ] Performance testing and monitoring
### 13. Documentation
- [ ] API documentation
- [ ] User guide
- [ ] Developer setup guide
- [ ] Architecture documentation
## 📋 Implementation Notes
### Dependencies
- Bookmarking system depends on IndexedDB integration
- Call sign setup depends on user profile system completion
- Enhanced moderation depends on existing moderation backend
### Technical Considerations
- Use React Query for state management
- Implement proper error boundaries
- Add loading states for all async operations
- Ensure accessibility compliance
- Follow existing code patterns and conventions
### Testing Strategy
- Unit tests for utility functions
- Integration tests for hooks and contexts
- Component tests for UI elements
- End-to-end tests for user flows
## 🎯 Success Metrics
- [ ] All FURPS requirements satisfied
- [ ] 90%+ test coverage
- [ ] Lighthouse performance score > 90
- [ ] Accessibility score > 95
- [ ] Mobile usability score > 90
## 📅 Timeline Estimate
- **Phase 1 (High Priority)**: 1-2 weeks
- **Phase 2 (Medium Priority)**: 2-3 weeks
- **Phase 3 (Low Priority)**: 3-4 weeks
- **Total Estimated Time**: 6-9 weeks
---
*Last updated: [Current Date]*
*Based on FURPS requirements analysis and codebase review*

View File

@ -21,6 +21,7 @@ import {
import { CypherImage } from './ui/CypherImage';
import { RelevanceIndicator } from './ui/relevance-indicator';
import { sortCells, SortOption } from '@/lib/utils/sorting';
import { usePending } from '@/hooks/usePending';
const CellList = () => {
const { cellsWithStats, isInitialLoading } = useForumData();
@ -137,6 +138,13 @@ const CellList = () => {
/>
)}
</div>
{usePending(cell.id).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
</span>
</div>
)}
<p className="text-cyber-neutral text-sm mb-3 line-clamp-2">
{cell.description}

View File

@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useAuth, useNetworkStatus } from '@/hooks';
import { useForum } from '@/contexts/useForum';
import { Button } from '@/components/ui/button';
import {
@ -29,6 +30,7 @@ const Header = () => {
const networkStatus = useNetworkStatus();
const location = useLocation();
const { toast } = useToast();
const forum = useForum();
// Use AppKit hooks for multi-chain support
const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' });
@ -192,6 +194,12 @@ const Header = () => {
<span className="text-xs text-cyber-neutral">
{networkStatus.getStatusMessage()}
</span>
{forum.lastSync && (
<span className="text-xs text-cyber-neutral ml-2">
Last updated {new Date(forum.lastSync).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
{forum.isSyncing ? ' • syncing…' : ''}
</span>
)}
</div>
{/* User Status */}

View File

@ -11,6 +11,7 @@ import {
} from '@/hooks';
import { RelevanceIndicator } from '@/components/ui/relevance-indicator';
import { AuthorDisplay } from '@/components/ui/author-display';
import { usePending, usePendingVote } from '@/hooks/usePending';
interface PostCardProps {
post: Post;
@ -33,6 +34,8 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
'voteScore' in post
? (post.voteScore as number)
: post.upvotes.length - post.downvotes.length;
const { isPending } = usePending(post.id);
const votePending = usePendingVote(post.id);
// ✅ Get user vote status from hook
const userVoteType = userVotes.getPostVoteType(post.id);
@ -93,6 +96,9 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
>
<ArrowDown className="w-5 h-5" />
</button>
{votePending.isPending && (
<span className="mt-1 text-[10px] text-yellow-400">syncing</span>
)}
</div>
{/* Content column */}
@ -146,6 +152,11 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
<MessageSquare className="w-4 h-4" />
<span>{commentCount} comments</span>
</div>
{isPending && (
<span className="px-2 py-0.5 rounded-sm bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
syncing
</span>
)}
<button className="hover:text-cyber-accent transition-colors">
Share
</button>

View File

@ -22,6 +22,21 @@ import { formatDistanceToNow } from 'date-fns';
import { RelevanceIndicator } from './ui/relevance-indicator';
import { AuthorDisplay } from './ui/author-display';
import { usePending, usePendingVote } from '@/hooks/usePending';
// Extracted child component to respect Rules of Hooks
const PendingBadge: React.FC<{ id: string }> = ({ id }) => {
const { isPending } = usePending(id);
if (!isPending) return null;
return (
<>
<span></span>
<span className="px-2 py-0.5 rounded-sm bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
syncing
</span>
</>
);
};
const PostDetail = () => {
const { postId } = useParams<{ postId: string }>();
@ -42,6 +57,10 @@ const PostDetail = () => {
const { canVote, canComment, canModerate } = usePermissions();
const userVotes = useUserVotes();
// ✅ Move ALL hook calls to the top, before any conditional logic
const postPending = usePending(post?.id);
const postVotePending = usePendingVote(post?.id);
const [newComment, setNewComment] = useState('');
if (!postId) return <div>Invalid post ID</div>;
@ -165,6 +184,9 @@ const PostDetail = () => {
>
<ArrowDown className="w-4 h-4" />
</button>
{postVotePending.isPending && (
<span className="mt-1 text-[10px] text-yellow-500">syncing</span>
)}
</div>
<div className="flex-1">
@ -198,6 +220,14 @@ const PostDetail = () => {
/>
</>
)}
{postPending.isPending && (
<>
<span></span>
<span className="px-2 py-0.5 rounded-sm bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
syncing
</span>
</>
)}
</div>
<h1 className="text-2xl font-bold mb-3">{post.title}</h1>
@ -322,6 +352,7 @@ const PostDetail = () => {
addSuffix: true,
})}
</span>
<PendingBadge id={comment.id} />
</div>
<p className="text-sm break-words">{comment.content}</p>
{canModerate(cell?.id || '') && !comment.moderated && (

View File

@ -30,7 +30,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { useToast } from '@/hooks/use-toast';
import { DisplayPreference } from '@/types/identity';
import { EDisplayPreference } from '@/types/identity';
const formSchema = z.object({
callSign: z
@ -42,7 +42,7 @@ const formSchema = z.object({
'Only letters, numbers, hyphens, and underscores allowed'
)
.refine(val => !/[-_]{2,}/.test(val), 'No consecutive special characters'),
displayPreference: z.nativeEnum(DisplayPreference),
displayPreference: z.nativeEnum(EDisplayPreference),
});
interface CallSignSetupDialogProps {
@ -69,7 +69,7 @@ export function CallSignSetupDialog({
defaultValues: {
callSign: currentUser?.callSign || '',
displayPreference:
currentUser?.displayPreference || DisplayPreference.WALLET_ADDRESS,
currentUser?.displayPreference || EDisplayPreference.WALLET_ADDRESS,
},
});
@ -164,10 +164,10 @@ export function CallSignSetupDialog({
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={DisplayPreference.CALL_SIGN}>
<SelectItem value={EDisplayPreference.CALL_SIGN}>
Call Sign (when available)
</SelectItem>
<SelectItem value={DisplayPreference.WALLET_ADDRESS}>
<SelectItem value={EDisplayPreference.WALLET_ADDRESS}>
Wallet Address
</SelectItem>
</SelectContent>

View File

@ -1,7 +1,7 @@
import React, { createContext, useState, useEffect, useMemo } from 'react';
import { useToast } from '@/components/ui/use-toast';
import { OpchanMessage } from '@/types/forum';
import { User, EVerificationStatus, DisplayPreference } from '@/types/identity';
import { User, EVerificationStatus, EDisplayPreference } from '@/types/identity';
import { WalletManager } from '@/lib/wallet';
import {
DelegationManager,
@ -201,7 +201,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
address,
walletType: isBitcoinConnected ? 'bitcoin' : 'ethereum',
verificationStatus: EVerificationStatus.VERIFIED_BASIC, // Connected wallets get basic verification by default
displayPreference: DisplayPreference.WALLET_ADDRESS,
displayPreference: EDisplayPreference.WALLET_ADDRESS,
lastChecked: Date.now(),
};

View File

@ -4,9 +4,10 @@ import React, {
useEffect,
useCallback,
useMemo,
useRef,
} from 'react';
import { Cell, Post, Comment, OpchanMessage } from '@/types/forum';
import { User, EVerificationStatus, DisplayPreference } from '@/types/identity';
import { Cell, Post, Comment } from '@/types/forum';
import { User, EVerificationStatus, EDisplayPreference } from '@/types/identity';
import { useToast } from '@/components/ui/use-toast';
import { ForumActions } from '@/lib/forum/ForumActions';
@ -19,6 +20,7 @@ import { DelegationManager } from '@/lib/delegation';
import { UserIdentityService } from '@/lib/services/UserIdentityService';
import { MessageService } from '@/lib/services/MessageService';
import { useAuth } from '@/contexts/useAuth';
import { localDatabase } from '@/lib/database/LocalDatabase';
interface ForumContextType {
cells: Cell[];
@ -30,6 +32,9 @@ interface ForumContextType {
userIdentityService: UserIdentityService | null;
// Granular loading states
isInitialLoading: boolean;
// Sync state
lastSync: number | null;
isSyncing: boolean;
isPostingCell: boolean;
isPostingPost: boolean;
isPostingComment: boolean;
@ -84,6 +89,10 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
const [posts, setPosts] = useState<Post[]>([]);
const [comments, setComments] = useState<Comment[]>([]);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [{ lastSync, isSyncing }, setSyncState] = useState({
lastSync: null as number | null,
isSyncing: false,
});
const [isPostingCell, setIsPostingCell] = useState(false);
const [isPostingPost, setIsPostingPost] = useState(false);
const [isPostingComment, setIsPostingComment] = useState(false);
@ -113,11 +122,6 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
// Transform message cache data to the expected types
const updateStateFromCache = useCallback(async () => {
// Use the verifyMessage function from delegationManager if available
const verifyFn = isAuthenticated
? async (message: OpchanMessage) =>
await delegationManager.verify(message)
: undefined;
// Build user verification status for relevance calculation
const relevanceCalculator = new RelevanceCalculator();
@ -181,7 +185,7 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
? ('ethereum' as const)
: ('bitcoin' as const),
verificationStatus: EVerificationStatus.UNVERIFIED,
displayPreference: DisplayPreference.WALLET_ADDRESS,
displayPreference: EDisplayPreference.WALLET_ADDRESS,
};
}
}
@ -196,7 +200,7 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
// Transform data with relevance calculation
const { cells, posts, comments } = await getDataFromCache(
verifyFn,
undefined,
initialStatus
);
@ -204,16 +208,23 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
setPosts(posts);
setComments(comments);
setUserVerificationStatus(initialStatus);
}, [delegationManager, isAuthenticated, currentUser, userIdentityService]);
// Sync state from LocalDatabase
setSyncState(localDatabase.getSyncState());
}, [currentUser, userIdentityService]);
const handleRefreshData = async () => {
setIsRefreshing(true);
try {
// SDS handles message syncing automatically, just update UI
await updateStateFromCache();
const { lastSync, isSyncing } = localDatabase.getSyncState();
toast({
title: 'Data Refreshed',
description: 'Your view has been updated.',
description: lastSync
? `Your view has been updated. Last sync: ${new Date(
lastSync
).toLocaleTimeString()}${isSyncing ? ' (syncing...)' : ''}`
: 'Your view has been updated.',
});
} catch (error) {
console.error('Error refreshing data:', error);
@ -233,11 +244,56 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
return unsubscribe;
}, [toast]);
const hasInitializedRef = useRef(false);
useEffect(() => {
if (hasInitializedRef.current) return;
hasInitializedRef.current = true;
const loadData = async () => {
setIsInitialLoading(true);
// Open Local DB and seed Waku cache on warm start before network init
try {
await localDatabase.open();
// Seed messageManager's in-memory cache from LocalDatabase for instant UI
const seeded = localDatabase.cache;
Object.assign(messageManager.messageCache.cells, seeded.cells);
Object.assign(messageManager.messageCache.posts, seeded.posts);
Object.assign(messageManager.messageCache.comments, seeded.comments);
Object.assign(messageManager.messageCache.votes, seeded.votes);
Object.assign(messageManager.messageCache.moderations, seeded.moderations);
Object.assign(messageManager.messageCache.userIdentities, seeded.userIdentities);
// Determine if we have any cached content
const hasSeedData =
Object.keys(seeded.cells).length > 0 ||
Object.keys(seeded.posts).length > 0 ||
Object.keys(seeded.comments).length > 0 ||
Object.keys(seeded.votes).length > 0;
// Render from local cache immediately (warm start) or empty (cold)
await updateStateFromCache();
// Initialize network and let incoming messages update LocalDatabase/Cache
await initializeNetwork(toast, updateStateFromCache, setError);
if (hasSeedData) {
setIsInitialLoading(false);
} else {
// Wait for first incoming message before showing UI
const unsubscribe = messageManager.onMessageReceived(() => {
setIsInitialLoading(false);
unsubscribe();
});
}
} catch (e) {
console.warn('LocalDatabase warm-start failed, continuing cold:', e);
// Initialize network even if local DB failed, keep loader until first message
await initializeNetwork(toast, updateStateFromCache, setError);
const unsubscribe = messageManager.onMessageReceived(() => {
setIsInitialLoading(false);
unsubscribe();
});
}
};
loadData();
@ -247,7 +303,16 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
// const { cleanup } = setupPeriodicQueries(updateStateFromCache);
return () => {}; // Return empty cleanup function
}, [isNetworkConnected, toast, updateStateFromCache]);
}, [toast, updateStateFromCache]);
// Subscribe to incoming messages to update UI in real-time
useEffect(() => {
const unsubscribe = messageManager.onMessageReceived(() => {
localDatabase.setSyncing(true);
updateStateFromCache().finally(() => localDatabase.setSyncing(false));
});
return unsubscribe;
}, [updateStateFromCache]);
// Simple reactive updates: check for new data periodically when connected
useEffect(() => {
@ -256,7 +321,8 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
const interval = setInterval(() => {
// Only update if we're connected and ready
if (messageManager.isReady) {
updateStateFromCache();
localDatabase.setSyncing(true);
updateStateFromCache().finally(() => localDatabase.setSyncing(false));
}
}, 15000); // 15 seconds - much less frequent than before
@ -553,6 +619,8 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
userVerificationStatus,
userIdentityService,
isInitialLoading,
lastSync,
isSyncing,
isPostingCell,
isPostingPost,
isPostingComment,

View File

@ -1,7 +1,7 @@
import { useCallback, useState } from 'react';
import { useForum } from '@/contexts/useForum';
import { useAuth } from '@/hooks/core/useEnhancedAuth';
import { DisplayPreference } from '@/types/identity';
import { EDisplayPreference } from '@/types/identity';
import { useToast } from '@/components/ui/use-toast';
export interface UserActionStates {
@ -12,10 +12,10 @@ export interface UserActionStates {
export interface UserActions extends UserActionStates {
updateCallSign: (callSign: string) => Promise<boolean>;
updateDisplayPreference: (preference: DisplayPreference) => Promise<boolean>;
updateDisplayPreference: (preference: EDisplayPreference) => Promise<boolean>;
updateProfile: (updates: {
callSign?: string;
displayPreference?: DisplayPreference;
displayPreference?: EDisplayPreference;
}) => Promise<boolean>;
clearCallSign: () => Promise<boolean>;
}
@ -124,7 +124,7 @@ export function useUserActions(): UserActions {
// Update display preference
const updateDisplayPreference = useCallback(
async (preference: DisplayPreference): Promise<boolean> => {
async (preference: EDisplayPreference): Promise<boolean> => {
if (!permissions.canUpdateProfile) {
toast({
title: 'Permission Denied',
@ -155,7 +155,7 @@ export function useUserActions(): UserActions {
if (success) {
const preferenceLabel =
preference === DisplayPreference.CALL_SIGN
preference === EDisplayPreference.CALL_SIGN
? 'Call Sign'
: 'Wallet Address';
@ -193,7 +193,7 @@ export function useUserActions(): UserActions {
const updateProfile = useCallback(
async (updates: {
callSign?: string;
displayPreference?: DisplayPreference;
displayPreference?: EDisplayPreference;
}): Promise<boolean> => {
if (!permissions.canUpdateProfile) {
toast({

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
import { useForum } from '@/contexts/useForum';
import { DisplayPreference, EVerificationStatus } from '@/types/identity';
import { EDisplayPreference, EVerificationStatus } from '@/types/identity';
export interface Badge {
type: 'verification' | 'ens' | 'ordinal' | 'callsign';
@ -80,36 +80,19 @@ export function useEnhancedUserDisplay(address: string): UserDisplayInfo {
}
try {
console.log(
'useEnhancedUserDisplay: Getting identity for address',
address
);
const identity = await userIdentityService.getUserIdentity(address);
console.log('useEnhancedUserDisplay: Received identity', identity);
if (identity) {
let displayName = `${address.slice(0, 6)}...${address.slice(-4)}`;
// Determine display name based on preferences
if (
identity.displayPreference === DisplayPreference.CALL_SIGN &&
identity.displayPreference === EDisplayPreference.CALL_SIGN &&
identity.callSign
) {
displayName = identity.callSign;
console.log(
'useEnhancedUserDisplay: Using call sign as display name',
identity.callSign
);
} else if (identity.ensName) {
displayName = identity.ensName;
console.log(
'useEnhancedUserDisplay: Using ENS as display name',
identity.ensName
);
} else {
console.log(
'useEnhancedUserDisplay: Using truncated address as display name'
);
}
// Generate badges
@ -177,9 +160,6 @@ export function useEnhancedUserDisplay(address: string): UserDisplayInfo {
error: null,
});
} else {
console.log(
'useEnhancedUserDisplay: No identity found, using fallback with verification info'
);
// Use verification info from forum context
const badges: Badge[] = [];
@ -234,7 +214,7 @@ export function useEnhancedUserDisplay(address: string): UserDisplayInfo {
};
getUserDisplayInfo();
}, [address, userIdentityService]);
}, [address, userIdentityService, verificationInfo]);
// Update display info when verification status changes reactively
useEffect(() => {
@ -253,6 +233,7 @@ export function useEnhancedUserDisplay(address: string): UserDisplayInfo {
verificationInfo.hasOrdinal,
verificationInfo.verificationStatus,
displayInfo.isLoading,
verificationInfo
]);
return displayInfo;

48
src/hooks/usePending.ts Normal file
View File

@ -0,0 +1,48 @@
import { useEffect, useState } from 'react';
import { localDatabase } from '@/lib/database/LocalDatabase';
import { useAuth } from '@/contexts/useAuth';
export function usePending(id: string | undefined) {
const [isPending, setIsPending] = useState<boolean>(
id ? localDatabase.isPending(id) : false
);
useEffect(() => {
if (!id) return;
setIsPending(localDatabase.isPending(id));
const unsubscribe = localDatabase.onPendingChange(() => {
setIsPending(localDatabase.isPending(id));
});
return unsubscribe;
}, [id]);
return { isPending };
}
export function usePendingVote(targetId: string | undefined) {
const { currentUser } = useAuth();
const [isPending, setIsPending] = useState<boolean>(false);
useEffect(() => {
const compute = () => {
if (!targetId || !currentUser?.address) return setIsPending(false);
// Find a vote authored by current user for this target that is pending
const pending = Object.values(localDatabase.cache.votes).some(v => {
return (
v.targetId === targetId &&
v.author === currentUser.address &&
localDatabase.isPending(v.id)
);
});
setIsPending(pending);
};
compute();
const unsub = localDatabase.onPendingChange(compute);
return unsub;
}, [targetId, currentUser?.address]);
return { isPending };
}

View File

@ -0,0 +1,317 @@
import {
MessageType,
CellCache,
PostCache,
CommentCache,
VoteCache,
UserIdentityCache,
ModerateMessage,
UserProfileUpdateMessage,
CellMessage,
PostMessage,
CommentMessage,
VoteMessage,
} from '@/types/waku';
import { OpchanMessage } from '@/types/forum';
import { MessageValidator } from '@/lib/utils/MessageValidator';
import { EVerificationStatus } from '@/types/identity';
import { openLocalDB, STORE, StoreName } from '@/lib/database/schema';
export interface LocalDatabaseCache {
cells: CellCache;
posts: PostCache;
comments: CommentCache;
votes: VoteCache;
moderations: { [targetId: string]: ModerateMessage };
userIdentities: UserIdentityCache;
}
/**
* Minimal in-memory LocalDatabase
* Mirrors CacheService message handling to enable incremental migration.
*/
export class LocalDatabase {
private processedMessageIds: Set<string> = new Set();
private validator: MessageValidator;
private db: IDBDatabase | null = null;
private _isSyncing: boolean = false;
private _lastSync: number | null = null;
private pendingIds: Set<string> = new Set();
private pendingListeners: Set<() => void> = new Set();
public readonly cache: LocalDatabaseCache = {
cells: {},
posts: {},
comments: {},
votes: {},
moderations: {},
userIdentities: {},
};
constructor() {
this.validator = new MessageValidator();
}
/**
* Open IndexedDB and hydrate in-memory cache.
*/
public async open(): Promise<void> {
this.db = await openLocalDB();
await this.hydrateFromIndexedDB();
await this.hydratePendingFromMeta();
}
/**
* Apply a message into the LocalDatabase.
* Returns true if the message was newly processed and stored.
*/
public async applyMessage(message: unknown): Promise<boolean> {
if (!(await this.validator.isValidMessage(message))) {
const partialMsg = message as {
id?: unknown;
type?: unknown;
signature?: unknown;
browserPubKey?: unknown;
};
console.warn('LocalDatabase: Rejecting invalid message', {
messageId: partialMsg?.id,
messageType: partialMsg?.type,
hasSignature: !!partialMsg?.signature,
hasBrowserPubKey: !!partialMsg?.browserPubKey,
});
return false;
}
const validMessage = message as OpchanMessage;
const messageKey = `${validMessage.type}:${validMessage.id}:${validMessage.timestamp}`;
if (this.processedMessageIds.has(messageKey)) {
return false;
}
this.processedMessageIds.add(messageKey);
this.storeMessage(validMessage);
return true;
}
/**
* Temporary alias to ease migration from CacheService.updateCache
*/
public async updateCache(message: unknown): Promise<boolean> {
return this.applyMessage(message);
}
public clear(): void {
this.processedMessageIds.clear();
this.cache.cells = {};
this.cache.posts = {};
this.cache.comments = {};
this.cache.votes = {};
this.cache.moderations = {};
this.cache.userIdentities = {};
}
private storeMessage(message: OpchanMessage): void {
switch (message.type) {
case MessageType.CELL:
if (!this.cache.cells[message.id] || this.cache.cells[message.id]?.timestamp !== message.timestamp) {
this.cache.cells[message.id] = message;
this.put(STORE.CELLS, message);
}
break;
case MessageType.POST:
if (!this.cache.posts[message.id] || this.cache.posts[message.id]?.timestamp !== message.timestamp) {
this.cache.posts[message.id] = message;
this.put(STORE.POSTS, message);
}
break;
case MessageType.COMMENT:
if (!this.cache.comments[message.id] || this.cache.comments[message.id]?.timestamp !== message.timestamp) {
this.cache.comments[message.id] = message;
this.put(STORE.COMMENTS, message);
}
break;
case MessageType.VOTE: {
const voteKey = `${message.targetId}:${message.author}`;
if (!this.cache.votes[voteKey] || this.cache.votes[voteKey]?.timestamp !== message.timestamp) {
this.cache.votes[voteKey] = message;
this.put(STORE.VOTES, { key: voteKey, ...message });
}
break;
}
case MessageType.MODERATE: {
const modMsg = message as ModerateMessage;
if (!this.cache.moderations[modMsg.targetId] || this.cache.moderations[modMsg.targetId]?.timestamp !== modMsg.timestamp) {
this.cache.moderations[modMsg.targetId] = modMsg;
this.put(STORE.MODERATIONS, modMsg);
}
break;
}
case MessageType.USER_PROFILE_UPDATE: {
const profileMsg = message as UserProfileUpdateMessage;
const { author, callSign, displayPreference, timestamp } = profileMsg;
if (!this.cache.userIdentities[author] || this.cache.userIdentities[author]?.lastUpdated !== timestamp) {
this.cache.userIdentities[author] = {
ensName: undefined,
ordinalDetails: undefined,
callSign,
displayPreference,
lastUpdated: timestamp,
verificationStatus: EVerificationStatus.UNVERIFIED,
};
// Persist with address keyPath
this.put(STORE.USER_IDENTITIES, {
address: author,
...this.cache.userIdentities[author],
});
}
break;
}
default:
console.warn('LocalDatabase: Received message with unknown type');
break;
}
// Update last sync time using local receipt time for accurate UI
this.updateLastSync(Date.now());
}
/**
* Hydrate cache from IndexedDB on warm start
*/
private async hydrateFromIndexedDB(): Promise<void> {
if (!this.db) return;
const [
cells,
posts,
comments,
votes,
moderations,
identities,
]: [
CellMessage[],
PostMessage[],
CommentMessage[],
(VoteMessage & { key: string })[],
ModerateMessage[],
({ address: string } & UserIdentityCache[string])[],
] = await Promise.all([
this.getAllFromStore<CellMessage>(STORE.CELLS),
this.getAllFromStore<PostMessage>(STORE.POSTS),
this.getAllFromStore<CommentMessage>(STORE.COMMENTS),
this.getAllFromStore<VoteMessage & { key: string }>(STORE.VOTES),
this.getAllFromStore<ModerateMessage>(STORE.MODERATIONS),
this.getAllFromStore<{ address: string } & UserIdentityCache[string]>(
STORE.USER_IDENTITIES
),
]);
this.cache.cells = Object.fromEntries(cells.map(c => [c.id, c]));
this.cache.posts = Object.fromEntries(posts.map(p => [p.id, p]));
this.cache.comments = Object.fromEntries(comments.map(cm => [cm.id, cm]));
this.cache.votes = Object.fromEntries(
votes.map(v => {
const { key, ...rest } = v;
const vote: VoteMessage = rest as VoteMessage;
return [key, vote];
})
);
this.cache.moderations = Object.fromEntries(moderations.map(m => [m.targetId, m]));
this.cache.userIdentities = Object.fromEntries(
identities.map(u => {
const { address, ...record } = u;
return [address, record];
})
);
}
private async hydratePendingFromMeta(): Promise<void> {
if (!this.db) return;
const meta = await this.getAllFromStore<{ key: string; value: unknown }>(
STORE.META
);
meta
.filter(entry => typeof entry.key === 'string' && entry.key.startsWith('pending:'))
.forEach(entry => {
const id = (entry.key as string).substring('pending:'.length);
this.pendingIds.add(id);
});
}
private getAllFromStore<T>(storeName: StoreName): Promise<T[]> {
return new Promise((resolve, reject) => {
if (!this.db) return resolve([]);
const tx = this.db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result as T[]);
});
}
private put(
storeName: StoreName,
value:
| CellMessage
| PostMessage
| CommentMessage
| (VoteMessage & { key: string })
| ModerateMessage
| ({ address: string } & UserIdentityCache[string])
| { key: string; value: unknown }
): void {
if (!this.db) return;
const tx = this.db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
store.put(value);
}
public getSyncState(): { lastSync: number | null; isSyncing: boolean } {
return { lastSync: this._lastSync, isSyncing: this._isSyncing };
}
public setSyncing(isSyncing: boolean): void {
this._isSyncing = isSyncing;
}
public updateLastSync(timestamp: number): void {
this._lastSync = Math.max(this._lastSync ?? 0, timestamp);
// persist in META store (best-effort)
if (!this.db) return;
const tx = this.db.transaction(STORE.META, 'readwrite');
const store = tx.objectStore(STORE.META);
store.put({ key: 'lastSync', value: this._lastSync });
}
public markPending(id: string): void {
this.pendingIds.add(id);
if (!this.db) return;
const tx = this.db.transaction(STORE.META, 'readwrite');
const store = tx.objectStore(STORE.META);
store.put({ key: `pending:${id}`, value: true });
this.pendingListeners.forEach(l => l());
}
public clearPending(id: string): void {
this.pendingIds.delete(id);
if (!this.db) return;
const tx = this.db.transaction(STORE.META, 'readwrite');
const store = tx.objectStore(STORE.META);
store.delete(`pending:${id}`);
this.pendingListeners.forEach(l => l());
}
public isPending(id: string): boolean {
return this.pendingIds.has(id);
}
public onPendingChange(listener: () => void): () => void {
this.pendingListeners.add(listener);
return () => this.pendingListeners.delete(listener);
}
}
export const localDatabase = new LocalDatabase();

View File

@ -0,0 +1,64 @@
export const DB_NAME = 'opchan-local';
export const DB_VERSION = 1;
export const STORE = {
CELLS: 'cells',
POSTS: 'posts',
COMMENTS: 'comments',
VOTES: 'votes',
MODERATIONS: 'moderations',
USER_IDENTITIES: 'userIdentities',
META: 'meta',
} as const;
export type StoreName = (typeof STORE)[keyof typeof STORE];
/**
* Open (and create/upgrade) the IndexedDB database used by LocalDatabase.
* Minimal schema focused on key-based access patterns we already use in memory.
*/
export function openLocalDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = () => {
const db = request.result;
// Create stores if they do not exist
if (!db.objectStoreNames.contains(STORE.CELLS)) {
db.createObjectStore(STORE.CELLS, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(STORE.POSTS)) {
const store = db.createObjectStore(STORE.POSTS, { keyPath: 'id' });
// Minimal index to fetch posts by cellId
store.createIndex('by_cellId', 'cellId', { unique: false });
}
if (!db.objectStoreNames.contains(STORE.COMMENTS)) {
const store = db.createObjectStore(STORE.COMMENTS, { keyPath: 'id' });
// Minimal index to fetch comments by postId
store.createIndex('by_postId', 'postId', { unique: false });
}
if (!db.objectStoreNames.contains(STORE.VOTES)) {
// Votes are keyed by composite key `${targetId}:${author}`
db.createObjectStore(STORE.VOTES, { keyPath: 'key' });
}
if (!db.objectStoreNames.contains(STORE.MODERATIONS)) {
// Moderations keyed by targetId
db.createObjectStore(STORE.MODERATIONS, { keyPath: 'targetId' });
}
if (!db.objectStoreNames.contains(STORE.USER_IDENTITIES)) {
// User identities keyed by address
db.createObjectStore(STORE.USER_IDENTITIES, { keyPath: 'address' });
}
if (!db.objectStoreNames.contains(STORE.META)) {
// Misc metadata like lastSync timestamps
db.createObjectStore(STORE.META, { keyPath: 'key' });
}
};
});
}

View File

@ -16,6 +16,9 @@ export interface DelegationFullStatus extends DelegationStatus {
}
export class DelegationManager {
private cachedDelegation: DelegationInfo | null = null;
private cachedAt: number = 0;
private static readonly CACHE_TTL_MS = 5 * 1000; // 5s to avoid hot-looping
private static readonly DURATION_HOURS = {
'7days': 24 * 7,
'30days': 24 * 30,
@ -81,7 +84,12 @@ export class DelegationManager {
* Sign a message with delegated key
*/
signMessage(message: UnsignedMessage): OpchanMessage | null {
const delegation = DelegationStorage.retrieve();
const now = Date.now();
if (!this.cachedDelegation || now - this.cachedAt > DelegationManager.CACHE_TTL_MS) {
this.cachedDelegation = DelegationStorage.retrieve();
this.cachedAt = now;
}
const delegation = this.cachedDelegation;
if (!delegation || Date.now() >= delegation.expiryTimestamp) {
return null;
}
@ -155,13 +163,16 @@ export class DelegationManager {
currentAddress?: string,
currentWalletType?: 'bitcoin' | 'ethereum'
): DelegationFullStatus {
const delegation = DelegationStorage.retrieve();
const now = Date.now();
if (!this.cachedDelegation || now - this.cachedAt > DelegationManager.CACHE_TTL_MS) {
this.cachedDelegation = DelegationStorage.retrieve();
this.cachedAt = now;
}
const delegation = this.cachedDelegation;
if (!delegation) {
return { hasDelegation: false, isValid: false };
}
// Check validity
const now = Date.now();
const hasExpired = now >= delegation.expiryTimestamp;
const addressMatches =
!currentAddress || delegation.walletAddress === currentAddress;

View File

@ -8,23 +8,10 @@ export class DelegationStorage {
* Store delegation information in localStorage
*/
static store(delegation: DelegationInfo): void {
console.log('DelegationStorage.store - storing delegation:', {
hasAuthMessage: !!delegation.authMessage,
hasWalletSignature: !!delegation.walletSignature,
hasExpiryTimestamp: delegation.expiryTimestamp !== undefined,
hasWalletAddress: !!delegation.walletAddress,
hasWalletType: !!delegation.walletType,
hasBrowserPublicKey: !!delegation.browserPublicKey,
hasBrowserPrivateKey: !!delegation.browserPrivateKey,
hasNonce: !!delegation.nonce,
authMessage: delegation.authMessage,
walletSignature: delegation.walletSignature,
expiryTimestamp: delegation.expiryTimestamp,
walletAddress: delegation.walletAddress,
walletType: delegation.walletType,
browserPublicKey: delegation.browserPublicKey,
nonce: delegation.nonce,
});
// Reduce verbose logging in production; keep minimal signal
if (import.meta.env?.MODE !== 'production') {
console.log('DelegationStorage.store');
}
localStorage.setItem(
DelegationStorage.STORAGE_KEY,
@ -41,23 +28,9 @@ export class DelegationStorage {
try {
const delegation = JSON.parse(delegationJson);
console.log('DelegationStorage.retrieve - retrieved delegation:', {
hasAuthMessage: !!delegation.authMessage,
hasWalletSignature: !!delegation.walletSignature,
hasExpiryTimestamp: delegation.expiryTimestamp !== undefined,
hasWalletAddress: !!delegation.walletAddress,
hasWalletType: !!delegation.walletType,
hasBrowserPublicKey: !!delegation.browserPublicKey,
hasBrowserPrivateKey: !!delegation.browserPrivateKey,
hasNonce: !!delegation.nonce,
authMessage: delegation.authMessage,
walletSignature: delegation.walletSignature,
expiryTimestamp: delegation.expiryTimestamp,
walletAddress: delegation.walletAddress,
walletType: delegation.walletType,
browserPublicKey: delegation.browserPublicKey,
nonce: delegation.nonce,
});
if (import.meta.env?.MODE !== 'production') {
console.log('DelegationStorage.retrieve');
}
return delegation;
} catch (e) {
console.error('Failed to parse delegation information', e);

View File

@ -13,8 +13,9 @@ import {
import { Cell, Comment, Post } from '@/types/forum';
import { EVerificationStatus, User } from '@/types/identity';
import { transformCell, transformComment, transformPost } from './transformers';
import { MessageService } from '@/lib/services';
import { DelegationManager } from '@/lib/delegation';
import { localDatabase } from '@/lib/database/LocalDatabase';
import messageManager from '@/lib/waku';
type ActionResult<T> = {
success: boolean;
@ -24,11 +25,9 @@ type ActionResult<T> = {
export class ForumActions {
private delegationManager: DelegationManager;
private messageService: MessageService;
constructor(delegationManager?: DelegationManager) {
this.delegationManager = delegationManager || new DelegationManager();
this.messageService = new MessageService(this.delegationManager);
}
/* ------------------------------------------------------------------
@ -72,7 +71,7 @@ export class ForumActions {
try {
const postId = uuidv4();
const postMessage: UnsignedPostMessage = {
const unsignedPost: UnsignedPostMessage = {
type: MessageType.POST,
id: postId,
cellId,
@ -82,28 +81,35 @@ export class ForumActions {
author: currentUser.address,
};
const result = await this.messageService.sendMessage(postMessage);
if (!result.success) {
const signed = this.delegationManager.signMessage(unsignedPost);
if (!signed) {
const status = this.delegationManager.getStatus(
currentUser.address,
currentUser.walletType
);
return {
success: false,
error: result.error || 'Failed to create post. Please try again.',
error: status.isValid
? 'Key delegation required. Please delegate a signing key from your profile menu.'
: 'Key delegation expired. Please re-delegate your key through the profile menu.',
};
}
await localDatabase.updateCache(signed);
localDatabase.markPending(signed.id);
localDatabase.setSyncing(true);
updateStateFromCache();
const transformedPost = await transformPost(
result.message! as PostMessage
);
messageManager
.sendMessage(signed)
.catch(err => console.error('Background send failed:', err))
.finally(() => localDatabase.setSyncing(false));
const transformedPost = await transformPost(signed as PostMessage);
if (!transformedPost) {
return {
success: false,
error: 'Failed to transform post data.',
};
return { success: false, error: 'Failed to transform post data.' };
}
return {
success: true,
data: transformedPost,
};
return { success: true, data: transformedPost };
} catch (error) {
console.error('Error creating post:', error);
return {
@ -150,7 +156,7 @@ export class ForumActions {
try {
const commentId = uuidv4();
const commentMessage: UnsignedCommentMessage = {
const unsignedComment: UnsignedCommentMessage = {
type: MessageType.COMMENT,
id: commentId,
postId,
@ -159,28 +165,39 @@ export class ForumActions {
author: currentUser.address,
};
const result = await this.messageService.sendMessage(commentMessage);
if (!result.success) {
// Optimistic path: sign locally, write to cache, mark pending, render immediately
const signed = this.delegationManager.signMessage(unsignedComment);
if (!signed) {
const status = this.delegationManager.getStatus(
currentUser.address,
currentUser.walletType
);
return {
success: false,
error: result.error || 'Failed to add comment. Please try again.',
error: status.isValid
? 'Key delegation required. Please delegate a signing key from your profile menu.'
: 'Key delegation expired. Please re-delegate your key through the profile menu.',
};
}
// Write immediately to LocalDatabase and reflect in UI
await localDatabase.updateCache(signed);
localDatabase.markPending(signed.id);
localDatabase.setSyncing(true);
updateStateFromCache();
const transformedComment = await transformComment(
result.message! as CommentMessage
);
if (!transformedComment) {
return {
success: false,
error: 'Failed to transform comment data.',
};
// Fire-and-forget network send; LocalDatabase will clear pending on ack
messageManager
.sendMessage(signed)
.catch(err => console.error('Background send failed:', err))
.finally(() => localDatabase.setSyncing(false));
const transformed = await transformComment(signed as CommentMessage);
if (!transformed) {
return { success: false, error: 'Failed to transform comment data.' };
}
return {
success: true,
data: transformedComment,
};
return { success: true, data: transformed };
} catch (error) {
console.error('Error creating comment:', error);
return {
@ -206,7 +223,7 @@ export class ForumActions {
try {
const cellId = uuidv4();
const cellMessage: UnsignedCellMessage = {
const unsignedCell: UnsignedCellMessage = {
type: MessageType.CELL,
id: cellId,
name,
@ -216,28 +233,35 @@ export class ForumActions {
author: currentUser.address,
};
const result = await this.messageService.sendMessage(cellMessage);
if (!result.success) {
const signed = this.delegationManager.signMessage(unsignedCell);
if (!signed) {
const status = this.delegationManager.getStatus(
currentUser.address,
currentUser.walletType
);
return {
success: false,
error: result.error || 'Failed to create cell. Please try again.',
error: status.isValid
? 'Key delegation required. Please delegate a signing key from your profile menu.'
: 'Key delegation expired. Please re-delegate your key through the profile menu.',
};
}
await localDatabase.updateCache(signed);
localDatabase.markPending(signed.id);
localDatabase.setSyncing(true);
updateStateFromCache();
const transformedCell = await transformCell(
result.message! as CellMessage
);
messageManager
.sendMessage(signed)
.catch(err => console.error('Background send failed:', err))
.finally(() => localDatabase.setSyncing(false));
const transformedCell = await transformCell(signed as CellMessage);
if (!transformedCell) {
return {
success: false,
error: 'Failed to transform cell data.',
};
return { success: false, error: 'Failed to transform cell data.' };
}
return {
success: true,
data: transformedCell,
};
return { success: true, data: transformedCell };
} catch (error) {
console.error('Error creating cell:', error);
return {
@ -288,7 +312,7 @@ export class ForumActions {
try {
const voteId = uuidv4();
const voteMessage: UnsignedVoteMessage = {
const unsignedVote: UnsignedVoteMessage = {
type: MessageType.VOTE,
id: voteId,
targetId,
@ -297,20 +321,31 @@ export class ForumActions {
author: currentUser.address,
};
const result = await this.messageService.sendMessage(voteMessage);
if (!result.success) {
const signed = this.delegationManager.signMessage(unsignedVote);
if (!signed) {
const status = this.delegationManager.getStatus(
currentUser.address,
currentUser.walletType
);
return {
success: false,
error:
result.error || 'Failed to register your vote. Please try again.',
error: status.isValid
? 'Key delegation required. Please delegate a signing key from your profile menu.'
: 'Key delegation expired. Please re-delegate your key through the profile menu.',
};
}
await localDatabase.updateCache(signed);
localDatabase.markPending(signed.id);
localDatabase.setSyncing(true);
updateStateFromCache();
return {
success: true,
data: true,
};
messageManager
.sendMessage(signed)
.catch(err => console.error('Background send failed:', err))
.finally(() => localDatabase.setSyncing(false));
return { success: true, data: true };
} catch (error) {
console.error('Error voting:', error);
return {
@ -346,7 +381,7 @@ export class ForumActions {
}
try {
const modMsg: UnsignedModerateMessage = {
const unsignedMod: UnsignedModerateMessage = {
type: MessageType.MODERATE,
id: uuidv4(),
cellId,
@ -357,19 +392,31 @@ export class ForumActions {
author: currentUser.address,
};
const result = await this.messageService.sendMessage(modMsg);
if (!result.success) {
const signed = this.delegationManager.signMessage(unsignedMod);
if (!signed) {
const status = this.delegationManager.getStatus(
currentUser.address,
currentUser.walletType
);
return {
success: false,
error: result.error || 'Failed to moderate post. Please try again.',
error: status.isValid
? 'Key delegation required. Please delegate a signing key from your profile menu.'
: 'Key delegation expired. Please re-delegate your key through the profile menu.',
};
}
await localDatabase.updateCache(signed);
localDatabase.markPending(signed.id);
localDatabase.setSyncing(true);
updateStateFromCache();
return {
success: true,
data: true,
};
messageManager
.sendMessage(signed)
.catch(err => console.error('Background send failed:', err))
.finally(() => localDatabase.setSyncing(false));
return { success: true, data: true };
} catch (error) {
console.error('Error moderating post:', error);
return {
@ -407,7 +454,7 @@ export class ForumActions {
}
try {
const modMsg: UnsignedModerateMessage = {
const unsignedMod: UnsignedModerateMessage = {
type: MessageType.MODERATE,
id: uuidv4(),
cellId,
@ -418,20 +465,31 @@ export class ForumActions {
author: currentUser.address,
};
const result = await this.messageService.sendMessage(modMsg);
if (!result.success) {
const signed = this.delegationManager.signMessage(unsignedMod);
if (!signed) {
const status = this.delegationManager.getStatus(
currentUser.address,
currentUser.walletType
);
return {
success: false,
error:
result.error || 'Failed to moderate comment. Please try again.',
error: status.isValid
? 'Key delegation required. Please delegate a signing key from your profile menu.'
: 'Key delegation expired. Please re-delegate your key through the profile menu.',
};
}
await localDatabase.updateCache(signed);
localDatabase.markPending(signed.id);
localDatabase.setSyncing(true);
updateStateFromCache();
return {
success: true,
data: true,
};
messageManager
.sendMessage(signed)
.catch(err => console.error('Background send failed:', err))
.finally(() => localDatabase.setSyncing(false));
return { success: true, data: true };
} catch (error) {
console.error('Error moderating comment:', error);
return {
@ -469,7 +527,7 @@ export class ForumActions {
}
try {
const modMsg: UnsignedModerateMessage = {
const unsignedMod: UnsignedModerateMessage = {
type: MessageType.MODERATE,
id: uuidv4(),
cellId,
@ -480,19 +538,31 @@ export class ForumActions {
timestamp: Date.now(),
};
const result = await this.messageService.sendMessage(modMsg);
if (!result.success) {
const signed = this.delegationManager.signMessage(unsignedMod);
if (!signed) {
const status = this.delegationManager.getStatus(
currentUser.address,
currentUser.walletType
);
return {
success: false,
error: result.error || 'Failed to moderate user. Please try again.',
error: status.isValid
? 'Key delegation required. Please delegate a signing key from your profile menu.'
: 'Key delegation expired. Please re-delegate your key through the profile menu.',
};
}
await localDatabase.updateCache(signed);
localDatabase.markPending(signed.id);
localDatabase.setSyncing(true);
updateStateFromCache();
return {
success: true,
data: true,
};
messageManager
.sendMessage(signed)
.catch(err => console.error('Background send failed:', err))
.finally(() => localDatabase.setSyncing(false));
return { success: true, data: true };
} catch (error) {
console.error('Error moderating user:', error);
return {

View File

@ -1,6 +1,6 @@
import { RelevanceCalculator } from '../RelevanceCalculator';
import { Post, Comment, UserVerificationStatus } from '@/types/forum';
import { User, EVerificationStatus, DisplayPreference } from '@/types/identity';
import { User, EVerificationStatus, EDisplayPreference } from '@/types/identity';
import { VoteMessage, MessageType } from '@/types/waku';
import { expect, describe, beforeEach, it } from 'vitest';
@ -78,7 +78,7 @@ describe('RelevanceCalculator', () => {
address: 'user1',
walletType: 'ethereum',
verificationStatus: EVerificationStatus.VERIFIED_OWNER,
displayPreference: DisplayPreference.WALLET_ADDRESS,
displayPreference: EDisplayPreference.WALLET_ADDRESS,
ensDetails: {
ensName: 'test.eth',
},
@ -95,7 +95,7 @@ describe('RelevanceCalculator', () => {
address: 'user3',
walletType: 'bitcoin',
verificationStatus: EVerificationStatus.VERIFIED_OWNER,
displayPreference: DisplayPreference.WALLET_ADDRESS,
displayPreference: EDisplayPreference.WALLET_ADDRESS,
ordinalDetails: {
ordinalId: '1',
ordinalDetails: 'test',
@ -112,7 +112,7 @@ describe('RelevanceCalculator', () => {
address: 'user2',
walletType: 'ethereum',
verificationStatus: EVerificationStatus.UNVERIFIED,
displayPreference: DisplayPreference.WALLET_ADDRESS,
displayPreference: EDisplayPreference.WALLET_ADDRESS,
ensDetails: undefined,
ordinalDetails: undefined,
lastChecked: Date.now(),
@ -278,7 +278,7 @@ describe('RelevanceCalculator', () => {
address: 'user1',
walletType: 'ethereum',
verificationStatus: EVerificationStatus.VERIFIED_OWNER,
displayPreference: DisplayPreference.WALLET_ADDRESS,
displayPreference: EDisplayPreference.WALLET_ADDRESS,
ensDetails: {
ensName: 'test.eth',
},
@ -289,7 +289,7 @@ describe('RelevanceCalculator', () => {
address: 'user2',
walletType: 'bitcoin',
verificationStatus: EVerificationStatus.UNVERIFIED,
displayPreference: DisplayPreference.WALLET_ADDRESS,
displayPreference: EDisplayPreference.WALLET_ADDRESS,
ensDetails: undefined,
ordinalDetails: undefined,
lastChecked: Date.now(),

View File

@ -8,37 +8,16 @@ import {
import messageManager from '@/lib/waku';
import { RelevanceCalculator } from './RelevanceCalculator';
import { UserVerificationStatus } from '@/types/forum';
import { MessageValidator } from '@/lib/utils/MessageValidator';
// Global validator instance for transformers
const messageValidator = new MessageValidator();
// Validation is enforced at ingestion time by LocalDatabase. Transformers assume
// cache contains only valid, verified messages.
export const transformCell = async (
cellMessage: CellMessage,
_verifyMessage?: unknown, // Deprecated parameter, kept for compatibility
_verifyMessage?: unknown,
userVerificationStatus?: UserVerificationStatus,
posts?: Post[]
): Promise<Cell | null> => {
// MANDATORY: All messages must have valid signatures
// Since CellMessage extends BaseMessage, it already has required signature fields
// But we still need to verify the signature cryptographically
if (!cellMessage.signature || !cellMessage.browserPubKey) {
console.warn(
`Cell message ${cellMessage.id} missing required signature fields`
);
return null;
}
// Verify signature using the message validator's crypto service
const validationReport =
await messageValidator.getValidationReport(cellMessage);
if (!validationReport.hasValidSignature) {
console.warn(
`Cell message ${cellMessage.id} failed signature validation:`,
validationReport.errors
);
return null;
}
// Message validity already enforced upstream
const transformedCell: Cell = {
id: cellMessage.id,
@ -81,49 +60,16 @@ export const transformCell = async (
export const transformPost = async (
postMessage: PostMessage,
_verifyMessage?: unknown, // Deprecated parameter, kept for compatibility
_verifyMessage?: unknown,
userVerificationStatus?: UserVerificationStatus
): Promise<Post | null> => {
// MANDATORY: All messages must have valid signatures
if (!postMessage.signature || !postMessage.browserPubKey) {
console.warn(
`Post message ${postMessage.id} missing required signature fields`
);
return null;
}
// Verify signature using the message validator's crypto service
const validationReport =
await messageValidator.getValidationReport(postMessage);
if (!validationReport.hasValidSignature) {
console.warn(
`Post message ${postMessage.id} failed signature validation:`,
validationReport.errors
);
return null;
}
// Message validity already enforced upstream
const votes = Object.values(messageManager.messageCache.votes).filter(
vote => vote.targetId === postMessage.id
);
// MANDATORY: Filter out votes with invalid signatures
const filteredVotes = await Promise.all(
votes.map(async vote => {
if (!vote.signature || !vote.browserPubKey) {
console.warn(`Vote ${vote.id} missing signature fields`);
return null;
}
const voteValidation = await messageValidator.getValidationReport(vote);
if (!voteValidation.hasValidSignature) {
console.warn(
`Vote ${vote.id} failed signature validation:`,
voteValidation.errors
);
return null;
}
return vote;
})
).then(votes => votes.filter((vote): vote is VoteMessage => vote !== null));
// Votes in cache are already validated; just map
const filteredVotes = votes;
const upvotes = filteredVotes.filter(
(vote): vote is VoteMessage => vote !== null && vote.value === 1
);
@ -172,6 +118,8 @@ export const transformPost = async (
: isUserModerated
? userModMsg!.timestamp
: undefined,
// mark pending for optimistic UI if not yet acknowledged
// not persisted as a field; UI can check via LocalDatabase
};
// Calculate relevance score if user verification status is provided
@ -227,48 +175,15 @@ export const transformPost = async (
export const transformComment = async (
commentMessage: CommentMessage,
_verifyMessage?: unknown, // Deprecated parameter, kept for compatibility
_verifyMessage?: unknown,
userVerificationStatus?: UserVerificationStatus
): Promise<Comment | null> => {
// MANDATORY: All messages must have valid signatures
if (!commentMessage.signature || !commentMessage.browserPubKey) {
console.warn(
`Comment message ${commentMessage.id} missing required signature fields`
);
return null;
}
// Verify signature using the message validator's crypto service
const validationReport =
await messageValidator.getValidationReport(commentMessage);
if (!validationReport.hasValidSignature) {
console.warn(
`Comment message ${commentMessage.id} failed signature validation:`,
validationReport.errors
);
return null;
}
// Message validity already enforced upstream
const votes = Object.values(messageManager.messageCache.votes).filter(
vote => vote.targetId === commentMessage.id
);
// MANDATORY: Filter out votes with invalid signatures
const filteredVotes = await Promise.all(
votes.map(async vote => {
if (!vote.signature || !vote.browserPubKey) {
console.warn(`Vote ${vote.id} missing signature fields`);
return null;
}
const voteValidation = await messageValidator.getValidationReport(vote);
if (!voteValidation.hasValidSignature) {
console.warn(
`Vote ${vote.id} failed signature validation:`,
voteValidation.errors
);
return null;
}
return vote;
})
).then(votes => votes.filter((vote): vote is typeof vote => vote !== null));
// Votes in cache are already validated
const filteredVotes = votes;
const upvotes = filteredVotes.filter(
(vote): vote is VoteMessage => vote !== null && vote.value === 1
);
@ -316,6 +231,7 @@ export const transformComment = async (
: isUserModerated
? userModMsg!.timestamp
: undefined,
// mark pending for optimistic UI via LocalDatabase lookup
};
// Calculate relevance score if user verification status is provided
@ -340,26 +256,9 @@ export const transformComment = async (
export const transformVote = async (
voteMessage: VoteMessage,
_verifyMessage?: unknown // Deprecated parameter, kept for compatibility
_verifyMessage?: unknown
): Promise<VoteMessage | null> => {
// MANDATORY: All messages must have valid signatures
if (!voteMessage.signature || !voteMessage.browserPubKey) {
console.warn(
`Vote message ${voteMessage.id} missing required signature fields`
);
return null;
}
// Verify signature using the message validator's crypto service
const validationReport =
await messageValidator.getValidationReport(voteMessage);
if (!validationReport.hasValidSignature) {
console.warn(
`Vote message ${voteMessage.id} failed signature validation:`,
validationReport.errors
);
return null;
}
// Message validity already enforced upstream
return voteMessage;
};

View File

@ -1,4 +1,4 @@
import { EVerificationStatus, DisplayPreference } from '@/types/identity';
import { EVerificationStatus, EDisplayPreference } from '@/types/identity';
import {
UnsignedUserProfileUpdateMessage,
UserProfileUpdateMessage,
@ -7,6 +7,7 @@ import {
} from '@/types/waku';
import { MessageService } from './MessageService';
import messageManager from '@/lib/waku';
import { localDatabase } from '@/lib/database/LocalDatabase';
export interface UserIdentity {
address: string;
@ -16,7 +17,7 @@ export interface UserIdentity {
ordinalDetails: string;
};
callSign?: string;
displayPreference: DisplayPreference;
displayPreference: EDisplayPreference;
lastUpdated: number;
verificationStatus: EVerificationStatus;
}
@ -36,7 +37,9 @@ export class UserIdentityService {
// Check internal cache first
if (this.userIdentityCache[address]) {
const cached = this.userIdentityCache[address];
console.log('UserIdentityService: Found in internal cache', cached);
if (import.meta.env?.DEV) {
console.debug('UserIdentityService: cache hit (internal)');
}
return {
address,
ensName: cached.ensName,
@ -44,8 +47,8 @@ export class UserIdentityService {
callSign: cached.callSign,
displayPreference:
cached.displayPreference === 'call-sign'
? DisplayPreference.CALL_SIGN
: DisplayPreference.WALLET_ADDRESS,
? EDisplayPreference.CALL_SIGN
: EDisplayPreference.WALLET_ADDRESS,
lastUpdated: cached.lastUpdated,
verificationStatus: this.mapVerificationStatus(
cached.verificationStatus
@ -53,42 +56,40 @@ export class UserIdentityService {
};
}
// Check CacheService for Waku messages
console.log(
'UserIdentityService: Checking CacheService for address',
address
);
console.log(
'UserIdentityService: messageManager available?',
!!messageManager
);
console.log(
'UserIdentityService: messageCache available?',
!!messageManager?.messageCache
);
console.log(
'UserIdentityService: userIdentities available?',
!!messageManager?.messageCache?.userIdentities
);
console.log(
'UserIdentityService: All userIdentities keys:',
Object.keys(messageManager?.messageCache?.userIdentities || {})
);
const cacheServiceData =
messageManager.messageCache.userIdentities[address];
console.log(
'UserIdentityService: CacheService data for',
// Check LocalDatabase first for persisted identities (warm start)
const persisted = localDatabase.cache.userIdentities[address];
if (persisted) {
this.userIdentityCache[address] = {
ensName: persisted.ensName,
ordinalDetails: persisted.ordinalDetails,
callSign: persisted.callSign,
displayPreference: persisted.displayPreference,
lastUpdated: persisted.lastUpdated,
verificationStatus: persisted.verificationStatus,
};
return {
address,
':',
cacheServiceData
);
ensName: persisted.ensName,
ordinalDetails: persisted.ordinalDetails,
callSign: persisted.callSign,
displayPreference:
persisted.displayPreference === 'call-sign'
? EDisplayPreference.CALL_SIGN
: EDisplayPreference.WALLET_ADDRESS,
lastUpdated: persisted.lastUpdated,
verificationStatus: this.mapVerificationStatus(
persisted.verificationStatus
),
};
}
// Fallback: Check Waku message cache
const cacheServiceData = messageManager.messageCache.userIdentities[address];
if (cacheServiceData) {
console.log(
'UserIdentityService: Found in CacheService',
cacheServiceData
);
if (import.meta.env?.DEV) {
console.debug('UserIdentityService: cache hit (message cache)');
}
// Store in internal cache for future use
this.userIdentityCache[address] = {
@ -107,8 +108,8 @@ export class UserIdentityService {
callSign: cacheServiceData.callSign,
displayPreference:
cacheServiceData.displayPreference === 'call-sign'
? DisplayPreference.CALL_SIGN
: DisplayPreference.WALLET_ADDRESS,
? EDisplayPreference.CALL_SIGN
: EDisplayPreference.WALLET_ADDRESS,
lastUpdated: cacheServiceData.lastUpdated,
verificationStatus: this.mapVerificationStatus(
cacheServiceData.verificationStatus
@ -116,9 +117,9 @@ export class UserIdentityService {
};
}
console.log(
'UserIdentityService: No cached data found, resolving from sources'
);
if (import.meta.env?.DEV) {
console.debug('UserIdentityService: cache miss, resolving');
}
// Try to resolve identity from various sources
const identity = await this.resolveUserIdentity(address);
@ -128,9 +129,9 @@ export class UserIdentityService {
ordinalDetails: identity.ordinalDetails,
callSign: identity.callSign,
displayPreference:
identity.displayPreference === DisplayPreference.CALL_SIGN
? 'call-sign'
: 'wallet-address',
identity.displayPreference === EDisplayPreference.CALL_SIGN
? EDisplayPreference.CALL_SIGN
: EDisplayPreference.WALLET_ADDRESS,
lastUpdated: identity.lastUpdated,
verificationStatus: identity.verificationStatus,
};
@ -150,8 +151,8 @@ export class UserIdentityService {
callSign: cached.callSign,
displayPreference:
cached.displayPreference === 'call-sign'
? DisplayPreference.CALL_SIGN
: DisplayPreference.WALLET_ADDRESS,
? EDisplayPreference.CALL_SIGN
: EDisplayPreference.WALLET_ADDRESS,
lastUpdated: cached.lastUpdated,
verificationStatus: this.mapVerificationStatus(cached.verificationStatus),
}));
@ -163,13 +164,12 @@ export class UserIdentityService {
async updateUserProfile(
address: string,
callSign: string,
displayPreference: DisplayPreference
displayPreference: EDisplayPreference
): Promise<boolean> {
try {
console.log('UserIdentityService: Updating profile for', address, {
callSign,
displayPreference,
});
if (import.meta.env?.DEV) {
console.debug('UserIdentityService: updating profile', { address });
}
const unsignedMessage: UnsignedUserProfileUpdateMessage = {
id: crypto.randomUUID(),
@ -178,23 +178,21 @@ export class UserIdentityService {
author: address,
callSign,
displayPreference:
displayPreference === DisplayPreference.CALL_SIGN
? 'call-sign'
: 'wallet-address',
displayPreference === EDisplayPreference.CALL_SIGN
? EDisplayPreference.CALL_SIGN
: EDisplayPreference.WALLET_ADDRESS,
};
console.log(
'UserIdentityService: Created unsigned message',
unsignedMessage
);
if (import.meta.env?.DEV) {
console.debug('UserIdentityService: created unsigned message');
}
const signedMessage =
await this.messageService.signAndBroadcastMessage(unsignedMessage);
console.log(
'UserIdentityService: Message broadcast result',
!!signedMessage
);
if (import.meta.env?.DEV) {
console.debug('UserIdentityService: message broadcast result', !!signedMessage);
}
return !!signedMessage;
} catch (error) {
@ -216,8 +214,8 @@ export class UserIdentityService {
]);
// Default to wallet address display preference
const defaultDisplayPreference: DisplayPreference =
DisplayPreference.WALLET_ADDRESS;
const defaultDisplayPreference: EDisplayPreference =
EDisplayPreference.WALLET_ADDRESS;
// Default verification status based on what we can resolve
let verificationStatus: EVerificationStatus =
@ -292,9 +290,9 @@ export class UserIdentityService {
ordinalDetails: undefined,
callSign: undefined,
displayPreference:
displayPreference === 'call-sign' ? 'call-sign' : 'wallet-address',
displayPreference === EDisplayPreference.CALL_SIGN ? EDisplayPreference.CALL_SIGN : EDisplayPreference.WALLET_ADDRESS,
lastUpdated: timestamp,
verificationStatus: 'unverified',
verificationStatus: EVerificationStatus.UNVERIFIED,
};
}
@ -303,7 +301,10 @@ export class UserIdentityService {
this.userIdentityCache[author] = {
...this.userIdentityCache[author],
callSign,
displayPreference,
displayPreference:
displayPreference === EDisplayPreference.CALL_SIGN
? EDisplayPreference.CALL_SIGN
: EDisplayPreference.WALLET_ADDRESS,
lastUpdated: timestamp,
};
}

View File

@ -32,7 +32,9 @@ export class WakuNodeManager {
const health = event.detail;
this._currentHealth = health;
console.log(`Waku health status: ${health}`);
if (import.meta.env?.DEV) {
console.debug(`Waku health status: ${health}`);
}
const wasReady = this._isReady;
this._isReady =

View File

@ -1,7 +1,6 @@
import { HealthStatus } from '@waku/sdk';
import { OpchanMessage } from '@/types/forum';
import { WakuNodeManager, HealthChangeCallback } from './core/WakuNodeManager';
import { CacheService } from './services/CacheService';
import {
MessageService,
MessageStatusCallback,
@ -12,12 +11,11 @@ export type { HealthChangeCallback, MessageStatusCallback };
class MessageManager {
private nodeManager: WakuNodeManager | null = null;
private cacheService: CacheService;
// LocalDatabase eliminates the need for CacheService
private messageService: MessageService | null = null;
private reliableMessaging: ReliableMessaging | null = null;
constructor() {
this.cacheService = new CacheService();
}
public static async create(): Promise<MessageManager> {
@ -32,7 +30,6 @@ class MessageManager {
// Now create message service with proper dependencies
this.messageService = new MessageService(
this.cacheService,
this.reliableMessaging,
this.nodeManager
);

View File

@ -56,9 +56,6 @@ export const initializeNetwork = async (
description: 'Connecting to the Waku network...',
});
// Load data from cache immediately - health monitoring will handle network status
updateStateFromCache();
// Check current network status and provide appropriate feedback
if (messageManager.isReady) {
toast({

View File

@ -1,169 +0,0 @@
import {
MessageType,
CellCache,
PostCache,
CommentCache,
VoteCache,
ModerateMessage,
UserProfileUpdateMessage,
UserIdentityCache,
} from '../../../types/waku';
import { OpchanMessage } from '@/types/forum';
import { MessageValidator } from '@/lib/utils/MessageValidator';
export interface MessageCache {
cells: CellCache;
posts: PostCache;
comments: CommentCache;
votes: VoteCache;
moderations: { [targetId: string]: ModerateMessage };
userIdentities: UserIdentityCache;
}
export class CacheService {
private processedMessageIds: Set<string> = new Set();
private validator: MessageValidator;
public readonly cache: MessageCache = {
cells: {},
posts: {},
comments: {},
votes: {},
moderations: {},
userIdentities: {},
};
constructor() {
this.validator = new MessageValidator();
}
public async updateCache(message: unknown): Promise<boolean> {
if (!(await this.validator.isValidMessage(message))) {
const partialMsg = message as {
id?: unknown;
type?: unknown;
signature?: unknown;
browserPubKey?: unknown;
};
console.warn('CacheService: Rejecting invalid message', {
messageId: partialMsg?.id,
messageType: partialMsg?.type,
hasSignature: !!partialMsg?.signature,
hasBrowserPubKey: !!partialMsg?.browserPubKey,
});
return false; // Reject invalid messages
}
// At this point we know message is valid OpchanMessage due to validation above
const validMessage = message as OpchanMessage;
// Check if we've already processed this exact message
const messageKey = `${validMessage.type}:${validMessage.id}:${validMessage.timestamp}`;
if (this.processedMessageIds.has(messageKey)) {
return false; // Already processed
}
this.processedMessageIds.add(messageKey);
this.storeMessage(validMessage);
return true; // Newly processed
}
private storeMessage(message: OpchanMessage): void {
switch (message.type) {
case MessageType.CELL:
if (
!this.cache.cells[message.id] ||
this.cache.cells[message.id]?.timestamp !== message.timestamp
) {
this.cache.cells[message.id] = message;
}
break;
case MessageType.POST:
if (
!this.cache.posts[message.id] ||
this.cache.posts[message.id]?.timestamp !== message.timestamp
) {
this.cache.posts[message.id] = message;
}
break;
case MessageType.COMMENT:
if (
!this.cache.comments[message.id] ||
this.cache.comments[message.id]?.timestamp !== message.timestamp
) {
this.cache.comments[message.id] = message;
}
break;
case MessageType.VOTE: {
const voteKey = `${message.targetId}:${message.author}`;
if (
!this.cache.votes[voteKey] ||
this.cache.votes[voteKey]?.timestamp !== message.timestamp
) {
this.cache.votes[voteKey] = message;
}
break;
}
case MessageType.MODERATE: {
const modMsg = message as ModerateMessage;
if (
!this.cache.moderations[modMsg.targetId] ||
this.cache.moderations[modMsg.targetId]?.timestamp !==
modMsg.timestamp
) {
this.cache.moderations[modMsg.targetId] = modMsg;
}
break;
}
case MessageType.USER_PROFILE_UPDATE: {
const profileMsg = message as UserProfileUpdateMessage;
const { author, callSign, displayPreference, timestamp } = profileMsg;
console.log('CacheService: Storing USER_PROFILE_UPDATE message', {
author,
callSign,
displayPreference,
timestamp,
});
if (
!this.cache.userIdentities[author] ||
this.cache.userIdentities[author]?.lastUpdated !== timestamp
) {
this.cache.userIdentities[author] = {
ensName: undefined,
ordinalDetails: undefined,
callSign,
displayPreference,
lastUpdated: timestamp,
verificationStatus: 'unverified', // Will be updated by UserIdentityService
};
console.log(
'CacheService: Updated user identity cache for',
author,
this.cache.userIdentities[author]
);
} else {
console.log(
'CacheService: Skipping update - same timestamp or already exists'
);
}
break;
}
default:
console.warn('Received message with unknown type');
break;
}
}
public clear(): void {
this.processedMessageIds.clear();
this.cache.cells = {};
this.cache.posts = {};
this.cache.comments = {};
this.cache.votes = {};
this.cache.moderations = {};
this.cache.userIdentities = {};
}
}

View File

@ -1,10 +1,10 @@
import { OpchanMessage } from '@/types/forum';
import { CacheService } from './CacheService';
import {
ReliableMessaging,
MessageStatusCallback,
} from '../core/ReliableMessaging';
import { WakuNodeManager } from '../core/WakuNodeManager';
import { localDatabase } from '@/lib/database/LocalDatabase';
export type MessageReceivedCallback = (message: OpchanMessage) => void;
export type { MessageStatusCallback };
@ -13,7 +13,6 @@ export class MessageService {
private messageReceivedCallbacks: Set<MessageReceivedCallback> = new Set();
constructor(
private cacheService: CacheService,
private reliableMessaging: ReliableMessaging | null,
private nodeManager: WakuNodeManager
) {
@ -23,10 +22,12 @@ export class MessageService {
private setupMessageHandling(): void {
if (this.reliableMessaging) {
this.reliableMessaging.onMessage(async message => {
const isNew = await this.cacheService.updateCache(message);
if (isNew) {
this.messageReceivedCallbacks.forEach(callback => callback(message));
}
localDatabase.setSyncing(true);
const isNew = await localDatabase.updateCache(message);
// Defensive: clear pending on inbound message to avoid stuck state
localDatabase.clearPending(message.id);
localDatabase.setSyncing(false);
if (isNew) this.messageReceivedCallbacks.forEach(cb => cb(message));
});
}
}
@ -45,9 +46,11 @@ export class MessageService {
try {
// Update cache optimistically
await this.cacheService.updateCache(message);
await localDatabase.updateCache(message);
localDatabase.markPending(message.id);
// Send via reliable messaging with status tracking
localDatabase.setSyncing(true);
await this.reliableMessaging.sendMessage(message, {
onSent: id => {
console.log(`Message ${id} sent`);
@ -56,10 +59,15 @@ export class MessageService {
onAcknowledged: id => {
console.log(`Message ${id} acknowledged`);
statusCallback?.onAcknowledged?.(id);
localDatabase.clearPending(message.id);
localDatabase.updateLastSync(Date.now());
localDatabase.setSyncing(false);
},
onError: (id, error) => {
console.error(`Message ${id} failed:`, error);
statusCallback?.onError?.(id, error);
// Keep pending entry to allow retry logic later
localDatabase.setSyncing(false);
},
});
@ -86,7 +94,7 @@ export class MessageService {
}
public get messageCache() {
return this.cacheService.cache;
return localDatabase.cache;
}
public cleanup(): void {

View File

@ -8,7 +8,11 @@ import { ChainNamespace } from '@reown/appkit-common';
import { config } from './config';
import { Provider } from '@reown/appkit-controllers';
import { WalletInfo, ActiveWallet } from './types';
import * as bitcoinMessage from 'bitcoinjs-message';
// Defer importing 'bitcoinjs-message' to avoid Node polyfill warnings in Vite
type BitcoinMessageModule = typeof import('bitcoinjs-message');
let bitcoinMessagePromise: Promise<BitcoinMessageModule> | null = null;
const loadBitcoinMessage = () =>
(bitcoinMessagePromise ??= import('bitcoinjs-message'));
export class WalletManager {
private static instance: WalletManager | null = null;
@ -186,12 +190,10 @@ export class WalletManager {
walletType: 'bitcoin' | 'ethereum'
): Promise<boolean> {
try {
console.log('WalletManager.verifySignature - verifying signature:', {
message,
signature,
walletAddress,
walletType,
});
if (import.meta.env?.DEV) {
// Keep this lightweight in dev; avoid logging full message/signature repeatedly
console.debug('WalletManager.verifySignature', { walletType });
}
if (walletType === 'ethereum') {
return await verifyEthereumMessage(config, {
address: walletAddress as `0x${string}`,
@ -199,19 +201,14 @@ export class WalletManager {
signature: signature as `0x${string}`,
});
} else if (walletType === 'bitcoin') {
console.log(
'WalletManager.verifySignature - verifying bitcoin signature:',
{
message,
walletAddress,
signature,
if (import.meta.env?.DEV) {
console.debug('WalletManager.verifySignature (bitcoin)');
}
);
const bitcoinMessage = await loadBitcoinMessage();
const result = bitcoinMessage.verify(message, walletAddress, signature);
console.log(
'WalletManager.verifySignature - bitcoin signature result:',
result
);
if (import.meta.env?.DEV) {
console.debug('WalletManager.verifySignature (bitcoin) result', result);
}
return result;
}

View File

@ -7,7 +7,7 @@ export type User = {
//TODO: implement call sign & display preference setup
callSign?: string;
displayPreference: DisplayPreference;
displayPreference: EDisplayPreference;
verificationStatus: EVerificationStatus;
@ -34,7 +34,7 @@ export interface EnsDetails {
ensName: string;
}
export enum DisplayPreference {
export enum EDisplayPreference {
CALL_SIGN = 'call-sign',
WALLET_ADDRESS = 'wallet-address',
}

View File

@ -1,3 +1,5 @@
import { EDisplayPreference, EVerificationStatus } from "./identity";
/**
* Message types for Waku communication
*/
@ -111,7 +113,7 @@ export interface ModerateMessage extends BaseMessage {
export interface UserProfileUpdateMessage extends BaseMessage {
type: MessageType.USER_PROFILE_UPDATE;
callSign?: string;
displayPreference: 'call-sign' | 'wallet-address';
displayPreference: EDisplayPreference;
}
/**
@ -160,12 +162,8 @@ export interface UserIdentityCache {
ordinalDetails: string;
};
callSign?: string;
displayPreference: 'call-sign' | 'wallet-address';
displayPreference: EDisplayPreference
lastUpdated: number;
verificationStatus:
| 'unverified'
| 'verified-basic'
| 'verified-owner'
| 'verifying';
verificationStatus: EVerificationStatus
};
}