mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-07 23:33:07 +00:00
feat: indexedDB + local first app
This commit is contained in:
parent
ab77654b81
commit
60fe855779
@ -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.
|
||||
@ -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.
|
||||
@ -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
179
TODO.md
Normal 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*
|
||||
@ -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}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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
48
src/hooks/usePending.ts
Normal 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 };
|
||||
}
|
||||
|
||||
|
||||
317
src/lib/database/LocalDatabase.ts
Normal file
317
src/lib/database/LocalDatabase.ts
Normal 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();
|
||||
|
||||
|
||||
64
src/lib/database/schema.ts
Normal file
64
src/lib/database/schema.ts
Normal 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' });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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
|
||||
);
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 = {};
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user