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",