diff --git a/app.json b/app.json index 7dd0c85..789e4fc 100644 --- a/app.json +++ b/app.json @@ -54,10 +54,11 @@ "enableFTS": true } } - ] + ], + "expo-secure-store" ], "experiments": { "typedRoutes": true } } -} \ No newline at end of file +} diff --git a/app/(tabs)/library/templates.tsx b/app/(tabs)/library/templates.tsx index 21a36ba..00597a2 100644 --- a/app/(tabs)/library/templates.tsx +++ b/app/(tabs)/library/templates.tsx @@ -207,7 +207,7 @@ export default function TemplatesScreen() { - {/* Rest of the components (sheets & FAB) remain the same */} + {/* Template Details with tabs */} {selectedTemplate && ( (undefined); + const [bannerImageUrl, setBannerImageUrl] = useState(undefined); + const [aboutText, setAboutText] = useState(undefined); + const [isLoginSheetOpen, setIsLoginSheetOpen] = useState(false); + + const displayName = isAuthenticated + ? (currentUser?.profile?.displayName || currentUser?.profile?.name || 'Nostr User') + : 'Guest User'; + + const username = isAuthenticated + ? (currentUser?.profile?.nip05 || '@user') + : '@guest'; + + // Reset profile data when authentication state changes + useEffect(() => { + if (!isAuthenticated) { + setProfileImageUrl(undefined); + setBannerImageUrl(undefined); + setAboutText(undefined); + } + }, [isAuthenticated]); + + // Extract profile data from Nostr profile + useEffect(() => { + if (currentUser?.profile) { + console.log('Profile data:', currentUser.profile); + + // Extract image URL + const imageUrl = currentUser.profile.image || + currentUser.profile.picture || + (currentUser.profile as any).avatar || + undefined; + setProfileImageUrl(imageUrl); + + // Extract banner URL + const bannerUrl = currentUser.profile.banner || + (currentUser.profile as any).background || + undefined; + setBannerImageUrl(bannerUrl); + + // Extract about text + const about = currentUser.profile.about || + (currentUser.profile as any).description || + undefined; + setAboutText(about); + } + }, [currentUser?.profile]); + + // Show different UI when not authenticated + if (!isAuthenticated) { + return ( + +
+ + + +

Guest User

+ Not logged in + + Login with your Nostr private key to view and manage your profile. + + +
+
+ + {/* NostrLoginSheet */} + setIsLoginSheetOpen(false)} + /> + + ); + } return ( @@ -35,19 +120,43 @@ export default function ProfileScreen() { paddingBottom: insets.bottom + 20 }} > - {/* Profile content remains the same */} - - - - - JD - - -

John Doe

- @johndoe + {/* Banner Image */} + + {bannerImageUrl ? ( + + + + ) : ( + + )} + + + {/* Profile Avatar and Name - positioned to overlap the banner */} + + +

{displayName}

