From b61381b86533162df6b43c5e901109ae131f3b34 Mon Sep 17 00:00:00 2001 From: DocNR Date: Thu, 6 Mar 2025 16:34:50 -0500 Subject: [PATCH] updated with NDK-mobile library and removed custom nostr functions --- CHANGELOG.md | 149 +++--- app/(tabs)/library/programs.tsx | 38 +- app/_layout.tsx | 1 - components/DatabaseProvider.tsx | 78 ++- components/NDKTest.tsx | 153 ++++++ components/library/ExerciseSheet.tsx | 13 +- lib/crypto-polyfill.ts | 91 ---- lib/db/schema.ts | 428 ++++++++--------- lib/db/services/DevSeederService.ts | 49 +- lib/db/services/EventCache.ts | 115 ----- lib/db/services/FavoritesService.ts | 142 ++++++ lib/db/services/PublicationQueueService.ts | 82 +++- lib/hooks/useNDK.ts | 42 +- lib/hooks/useProfile.tsx | 77 +++ lib/hooks/useSubscribe.ts | 71 ++- lib/initNDK.ts | 52 +- lib/mobile-signer.ts | 76 --- lib/stores/ndk.ts | 521 ++++++--------------- stores/workoutStore.ts | 76 +-- 19 files changed, 1063 insertions(+), 1191 deletions(-) create mode 100644 components/NDKTest.tsx delete mode 100644 lib/crypto-polyfill.ts delete mode 100644 lib/db/services/EventCache.ts create mode 100644 lib/db/services/FavoritesService.ts create mode 100644 lib/hooks/useProfile.tsx delete mode 100644 lib/mobile-signer.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d52475..2d5c7af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,25 +5,47 @@ All notable changes to the POWR project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +# Changelog - March 6, 2025 + +## Added +- NDK mobile integration for Nostr functionality + - Added event publishing and subscription capabilities + - Implemented proper type safety for NDK interactions + - Created testing components for NDK functionality verification +- Enhanced exercise management with Nostr support + - Implemented exercise creation, editing, and forking workflows + - Added support for custom exercise event kinds (33401) + - Built exercise publication queue for offline-first functionality +- User profile integration + - Added profile fetching and caching + - Implemented profile-based permissions for content editing + - Fixed type definitions for NDK user profiles +- Robust workout state management + - Fixed favorites persistence in SQLite + - Added template-based workout initialization + - Implemented workout tracking with real-time updates + +## Fixed +- TypeScript errors across multiple components: + - Resolved NDK-related type errors in ExerciseSheet component + - Fixed FavoritesService reference errors in workoutStore + - Corrected null/undefined handling in NDKEvent initialization + - Fixed profile type compatibility in useProfile hook + - Added proper type definitions for NDK UserProfile +- Dependency errors in PublicationQueue and DevSeeder services +- Source and authorization checks for exercise editing permissions +- Component interoperability with NDK mobile + +## Improved +- Refactored code for better type safety +- Enhanced error handling with proper type checking +- Improved Nostr event creation workflow with NDK +- Streamlined user authentication process +- Enhanced development environment with better type checking + ## [Unreleased] ### Added -- Comprehensive exercise management features - - Added exercise editing functionality - - Implemented exercise forking for Nostr exercises - - Created local-first editing with offline support - - Added publication queue for deferred Nostr publishing - - Built robust exercise update workflow - - Implemented source-aware editing permissions -- Connectivity service for network state management - - Added real-time connectivity monitoring - - Implemented persistence for offline state - - Built automatic retry system for failed requests - - Created hook-based connectivity API for components -- Extended database schema for publication queuing - - Added publication_queue table - - Implemented attempt tracking and rate limiting - - Added app_status table for system-wide states - Successful Nostr protocol integration - Implemented NDK-mobile for React Native compatibility - Added secure key management with Expo SecureStore @@ -49,6 +71,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added workout persistence and recovery - Built automatic timer management with background support - Developed minimization and maximization functionality +- Zustand workout store for state management + - Created comprehensive workout state store with Zustand + - Implemented selectors for efficient state access + - Added workout persistence and recovery + - Built automatic timer management with background support + - Developed minimization and maximization functionality - Workout tracking implementation with real-time tracking - Added workout timer with proper background handling - Implemented rest timer functionality @@ -95,24 +123,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved workout history visualization ### Changed -- Enhanced exercise detail viewer - - Replaced bottom sheet with full-screen modal - - Added tabbed interface for information organization - - Implemented edit capability with ownership detection - - Added fork functionality for Nostr exercises from other users - - Improved progress visualization with charts -- Redesigned exercise editor - - Created multi-purpose editor for create/edit/fork workflows - - Added context-aware UI based on exercise source - - Implemented specialized buttons based on workflow type - - Added better form validation and feedback - - Improved keyboard handling across platforms -- Improved workflow architecture for model context protocol - - Implemented offline-first editing paradigm - - Added cryptographic signing before submission - - Built local caching with deferred publishing - - Created connectivity-aware operation queueing - - Added proper error recovery and retry mechanisms - Improved workout screen navigation consistency - Standardized screen transitions and gestures - Added back buttons for clearer navigation @@ -156,12 +166,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Enhanced visual separation between template metadata and content ### Fixed -- Exercise update functionality using delete-recreate pattern -- Exercise data type handling in forking operation -- TypeScript errors in exercise component interfaces -- Nostr event queuing and retry mechanism -- Exercise ownership detection for edit vs fork workflows -- Connectivity monitoring edge cases - Workout navigation gesture handling issues - Workout timer inconsistency during app background state - Exercise deletion functionality @@ -178,22 +182,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Content rendering issues in bottom sheet components ### Technical Details -1. Exercise Management: - - Implemented edit/fork/create workflows with unified interface - - Built local-first editing pattern with offline support - - Added publication queue for deferred Nostr submissions - - Created robust update mechanism in useExercises hook - - Implemented source-aware editing permissions - - Added ownership detection for exercise operations - -2. Connectivity Management: - - Implemented singleton ConnectivityService for app-wide monitoring - - Added NetInfo integration for real-time status detection - - Built React hook for component-level connectivity awareness - - Created database persistence for connectivity state - - Implemented event-based notification system - -3. Nostr Integration: +1. Nostr Integration: - Implemented @nostr-dev-kit/ndk-mobile package for React Native compatibility - Created dedicated NDK store using Zustand for state management - Built secure key storage and retrieval using Expo SecureStore @@ -201,65 +190,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added relay connection management with status tracking - Developed proper error handling for network operations -4. Cryptographic Implementation: +2. Cryptographic Implementation: - Integrated react-native-get-random-values for crypto API polyfill - Implemented NDKMobilePrivateKeySigner for key operations - Added proper key format handling (hex, nsec) - Created secure key generation functionality - Built robust error handling for cryptographic operations -5. Programs Testing Component: +3. Programs Testing Component: - Developed dual-purpose interface for Database and Nostr testing - Implemented login system with key generation and secure storage - Built event creation interface with multiple event kinds - Added event querying and display functionality - Created detailed event inspection with tag visualization - Added relay status monitoring - -6. Database Schema Enforcement: +4. Database Schema Enforcement: - Added CHECK constraints for equipment types - Added CHECK constraints for exercise types - Added CHECK constraints for categories - Proper handling of foreign key constraints - -7. Input Validation: +5. Input Validation: - Equipment options: bodyweight, barbell, dumbbell, kettlebell, machine, cable, other - Exercise types: strength, cardio, bodyweight - Categories: Push, Pull, Legs, Core - Difficulty levels: beginner, intermediate, advanced - Movement patterns: push, pull, squat, hinge, carry, rotation - -8. Error Handling: +6. Error Handling: - Added SQLite error type definitions - Improved error propagation in LibraryService - Added transaction rollback on constraint violations - -9. Database Services: +7. Database Services: - Added EventCache service for Nostr events - Improved ExerciseService with transaction awareness - Added DevSeederService for development data - Enhanced error handling and logging - -10. Workout State Management with Zustand: - - Implemented selector pattern for performance optimization - - Added module-level timer references for background operation - - Created workout persistence with auto-save functionality - - Developed state recovery for crash protection - - Added support for future Nostr integration - - Implemented workout minimization for multi-tasking - -11. Template Details UI Architecture: - - Implemented MaterialTopTabNavigator for content organization - - Created screen-specific components for each tab - - Developed conditional rendering based on template source - - Implemented context-aware action buttons - - Added proper navigation state handling +8. Workout State Management with Zustand: + - Implemented selector pattern for performance optimization + - Added module-level timer references for background operation + - Created workout persistence with auto-save functionality + - Developed state recovery for crash protection + - Added support for future Nostr integration + - Implemented workout minimization for multi-tasking +9. Template Details UI Architecture: + - Implemented MaterialTopTabNavigator for content organization + - Created screen-specific components for each tab + - Developed conditional rendering based on template source + - Implemented context-aware action buttons + - Added proper navigation state handling ### Migration Notes -- Exercise editing now follows an offline-first approach with Nostr awareness -- ExerciseSheet component replaces separate create/edit components -- Exercise updates require proper source and metadata handling -- Publication queue provides automatic retry for Nostr events - Exercise creation now enforces schema constraints - Input validation prevents invalid data entry - Enhanced error messages provide better debugging information diff --git a/app/(tabs)/library/programs.tsx b/app/(tabs)/library/programs.tsx index 1428781..9b25d18 100644 --- a/app/(tabs)/library/programs.tsx +++ b/app/(tabs)/library/programs.tsx @@ -1,6 +1,6 @@ // app/(tabs)/library/programs.tsx import React, { useState, useEffect } from 'react'; -import { View, ScrollView, TextInput, ActivityIndicator, Platform, TouchableOpacity, Modal } from 'react-native'; +import { View, ScrollView, TextInput, ActivityIndicator, Platform, TouchableOpacity } from 'react-native'; import { Text } from '@/components/ui/text'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -8,7 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import NostrLoginSheet from '@/components/sheets/NostrLoginSheet'; import { AlertCircle, CheckCircle2, Database, RefreshCcw, Trash2, - Code, Search, ListFilter, Wifi, Zap, FileJson, X, Info + Code, Search, ListFilter, Wifi, Zap, FileJson } from 'lucide-react-native'; import { useSQLiteContext } from 'expo-sqlite'; import { useNDK, useNDKAuth, useNDKCurrentUser } from '@/lib/hooks/useNDK'; @@ -50,6 +50,7 @@ const initialFilters: FilterOptions = { tags: [], source: [] }; + export default function ProgramsScreen() { const db = useSQLiteContext(); @@ -77,7 +78,7 @@ export default function ProgramsScreen() { const [statusMessage, setStatusMessage] = useState(''); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(false); - const [eventKind, setEventKind] = useState(NostrEventKind.EXERCISE); + const [eventKind, setEventKind] = useState(NostrEventKind.TEXT); const [eventContent, setEventContent] = useState(''); const [isLoginSheetOpen, setIsLoginSheetOpen] = useState(false); @@ -87,7 +88,8 @@ export default function ProgramsScreen() { const { login, logout, generateKeys } = useNDKAuth(); // Tab state - const [activeTab, setActiveTab] = useState('database'); + const [activeTab, setActiveTab] = useState('nostr'); // Default to nostr tab for testing + useEffect(() => { // Check database status checkDatabase(); @@ -261,6 +263,7 @@ export default function ProgramsScreen() { setActiveFilters(totalFilters); // Implement filtering logic for programs when available }; + // NOSTR FUNCTIONS // Handle login dialog @@ -317,15 +320,6 @@ export default function ProgramsScreen() { setEventContent('Hello from POWR App - Test Note'); } } else if (eventKind === NostrEventKind.EXERCISE) { - // Your existing exercise event code - const uniqueId = `exercise-${timestamp}`; - tags.push( - ['d', uniqueId], - ['title', eventContent || 'Test Exercise'], - // Rest of your tags... - ); - } - if (eventKind === NostrEventKind.EXERCISE) { const uniqueId = `exercise-${timestamp}`; tags.push( ['d', uniqueId], @@ -366,7 +360,8 @@ export default function ProgramsScreen() { } // Use the NDK store's publishEvent function - const event = await useNDKStore.getState().publishEvent(eventKind, eventContent, tags); + const content = eventContent || `Test ${eventKind === NostrEventKind.TEXT ? 'note' : 'event'} from POWR App`; + const event = await useNDKStore.getState().publishEvent(eventKind, content, tags); if (event) { // Add the published event to our display list @@ -410,7 +405,7 @@ export default function ProgramsScreen() { // Create a filter for the specific kind const filter = { kinds: [eventKind as number], limit: 20 }; - // Use the NDK store's fetchEventsByFilter function + // Get events using NDK const fetchedEvents = await useNDKStore.getState().fetchEventsByFilter(filter); const displayEvents: DisplayEvent[] = []; @@ -437,6 +432,7 @@ export default function ProgramsScreen() { setLoading(false); } }; + return ( {/* Search bar with filter button */} @@ -497,6 +493,7 @@ export default function ProgramsScreen() { Nostr + {/* Tab Content */} {activeTab === 'database' && ( @@ -690,14 +687,14 @@ export default function ProgramsScreen() { Active Relay: wss://powr.duckdns.org - Note: To publish to additional relays, uncomment them in stores/ndk.ts + Note: To publish to additional relays, update them in stores/ndk.ts )} - {/* Login Modal */} + {/* NostrLoginSheet component */} + {/* Event JSON Viewer */} @@ -878,12 +876,12 @@ export default function ProgramsScreen() { How to test Nostr integration: 1. Click "Login with Nostr" to authenticate - 2. On the login sheet, click "Generate New Keys" to create a new Nostr identity + 2. On the login sheet, click "Generate Key" to create a new Nostr identity 3. Login with the generated keys - 4. Select an event kind (Exercise, Template, or Workout) + 4. Select an event kind (Text Note, Exercise, Template, or Workout) 5. Enter optional content and click "Publish" 6. Use "Query Events" to fetch existing events of the selected kind - Using NDK for Nostr integration provides a more reliable experience than direct WebSocket connections. + Using NDK Mobile for Nostr integration provides a more reliable experience with proper cryptographic operations. diff --git a/app/_layout.tsx b/app/_layout.tsx index 3a8453c..10a2000 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,6 +1,5 @@ // app/_layout.tsx import 'expo-dev-client'; -import '../lib/crypto-polyfill'; // Import crypto polyfill first import '@/global.css'; import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { Stack } from 'expo-router'; diff --git a/components/DatabaseProvider.tsx b/components/DatabaseProvider.tsx index f4a9309..9886e23 100644 --- a/components/DatabaseProvider.tsx +++ b/components/DatabaseProvider.tsx @@ -3,22 +3,27 @@ import { View, ActivityIndicator, Text } from 'react-native'; import { SQLiteProvider, openDatabaseSync, SQLiteDatabase } from 'expo-sqlite'; import { schema } from '@/lib/db/schema'; import { ExerciseService } from '@/lib/db/services/ExerciseService'; -import { EventCache } from '@/lib/db/services/EventCache'; import { DevSeederService } from '@/lib/db/services/DevSeederService'; +import { PublicationQueueService } from '@/lib/db/services/PublicationQueueService'; +import { FavoritesService } from '@/lib/db/services/FavoritesService'; import { logDatabaseInfo } from '@/lib/db/debug'; +import { useNDKStore } from '@/lib/stores/ndk'; // Create context for services interface DatabaseServicesContextValue { exerciseService: ExerciseService | null; - eventCache: EventCache | null; devSeeder: DevSeederService | null; - // Remove NostrService since we're using the hooks-based approach now + publicationQueue: PublicationQueueService | null; + favoritesService: FavoritesService | null; + db: SQLiteDatabase | null; } const DatabaseServicesContext = React.createContext({ exerciseService: null, - eventCache: null, devSeeder: null, + publicationQueue: null, + favoritesService: null, + db: null, }); interface DatabaseProviderProps { @@ -30,9 +35,22 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) { const [error, setError] = React.useState(null); const [services, setServices] = React.useState({ exerciseService: null, - eventCache: null, devSeeder: null, + publicationQueue: null, + favoritesService: null, + db: null, }); + + // Get NDK from store to provide to services + const ndk = useNDKStore(state => state.ndk); + + // Effect to set NDK on services when it becomes available + React.useEffect(() => { + if (ndk && services.devSeeder && services.publicationQueue) { + services.devSeeder.setNDK(ndk); + services.publicationQueue.setNDK(ndk); + } + }, [ndk, services]); React.useEffect(() => { async function initDatabase() { @@ -45,15 +63,27 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) { // Initialize services console.log('[DB] Initializing services...'); - const eventCache = new EventCache(db); const exerciseService = new ExerciseService(db); - const devSeeder = new DevSeederService(db, exerciseService, eventCache); + const devSeeder = new DevSeederService(db, exerciseService); + const publicationQueue = new PublicationQueueService(db); + const favoritesService = new FavoritesService(db); + + // Initialize the favorites service + await favoritesService.initialize(); + + // Initialize NDK on services if available + if (ndk) { + devSeeder.setNDK(ndk); + publicationQueue.setNDK(ndk); + } // Set services setServices({ exerciseService, - eventCache, devSeeder, + publicationQueue, + favoritesService, + db, }); // Seed development database @@ -110,18 +140,34 @@ export function useExerciseService() { return context.exerciseService; } -export function useEventCache() { - const context = React.useContext(DatabaseServicesContext); - if (!context.eventCache) { - throw new Error('Event cache not initialized'); - } - return context.eventCache; -} - export function useDevSeeder() { const context = React.useContext(DatabaseServicesContext); if (!context.devSeeder) { throw new Error('Dev seeder not initialized'); } return context.devSeeder; +} + +export function usePublicationQueue() { + const context = React.useContext(DatabaseServicesContext); + if (!context.publicationQueue) { + throw new Error('Publication queue not initialized'); + } + return context.publicationQueue; +} + +export function useFavoritesService() { + const context = React.useContext(DatabaseServicesContext); + if (!context.favoritesService) { + throw new Error('Favorites service not initialized'); + } + return context.favoritesService; +} + +export function useDatabase() { + const context = React.useContext(DatabaseServicesContext); + if (!context.db) { + throw new Error('Database not initialized'); + } + return context.db; } \ No newline at end of file diff --git a/components/NDKTest.tsx b/components/NDKTest.tsx new file mode 100644 index 0000000..e48dd5b --- /dev/null +++ b/components/NDKTest.tsx @@ -0,0 +1,153 @@ +// components/NDKTest.tsx +import React, { useState, useEffect } from 'react'; +import { View, ScrollView } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Wifi, AlertCircle, Check, RefreshCw, Zap } from 'lucide-react-native'; +import { useNDK, useNDKAuth, useNDKCurrentUser, useNDKEvents } from '@/lib/hooks/useNDK'; +import { useSubscribe } from '@/lib/hooks/useSubscribe'; +import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk-mobile'; +import { NostrEventKind } from '@/types/nostr'; + +export default function NDKTest() { + const { ndk, isLoading: ndkLoading } = useNDK(); + const { isAuthenticated } = useNDKCurrentUser(); + const { publishEvent } = useNDKEvents(); + const [testStatus, setTestStatus] = useState('Ready'); + const [testEvent, setTestEvent] = useState(null); + + // Test subscription to see our own events + const { events: receivedEvents, isLoading: subLoading, resubscribe } = + useSubscribe( + testEvent ? [{ kinds: [1], ids: [testEvent.id] }] : false, + { closeOnEose: false } + ); + + useEffect(() => { + if (receivedEvents.length > 0) { + setTestStatus('Event successfully published and received!'); + } + }, [receivedEvents]); + + const runPublishTest = async () => { + if (!ndk || !isAuthenticated) { + setTestStatus('Error: Not authenticated'); + return; + } + + try { + setTestStatus('Creating test event...'); + + // Create event using NDK Mobile + const event = new NDKEvent(ndk); + event.kind = NDKKind.Text; + event.content = `Testing NDK Mobile from POWR app! ${new Date().toISOString()}`; + + // Sign and publish + await event.publish(); + + setTestEvent(event); + setTestStatus(`Event published with ID: ${event.id}`); + + // Resubscribe to see if we can receive our own event + resubscribe(); + } catch (error) { + console.error('Test error:', error); + setTestStatus(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }; + + const runCustomEventTest = async () => { + if (!ndk || !isAuthenticated) { + setTestStatus('Error: Not authenticated'); + return; + } + + try { + setTestStatus('Creating custom exercise event...'); + + const result = await publishEvent( + NostrEventKind.EXERCISE, + "Test exercise description", + [ + ['d', `exercise-${Date.now()}`], + ['title', 'Test Exercise'], + ['type', 'strength'], + ['category', 'Legs'], + ['format', 'weight', 'reps'], + ['format_units', 'kg', 'count'], + ['equipment', 'barbell'] + ] + ); + + if (result) { + setTestStatus(`Custom event published with ID: ${result.id}`); + } else { + setTestStatus('Failed to publish custom event'); + } + } catch (error) { + console.error('Custom event test error:', error); + setTestStatus(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }; + + return ( + + + + + + NDK Mobile Test + + + + + Status: {ndkLoading ? 'Loading NDK...' : ndk ? 'NDK initialized' : 'Not initialized'} + Authenticated: {isAuthenticated ? 'Yes' : 'No'} + + + + + Test Status: {testStatus} + + + {testEvent && ( + + Event ID: {testEvent.id} + + )} + + {receivedEvents.length > 0 && ( + + + + Event successfully verified + + + )} + + + + + + + + + + ); +} \ No newline at end of file diff --git a/components/library/ExerciseSheet.tsx b/components/library/ExerciseSheet.tsx index cce4f32..431e650 100644 --- a/components/library/ExerciseSheet.tsx +++ b/components/library/ExerciseSheet.tsx @@ -18,8 +18,9 @@ import { ExerciseDisplay } from '@/types/exercise'; import { StorageSource } from '@/types/shared'; +import { NDKEvent } from '@nostr-dev-kit/ndk-mobile'; import { useNDKStore } from '@/lib/stores/ndk'; -import { useEventCache } from '@/components/DatabaseProvider'; +import { useExerciseService, usePublicationQueue } from '@/components/DatabaseProvider'; interface ExerciseSheetProps { isOpen: boolean; @@ -67,7 +68,7 @@ export function ExerciseSheet({ isOpen, onClose, onSubmit, exerciseToEdit, mode: const { isDarkColorScheme } = useColorScheme(); const [formData, setFormData] = useState(DEFAULT_FORM_DATA); const ndkStore = useNDKStore(); - const eventCache = useEventCache(); + const publicationQueue = usePublicationQueue(); // Determine if we're in edit, create, or fork mode const hasExercise = !!exerciseToEdit; @@ -170,11 +171,15 @@ export function ExerciseSheet({ isOpen, onClose, onSubmit, exerciseToEdit, mode: } // Create and attempt to publish the event - const event = await ndkStore.createEvent(33401, exercise.description || '', nostrTags); + const event = new NDKEvent(ndkStore.ndk || undefined); + event.kind = 33401; // Or whatever kind you need + event.content = exercise.description || ''; + event.tags = nostrTags; + await event.sign(); if (event) { // Queue for publication (this will publish immediately if online) - await ndkStore.queueEventForPublishing(event); + await publicationQueue.queueEvent(event); // If this is a new exercise, add nostr to sources if (!exerciseToEdit) { diff --git a/lib/crypto-polyfill.ts b/lib/crypto-polyfill.ts deleted file mode 100644 index 45e0ca2..0000000 --- a/lib/crypto-polyfill.ts +++ /dev/null @@ -1,91 +0,0 @@ -// lib/crypto-polyfill.ts -import 'react-native-get-random-values'; -import * as Crypto from 'expo-crypto'; - -// Set up a more reliable polyfill -export function setupCryptoPolyfill() { - console.log('Setting up crypto polyfill...'); - - // Instead of using Object.defineProperty, let's use a different approach - try { - // First check if crypto exists and has getRandomValues - if (typeof global.crypto === 'undefined') { - (global as any).crypto = {}; - } - - // Only define getRandomValues if it doesn't exist or isn't working - if (!global.crypto.getRandomValues) { - console.log('Defining getRandomValues implementation'); - (global.crypto as any).getRandomValues = function(array: Uint8Array) { - console.log('Custom getRandomValues called'); - try { - return Crypto.getRandomBytes(array.length); - } catch (e) { - console.error('Error in getRandomValues:', e); - throw e; - } - }; - } - - // Test if it works - const testArray = new Uint8Array(8); - try { - const result = global.crypto.getRandomValues(testArray); - console.log('Crypto polyfill test result:', !!result); - return true; - } catch (testError) { - console.error('Crypto test failed:', testError); - return false; - } - } catch (error) { - console.error('Error setting up crypto polyfill:', error); - return false; - } -} - -// Also expose a monkey-patching function for the specific libraries -export function monkeyPatchNostrLibraries() { - try { - console.log('Attempting to monkey-patch nostr libraries...'); - - // Direct monkey patching of the randomBytes function in nostr libraries - // This is an extreme approach but might be necessary - const customRandomBytes = function(length: number): Uint8Array { - console.log('Using custom randomBytes implementation'); - return Crypto.getRandomBytes(length); - }; - - // Try to locate and patch the randomBytes function - try { - // Try to access the module using require - const nobleHashes = require('@noble/hashes/utils'); - if (nobleHashes && nobleHashes.randomBytes) { - console.log('Patching @noble/hashes/utils randomBytes'); - (nobleHashes as any).randomBytes = customRandomBytes; - } - } catch (e) { - console.log('Could not patch @noble/hashes/utils:', e); - } - - // Also try to patch nostr-tools if available - try { - const nostrTools = require('nostr-tools'); - if (nostrTools && nostrTools.crypto && nostrTools.crypto.randomBytes) { - console.log('Patching nostr-tools crypto.randomBytes'); - (nostrTools.crypto as any).randomBytes = customRandomBytes; - } - } catch (e) { - console.log('Could not patch nostr-tools:', e); - } - - return true; - } catch (error) { - console.error('Error in monkey patching:', error); - return false; - } -} - -// Set up the polyfill -setupCryptoPolyfill(); -// Try monkey patching as well -monkeyPatchNostrLibraries(); \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 2ab5601..6399a85 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -2,7 +2,7 @@ import { SQLiteDatabase } from 'expo-sqlite'; import { Platform } from 'react-native'; -export const SCHEMA_VERSION = 5; // Updated to version 5 for publication queue table +export const SCHEMA_VERSION = 1; class Schema { private async getCurrentVersion(db: SQLiteDatabase): Promise { @@ -34,6 +34,14 @@ class Schema { console.log(`[Schema] Initializing database on ${Platform.OS}`); const currentVersion = await this.getCurrentVersion(db); + // If we already have the current version, no need to recreate tables + if (currentVersion === SCHEMA_VERSION) { + console.log(`[Schema] Database already at version ${SCHEMA_VERSION}`); + return; + } + + console.log(`[Schema] Creating tables for version ${SCHEMA_VERSION}`); + // Schema version tracking await db.execAsync(` CREATE TABLE IF NOT EXISTS schema_version ( @@ -42,220 +50,220 @@ class Schema { ); `); - if (currentVersion < 1) { - console.log('[Schema] Performing fresh install'); - - // Drop existing tables if they exist - await db.execAsync(`DROP TABLE IF EXISTS exercise_tags`); - await db.execAsync(`DROP TABLE IF EXISTS exercises`); - await db.execAsync(`DROP TABLE IF EXISTS event_tags`); - await db.execAsync(`DROP TABLE IF EXISTS nostr_events`); - - // Create base tables - await db.execAsync(` - CREATE TABLE exercises ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - type TEXT NOT NULL CHECK(type IN ('strength', 'cardio', 'bodyweight')), - category TEXT NOT NULL, - equipment TEXT, - description TEXT, - format_json TEXT, - format_units_json TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - source TEXT NOT NULL DEFAULT 'local' - ); - `); - - await db.execAsync(` - CREATE TABLE exercise_tags ( - exercise_id TEXT NOT NULL, - tag TEXT NOT NULL, - FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE, - UNIQUE(exercise_id, tag) - ); - CREATE INDEX idx_exercise_tags ON exercise_tags(tag); - `); - - await db.runAsync( - 'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)', - [1, Date.now()] - ); - - console.log('[Schema] Base tables created successfully'); - } - - // Update to version 2 if needed - Nostr support - if (currentVersion < 2) { - console.log('[Schema] Upgrading to version 2'); - - await db.execAsync(` - CREATE TABLE IF NOT EXISTS nostr_events ( - id TEXT PRIMARY KEY, - pubkey TEXT NOT NULL, - kind INTEGER NOT NULL, - created_at INTEGER NOT NULL, - content TEXT NOT NULL, - sig TEXT, - raw_event TEXT NOT NULL, - received_at INTEGER NOT NULL - ); - `); - - await db.execAsync(` - CREATE TABLE IF NOT EXISTS event_tags ( - event_id TEXT NOT NULL, - name TEXT NOT NULL, - value TEXT NOT NULL, - index_num INTEGER NOT NULL, - FOREIGN KEY(event_id) REFERENCES nostr_events(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_event_tags ON event_tags(name, value); - `); - - // Add Nostr reference to exercises - try { - await db.execAsync(`ALTER TABLE exercises ADD COLUMN nostr_event_id TEXT REFERENCES nostr_events(id)`); - } catch (e) { - console.log('[Schema] Note: nostr_event_id column may already exist'); - } - - await db.runAsync( - 'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)', - [2, Date.now()] - ); - - console.log('[Schema] Version 2 upgrade completed'); - } - - // Update to version 3 if needed - Event Cache - if (currentVersion < 3) { - console.log('[Schema] Upgrading to version 3'); - - // Create cache metadata table - await db.execAsync(` - CREATE TABLE IF NOT EXISTS cache_metadata ( - content_id TEXT PRIMARY KEY, - content_type TEXT NOT NULL, - last_accessed INTEGER NOT NULL, - access_count INTEGER NOT NULL DEFAULT 0, - cache_priority INTEGER NOT NULL DEFAULT 0 - ); - `); - - // Create media cache table - await db.execAsync(` - CREATE TABLE IF NOT EXISTS exercise_media ( - exercise_id TEXT NOT NULL, - media_type TEXT NOT NULL, - content BLOB NOT NULL, - thumbnail BLOB, - created_at INTEGER NOT NULL, - FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE - ); - `); - - await db.runAsync( - 'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)', - [3, Date.now()] - ); - - console.log('[Schema] Version 3 upgrade completed'); - } - - // Update to version 4 if needed - User Profiles - if (currentVersion < 4) { - console.log('[Schema] Upgrading to version 4'); - - // Create user profiles table - await db.execAsync(` - CREATE TABLE IF NOT EXISTS user_profiles ( - pubkey TEXT PRIMARY KEY, - name TEXT, - display_name TEXT, - about TEXT, - website TEXT, - picture TEXT, - nip05 TEXT, - lud16 TEXT, - last_updated INTEGER - ); - `); - - // Create index for faster lookup - await db.execAsync(` - CREATE INDEX IF NOT EXISTS idx_user_profiles_last_updated - ON user_profiles(last_updated DESC); - `); - - // Create user relays table for storing preferred relays - await db.execAsync(` - CREATE TABLE IF NOT EXISTS user_relays ( - pubkey TEXT NOT NULL, - relay_url TEXT NOT NULL, - read BOOLEAN NOT NULL DEFAULT 1, - write BOOLEAN NOT NULL DEFAULT 1, - created_at INTEGER NOT NULL, - PRIMARY KEY (pubkey, relay_url), - FOREIGN KEY(pubkey) REFERENCES user_profiles(pubkey) ON DELETE CASCADE - ); - `); - - await db.runAsync( - 'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)', - [4, Date.now()] - ); - - console.log('[Schema] Version 4 upgrade completed'); - } - - // Update to version 5 if needed - Publication Queue - if (currentVersion < 5) { - console.log('[Schema] Upgrading to version 5'); - - // Create publication queue table - await db.execAsync(` - CREATE TABLE IF NOT EXISTS publication_queue ( - event_id TEXT PRIMARY KEY, - attempts INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - last_attempt INTEGER, - payload TEXT NOT NULL, - FOREIGN KEY(event_id) REFERENCES nostr_events(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_publication_queue_created - ON publication_queue(created_at ASC); - `); - - // Create app status table for tracking connectivity - await db.execAsync(` - CREATE TABLE IF NOT EXISTS app_status ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at INTEGER NOT NULL - ); - `); - - await db.runAsync( - 'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)', - [5, Date.now()] - ); - - console.log('[Schema] Version 5 upgrade completed'); - } - - // Verify final schema - const tables = await db.getAllAsync<{ name: string }>( - "SELECT name FROM sqlite_master WHERE type='table'" - ); - console.log('[Schema] Final tables:', tables.map(t => t.name).join(', ')); - console.log(`[Schema] Database initialized at version ${await this.getCurrentVersion(db)}`); + // Drop all existing tables (except schema_version) + await this.dropAllTables(db); + + // Create all tables in their latest form + await this.createAllTables(db); + + // Update schema version + await this.updateSchemaVersion(db); + + console.log(`[Schema] Database initialized at version ${SCHEMA_VERSION}`); } catch (error) { console.error('[Schema] Error creating tables:', error); throw error; } } + + private async dropAllTables(db: SQLiteDatabase): Promise { + // Get list of all tables excluding schema_version + const tables = await db.getAllAsync<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type='table' AND name != 'schema_version'" + ); + + // Drop each table + for (const { name } of tables) { + await db.execAsync(`DROP TABLE IF EXISTS ${name}`); + console.log(`[Schema] Dropped table: ${name}`); + } + } + + private async createAllTables(db: SQLiteDatabase): Promise { + // Create exercises table + await db.execAsync(` + CREATE TABLE exercises ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('strength', 'cardio', 'bodyweight')), + category TEXT NOT NULL, + equipment TEXT, + description TEXT, + format_json TEXT, + format_units_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + source TEXT NOT NULL DEFAULT 'local', + nostr_event_id TEXT + ); + `); + + // Create exercise_tags table + await db.execAsync(` + CREATE TABLE exercise_tags ( + exercise_id TEXT NOT NULL, + tag TEXT NOT NULL, + FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE, + UNIQUE(exercise_id, tag) + ); + CREATE INDEX idx_exercise_tags ON exercise_tags(tag); + `); + + // Create nostr_events table + await db.execAsync(` + CREATE TABLE nostr_events ( + id TEXT PRIMARY KEY, + pubkey TEXT NOT NULL, + kind INTEGER NOT NULL, + created_at INTEGER NOT NULL, + content TEXT NOT NULL, + sig TEXT, + raw_event TEXT NOT NULL, + received_at INTEGER NOT NULL + ); + `); + + // Create event_tags table + await db.execAsync(` + CREATE TABLE event_tags ( + event_id TEXT NOT NULL, + name TEXT NOT NULL, + value TEXT NOT NULL, + index_num INTEGER NOT NULL, + FOREIGN KEY(event_id) REFERENCES nostr_events(id) ON DELETE CASCADE + ); + CREATE INDEX idx_event_tags ON event_tags(name, value); + `); + + // Create cache metadata table + await db.execAsync(` + CREATE TABLE cache_metadata ( + content_id TEXT PRIMARY KEY, + content_type TEXT NOT NULL, + last_accessed INTEGER NOT NULL, + access_count INTEGER NOT NULL DEFAULT 0, + cache_priority INTEGER NOT NULL DEFAULT 0 + ); + `); + + // Create media cache table + await db.execAsync(` + CREATE TABLE exercise_media ( + exercise_id TEXT NOT NULL, + media_type TEXT NOT NULL, + content BLOB NOT NULL, + thumbnail BLOB, + created_at INTEGER NOT NULL, + FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE + ); + `); + + // Create user profiles table + await db.execAsync(` + CREATE TABLE user_profiles ( + pubkey TEXT PRIMARY KEY, + name TEXT, + display_name TEXT, + about TEXT, + website TEXT, + picture TEXT, + nip05 TEXT, + lud16 TEXT, + last_updated INTEGER + ); + CREATE INDEX idx_user_profiles_last_updated ON user_profiles(last_updated DESC); + `); + + // Create user relays table + await db.execAsync(` + CREATE TABLE user_relays ( + pubkey TEXT NOT NULL, + relay_url TEXT NOT NULL, + read BOOLEAN NOT NULL DEFAULT 1, + write BOOLEAN NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + PRIMARY KEY (pubkey, relay_url), + FOREIGN KEY(pubkey) REFERENCES user_profiles(pubkey) ON DELETE CASCADE + ); + `); + + // Create publication queue table + await db.execAsync(` + CREATE TABLE publication_queue ( + event_id TEXT PRIMARY KEY, + attempts INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + last_attempt INTEGER, + payload TEXT NOT NULL, + FOREIGN KEY(event_id) REFERENCES nostr_events(id) ON DELETE CASCADE + ); + CREATE INDEX idx_publication_queue_created ON publication_queue(created_at ASC); + `); + + // Create app status table + await db.execAsync(` + CREATE TABLE app_status ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + `); + + // Create NDK cache table + await db.execAsync(` + CREATE TABLE ndk_cache ( + id TEXT PRIMARY KEY, + event TEXT NOT NULL, + created_at INTEGER NOT NULL, + kind INTEGER NOT NULL + ); + CREATE INDEX idx_ndk_cache_kind ON ndk_cache(kind); + CREATE INDEX idx_ndk_cache_created ON ndk_cache(created_at); + `); + + // Create favorites table + await db.execAsync(` + CREATE TABLE favorites ( + id TEXT PRIMARY KEY, + content_type TEXT NOT NULL, + content_id TEXT NOT NULL, + content TEXT NOT NULL, + pubkey TEXT, + created_at INTEGER NOT NULL, + UNIQUE(content_type, content_id) + ); + CREATE INDEX idx_favorites_content_type ON favorites(content_type); + CREATE INDEX idx_favorites_content_id ON favorites(content_id); + `); + } + + private async updateSchemaVersion(db: SQLiteDatabase): Promise { + // Delete any existing schema version records + await db.runAsync('DELETE FROM schema_version'); + + // Insert the current version + await db.runAsync( + 'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)', + [SCHEMA_VERSION, Date.now()] + ); + } + + async resetDatabase(db: SQLiteDatabase): Promise { + if (!__DEV__) return; // Only allow in development + + try { + console.log('[Schema] Resetting database...'); + + await this.dropAllTables(db); + await this.createAllTables(db); + await this.updateSchemaVersion(db); + + console.log('[Schema] Database reset complete'); + } catch (error) { + console.error('[Schema] Error resetting database:', error); + throw error; + } + } } export const schema = new Schema(); \ No newline at end of file diff --git a/lib/db/services/DevSeederService.ts b/lib/db/services/DevSeederService.ts index c014040..eacc1f0 100644 --- a/lib/db/services/DevSeederService.ts +++ b/lib/db/services/DevSeederService.ts @@ -1,23 +1,25 @@ // lib/db/services/DevSeederService.ts import { SQLiteDatabase } from 'expo-sqlite'; import { ExerciseService } from './ExerciseService'; -import { EventCache } from './EventCache'; import { logDatabaseInfo } from '../debug'; import { mockExerciseEvents, convertNostrToExercise } from '../../mocks/exercises'; +import NDK, { NDKEvent } from '@nostr-dev-kit/ndk-mobile'; export class DevSeederService { private db: SQLiteDatabase; private exerciseService: ExerciseService; - private eventCache: EventCache; + private ndk: NDK | null = null; constructor( db: SQLiteDatabase, - exerciseService: ExerciseService, - eventCache: EventCache + exerciseService: ExerciseService ) { this.db = db; this.exerciseService = exerciseService; - this.eventCache = eventCache; + } + + setNDK(ndk: NDK) { + this.ndk = ndk; } async seedDatabase() { @@ -42,10 +44,36 @@ export class DevSeederService { console.log('Seeding mock exercises...'); // Process all events within the same transaction - for (const event of mockExerciseEvents) { - // Pass true to indicate we're in a transaction - await this.eventCache.setEvent(event, true); - const exercise = convertNostrToExercise(event); + for (const eventData of mockExerciseEvents) { + if (this.ndk) { + // If NDK is available, use it to cache the event + const event = new NDKEvent(this.ndk); + Object.assign(event, eventData); + + // Cache the event in NDK + if (this.ndk) { + const ndkEvent = new NDKEvent(this.ndk); + + // Copy event properties + ndkEvent.kind = eventData.kind; + ndkEvent.content = eventData.content; + ndkEvent.created_at = eventData.created_at; + ndkEvent.tags = eventData.tags; + + // If we have mock signatures, use them + if (eventData.sig) { + ndkEvent.sig = eventData.sig; + ndkEvent.id = eventData.id || ''; + ndkEvent.pubkey = eventData.pubkey || ''; + } else if (this.ndk.signer) { + // Otherwise sign if possible + await ndkEvent.sign(); + } + } + } + + // Create exercise from the mock data regardless of NDK availability + const exercise = convertNostrToExercise(eventData); await this.exerciseService.createExercise(exercise, true); } @@ -73,7 +101,8 @@ export class DevSeederService { 'exercise_tags', 'nostr_events', 'event_tags', - 'cache_metadata' + 'cache_metadata', + 'ndk_cache' // Add the NDK Mobile cache table ]; for (const table of tables) { diff --git a/lib/db/services/EventCache.ts b/lib/db/services/EventCache.ts deleted file mode 100644 index a705c12..0000000 --- a/lib/db/services/EventCache.ts +++ /dev/null @@ -1,115 +0,0 @@ -// lib/db/services/EventCache.ts -import { SQLiteDatabase } from 'expo-sqlite'; -import { NostrEvent } from '@/types/nostr'; - -export class EventCache { - private db: SQLiteDatabase; - private writeBuffer: { query: string; params: any[] }[] = []; - - constructor(db: SQLiteDatabase) { - this.db = db; - } - - async setEvent(event: NostrEvent, inTransaction: boolean = false): Promise { - try { - // Store queries to execute - const queries = [ - { - query: `INSERT OR REPLACE INTO nostr_events - (id, pubkey, kind, created_at, content, sig, raw_event, received_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - params: [ - event.id || '', // Convert undefined to empty string - event.pubkey || '', - event.kind, - event.created_at, - event.content, - event.sig || '', - JSON.stringify(event), - Date.now() - ] - }, - // Add metadata query - { - query: `INSERT OR REPLACE INTO cache_metadata - (content_id, content_type, last_accessed, access_count) - VALUES (?, ?, ?, 1) - ON CONFLICT(content_id) DO UPDATE SET - last_accessed = ?, - access_count = access_count + 1`, - params: [ - event.id || '', - 'event', - Date.now(), - Date.now() - ] - } - ]; - - // Add tag queries - event.tags.forEach((tag, index) => { - queries.push({ - query: `INSERT OR REPLACE INTO event_tags - (event_id, name, value, index_num) - VALUES (?, ?, ?, ?)`, - params: [ - event.id || '', - tag[0] || '', - tag[1] || '', - index - ] - }); - }); - - // If we're already in a transaction, just execute the queries - if (inTransaction) { - for (const { query, params } of queries) { - await this.db.runAsync(query, params); - } - } else { - // Otherwise, wrap in our own transaction - await this.db.withTransactionAsync(async () => { - for (const { query, params } of queries) { - await this.db.runAsync(query, params); - } - }); - } - } catch (error) { - console.error('Error caching event:', error); - throw error; - } - } - - async getEvent(id: string): Promise { - try { - const event = await this.db.getFirstAsync( - `SELECT * FROM nostr_events WHERE id = ?`, - [id] - ); - - if (!event) return null; - - // Get tags - const tags = await this.db.getAllAsync<{ name: string; value: string }>( - `SELECT name, value FROM event_tags WHERE event_id = ? ORDER BY index_num`, - [id] - ); - - // Update access metadata - await this.db.runAsync( - `UPDATE cache_metadata - SET last_accessed = ?, access_count = access_count + 1 - WHERE content_id = ?`, - [Date.now(), id] - ); - - return { - ...event, - tags: tags.map(tag => [tag.name, tag.value]) - }; - } catch (error) { - console.error('Error getting event from cache:', error); - return null; - } - } -} \ No newline at end of file diff --git a/lib/db/services/FavoritesService.ts b/lib/db/services/FavoritesService.ts new file mode 100644 index 0000000..cb84757 --- /dev/null +++ b/lib/db/services/FavoritesService.ts @@ -0,0 +1,142 @@ +// lib/db/services/FavoritesService.ts +import { SQLiteDatabase } from 'expo-sqlite'; +import { generateId } from '@/utils/ids'; + +type ContentType = 'template' | 'exercise' | 'workout'; + +export class FavoritesService { + private db: SQLiteDatabase; + + constructor(db: SQLiteDatabase) { + this.db = db; + } + + async initialize(): Promise { + try { + // Ensure the table exists with the right schema + await this.db.execAsync(` + CREATE TABLE IF NOT EXISTS favorites ( + id TEXT PRIMARY KEY, + content_type TEXT NOT NULL, + content_id TEXT NOT NULL, + content TEXT NOT NULL, + pubkey TEXT, + created_at INTEGER NOT NULL, + UNIQUE(content_type, content_id) + ); + CREATE INDEX IF NOT EXISTS idx_favorites_content ON favorites(content_type, content_id); + `); + } catch (error) { + console.error('[FavoritesService] Error initializing favorites table:', error); + throw error; + } + } + + async addFavorite(contentType: ContentType, contentId: string, content: T, pubkey?: string): Promise { + try { + const id = generateId('local'); + const now = Date.now(); + + await this.db.runAsync( + `INSERT OR REPLACE INTO favorites (id, content_type, content_id, content, pubkey, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + id, + contentType, + contentId, + JSON.stringify(content), + pubkey || null, + now + ] + ); + + return id; + } catch (error) { + console.error('[FavoritesService] Error adding favorite:', error); + throw error; + } + } + + async removeFavorite(contentType: ContentType, contentId: string): Promise { + try { + await this.db.runAsync( + `DELETE FROM favorites WHERE content_type = ? AND content_id = ?`, + [contentType, contentId] + ); + } catch (error) { + console.error('[FavoritesService] Error removing favorite:', error); + throw error; + } + } + + async isFavorite(contentType: ContentType, contentId: string): Promise { + try { + const result = await this.db.getFirstAsync<{ count: number }>( + `SELECT COUNT(*) as count FROM favorites WHERE content_type = ? AND content_id = ?`, + [contentType, contentId] + ); + + return (result?.count || 0) > 0; + } catch (error) { + console.error('[FavoritesService] Error checking favorite status:', error); + return false; + } + } + + async getFavoriteIds(contentType: ContentType): Promise { + try { + const result = await this.db.getAllAsync<{ content_id: string }>( + `SELECT content_id FROM favorites WHERE content_type = ?`, + [contentType] + ); + + return result.map(item => item.content_id); + } catch (error) { + console.error('[FavoritesService] Error fetching favorite IDs:', error); + return []; + } + } + + async getFavorites(contentType: ContentType): Promise> { + try { + const result = await this.db.getAllAsync<{ + id: string, + content_id: string, + content: string, + created_at: number + }>( + `SELECT id, content_id, content, created_at FROM favorites WHERE content_type = ?`, + [contentType] + ); + + return result.map(item => ({ + id: item.content_id, + content: JSON.parse(item.content) as T, + addedAt: item.created_at + })); + } catch (error) { + console.error('[FavoritesService] Error fetching favorites:', error); + return []; + } + } + + async getContentById(contentType: ContentType, contentId: string): Promise { + try { + const result = await this.db.getFirstAsync<{ + content: string + }>( + `SELECT content FROM favorites WHERE content_type = ? AND content_id = ?`, + [contentType, contentId] + ); + + if (result?.content) { + return JSON.parse(result.content) as T; + } + + return null; + } catch (error) { + console.error('[FavoritesService] Error fetching content:', error); + return null; + } + } +} \ No newline at end of file diff --git a/lib/db/services/PublicationQueueService.ts b/lib/db/services/PublicationQueueService.ts index b4fc2a1..478ef60 100644 --- a/lib/db/services/PublicationQueueService.ts +++ b/lib/db/services/PublicationQueueService.ts @@ -1,15 +1,18 @@ // lib/db/services/PublicationQueueService.ts import { SQLiteDatabase } from 'expo-sqlite'; +import NDK, { NDKEvent } from '@nostr-dev-kit/ndk-mobile'; import { NostrEvent } from '@/types/nostr'; -import { EventCache } from './EventCache'; export class PublicationQueueService { private db: SQLiteDatabase; - private eventCache: EventCache; + private ndk: NDK | null = null; - constructor(db: SQLiteDatabase, eventCache: EventCache) { + constructor(db: SQLiteDatabase) { this.db = db; - this.eventCache = eventCache; + } + + setNDK(ndk: NDK) { + this.ndk = ndk; } /** @@ -17,27 +20,36 @@ export class PublicationQueueService { * @param event The Nostr event to queue * @returns Promise that resolves when the event is queued */ - async queueEvent(event: NostrEvent): Promise { + async queueEvent(event: NostrEvent | NDKEvent): Promise { try { - // First, ensure the event is cached - await this.eventCache.setEvent(event); + // Convert to the right format for storage + const eventId = event instanceof NDKEvent ? event.id : event.id || ''; + const payload = event instanceof NDKEvent ? + JSON.stringify(event.rawEvent()) : + JSON.stringify(event); - // Then add to publication queue - const payload = JSON.stringify(event); + // Cache the event if NDK is available + if (this.ndk && event instanceof NDKEvent) { + // NDK handles caching internally during sign and publish + if (!event.sig) { + await event.sign(); + } + } + // Add to publication queue await this.db.runAsync( `INSERT OR REPLACE INTO publication_queue (event_id, attempts, created_at, payload) VALUES (?, ?, ?, ?)`, [ - event.id || '', // Add default empty string if undefined + eventId, 0, Date.now(), - JSON.stringify(event) + payload ] ); - console.log(`[Queue] Event ${event.id} queued for publishing`); + console.log(`[Queue] Event ${eventId} queued for publishing`); } catch (error) { console.error('[Queue] Error queueing event:', error); throw error; @@ -82,6 +94,52 @@ export class PublicationQueueService { } } + /** + * Process pending events using NDK + * @returns Promise that resolves when processing is complete + */ + async processQueue(): Promise { + if (!this.ndk) { + console.log('[Queue] NDK not available, skipping queue processing'); + return; + } + + try { + const pendingEvents = await this.getPendingEvents(); + console.log(`[Queue] Processing ${pendingEvents.length} pending events`); + + for (const item of pendingEvents) { + try { + // Update attempt count + await this.incrementAttempt(item.id); + + // Create NDK event and publish + const event = new NDKEvent(this.ndk); + const rawEvent = item.payload; + + // Copy properties from raw event + event.id = rawEvent.id || ''; + event.pubkey = rawEvent.pubkey || ''; + event.kind = rawEvent.kind || 0; + event.created_at = rawEvent.created_at || Math.floor(Date.now() / 1000); + event.content = rawEvent.content || ''; + event.tags = rawEvent.tags || []; + event.sig = rawEvent.sig || ''; + + // Publish + await event.publish(); + + // Remove from queue on success + await this.removeEvent(item.id); + } catch (error) { + console.error(`[Queue] Failed to publish event ${item.id}:`, error); + } + } + } catch (error) { + console.error('[Queue] Error processing queue:', error); + } + } + /** * Update the attempt count for an event * @param eventId ID of the event diff --git a/lib/hooks/useNDK.ts b/lib/hooks/useNDK.ts index ef4569e..3900ee0 100644 --- a/lib/hooks/useNDK.ts +++ b/lib/hooks/useNDK.ts @@ -1,18 +1,15 @@ // lib/hooks/useNDK.ts import { useEffect } from 'react'; import { useNDKStore } from '@/lib/stores/ndk'; -import type { NDKUser } from '@nostr-dev-kit/ndk'; +import type { NDKUser, NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk-mobile'; -/** - * Hook to access NDK instance and initialization status - */ +// Core hook for NDK access export function useNDK() { - const { ndk, isLoading, error, init, relayStatus } = useNDKStore(state => ({ + const { ndk, isLoading, error, init } = useNDKStore(state => ({ ndk: state.ndk, isLoading: state.isLoading, error: state.error, - init: state.init, - relayStatus: state.relayStatus + init: state.init })); useEffect(() => { @@ -21,22 +18,11 @@ export function useNDK() { } }, [ndk, isLoading, init]); - return { - ndk, - isLoading, - error, - relayStatus - }; + return { ndk, isLoading, error }; } -/** - * Hook to access current NDK user information - */ -export function useNDKCurrentUser(): { - currentUser: NDKUser | null; - isAuthenticated: boolean; - isLoading: boolean; -} { +// Hook for current user info +export function useNDKCurrentUser() { const { currentUser, isAuthenticated, isLoading } = useNDKStore(state => ({ currentUser: state.currentUser, isAuthenticated: state.isAuthenticated, @@ -50,16 +36,14 @@ export function useNDKCurrentUser(): { }; } -/** - * Hook to access NDK authentication methods - */ +// Hook for authentication actions export function useNDKAuth() { - const { login, logout, isAuthenticated, isLoading, generateKeys } = useNDKStore(state => ({ + const { login, logout, generateKeys, isAuthenticated, isLoading } = useNDKStore(state => ({ login: state.login, logout: state.logout, + generateKeys: state.generateKeys, isAuthenticated: state.isAuthenticated, - isLoading: state.isLoading, - generateKeys: state.generateKeys + isLoading: state.isLoading })); return { @@ -71,9 +55,7 @@ export function useNDKAuth() { }; } -/** - * Hook for direct access to Nostr event actions - */ +// New hook for event operations export function useNDKEvents() { const { publishEvent, fetchEventsByFilter } = useNDKStore(state => ({ publishEvent: state.publishEvent, diff --git a/lib/hooks/useProfile.tsx b/lib/hooks/useProfile.tsx new file mode 100644 index 0000000..3a54e95 --- /dev/null +++ b/lib/hooks/useProfile.tsx @@ -0,0 +1,77 @@ +// lib/hooks/useProfile.ts +import { useState, useEffect } from 'react'; +import { NDKUser, NDKUserProfile } from '@nostr-dev-kit/ndk-mobile'; +import { useNDK } from './useNDK'; + +export function useProfile(pubkey: string | undefined) { + const { ndk } = useNDK(); + const [profile, setProfile] = useState(null); + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!ndk || !pubkey) { + setIsLoading(false); + return; + } + + const fetchProfile = async () => { + try { + setIsLoading(true); + setError(null); + + // Create NDK user + const ndkUser = ndk.getUser({ pubkey }); + + // Fetch profile + await ndkUser.fetchProfile(); + + // Normalize profile data, similar to your current implementation + if (ndkUser.profile) { + // Ensure image property exists (some clients use picture instead) + if (!ndkUser.profile.image && (ndkUser.profile as any).picture) { + ndkUser.profile.image = (ndkUser.profile as any).picture; + } + } + + setUser(ndkUser); + setProfile(ndkUser.profile || null); + setIsLoading(false); + } catch (err) { + console.error('Error fetching profile:', err); + setError(err instanceof Error ? err : new Error('Failed to fetch profile')); + setIsLoading(false); + } + }; + + fetchProfile(); + }, [ndk, pubkey]); + + const refreshProfile = async () => { + if (!ndk || !pubkey) return; + + try { + setIsLoading(true); + setError(null); + + const ndkUser = ndk.getUser({ pubkey }); + await ndkUser.fetchProfile(); + + setUser(ndkUser); + setProfile(ndkUser.profile || null); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to refresh profile')); + } finally { + setIsLoading(false); + } + }; + + return { + profile, + user, + isLoading, + error, + refreshProfile + }; +} \ No newline at end of file diff --git a/lib/hooks/useSubscribe.ts b/lib/hooks/useSubscribe.ts index fb3a1f7..4e64d0d 100644 --- a/lib/hooks/useSubscribe.ts +++ b/lib/hooks/useSubscribe.ts @@ -1,24 +1,15 @@ // lib/hooks/useSubscribe.ts -import { useEffect, useState, useRef } from 'react'; -import { NDKFilter, NDKSubscription } from '@nostr-dev-kit/ndk'; -import { NDKEvent, NDKUser } from '@nostr-dev-kit/ndk-mobile'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import { NDKEvent, NDKFilter, NDKSubscription, NDKSubscriptionOptions } from '@nostr-dev-kit/ndk-mobile'; import { useNDK } from './useNDK'; -interface UseSubscribeOptions { +interface UseSubscribeOptions extends Partial { enabled?: boolean; - closeOnEose?: boolean; deduplicate?: boolean; } -/** - * Hook to subscribe to Nostr events - * - * @param filters The NDK filter or array of filters - * @param options Optional configuration options - * @returns Object containing events, loading state, and EOSE status - */ export function useSubscribe( - filters: NDKFilter | NDKFilter[] | false, + filters: NDKFilter[] | false, options: UseSubscribeOptions = {} ) { const { ndk } = useNDK(); @@ -31,40 +22,46 @@ export function useSubscribe( const { enabled = true, closeOnEose = false, - deduplicate = true + deduplicate = true, + ...subscriptionOptions } = options; - useEffect(() => { - // Clean up previous subscription if exists + // Function to clear all events + const clearEvents = useCallback(() => { + setEvents([]); + }, []); + + // Function to manually resubscribe + const resubscribe = useCallback(() => { if (subscriptionRef.current) { subscriptionRef.current.stop(); subscriptionRef.current = null; } - - // Reset state when filters change setEvents([]); setEose(false); - - // Check prerequisites + setIsLoading(true); + }, []); + + useEffect(() => { if (!ndk || !filters || !enabled) { setIsLoading(false); return; } setIsLoading(true); + setEose(false); try { - // Convert single filter to array if needed - const filterArray = Array.isArray(filters) ? filters : [filters]; + // Create subscription with NDK Mobile + const subscription = ndk.subscribe(filters, { + closeOnEose, + ...subscriptionOptions + }); - // Create subscription - const subscription = ndk.subscribe(filterArray); subscriptionRef.current = subscription; - // Handle incoming events subscription.on('event', (event: NDKEvent) => { setEvents(prev => { - // Deduplicate events if enabled if (deduplicate && prev.some(e => e.id === event.id)) { return prev; } @@ -72,15 +69,9 @@ export function useSubscribe( }); }); - // Handle end of stored events subscription.on('eose', () => { setIsLoading(false); setEose(true); - - if (closeOnEose && subscriptionRef.current) { - subscriptionRef.current.stop(); - subscriptionRef.current = null; - } }); } catch (error) { console.error('[useSubscribe] Error:', error); @@ -94,20 +85,14 @@ export function useSubscribe( subscriptionRef.current = null; } }; - }, [ndk, enabled, closeOnEose, deduplicate, JSON.stringify(filters)]); + }, [ndk, enabled, closeOnEose, JSON.stringify(filters), JSON.stringify(subscriptionOptions)]); return { events, isLoading, - eose, - resubscribe: () => { - if (subscriptionRef.current) { - subscriptionRef.current.stop(); - subscriptionRef.current = null; - } - setEvents([]); - setEose(false); - setIsLoading(true); - } + eose, + clearEvents, + resubscribe, + subscription: subscriptionRef.current }; } \ No newline at end of file diff --git a/lib/initNDK.ts b/lib/initNDK.ts index 4ec49ad..37789f8 100644 --- a/lib/initNDK.ts +++ b/lib/initNDK.ts @@ -2,25 +2,21 @@ import 'react-native-get-random-values'; // This must be the first import import NDK, { NDKCacheAdapterSqlite } from '@nostr-dev-kit/ndk-mobile'; import * as SecureStore from 'expo-secure-store'; -import { NDKMobilePrivateKeySigner } from './mobile-signer'; -// Constants for SecureStore -const PRIVATE_KEY_STORAGE_KEY = 'nostr_privkey'; - -// Default relays +// Use the same default relays you have in your current implementation const DEFAULT_RELAYS = [ 'wss://powr.duckdns.org', 'wss://relay.damus.io', 'wss://relay.nostr.band', + 'wss://purplepag.es', 'wss://nos.lol' ]; export async function initializeNDK() { console.log('Initializing NDK with mobile adapter...'); - // Create a mobile-specific cache adapter with a valid maxSize - // The error shows maxSize must be greater than 0 - const cacheAdapter = new NDKCacheAdapterSqlite('powr', 1000); // Use 1000 as maxSize + // Create a mobile-specific cache adapter + const cacheAdapter = new NDKCacheAdapterSqlite('powr', 1000); // Initialize NDK with mobile-specific options const ndk = new NDK({ @@ -36,43 +32,5 @@ export async function initializeNDK() { // Connect to relays await ndk.connect(); - // Set up relay status tracking - const relayStatus: Record = {}; - DEFAULT_RELAYS.forEach(url => { - relayStatus[url] = 'connecting'; - - const relay = ndk.pool.getRelay(url); - if (relay) { - relay.on('connect', () => { - console.log(`Connected to relay: ${url}`); - relayStatus[url] = 'connected'; - }); - - relay.on('disconnect', () => { - console.log(`Disconnected from relay: ${url}`); - relayStatus[url] = 'disconnected'; - }); - } - }); - - // Check for saved private key - try { - const privateKey = await SecureStore.getItemAsync(PRIVATE_KEY_STORAGE_KEY); - if (privateKey) { - console.log('[NDK] Found saved private key, initializing signer'); - - // Create mobile-specific signer with private key - const signer = new NDKMobilePrivateKeySigner(privateKey); - ndk.signer = signer; - - // Log success - console.log('[NDK] Signer initialized successfully'); - } - } catch (error) { - console.error('[NDK] Error initializing with saved key:', error); - // Remove invalid key - await SecureStore.deleteItemAsync(PRIVATE_KEY_STORAGE_KEY); - } - - return { ndk, relayStatus }; + return { ndk }; } \ No newline at end of file diff --git a/lib/mobile-signer.ts b/lib/mobile-signer.ts deleted file mode 100644 index 6b215a9..0000000 --- a/lib/mobile-signer.ts +++ /dev/null @@ -1,76 +0,0 @@ -// lib/mobile-signer.ts -import 'react-native-get-random-values'; // First import - most important! -import * as Crypto from 'expo-crypto'; -import { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk-mobile'; // Import from ndk-mobile -import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; -import * as nostrTools from 'nostr-tools'; - -/** - * A custom signer implementation for React Native - * Extends NDKPrivateKeySigner to handle different key formats - */ -export class NDKMobilePrivateKeySigner extends NDKPrivateKeySigner { - constructor(privateKey: string) { - // Handle different private key formats - let hexKey = privateKey; - - // Convert nsec to hex if needed - if (privateKey.startsWith('nsec')) { - try { - const { type, data } = nostrTools.nip19.decode(privateKey); - if (type === 'nsec') { - // Handle the data as string (already in hex format) - if (typeof data === 'string') { - hexKey = data; - } - // Handle if it's a Uint8Array - else if (data instanceof Uint8Array) { - hexKey = bytesToHex(data); - } - } else { - throw new Error('Not an nsec key'); - } - } catch (e) { - console.error('Error processing nsec key:', e); - throw new Error('Invalid private key format'); - } - } - - // Call the parent constructor with the hex key - super(hexKey); - } -} - -/** - * Generate a new Nostr keypair - * Uses Expo's crypto functions directly instead of relying on polyfills - */ -export function generateKeyPair() { - try { - let privateKeyBytes; - - // Try expo-crypto - privateKeyBytes = Crypto.getRandomBytes(32); - - const privateKey = bytesToHex(privateKeyBytes); - - // Get the public key from the private key using nostr-tools - const publicKey = nostrTools.getPublicKey(privateKeyBytes); - - // Encode keys in bech32 format - // Fixed the parameter types for nsecEncode - it needs Uint8Array not string - const nsec = nostrTools.nip19.nsecEncode(privateKeyBytes); - const npub = nostrTools.nip19.npubEncode(publicKey); - - // Make sure we return the object with all properties - return { - privateKey, - publicKey, - nsec, - npub - }; - } catch (error) { - console.error('[MobileSigner] Error generating key pair:', error); - throw error; // Return the actual error for better debugging - } -} \ No newline at end of file diff --git a/lib/stores/ndk.ts b/lib/stores/ndk.ts index 5f082a3..4e45e47 100644 --- a/lib/stores/ndk.ts +++ b/lib/stores/ndk.ts @@ -1,25 +1,24 @@ // lib/stores/ndk.ts -// IMPORTANT: 'react-native-get-random-values' must be the first import to ensure -// proper crypto polyfill application before other libraries are loaded import 'react-native-get-random-values'; -import { Platform } from 'react-native'; import { create } from 'zustand'; -// Using standard NDK types but importing NDKEvent from ndk-mobile for compatibility -import NDK, { NDKFilter } from '@nostr-dev-kit/ndk'; -import { NDKEvent, NDKUser } from '@nostr-dev-kit/ndk-mobile'; +import NDK, { + NDKEvent, + NDKUser, + NDKRelay, + NDKPrivateKeySigner +} from '@nostr-dev-kit/ndk'; +import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; import * as SecureStore from 'expo-secure-store'; -import * as Crypto from 'expo-crypto'; -import { openDatabaseSync } from 'expo-sqlite'; -import { NDKMobilePrivateKeySigner, generateKeyPair } from '@/lib/mobile-signer'; // Constants for SecureStore const PRIVATE_KEY_STORAGE_KEY = 'nostr_privkey'; // Default relays const DEFAULT_RELAYS = [ - 'wss://powr.duckdns.org', // Your primary relay + 'wss://powr.duckdns.org', 'wss://relay.damus.io', 'wss://relay.nostr.band', + 'wss://purplepag.es', 'wss://nos.lol' ]; @@ -29,7 +28,7 @@ type NDKStoreState = { isLoading: boolean; isAuthenticated: boolean; error: Error | null; - relayStatus: Record; + relayStatus: Record; }; type NDKStoreActions = { @@ -38,14 +37,27 @@ type NDKStoreActions = { logout: () => Promise; generateKeys: () => { privateKey: string; publicKey: string; nsec: string; npub: string }; publishEvent: (kind: number, content: string, tags: string[][]) => Promise; - createEvent: (kind: number, content: string, tags: string[][]) => Promise; - queueEventForPublishing: (event: NDKEvent) => Promise; - processPublicationQueue: () => Promise; - fetchEventsByFilter: (filter: NDKFilter) => Promise; + fetchUserProfile: (pubkey: string) => Promise; + fetchEventsByFilter: (filter: any) => Promise; }; +// Helper to convert byte array to hex string +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map(byte => byte.toString(16).padStart(2, '0')) + .join(''); +} + +// Helper to convert hex string to byte array +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; +} + export const useNDKStore = create((set, get) => ({ - // State properties ndk: null, currentUser: null, isLoading: false, @@ -53,102 +65,53 @@ export const useNDKStore = create((set, get) => error: null, relayStatus: {}, - // Initialize NDK init: async () => { try { console.log('[NDK] Initializing...'); - console.log('NDK init crypto polyfill check:', { - cryptoDefined: typeof global.crypto !== 'undefined', - getRandomValuesDefined: typeof global.crypto?.getRandomValues !== 'undefined' - }); - set({ isLoading: true, error: null }); - // Initialize relay status tracking - const relayStatus: Record = {}; - DEFAULT_RELAYS.forEach(r => { - relayStatus[r] = 'connecting'; - }); - set({ relayStatus }); - - // IMPORTANT: Due to the lack of an Expo config plugin for ndk-mobile, - // we're using a standard NDK initialization approach rather than trying to use - // ndk-mobile's native modules, which require a custom build. - // - // When an Expo plugin becomes available for ndk-mobile, we can remove this - // fallback approach and use the initializeNDK() function directly. - console.log('[NDK] Using standard NDK initialization'); - // Initialize NDK with relays const ndk = new NDK({ explicitRelayUrls: DEFAULT_RELAYS }); - // Connect to relays - await ndk.connect(); - - // Setup relay status updates + // Setup relay status tracking + const relayStatus: Record = {}; DEFAULT_RELAYS.forEach(url => { - const relay = ndk.pool.getRelay(url); - if (relay) { - relay.on('connect', () => { - set(state => ({ - relayStatus: { - ...state.relayStatus, - [url]: 'connected' - } - })); - }); - - relay.on('disconnect', () => { - set(state => ({ - relayStatus: { - ...state.relayStatus, - [url]: 'disconnected' - } - })); - }); - - // Set error status if not connected within timeout - setTimeout(() => { - set(state => { - if (state.relayStatus[url] === 'connecting') { - return { - relayStatus: { - ...state.relayStatus, - [url]: 'error' - } - }; - } - return state; - }); - }, 10000); - } + relayStatus[url] = 'connecting'; }); - set({ ndk }); + // Monitor relay connections + ndk.pool.on('relay:connect', (relay: NDKRelay) => { + console.log(`[NDK] Relay connected: ${relay.url}`); + set(state => ({ + relayStatus: { + ...state.relayStatus, + [relay.url]: 'connected' + } + })); + }); + + ndk.pool.on('relay:disconnect', (relay: NDKRelay) => { + console.log(`[NDK] Relay disconnected: ${relay.url}`); + set(state => ({ + relayStatus: { + ...state.relayStatus, + [relay.url]: 'disconnected' + } + })); + }); + + await ndk.connect(); + set({ ndk, relayStatus }); // Check for saved private key - const privateKey = await SecureStore.getItemAsync(PRIVATE_KEY_STORAGE_KEY); - if (privateKey) { + const privateKeyHex = await SecureStore.getItemAsync(PRIVATE_KEY_STORAGE_KEY); + if (privateKeyHex) { console.log('[NDK] Found saved private key, initializing signer'); try { - // Create mobile-specific signer with private key - const signer = new NDKMobilePrivateKeySigner(privateKey); - ndk.signer = signer; - - // Get user and profile - const user = await ndk.signer.user(); - - if (user) { - console.log('[NDK] User authenticated:', user.pubkey); - await user.fetchProfile(); - set({ - currentUser: user, - isAuthenticated: true - }); - } + await get().login(privateKeyHex); } catch (error) { console.error('[NDK] Error initializing with saved key:', error); // Remove invalid key @@ -156,26 +119,6 @@ export const useNDKStore = create((set, get) => } } - // Set up connectivity monitoring to process publication queue - try { - const { ConnectivityService } = await import('@/lib/db/services/ConnectivityService'); - - // Process queue on initial connection - if (ConnectivityService.getInstance().getConnectionStatus()) { - get().processPublicationQueue(); - } - - // Add listener to process queue when coming online - ConnectivityService.getInstance().addListener((isOnline) => { - if (isOnline) { - console.log('[NDK] Connection restored, processing publication queue'); - get().processPublicationQueue(); - } - }); - } catch (error) { - console.error('[NDK] Error setting up connectivity monitoring:', error); - } - set({ isLoading: false }); } catch (error) { console.error('[NDK] Initialization error:', error); @@ -186,7 +129,7 @@ export const useNDKStore = create((set, get) => } }, - login: async (privateKey?: string) => { + login: async (privateKeyInput?: string) => { set({ isLoading: true, error: null }); try { @@ -196,14 +139,28 @@ export const useNDKStore = create((set, get) => } // If no private key is provided, generate one - let userPrivateKey = privateKey; - if (!userPrivateKey) { - const { privateKey: generatedKey } = get().generateKeys(); - userPrivateKey = generatedKey; + let privateKeyHex = privateKeyInput; + if (!privateKeyHex) { + const { privateKey } = get().generateKeys(); + privateKeyHex = privateKey; } - // Create mobile-specific signer with private key - const signer = new NDKMobilePrivateKeySigner(userPrivateKey); + // Handle nsec format + if (privateKeyHex.startsWith('nsec')) { + try { + const decoded = nip19.decode(privateKeyHex); + if (decoded.type === 'nsec') { + // Get the data as hex + privateKeyHex = bytesToHex(decoded.data as any); + } + } catch (error) { + console.error('Error decoding nsec:', error); + throw new Error('Invalid nsec format'); + } + } + + // Create signer with private key + const signer = new NDKPrivateKeySigner(privateKeyHex); ndk.signer = signer; // Get user @@ -225,8 +182,8 @@ export const useNDKStore = create((set, get) => console.log('[NDK] User profile loaded:', user.profile); } - // Save the private key securely - await SecureStore.setItemAsync(PRIVATE_KEY_STORAGE_KEY, userPrivateKey); + // Save the private key hex string securely + await SecureStore.setItemAsync(PRIVATE_KEY_STORAGE_KEY, privateKeyHex); set({ currentUser: user, @@ -270,7 +227,25 @@ export const useNDKStore = create((set, get) => generateKeys: () => { try { - return generateKeyPair(); + // Generate a new secret key (returns Uint8Array) + const secretKeyBytes = generateSecretKey(); + + // Convert to hex for storage + const privateKey = bytesToHex(secretKeyBytes); + + // Get public key + const publicKey = getPublicKey(secretKeyBytes); + + // Generate nsec and npub + const nsec = nip19.nsecEncode(secretKeyBytes); + const npub = nip19.npubEncode(publicKey); + + return { + privateKey, + publicKey, + nsec, + npub + }; } catch (error) { console.error('[NDK] Error generating keys:', error); set({ error: error instanceof Error ? error : new Error('Failed to generate keys') }); @@ -278,15 +253,6 @@ export const useNDKStore = create((set, get) => } }, - // IMPORTANT: This method uses monkey patching to make event signing work - // in React Native environment. This is necessary because the underlying - // Nostr libraries expect Web Crypto API to be available. - // - // When ndk-mobile gets proper Expo support, this function can be simplified to: - // 1. Create the event - // 2. Call event.sign() directly - // 3. Call event.publish() - // without the monkey patching code. publishEvent: async (kind: number, content: string, tags: string[][]) => { try { const { ndk, isAuthenticated, currentUser } = get(); @@ -300,276 +266,54 @@ export const useNDKStore = create((set, get) => } // Create event - console.log('Creating event...'); const event = new NDKEvent(ndk); event.kind = kind; event.content = content; event.tags = tags; - // MONKEY PATCHING APPROACH: - // This is needed because the standard NDK doesn't properly work with - // React Native's crypto implementation. When ndk-mobile adds proper Expo - // support, this can be removed. - try { - // Define custom function for random bytes generation - const customRandomBytes = (length: number): Uint8Array => { - console.log('Using custom randomBytes in event signing'); - return (Crypto as any).getRandomBytes(length); - }; - - // Try to find and override the randomBytes function - const nostrTools = require('nostr-tools'); - const nobleHashes = require('@noble/hashes/utils'); - - // Backup original functions - const originalNobleRandomBytes = nobleHashes.randomBytes; - - // Override with our implementation - (nobleHashes as any).randomBytes = customRandomBytes; - - // Sign event - console.log('Signing event with patched libraries...'); - await event.sign(); - - // Restore original functions - (nobleHashes as any).randomBytes = originalNobleRandomBytes; - - console.log('Event signed successfully'); - } catch (signError) { - console.error('Error signing event:', signError); - throw signError; - } - - // Publish the event - console.log('Publishing event...'); + // Sign and publish + await event.sign(); await event.publish(); console.log('Event published successfully:', event.id); return event; } catch (error) { console.error('Error publishing event:', error); - console.error('Error details:', error instanceof Error ? error.stack : 'Unknown error'); set({ error: error instanceof Error ? error : new Error('Failed to publish event') }); return null; } }, - - // Create and sign a Nostr event without publishing it - createEvent: async (kind: number, content: string, tags: string[][]): Promise => { + + // Fetch profile for any user + fetchUserProfile: async (pubkey: string) => { try { - const { ndk, isAuthenticated, currentUser } = get(); - + const { ndk } = get(); if (!ndk) { throw new Error('NDK not initialized'); } - if (!isAuthenticated || !currentUser) { - throw new Error('Not authenticated'); - } + const user = ndk.getUser({ pubkey }); + await user.fetchProfile(); - // Create event - const event = new NDKEvent(ndk); - event.kind = kind; - event.content = content; - event.tags = tags; - - // Define custom function for random bytes generation - const customRandomBytes = (length: number): Uint8Array => { - console.log('Using custom randomBytes in event signing'); - return (Crypto as any).getRandomBytes(length); - }; - - // Try to find and override the randomBytes function - const nostrTools = require('nostr-tools'); - const nobleHashes = require('@noble/hashes/utils'); - - // Backup original functions - const originalNobleRandomBytes = nobleHashes.randomBytes; - - // Override with our implementation - (nobleHashes as any).randomBytes = customRandomBytes; - - // Sign the event but don't publish - try { - await event.sign(); - } finally { - // Restore original functions - (nobleHashes as any).randomBytes = originalNobleRandomBytes; - } - - return event; + return user; } catch (error) { - console.error('Error creating event:', error); - set({ error: error instanceof Error ? error : new Error('Failed to create event') }); + console.error('Error fetching user profile:', error); + set({ error: error instanceof Error ? error : new Error('Failed to fetch user profile') }); return null; } }, - - // Queue an event for publishing when online - queueEventForPublishing: async (event: NDKEvent): Promise => { - try { - // Only proceed if the event has an ID and signature - if (!event.id || !event.sig) { - throw new Error('Event must be signed before queueing'); - } - - // First cache the event itself - try { - const EventCache = (await import('@/lib/db/services/EventCache')).EventCache; - const db = openDatabaseSync('powr.db'); - const cache = new EventCache(db); - - // Convert NDKEvent to NostrEvent for caching - await cache.setEvent({ - id: event.id, - pubkey: event.pubkey, - kind: event.kind || 0, - created_at: event.created_at || Math.floor(Date.now() / 1000), - content: event.content, - tags: event.tags.map(tag => tag.map(item => String(item))), - sig: event.sig - }); - - // Then add to publication queue - await db.runAsync( - `INSERT OR REPLACE INTO publication_queue - (event_id, attempts, created_at, payload) - VALUES (?, ?, ?, ?)`, - [ - event.id, - 0, - Date.now(), - JSON.stringify({ - id: event.id, - pubkey: event.pubkey, - kind: event.kind, - created_at: event.created_at, - content: event.content, - tags: event.tags, - sig: event.sig - }) - ] - ); - } catch (cacheError) { - console.error('Error caching event:', cacheError); - // Continue to try publishing even if caching fails - } - - // Try to publish immediately if online - try { - const ConnectivityService = (await import('@/lib/db/services/ConnectivityService')).ConnectivityService; - - if (ConnectivityService.getInstance().getConnectionStatus()) { - try { - await event.publish(); - - // Remove from queue if successful - const db = openDatabaseSync('powr.db'); - await db.runAsync( - `DELETE FROM publication_queue WHERE event_id = ?`, - [event.id] - ); - - console.log('Event published successfully:', event.id); - return true; - } catch (publishError) { - console.log('Event queued for later publishing:', event.id); - return false; - } - } else { - console.log('Event queued for later publishing (offline):', event.id); - return false; - } - } catch (connectivityError) { - console.error('Error checking connectivity:', connectivityError); - // Assume offline if connectivity service fails - return false; - } - } catch (error) { - console.error('Error queueing event for publishing:', error); - return false; - } - }, - - // Process the publication queue - processPublicationQueue: async (): Promise => { + + // Fetch events by filter + fetchEventsByFilter: async (filter: any) => { try { const { ndk } = get(); - if (!ndk) return; - - const db = openDatabaseSync('powr.db'); - - // Get all queued events that haven't exceeded max attempts - const queuedEvents = await db.getAllAsync<{ - event_id: string; - attempts: number; - payload: string; - }>( - `SELECT event_id, attempts, payload - FROM publication_queue - WHERE attempts < 5 - ORDER BY created_at ASC` - ); - - console.log(`Processing publication queue: ${queuedEvents.length} events`); - - for (const item of queuedEvents) { - try { - // Update attempt count and timestamp - await db.runAsync( - `UPDATE publication_queue - SET attempts = attempts + 1, - last_attempt = ? - WHERE event_id = ?`, - [Date.now(), item.event_id] - ); - - // Parse the event from payload - const eventData = JSON.parse(item.payload); - - // Create a new NDKEvent - const event = new NDKEvent(ndk); - - // Copy properties - event.id = eventData.id; - event.pubkey = eventData.pubkey; - event.kind = eventData.kind; - event.created_at = eventData.created_at; - event.content = eventData.content; - event.tags = eventData.tags; - event.sig = eventData.sig; - - // Publish the event - await event.publish(); - - // Remove from queue on success - await db.runAsync( - `DELETE FROM publication_queue WHERE event_id = ?`, - [item.event_id] - ); - - console.log(`Published queued event: ${item.event_id}`); - } catch (error) { - console.error(`Error publishing queued event ${item.event_id}:`, error); - } - } - } catch (error) { - console.error('Error processing publication queue:', error); - } - }, - - fetchEventsByFilter: async (filter: NDKFilter) => { - try { - const { ndk } = get(); - if (!ndk) { throw new Error('NDK not initialized'); } - // Fetch events + // Fetch events using NDK const events = await ndk.fetchEvents(filter); - // Convert Set to Array return Array.from(events); } catch (error) { console.error('Error fetching events:', error); @@ -577,4 +321,39 @@ export const useNDKStore = create((set, get) => return []; } } -})); \ No newline at end of file +})); + +// Export hooks for using the store +export function useNDK() { + return useNDKStore(state => ({ + ndk: state.ndk, + isLoading: state.isLoading, + error: state.error, + init: state.init + })); +} + +export function useNDKCurrentUser() { + return useNDKStore(state => ({ + currentUser: state.currentUser, + isAuthenticated: state.isAuthenticated, + isLoading: state.isLoading + })); +} + +export function useNDKAuth() { + return useNDKStore(state => ({ + login: state.login, + logout: state.logout, + generateKeys: state.generateKeys, + isAuthenticated: state.isAuthenticated, + isLoading: state.isLoading + })); +} + +export function useNDKEvents() { + return useNDKStore(state => ({ + publishEvent: state.publishEvent, + fetchEventsByFilter: state.fetchEventsByFilter + })); +} \ No newline at end of file diff --git a/stores/workoutStore.ts b/stores/workoutStore.ts index 9e1f4c0..67df21c 100644 --- a/stores/workoutStore.ts +++ b/stores/workoutStore.ts @@ -19,6 +19,8 @@ import type { } from '@/types/templates'; import type { BaseExercise } from '@/types/exercise'; import { openDatabaseSync } from 'expo-sqlite'; +import { FavoritesService } from '@/lib/db/services/FavoritesService'; + const AUTO_SAVE_INTERVAL = 30000; // 30 seconds @@ -500,27 +502,12 @@ const useWorkoutStoreBase = create { try { + // Get the favorites service through a local import trick since we can't use hooks here const db = openDatabaseSync('powr.db'); + const favoritesService = new FavoritesService(db); - // Ensure favorites table exists - await db.execAsync(` - CREATE TABLE IF NOT EXISTS favorites ( - id TEXT PRIMARY KEY, - content_type TEXT NOT NULL, - content_id TEXT NOT NULL, - content TEXT NOT NULL, - created_at INTEGER NOT NULL, - UNIQUE(content_type, content_id) - ); - CREATE INDEX IF NOT EXISTS idx_favorites_content ON favorites(content_type, content_id); - `); - - // Load just the IDs from SQLite - const result = await db.getAllAsync<{ - content_id: string - }>(`SELECT content_id FROM favorites WHERE content_type = 'template'`); - - const favoriteIds = result.map(item => item.content_id); + // Load just the IDs + const favoriteIds = await favoritesService.getFavoriteIds('template'); set({ favoriteIds, @@ -528,7 +515,6 @@ const useWorkoutStoreBase = create '?').join(','); - - // Get full content for all favorited templates - const result = await db.getAllAsync<{ - id: string, - content_type: string, - content_id: string, - content: string, - created_at: number - }>(`SELECT * FROM favorites WHERE content_type = 'template' AND content_id IN (${placeholders})`, - get().favoriteIds - ); - - return result.map(item => ({ - id: item.content_id, - content: JSON.parse(item.content), - addedAt: item.created_at - })); + return await favoritesService.getFavorites('template'); } catch (error) { console.error('Error fetching favorites content:', error); return []; } }, - + addFavorite: async (template: WorkoutTemplate) => { try { const db = openDatabaseSync('powr.db'); - const now = Date.now(); + const favoritesService = new FavoritesService(db); - // Add to SQLite database - await db.runAsync( - `INSERT OR REPLACE INTO favorites (id, content_type, content_id, content, created_at) - VALUES (?, ?, ?, ?, ?)`, - [ - generateId('local'), // Generate a unique ID for the favorite entry - 'template', - template.id, - JSON.stringify(template), - now - ] - ); + // Add to favorites database + await favoritesService.addFavorite('template', template.id, template); // Update just the ID in memory state set(state => { @@ -610,16 +568,14 @@ const useWorkoutStoreBase = create { try { const db = openDatabaseSync('powr.db'); + const favoritesService = new FavoritesService(db); - // Remove from SQLite database - await db.runAsync( - `DELETE FROM favorites WHERE content_type = 'template' AND content_id = ?`, - [templateId] - ); + // Remove from favorites database + await favoritesService.removeFavorite('template', templateId); // Update IDs in memory state set(state => ({