nostr login and initial settings component, workout template details component

This commit is contained in:
DocNR 2025-02-22 01:16:33 -05:00
parent bd123017f4
commit 05d3c02523
20 changed files with 4327 additions and 311 deletions

View File

@ -54,7 +54,8 @@
"enableFTS": true "enableFTS": true
} }
} }
] ],
"expo-secure-store"
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true

View File

@ -207,7 +207,7 @@ export default function TemplatesScreen() {
<View className="h-20" /> <View className="h-20" />
</ScrollView> </ScrollView>
{/* Rest of the components (sheets & FAB) remain the same */} {/* Template Details with tabs */}
{selectedTemplate && ( {selectedTemplate && (
<TemplateDetails <TemplateDetails
template={selectedTemplate} template={selectedTemplate}

View File

@ -1,18 +1,103 @@
// app/(tabs)/profile.tsx // app/(tabs)/profile.tsx
import { View, ScrollView } from 'react-native'; import { View, ScrollView, ImageBackground } from 'react-native';
import { Settings } from 'lucide-react-native'; import { useState, useEffect } from 'react';
import { Settings, LogIn } from 'lucide-react-native';
import { H1 } from '@/components/ui/typography'; import { H1 } from '@/components/ui/typography';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text'; import { Text } from '@/components/ui/text';
import Header from '@/components/Header'; import Header from '@/components/Header';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { TabScreen } from '@/components/layout/TabScreen'; import { TabScreen } from '@/components/layout/TabScreen';
import UserAvatar from '@/components/UserAvatar';
const PLACEHOLDER_IMAGE = 'https://github.com/shadcn.png'; import { useNDKCurrentUser } from '@/lib/hooks/useNDK';
import NostrLoginSheet from '@/components/sheets/NostrLoginSheet';
export default function ProfileScreen() { export default function ProfileScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { currentUser, isAuthenticated } = useNDKCurrentUser();
const [profileImageUrl, setProfileImageUrl] = useState<string | undefined>(undefined);
const [bannerImageUrl, setBannerImageUrl] = useState<string | undefined>(undefined);
const [aboutText, setAboutText] = useState<string | undefined>(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 (
<TabScreen>
<Header title="Profile" />
<View className="flex-1 items-center justify-center p-6">
<View className="items-center mb-8">
<UserAvatar
size="xl"
fallback="G"
className="mb-4"
isInteractive={false}
/>
<H1 className="text-xl font-semibold mb-1">Guest User</H1>
<Text className="text-muted-foreground mb-6">Not logged in</Text>
<Text className="text-center text-muted-foreground mb-8">
Login with your Nostr private key to view and manage your profile.
</Text>
<Button
onPress={() => setIsLoginSheetOpen(true)}
className="px-6"
>
<Text>Login with Nostr</Text>
</Button>
</View>
</View>
{/* NostrLoginSheet */}
<NostrLoginSheet
open={isLoginSheetOpen}
onClose={() => setIsLoginSheetOpen(false)}
/>
</TabScreen>
);
}
return ( return (
<TabScreen> <TabScreen>
@ -35,19 +120,43 @@ export default function ProfileScreen() {
paddingBottom: insets.bottom + 20 paddingBottom: insets.bottom + 20
}} }}
> >
{/* Profile content remains the same */} {/* Banner Image */}
<View className="items-center pt-6 pb-8"> <View className="w-full h-40 relative">
<Avatar className="w-24 h-24 mb-4" alt="Profile picture"> {bannerImageUrl ? (
<AvatarImage source={{ uri: PLACEHOLDER_IMAGE }} /> <ImageBackground
<AvatarFallback> source={{ uri: bannerImageUrl }}
<Text className="text-2xl">JD</Text> className="w-full h-full"
</AvatarFallback> resizeMode="cover"
</Avatar> >
<H1 className="text-xl font-semibold mb-1">John Doe</H1> <View className="absolute inset-0 bg-black/20" />
<Text className="text-muted-foreground">@johndoe</Text> </ImageBackground>
) : (
<View className="w-full h-full bg-accent" />
)}
</View> </View>
<View className="flex-row justify-around px-4 py-6 bg-card"> {/* Profile Avatar and Name - positioned to overlap the banner */}
<View className="items-center -mt-16 pb-6">
<UserAvatar
size="xl"
uri={profileImageUrl}
fallback={displayName.charAt(0)}
className="mb-4 border-4 border-background"
isInteractive={false}
/>
<H1 className="text-xl font-semibold mb-1">{displayName}</H1>
<Text className="text-muted-foreground mb-4">{username}</Text>
{/* About section */}
{aboutText && (
<View className="px-6 py-2 w-full">
<Text className="text-center text-foreground">{aboutText}</Text>
</View>
)}
</View>
{/* Stats */}
<View className="flex-row justify-around px-4 py-6 bg-card mx-4 rounded-lg">
<View className="items-center"> <View className="items-center">
<Text className="text-2xl font-bold">24</Text> <Text className="text-2xl font-bold">24</Text>
<Text className="text-muted-foreground">Workouts</Text> <Text className="text-muted-foreground">Workouts</Text>

View File

@ -1,3 +1,4 @@
// app/_layout.tsx
import '@/global.css'; import '@/global.css';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
@ -11,6 +12,9 @@ import { setAndroidNavigationBar } from '@/lib/android-navigation-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { DatabaseProvider } from '@/components/DatabaseProvider'; import { DatabaseProvider } from '@/components/DatabaseProvider';
import { ErrorBoundary } from '@/components/ErrorBoundary'; 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 = { const LIGHT_THEME = {
...DefaultTheme, ...DefaultTheme,
@ -25,21 +29,26 @@ const DARK_THEME = {
export default function RootLayout() { export default function RootLayout() {
const [isInitialized, setIsInitialized] = React.useState(false); const [isInitialized, setIsInitialized] = React.useState(false);
const { colorScheme, isDarkColorScheme } = useColorScheme(); const { colorScheme, isDarkColorScheme } = useColorScheme();
const { init } = useNDKStore();
React.useEffect(() => { React.useEffect(() => {
async function init() { async function initApp() {
try { try {
if (Platform.OS === 'web') { if (Platform.OS === 'web') {
document.documentElement.classList.add('bg-background'); document.documentElement.classList.add('bg-background');
} }
setAndroidNavigationBar(colorScheme); setAndroidNavigationBar(colorScheme);
// Initialize NDK
await init();
setIsInitialized(true); setIsInitialized(true);
} catch (error) { } catch (error) {
console.error('Failed to initialize:', error); console.error('Failed to initialize:', error);
} }
} }
init(); initApp();
}, []); }, []);
if (!isInitialized) { if (!isInitialized) {
@ -55,6 +64,7 @@ export default function RootLayout() {
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<DatabaseProvider> <DatabaseProvider>
<ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}> <ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}>
<SettingsDrawerProvider>
<StatusBar style={isDarkColorScheme ? 'light' : 'dark'} /> <StatusBar style={isDarkColorScheme ? 'light' : 'dark'} />
<Stack screenOptions={{ headerShown: false }}> <Stack screenOptions={{ headerShown: false }}>
<Stack.Screen <Stack.Screen
@ -64,7 +74,12 @@ export default function RootLayout() {
}} }}
/> />
</Stack> </Stack>
{/* Settings drawer needs to be outside the navigation stack */}
<SettingsDrawer />
<PortalHost /> <PortalHost />
</SettingsDrawerProvider>
</ThemeProvider> </ThemeProvider>
</DatabaseProvider> </DatabaseProvider>
</GestureHandlerRootView> </GestureHandlerRootView>

View File

@ -1,4 +1,3 @@
// components/DatabaseProvider.tsx
import React from 'react'; import React from 'react';
import { View, ActivityIndicator, Text } from 'react-native'; import { View, ActivityIndicator, Text } from 'react-native';
import { SQLiteProvider, openDatabaseSync, SQLiteDatabase } from 'expo-sqlite'; import { SQLiteProvider, openDatabaseSync, SQLiteDatabase } from 'expo-sqlite';
@ -13,6 +12,7 @@ interface DatabaseServicesContextValue {
exerciseService: ExerciseService | null; exerciseService: ExerciseService | null;
eventCache: EventCache | null; eventCache: EventCache | null;
devSeeder: DevSeederService | null; devSeeder: DevSeederService | null;
// Remove NostrService since we're using the hooks-based approach now
} }
const DatabaseServicesContext = React.createContext<DatabaseServicesContextValue>({ const DatabaseServicesContext = React.createContext<DatabaseServicesContextValue>({

View File

@ -1,36 +1,106 @@
// components/Header.tsx // components/Header.tsx
import React from 'react'; import React from 'react';
import { View, Platform } from 'react-native'; import { View, StyleSheet, Platform } from 'react-native';
import { Text } from '@/components/ui/text'; import { useTheme } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; 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 { interface HeaderProps {
title: string; title?: string;
hideTitle?: boolean;
rightElement?: React.ReactNode; 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 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 ( return (
<View <View
className="flex-row items-center justify-between bg-card border-b border-border" style={[
style={{ styles.header,
paddingTop: Platform.OS === 'ios' ? insets.top : insets.top + 8, {
paddingHorizontal: 16, paddingTop: insets.top,
paddingBottom: 12, backgroundColor: theme.colors.card,
}} borderBottomColor: theme.colors.border
}
]}
> >
<View className="flex-1"> <View style={styles.headerContent}>
<Text className="text-2xl font-bold">{title}</Text> {/* Left side - User avatar that opens settings drawer */}
{children} <UserAvatar
</View> size="sm"
{rightElement && ( uri={profileImageUrl}
<View className="ml-4"> onPress={openDrawer}
{rightElement} fallback={fallbackLetter}
/>
{/* Middle - Title */}
<View style={styles.titleContainer}>
<Text style={styles.title}>{title}</Text>
</View> </View>
{/* Right side - Custom element or default notifications */}
<View style={styles.rightContainer}>
{rightElement || (
<Button
variant="ghost"
size="icon"
onPress={() => {}}
>
<Bell size={24} className="text-foreground" />
</Button>
)} )}
</View> </View>
</View>
</View>
); );
} }
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',
},
});

View File

@ -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: (
<Switch
checked={isDarkColorScheme}
onCheckedChange={toggleColorScheme}
/>
),
},
{
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 (
<>
<View style={StyleSheet.absoluteFill}>
{/* Backdrop overlay */}
<Animated.View
style={[
StyleSheet.absoluteFill,
{
backgroundColor: 'rgba(0, 0, 0, 0.4)',
opacity: fadeAnim
}
]}
>
<Pressable
style={StyleSheet.absoluteFill}
onPress={closeDrawer}
/>
</Animated.View>
{/* Drawer */}
<Animated.View
style={[
styles.drawer,
{
transform: [{ translateX: slideAnim }],
backgroundColor: theme.colors.card,
borderRightColor: theme.colors.border,
}
]}
>
<SafeAreaView style={{ flex: 1 }} edges={['top', 'bottom']}>
{/* Header with close button */}
<View style={styles.header}>
<Text className="text-xl font-semibold">Settings</Text>
<Button
variant="ghost"
size="icon"
onPress={closeDrawer}
className="absolute right-4"
>
<X size={24} className="text-foreground" />
</Button>
</View>
{/* Profile section - make it touchable */}
<TouchableOpacity
style={[styles.profileSection, { borderBottomColor: theme.colors.border }]}
onPress={handleProfileClick}
activeOpacity={0.7}
>
<Avatar
alt={currentUser?.profile?.name || "User profile"}
className="h-16 w-16"
>
{isAuthenticated && currentUser?.profile?.image ? (
<AvatarImage source={{ uri: currentUser.profile.image }} />
) : null}
<AvatarFallback>
{isAuthenticated && currentUser?.profile?.name ? (
<Text className="text-foreground">
{currentUser.profile.name.charAt(0).toUpperCase()}
</Text>
) : (
<User size={28} />
)}
</AvatarFallback>
</Avatar>
<View style={styles.profileInfo}>
<Text className="text-lg font-semibold">
{isAuthenticated ? currentUser?.profile?.name || 'Nostr User' : 'Not Logged In'}
</Text>
<Text className="text-muted-foreground">
{isAuthenticated ? 'Edit Profile' : 'Login with Nostr'}
</Text>
</View>
<ChevronRight size={20} color={theme.colors.text} />
</TouchableOpacity>
{/* Menu items */}
<ScrollView
style={styles.menuList}
contentContainerStyle={styles.menuContent}
showsVerticalScrollIndicator={false}
>
{menuItems.map((item, index) => (
<View key={item.id}>
<TouchableOpacity
style={styles.menuItem}
onPress={item.onPress}
activeOpacity={0.7}
>
<View style={styles.menuItemLeft}>
<item.icon size={22} color={theme.colors.text} />
<Text className="text-base ml-3">{item.label}</Text>
</View>
{item.rightElement ? (
item.rightElement
) : (
<ChevronRight size={20} color={theme.colors.text} />
)}
</TouchableOpacity>
{index < menuItems.length - 1 && (
<Separator className="mb-1 mt-1" />
)}
</View>
))}
</ScrollView>
{/* Sign out button at the bottom - only show when authenticated */}
{isAuthenticated && (
<View style={styles.footer}>
<Button
variant="destructive"
className="w-full"
onPress={handleSignOut}
>
<Text className="text-destructive-foreground">Sign Out</Text>
</Button>
</View>
)}
</SafeAreaView>
</Animated.View>
</View>
{/* Only render the NostrLoginSheet on iOS */}
{Platform.OS === 'ios' && (
<NostrLoginSheet
open={isLoginSheetOpen}
onClose={() => setIsLoginSheetOpen(false)}
/>
)}
{/* Sign Out Alert Dialog */}
<AlertDialog open={showSignOutAlert} onOpenChange={setShowSignOutAlert}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>For Real?</AlertDialogTitle>
<AlertDialogDescription>
<Text>
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.
</Text>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onPress={() => setShowSignOutAlert(false)}>
<Text>Cancel</Text>
</AlertDialogCancel>
<AlertDialogAction
onPress={confirmSignOut}
className="bg-destructive text-destructive-foreground"
>
<Text>Sign Out</Text>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
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,
},
});

94
components/UserAvatar.tsx Normal file
View File

@ -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<string | undefined>(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 = (
<Avatar
className={containerStyles}
alt="User profile image"
>
{imageUri && !imageError ? (
<AvatarImage
source={{ uri: imageUri }}
onError={handleImageError}
/>
) : (
<AvatarFallback>
<Text className="text-foreground">{fallback}</Text>
</AvatarFallback>
)}
</Avatar>
);
if (!isInteractive) return avatarContent;
return (
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.8}
accessibilityRole="button"
accessibilityLabel="User profile"
{...props}
>
{avatarContent}
</TouchableOpacity>
);
};
export default UserAvatar;

View File

@ -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 (
<Sheet
isOpen={open}
onClose={onClose}
>
<SheetContent>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}
>
<SheetHeader>
<SheetTitle>Login with Nostr</SheetTitle>
{/* Removed the X close button here */}
</SheetHeader>
<ScrollView style={styles.scrollView}>
<View style={styles.content}>
<Text className="mb-2">Enter your Nostr private key</Text>
<Input
placeholder="nsec1..."
value={privateKey}
onChangeText={setPrivateKey}
secureTextEntry
autoCapitalize="none"
className="mb-4"
/>
<Button
onPress={handleLogin}
disabled={isLoading}
className="w-full mb-6"
>
<Text>{isLoading ? 'Logging in...' : 'Login'}</Text>
{!isLoading}
</Button>
<Separator className="mb-4" />
<View className="bg-secondary/30 p-3 rounded-md">
<View className="flex-row items-center mb-2">
<Info size={16} className="mr-3 text-muted-foreground" />
<Text className="font-semibold">What is a Nostr Key?</Text>
</View>
<Text className="text-sm text-muted-foreground mb-2">
Nostr is a decentralized protocol where your private key (nsec) is your identity and password.
</Text>
<Text className="text-sm text-muted-foreground">
Your private key is securely stored on your device and is never sent to any servers.
</Text>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SheetContent>
</Sheet>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollView: {
flex: 1,
},
content: {
padding: 16,
},
});

View File

@ -17,7 +17,6 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Template, TemplateExerciseDisplay } from '@/types/templates'; import { Template, TemplateExerciseDisplay } from '@/types/templates';
interface TemplateCardProps { interface TemplateCardProps {
@ -35,8 +34,8 @@ export function TemplateCard({
onFavorite, onFavorite,
onStartWorkout onStartWorkout
}: TemplateCardProps) { }: TemplateCardProps) {
const [showSheet, setShowSheet] = React.useState(false);
const [showDeleteAlert, setShowDeleteAlert] = React.useState(false); const [showDeleteAlert, setShowDeleteAlert] = React.useState(false);
const lastUsed = template.metadata?.lastUsed ? new Date(template.metadata.lastUsed) : undefined;
const { const {
id, id,
@ -47,25 +46,16 @@ export function TemplateCard({
description, description,
tags = [], tags = [],
source, source,
metadata,
isFavorite isFavorite
} = template; } = template;
const lastUsed = metadata?.lastUsed ? new Date(metadata.lastUsed) : undefined;
const handleConfirmDelete = () => { const handleConfirmDelete = () => {
onDelete(id); onDelete(id);
setShowDeleteAlert(false); setShowDeleteAlert(false);
}; };
const handleCardPress = () => {
setShowSheet(true);
onPress();
};
return ( return (
<> <TouchableOpacity onPress={onPress} activeOpacity={0.7}>
<TouchableOpacity onPress={handleCardPress} activeOpacity={0.7}>
<Card className="mx-4"> <Card className="mx-4">
<CardContent className="p-4"> <CardContent className="p-4">
<View className="flex-row justify-between items-start"> <View className="flex-row justify-between items-start">
@ -97,7 +87,7 @@ export function TemplateCard({
Exercises: Exercises:
</Text> </Text>
<View className="gap-1"> <View className="gap-1">
{exercises.slice(0, 3).map((exercise: TemplateExerciseDisplay, index: number) => ( {exercises.slice(0, 3).map((exercise, index) => (
<Text key={index} className="text-sm text-muted-foreground"> <Text key={index} className="text-sm text-muted-foreground">
{exercise.title} ({exercise.targetSets}×{exercise.targetReps}) {exercise.title} ({exercise.targetSets}×{exercise.targetReps})
</Text> </Text>
@ -119,7 +109,7 @@ export function TemplateCard({
{tags.length > 0 && ( {tags.length > 0 && (
<View className="flex-row flex-wrap gap-2 mt-2"> <View className="flex-row flex-wrap gap-2 mt-2">
{tags.map((tag: string) => ( {tags.map(tag => (
<Badge key={tag} variant="outline" className="text-xs"> <Badge key={tag} variant="outline" className="text-xs">
<Text>{tag}</Text> <Text>{tag}</Text>
</Badge> </Badge>
@ -140,6 +130,7 @@ export function TemplateCard({
size="icon" size="icon"
onPress={onStartWorkout} onPress={onStartWorkout}
className="native:h-10 native:w-10" className="native:h-10 native:w-10"
accessibilityLabel="Start workout"
> >
<Play className="text-primary" size={20} /> <Play className="text-primary" size={20} />
</Button> </Button>
@ -148,6 +139,7 @@ export function TemplateCard({
size="icon" size="icon"
onPress={onFavorite} onPress={onFavorite}
className="native:h-10 native:w-10" className="native:h-10 native:w-10"
accessibilityLabel={isFavorite ? "Remove from favorites" : "Add to favorites"}
> >
<Star <Star
className={isFavorite ? "text-primary" : "text-muted-foreground"} className={isFavorite ? "text-primary" : "text-muted-foreground"}
@ -161,6 +153,7 @@ export function TemplateCard({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="native:h-10 native:w-10 native:bg-muted/50 items-center justify-center" className="native:h-10 native:w-10 native:bg-muted/50 items-center justify-center"
accessibilityLabel="Delete template"
> >
<Trash2 <Trash2
size={20} size={20}
@ -196,58 +189,5 @@ export function TemplateCard({
</CardContent> </CardContent>
</Card> </Card>
</TouchableOpacity> </TouchableOpacity>
{/* Sheet for detailed view */}
<Sheet isOpen={showSheet} onClose={() => setShowSheet(false)}>
<SheetHeader>
<SheetTitle>
<Text className="text-xl font-bold">{title}</Text>
</SheetTitle>
</SheetHeader>
<SheetContent>
<View className="gap-6">
{description && (
<View>
<Text className="text-base font-semibold mb-2">Description</Text>
<Text className="text-base leading-relaxed">{description}</Text>
</View>
)}
<View>
<Text className="text-base font-semibold mb-2">Details</Text>
<View className="gap-2">
<Text className="text-base">Type: {type}</Text>
<Text className="text-base">Category: {category}</Text>
<Text className="text-base">Source: {source}</Text>
{metadata?.useCount && (
<Text className="text-base">Times Used: {metadata.useCount}</Text>
)}
</View>
</View>
<View>
<Text className="text-base font-semibold mb-2">Exercises</Text>
<View className="gap-2">
{exercises.map((exercise: TemplateExerciseDisplay, index: number) => (
<Text key={index} className="text-base">
{exercise.title} ({exercise.targetSets}×{exercise.targetReps})
</Text>
))}
</View>
</View>
{tags.length > 0 && (
<View>
<Text className="text-base font-semibold mb-2">Tags</Text>
<View className="flex-row flex-wrap gap-2">
{tags.map((tag: string) => (
<Badge key={tag} variant="secondary">
<Text>{tag}</Text>
</Badge>
))}
</View>
</View>
)}
</View>
</SheetContent>
</Sheet>
</>
); );
} }

View File

@ -14,13 +14,10 @@ import {
Calendar, Calendar,
Hash, Hash,
ClipboardList, ClipboardList,
Settings Settings,
LineChart
} from 'lucide-react-native'; } from 'lucide-react-native';
import { import { WorkoutTemplate, getSourceDisplay } from '@/types/templates';
WorkoutTemplate,
TemplateSource,
getSourceDisplay
} from '@/types/templates';
import { useTheme } from '@react-navigation/native'; import { useTheme } from '@react-navigation/native';
import type { CustomTheme } from '@/lib/theme'; import type { CustomTheme } from '@/lib/theme';
@ -43,7 +40,7 @@ function OverviewTab({ template, onEdit }: { template: WorkoutTemplate; onEdit?:
exercises = [], exercises = [],
tags = [], tags = [],
metadata, metadata,
availability // Replace source with availability availability
} = template; } = template;
// Calculate source type from availability // Calculate source type from availability
@ -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 // History Tab Component
function HistoryTab({ template }: { template: WorkoutTemplate }) { function HistoryTab({ template }: { template: WorkoutTemplate }) {
return ( return (
@ -206,22 +190,44 @@ function HistoryTab({ template }: { template: WorkoutTemplate }) {
{/* Performance Stats */} {/* Performance Stats */}
<View> <View>
<Text className="text-base font-semibold text-foreground mb-4">Performance Summary</Text> <Text className="text-base font-semibold text-foreground mb-4">Performance Summary</Text>
<View className="flex-row gap-4"> <View className="gap-4">
<View className="flex-1 bg-card p-4 rounded-lg"> <View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Avg. Duration</Text> <Text className="text-sm text-muted-foreground mb-1">Usage Stats</Text>
<View className="flex-row justify-between mt-2">
<View>
<Text className="text-sm text-muted-foreground">Total Workouts</Text>
<Text className="text-lg font-semibold text-foreground">
{template.metadata?.useCount || 0}
</Text>
</View>
<View>
<Text className="text-sm text-muted-foreground">Avg. Duration</Text>
<Text className="text-lg font-semibold text-foreground"> <Text className="text-lg font-semibold text-foreground">
{template.metadata?.averageDuration {template.metadata?.averageDuration
? `${Math.round(template.metadata.averageDuration / 60)}m` ? `${Math.round(template.metadata.averageDuration / 60)}m`
: '--'} : '--'}
</Text> </Text>
</View> </View>
<View className="flex-1 bg-card p-4 rounded-lg"> <View>
<Text className="text-sm text-muted-foreground mb-1">Completion Rate</Text> <Text className="text-sm text-muted-foreground">Completion Rate</Text>
<Text className="text-lg font-semibold text-foreground">--</Text> <Text className="text-lg font-semibold text-foreground">--</Text>
</View> </View>
</View> </View>
</View> </View>
{/* Progress Chart Placeholder */}
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-4">Progress Over Time</Text>
<View className="h-40 items-center justify-center">
<LineChart size={24} className="text-muted-foreground mb-2" />
<Text className="text-sm text-muted-foreground">
Progress tracking coming soon
</Text>
</View>
</View>
</View>
</View>
{/* History List */} {/* History List */}
<View> <View>
<Text className="text-base font-semibold text-foreground mb-4">Recent Workouts</Text> <Text className="text-base font-semibold text-foreground mb-4">Recent Workouts</Text>
@ -232,6 +238,9 @@ function HistoryTab({ template }: { template: WorkoutTemplate }) {
<Text className="text-muted-foreground text-center"> <Text className="text-muted-foreground text-center">
No workout history available yet No workout history available yet
</Text> </Text>
<Text className="text-sm text-muted-foreground text-center mt-1">
Complete a workout using this template to see your history
</Text>
</View> </View>
</View> </View>
</View> </View>
@ -250,6 +259,13 @@ function SettingsTab({ template }: { template: WorkoutTemplate }) {
restBetweenRounds, restBetweenRounds,
} = template; } = 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 ( return (
<ScrollView <ScrollView
className="flex-1 px-4" className="flex-1 px-4"
@ -261,31 +277,31 @@ function SettingsTab({ template }: { template: WorkoutTemplate }) {
<Text className="text-base font-semibold text-foreground mb-4">Workout Settings</Text> <Text className="text-base font-semibold text-foreground mb-4">Workout Settings</Text>
<View className="gap-4"> <View className="gap-4">
<View className="bg-card p-4 rounded-lg"> <View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Type</Text> <Text className="text-sm text-muted-foreground mb-1">Workout Type</Text>
<Text className="text-base font-medium text-foreground capitalize">{type}</Text> <Text className="text-base font-medium text-foreground capitalize">{type}</Text>
</View> </View>
{rounds && ( {rounds && (
<View className="bg-card p-4 rounded-lg"> <View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Rounds</Text> <Text className="text-sm text-muted-foreground mb-1">Number of Rounds</Text>
<Text className="text-base font-medium text-foreground">{rounds}</Text> <Text className="text-base font-medium text-foreground">{rounds}</Text>
</View> </View>
)} )}
{duration && ( {duration && (
<View className="bg-card p-4 rounded-lg"> <View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Duration</Text> <Text className="text-sm text-muted-foreground mb-1">Total Duration</Text>
<Text className="text-base font-medium text-foreground"> <Text className="text-base font-medium text-foreground">
{Math.floor(duration / 60)}:{(duration % 60).toString().padStart(2, '0')} {formatTime(duration)}
</Text> </Text>
</View> </View>
)} )}
{interval && ( {interval && (
<View className="bg-card p-4 rounded-lg"> <View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Interval</Text> <Text className="text-sm text-muted-foreground mb-1">Interval Time</Text>
<Text className="text-base font-medium text-foreground"> <Text className="text-base font-medium text-foreground">
{Math.floor(interval / 60)}:{(interval % 60).toString().padStart(2, '0')} {formatTime(interval)}
</Text> </Text>
</View> </View>
)} )}
@ -294,12 +310,23 @@ function SettingsTab({ template }: { template: WorkoutTemplate }) {
<View className="bg-card p-4 rounded-lg"> <View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Rest Between Rounds</Text> <Text className="text-sm text-muted-foreground mb-1">Rest Between Rounds</Text>
<Text className="text-base font-medium text-foreground"> <Text className="text-base font-medium text-foreground">
{Math.floor(restBetweenRounds / 60)}:{(restBetweenRounds % 60).toString().padStart(2, '0')} {formatTime(restBetweenRounds)}
</Text> </Text>
</View> </View>
)} )}
</View> </View>
</View> </View>
{/* Sync Settings */}
<View>
<Text className="text-base font-semibold text-foreground mb-4">Sync Settings</Text>
<View className="bg-card p-4 rounded-lg">
<Text className="text-sm text-muted-foreground mb-1">Template Source</Text>
<Text className="text-base font-medium text-foreground">
{getSourceDisplay(template)}
</Text>
</View>
</View>
</View> </View>
</ScrollView> </ScrollView>
); );
@ -321,7 +348,7 @@ export function TemplateDetails({
</SheetTitle> </SheetTitle>
</SheetHeader> </SheetHeader>
<SheetContent> <SheetContent>
<View style={{ flex: 1, minHeight: 400 }}> <View style={{ flex: 1, minHeight: 400 }} className="rounded-t-[10px]">
<Tab.Navigator <Tab.Navigator
style={{ flex: 1 }} style={{ flex: 1 }}
screenOptions={{ screenOptions={{

File diff suppressed because it is too large Load Diff

View File

@ -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<string>;
updateFolder: (id: string, data: Partial<TemplateFolder>) => Promise<void>;
deleteFolder: (id: string) => Promise<void>;
reorderFolder: (id: string, newOrder: number) => Promise<void>;
}
// 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)

View File

@ -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<SettingsDrawerContextType | undefined>(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 (
<SettingsDrawerContext.Provider value={{ isDrawerOpen, openDrawer, closeDrawer, toggleDrawer }}>
{children}
</SettingsDrawerContext.Provider>
);
}
export function useSettingsDrawer(): SettingsDrawerContextType {
const context = useContext(SettingsDrawerContext);
if (context === undefined) {
throw new Error('useSettingsDrawer must be used within a SettingsDrawerProvider');
}
return context;
}

View File

@ -2,7 +2,7 @@
import { SQLiteDatabase } from 'expo-sqlite'; import { SQLiteDatabase } from 'expo-sqlite';
import { Platform } from 'react-native'; 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 { class Schema {
private async getCurrentVersion(db: SQLiteDatabase): Promise<number> { private async getCurrentVersion(db: SQLiteDatabase): Promise<number> {
@ -164,6 +164,52 @@ class Schema {
console.log('[Schema] Version 3 upgrade completed'); 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 // Verify final schema
const tables = await db.getAllAsync<{ name: string }>( const tables = await db.getAllAsync<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type='table'" "SELECT name FROM sqlite_master WHERE type='table'"

41
lib/hooks/useNDK.ts Normal file
View File

@ -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
};
}

68
lib/hooks/useSubscribe.ts Normal file
View File

@ -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<NDKEvent[]>([]);
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 };
}

197
lib/stores/ndk.ts Normal file
View File

@ -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<void>;
login: (privateKey: string) => Promise<boolean>;
logout: () => Promise<void>;
getPublicKey: () => Promise<string | null>;
};
export const useNDKStore = create<NDKStoreState>((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;
}
}));

512
package-lock.json generated
View File

@ -10,6 +10,7 @@
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@expo/cli": "^0.22.16", "@expo/cli": "^0.22.16",
"@nostr-dev-kit/ndk": "^2.12.0",
"@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-alert-dialog": "^1.1.6",
"@react-native-clipboard/clipboard": "^1.16.1", "@react-native-clipboard/clipboard": "^1.16.1",
"@react-navigation/material-top-tabs": "^7.1.0", "@react-navigation/material-top-tabs": "^7.1.0",
@ -48,6 +49,7 @@
"expo-linking": "~7.0.4", "expo-linking": "~7.0.4",
"expo-navigation-bar": "~4.0.8", "expo-navigation-bar": "~4.0.8",
"expo-router": "~4.0.16", "expo-router": "~4.0.16",
"expo-secure-store": "~14.0.1",
"expo-splash-screen": "~0.29.20", "expo-splash-screen": "~0.29.20",
"expo-sqlite": "~15.1.2", "expo-sqlite": "~15.1.2",
"expo-status-bar": "~2.0.1", "expo-status-bar": "~2.0.1",
@ -56,6 +58,7 @@
"jest-expo": "~52.0.3", "jest-expo": "~52.0.3",
"lucide-react-native": "^0.378.0", "lucide-react-native": "^0.378.0",
"nativewind": "^4.1.23", "nativewind": "^4.1.23",
"nostr-tools": "^2.10.4",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-native": "0.76.7", "react-native": "0.76.7",
@ -70,7 +73,7 @@
"tailwind-merge": "^2.2.1", "tailwind-merge": "^2.2.1",
"tailwindcss": "3.3.5", "tailwindcss": "3.3.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zustand": "^4.4.7" "zustand": "^4.5.6"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.26.0", "@babel/core": "^7.26.0",
@ -3772,6 +3775,51 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -3807,6 +3855,28 @@
"node": ">= 8" "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": { "node_modules/@npmcli/fs": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz", "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": { "node_modules/@segment/loosely-validate-event": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@segment/loosely-validate-event/-/loosely-validate-event-2.0.0.tgz", "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==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT" "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": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -10840,6 +11025,19 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/data-uri-to-buffer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz", "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": ">= 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": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -11444,6 +11682,21 @@
"node": ">=4.0" "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": { "node_modules/esprima": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
@ -11497,6 +11750,16 @@
"node": ">= 0.6" "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": { "node_modules/event-target-shim": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
@ -11916,6 +12179,15 @@
"node": ">=10" "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": { "node_modules/expo-splash-screen": {
"version": "0.29.22", "version": "0.29.22",
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-0.29.22.tgz", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-0.29.22.tgz",
@ -11975,6 +12247,15 @@
"integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==", "integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==",
"license": "Apache-2.0" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "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" "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": { "node_modules/is-wsl": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@ -14608,6 +14895,27 @@
"node": ">=6" "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": { "node_modules/lighthouse-logger": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz",
@ -15829,6 +16137,12 @@
"integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==", "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==",
"license": "MIT" "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": { "node_modules/nice-try": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
@ -15898,6 +16212,17 @@
"node": ">= 6.13.0" "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": { "node_modules/node-int64": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@ -15919,6 +16244,86 @@
"node": ">=0.10.0" "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": { "node_modules/npm-package-arg": {
"version": "11.0.3", "version": "11.0.3",
"resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", "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==", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"license": "Apache-2.0" "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": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "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": { "node_modules/turbo-stream": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz",
"integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==", "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==",
"license": "ISC" "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": { "node_modules/type-detect": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
@ -19348,6 +19771,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/typescript": {
"version": "5.7.3", "version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
@ -19362,6 +19794,12 @@
"node": ">=14.17" "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": { "node_modules/ua-parser-js": {
"version": "1.0.40", "version": "1.0.40",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz", "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" "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": { "node_modules/util": {
"version": "0.12.5", "version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
@ -19810,6 +20270,47 @@
"node": ">=10.13.0" "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": { "node_modules/whatwg-encoding": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
@ -20064,6 +20565,15 @@
"node": ">=10" "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -24,6 +24,7 @@
}, },
"dependencies": { "dependencies": {
"@expo/cli": "^0.22.16", "@expo/cli": "^0.22.16",
"@nostr-dev-kit/ndk": "^2.12.0",
"@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-alert-dialog": "^1.1.6",
"@react-native-clipboard/clipboard": "^1.16.1", "@react-native-clipboard/clipboard": "^1.16.1",
"@react-navigation/material-top-tabs": "^7.1.0", "@react-navigation/material-top-tabs": "^7.1.0",
@ -62,6 +63,7 @@
"expo-linking": "~7.0.4", "expo-linking": "~7.0.4",
"expo-navigation-bar": "~4.0.8", "expo-navigation-bar": "~4.0.8",
"expo-router": "~4.0.16", "expo-router": "~4.0.16",
"expo-secure-store": "~14.0.1",
"expo-splash-screen": "~0.29.20", "expo-splash-screen": "~0.29.20",
"expo-sqlite": "~15.1.2", "expo-sqlite": "~15.1.2",
"expo-status-bar": "~2.0.1", "expo-status-bar": "~2.0.1",
@ -70,6 +72,7 @@
"jest-expo": "~52.0.3", "jest-expo": "~52.0.3",
"lucide-react-native": "^0.378.0", "lucide-react-native": "^0.378.0",
"nativewind": "^4.1.23", "nativewind": "^4.1.23",
"nostr-tools": "^2.10.4",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-native": "0.76.7", "react-native": "0.76.7",
@ -84,8 +87,7 @@
"tailwind-merge": "^2.2.1", "tailwind-merge": "^2.2.1",
"tailwindcss": "3.3.5", "tailwindcss": "3.3.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zustand": "^4.4.7", "zustand": "^4.5.6"
"expo-secure-store": "~14.0.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.26.0", "@babel/core": "^7.26.0",