mirror of
https://github.com/DocNR/POWR.git
synced 2025-04-23 01:01:27 +00:00
feat(profile): Improve profile loading performance with tiered display system
- Add ultra-early content display after just 500ms with ANY data available - Implement progressive content loading with three-tier timeout system - Reduce timeouts from 5s to 4s on Android and 4s to 3s on iOS - Enhance render state logic to prioritize partial content display - Improve parallel data loading for all profile elements - Add multiple fallback timers to ensure content always displays - Update CHANGELOG.md with detailed performance improvements This commit dramatically improves perceived performance by showing content as soon as it becomes available rather than waiting for complete data load.
This commit is contained in:
parent
e6f1677d2c
commit
c441c5afa5
170
CHANGELOG.md
170
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 user experience during temporary API failures
|
||||||
|
|
||||||
### Improved
|
### 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
|
- Console logging system
|
||||||
- Implemented configurable module-level logging controls
|
- Implemented configurable module-level logging controls
|
||||||
- Added quiet mode toggle for easier troubleshooting
|
- 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
|
- Created comprehensive logging documentation
|
||||||
|
|
||||||
### Fixed
|
### 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
|
- Android profile screen hanging issues
|
||||||
- Fixed infinite loading state on profile screen with proper timeouts
|
- Fixed infinite loading state on profile screen with proper timeouts
|
||||||
- Enhanced NostrBandService with AbortController and abort signal support
|
- Enhanced NostrBandService with AbortController and abort signal support
|
||||||
@ -690,117 +745,4 @@ g
|
|||||||
- Overall data persistence architecture
|
- Overall data persistence architecture
|
||||||
- Consistent service-based approach
|
- Consistent service-based approach
|
||||||
- Improved type safety
|
- Improved type safety
|
||||||
- Enhanced error propagation
|
- Enhanced error
|
||||||
|
|
||||||
# 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
|
|
||||||
|
@ -1,399 +1,44 @@
|
|||||||
// app/(tabs)/profile/overview.tsx
|
// app/(tabs)/profile/overview.tsx
|
||||||
import React, { useState, useCallback, useEffect } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { View, FlatList, RefreshControl, Pressable, TouchableOpacity, ImageBackground, Clipboard, Platform } from 'react-native';
|
import { View, ActivityIndicator } from 'react-native';
|
||||||
import { Text } from '@/components/ui/text';
|
import { Text } from '@/components/ui/text';
|
||||||
import { Button } from '@/components/ui/button';
|
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 NostrProfileLogin from '@/components/social/NostrProfileLogin';
|
||||||
import EnhancedSocialPost from '@/components/social/EnhancedSocialPost';
|
import ProfileFeed from '@/components/profile/ProfileFeed';
|
||||||
import EmptyFeed from '@/components/social/EmptyFeed';
|
import { useProfilePageData } from '@/lib/hooks/useProfilePageData';
|
||||||
import { useSocialFeed } from '@/lib/hooks/useSocialFeed';
|
import type { AnyFeedEntry } from '@/types/feed';
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
export default function OverviewScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
// Use our custom hook for all data needs (always calls hooks in same order)
|
||||||
const router = useRouter();
|
|
||||||
const theme = useTheme() as CustomTheme;
|
|
||||||
const { currentUser, isAuthenticated } = useNDKCurrentUser();
|
|
||||||
|
|
||||||
// Initialize all state hooks at the top to maintain consistent ordering
|
|
||||||
const [isLoginSheetOpen, setIsLoginSheetOpen] = useState(false);
|
|
||||||
const [feedItems, setFeedItems] = useState<any[]>([]);
|
|
||||||
const [feedLoading, setFeedLoading] = useState(false);
|
|
||||||
const [isOffline, setIsOffline] = useState(false);
|
|
||||||
const [entries, setEntries] = useState<AnyFeedEntry[]>([]);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
const [renderError, setRenderError] = useState<Error | null>(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 {
|
const {
|
||||||
followersCount,
|
isAuthenticated,
|
||||||
followingCount,
|
currentUser,
|
||||||
isLoading: statsLoading,
|
stats,
|
||||||
refresh: refreshStats,
|
bannerImage,
|
||||||
lastRefreshed: statsLastRefreshed
|
feed,
|
||||||
} = useProfileStats({
|
renderState,
|
||||||
pubkey: pubkey || '',
|
renderError,
|
||||||
refreshInterval: 10000 // 10 second refresh interval for real-time updates
|
refreshAll,
|
||||||
});
|
setRenderError
|
||||||
|
} = useProfilePageData();
|
||||||
|
|
||||||
// Track last fetch time to force component updates
|
// Track refreshing state
|
||||||
const [lastStatsFetch, setLastStatsFetch] = useState<number>(Date.now());
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
// Update the lastStatsFetch whenever stats are refreshed
|
// Handle refresh
|
||||||
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 (
|
|
||||||
<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">{followingDisplay}</Text>
|
|
||||||
<Text className="text-muted-foreground"> following</Text>
|
|
||||||
</Text>
|
|
||||||
{isManuallyRefreshing && (
|
|
||||||
<ActivityIndicator size="small" style={{ marginLeft: 4 }} />
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
className="py-1 flex-row items-center"
|
|
||||||
onPress={triggerManualRefresh}
|
|
||||||
disabled={isManuallyRefreshing}
|
|
||||||
accessibilityLabel="Refresh follower stats"
|
|
||||||
>
|
|
||||||
<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(() => {
|
|
||||||
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
|
|
||||||
const handleRefresh = useCallback(async () => {
|
const handleRefresh = useCallback(async () => {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
try {
|
try {
|
||||||
console.log(`[${Platform.OS}] Starting full profile refresh...`);
|
await refreshAll();
|
||||||
|
|
||||||
// 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);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
}
|
}
|
||||||
}, [resetFeed, refreshStats, refetchBannerImage, followersCount, followingCount, setLastStatsFetch]);
|
}, [refreshAll]);
|
||||||
|
|
||||||
// Handle post selection
|
// Handle post selection
|
||||||
const handlePostPress = useCallback((entry: AnyFeedEntry) => {
|
const handlePostPress = useCallback((entry: AnyFeedEntry) => {
|
||||||
@ -401,207 +46,14 @@ export default function OverviewScreen() {
|
|||||||
console.log(`Selected ${entry.type}:`, entry);
|
console.log(`Selected ${entry.type}:`, entry);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Copy npub to clipboard
|
// Render the login screen
|
||||||
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 }) => (
|
|
||||||
<EnhancedSocialPost
|
|
||||||
item={convertToLegacyFeedItem(item)}
|
|
||||||
onPress={() => 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 (
|
|
||||||
<View>
|
|
||||||
{/* Banner Image */}
|
|
||||||
<View className="w-full h-40 relative">
|
|
||||||
{bannerImageUrl ? (
|
|
||||||
<ImageBackground
|
|
||||||
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-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>
|
|
||||||
|
|
||||||
<View className="px-4 -mt-16 pb-2">
|
|
||||||
<View className="flex-row items-end mb-4">
|
|
||||||
{/* Left side - Avatar */}
|
|
||||||
<UserAvatar
|
|
||||||
size="xl"
|
|
||||||
uri={profileImageUrl}
|
|
||||||
pubkey={pubkey}
|
|
||||||
name={displayName}
|
|
||||||
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 */}
|
|
||||||
<View className="ml-auto mb-2">
|
|
||||||
<TouchableOpacity
|
|
||||||
className="px-4 h-10 items-center justify-center rounded-md bg-muted"
|
|
||||||
onPress={handleEditProfilePress}
|
|
||||||
>
|
|
||||||
<Text className="font-medium">Edit Profile</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Profile info */}
|
|
||||||
<View>
|
|
||||||
<Text className="text-xl font-bold">{displayName}</Text>
|
|
||||||
<Text className="text-muted-foreground">{username}</Text>
|
|
||||||
|
|
||||||
{/* Display npub below username with sharing options */}
|
|
||||||
{npubFormat && (
|
|
||||||
<View className="flex-row items-center mt-1 mb-2">
|
|
||||||
<Text className="text-xs text-muted-foreground font-mono">
|
|
||||||
{shortenedNpub}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="ml-2 p-1"
|
|
||||||
onPress={handleCopyButtonPress}
|
|
||||||
accessibilityLabel="Copy public key"
|
|
||||||
accessibilityHint="Copies your Nostr public key to clipboard"
|
|
||||||
>
|
|
||||||
<Copy size={12} color={theme.colors.text} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="ml-2 p-1"
|
|
||||||
onPress={handleQrButtonPress}
|
|
||||||
accessibilityLabel="Show QR Code"
|
|
||||||
accessibilityHint="Shows a QR code with your Nostr public key"
|
|
||||||
>
|
|
||||||
<QrCode size={12} color={theme.colors.text} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Follower stats - render the separated component to avoid hook ordering issues */}
|
|
||||||
<ProfileFollowerStats />
|
|
||||||
|
|
||||||
{/* About text */}
|
|
||||||
{aboutText && (
|
|
||||||
<Text className="mb-3">{aboutText}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<View className="h-px bg-border w-full mt-2" />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
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(() => {
|
const renderLoginScreen = useCallback(() => {
|
||||||
return (
|
return (
|
||||||
<NostrProfileLogin message="Login with your Nostr private key to view your profile and posts." />
|
<NostrProfileLogin message="Login with your Nostr private key to view your profile and posts." />
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Render loading screen
|
||||||
const renderLoadingScreen = useCallback(() => {
|
const renderLoadingScreen = useCallback(() => {
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 items-center justify-center">
|
<View className="flex-1 items-center justify-center">
|
||||||
@ -610,102 +62,76 @@ export default function OverviewScreen() {
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Render error screen
|
||||||
|
const renderErrorScreen = useCallback(() => {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center p-4">
|
||||||
|
<Text className="text-lg font-bold mb-2">Something went wrong</Text>
|
||||||
|
<Text className="text-sm text-muted-foreground mb-4 text-center">
|
||||||
|
{renderError?.message || "We had trouble loading your profile. Please try again."}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
setRenderError(null);
|
||||||
|
handleRefresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [renderError, handleRefresh, setRenderError]);
|
||||||
|
|
||||||
|
// Render the main content
|
||||||
const renderMainContent = useCallback(() => {
|
const renderMainContent = useCallback(() => {
|
||||||
// Catch and recover from any rendering errors
|
// Create a wrapper for the stats refresh function to match expected Promise<void> type
|
||||||
|
const refreshStatsWrapper = async () => {
|
||||||
|
if (stats.refresh) {
|
||||||
try {
|
try {
|
||||||
return (
|
await stats.refresh();
|
||||||
<View className="flex-1">
|
|
||||||
<FlatList
|
|
||||||
data={entries}
|
|
||||||
keyExtractor={(item) => item.id}
|
|
||||||
renderItem={renderItem}
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl
|
|
||||||
refreshing={isRefreshing}
|
|
||||||
onRefresh={handleRefresh}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
ListHeaderComponent={<ProfileHeader />}
|
|
||||||
ListEmptyComponent={
|
|
||||||
<View className="px-4 py-8">
|
|
||||||
<EmptyFeed message="No posts yet. Share your workouts or create posts to see them here." />
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingBottom: insets.bottom + 20,
|
|
||||||
flexGrow: entries.length === 0 ? 1 : undefined
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setRenderError(error instanceof Error ? error : new Error(String(error)));
|
console.error('Error refreshing stats:', error);
|
||||||
// Fallback UI when rendering fails
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 items-center justify-center p-4">
|
<ProfileFeed
|
||||||
<Text className="text-lg font-bold mb-2">Something went wrong</Text>
|
feedEntries={feed.entries}
|
||||||
<Text className="text-sm text-muted-foreground mb-4 text-center">
|
isRefreshing={isRefreshing}
|
||||||
We had trouble loading your profile. Please try again.
|
onRefresh={handleRefresh}
|
||||||
</Text>
|
onPostPress={handlePostPress}
|
||||||
<Button
|
user={currentUser}
|
||||||
onPress={() => {
|
bannerImageUrl={bannerImage.url || undefined}
|
||||||
setRenderError(null);
|
defaultBannerUrl={bannerImage.defaultUrl}
|
||||||
handleRefresh();
|
followersCount={stats.followersCount}
|
||||||
}}
|
followingCount={stats.followingCount}
|
||||||
>
|
refreshStats={refreshStatsWrapper}
|
||||||
Retry
|
isStatsLoading={stats.isLoading}
|
||||||
</Button>
|
/>
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}, [
|
||||||
}, [entries, renderItem, isRefreshing, handleRefresh, ProfileHeader, insets.bottom]);
|
feed.entries,
|
||||||
|
isRefreshing,
|
||||||
|
handleRefresh,
|
||||||
|
handlePostPress,
|
||||||
|
currentUser,
|
||||||
|
bannerImage.url,
|
||||||
|
bannerImage.defaultUrl,
|
||||||
|
stats.followersCount,
|
||||||
|
stats.followingCount,
|
||||||
|
stats.refresh,
|
||||||
|
stats.isLoading
|
||||||
|
]);
|
||||||
|
|
||||||
// Final conditional return after all hooks have been called
|
// SINGLE RETURN STATEMENT with conditional rendering
|
||||||
// This ensures consistent hook ordering across renders
|
// This avoids hook ordering issues by ensuring all hooks are always called
|
||||||
if (!isAuthenticated) {
|
|
||||||
return renderLoginScreen();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (renderError) {
|
|
||||||
// Render error recovery UI
|
|
||||||
return (
|
|
||||||
<View className="flex-1 items-center justify-center p-4">
|
|
||||||
<Text className="text-lg font-bold mb-2">Something went wrong</Text>
|
|
||||||
<Text className="text-sm text-muted-foreground mb-4 text-center">
|
|
||||||
{renderError.message || "We had trouble loading your profile. Please try again."}
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
setRenderError(null);
|
|
||||||
handleRefresh();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (
|
return (
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<ProfileHeader />
|
{renderState === 'login' && renderLoginScreen()}
|
||||||
<View className="flex-1 items-center justify-center p-4">
|
{renderState === 'loading' && renderLoadingScreen()}
|
||||||
<Text className="mb-4 text-center">
|
{renderState === 'error' && renderErrorScreen()}
|
||||||
Some content may still be loading.
|
{renderState === 'content' && renderMainContent()}
|
||||||
</Text>
|
|
||||||
<Button onPress={handleRefresh}>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return renderLoadingScreen();
|
|
||||||
}
|
|
||||||
|
|
||||||
return renderMainContent();
|
|
||||||
}
|
|
||||||
|
107
components/profile/ProfileFeed.tsx
Normal file
107
components/profile/ProfileFeed.tsx
Normal file
@ -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<void>;
|
||||||
|
isStatsLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profile feed component that displays the user's posts
|
||||||
|
* Pure presentational component with performance optimizations
|
||||||
|
*/
|
||||||
|
const ProfileFeed: React.FC<ProfileFeedProps> = ({
|
||||||
|
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 }) => (
|
||||||
|
<EnhancedSocialPost
|
||||||
|
item={convertToLegacyFeedItem(item)}
|
||||||
|
onPress={() => onPostPress(item)}
|
||||||
|
/>
|
||||||
|
), [onPostPress]);
|
||||||
|
|
||||||
|
// Performance optimization: memoize key extractor
|
||||||
|
const keyExtractor = useCallback((item: AnyFeedEntry) => item.id, []);
|
||||||
|
|
||||||
|
// Performance optimization: memoize header component
|
||||||
|
const ListHeaderComponent = useMemo(() => (
|
||||||
|
<ProfileHeader
|
||||||
|
user={user}
|
||||||
|
bannerImageUrl={bannerImageUrl}
|
||||||
|
defaultBannerUrl={defaultBannerUrl}
|
||||||
|
followersCount={followersCount}
|
||||||
|
followingCount={followingCount}
|
||||||
|
refreshStats={refreshStats}
|
||||||
|
isStatsLoading={isStatsLoading}
|
||||||
|
/>
|
||||||
|
), [user, bannerImageUrl, defaultBannerUrl, followersCount, followingCount, refreshStats, isStatsLoading]);
|
||||||
|
|
||||||
|
// Performance optimization: memoize empty component
|
||||||
|
const ListEmptyComponent = useMemo(() => (
|
||||||
|
<View className="px-4 py-8">
|
||||||
|
<EmptyFeed message="No posts yet. Share your workouts or create posts to see them here." />
|
||||||
|
</View>
|
||||||
|
), []);
|
||||||
|
|
||||||
|
// Performance optimization: memoize content container style
|
||||||
|
const contentContainerStyle = useMemo(() => ({
|
||||||
|
paddingBottom: insets.bottom + 20,
|
||||||
|
flexGrow: feedEntries.length === 0 ? 1 : undefined
|
||||||
|
}), [insets.bottom, feedEntries.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
data={feedEntries}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
renderItem={renderItem}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
|
ListEmptyComponent={ListEmptyComponent}
|
||||||
|
contentContainerStyle={contentContainerStyle}
|
||||||
|
|
||||||
|
// Performance optimizations for FlatList
|
||||||
|
removeClippedSubviews={true}
|
||||||
|
maxToRenderPerBatch={5}
|
||||||
|
initialNumToRender={8}
|
||||||
|
windowSize={5}
|
||||||
|
updateCellsBatchingPeriod={50}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileFeed;
|
293
components/profile/ProfileHeader.tsx
Normal file
293
components/profile/ProfileHeader.tsx
Normal file
@ -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<void>;
|
||||||
|
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<ProfileHeaderProps> = ({
|
||||||
|
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 (
|
||||||
|
<View>
|
||||||
|
{/* Banner Image */}
|
||||||
|
<View className="w-full h-40 relative">
|
||||||
|
{bannerImageUrl ? (
|
||||||
|
<ImageBackground
|
||||||
|
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}`);
|
||||||
|
}}
|
||||||
|
onLoad={() => {
|
||||||
|
console.log(`Banner image loaded successfully: ${bannerImageUrl}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="absolute inset-0 bg-black/20" />
|
||||||
|
</ImageBackground>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<View className="px-4 -mt-16 pb-2">
|
||||||
|
<View className="flex-row items-end mb-4">
|
||||||
|
{/* Left side - Avatar */}
|
||||||
|
<UserAvatar
|
||||||
|
size="xl"
|
||||||
|
uri={profileImageUrl}
|
||||||
|
pubkey={pubkey}
|
||||||
|
name={displayName}
|
||||||
|
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 */}
|
||||||
|
<View className="ml-auto mb-2">
|
||||||
|
<TouchableOpacity
|
||||||
|
className="px-4 h-10 items-center justify-center rounded-md bg-muted"
|
||||||
|
onPress={handleEditProfilePress}
|
||||||
|
>
|
||||||
|
<Text className="font-medium">Edit Profile</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Profile info */}
|
||||||
|
<View>
|
||||||
|
<Text className="text-xl font-bold">{displayName}</Text>
|
||||||
|
<Text className="text-muted-foreground">{username}</Text>
|
||||||
|
|
||||||
|
{/* Display npub below username with sharing options */}
|
||||||
|
{npubFormat && (
|
||||||
|
<View className="flex-row items-center mt-1 mb-2">
|
||||||
|
<Text className="text-xs text-muted-foreground font-mono">
|
||||||
|
{shortenedNpub}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="ml-2 p-1"
|
||||||
|
onPress={handleCopyButtonPress}
|
||||||
|
accessibilityLabel="Copy public key"
|
||||||
|
accessibilityHint="Copies your Nostr public key to clipboard"
|
||||||
|
>
|
||||||
|
<Copy size={12} color={theme.colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="ml-2 p-1"
|
||||||
|
onPress={handleQrButtonPress}
|
||||||
|
accessibilityLabel="Show QR Code"
|
||||||
|
accessibilityHint="Shows a QR code with your Nostr public key"
|
||||||
|
>
|
||||||
|
<QrCode size={12} color={theme.colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Follower stats */}
|
||||||
|
<ProfileStats
|
||||||
|
followersCount={followersCount}
|
||||||
|
followingCount={followingCount}
|
||||||
|
refreshStats={refreshStats}
|
||||||
|
isLoading={isStatsLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* About text */}
|
||||||
|
{aboutText && (
|
||||||
|
<Text className="mb-3">{aboutText}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<View className="h-px bg-border w-full mt-2" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ProfileStats subcomponent
|
||||||
|
interface ProfileStatsProps {
|
||||||
|
followersCount: number;
|
||||||
|
followingCount: number;
|
||||||
|
refreshStats: () => Promise<void>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfileStats: React.FC<ProfileStatsProps> = ({
|
||||||
|
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 (
|
||||||
|
<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">{followingDisplay}</Text>
|
||||||
|
<Text className="text-muted-foreground"> following</Text>
|
||||||
|
</Text>
|
||||||
|
{isManuallyRefreshing && (
|
||||||
|
<ActivityIndicator size="small" style={{ marginLeft: 4 }} />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className="py-1 flex-row items-center"
|
||||||
|
onPress={triggerManualRefresh}
|
||||||
|
disabled={isManuallyRefreshing}
|
||||||
|
accessibilityLabel="Refresh follower stats"
|
||||||
|
>
|
||||||
|
<Text>
|
||||||
|
<Text className="font-bold">{followersDisplay}</Text>
|
||||||
|
<Text className="text-muted-foreground"> followers</Text>
|
||||||
|
</Text>
|
||||||
|
{isManuallyRefreshing && (
|
||||||
|
<ActivityIndicator size="small" style={{ marginLeft: 4 }} />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileHeader;
|
78
docs/technical/auth/auth_fixes_summary.md
Normal file
78
docs/technical/auth/auth_fixes_summary.md
Normal file
@ -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
|
@ -5,6 +5,14 @@ import { NDKAmberSigner } from '../signers/NDKAmberSigner';
|
|||||||
import { generateId, generateDTag } from '@/utils/ids';
|
import { generateId, generateDTag } from '@/utils/ids';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { AuthMethod } from './types';
|
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
|
* Auth Service for managing authentication with NDK and React Query
|
||||||
@ -19,78 +27,185 @@ import { AuthMethod } from './types';
|
|||||||
export class AuthService {
|
export class AuthService {
|
||||||
private ndk: NDK;
|
private ndk: NDK;
|
||||||
private initialized: boolean = false;
|
private initialized: boolean = false;
|
||||||
|
private initPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
constructor(ndk: NDK) {
|
constructor(ndk: NDK) {
|
||||||
this.ndk = ndk;
|
this.ndk = ndk;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the auth service
|
* Initialize the auth service - with improved error handling
|
||||||
* This is called automatically by the ReactQueryAuthProvider
|
* This is called automatically by the ReactQueryAuthProvider
|
||||||
*/
|
*/
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
|
// Single initialization pattern with promise caching
|
||||||
|
if (this.initPromise) {
|
||||||
|
logger.debug("Initialization already in progress, waiting for completion");
|
||||||
|
return this.initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.initialized) {
|
if (this.initialized) {
|
||||||
|
logger.debug("Already initialized, skipping");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a promise we can return for concurrent calls
|
||||||
|
this.initPromise = this._doInitialize();
|
||||||
try {
|
try {
|
||||||
// Check if we have credentials stored
|
await this.initPromise;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login with a private key
|
* Internal method that does the actual initialization work
|
||||||
|
*/
|
||||||
|
private async _doInitialize(): Promise<void> {
|
||||||
|
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 privateKey hex private key
|
||||||
|
* @param saveKey whether to save the key to SecureStore (default true)
|
||||||
* @returns NDK user
|
* @returns NDK user
|
||||||
*/
|
*/
|
||||||
async loginWithPrivateKey(privateKey: string): Promise<NDKUser> {
|
async loginWithPrivateKey(privateKey: string, saveKey: boolean = true): Promise<NDKUser> {
|
||||||
try {
|
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
|
// Create signer
|
||||||
const signer = new NDKPrivateKeySigner(privateKey);
|
const signer = new NDKPrivateKeySigner(privateKey);
|
||||||
this.ndk.signer = signer;
|
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();
|
await this.ndk.connect();
|
||||||
|
|
||||||
if (!this.ndk.activeUser) {
|
if (!this.ndk.activeUser) {
|
||||||
|
logger.error(`[${platform}] NDK connect succeeded but activeUser is null`);
|
||||||
throw new Error('Failed to set active user after login');
|
throw new Error('Failed to set active user after login');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist the key securely
|
const pubkeyFragment = this.ndk.activeUser.pubkey.substring(0, 8);
|
||||||
await SecureStore.setItemAsync('powr.private_key', privateKey);
|
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;
|
return this.ndk.activeUser;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AuthService] Error logging in with private key:', error);
|
logger.error("Error logging in with private key:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login with Amber external signer
|
* Login with Amber external signer - enhanced error handling
|
||||||
* @returns NDK user
|
* @returns NDK user
|
||||||
*/
|
*/
|
||||||
async loginWithAmber(): Promise<NDKUser> {
|
async loginWithAmber(): Promise<NDKUser> {
|
||||||
try {
|
try {
|
||||||
|
logger.info("Requesting public key from Amber");
|
||||||
// Request public key from Amber
|
// Request public key from Amber
|
||||||
const { pubkey, packageName } = await NDKAmberSigner.requestPublicKey();
|
const { pubkey, packageName } = await NDKAmberSigner.requestPublicKey();
|
||||||
|
|
||||||
|
logger.debug("Creating Amber signer with pubkey:", pubkey);
|
||||||
// Create Amber signer
|
// Create Amber signer
|
||||||
const amberSigner = new NDKAmberSigner(pubkey, packageName);
|
const amberSigner = new NDKAmberSigner(pubkey, packageName);
|
||||||
|
|
||||||
@ -98,11 +213,15 @@ export class AuthService {
|
|||||||
this.ndk.signer = amberSigner;
|
this.ndk.signer = amberSigner;
|
||||||
|
|
||||||
// Connect and get user
|
// Connect and get user
|
||||||
|
logger.debug("Connecting to NDK with Amber signer");
|
||||||
await this.ndk.connect();
|
await this.ndk.connect();
|
||||||
if (!this.ndk.activeUser) {
|
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');
|
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
|
// Store the signer info
|
||||||
const signerData = {
|
const signerData = {
|
||||||
pubkey: pubkey,
|
pubkey: pubkey,
|
||||||
@ -113,12 +232,14 @@ export class AuthService {
|
|||||||
data: signerData
|
data: signerData
|
||||||
});
|
});
|
||||||
|
|
||||||
await SecureStore.setItemAsync('nostr_external_signer', externalSignerInfo);
|
logger.debug("Saving Amber signer data to SecureStore");
|
||||||
await SecureStore.deleteItemAsync('powr.private_key'); // Clear any stored private key
|
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;
|
return this.ndk.activeUser;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AuthService] Error logging in with Amber:', error);
|
logger.error("Error logging in with Amber:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -130,6 +251,7 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
private async restoreAmberSigner(signerData: any): Promise<NDKUser> {
|
private async restoreAmberSigner(signerData: any): Promise<NDKUser> {
|
||||||
try {
|
try {
|
||||||
|
logger.debug("Restoring Amber signer with data:", signerData);
|
||||||
// Create Amber signer with existing data
|
// Create Amber signer with existing data
|
||||||
const amberSigner = new NDKAmberSigner(signerData.pubkey, signerData.packageName);
|
const amberSigner = new NDKAmberSigner(signerData.pubkey, signerData.packageName);
|
||||||
|
|
||||||
@ -137,14 +259,18 @@ export class AuthService {
|
|||||||
this.ndk.signer = amberSigner;
|
this.ndk.signer = amberSigner;
|
||||||
|
|
||||||
// Connect and get user
|
// Connect and get user
|
||||||
|
logger.debug("Connecting to NDK with restored Amber signer");
|
||||||
await this.ndk.connect();
|
await this.ndk.connect();
|
||||||
if (!this.ndk.activeUser) {
|
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');
|
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;
|
return this.ndk.activeUser;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AuthService] Error restoring Amber signer:', error);
|
logger.error("Error restoring Amber signer:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -155,6 +281,7 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
async createEphemeralKey(): Promise<NDKUser> {
|
async createEphemeralKey(): Promise<NDKUser> {
|
||||||
try {
|
try {
|
||||||
|
logger.info("Creating ephemeral key");
|
||||||
// Generate a random key (not persisted)
|
// Generate a random key (not persisted)
|
||||||
// This creates a hex string of 64 characters (32 bytes)
|
// This creates a hex string of 64 characters (32 bytes)
|
||||||
// Use uuidv4 to generate random bytes
|
// Use uuidv4 to generate random bytes
|
||||||
@ -166,18 +293,24 @@ export class AuthService {
|
|||||||
this.ndk.signer = signer;
|
this.ndk.signer = signer;
|
||||||
|
|
||||||
// Connect and get user
|
// Connect and get user
|
||||||
|
logger.debug("Connecting to NDK with ephemeral key");
|
||||||
await this.ndk.connect();
|
await this.ndk.connect();
|
||||||
if (!this.ndk.activeUser) {
|
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');
|
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
|
// Clear any stored credentials
|
||||||
await SecureStore.deleteItemAsync('powr.private_key');
|
logger.debug("Clearing stored credentials for ephemeral 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);
|
||||||
|
|
||||||
return this.ndk.activeUser;
|
return this.ndk.activeUser;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AuthService] Error creating ephemeral key:', error);
|
logger.error("Error creating ephemeral key:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -187,18 +320,20 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
logger.info("Logging out user");
|
||||||
|
|
||||||
// Clear stored credentials
|
// Clear stored credentials
|
||||||
await SecureStore.deleteItemAsync('powr.private_key');
|
await SecureStore.deleteItemAsync(SECURE_STORE_KEYS.PRIVATE_KEY);
|
||||||
await SecureStore.deleteItemAsync('nostr_external_signer');
|
await SecureStore.deleteItemAsync(SECURE_STORE_KEYS.EXTERNAL_SIGNER);
|
||||||
|
await SecureStore.deleteItemAsync(SECURE_STORE_KEYS.PUBKEY);
|
||||||
|
|
||||||
// Reset NDK
|
// Reset NDK
|
||||||
this.ndk.signer = undefined;
|
this.ndk.signer = undefined;
|
||||||
|
|
||||||
// Simple cleanup for NDK instance
|
|
||||||
// NDK doesn't have a formal disconnect method
|
|
||||||
try {
|
|
||||||
// Clean up relay connections if they exist
|
// Clean up relay connections if they exist
|
||||||
|
try {
|
||||||
if (this.ndk.pool) {
|
if (this.ndk.pool) {
|
||||||
|
logger.debug("Cleaning up relay connections");
|
||||||
// Cast to any to bypass TypeScript errors with internal NDK API
|
// Cast to any to bypass TypeScript errors with internal NDK API
|
||||||
const pool = this.ndk.pool as any;
|
const pool = this.ndk.pool as any;
|
||||||
if (pool.relayByUrl) {
|
if (pool.relayByUrl) {
|
||||||
@ -206,18 +341,18 @@ export class AuthService {
|
|||||||
try {
|
try {
|
||||||
if (relay && relay.close) relay.close();
|
if (relay && relay.close) relay.close();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Error closing relay:', e);
|
logger.warn("Error closing relay:", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (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) {
|
} catch (error) {
|
||||||
console.error('[AuthService] Error during logout:', error);
|
logger.error("Error during logout:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -228,19 +363,23 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
async getCurrentAuthMethod(): Promise<AuthMethod | undefined> {
|
async getCurrentAuthMethod(): Promise<AuthMethod | undefined> {
|
||||||
try {
|
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';
|
return 'private_key';
|
||||||
}
|
}
|
||||||
|
|
||||||
const externalSignerJson = await SecureStore.getItemAsync('nostr_external_signer');
|
const externalSignerJson = await SecureStore.getItemAsync(SECURE_STORE_KEYS.EXTERNAL_SIGNER);
|
||||||
if (externalSignerJson) {
|
if (externalSignerJson) {
|
||||||
const { method } = JSON.parse(externalSignerJson);
|
const { method } = JSON.parse(externalSignerJson);
|
||||||
|
logger.debug(`Found external signer authentication: ${method}`);
|
||||||
return method === 'amber' ? 'amber' : undefined;
|
return method === 'amber' ? 'amber' : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug("No authentication method found");
|
||||||
return undefined;
|
return undefined;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AuthService] Error getting current auth method:', error);
|
logger.error("Error getting current auth method:", error);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { createQueryClient } from '../queryClient';
|
import { createQueryClient } from '../queryClient';
|
||||||
import NDK from '@nostr-dev-kit/ndk-mobile';
|
import NDK from '@nostr-dev-kit/ndk-mobile';
|
||||||
import { initializeNDK } from '@/lib/initNDK';
|
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
|
// Create context for NDK instance
|
||||||
export const NDKContext = createContext<{ ndk: NDK | null; isInitialized: boolean }>({
|
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.
|
* Main provider component for React Query integration with authentication.
|
||||||
* This component:
|
* This component:
|
||||||
* - Creates and configures the QueryClient
|
* - Creates and configures the QueryClient
|
||||||
* - Creates an NDK instance
|
* - Creates an NDK instance with proper credential restoration
|
||||||
* - Provides React Query context and NDK context
|
* - Provides React Query context and NDK context
|
||||||
* - Ensures consistent hook ordering regardless of initialization state
|
* - Ensures consistent hook ordering regardless of initialization state
|
||||||
*/
|
*/
|
||||||
@ -40,37 +46,63 @@ export function ReactQueryAuthProvider({
|
|||||||
const [ndk, setNdk] = useState<NDK | null>(null);
|
const [ndk, setNdk] = useState<NDK | null>(null);
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
|
||||||
|
// Track initialization attempts
|
||||||
|
const initAttemptRef = useRef(0);
|
||||||
|
|
||||||
// NDK context value (memoized to prevent unnecessary re-renders)
|
// NDK context value (memoized to prevent unnecessary re-renders)
|
||||||
const ndkContextValue = useMemo(() => ({
|
const ndkContextValue = useMemo(() => ({
|
||||||
ndk,
|
ndk,
|
||||||
isInitialized
|
isInitialized
|
||||||
}), [ndk, isInitialized]);
|
}), [ndk, isInitialized]);
|
||||||
|
|
||||||
// Initialize NDK only if enableNDK is true
|
// Enhanced initialization with credential checking
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip NDK initialization if enableNDK is false
|
// Skip NDK initialization if enableNDK is false
|
||||||
if (!enableNDK) {
|
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
|
setIsInitialized(true); // Still mark as initialized so the app can proceed
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track this initialization attempt
|
||||||
|
const currentAttempt = ++initAttemptRef.current;
|
||||||
|
|
||||||
const initNDK = async () => {
|
const initNDK = async () => {
|
||||||
try {
|
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');
|
const result = await initializeNDK('react-query');
|
||||||
|
|
||||||
|
// Update state only if this is still the most recent initialization attempt
|
||||||
|
if (currentAttempt === initAttemptRef.current) {
|
||||||
setNdk(result.ndk);
|
setNdk(result.ndk);
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
console.log('[ReactQueryAuthProvider] NDK initialized successfully');
|
logger.info("NDK initialized successfully");
|
||||||
|
|
||||||
|
// Force refetch auth state to ensure it's up to date
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['auth', 'current'] });
|
||||||
|
}
|
||||||
} catch (err) {
|
} 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
|
// Still mark as initialized so the app can handle the error state
|
||||||
|
if (currentAttempt === initAttemptRef.current) {
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
initNDK();
|
initNDK();
|
||||||
}, [enableOfflineMode, enableNDK]);
|
}, [enableOfflineMode, enableNDK, queryClient]);
|
||||||
|
|
||||||
// Always render children, regardless of NDK initialization status
|
// Always render children, regardless of NDK initialization status
|
||||||
// This ensures consistent hook ordering in child components
|
// This ensures consistent hook ordering in child components
|
||||||
|
18
lib/auth/constants.ts
Normal file
18
lib/auth/constants.ts
Normal file
@ -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';
|
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useContext } from 'react';
|
import { useCallback, useMemo, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
@ -12,7 +12,10 @@ import { AuthService } from '@/lib/auth/AuthService';
|
|||||||
import { useNDK } from './useNDK';
|
import { useNDK } from './useNDK';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
import { AuthMethod } from '@/lib/auth/types';
|
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
|
* 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.
|
* React Query-based hook for managing authentication state.
|
||||||
* Provides queries and mutations for working with user authentication.
|
* Provides queries and mutations for working with user authentication.
|
||||||
@ -56,17 +59,32 @@ export type LoginParams =
|
|||||||
* - Login with different methods (private key, Amber, ephemeral)
|
* - Login with different methods (private key, Amber, ephemeral)
|
||||||
* - Logout functionality
|
* - Logout functionality
|
||||||
* - Automatic revalidation of auth state
|
* - Automatic revalidation of auth state
|
||||||
|
* - Improved persistence across app restarts
|
||||||
*/
|
*/
|
||||||
export function useAuthQuery() {
|
export function useAuthQuery() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { ndk } = useNDK();
|
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<AuthService | null>(null);
|
||||||
|
|
||||||
// Create auth service (or a stub if NDK isn't ready)
|
// Create auth service (or a stub if NDK isn't ready)
|
||||||
const authService = useMemo(() => {
|
const authService = useMemo(() => {
|
||||||
if (!ndk) {
|
if (!ndk) {
|
||||||
// Return a placeholder that returns unauthenticated state
|
// Return a placeholder that returns unauthenticated state
|
||||||
// This prevents errors when NDK is still initializing
|
// 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
|
// Create a placeholder with just the methods we need for the query function
|
||||||
const placeholderService = {
|
const placeholderService = {
|
||||||
initialize: async () => {},
|
initialize: async () => {},
|
||||||
@ -82,23 +100,62 @@ export function useAuthQuery() {
|
|||||||
return placeholderService as unknown as AuthService;
|
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]);
|
}, [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<AuthState> = useQuery({
|
const authQuery: UseQueryResult<AuthState> = useQuery({
|
||||||
queryKey: QUERY_KEYS.auth.current(),
|
queryKey: QUERY_KEYS.auth.current(),
|
||||||
queryFn: async (): Promise<AuthState> => {
|
queryFn: async (): Promise<AuthState> => {
|
||||||
|
logger.debug("Running auth query function");
|
||||||
|
|
||||||
if (!ndk) {
|
if (!ndk) {
|
||||||
|
logger.debug("No NDK instance, returning unauthenticated");
|
||||||
return { status: 'unauthenticated' };
|
return { status: 'unauthenticated' };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Initialize auth service
|
// Initialize auth service (will be fast if already initialized)
|
||||||
|
logger.debug("Initializing auth service in query function");
|
||||||
await authService.initialize();
|
await authService.initialize();
|
||||||
|
|
||||||
// Check if user is authenticated
|
// Check if user is authenticated
|
||||||
if (ndk.activeUser) {
|
if (ndk.activeUser) {
|
||||||
|
logger.info("NDK has active user:", ndk.activeUser.pubkey);
|
||||||
const method = await authService.getCurrentAuthMethod();
|
const method = await authService.getCurrentAuthMethod();
|
||||||
return {
|
return {
|
||||||
status: 'authenticated',
|
status: 'authenticated',
|
||||||
@ -107,66 +164,91 @@ export function useAuthQuery() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug("No active user, returning unauthenticated");
|
||||||
return { status: 'unauthenticated' };
|
return { status: 'unauthenticated' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[useAuthQuery] Error getting auth state:', error);
|
logger.error("Error getting auth state:", error);
|
||||||
return { status: 'unauthenticated' };
|
return { status: 'unauthenticated' };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
staleTime: Infinity, // Auth state doesn't go stale by itself
|
// Adjusted query settings for better reliability
|
||||||
refetchOnWindowFocus: false,
|
staleTime: 10 * 60 * 1000, // 10 minutes - don't refetch too often
|
||||||
|
refetchOnWindowFocus: true, // Refresh when app comes to foreground
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: true, // Refresh when network reconnects
|
||||||
retry: false,
|
retry: 2, // Retry a couple times if fails
|
||||||
|
refetchInterval: false, // Don't auto-refresh continuously
|
||||||
});
|
});
|
||||||
|
|
||||||
// Login mutation
|
// Login mutation - with better success handling
|
||||||
const loginMutation: UseMutationResult<NDKUser, Error, LoginParams> = useMutation({
|
const loginMutation = useMutation<NDKUser, Error, LoginParams>({
|
||||||
mutationFn: async (params: LoginParams): Promise<NDKUser> => {
|
mutationFn: async (params: LoginParams): Promise<NDKUser> => {
|
||||||
if (!ndk) {
|
if (!ndk) {
|
||||||
|
logger.error("Login attempted without NDK instance");
|
||||||
throw new Error('NDK instance is required for login');
|
throw new Error('NDK instance is required for login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(`Attempting login with method: ${params.method}`);
|
||||||
|
|
||||||
switch (params.method) {
|
switch (params.method) {
|
||||||
case 'private_key':
|
case 'private_key':
|
||||||
return authService.loginWithPrivateKey(params.privateKey);
|
return authService.loginWithPrivateKey(params.privateKey);
|
||||||
case 'amber':
|
case 'amber':
|
||||||
if (Platform.OS !== 'android') {
|
if (Platform.OS !== 'android') {
|
||||||
|
logger.error("Amber login attempted on non-Android platform");
|
||||||
throw new Error('Amber login is only available on Android');
|
throw new Error('Amber login is only available on Android');
|
||||||
}
|
}
|
||||||
return authService.loginWithAmber();
|
return authService.loginWithAmber();
|
||||||
case 'ephemeral':
|
case 'ephemeral':
|
||||||
return authService.createEphemeralKey();
|
return authService.createEphemeralKey();
|
||||||
default:
|
default:
|
||||||
|
logger.error("Invalid login method:", params.method);
|
||||||
throw new Error('Invalid login method');
|
throw new Error('Invalid login method');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: async (user, variables) => {
|
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
|
// Update auth state after successful login
|
||||||
queryClient.setQueryData<AuthState>(QUERY_KEYS.auth.current(), {
|
queryClient.setQueryData<AuthState>(QUERY_KEYS.auth.current(), {
|
||||||
status: 'authenticated',
|
status: 'authenticated',
|
||||||
user,
|
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 });
|
await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.auth.all });
|
||||||
|
|
||||||
|
// Also mark initialization as complete
|
||||||
|
initializationRef.current.initialized = true;
|
||||||
|
initializationRef.current.error = null;
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error('[useAuthQuery] Login error:', error);
|
logger.error("Login error:", error);
|
||||||
|
// Clear any partial auth data on failure
|
||||||
|
queryClient.setQueryData<AuthState>(QUERY_KEYS.auth.current(), {
|
||||||
|
status: 'unauthenticated',
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Logout mutation
|
// Logout mutation - with better error handling
|
||||||
const logoutMutation: UseMutationResult<void, Error, void> = useMutation({
|
const logoutMutation: UseMutationResult<void, Error, void> = useMutation({
|
||||||
mutationFn: async (): Promise<void> => {
|
mutationFn: async (): Promise<void> => {
|
||||||
if (!ndk) {
|
if (!ndk) {
|
||||||
|
logger.error("Logout attempted without NDK instance");
|
||||||
throw new Error('NDK instance is required for logout');
|
throw new Error('NDK instance is required for logout');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info("Performing logout");
|
||||||
await authService.logout();
|
await authService.logout();
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
logger.info("Logout successful, resetting auth state");
|
||||||
|
|
||||||
// Set auth state to unauthenticated after successful logout
|
// Set auth state to unauthenticated after successful logout
|
||||||
queryClient.setQueryData<AuthState>(QUERY_KEYS.auth.current(), {
|
queryClient.setQueryData<AuthState>(QUERY_KEYS.auth.current(), {
|
||||||
status: 'unauthenticated',
|
status: 'unauthenticated',
|
||||||
@ -176,13 +258,24 @@ export function useAuthQuery() {
|
|||||||
await queryClient.invalidateQueries();
|
await queryClient.invalidateQueries();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error('[useAuthQuery] Logout error:', error);
|
logger.error("Logout error:", error);
|
||||||
|
|
||||||
|
// Try to reset auth state even on error
|
||||||
|
try {
|
||||||
|
queryClient.setQueryData<AuthState>(QUERY_KEYS.auth.current(), {
|
||||||
|
status: 'unauthenticated',
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to reset auth state after logout error:", e);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Login function
|
// Login function
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
(params: LoginParams) => {
|
(params: LoginParams) => {
|
||||||
|
logger.info(`Login requested with method: ${params.method}`);
|
||||||
return loginMutation.mutateAsync(params);
|
return loginMutation.mutateAsync(params);
|
||||||
},
|
},
|
||||||
[loginMutation]
|
[loginMutation]
|
||||||
@ -190,6 +283,7 @@ export function useAuthQuery() {
|
|||||||
|
|
||||||
// Logout function
|
// Logout function
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
|
logger.info("Logout requested");
|
||||||
return logoutMutation.mutateAsync();
|
return logoutMutation.mutateAsync();
|
||||||
}, [logoutMutation]);
|
}, [logoutMutation]);
|
||||||
|
|
||||||
|
277
lib/hooks/useProfilePageData.tsx
Normal file
277
lib/hooks/useProfilePageData.tsx
Normal file
@ -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<any[]>([]);
|
||||||
|
const [feedLoading, setFeedLoading] = useState(false);
|
||||||
|
const [isOffline, setIsOffline] = useState(false);
|
||||||
|
const [entries, setEntries] = useState<AnyFeedEntry[]>([]);
|
||||||
|
const [renderError, setRenderError] = useState<Error | null>(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,
|
||||||
|
};
|
||||||
|
}
|
@ -52,13 +52,13 @@ export function useProfileStats(options: UseProfileStatsOptions = {}) {
|
|||||||
refetchInterval: refreshInterval > 0 ? refreshInterval : 30000, // 30 seconds default on Android
|
refetchInterval: refreshInterval > 0 ? refreshInterval : 30000, // 30 seconds default on Android
|
||||||
},
|
},
|
||||||
ios: {
|
ios: {
|
||||||
// More aggressive settings for iOS
|
// More aggressive settings for iOS with reduced timeout
|
||||||
staleTime: 0, // No stale time - always refetch when used
|
staleTime: 0, // No stale time - always refetch when used
|
||||||
gcTime: 2 * 60 * 1000, // 2 minutes
|
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
|
retryDelay: 1000, // 1 second between retries
|
||||||
timeout: 10000, // 10 second timeout for iOS
|
timeout: 6000, // Reduced to 6 second timeout for iOS (was 10s)
|
||||||
refetchInterval: refreshInterval > 0 ? refreshInterval : 10000, // 10 seconds default on iOS
|
refetchInterval: refreshInterval > 0 ? refreshInterval : 15000, // 15 seconds default on iOS (was 10s)
|
||||||
},
|
},
|
||||||
default: {
|
default: {
|
||||||
// Fallback settings
|
// Fallback settings
|
||||||
@ -88,13 +88,19 @@ export function useProfileStats(options: UseProfileStatsOptions = {}) {
|
|||||||
logger.info(`[${platform}] Fetching profile stats for ${pubkey?.substring(0, 8)}...`);
|
logger.info(`[${platform}] Fetching profile stats for ${pubkey?.substring(0, 8)}...`);
|
||||||
|
|
||||||
try {
|
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(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
if (!signal.aborted && isMounted.current) {
|
if (!signal.aborted && isMounted.current) {
|
||||||
logger.warn(`[${platform}] Profile stats fetch timed out after ${platformConfig.timeout}ms`);
|
logger.warn(`[${platform}] Profile stats fetch timed out after ${platformConfig.timeout}ms`);
|
||||||
// Create a controller to manually abort if we hit our timeout
|
// Abort our manual controller to cancel the fetch
|
||||||
const abortController = new AbortController();
|
try {
|
||||||
abortController.abort();
|
timeoutController.abort();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`[${platform}] Error aborting fetch: ${e}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, platformConfig.timeout);
|
}, platformConfig.timeout);
|
||||||
|
|
||||||
@ -130,8 +136,9 @@ export function useProfileStats(options: UseProfileStatsOptions = {}) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${platform}] Error fetching profile stats: ${error}`);
|
logger.error(`[${platform}] Error fetching profile stats: ${error}`);
|
||||||
|
|
||||||
// On Android, return fallback values rather than throwing
|
// On any platform, return fallback values rather than throwing
|
||||||
if (platform === 'Android') {
|
// This prevents the UI from hanging in error states
|
||||||
|
logger.warn(`[${platform}] Returning fallback stats after error`);
|
||||||
return {
|
return {
|
||||||
pubkey,
|
pubkey,
|
||||||
followersCount: 0,
|
followersCount: 0,
|
||||||
@ -140,9 +147,6 @@ export function useProfileStats(options: UseProfileStatsOptions = {}) {
|
|||||||
error: error instanceof Error ? error : new Error(String(error))
|
error: error instanceof Error ? error : new Error(String(error))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
// Configuration based on platform
|
// Configuration based on platform
|
||||||
staleTime: platformConfig.staleTime,
|
staleTime: platformConfig.staleTime,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user