mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-03 15:52:06 +00:00
updated with NDK-mobile library and removed custom nostr functions
This commit is contained in:
parent
2564450333
commit
b61381b865
149
CHANGELOG.md
149
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
|
||||
|
@ -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<DisplayEvent[]>([]);
|
||||
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 (
|
||||
<View className="flex-1 bg-background">
|
||||
{/* Search bar with filter button */}
|
||||
@ -497,6 +493,7 @@ export default function ProgramsScreen() {
|
||||
<Text className={activeTab === 'nostr' ? 'text-white' : 'text-foreground'}>Nostr</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'database' && (
|
||||
<ScrollView className="flex-1 p-4">
|
||||
@ -690,14 +687,14 @@ export default function ProgramsScreen() {
|
||||
<Text className="font-medium mt-3">Active Relay:</Text>
|
||||
<Text className="text-sm text-muted-foreground">wss://powr.duckdns.org</Text>
|
||||
<Text className="text-xs text-muted-foreground mt-1">
|
||||
Note: To publish to additional relays, uncomment them in stores/ndk.ts
|
||||
Note: To publish to additional relays, update them in stores/ndk.ts
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Login Modal */}
|
||||
{/* NostrLoginSheet component */}
|
||||
<NostrLoginSheet
|
||||
open={isLoginSheetOpen}
|
||||
onClose={handleCloseLogin}
|
||||
@ -839,6 +836,7 @@ export default function ProgramsScreen() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Event JSON Viewer */}
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
@ -878,12 +876,12 @@ export default function ProgramsScreen() {
|
||||
<Text className="font-medium mb-2">How to test Nostr integration:</Text>
|
||||
<View className="space-y-2">
|
||||
<Text>1. Click "Login with Nostr" to authenticate</Text>
|
||||
<Text>2. On the login sheet, click "Generate New Keys" to create a new Nostr identity</Text>
|
||||
<Text>2. On the login sheet, click "Generate Key" to create a new Nostr identity</Text>
|
||||
<Text>3. Login with the generated keys</Text>
|
||||
<Text>4. Select an event kind (Exercise, Template, or Workout)</Text>
|
||||
<Text>4. Select an event kind (Text Note, Exercise, Template, or Workout)</Text>
|
||||
<Text>5. Enter optional content and click "Publish"</Text>
|
||||
<Text>6. Use "Query Events" to fetch existing events of the selected kind</Text>
|
||||
<Text className="mt-2 text-muted-foreground">Using NDK for Nostr integration provides a more reliable experience than direct WebSocket connections.</Text>
|
||||
<Text className="mt-2 text-muted-foreground">Using NDK Mobile for Nostr integration provides a more reliable experience with proper cryptographic operations.</Text>
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
@ -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';
|
||||
|
@ -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<DatabaseServicesContextValue>({
|
||||
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<string | null>(null);
|
||||
const [services, setServices] = React.useState<DatabaseServicesContextValue>({
|
||||
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;
|
||||
}
|
153
components/NDKTest.tsx
Normal file
153
components/NDKTest.tsx
Normal file
@ -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<string>('Ready');
|
||||
const [testEvent, setTestEvent] = useState<NDKEvent | null>(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 (
|
||||
<ScrollView className="flex-1 p-4">
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex-row items-center gap-2">
|
||||
<Wifi size={20} className="text-foreground" />
|
||||
<Text className="text-lg font-semibold">NDK Mobile Test</Text>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<View className="space-y-4">
|
||||
<Text>Status: {ndkLoading ? 'Loading NDK...' : ndk ? 'NDK initialized' : 'Not initialized'}</Text>
|
||||
<Text>Authenticated: {isAuthenticated ? 'Yes' : 'No'}</Text>
|
||||
|
||||
<View className="bg-muted p-3 rounded-md">
|
||||
<View className="flex-row items-center mb-2">
|
||||
<Zap size={16} className="mr-3 text-primary" />
|
||||
<Text className="font-semibold">Test Status: {testStatus}</Text>
|
||||
</View>
|
||||
|
||||
{testEvent && (
|
||||
<Text className="text-xs text-muted-foreground mt-2">
|
||||
Event ID: {testEvent.id}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{receivedEvents.length > 0 && (
|
||||
<View className="mt-2 border-t border-border pt-2">
|
||||
<View className="flex-row items-center">
|
||||
<Check size={16} className="mr-2 text-green-500" />
|
||||
<Text className="text-green-500">Event successfully verified</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
onPress={runPublishTest}
|
||||
disabled={!isAuthenticated || ndkLoading}
|
||||
className="w-full"
|
||||
>
|
||||
<RefreshCw size={18} className="mr-2 text-primary-foreground" />
|
||||
<Text className="text-primary-foreground">Test Basic Event</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onPress={runCustomEventTest}
|
||||
disabled={!isAuthenticated || ndkLoading}
|
||||
className="w-full"
|
||||
>
|
||||
<Zap size={18} className="mr-2 text-primary-foreground" />
|
||||
<Text className="text-primary-foreground">Test Custom Event</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
@ -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) {
|
||||
|
@ -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();
|
428
lib/db/schema.ts
428
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<number> {
|
||||
@ -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<void> {
|
||||
// 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<void> {
|
||||
// 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<void> {
|
||||
// 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<void> {
|
||||
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();
|
@ -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) {
|
||||
|
@ -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<void> {
|
||||
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<NostrEvent | null> {
|
||||
try {
|
||||
const event = await this.db.getFirstAsync<any>(
|
||||
`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;
|
||||
}
|
||||
}
|
||||
}
|
142
lib/db/services/FavoritesService.ts
Normal file
142
lib/db/services/FavoritesService.ts
Normal file
@ -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<void> {
|
||||
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<T>(contentType: ContentType, contentId: string, content: T, pubkey?: string): Promise<string> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<string[]> {
|
||||
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<T>(contentType: ContentType): Promise<Array<{id: string, content: T, addedAt: number}>> {
|
||||
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<T>(contentType: ContentType, contentId: string): Promise<T | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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<void> {
|
||||
async queueEvent(event: NostrEvent | NDKEvent): Promise<void> {
|
||||
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<void> {
|
||||
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
|
||||
|
@ -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,
|
||||
|
77
lib/hooks/useProfile.tsx
Normal file
77
lib/hooks/useProfile.tsx
Normal file
@ -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<NDKUserProfile | null>(null);
|
||||
const [user, setUser] = useState<NDKUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(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
|
||||
};
|
||||
}
|
@ -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<NDKSubscriptionOptions> {
|
||||
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
|
||||
};
|
||||
}
|
@ -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<string, 'connected' | 'connecting' | 'disconnected' | 'error'> = {};
|
||||
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 };
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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<string, 'connected' | 'connecting' | 'disconnected' | 'error'>;
|
||||
relayStatus: Record<string, 'connected' | 'connecting' | 'disconnected'>;
|
||||
};
|
||||
|
||||
type NDKStoreActions = {
|
||||
@ -38,14 +37,27 @@ type NDKStoreActions = {
|
||||
logout: () => Promise<void>;
|
||||
generateKeys: () => { privateKey: string; publicKey: string; nsec: string; npub: string };
|
||||
publishEvent: (kind: number, content: string, tags: string[][]) => Promise<NDKEvent | null>;
|
||||
createEvent: (kind: number, content: string, tags: string[][]) => Promise<NDKEvent | null>;
|
||||
queueEventForPublishing: (event: NDKEvent) => Promise<boolean>;
|
||||
processPublicationQueue: () => Promise<void>;
|
||||
fetchEventsByFilter: (filter: NDKFilter) => Promise<NDKEvent[]>;
|
||||
fetchUserProfile: (pubkey: string) => Promise<NDKUser | null>;
|
||||
fetchEventsByFilter: (filter: any) => Promise<NDKEvent[]>;
|
||||
};
|
||||
|
||||
// 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<NDKStoreState & NDKStoreActions>((set, get) => ({
|
||||
// State properties
|
||||
ndk: null,
|
||||
currentUser: null,
|
||||
isLoading: false,
|
||||
@ -53,102 +65,53 @@ export const useNDKStore = create<NDKStoreState & NDKStoreActions>((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<string, 'connected' | 'connecting' | 'disconnected' | 'error'> = {};
|
||||
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<string, 'connected' | 'connecting' | 'disconnected'> = {};
|
||||
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<NDKStoreState & NDKStoreActions>((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<NDKStoreState & NDKStoreActions>((set, get) =>
|
||||
}
|
||||
},
|
||||
|
||||
login: async (privateKey?: string) => {
|
||||
login: async (privateKeyInput?: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
@ -196,14 +139,28 @@ export const useNDKStore = create<NDKStoreState & NDKStoreActions>((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<NDKStoreState & NDKStoreActions>((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<NDKStoreState & NDKStoreActions>((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<NDKStoreState & NDKStoreActions>((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<NDKStoreState & NDKStoreActions>((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<NDKEvent | null> => {
|
||||
|
||||
// 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<boolean> => {
|
||||
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<void> => {
|
||||
|
||||
// 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<NDKStoreState & NDKStoreActions>((set, get) =>
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}));
|
||||
}));
|
||||
|
||||
// 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
|
||||
}));
|
||||
}
|
@ -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<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
// Favorite Management with SQLite persistence - IMPROVED VERSION
|
||||
loadFavorites: async () => {
|
||||
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<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
});
|
||||
|
||||
console.log(`Loaded ${favoriteIds.length} favorite IDs from database`);
|
||||
// Don't return anything (void)
|
||||
} catch (error) {
|
||||
console.error('Error loading favorites:', error);
|
||||
set({ favoritesLoaded: true }); // Mark as loaded even on error
|
||||
@ -548,52 +534,24 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
return [];
|
||||
}
|
||||
|
||||
// Query the full content from SQLite
|
||||
try {
|
||||
const db = openDatabaseSync('powr.db');
|
||||
const favoritesService = new FavoritesService(db);
|
||||
|
||||
// Generate placeholders for the SQL query
|
||||
const placeholders = get().favoriteIds.map(() => '?').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<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
removeFavorite: async (templateId: string) => {
|
||||
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 => ({
|
||||
|
Loading…
x
Reference in New Issue
Block a user