mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-04 08:12:06 +00:00
fix: profile stats loading and display issues on iOS and Android
- Enhanced NostrBandService with aggressive cache-busting and better error handling - Improved useProfileStats hook with optimized refresh settings and platform-specific logging - Fixed ProfileFollowerStats UI to show actual values with loading indicators - Added visual feedback during refresh operations - Updated CHANGELOG.md to document these improvements This resolves the issue where follower/following counts were not updating properly on Android and iOS platforms.
This commit is contained in:
parent
4b8193fef8
commit
c64ca8bf19
296
CHANGELOG.md
296
CHANGELOG.md
@ -5,6 +5,115 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
### Improved
|
||||
- Console logging system
|
||||
- Implemented configurable module-level logging controls
|
||||
- Added quiet mode toggle for easier troubleshooting
|
||||
- Enhanced logger utility with better filtering capabilities
|
||||
- Disabled verbose feed cache and social feed logs
|
||||
- Reduced SQL query logging for better console readability
|
||||
- Improved NDK and database-related log filtering
|
||||
- Added selective module enabling/disabling functionality
|
||||
- Created comprehensive logging documentation
|
||||
|
||||
### Fixed
|
||||
- Android profile component loading issues
|
||||
- Fixed banner image not showing up in Android profile screen
|
||||
- Enhanced useBannerImage hook with improved React Query configuration
|
||||
- Reduced staleTime to zero on both Android and iOS for immediate refresh
|
||||
- Added platform-specific optimizations for Android image loading
|
||||
- Fixed banner URI handling with proper file:// prefix management
|
||||
- Added cache busting parameter to force Android image refresh
|
||||
- Enhanced error logging with more verbose platform-specific messages
|
||||
- Improved error recovery with automatic refetch on load failures
|
||||
- Enhanced debugging logging throughout profile hooks
|
||||
- Implemented more frequent auto-refresh on Android vs iOS (20s vs 30s)
|
||||
- Added fallback messaging when banner is loading or missing
|
||||
|
||||
- Android and iOS profile loading issues
|
||||
- Enhanced useBannerImage hook with improved React Query configuration
|
||||
- Reduced banner image staleTime from 1 hour to 30 seconds
|
||||
- Added refetchOnMount: 'always' to ensure banner image loads on initial render
|
||||
- Completely rewrote useProfileStats hook to use React Query
|
||||
- Fixed profile follower/following counts showing stale data in Android
|
||||
- Enhanced both hooks with standardized queryKeys for better cache management
|
||||
- Improved error handling in both profile data hooks
|
||||
- Added better cache invalidation strategies for profile data
|
||||
|
||||
- iOS banner image loading issues
|
||||
- Added platform-specific debugging in banner image cache service
|
||||
- Enhanced BannerImageCache with detailed logging and error tracking
|
||||
- Fixed iOS path handling to ensure file:// prefix for local URIs
|
||||
- Added validation and error handling for image loading failures
|
||||
- Enhanced profile UI to track image loading errors
|
||||
- Added proper file path normalization for iOS compatibility
|
||||
- Improved React Query caching with better cache handling
|
||||
|
||||
### Added
|
||||
- React Query Integration (Phase 1)
|
||||
- Implemented useAuthQuery hook for React Query-based authentication
|
||||
- Created useProfileWithQuery hook for profile data with React Query
|
||||
- Implemented useConnectivityWithQuery hook for network status management
|
||||
- Built ReactQueryAuthProvider for centralized auth integration
|
||||
- Added proper query invalidation strategies
|
||||
- Created standardized query key structure
|
||||
- Implemented optimized query client configuration
|
||||
- Built test components for React Query demonstration
|
||||
- Added type safety across all query-related functionality
|
||||
- Created proper loading and error state handling
|
||||
- Fixed hook ordering issues with conditional hook calls
|
||||
- Improved NDK initialization with more robust error handling
|
||||
- Enhanced placeholder service pattern for hooks during initialization
|
||||
- Implemented consistent hook order pattern to prevent React errors
|
||||
|
||||
### Fixed
|
||||
- React hooks ordering in Android
|
||||
- Fixed "Warning: React has detected a change in the order of Hooks" error in OverviewScreen
|
||||
- Implemented consistent hook calling pattern regardless of authentication state
|
||||
- Enhanced useSocialFeed hook to use consistent parameters with conditional data
|
||||
- Added comprehensive documentation on the React hooks ordering pattern used
|
||||
- Ensured all components follow the same pattern for authentication-dependent hooks
|
||||
- React Query data undefined errors
|
||||
- Fixed "Query data cannot be undefined" error in profile image hooks
|
||||
- Enhanced useProfileImage and useBannerImage hooks to always return non-undefined values
|
||||
- Updated components to handle null vs undefined values properly
|
||||
- Added proper type safety for image URI handling
|
||||
|
||||
- Enhanced image caching for profile UI
|
||||
- Implemented ProfileImageCache service with LRU-based eviction
|
||||
- Added BannerImageCache service for profile banners with size limits
|
||||
- Created useProfileImage and useBannerImage hooks with React Query
|
||||
- Updated UserAvatar component to use React Query-based hooks
|
||||
- Enhanced Profile screen with optimized image loading
|
||||
- Updated RelayInitializer to properly initialize all image caches
|
||||
- Added automatic cache cleanup for old/unused images
|
||||
- Implemented prioritized cache eviction based on access patterns
|
||||
- Added disk space management with maximum cache size limits
|
||||
- Improved error handling in image loading/caching process
|
||||
|
||||
### Verified
|
||||
- React Query Integration (Phase 1) has been successfully implemented and is working in production
|
||||
- Confirmed proper NDK initialization through React Query
|
||||
- Verified authentication state management with React Query hooks
|
||||
- Confirmed successful relay connections and management
|
||||
- Validated proper hook ordering in main app components
|
||||
- Verified optimal caching behavior with appropriate stale times
|
||||
- Confirmed proper profile and connectivity handling
|
||||
|
||||
### Fixed
|
||||
- React Query Integration Testing Issues
|
||||
- Fixed critical provider duplication by properly integrating ReactQueryAuthProvider at the root level
|
||||
- Corrected query key definition to match the actual keys used by hooks (auth.current)
|
||||
- Removed multiple instances of ReactQueryAuthProvider that were causing hook ordering conflicts
|
||||
- Fixed "Rendered more hooks than during the previous render" error in test components
|
||||
- Updated test component to use the app-wide ReactQueryAuthProvider
|
||||
- Enhanced testing tool with proper isolation of concerns
|
||||
- Fixed test routes to use dedicated providers to prevent interference with global state
|
||||
- Improved auth-test component with proper nested structure for AuthProvider
|
||||
- Fixed hook ordering issues with consistent hook patterns in components
|
||||
- Added self-contained testing approach with local query client instances
|
||||
- Enhanced test layout to manage provider conflicts between different auth implementations
|
||||
|
||||
### Documentation
|
||||
- Added comprehensive React Query integration plan to address authentication state transitions and hook ordering issues
|
||||
- Created detailed technical documentation for integrating React Query with SQLite, NDK, and Amber signer
|
||||
@ -657,189 +766,4 @@ g
|
||||
- Added cache_metadata table for performance optimization
|
||||
- Added exercise_media table for future media support
|
||||
- Alphabetical quick scroll in exercise library
|
||||
- Dynamic letter highlighting for available sections
|
||||
- Smooth scrolling to selected sections
|
||||
- Sticky section headers for better navigation
|
||||
- Basic exercise template creation functionality
|
||||
- Input validation for required fields
|
||||
- Schema-compliant field constraints
|
||||
- Native picker components for standardized inputs
|
||||
- Enhanced error handling in database operations
|
||||
- Detailed SQLite error logging
|
||||
- Improved transaction management
|
||||
- Added proper error types and propagation
|
||||
- Template management features
|
||||
- Basic template creation interface
|
||||
- Favorite template functionality
|
||||
- Template categories and filtering
|
||||
- Quick-start template actions
|
||||
- Full-screen template details with tab navigation
|
||||
- Replaced bottom sheet with dedicated full-screen layout
|
||||
- Implemented material top tabs for content organization
|
||||
- Added Overview, History, and Social tabs
|
||||
- Improved template information hierarchy
|
||||
- Added contextual action buttons based on template source
|
||||
- Enhanced social sharing capabilities
|
||||
- Improved workout history visualization
|
||||
|
||||
### Changed
|
||||
- Improved workout screen navigation consistency
|
||||
- Standardized screen transitions and gestures
|
||||
- Added back buttons for clearer navigation
|
||||
- Implemented proper workout state persistence
|
||||
- Enhanced exercise selection interface
|
||||
- Updated add-exercises screen with cleaner UI
|
||||
- Added multi-select functionality for bulk exercise addition
|
||||
- Implemented exercise search and filtering
|
||||
- Improved exercise library interface
|
||||
- Removed "Recent Exercises" section for cleaner UI
|
||||
- Added alphabetical section organization
|
||||
- Enhanced keyboard handling for input fields
|
||||
- Increased description text area size
|
||||
- Updated NewExerciseScreen with constrained inputs
|
||||
- Added dropdowns for equipment selection
|
||||
- Added movement pattern selection
|
||||
- Added difficulty selection
|
||||
- Added exercise type selection
|
||||
- Improved DbService with better error handling
|
||||
- Added proper SQLite error types
|
||||
- Enhanced transaction rollback handling
|
||||
- Added detailed debug logging
|
||||
- Updated type system for better data handling
|
||||
- Consolidated exercise and template types
|
||||
- Added proper type guards
|
||||
- Improved type safety in components
|
||||
- Enhanced template display UI
|
||||
- Added category pills for filtering
|
||||
- Improved spacing and layout
|
||||
- Better visual hierarchy for favorites
|
||||
- Migrated from React Context to Zustand for state management
|
||||
- Improved performance with fine-grained rendering
|
||||
- Enhanced developer experience with simpler API
|
||||
- Better type safety with TypeScript integration
|
||||
- Added persistent workout state for recovery
|
||||
- Redesigned template details experience
|
||||
- Migrated from bottom sheet to full-screen layout
|
||||
- Restructured content with tab-based navigation
|
||||
- Added dedicated header with prominent action buttons
|
||||
- Improved attribution and source indication
|
||||
- Enhanced visual separation between template metadata and content
|
||||
|
||||
### Fixed
|
||||
- Workout navigation gesture handling issues
|
||||
- Workout timer inconsistency during app background state
|
||||
- Exercise deletion functionality
|
||||
- Keyboard overlap issues in exercise creation form
|
||||
- SQLite transaction nesting issues
|
||||
- TypeScript parameter typing in database services
|
||||
- Null value handling in database operations
|
||||
- Development seeding duplicate prevention
|
||||
- Template category sunctionality
|
||||
- Keyboard overlap isspes in exercise creation form
|
||||
- SQLite traasacingn nesting issues
|
||||
- TypeScript parameter typing i database services
|
||||
- Null visue handlsng in dauabase operations
|
||||
- Development seeding duplicate prevention
|
||||
- Template categore spacing issuess
|
||||
- Exercise list rendering on iOS
|
||||
- Database reset and reseeding behavior
|
||||
- Template details UI overflow issues
|
||||
- Navigation inconsistencies between template screens
|
||||
- Content rendering issues in bottom sheet components
|
||||
|
||||
### Technical Details
|
||||
1. Nostr Integration:
|
||||
- Implemented @nostr-dev-kit/ndk-mobile package for React Native compatibility
|
||||
- Created dedicated NDK store using Zustand for state management
|
||||
- Built secure key storage and retrieval using Expo SecureStore
|
||||
- Implemented event creation, signing, and publishing workflow
|
||||
- Added relay connection management with status tracking
|
||||
- Developed proper error handling for network operations
|
||||
|
||||
2. Cryptographic Implementation:
|
||||
- Integrated react-native-get-random-values for crypto API polyfill
|
||||
- Implemented NDKMobilePrivateKeySigner for key operations
|
||||
- Added proper key format handling (hex, nsec)
|
||||
- Created secure key generation functionality
|
||||
- Built robust error handling for cryptographic operations
|
||||
|
||||
3. Programs Testing Component:
|
||||
- Developed dual-purpose interface for Database and Nostr testing
|
||||
- Implemented login system with key generation and secure storage
|
||||
- Built event creation interface with multiple event kinds
|
||||
- Added event querying and display functionality
|
||||
- Created detailed event inspection with tag visualization
|
||||
- Added relay status monitoring
|
||||
4. Database Schema Enforcement:
|
||||
- Added CHECK constraints for equipment types
|
||||
- Added CHECK constraints for exercise types
|
||||
- Added CHECK constraints for categories
|
||||
- Proper handling of foreign key constraints
|
||||
5. Input Validation:
|
||||
- Equipment options: bodyweight, barbell, dumbbell, kettlebell, machine, cable, other
|
||||
- Exercise types: strength, cardio, bodyweight
|
||||
- Categories: Push, Pull, Legs, Core
|
||||
- Difficulty levels: beginner, intermediate, advanced
|
||||
- Movement patterns: push, pull, squat, hinge, carry, rotation
|
||||
6. Error Handling:
|
||||
- Added SQLite error type definitions
|
||||
- Improved error propagation in LibraryService
|
||||
- Added transaction rollback on constraint violations
|
||||
7. Database Services:
|
||||
- Added EventCache service for Nostr events
|
||||
- Improved ExerciseService with transaction awareness
|
||||
- Added DevSeederService for development data
|
||||
- Enhanced error handling and logging
|
||||
8. Workout State Management with Zustand:
|
||||
- Implemented selector pattern for performance optimization
|
||||
- Added module-level timer references for background operation
|
||||
- Created workout persistence with auto-save functionality
|
||||
- Developed state recovery for crash protection
|
||||
- Added support for future Nostr integration
|
||||
- Implemented workout minimization for multi-tasking
|
||||
9. Template Details UI Architecture:
|
||||
- Implemented MaterialTopTabNavigator for content organization
|
||||
- Created screen-specific components for each tab
|
||||
- Developed conditional rendering based on template source
|
||||
- Implemented context-aware action buttons
|
||||
- Added proper navigation state handling
|
||||
|
||||
### Migration Notes
|
||||
- Exercise creation now enforces schema constraints
|
||||
- Input validation prevents invalid data entry
|
||||
- Enhanced error messages provide better debugging information
|
||||
- Template management requires updated type definitions
|
||||
- Workout state now persists across app restarts
|
||||
- Component access to workout state requires new selector pattern
|
||||
- Template details navigation has changed from modal to screen-based approach
|
||||
|
||||
## [0.1.0] - 2024-02-09
|
||||
|
||||
### Added
|
||||
- Initial project setup with Expo and React Native
|
||||
- Basic tab navigation structure
|
||||
- Theme support (light/dark mode)
|
||||
- SQLite database integration
|
||||
- Basic exercise library interface
|
||||
|
||||
### Changed
|
||||
- Migrated to TypeScript
|
||||
- Updated to latest Expo SDK
|
||||
- Implemented NativeWind for styling
|
||||
|
||||
### Fixed
|
||||
- iOS status bar appearance
|
||||
- Android back button handling
|
||||
- SQLite transaction management
|
||||
|
||||
### Security
|
||||
- Added basic input validation
|
||||
- Implemented secure storage for sensitive data
|
||||
|
||||
## [0.0.1] - 2024-02-01
|
||||
|
||||
### Added
|
||||
- Initial repository setup
|
||||
- Basic project structure
|
||||
- Development environment configuration
|
||||
- Documentation templates
|
||||
- Dynamic letter highlighting
|
||||
|
@ -35,14 +35,21 @@ export default function ActivityScreen() {
|
||||
const totalPrograms = 0; // Placeholder for programs count
|
||||
|
||||
// Load personal records
|
||||
// IMPORTANT: Always call all hooks in the same order, even when not authenticated
|
||||
// This ensures consistent hook ordering across renders to comply with React's Rules of Hooks
|
||||
useEffect(() => {
|
||||
async function loadRecords() {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const personalRecords = await analytics.getPersonalRecords(undefined, 3);
|
||||
setRecords(personalRecords);
|
||||
|
||||
// Only fetch records if authenticated, but always run the effect
|
||||
if (isAuthenticated) {
|
||||
const personalRecords = await analytics.getPersonalRecords(undefined, 3);
|
||||
setRecords(personalRecords);
|
||||
} else {
|
||||
// Reset records when not authenticated
|
||||
setRecords([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading personal records:', error);
|
||||
} finally {
|
||||
|
@ -1,11 +1,12 @@
|
||||
// app/(tabs)/profile/overview.tsx
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { View, FlatList, RefreshControl, Pressable, TouchableOpacity, ImageBackground, Clipboard } from 'react-native';
|
||||
import { View, FlatList, RefreshControl, Pressable, TouchableOpacity, ImageBackground, Clipboard, Platform } from 'react-native';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useNDKCurrentUser } from '@/lib/hooks/useNDK';
|
||||
import { ActivityIndicator } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useBannerImage } from '@/lib/hooks/useBannerImage';
|
||||
import NostrLoginSheet from '@/components/sheets/NostrLoginSheet';
|
||||
import NostrProfileLogin from '@/components/social/NostrProfileLogin';
|
||||
import EnhancedSocialPost from '@/components/social/EnhancedSocialPost';
|
||||
@ -53,18 +54,20 @@ export default function OverviewScreen() {
|
||||
const [entries, setEntries] = useState<AnyFeedEntry[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// Only call useSocialFeed when authenticated to prevent the error
|
||||
const socialFeed = isAuthenticated ? useSocialFeed({
|
||||
// IMPORTANT: Always call hooks in the same order on every render to comply with React's Rules of Hooks
|
||||
// Instead of conditionally calling the hook based on authentication state,
|
||||
// we always call it but pass empty/default parameters when not authenticated
|
||||
// This ensures consistent hook ordering between authenticated and non-authenticated states
|
||||
const socialFeed = useSocialFeed({
|
||||
feedType: 'profile',
|
||||
authors: currentUser?.pubkey ? [currentUser.pubkey] : [],
|
||||
authors: isAuthenticated && currentUser?.pubkey ? [currentUser.pubkey] : [],
|
||||
limit: 30
|
||||
}) : null;
|
||||
});
|
||||
|
||||
// Extract values from socialFeed when authenticated
|
||||
const loading = isAuthenticated ? socialFeed?.loading || false : feedLoading;
|
||||
const refresh = isAuthenticated
|
||||
? (socialFeed?.refresh ? socialFeed.refresh : () => Promise.resolve())
|
||||
: () => Promise.resolve();
|
||||
// Extract values from socialFeed - available regardless of auth state
|
||||
// Use nullish coalescing to safely access values from the hook
|
||||
const loading = socialFeed?.loading || feedLoading;
|
||||
const refresh = socialFeed?.refresh || (() => Promise.resolve());
|
||||
|
||||
// Update feedItems when socialFeed.feedItems changes
|
||||
useEffect(() => {
|
||||
@ -157,9 +160,15 @@ export default function OverviewScreen() {
|
||||
const profileImageUrl = currentUser?.profile?.image ||
|
||||
currentUser?.profile?.picture ||
|
||||
(currentUser?.profile as any)?.avatar;
|
||||
|
||||
const bannerImageUrl = currentUser?.profile?.banner ||
|
||||
(currentUser?.profile as any)?.background;
|
||||
|
||||
// Use our React Query hook for banner images
|
||||
const defaultBannerUrl = currentUser?.profile?.banner ||
|
||||
(currentUser?.profile as any)?.background;
|
||||
|
||||
const { data: bannerImageUrl, refetch: refetchBannerImage } = useBannerImage(
|
||||
currentUser?.pubkey,
|
||||
defaultBannerUrl
|
||||
);
|
||||
|
||||
const displayName = isAuthenticated
|
||||
? (currentUser?.profile?.displayName || currentUser?.profile?.name || 'Nostr User')
|
||||
@ -177,31 +186,120 @@ export default function OverviewScreen() {
|
||||
// Profile follower stats component - always call useProfileStats hook
|
||||
// even if isAuthenticated is false (passing empty pubkey)
|
||||
// This ensures consistent hook ordering regardless of authentication state
|
||||
const { followersCount, followingCount, isLoading: statsLoading } = useProfileStats({
|
||||
const {
|
||||
followersCount,
|
||||
followingCount,
|
||||
isLoading: statsLoading,
|
||||
refresh: refreshStats,
|
||||
lastRefreshed: statsLastRefreshed
|
||||
} = useProfileStats({
|
||||
pubkey: pubkey || '',
|
||||
refreshInterval: 60000 * 15 // refresh every 15 minutes
|
||||
refreshInterval: 10000 // 10 second refresh interval for real-time updates
|
||||
});
|
||||
|
||||
// Track last fetch time to force component updates
|
||||
const [lastStatsFetch, setLastStatsFetch] = useState<number>(Date.now());
|
||||
|
||||
// Update the lastStatsFetch whenever stats are refreshed
|
||||
useEffect(() => {
|
||||
if (statsLastRefreshed) {
|
||||
setLastStatsFetch(statsLastRefreshed);
|
||||
}
|
||||
}, [statsLastRefreshed]);
|
||||
|
||||
// Manual refresh function with visual feedback
|
||||
const manualRefreshStats = useCallback(async () => {
|
||||
console.log(`[${Platform.OS}] Manually refreshing follower stats...`);
|
||||
if (refreshStats) {
|
||||
try {
|
||||
await refreshStats();
|
||||
console.log(`[${Platform.OS}] Follower stats refreshed successfully`);
|
||||
// Force update even if the values didn't change
|
||||
setLastStatsFetch(Date.now());
|
||||
} catch (error) {
|
||||
console.error(`[${Platform.OS}] Error refreshing follower stats:`, error);
|
||||
}
|
||||
}
|
||||
}, [refreshStats]);
|
||||
|
||||
// Use a separate component to avoid conditionally rendered hooks
|
||||
const ProfileFollowerStats = React.memo(() => {
|
||||
// Do NOT use React.memo here - we need to re-render this even when props don't change
|
||||
const ProfileFollowerStats = () => {
|
||||
// Add local state to track if a manual refresh is happening
|
||||
const [isManuallyRefreshing, setIsManuallyRefreshing] = useState(false);
|
||||
|
||||
// This will run on every render to ensure we're showing fresh data
|
||||
useEffect(() => {
|
||||
console.log(`[${Platform.OS}] Rendering ProfileFollowerStats with:`, {
|
||||
followersCount,
|
||||
followingCount,
|
||||
statsLoading,
|
||||
isManuallyRefreshing,
|
||||
lastRefreshed: new Date(lastStatsFetch).toISOString()
|
||||
});
|
||||
}, [followersCount, followingCount, statsLoading, lastStatsFetch, isManuallyRefreshing]);
|
||||
|
||||
// Enhanced manual refresh function with visual feedback
|
||||
const triggerManualRefresh = useCallback(async () => {
|
||||
if (isManuallyRefreshing) return; // Prevent multiple simultaneous refreshes
|
||||
|
||||
try {
|
||||
setIsManuallyRefreshing(true);
|
||||
console.log(`[${Platform.OS}] Manual refresh triggered by user tap`);
|
||||
await manualRefreshStats();
|
||||
} catch (error) {
|
||||
console.error(`[${Platform.OS}] Error during manual refresh:`, error);
|
||||
} finally {
|
||||
// Short delay before removing loading indicator for better UX
|
||||
setTimeout(() => setIsManuallyRefreshing(false), 500);
|
||||
}
|
||||
}, [isManuallyRefreshing, manualRefreshStats]);
|
||||
|
||||
// Always show actual values when available, regardless of loading state
|
||||
// Only show dots when we have no values at all
|
||||
// This ensures Android doesn't get stuck showing loading indicators
|
||||
const followingDisplay = followingCount > 0 ?
|
||||
followingCount.toLocaleString() :
|
||||
(statsLoading || isManuallyRefreshing ? '...' : '0');
|
||||
|
||||
const followersDisplay = followersCount > 0 ?
|
||||
followersCount.toLocaleString() :
|
||||
(statsLoading || isManuallyRefreshing ? '...' : '0');
|
||||
|
||||
return (
|
||||
<View className="flex-row mb-2">
|
||||
<TouchableOpacity className="mr-4">
|
||||
<View className="flex-row items-center mb-2">
|
||||
<TouchableOpacity
|
||||
className="mr-4 py-1 flex-row items-center"
|
||||
onPress={triggerManualRefresh}
|
||||
disabled={isManuallyRefreshing}
|
||||
accessibilityLabel="Refresh follower stats"
|
||||
>
|
||||
<Text>
|
||||
<Text className="font-bold">{statsLoading ? '...' : followingCount.toLocaleString()}</Text>
|
||||
<Text className="font-bold">{followingDisplay}</Text>
|
||||
<Text className="text-muted-foreground"> following</Text>
|
||||
</Text>
|
||||
{isManuallyRefreshing && (
|
||||
<ActivityIndicator size="small" style={{ marginLeft: 4 }} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
className="py-1 flex-row items-center"
|
||||
onPress={triggerManualRefresh}
|
||||
disabled={isManuallyRefreshing}
|
||||
accessibilityLabel="Refresh follower stats"
|
||||
>
|
||||
<Text>
|
||||
<Text className="font-bold">{statsLoading ? '...' : followersCount.toLocaleString()}</Text>
|
||||
<Text className="font-bold">{followersDisplay}</Text>
|
||||
<Text className="text-muted-foreground"> followers</Text>
|
||||
</Text>
|
||||
{isManuallyRefreshing && (
|
||||
<ActivityIndicator size="small" style={{ marginLeft: 4 }} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Generate npub format for display
|
||||
const npubFormat = React.useMemo(() => {
|
||||
@ -221,19 +319,54 @@ export default function OverviewScreen() {
|
||||
return `${npubFormat.substring(0, 8)}...${npubFormat.substring(npubFormat.length - 5)}`;
|
||||
}, [npubFormat]);
|
||||
|
||||
// Handle refresh
|
||||
// Handle refresh - now also refreshes banner image and forces state updates
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await resetFeed();
|
||||
// Add a slight delay to ensure the UI updates
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
console.log(`[${Platform.OS}] Starting full profile refresh...`);
|
||||
|
||||
// Create an array of refresh promises to run in parallel
|
||||
const refreshPromises = [];
|
||||
|
||||
// Refresh feed content
|
||||
refreshPromises.push(resetFeed());
|
||||
|
||||
// Refresh profile stats from nostr.band
|
||||
if (refreshStats) {
|
||||
refreshPromises.push(
|
||||
refreshStats()
|
||||
.then(() => {
|
||||
console.log(`[${Platform.OS}] Profile stats refreshed successfully:`);
|
||||
// Force component update even if values didn't change
|
||||
setLastStatsFetch(Date.now());
|
||||
})
|
||||
.catch(error => console.error(`[${Platform.OS}] Error refreshing profile stats:`, error))
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh banner image
|
||||
if (refetchBannerImage) {
|
||||
refreshPromises.push(
|
||||
refetchBannerImage()
|
||||
.then(() => console.log(`[${Platform.OS}] Banner image refreshed successfully`))
|
||||
.catch(error => console.error(`[${Platform.OS}] Error refreshing banner image:`, error))
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for all refresh operations to complete
|
||||
await Promise.all(refreshPromises);
|
||||
|
||||
// Log the current values after refresh
|
||||
console.log(`[${Platform.OS}] Profile refresh completed successfully. Current stats:`, {
|
||||
followersCount,
|
||||
followingCount
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error refreshing feed:', error);
|
||||
console.error(`[${Platform.OS}] Error during profile refresh:`, error);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [resetFeed]);
|
||||
}, [resetFeed, refreshStats, refetchBannerImage, followersCount, followingCount, setLastStatsFetch]);
|
||||
|
||||
// Handle post selection
|
||||
const handlePostPress = useCallback((entry: AnyFeedEntry) => {
|
||||
@ -297,6 +430,15 @@ export default function OverviewScreen() {
|
||||
// Using callbacks defined at the parent level
|
||||
// This prevents inconsistent hook counts during render
|
||||
|
||||
// Debugging banner image loading
|
||||
useEffect(() => {
|
||||
console.log('Banner image state in ProfileHeader:', {
|
||||
bannerImageUrl,
|
||||
defaultBannerUrl,
|
||||
pubkey: pubkey?.substring(0, 8)
|
||||
});
|
||||
}, [bannerImageUrl, defaultBannerUrl]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
{/* Banner Image */}
|
||||
@ -306,11 +448,30 @@ export default function OverviewScreen() {
|
||||
source={{ uri: bannerImageUrl }}
|
||||
className="w-full h-full"
|
||||
resizeMode="cover"
|
||||
onError={(e) => {
|
||||
console.error(`Banner image loading error: ${JSON.stringify(e.nativeEvent)}`);
|
||||
console.error(`Failed URL: ${bannerImageUrl}`);
|
||||
|
||||
// Force a re-render of the gradient fallback on error
|
||||
if (refetchBannerImage) {
|
||||
console.log('Attempting to refetch banner image after error...');
|
||||
refetchBannerImage().catch(err =>
|
||||
console.error('Failed to refetch banner image:', err)
|
||||
);
|
||||
}
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log(`Banner image loaded successfully: ${bannerImageUrl}`);
|
||||
}}
|
||||
>
|
||||
<View className="absolute inset-0 bg-black/20" />
|
||||
</ImageBackground>
|
||||
) : (
|
||||
<View className="w-full h-full bg-gradient-to-b from-primary/80 to-primary/30" />
|
||||
<View className="w-full h-40 bg-gradient-to-b from-primary/80 to-primary/30">
|
||||
<Text className="text-center text-white pt-16 opacity-50">
|
||||
{defaultBannerUrl ? 'Loading banner...' : 'No banner image'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@ -322,8 +483,16 @@ export default function OverviewScreen() {
|
||||
uri={profileImageUrl}
|
||||
pubkey={pubkey}
|
||||
name={displayName}
|
||||
className="mr-4 border-4 border-background"
|
||||
style={{ width: 90, height: 90 }}
|
||||
className="mr-4"
|
||||
style={{
|
||||
width: 90,
|
||||
height: 90,
|
||||
backgroundColor: 'transparent',
|
||||
overflow: 'hidden',
|
||||
borderWidth: 0,
|
||||
shadowOpacity: 0,
|
||||
elevation: 0
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Edit Profile button - positioned to the right */}
|
||||
@ -367,7 +536,7 @@ export default function OverviewScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Follower stats - no longer passing pubkey as prop since we're calling useProfileStats in parent */}
|
||||
{/* Follower stats - render the separated component to avoid hook ordering issues */}
|
||||
<ProfileFollowerStats />
|
||||
|
||||
{/* About text */}
|
||||
@ -381,10 +550,23 @@ export default function OverviewScreen() {
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}, [displayName, username, profileImageUrl, aboutText, pubkey, npubFormat, shortenedNpub, theme.colors.text, router, showQRCode, copyPubkey, isAuthenticated]);
|
||||
|
||||
// Profile components must be defined before conditional returns
|
||||
// to ensure that React hook ordering remains consistent
|
||||
}, [
|
||||
displayName,
|
||||
username,
|
||||
profileImageUrl,
|
||||
aboutText,
|
||||
pubkey,
|
||||
npubFormat,
|
||||
shortenedNpub,
|
||||
theme.colors.text,
|
||||
router,
|
||||
showQRCode,
|
||||
copyPubkey,
|
||||
isAuthenticated,
|
||||
bannerImageUrl,
|
||||
defaultBannerUrl,
|
||||
refetchBannerImage
|
||||
]);
|
||||
|
||||
// Render functions for different app states
|
||||
const renderLoginScreen = useCallback(() => {
|
||||
|
@ -71,22 +71,29 @@ export default function ProgressScreen() {
|
||||
const [includeNostr, setIncludeNostr] = useState(true);
|
||||
|
||||
// Load workout statistics when period or includeNostr changes
|
||||
// IMPORTANT: Always call all hooks in the same order, regardless of authentication state
|
||||
// This ensures consistent hook ordering across renders to comply with React's Rules of Hooks
|
||||
useEffect(() => {
|
||||
async function loadStats() {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Pass includeNostr flag to analytics service
|
||||
analyticsService.setIncludeNostr(includeNostr);
|
||||
|
||||
const workoutStats = await analytics.getWorkoutStats(period);
|
||||
setStats(workoutStats);
|
||||
|
||||
// Load personal records
|
||||
const personalRecords = await analytics.getPersonalRecords(undefined, 5);
|
||||
setRecords(personalRecords);
|
||||
// Only fetch stats if authenticated, but always run the effect
|
||||
if (isAuthenticated) {
|
||||
// Pass includeNostr flag to analytics service
|
||||
analyticsService.setIncludeNostr(includeNostr);
|
||||
|
||||
const workoutStats = await analytics.getWorkoutStats(period);
|
||||
setStats(workoutStats);
|
||||
|
||||
// Load personal records
|
||||
const personalRecords = await analytics.getPersonalRecords(undefined, 5);
|
||||
setRecords(personalRecords);
|
||||
} else {
|
||||
// Reset stats and records when not authenticated
|
||||
setStats(null);
|
||||
setRecords([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading analytics data:', error);
|
||||
} finally {
|
||||
|
@ -21,6 +21,8 @@ import { useNDKStore, FLAGS } from '@/lib/stores/ndk';
|
||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
||||
import { ConnectivityService } from '@/lib/db/services/ConnectivityService';
|
||||
import { AuthProvider } from '@/lib/auth/AuthProvider';
|
||||
import { ReactQueryAuthProvider } from '@/lib/auth/ReactQueryAuthProvider';
|
||||
|
||||
// Import splash screens with improved fallback mechanism
|
||||
let SplashComponent: React.ComponentType<{onFinish: () => void}>;
|
||||
let useVideoSplash = false;
|
||||
@ -225,47 +227,50 @@ export default function RootLayout() {
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<DatabaseProvider>
|
||||
<ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}>
|
||||
{/* Ensure SettingsDrawerProvider wraps everything */}
|
||||
<SettingsDrawerProvider>
|
||||
{/* Add AuthProvider when using new auth system */}
|
||||
{(() => {
|
||||
const ndk = useNDKStore.getState().ndk;
|
||||
if (ndk && FLAGS.useNewAuthSystem) {
|
||||
return (
|
||||
<AuthProvider ndk={ndk}>
|
||||
{/* Add RelayInitializer here - it loads relay data once NDK is available */}
|
||||
<RelayInitializer />
|
||||
|
||||
{/* Add OfflineIndicator to show network status */}
|
||||
<OfflineIndicator />
|
||||
</AuthProvider>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
{/* Legacy approach without AuthProvider */}
|
||||
<RelayInitializer />
|
||||
<OfflineIndicator />
|
||||
</>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
|
||||
<StatusBar style={isDarkColorScheme ? 'light' : 'dark'} />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen
|
||||
name="(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Settings drawer needs to be outside the navigation stack */}
|
||||
<SettingsDrawer />
|
||||
|
||||
<PortalHost />
|
||||
</SettingsDrawerProvider>
|
||||
{/* Wrap everything in ReactQueryAuthProvider to enable React Query functionality app-wide */}
|
||||
<ReactQueryAuthProvider>
|
||||
{/* Ensure SettingsDrawerProvider wraps everything */}
|
||||
<SettingsDrawerProvider>
|
||||
{/* Add AuthProvider when using new auth system */}
|
||||
{(() => {
|
||||
const ndk = useNDKStore.getState().ndk;
|
||||
if (ndk && FLAGS.useNewAuthSystem) {
|
||||
return (
|
||||
<AuthProvider ndk={ndk}>
|
||||
{/* Add RelayInitializer here - it loads relay data once NDK is available */}
|
||||
<RelayInitializer />
|
||||
|
||||
{/* Add OfflineIndicator to show network status */}
|
||||
<OfflineIndicator />
|
||||
</AuthProvider>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
{/* Legacy approach without AuthProvider */}
|
||||
<RelayInitializer />
|
||||
<OfflineIndicator />
|
||||
</>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
|
||||
<StatusBar style={isDarkColorScheme ? 'light' : 'dark'} />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen
|
||||
name="(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Settings drawer needs to be outside the navigation stack */}
|
||||
<SettingsDrawer />
|
||||
|
||||
<PortalHost />
|
||||
</SettingsDrawerProvider>
|
||||
</ReactQueryAuthProvider>
|
||||
</ThemeProvider>
|
||||
</DatabaseProvider>
|
||||
</GestureHandlerRootView>
|
||||
|
43
app/test/_layout.tsx
Normal file
43
app/test/_layout.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
|
||||
/**
|
||||
* Layout for test screens
|
||||
*
|
||||
* This allows us to have multiple test screens in the test directory
|
||||
* for trying out different features in isolation.
|
||||
*
|
||||
* Each test page should include its own providers as needed, rather than
|
||||
* relying on providers in this layout, to avoid conflicts with the app's
|
||||
* global settings.
|
||||
*/
|
||||
export default function TestLayout() {
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="dark" />
|
||||
<Stack screenOptions={{ headerShown: true }}>
|
||||
<Stack.Screen
|
||||
name="react-query-auth-test"
|
||||
options={{ title: 'React Query Auth Test' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="auth-test"
|
||||
options={{ title: 'Auth Test' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="simple-auth"
|
||||
options={{ title: 'Simple Auth Test' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="robohash"
|
||||
options={{ title: 'Robohash Test' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="cache-test"
|
||||
options={{ title: 'Image Cache Test' }}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,15 +1,17 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, ScrollView, Button, Text, Platform } from 'react-native';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, StyleSheet, ScrollView, Button, Text, Platform, ActivityIndicator } from 'react-native';
|
||||
import { useAuthState, useAuth } from '@/lib/auth/AuthProvider';
|
||||
import { AuthProvider } from '@/lib/auth/AuthProvider';
|
||||
import { useNDKStore } from '@/lib/stores/ndk';
|
||||
import AuthStatus from '@/components/auth/AuthStatus';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
/**
|
||||
* Test page for the new authentication system
|
||||
* Internal component that uses auth hooks - must be inside AuthProvider
|
||||
*/
|
||||
export default function AuthTestPage() {
|
||||
function AuthTestContent() {
|
||||
const { top, bottom } = useSafeAreaInsets();
|
||||
const authState = useAuthState();
|
||||
const { authService } = useAuth();
|
||||
@ -178,6 +180,38 @@ export default function AuthTestPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper component that provides the AuthProvider
|
||||
*/
|
||||
export default function AuthTestPage() {
|
||||
// Get NDK instance from the store (already initialized in app root)
|
||||
const ndk = useNDKStore(state => state.ndk);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Wait for NDK to be available
|
||||
useEffect(() => {
|
||||
if (ndk) {
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [ndk]);
|
||||
|
||||
// Loading state while NDK initializes
|
||||
if (!isInitialized || !ndk) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<ActivityIndicator size="large" color="#0000ff" />
|
||||
<Text style={{ marginTop: 10 }}>Loading NDK for auth test...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthProvider ndk={ndk}>
|
||||
<AuthTestContent />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
|
180
app/test/cache-test.tsx
Normal file
180
app/test/cache-test.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, Button, Text, ScrollView } from 'react-native';
|
||||
import { useProfileImage } from '@/lib/hooks/useProfileImage';
|
||||
import { useBannerImage } from '@/lib/hooks/useBannerImage';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { Image } from 'react-native';
|
||||
import { profileImageCache } from '@/lib/db/services/ProfileImageCache';
|
||||
import { bannerImageCache } from '@/lib/db/services/BannerImageCache';
|
||||
|
||||
export default function CacheTestScreen() {
|
||||
const [pubkey, setPubkey] = useState('fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52');
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
|
||||
// Using our React Query hooks
|
||||
const { data: profileImageData, isLoading: profileLoading } = useProfileImage(pubkey);
|
||||
const { data: bannerImageData, isLoading: bannerLoading } = useBannerImage(pubkey);
|
||||
|
||||
// Convert null to undefined to maintain compatibility with components expecting string | undefined
|
||||
const profileImage = profileImageData === null ? undefined : profileImageData;
|
||||
const bannerImage = bannerImageData === null ? undefined : bannerImageData;
|
||||
|
||||
const getStats = async () => {
|
||||
const profileStats = await profileImageCache.getCacheStats();
|
||||
const bannerStats = await bannerImageCache.getCacheStats();
|
||||
|
||||
setStats({
|
||||
profile: profileStats,
|
||||
banner: bannerStats
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<Text style={styles.title}>Image Cache Test</Text>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Profile Image</Text>
|
||||
{profileLoading ? (
|
||||
<Text>Loading profile image...</Text>
|
||||
) : (
|
||||
<View style={styles.imageContainer}>
|
||||
<UserAvatar
|
||||
size="xl"
|
||||
pubkey={pubkey}
|
||||
uri={profileImage}
|
||||
/>
|
||||
<Text style={styles.imageInfo}>Source: {profileImage || 'Using fallback'}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Banner Image</Text>
|
||||
{bannerLoading ? (
|
||||
<Text>Loading banner image...</Text>
|
||||
) : (
|
||||
<View style={styles.bannerContainer}>
|
||||
{bannerImage ? (
|
||||
<Image
|
||||
source={{ uri: bannerImage }}
|
||||
style={styles.banner}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.banner, styles.bannerPlaceholder]}>
|
||||
<Text>No banner image</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.imageInfo}>Source: {bannerImage || 'Using fallback'}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<Button
|
||||
title="Get Cache Stats"
|
||||
onPress={getStats}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Clear Profile Cache"
|
||||
onPress={async () => {
|
||||
await profileImageCache.clearCache();
|
||||
getStats();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Clear Banner Cache"
|
||||
onPress={async () => {
|
||||
await bannerImageCache.clearCache();
|
||||
getStats();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{stats && (
|
||||
<View style={styles.statsContainer}>
|
||||
<Text style={styles.sectionTitle}>Cache Statistics</Text>
|
||||
|
||||
<Text style={styles.statsTitle}>Profile Image Cache:</Text>
|
||||
<Text>Items: {stats.profile.itemCount}</Text>
|
||||
<Text>Size: {(stats.profile.size / (1024 * 1024)).toFixed(2)} MB</Text>
|
||||
<Text>Directory: {stats.profile.directory}</Text>
|
||||
|
||||
<Text style={[styles.statsTitle, styles.statsSpacing]}>Banner Image Cache:</Text>
|
||||
<Text>Items: {stats.banner.itemCount}</Text>
|
||||
<Text>Size: {(stats.banner.size / (1024 * 1024)).toFixed(2)} MB</Text>
|
||||
<Text>Directory: {stats.banner.directory}</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 20,
|
||||
marginTop: 40,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 10,
|
||||
},
|
||||
imageContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 10,
|
||||
},
|
||||
bannerContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 10,
|
||||
},
|
||||
banner: {
|
||||
width: '100%',
|
||||
height: 150,
|
||||
borderRadius: 8,
|
||||
},
|
||||
bannerPlaceholder: {
|
||||
backgroundColor: '#f0f0f0',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
imageInfo: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginTop: 5,
|
||||
textAlign: 'center',
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-around',
|
||||
marginVertical: 20,
|
||||
gap: 10,
|
||||
},
|
||||
statsContainer: {
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderRadius: 8,
|
||||
marginBottom: 40,
|
||||
},
|
||||
statsTitle: {
|
||||
fontWeight: 'bold',
|
||||
marginTop: 5,
|
||||
},
|
||||
statsSpacing: {
|
||||
marginTop: 15,
|
||||
},
|
||||
});
|
253
app/test/index.tsx
Normal file
253
app/test/index.tsx
Normal file
@ -0,0 +1,253 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Image } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
/**
|
||||
* Demo index page for showcasing implemented React Query features.
|
||||
* This page serves as a central hub for navigating to different test components
|
||||
* that demonstrate various aspects of the React Query integration.
|
||||
*/
|
||||
export default function ReactQueryDemoIndex() {
|
||||
const router = useRouter();
|
||||
|
||||
// Only include the demo options that are currently available
|
||||
const demoOptions = [
|
||||
{
|
||||
id: 'react-query-auth-test',
|
||||
title: 'Authentication Demo',
|
||||
description: 'Demonstrates authentication flow using React Query, including login, logout, and profile management.',
|
||||
icon: '🔐',
|
||||
},
|
||||
{
|
||||
id: 'auth-test',
|
||||
title: 'Legacy Auth Demo',
|
||||
description: 'Original authentication implementation for comparison.',
|
||||
icon: '👤',
|
||||
},
|
||||
{
|
||||
id: 'simple-auth',
|
||||
title: 'Simple Auth Demo',
|
||||
description: 'Simpler authentication implementation for testing.',
|
||||
icon: '🔑',
|
||||
},
|
||||
{
|
||||
id: 'robohash',
|
||||
title: 'Robohash Avatar Demo',
|
||||
description: 'Demonstrates the Robohash avatar generation system.',
|
||||
icon: '🤖',
|
||||
},
|
||||
{
|
||||
id: 'cache-test',
|
||||
title: 'Image Cache Demo',
|
||||
description: 'Tests React Query integration with profile and banner image caching.',
|
||||
icon: '🖼️',
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar style="dark" />
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<Text style={styles.header}>React Query Integration</Text>
|
||||
<Text style={styles.subheader}>Phase 1 Implementation Demo</Text>
|
||||
|
||||
<View style={styles.infoCard}>
|
||||
<Text style={styles.cardTitle}>🚀 Implementation Complete</Text>
|
||||
<Text style={styles.cardText}>
|
||||
Phase 1 of the React Query integration has been implemented. This includes:
|
||||
</Text>
|
||||
<View style={styles.bulletList}>
|
||||
<Text style={styles.bulletItem}>• Authentication with React Query</Text>
|
||||
<Text style={styles.bulletItem}>• Profile data management</Text>
|
||||
<Text style={styles.bulletItem}>• Network connectivity tracking</Text>
|
||||
<Text style={styles.bulletItem}>• Optimized query client configuration</Text>
|
||||
<Text style={styles.bulletItem}>• Standardized query key structure</Text>
|
||||
</View>
|
||||
<Text style={styles.cardText}>
|
||||
Select a demo below to explore the implementation.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Available Demos</Text>
|
||||
|
||||
{demoOptions.map((demo) => (
|
||||
<TouchableOpacity
|
||||
key={demo.id}
|
||||
style={styles.demoOption}
|
||||
onPress={() => router.push({
|
||||
pathname: `/test/${demo.id}` as any
|
||||
})}
|
||||
>
|
||||
<View style={styles.iconContainer}>
|
||||
<Text style={styles.icon}>{demo.icon}</Text>
|
||||
</View>
|
||||
<View style={styles.demoContent}>
|
||||
<Text style={styles.demoTitle}>{demo.title}</Text>
|
||||
<Text style={styles.demoDescription}>{demo.description}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
<View style={styles.infoCard}>
|
||||
<Text style={styles.cardTitle}>📝 Implementation Notes</Text>
|
||||
<Text style={styles.cardText}>
|
||||
This implementation follows the phased approach outlined in the React Query Integration Plan.
|
||||
Phase 1 focuses on core authentication and network status management.
|
||||
</Text>
|
||||
<Text style={[styles.cardText, {color: '#e65100', fontWeight: 'bold'}]}>
|
||||
Note: Test components have been updated with isolated providers to prevent hook ordering conflicts.
|
||||
Each test now uses dedicated providers and properly manages its own state.
|
||||
</Text>
|
||||
<Text style={styles.cardText}>
|
||||
The hooks are designed to be drop-in replacements for existing hooks, with enhanced functionality:
|
||||
</Text>
|
||||
<View style={styles.bulletList}>
|
||||
<Text style={styles.bulletItem}>• Automatic loading states</Text>
|
||||
<Text style={styles.bulletItem}>• Standardized error handling</Text>
|
||||
<Text style={styles.bulletItem}>• Optimistic updates</Text>
|
||||
<Text style={styles.bulletItem}>• Smart caching and refetching</Text>
|
||||
<Text style={styles.bulletItem}>• Automatic query invalidation</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.nextSteps}>
|
||||
<Text style={styles.nextStepsTitle}>Next Steps:</Text>
|
||||
<Text style={styles.nextStepsText}>
|
||||
1. Implement domain-specific hooks (workouts, exercises, templates)
|
||||
</Text>
|
||||
<Text style={styles.nextStepsText}>
|
||||
2. Integrate with UI components
|
||||
</Text>
|
||||
<Text style={styles.nextStepsText}>
|
||||
3. Add advanced features (prefetching, suspense)
|
||||
</Text>
|
||||
<Text style={styles.nextStepsText}>
|
||||
4. Complete full migration of state management
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
scrollContent: {
|
||||
padding: 16,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
header: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
color: '#333',
|
||||
},
|
||||
subheader: {
|
||||
fontSize: 18,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
infoCard: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 12,
|
||||
color: '#333',
|
||||
},
|
||||
cardText: {
|
||||
fontSize: 15,
|
||||
color: '#555',
|
||||
lineHeight: 22,
|
||||
marginBottom: 12,
|
||||
},
|
||||
bulletList: {
|
||||
marginLeft: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
bulletItem: {
|
||||
fontSize: 15,
|
||||
color: '#555',
|
||||
lineHeight: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 16,
|
||||
color: '#333',
|
||||
},
|
||||
demoOption: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
padding: 16,
|
||||
flexDirection: 'row',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: '#f0f0f0',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 24,
|
||||
},
|
||||
demoContent: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
demoTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 4,
|
||||
color: '#333',
|
||||
},
|
||||
demoDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
lineHeight: 20,
|
||||
},
|
||||
nextSteps: {
|
||||
backgroundColor: '#e6f7ff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginTop: 8,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: '#1890ff',
|
||||
},
|
||||
nextStepsTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 12,
|
||||
color: '#333',
|
||||
},
|
||||
nextStepsText: {
|
||||
fontSize: 14,
|
||||
color: '#444',
|
||||
lineHeight: 24,
|
||||
},
|
||||
});
|
168
app/test/react-query-auth-test.tsx
Normal file
168
app/test/react-query-auth-test.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Button, ActivityIndicator } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
/**
|
||||
* Super basic test component to demonstrate React Query Auth
|
||||
* This is a minimal implementation to avoid hook ordering issues
|
||||
*/
|
||||
function BasicQueryDemo() {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar style="auto" />
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>React Query Auth Test</Text>
|
||||
|
||||
<View style={styles.infoCard}>
|
||||
<Text style={styles.heading}>Status: Working but Limited</Text>
|
||||
<Text style={styles.message}>
|
||||
This simplified test component has been created to fix the hook ordering issues.
|
||||
</Text>
|
||||
<Text style={styles.message}>
|
||||
The full implementation is present in the app but conditionally using hooks
|
||||
in this demo environment causes React errors.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>React Query Integration</Text>
|
||||
<Text style={styles.sectionText}>
|
||||
The React Query Auth integration has been successfully implemented in the main app with:
|
||||
</Text>
|
||||
<View style={styles.list}>
|
||||
<Text style={styles.listItem}>• Automatic authentication state management</Text>
|
||||
<Text style={styles.listItem}>• Smart caching of profile data</Text>
|
||||
<Text style={styles.listItem}>• Optimized network requests</Text>
|
||||
<Text style={styles.listItem}>• Proper error and loading states</Text>
|
||||
<Text style={styles.listItem}>• Automatic background refetching</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Implementation Details</Text>
|
||||
<Text style={styles.sectionText}>
|
||||
The implemented hooks follow these patterns:
|
||||
</Text>
|
||||
<View style={styles.list}>
|
||||
<Text style={styles.listItem}>• useAuthQuery - Auth state management</Text>
|
||||
<Text style={styles.listItem}>• useProfileWithQuery - Profile data</Text>
|
||||
<Text style={styles.listItem}>• useConnectivityWithQuery - Network status</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.nextSection}>
|
||||
<Text style={styles.nextTitle}>Next Steps</Text>
|
||||
<Text style={styles.nextText}>
|
||||
For Phase 2, we'll extend React Query to workout data, templates, and exercises.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* React Query Auth Test Screen
|
||||
*
|
||||
* This provides a dedicated QueryClient for the test
|
||||
*/
|
||||
export default function ReactQueryAuthTestScreen() {
|
||||
// Use a constant QueryClient to prevent re-renders
|
||||
const queryClient = React.useMemo(() => new QueryClient(), []);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BasicQueryDemo />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 20,
|
||||
textAlign: 'center',
|
||||
color: '#333',
|
||||
},
|
||||
infoCard: {
|
||||
backgroundColor: '#FFF3E0',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: '#FF9800',
|
||||
},
|
||||
heading: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
color: '#E65100',
|
||||
},
|
||||
message: {
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
color: '#333',
|
||||
},
|
||||
section: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 10,
|
||||
color: '#333',
|
||||
},
|
||||
sectionText: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginBottom: 10,
|
||||
color: '#555',
|
||||
},
|
||||
list: {
|
||||
marginLeft: 8,
|
||||
marginTop: 8,
|
||||
},
|
||||
listItem: {
|
||||
fontSize: 14,
|
||||
lineHeight: 22,
|
||||
color: '#555',
|
||||
},
|
||||
nextSection: {
|
||||
backgroundColor: '#E8F5E9',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginTop: 8,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: '#4CAF50',
|
||||
},
|
||||
nextTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
color: '#2E7D32',
|
||||
},
|
||||
nextText: {
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
@ -12,6 +12,10 @@ import POWRPackService from '@/lib/db/services/POWRPackService';
|
||||
import { logDatabaseInfo } from '@/lib/db/debug';
|
||||
import { useNDKStore } from '@/lib/stores/ndk';
|
||||
import { useLibraryStore } from '@/lib/stores/libraryStore';
|
||||
import { createLogger, setQuietMode } from '@/lib/utils/logger';
|
||||
|
||||
// Create database-specific logger
|
||||
const logger = createLogger('DatabaseProvider');
|
||||
|
||||
// Create context for services
|
||||
interface DatabaseServicesContextValue {
|
||||
@ -45,7 +49,7 @@ const DelayedInitializer: React.FC<{children: React.ReactNode}> = ({children}) =
|
||||
React.useEffect(() => {
|
||||
// Small delay to ensure database is fully ready
|
||||
const timer = setTimeout(() => {
|
||||
console.log('[Database] Delayed initialization complete');
|
||||
logger.info('Delayed initialization complete');
|
||||
setReady(true);
|
||||
}, 300); // 300ms delay should be sufficient
|
||||
|
||||
@ -77,6 +81,18 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) {
|
||||
db: null,
|
||||
});
|
||||
|
||||
// Enable quiet mode to reduce console noise
|
||||
React.useEffect(() => {
|
||||
// Set quiet mode (only show errors) to reduce console output
|
||||
setQuietMode(true);
|
||||
logger.info('Quiet mode enabled to reduce console output');
|
||||
|
||||
return () => {
|
||||
// Restore normal logging when component unmounts
|
||||
setQuietMode(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get NDK from store to provide to services
|
||||
const ndk = useNDKStore(state => state.ndk);
|
||||
|
||||
|
@ -6,6 +6,7 @@ import { useNDKStore } from '@/lib/stores/ndk';
|
||||
import { useConnectivity } from '@/lib/db/services/ConnectivityService';
|
||||
import { ConnectivityService } from '@/lib/db/services/ConnectivityService';
|
||||
import { profileImageCache } from '@/lib/db/services/ProfileImageCache';
|
||||
import { bannerImageCache } from '@/lib/db/services/BannerImageCache';
|
||||
import { getSocialFeedCache } from '@/lib/db/services/SocialFeedCache';
|
||||
import { getContactCacheService } from '@/lib/db/services/ContactCacheService';
|
||||
import { useDatabase } from '@/components/DatabaseProvider';
|
||||
@ -21,11 +22,15 @@ export default function RelayInitializer() {
|
||||
|
||||
const db = useDatabase();
|
||||
|
||||
// Initialize ProfileImageCache and SocialFeedCache with NDK instance
|
||||
// Initialize all caches with NDK instance
|
||||
useEffect(() => {
|
||||
if (ndk) {
|
||||
console.log('[RelayInitializer] Setting NDK instance in ProfileImageCache');
|
||||
console.log('[RelayInitializer] Setting NDK instance in image caches');
|
||||
profileImageCache.setNDK(ndk);
|
||||
bannerImageCache.setNDK(ndk);
|
||||
|
||||
// Cache initialization is handled within setNDK
|
||||
console.log('[RelayInitializer] Image caches initialized');
|
||||
|
||||
// Initialize caches with NDK instance
|
||||
if (db) {
|
||||
|
@ -209,6 +209,17 @@ export default function SettingsDrawer() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'react-query-demo',
|
||||
icon: RefreshCw,
|
||||
label: 'React Query Demo',
|
||||
onPress: () => {
|
||||
closeDrawer();
|
||||
router.push({
|
||||
pathname: "/test" as any
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'relays',
|
||||
icon: Globe,
|
||||
|
@ -1,11 +1,11 @@
|
||||
// components/UserAvatar.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { RobohashAvatar } from '@/components/ui/avatar';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { profileImageCache } from '@/lib/db/services/ProfileImageCache';
|
||||
import { getRobohashUrl, getAvatarSeed } from '@/utils/avatar';
|
||||
import { getAvatarSeed } from '@/utils/avatar';
|
||||
import { useProfileImage } from '@/lib/hooks/useProfileImage';
|
||||
|
||||
interface UserAvatarProps {
|
||||
uri?: string;
|
||||
@ -34,41 +34,46 @@ export default function UserAvatar({
|
||||
style,
|
||||
onPress,
|
||||
}: UserAvatarProps) {
|
||||
const [cachedImage, setCachedImage] = useState<string | null>(null);
|
||||
// Get profile image with React Query integration and extract the refetch function
|
||||
const { data: cachedImage, isLoading, isError, error, refetch: refreshProfileImage } = useProfileImage(
|
||||
!uri && pubkey ? pubkey : undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
// Attempt to load cached profile image if available
|
||||
useEffect(() => {
|
||||
if (!uri && pubkey) {
|
||||
// Try to get cached image if URI is not provided
|
||||
profileImageCache.getProfileImageUri(pubkey)
|
||||
.then((cachedUri: string | undefined) => {
|
||||
if (cachedUri) {
|
||||
setCachedImage(cachedUri);
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
console.error('Error getting cached profile image:', error);
|
||||
});
|
||||
// Log any errors loading the profile image
|
||||
React.useEffect(() => {
|
||||
if (isError && error) {
|
||||
console.error(`Error loading profile image for ${pubkey?.substring(0, 8) || 'unknown'}: `, error);
|
||||
}
|
||||
}, [uri, pubkey]);
|
||||
}, [isError, error, pubkey]);
|
||||
|
||||
// Get a consistent seed for Robohash using our utility function
|
||||
const seed = React.useMemo(() => {
|
||||
return getAvatarSeed(pubkey, name || 'anonymous-user');
|
||||
}, [pubkey, name]);
|
||||
|
||||
// Use cached image if available, otherwise use provided URI
|
||||
const imageUrl = uri || cachedImage || undefined;
|
||||
// Use provided URI, cached image, or undefined
|
||||
// Convert null to undefined to maintain backwards compatibility with components expecting string | undefined
|
||||
const imageUrl = uri || (cachedImage === null ? undefined : cachedImage);
|
||||
|
||||
return (
|
||||
<View className={cn("items-center", className)}>
|
||||
<View
|
||||
className={cn("items-center", className)}
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
>
|
||||
<RobohashAvatar
|
||||
uri={imageUrl}
|
||||
seed={seed}
|
||||
size={size}
|
||||
onPress={onPress}
|
||||
isInteractive={Boolean(onPress)}
|
||||
style={style}
|
||||
style={{
|
||||
...style,
|
||||
backgroundColor: 'transparent',
|
||||
shadowColor: 'transparent',
|
||||
borderColor: 'transparent',
|
||||
elevation: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{showName && name && (
|
||||
|
@ -14,6 +14,7 @@ const Avatar = React.forwardRef<AvatarPrimitive.RootRef, AvatarPrimitive.RootPro
|
||||
<AvatarPrimitiveRoot
|
||||
ref={ref}
|
||||
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@ -25,6 +26,7 @@ const AvatarImage = React.forwardRef<AvatarPrimitive.ImageRef, AvatarPrimitive.I
|
||||
<AvatarPrimitiveImage
|
||||
ref={ref}
|
||||
className={cn('aspect-square h-full w-full', className)}
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@ -36,9 +38,10 @@ const AvatarFallback = React.forwardRef<AvatarPrimitive.FallbackRef, AvatarPrimi
|
||||
<AvatarPrimitiveFallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full items-center justify-center rounded-full bg-muted',
|
||||
'flex h-full w-full items-center justify-center rounded-full',
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@ -56,9 +59,10 @@ const RobohashFallback = React.forwardRef<AvatarPrimitive.FallbackRef, RobohashF
|
||||
<AvatarPrimitiveFallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full items-center justify-center rounded-full bg-muted p-0 overflow-hidden',
|
||||
'flex h-full w-full items-center justify-center rounded-full p-0 overflow-hidden',
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
{...props}
|
||||
>
|
||||
<Image
|
||||
|
103
docs/technical/logging-system.md
Normal file
103
docs/technical/logging-system.md
Normal file
@ -0,0 +1,103 @@
|
||||
# POWR App Logging System
|
||||
|
||||
This document describes the logging system implemented in the POWR app, including how it helps reduce console noise while preserving important logs for debugging.
|
||||
|
||||
## Overview
|
||||
|
||||
The POWR app uses a configurable logging system that allows for fine-grained control over log output. The system:
|
||||
|
||||
- Supports multiple log levels (ERROR, WARN, INFO, DEBUG, VERBOSE)
|
||||
- Provides module-based filtering to enable/disable logs from specific components
|
||||
- Defaults to a reduced noise configuration in both development and production
|
||||
- Offers runtime configuration for debugging specific modules
|
||||
|
||||
## Key Features
|
||||
|
||||
### Log Levels
|
||||
|
||||
The system supports the following log levels, in order of increasing verbosity:
|
||||
|
||||
1. **ERROR** (0): Critical errors only
|
||||
2. **WARN** (1): Errors and warnings
|
||||
3. **INFO** (2): Normal operational information
|
||||
4. **DEBUG** (3): Detailed debugging information
|
||||
5. **VERBOSE** (4): Extremely detailed diagnostic information
|
||||
|
||||
### Module Filtering
|
||||
|
||||
Logs are categorized by module to allow selective filtering. By default, the following module types are disabled:
|
||||
|
||||
- **Social Feed modules**: SocialFeed, SocialFeedCache, SocialFeed.EventProcessing
|
||||
- **Database modules**: SQLite, Database, Schema
|
||||
- **Network modules**: NDK, RelayService, RelayStore
|
||||
|
||||
### Quiet Mode
|
||||
|
||||
The system includes a `setQuietMode` function that can be used to toggle between minimal (errors only) and normal logging. This is useful during app initialization when many operations generate a large volume of logs.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Logging
|
||||
|
||||
```typescript
|
||||
import { createLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = createLogger('MyComponent');
|
||||
|
||||
// Usage with different log levels
|
||||
logger.error('This is an error message');
|
||||
logger.warn('This is a warning');
|
||||
logger.info('This is an informational message');
|
||||
logger.debug('This is a debug message');
|
||||
logger.verbose('This is a verbose message');
|
||||
```
|
||||
|
||||
### Quiet Mode
|
||||
|
||||
```typescript
|
||||
import { setQuietMode } from '@/lib/utils/logger';
|
||||
|
||||
// Only show error messages
|
||||
setQuietMode(true);
|
||||
|
||||
// Restore normal logging
|
||||
setQuietMode(false);
|
||||
```
|
||||
|
||||
### Enabling/Disabling Modules
|
||||
|
||||
```typescript
|
||||
import { enableModule, disableModule } from '@/lib/utils/logger';
|
||||
|
||||
// Enable logs from a specific module
|
||||
enableModule('SocialFeed');
|
||||
|
||||
// Disable logs from a module
|
||||
disableModule('Database');
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
The logging system is implemented in `lib/utils/logger.ts`. Key components include:
|
||||
|
||||
1. The `LogLevel` enum defining available log levels
|
||||
2. The `createLogger` function for creating module-specific loggers
|
||||
3. Configuration for disabled modules that is applied application-wide
|
||||
4. The `setQuietMode` function for global logging level control
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you need to see logs from a specific module while debugging:
|
||||
|
||||
1. Use the Chrome/Safari developer console filter to search for specific module names
|
||||
2. Enable logging for a specific module: `enableModule('ModuleName')`
|
||||
3. Temporarily disable quiet mode: `setQuietMode(false)`
|
||||
4. Adjust the global log level: `setLogLevel(LogLevel.DEBUG)`
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Create module-specific loggers with meaningful names
|
||||
2. Use appropriate log levels (ERROR, WARN, INFO, DEBUG, VERBOSE)
|
||||
3. Provide context in messages to make them more useful
|
||||
4. Avoid logging sensitive information
|
||||
5. Structure log messages to be easily searchable
|
@ -11,16 +11,16 @@ PODS:
|
||||
- ExpoModulesCore
|
||||
- Expo (52.0.42):
|
||||
- ExpoModulesCore
|
||||
- expo-dev-client (5.0.16):
|
||||
- expo-dev-client (5.0.18):
|
||||
- EXManifests
|
||||
- expo-dev-launcher
|
||||
- expo-dev-menu
|
||||
- expo-dev-menu-interface
|
||||
- EXUpdatesInterface
|
||||
- expo-dev-launcher (5.0.32):
|
||||
- expo-dev-launcher (5.0.33):
|
||||
- DoubleConversion
|
||||
- EXManifests
|
||||
- expo-dev-launcher/Main (= 5.0.32)
|
||||
- expo-dev-launcher/Main (= 5.0.33)
|
||||
- expo-dev-menu
|
||||
- expo-dev-menu-interface
|
||||
- ExpoModulesCore
|
||||
@ -46,7 +46,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- expo-dev-launcher/Main (5.0.32):
|
||||
- expo-dev-launcher/Main (5.0.33):
|
||||
- DoubleConversion
|
||||
- EXManifests
|
||||
- expo-dev-launcher/Unsafe
|
||||
@ -75,7 +75,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- expo-dev-launcher/Unsafe (5.0.32):
|
||||
- expo-dev-launcher/Unsafe (5.0.33):
|
||||
- DoubleConversion
|
||||
- EXManifests
|
||||
- expo-dev-menu
|
||||
@ -103,10 +103,10 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- expo-dev-menu (6.0.22):
|
||||
- expo-dev-menu (6.0.23):
|
||||
- DoubleConversion
|
||||
- expo-dev-menu/Main (= 6.0.22)
|
||||
- expo-dev-menu/ReactNativeCompatibles (= 6.0.22)
|
||||
- expo-dev-menu/Main (= 6.0.23)
|
||||
- expo-dev-menu/ReactNativeCompatibles (= 6.0.23)
|
||||
- glog
|
||||
- hermes-engine
|
||||
- RCT-Folly (= 2024.01.01.00)
|
||||
@ -127,7 +127,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- expo-dev-menu-interface (1.9.3)
|
||||
- expo-dev-menu/Main (6.0.22):
|
||||
- expo-dev-menu/Main (6.0.23):
|
||||
- DoubleConversion
|
||||
- EXManifests
|
||||
- expo-dev-menu-interface
|
||||
@ -153,7 +153,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- expo-dev-menu/ReactNativeCompatibles (6.0.22):
|
||||
- expo-dev-menu/ReactNativeCompatibles (6.0.23):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
@ -174,7 +174,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- expo-dev-menu/SafeAreaView (6.0.22):
|
||||
- expo-dev-menu/SafeAreaView (6.0.23):
|
||||
- DoubleConversion
|
||||
- ExpoModulesCore
|
||||
- glog
|
||||
@ -196,7 +196,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- expo-dev-menu/Vendored (6.0.22):
|
||||
- expo-dev-menu/Vendored (6.0.23):
|
||||
- DoubleConversion
|
||||
- expo-dev-menu/SafeAreaView
|
||||
- glog
|
||||
@ -261,7 +261,7 @@ PODS:
|
||||
- ExpoModulesCore
|
||||
- ExpoSplashScreen (0.29.22):
|
||||
- ExpoModulesCore
|
||||
- ExpoSQLite (15.1.3):
|
||||
- ExpoSQLite (15.1.4):
|
||||
- ExpoModulesCore
|
||||
- ExpoSystemUI (4.0.9):
|
||||
- ExpoModulesCore
|
||||
@ -2412,9 +2412,9 @@ SPEC CHECKSUMS:
|
||||
EXJSONUtils: 01fc7492b66c234e395dcffdd5f53439c5c29c93
|
||||
EXManifests: 807ab5394ca9f8dd5e64283f02876b2f85c4eb72
|
||||
Expo: e8f11c8e0290deca7be9254569e23f884b95a777
|
||||
expo-dev-client: 21a299c19fe7bee746b2a32d322633b4c562f474
|
||||
expo-dev-launcher: 9ce07a4cdc8147e4caefa297833c9d3bc93c931a
|
||||
expo-dev-menu: c658900804df6345277f732a6a1645e28665fcab
|
||||
expo-dev-client: 3bbeb280f6c7ef5ff3dea062e2780d61d7f89bac
|
||||
expo-dev-launcher: 4777c0ab1caf8befce660f7f75ac2bd3900e18fc
|
||||
expo-dev-menu: 874db0bc6b0408fe95996f055281cdcbdef2f1b8
|
||||
expo-dev-menu-interface: 00dc42302a72722fdecec3fa048de84a9133bcc4
|
||||
ExpoAsset: 48386d40d53a8c1738929b3ed509bcad595b5516
|
||||
ExpoCrypto: e97e864c8d7b9ce4a000bca45dddb93544a1b2b4
|
||||
@ -2427,7 +2427,7 @@ SPEC CHECKSUMS:
|
||||
ExpoModulesCore: 98297c2cc7977c43740a2e52d850d94ac8dbf176
|
||||
ExpoSecureStore: 9a3665d7161dd1031be386e278bffd623658f316
|
||||
ExpoSplashScreen: 0f281e3c2ded4757d2309276c682d023c6299c77
|
||||
ExpoSQLite: 723ec9a52346955e67e5c72556c4d4215f752b45
|
||||
ExpoSQLite: 10fceac6748e9e8f010c70733e0e704fa67399ab
|
||||
ExpoSystemUI: b82a45cf0f6a4fa18d07c46deba8725dd27688b4
|
||||
EXUpdatesInterface: 7c977640bdd8b85833c19e3959ba46145c5719db
|
||||
FBLazyVector: 8fa248633c0736c734d06b43e0f81b1a1bf91395
|
||||
|
@ -1,314 +1,247 @@
|
||||
import NDK, { NDKUser, NDKSigner, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk-mobile";
|
||||
import { Platform } from "react-native";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { AuthMethod } from "./types";
|
||||
import { AuthStateManager } from "./AuthStateManager";
|
||||
import { SigningQueue } from "./SigningQueue";
|
||||
|
||||
// Constants for SecureStore
|
||||
const PRIVATE_KEY_STORAGE_KEY = "powr.private_key";
|
||||
const EXTERNAL_SIGNER_STORAGE_KEY = "nostr_external_signer";
|
||||
import NDK, { NDKUser, NDKEvent, NDKSigner } from '@nostr-dev-kit/ndk-mobile';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk-mobile';
|
||||
import { NDKAmberSigner } from '../signers/NDKAmberSigner';
|
||||
import { generateId, generateDTag } from '@/utils/ids';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { AuthMethod } from './types';
|
||||
|
||||
/**
|
||||
* Service that manages authentication operations
|
||||
* Acts as the central implementation for all auth-related functionality
|
||||
* Auth Service for managing authentication with NDK and React Query
|
||||
*
|
||||
* Provides functionality for:
|
||||
* - Login with private key
|
||||
* - Login with Amber external signer
|
||||
* - Ephemeral key generation
|
||||
* - Secure credential storage
|
||||
* - Logout and cleanup
|
||||
*/
|
||||
export class AuthService {
|
||||
private ndk: NDK;
|
||||
private signingQueue = new SigningQueue();
|
||||
|
||||
private initialized: boolean = false;
|
||||
|
||||
constructor(ndk: NDK) {
|
||||
this.ndk = ndk;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initialize from stored state
|
||||
* Initialize the auth service
|
||||
* This is called automatically by the ReactQueryAuthProvider
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[AuthService] Initializing...");
|
||||
|
||||
// Try to restore previous auth session
|
||||
const privateKey = await SecureStore.getItemAsync(PRIVATE_KEY_STORAGE_KEY);
|
||||
|
||||
// Check if we have credentials stored
|
||||
const privateKey = await SecureStore.getItemAsync('powr.private_key');
|
||||
const externalSignerJson = await SecureStore.getItemAsync('nostr_external_signer');
|
||||
|
||||
// Login with stored credentials if available
|
||||
if (privateKey) {
|
||||
console.log("[AuthService] Found stored private key, attempting to login");
|
||||
await this.loginWithPrivateKey(privateKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to restore external signer session
|
||||
const externalSignerJson = await SecureStore.getItemAsync(EXTERNAL_SIGNER_STORAGE_KEY);
|
||||
if (externalSignerJson) {
|
||||
try {
|
||||
const signerInfo = JSON.parse(externalSignerJson);
|
||||
if (signerInfo.type === "amber" && signerInfo.pubkey && signerInfo.packageName) {
|
||||
console.log("[AuthService] Found stored external signer info, attempting to login");
|
||||
await this.loginWithAmber(signerInfo.pubkey, signerInfo.packageName);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[AuthService] Error parsing external signer info:", error);
|
||||
// Continue to unauthenticated state
|
||||
} else if (externalSignerJson) {
|
||||
const { method, data } = JSON.parse(externalSignerJson);
|
||||
if (method === 'amber') {
|
||||
await this.restoreAmberSigner(data);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[AuthService] No stored credentials found, remaining unauthenticated");
|
||||
|
||||
this.initialized = true;
|
||||
} catch (error) {
|
||||
console.error("[AuthService] Error initializing auth service:", error);
|
||||
AuthStateManager.setError(error instanceof Error ? error : new Error(String(error)));
|
||||
console.error('[AuthService] Error initializing auth service:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Login with a private key
|
||||
* @param privateKey hex private key
|
||||
* @returns NDK user
|
||||
*/
|
||||
async loginWithPrivateKey(privateKey: string): Promise<NDKUser> {
|
||||
try {
|
||||
console.log("[AuthService] Starting private key login");
|
||||
AuthStateManager.setAuthenticating("private_key");
|
||||
|
||||
// Clean the input
|
||||
privateKey = privateKey.trim();
|
||||
|
||||
// Configure NDK with private key signer
|
||||
this.ndk.signer = await this.createPrivateKeySigner(privateKey);
|
||||
// Create signer
|
||||
const signer = new NDKPrivateKeySigner(privateKey);
|
||||
this.ndk.signer = signer;
|
||||
|
||||
// Get user
|
||||
const user = await this.ndk.signer.user();
|
||||
console.log("[AuthService] Signer created, user retrieved:", user.npub);
|
||||
|
||||
// Fetch profile information if possible
|
||||
try {
|
||||
await user.fetchProfile();
|
||||
console.log("[AuthService] Profile fetched successfully");
|
||||
} catch (profileError) {
|
||||
console.warn("[AuthService] Warning: Could not fetch user profile:", profileError);
|
||||
// Continue even if profile fetch fails
|
||||
await this.ndk.connect();
|
||||
if (!this.ndk.activeUser) {
|
||||
throw new Error('Failed to set active user after login');
|
||||
}
|
||||
|
||||
// Store key securely
|
||||
await SecureStore.setItemAsync(PRIVATE_KEY_STORAGE_KEY, privateKey);
|
||||
|
||||
// Update auth state
|
||||
AuthStateManager.setAuthenticated(user, "private_key");
|
||||
console.log("[AuthService] Private key login complete");
|
||||
|
||||
return user;
|
||||
|
||||
// Persist the key securely
|
||||
await SecureStore.setItemAsync('powr.private_key', privateKey);
|
||||
|
||||
return this.ndk.activeUser;
|
||||
} catch (error) {
|
||||
console.error("[AuthService] Private key login error:", error);
|
||||
AuthStateManager.setError(error instanceof Error ? error : new Error(String(error)));
|
||||
console.error('[AuthService] Error logging in with private key:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Login with Amber signer
|
||||
* Login with Amber external signer
|
||||
* @returns NDK user
|
||||
*/
|
||||
async loginWithAmber(pubkey?: string, packageName?: string): Promise<NDKUser> {
|
||||
async loginWithAmber(): Promise<NDKUser> {
|
||||
try {
|
||||
console.log("[AuthService] Starting Amber login");
|
||||
AuthStateManager.setAuthenticating("amber");
|
||||
// Request public key from Amber
|
||||
const { pubkey, packageName } = await NDKAmberSigner.requestPublicKey();
|
||||
|
||||
// Request public key from Amber if not provided
|
||||
let effectivePubkey = pubkey;
|
||||
let effectivePackageName = packageName;
|
||||
// Create Amber signer
|
||||
const amberSigner = new NDKAmberSigner(pubkey, packageName);
|
||||
|
||||
if (!effectivePubkey || !effectivePackageName) {
|
||||
console.log("[AuthService] No pubkey/packageName provided, requesting from Amber");
|
||||
const info = await this.requestAmberPublicKey();
|
||||
effectivePubkey = info.pubkey;
|
||||
effectivePackageName = info.packageName;
|
||||
// Set as NDK signer
|
||||
this.ndk.signer = amberSigner;
|
||||
|
||||
// Connect and get user
|
||||
await this.ndk.connect();
|
||||
if (!this.ndk.activeUser) {
|
||||
throw new Error('Failed to set active user after amber login');
|
||||
}
|
||||
|
||||
// Create an NDKAmberSigner
|
||||
console.log("[AuthService] Creating Amber signer with pubkey:", effectivePubkey);
|
||||
this.ndk.signer = await this.createAmberSigner(effectivePubkey, effectivePackageName);
|
||||
|
||||
// Get user
|
||||
const user = await this.ndk.signer.user();
|
||||
console.log("[AuthService] User fetched from Amber signer");
|
||||
|
||||
// Fetch profile
|
||||
try {
|
||||
await user.fetchProfile();
|
||||
console.log("[AuthService] Profile fetched successfully");
|
||||
} catch (profileError) {
|
||||
console.warn("[AuthService] Warning: Could not fetch user profile:", profileError);
|
||||
// Continue even if profile fetch fails
|
||||
}
|
||||
|
||||
// Store signer info securely
|
||||
const signerInfo = JSON.stringify({
|
||||
type: "amber",
|
||||
pubkey: effectivePubkey,
|
||||
packageName: effectivePackageName
|
||||
// Store the signer info
|
||||
const signerData = {
|
||||
pubkey: pubkey,
|
||||
packageName: packageName
|
||||
};
|
||||
const externalSignerInfo = JSON.stringify({
|
||||
method: 'amber',
|
||||
data: signerData
|
||||
});
|
||||
await SecureStore.setItemAsync(EXTERNAL_SIGNER_STORAGE_KEY, signerInfo);
|
||||
|
||||
// Update auth state
|
||||
AuthStateManager.setAuthenticated(user, "amber");
|
||||
console.log("[AuthService] Amber login complete");
|
||||
await SecureStore.setItemAsync('nostr_external_signer', externalSignerInfo);
|
||||
await SecureStore.deleteItemAsync('powr.private_key'); // Clear any stored private key
|
||||
|
||||
return user;
|
||||
return this.ndk.activeUser;
|
||||
} catch (error) {
|
||||
console.error("[AuthService] Amber login error:", error);
|
||||
AuthStateManager.setError(error instanceof Error ? error : new Error(String(error)));
|
||||
console.error('[AuthService] Error logging in with Amber:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ephemeral key (no login)
|
||||
* Restore an Amber signer session
|
||||
* @param signerData Previous signer data
|
||||
* @returns NDK user
|
||||
*/
|
||||
private async restoreAmberSigner(signerData: any): Promise<NDKUser> {
|
||||
try {
|
||||
// Create Amber signer with existing data
|
||||
const amberSigner = new NDKAmberSigner(signerData.pubkey, signerData.packageName);
|
||||
|
||||
// Set as NDK signer
|
||||
this.ndk.signer = amberSigner;
|
||||
|
||||
// Connect and get user
|
||||
await this.ndk.connect();
|
||||
if (!this.ndk.activeUser) {
|
||||
throw new Error('Failed to set active user after amber signer restore');
|
||||
}
|
||||
|
||||
return this.ndk.activeUser;
|
||||
} catch (error) {
|
||||
console.error('[AuthService] Error restoring Amber signer:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ephemeral key for temporary use
|
||||
* @returns NDK user
|
||||
*/
|
||||
async createEphemeralKey(): Promise<NDKUser> {
|
||||
try {
|
||||
console.log("[AuthService] Creating ephemeral key");
|
||||
AuthStateManager.setAuthenticating("ephemeral");
|
||||
// Generate a random key (not persisted)
|
||||
// This creates a hex string of 64 characters (32 bytes)
|
||||
// Use uuidv4 to generate random bytes
|
||||
const randomId = uuidv4().replace(/-/g, '') + uuidv4().replace(/-/g, '');
|
||||
const privateKey = randomId.substring(0, 64); // Ensure exactly 64 hex chars (32 bytes)
|
||||
const signer = new NDKPrivateKeySigner(privateKey);
|
||||
|
||||
// Generate a random key
|
||||
this.ndk.signer = await this.createEphemeralSigner();
|
||||
// Set as NDK signer
|
||||
this.ndk.signer = signer;
|
||||
|
||||
// Get user
|
||||
const user = await this.ndk.signer.user();
|
||||
console.log("[AuthService] Ephemeral key created, user npub:", user.npub);
|
||||
// Connect and get user
|
||||
await this.ndk.connect();
|
||||
if (!this.ndk.activeUser) {
|
||||
throw new Error('Failed to set active user after ephemeral key creation');
|
||||
}
|
||||
|
||||
// Update auth state
|
||||
AuthStateManager.setAuthenticated(user, "ephemeral");
|
||||
console.log("[AuthService] Ephemeral login complete");
|
||||
// Clear any stored credentials
|
||||
await SecureStore.deleteItemAsync('powr.private_key');
|
||||
await SecureStore.deleteItemAsync('nostr_external_signer');
|
||||
|
||||
return user;
|
||||
return this.ndk.activeUser;
|
||||
} catch (error) {
|
||||
console.error("[AuthService] Ephemeral key creation error:", error);
|
||||
AuthStateManager.setError(error instanceof Error ? error : new Error(String(error)));
|
||||
console.error('[AuthService] Error creating ephemeral key:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Logout
|
||||
* Log out the current user
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
console.log("[AuthService] Logging out");
|
||||
// Clear stored credentials
|
||||
await SecureStore.deleteItemAsync('powr.private_key');
|
||||
await SecureStore.deleteItemAsync('nostr_external_signer');
|
||||
|
||||
// Cancel any pending sign operations
|
||||
this.signingQueue.cancelAll("User logged out");
|
||||
|
||||
// Notify the Amber app of session termination (Android only)
|
||||
if (Platform.OS === "android" && this.ndk.signer) {
|
||||
try {
|
||||
const signerInfo = await SecureStore.getItemAsync(EXTERNAL_SIGNER_STORAGE_KEY);
|
||||
if (signerInfo) {
|
||||
console.log("[AuthService] Notifying Amber of session termination");
|
||||
// This would call the native module method to terminate the Amber session
|
||||
// Will be implemented in the AmberSignerModule.kt
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[AuthService] Error terminating Amber session:", error);
|
||||
// Continue with logout even if Amber notification fails
|
||||
}
|
||||
}
|
||||
|
||||
// Clear NDK signer
|
||||
console.log("[AuthService] Clearing NDK signer");
|
||||
// Reset NDK
|
||||
this.ndk.signer = undefined;
|
||||
|
||||
// Clear auth state - this will also clear storage
|
||||
await AuthStateManager.logout();
|
||||
|
||||
console.log("[AuthService] Logout complete");
|
||||
} catch (error) {
|
||||
console.error("[AuthService] Logout error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Private helper methods for creating specific signers
|
||||
|
||||
/**
|
||||
* Creates a private key signer from a hex or nsec string
|
||||
*/
|
||||
private async createPrivateKeySigner(privateKey: string): Promise<NDKSigner> {
|
||||
console.log("[AuthService] Creating private key signer");
|
||||
|
||||
// Handle nsec formatted keys
|
||||
if (privateKey.startsWith("nsec")) {
|
||||
// Simple cleanup for NDK instance
|
||||
// NDK doesn't have a formal disconnect method
|
||||
try {
|
||||
const { nip19 } = await import("nostr-tools");
|
||||
const { data } = nip19.decode(privateKey);
|
||||
// Convert the decoded data (Uint8Array) to hex string
|
||||
privateKey = Buffer.from(data as Uint8Array).toString("hex");
|
||||
} catch (error) {
|
||||
console.error("[AuthService] Error decoding nsec:", error);
|
||||
throw new Error("Invalid nsec format");
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure private key is valid hex format
|
||||
if (privateKey.length !== 64 || !/^[0-9a-f]+$/i.test(privateKey)) {
|
||||
throw new Error("Invalid private key format - must be nsec or 64-character hex");
|
||||
}
|
||||
|
||||
return new NDKPrivateKeySigner(privateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a public key from Amber
|
||||
*/
|
||||
private async requestAmberPublicKey(): Promise<{ pubkey: string, packageName: string }> {
|
||||
console.log("[AuthService] Requesting public key from Amber");
|
||||
|
||||
if (Platform.OS !== "android") {
|
||||
throw new Error("Amber signer is only available on Android");
|
||||
}
|
||||
|
||||
try {
|
||||
// We'll dynamically import NDKAmberSigner to avoid circular dependencies
|
||||
const { default: NDKAmberSigner } = await import("@/lib/signers/NDKAmberSigner");
|
||||
// Call the static method to request a public key
|
||||
const { pubkey, packageName } = await NDKAmberSigner.requestPublicKey();
|
||||
|
||||
if (!pubkey || !packageName) {
|
||||
throw new Error("Amber returned invalid pubkey or packageName");
|
||||
// Clean up relay connections if they exist
|
||||
if (this.ndk.pool) {
|
||||
// Cast to any to bypass TypeScript errors with internal NDK API
|
||||
const pool = this.ndk.pool as any;
|
||||
if (pool.relayByUrl) {
|
||||
Object.values(pool.relayByUrl).forEach((relay: any) => {
|
||||
try {
|
||||
if (relay && relay.close) relay.close();
|
||||
} catch (e) {
|
||||
console.warn('Error closing relay:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error during NDK resource cleanup:', e);
|
||||
}
|
||||
|
||||
return { pubkey, packageName };
|
||||
console.log('[AuthService] Logged out successfully');
|
||||
} catch (error) {
|
||||
console.error("[AuthService] Error requesting public key from Amber:", error);
|
||||
console.error('[AuthService] Error during logout:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates an Amber signer with the given pubkey and package name
|
||||
* Get the current authentication method
|
||||
* @returns Auth method or undefined if not authenticated
|
||||
*/
|
||||
private async createAmberSigner(pubkey: string, packageName: string): Promise<NDKSigner> {
|
||||
console.log("[AuthService] Creating Amber signer");
|
||||
|
||||
if (Platform.OS !== "android") {
|
||||
throw new Error("Amber signer is only available on Android");
|
||||
async getCurrentAuthMethod(): Promise<AuthMethod | undefined> {
|
||||
try {
|
||||
if (await SecureStore.getItemAsync('powr.private_key')) {
|
||||
return 'private_key';
|
||||
}
|
||||
|
||||
const externalSignerJson = await SecureStore.getItemAsync('nostr_external_signer');
|
||||
if (externalSignerJson) {
|
||||
const { method } = JSON.parse(externalSignerJson);
|
||||
return method === 'amber' ? 'amber' : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.error('[AuthService] Error getting current auth method:', error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Dynamically import to avoid circular dependencies
|
||||
const { default: NDKAmberSigner } = await import("@/lib/signers/NDKAmberSigner");
|
||||
return new NDKAmberSigner(pubkey, packageName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an ephemeral signer with a random keypair
|
||||
*/
|
||||
private async createEphemeralSigner(): Promise<NDKSigner> {
|
||||
console.log("[AuthService] Creating ephemeral signer");
|
||||
|
||||
// Generate a new random keypair
|
||||
const { generateSecretKey } = await import("nostr-tools");
|
||||
const secretKeyBytes = generateSecretKey();
|
||||
// Convert to hex for the private key signer
|
||||
const privateKey = Array.from(secretKeyBytes)
|
||||
.map(byte => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
return new NDKPrivateKeySigner(privateKey);
|
||||
}
|
||||
}
|
||||
|
91
lib/auth/ReactQueryAuthProvider.tsx
Normal file
91
lib/auth/ReactQueryAuthProvider.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import React, { ReactNode, useEffect, useState, createContext, useMemo } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { createQueryClient } from '../queryClient';
|
||||
import NDK from '@nostr-dev-kit/ndk-mobile';
|
||||
import { initializeNDK } from '@/lib/initNDK';
|
||||
|
||||
// Create context for NDK instance
|
||||
export const NDKContext = createContext<{ ndk: NDK | null; isInitialized: boolean }>({
|
||||
ndk: null,
|
||||
isInitialized: false,
|
||||
});
|
||||
|
||||
interface ReactQueryAuthProviderProps {
|
||||
children: ReactNode;
|
||||
enableOfflineMode?: boolean;
|
||||
queryClient?: QueryClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReactQueryAuthProvider
|
||||
*
|
||||
* Main provider component for React Query integration with authentication.
|
||||
* This component:
|
||||
* - Creates and configures the QueryClient
|
||||
* - Creates an NDK instance
|
||||
* - Provides React Query context and NDK context
|
||||
* - Ensures consistent hook ordering regardless of initialization state
|
||||
*/
|
||||
export function ReactQueryAuthProvider({
|
||||
children,
|
||||
enableOfflineMode = true,
|
||||
queryClient: customQueryClient,
|
||||
}: ReactQueryAuthProviderProps) {
|
||||
// Create Query Client if not provided (always created)
|
||||
const queryClient = useMemo(() => customQueryClient ?? createQueryClient(), [customQueryClient]);
|
||||
|
||||
// NDK state - but we ALWAYS render regardless of state
|
||||
const [ndk, setNdk] = useState<NDK | null>(null);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// NDK context value (memoized to prevent unnecessary re-renders)
|
||||
const ndkContextValue = useMemo(() => ({
|
||||
ndk,
|
||||
isInitialized
|
||||
}), [ndk, isInitialized]);
|
||||
|
||||
// Initialize NDK
|
||||
useEffect(() => {
|
||||
const initNDK = async () => {
|
||||
try {
|
||||
console.log('[ReactQueryAuthProvider] Initializing NDK...');
|
||||
const result = await initializeNDK();
|
||||
setNdk(result.ndk);
|
||||
setIsInitialized(true);
|
||||
console.log('[ReactQueryAuthProvider] NDK initialized successfully');
|
||||
} catch (err) {
|
||||
console.error('[ReactQueryAuthProvider] Error initializing NDK:', err);
|
||||
// Still mark as initialized so the app can handle the error state
|
||||
setIsInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
initNDK();
|
||||
}, [enableOfflineMode]);
|
||||
|
||||
// Always render children, regardless of NDK initialization status
|
||||
// This ensures consistent hook ordering in child components
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NDKContext.Provider value={ndkContextValue}>
|
||||
{children}
|
||||
</NDKContext.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Example usage in app/_layout.tsx:
|
||||
*
|
||||
* ```tsx
|
||||
* import { ReactQueryAuthProvider } from '@/lib/auth/ReactQueryAuthProvider';
|
||||
*
|
||||
* export default function RootLayout() {
|
||||
* return (
|
||||
* <ReactQueryAuthProvider>
|
||||
* <Stack />
|
||||
* </ReactQueryAuthProvider>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
@ -1,9 +1,12 @@
|
||||
// lib/db/db-service.ts
|
||||
import { SQLiteDatabase } from 'expo-sqlite';
|
||||
import { createLogger } from '@/lib/utils/logger';
|
||||
|
||||
// Create database-specific logger
|
||||
const logger = createLogger('SQLite');
|
||||
|
||||
export class DbService {
|
||||
private db: SQLiteDatabase;
|
||||
private readonly DEBUG = __DEV__;
|
||||
|
||||
constructor(db: SQLiteDatabase) {
|
||||
this.db = db;
|
||||
@ -11,25 +14,21 @@ export class DbService {
|
||||
|
||||
async execAsync(sql: string): Promise<void> {
|
||||
try {
|
||||
if (this.DEBUG) {
|
||||
console.log('Executing SQL:', sql);
|
||||
}
|
||||
logger.debug('Executing SQL:', sql);
|
||||
await this.db.execAsync(sql);
|
||||
} catch (error) {
|
||||
console.error('SQL Error:', error);
|
||||
logger.error('SQL Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async runAsync(sql: string, params: any[] = []) {
|
||||
try {
|
||||
if (this.DEBUG) {
|
||||
console.log('Running SQL:', sql);
|
||||
console.log('Parameters:', params);
|
||||
}
|
||||
logger.debug('Running SQL:', sql);
|
||||
logger.debug('Parameters:', params);
|
||||
return await this.db.runAsync(sql, params);
|
||||
} catch (error) {
|
||||
console.error('SQL Error:', error);
|
||||
logger.error('SQL Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -38,7 +37,7 @@ export class DbService {
|
||||
try {
|
||||
return await this.db.getFirstAsync<T>(sql, params);
|
||||
} catch (error) {
|
||||
console.error('SQL Error:', error);
|
||||
logger.error('SQL Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -47,7 +46,7 @@ export class DbService {
|
||||
try {
|
||||
return await this.db.getAllAsync<T>(sql, params);
|
||||
} catch (error) {
|
||||
console.error('SQL Error:', error);
|
||||
logger.error('SQL Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -58,8 +57,8 @@ export class DbService {
|
||||
await action();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Transaction Error:', error);
|
||||
logger.error('Transaction Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
474
lib/db/services/BannerImageCache.ts
Normal file
474
lib/db/services/BannerImageCache.ts
Normal file
@ -0,0 +1,474 @@
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import NDK, { NDKUser, NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk-mobile';
|
||||
import { createLogger, enableModule } from '@/lib/utils/logger';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
// Enable logging for BannerImageCache
|
||||
enableModule('BannerImageCache');
|
||||
const logger = createLogger('BannerImageCache');
|
||||
const platformTag = Platform.OS === 'ios' ? '[iOS]' : '[Android]';
|
||||
|
||||
// Constants for cache management
|
||||
const MAX_CACHE_SIZE = 150 * 1024 * 1024; // 150MB limit for banner images (larger than profile images)
|
||||
const MAX_CACHE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const CACHE_FRESHNESS_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
interface CacheAccessRecord {
|
||||
pubkey: string;
|
||||
path: string;
|
||||
size: number;
|
||||
lastAccessed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for caching profile banner images
|
||||
* This service downloads and caches banner images locally,
|
||||
* providing offline access and reducing network usage
|
||||
*
|
||||
* Enhanced with:
|
||||
* - LRU-based eviction for space management
|
||||
* - Size-based limits (150MB max)
|
||||
* - Usage tracking for intelligent cleanup
|
||||
*/
|
||||
export class BannerImageCache {
|
||||
private cacheDirectory: string;
|
||||
private ndk: NDK | null = null;
|
||||
private accessLog: Map<string, number> = new Map(); // Track last access times
|
||||
private cacheSize: number = 0; // Track total cache size
|
||||
private initialized: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this.cacheDirectory = `${FileSystem.cacheDirectory}banner-images/`;
|
||||
this.ensureCacheDirectoryExists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the NDK instance for profile fetching
|
||||
* @param ndk NDK instance
|
||||
*/
|
||||
setNDK(ndk: NDK) {
|
||||
this.ndk = ndk;
|
||||
|
||||
// Initialize cache metadata when NDK is set
|
||||
if (!this.initialized) {
|
||||
this.initializeCacheMetadata();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the cache directory exists
|
||||
* @private
|
||||
*/
|
||||
private async ensureCacheDirectoryExists() {
|
||||
try {
|
||||
const dirInfo = await FileSystem.getInfoAsync(this.cacheDirectory);
|
||||
if (!dirInfo.exists) {
|
||||
await FileSystem.makeDirectoryAsync(this.cacheDirectory, { intermediates: true });
|
||||
console.log(`Created banner image cache directory: ${this.cacheDirectory}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating banner cache directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize cache metadata by scanning the cache directory
|
||||
* @private
|
||||
*/
|
||||
private async initializeCacheMetadata() {
|
||||
try {
|
||||
// Get list of all cached files
|
||||
const files = await FileSystem.readDirectoryAsync(this.cacheDirectory);
|
||||
this.cacheSize = 0;
|
||||
|
||||
// Process each file to build the access log and calculate total size
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('_banner.jpg')) continue;
|
||||
|
||||
const filePath = `${this.cacheDirectory}${file}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
||||
|
||||
if (fileInfo.exists && fileInfo.size) {
|
||||
const pubkey = file.replace('_banner.jpg', '');
|
||||
|
||||
// Add to access log with current time (conservative approach)
|
||||
this.accessLog.set(pubkey, Date.now());
|
||||
|
||||
// Add to total cache size
|
||||
this.cacheSize += fileInfo.size;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Banner image cache initialized: ${files.length} files, ${(this.cacheSize / (1024 * 1024)).toFixed(2)} MB`);
|
||||
|
||||
// If cache is over the limit already, clean it up
|
||||
if (this.cacheSize > MAX_CACHE_SIZE) {
|
||||
this.enforceSizeLimit();
|
||||
}
|
||||
|
||||
// Mark as initialized
|
||||
this.initialized = true;
|
||||
|
||||
// Also clear old cache based on time
|
||||
this.clearOldCache();
|
||||
} catch (error) {
|
||||
console.error('Error initializing banner cache metadata:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a cached banner image URI or download if needed
|
||||
* @param pubkey User's public key
|
||||
* @param fallbackUrl Fallback URL to use if no cached image is found
|
||||
* @returns Promise with the cached image URI or fallback URL
|
||||
*/
|
||||
async getBannerImageUri(pubkey?: string, fallbackUrl?: string): Promise<string | undefined> {
|
||||
try {
|
||||
if (!pubkey) {
|
||||
logger.warn(`${platformTag} getBannerImageUri called without pubkey`);
|
||||
return fallbackUrl;
|
||||
}
|
||||
|
||||
logger.info(`${platformTag} Getting banner for pubkey: ${pubkey.substring(0, 8)}...`);
|
||||
|
||||
// Check if image exists in cache
|
||||
const cachedPath = `${this.cacheDirectory}${pubkey}_banner.jpg`;
|
||||
logger.debug(`${platformTag} Checking cache at path: ${cachedPath}`);
|
||||
const fileInfo = await FileSystem.getInfoAsync(cachedPath);
|
||||
|
||||
if (fileInfo.exists && fileInfo.size > 0) {
|
||||
// Update access time regardless of whether we'll use it or redownload
|
||||
this.accessLog.set(pubkey, Date.now());
|
||||
logger.info(`${platformTag} Found cached banner image (size: ${fileInfo.size} bytes)`);
|
||||
|
||||
// Check if cache is fresh (less than 24 hours old)
|
||||
const stats = await FileSystem.getInfoAsync(cachedPath, { md5: false });
|
||||
logger.debug(`${platformTag} Cache file stats: ${JSON.stringify(stats)}`);
|
||||
|
||||
// Type assertion for modificationTime which might not be in the type definition
|
||||
const modTime = (stats as any).modificationTime || 0;
|
||||
const cacheAge = Date.now() - modTime * 1000;
|
||||
|
||||
logger.debug(`${platformTag} Cache age: ${(cacheAge / (1000 * 60 * 60)).toFixed(1)} hours, threshold: ${(CACHE_FRESHNESS_MS / (1000 * 60 * 60))} hours`);
|
||||
|
||||
if (cacheAge < CACHE_FRESHNESS_MS) {
|
||||
logger.info(`${platformTag} Using cached banner image for ${pubkey.substring(0, 8)}...`);
|
||||
// iOS might need a full file:// prefix for the path
|
||||
const fullPath = Platform.OS === 'ios'
|
||||
? (cachedPath.startsWith('file://') ? cachedPath : `file://${cachedPath}`)
|
||||
: cachedPath;
|
||||
|
||||
logger.debug(`${platformTag} Returning cache path: ${fullPath}`);
|
||||
return fullPath;
|
||||
} else {
|
||||
logger.info(`${platformTag} Cached image is stale (${(cacheAge / (1000 * 60 * 60)).toFixed(1)} hours old), will redownload`);
|
||||
}
|
||||
} else {
|
||||
logger.info(`${platformTag} No cached banner image found or file is empty`);
|
||||
}
|
||||
|
||||
// Before downloading, make sure we have enough space
|
||||
await this.enforceSizeLimit();
|
||||
|
||||
// If not in cache or stale, try to get from NDK
|
||||
if (this.ndk) {
|
||||
logger.info(`${platformTag} Attempting to fetch profile data from NDK`);
|
||||
const user = new NDKUser({ pubkey });
|
||||
user.ndk = this.ndk;
|
||||
|
||||
// Get profile from NDK cache first
|
||||
try {
|
||||
logger.debug(`${platformTag} Fetching profile with CACHE_FIRST strategy`);
|
||||
await user.fetchProfile({
|
||||
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST
|
||||
});
|
||||
|
||||
// Log profile data for debugging
|
||||
logger.debug(`${platformTag} Profile data received: ${JSON.stringify({
|
||||
hasBanner: !!user.profile?.banner,
|
||||
hasBackground: !!(user.profile as any)?.background,
|
||||
hasFallback: !!fallbackUrl
|
||||
})}`);
|
||||
|
||||
let imageUrl = user.profile?.banner ||
|
||||
(user.profile as any)?.background ||
|
||||
fallbackUrl;
|
||||
|
||||
if (imageUrl) {
|
||||
logger.info(`${platformTag} Found image URL: ${imageUrl}`);
|
||||
try {
|
||||
// Download and cache the image
|
||||
logger.info(`${platformTag} Downloading banner image for ${pubkey.substring(0, 8)}... from ${imageUrl}`);
|
||||
const downloadResult = await FileSystem.downloadAsync(imageUrl, cachedPath);
|
||||
logger.debug(`${platformTag} Download result: ${JSON.stringify({
|
||||
status: downloadResult.status,
|
||||
headers: downloadResult.headers,
|
||||
})}`);
|
||||
|
||||
// Verify the downloaded file exists and has content
|
||||
if (downloadResult.status === 200) {
|
||||
const fileInfo = await FileSystem.getInfoAsync(cachedPath);
|
||||
logger.debug(`${platformTag} Downloaded file info: ${JSON.stringify(fileInfo)}`);
|
||||
|
||||
if (fileInfo.exists && fileInfo.size > 0) {
|
||||
logger.info(`${platformTag} Successfully cached banner image (${(fileInfo.size / 1024).toFixed(1)} KB)`);
|
||||
|
||||
// Update cache metadata
|
||||
this.accessLog.set(pubkey, Date.now());
|
||||
this.cacheSize += fileInfo.size;
|
||||
|
||||
// iOS might need a full file:// prefix for the path
|
||||
const fullPath = Platform.OS === 'ios'
|
||||
? (cachedPath.startsWith('file://') ? cachedPath : `file://${cachedPath}`)
|
||||
: cachedPath;
|
||||
|
||||
logger.debug(`${platformTag} Returning downloaded image path: ${fullPath}`);
|
||||
return fullPath;
|
||||
} else {
|
||||
logger.warn(`${platformTag} Downloaded banner file is empty or missing: ${cachedPath}`);
|
||||
// Delete the empty file
|
||||
await FileSystem.deleteAsync(cachedPath, { idempotent: true });
|
||||
return fallbackUrl;
|
||||
}
|
||||
} else {
|
||||
logger.warn(`${platformTag} Failed to download banner, status: ${downloadResult.status}`);
|
||||
return fallbackUrl;
|
||||
}
|
||||
} catch (downloadError) {
|
||||
logger.error(`${platformTag} Error downloading banner: ${downloadError}`);
|
||||
if (downloadError instanceof Error) {
|
||||
logger.error(`${platformTag} Error details: ${downloadError.message}`);
|
||||
logger.debug(`${platformTag} Stack trace: ${downloadError.stack}`);
|
||||
}
|
||||
|
||||
// Clean up any partial downloads
|
||||
try {
|
||||
const fileInfo = await FileSystem.getInfoAsync(cachedPath);
|
||||
if (fileInfo.exists) {
|
||||
await FileSystem.deleteAsync(cachedPath, { idempotent: true });
|
||||
logger.debug(`${platformTag} Cleaned up partial download`);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
logger.error(`${platformTag} Error cleaning up failed download: ${cleanupError}`);
|
||||
}
|
||||
return fallbackUrl;
|
||||
}
|
||||
} else {
|
||||
logger.info(`${platformTag} No banner image URL found in profile`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Could not fetch profile from cache:', error);
|
||||
}
|
||||
|
||||
// If not in cache and no fallback, try network
|
||||
if (!fallbackUrl) {
|
||||
logger.info(`${platformTag} No fallback URL provided, trying network fetch as last resort`);
|
||||
try {
|
||||
logger.debug(`${platformTag} Fetching profile with CACHE_FIRST strategy (retry attempt)`);
|
||||
await user.fetchProfile({
|
||||
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST
|
||||
});
|
||||
const imageUrl = user.profile?.banner || (user.profile as any)?.background;
|
||||
|
||||
if (imageUrl) {
|
||||
logger.info(`${platformTag} Found image URL in second attempt: ${imageUrl}`);
|
||||
// Download and cache the image
|
||||
const downloadResult = await FileSystem.downloadAsync(imageUrl, cachedPath);
|
||||
logger.debug(`${platformTag} Second download result: ${JSON.stringify({
|
||||
status: downloadResult.status,
|
||||
headers: downloadResult.headers,
|
||||
})}`);
|
||||
|
||||
// Update cache metadata if successful
|
||||
if (downloadResult.status === 200) {
|
||||
const fileInfo = await FileSystem.getInfoAsync(cachedPath);
|
||||
if (fileInfo.exists && fileInfo.size > 0) {
|
||||
logger.info(`${platformTag} Successfully cached banner in second attempt (${(fileInfo.size / 1024).toFixed(1)} KB)`);
|
||||
this.accessLog.set(pubkey, Date.now());
|
||||
this.cacheSize += fileInfo.size;
|
||||
|
||||
// iOS might need a full file:// prefix for the path
|
||||
const fullPath = Platform.OS === 'ios'
|
||||
? (cachedPath.startsWith('file://') ? cachedPath : `file://${cachedPath}`)
|
||||
: cachedPath;
|
||||
|
||||
logger.debug(`${platformTag} Returning downloaded image path from second attempt: ${fullPath}`);
|
||||
return fullPath;
|
||||
} else {
|
||||
logger.warn(`${platformTag} Second downloaded banner file is empty or missing`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`${platformTag} Second download attempt failed, status: ${downloadResult.status}`);
|
||||
}
|
||||
} else {
|
||||
logger.info(`${platformTag} No banner URL found in second profile fetch attempt`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`${platformTag} Error fetching profile from network: ${error}`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(`${platformTag} Error details: ${error.message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.info(`${platformTag} Using fallback URL as last resort: ${fallbackUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Return fallback URL if provided and nothing in cache
|
||||
return fallbackUrl;
|
||||
} catch (error) {
|
||||
console.error('Error getting banner image:', error);
|
||||
return fallbackUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the cache size limit by removing least recently used items
|
||||
* @private
|
||||
*/
|
||||
private async enforceSizeLimit() {
|
||||
try {
|
||||
// If we're under the limit, no need to clean up
|
||||
if (this.cacheSize <= MAX_CACHE_SIZE * 0.9) { // 90% threshold to avoid cleaning up too often
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert the access log to an array for sorting
|
||||
const accessRecords: CacheAccessRecord[] = [];
|
||||
|
||||
// Get all cache files with their metadata
|
||||
const files = await FileSystem.readDirectoryAsync(this.cacheDirectory);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('_banner.jpg')) continue;
|
||||
|
||||
const pubkey = file.replace('_banner.jpg', '');
|
||||
const path = `${this.cacheDirectory}${file}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(path);
|
||||
|
||||
if (fileInfo.exists && fileInfo.size) {
|
||||
accessRecords.push({
|
||||
pubkey,
|
||||
path,
|
||||
size: fileInfo.size,
|
||||
lastAccessed: this.accessLog.get(pubkey) || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by last accessed time (oldest first)
|
||||
accessRecords.sort((a, b) => a.lastAccessed - b.lastAccessed);
|
||||
|
||||
// Delete oldest files until we're under the size limit
|
||||
let removedCount = 0;
|
||||
let freedSpace = 0;
|
||||
|
||||
for (const record of accessRecords) {
|
||||
// Stop if we've freed enough space (aim for 75% of max to leave headroom)
|
||||
if (this.cacheSize - freedSpace <= MAX_CACHE_SIZE * 0.75) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
await FileSystem.deleteAsync(record.path, { idempotent: true });
|
||||
|
||||
// Update cache metadata
|
||||
this.accessLog.delete(record.pubkey);
|
||||
freedSpace += record.size;
|
||||
removedCount++;
|
||||
|
||||
console.log(`Removed old banner image: ${record.pubkey} (${(record.size / 1024).toFixed(1)} KB)`);
|
||||
} catch (error) {
|
||||
console.error(`Error removing cache file ${record.path}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update total cache size
|
||||
this.cacheSize -= freedSpace;
|
||||
|
||||
if (removedCount > 0) {
|
||||
console.log(`Cleaned up banner image cache: removed ${removedCount} files, freed ${(freedSpace / (1024 * 1024)).toFixed(2)} MB`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error enforcing cache size limit:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear old cached images
|
||||
* @param maxAgeDays Maximum age in days (default: 7)
|
||||
* @returns Promise that resolves when clearing is complete
|
||||
*/
|
||||
async clearOldCache(maxAgeDays: number = 7): Promise<void> {
|
||||
try {
|
||||
const files = await FileSystem.readDirectoryAsync(this.cacheDirectory);
|
||||
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
let clearedCount = 0;
|
||||
let clearedSize = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = `${this.cacheDirectory}${file}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
||||
|
||||
if (fileInfo.exists) {
|
||||
// Type assertion for modificationTime
|
||||
const modTime = (fileInfo as any).modificationTime || 0;
|
||||
const fileAge = now - modTime * 1000;
|
||||
if (fileAge > maxAgeMs) {
|
||||
// Get file size for statistics before deleting
|
||||
const size = fileInfo.size || 0;
|
||||
|
||||
await FileSystem.deleteAsync(filePath);
|
||||
|
||||
// Update cache metadata
|
||||
const pubkey = file.replace('_banner.jpg', '');
|
||||
this.accessLog.delete(pubkey);
|
||||
this.cacheSize -= size;
|
||||
|
||||
clearedCount++;
|
||||
clearedSize += size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Cleared ${clearedCount} old banner images from cache (${(clearedSize / (1024 * 1024)).toFixed(2)} MB)`);
|
||||
} catch (error) {
|
||||
console.error('Error clearing old banner cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the entire cache
|
||||
* @returns Promise that resolves when clearing is complete
|
||||
*/
|
||||
async clearCache(): Promise<void> {
|
||||
try {
|
||||
await FileSystem.deleteAsync(this.cacheDirectory, { idempotent: true });
|
||||
await this.ensureCacheDirectoryExists();
|
||||
|
||||
// Reset metadata
|
||||
this.accessLog.clear();
|
||||
this.cacheSize = 0;
|
||||
this.initialized = false;
|
||||
|
||||
console.log('Banner image cache cleared');
|
||||
} catch (error) {
|
||||
console.error('Error clearing banner cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current cache statistics
|
||||
* @returns Object with cache statistics
|
||||
*/
|
||||
async getCacheStats() {
|
||||
return {
|
||||
size: this.cacheSize,
|
||||
itemCount: this.accessLog.size,
|
||||
directory: this.cacheDirectory
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const bannerImageCache = new BannerImageCache();
|
@ -2,6 +2,10 @@
|
||||
import { SQLiteDatabase } from 'expo-sqlite';
|
||||
import { NostrEvent } from '@/types/nostr';
|
||||
import { DbService } from '../db-service';
|
||||
import { createLogger } from '@/lib/utils/logger';
|
||||
|
||||
// Create cache-specific logger
|
||||
const logger = createLogger('EventCache');
|
||||
|
||||
export class EventCache {
|
||||
private db: DbService;
|
||||
@ -66,7 +70,7 @@ export class EventCache {
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error caching event:', error);
|
||||
logger.error('Error caching event:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -125,7 +129,7 @@ export class EventCache {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error caching event without transaction:', error);
|
||||
logger.error('Error caching event without transaction:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -173,7 +177,7 @@ export class EventCache {
|
||||
|
||||
return nostrEvent;
|
||||
} catch (error) {
|
||||
console.error('Error retrieving event:', error);
|
||||
logger.error('Error retrieving event:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
@ -2,14 +2,34 @@ import * as FileSystem from 'expo-file-system';
|
||||
import NDK, { NDKUser, NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk-mobile';
|
||||
import * as Crypto from 'expo-crypto';
|
||||
|
||||
// Constants for cache management
|
||||
const MAX_CACHE_SIZE = 50 * 1024 * 1024; // 50MB limit for profile images
|
||||
const MAX_CACHE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const CACHE_FRESHNESS_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
interface CacheAccessRecord {
|
||||
pubkey: string;
|
||||
path: string;
|
||||
size: number;
|
||||
lastAccessed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for caching profile images
|
||||
* This service downloads and caches profile images locally,
|
||||
* providing offline access and reducing network usage
|
||||
*
|
||||
* Enhanced with:
|
||||
* - LRU-based eviction for space management
|
||||
* - Size-based limits (50MB max)
|
||||
* - Usage tracking for intelligent cleanup
|
||||
*/
|
||||
export class ProfileImageCache {
|
||||
private cacheDirectory: string;
|
||||
private ndk: NDK | null = null;
|
||||
private accessLog: Map<string, number> = new Map(); // Track last access times
|
||||
private cacheSize: number = 0; // Track total cache size
|
||||
private initialized: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this.cacheDirectory = `${FileSystem.cacheDirectory}profile-images/`;
|
||||
@ -22,6 +42,11 @@ export class ProfileImageCache {
|
||||
*/
|
||||
setNDK(ndk: NDK) {
|
||||
this.ndk = ndk;
|
||||
|
||||
// Initialize cache metadata when NDK is set
|
||||
if (!this.initialized) {
|
||||
this.initializeCacheMetadata();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -40,6 +65,51 @@ export class ProfileImageCache {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize cache metadata by scanning the cache directory
|
||||
* @private
|
||||
*/
|
||||
private async initializeCacheMetadata() {
|
||||
try {
|
||||
// Get list of all cached files
|
||||
const files = await FileSystem.readDirectoryAsync(this.cacheDirectory);
|
||||
this.cacheSize = 0;
|
||||
|
||||
// Process each file to build the access log and calculate total size
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.jpg')) continue;
|
||||
|
||||
const filePath = `${this.cacheDirectory}${file}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
||||
|
||||
if (fileInfo.exists && fileInfo.size) {
|
||||
const pubkey = file.replace('.jpg', '');
|
||||
|
||||
// Add to access log with current time (conservative approach)
|
||||
this.accessLog.set(pubkey, Date.now());
|
||||
|
||||
// Add to total cache size
|
||||
this.cacheSize += fileInfo.size;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Profile image cache initialized: ${files.length} files, ${(this.cacheSize / (1024 * 1024)).toFixed(2)} MB`);
|
||||
|
||||
// If cache is over the limit already, clean it up
|
||||
if (this.cacheSize > MAX_CACHE_SIZE) {
|
||||
this.enforceSizeLimit();
|
||||
}
|
||||
|
||||
// Mark as initialized
|
||||
this.initialized = true;
|
||||
|
||||
// Also clear old cache based on time
|
||||
this.clearOldCache();
|
||||
} catch (error) {
|
||||
console.error('Error initializing cache metadata:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract pubkey from a profile image URI
|
||||
* @param uri Profile image URI
|
||||
@ -86,20 +156,25 @@ export class ProfileImageCache {
|
||||
const cachedPath = `${this.cacheDirectory}${pubkey}.jpg`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(cachedPath);
|
||||
|
||||
if (fileInfo.exists) {
|
||||
if (fileInfo.exists && fileInfo.size > 0) {
|
||||
// Update access time regardless of whether we'll use it or redownload
|
||||
this.accessLog.set(pubkey, Date.now());
|
||||
|
||||
// Check if cache is fresh (less than 24 hours old)
|
||||
const stats = await FileSystem.getInfoAsync(cachedPath, { md5: false });
|
||||
// Type assertion for modificationTime which might not be in the type definition
|
||||
const modTime = (stats as any).modificationTime || 0;
|
||||
const cacheAge = Date.now() - modTime * 1000;
|
||||
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
if (cacheAge < maxAge) {
|
||||
if (cacheAge < CACHE_FRESHNESS_MS) {
|
||||
console.log(`Using cached profile image for ${pubkey}`);
|
||||
return cachedPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Before downloading, make sure we have enough space
|
||||
await this.enforceSizeLimit();
|
||||
|
||||
// If not in cache or stale, try to get from NDK
|
||||
if (this.ndk) {
|
||||
const user = new NDKUser({ pubkey });
|
||||
@ -122,7 +197,11 @@ export class ProfileImageCache {
|
||||
if (downloadResult.status === 200) {
|
||||
const fileInfo = await FileSystem.getInfoAsync(cachedPath);
|
||||
if (fileInfo.exists && fileInfo.size > 0) {
|
||||
console.log(`Successfully cached profile image for ${pubkey}`);
|
||||
console.log(`Successfully cached profile image for ${pubkey} (${(fileInfo.size / 1024).toFixed(1)} KB)`);
|
||||
|
||||
// Update cache metadata
|
||||
this.accessLog.set(pubkey, Date.now());
|
||||
this.cacheSize += fileInfo.size;
|
||||
return cachedPath;
|
||||
} else {
|
||||
console.warn(`Downloaded image file is empty or missing: ${cachedPath}`);
|
||||
@ -163,7 +242,16 @@ export class ProfileImageCache {
|
||||
if (imageUrl) {
|
||||
// Download and cache the image
|
||||
console.log(`Downloading profile image for ${pubkey} from ${imageUrl}`);
|
||||
await FileSystem.downloadAsync(imageUrl, cachedPath);
|
||||
const downloadResult = await FileSystem.downloadAsync(imageUrl, cachedPath);
|
||||
|
||||
// Update cache metadata if successful
|
||||
if (downloadResult.status === 200) {
|
||||
const fileInfo = await FileSystem.getInfoAsync(cachedPath);
|
||||
if (fileInfo.exists && fileInfo.size > 0) {
|
||||
this.accessLog.set(pubkey, Date.now());
|
||||
this.cacheSize += fileInfo.size;
|
||||
}
|
||||
}
|
||||
return cachedPath;
|
||||
}
|
||||
} catch (error) {
|
||||
@ -180,6 +268,77 @@ export class ProfileImageCache {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the cache size limit by removing least recently used items
|
||||
* @private
|
||||
*/
|
||||
private async enforceSizeLimit() {
|
||||
try {
|
||||
// If we're under the limit, no need to clean up
|
||||
if (this.cacheSize <= MAX_CACHE_SIZE * 0.9) { // 90% threshold to avoid cleaning up too often
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert the access log to an array for sorting
|
||||
const accessRecords: CacheAccessRecord[] = [];
|
||||
|
||||
// Get all cache files with their metadata
|
||||
const files = await FileSystem.readDirectoryAsync(this.cacheDirectory);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.jpg')) continue;
|
||||
|
||||
const pubkey = file.replace('.jpg', '');
|
||||
const path = `${this.cacheDirectory}${file}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(path);
|
||||
|
||||
if (fileInfo.exists && fileInfo.size) {
|
||||
accessRecords.push({
|
||||
pubkey,
|
||||
path,
|
||||
size: fileInfo.size,
|
||||
lastAccessed: this.accessLog.get(pubkey) || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by last accessed time (oldest first)
|
||||
accessRecords.sort((a, b) => a.lastAccessed - b.lastAccessed);
|
||||
|
||||
// Delete oldest files until we're under the size limit
|
||||
let removedCount = 0;
|
||||
let freedSpace = 0;
|
||||
|
||||
for (const record of accessRecords) {
|
||||
// Stop if we've freed enough space (aim for 75% of max to leave headroom)
|
||||
if (this.cacheSize - freedSpace <= MAX_CACHE_SIZE * 0.75) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
await FileSystem.deleteAsync(record.path, { idempotent: true });
|
||||
|
||||
// Update cache metadata
|
||||
this.accessLog.delete(record.pubkey);
|
||||
freedSpace += record.size;
|
||||
removedCount++;
|
||||
|
||||
console.log(`Removed old profile image: ${record.pubkey} (${(record.size / 1024).toFixed(1)} KB)`);
|
||||
} catch (error) {
|
||||
console.error(`Error removing cache file ${record.path}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update total cache size
|
||||
this.cacheSize -= freedSpace;
|
||||
|
||||
if (removedCount > 0) {
|
||||
console.log(`Cleaned up profile image cache: removed ${removedCount} files, freed ${(freedSpace / (1024 * 1024)).toFixed(2)} MB`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error enforcing cache size limit:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear old cached images
|
||||
* @param maxAgeDays Maximum age in days (default: 7)
|
||||
@ -191,6 +350,7 @@ export class ProfileImageCache {
|
||||
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
let clearedCount = 0;
|
||||
let clearedSize = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = `${this.cacheDirectory}${file}`;
|
||||
@ -201,13 +361,23 @@ export class ProfileImageCache {
|
||||
const modTime = (fileInfo as any).modificationTime || 0;
|
||||
const fileAge = now - modTime * 1000;
|
||||
if (fileAge > maxAgeMs) {
|
||||
// Get file size for statistics before deleting
|
||||
const size = fileInfo.size || 0;
|
||||
|
||||
await FileSystem.deleteAsync(filePath);
|
||||
|
||||
// Update cache metadata
|
||||
const pubkey = file.replace('.jpg', '');
|
||||
this.accessLog.delete(pubkey);
|
||||
this.cacheSize -= size;
|
||||
|
||||
clearedCount++;
|
||||
clearedSize += size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Cleared ${clearedCount} old profile images from cache`);
|
||||
console.log(`Cleared ${clearedCount} old profile images from cache (${(clearedSize / (1024 * 1024)).toFixed(2)} MB)`);
|
||||
} catch (error) {
|
||||
console.error('Error clearing old cache:', error);
|
||||
}
|
||||
@ -221,11 +391,29 @@ export class ProfileImageCache {
|
||||
try {
|
||||
await FileSystem.deleteAsync(this.cacheDirectory, { idempotent: true });
|
||||
await this.ensureCacheDirectoryExists();
|
||||
|
||||
// Reset metadata
|
||||
this.accessLog.clear();
|
||||
this.cacheSize = 0;
|
||||
this.initialized = false;
|
||||
|
||||
console.log('Profile image cache cleared');
|
||||
} catch (error) {
|
||||
console.error('Error clearing cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current cache statistics
|
||||
* @returns Object with cache statistics
|
||||
*/
|
||||
async getCacheStats() {
|
||||
return {
|
||||
size: this.cacheSize,
|
||||
itemCount: this.accessLog.size,
|
||||
directory: this.cacheDirectory
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
|
@ -6,6 +6,10 @@ import { DbService } from '../db-service';
|
||||
import { POWR_EVENT_KINDS } from '@/types/nostr-workout';
|
||||
import { FeedItem } from '@/lib/hooks/useSocialFeed';
|
||||
import { LRUCache } from 'typescript-lru-cache';
|
||||
import { createLogger } from '@/lib/utils/logger';
|
||||
|
||||
// Create cache-specific logger
|
||||
const logger = createLogger('SocialFeedCache');
|
||||
|
||||
/**
|
||||
* Service for caching social feed events
|
||||
@ -62,7 +66,7 @@ export class SocialFeedCache {
|
||||
private bufferWrite(query: string, params: any[]) {
|
||||
// Limit buffer size to prevent memory issues
|
||||
if (this.writeBuffer.length >= 1000) {
|
||||
console.warn('[SocialFeedCache] Write buffer is full, dropping oldest operation');
|
||||
logger.warn('Write buffer is full, dropping oldest operation');
|
||||
this.writeBuffer.shift(); // Remove oldest operation
|
||||
}
|
||||
|
||||
@ -146,7 +150,7 @@ export class SocialFeedCache {
|
||||
// Execute the transaction
|
||||
await transaction();
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedCache] Error executing queued transaction:', error);
|
||||
logger.error('Error executing queued transaction:', error);
|
||||
} finally {
|
||||
// Release the lock
|
||||
SocialFeedCache.releaseTransactionLock();
|
||||
@ -174,7 +178,7 @@ export class SocialFeedCache {
|
||||
|
||||
// Check if database is available
|
||||
if (!this.isDbAvailable()) {
|
||||
console.log('[SocialFeedCache] Database not available, delaying flush');
|
||||
logger.info('Database not available, delaying flush');
|
||||
this.scheduleNextFlush(true); // Schedule with backoff
|
||||
return;
|
||||
}
|
||||
@ -189,7 +193,7 @@ export class SocialFeedCache {
|
||||
try {
|
||||
// Check if we've exceeded the maximum retry count
|
||||
if (this.retryCount > this.maxRetryCount) {
|
||||
console.warn(`[SocialFeedCache] Exceeded maximum retry count (${this.maxRetryCount}), dropping ${bufferCopy.length} operations`);
|
||||
logger.warn(`Exceeded maximum retry count (${this.maxRetryCount}), dropping ${bufferCopy.length} operations`);
|
||||
// Reset retry count but don't retry these operations
|
||||
this.retryCount = 0;
|
||||
this.processingTransaction = false;
|
||||
@ -210,7 +214,7 @@ export class SocialFeedCache {
|
||||
await this.db.runAsync(query, params);
|
||||
} catch (innerError) {
|
||||
// Log individual query errors but continue with other queries
|
||||
console.error(`[SocialFeedCache] Error executing query: ${query}`, innerError);
|
||||
logger.error(`Error executing query: ${query}`, innerError);
|
||||
// Don't rethrow to allow other queries to proceed
|
||||
}
|
||||
}
|
||||
@ -220,7 +224,7 @@ export class SocialFeedCache {
|
||||
this.retryCount = 0;
|
||||
this.dbAvailable = true; // Mark database as available
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedCache] Error in transaction:', error);
|
||||
logger.error('Error in transaction:', error);
|
||||
|
||||
// Check for database connection errors
|
||||
if (error instanceof Error &&
|
||||
@ -228,7 +232,7 @@ export class SocialFeedCache {
|
||||
error.message.includes('Database not available'))) {
|
||||
// Mark database as unavailable
|
||||
this.dbAvailable = false;
|
||||
console.warn('[SocialFeedCache] Database connection issue detected, marking as unavailable');
|
||||
logger.warn('Database connection issue detected, marking as unavailable');
|
||||
|
||||
// Add all operations back to the buffer
|
||||
this.writeBuffer = [...bufferCopy, ...this.writeBuffer];
|
||||
@ -251,7 +255,7 @@ export class SocialFeedCache {
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedCache] Error flushing write buffer:', error);
|
||||
logger.error('Error flushing write buffer:', error);
|
||||
} finally {
|
||||
this.processingTransaction = false;
|
||||
this.scheduleNextFlush();
|
||||
@ -278,7 +282,7 @@ export class SocialFeedCache {
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[SocialFeedCache] Scheduling next flush in ${delay}ms (retry: ${this.retryCount})`);
|
||||
logger.debug(`Scheduling next flush in ${delay}ms (retry: ${this.retryCount})`);
|
||||
this.bufferFlushTimer = setTimeout(() => this.flushWriteBuffer(), delay);
|
||||
}
|
||||
}
|
||||
@ -305,9 +309,9 @@ export class SocialFeedCache {
|
||||
ON feed_cache (feed_type, created_at DESC)
|
||||
`);
|
||||
|
||||
console.log('[SocialFeedCache] Feed cache table initialized');
|
||||
logger.info('Feed cache table initialized');
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedCache] Error initializing table:', error);
|
||||
logger.error('Error initializing table:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@ -394,7 +398,7 @@ export class SocialFeedCache {
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedCache] Error caching event:', error);
|
||||
logger.error('Error caching event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@ -470,7 +474,7 @@ export class SocialFeedCache {
|
||||
|
||||
return events;
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedCache] Error getting cached events:', error);
|
||||
logger.error('Error getting cached events:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -530,7 +534,7 @@ export class SocialFeedCache {
|
||||
tags: event.tags || []
|
||||
}, true); // Skip if already exists
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedCache] Error caching referenced event:', error);
|
||||
logger.error('Error caching referenced event:', error);
|
||||
// Continue even if caching fails - we can still return the event
|
||||
}
|
||||
|
||||
@ -539,7 +543,7 @@ export class SocialFeedCache {
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedCache] Error caching referenced event:', error);
|
||||
logger.error('Error caching referenced event:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -573,7 +577,7 @@ export class SocialFeedCache {
|
||||
|
||||
return ndkEvent;
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedCache] Error getting cached event:', error);
|
||||
logger.error('Error getting cached event:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -600,9 +604,9 @@ export class SocialFeedCache {
|
||||
[cutoffTimestamp]
|
||||
);
|
||||
|
||||
console.log(`[SocialFeedCache] Cleared ${oldEvents.length} old events from feed cache`);
|
||||
logger.info(`Cleared ${oldEvents.length} old events from feed cache`);
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedCache] Error clearing old cache:', error);
|
||||
logger.error('Error clearing old cache:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
220
lib/hooks/useAuthQuery.ts
Normal file
220
lib/hooks/useAuthQuery.ts
Normal file
@ -0,0 +1,220 @@
|
||||
import { useCallback, useMemo, useContext } from 'react';
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseMutationResult,
|
||||
UseQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
import NDK, { NDKUser } from '@nostr-dev-kit/ndk-mobile';
|
||||
import { QUERY_KEYS } from '@/lib/queryKeys';
|
||||
import { AuthService } from '@/lib/auth/AuthService';
|
||||
import { useNDK } from './useNDK';
|
||||
import { Platform } from 'react-native';
|
||||
import { AuthMethod } from '@/lib/auth/types';
|
||||
import { NDKContext } from '@/lib/auth/ReactQueryAuthProvider';
|
||||
|
||||
/**
|
||||
* Authentication state type
|
||||
*/
|
||||
export type AuthState =
|
||||
| {
|
||||
status: 'loading';
|
||||
}
|
||||
| {
|
||||
status: 'authenticated';
|
||||
user: NDKUser;
|
||||
method: AuthMethod;
|
||||
}
|
||||
| {
|
||||
status: 'unauthenticated';
|
||||
};
|
||||
|
||||
/**
|
||||
* Login parameters
|
||||
*/
|
||||
export type LoginParams =
|
||||
| {
|
||||
method: 'private_key';
|
||||
privateKey: string;
|
||||
}
|
||||
| {
|
||||
method: 'amber';
|
||||
}
|
||||
| {
|
||||
method: 'ephemeral';
|
||||
};
|
||||
|
||||
/**
|
||||
* useAuthQuery Hook
|
||||
*
|
||||
* React Query-based hook for managing authentication state.
|
||||
* Provides queries and mutations for working with user authentication.
|
||||
*
|
||||
* Features:
|
||||
* - Authentication state management
|
||||
* - Login with different methods (private key, Amber, ephemeral)
|
||||
* - Logout functionality
|
||||
* - Automatic revalidation of auth state
|
||||
*/
|
||||
export function useAuthQuery() {
|
||||
const queryClient = useQueryClient();
|
||||
const { ndk } = useNDK();
|
||||
|
||||
// Create auth service (or a stub if NDK isn't ready)
|
||||
const authService = useMemo(() => {
|
||||
if (!ndk) {
|
||||
// Return a placeholder that returns unauthenticated state
|
||||
// This prevents errors when NDK is still initializing
|
||||
console.log('[useAuthQuery] NDK not available yet, using placeholder auth service');
|
||||
// Create a placeholder with just the methods we need for the query function
|
||||
const placeholderService = {
|
||||
initialize: async () => {},
|
||||
loginWithPrivateKey: async () => { throw new Error('NDK not initialized'); },
|
||||
loginWithAmber: async () => { throw new Error('NDK not initialized'); },
|
||||
createEphemeralKey: async () => { throw new Error('NDK not initialized'); },
|
||||
logout: async () => {},
|
||||
getCurrentAuthMethod: async () => undefined,
|
||||
};
|
||||
|
||||
// Use a type assertion to bypass TypeScript's type checking
|
||||
// This is safe because we only use a subset of the methods in this hook
|
||||
return placeholderService as unknown as AuthService;
|
||||
}
|
||||
|
||||
return new AuthService(ndk);
|
||||
}, [ndk]);
|
||||
|
||||
// Query to get current auth state
|
||||
const authQuery: UseQueryResult<AuthState> = useQuery({
|
||||
queryKey: QUERY_KEYS.auth.current(),
|
||||
queryFn: async (): Promise<AuthState> => {
|
||||
if (!ndk) {
|
||||
return { status: 'unauthenticated' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize auth service
|
||||
await authService.initialize();
|
||||
|
||||
// Check if user is authenticated
|
||||
if (ndk.activeUser) {
|
||||
const method = await authService.getCurrentAuthMethod();
|
||||
return {
|
||||
status: 'authenticated',
|
||||
user: ndk.activeUser,
|
||||
method: method || 'private_key',
|
||||
};
|
||||
}
|
||||
|
||||
return { status: 'unauthenticated' };
|
||||
} catch (error) {
|
||||
console.error('[useAuthQuery] Error getting auth state:', error);
|
||||
return { status: 'unauthenticated' };
|
||||
}
|
||||
},
|
||||
staleTime: Infinity, // Auth state doesn't go stale by itself
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: false,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Login mutation
|
||||
const loginMutation: UseMutationResult<NDKUser, Error, LoginParams> = useMutation({
|
||||
mutationFn: async (params: LoginParams): Promise<NDKUser> => {
|
||||
if (!ndk) {
|
||||
throw new Error('NDK instance is required for login');
|
||||
}
|
||||
|
||||
switch (params.method) {
|
||||
case 'private_key':
|
||||
return authService.loginWithPrivateKey(params.privateKey);
|
||||
case 'amber':
|
||||
if (Platform.OS !== 'android') {
|
||||
throw new Error('Amber login is only available on Android');
|
||||
}
|
||||
return authService.loginWithAmber();
|
||||
case 'ephemeral':
|
||||
return authService.createEphemeralKey();
|
||||
default:
|
||||
throw new Error('Invalid login method');
|
||||
}
|
||||
},
|
||||
onSuccess: async (user, variables) => {
|
||||
// Update auth state after successful login
|
||||
queryClient.setQueryData<AuthState>(QUERY_KEYS.auth.current(), {
|
||||
status: 'authenticated',
|
||||
user,
|
||||
method: variables.method,
|
||||
});
|
||||
|
||||
// Invalidate any queries that depend on authentication
|
||||
await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.auth.all });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('[useAuthQuery] Login error:', error);
|
||||
},
|
||||
});
|
||||
|
||||
// Logout mutation
|
||||
const logoutMutation: UseMutationResult<void, Error, void> = useMutation({
|
||||
mutationFn: async (): Promise<void> => {
|
||||
if (!ndk) {
|
||||
throw new Error('NDK instance is required for logout');
|
||||
}
|
||||
|
||||
await authService.logout();
|
||||
},
|
||||
onSuccess: async () => {
|
||||
// Set auth state to unauthenticated after successful logout
|
||||
queryClient.setQueryData<AuthState>(QUERY_KEYS.auth.current(), {
|
||||
status: 'unauthenticated',
|
||||
});
|
||||
|
||||
// Reset any queries that depend on authentication
|
||||
await queryClient.invalidateQueries();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('[useAuthQuery] Logout error:', error);
|
||||
},
|
||||
});
|
||||
|
||||
// Login function
|
||||
const login = useCallback(
|
||||
(params: LoginParams) => {
|
||||
return loginMutation.mutateAsync(params);
|
||||
},
|
||||
[loginMutation]
|
||||
);
|
||||
|
||||
// Logout function
|
||||
const logout = useCallback(() => {
|
||||
return logoutMutation.mutateAsync();
|
||||
}, [logoutMutation]);
|
||||
|
||||
// Derived state
|
||||
const auth = authQuery.data;
|
||||
const isLoading = authQuery.isLoading;
|
||||
const isAuthenticated = auth?.status === 'authenticated';
|
||||
const user = isAuthenticated ? auth.user : undefined;
|
||||
const isAuthenticating = loginMutation.isPending;
|
||||
|
||||
return {
|
||||
// State
|
||||
auth,
|
||||
isLoading,
|
||||
isAuthenticated,
|
||||
user,
|
||||
|
||||
// Actions
|
||||
login,
|
||||
logout,
|
||||
isAuthenticating,
|
||||
|
||||
// Raw query/mutation objects for advanced use
|
||||
authQuery,
|
||||
loginMutation,
|
||||
logoutMutation,
|
||||
};
|
||||
}
|
109
lib/hooks/useBannerImage.ts
Normal file
109
lib/hooks/useBannerImage.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { bannerImageCache } from '@/lib/db/services/BannerImageCache';
|
||||
import { useNDK } from '@/lib/hooks/useNDK';
|
||||
import { Platform } from 'react-native';
|
||||
import { createLogger, enableModule } from '@/lib/utils/logger';
|
||||
import { QUERY_KEYS } from '@/lib/queryKeys';
|
||||
|
||||
// Enable logging for useBannerImage
|
||||
enableModule('useBannerImage');
|
||||
const logger = createLogger('useBannerImage');
|
||||
const platformTag = Platform.OS === 'ios' ? '[iOS]' : '[Android]';
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage banner images with React Query integration
|
||||
* - Caches banner images in local filesystem and memory cache
|
||||
* - Automatically handles refreshing stale images
|
||||
* - Provides loading and error states
|
||||
* - Enhanced with platform-specific path handling and debugging
|
||||
* - Optimized with improved refresh policies for both iOS and Android
|
||||
*
|
||||
* @param pubkey The user's public key
|
||||
* @param fallbackUrl Optional fallback URL for missing banners
|
||||
* @returns Object with banner URI and status information
|
||||
*/
|
||||
export function useBannerImage(pubkey?: string, fallbackUrl?: string) {
|
||||
const { ndk } = useNDK();
|
||||
|
||||
// Set NDK in banner image cache if available
|
||||
if (ndk && bannerImageCache) {
|
||||
logger.debug(`${platformTag} Setting NDK in banner image cache`);
|
||||
bannerImageCache.setNDK(ndk);
|
||||
}
|
||||
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.profile.bannerImage(pubkey),
|
||||
queryFn: async () => {
|
||||
if (!pubkey) {
|
||||
logger.info(`${platformTag} No pubkey provided to useBannerImage, returning fallback`);
|
||||
return fallbackUrl;
|
||||
}
|
||||
|
||||
logger.info(`${platformTag} Fetching banner image for pubkey: ${pubkey.substring(0, 8)}...`);
|
||||
|
||||
try {
|
||||
// Get banner URI from cache service
|
||||
const bannerUri = await bannerImageCache.getBannerImageUri(pubkey, fallbackUrl);
|
||||
|
||||
if (!bannerUri) {
|
||||
logger.warn(`${platformTag} No banner URI returned from cache service`);
|
||||
return fallbackUrl || null;
|
||||
}
|
||||
|
||||
logger.debug(`${platformTag} Raw banner URI from cache: ${bannerUri}`);
|
||||
|
||||
// Platform-specific URI handling
|
||||
if (Platform.OS === 'ios') {
|
||||
// iOS path handling - ensure file:// prefix is present
|
||||
if (bannerUri.startsWith('/') && !bannerUri.startsWith('file://')) {
|
||||
logger.debug(`${platformTag} Adding file:// prefix to iOS path: ${bannerUri}`);
|
||||
const fixedUri = `file://${bannerUri}`;
|
||||
logger.info(`${platformTag} Returning fixed iOS banner URI: ${fixedUri}`);
|
||||
return fixedUri;
|
||||
}
|
||||
} else if (Platform.OS === 'android') {
|
||||
// Android path handling - ensure path is in the correct format
|
||||
if (bannerUri.startsWith('/') && !bannerUri.startsWith('file://')) {
|
||||
logger.debug(`${platformTag} Adding file:// prefix to Android path: ${bannerUri}`);
|
||||
const fixedUri = `file://${bannerUri}`;
|
||||
logger.info(`${platformTag} Returning fixed Android banner URI: ${fixedUri}`);
|
||||
return fixedUri;
|
||||
}
|
||||
|
||||
// Special handling for Android remote URLs
|
||||
if (bannerUri.startsWith('http') && !bannerUri.includes('?t=')) {
|
||||
// Add cache-busting parameter to force reload on Android
|
||||
const cacheParam = `?t=${Date.now()}`;
|
||||
logger.debug(`${platformTag} Adding cache-busting parameter to Android remote URL`);
|
||||
const fixedUri = `${bannerUri}${cacheParam}`;
|
||||
logger.info(`${platformTag} Returning fixed Android remote URL: ${fixedUri}`);
|
||||
return fixedUri;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`${platformTag} Returning unmodified banner URI: ${bannerUri}`);
|
||||
|
||||
// Ensure we never return undefined to React Query
|
||||
return bannerUri || fallbackUrl || null;
|
||||
} catch (error) {
|
||||
logger.error(`${platformTag} Error in useBannerImage: ${error}`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(`${platformTag} Error details: ${error.message}`);
|
||||
logger.debug(`${platformTag} Stack trace: ${error.stack}`);
|
||||
}
|
||||
// Return fallback or null, but never undefined
|
||||
return fallbackUrl || null;
|
||||
}
|
||||
},
|
||||
// Aggressive refresh configuration
|
||||
staleTime: 0, // No stale time - always refetch
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes cache time
|
||||
retry: 3, // Increase retries for Android
|
||||
retryDelay: 800, // Retry slightly faster on Android
|
||||
refetchOnMount: 'always', // Always refetch when component mounts
|
||||
refetchOnWindowFocus: true, // Refresh when window focuses
|
||||
refetchInterval: Platform.OS === 'android' ? 20000 : 30000, // Refetch more frequently on Android
|
||||
enabled: !!pubkey, // Only run the query if we have a pubkey
|
||||
networkMode: 'always' // Try to fetch even when offline
|
||||
});
|
||||
}
|
92
lib/hooks/useConnectivityWithQuery.ts
Normal file
92
lib/hooks/useConnectivityWithQuery.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
|
||||
import { QUERY_KEYS } from '@/lib/queryKeys';
|
||||
import { ConnectivityService } from '@/lib/db/services/ConnectivityService';
|
||||
|
||||
/**
|
||||
* Connection state type
|
||||
*/
|
||||
export type ConnectionState = {
|
||||
isOnline: boolean;
|
||||
type: string;
|
||||
lastUpdated: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* useConnectivityWithQuery Hook
|
||||
*
|
||||
* React Query-based hook for monitoring network connectivity status.
|
||||
*
|
||||
* Features:
|
||||
* - Real-time monitoring of online/offline status
|
||||
* - Connection type information
|
||||
* - Consistent state across the app
|
||||
* - Automatic event subscription and cleanup
|
||||
*/
|
||||
export function useConnectivityWithQuery() {
|
||||
const queryClient = useQueryClient();
|
||||
const connectivityService = ConnectivityService.getInstance();
|
||||
|
||||
// Query for network status
|
||||
const { data: connectionState, ...rest } = useQuery({
|
||||
queryKey: QUERY_KEYS.system.connectivity(),
|
||||
queryFn: async (): Promise<ConnectionState> => {
|
||||
const netInfo = await NetInfo.fetch();
|
||||
const isOnline = netInfo.isConnected ?? false;
|
||||
|
||||
return {
|
||||
isOnline,
|
||||
type: netInfo.type,
|
||||
lastUpdated: Date.now()
|
||||
};
|
||||
},
|
||||
// Never consider connectivity stale
|
||||
staleTime: Infinity,
|
||||
// Don't refetch on window focus, we'll handle updates via listeners
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Subscribe to network status changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
|
||||
const isOnline = state.isConnected ?? false;
|
||||
|
||||
// Update query data
|
||||
queryClient.setQueryData<ConnectionState>(
|
||||
QUERY_KEYS.system.connectivity(),
|
||||
{
|
||||
isOnline,
|
||||
type: state.type,
|
||||
lastUpdated: Date.now()
|
||||
}
|
||||
);
|
||||
|
||||
// Update connectivity service offline mode (inverse of isOnline)
|
||||
connectivityService.setOfflineMode(!isOnline);
|
||||
|
||||
// If we're going online, invalidate queries that need fresh data
|
||||
if (isOnline) {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.relays.status() });
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup subscription
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [queryClient, connectivityService]);
|
||||
|
||||
// Default to offline if no data is available yet
|
||||
const defaultState: ConnectionState = {
|
||||
isOnline: false,
|
||||
type: 'unknown',
|
||||
lastUpdated: Date.now()
|
||||
};
|
||||
|
||||
// Return connection state and additional query information
|
||||
return {
|
||||
...connectionState || defaultState,
|
||||
...rest
|
||||
};
|
||||
}
|
@ -1,23 +1,18 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useContext } from 'react';
|
||||
import { NDKContext } from '@/lib/auth/ReactQueryAuthProvider';
|
||||
import { useNDKStore } from '@/lib/stores/ndk';
|
||||
import type { NDKUser, NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk-mobile';
|
||||
|
||||
// Core hook for NDK access
|
||||
// Core hook for NDK access
|
||||
// Uses the context from ReactQueryAuthProvider rather than Zustand store
|
||||
export function useNDK() {
|
||||
const { ndk, isLoading, error, init } = useNDKStore(state => ({
|
||||
ndk: state.ndk,
|
||||
isLoading: state.isLoading,
|
||||
error: state.error,
|
||||
init: state.init
|
||||
}));
|
||||
const { ndk, isInitialized } = useContext(NDKContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ndk && !isLoading) {
|
||||
init();
|
||||
}
|
||||
}, [ndk, isLoading, init]);
|
||||
|
||||
return { ndk, isLoading, error };
|
||||
return {
|
||||
ndk,
|
||||
isLoading: !isInitialized,
|
||||
error: !ndk && isInitialized ? new Error('NDK initialization failed') : undefined
|
||||
};
|
||||
}
|
||||
|
||||
// Hook for current user info
|
||||
|
77
lib/hooks/useProfileImage.ts
Normal file
77
lib/hooks/useProfileImage.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { profileImageCache } from '@/lib/db/services/ProfileImageCache';
|
||||
import { useNDK } from '@/lib/hooks/useNDK';
|
||||
import { Platform } from 'react-native';
|
||||
import { createLogger, enableModule } from '@/lib/utils/logger';
|
||||
import { QUERY_KEYS } from '@/lib/queryKeys';
|
||||
|
||||
// Enable logging for useProfileImage
|
||||
enableModule('useProfileImage');
|
||||
const logger = createLogger('useProfileImage');
|
||||
const platformTag = Platform.OS === 'ios' ? '[iOS]' : '[Android]';
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage profile images with React Query integration
|
||||
* - Caches profile images in local filesystem and memory cache
|
||||
* - Automatically handles refreshing stale images
|
||||
* - Provides loading and error states
|
||||
* - Enhanced with platform-specific logging and debugging
|
||||
* - Optimized with improved refresh policies
|
||||
*
|
||||
* @param pubkey The user's public key
|
||||
* @param fallbackUrl Optional fallback URL for missing profile images
|
||||
* @returns Object with profile image URI and status information
|
||||
*/
|
||||
export function useProfileImage(pubkey?: string, fallbackUrl?: string) {
|
||||
const { ndk } = useNDK();
|
||||
|
||||
// Set NDK in profile image cache if available
|
||||
if (ndk && profileImageCache) {
|
||||
logger.debug(`${platformTag} Setting NDK in profile image cache`);
|
||||
profileImageCache.setNDK(ndk);
|
||||
}
|
||||
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.profile.profileImage(pubkey),
|
||||
queryFn: async () => {
|
||||
if (!pubkey) {
|
||||
logger.info(`${platformTag} No pubkey provided to useProfileImage, returning fallback`);
|
||||
return fallbackUrl;
|
||||
}
|
||||
|
||||
logger.info(`${platformTag} Fetching profile image for pubkey: ${pubkey.substring(0, 8)}...`);
|
||||
|
||||
try {
|
||||
const imageUri = await profileImageCache.getProfileImageUri(pubkey, fallbackUrl);
|
||||
|
||||
if (!imageUri) {
|
||||
logger.warn(`${platformTag} No image URI returned from cache service`);
|
||||
return fallbackUrl || null;
|
||||
}
|
||||
|
||||
logger.info(`${platformTag} Returning profile image URI: ${imageUri}`);
|
||||
|
||||
// Ensure we never return undefined to React Query
|
||||
return imageUri || fallbackUrl || null;
|
||||
} catch (error) {
|
||||
logger.error(`${platformTag} Error in useProfileImage: ${error}`);
|
||||
if (error instanceof Error) {
|
||||
logger.error(`${platformTag} Error details: ${error.message}`);
|
||||
logger.debug(`${platformTag} Stack trace: ${error.stack}`);
|
||||
}
|
||||
// Return fallback or null, but never undefined
|
||||
return fallbackUrl || null;
|
||||
}
|
||||
},
|
||||
// Aggressive refresh configuration matching useBannerImage
|
||||
staleTime: 0, // No stale time - always refetch
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes cache time
|
||||
retry: 2, // Increase retries to 2
|
||||
retryDelay: 1000, // Retry after 1 second
|
||||
refetchOnMount: 'always', // Always refetch when component mounts
|
||||
refetchOnWindowFocus: true, // Refresh when window focuses
|
||||
refetchInterval: 30000, // Refetch every 30 seconds
|
||||
enabled: !!pubkey, // Only run the query if we have a pubkey
|
||||
networkMode: 'always' // Try to fetch even when offline
|
||||
});
|
||||
}
|
@ -1,7 +1,14 @@
|
||||
// lib/hooks/useProfileStats.ts
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { nostrBandService, ProfileStats } from '@/lib/services/NostrBandService';
|
||||
import { useNDKCurrentUser } from '@/lib/hooks/useNDK';
|
||||
import { createLogger, enableModule } from '@/lib/utils/logger';
|
||||
import { QUERY_KEYS } from '@/lib/queryKeys';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
// Enable logging
|
||||
enableModule('useProfileStats');
|
||||
const logger = createLogger('useProfileStats');
|
||||
const platform = Platform.OS === 'ios' ? 'iOS' : 'Android';
|
||||
|
||||
interface UseProfileStatsOptions {
|
||||
pubkey?: string;
|
||||
@ -9,8 +16,9 @@ interface UseProfileStatsOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch profile statistics from nostr.band API
|
||||
* Hook to fetch profile statistics from nostr.band API using React Query
|
||||
* Provides follower/following counts and other statistics
|
||||
* Enhanced with proper caching and refresh behavior
|
||||
*/
|
||||
export function useProfileStats(options: UseProfileStatsOptions = {}) {
|
||||
const { currentUser } = useNDKCurrentUser();
|
||||
@ -22,58 +30,107 @@ export function useProfileStats(options: UseProfileStatsOptions = {}) {
|
||||
// Use provided pubkey or fall back to current user's pubkey
|
||||
const pubkey = optionsPubkey || currentUser?.pubkey;
|
||||
|
||||
const [stats, setStats] = useState<ProfileStats>({
|
||||
const query = useQuery({
|
||||
queryKey: QUERY_KEYS.profile.stats(pubkey),
|
||||
queryFn: async () => {
|
||||
if (!pubkey) {
|
||||
logger.warn(`[${platform}] No pubkey provided to useProfileStats`);
|
||||
return {
|
||||
pubkey: '',
|
||||
followersCount: 0,
|
||||
followingCount: 0,
|
||||
isLoading: false,
|
||||
error: null
|
||||
} as ProfileStats;
|
||||
}
|
||||
|
||||
logger.info(`[${platform}] Fetching profile stats for ${pubkey?.substring(0, 8)}...`);
|
||||
|
||||
try {
|
||||
// Add timestamp to bust cache on every request
|
||||
const apiUrl = `https://api.nostr.band/v0/stats/profile/${pubkey}?_t=${Date.now()}`;
|
||||
logger.info(`[${platform}] Fetching from URL: ${apiUrl}`);
|
||||
|
||||
// Force bypass cache to get latest counts when explicitly fetched
|
||||
const profileStats = await nostrBandService.fetchProfileStats(pubkey, true);
|
||||
|
||||
logger.info(`[${platform}] Retrieved profile stats: ${JSON.stringify({
|
||||
followersCount: profileStats.followersCount,
|
||||
followingCount: profileStats.followingCount
|
||||
})}`);
|
||||
|
||||
// React Query will handle caching for us
|
||||
return {
|
||||
...profileStats,
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`[${platform}] Error fetching profile stats: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
// Configuration - force aggressive refresh behavior
|
||||
staleTime: 0, // No stale time - always refetch when used
|
||||
gcTime: 2 * 60 * 1000, // 2 minutes (reduced from 5 minutes)
|
||||
retry: 3, // Increase retries
|
||||
retryDelay: 1000, // 1 second between retries
|
||||
refetchOnMount: 'always', // Always refetch on mount
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: refreshInterval > 0 ? refreshInterval : 10000, // Default to 10 second refresh
|
||||
enabled: !!pubkey,
|
||||
});
|
||||
|
||||
// Enable more verbose debugging
|
||||
if (pubkey && (query.isLoading || query.isPending)) {
|
||||
logger.info(`[${platform}] ProfileStats loading for ${pubkey.substring(0, 8)}...`);
|
||||
}
|
||||
|
||||
if (query.error) {
|
||||
logger.error(`[${platform}] ProfileStats error: ${query.error}`);
|
||||
}
|
||||
|
||||
if (query.isSuccess && query.data) {
|
||||
logger.info(`[${platform}] ProfileStats success:`, {
|
||||
followersCount: query.data.followersCount,
|
||||
followingCount: query.data.followingCount
|
||||
});
|
||||
}
|
||||
|
||||
// Use a properly typed default value for when query.data is undefined
|
||||
const defaultStats: ProfileStats = {
|
||||
pubkey: pubkey || '',
|
||||
followersCount: 0,
|
||||
followingCount: 0,
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
// Access the data directly from query.data with typed default
|
||||
const data = query.data || defaultStats;
|
||||
|
||||
// Create explicit copy of values to ensure reactive updates
|
||||
const result = {
|
||||
pubkey: pubkey || '',
|
||||
followersCount: data.followersCount,
|
||||
followingCount: data.followingCount,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error instanceof Error ? query.error : null,
|
||||
refresh: async () => {
|
||||
logger.info(`[${platform}] Manually refreshing stats for ${pubkey?.substring(0, 8)}...`);
|
||||
return query.refetch();
|
||||
},
|
||||
lastRefreshed: query.dataUpdatedAt
|
||||
};
|
||||
|
||||
// Log every time we return stats for debugging
|
||||
logger.debug(`[${platform}] Returning stats:`, {
|
||||
followersCount: result.followersCount,
|
||||
followingCount: result.followingCount,
|
||||
isLoading: result.isLoading,
|
||||
lastRefreshed: new Date(result.lastRefreshed).toISOString()
|
||||
});
|
||||
|
||||
const [lastRefreshed, setLastRefreshed] = useState<number>(0);
|
||||
|
||||
// Function to fetch profile stats
|
||||
const fetchStats = useCallback(async () => {
|
||||
if (!pubkey) return;
|
||||
|
||||
setStats(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const profileStats = await nostrBandService.fetchProfileStats(pubkey);
|
||||
setStats({
|
||||
...profileStats,
|
||||
isLoading: false
|
||||
});
|
||||
setLastRefreshed(Date.now());
|
||||
} catch (error) {
|
||||
console.error('Error in useProfileStats:', error);
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error : new Error('Unknown error')
|
||||
}));
|
||||
}
|
||||
}, [pubkey]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
if (pubkey) {
|
||||
fetchStats();
|
||||
}
|
||||
}, [pubkey, fetchStats]);
|
||||
|
||||
// Set up refresh interval if specified
|
||||
useEffect(() => {
|
||||
if (refreshInterval > 0 && pubkey) {
|
||||
const intervalId = setInterval(fetchStats, refreshInterval);
|
||||
return () => clearInterval(intervalId);
|
||||
}
|
||||
}, [refreshInterval, pubkey, fetchStats]);
|
||||
|
||||
// Return stats and helper functions
|
||||
return {
|
||||
...stats,
|
||||
refresh: fetchStats,
|
||||
lastRefreshed
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
141
lib/hooks/useProfileWithQuery.ts
Normal file
141
lib/hooks/useProfileWithQuery.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { QUERY_KEYS } from '@/lib/queryKeys';
|
||||
import { useAuthQuery } from './useAuthQuery';
|
||||
import NDK, { NDKEvent, NDKUser } from '@nostr-dev-kit/ndk-mobile';
|
||||
|
||||
interface ProfileData {
|
||||
profile?: {
|
||||
displayName?: string;
|
||||
name?: string;
|
||||
about?: string;
|
||||
website?: string;
|
||||
picture?: string;
|
||||
banner?: string;
|
||||
nip05?: string;
|
||||
lud16?: string;
|
||||
};
|
||||
pubkey: string;
|
||||
raw?: NDKEvent;
|
||||
lastUpdated?: number;
|
||||
}
|
||||
|
||||
interface ProfileUpdateParams {
|
||||
displayName?: string;
|
||||
name?: string;
|
||||
about?: string;
|
||||
website?: string;
|
||||
picture?: string;
|
||||
banner?: string;
|
||||
nip05?: string;
|
||||
lud16?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* useProfileWithQuery Hook
|
||||
*
|
||||
* React Query-based hook for fetching and updating user profiles
|
||||
*
|
||||
* Features:
|
||||
* - Fetch profile data for the authenticated user or any pubkey
|
||||
* - Update the user's profile
|
||||
* - Optimistic updates
|
||||
* - Automatic revalidation
|
||||
*/
|
||||
export function useProfileWithQuery(pubkey?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const { user, isAuthenticated } = useAuthQuery();
|
||||
|
||||
// Use the provided pubkey, or the authenticated user's pubkey if not provided
|
||||
const targetPubkey = pubkey || user?.pubkey;
|
||||
const isSelf = isAuthenticated && user?.pubkey === targetPubkey;
|
||||
|
||||
// No pubkey available - return placeholder state
|
||||
if (!targetPubkey) {
|
||||
return {
|
||||
profile: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
isUpdating: false,
|
||||
updateProfile: async () => {
|
||||
throw new Error('Cannot update profile without a pubkey');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Query for profile data
|
||||
const {
|
||||
data: profile,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch
|
||||
} = useQuery({
|
||||
queryKey: QUERY_KEYS.auth.profile(targetPubkey),
|
||||
queryFn: async (): Promise<ProfileData> => {
|
||||
// Basic implementation - in reality, this would fetch from NDK and SQLite
|
||||
return {
|
||||
pubkey: targetPubkey,
|
||||
profile: {
|
||||
displayName: targetPubkey.slice(0, 8),
|
||||
about: 'Profile fetched with React Query',
|
||||
},
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
enabled: !!targetPubkey,
|
||||
});
|
||||
|
||||
// Mutation for updating profile
|
||||
const { mutateAsync, isPending: isUpdating } = useMutation({
|
||||
mutationFn: async (params: ProfileUpdateParams): Promise<ProfileData> => {
|
||||
if (!isSelf) {
|
||||
throw new Error('Cannot update profile of another user');
|
||||
}
|
||||
|
||||
// In a real implementation, this would create and publish a kind 0 event
|
||||
console.log('[useProfileWithQuery] Updating profile:', params);
|
||||
|
||||
// Return the updated profile (optimistically)
|
||||
return {
|
||||
pubkey: targetPubkey,
|
||||
profile: {
|
||||
...profile?.profile,
|
||||
...params,
|
||||
},
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
// Update the profile in the cache
|
||||
queryClient.setQueryData(
|
||||
QUERY_KEYS.auth.profile(targetPubkey),
|
||||
data
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Update profile function
|
||||
const updateProfile = useCallback(
|
||||
async (params: ProfileUpdateParams) => {
|
||||
if (!isSelf) {
|
||||
throw new Error('Cannot update profile of another user');
|
||||
}
|
||||
|
||||
return await mutateAsync(params);
|
||||
},
|
||||
[isSelf, mutateAsync]
|
||||
);
|
||||
|
||||
return {
|
||||
profile,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
isUpdating,
|
||||
updateProfile,
|
||||
refetch,
|
||||
};
|
||||
}
|
@ -7,6 +7,7 @@ import { useNDK } from '@/lib/hooks/useNDK';
|
||||
import { SQLiteDatabase } from 'expo-sqlite';
|
||||
import { ConnectivityService } from '@/lib/db/services/ConnectivityService';
|
||||
import { useDatabase } from '@/components/DatabaseProvider';
|
||||
import { createLogger, socialFeedLogger, eventProcessingLogger } from '@/lib/utils/logger';
|
||||
import {
|
||||
parseWorkoutRecord,
|
||||
parseExerciseTemplate,
|
||||
@ -64,16 +65,16 @@ export function useSocialFeed(
|
||||
useEffect(() => {
|
||||
if (ndk && !socialServiceRef.current) {
|
||||
try {
|
||||
console.log('[useSocialFeed] Initializing SocialFeedService');
|
||||
socialFeedLogger.info('Initializing SocialFeedService');
|
||||
socialServiceRef.current = new SocialFeedService(ndk, db);
|
||||
console.log('[useSocialFeed] SocialFeedService initialized successfully');
|
||||
socialFeedLogger.info('SocialFeedService initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('[useSocialFeed] Error initializing SocialFeedService:', error);
|
||||
socialFeedLogger.error('Error initializing SocialFeedService:', error);
|
||||
// Log more detailed error information
|
||||
if (error instanceof Error) {
|
||||
console.error(`[useSocialFeed] Error details: ${error.message}`);
|
||||
socialFeedLogger.error(`Error details: ${error.message}`);
|
||||
if (error.stack) {
|
||||
console.error(`[useSocialFeed] Stack trace: ${error.stack}`);
|
||||
socialFeedLogger.error(`Stack trace: ${error.stack}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -99,7 +100,7 @@ export function useSocialFeed(
|
||||
// Skip if we've seen this event before or event has no ID
|
||||
if (!event.id || seenEvents.current.has(event.id)) return;
|
||||
|
||||
console.log(`Processing event ${event.id}, kind ${event.kind} from ${event.pubkey}`);
|
||||
eventProcessingLogger.verbose(`Processing event ${event.id}, kind ${event.kind} from ${event.pubkey}`);
|
||||
|
||||
// Check if this event is quoted by another event we've already seen
|
||||
// Skip unless it's from the POWR account (always show POWR content)
|
||||
@ -107,7 +108,7 @@ export function useSocialFeed(
|
||||
quotedEvents.current.has(event.id) &&
|
||||
event.pubkey !== POWR_PUBKEY_HEX
|
||||
) {
|
||||
console.log(`Event ${event.id} filtered out: quoted=${true}, pubkey=${event.pubkey}`);
|
||||
eventProcessingLogger.debug(`Event ${event.id} filtered out: quoted=${true}, pubkey=${event.pubkey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -293,16 +294,16 @@ export function useSocialFeed(
|
||||
if (dTags.length > 0) {
|
||||
const identifier = dTags[0][1];
|
||||
if (identifier && quotedEvents.current.has(`${event.pubkey}:${identifier}`)) {
|
||||
// This addressable event is quoted, so we'll skip it
|
||||
console.log(`Addressable event ${event.id} filtered out: quoted as ${event.pubkey}:${identifier}`);
|
||||
return;
|
||||
// This addressable event is quoted, so we'll skip it
|
||||
eventProcessingLogger.debug(`Addressable event ${event.id} filtered out: quoted as ${event.pubkey}:${identifier}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add to feed items if we were able to parse it
|
||||
if (feedItem) {
|
||||
console.log(`Adding event ${event.id} to feed as ${feedItem.type}`);
|
||||
eventProcessingLogger.verbose(`Adding event ${event.id} to feed as ${feedItem.type}`);
|
||||
setFeedItems(current => {
|
||||
const newItems = [...current, feedItem as FeedItem];
|
||||
// Sort by created_at (most recent first)
|
||||
@ -315,7 +316,7 @@ export function useSocialFeed(
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing event:', error);
|
||||
eventProcessingLogger.error('Error processing event:', error);
|
||||
}
|
||||
}, [oldestTimestamp, options.feedType]);
|
||||
|
||||
@ -380,7 +381,7 @@ export function useSocialFeed(
|
||||
|
||||
// Prevent rapid resubscriptions unless forceRefresh is true
|
||||
if (subscriptionCooldown.current && !forceRefresh) {
|
||||
console.log('[useSocialFeed] Subscription on cooldown, skipping (use forceRefresh to override)');
|
||||
socialFeedLogger.debug('Subscription on cooldown, skipping (use forceRefresh to override)');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -388,11 +389,11 @@ export function useSocialFeed(
|
||||
// Reset counter if this is a forced refresh
|
||||
if (forceRefresh) {
|
||||
subscriptionAttempts.current = 0;
|
||||
console.log('[useSocialFeed] Force refresh requested, resetting attempt counter');
|
||||
socialFeedLogger.debug('Force refresh requested, resetting attempt counter');
|
||||
} else {
|
||||
subscriptionAttempts.current += 1;
|
||||
if (subscriptionAttempts.current > maxSubscriptionAttempts) {
|
||||
console.error(`[useSocialFeed] Too many subscription attempts (${subscriptionAttempts.current}), giving up`);
|
||||
socialFeedLogger.error(`Too many subscription attempts (${subscriptionAttempts.current}), giving up`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@ -403,16 +404,16 @@ export function useSocialFeed(
|
||||
// Initialize social service if not already done
|
||||
if (!socialServiceRef.current) {
|
||||
try {
|
||||
console.log('[useSocialFeed] Initializing SocialFeedService in loadFeed');
|
||||
socialFeedLogger.info('Initializing SocialFeedService in loadFeed');
|
||||
socialServiceRef.current = new SocialFeedService(ndk, db);
|
||||
console.log('[useSocialFeed] SocialFeedService initialized successfully in loadFeed');
|
||||
socialFeedLogger.info('SocialFeedService initialized successfully in loadFeed');
|
||||
} catch (error) {
|
||||
console.error('[useSocialFeed] Error initializing SocialFeedService in loadFeed:', error);
|
||||
socialFeedLogger.error('Error initializing SocialFeedService in loadFeed:', error);
|
||||
// Log more detailed error information
|
||||
if (error instanceof Error) {
|
||||
console.error(`[useSocialFeed] Error details: ${error.message}`);
|
||||
socialFeedLogger.error(`Error details: ${error.message}`);
|
||||
if (error.stack) {
|
||||
console.error(`[useSocialFeed] Stack trace: ${error.stack}`);
|
||||
socialFeedLogger.error(`Stack trace: ${error.stack}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -423,7 +424,7 @@ export function useSocialFeed(
|
||||
|
||||
// Clean up any existing subscription
|
||||
if (subscriptionRef.current) {
|
||||
console.log(`[useSocialFeed] Cleaning up existing subscription for ${feedOptions.feedType} feed`);
|
||||
socialFeedLogger.debug(`Cleaning up existing subscription for ${feedOptions.feedType} feed`);
|
||||
subscriptionRef.current.unsubscribe();
|
||||
subscriptionRef.current = null;
|
||||
}
|
||||
@ -437,19 +438,19 @@ export function useSocialFeed(
|
||||
}, 5000); // Increased cooldown period
|
||||
|
||||
try {
|
||||
console.log(`[useSocialFeed] Loading ${feedOptions.feedType} feed with authors:`, feedOptions.authors);
|
||||
console.log(`[useSocialFeed] Time range: since=${new Date(feedOptions.since * 1000).toISOString()}, until=${feedOptions.until ? new Date(feedOptions.until * 1000).toISOString() : 'now'}`);
|
||||
socialFeedLogger.info(`Loading ${feedOptions.feedType} feed with authors:`, feedOptions.authors);
|
||||
socialFeedLogger.debug(`Time range: since=${new Date(feedOptions.since * 1000).toISOString()}, until=${feedOptions.until ? new Date(feedOptions.until * 1000).toISOString() : 'now'}`);
|
||||
|
||||
// For following feed, log if we have no authors but continue with subscription
|
||||
// The socialFeedService will use the POWR_PUBKEY_HEX as fallback
|
||||
if (feedOptions.feedType === 'following' && (!feedOptions.authors || feedOptions.authors.length === 0)) {
|
||||
console.log('[useSocialFeed] Following feed with no authors, continuing with fallback');
|
||||
socialFeedLogger.info('Following feed with no authors, continuing with fallback');
|
||||
// We'll continue with the subscription and rely on the fallback in socialFeedService
|
||||
}
|
||||
|
||||
// Build and validate filters before subscribing
|
||||
if (!socialServiceRef.current) {
|
||||
console.error('[useSocialFeed] Social service not initialized');
|
||||
socialFeedLogger.error('Social service not initialized');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@ -464,12 +465,12 @@ export function useSocialFeed(
|
||||
});
|
||||
|
||||
if (!filters || Object.keys(filters).length === 0) {
|
||||
console.log('[useSocialFeed] No valid filters to subscribe with, skipping');
|
||||
socialFeedLogger.warn('No valid filters to subscribe with, skipping');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[useSocialFeed] Subscribing with filters:`, JSON.stringify(filters));
|
||||
socialFeedLogger.debug(`Subscribing with filters:`, JSON.stringify(filters));
|
||||
|
||||
// Subscribe to feed
|
||||
const subscription = await socialServiceRef.current.subscribeFeed({
|
||||
@ -488,11 +489,11 @@ export function useSocialFeed(
|
||||
if (subscription) {
|
||||
subscriptionRef.current = subscription;
|
||||
} else {
|
||||
console.error('[useSocialFeed] Failed to create subscription');
|
||||
socialFeedLogger.error('Failed to create subscription');
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[useSocialFeed] Error loading feed:', error);
|
||||
socialFeedLogger.error('Error loading feed:', error);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [ndk, db, feedOptions, processEvent]);
|
||||
@ -506,16 +507,16 @@ export function useSocialFeed(
|
||||
// Initialize social service if not already done
|
||||
if (!socialServiceRef.current) {
|
||||
try {
|
||||
console.log('[useSocialFeed] Initializing SocialFeedService in loadCachedFeed');
|
||||
socialFeedLogger.info('Initializing SocialFeedService in loadCachedFeed');
|
||||
socialServiceRef.current = new SocialFeedService(ndk, db);
|
||||
console.log('[useSocialFeed] SocialFeedService initialized successfully in loadCachedFeed');
|
||||
socialFeedLogger.info('SocialFeedService initialized successfully in loadCachedFeed');
|
||||
} catch (error) {
|
||||
console.error('[useSocialFeed] Error initializing SocialFeedService in loadCachedFeed:', error);
|
||||
socialFeedLogger.error('Error initializing SocialFeedService in loadCachedFeed:', error);
|
||||
// Log more detailed error information
|
||||
if (error instanceof Error) {
|
||||
console.error(`[useSocialFeed] Error details: ${error.message}`);
|
||||
socialFeedLogger.error(`Error details: ${error.message}`);
|
||||
if (error.stack) {
|
||||
console.error(`[useSocialFeed] Stack trace: ${error.stack}`);
|
||||
socialFeedLogger.error(`Stack trace: ${error.stack}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -540,12 +541,12 @@ export function useSocialFeed(
|
||||
processEvent(event);
|
||||
}
|
||||
} catch (cacheError) {
|
||||
console.error('Error retrieving cached events:', cacheError);
|
||||
socialFeedLogger.error('Error retrieving cached events:', cacheError);
|
||||
// Continue even if cache retrieval fails - we'll try to fetch from network
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading cached feed:', error);
|
||||
socialFeedLogger.error('Error loading cached feed:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -553,7 +554,7 @@ export function useSocialFeed(
|
||||
|
||||
// Refresh feed (clear events and reload)
|
||||
const refresh = useCallback(async (forceRefresh = true) => {
|
||||
console.log(`Refreshing ${options.feedType} feed (force=${forceRefresh})`);
|
||||
socialFeedLogger.info(`Refreshing ${options.feedType} feed (force=${forceRefresh})`);
|
||||
setFeedItems([]);
|
||||
seenEvents.current.clear();
|
||||
quotedEvents.current.clear(); // Also reset quoted events
|
||||
|
90
lib/queryClient.ts
Normal file
90
lib/queryClient.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
/**
|
||||
* Creates and configures a React Query client with optimal settings.
|
||||
*
|
||||
* @returns Configured QueryClient instance
|
||||
*/
|
||||
export function createQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Cache time = 1 hour (data kept in memory)
|
||||
gcTime: 60 * 60 * 1000,
|
||||
|
||||
// Stale time = 30 seconds (before background refetch)
|
||||
staleTime: 30 * 1000,
|
||||
|
||||
// Retry failed queries 3 times with exponential backoff
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex) => Math.min(
|
||||
1000 * 2 ** attemptIndex,
|
||||
30000
|
||||
),
|
||||
|
||||
// Don't refetch on window focus in app (native behavior)
|
||||
refetchOnWindowFocus: Platform.OS === 'web',
|
||||
|
||||
// Refetch when gaining connectivity
|
||||
refetchOnReconnect: true,
|
||||
|
||||
// Disable automatic refetching when a query mount is happening
|
||||
// This prevents excessive data fetching during navigation
|
||||
refetchOnMount: false,
|
||||
},
|
||||
mutations: {
|
||||
// Retry failed mutations 2 times
|
||||
retry: 2,
|
||||
retryDelay: (attemptIndex) => Math.min(
|
||||
1000 * 2 ** attemptIndex,
|
||||
10000
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to update an item in a list by ID
|
||||
* Utility for optimistic updates in React Query
|
||||
*/
|
||||
export function updateItemInList<T extends { id: string }>(
|
||||
oldData: T[] | undefined,
|
||||
updatedItem: T
|
||||
): T[] {
|
||||
if (!oldData) return [updatedItem];
|
||||
|
||||
return oldData.map(item =>
|
||||
item.id === updatedItem.id ? updatedItem : item
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to remove an item from a list by ID
|
||||
* Utility for optimistic updates in React Query
|
||||
*/
|
||||
export function removeItemFromList<T extends { id: string }>(
|
||||
oldData: T[] | undefined,
|
||||
idToRemove: string
|
||||
): T[] {
|
||||
if (!oldData) return [];
|
||||
|
||||
return oldData.filter(item => item.id !== idToRemove);
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to add an item to a list
|
||||
* Utility for optimistic updates in React Query
|
||||
*/
|
||||
export function addItemToList<T>(
|
||||
oldData: T[] | undefined,
|
||||
newItem: T,
|
||||
prepend = true
|
||||
): T[] {
|
||||
if (!oldData) return [newItem];
|
||||
|
||||
return prepend
|
||||
? [newItem, ...oldData]
|
||||
: [...oldData, newItem];
|
||||
}
|
86
lib/queryKeys.ts
Normal file
86
lib/queryKeys.ts
Normal file
@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Query keys for React Query
|
||||
*
|
||||
* This file defines constants used as queryKeys to identify different queries.
|
||||
* Using a structured approach to queryKeys makes it easier to:
|
||||
* 1. Understand the purpose of each query
|
||||
* 2. Invalidate related queries
|
||||
* 3. Ensure consistent naming throughout the app
|
||||
*/
|
||||
|
||||
export const QUERY_KEYS = {
|
||||
// Auth related queries
|
||||
auth: {
|
||||
all: ['auth'] as const,
|
||||
current: () => [...QUERY_KEYS.auth.all, 'current'] as const,
|
||||
profile: (pubkey: string) => [...QUERY_KEYS.auth.all, 'profile', pubkey] as const,
|
||||
},
|
||||
|
||||
// Relay related queries
|
||||
relays: {
|
||||
all: ['relays'] as const,
|
||||
status: () => [...QUERY_KEYS.relays.all, 'status'] as const,
|
||||
list: () => [...QUERY_KEYS.relays.all, 'list'] as const,
|
||||
},
|
||||
|
||||
// System related queries
|
||||
system: {
|
||||
all: ['system'] as const,
|
||||
connectivity: () => [...QUERY_KEYS.system.all, 'connectivity'] as const,
|
||||
},
|
||||
|
||||
// Workouts related queries
|
||||
workouts: {
|
||||
all: ['workouts'] as const,
|
||||
detail: (id: string) => [...QUERY_KEYS.workouts.all, 'detail', id] as const,
|
||||
list: (filters?: any) => [...QUERY_KEYS.workouts.all, 'list', filters] as const,
|
||||
history: (pubkey: string) => [...QUERY_KEYS.workouts.all, 'history', pubkey] as const,
|
||||
},
|
||||
|
||||
// Templates related queries
|
||||
templates: {
|
||||
all: ['templates'] as const,
|
||||
detail: (id: string) => [...QUERY_KEYS.templates.all, 'detail', id] as const,
|
||||
list: (filters?: any) => [...QUERY_KEYS.templates.all, 'list', filters] as const,
|
||||
favorites: () => [...QUERY_KEYS.templates.all, 'favorites'] as const,
|
||||
},
|
||||
|
||||
// Exercises related queries
|
||||
exercises: {
|
||||
all: ['exercises'] as const,
|
||||
detail: (id: string) => [...QUERY_KEYS.exercises.all, 'detail', id] as const,
|
||||
list: (filters?: any) => [...QUERY_KEYS.exercises.all, 'list', filters] as const,
|
||||
},
|
||||
|
||||
// Social feed related queries
|
||||
feed: {
|
||||
all: ['feed'] as const,
|
||||
global: (filters?: any) => [...QUERY_KEYS.feed.all, 'global', filters] as const,
|
||||
following: (pubkey: string, filters?: any) => [...QUERY_KEYS.feed.all, 'following', pubkey, filters] as const,
|
||||
user: (pubkey: string, filters?: any) => [...QUERY_KEYS.feed.all, 'user', pubkey, filters] as const,
|
||||
thread: (id: string) => [...QUERY_KEYS.feed.all, 'thread', id] as const,
|
||||
},
|
||||
|
||||
// Contact list related queries
|
||||
contacts: {
|
||||
all: ['contacts'] as const,
|
||||
list: (pubkey: string) => [...QUERY_KEYS.contacts.all, 'list', pubkey] as const,
|
||||
followers: (pubkey: string) => [...QUERY_KEYS.contacts.all, 'followers', pubkey] as const,
|
||||
following: (pubkey: string) => [...QUERY_KEYS.contacts.all, 'following', pubkey] as const,
|
||||
},
|
||||
|
||||
// Profile related queries
|
||||
profile: {
|
||||
all: ['profile'] as const,
|
||||
stats: (pubkey?: string) => [...QUERY_KEYS.profile.all, 'stats', pubkey] as const,
|
||||
bannerImage: (pubkey?: string) => [...QUERY_KEYS.profile.all, 'bannerImage', pubkey] as const,
|
||||
profileImage: (pubkey?: string) => [...QUERY_KEYS.profile.all, 'profileImage', pubkey] as const,
|
||||
},
|
||||
|
||||
// POWR Packs related queries
|
||||
powrPacks: {
|
||||
all: ['powrPacks'] as const,
|
||||
list: () => [...QUERY_KEYS.powrPacks.all, 'list'] as const,
|
||||
detail: (id: string) => [...QUERY_KEYS.powrPacks.all, 'detail', id] as const,
|
||||
},
|
||||
};
|
@ -1,5 +1,12 @@
|
||||
// lib/services/NostrBandService.ts
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { createLogger, enableModule } from '@/lib/utils/logger';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
// Enable logging
|
||||
enableModule('NostrBandService');
|
||||
const logger = createLogger('NostrBandService');
|
||||
const platform = Platform.OS === 'ios' ? 'iOS' : 'Android';
|
||||
|
||||
export interface ProfileStats {
|
||||
pubkey: string;
|
||||
@ -26,14 +33,19 @@ interface NostrBandProfileStatsResponse {
|
||||
*/
|
||||
export class NostrBandService {
|
||||
private readonly apiUrl = 'https://api.nostr.band';
|
||||
private cacheDisabled = true; // Disable any internal caching
|
||||
|
||||
/**
|
||||
* Fetches profile statistics from nostr.band API
|
||||
* @param pubkey Pubkey in hex format or npub format
|
||||
* @param forceFresh Whether to bypass any caching with a cache-busting parameter
|
||||
* @returns Promise with profile stats
|
||||
*/
|
||||
async fetchProfileStats(pubkey: string): Promise<ProfileStats> {
|
||||
async fetchProfileStats(pubkey: string, forceFresh: boolean = true): Promise<ProfileStats> {
|
||||
try {
|
||||
// Always log request details
|
||||
logger.info(`[${platform}] Fetching profile stats for pubkey: ${pubkey.substring(0, 8)}...`);
|
||||
|
||||
// Check if pubkey is npub or hex and convert if needed
|
||||
let hexPubkey = pubkey;
|
||||
if (pubkey.startsWith('npub')) {
|
||||
@ -43,48 +55,68 @@ export class NostrBandService {
|
||||
hexPubkey = decoded.data as string;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error decoding npub:', error);
|
||||
logger.error(`[${platform}] Error decoding npub:`, error);
|
||||
throw new Error('Invalid npub format');
|
||||
}
|
||||
}
|
||||
|
||||
// Build URL
|
||||
// Always force cache busting
|
||||
const cacheBuster = `?_t=${Date.now()}`;
|
||||
const endpoint = `/v0/stats/profile/${hexPubkey}`;
|
||||
const url = `${this.apiUrl}${endpoint}`;
|
||||
const url = `${this.apiUrl}${endpoint}${cacheBuster}`;
|
||||
|
||||
// Fetch data
|
||||
const response = await fetch(url);
|
||||
logger.info(`[${platform}] Fetching from: ${url}`);
|
||||
|
||||
// Fetch with explicit no-cache headers
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error(`[${platform}] API error: ${response.status} - ${errorText}`);
|
||||
throw new Error(`API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as NostrBandProfileStatsResponse;
|
||||
|
||||
// Check if we got valid data
|
||||
if (!data || !data.stats || !data.stats[hexPubkey]) {
|
||||
logger.error(`[${platform}] Invalid response from API:`, JSON.stringify(data));
|
||||
throw new Error('Invalid response from API');
|
||||
}
|
||||
|
||||
// Extract relevant stats
|
||||
const profileStats = data.stats[hexPubkey];
|
||||
|
||||
if (!profileStats) {
|
||||
throw new Error('Profile stats not found in response');
|
||||
}
|
||||
|
||||
return {
|
||||
// Create result with real data - ensure we have non-null values
|
||||
const result = {
|
||||
pubkey: hexPubkey,
|
||||
followersCount: profileStats.followers_pubkey_count ?? 0,
|
||||
followingCount: profileStats.pub_following_pubkey_count ?? 0,
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
// Log the fetched stats
|
||||
logger.info(`[${platform}] Fetched stats for ${hexPubkey.substring(0, 8)}:`, {
|
||||
followersCount: result.followersCount,
|
||||
followingCount: result.followingCount
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error fetching profile stats:', error);
|
||||
return {
|
||||
pubkey,
|
||||
followersCount: 0,
|
||||
followingCount: 0,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error : new Error('Unknown error')
|
||||
};
|
||||
// Log the error with platform info
|
||||
logger.error(`[${platform}] Error fetching profile stats:`, error);
|
||||
|
||||
// Always throw to allow the query to properly retry
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,10 @@ import { SQLiteDatabase } from 'expo-sqlite';
|
||||
import { getSocialFeedCache } from '@/lib/db/services/SocialFeedCache';
|
||||
import { ConnectivityService } from '@/lib/db/services/ConnectivityService';
|
||||
import { POWR_PUBKEY_HEX } from '@/lib/hooks/useFeedHooks';
|
||||
import { createLogger } from '@/lib/utils/logger';
|
||||
|
||||
// Create service-specific logger
|
||||
const logger = createLogger('SocialFeedService');
|
||||
|
||||
export class SocialFeedService {
|
||||
private ndk: NDK;
|
||||
@ -22,7 +26,7 @@ export class SocialFeedService {
|
||||
this.socialFeedCache = getSocialFeedCache(db);
|
||||
this.socialFeedCache.setNDK(ndk);
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedService] Error initializing SocialFeedCache:', error);
|
||||
logger.error('Error initializing SocialFeedCache:', error);
|
||||
// Continue without cache - we'll still be able to fetch from network
|
||||
this.socialFeedCache = null;
|
||||
}
|
||||
@ -50,7 +54,7 @@ export class SocialFeedService {
|
||||
try {
|
||||
return await this.socialFeedCache.getCachedEvents(feedType, limit, since, until);
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedService] Error retrieving cached events:', error);
|
||||
logger.error('Error retrieving cached events:', error);
|
||||
// Return empty array on error
|
||||
return [];
|
||||
}
|
||||
@ -116,7 +120,7 @@ export class SocialFeedService {
|
||||
if (feedType === 'profile') {
|
||||
// Profile feed: Show all of a user's posts
|
||||
if (!Array.isArray(authors) || authors.length === 0) {
|
||||
console.error('[SocialFeedService] Profile feed requires authors');
|
||||
logger.error('Profile feed requires authors');
|
||||
return { ...baseFilter, kinds: [] }; // Return empty filter if no authors
|
||||
}
|
||||
|
||||
@ -140,11 +144,11 @@ export class SocialFeedService {
|
||||
} else if (feedType === 'powr') {
|
||||
// POWR feed: Show all content from POWR account(s)
|
||||
if (!Array.isArray(authors) || authors.length === 0) {
|
||||
console.error('[SocialFeedService] POWR feed requires authors');
|
||||
logger.error('POWR feed requires authors');
|
||||
|
||||
// For POWR feed, if no authors provided, use the POWR_PUBKEY_HEX as fallback
|
||||
if (POWR_PUBKEY_HEX) {
|
||||
console.log('[SocialFeedService] Using POWR account as fallback for POWR feed');
|
||||
logger.info('Using POWR account as fallback for POWR feed');
|
||||
const fallbackAuthors = [POWR_PUBKEY_HEX];
|
||||
return {
|
||||
...baseFilter,
|
||||
@ -166,12 +170,12 @@ export class SocialFeedService {
|
||||
// Following feed: Show content from followed users
|
||||
if (!Array.isArray(authors) || authors.length === 0) {
|
||||
// Initial load often has no contacts yet - this is normal
|
||||
console.log('[SocialFeedService] No contacts available for following feed yet, using fallback');
|
||||
logger.info('No contacts available for following feed yet, using fallback');
|
||||
|
||||
// For following feed, if no authors provided, use the POWR_PUBKEY_HEX as fallback
|
||||
// This ensures at least some content is shown
|
||||
if (POWR_PUBKEY_HEX) {
|
||||
console.log('[SocialFeedService] Using POWR account as fallback for initial Following feed load');
|
||||
logger.info('Using POWR account as fallback for initial Following feed load');
|
||||
const fallbackAuthors = [POWR_PUBKEY_HEX];
|
||||
|
||||
return [
|
||||
@ -199,14 +203,14 @@ export class SocialFeedService {
|
||||
// 2. Social posts and articles from followed users with fitness tags
|
||||
|
||||
// Log the authors to help with debugging
|
||||
console.log(`[SocialFeedService] Following feed with ${authors.length} authors:`,
|
||||
logger.debug(`Following feed with ${authors.length} authors:`,
|
||||
authors.length > 5 ? authors.slice(0, 5).join(', ') + '...' : authors.join(', '));
|
||||
|
||||
// Always include POWR account in following feed
|
||||
let followingAuthors = [...authors];
|
||||
if (POWR_PUBKEY_HEX && !followingAuthors.includes(POWR_PUBKEY_HEX)) {
|
||||
followingAuthors.push(POWR_PUBKEY_HEX);
|
||||
console.log('[SocialFeedService] Added POWR account to following feed authors');
|
||||
logger.debug('Added POWR account to following feed authors');
|
||||
}
|
||||
|
||||
return [
|
||||
@ -244,7 +248,7 @@ export class SocialFeedService {
|
||||
];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedService] Error building filters:', error);
|
||||
logger.error('Error building filters:', error);
|
||||
// Return a safe default filter that won't crash but also won't return much
|
||||
return {
|
||||
kinds: [1], // Just social posts
|
||||
@ -275,7 +279,7 @@ export class SocialFeedService {
|
||||
const consolidatedFilter = this.buildFilters(options);
|
||||
|
||||
// Log the consolidated filter
|
||||
console.log(`[SocialFeedService] Subscribing to ${feedType} feed with filter:`, consolidatedFilter);
|
||||
logger.debug(`Subscribing to ${feedType} feed with filter:`, consolidatedFilter);
|
||||
|
||||
// Create a single subscription with the consolidated filter
|
||||
const subscription = this.ndk.subscribe(consolidatedFilter, {
|
||||
@ -288,9 +292,9 @@ export class SocialFeedService {
|
||||
if (this.socialFeedCache) {
|
||||
try {
|
||||
this.socialFeedCache.cacheEvent(event, feedType)
|
||||
.catch(err => console.error('[SocialFeedService] Error caching event:', err));
|
||||
.catch(err => logger.error('Error caching event:', err));
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedService] Exception while caching event:', error);
|
||||
logger.error('Exception while caching event:', error);
|
||||
// Continue even if caching fails - we'll still pass the event to the callback
|
||||
}
|
||||
}
|
||||
@ -301,14 +305,14 @@ export class SocialFeedService {
|
||||
|
||||
// Set up EOSE handler
|
||||
subscription.on('eose', () => {
|
||||
console.log(`[SocialFeedService] Received EOSE for ${feedType} feed`);
|
||||
logger.debug(`Received EOSE for ${feedType} feed`);
|
||||
if (onEose) onEose();
|
||||
});
|
||||
|
||||
// Return a Promise with the unsubscribe object
|
||||
return Promise.resolve({
|
||||
unsubscribe: () => {
|
||||
console.log(`[SocialFeedService] Unsubscribing from ${feedType} feed`);
|
||||
logger.debug(`Unsubscribing from ${feedType} feed`);
|
||||
subscription.stop();
|
||||
}
|
||||
});
|
||||
@ -400,7 +404,7 @@ export class SocialFeedService {
|
||||
return cachedEvent;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedService] Error retrieving cached event:', error);
|
||||
logger.error('Error retrieving cached event:', error);
|
||||
// Continue to network fetch if cache fails
|
||||
}
|
||||
}
|
||||
@ -429,7 +433,7 @@ export class SocialFeedService {
|
||||
try {
|
||||
await this.socialFeedCache.cacheEvent(event, 'referenced');
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedService] Error caching referenced event:', error);
|
||||
logger.error('Error caching referenced event:', error);
|
||||
// Continue even if caching fails - we can still return the event
|
||||
}
|
||||
}
|
||||
@ -458,7 +462,7 @@ export class SocialFeedService {
|
||||
try {
|
||||
await this.socialFeedCache.cacheEvent(event, 'referenced');
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedService] Error caching referenced event:', error);
|
||||
logger.error('Error caching referenced event:', error);
|
||||
// Continue even if caching fails - we can still return the event
|
||||
}
|
||||
}
|
||||
@ -529,17 +533,17 @@ export class SocialFeedService {
|
||||
if (isOnline) {
|
||||
await event.publish();
|
||||
} else {
|
||||
console.log('[SocialFeedService] Offline, event will be published when online');
|
||||
logger.info('Offline, event will be published when online');
|
||||
}
|
||||
|
||||
// Cache the event if we have a cache
|
||||
if (this.socialFeedCache) {
|
||||
try {
|
||||
await this.socialFeedCache.cacheEvent(event, 'workout');
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedService] Error caching workout event:', error);
|
||||
// Continue even if caching fails - the event was still published
|
||||
}
|
||||
await this.socialFeedCache.cacheEvent(event, 'workout');
|
||||
} catch (error) {
|
||||
logger.error('Error caching workout event:', error);
|
||||
// Continue even if caching fails - the event was still published
|
||||
}
|
||||
}
|
||||
|
||||
// Create social share if requested
|
||||
|
181
lib/utils/logger.ts
Normal file
181
lib/utils/logger.ts
Normal file
@ -0,0 +1,181 @@
|
||||
// lib/utils/logger.ts
|
||||
/**
|
||||
* Logging levels to control verbosity
|
||||
*/
|
||||
export enum LogLevel {
|
||||
ERROR = 0, // Only errors
|
||||
WARN = 1, // Errors and warnings
|
||||
INFO = 2, // Normal operational information
|
||||
DEBUG = 3, // Detailed information for debugging
|
||||
VERBOSE = 4 // Very detailed information
|
||||
}
|
||||
|
||||
interface LogConfig {
|
||||
level: LogLevel;
|
||||
enabledModules: string[];
|
||||
disabledModules: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Global logging configuration
|
||||
*/
|
||||
const config: LogConfig = {
|
||||
level: __DEV__ ? LogLevel.WARN : LogLevel.WARN, // Using WARN even in dev mode to reduce logs
|
||||
enabledModules: [], // If empty, all non-disabled modules are enabled
|
||||
disabledModules: [
|
||||
// Social feed modules
|
||||
'SocialFeed.EventProcessing',
|
||||
'SocialFeedCache',
|
||||
'SocialFeed',
|
||||
'useFeedHooks',
|
||||
|
||||
// Database modules
|
||||
'Database',
|
||||
'DB',
|
||||
'Schema',
|
||||
'SQLite',
|
||||
|
||||
// Network & connection modules
|
||||
'NDK',
|
||||
'ContactCacheService',
|
||||
'RelayService',
|
||||
'ReactQueryAuthProvider',
|
||||
'RelayStore',
|
||||
'RelayInitializer'
|
||||
] // Modules to disable by default
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current logging level
|
||||
*/
|
||||
export function getLogLevel(): LogLevel {
|
||||
return config.level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the global logging level
|
||||
*/
|
||||
export function setLogLevel(level: LogLevel): void {
|
||||
config.level = level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable logging for specific module
|
||||
*/
|
||||
export function enableModule(moduleName: string): void {
|
||||
// Remove from disabled modules if present
|
||||
const index = config.disabledModules.indexOf(moduleName);
|
||||
if (index !== -1) {
|
||||
config.disabledModules.splice(index, 1);
|
||||
}
|
||||
|
||||
// Add to enabled modules if not already there
|
||||
if (!config.enabledModules.includes(moduleName)) {
|
||||
config.enabledModules.push(moduleName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable logging for specific module
|
||||
*/
|
||||
export function disableModule(moduleName: string): void {
|
||||
// Remove from enabled modules if present
|
||||
const index = config.enabledModules.indexOf(moduleName);
|
||||
if (index !== -1) {
|
||||
config.enabledModules.splice(index, 1);
|
||||
}
|
||||
|
||||
// Add to disabled modules if not already there
|
||||
if (!config.disabledModules.includes(moduleName)) {
|
||||
config.disabledModules.push(moduleName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if logging is enabled for the given module and level
|
||||
*/
|
||||
function isLoggingEnabled(moduleName: string, level: LogLevel): boolean {
|
||||
// Check if level is enabled
|
||||
if (level > config.level) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if module is explicitly disabled
|
||||
if (config.disabledModules.includes(moduleName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we have a specific enabled list
|
||||
if (config.enabledModules.length > 0) {
|
||||
return config.enabledModules.includes(moduleName);
|
||||
}
|
||||
|
||||
// If we get here, the module is not disabled and there's no specific enabled list
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a logger for a specific module
|
||||
*/
|
||||
export function createLogger(moduleName: string) {
|
||||
return {
|
||||
error: (message: string, ...args: any[]) => {
|
||||
if (isLoggingEnabled(moduleName, LogLevel.ERROR)) {
|
||||
console.error(`[${moduleName}] ${message}`, ...args);
|
||||
}
|
||||
},
|
||||
|
||||
warn: (message: string, ...args: any[]) => {
|
||||
if (isLoggingEnabled(moduleName, LogLevel.WARN)) {
|
||||
console.warn(`[${moduleName}] ${message}`, ...args);
|
||||
}
|
||||
},
|
||||
|
||||
info: (message: string, ...args: any[]) => {
|
||||
if (isLoggingEnabled(moduleName, LogLevel.INFO)) {
|
||||
console.log(`[${moduleName}] ${message}`, ...args);
|
||||
}
|
||||
},
|
||||
|
||||
debug: (message: string, ...args: any[]) => {
|
||||
if (isLoggingEnabled(moduleName, LogLevel.DEBUG)) {
|
||||
console.log(`[${moduleName}] ${message}`, ...args);
|
||||
}
|
||||
},
|
||||
|
||||
verbose: (message: string, ...args: any[]) => {
|
||||
if (isLoggingEnabled(moduleName, LogLevel.VERBOSE)) {
|
||||
console.log(`[${moduleName}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable only error logs to reduce noise during troubleshooting
|
||||
* @param enable Whether to enable quiet mode (only errors)
|
||||
*/
|
||||
export function setQuietMode(enable: boolean): void {
|
||||
if (enable) {
|
||||
// Save current log level before changing it
|
||||
const savedLevel = config.level;
|
||||
// Only show errors
|
||||
config.level = LogLevel.ERROR;
|
||||
console.log('🔇 Logger quiet mode enabled - only showing errors');
|
||||
return;
|
||||
} else {
|
||||
// Restore to INFO logs
|
||||
config.level = LogLevel.INFO;
|
||||
console.log('🔊 Logger quiet mode disabled - showing normal logs');
|
||||
}
|
||||
}
|
||||
|
||||
// Create a default logger
|
||||
export const logger = createLogger('App');
|
||||
|
||||
// Add some common module loggers
|
||||
export const socialFeedLogger = createLogger('SocialFeed');
|
||||
export const eventProcessingLogger = createLogger('SocialFeed.EventProcessing');
|
||||
export const databaseLogger = createLogger('Database');
|
||||
export const networkLogger = createLogger('Network');
|
||||
export const authLogger = createLogger('Auth');
|
27
package-lock.json
generated
27
package-lock.json
generated
@ -47,6 +47,7 @@
|
||||
"@rn-primitives/toggle-group": "^1.1.0",
|
||||
"@rn-primitives/tooltip": "~1.1.0",
|
||||
"@rn-primitives/types": "~1.1.0",
|
||||
"@tanstack/react-query": "^5.71.5",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
@ -8988,6 +8989,32 @@
|
||||
"@sinonjs/commons": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.71.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.71.5.tgz",
|
||||
"integrity": "sha512-XOQ5SyjCdwhxyLksGKWSL5poqyEXYPDnsrZAzJm2LgrMm4Yh6VOrfC+IFosXreDw9HNqC11YAMY3HlfHjNzuaA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.71.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.71.5.tgz",
|
||||
"integrity": "sha512-WpxZWy4fDASjY+iAaXB+aY+LC95PQ34W6EWVkjJ0hdzWWbczFnr9nHvHkVDpwdR18I1NO8igNGQJFrLrgyzI8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.71.5"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tootallnate/once": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
|
||||
|
@ -61,6 +61,7 @@
|
||||
"@rn-primitives/toggle-group": "^1.1.0",
|
||||
"@rn-primitives/tooltip": "~1.1.0",
|
||||
"@rn-primitives/types": "~1.1.0",
|
||||
"@tanstack/react-query": "^5.71.5",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
|
Loading…
x
Reference in New Issue
Block a user