+ {username} + + {/* About section */} + {aboutText && ( + + {aboutText} + + )}
- + {/* Stats */} + 24 Workouts diff --git a/app/_layout.tsx b/app/_layout.tsx index b263564..b9ff0d0 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,3 +1,4 @@ +// app/_layout.tsx import '@/global.css'; import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { Stack } from 'expo-router'; @@ -11,6 +12,9 @@ import { setAndroidNavigationBar } from '@/lib/android-navigation-bar'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { DatabaseProvider } from '@/components/DatabaseProvider'; import { ErrorBoundary } from '@/components/ErrorBoundary'; +import { SettingsDrawerProvider } from '@/lib/contexts/SettingsDrawerContext'; +import SettingsDrawer from '@/components/SettingsDrawer'; +import { useNDKStore } from '@/lib/stores/ndk'; const LIGHT_THEME = { ...DefaultTheme, @@ -25,21 +29,26 @@ const DARK_THEME = { export default function RootLayout() { const [isInitialized, setIsInitialized] = React.useState(false); const { colorScheme, isDarkColorScheme } = useColorScheme(); + const { init } = useNDKStore(); React.useEffect(() => { - async function init() { + async function initApp() { try { if (Platform.OS === 'web') { document.documentElement.classList.add('bg-background'); } setAndroidNavigationBar(colorScheme); + + // Initialize NDK + await init(); + setIsInitialized(true); } catch (error) { console.error('Failed to initialize:', error); } } - init(); + initApp(); }, []); if (!isInitialized) { @@ -55,16 +64,22 @@ export default function RootLayout() { - - - - - + + + + + + + {/* Settings drawer needs to be outside the navigation stack */} + + + + diff --git a/components/DatabaseProvider.tsx b/components/DatabaseProvider.tsx index ba37ebb..f4a9309 100644 --- a/components/DatabaseProvider.tsx +++ b/components/DatabaseProvider.tsx @@ -1,4 +1,3 @@ -// components/DatabaseProvider.tsx import React from 'react'; import { View, ActivityIndicator, Text } from 'react-native'; import { SQLiteProvider, openDatabaseSync, SQLiteDatabase } from 'expo-sqlite'; @@ -13,6 +12,7 @@ interface DatabaseServicesContextValue { exerciseService: ExerciseService | null; eventCache: EventCache | null; devSeeder: DevSeederService | null; + // Remove NostrService since we're using the hooks-based approach now } const DatabaseServicesContext = React.createContext({ diff --git a/components/Header.tsx b/components/Header.tsx index a45ce7d..36fcde7 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,36 +1,106 @@ // components/Header.tsx import React from 'react'; -import { View, Platform } from 'react-native'; -import { Text } from '@/components/ui/text'; +import { View, StyleSheet, Platform } from 'react-native'; +import { useTheme } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Bell } from 'lucide-react-native'; +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import UserAvatar from '@/components/UserAvatar'; +import { useSettingsDrawer } from '@/lib/contexts/SettingsDrawerContext'; +import { useNDKCurrentUser } from '@/lib/hooks/useNDK'; interface HeaderProps { - title: string; + title?: string; + hideTitle?: boolean; rightElement?: React.ReactNode; - children?: React.ReactNode; } -export default function Header({ title, rightElement, children }: HeaderProps) { +export default function Header({ + title, + hideTitle = false, + rightElement +}: HeaderProps) { + const theme = useTheme(); const insets = useSafeAreaInsets(); + const { openDrawer } = useSettingsDrawer(); + const { currentUser, isAuthenticated } = useNDKCurrentUser(); + // Extract profile image URL + const profileImageUrl = currentUser?.profile?.image; + + // Get first letter of name for fallback + const fallbackLetter = isAuthenticated && currentUser?.profile?.name + ? currentUser.profile.name.charAt(0).toUpperCase() + : 'G'; + + if (hideTitle) return null; + return ( - - - {title} - {children} - - {rightElement && ( - - {rightElement} + + {/* Left side - User avatar that opens settings drawer */} + + + {/* Middle - Title */} + + {title} - )} + + {/* Right side - Custom element or default notifications */} + + {rightElement || ( + + )} + + ); -} \ No newline at end of file +} + +const styles = StyleSheet.create({ + header: { + width: '100%', + borderBottomWidth: Platform.OS === 'ios' ? 0.5 : 1, + }, + headerContent: { + height: 60, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + }, + titleContainer: { + flex: 1, + alignItems: 'center', + paddingHorizontal: 16, + }, + title: { + fontSize: 20, + fontWeight: '600', + }, + rightContainer: { + minWidth: 40, + alignItems: 'flex-end', + }, +}); \ No newline at end of file diff --git a/components/SettingsDrawer.tsx b/components/SettingsDrawer.tsx new file mode 100644 index 0000000..ac514dd --- /dev/null +++ b/components/SettingsDrawer.tsx @@ -0,0 +1,414 @@ +// components/SettingsDrawer.tsx +import React, { useEffect, useRef, useState } from 'react'; +import { View, StyleSheet, Animated, Dimensions, ScrollView, Pressable, Platform, TouchableOpacity } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useTheme } from '@react-navigation/native'; +import { useRouter } from 'expo-router'; +import { useSettingsDrawer } from '@/lib/contexts/SettingsDrawerContext'; +import { + Moon, Sun, LogOut, User, ChevronRight, X, Bell, HelpCircle, + Smartphone, Database, Zap, RefreshCw, AlertTriangle +} from 'lucide-react-native'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Separator } from '@/components/ui/separator'; +import { Text } from '@/components/ui/text'; +import { useColorScheme } from '@/lib/useColorScheme'; +import NostrLoginSheet from '@/components/sheets/NostrLoginSheet'; +import { useNDKCurrentUser, useNDKAuth } from '@/lib/hooks/useNDK'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const DRAWER_WIDTH = SCREEN_WIDTH * 0.85; + +type MenuItem = { + id: string; + icon: React.ElementType; + label: string; + onPress: () => void; + rightElement?: React.ReactNode; +}; + +export default function SettingsDrawer() { + const router = useRouter(); + const { isDrawerOpen, closeDrawer } = useSettingsDrawer(); + const { currentUser, isAuthenticated } = useNDKCurrentUser(); + const { logout } = useNDKAuth(); + const { toggleColorScheme, isDarkColorScheme } = useColorScheme(); + const theme = useTheme(); + const [isLoginSheetOpen, setIsLoginSheetOpen] = useState(false); + const [showSignOutAlert, setShowSignOutAlert] = useState(false); + + const slideAnim = useRef(new Animated.Value(-DRAWER_WIDTH)).current; + const fadeAnim = useRef(new Animated.Value(0)).current; + + // Handle drawer animation when open state changes + useEffect(() => { + if (isDrawerOpen) { + Animated.parallel([ + Animated.spring(slideAnim, { + toValue: 0, + useNativeDriver: true, + speed: 20, + bounciness: 4 + }), + Animated.timing(fadeAnim, { + toValue: 1, + duration: 200, + useNativeDriver: true + }) + ]).start(); + } else { + Animated.parallel([ + Animated.spring(slideAnim, { + toValue: -DRAWER_WIDTH, + useNativeDriver: true, + speed: 20, + bounciness: 4 + }), + Animated.timing(fadeAnim, { + toValue: 0, + duration: 200, + useNativeDriver: true + }) + ]).start(); + } + }, [isDrawerOpen]); + + // Navigate to relevant screen for login + const navigateToLogin = () => { + // Go to the profile tab which should have login functionality + closeDrawer(); + setTimeout(() => { + router.push("/(tabs)/profile"); + }, 300); + }; + + // Handle profile click - different behavior on iOS vs Android + const handleProfileClick = () => { + if (!isAuthenticated) { + if (Platform.OS === 'ios') { + // On iOS, use the sheet directly + setIsLoginSheetOpen(true); + } else { + // On Android, navigate to profile tab + navigateToLogin(); + } + } else { + // Navigate to profile edit screen in the future + console.log('Navigate to profile edit'); + } + }; + + // Handle sign out button click + const handleSignOut = () => { + setShowSignOutAlert(true); + }; + + // Function to handle confirmed sign out + const confirmSignOut = async () => { + await logout(); + closeDrawer(); + }; + + // Nostr integration handler + const handleNostrIntegration = () => { + if (!isAuthenticated) { + if (Platform.OS === 'ios') { + // On iOS, use the sheet directly + setIsLoginSheetOpen(true); + } else { + // On Android, navigate to profile tab + navigateToLogin(); + } + } else { + // Show Nostr settings in the future + console.log('Show Nostr settings'); + } + }; + + // Define menu items + const menuItems: MenuItem[] = [ + { + id: 'appearance', + icon: isDarkColorScheme ? Moon : Sun, + label: 'Dark Mode', + onPress: () => {}, + rightElement: ( + + ), + }, + { + id: 'notifications', + icon: Bell, + label: 'Notifications', + onPress: () => closeDrawer(), + }, + { + id: 'data-sync', + icon: RefreshCw, + label: 'Data Sync', + onPress: () => closeDrawer(), + }, + { + id: 'backup-restore', + icon: Database, + label: 'Backup & Restore', + onPress: () => closeDrawer(), + }, + { + id: 'device', + icon: Smartphone, + label: 'Device Settings', + onPress: () => closeDrawer(), + }, + { + id: 'nostr', + icon: Zap, + label: 'Nostr Integration', + onPress: handleNostrIntegration, + }, + { + id: 'about', + icon: HelpCircle, + label: 'About', + onPress: () => closeDrawer(), + }, + ]; + + if (!isDrawerOpen) return null; + + return ( + <> + + {/* Backdrop overlay */} + + + + + {/* Drawer */} + + + {/* Header with close button */} + + Settings + + + + {/* Profile section - make it touchable */} + + + {isAuthenticated && currentUser?.profile?.image ? ( + + ) : null} + + {isAuthenticated && currentUser?.profile?.name ? ( + + {currentUser.profile.name.charAt(0).toUpperCase()} + + ) : ( + + )} + + + + + {isAuthenticated ? currentUser?.profile?.name || 'Nostr User' : 'Not Logged In'} + + + {isAuthenticated ? 'Edit Profile' : 'Login with Nostr'} + + + + + + {/* Menu items */} + + {menuItems.map((item, index) => ( + + + + + {item.label} + + + {item.rightElement ? ( + item.rightElement + ) : ( + + )} + + + {index < menuItems.length - 1 && ( + + )} + + ))} + + + {/* Sign out button at the bottom - only show when authenticated */} + {isAuthenticated && ( + + + + )} + + + + + {/* Only render the NostrLoginSheet on iOS */} + {Platform.OS === 'ios' && ( + setIsLoginSheetOpen(false)} + /> + )} + + {/* Sign Out Alert Dialog */} + + + + For Real? + + + Are you sure you want to sign out? Make sure you've backed up your private key. + Lost keys cannot be recovered and all your data will be inaccessible. + + + + + setShowSignOutAlert(false)}> + Cancel + + + Sign Out + + + + + + ); +} + +const styles = StyleSheet.create({ + drawer: { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + width: DRAWER_WIDTH, + borderRightWidth: 1, + ...Platform.select({ + ios: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + }, + android: { + elevation: 5, + }, + }), + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 16, + height: 60, + }, + profileSection: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + }, + profileInfo: { + flex: 1, + marginLeft: 12, + }, + menuList: { + flex: 1, + }, + menuContent: { + padding: 16, + }, + menuItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 12, + }, + menuItemLeft: { + flexDirection: 'row', + alignItems: 'center', + }, + footer: { + padding: 16, + paddingBottom: Platform.OS === 'ios' ? 16 : 16, + }, +}); \ No newline at end of file diff --git a/components/UserAvatar.tsx b/components/UserAvatar.tsx new file mode 100644 index 0000000..f30ff0f --- /dev/null +++ b/components/UserAvatar.tsx @@ -0,0 +1,94 @@ +// components/UserAvatar.tsx +import React, { useState, useEffect } from 'react'; +import { TouchableOpacity, TouchableOpacityProps, GestureResponderEvent } from 'react-native'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Text } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; + +interface UserAvatarProps extends TouchableOpacityProps { + uri?: string; + size?: 'sm' | 'md' | 'lg' | 'xl'; + fallback?: string; + isInteractive?: boolean; + className?: string; +} + +const UserAvatar = ({ + uri, + size = 'md', + fallback = 'U', + isInteractive = true, + className, + onPress, + ...props +}: UserAvatarProps) => { + const [imageError, setImageError] = useState(false); + const [imageUri, setImageUri] = useState(uri); + + // Update imageUri when uri prop changes + useEffect(() => { + setImageUri(uri); + setImageError(false); + }, [uri]); + + // Log the URI for debugging + console.log("Avatar URI:", uri); + + const containerStyles = cn( + { + 'w-8 h-8': size === 'sm', + 'w-10 h-10': size === 'md', + 'w-12 h-12': size === 'lg', + 'w-24 h-24': size === 'xl', + }, + className + ); + + const handlePress = (event: GestureResponderEvent) => { + if (onPress) { + onPress(event); + } else if (isInteractive) { + // Default behavior if no onPress provided + console.log('Avatar pressed'); + } + }; + + const handleImageError = () => { + console.error("Failed to load image from URI:", imageUri); + setImageError(true); + }; + + const avatarContent = ( + + {imageUri && !imageError ? ( + + ) : ( + + {fallback} + + )} + + ); + + if (!isInteractive) return avatarContent; + + return ( + + {avatarContent} + + ); +}; + +export default UserAvatar; \ No newline at end of file diff --git a/components/sheets/NostrLoginSheet.tsx b/components/sheets/NostrLoginSheet.tsx new file mode 100644 index 0000000..587c999 --- /dev/null +++ b/components/sheets/NostrLoginSheet.tsx @@ -0,0 +1,110 @@ +// components/sheets/NostrLoginSheet.tsx +import React, { useState } from 'react'; +import { View, StyleSheet, Alert, Platform, KeyboardAvoidingView, ScrollView } from 'react-native'; +import { Info, ArrowRight } from 'lucide-react-native'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; +import { useNDKAuth } from '@/lib/hooks/useNDK'; + +interface NostrLoginSheetProps { + open: boolean; + onClose: () => void; +} + +export default function NostrLoginSheet({ open, onClose }: NostrLoginSheetProps) { + const [privateKey, setPrivateKey] = useState(''); + const { login, isLoading } = useNDKAuth(); + + const handleLogin = async () => { + if (!privateKey.trim()) { + Alert.alert('Error', 'Please enter your private key'); + return; + } + + try { + const success = await login(privateKey); + + if (success) { + setPrivateKey(''); + onClose(); + } else { + Alert.alert('Login Error', 'Failed to login with the provided private key'); + } + } catch (error) { + console.error('Login error:', error); + Alert.alert('Error', 'An unexpected error occurred'); + } + }; + + return ( + + + + + Login with Nostr + {/* Removed the X close button here */} + + + + + Enter your Nostr private key + + + + + + + + + + What is a Nostr Key? + + + Nostr is a decentralized protocol where your private key (nsec) is your identity and password. + + + Your private key is securely stored on your device and is never sent to any servers. + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollView: { + flex: 1, + }, + content: { + padding: 16, + }, +}); \ No newline at end of file diff --git a/components/templates/TemplateCard.tsx b/components/templates/TemplateCard.tsx index 2c4f73a..c549f03 100644 --- a/components/templates/TemplateCard.tsx +++ b/components/templates/TemplateCard.tsx @@ -17,7 +17,6 @@ import { AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; -import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { Template, TemplateExerciseDisplay } from '@/types/templates'; interface TemplateCardProps { @@ -35,8 +34,8 @@ export function TemplateCard({ onFavorite, onStartWorkout }: TemplateCardProps) { - const [showSheet, setShowSheet] = React.useState(false); const [showDeleteAlert, setShowDeleteAlert] = React.useState(false); + const lastUsed = template.metadata?.lastUsed ? new Date(template.metadata.lastUsed) : undefined; const { id, @@ -47,207 +46,148 @@ export function TemplateCard({ description, tags = [], source, - metadata, isFavorite } = template; - const lastUsed = metadata?.lastUsed ? new Date(metadata.lastUsed) : undefined; - const handleConfirmDelete = () => { onDelete(id); setShowDeleteAlert(false); }; - const handleCardPress = () => { - setShowSheet(true); - onPress(); - }; - return ( - <> - - - - - - - - {title} - - - {source} - - - - - - {type} - - - {category} - - + + + + + + + + {title} + + + {source} + + + + + + {type} + + + {category} + + - {exercises.length > 0 && ( - - - Exercises: - - - {exercises.slice(0, 3).map((exercise: TemplateExerciseDisplay, index: number) => ( - - • {exercise.title} ({exercise.targetSets}×{exercise.targetReps}) - - ))} - {exercises.length > 3 && ( - - +{exercises.length - 3} more - - )} - - - )} - - {description && ( - - {description} + {exercises.length > 0 && ( + + + Exercises: - )} - - {tags.length > 0 && ( - - {tags.map((tag: string) => ( - - {tag} - + + {exercises.slice(0, 3).map((exercise, index) => ( + + • {exercise.title} ({exercise.targetSets}×{exercise.targetReps}) + ))} + {exercises.length > 3 && ( + + +{exercises.length - 3} more + + )} - )} + + )} - {lastUsed && ( - - Last used: {lastUsed.toLocaleDateString()} - - )} - + {description && ( + + {description} + + )} - - - - - - - - - - - Delete Template - - - Are you sure you want to delete {title}? This action cannot be undone. - - - - - Cancel - - - Delete - - - - - - - - - - - {/* Sheet for detailed view */} - setShowSheet(false)}> - - - {title} - - - - - {description && ( - - Description - {description} - - )} - - Details - - Type: {type} - Category: {category} - Source: {source} - {metadata?.useCount && ( - Times Used: {metadata.useCount} - )} - - - - Exercises - - {exercises.map((exercise: TemplateExerciseDisplay, index: number) => ( - - {exercise.title} ({exercise.targetSets}×{exercise.targetReps}) - - ))} - - - {tags.length > 0 && ( - - Tags - - {tags.map((tag: string) => ( - + {tags.length > 0 && ( + + {tags.map(tag => ( + {tag} ))} - - )} + )} + + {lastUsed && ( + + Last used: {lastUsed.toLocaleDateString()} + + )} + + + + + + + + + + + + + Delete Template + + + Are you sure you want to delete {title}? This action cannot be undone. + + + + + Cancel + + + Delete + + + + + - - - + + + ); } \ No newline at end of file diff --git a/components/templates/TemplateDetails.tsx b/components/templates/TemplateDetails.tsx index b463494..efe972b 100644 --- a/components/templates/TemplateDetails.tsx +++ b/components/templates/TemplateDetails.tsx @@ -13,14 +13,11 @@ import { Target, Calendar, Hash, - ClipboardList, - Settings + ClipboardList, + Settings, + LineChart } from 'lucide-react-native'; -import { - WorkoutTemplate, - TemplateSource, - getSourceDisplay -} from '@/types/templates'; +import { WorkoutTemplate, getSourceDisplay } from '@/types/templates'; import { useTheme } from '@react-navigation/native'; import type { CustomTheme } from '@/lib/theme'; @@ -35,23 +32,23 @@ interface TemplateDetailsProps { // Overview Tab Component function OverviewTab({ template, onEdit }: { template: WorkoutTemplate; onEdit?: () => void }) { - const { - title, - type, - category, - description, - exercises = [], - tags = [], - metadata, - availability // Replace source with availability - } = template; - - // Calculate source type from availability - const sourceType = availability.source.includes('nostr') - ? 'nostr' - : availability.source.includes('powr') - ? 'powr' - : 'local'; + const { + title, + type, + category, + description, + exercises = [], + tags = [], + metadata, + availability + } = template; + + // Calculate source type from availability + const sourceType = availability.source.includes('nostr') + ? 'nostr' + : availability.source.includes('powr') + ? 'powr' + : 'local'; return ( {exercises.map((exerciseConfig, index) => ( - - - {exerciseConfig.exercise.title} - - - {exerciseConfig.targetSets} sets × {exerciseConfig.targetReps} reps - - {exerciseConfig.notes && ( - - {exerciseConfig.notes} - - )} - - ))} + + + {exerciseConfig.exercise.title} + + + {exerciseConfig.targetSets} sets × {exerciseConfig.targetReps} reps + + {exerciseConfig.notes && ( + + {exerciseConfig.notes} + + )} + + ))} @@ -182,19 +179,6 @@ function OverviewTab({ template, onEdit }: { template: WorkoutTemplate; onEdit?: ); } -// Function to format template source for display -function formatTemplateSource(source: TemplateSource | undefined, templateSource: 'local' | 'powr' | 'nostr'): string { - if (!source) { - return templateSource === 'local' ? 'Local Template' : templateSource.toUpperCase(); - } - - const author = source.authorName || 'Unknown Author'; - if (source.version) { - return `Modified from ${author} (v${source.version})`; - } - return `Original by ${author}`; -} - // History Tab Component function HistoryTab({ template }: { template: WorkoutTemplate }) { return ( @@ -206,18 +190,40 @@ function HistoryTab({ template }: { template: WorkoutTemplate }) { {/* Performance Stats */} Performance Summary - - - Avg. Duration - - {template.metadata?.averageDuration - ? `${Math.round(template.metadata.averageDuration / 60)}m` - : '--'} - + + + Usage Stats + + + Total Workouts + + {template.metadata?.useCount || 0} + + + + Avg. Duration + + {template.metadata?.averageDuration + ? `${Math.round(template.metadata.averageDuration / 60)}m` + : '--'} + + + + Completion Rate + -- + + - - Completion Rate - -- + + {/* Progress Chart Placeholder */} + + Progress Over Time + + + + Progress tracking coming soon + + @@ -232,6 +238,9 @@ function HistoryTab({ template }: { template: WorkoutTemplate }) { No workout history available yet + + Complete a workout using this template to see your history + @@ -250,6 +259,13 @@ function SettingsTab({ template }: { template: WorkoutTemplate }) { restBetweenRounds, } = template; + // Helper function to format seconds into MM:SS + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + return ( Workout Settings - Type + Workout Type {type} {rounds && ( - Rounds + Number of Rounds {rounds} )} {duration && ( - Duration + Total Duration - {Math.floor(duration / 60)}:{(duration % 60).toString().padStart(2, '0')} + {formatTime(duration)} )} {interval && ( - Interval + Interval Time - {Math.floor(interval / 60)}:{(interval % 60).toString().padStart(2, '0')} + {formatTime(interval)} )} @@ -294,12 +310,23 @@ function SettingsTab({ template }: { template: WorkoutTemplate }) { Rest Between Rounds - {Math.floor(restBetweenRounds / 60)}:{(restBetweenRounds % 60).toString().padStart(2, '0')} + {formatTime(restBetweenRounds)} )} + + {/* Sync Settings */} + + Sync Settings + + Template Source + + {getSourceDisplay(template)} + + + ); @@ -321,7 +348,7 @@ export function TemplateDetails({ - + void; + closeDrawer: () => void; + toggleDrawer: () => void; +} + +const SettingsDrawerContext = createContext(undefined); + +export function SettingsDrawerProvider({ children }: { children: ReactNode }) { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + const openDrawer = () => setIsDrawerOpen(true); + const closeDrawer = () => setIsDrawerOpen(false); + const toggleDrawer = () => setIsDrawerOpen(prev => !prev); + + return ( + + {children} + + ); +} + +export function useSettingsDrawer(): SettingsDrawerContextType { + const context = useContext(SettingsDrawerContext); + if (context === undefined) { + throw new Error('useSettingsDrawer must be used within a SettingsDrawerProvider'); + } + return context; +} +``` + +#### 2.2 Nostr Context with Secure Storage + +Create `lib/contexts/NostrContext.tsx` for handling Nostr authentication and user profile with secure storage: + +```tsx +// lib/contexts/NostrContext.tsx +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import * as SecureStore from 'expo-secure-store'; + +// Define Nostr user profile types +interface NostrProfile { + pubkey: string; + name?: string; + displayName?: string; + about?: string; + website?: string; + picture?: string; + nip05?: string; + lud16?: string; // Lightning address +} + +interface NostrContextType { + isAuthenticated: boolean; + userProfile: NostrProfile | null; + isLoading: boolean; + login: (privateKey: string) => Promise; + logout: () => Promise; + updateProfile: (profile: Partial) => void; + getPrivateKey: () => Promise; +} + +const NostrContext = createContext(undefined); + +// Constants for SecureStore +const PRIVATE_KEY_STORAGE_KEY = 'nostr_privkey'; +const PROFILE_STORAGE_KEY = 'nostr_profile'; + +export function NostrProvider({ children }: { children: ReactNode }) { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [userProfile, setUserProfile] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // Check for existing saved credentials on startup + useEffect(() => { + const loadSavedCredentials = async () => { + try { + // Load the private key from secure storage + const savedPrivkey = await SecureStore.getItemAsync(PRIVATE_KEY_STORAGE_KEY); + + // Try to load cached profile data + const savedProfileJson = await SecureStore.getItemAsync(PROFILE_STORAGE_KEY); + const savedProfile = savedProfileJson ? JSON.parse(savedProfileJson) : null; + + if (savedProfile) { + setUserProfile(savedProfile); + } + + if (savedPrivkey) { + // If we have the key, we're authenticated + setIsAuthenticated(true); + + // If we don't have a profile yet, try to fetch it + if (!savedProfile) { + // In a real implementation, you would fetch the profile here + // For now, just create a mock profile + const mockProfile = createMockProfile(savedPrivkey); + setUserProfile(mockProfile); + await saveProfile(mockProfile); + } + } + } catch (error) { + console.error('Error loading saved credentials:', error); + } finally { + setIsLoading(false); + } + }; + + loadSavedCredentials(); + }, []); + + // Create a mock profile (replace with real implementation) + const createMockProfile = (privateKey: string): NostrProfile => { + // In a real implementation, derive the public key from the private key + const mockPubkey = 'npub1' + privateKey.substring(0, 6); // Just for demo + + return { + pubkey: mockPubkey, + name: 'POWR User', + displayName: 'POWR User', + picture: 'https://robohash.org/' + mockPubkey, + }; + }; + + // Save profile to storage + const saveProfile = async (profile: NostrProfile) => { + await SecureStore.setItemAsync(PROFILE_STORAGE_KEY, JSON.stringify(profile)); + }; + + // Login with private key + const login = async (privateKey: string): Promise => { + setIsLoading(true); + + try { + // Save the private key securely + await SecureStore.setItemAsync(PRIVATE_KEY_STORAGE_KEY, privateKey); + + // Create a profile (mock for now, in reality fetch from relay) + const profile = createMockProfile(privateKey); + + // Save the profile data + await saveProfile(profile); + + // Update state + setUserProfile(profile); + setIsAuthenticated(true); + + return true; + } catch (error) { + console.error('Login error:', error); + return false; + } finally { + setIsLoading(false); + } + }; + + // Logout - securely remove the private key + const logout = async () => { + try { + // Remove the private key from secure storage + await SecureStore.deleteItemAsync(PRIVATE_KEY_STORAGE_KEY); + + // Also remove the profile data + await SecureStore.deleteItemAsync(PROFILE_STORAGE_KEY); + + // Update state + setUserProfile(null); + setIsAuthenticated(false); + } catch (error) { + console.error('Logout error:', error); + } + }; + + // Get the private key when needed for operations (like signing) + const getPrivateKey = async (): Promise => { + try { + return await SecureStore.getItemAsync(PRIVATE_KEY_STORAGE_KEY); + } catch (error) { + console.error('Error retrieving private key:', error); + return null; + } + }; + + // Update user profile + const updateProfile = (profile: Partial) => { + if (userProfile) { + const updatedProfile = { ...userProfile, ...profile }; + setUserProfile(updatedProfile); + + // Save the updated profile + saveProfile(updatedProfile); + } + }; + + return ( + + {children} + + ); +} + +export function useNostr(): NostrContextType { + const context = useContext(NostrContext); + if (context === undefined) { + throw new Error('useNostr must be used within a NostrProvider'); + } + return context; +} +``` + +### 3. Add the Components + +#### 3.1 User Avatar Component + +Create `components/UserAvatar.tsx`: + +```tsx +// components/UserAvatar.tsx +import React from 'react'; +import { TouchableOpacity } from 'react-native'; +import { useTheme } from '@react-navigation/native'; +import { User } from 'lucide-react-native'; +import { useSettingsDrawer } from '@/lib/contexts/SettingsDrawerContext'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; + +interface UserAvatarProps { + size?: number; + avatarUrl?: string; + username?: string; + isNostrUser?: boolean; +} + +export default function UserAvatar({ + size = 40, + avatarUrl, + username, + isNostrUser = false +}: UserAvatarProps) { + const { openDrawer } = useSettingsDrawer(); + const theme = useTheme(); + + const getInitials = () => { + if (!username) return ''; + return username.charAt(0).toUpperCase(); + }; + + return ( + + + {avatarUrl && isNostrUser ? ( + + ) : null} + + {username ? ( + getInitials() + ) : ( + + )} + + + + ); +} +``` + +#### 3.2 Settings Drawer Component + +Create `components/SettingsDrawer.tsx`: + +```tsx +// components/SettingsDrawer.tsx +import React, { useEffect, useRef } from 'react'; +import { View, StyleSheet, Animated, Dimensions, ScrollView, Pressable, Platform } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useTheme } from '@react-navigation/native'; +import { useSettingsDrawer } from '@/lib/contexts/SettingsDrawerContext'; +import { useNostr } from '@/lib/contexts/NostrContext'; +import { + Moon, Sun, LogOut, User, ChevronRight, X, Bell, HelpCircle, Smartphone, Database, Zap, RefreshCw +} from 'lucide-react-native'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Separator } from '@/components/ui/separator'; +import { Text } from '@/components/ui/text'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const DRAWER_WIDTH = SCREEN_WIDTH * 0.85; + +type MenuItem = { + id: string; + icon: React.ElementType; + label: string; + onPress: () => void; + rightElement?: React.ReactNode; +}; + +export default function SettingsDrawer() { + const { isDrawerOpen, closeDrawer } = useSettingsDrawer(); + const { userProfile, isAuthenticated, logout } = useNostr(); + const theme = useTheme(); + const isDarkMode = theme.dark; + + const slideAnim = useRef(new Animated.Value(-DRAWER_WIDTH)).current; + const fadeAnim = useRef(new Animated.Value(0)).current; + + // Handle drawer animation when open state changes + useEffect(() => { + if (isDrawerOpen) { + Animated.parallel([ + Animated.spring(slideAnim, { + toValue: 0, + useNativeDriver: true, + speed: 20, + bounciness: 4 + }), + Animated.timing(fadeAnim, { + toValue: 1, + duration: 200, + useNativeDriver: true + }) + ]).start(); + } else { + Animated.parallel([ + Animated.spring(slideAnim, { + toValue: -DRAWER_WIDTH, + useNativeDriver: true, + speed: 20, + bounciness: 4 + }), + Animated.timing(fadeAnim, { + toValue: 0, + duration: 200, + useNativeDriver: true + }) + ]).start(); + } + }, [isDrawerOpen]); + + // Toggle dark mode handler + const handleToggleDarkMode = () => { + // Implement your theme toggle logic here + console.log('Toggle dark mode'); + }; + + // Handle sign out + const handleSignOut = async () => { + await logout(); + closeDrawer(); + }; + + // Define menu items + const menuItems: MenuItem[] = [ + { + id: 'appearance', + icon: isDarkMode ? Moon : Sun, + label: 'Dark Mode', + onPress: () => {}, + rightElement: ( + + ), + }, + { + id: 'notifications', + icon: Bell, + label: 'Notifications', + onPress: () => closeDrawer(), + }, + { + id: 'data-sync', + icon: RefreshCw, + label: 'Data Sync', + onPress: () => closeDrawer(), + }, + { + id: 'backup-restore', + icon: Database, + label: 'Backup & Restore', + onPress: () => closeDrawer(), + }, + { + id: 'device', + icon: Smartphone, + label: 'Device Settings', + onPress: () => closeDrawer(), + }, + { + id: 'nostr', + icon: Zap, + label: 'Nostr Integration', + onPress: () => closeDrawer(), + }, + { + id: 'about', + icon: HelpCircle, + label: 'About', + onPress: () => closeDrawer(), + }, + ]; + + if (!isDrawerOpen) return null; + + return ( + + {/* Backdrop overlay */} + + + + + {/* Drawer */} + + + {/* Header with close button */} + + Settings + + + + {/* Profile section */} + + + {isAuthenticated && userProfile?.picture ? ( + + ) : null} + + {isAuthenticated && userProfile?.name ? ( + userProfile.name.charAt(0).toUpperCase() + ) : ( + + )} + + + + + {isAuthenticated ? userProfile?.name || 'Nostr User' : 'Not Logged In'} + + + {isAuthenticated ? 'Edit Profile' : 'Login with Nostr'} + + + + + + {/* Menu items */} + + {menuItems.map((item, index) => ( + + + + + {item.label} + + + {item.rightElement ? ( + item.rightElement + ) : ( + + )} + + + {index < menuItems.length - 1 && ( + + )} + + ))} + + + {/* Sign out button at the bottom */} + + + + + + + ); +} + +const styles = StyleSheet.create({ + drawer: { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + width: DRAWER_WIDTH, + borderRightWidth: 1, + ...Platform.select({ + ios: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + }, + android: { + elevation: 5, + }, + }), + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 16, + height: 60, + }, + profileSection: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + }, + profileInfo: { + flex: 1, + marginLeft: 12, + }, + menuList: { + flex: 1, + }, + menuContent: { + padding: 16, + }, + menuItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 12, + }, + menuItemLeft: { + flexDirection: 'row', + alignItems: 'center', + }, + footer: { + padding: 16, + paddingBottom: Platform.OS === 'ios' ? 16 : 16, + }, +}); +``` + +#### 3.3 Update Header Component + +Update your `components/Header.tsx` to include the UserAvatar: + +```tsx +// components/Header.tsx +import React from 'react'; +import { View, StyleSheet, Platform } from 'react-native'; +import { useTheme } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Bell } from 'lucide-react-native'; +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import UserAvatar from '@/components/UserAvatar'; +import { useNostr } from '@/lib/contexts/NostrContext'; + +interface HeaderProps { + title?: string; + hideTitle?: boolean; + rightElement?: React.ReactNode; +} + +export default function Header({ + title, + hideTitle = false, + rightElement +}: HeaderProps) { + const theme = useTheme(); + const insets = useSafeAreaInsets(); + const { userProfile, isAuthenticated } = useNostr(); + + if (hideTitle) return null; + + return ( + + + {/* Left side - User avatar that opens settings drawer */} + + + {/* Middle - Title */} + + {title} + + + {/* Right side - Custom element or default notifications */} + + {rightElement || ( + + )} + + + + ); +} + +const styles = StyleSheet.create({ + header: { + width: '100%', + borderBottomWidth: Platform.OS === 'ios' ? 0.5 : 1, + }, + headerContent: { + height: 60, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + }, + titleContainer: { + flex: 1, + alignItems: 'center', + paddingHorizontal: 16, + }, + title: { + fontSize: 20, + fontWeight: '600', + }, + rightContainer: { + minWidth: 40, + alignItems: 'flex-end', + }, +}); +``` + +### 4. Create a Nostr Login Screen/Sheet + +Create a screen or sheet for logging in with a Nostr private key: + +```tsx +// components/sheets/NostrLoginSheet.tsx +import React, { useState } from 'react'; +import { View, StyleSheet, Alert, Platform, KeyboardAvoidingView } from 'react-native'; +import { X } from 'lucide-react-native'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useNostr } from '@/lib/contexts/NostrContext'; + +interface NostrLoginSheetProps { + isOpen: boolean; + onClose: () => void; +} + +export default function NostrLoginSheet({ isOpen, onClose }: NostrLoginSheetProps) { + const [privateKey, setPrivateKey] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const { login } = useNostr(); + + const handleLogin = async () => { + if (!privateKey.trim()) { + Alert.alert('Error', 'Please enter your private key'); + return; + } + + setIsLoading(true); + + try { + const success = await login(privateKey); + + if (success) { + setPrivateKey(''); + onClose(); + } else { + Alert.alert('Login Error', 'Failed to login with the provided private key'); + } + } catch (error) { + console.error('Login error:', error); + Alert.alert('Error', 'An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + + Login with Nostr + + + + + Enter your Nostr private key (nsec) + + + + + + Your private key is securely stored on your device and is never sent to any servers. + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + padding: 16, + }, +}); +``` + +### 5. Wrap Your App with the Providers + +Update your app's root component: + +```tsx +// app/_layout.tsx +import { SettingsDrawerProvider } from '@/lib/contexts/SettingsDrawerContext'; +import { NostrProvider } from '@/lib/contexts/NostrContext'; +import SettingsDrawer from '@/components/SettingsDrawer'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; + +export default function RootLayout() { + return ( + + + + + + + + + + {/* Settings drawer needs to be outside the navigation stack */} + + + + + + + + ); +} +``` + +## Secure Storage Considerations + +### Benefits of Using Expo SecureStore + +1. **Encryption**: `expo-secure-store` encrypts the data before storing it on the device. + - On iOS, it uses Keychain Services with proper security attributes + - On Android, it uses encrypted SharedPreferences backed by Android's Keystore system + +2. **Isolation**: Each Expo project has a separate storage system and has no access to the storage of other Expo projects. + +3. **Security Best Practices**: The implementation follows platform-specific security best practices. + +4. **Biometric Authentication**: Supports requiring biometric authentication for accessing sensitive data (optional). + +### Usage Guidelines + +1. **Key Naming**: Use consistent, descriptive key names like `PRIVATE_KEY_STORAGE_KEY` to avoid collisions. + +2. **Error Handling**: Always handle errors from SecureStore operations as they can fail due to various system conditions. + +3. **Size Limits**: Be aware that SecureStore has a value size limit of 2048 bytes. This is sufficient for private keys but may not be for larger data. + +4. **Cleanup**: Always remove sensitive data when it's no longer needed, especially when users log out. + +5. **Application Uninstallation**: Note that data in SecureStore is deleted when the application is uninstalled. + +### Real-World Implementation Notes + +When implementing this with a real Nostr library: + +1. **Signing Operations**: Use `getPrivateKey()` to retrieve the key only when needed for signing operations, then don't keep it in memory longer than necessary. + +2. **Content Updates**: When updating profile information or publishing content: + - Get the private key with `getPrivateKey()` + - Create and sign the event + - Publish it to relays + - Never store the private key in state or other non-secure storage + +3. **Access Permissions**: Consider using the `requireAuthentication` option with SecureStore for additional security when the device supports biometric authentication. + +## Customizing for Your App + +### Theme Integration + +The drawer uses the theme from `@react-navigation/native`. Make sure your theme defines the following colors: + +- `card`: Background color for the drawer +- `border`: Color for separator lines +- `text`: Text color +- `primary`: Color for primary actions + +### Menu Items + +Customize the menu items in the settings drawer based on your app's needs. Each item should have: + +```tsx +{ + id: 'unique-id', + icon: IconComponent, + label: 'Menu Item Name', + onPress: () => { /* handler */ }, + rightElement?: optional JSX element +} +``` + +### Testing Checklist + +After implementation, verify these behaviors: + +1. ✅ Settings drawer opens when tapping the user avatar in the header +2. ✅ Drawer closes when tapping outside or the X button +3. ✅ Private key is securely stored using expo-secure-store +4. ✅ Avatar displays user's profile picture or initials when logged in +5. ✅ Menu items navigate to correct screens +6. ✅ Sign out removes private key from secure storage +7. ✅ Test on both iOS and Android + +## Integration with NDK + +If you're planning to use NDK (Nostr Development Kit) for Nostr integration, here's how to connect it with the secure storage system: + +```tsx +// lib/nostr/ndk-service.ts +import NDK, { NDKPrivateKeySigner, NDKEvent, NDKUser } from '@nostr-dev-kit/ndk'; +import { useNostr } from '@/lib/contexts/NostrContext'; + +// Default relays to connect to +const DEFAULT_RELAYS = [ + 'wss://relay.damus.io', + 'wss://relay.nostr.band', + 'wss://purplepag.es' +]; + +export class NDKService { + private static instance: NDKService; + private ndk: NDK | null = null; + private relays: string[] = DEFAULT_RELAYS; + + private constructor() {} + + public static getInstance(): NDKService { + if (!NDKService.instance) { + NDKService.instance = new NDKService(); + } + return NDKService.instance; + } + + /** + * Initialize NDK with user's private key + */ + public async initialize(privateKey: string | null, customRelays?: string[]): Promise { + try { + if (privateKey) { + // Create a signer with the private key + const signer = new NDKPrivateKeySigner(privateKey); + + // Use custom relays if provided + if (customRelays && customRelays.length > 0) { + this.relays = customRelays; + } + + // Initialize NDK with signer and relays + this.ndk = new NDK({ + explicitRelayUrls: this.relays, + signer + }); + + // Connect to relays + await this.ndk.connect(); + return true; + } + return false; + } catch (error) { + console.error('Error initializing NDK:', error); + return false; + } + } + + /** + * Get the current user's profile + */ + public async fetchUserProfile(pubkey: string): Promise { + if (!this.ndk) throw new Error('NDK not initialized'); + + try { + const user = new NDKUser({ pubkey }); + await user.fetchProfile(this.ndk); + + return user.profile || null; + } catch (error) { + console.error('Error fetching user profile:', error); + return null; + } + } + + /** + * Publish a kind 0 (profile metadata) event + */ + public async updateProfile(profileData: Record): Promise { + if (!this.ndk) throw new Error('NDK not initialized'); + + try { + // Create a new kind 0 event + const event = new NDKEvent(this.ndk); + event.kind = 0; + event.content = JSON.stringify(profileData); + + // Sign and publish the event + await event.publish(); + return true; + } catch (error) { + console.error('Error updating profile:', error); + return false; + } + } + + /** + * Publish a Nostr event + */ + public async publishEvent(kind: number, content: string, tags: string[][] = []): Promise { + if (!this.ndk) throw new Error('NDK not initialized'); + + try { + // Create a new event + const event = new NDKEvent(this.ndk); + event.kind = kind; + event.content = content; + event.tags = tags; + + // Sign and publish the event + await event.publish(); + return event.id; + } catch (error) { + console.error('Error publishing event:', error); + return null; + } + } +} + +/** + * Hook to use NDK with secure storage + */ +export function useNDKWithSecureStorage() { + const { getPrivateKey, isAuthenticated, userProfile, updateProfile } = useNostr(); + const ndkService = NDKService.getInstance(); + + /** + * Initialize NDK with the user's private key from secure storage + */ + const initializeNDK = async (customRelays?: string[]): Promise => { + if (!isAuthenticated) return false; + + try { + // Get the private key from secure storage + const privateKey = await getPrivateKey(); + + if (!privateKey) { + console.error('Private key not found in secure storage'); + return false; + } + + // Initialize NDK with the private key + return await ndkService.initialize(privateKey, customRelays); + } catch (error) { + console.error('Error initializing NDK:', error); + return false; + } + }; + + /** + * Update the user's profile data + */ + const updateNostrProfile = async (profileData: Record): Promise => { + try { + // Update profile in NDK + const success = await ndkService.updateProfile(profileData); + + if (success && userProfile) { + // Also update the local profile + updateProfile({ + name: profileData.name, + displayName: profileData.display_name, + about: profileData.about, + picture: profileData.picture, + website: profileData.website, + nip05: profileData.nip05, + lud16: profileData.lud16 + }); + } + + return success; + } catch (error) { + console.error('Error updating profile:', error); + return false; + } + }; + + /** + * Publish a workout or exercise template to Nostr + */ + const publishWorkoutEvent = async (kind: number, content: string, tags: string[][] = []): Promise => { + try { + await initializeNDK(); + return await ndkService.publishEvent(kind, content, tags); + } catch (error) { + console.error('Error publishing workout event:', error); + return null; + } + }; + + return { + initializeNDK, + updateNostrProfile, + publishWorkoutEvent + }; +} +``` + +## Using the Settings Drawer with Nostr Integration + +Once you have set up the NDK integration, you can use it in your app with the settings drawer to create a seamless user experience. Here's how to connect everything: + +### 1. Initialize NDK on App Load + +First, initialize NDK when the app loads if the user is already authenticated: + +```tsx +// app/_layout.tsx +import { useEffect } from 'react'; +import { useNDKWithSecureStorage } from '@/lib/nostr/ndk-service'; +import { useNostr } from '@/lib/contexts/NostrContext'; + +export default function RootLayout() { + const { isAuthenticated } = useNostr(); + const { initializeNDK } = useNDKWithSecureStorage(); + + useEffect(() => { + if (isAuthenticated) { + // Initialize NDK when app loads if user is authenticated + initializeNDK().then((success) => { + console.log('NDK initialized:', success); + }); + } + }, [isAuthenticated]); + + return ( + // ...existing layout code + ); +} +``` + +### 2. Handle Nostr Login + +When a user logs in with their private key, initialize NDK: + +```tsx +// components/sheets/NostrLoginSheet.tsx +import { useNDKWithSecureStorage } from '@/lib/nostr/ndk-service'; + +export default function NostrLoginSheet({ isOpen, onClose }: NostrLoginSheetProps) { + const { login } = useNostr(); + const { initializeNDK } = useNDKWithSecureStorage(); + + const handleLogin = async () => { + // ...existing login code + + try { + const success = await login(privateKey); + + if (success) { + // Initialize NDK after successful login + await initializeNDK(); + + setPrivateKey(''); + onClose(); + } else { + Alert.alert('Login Error', 'Failed to login with the provided private key'); + } + } catch (error) { + // ...error handling + } + }; + + // ...rest of the component +} +``` + +### 3. Publish Workout Events + +You can now use the NDK service to publish workout events: + +```tsx +// components/workout/SaveWorkout.tsx +import { useNDKWithSecureStorage } from '@/lib/nostr/ndk-service'; + +export default function SaveWorkout({ workout }) { + const { publishWorkoutEvent } = useNDKWithSecureStorage(); + + const handleSaveToNostr = async () => { + try { + // Prepare workout data + const content = JSON.stringify(workout); + + // Add appropriate tags + const tags = [ + ['t', 'workout'], + ['d', workout.id], + ['title', workout.title], + // Add more tags as needed + ]; + + // Publish to Nostr (kind 33402 for workout templates, 33403 for workout records) + const eventId = await publishWorkoutEvent(33402, content, tags); + + if (eventId) { + Alert.alert('Success', 'Workout published to Nostr!'); + } else { + Alert.alert('Error', 'Failed to publish workout'); + } + } catch (error) { + console.error('Error publishing workout:', error); + Alert.alert('Error', 'Failed to publish workout'); + } + }; + + // ...rest of the component +} +``` + +### 4. Update Profile Information + +Allow users to update their Nostr profile: + +```tsx +// components/profile/EditProfile.tsx +import { useNDKWithSecureStorage } from '@/lib/nostr/ndk-service'; + +export default function EditProfile() { + const { userProfile } = useNostr(); + const { updateNostrProfile } = useNDKWithSecureStorage(); + + const [name, setName] = useState(userProfile?.name || ''); + const [about, setAbout] = useState(userProfile?.about || ''); + // ...other profile fields + + const handleSaveProfile = async () => { + try { + const profileData = { + name, + display_name: name, // Often these are the same + about, + // ...other profile fields + }; + + const success = await updateNostrProfile(profileData); + + if (success) { + Alert.alert('Success', 'Profile updated successfully!'); + } else { + Alert.alert('Error', 'Failed to update profile'); + } + } catch (error) { + console.error('Error updating profile:', error); + Alert.alert('Error', 'Failed to update profile'); + } + }; + + // ...rest of the component +} +``` + +## Advanced Features and Customizations + +### 1. Biometric Authentication for Private Key Access + +For extra security, you can enable biometric authentication when accessing the private key: + +```tsx +// lib/contexts/NostrContext.tsx +import * as LocalAuthentication from 'expo-local-authentication'; + +// Update the getPrivateKey method +const getPrivateKey = async (): Promise => { + try { + // Check if device supports biometric authentication + const compatible = await LocalAuthentication.hasHardwareAsync(); + if (compatible) { + // Authenticate user + const result = await LocalAuthentication.authenticateAsync({ + promptMessage: 'Authenticate to access your private key', + }); + + if (!result.success) { + throw new Error('Authentication failed'); + } + } + + // Get the private key after successful authentication + return await SecureStore.getItemAsync(PRIVATE_KEY_STORAGE_KEY); + } catch (error) { + console.error('Error retrieving private key:', error); + return null; + } +}; +``` + +### 2. Custom Themes in the Settings Drawer + +Add a theme selector in the settings drawer: + +```tsx +// components/SettingsDrawer.tsx +const ThemeOption = ({ theme, isActive, onSelect }) => ( + onSelect(theme.name)} + > + + + + {theme.name} + +); + +// Add a theme selector to your menu +const ThemeSelector = () => ( + + Theme + + handleToggleDarkMode(false)} /> + handleToggleDarkMode(true)} /> + + +); +``` + +### 3. Relay Management + +Add relay management functionality to the settings drawer: + +```tsx +// components/settings/RelaysManager.tsx +import { useState, useEffect } from 'react'; +import { View, FlatList, TouchableOpacity, StyleSheet, Alert } from 'react-native'; +import { Plus, X } from 'lucide-react-native'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { Input } from '@/components/ui/input'; +import { useNDKWithSecureStorage } from '@/lib/nostr/ndk-service'; + +export default function RelaysManager() { + const [relays, setRelays] = useState([]); + const [newRelay, setNewRelay] = useState(''); + const { initializeNDK } = useNDKWithSecureStorage(); + + // Load saved relays on mount + useEffect(() => { + loadRelays(); + }, []); + + const loadRelays = async () => { + // Load relays from storage + // This is a placeholder - implement your own relay storage + }; + + const addRelay = async () => { + if (!newRelay.trim() || !newRelay.startsWith('wss://')) { + Alert.alert('Invalid Relay', 'Please enter a valid relay URL (wss://)'); + return; + } + + setRelays([...relays, newRelay]); + setNewRelay(''); + + // Re-initialize NDK with the new relay list + await initializeNDK([...relays, newRelay]); + }; + + const removeRelay = async (relay) => { + const updatedRelays = relays.filter(r => r !== relay); + setRelays(updatedRelays); + + // Re-initialize NDK with the updated relay list + await initializeNDK(updatedRelays); + }; + + return ( + + Relay Management + + + + + + + item} + renderItem={({ item }) => ( + + {item} + removeRelay(item)}> + + + + )} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 16, + }, + inputContainer: { + flexDirection: 'row', + marginBottom: 16, + }, + relayItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#e5e7eb', + }, +}); +``` + +## Conclusion + +This implementation guide provides a complete solution for integrating a settings drawer into your POWR app, using the user's Nostr avatar in the header to open it. The implementation uses expo-secure-store for securely handling private keys and provides a clean, user-friendly interface that matches your existing styling. + +By following this guide, you can implement: + +1. A secure Nostr authentication system with private key management +2. A profile-driven avatar component that opens the settings drawer +3. A fully animated settings drawer with customizable menu items +4. Integration with NDK for publishing workout data to the Nostr network + +This approach prioritizes security, user experience, and code maintainability, making it a solid foundation for your fitness app's Nostr integration.# Settings Drawer Implementation Guide + +This guide explains how to integrate the settings drawer into your POWR app, using the user's Nostr avatar in the header to open it. The implementation follows your existing shadcn/ui component patterns and matches the styling of your current components. + +## Files Overview + +1. **SettingsDrawer.tsx** - The main drawer component +2. **SettingsDrawerContext.tsx** - Context provider for managing drawer state +3. **UserAvatar.tsx** - Component for displaying user avatar and opening the drawer +4. **NostrContext.tsx** - Context provider for Nostr authentication and profile data using secure storage +5. **Header.tsx** - Updated header component that includes the UserAvatar + +## Are we storing some of the cached user data in our sqlite db (eg, user metadata, PFP, etc) + +You're right to be thinking about local caching for user data. In the current implementation, we're only storing the user profile data using `expo-secure-store` alongside the private key. This approach has some limitations: + +1. `expo-secure-store` has a 2048 byte size limit per entry, which is fine for keys but could be restrictive for larger profile data. +2. We're not taking advantage of your existing SQLite database infrastructure. +3. This approach doesn't handle caching data from other Nostr users you might interact with. + +Let's enhance our implementation to store user metadata and profile pictures in your SQLite database. Here's how we can approach this: + +### 1. Create a User Database Service + +```typescript +// lib/db/services/UserProfileService.ts +import { DbService } from '@/lib/db/db-service'; + +export interface UserProfile { + pubkey: string; + name?: string; + displayName?: string; + about?: string; + website?: string; + picture?: string; + nip05?: string; + lud16?: string; + lastUpdated: number; +} + +export class UserProfileService { + constructor(private db: DbService) {} + + async initialize(): Promise { + await this.db.execute(` + 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 + ) + `); + } + + async saveProfile(profile: UserProfile): Promise { + await this.db.execute(` + INSERT OR REPLACE INTO user_profiles ( + pubkey, name, display_name, about, website, picture, nip05, lud16, last_updated + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + profile.pubkey, + profile.name || null, + profile.displayName || null, + profile.about || null, + profile.website || null, + profile.picture || null, + profile.nip05 || null, + profile.lud16 || null, + profile.lastUpdated || Date.now() + ]); + } + + async getProfile(pubkey: string): Promise { + const result = await this.db.query(` + SELECT * FROM user_profiles WHERE pubkey = ? + `, [pubkey]); + + if (result.rows.length === 0) { + return null; + } + + const item = result.rows.item(0); + return { + pubkey: item.pubkey, + name: item.name, + displayName: item.display_name, + about: item.about, + website: item.website, + picture: item.picture, + nip05: item.nip05, + lud16: item.lud16, + lastUpdated: item.last_updated + }; + } + + async getAllProfiles(): Promise { + const result = await this.db.query(` + SELECT * FROM user_profiles + ORDER BY last_updated DESC + `); + + const profiles: UserProfile[] = []; + for (let i = 0; i < result.rows.length; i++) { + const item = result.rows.item(i); + profiles.push({ + pubkey: item.pubkey, + name: item.name, + displayName: item.display_name, + about: item.about, + website: item.website, + picture: item.picture, + nip05: item.nip05, + lud16: item.lud16, + lastUpdated: item.last_updated + }); + } + return profiles; + } + + async deleteProfile(pubkey: string): Promise { + await this.db.execute(` + DELETE FROM user_profiles WHERE pubkey = ? + `, [pubkey]); + } + + // For caching profile pictures specifically + async cachePictureForPubkey(pubkey: string, pictureUrl: string): Promise { + await this.db.execute(` + UPDATE user_profiles + SET picture = ?, last_updated = ? + WHERE pubkey = ? + `, [pictureUrl, Date.now(), pubkey]); + } +} +``` + +### 2. Modify the NostrContext to Use SQLite for Profile Data + +```typescript +// lib/contexts/NostrContext.tsx +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import * as SecureStore from 'expo-secure-store'; +import { DbService } from '@/lib/db/db-service'; +import { UserProfileService, UserProfile } from '@/lib/db/services/UserProfileService'; + +// SecureStore is still used for the private key +const PRIVATE_KEY_STORAGE_KEY = 'nostr_privkey'; +const CURRENT_USER_PUBKEY_KEY = 'current_user_pubkey'; + +interface NostrContextType { + isAuthenticated: boolean; + userProfile: UserProfile | null; + isLoading: boolean; + login: (privateKey: string) => Promise; + logout: () => Promise; + updateProfile: (profile: Partial) => Promise; + getPrivateKey: () => Promise; + getUserProfile: (pubkey: string) => Promise; + cacheUserProfile: (profile: UserProfile) => Promise; +} + +const NostrContext = createContext(undefined); + +export function NostrProvider({ children }: { children: ReactNode }) { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [userProfile, setUserProfile] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [userProfileService, setUserProfileService] = useState(null); + + // Initialize database services + useEffect(() => { + const initDb = async () => { + const dbService = new DbService(); + const profileService = new UserProfileService(dbService); + + await profileService.initialize(); + setUserProfileService(profileService); + + // Load saved credentials + await loadSavedCredentials(profileService); + }; + + initDb(); + }, []); + + // Load saved credentials + const loadSavedCredentials = async (profileService: UserProfileService) => { + try { + // Load the private key from secure storage + const savedPrivkey = await SecureStore.getItemAsync(PRIVATE_KEY_STORAGE_KEY); + const currentPubkey = await SecureStore.getItemAsync(CURRENT_USER_PUBKEY_KEY); + + if (savedPrivkey && currentPubkey) { + // If we have the key and pubkey, we're authenticated + setIsAuthenticated(true); + + // Try to load cached profile from database + const profile = await profileService.getProfile(currentPubkey); + + if (profile) { + setUserProfile(profile); + } else { + // If no profile in database, create a basic one + const basicProfile: UserProfile = { + pubkey: currentPubkey, + lastUpdated: Date.now() + }; + await profileService.saveProfile(basicProfile); + setUserProfile(basicProfile); + } + } + } catch (error) { + console.error('Error loading saved credentials:', error); + } finally { + setIsLoading(false); + } + }; + + // Login with private key + const login = async (privateKey: string): Promise => { + if (!userProfileService) return false; + + setIsLoading(true); + + try { + // This would be your actual Nostr login logic + // For example: + // 1. Derive public key from private key + // Here we're just using a mock implementation + const mockPubkey = 'npub1' + privateKey.substring(0, 6); // Just for demo + + // Save the private key securely + await SecureStore.setItemAsync(PRIVATE_KEY_STORAGE_KEY, privateKey); + await SecureStore.setItemAsync(CURRENT_USER_PUBKEY_KEY, mockPubkey); + + // Create a profile + const profile: UserProfile = { + pubkey: mockPubkey, + name: 'POWR User', + displayName: 'POWR User', + picture: 'https://robohash.org/' + mockPubkey, + lastUpdated: Date.now() + }; + + // Save to database + await userProfileService.saveProfile(profile); + + // Update state + setUserProfile(profile); + setIsAuthenticated(true); + + return true; + } catch (error) { + console.error('Login error:', error); + return false; + } finally { + setIsLoading(false); + } + }; + + // Logout - securely remove the private key + const logout = async () => { + try { + // Remove from secure storage + await SecureStore.deleteItemAsync(PRIVATE_KEY_STORAGE_KEY); + await SecureStore.deleteItemAsync(CURRENT_USER_PUBKEY_KEY); + + // Update state + setUserProfile(null); + setIsAuthenticated(false); + } catch (error) { + console.error('Logout error:', error); + } + }; + + // Get the private key when needed for operations (like signing) + const getPrivateKey = async (): Promise => { + try { + return await SecureStore.getItemAsync(PRIVATE_KEY_STORAGE_KEY); + } catch (error) { + console.error('Error retrieving private key:', error); + return null; + } + }; + + // Update user profile + const updateProfile = async (profile: Partial) => { + if (!userProfileService || !userProfile) return; + + const updatedProfile = { + ...userProfile, + ...profile, + lastUpdated: Date.now() + }; + + // Save to database + await userProfileService.saveProfile(updatedProfile); + + // Update state + setUserProfile(updatedProfile); + }; + + // Get a user profile from the database + const getUserProfile = async (pubkey: string): Promise => { + if (!userProfileService) return null; + return await userProfileService.getProfile(pubkey); + }; + + // Cache a user profile in the database + const cacheUserProfile = async (profile: UserProfile) => { + if (!userProfileService) return; + await userProfileService.saveProfile(profile); + }; + + return ( + + {children} + + ); +} + +export function useNostr(): NostrContextType { + const context = useContext(NostrContext); + if (context === undefined) { + throw new Error('useNostr must be used within a NostrProvider'); + } + return context; +} +``` + +### 3. Create a ProfileCache Service for NDK Integration + +```typescript +// lib/nostr/profile-cache.ts +import { NDKUser } from '@nostr-dev-kit/ndk'; +import { useNostr } from '@/lib/contexts/NostrContext'; +import { UserProfile } from '@/lib/db/services/UserProfileService'; + +export function useProfileCache() { + const { cacheUserProfile, getUserProfile } = useNostr(); + + // Convert NDK user to UserProfile format + const ndkUserToProfile = (ndkUser: NDKUser): UserProfile => { + return { + pubkey: ndkUser.pubkey, + name: ndkUser.profile?.name, + displayName: ndkUser.profile?.displayName, + about: ndkUser.profile?.about, + website: ndkUser.profile?.website, + picture: ndkUser.profile?.image || ndkUser.profile?.picture, + nip05: ndkUser.profile?.nip05, + lud16: ndkUser.profile?.lud16, + lastUpdated: Date.now() + }; + }; + + // Get profile from cache or fetch from NDK + const getProfile = async (pubkey: string, ndk: any): Promise => { + // First try to get from cache + const cachedProfile = await getUserProfile(pubkey); + + // If cached and recent (less than 1 hour old), use it + if (cachedProfile && (Date.now() - cachedProfile.lastUpdated < 3600000)) { + return cachedProfile; + } + + try { + // Fetch from NDK + const ndkUser = new NDKUser({ pubkey }); + await ndkUser.fetchProfile(ndk); + + if (ndkUser.profile) { + // Convert and cache + const profile = ndkUserToProfile(ndkUser); + await cacheUserProfile(profile); + return profile; + } + + // If we have cached data but it's old, still return it + if (cachedProfile) { + return cachedProfile; + } + + return null; + } catch (error) { + console.error('Error fetching profile from NDK:', error); + + // Return cached data even if old on error + if (cachedProfile) { + return cachedProfile; + } + + return null; + } + }; + + // Cache multiple profiles at once (useful for timelines) + const cacheProfiles = async (ndkUsers: NDKUser[]): Promise => { + const profiles = ndkUsers + .filter(user => user.profile) + .map(ndkUserToProfile); + + for (const profile of profiles) { + await cacheUserProfile(profile); + } + }; + + return { + getProfile, + cacheProfiles + }; +} +``` + +### 4. Update Your UserAvatar Component to Use Cached Data + +```typescript +// components/UserAvatar.tsx +import React, { useEffect, useState } from 'react'; +import { TouchableOpacity } from 'react-native'; +import { useTheme } from '@react-navigation/native'; +import { User } from 'lucide-react-native'; +import { useSettingsDrawer } from '@/lib/contexts/SettingsDrawerContext'; +import { useNostr } from '@/lib/contexts/NostrContext'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { UserProfile } from '@/lib/db/services/UserProfileService'; + +interface UserAvatarProps { + size?: number; + pubkey?: string; // Can specify pubkey for other users +} + +export default function UserAvatar({ + size = 40, + pubkey +}: UserAvatarProps) { + const { openDrawer } = useSettingsDrawer(); + const { userProfile, isAuthenticated, getUserProfile } = useNostr(); + const [profile, setProfile] = useState(null); + const theme = useTheme(); + + // Load profile data + useEffect(() => { + const loadProfile = async () => { + if (pubkey) { + // If a pubkey is provided, load that user's profile + const profileData = await getUserProfile(pubkey); + setProfile(profileData); + } else { + // Otherwise, use the current user's profile + setProfile(userProfile); + } + }; + + loadProfile(); + }, [pubkey, userProfile]); + + const getInitials = () => { + if (!profile?.name) return ''; + return profile.name.charAt(0).toUpperCase(); + }; + + const handlePress = () => { + // Only open drawer for current user's avatar + if (!pubkey) { + openDrawer(); + } + }; + + return ( + + + {profile?.picture ? ( + + ) : null} + + {profile?.name ? ( + getInitials() + ) : ( + + )} + + + + ); +} +``` + +### 5. Add Image Caching for Profile Pictures + +For better performance with profile pictures, you could also implement a dedicated image caching system. A popular library for this is `expo-file-system` combined with `react-native-fast-image`. + +```typescript +// lib/utils/image-cache.ts +import * as FileSystem from 'expo-file-system'; +import { DbService } from '@/lib/db/db-service'; + +export class ImageCache { + private db: DbService; + private cacheDir: string; + + constructor(db: DbService) { + this.db = db; + this.cacheDir = `${FileSystem.cacheDirectory}images/`; + this.initialize(); + } + + private async initialize() { + const dirInfo = await FileSystem.getInfoAsync(this.cacheDir); + if (!dirInfo.exists) { + await FileSystem.makeDirectoryAsync(this.cacheDir, { intermediates: true }); + } + + await this.db.execute(` + CREATE TABLE IF NOT EXISTS image_cache ( + url TEXT PRIMARY KEY, + path TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_used INTEGER NOT NULL + ) + `); + } + + async getCachedImagePath(url: string): Promise { + // Check if URL is in database + const result = await this.db.query(` + SELECT path FROM image_cache WHERE url = ? + `, [url]); + + if (result.rows.length === 0) { + return null; + } + + const path = result.rows.item(0).path; + + // Check if file exists + const fileInfo = await FileSystem.getInfoAsync(path); + if (!fileInfo.exists) { + // Remove from database if file doesn't exist + await this.db.execute(` + DELETE FROM image_cache WHERE url = ? + `, [url]); + return null; + } + + // Update last used + await this.db.execute(` + UPDATE image_cache SET last_used = ? WHERE url = ? + `, [Date.now(), url]); + + return path; + } + + async cacheImage(url: string): Promise { + // Check if already cached + const cachedPath = await this.getCachedImagePath(url); + if (cachedPath) { + return cachedPath; + } + + // Generate unique filename + const filename = `${Date.now()}-${Math.floor(Math.random() * 1000000)}`; + const path = `${this.cacheDir}${filename}`; + + // Download image + await FileSystem.downloadAsync(url, path); + + // Save to database + await this.db.execute(` + INSERT INTO image_cache (url, path, created_at, last_used) + VALUES (?, ?, ?, ?) + `, [url, path, Date.now(), Date.now()]); + + return path; + } + + async clearOldCache(maxAge: number = 7 * 24 * 60 * 60 * 1000) { // Default 7 days + const cutoff = Date.now() - maxAge; + + // Get old files + const result = await this.db.query(` + SELECT url, path FROM image_cache WHERE last_used < ? + `, [cutoff]); + + // Delete files and database entries + for (let i = 0; i < result.rows.length; i++) { + const item = result.rows.item(i); + try { + await FileSystem.deleteAsync(item.path, { idempotent: true }); + } catch (error) { + console.error(`Error deleting cached image: ${error}`); + } + } + + // Remove from database + await this.db.execute(` + DELETE FROM image_cache WHERE last_used < ? + `, [cutoff]); + } +} +``` + +### Benefits of This Approach: + +1. **Efficiency**: Store user profiles in SQLite for better performance and querying capabilities. +2. **Data Persistence**: Profile data persists across app restarts. +3. **Caching**: Cache profiles of other users you interact with, not just your own. +4. **Image Caching**: Optimize network usage by caching profile pictures. +5. **Data Freshness**: Automatically update stale data when needed. + +This approach gives you a more robust system for handling Nostr user data, profile pictures, and other metadata, leveraging your existing SQLite infrastructure while keeping the private key securely stored in `expo-secure-store`. \ No newline at end of file diff --git a/docs/design/Templates/TemplateOrganization.md b/docs/design/Templates/TemplateOrganization.md new file mode 100644 index 0000000..d0dce82 --- /dev/null +++ b/docs/design/Templates/TemplateOrganization.md @@ -0,0 +1,204 @@ +# Template Organization and Drag-and-Drop Feature + +## Problem Statement +Users need a more flexible way to organize their growing collection of workout templates. Currently, templates are organized in a flat list with basic filtering. Users need the ability to create custom folders and easily reorganize templates through drag-and-drop interactions. + +## Requirements + +### Functional Requirements +- Create, rename, and delete folders +- Move templates between folders via drag-and-drop +- Reorder templates within folders +- Reorder folders themselves +- Collapse/expand folder views +- Support template search across folders +- Maintain existing category and favorite filters +- Support template color coding +- Batch move/delete operations + +### Non-Functional Requirements +- Smooth drag animations (60fps) +- Persist folder structure locally +- Support offline operation +- Sync with Nostr when available +- Maintain current performance with 100+ templates +- Accessible drag-and-drop interactions + +## Design Decisions + +### 1. Folder Data Structure +Using a hierarchical structure with templates linked to folders: + +```typescript +interface TemplateFolder { + id: string; + name: string; + color?: string; + icon?: string; + order: number; + created_at: number; + updated_at: number; +} + +interface Template { + // ... existing fields ... + folder_id?: string; // Optional - null means root level + order: number; // Position within folder or root +} +``` + +Rationale: +- Simple but flexible structure +- Easy to query and update +- Supports future nested folders if needed +- Maintains compatibility with existing template structure + +### 2. Drag-and-Drop Implementation +Using react-native-reanimated and react-native-gesture-handler: + +Rationale: +- Native performance for animations +- Built-in gesture handling +- Good community support +- Cross-platform compatibility +- Rich animation capabilities + +## Technical Design + +### Core Components +```typescript +// Folder management +interface FolderManagerHook { + folders: TemplateFolder[]; + createFolder: (name: string) => Promise; + updateFolder: (id: string, data: Partial) => Promise; + deleteFolder: (id: string) => Promise; + reorderFolder: (id: string, newOrder: number) => Promise; +} + +// Draggable template component +interface DraggableTemplateProps { + template: Template; + onDragStart?: () => void; + onDragEnd?: (dropZone: DropZone) => void; + isDragging?: boolean; +} + +// Drop zone types +type DropZone = { + type: 'folder' | 'root' | 'template'; + id: string; + position: 'before' | 'after' | 'inside'; +} +``` + +### Integration Points +- SQLite database for local storage +- Nostr event kind for folder structure +- Template list screen +- Template filtering system +- Drag animation system + +## Implementation Plan + +### Phase 1: Foundation +1. Update database schema +2. Create folder management hooks +3. Implement basic folder CRUD operations +4. Add folder view to template screen + +### Phase 2: Drag and Drop +1. Implement DraggableTemplateCard +2. Add drag gesture handling +3. Create drop zone detection +4. Implement reordering logic + +### Phase 3: Enhancement +1. Add folder customization +2. Implement batch operations +3. Add Nostr sync support +4. Polish animations and feedback + +## Testing Strategy + +### Unit Tests +- Folder CRUD operations +- Template ordering logic +- Drop zone detection +- Data structure validation + +### Integration Tests +- Drag and drop flows +- Folder persistence +- Search across folders +- Filter interactions + +## Observability + +### Logging +- Folder operations +- Drag and drop events +- Error conditions +- Performance metrics + +### Metrics +- Folder usage statistics +- Common template organizations +- Operation success rates +- Animation performance + +## Future Considerations + +### Potential Enhancements +- Nested folders +- Folder sharing +- Template duplicating +- Advanced sorting options +- Folder templates +- Batch operations +- Grid view option + +### Known Limitations +- Initial complexity increase +- Performance with very large template sets +- Cross-device sync challenges +- Platform-specific gesture differences + +## Dependencies + +### Runtime Dependencies +- react-native-reanimated ^3.0.0 +- react-native-gesture-handler ^2.0.0 +- SQLite storage +- NDK for Nostr sync + +### Development Dependencies +- TypeScript +- Jest for testing +- React Native testing library + +## Security Considerations +- Validate folder names +- Sanitize template data +- Secure local storage +- Safe Nostr event handling + +## Rollout Strategy + +### Development Phase +1. Implement core folder structure +2. Add basic drag-and-drop +3. Beta test with power users +4. Polish based on feedback + +### Production Deployment +1. Feature flag for initial release +2. Gradual rollout to users +3. Monitor performance metrics +4. Collect user feedback + +## References +- [React Native Reanimated Documentation](https://docs.swmansion.com/react-native-reanimated/) +- [React Native Gesture Handler Documentation](https://docs.swmansion.com/react-native-gesture-handler/) +- [SQLite Documentation](https://www.sqlite.org/docs.html) +- [Nostr NIP-01 Specification](https://github.com/nostr-protocol/nips/blob/master/01.md) \ No newline at end of file diff --git a/lib/contexts/SettingsDrawerContext.tsx b/lib/contexts/SettingsDrawerContext.tsx new file mode 100644 index 0000000..86f1e40 --- /dev/null +++ b/lib/contexts/SettingsDrawerContext.tsx @@ -0,0 +1,33 @@ +// lib/contexts/SettingsDrawerContext.tsx +import React, { createContext, useContext, useState, ReactNode } from 'react'; + +interface SettingsDrawerContextType { + isDrawerOpen: boolean; + openDrawer: () => void; + closeDrawer: () => void; + toggleDrawer: () => void; +} + +const SettingsDrawerContext = createContext(undefined); + +export function SettingsDrawerProvider({ children }: { children: ReactNode }) { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + const openDrawer = () => setIsDrawerOpen(true); + const closeDrawer = () => setIsDrawerOpen(false); + const toggleDrawer = () => setIsDrawerOpen(prev => !prev); + + return ( + + {children} + + ); +} + +export function useSettingsDrawer(): SettingsDrawerContextType { + const context = useContext(SettingsDrawerContext); + if (context === undefined) { + throw new Error('useSettingsDrawer must be used within a SettingsDrawerProvider'); + } + return context; +} \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 0c33705..7f33da7 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -2,7 +2,7 @@ import { SQLiteDatabase } from 'expo-sqlite'; import { Platform } from 'react-native'; -export const SCHEMA_VERSION = 3; // Incrementing version for new tables +export const SCHEMA_VERSION = 4; // Updated to version 4 for user_profiles table class Schema { private async getCurrentVersion(db: SQLiteDatabase): Promise { @@ -164,6 +164,52 @@ class Schema { 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'); + } + // Verify final schema const tables = await db.getAllAsync<{ name: string }>( "SELECT name FROM sqlite_master WHERE type='table'" diff --git a/lib/hooks/useNDK.ts b/lib/hooks/useNDK.ts new file mode 100644 index 0000000..d8a6640 --- /dev/null +++ b/lib/hooks/useNDK.ts @@ -0,0 +1,41 @@ +// lib/hooks/useNDK.ts +import { useEffect } from 'react'; +import { useNDKStore } from '../stores/ndk'; +import type { NDKUser } from '@nostr-dev-kit/ndk'; + +export function useNDK() { + const { ndk, isLoading, init } = useNDKStore(); + + useEffect(() => { + if (!ndk && !isLoading) { + init(); + } + }, [ndk, isLoading, init]); + + return { ndk, isLoading }; +} + +export function useNDKCurrentUser(): { + currentUser: NDKUser | null; + isAuthenticated: boolean; + isLoading: boolean; +} { + const { currentUser, isAuthenticated, isLoading } = useNDKStore(); + + return { + currentUser, + isAuthenticated, + isLoading + }; +} + +export function useNDKAuth() { + const { login, logout, isAuthenticated, isLoading } = useNDKStore(); + + return { + login, + logout, + isAuthenticated, + isLoading + }; +} \ No newline at end of file diff --git a/lib/hooks/useSubscribe.ts b/lib/hooks/useSubscribe.ts new file mode 100644 index 0000000..3b907c3 --- /dev/null +++ b/lib/hooks/useSubscribe.ts @@ -0,0 +1,68 @@ +// lib/hooks/useSubscribe.ts +import { useEffect, useState } from 'react'; +import { NDKEvent, NDKFilter, NDKSubscription } from '@nostr-dev-kit/ndk'; +import { useNDK } from './useNDK'; + +interface UseSubscribeOptions { + enabled?: boolean; + closeOnEose?: boolean; +} + +export function useSubscribe( + filters: NDKFilter[] | false, + options: UseSubscribeOptions = {} +) { + const { ndk } = useNDK(); + const [events, setEvents] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [eose, setEose] = useState(false); + + // Default options + const { enabled = true, closeOnEose = false } = options; + + useEffect(() => { + if (!ndk || !filters || !enabled) { + setIsLoading(false); + return; + } + + setIsLoading(true); + setEose(false); + + let subscription: NDKSubscription; + + try { + subscription = ndk.subscribe(filters); + + subscription.on('event', (event: NDKEvent) => { + setEvents(prev => { + // Avoid duplicates + if (prev.some(e => e.id === event.id)) { + return prev; + } + return [...prev, event]; + }); + }); + + subscription.on('eose', () => { + setIsLoading(false); + setEose(true); + + if (closeOnEose) { + subscription.stop(); + } + }); + } catch (error) { + console.error('[useSubscribe] Error:', error); + setIsLoading(false); + } + + return () => { + if (subscription) { + subscription.stop(); + } + }; + }, [ndk, enabled, closeOnEose, JSON.stringify(filters)]); + + return { events, isLoading, eose }; +} \ No newline at end of file diff --git a/lib/stores/ndk.ts b/lib/stores/ndk.ts new file mode 100644 index 0000000..01be4ef --- /dev/null +++ b/lib/stores/ndk.ts @@ -0,0 +1,197 @@ +// lib/stores/ndk.ts +import { create } from 'zustand'; +import NDK, { NDKEvent, NDKPrivateKeySigner, NDKUser } from '@nostr-dev-kit/ndk'; +import * as SecureStore from 'expo-secure-store'; +import { nip19 } from 'nostr-tools'; + +// Constants for SecureStore +const PRIVATE_KEY_STORAGE_KEY = 'nostr_privkey'; + +// Default relays +const DEFAULT_RELAYS = [ + 'wss://relay.damus.io', + 'wss://relay.nostr.band', + 'wss://purplepag.es', + 'wss://nos.lol' +]; + +// Helper function to convert Array/Uint8Array to hex string +function arrayToHex(array: number[] | Uint8Array): string { + return Array.from(array) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} + +type NDKStoreState = { + ndk: NDK | null; + currentUser: NDKUser | null; + isLoading: boolean; + isAuthenticated: boolean; + init: () => Promise; + login: (privateKey: string) => Promise; + logout: () => Promise; + getPublicKey: () => Promise; +}; + +export const useNDKStore = create((set, get) => ({ + ndk: null, + currentUser: null, + isLoading: true, + isAuthenticated: false, + + init: async () => { + try { + console.log('[NDK] Initializing...'); + // Initialize NDK with relays + const ndk = new NDK({ + explicitRelayUrls: DEFAULT_RELAYS + }); + + await ndk.connect(); + set({ ndk }); + + // Check for saved private key + const privateKey = await SecureStore.getItemAsync(PRIVATE_KEY_STORAGE_KEY); + if (privateKey) { + console.log('[NDK] Found saved private key, initializing signer'); + + try { + // Create signer with private key + const signer = new NDKPrivateKeySigner(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 + }); + } + } catch (error) { + console.error('[NDK] Error initializing with saved key:', error); + // Remove invalid key + await SecureStore.deleteItemAsync(PRIVATE_KEY_STORAGE_KEY); + } + } + } catch (error) { + console.error('[NDK] Initialization error:', error); + } finally { + set({ isLoading: false }); + } + }, + +// lib/stores/ndk.ts - updated login method +login: async (privateKey: string) => { + set({ isLoading: true }); + + try { + const { ndk } = get(); + if (!ndk) { + console.error('[NDK] NDK not initialized'); + return false; + } + + // Process the private key (handle nsec format) + let hexKey = privateKey; + + if (privateKey.startsWith('nsec1')) { + try { + const { type, data } = nip19.decode(privateKey); + if (type !== 'nsec') { + throw new Error('Invalid nsec key'); + } + + // Handle different data types + if (typeof data === 'string') { + hexKey = data; + } else if (Array.isArray(data)) { + // Convert array to hex string + hexKey = arrayToHex(data); + } else if (data instanceof Uint8Array) { + // Convert Uint8Array to hex string + hexKey = arrayToHex(data); + } else { + throw new Error('Unsupported key format'); + } + } catch (error) { + console.error('[NDK] Key decode error:', error); + throw new Error('Invalid private key format'); + } + } + + // Create signer with hex key + console.log('[NDK] Creating signer with key'); + const signer = new NDKPrivateKeySigner(hexKey); + ndk.signer = signer; + + // Get user + const user = await ndk.signer.user(); + if (!user) { + throw new Error('Failed to get user from signer'); + } + + // Fetch user profile + console.log('[NDK] Fetching user profile'); + await user.fetchProfile(); + + // Process profile data to ensure image property is set + if (user.profile) { + if (!user.profile.image && (user.profile as any).picture) { + user.profile.image = (user.profile as any).picture; + } + + console.log('[NDK] User profile loaded:', user.profile); + } + + // Save the private key securely + await SecureStore.setItemAsync(PRIVATE_KEY_STORAGE_KEY, hexKey); + + set({ + currentUser: user, + isAuthenticated: true + }); + + return true; + } catch (error) { + console.error('[NDK] Login error:', error); + return false; + } finally { + set({ isLoading: false }); + } + }, + + logout: async () => { + try { + // Remove private key from secure storage + await SecureStore.deleteItemAsync(PRIVATE_KEY_STORAGE_KEY); + + // Reset NDK state + const { ndk } = get(); + if (ndk) { + ndk.signer = undefined; + } + + // Completely reset the user state + set({ + currentUser: null, + isAuthenticated: false + }); + + console.log('[NDK] User logged out successfully'); + } catch (error) { + console.error('[NDK] Logout error:', error); + } + }, + + getPublicKey: async () => { + const { currentUser } = get(); + if (currentUser) { + return currentUser.pubkey; + } + return null; + } +})); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cbf1b7f..18bbaf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "dependencies": { "@expo/cli": "^0.22.16", + "@nostr-dev-kit/ndk": "^2.12.0", "@radix-ui/react-alert-dialog": "^1.1.6", "@react-native-clipboard/clipboard": "^1.16.1", "@react-navigation/material-top-tabs": "^7.1.0", @@ -48,6 +49,7 @@ "expo-linking": "~7.0.4", "expo-navigation-bar": "~4.0.8", "expo-router": "~4.0.16", + "expo-secure-store": "~14.0.1", "expo-splash-screen": "~0.29.20", "expo-sqlite": "~15.1.2", "expo-status-bar": "~2.0.1", @@ -56,6 +58,7 @@ "jest-expo": "~52.0.3", "lucide-react-native": "^0.378.0", "nativewind": "^4.1.23", + "nostr-tools": "^2.10.4", "react": "18.3.1", "react-dom": "18.3.1", "react-native": "0.76.7", @@ -70,7 +73,7 @@ "tailwind-merge": "^2.2.1", "tailwindcss": "3.3.5", "tailwindcss-animate": "^1.0.7", - "zustand": "^4.4.7" + "zustand": "^4.5.6" }, "devDependencies": { "@babel/core": "^7.26.0", @@ -3772,6 +3775,51 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/ciphers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", + "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.1.tgz", + "integrity": "sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.7.1" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz", + "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/secp256k1": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-2.2.3.tgz", + "integrity": "sha512-l7r5oEQym9Us7EAigzg30/PQAvynhMt2uoYtT3t26eGDVm9Yii5mZ5jWSWmZ/oSIR2Et0xfc6DXrG0bZ787V3w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3807,6 +3855,28 @@ "node": ">= 8" } }, + "node_modules/@nostr-dev-kit/ndk": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.12.0.tgz", + "integrity": "sha512-B9NKdgn9CKNn0WHIFzj7SxeZhr+daT5im/ozj9Ey791MkaZiTB5XUCy5j9O15FDHTyFy0/gpCyq7LvJKIxCOoA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@noble/secp256k1": "^2.1.0", + "@scure/base": "^1.1.9", + "debug": "^4.3.6", + "light-bolt11-decoder": "^3.2.0", + "nostr-tools": "^2.7.1", + "tseep": "^1.2.2", + "typescript-lru-cache": "^2.0.0", + "utf8-buffer": "^1.0.0", + "websocket-polyfill": "^0.0.3" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@npmcli/fs": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz", @@ -8688,6 +8758,108 @@ } } }, + "node_modules/@scure/base": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.4.tgz", + "integrity": "sha512-5Yy9czTO47mqz+/J8GM6GIId4umdCk1wc1q8rKERQulIoc8VP9pzDcghv10Tl2E7R96ZUx/PhND3ESYUQX8NuQ==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz", + "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.1.0", + "@noble/hashes": "~1.3.1", + "@scure/base": "~1.1.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz", + "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.1" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", + "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@scure/base": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", + "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz", + "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.3.0", + "@scure/base": "~1.1.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", + "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@scure/base": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", + "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@segment/loosely-validate-event": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@segment/loosely-validate-event/-/loosely-validate-event-2.0.0.tgz", @@ -9952,6 +10124,19 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/bufferutil": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", + "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -10840,6 +11025,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/data-uri-to-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz", @@ -11362,6 +11560,46 @@ "node": ">= 0.4" } }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -11444,6 +11682,21 @@ "node": ">=4.0" } }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -11497,6 +11750,16 @@ "node": ">= 0.6" } }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -11916,6 +12179,15 @@ "node": ">=10" } }, + "node_modules/expo-secure-store": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-14.0.1.tgz", + "integrity": "sha512-QUS+j4+UG4jRQalgnpmTvvrFnMVLqPiUZRzYPnG3+JrZ5kwVW2w6YS3WWerPoR7C6g3y/a2htRxRSylsDs+TaQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-splash-screen": { "version": "0.29.22", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-0.29.22.tgz", @@ -11975,6 +12247,15 @@ "integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==", "license": "Apache-2.0" }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -13278,6 +13559,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -14608,6 +14895,27 @@ "node": ">=6" } }, + "node_modules/light-bolt11-decoder": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.2.0.tgz", + "integrity": "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==", + "license": "MIT", + "dependencies": { + "@scure/base": "1.1.1" + } + }, + "node_modules/light-bolt11-decoder/node_modules/@scure/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT" + }, "node_modules/lighthouse-logger": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", @@ -15829,6 +16137,12 @@ "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==", "license": "MIT" }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, "node_modules/nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -15898,6 +16212,17 @@ "node": ">= 6.13.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -15919,6 +16244,86 @@ "node": ">=0.10.0" } }, + "node_modules/nostr-tools": { + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.10.4.tgz", + "integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==", + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "^0.5.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.1", + "@scure/base": "1.1.1", + "@scure/bip32": "1.3.1", + "@scure/bip39": "1.2.1" + }, + "optionalDependencies": { + "nostr-wasm": "0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-tools/node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@noble/hashes": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@scure/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT" + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "license": "MIT", + "optional": true + }, "node_modules/npm-package-arg": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", @@ -19315,18 +19720,36 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "license": "Apache-2.0" }, + "node_modules/tseep": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tseep/-/tseep-1.3.1.tgz", + "integrity": "sha512-ZPtfk1tQnZVyr7BPtbJ93qaAh2lZuIOpTMjhrYa4XctT8xe7t4SAW9LIxrySDuYMsfNNayE51E/WNGrNVgVicQ==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tstl": { + "version": "2.5.16", + "resolved": "https://registry.npmjs.org/tstl/-/tstl-2.5.16.tgz", + "integrity": "sha512-+O2ybLVLKcBwKm4HymCEwZIT0PpwS3gCYnxfSDEjJEKADvIFruaQjd3m7CAKNU1c7N3X3WjVz87re7TA2A5FUw==", + "license": "MIT" + }, "node_modules/turbo-stream": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==", "license": "ISC" }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -19348,6 +19771,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, "node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", @@ -19362,6 +19794,12 @@ "node": ">=14.17" } }, + "node_modules/typescript-lru-cache": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/typescript-lru-cache/-/typescript-lru-cache-2.0.0.tgz", + "integrity": "sha512-Jp57Qyy8wXeMkdNuZiglE6v2Cypg13eDA1chHwDG6kq51X7gk4K7P7HaDdzZKCxkegXkVHNcPD0n5aW6OZH3aA==", + "license": "MIT" + }, "node_modules/ua-parser-js": { "version": "1.0.40", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz", @@ -19598,6 +20036,28 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/utf8-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/utf8-buffer/-/utf8-buffer-1.0.0.tgz", + "integrity": "sha512-ueuhzvWnp5JU5CiGSY4WdKbiN/PO2AZ/lpeLiz2l38qwdLy/cW40XobgyuIWucNyum0B33bVB0owjFCeGBSLqg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -19810,6 +20270,47 @@ "node": ">=10.13.0" } }, + "node_modules/websocket": { + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz", + "integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==", + "license": "Apache-2.0", + "dependencies": { + "bufferutil": "^4.0.1", + "debug": "^2.2.0", + "es5-ext": "^0.10.63", + "typedarray-to-buffer": "^3.1.5", + "utf-8-validate": "^5.0.2", + "yaeti": "^0.0.6" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/websocket-polyfill": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/websocket-polyfill/-/websocket-polyfill-0.0.3.tgz", + "integrity": "sha512-pF3kR8Uaoau78MpUmFfzbIRxXj9PeQrCuPepGE6JIsfsJ/o/iXr07Q2iQNzKSSblQJ0FiGWlS64N4pVSm+O3Dg==", + "dependencies": { + "tstl": "^2.0.7", + "websocket": "^1.0.28" + } + }, + "node_modules/websocket/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/websocket/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/whatwg-encoding": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", @@ -20064,6 +20565,15 @@ "node": ">=10" } }, + "node_modules/yaeti": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", + "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", + "license": "MIT", + "engines": { + "node": ">=0.10.32" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 673cd58..f4cedb1 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@expo/cli": "^0.22.16", + "@nostr-dev-kit/ndk": "^2.12.0", "@radix-ui/react-alert-dialog": "^1.1.6", "@react-native-clipboard/clipboard": "^1.16.1", "@react-navigation/material-top-tabs": "^7.1.0", @@ -62,6 +63,7 @@ "expo-linking": "~7.0.4", "expo-navigation-bar": "~4.0.8", "expo-router": "~4.0.16", + "expo-secure-store": "~14.0.1", "expo-splash-screen": "~0.29.20", "expo-sqlite": "~15.1.2", "expo-status-bar": "~2.0.1", @@ -70,6 +72,7 @@ "jest-expo": "~52.0.3", "lucide-react-native": "^0.378.0", "nativewind": "^4.1.23", + "nostr-tools": "^2.10.4", "react": "18.3.1", "react-dom": "18.3.1", "react-native": "0.76.7", @@ -84,8 +87,7 @@ "tailwind-merge": "^2.2.1", "tailwindcss": "3.3.5", "tailwindcss-animate": "^1.0.7", - "zustand": "^4.4.7", - "expo-secure-store": "~14.0.1" + "zustand": "^4.5.6" }, "devDependencies": { "@babel/core": "^7.26.0",