POWR/app/(tabs)/profile/overview.tsx
DocNR 755e86e30b fix(profile): standardize login experience across profile screens
- Create reusable NostrProfileLogin component for consistent UI across all screens
- Fix profile feed error when not authenticated by conditionally calling useSocialFeed
- Resolve hook ordering inconsistencies to prevent React errors
- Improve feed loading state management during authentication transitions
- Update all profile screens (overview, activity, progress, settings) to use shared component
- Add proper error handling for profile data loading when not authenticated

This change resolves the console error that appeared when accessing the profile tab
while not logged in, while also providing a better user experience through
consistent styling and messaging across all profile screens.
2025-04-01 21:28:50 -04:00

426 lines
14 KiB
TypeScript

// app/(tabs)/profile/overview.tsx
import React, { useState, useCallback, useEffect } from 'react';
import { View, FlatList, RefreshControl, Pressable, TouchableOpacity, ImageBackground, Clipboard } from 'react-native';
import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button';
import { useNDKCurrentUser } from '@/lib/hooks/useNDK';
import { ActivityIndicator } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import NostrLoginSheet from '@/components/sheets/NostrLoginSheet';
import NostrProfileLogin from '@/components/social/NostrProfileLogin';
import EnhancedSocialPost from '@/components/social/EnhancedSocialPost';
import EmptyFeed from '@/components/social/EmptyFeed';
import { useSocialFeed } from '@/lib/hooks/useSocialFeed';
import { useProfileStats } from '@/lib/hooks/useProfileStats';
import {
AnyFeedEntry,
WorkoutFeedEntry,
ExerciseFeedEntry,
TemplateFeedEntry,
SocialFeedEntry,
ArticleFeedEntry
} from '@/types/feed';
import UserAvatar from '@/components/UserAvatar';
import { useRouter } from 'expo-router';
import { QrCode, Mail, Copy } from 'lucide-react-native';
import { useTheme } from '@react-navigation/native';
import type { CustomTheme } from '@/lib/theme';
import { Alert } from 'react-native';
import { nip19 } from 'nostr-tools';
// Define the conversion function for feed items
function convertToLegacyFeedItem(entry: AnyFeedEntry) {
return {
id: entry.eventId,
type: entry.type,
originalEvent: entry.event!,
parsedContent: entry.content!,
createdAt: (entry.timestamp || Date.now()) / 1000
};
}
export default function OverviewScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const theme = useTheme() as CustomTheme;
const { currentUser, isAuthenticated } = useNDKCurrentUser();
const [isLoginSheetOpen, setIsLoginSheetOpen] = useState(false);
// Initialize feed related state
const [feedItems, setFeedItems] = useState<any[]>([]);
const [feedLoading, setFeedLoading] = useState(false);
const [isOffline, setIsOffline] = useState(false);
// Only call useSocialFeed when authenticated to prevent the error
const socialFeed = isAuthenticated ? useSocialFeed({
feedType: 'profile',
authors: currentUser?.pubkey ? [currentUser.pubkey] : [],
limit: 30
}) : null;
// Extract values from socialFeed when authenticated
const loading = isAuthenticated ? socialFeed?.loading || false : feedLoading;
const refresh = isAuthenticated
? (socialFeed?.refresh ? socialFeed.refresh : () => Promise.resolve())
: () => Promise.resolve();
// Update feedItems when socialFeed.feedItems changes
useEffect(() => {
if (isAuthenticated && socialFeed) {
setFeedItems(socialFeed.feedItems);
setIsOffline(socialFeed.isOffline);
}
}, [isAuthenticated, socialFeed?.feedItems, socialFeed?.isOffline]);
// Convert to the format expected by the component
const entries = React.useMemo(() => {
return feedItems.map(item => {
// Create a properly typed AnyFeedEntry based on the item type
const baseEntry = {
id: item.id,
eventId: item.id,
event: item.originalEvent,
timestamp: item.createdAt * 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;
}
});
}, [feedItems]);
const resetFeed = refresh;
const hasContent = entries.length > 0;
const [isRefreshing, setIsRefreshing] = useState(false);
// Profile data
const profileImageUrl = currentUser?.profile?.image ||
currentUser?.profile?.picture ||
(currentUser?.profile as any)?.avatar;
const bannerImageUrl = currentUser?.profile?.banner ||
(currentUser?.profile as any)?.background;
const displayName = isAuthenticated
? (currentUser?.profile?.displayName || currentUser?.profile?.name || 'Nostr User')
: 'Guest User';
const username = isAuthenticated
? (currentUser?.profile?.nip05 || '@user')
: '@guest';
const aboutText = currentUser?.profile?.about ||
(currentUser?.profile as any)?.description;
const pubkey = currentUser?.pubkey;
// Profile follower stats component - always call useProfileStats hook
// even if isAuthenticated is false (passing empty pubkey)
// This ensures consistent hook ordering regardless of authentication state
const { followersCount, followingCount, isLoading: statsLoading } = useProfileStats({
pubkey: pubkey || '',
refreshInterval: 60000 * 15 // refresh every 15 minutes
});
// Use a separate component to avoid conditionally rendered hooks
const ProfileFollowerStats = React.memo(() => {
return (
<View className="flex-row mb-2">
<TouchableOpacity className="mr-4">
<Text>
<Text className="font-bold">{statsLoading ? '...' : followingCount.toLocaleString()}</Text>
<Text className="text-muted-foreground"> following</Text>
</Text>
</TouchableOpacity>
<TouchableOpacity>
<Text>
<Text className="font-bold">{statsLoading ? '...' : followersCount.toLocaleString()}</Text>
<Text className="text-muted-foreground"> followers</Text>
</Text>
</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
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
try {
await resetFeed();
// Add a slight delay to ensure the UI updates
await new Promise(resolve => setTimeout(resolve, 300));
} catch (error) {
console.error('Error refreshing feed:', error);
} finally {
setIsRefreshing(false);
}
}, [resetFeed]);
// Handle post selection
const handlePostPress = useCallback((entry: AnyFeedEntry) => {
// Just log the entry info for now
console.log(`Selected ${entry.type}:`, entry);
}, []);
// Copy npub to clipboard
const copyPubkey = useCallback(() => {
if (pubkey) {
try {
const npub = nip19.npubEncode(pubkey);
Clipboard.setString(npub);
Alert.alert('Copied', 'Public key copied to clipboard in npub format');
console.log('npub copied to clipboard:', npub);
} catch (error) {
console.error('Error copying npub:', error);
Alert.alert('Error', 'Failed to copy public key');
}
}
}, [pubkey]);
// Show QR code alert
const showQRCode = useCallback(() => {
Alert.alert('QR Code', 'QR Code functionality will be implemented soon', [
{ text: 'OK' }
]);
}, []);
// Memoize render item function
const renderItem = useCallback(({ item }: { item: AnyFeedEntry }) => (
<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
return (
<View>
{/* Banner Image */}
<View className="w-full h-40 relative">
{bannerImageUrl ? (
<ImageBackground
source={{ uri: bannerImageUrl }}
className="w-full h-full"
resizeMode="cover"
>
<View className="absolute inset-0 bg-black/20" />
</ImageBackground>
) : (
<View className="w-full h-full bg-gradient-to-b from-primary/80 to-primary/30" />
)}
</View>
<View className="px-4 -mt-16 pb-2">
<View className="flex-row items-end mb-4">
{/* Left side - Avatar */}
<UserAvatar
size="xl"
uri={profileImageUrl}
fallback={displayName.charAt(0)}
className="mr-4 border-4 border-background"
isInteractive={false}
style={{ width: 90, height: 90 }}
/>
{/* 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 - no longer passing pubkey as prop since we're calling useProfileStats in parent */}
<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]);
// Profile components must be defined before conditional returns
// to ensure that React hook ordering remains consistent
// Render functions for different app states
const renderLoginScreen = useCallback(() => {
return (
<NostrProfileLogin message="Login with your Nostr private key to view your profile and posts." />
);
}, []);
const renderLoadingScreen = useCallback(() => {
return (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
);
}, []);
const renderMainContent = useCallback(() => {
return (
<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>
);
}, [entries, renderItem, isRefreshing, handleRefresh, ProfileHeader, insets.bottom]);
// Final conditional return after all hooks have been called
// This ensures consistent hook ordering across renders
if (!isAuthenticated) {
return renderLoginScreen();
}
if (loading && entries.length === 0) {
return renderLoadingScreen();
}
return renderMainContent();
}