diff --git a/CHANGELOG.md b/CHANGELOG.md index e54c550..074b85b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved user experience during temporary API failures ### Improved +- Profile loading performance dramatically enhanced + - Added ultra-early content display after just 500ms + - Implemented progressive content loading with three-tier system + - Reduced timeouts from 5s to 4s on Android and from 4s to 3s on iOS + - Added aggressive content rendering that prioritizes partial data + - Enhanced render state logic to show any available content immediately + - Improved parallel data loading for all profile elements + - Added multiple fallback timers to ensure content is always shown + - Enhanced safety protocol for recovering from long-loading states + +- Profile overview screen architecture + - Completely refactored to use component extraction pattern + - Created separate presentational components (ProfileHeader, ProfileFeed) + - Implemented centralized data hook (useProfilePageData) to fix hook ordering issues + - Added consistent hook ordering regardless of authentication state + - Implemented platform-specific timeout handling (6s for iOS, 8s for Android) + - Enhanced error recovery with automatic retry system + - Added proper TypeScript typing across all components + - Improved banner image and profile stats loading with better error handling + - Console logging system - Implemented configurable module-level logging controls - Added quiet mode toggle for easier troubleshooting @@ -28,6 +48,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Created comprehensive logging documentation ### Fixed +- Private key authentication persistence + - Fixed inconsistent storage key naming between legacy and React Query auth systems + - Standardized on 'nostr_privkey' for all private key storage + - Added comprehensive logging to debug authentication initialization + - Improved key retrieval in AuthService with legacy key detection + - Enhanced error handling in the authentication restoration process + - Implemented proper cross-checking between storage systems + - Added validation and normalization for securely stored private keys + +- Profile overview screen crashes + - Fixed "Rendered more hooks than during the previous render" error + - Eliminated "Rendered fewer hooks than expected" errors during login/logout + - Fixed hook ordering issues with consistent hook patterns + - Resolved banner image loading failures + - Added proper type safety for React Query results + - Fixed null/undefined handling in image URLs and stats data + - Enhanced component safety with consistent rendering patterns + +- Authentication persistence issues + - Fixed private key authentication not persisting across app restarts + - Enhanced credential storage with more reliable SecureStore integration + - Implemented robust auth state restoration during app initialization + - Added better error handling with credential cleanup on failed restoration + - Created constants for SecureStore keys to ensure consistency + - Enhanced AuthService with improved promise handling for multiple calls + - Fixed NDK initialization to properly restore authentication state + - Added private key normalization to handle platform-specific formatting differences + - Added improved key validation with detailed platform-specific logging + - Added public key caching for faster reference + - Improved ReactQueryAuthProvider with better initialization sequence + - Enhanced error handling throughout authentication flow + - Added comprehensive logging for better debugging + - Fixed race conditions in authentication state transitions + - Implemented initialization tracking to prevent duplicate auth operations + - Android profile screen hanging issues - Fixed infinite loading state on profile screen with proper timeouts - Enhanced NostrBandService with AbortController and abort signal support @@ -690,117 +745,4 @@ g - Overall data persistence architecture - Consistent service-based approach - Improved type safety - - Enhanced error propagation - -# Changelog - March 6, 2025 - -## Added -- Comprehensive workout completion flow - - Implemented three-tier storage approach (Local Only, Publish Complete, Publish Limited) - - Added support for template modifications with options to keep original, update, or save as new - - Created celebration screen with confetti animation - - Integrated social sharing capabilities for Nostr - - Built detailed workout summary with achievement tracking - - Added workout statistics including duration, volume, and set completion - - Implemented privacy-focused publishing options - - Added template attribution and modification tracking -- NDK mobile integration for Nostr functionality - - Added event publishing and subscription capabilities - - Implemented proper type safety for NDK interactions - - Created testing components for NDK functionality verification -- Enhanced exercise management with Nostr support - - Implemented exercise creation, editing, and forking workflows - - Added support for custom exercise event kinds (33401) - - Built exercise publication queue for offline-first functionality -- User profile integration - - Added profile fetching and caching - - Implemented profile-based permissions for content editing - - Fixed type definitions for NDK user profiles -- Robust workout state management - - Fixed favorites persistence in SQLite - - Added template-based workout initialization - - Implemented workout tracking with real-time updates - -## Fixed -- TypeScript errors across multiple components: - - Resolved NDK-related type errors in ExerciseSheet component - - Fixed FavoritesService reference errors in workoutStore - - Corrected null/undefined handling in NDKEvent initialization - - Fixed profile type compatibility in useProfile hook - - Added proper type definitions for NDK UserProfile -- Dependency errors in PublicationQueue and DevSeeder services -- Source and authorization checks for exercise editing permissions -- Component interoperability with NDK mobile - -## Improved -- Enhanced relay connection management - - Added timeout-based connection attempts - - Implemented better connection status tracking - - Added relay connectivity verification before publishing - - Improved error handling for publishing failures -- Workout completion UI - - Added scrollable interfaces for better content accessibility - - Enhanced visual feedback for selected options - - Improved button placement and visual hierarchy - - Added clearer visual indicators for selected storage options -- Refactored code for better type safety -- Enhanced error handling with proper type checking -- Improved Nostr event creation workflow with NDK -- Streamlined user authentication process -- Enhanced development environment with better type checking - -## [Unreleased] - -### Added -- Successful Nostr protocol integration - - Implemented NDK-mobile for React Native compatibility - - Added secure key management with Expo SecureStore - - Created event signing and publishing functionality - - Built relay connection management system - - Implemented event caching for offline support - - Added support for various Nostr event kinds (Text, Exercise, Template, Workout) -- Programs component for testing Nostr functionality - - Created tabbed interface with Database and Nostr sections - - Implemented user authentication flow - - Added event creation with multiple event types - - Built query functionality for retrieving events - - Developed event display with detailed tag inspection - - Added login/logout capabilities with secure key handling -- Enhanced crypto support for React Native environment - - Implemented proper cryptographic polyfills - - Added secure random number generation - - Built robust key management system - - Developed signer implementation for Nostr -- Zustand workout store for state management - - Created comprehensive workout state store with Zustand - - Implemented selectors for efficient state access - - Added workout persistence and recovery - - Built automatic timer management with background support - - Developed minimization and maximization functionality -- Zustand workout store for state management - - Created comprehensive workout state store with Zustand - - Implemented selectors for efficient state access - - Added workout persistence and recovery - - Built automatic timer management with background support - - Developed minimization and maximization functionality -- Workout tracking implementation with real-time tracking - - Added workout timer with proper background handling - - Implemented rest timer functionality - - Added exercise set tracking with weight and reps - - Created workout minimization and maximization system - - Implemented active workout bar for minimized workouts -- SQLite database implementation with development seeding - - Successfully integrated SQLite with proper transaction handling - - Added mock exercise library with 10 initial exercises - - Implemented development database seeder - - Added debug logging for database operations -- Event caching system for future Nostr integration - - Added EventCache service for Nostr event handling - - Implemented proper transaction management - - Added cache metadata tracking -- Database schema improvements - - Added nostr_events and event_tags tables - - Added cache_metadata table for performance optimization - - Added exercise_media table for future media support -- Alphabetical quick scroll in exercise library - - Dynamic letter highlighting + - Enhanced error diff --git a/app/(tabs)/profile/overview.tsx b/app/(tabs)/profile/overview.tsx index bd26b7b..ebba96b 100644 --- a/app/(tabs)/profile/overview.tsx +++ b/app/(tabs)/profile/overview.tsx @@ -1,399 +1,44 @@ // app/(tabs)/profile/overview.tsx -import React, { useState, useCallback, useEffect } from 'react'; -import { View, FlatList, RefreshControl, Pressable, TouchableOpacity, ImageBackground, Clipboard, Platform } from 'react-native'; +import React, { useState, useCallback } from 'react'; +import { View, ActivityIndicator } 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'; -import EmptyFeed from '@/components/social/EmptyFeed'; -import { useSocialFeed } from '@/lib/hooks/useSocialFeed'; -import { useProfileStats } from '@/lib/hooks/useProfileStats'; -import { - AnyFeedEntry, - WorkoutFeedEntry, - ExerciseFeedEntry, - TemplateFeedEntry, - SocialFeedEntry, - ArticleFeedEntry -} from '@/types/feed'; -import UserAvatar from '@/components/UserAvatar'; -import { useRouter } from 'expo-router'; -import { QrCode, Mail, Copy } from 'lucide-react-native'; -import { useTheme } from '@react-navigation/native'; -import type { CustomTheme } from '@/lib/theme'; -import { Alert } from 'react-native'; -import { nip19 } from 'nostr-tools'; - -// Define the conversion function for feed items -function convertToLegacyFeedItem(entry: AnyFeedEntry) { - return { - id: entry.eventId, - type: entry.type, - originalEvent: entry.event!, - parsedContent: entry.content!, - createdAt: (entry.timestamp || Date.now()) / 1000 - }; -} +import ProfileFeed from '@/components/profile/ProfileFeed'; +import { useProfilePageData } from '@/lib/hooks/useProfilePageData'; +import type { AnyFeedEntry } from '@/types/feed'; +/** + * Profile overview screen - refactored for consistent hook ordering + * This component now uses the useProfilePageData hook to handle all data fetching + * and state management, avoiding hook ordering issues. + */ export default function OverviewScreen() { - const insets = useSafeAreaInsets(); - const router = useRouter(); - const theme = useTheme() as CustomTheme; - const { currentUser, isAuthenticated } = useNDKCurrentUser(); + // Use our custom hook for all data needs (always calls hooks in same order) + const { + isAuthenticated, + currentUser, + stats, + bannerImage, + feed, + renderState, + renderError, + refreshAll, + setRenderError + } = useProfilePageData(); - // Initialize all state hooks at the top to maintain consistent ordering - const [isLoginSheetOpen, setIsLoginSheetOpen] = useState(false); - const [feedItems, setFeedItems] = useState([]); - const [feedLoading, setFeedLoading] = useState(false); - const [isOffline, setIsOffline] = useState(false); - const [entries, setEntries] = useState([]); + // Track refreshing state const [isRefreshing, setIsRefreshing] = useState(false); - const [renderError, setRenderError] = useState(null); - const [loadAttempts, setLoadAttempts] = useState(0); - // 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: isAuthenticated && currentUser?.pubkey ? [currentUser.pubkey] : [], - limit: 30 - }); - - // 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()); - - // Safety timeout for Android - force refresh the view if stuck loading for too long - useEffect(() => { - let timeoutId: NodeJS.Timeout | null = null; - - if (Platform.OS === 'android' && isAuthenticated && loading && loadAttempts < 3) { - // Set a safety timeout - if loading takes more than 8 seconds, force a refresh - timeoutId = setTimeout(() => { - console.log('[Android] Profile view safety timeout triggered, forcing refresh'); - setLoadAttempts(prev => prev + 1); - setFeedLoading(false); - if (refresh) { - try { - refresh(); - } catch (e) { - console.error('[Android] Force refresh error:', e); - } - } - }, 8000); - } - - return () => { - if (timeoutId) clearTimeout(timeoutId); - }; - }, [isAuthenticated, loading, refresh, loadAttempts]); - - // Update feedItems when socialFeed.feedItems changes - useEffect(() => { - if (isAuthenticated && socialFeed) { - setFeedItems(socialFeed.feedItems); - setIsOffline(socialFeed.isOffline); - } else { - // Clear feed items when logged out - setFeedItems([]); - } - }, [isAuthenticated, socialFeed?.feedItems, socialFeed?.isOffline]); - - // Process feedItems into entries when feedItems changes - // This needs to be a separate effect to avoid breaking hook order during logout - useEffect(() => { - if (!feedItems || !Array.isArray(feedItems)) { - setEntries([]); - return; - } - - // Map items and filter out any nulls - const mappedItems = feedItems.map(item => { - if (!item) return null; - - // Create a properly typed AnyFeedEntry based on the item type - // with null safety for all item properties - const baseEntry = { - id: item.id || `temp-${Date.now()}-${Math.random()}`, - eventId: item.id || `temp-${Date.now()}-${Math.random()}`, - event: item.originalEvent || {}, - timestamp: ((item.createdAt || Math.floor(Date.now() / 1000)) * 1000), - }; - - // Add type-specific properties - switch (item.type) { - case 'workout': - return { - ...baseEntry, - type: 'workout', - content: item.parsedContent || {} - } as WorkoutFeedEntry; - - case 'exercise': - return { - ...baseEntry, - type: 'exercise', - content: item.parsedContent || {} - } as ExerciseFeedEntry; - - case 'template': - return { - ...baseEntry, - type: 'template', - content: item.parsedContent || {} - } as TemplateFeedEntry; - - case 'social': - return { - ...baseEntry, - type: 'social', - content: item.parsedContent || {} - } as SocialFeedEntry; - - case 'article': - return { - ...baseEntry, - type: 'article', - content: item.parsedContent || {} - } as ArticleFeedEntry; - - default: - // Fallback to social type if unknown - return { - ...baseEntry, - type: 'social', - content: item.parsedContent || {} - } as SocialFeedEntry; - } - }); - - // Filter out nulls to satisfy TypeScript - const filteredEntries = mappedItems.filter((item): item is AnyFeedEntry => item !== null); - setEntries(filteredEntries); - }, [feedItems]); - - const resetFeed = refresh; - const hasContent = entries.length > 0; - - // Profile data - const profileImageUrl = currentUser?.profile?.image || - currentUser?.profile?.picture || - (currentUser?.profile as any)?.avatar; - - // 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') - : 'Guest User'; - - const username = isAuthenticated - ? (currentUser?.profile?.nip05 || '@user') - : '@guest'; - - const aboutText = currentUser?.profile?.about || - (currentUser?.profile as any)?.description; - - const pubkey = currentUser?.pubkey; - - // 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, - refresh: refreshStats, - lastRefreshed: statsLastRefreshed - } = useProfileStats({ - pubkey: pubkey || '', - refreshInterval: 10000 // 10 second refresh interval for real-time updates - }); - - // Track last fetch time to force component updates - const [lastStatsFetch, setLastStatsFetch] = useState(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 - // 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 ( - - - - {followingDisplay} - following - - {isManuallyRefreshing && ( - - )} - - - - - {followersDisplay} - followers - - {isManuallyRefreshing && ( - - )} - - - ); - }; - - // Generate npub format for display - const npubFormat = React.useMemo(() => { - if (!pubkey) return ''; - try { - const npub = nip19.npubEncode(pubkey); - return npub; - } catch (error) { - console.error('Error encoding npub:', error); - return ''; - } - }, [pubkey]); - - // Get shortened npub display version - const shortenedNpub = React.useMemo(() => { - if (!npubFormat) return ''; - return `${npubFormat.substring(0, 8)}...${npubFormat.substring(npubFormat.length - 5)}`; - }, [npubFormat]); - - // Handle refresh - now also refreshes banner image and forces state updates + // Handle refresh const handleRefresh = useCallback(async () => { setIsRefreshing(true); try { - 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(`[${Platform.OS}] Error during profile refresh:`, error); + await refreshAll(); } finally { setIsRefreshing(false); } - }, [resetFeed, refreshStats, refetchBannerImage, followersCount, followingCount, setLastStatsFetch]); + }, [refreshAll]); // Handle post selection const handlePostPress = useCallback((entry: AnyFeedEntry) => { @@ -401,207 +46,14 @@ export default function OverviewScreen() { console.log(`Selected ${entry.type}:`, entry); }, []); - // Copy npub to clipboard - const copyPubkey = useCallback(() => { - if (pubkey) { - try { - const npub = nip19.npubEncode(pubkey); - Clipboard.setString(npub); - Alert.alert('Copied', 'Public key copied to clipboard in npub format'); - console.log('npub copied to clipboard:', npub); - } catch (error) { - console.error('Error copying npub:', error); - Alert.alert('Error', 'Failed to copy public key'); - } - } - }, [pubkey]); - - // Show QR code alert - const showQRCode = useCallback(() => { - Alert.alert('QR Code', 'QR Code functionality will be implemented soon', [ - { text: 'OK' } - ]); - }, []); - - // Memoize render item function - const renderItem = useCallback(({ item }: { item: AnyFeedEntry }) => ( - handlePostPress(item)} - /> - ), [handlePostPress]); - - // IMPORTANT: All callback hooks must be defined before any conditional returns - // to ensure consistent hook ordering across renders - - // Define all the callbacks at the same level, regardless of authentication state - const handleEditProfilePress = useCallback(() => { - if (router && isAuthenticated) { - router.push('/profile/settings'); - } - }, [router, isAuthenticated]); - - const handleCopyButtonPress = useCallback(() => { - if (pubkey) { - copyPubkey(); - } - }, [pubkey, copyPubkey]); - - const handleQrButtonPress = useCallback(() => { - showQRCode(); - }, [showQRCode]); - - // Profile header component - making sure we have the same hooks - // regardless of authentication state to avoid hook ordering issues - const ProfileHeader = useCallback(() => { - // 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 ( - - {/* Banner Image */} - - {bannerImageUrl ? ( - { - 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}`); - }} - > - - - ) : ( - - - {defaultBannerUrl ? 'Loading banner...' : 'No banner image'} - - - )} - - - - - {/* Left side - Avatar */} - - - {/* Edit Profile button - positioned to the right */} - - - Edit Profile - - - - - {/* Profile info */} - - {displayName} - {username} - - {/* Display npub below username with sharing options */} - {npubFormat && ( - - - {shortenedNpub} - - - - - - - - - )} - - {/* Follower stats - render the separated component to avoid hook ordering issues */} - - - {/* About text */} - {aboutText && ( - {aboutText} - )} - - - {/* Divider */} - - - - ); - }, [ - displayName, - username, - profileImageUrl, - aboutText, - pubkey, - npubFormat, - shortenedNpub, - theme.colors.text, - router, - showQRCode, - copyPubkey, - isAuthenticated, - bannerImageUrl, - defaultBannerUrl, - refetchBannerImage - ]); - - // Render functions for different app states + // Render the login screen const renderLoginScreen = useCallback(() => { return ( ); }, []); + // Render loading screen const renderLoadingScreen = useCallback(() => { return ( @@ -610,69 +62,13 @@ export default function OverviewScreen() { ); }, []); - const renderMainContent = useCallback(() => { - // Catch and recover from any rendering errors - try { - return ( - - item.id} - renderItem={renderItem} - refreshControl={ - - } - ListHeaderComponent={} - ListEmptyComponent={ - - - - } - contentContainerStyle={{ - paddingBottom: insets.bottom + 20, - flexGrow: entries.length === 0 ? 1 : undefined - }} - /> - - ); - } catch (error) { - setRenderError(error instanceof Error ? error : new Error(String(error))); - // Fallback UI when rendering fails - return ( - - Something went wrong - - We had trouble loading your profile. Please try again. - - - - ); - } - }, [entries, renderItem, isRefreshing, handleRefresh, ProfileHeader, insets.bottom]); - - // Final conditional return after all hooks have been called - // This ensures consistent hook ordering across renders - if (!isAuthenticated) { - return renderLoginScreen(); - } - - if (renderError) { - // Render error recovery UI + // Render error screen + const renderErrorScreen = useCallback(() => { return ( Something went wrong - {renderError.message || "We had trouble loading your profile. Please try again."} + {renderError?.message || "We had trouble loading your profile. Please try again."} ); - } + }, [renderError, handleRefresh, setRenderError]); - // Show loading screen, but with a maximum timeout on Android - if (loading && entries.length === 0) { - if (Platform.OS === 'android' && loadAttempts >= 3) { - // After 3 attempts, show content anyway with a refresh button - return ( - - - - - Some content may still be loading. - - - - - ); - } - return renderLoadingScreen(); - } + // Render the main content + const renderMainContent = useCallback(() => { + // Create a wrapper for the stats refresh function to match expected Promise type + const refreshStatsWrapper = async () => { + if (stats.refresh) { + try { + await stats.refresh(); + } catch (error) { + console.error('Error refreshing stats:', error); + } + } + }; + + return ( + + ); + }, [ + feed.entries, + isRefreshing, + handleRefresh, + handlePostPress, + currentUser, + bannerImage.url, + bannerImage.defaultUrl, + stats.followersCount, + stats.followingCount, + stats.refresh, + stats.isLoading + ]); - return renderMainContent(); + // SINGLE RETURN STATEMENT with conditional rendering + // This avoids hook ordering issues by ensuring all hooks are always called + return ( + + {renderState === 'login' && renderLoginScreen()} + {renderState === 'loading' && renderLoadingScreen()} + {renderState === 'error' && renderErrorScreen()} + {renderState === 'content' && renderMainContent()} + + ); } diff --git a/components/profile/ProfileFeed.tsx b/components/profile/ProfileFeed.tsx new file mode 100644 index 0000000..5871251 --- /dev/null +++ b/components/profile/ProfileFeed.tsx @@ -0,0 +1,107 @@ +// components/profile/ProfileFeed.tsx +import React, { useCallback, useMemo } from 'react'; +import { View, FlatList, RefreshControl } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import EmptyFeed from '@/components/social/EmptyFeed'; +import EnhancedSocialPost from '@/components/social/EnhancedSocialPost'; +import { convertToLegacyFeedItem } from '@/lib/hooks/useProfilePageData'; +import type { AnyFeedEntry } from '@/types/feed'; +import ProfileHeader from './ProfileHeader'; +import { NDKUser } from '@nostr-dev-kit/ndk'; + +interface ProfileFeedProps { + feedEntries: AnyFeedEntry[]; + isRefreshing: boolean; + onRefresh: () => void; + onPostPress: (entry: AnyFeedEntry) => void; + user: NDKUser | null; + bannerImageUrl?: string; + defaultBannerUrl?: string; + followersCount: number; + followingCount: number; + refreshStats: () => Promise; + isStatsLoading: boolean; +} + +/** + * Profile feed component that displays the user's posts + * Pure presentational component with performance optimizations + */ +const ProfileFeed: React.FC = ({ + feedEntries, + isRefreshing, + onRefresh, + onPostPress, + user, + bannerImageUrl, + defaultBannerUrl, + followersCount, + followingCount, + refreshStats, + isStatsLoading, +}) => { + const insets = useSafeAreaInsets(); + + // Performance optimization: memoize item renderer + const renderItem = useCallback(({ item }: { item: AnyFeedEntry }) => ( + onPostPress(item)} + /> + ), [onPostPress]); + + // Performance optimization: memoize key extractor + const keyExtractor = useCallback((item: AnyFeedEntry) => item.id, []); + + // Performance optimization: memoize header component + const ListHeaderComponent = useMemo(() => ( + + ), [user, bannerImageUrl, defaultBannerUrl, followersCount, followingCount, refreshStats, isStatsLoading]); + + // Performance optimization: memoize empty component + const ListEmptyComponent = useMemo(() => ( + + + + ), []); + + // Performance optimization: memoize content container style + const contentContainerStyle = useMemo(() => ({ + paddingBottom: insets.bottom + 20, + flexGrow: feedEntries.length === 0 ? 1 : undefined + }), [insets.bottom, feedEntries.length]); + + return ( + + } + ListHeaderComponent={ListHeaderComponent} + ListEmptyComponent={ListEmptyComponent} + contentContainerStyle={contentContainerStyle} + + // Performance optimizations for FlatList + removeClippedSubviews={true} + maxToRenderPerBatch={5} + initialNumToRender={8} + windowSize={5} + updateCellsBatchingPeriod={50} + /> + ); +}; + +export default ProfileFeed; diff --git a/components/profile/ProfileHeader.tsx b/components/profile/ProfileHeader.tsx new file mode 100644 index 0000000..168b377 --- /dev/null +++ b/components/profile/ProfileHeader.tsx @@ -0,0 +1,293 @@ +// components/profile/ProfileHeader.tsx +import React, { useEffect } from 'react'; +import { View, TouchableOpacity, ImageBackground, Platform, ActivityIndicator } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { useRouter } from 'expo-router'; +import { Copy, QrCode } from 'lucide-react-native'; +import { useTheme } from '@react-navigation/native'; +import type { CustomTheme } from '@/lib/theme'; +import { Alert, Clipboard } from 'react-native'; +import { nip19 } from 'nostr-tools'; +import UserAvatar from '@/components/UserAvatar'; +import { NDKUser } from '@nostr-dev-kit/ndk'; + +interface ProfileHeaderProps { + user: NDKUser | null; + bannerImageUrl?: string; + defaultBannerUrl?: string; + followersCount: number; + followingCount: number; + refreshStats: () => Promise; + isStatsLoading: boolean; +} + +/** + * Profile header component displaying banner, avatar, user details, and stats + * Pure presentational component (no hooks except for UI behavior) + */ +const ProfileHeader: React.FC = ({ + user, + bannerImageUrl, + defaultBannerUrl, + followersCount, + followingCount, + refreshStats, + isStatsLoading, +}) => { + const router = useRouter(); + const theme = useTheme() as CustomTheme; + + // Extract user profile data with fallbacks + const profileImageUrl = user?.profile?.image || + user?.profile?.picture || + (user?.profile as any)?.avatar; + + const displayName = (user?.profile?.displayName || user?.profile?.name || 'Nostr User'); + const username = (user?.profile?.nip05 || '@user'); + const aboutText = user?.profile?.about || (user?.profile as any)?.description; + const pubkey = user?.pubkey; + + // Debug banner image loading + useEffect(() => { + console.log('Banner image state in ProfileHeader:', { + bannerImageUrl, + defaultBannerUrl, + pubkey: pubkey?.substring(0, 8) + }); + }, [bannerImageUrl, defaultBannerUrl, pubkey]); + + // Generate npub format for display + const npubFormat = React.useMemo(() => { + if (!pubkey) return ''; + try { + const npub = nip19.npubEncode(pubkey); + return npub; + } catch (error) { + console.error('Error encoding npub:', error); + return ''; + } + }, [pubkey]); + + // Get shortened npub display version + const shortenedNpub = React.useMemo(() => { + if (!npubFormat) return ''; + return `${npubFormat.substring(0, 8)}...${npubFormat.substring(npubFormat.length - 5)}`; + }, [npubFormat]); + + // Handle profile edit button press + const handleEditProfilePress = () => { + if (router) { + router.push('/profile/settings'); + } + }; + + // Copy npub to clipboard + const handleCopyButtonPress = () => { + if (pubkey) { + try { + const npub = nip19.npubEncode(pubkey); + Clipboard.setString(npub); + Alert.alert('Copied', 'Public key copied to clipboard in npub format'); + console.log('npub copied to clipboard:', npub); + } catch (error) { + console.error('Error copying npub:', error); + Alert.alert('Error', 'Failed to copy public key'); + } + } + }; + + // Show QR code alert (placeholder) + const handleQrButtonPress = () => { + Alert.alert('QR Code', 'QR Code functionality will be implemented soon', [ + { text: 'OK' } + ]); + }; + + return ( + + {/* Banner Image */} + + {bannerImageUrl ? ( + { + console.error(`Banner image loading error: ${JSON.stringify(e.nativeEvent)}`); + console.error(`Failed URL: ${bannerImageUrl}`); + }} + onLoad={() => { + console.log(`Banner image loaded successfully: ${bannerImageUrl}`); + }} + > + + + ) : ( + + + {defaultBannerUrl ? 'Loading banner...' : 'No banner image'} + + + )} + + + + + {/* Left side - Avatar */} + + + {/* Edit Profile button - positioned to the right */} + + + Edit Profile + + + + + {/* Profile info */} + + {displayName} + {username} + + {/* Display npub below username with sharing options */} + {npubFormat && ( + + + {shortenedNpub} + + + + + + + + + )} + + {/* Follower stats */} + + + {/* About text */} + {aboutText && ( + {aboutText} + )} + + + {/* Divider */} + + + + ); +}; + +// ProfileStats subcomponent +interface ProfileStatsProps { + followersCount: number; + followingCount: number; + refreshStats: () => Promise; + isLoading: boolean; +} + +const ProfileStats: React.FC = ({ + followersCount, + followingCount, + refreshStats, + isLoading +}) => { + const [isManuallyRefreshing, setIsManuallyRefreshing] = React.useState(false); + + // Enhanced manual refresh function with visual feedback + const triggerManualRefresh = React.useCallback(async () => { + if (isManuallyRefreshing) return; // Prevent multiple simultaneous refreshes + + try { + setIsManuallyRefreshing(true); + console.log(`[${Platform.OS}] Manual refresh triggered by user tap`); + await refreshStats(); + } 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, refreshStats]); + + // Always show actual values when available, regardless of loading state + // Only show dots when we have no values at all + const followingDisplay = followingCount > 0 ? + followingCount.toLocaleString() : + (isLoading || isManuallyRefreshing ? '...' : '0'); + + const followersDisplay = followersCount > 0 ? + followersCount.toLocaleString() : + (isLoading || isManuallyRefreshing ? '...' : '0'); + + return ( + + + + {followingDisplay} + following + + {isManuallyRefreshing && ( + + )} + + + + + {followersDisplay} + followers + + {isManuallyRefreshing && ( + + )} + + + ); +}; + +export default ProfileHeader; diff --git a/docs/technical/auth/auth_fixes_summary.md b/docs/technical/auth/auth_fixes_summary.md new file mode 100644 index 0000000..891339a --- /dev/null +++ b/docs/technical/auth/auth_fixes_summary.md @@ -0,0 +1,78 @@ +# Authentication Persistence Fixes + +This document summarizes the improvements made to fix authentication persistence issues in the POWR app. + +## Overview + +The authentication system was enhanced to ensure reliable persistence of user credentials across app restarts. Previously, users needed to re-authenticate each time they restarted the app, even though their credentials were being stored in SecureStore. + +## Key Improvements + +### 1. Enhanced AuthService + +- **Improved initialization process**: Added promise caching for concurrent calls to prevent race conditions +- **More robust credential restoration**: Better error handling when restoring stored private keys or external signers +- **SecureStore key constants**: Added constants for all SecureStore keys to avoid inconsistencies +- **Public key caching**: Added storage of public key alongside private key for faster access +- **Clean credential handling**: Automatic cleanup of invalid credentials when restoration fails +- **Enhanced logging**: Comprehensive logging throughout the authentication flow + +### 2. Improved ReactQueryAuthProvider + +- **Better NDK initialization**: Enhanced initialization with credential pre-checking +- **Initialization tracking**: Added tracking of initialization attempts to prevent duplicates +- **State management**: Improved state updates to ensure they only occur for the most recent initialization +- **Auth state invalidation**: Force refresh of auth state after initialization +- **Error resilience**: Better error handling throughout the provider + +### 3. Fixed useAuthQuery Hook + +- **Pre-initialization**: Added pre-initialization of auth service when the hook is first used +- **Query configuration**: Adjusted query settings for better reliability (staleTime, refetchOnWindowFocus, etc.) +- **Type safety**: Fixed TypeScript errors to ensure proper type checking +- **Initialization reference**: Added reference tracking to prevent memory leaks +- **Better error handling**: Enhanced error management throughout the hook + +## Implementation Details + +### AuthService Changes + +- Added promise caching with `initPromise` to handle concurrent initialization calls +- Split initialization into public `initialize()` and private `_doInitialize()` methods +- Improved error handling with try/catch blocks and proper cleanup +- Added constants for secure storage keys (`POWR.PRIVATE_KEY`, etc.) +- Enhanced login methods with optional `saveKey` parameter +- Added public key storage for faster reference + +### ReactQueryAuthProvider Changes + +- Added initialization attempt tracking to prevent race conditions +- Added pre-checking of credentials before NDK initialization +- Enhanced state management to only update for the most recent initialization attempt +- Improved error resilience to ensure app works even if initialization fails +- Added forced query invalidation after successful initialization + +### useAuthQuery Changes + +- Added initialization state tracking with useRef +- Pre-initializes auth service when hook is first used +- Improved mutation error handling with better cleanup +- Fixed TypeScript errors with proper type assertions +- Enhanced query settings for better reliability and performance + +## Testing + +The authentication persistence has been thoroughly tested across various scenarios: + +1. App restart with private key authentication +2. App restart with external signer (Amber) authentication +3. Logout and login within the same session +4. Network disconnection and reconnection scenarios +5. Force quit and restart of the application + +## Future Considerations + +- Consider adding a periodic auth token refresh mechanism +- Explore background token validation to prevent session timeout +- Implement token expiration handling if needed in the future +- Potentially add multi-account support with secure credential switching diff --git a/lib/auth/AuthService.ts b/lib/auth/AuthService.ts index 5c08a25..b042f01 100644 --- a/lib/auth/AuthService.ts +++ b/lib/auth/AuthService.ts @@ -5,6 +5,14 @@ import { NDKAmberSigner } from '../signers/NDKAmberSigner'; import { generateId, generateDTag } from '@/utils/ids'; import { v4 as uuidv4 } from 'uuid'; import { AuthMethod } from './types'; +import { createLogger, enableModule } from '@/lib/utils/logger'; +import { SECURE_STORE_KEYS } from './constants'; +import { Platform } from 'react-native'; + +// Create auth-specific logger with extended logging +enableModule('AuthService'); +const logger = createLogger('AuthService'); +const platform = Platform.OS === 'ios' ? 'iOS' : 'Android'; /** * Auth Service for managing authentication with NDK and React Query @@ -19,78 +27,185 @@ import { AuthMethod } from './types'; export class AuthService { private ndk: NDK; private initialized: boolean = false; + private initPromise: Promise | null = null; constructor(ndk: NDK) { this.ndk = ndk; } /** - * Initialize the auth service + * Initialize the auth service - with improved error handling * This is called automatically by the ReactQueryAuthProvider */ async initialize(): Promise { + // Single initialization pattern with promise caching + if (this.initPromise) { + logger.debug("Initialization already in progress, waiting for completion"); + return this.initPromise; + } + if (this.initialized) { + logger.debug("Already initialized, skipping"); return; } + // Create a promise we can return for concurrent calls + this.initPromise = this._doInitialize(); try { - // 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) { - await this.loginWithPrivateKey(privateKey); - } else if (externalSignerJson) { - const { method, data } = JSON.parse(externalSignerJson); - if (method === 'amber') { - await this.restoreAmberSigner(data); - } - } - + await this.initPromise; this.initialized = true; } catch (error) { - console.error('[AuthService] Error initializing auth service:', error); + logger.error("Initialization failed:", error); + // Reset promise so we can try again later + this.initPromise = null; throw error; } } /** - * Login with a private key + * Internal method that does the actual initialization work + */ + private async _doInitialize(): Promise { + try { + logger.info(`[${platform}] Starting initialization...`); + + // Check if we have credentials stored + const privateKey = await SecureStore.getItemAsync(SECURE_STORE_KEYS.PRIVATE_KEY); + const externalSignerJson = await SecureStore.getItemAsync(SECURE_STORE_KEYS.EXTERNAL_SIGNER); + const storedPubkey = await SecureStore.getItemAsync(SECURE_STORE_KEYS.PUBKEY); + + // Check both storage keys for compatibility with legacy storage + const legacyPrivateKey = await SecureStore.getItemAsync('nostr_privkey'); + const newPrivateKey = await SecureStore.getItemAsync('powr.private_key'); + + logger.debug(`[${platform}] Found stored credentials:`, { + hasPrivateKey: !!privateKey, + hasExternalSigner: !!externalSignerJson, + storedPubkey: storedPubkey ? storedPubkey.substring(0, 8) + '...' : null, + hasLegacyPrivateKey: !!legacyPrivateKey, + hasNewPrivateKey: !!newPrivateKey, + storageKeyUsed: SECURE_STORE_KEYS.PRIVATE_KEY + }); + + // Login with stored credentials if available + if (privateKey) { + logger.info(`[${platform}] Restoring from private key`); + try { + // Try to normalize the key if needed (some platforms may add extra characters) + let normalizedKey = privateKey.trim(); + // If key is longer than 64 chars, truncate it to the standard length + if (normalizedKey.length > 64) { + logger.warn(`[${platform}] Trimming private key from ${normalizedKey.length} chars to 64 chars`); + normalizedKey = normalizedKey.substring(0, 64); + } + + await this.loginWithPrivateKey(normalizedKey, false); // false = don't save again + logger.info(`[${platform}] Successfully restored private key auth`); + + // Double-check that pubkey was saved + const currentPubkey = this.ndk.activeUser?.pubkey; + if (currentPubkey && (!storedPubkey || storedPubkey !== currentPubkey)) { + logger.info(`[${platform}] Updating stored pubkey to match current user`); + await SecureStore.setItemAsync(SECURE_STORE_KEYS.PUBKEY, currentPubkey); + } + } catch (e) { + logger.error(`[${platform}] Error restoring private key auth:`, e); + // If we failed to restore, delete the stored key to avoid persistent errors + await SecureStore.deleteItemAsync(SECURE_STORE_KEYS.PRIVATE_KEY); + throw e; + } + } else if (externalSignerJson) { + try { + logger.info("Restoring from external signer"); + const { method, data } = JSON.parse(externalSignerJson); + if (method === 'amber') { + await this.restoreAmberSigner(data); + logger.info("Successfully restored Amber signer"); + } + } catch (e) { + logger.error("Error restoring external signer:", e); + // If we failed to restore, delete the stored data to avoid persistent errors + await SecureStore.deleteItemAsync(SECURE_STORE_KEYS.EXTERNAL_SIGNER); + throw e; + } + } else { + logger.debug("No stored credentials found"); + } + + logger.info("Initialization complete"); + } catch (error) { + logger.error("Initialization error:", error); + throw error; + } + } + + /** + * Login with a private key - with improved logging and error handling * @param privateKey hex private key + * @param saveKey whether to save the key to SecureStore (default true) * @returns NDK user */ - async loginWithPrivateKey(privateKey: string): Promise { + async loginWithPrivateKey(privateKey: string, saveKey: boolean = true): Promise { try { + logger.debug(`[${platform}] Creating private key signer, key length: ${privateKey.length}`); + + // Debug verification for the key format + if (privateKey.length !== 64) { + logger.warn(`[${platform}] Private key has unusual length: ${privateKey.length}, expected 64 chars`); + // But we'll still try to use it + } + + // Log a small fragment of the key for debugging + if (privateKey.length > 0) { + const keyPrefix = privateKey.substring(0, 4); + logger.debug(`[${platform}] Private key starts with: ${keyPrefix}...`); + } + // Create signer const signer = new NDKPrivateKeySigner(privateKey); this.ndk.signer = signer; - // Get user + // Make sure we're connected + logger.debug(`[${platform}] Connecting to NDK with private key signer`); await this.ndk.connect(); + if (!this.ndk.activeUser) { + logger.error(`[${platform}] NDK connect succeeded but activeUser is null`); throw new Error('Failed to set active user after login'); } - // Persist the key securely - await SecureStore.setItemAsync('powr.private_key', privateKey); + const pubkeyFragment = this.ndk.activeUser.pubkey.substring(0, 8); + logger.info(`[${platform}] Successfully logged in with private key for user: ${pubkeyFragment}...`); + + // Persist the key securely if requested + if (saveKey) { + logger.debug(`[${platform}] Saving private key to SecureStore`); + await SecureStore.setItemAsync(SECURE_STORE_KEYS.PRIVATE_KEY, privateKey); + + // Also save the public key for faster reference + await SecureStore.setItemAsync(SECURE_STORE_KEYS.PUBKEY, this.ndk.activeUser.pubkey); + + logger.debug(`[${platform}] Credentials saved successfully`); + } return this.ndk.activeUser; } catch (error) { - console.error('[AuthService] Error logging in with private key:', error); + logger.error("Error logging in with private key:", error); throw error; } } /** - * Login with Amber external signer + * Login with Amber external signer - enhanced error handling * @returns NDK user */ async loginWithAmber(): Promise { try { + logger.info("Requesting public key from Amber"); // Request public key from Amber const { pubkey, packageName } = await NDKAmberSigner.requestPublicKey(); + logger.debug("Creating Amber signer with pubkey:", pubkey); // Create Amber signer const amberSigner = new NDKAmberSigner(pubkey, packageName); @@ -98,11 +213,15 @@ export class AuthService { this.ndk.signer = amberSigner; // Connect and get user + logger.debug("Connecting to NDK with Amber signer"); await this.ndk.connect(); if (!this.ndk.activeUser) { + logger.error("NDK connect succeeded but activeUser is null for Amber signer"); throw new Error('Failed to set active user after amber login'); } + logger.info("Successfully logged in with Amber for user:", pubkey); + // Store the signer info const signerData = { pubkey: pubkey, @@ -113,12 +232,14 @@ export class AuthService { data: signerData }); - await SecureStore.setItemAsync('nostr_external_signer', externalSignerInfo); - await SecureStore.deleteItemAsync('powr.private_key'); // Clear any stored private key + logger.debug("Saving Amber signer data to SecureStore"); + await SecureStore.setItemAsync(SECURE_STORE_KEYS.EXTERNAL_SIGNER, externalSignerInfo); + await SecureStore.setItemAsync(SECURE_STORE_KEYS.PUBKEY, pubkey); + await SecureStore.deleteItemAsync(SECURE_STORE_KEYS.PRIVATE_KEY); // Clear any stored private key return this.ndk.activeUser; } catch (error) { - console.error('[AuthService] Error logging in with Amber:', error); + logger.error("Error logging in with Amber:", error); throw error; } } @@ -130,6 +251,7 @@ export class AuthService { */ private async restoreAmberSigner(signerData: any): Promise { try { + logger.debug("Restoring Amber signer with data:", signerData); // Create Amber signer with existing data const amberSigner = new NDKAmberSigner(signerData.pubkey, signerData.packageName); @@ -137,14 +259,18 @@ export class AuthService { this.ndk.signer = amberSigner; // Connect and get user + logger.debug("Connecting to NDK with restored Amber signer"); await this.ndk.connect(); if (!this.ndk.activeUser) { + logger.error("NDK connect succeeded but activeUser is null for restored Amber signer"); throw new Error('Failed to set active user after amber signer restore'); } + logger.info("Successfully restored Amber signer for user:", signerData.pubkey); + return this.ndk.activeUser; } catch (error) { - console.error('[AuthService] Error restoring Amber signer:', error); + logger.error("Error restoring Amber signer:", error); throw error; } } @@ -155,6 +281,7 @@ export class AuthService { */ async createEphemeralKey(): Promise { try { + logger.info("Creating ephemeral key"); // Generate a random key (not persisted) // This creates a hex string of 64 characters (32 bytes) // Use uuidv4 to generate random bytes @@ -166,18 +293,24 @@ export class AuthService { this.ndk.signer = signer; // Connect and get user + logger.debug("Connecting to NDK with ephemeral key"); await this.ndk.connect(); if (!this.ndk.activeUser) { + logger.error("NDK connect succeeded but activeUser is null for ephemeral key"); throw new Error('Failed to set active user after ephemeral key creation'); } + logger.info("Successfully created ephemeral key for user:", this.ndk.activeUser.pubkey); + // Clear any stored credentials - await SecureStore.deleteItemAsync('powr.private_key'); - await SecureStore.deleteItemAsync('nostr_external_signer'); + logger.debug("Clearing stored credentials for ephemeral key"); + await SecureStore.deleteItemAsync(SECURE_STORE_KEYS.PRIVATE_KEY); + await SecureStore.deleteItemAsync(SECURE_STORE_KEYS.EXTERNAL_SIGNER); + await SecureStore.deleteItemAsync(SECURE_STORE_KEYS.PUBKEY); return this.ndk.activeUser; } catch (error) { - console.error('[AuthService] Error creating ephemeral key:', error); + logger.error("Error creating ephemeral key:", error); throw error; } } @@ -187,18 +320,20 @@ export class AuthService { */ async logout(): Promise { try { + logger.info("Logging out user"); + // Clear stored credentials - await SecureStore.deleteItemAsync('powr.private_key'); - await SecureStore.deleteItemAsync('nostr_external_signer'); + await SecureStore.deleteItemAsync(SECURE_STORE_KEYS.PRIVATE_KEY); + await SecureStore.deleteItemAsync(SECURE_STORE_KEYS.EXTERNAL_SIGNER); + await SecureStore.deleteItemAsync(SECURE_STORE_KEYS.PUBKEY); // Reset NDK this.ndk.signer = undefined; - // Simple cleanup for NDK instance - // NDK doesn't have a formal disconnect method + // Clean up relay connections if they exist try { - // Clean up relay connections if they exist if (this.ndk.pool) { + logger.debug("Cleaning up relay connections"); // Cast to any to bypass TypeScript errors with internal NDK API const pool = this.ndk.pool as any; if (pool.relayByUrl) { @@ -206,18 +341,18 @@ export class AuthService { try { if (relay && relay.close) relay.close(); } catch (e) { - console.warn('Error closing relay:', e); + logger.warn("Error closing relay:", e); } }); } } } catch (e) { - console.warn('Error during NDK resource cleanup:', e); + logger.warn("Error during NDK resource cleanup:", e); } - console.log('[AuthService] Logged out successfully'); + logger.info("Logged out successfully"); } catch (error) { - console.error('[AuthService] Error during logout:', error); + logger.error("Error during logout:", error); throw error; } } @@ -228,19 +363,23 @@ export class AuthService { */ async getCurrentAuthMethod(): Promise { try { - if (await SecureStore.getItemAsync('powr.private_key')) { + logger.debug("Getting current auth method"); + if (await SecureStore.getItemAsync(SECURE_STORE_KEYS.PRIVATE_KEY)) { + logger.debug("Found private key authentication"); return 'private_key'; } - const externalSignerJson = await SecureStore.getItemAsync('nostr_external_signer'); + const externalSignerJson = await SecureStore.getItemAsync(SECURE_STORE_KEYS.EXTERNAL_SIGNER); if (externalSignerJson) { const { method } = JSON.parse(externalSignerJson); + logger.debug(`Found external signer authentication: ${method}`); return method === 'amber' ? 'amber' : undefined; } + logger.debug("No authentication method found"); return undefined; } catch (error) { - console.error('[AuthService] Error getting current auth method:', error); + logger.error("Error getting current auth method:", error); return undefined; } } diff --git a/lib/auth/ReactQueryAuthProvider.tsx b/lib/auth/ReactQueryAuthProvider.tsx index e3e8cfd..7df4ec6 100644 --- a/lib/auth/ReactQueryAuthProvider.tsx +++ b/lib/auth/ReactQueryAuthProvider.tsx @@ -1,8 +1,14 @@ -import React, { ReactNode, useEffect, useState, createContext, useMemo } from 'react'; +import React, { ReactNode, useEffect, useState, createContext, useMemo, useRef } 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'; +import { createLogger } from '@/lib/utils/logger'; +import * as SecureStore from 'expo-secure-store'; +import { SECURE_STORE_KEYS } from './constants'; + +// Create auth-specific logger +const logger = createLogger('ReactQueryAuthProvider'); // Create context for NDK instance export const NDKContext = createContext<{ ndk: NDK | null; isInitialized: boolean }>({ @@ -18,12 +24,12 @@ interface ReactQueryAuthProviderProps { } /** - * ReactQueryAuthProvider + * ReactQueryAuthProvider - Enhanced with persistence support * * Main provider component for React Query integration with authentication. * This component: * - Creates and configures the QueryClient - * - Creates an NDK instance + * - Creates an NDK instance with proper credential restoration * - Provides React Query context and NDK context * - Ensures consistent hook ordering regardless of initialization state */ @@ -40,37 +46,63 @@ export function ReactQueryAuthProvider({ const [ndk, setNdk] = useState(null); const [isInitialized, setIsInitialized] = useState(false); + // Track initialization attempts + const initAttemptRef = useRef(0); + // NDK context value (memoized to prevent unnecessary re-renders) const ndkContextValue = useMemo(() => ({ ndk, isInitialized }), [ndk, isInitialized]); - // Initialize NDK only if enableNDK is true + // Enhanced initialization with credential checking useEffect(() => { // Skip NDK initialization if enableNDK is false if (!enableNDK) { - console.log('[ReactQueryAuthProvider] NDK initialization skipped (enableNDK=false)'); + logger.info("NDK initialization skipped (enableNDK=false)"); setIsInitialized(true); // Still mark as initialized so the app can proceed return; } + // Track this initialization attempt + const currentAttempt = ++initAttemptRef.current; + const initNDK = async () => { try { - console.log('[ReactQueryAuthProvider] Initializing NDK...'); + logger.info(`Initializing NDK (attempt ${currentAttempt})...`); + + // Pre-check for credentials to improve logging - using constants for key names + const hasPrivateKey = await SecureStore.getItemAsync(SECURE_STORE_KEYS.PRIVATE_KEY); + const hasExternalSigner = await SecureStore.getItemAsync(SECURE_STORE_KEYS.EXTERNAL_SIGNER); + + logger.debug("Auth credentials status:", { + hasPrivateKey: !!hasPrivateKey, + hasExternalSigner: !!hasExternalSigner + }); + + // Initialize NDK with context name const result = await initializeNDK('react-query'); - setNdk(result.ndk); - setIsInitialized(true); - console.log('[ReactQueryAuthProvider] NDK initialized successfully'); + + // Update state only if this is still the most recent initialization attempt + if (currentAttempt === initAttemptRef.current) { + setNdk(result.ndk); + setIsInitialized(true); + logger.info("NDK initialized successfully"); + + // Force refetch auth state to ensure it's up to date + queryClient.invalidateQueries({ queryKey: ['auth', 'current'] }); + } } catch (err) { - console.error('[ReactQueryAuthProvider] Error initializing NDK:', err); + logger.error("Error initializing NDK:", err); // Still mark as initialized so the app can handle the error state - setIsInitialized(true); + if (currentAttempt === initAttemptRef.current) { + setIsInitialized(true); + } } }; initNDK(); - }, [enableOfflineMode, enableNDK]); + }, [enableOfflineMode, enableNDK, queryClient]); // Always render children, regardless of NDK initialization status // This ensures consistent hook ordering in child components diff --git a/lib/auth/constants.ts b/lib/auth/constants.ts new file mode 100644 index 0000000..6283ba3 --- /dev/null +++ b/lib/auth/constants.ts @@ -0,0 +1,18 @@ +/** + * Auth-related constants used throughout the authentication system + */ + +/** + * SecureStore keys for storing authentication-related data + * Using constants ensures consistent keys across the app + */ +export const SECURE_STORE_KEYS = { + PRIVATE_KEY: 'nostr_privkey', // Changed to match ndk.ts store key + EXTERNAL_SIGNER: 'nostr_external_signer', + PUBKEY: 'nostr_pubkey' +}; + +/** + * Authentication methods supported by the app + */ +export type AuthMethod = 'private_key' | 'amber' | 'ephemeral'; diff --git a/lib/hooks/useAuthQuery.ts b/lib/hooks/useAuthQuery.ts index c69a118..6649f42 100644 --- a/lib/hooks/useAuthQuery.ts +++ b/lib/hooks/useAuthQuery.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useContext } from 'react'; +import { useCallback, useMemo, useEffect, useRef } from 'react'; import { useMutation, useQuery, @@ -12,7 +12,10 @@ 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'; +import { createLogger } from '@/lib/utils/logger'; + +// Create auth-specific logger +const logger = createLogger('useAuthQuery'); /** * Authentication state type @@ -46,7 +49,7 @@ export type LoginParams = }; /** - * useAuthQuery Hook + * useAuthQuery Hook - Enhanced with better persistence and error handling * * React Query-based hook for managing authentication state. * Provides queries and mutations for working with user authentication. @@ -56,17 +59,32 @@ export type LoginParams = * - Login with different methods (private key, Amber, ephemeral) * - Logout functionality * - Automatic revalidation of auth state + * - Improved persistence across app restarts */ export function useAuthQuery() { const queryClient = useQueryClient(); const { ndk } = useNDK(); + // Track initialization state + const initializationRef = useRef<{ + isInitializing: boolean; + initialized: boolean; + error: Error | null; + }>({ + isInitializing: false, + initialized: false, + error: null + }); + + // Track auth service instance + const authServiceRef = useRef(null); + // 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'); + logger.info("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 () => {}, @@ -82,23 +100,62 @@ export function useAuthQuery() { return placeholderService as unknown as AuthService; } - return new AuthService(ndk); + logger.info("Creating AuthService with NDK instance"); + const service = new AuthService(ndk); + authServiceRef.current = service; + return service; }, [ndk]); - // Query to get current auth state + // Pre-initialize auth service when it changes + useEffect(() => { + const initRef = initializationRef.current; + + if (!authService || initRef.isInitializing || initRef.initialized) { + return; + } + + const initialize = async () => { + initRef.isInitializing = true; + try { + logger.info("Pre-initializing auth service"); + await authService.initialize(); + initRef.initialized = true; + initRef.error = null; + + // Force refetch of auth state + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.auth.current() }); + + logger.info("Auth service pre-initialization complete"); + } catch (error) { + logger.error("Auth service pre-initialization failed:", error); + initRef.error = error as Error; + } finally { + initRef.isInitializing = false; + } + }; + + initialize(); + }, [authService, queryClient]); + + // Query to get current auth state - enhanced with better error handling and retry const authQuery: UseQueryResult = useQuery({ queryKey: QUERY_KEYS.auth.current(), queryFn: async (): Promise => { + logger.debug("Running auth query function"); + if (!ndk) { + logger.debug("No NDK instance, returning unauthenticated"); return { status: 'unauthenticated' }; } try { - // Initialize auth service + // Initialize auth service (will be fast if already initialized) + logger.debug("Initializing auth service in query function"); await authService.initialize(); // Check if user is authenticated if (ndk.activeUser) { + logger.info("NDK has active user:", ndk.activeUser.pubkey); const method = await authService.getCurrentAuthMethod(); return { status: 'authenticated', @@ -107,66 +164,91 @@ export function useAuthQuery() { }; } + logger.debug("No active user, returning unauthenticated"); return { status: 'unauthenticated' }; } catch (error) { - console.error('[useAuthQuery] Error getting auth state:', error); + logger.error("Error getting auth state:", error); return { status: 'unauthenticated' }; } }, - staleTime: Infinity, // Auth state doesn't go stale by itself - refetchOnWindowFocus: false, + // Adjusted query settings for better reliability + staleTime: 10 * 60 * 1000, // 10 minutes - don't refetch too often + refetchOnWindowFocus: true, // Refresh when app comes to foreground refetchOnMount: true, - refetchOnReconnect: false, - retry: false, + refetchOnReconnect: true, // Refresh when network reconnects + retry: 2, // Retry a couple times if fails + refetchInterval: false, // Don't auto-refresh continuously }); - // Login mutation - const loginMutation: UseMutationResult = useMutation({ + // Login mutation - with better success handling + const loginMutation = useMutation({ mutationFn: async (params: LoginParams): Promise => { if (!ndk) { + logger.error("Login attempted without NDK instance"); throw new Error('NDK instance is required for login'); } + logger.info(`Attempting login with method: ${params.method}`); + switch (params.method) { case 'private_key': return authService.loginWithPrivateKey(params.privateKey); case 'amber': if (Platform.OS !== 'android') { + logger.error("Amber login attempted on non-Android platform"); throw new Error('Amber login is only available on Android'); } return authService.loginWithAmber(); case 'ephemeral': return authService.createEphemeralKey(); default: + logger.error("Invalid login method:", params.method); throw new Error('Invalid login method'); } }, onSuccess: async (user, variables) => { + logger.info("Login successful, updating auth state"); + + // Get the method from the variables and use type assertion since TS is having trouble + const method = (variables as LoginParams).method; + // Update auth state after successful login queryClient.setQueryData(QUERY_KEYS.auth.current(), { status: 'authenticated', user, - method: variables.method, + method, }); - // Invalidate any queries that depend on authentication + // Force update all auth-dependent queries await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.auth.all }); + + // Also mark initialization as complete + initializationRef.current.initialized = true; + initializationRef.current.error = null; }, onError: (error) => { - console.error('[useAuthQuery] Login error:', error); + logger.error("Login error:", error); + // Clear any partial auth data on failure + queryClient.setQueryData(QUERY_KEYS.auth.current(), { + status: 'unauthenticated', + }); }, }); - // Logout mutation + // Logout mutation - with better error handling const logoutMutation: UseMutationResult = useMutation({ mutationFn: async (): Promise => { if (!ndk) { + logger.error("Logout attempted without NDK instance"); throw new Error('NDK instance is required for logout'); } + logger.info("Performing logout"); await authService.logout(); }, onSuccess: async () => { + logger.info("Logout successful, resetting auth state"); + // Set auth state to unauthenticated after successful logout queryClient.setQueryData(QUERY_KEYS.auth.current(), { status: 'unauthenticated', @@ -176,13 +258,24 @@ export function useAuthQuery() { await queryClient.invalidateQueries(); }, onError: (error) => { - console.error('[useAuthQuery] Logout error:', error); + logger.error("Logout error:", error); + + // Try to reset auth state even on error + try { + queryClient.setQueryData(QUERY_KEYS.auth.current(), { + status: 'unauthenticated', + }); + queryClient.invalidateQueries(); + } catch (e) { + logger.error("Failed to reset auth state after logout error:", e); + } }, }); // Login function const login = useCallback( (params: LoginParams) => { + logger.info(`Login requested with method: ${params.method}`); return loginMutation.mutateAsync(params); }, [loginMutation] @@ -190,6 +283,7 @@ export function useAuthQuery() { // Logout function const logout = useCallback(() => { + logger.info("Logout requested"); return logoutMutation.mutateAsync(); }, [logoutMutation]); diff --git a/lib/hooks/useProfilePageData.tsx b/lib/hooks/useProfilePageData.tsx new file mode 100644 index 0000000..2445a54 --- /dev/null +++ b/lib/hooks/useProfilePageData.tsx @@ -0,0 +1,277 @@ +// lib/hooks/useProfilePageData.tsx +import { useState, useEffect, useCallback } from 'react'; +import { Platform } from 'react-native'; +import { useNDKCurrentUser } from '@/lib/hooks/useNDK'; +import { useProfileStats } from '@/lib/hooks/useProfileStats'; +import { useBannerImage } from '@/lib/hooks/useBannerImage'; +import { useSocialFeed } from '@/lib/hooks/useSocialFeed'; +import type { AnyFeedEntry, WorkoutFeedEntry, SocialFeedEntry, TemplateFeedEntry, ExerciseFeedEntry, ArticleFeedEntry } from '@/types/feed'; + +// Helper function to convert feed entries for social posts component +export function convertToLegacyFeedItem(entry: AnyFeedEntry) { + return { + id: entry.eventId, + type: entry.type, + originalEvent: entry.event!, + parsedContent: entry.content!, + createdAt: (entry.timestamp || Date.now()) / 1000 + }; +} + +// Define possible render states +type RenderState = 'login' | 'loading' | 'content' | 'error'; + +/** + * Custom hook for managing profile page data and state + * Centralizes all data fetching logic in one place + */ +export function useProfilePageData() { + // Authentication state (always call this hook) + const { currentUser, isAuthenticated } = useNDKCurrentUser(); + + // Initialize all state hooks at the top + const [feedItems, setFeedItems] = useState([]); + const [feedLoading, setFeedLoading] = useState(false); + const [isOffline, setIsOffline] = useState(false); + const [entries, setEntries] = useState([]); + const [renderError, setRenderError] = useState(null); + const [loadAttempts, setLoadAttempts] = useState(0); + + // Current pubkey (or empty string if not authenticated) + const pubkey = currentUser?.pubkey || ''; + + // Always call all hooks regardless of auth state + // For unauthorized state, we pass empty pubkey or arrays + + // Profile stats (with empty pubkey fallback) + const stats = useProfileStats({ + pubkey, + refreshInterval: 10000 + }); + + // Banner image (with empty pubkey fallback) + const defaultBannerUrl = currentUser?.profile?.banner || + (currentUser?.profile as any)?.background; + + const { data: bannerImageUrl, refetch: refetchBanner } = useBannerImage( + pubkey, + defaultBannerUrl + ); + + // Social feed (with empty authors array fallback for unauthenticated state) + const socialFeed = useSocialFeed({ + feedType: 'profile', + authors: isAuthenticated && pubkey ? [pubkey] : [], + limit: 30 + }); + + // Extract values from socialFeed with fallbacks + const loading = socialFeed?.loading || feedLoading; + const refresh = socialFeed?.refresh || (() => Promise.resolve()); + + // Performance optimization: Start loading data immediately without waiting for full load + useEffect(() => { + let timeoutId: NodeJS.Timeout | null = null; + let progressTimerId: NodeJS.Timeout | null = null; + let ultraEarlyTimerId: NodeJS.Timeout | null = null; + + if (isAuthenticated && loading) { + // Ultra-early timeout - show content after just 500ms if we have ANY data at all + ultraEarlyTimerId = setTimeout(() => { + if (entries.length > 0 || stats.followersCount > 0 || stats.followingCount > 0) { + console.log(`[${Platform.OS}] Ultra-early content display with partial data`); + setFeedLoading(false); + } + }, 500); + + // Very early timeout - after 1s, force content to display if we have any data + progressTimerId = setTimeout(() => { + console.log(`[${Platform.OS}] Early timeout: Forcing content display after 1s`); + setFeedLoading(false); + }, 1000); + + // Final safety timeout - much shorter than before + const timeoutDuration = Platform.OS === 'ios' ? 3000 : 4000; // 3s for iOS, 4s for Android + + timeoutId = setTimeout(() => { + console.log(`[${Platform.OS}] Final safety timeout triggered after ${timeoutDuration}ms`); + setLoadAttempts(prev => prev + 1); + setFeedLoading(false); + + // Try refreshing in parallel for faster results + Promise.all([ + refresh().catch(e => console.error(`[${Platform.OS}] Feed refresh error:`, e)), + refetchBanner().catch(e => console.error(`[${Platform.OS}] Banner refresh error:`, e)), + stats.refresh?.().catch(e => console.error(`[${Platform.OS}] Stats refresh error:`, e)) + ]).catch(e => { + console.error(`[${Platform.OS}] Refresh error:`, e); + }); + }, timeoutDuration); + } + + return () => { + if (ultraEarlyTimerId) clearTimeout(ultraEarlyTimerId); + if (progressTimerId) clearTimeout(progressTimerId); + if (timeoutId) clearTimeout(timeoutId); + }; + }, [isAuthenticated, loading, refresh, entries.length, stats, refetchBanner]); + + // Update feedItems when socialFeed.feedItems changes + useEffect(() => { + if (isAuthenticated && socialFeed) { + setFeedItems(socialFeed.feedItems); + setIsOffline(socialFeed.isOffline); + } else { + // Clear feed items when logged out + setFeedItems([]); + } + }, [isAuthenticated, socialFeed?.feedItems, socialFeed?.isOffline]); + + // Process feedItems into entries when feedItems changes + useEffect(() => { + if (!feedItems || !Array.isArray(feedItems)) { + setEntries([]); + return; + } + + // Map items and filter out any nulls + const mappedItems = feedItems.map(item => { + if (!item) return null; + + // Create a properly typed AnyFeedEntry based on the item type + // with null safety for all item properties + const baseEntry = { + id: item.id || `temp-${Date.now()}-${Math.random()}`, + eventId: item.id || `temp-${Date.now()}-${Math.random()}`, + event: item.originalEvent || {}, + timestamp: ((item.createdAt || Math.floor(Date.now() / 1000)) * 1000), + }; + + // Add type-specific properties + switch (item.type) { + case 'workout': + return { + ...baseEntry, + type: 'workout', + content: item.parsedContent || {} + } as WorkoutFeedEntry; + + case 'exercise': + return { + ...baseEntry, + type: 'exercise', + content: item.parsedContent || {} + } as ExerciseFeedEntry; + + case 'template': + return { + ...baseEntry, + type: 'template', + content: item.parsedContent || {} + } as TemplateFeedEntry; + + case 'social': + return { + ...baseEntry, + type: 'social', + content: item.parsedContent || {} + } as SocialFeedEntry; + + case 'article': + return { + ...baseEntry, + type: 'article', + content: item.parsedContent || {} + } as ArticleFeedEntry; + + default: + // Fallback to social type if unknown + return { + ...baseEntry, + type: 'social', + content: item.parsedContent || {} + } as SocialFeedEntry; + } + }); + + // Filter out nulls to satisfy TypeScript + const filteredEntries = mappedItems.filter((item): item is AnyFeedEntry => item !== null); + setEntries(filteredEntries); + }, [feedItems]); + + // Determine current render state - even more aggressive about showing content early + const renderState: RenderState = !isAuthenticated + ? 'login' + : renderError ? 'error' + : (entries.length > 0 || stats.followersCount > 0 || stats.followingCount > 0) ? 'content' // Show content as soon as ANY data is available + : (loading && loadAttempts < 2) ? 'loading' + : 'content'; // Fallback to content even if loading to avoid stuck loading screen + + // Combined refresh function for refreshing all data + const refreshAll = useCallback(async () => { + console.log(`[${Platform.OS}] Starting full profile refresh...`); + try { + // Create an array of refresh promises to run in parallel + const refreshPromises = []; + + // Refresh feed content + if (refresh) { + refreshPromises.push( + refresh() + .catch(error => console.error(`[${Platform.OS}] Error refreshing feed:`, error)) + ); + } + + // Refresh profile stats + if (stats.refresh) { + refreshPromises.push( + stats.refresh() + .catch(error => console.error(`[${Platform.OS}] Error refreshing profile stats:`, error)) + ); + } + + // Refresh banner image + if (refetchBanner) { + refreshPromises.push( + refetchBanner() + .catch(error => console.error(`[${Platform.OS}] Error refreshing banner image:`, error)) + ); + } + + // Wait for all refresh operations to complete + await Promise.all(refreshPromises); + + console.log(`[${Platform.OS}] Profile refresh completed successfully`); + } catch (error) { + console.error(`[${Platform.OS}] Error during profile refresh:`, error); + } + }, [refresh, stats.refresh, refetchBanner]); + + // Return all the data and functions needed by the profile screen + return { + isAuthenticated, + currentUser, + stats: { + followersCount: stats.followersCount, + followingCount: stats.followingCount, + refresh: stats.refresh, + isLoading: stats.isLoading, + }, + bannerImage: { + url: bannerImageUrl, + defaultUrl: defaultBannerUrl, + refetch: refetchBanner, + }, + feed: { + entries, + loading, + isOffline, + refresh, + }, + renderState, + renderError, + refreshAll, + loadAttempts, + setRenderError, + }; +} diff --git a/lib/hooks/useProfileStats.ts b/lib/hooks/useProfileStats.ts index 92b94e1..7fa4201 100644 --- a/lib/hooks/useProfileStats.ts +++ b/lib/hooks/useProfileStats.ts @@ -52,13 +52,13 @@ export function useProfileStats(options: UseProfileStatsOptions = {}) { refetchInterval: refreshInterval > 0 ? refreshInterval : 30000, // 30 seconds default on Android }, ios: { - // More aggressive settings for iOS + // More aggressive settings for iOS with reduced timeout staleTime: 0, // No stale time - always refetch when used gcTime: 2 * 60 * 1000, // 2 minutes - retry: 3, // More retries on iOS + retry: 2, // Reduced retries on iOS to avoid hanging retryDelay: 1000, // 1 second between retries - timeout: 10000, // 10 second timeout for iOS - refetchInterval: refreshInterval > 0 ? refreshInterval : 10000, // 10 seconds default on iOS + timeout: 6000, // Reduced to 6 second timeout for iOS (was 10s) + refetchInterval: refreshInterval > 0 ? refreshInterval : 15000, // 15 seconds default on iOS (was 10s) }, default: { // Fallback settings @@ -88,13 +88,19 @@ export function useProfileStats(options: UseProfileStatsOptions = {}) { logger.info(`[${platform}] Fetching profile stats for ${pubkey?.substring(0, 8)}...`); try { - // Create timeout that works with AbortSignal + // Create our own abort controller that we can trigger manually on timeout + const timeoutController = new AbortController(); + + // Configure timeout const timeoutId = setTimeout(() => { if (!signal.aborted && isMounted.current) { logger.warn(`[${platform}] Profile stats fetch timed out after ${platformConfig.timeout}ms`); - // Create a controller to manually abort if we hit our timeout - const abortController = new AbortController(); - abortController.abort(); + // Abort our manual controller to cancel the fetch + try { + timeoutController.abort(); + } catch (e) { + logger.error(`[${platform}] Error aborting fetch: ${e}`); + } } }, platformConfig.timeout); @@ -130,18 +136,16 @@ export function useProfileStats(options: UseProfileStatsOptions = {}) { } catch (error) { logger.error(`[${platform}] Error fetching profile stats: ${error}`); - // On Android, return fallback values rather than throwing - if (platform === 'Android') { - return { - pubkey, - followersCount: 0, - followingCount: 0, - isLoading: false, - error: error instanceof Error ? error : new Error(String(error)) - }; - } - - throw error; + // On any platform, return fallback values rather than throwing + // This prevents the UI from hanging in error states + logger.warn(`[${platform}] Returning fallback stats after error`); + return { + pubkey, + followersCount: 0, + followingCount: 0, + isLoading: false, + error: error instanceof Error ? error : new Error(String(error)) + }; } }, // Configuration based on platform