restructure and optimization of social tab

This commit is contained in:
DocNR 2025-03-21 22:21:45 -04:00
parent 24e7f53ac3
commit 58194c0eb3
24 changed files with 5470 additions and 387 deletions

View File

@ -5,6 +5,60 @@ All notable changes to the POWR project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
# Changelog - March 20, 2025
## Improved
- Enhanced Social Feed UI
- Redesigned feed posts with divider-based layout instead of cards
- Implemented edge-to-edge content display with hairline separators
- Optimized post spacing for more compact, Twitter-like appearance
- Reduced vertical padding between post elements
- Tightened spacing between content and action buttons
- Fixed image loading for POWR Pack images
- Enhanced overall feed performance with component memoization
- Improved empty state messaging
- Fixed infinite loop issues in feed subscription management
- Added proper feed reset and refresh functionality
- Enhanced debugging tools for relay connection troubleshooting
- Improved feed state management with proper lifecycle handling
- Optimized rendering for long lists with virtualized FlatList
- Added scrollToTop functionality for new content
# Changelog - March 19, 2025
## Added
- Social Feed Integration
- Implemented tabbed social feed with Following, POWR, and Global tabs
- Created EnhancedSocialPost component for rendering workout events
- Added support for viewing workout records, exercise templates, and workout templates
- Implemented post interaction features (likes, comments)
- Added workout detail screen for viewing complete workout information
- Integrated with Nostr protocol for decentralized social content
- Created SocialFeedService for fetching and managing social content
- Implemented useFollowingFeed, usePOWRFeed, and useGlobalFeed hooks
- Added user profile integration with avatar display
- Created POWRPackSection for discovering shared workout templates
## Improved
- Enhanced profile handling
- Added robust error management for profile image loading
- Implemented proper state management to prevent infinite update loops
- Better memory management with cleanup on component unmount
- Workout content display
- Created rich workout event cards with detailed exercise information
- Added support for displaying workout duration, exercises, and performance metrics
- Implemented proper text handling for React Native
- Nostr integration
- Added support for exercise, template, and workout event kinds
- Implemented event parsing for different content types
- Created useSocialFeed hook with pagination support
- Enhanced NDK integration with better error handling
- UI/UX enhancements
- Added pull-to-refresh for feed updates
- Implemented load more functionality for pagination
- Created skeleton loading states for better loading experience
- Enhanced navigation between feed and detail screens
# Changelog - March 12, 2025
## Added

17
app/(social)/_layout.tsx Normal file
View File

@ -0,0 +1,17 @@
// app/(social)/_layout.tsx
import React from 'react';
import { Stack } from 'expo-router';
export default function SocialLayout() {
return (
<Stack>
<Stack.Screen
name="workout/[id]"
options={{
headerShown: false,
presentation: 'card'
}}
/>
</Stack>
);
}

View File

@ -0,0 +1,420 @@
// app/(social)/workout/[id].tsx
import React, { useEffect, useState } from 'react';
import { View, ScrollView, ActivityIndicator, TouchableOpacity } from 'react-native';
import { Text } from '@/components/ui/text';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import {
User,
Calendar,
Clock,
ChevronLeft,
Dumbbell,
Heart,
MessageCircle,
CheckCircle
} from 'lucide-react-native';
import { useLocalSearchParams, useRouter, Stack } from 'expo-router';
import { useNDK } from '@/lib/hooks/useNDK';
import { useProfile } from '@/lib/hooks/useProfile';
import { parseWorkoutRecord, POWR_EVENT_KINDS } from '@/types/nostr-workout';
import { SocialFeedService } from '@/lib/social/socialFeedService';
import { format } from 'date-fns';
import { Input } from '@/components/ui/input';
export default function WorkoutDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { ndk } = useNDK();
const [event, setEvent] = useState<any>(null);
const [workout, setWorkout] = useState<any>(null);
const [comments, setComments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [commentCount, setCommentCount] = useState(0);
const [likeCount, setLikeCount] = useState(0);
const [liked, setLiked] = useState(false);
const [commentText, setCommentText] = useState('');
const [isPostingComment, setIsPostingComment] = useState(false);
// Profile for the workout author
const { profile } = useProfile(workout?.author);
// Fetch the workout event
useEffect(() => {
if (!ndk || !id) return;
const fetchEvent = async () => {
try {
setLoading(true);
// Fetch the workout event
const filter = {
ids: [id],
kinds: [POWR_EVENT_KINDS.WORKOUT_RECORD]
};
const events = await ndk.fetchEvents(filter);
if (events.size > 0) {
const workoutEvent = Array.from(events)[0];
setEvent(workoutEvent);
// Parse the workout data
const parsedWorkout = parseWorkoutRecord(workoutEvent);
setWorkout(parsedWorkout);
// Fetch comments
const socialService = new SocialFeedService(ndk);
const fetchedComments = await socialService.getComments(id);
setComments(fetchedComments);
setCommentCount(fetchedComments.length);
// Fetch likes
const likesFilter = {
kinds: [POWR_EVENT_KINDS.REACTION],
'#e': [id]
};
const likes = await ndk.fetchEvents(likesFilter);
setLikeCount(likes.size);
}
setLoading(false);
} catch (error) {
console.error('Error fetching workout:', error);
setLoading(false);
}
};
fetchEvent();
}, [ndk, id]);
// Handle like button press
const handleLike = async () => {
if (!ndk || !event) return;
try {
const socialService = new SocialFeedService(ndk);
await socialService.reactToEvent(event);
setLiked(true);
setLikeCount(prev => prev + 1);
} catch (error) {
console.error('Error liking workout:', error);
}
};
// Handle comment submission
const handleSubmitComment = async () => {
if (!ndk || !event || !commentText.trim() || isPostingComment) return;
setIsPostingComment(true);
try {
const socialService = new SocialFeedService(ndk);
const comment = await socialService.postComment(event, commentText.trim());
// Add the new comment to the list
setComments(prev => [...prev, comment]);
setCommentCount(prev => prev + 1);
// Clear the input
setCommentText('');
} catch (error) {
console.error('Error posting comment:', error);
} finally {
setIsPostingComment(false);
}
};
// Format date string
const formatDate = (timestamp?: number) => {
if (!timestamp) return 'Unknown date';
try {
return format(new Date(timestamp), 'PPP');
} catch (error) {
return 'Invalid date';
}
};
// Format time string
const formatTime = (timestamp?: number) => {
if (!timestamp) return '';
try {
return format(new Date(timestamp), 'p');
} catch (error) {
return '';
}
};
// Format duration
const formatDuration = (startTime?: number, endTime?: number) => {
if (!startTime || !endTime) return 'Unknown duration';
const durationMs = endTime - startTime;
const minutes = Math.floor(durationMs / 60000);
if (minutes < 60) {
return `${minutes} minutes`;
}
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (mins === 0) {
return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
}
return `${hours} ${hours === 1 ? 'hour' : 'hours'} ${mins} ${mins === 1 ? 'minute' : 'minutes'}`;
};
// Format comment time
const formatCommentTime = (timestamp: number) => {
try {
const date = new Date(timestamp * 1000);
return format(date, 'MMM d, yyyy • h:mm a');
} catch (error) {
return 'Unknown time';
}
};
// Handle back button press
const handleBack = () => {
router.back();
};
// Set up header with proper back button for iOS
return (
<>
<Stack.Screen
options={{
headerTitle: workout?.title || 'Workout Details',
headerShown: true,
headerLeft: () => (
<TouchableOpacity onPress={handleBack}>
<ChevronLeft size={24} />
</TouchableOpacity>
),
}}
/>
{loading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" />
<Text className="mt-4">Loading workout...</Text>
</View>
) : !workout ? (
<View className="flex-1 items-center justify-center p-4">
<Text className="text-lg text-center mb-4">Workout not found</Text>
<Button onPress={handleBack}>
<Text>Go back</Text>
</Button>
</View>
) : (
<ScrollView className="flex-1 bg-background">
<View className="p-4">
{/* Workout header */}
<View className="mb-6">
<Text className="text-2xl font-bold mb-2">{workout.title}</Text>
{/* Author info */}
<View className="flex-row items-center mb-4">
<Avatar className="h-8 w-8 mr-2" alt={profile?.name || 'User avatar'}>
{profile?.image ? (
<AvatarImage source={{ uri: profile.image }} />
) : (
<AvatarFallback>
<User size={16} />
</AvatarFallback>
)}
</Avatar>
<View className="flex-row items-center">
<Text className="text-muted-foreground">
{profile?.name || 'Nostr User'}
</Text>
{profile?.nip05 && (
<CheckCircle size={14} className="text-primary ml-1" />
)}
</View>
</View>
{/* Time and date */}
<View className="flex-row flex-wrap gap-2 mb-2">
<View className="flex-row items-center bg-muted/30 px-3 py-1 rounded-full">
<Calendar size={16} className="mr-1" />
<Text className="text-sm">{formatDate(workout.startTime)}</Text>
</View>
<View className="flex-row items-center bg-muted/30 px-3 py-1 rounded-full">
<Clock size={16} className="mr-1" />
<Text className="text-sm">{formatTime(workout.startTime)}</Text>
</View>
{workout.endTime && (
<View className="flex-row items-center bg-muted/30 px-3 py-1 rounded-full">
<Dumbbell size={16} className="mr-1" />
<Text className="text-sm">
{formatDuration(workout.startTime, workout.endTime)}
</Text>
</View>
)}
</View>
{/* Workout type */}
<Badge className="mr-2">
<Text>{workout.type}</Text>
</Badge>
</View>
{/* Workout notes */}
{workout.notes && (
<Card className="mb-6">
<CardHeader>
<Text className="font-semibold">Notes</Text>
</CardHeader>
<CardContent>
<Text>{workout.notes}</Text>
</CardContent>
</Card>
)}
{/* Exercise list */}
<Card className="mb-6">
<CardHeader>
<Text className="font-semibold">Exercises</Text>
</CardHeader>
<CardContent>
{workout.exercises.length === 0 ? (
<Text className="text-muted-foreground">No exercises recorded</Text>
) : (
workout.exercises.map((exercise: any, index: number) => (
<View key={index} className="mb-4 pb-4 border-b border-border last:border-0 last:mb-0 last:pb-0">
<Text className="font-medium text-lg mb-1">{exercise.name}</Text>
<View className="flex-row flex-wrap gap-2 mt-2">
{exercise.weight && (
<Badge variant="outline">
<Text>{exercise.weight}kg</Text>
</Badge>
)}
{exercise.reps && (
<Badge variant="outline">
<Text>{exercise.reps} reps</Text>
</Badge>
)}
{exercise.rpe && (
<Badge variant="outline">
<Text>RPE {exercise.rpe}</Text>
</Badge>
)}
{exercise.setType && (
<Badge variant="outline">
<Text>{exercise.setType}</Text>
</Badge>
)}
</View>
</View>
))
)}
</CardContent>
</Card>
{/* Interactions */}
<Card className="mb-6">
<CardHeader>
<Text className="font-semibold">Interactions</Text>
</CardHeader>
<CardContent>
<View className="flex-row gap-4">
<Button
variant="outline"
className="flex-row items-center gap-2"
onPress={handleLike}
disabled={liked}
>
<Heart size={18} className={liked ? "text-red-500" : "text-muted-foreground"} fill={liked ? "#ef4444" : "none"} />
<Text>{likeCount} Likes</Text>
</Button>
<Button
variant="outline"
className="flex-row items-center gap-2"
>
<MessageCircle size={18} className="text-muted-foreground" />
<Text>{commentCount} Comments</Text>
</Button>
</View>
</CardContent>
</Card>
{/* Comments section */}
<Card className="mb-6">
<CardHeader>
<Text className="font-semibold">Comments</Text>
</CardHeader>
<CardContent>
{/* Comment input */}
<View className="mb-4 flex-row">
<Input
className="flex-1 mr-2"
placeholder="Add a comment..."
value={commentText}
onChangeText={setCommentText}
/>
<Button
onPress={handleSubmitComment}
disabled={!commentText.trim() || isPostingComment}
>
<Text>{isPostingComment ? 'Posting...' : 'Post'}</Text>
</Button>
</View>
{/* Comments list */}
{comments.length === 0 ? (
<Text className="text-muted-foreground">No comments yet. Be the first to comment!</Text>
) : (
comments.map((comment, index) => (
<View key={index} className="mb-4 pb-4 border-b border-border last:border-0 last:mb-0 last:pb-0">
{/* Comment author */}
<View className="flex-row items-center mb-2">
<Avatar className="h-6 w-6 mr-2" alt="Comment author">
<AvatarFallback>
<User size={12} />
</AvatarFallback>
</Avatar>
<Text className="text-sm text-muted-foreground">
{comment.pubkey.slice(0, 8)}...
</Text>
<Text className="text-xs text-muted-foreground ml-2">
{formatCommentTime(comment.created_at)}
</Text>
</View>
{/* Comment content */}
<Text>{comment.content}</Text>
</View>
))
)}
</CardContent>
</Card>
{/* Back button at bottom for additional usability */}
<Button
onPress={handleBack}
className="mb-8"
>
<Text>Back to Feed</Text>
</Button>
</View>
</ScrollView>
)}
</>
);
}

View File

@ -1,90 +1,264 @@
// app/(tabs)/social/following.tsx
import React from 'react';
import { View, ScrollView, RefreshControl } from 'react-native';
import React, { useCallback, useState, useRef } from 'react';
import { View, FlatList, RefreshControl, TouchableOpacity } from 'react-native';
import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button';
import SocialPost from '@/components/social/SocialPost';
import { useNDKCurrentUser } from '@/lib/hooks/useNDK';
import EnhancedSocialPost from '@/components/social/EnhancedSocialPost';
import { router } from 'expo-router';
import NostrLoginPrompt from '@/components/social/NostrLoginPrompt';
import EmptyFeed from '@/components/social/EmptyFeed';
import { useNDKCurrentUser, useNDK } from '@/lib/hooks/useNDK';
import { useFollowingFeed } from '@/lib/hooks/useFeedHooks';
import { ChevronUp, Bug } from 'lucide-react-native';
import { AnyFeedEntry } from '@/types/feed';
// Sample mock data for posts
const MOCK_POSTS = [
{
id: '1',
author: {
name: 'Jane Fitness',
handle: 'janefitness',
avatar: 'https://randomuser.me/api/portraits/women/32.jpg',
pubkey: 'npub1q8s7vw...'
},
content: 'Just crushed this leg workout! New PR on squat 💪 #fitness #legday',
createdAt: new Date(Date.now() - 3600000 * 2), // 2 hours ago
metrics: {
likes: 24,
comments: 5,
reposts: 3
},
workout: {
title: 'Leg Day Destroyer',
exercises: ['Squats', 'Lunges', 'Leg Press'],
duration: 45
}
},
{
id: '2',
author: {
name: 'Mark Strong',
handle: 'markstrong',
avatar: 'https://randomuser.me/api/portraits/men/45.jpg',
pubkey: 'npub1z92r3...'
},
content: 'Morning cardio session complete! 5K run in 22 minutes. Starting the day right! #running #cardio',
createdAt: new Date(Date.now() - 3600000 * 5), // 5 hours ago
metrics: {
likes: 18,
comments: 2,
reposts: 1
},
workout: {
title: 'Morning Cardio',
exercises: ['Running'],
duration: 22
}
}
];
// Define the conversion function here to avoid import issues
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 FollowingScreen() {
const { isAuthenticated } = useNDKCurrentUser();
const [refreshing, setRefreshing] = React.useState(false);
const [posts, setPosts] = React.useState(MOCK_POSTS);
const { isAuthenticated, currentUser } = useNDKCurrentUser();
const { ndk } = useNDK();
const {
entries,
newEntries,
loading,
resetFeed,
clearNewEntries,
hasFollows,
followCount,
followedUsers,
isLoadingContacts
} = useFollowingFeed();
const onRefresh = React.useCallback(() => {
setRefreshing(true);
// Simulate fetch - in a real app, this would be a call to load posts
setTimeout(() => {
setRefreshing(false);
}, 1500);
}, []);
const [isRefreshing, setIsRefreshing] = useState(false);
const [showNewButton, setShowNewButton] = useState(false);
const [showDebug, setShowDebug] = useState(false);
// Use ref for FlatList to scroll to top
const listRef = useRef<FlatList>(null);
// Show new entries button when we have new content
React.useEffect(() => {
if (newEntries.length > 0) {
setShowNewButton(true);
}
}, [newEntries.length]); // Depend on length, not array reference
// If not authenticated, show login prompt
if (!isAuthenticated) {
return <NostrLoginPrompt message="Log in to see posts from people you follow" />;
}
if (posts.length === 0) {
return <EmptyFeed message="You're not following anyone yet. Discover people to follow in the POWR or Global feeds." />;
// Handle showing new entries
const handleShowNewEntries = useCallback(() => {
clearNewEntries();
setShowNewButton(false);
// Scroll to top
listRef.current?.scrollToOffset({ offset: 0, animated: true });
}, [clearNewEntries]);
// Handle refresh - updated with proper reset handling
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]);
// Check relay connections
const checkRelayConnections = useCallback(() => {
if (!ndk) return;
console.log("=== RELAY CONNECTION STATUS ===");
if (ndk.pool && ndk.pool.relays) {
console.log(`Connected to ${ndk.pool.relays.size} relays:`);
ndk.pool.relays.forEach((relay) => {
console.log(`- ${relay.url}: ${relay.status}`);
});
} else {
console.log("No relay pool or connections available");
}
console.log("===============================");
}, [ndk]);
// Handle post selection - simplified for testing
const handlePostPress = useCallback((entry: AnyFeedEntry) => {
// Just show an alert with the entry info for testing
alert(`Selected ${entry.type} with ID: ${entry.eventId}`);
// Alternatively, log to console for debugging
console.log(`Selected ${entry.type}:`, entry);
}, []);
// Memoize render item function
const renderItem = useCallback(({ item }: { item: AnyFeedEntry }) => (
<EnhancedSocialPost
item={convertToLegacyFeedItem(item)}
onPress={() => handlePostPress(item)}
/>
), [handlePostPress]);
// Debug controls component - memoized
const DebugControls = useCallback(() => (
<View className="bg-gray-100 p-4 rounded-lg mx-4 mb-4">
<Text className="font-bold mb-2">Debug Info:</Text>
<Text>User: {currentUser?.pubkey?.substring(0, 8)}...</Text>
<Text>Following Count: {followCount || 0}</Text>
<Text>Feed Items: {entries.length}</Text>
<Text>Loading: {loading ? "Yes" : "No"}</Text>
<Text>Loading Contacts: {isLoadingContacts ? "Yes" : "No"}</Text>
{followedUsers && followedUsers.length > 0 && (
<View className="mt-2">
<Text className="font-bold">Followed Users:</Text>
{followedUsers.slice(0, 3).map((pubkey, idx) => (
<Text key={idx} className="text-xs">{idx+1}. {pubkey.substring(0, 12)}...</Text>
))}
{followedUsers.length > 3 && (
<Text className="text-xs">...and {followedUsers.length - 3} more</Text>
)}
</View>
)}
<View className="flex-row mt-4 justify-between">
<TouchableOpacity
className="bg-blue-500 p-2 rounded flex-1 mr-2"
onPress={checkRelayConnections}
>
<Text className="text-white text-center">Check Relays</Text>
</TouchableOpacity>
<TouchableOpacity
className="bg-green-500 p-2 rounded flex-1"
onPress={handleRefresh}
>
<Text className="text-white text-center">Force Refresh</Text>
</TouchableOpacity>
</View>
</View>
), [currentUser?.pubkey, followCount, entries.length, loading, isLoadingContacts, followedUsers, checkRelayConnections, handleRefresh]);
// If user doesn't follow anyone
if (isAuthenticated && !hasFollows) {
return (
<View className="flex-1 items-center justify-center p-8">
<Text className="text-center mb-4">
You're not following anyone yet. Find and follow other users to see their content here.
</Text>
{/* Debug toggle */}
<TouchableOpacity
className="mt-4 bg-gray-200 py-2 px-4 rounded"
onPress={() => setShowDebug(!showDebug)}
>
<Text>{showDebug ? "Hide" : "Show"} Debug Info</Text>
</TouchableOpacity>
{showDebug && (
<View className="mt-4 p-4 bg-gray-100 rounded w-full">
<Text className="text-xs">User pubkey: {currentUser?.pubkey?.substring(0, 12)}...</Text>
<Text className="text-xs">Authenticated: {isAuthenticated ? "Yes" : "No"}</Text>
<Text className="text-xs">Follow count: {followCount || 0}</Text>
<Text className="text-xs">Has NDK follows: {currentUser?.follows ? "Yes" : "No"}</Text>
<Text className="text-xs">NDK follows count: {
typeof currentUser?.follows === 'function' ? 'Function' :
(currentUser?.follows && Array.isArray(currentUser?.follows)) ? (currentUser?.follows as any[]).length :
(currentUser?.follows && typeof currentUser?.follows === 'object' && 'size' in currentUser?.follows) ?
(currentUser?.follows as any).size :
'unknown'
}</Text>
<Text className="text-xs">Loading contacts: {isLoadingContacts ? "Yes" : "No"}</Text>
{/* Toggle relays button */}
<TouchableOpacity
className="mt-2 bg-blue-200 py-1 px-2 rounded"
onPress={checkRelayConnections}
>
<Text className="text-xs">Check Relay Connections</Text>
</TouchableOpacity>
{/* Manual refresh */}
<TouchableOpacity
className="mt-2 bg-green-200 py-1 px-2 rounded"
onPress={handleRefresh}
>
<Text className="text-xs">Force Refresh Feed</Text>
</TouchableOpacity>
</View>
)}
</View>
);
}
return (
<ScrollView
className="flex-1"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
{posts.map(post => (
<SocialPost key={post.id} post={post} />
))}
</ScrollView>
<View className="flex-1">
{/* Debug toggle button */}
<TouchableOpacity
className="absolute top-2 right-2 z-10 bg-gray-200 p-2 rounded-full"
onPress={() => setShowDebug(!showDebug)}
>
<Bug size={16} color="#666" />
</TouchableOpacity>
{/* Debug panel */}
{showDebug && <DebugControls />}
{showNewButton && (
<TouchableOpacity
className="absolute top-2 left-0 right-0 z-10 mx-auto w-40 rounded-full bg-primary py-2 px-4 flex-row items-center justify-center"
onPress={handleShowNewEntries}
activeOpacity={0.8}
>
<ChevronUp size={16} color="white" />
<Text className="text-white font-medium ml-1">
New Posts ({newEntries.length})
</Text>
</TouchableOpacity>
)}
<FlatList
ref={listRef}
data={entries}
keyExtractor={(item) => item.id}
renderItem={renderItem}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
/>
}
ListEmptyComponent={
loading || isLoadingContacts ? (
<View className="flex-1 items-center justify-center p-8">
<Text>Loading followed content...</Text>
</View>
) : (
<View className="flex-1 items-center justify-center p-8">
<Text>No posts from followed users found</Text>
<Text className="text-sm text-gray-500 mt-2">
You're following {followCount || 0} users, but no content was found.
</Text>
<Text className="text-sm text-gray-500 mt-1">
This could be because they haven't posted recently,
or their content is not available on connected relays.
</Text>
</View>
)
}
contentContainerStyle={{ paddingVertical: 0 }} // Changed from paddingVertical: 8
/>
</View>
);
}

View File

@ -1,114 +1,130 @@
// app/(tabs)/social/global.tsx
import React from 'react';
import { View, ScrollView, RefreshControl } from 'react-native';
import React, { useCallback, useState, useRef } from 'react';
import { View, FlatList, RefreshControl, TouchableOpacity } from 'react-native';
import { Text } from '@/components/ui/text';
import SocialPost from '@/components/social/SocialPost';
import EnhancedSocialPost from '@/components/social/EnhancedSocialPost';
import { useGlobalFeed } from '@/lib/hooks/useFeedHooks';
import { router } from 'expo-router';
import { ChevronUp } from 'lucide-react-native';
import { AnyFeedEntry } from '@/types/feed';
// Sample mock data for global feed - more diverse content
const GLOBAL_POSTS = [
{
id: '1',
author: {
name: 'Strength Coach',
handle: 'strengthcoach',
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
pubkey: 'npub1q8s7vw...'
},
content: 'Form tip: When squatting, make sure your knees track in line with your toes. This helps protect your knees and ensures proper muscle engagement. #squatform #technique',
createdAt: new Date(Date.now() - 3600000 * 3), // 3 hours ago
metrics: {
likes: 132,
comments: 28,
reposts: 45
}
},
{
id: '2',
author: {
name: 'Marathon Runner',
handle: 'marathoner',
avatar: 'https://randomuser.me/api/portraits/women/28.jpg',
pubkey: 'npub1z92r3...'
},
content: 'Just finished my 10th marathon this year! Boston Marathon was an amazing experience. Thanks for all the support! #marathon #running #endurance',
createdAt: new Date(Date.now() - 3600000 * 14), // 14 hours ago
metrics: {
likes: 214,
comments: 38,
reposts: 22
},
workout: {
title: 'Boston Marathon',
exercises: ['Running'],
duration: 218 // 3:38 marathon
}
},
{
id: '3',
author: {
name: 'PowerLifter',
handle: 'liftsheavy',
avatar: 'https://randomuser.me/api/portraits/men/85.jpg',
pubkey: 'npub1xne8q...'
},
content: 'NEW PR ALERT! 💪 Just hit 500lbs on deadlift after 3 years of consistent training. Proof that patience and consistency always win. #powerlifting #deadlift #pr',
createdAt: new Date(Date.now() - 3600000 * 36), // 36 hours ago
metrics: {
likes: 347,
comments: 72,
reposts: 41
},
workout: {
title: 'Deadlift Day',
exercises: ['Deadlifts', 'Back Accessories'],
duration: 65
}
},
{
id: '4',
author: {
name: 'Yoga Master',
handle: 'yogalife',
avatar: 'https://randomuser.me/api/portraits/women/50.jpg',
pubkey: 'npub1r72df...'
},
content: 'Morning yoga flow to start the day centered and grounded. Remember that flexibility isn\'t just physical - it\'s mental too. #yoga #morningroutine #wellness',
createdAt: new Date(Date.now() - 3600000 * 48), // 2 days ago
metrics: {
likes: 183,
comments: 12,
reposts: 25
},
workout: {
title: 'Morning Yoga Flow',
exercises: ['Yoga'],
duration: 30
}
}
];
// Define the conversion function here to avoid import issues
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 GlobalScreen() {
const [refreshing, setRefreshing] = React.useState(false);
const [posts, setPosts] = React.useState(GLOBAL_POSTS);
const {
entries,
newEntries,
loading,
resetFeed,
clearNewEntries
} = useGlobalFeed();
const onRefresh = React.useCallback(() => {
setRefreshing(true);
// Simulate fetch
setTimeout(() => {
setRefreshing(false);
}, 1500);
const [isRefreshing, setIsRefreshing] = useState(false);
const [showNewButton, setShowNewButton] = useState(false);
// Use ref for FlatList to scroll to top
const listRef = useRef<FlatList>(null);
// Show new entries button when we have new content
React.useEffect(() => {
if (newEntries.length > 0) {
setShowNewButton(true);
}
}, [newEntries.length]); // Depend on length, not array reference
// Handle showing new entries
const handleShowNewEntries = useCallback(() => {
clearNewEntries();
setShowNewButton(false);
// Scroll to top
listRef.current?.scrollToOffset({ offset: 0, animated: true });
}, [clearNewEntries]);
// 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 - simplified for testing
const handlePostPress = useCallback((entry: AnyFeedEntry) => {
// Just show an alert with the entry info for testing
alert(`Selected ${entry.type} with ID: ${entry.eventId}`);
// Alternatively, log to console for debugging
console.log(`Selected ${entry.type}:`, entry);
}, []);
// Memoize render item function
const renderItem = useCallback(({ item }: { item: AnyFeedEntry }) => (
<EnhancedSocialPost
item={convertToLegacyFeedItem(item)}
onPress={() => handlePostPress(item)}
/>
), [handlePostPress]);
// Memoize empty component
const renderEmptyComponent = useCallback(() => (
loading ? (
<View className="flex-1 items-center justify-center p-8">
<Text>Loading global content...</Text>
</View>
) : (
<View className="flex-1 items-center justify-center p-8">
<Text>No global content found</Text>
<Text className="text-sm text-gray-500 mt-2">
Try connecting to more relays or check back later.
</Text>
</View>
)
), [loading]);
return (
<ScrollView
className="flex-1"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
{posts.map(post => (
<SocialPost key={post.id} post={post} />
))}
</ScrollView>
<View className="flex-1">
{showNewButton && (
<TouchableOpacity
className="absolute top-2 left-0 right-0 z-10 mx-auto w-40 rounded-full bg-primary py-2 px-4 flex-row items-center justify-center"
onPress={handleShowNewEntries}
activeOpacity={0.8}
>
<ChevronUp size={16} color="white" />
<Text className="text-white font-medium ml-1">
New Posts ({newEntries.length})
</Text>
</TouchableOpacity>
)}
<FlatList
ref={listRef}
data={entries}
keyExtractor={(item) => item.id}
renderItem={renderItem}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
/>
}
ListEmptyComponent={renderEmptyComponent}
contentContainerStyle={{ paddingVertical: 0 }}
/>
</View>
);
}

View File

@ -1,93 +1,105 @@
// app/(tabs)/social/powr.tsx
import React from 'react';
import { View, ScrollView, RefreshControl } from 'react-native';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { View, FlatList, RefreshControl, TouchableOpacity } from 'react-native';
import { Text } from '@/components/ui/text';
import SocialPost from '@/components/social/SocialPost';
import { Zap } from 'lucide-react-native';
import POWRPackSection from '@/components/social/POWRPackSection'; // Add this import
import EnhancedSocialPost from '@/components/social/EnhancedSocialPost';
import { ChevronUp, Zap } from 'lucide-react-native';
import POWRPackSection from '@/components/social/POWRPackSection';
import { usePOWRFeed } from '@/lib/hooks/useFeedHooks';
import { router } from 'expo-router';
import { AnyFeedEntry } from '@/types/feed';
// Sample mock data for posts from POWR team/recommendations
const POWR_POSTS = [
{
id: '1',
author: {
name: 'POWR Team',
handle: 'powrteam',
avatar: 'https://i.pravatar.cc/150?img=12',
pubkey: 'npub1q8s7vw...',
verified: true
},
content: 'Welcome to the new social feed in POWR! Share your workouts, follow friends and get inspired by the global fitness community. #powrapp',
createdAt: new Date(Date.now() - 3600000 * 48), // 2 days ago
metrics: {
likes: 158,
comments: 42,
reposts: 27
},
featured: true
},
{
id: '2',
author: {
name: 'Sarah Trainer',
handle: 'sarahfitness',
avatar: 'https://randomuser.me/api/portraits/women/44.jpg',
pubkey: 'npub1z92r3...'
},
content: 'Just released my new 30-day strength program! Check it out in my profile and let me know what you think. #strengthtraining #30daychallenge',
createdAt: new Date(Date.now() - 3600000 * 24), // 1 day ago
metrics: {
likes: 84,
comments: 15,
reposts: 12
},
workout: {
title: '30-Day Strength Builder',
exercises: ['Full Program'],
isProgramPreview: true
}
},
{
id: '3',
author: {
name: 'POWR Team',
handle: 'powrteam',
avatar: 'https://i.pravatar.cc/150?img=12',
pubkey: 'npub1q8s7vw...',
verified: true
},
content: 'New features alert! You can now track your rest periods automatically and share your PRs directly to your feed. Update to the latest version to try it out!',
createdAt: new Date(Date.now() - 3600000 * 72), // 3 days ago
metrics: {
likes: 207,
comments: 31,
reposts: 45
},
featured: true
}
];
// Define the conversion function here to avoid import issues
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 PowerScreen() {
const [refreshing, setRefreshing] = React.useState(false);
const [posts, setPosts] = React.useState(POWR_POSTS);
const {
entries,
newEntries,
loading,
resetFeed,
clearNewEntries
} = usePOWRFeed();
const onRefresh = React.useCallback(() => {
setRefreshing(true);
// Simulate fetch
setTimeout(() => {
setRefreshing(false);
}, 1500);
const [isRefreshing, setIsRefreshing] = useState(false);
const [showNewButton, setShowNewButton] = useState(false);
// Use ref for list to scroll to top
const listRef = useRef<FlatList>(null);
// Break the dependency cycle by using proper effect
useEffect(() => {
// Only set the button if we have new entries
if (newEntries.length > 0) {
setShowNewButton(true);
}
}, [newEntries.length]); // Depend only on length change, not the array itself
// Handle showing new entries
const handleShowNewEntries = useCallback(() => {
clearNewEntries();
setShowNewButton(false);
// Scroll to top
listRef.current?.scrollToOffset({ offset: 0, animated: true });
}, [clearNewEntries]);
// Handle refresh - updated with proper reset handling
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 - simplified for testing
const handlePostPress = useCallback((entry: AnyFeedEntry) => {
// Just show an alert with the entry info for testing
alert(`Selected ${entry.type} with ID: ${entry.eventId}`);
// Alternatively, log to console for debugging
console.log(`Selected ${entry.type}:`, entry);
}, []);
return (
<ScrollView
className="flex-1"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
// Memoize render item to prevent re-renders
const renderItem = useCallback(({ item }: { item: AnyFeedEntry }) => (
<EnhancedSocialPost
item={convertToLegacyFeedItem(item)}
onPress={() => handlePostPress(item)}
/>
), [handlePostPress]);
// Memoize empty component
const renderEmptyComponent = useCallback(() => (
loading ? (
<View className="flex-1 items-center justify-center p-8">
<Text>Loading POWR content...</Text>
</View>
) : (
<View className="flex-1 items-center justify-center p-8">
<Text>No POWR content found</Text>
</View>
)
), [loading]);
// Header component
const renderHeaderComponent = useCallback(() => (
<>
{/* POWR Welcome Section */}
<View className="p-4 border-b border-border bg-primary/5">
<View className="p-4 border-b border-border bg-primary/5 mb-2">
<View className="flex-row items-center mb-2">
<Zap size={20} className="mr-2 text-primary" fill="currentColor" />
<Text className="text-lg font-bold">POWR Community</Text>
@ -97,13 +109,41 @@ export default function PowerScreen() {
</Text>
</View>
{/* POWR Packs Section - Add this */}
{/* POWR Packs Section */}
<POWRPackSection />
</>
), []);
{/* Posts */}
{posts.map(post => (
<SocialPost key={post.id} post={post} />
))}
</ScrollView>
return (
<View className="flex-1">
{showNewButton && (
<TouchableOpacity
className="absolute top-2 left-0 right-0 z-10 mx-auto w-40 rounded-full bg-primary py-2 px-4 flex-row items-center justify-center"
onPress={handleShowNewEntries}
activeOpacity={0.8}
>
<ChevronUp size={16} color="white" />
<Text className="text-white font-medium ml-1">
New Posts ({newEntries.length})
</Text>
</TouchableOpacity>
)}
<FlatList
ref={listRef}
data={entries}
keyExtractor={(item) => item.id}
renderItem={renderItem}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
/>
}
ListHeaderComponent={renderHeaderComponent}
ListEmptyComponent={renderEmptyComponent}
contentContainerStyle={{ flexGrow: 1, paddingBottom: 16 }}
/>
</View>
);
}

View File

@ -0,0 +1,532 @@
// components/social/EnhancedSocialPost.tsx
import React, { useEffect, useState, useMemo } from 'react';
import { View, TouchableOpacity, Image, ScrollView } from 'react-native';
import { Text } from '@/components/ui/text';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Heart, MessageCircle, Repeat, Share, User, Clock, Dumbbell, CheckCircle, FileText } from 'lucide-react-native';
import { useProfile } from '@/lib/hooks/useProfile';
import { useNDK } from '@/lib/hooks/useNDK';
import { FeedItem } from '@/lib/hooks/useSocialFeed';
import { SocialFeedService } from '@/lib/social/socialFeedService';
import {
ParsedSocialPost,
ParsedWorkoutRecord,
ParsedExerciseTemplate,
ParsedWorkoutTemplate,
ParsedLongformContent
} from '@/types/nostr-workout';
import { formatDistance } from 'date-fns';
import Markdown from 'react-native-markdown-display';
// Helper functions for all components to use
// Format timestamp
function formatTimestamp(timestamp: number) {
try {
return formatDistance(new Date(timestamp * 1000), new Date(), { addSuffix: true });
} catch (error) {
return 'recently';
}
}
// Helper function to format duration in ms to readable format
function formatDuration(milliseconds: number): string {
const minutes = Math.floor(milliseconds / 60000);
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (mins === 0) {
return `${hours}h`;
}
return `${hours}h ${mins}m`;
}
// Helper function to format minutes
function formatMinutes(minutes: number): string {
if (isNaN(minutes)) return '';
if (minutes < 60) {
return `${Math.floor(minutes)}m`;
}
const hours = Math.floor(minutes / 60);
const mins = Math.floor(minutes % 60);
if (mins === 0) {
return `${hours}h`;
}
return `${hours}h ${mins}m`;
}
interface SocialPostProps {
item: FeedItem;
onPress?: () => void;
}
export default function EnhancedSocialPost({ item, onPress }: SocialPostProps) {
const { ndk } = useNDK();
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(0);
const [imageError, setImageError] = useState(false);
const { profile } = useProfile(item.originalEvent.pubkey);
// Get likes count
useEffect(() => {
if (!ndk) return;
let mounted = true;
const fetchLikes = async () => {
try {
const filter = {
kinds: [7], // Reactions
'#e': [item.id]
};
const events = await ndk.fetchEvents(filter);
if (mounted) {
setLikeCount(events.size);
}
} catch (error) {
console.error('Error fetching likes:', error);
}
};
fetchLikes();
return () => {
mounted = false;
};
}, [ndk, item.id]);
// Handle like button press
const handleLike = async () => {
if (!ndk) return;
try {
const socialService = new SocialFeedService(ndk);
await socialService.reactToEvent(item.originalEvent);
setLiked(true);
setLikeCount(prev => prev + 1);
} catch (error) {
console.error('Error liking post:', error);
}
};
// Handle image error
const handleImageError = () => {
setImageError(true);
};
// Render based on feed item type
const renderContent = () => {
switch (item.type) {
case 'workout':
return <WorkoutContent workout={item.parsedContent as ParsedWorkoutRecord} />;
case 'exercise':
return <ExerciseContent exercise={item.parsedContent as ParsedExerciseTemplate} />;
case 'template':
return <TemplateContent template={item.parsedContent as ParsedWorkoutTemplate} />;
case 'social':
return <SocialContent post={item.parsedContent as ParsedSocialPost} />;
case 'article':
return <ArticleContent article={item.parsedContent as ParsedLongformContent} />;
default:
return null;
}
};
// Memoize the author name to prevent unnecessary re-renders
const authorName = useMemo(() => {
return profile?.name || 'Nostr User';
}, [profile?.name]);
return (
<TouchableOpacity activeOpacity={0.7} onPress={onPress}>
<View className="py-3 px-4">
<View className="flex-row">
<Avatar className="h-10 w-10 mr-3" alt={profile?.name || 'User'}>
{profile?.image && !imageError ? (
<AvatarImage
source={{ uri: profile.image }}
onError={handleImageError}
/>
) : (
<AvatarFallback>
<User size={18} />
</AvatarFallback>
)}
</Avatar>
<View className="flex-1">
<View className="flex-row items-center">
<Text className="font-semibold">{authorName}</Text>
{profile?.nip05 && (
<CheckCircle size={14} className="text-primary ml-1" />
)}
</View>
<Text className="text-xs text-muted-foreground">
{formatTimestamp(item.createdAt)}
</Text>
</View>
</View>
<View className="mt-3">
{renderContent()}
</View>
{/* Reduced space between content and action buttons */}
<View className="flex-row justify-between items-center mt-2">
<TouchableOpacity
className="flex-row items-center"
activeOpacity={0.7}
onPress={handleLike}
disabled={liked}
>
<Heart
size={18}
className={liked ? "text-red-500" : "text-muted-foreground"}
fill={liked ? "#ef4444" : "none"}
/>
{likeCount > 0 && (
<Text className="ml-1 text-xs">
{likeCount}
</Text>
)}
</TouchableOpacity>
<TouchableOpacity className="flex-row items-center" activeOpacity={0.7}>
<MessageCircle size={18} className="text-muted-foreground" />
</TouchableOpacity>
<TouchableOpacity className="flex-row items-center" activeOpacity={0.7}>
<Repeat size={18} className="text-muted-foreground" />
</TouchableOpacity>
<TouchableOpacity className="flex-row items-center" activeOpacity={0.7}>
<Share size={18} className="text-muted-foreground" />
</TouchableOpacity>
</View>
</View>
{/* Hairline divider */}
<View className="h-px bg-border w-full" />
</TouchableOpacity>
);
}
// Component for workout records
function WorkoutContent({ workout }: { workout: ParsedWorkoutRecord }) {
return (
<View>
<Text className="text-lg font-semibold mb-2">{workout.title}</Text>
{workout.notes && (
<Text className="mb-2">{workout.notes}</Text>
)}
<View className="bg-muted/30 p-3 rounded-lg mb-0">
<View className="flex-row items-center mb-2">
<Dumbbell size={16} className="text-primary mr-2" />
<Text className="font-medium">{workout.type} workout</Text>
{workout.startTime && workout.endTime && (
<View className="flex-row items-center ml-auto">
<Clock size={14} className="text-muted-foreground mr-1" />
<Text className="text-sm text-muted-foreground">
{formatDuration(workout.endTime - workout.startTime)}
</Text>
</View>
)}
</View>
{workout.exercises.length > 0 && (
<View>
<Text className="font-medium mb-1">Exercises:</Text>
{workout.exercises.slice(0, 3).map((exercise, index) => (
<Text key={index} className="text-sm">
{exercise.name}
{exercise.weight ? ` - ${exercise.weight}kg` : ''}
{exercise.reps ? ` × ${exercise.reps}` : ''}
{exercise.rpe ? ` @ RPE ${exercise.rpe}` : ''}
</Text>
))}
{workout.exercises.length > 3 && (
<Text className="text-xs text-muted-foreground mt-1">
+{workout.exercises.length - 3} more exercises
</Text>
)}
</View>
)}
</View>
</View>
);
}
// Component for exercise templates
function ExerciseContent({ exercise }: { exercise: ParsedExerciseTemplate }) {
return (
<View>
<Text className="text-lg font-semibold mb-2">{exercise.title}</Text>
{exercise.description && (
<Text className="mb-2">{exercise.description}</Text>
)}
<View className="bg-muted/30 p-3 rounded-lg mb-0">
<View className="flex-row flex-wrap mb-2">
{exercise.equipment && (
<Badge variant="outline" className="mr-2 mb-1">
<Text>{exercise.equipment}</Text>
</Badge>
)}
{exercise.difficulty && (
<Badge variant="outline" className="mr-2 mb-1">
<Text>{exercise.difficulty}</Text>
</Badge>
)}
</View>
{exercise.format.length > 0 && (
<View className="mb-2">
<Text className="font-medium mb-1">Tracks:</Text>
<View className="flex-row flex-wrap">
{exercise.format.map((format, index) => (
<Badge key={index} className="mr-2 mb-1">
<Text>{format}</Text>
</Badge>
))}
</View>
</View>
)}
{exercise.tags.length > 0 && (
<View className="flex-row flex-wrap">
{exercise.tags.map((tag, index) => (
<Text key={index} className="text-xs text-primary mr-2">
#{tag}
</Text>
))}
</View>
)}
</View>
</View>
);
}
// Component for workout templates
function TemplateContent({ template }: { template: ParsedWorkoutTemplate }) {
return (
<View>
<Text className="text-lg font-semibold mb-2">{template.title}</Text>
{template.description && (
<Text className="mb-2">{template.description}</Text>
)}
<View className="bg-muted/30 p-3 rounded-lg mb-0">
<View className="flex-row items-center mb-2">
<Dumbbell size={16} className="text-primary mr-2" />
<Text className="font-medium">{template.type} template</Text>
{template.duration && (
<View className="flex-row items-center ml-auto">
<Clock size={14} className="text-muted-foreground mr-1" />
<Text className="text-sm text-muted-foreground">
{formatMinutes(template.duration / 60)} {/* Convert seconds to minutes */}
</Text>
</View>
)}
</View>
{template.rounds && (
<Text className="text-sm mb-1">
{template.rounds} {template.rounds === 1 ? 'round' : 'rounds'}
</Text>
)}
{template.exercises.length > 0 && (
<View>
<Text className="font-medium mb-1">Exercises:</Text>
{template.exercises.slice(0, 3).map((exercise, index) => (
<Text key={index} className="text-sm">
{exercise.name || 'Exercise ' + (index + 1)}
</Text>
))}
{template.exercises.length > 3 && (
<Text className="text-xs text-muted-foreground mt-1">
+{template.exercises.length - 3} more exercises
</Text>
)}
</View>
)}
{template.tags.length > 0 && (
<View className="flex-row flex-wrap mt-2">
{template.tags.map((tag, index) => (
<Text key={index} className="text-xs text-primary mr-2">
#{tag}
</Text>
))}
</View>
)}
</View>
</View>
);
}
// Component for social posts
function SocialContent({ post }: { post: ParsedSocialPost }) {
// Render the social post content
const renderMainContent = () => (
<Text className="mb-2">{post.content}</Text>
);
// Render quoted content if available
const renderQuotedContent = () => {
if (!post.quotedContent || !post.quotedContent.resolved) return null;
const { type, resolved } = post.quotedContent;
return (
<View className="bg-muted/30 p-3 rounded-lg">
<Text className="text-sm font-medium mb-1">
{type === 'workout' ? 'Workout' :
type === 'exercise' ? 'Exercise' :
type === 'template' ? 'Workout Template' :
type === 'article' ? 'Article' : 'Post'}:
</Text>
{type === 'workout' && <WorkoutQuote workout={resolved as ParsedWorkoutRecord} />}
{type === 'exercise' && <ExerciseQuote exercise={resolved as ParsedExerciseTemplate} />}
{type === 'template' && <TemplateQuote template={resolved as ParsedWorkoutTemplate} />}
{type === 'article' && <ArticleQuote article={resolved as ParsedLongformContent} />}
</View>
);
};
return (
<View>
{renderMainContent()}
{renderQuotedContent()}
</View>
);
}
// Component for long-form content
function ArticleContent({ article }: { article: ParsedLongformContent }) {
// Limit content preview to a reasonable length
const previewLength = 200;
const hasFullContent = article.content && article.content.length > previewLength;
return (
<View>
<View className="flex-row items-center mb-2">
<FileText size={16} className="text-primary mr-2" />
<Text className="font-medium">Article</Text>
</View>
<Text className="text-lg font-semibold mb-2">{article.title}</Text>
{article.image && (
<Image
source={{ uri: article.image }}
className="w-full h-48 rounded-md mb-2"
resizeMode="cover"
/>
)}
{article.summary ? (
<Text className="mb-0">{article.summary}</Text>
) : (
<ScrollView style={{ maxHeight: 200 }}>
<Markdown>
{hasFullContent ? article.content.substring(0, previewLength) + '...' : article.content}
</Markdown>
</ScrollView>
)}
{article.publishedAt && (
<Text className="text-xs text-muted-foreground mt-1">
Published: {formatTimestamp(article.publishedAt)}
</Text>
)}
{article.tags.length > 0 && (
<View className="flex-row flex-wrap mt-1">
{article.tags.map((tag, index) => (
<Text key={index} className="text-xs text-primary mr-2">
#{tag}
</Text>
))}
</View>
)}
</View>
);
}
// Add ArticleQuote component for quoted articles
function ArticleQuote({ article }: { article: ParsedLongformContent }) {
return (
<View>
<Text className="font-medium">{article.title}</Text>
{article.summary ? (
<Text className="text-sm text-muted-foreground">{article.summary}</Text>
) : (
<Text className="text-sm text-muted-foreground">
{article.content ? article.content.substring(0, 100) + '...' : 'No content'}
</Text>
)}
</View>
);
}
// Simplified versions of content for quoted posts
function WorkoutQuote({ workout }: { workout: ParsedWorkoutRecord }) {
return (
<View>
<Text className="font-medium">{workout.title}</Text>
<Text className="text-sm text-muted-foreground">
{workout.exercises.length} exercises {
workout.startTime && workout.endTime ?
formatDuration(workout.endTime - workout.startTime) :
'Duration N/A'
}
</Text>
</View>
);
}
function ExerciseQuote({ exercise }: { exercise: ParsedExerciseTemplate }) {
return (
<View>
<Text className="font-medium">{exercise.title}</Text>
{exercise.equipment && (
<Text className="text-sm text-muted-foreground">
{exercise.equipment} {exercise.difficulty || 'Any level'}
</Text>
)}
</View>
);
}
function TemplateQuote({ template }: { template: ParsedWorkoutTemplate }) {
return (
<View>
<Text className="font-medium">{template.title}</Text>
<Text className="text-sm text-muted-foreground">
{template.type} {template.exercises.length} exercises
</Text>
</View>
);
}

View File

@ -189,13 +189,6 @@ export default function POWRPackSection() {
}
}, [ndk]);
// Add debug logging for rendering
console.log('Rendering packs, count:', featuredPacks.length);
if (featuredPacks.length > 0) {
console.log('First pack keys:', Object.keys(featuredPacks[0]));
console.log('First pack has tags:', featuredPacks[0].tags ? featuredPacks[0].tags.length : 'no tags');
}
return (
<View style={styles.container}>
<View style={styles.header}>
@ -240,10 +233,10 @@ export default function POWRPackSection() {
) : featuredPacks.length > 0 ? (
// Pack cards
featuredPacks.map((pack, idx) => {
console.log(`Rendering pack ${idx}, tags exist:`, pack.tags ? 'yes' : 'no');
const title = findTagValue(pack.tags || [], 'name') || 'Unnamed Pack';
const description = findTagValue(pack.tags || [], 'about') || '';
const image = findTagValue(pack.tags || [], 'image') || null;
const image = findTagValue(pack.tags || [], 'image') ||
findTagValue(pack.tags || [], 'picture') || null;
// Add fallback for tags
const tags = pack.tags || [];
@ -265,7 +258,13 @@ export default function POWRPackSection() {
<Card style={styles.packCard}>
<CardContent style={styles.cardContent}>
{image ? (
<Image source={{ uri: image }} style={styles.packImage} />
<Image
source={{ uri: image }}
style={styles.packImage}
onError={(error) => {
console.error(`Failed to load image: ${image}`, error.nativeEvent.error);
}}
/>
) : (
<View style={styles.placeholderImage}>
<PackageOpen size={32} color="#6b7280" />

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 46;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@ -30,7 +30,7 @@
7A4D352CD337FB3A3BF06240 /* Pods-powr.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-powr.release.xcconfig"; path = "Target Support Files/Pods-powr/Pods-powr.release.xcconfig"; sourceTree = "<group>"; };
8FC11BF7530F46208CFF1732 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "powr/noop-file.swift"; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = powr/SplashScreen.storyboard; sourceTree = "<group>"; };
AF7B76EF4E55CFEE116289C3 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = powr/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
AF7B76EF4E55CFEE116289C3 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = powr/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
C69404F7CA9E471FAA045993 /* powr-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "powr-Bridging-Header.h"; path = "powr/powr-Bridging-Header.h"; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
@ -349,14 +349,20 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = powr/powr.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 944AF56S27;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"FB_SONARKIT_ENABLED=1",
);
INFOPLIST_FILE = powr/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = POWR;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
@ -364,7 +370,7 @@
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.powr.app;
PRODUCT_BUNDLE_IDENTIFIER = io.proofofworkout.powr;
PRODUCT_NAME = powr;
SWIFT_OBJC_BRIDGING_HEADER = "powr/powr-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -382,9 +388,15 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = powr/powr.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 944AF56S27;
INFOPLIST_FILE = powr/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = POWR;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
@ -392,7 +404,7 @@
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.powr.app;
PRODUCT_BUNDLE_IDENTIFIER = io.proofofworkout.powr;
PRODUCT_NAME = powr;
SWIFT_OBJC_BRIDGING_HEADER = "powr/powr-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@ -449,14 +461,14 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
OTHER_LDFLAGS = "$(inherited) ";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
@ -505,13 +517,13 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
OTHER_LDFLAGS = "$(inherited) ";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = true;

View File

@ -1,81 +1,81 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>powr</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
<string>com.powr.app</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSUserActivityTypes</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Automatic</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>powr</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
<string>com.powr.app</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSUserActivityTypes</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Automatic</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>

117
lib/hooks/useContactList.ts Normal file
View File

@ -0,0 +1,117 @@
// lib/hooks/useContactList.ts
import { useState, useEffect, useCallback } from 'react';
import { NDKEvent, NDKUser, NDKKind, NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk-mobile';
import { useNDK } from '@/lib/hooks/useNDK';
import { POWR_PUBKEY_HEX } from '@/lib/hooks/useFeedHooks';
export function useContactList(pubkey: string | undefined) {
const { ndk } = useNDK();
const [contacts, setContacts] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchContactList = useCallback(async () => {
if (!ndk || !pubkey) return;
setIsLoading(true);
setError(null);
try {
// Try multiple approaches to ensure reliability
// Approach 1: Use NDK user's direct followSet method
const user = new NDKUser({ pubkey });
user.ndk = ndk;
let contactSet: Set<string> = new Set();
try {
contactSet = await user.followSet({
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
closeOnEose: true
});
console.log(`Found ${contactSet.size} contacts via followSet()`);
} catch (err) {
console.log('Could not fetch follows using followSet():', err);
}
// Approach 2: Directly fetch kind 3 event if method 1 failed
if (contactSet.size === 0) {
const contactEvents = await ndk.fetchEvents({
kinds: [3],
authors: [pubkey],
limit: 1
});
if (contactEvents.size > 0) {
const contactEvent = Array.from(contactEvents)[0];
const extractedContacts = contactEvent.tags
.filter(tag => tag[0] === 'p')
.map(tag => tag[1]);
if (extractedContacts.length > 0) {
console.log(`Found ${extractedContacts.length} contacts via direct kind:3 fetch`);
contactSet = new Set([...contactSet, ...extractedContacts]);
}
}
}
// Approach 3: Try to search for contacts from user's public cached events
if (contactSet.size === 0) {
try {
const userEvents = await ndk.fetchEvents({
authors: [pubkey],
kinds: [3],
limit: 5
});
for (const event of userEvents) {
const extractedContacts = event.tags
.filter(tag => tag[0] === 'p')
.map(tag => tag[1]);
if (extractedContacts.length > 0) {
console.log(`Found ${extractedContacts.length} contacts from cached events`);
contactSet = new Set([...contactSet, ...extractedContacts]);
break;
}
}
} catch (err) {
console.error('Error fetching user events for contacts:', err);
}
}
// Include self to ensure self-created content is visible
contactSet.add(pubkey);
// Add POWR pubkey if available
if (POWR_PUBKEY_HEX) {
contactSet.add(POWR_PUBKEY_HEX);
}
// Convert to array and update state
const contactArray = Array.from(contactSet);
setContacts(contactArray);
} catch (err) {
console.error('Error fetching contact list:', err);
setError(err instanceof Error ? err : new Error('Failed to fetch contacts'));
} finally {
setIsLoading(false);
}
}, [ndk, pubkey]);
// Fetch on mount and when dependencies change
useEffect(() => {
if (ndk && pubkey) {
fetchContactList();
}
}, [ndk, pubkey, fetchContactList]);
return {
contacts,
isLoading,
error,
refetch: fetchContactList,
hasContacts: contacts.length > 0
};
}

353
lib/hooks/useFeedEvents.ts Normal file
View File

@ -0,0 +1,353 @@
// lib/hooks/useFeedEvents.ts
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { NDKEvent, NDKFilter, NDKSubscription } from '@nostr-dev-kit/ndk-mobile';
import { useNDK } from '@/lib/hooks/useNDK';
import { AnyFeedEntry, FeedOptions, UpdateEntryFn } from '@/types/feed';
import { POWR_EVENT_KINDS } from '@/types/nostr-workout';
import {
parseWorkoutRecord,
parseExerciseTemplate,
parseWorkoutTemplate,
parseSocialPost,
parseLongformContent
} from '@/types/nostr-workout';
// Default sort function - most recent first
const defaultSortFn = (a: AnyFeedEntry, b: AnyFeedEntry) => b.timestamp - a.timestamp;
export function useFeedEvents(
filters: NDKFilter[] | false,
options: FeedOptions = {}
) {
const { ndk } = useNDK();
const [entries, setEntries] = useState<AnyFeedEntry[]>([]);
const [newEntries, setNewEntries] = useState<AnyFeedEntry[]>([]);
const [loading, setLoading] = useState(true);
const [eose, setEose] = useState(false);
// Add state to control subscription
const [subscriptionEnabled, setSubscriptionEnabled] = useState(true);
const isResetting = useRef(false);
// Keep references to entries and options
const entriesRef = useRef<Record<string, AnyFeedEntry>>({});
const subscriptionRef = useRef<NDKSubscription | null>(null);
const seenEventsRef = useRef<Set<string>>(new Set());
const quotedEventsRef = useRef<Set<string>>(new Set());
// Options with defaults
const {
subId = 'feed',
enabled = true,
filterFn,
sortFn = defaultSortFn
} = options;
// Clear all new entries
const clearNewEntries = useCallback(() => {
setNewEntries([]);
}, []);
// Reset feed
const resetFeed = useCallback(async () => {
// Set a flag to prevent multiple resets in progress
if (isResetting.current) {
console.log('[Feed] Reset already in progress, ignoring request');
return;
}
console.log('[Feed] Starting reset');
isResetting.current = true;
try {
// Cancel the existing subscription
if (subscriptionRef.current) {
subscriptionRef.current.stop();
subscriptionRef.current = null;
}
// Reset state
entriesRef.current = {};
seenEventsRef.current.clear();
quotedEventsRef.current.clear();
setEntries([]);
setNewEntries([]);
setEose(false);
setLoading(true);
// Important: Add a small delay before toggling subscription state
await new Promise(resolve => setTimeout(resolve, 50));
// Force a new subscription by toggling enabled
setSubscriptionEnabled(false);
// Another small delay
await new Promise(resolve => setTimeout(resolve, 50));
// Re-enable the subscription
setSubscriptionEnabled(true);
console.log('[Feed] Reset complete');
return Promise.resolve();
} catch (error) {
console.error('[Feed] Error during reset:', error);
throw error;
} finally {
isResetting.current = false;
}
}, []);
// Update an entry
const updateEntry: UpdateEntryFn = useCallback((id, updater) => {
const existingEntry = entriesRef.current[id];
if (!existingEntry) return;
const updatedEntry = updater(existingEntry);
entriesRef.current[id] = updatedEntry;
// Update state for re-render
setEntries(Object.values(entriesRef.current).sort(sortFn));
}, [sortFn]);
// Process event and create entry
const processEvent = useCallback((event: NDKEvent) => {
// Skip if no ID
if (!event.id) return;
const eventId = event.id;
const timestamp = event.created_at ? event.created_at * 1000 : Date.now();
// Skip if we've already seen this event
if (seenEventsRef.current.has(eventId)) return;
seenEventsRef.current.add(eventId);
// Check for quoted events to avoid duplicates
if (event.kind === 1) {
// Track any direct e-tag references
event.tags
.filter(tag => tag[0] === 'e')
.forEach(tag => {
if (tag[1]) quotedEventsRef.current.add(tag[1]);
});
// Also check for quoted content in NIP-27 format
if (event.content) {
const nostrUriMatches = event.content.match(/nostr:(note1|nevent1|naddr1)[a-z0-9]+/g);
if (nostrUriMatches) {
// Add these to quotedEvents
}
}
}
// Create base entry
let entry: AnyFeedEntry | null = null;
try {
switch (event.kind) {
case POWR_EVENT_KINDS.WORKOUT_RECORD: // 1301
entry = {
id: `workout-${eventId}`,
eventId,
event,
timestamp,
type: 'workout',
content: parseWorkoutRecord(event)
};
break;
case POWR_EVENT_KINDS.EXERCISE_TEMPLATE: // 33401
entry = {
id: `exercise-${eventId}`,
eventId,
event,
timestamp,
type: 'exercise',
content: parseExerciseTemplate(event)
};
break;
case POWR_EVENT_KINDS.WORKOUT_TEMPLATE: // 33402
entry = {
id: `template-${eventId}`,
eventId,
event,
timestamp,
type: 'template',
content: parseWorkoutTemplate(event)
};
break;
case POWR_EVENT_KINDS.SOCIAL_POST: // 1
entry = {
id: `social-${eventId}`,
eventId,
event,
timestamp,
type: 'social',
content: parseSocialPost(event)
};
break;
case 30023: // Published article
case 30024: // Draft article
entry = {
id: `article-${eventId}`,
eventId,
event,
timestamp,
type: 'article',
content: parseLongformContent(event)
};
break;
default:
// Skip unsupported event kinds
return;
}
// Apply filter function if provided
if (filterFn && !filterFn(entry)) {
return;
}
// Add entry to our records
entriesRef.current[entry.id] = entry;
// Update entries state
const updatedEntries = Object.values(entriesRef.current).sort(sortFn);
setEntries(updatedEntries);
// Add to new entries if we've already received EOSE
if (eose && entry) {
// Explicitly check and assert entry is not null
const nonNullEntry: AnyFeedEntry = entry; // This will catch if entry is incorrectly typed
setNewEntries(prev => {
const entries: AnyFeedEntry[] = [...prev];
entries.push(nonNullEntry);
return entries;
});
}
} catch (error) {
console.error('Error processing event:', error, event);
}
}, [eose, filterFn, sortFn]);
// Function to fetch specific event types (e.g., POWR Packs)
const fetchSpecificEvents = useCallback(async (
specificFilters: NDKFilter[],
options: {
limit?: number;
processFn?: (event: NDKEvent) => any;
} = {}
) => {
if (!ndk || !specificFilters || specificFilters.length === 0) return [];
const { limit = 50, processFn } = options;
const results: any[] = [];
try {
setLoading(true);
for (const filter of specificFilters) {
// Ensure we have a reasonable limit
const filterWithLimit = { ...filter, limit };
// Fetch events
const events = await ndk.fetchEvents(filterWithLimit);
if (events.size > 0) {
// Process events if a processing function is provided
if (processFn) {
for (const event of events) {
try {
const processed = processFn(event);
if (processed) results.push(processed);
} catch (err) {
console.error('Error processing event:', err);
}
}
} else {
// Otherwise just add the events
for (const event of events) {
results.push(event);
}
}
}
}
return results;
} catch (error) {
console.error('Error fetching specific events:', error);
return [];
} finally {
setLoading(false);
}
}, [ndk]);
// Subscribe to events
useEffect(() => {
if (!ndk || !filters || !enabled || !subscriptionEnabled) {
setLoading(false);
return;
}
// Clean up any existing subscription
if (subscriptionRef.current) {
subscriptionRef.current.stop();
subscriptionRef.current = null;
}
setLoading(true);
setEose(false);
// Track component mount state
let isMounted = true;
const subscription = ndk.subscribe(filters, { subId });
subscriptionRef.current = subscription;
// Event handler
const handleEvent = (event: NDKEvent) => {
if (isMounted) {
processEvent(event);
}
};
// EOSE handler
const handleEose = () => {
if (isMounted) {
setLoading(false);
setEose(true);
setNewEntries([]);
}
};
subscription.on('event', handleEvent);
subscription.on('eose', handleEose);
// Cleanup
return () => {
isMounted = false;
if (subscription) {
subscription.off('event', handleEvent);
subscription.off('eose', handleEose);
subscription.stop();
}
subscriptionRef.current = null;
};
}, [ndk, filters, enabled, subscriptionEnabled, subId, processEvent]);
// Return sorted entries and helpers
return {
entries,
newEntries,
loading,
eose,
updateEntry,
clearNewEntries,
resetFeed,
fetchSpecificEvents
};
}

327
lib/hooks/useFeedHooks.ts Normal file
View File

@ -0,0 +1,327 @@
// lib/hooks/useFeedHooks.ts
import { useMemo, useCallback, useState, useEffect } from 'react';
import { useNDKCurrentUser, useNDK } from '@/lib/hooks/useNDK';
import { nip19 } from 'nostr-tools';
import { NDKFilter, NDKEvent, NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk-mobile';
import { useFeedEvents } from '@/lib/hooks/useFeedEvents';
import { useFeedMonitor } from '@/lib/hooks/useFeedMonitor';
import { FeedOptions, AnyFeedEntry } from '@/types/feed';
import { POWR_EVENT_KINDS } from '@/types/nostr-workout';
// POWR official account pubkey
export const POWR_ACCOUNT_PUBKEY = 'npub1p0wer69rpkraqs02l5v8rutagfh6g9wxn2dgytkv44ysz7avt8nsusvpjk';
// Convert POWR account pubkey to hex at the module level
export let POWR_PUBKEY_HEX: string = '';
try {
if (POWR_ACCOUNT_PUBKEY.startsWith('npub')) {
const decoded = nip19.decode(POWR_ACCOUNT_PUBKEY);
POWR_PUBKEY_HEX = decoded.data as string;
} else {
POWR_PUBKEY_HEX = POWR_ACCOUNT_PUBKEY;
}
console.log("Initialized POWR pubkey hex:", POWR_PUBKEY_HEX);
} catch (error) {
console.error('Error decoding POWR account npub:', error);
POWR_PUBKEY_HEX = '';
}
/**
* Hook for the Following tab in the social feed
* Shows content from authors the user follows
*/
export function useFollowingFeed(options: FeedOptions = {}) {
const { currentUser } = useNDKCurrentUser();
const { ndk } = useNDK();
const [followedUsers, setFollowedUsers] = useState<string[]>([]);
const [isLoadingContacts, setIsLoadingContacts] = useState(true);
// Improved contact list fetching
useEffect(() => {
if (!ndk || !currentUser?.pubkey) {
setIsLoadingContacts(false);
return;
}
console.log("Fetching contact list for user:", currentUser.pubkey);
setIsLoadingContacts(true);
const fetchContactList = async () => {
try {
// Try multiple approaches for maximum reliability
let contacts: string[] = [];
// First try: Use NDK user's native follows
if (currentUser.follows) {
try {
// Check if follows is an array, a Set, or a function
if (Array.isArray(currentUser.follows)) {
contacts = [...currentUser.follows];
console.log(`Found ${contacts.length} contacts from array`);
} else if (currentUser.follows instanceof Set) {
contacts = Array.from(currentUser.follows);
console.log(`Found ${contacts.length} contacts from Set`);
} else if (typeof currentUser.follows === 'function') {
// If it's a function, try to call it
try {
const followsResult = await currentUser.followSet();
if (followsResult instanceof Set) {
contacts = Array.from(followsResult);
console.log(`Found ${contacts.length} contacts from followSet() function`);
}
} catch (err) {
console.error("Error calling followSet():", err);
}
}
} catch (err) {
console.log("Error processing follows:", err);
}
}
// Second try: Direct kind:3 fetch
if (contacts.length === 0) {
try {
const contactEvents = await ndk.fetchEvents({
kinds: [3],
authors: [currentUser.pubkey],
limit: 1
}, {
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST
});
if (contactEvents.size > 0) {
const contactEvent = Array.from(contactEvents)[0];
const extracted = contactEvent.tags
.filter(tag => tag[0] === 'p')
.map(tag => tag[1]);
if (extracted.length > 0) {
console.log(`Found ${extracted.length} contacts via direct kind:3 fetch`);
contacts = extracted;
}
}
} catch (err) {
console.error("Error fetching kind:3 events:", err);
}
}
// If still no contacts found, try fetching any recent events and look for p-tags
if (contacts.length === 0) {
try {
const userEvents = await ndk.fetchEvents({
authors: [currentUser.pubkey],
kinds: [1, 3, 7], // Notes, contacts, reactions
limit: 10
}, {
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST
});
// Collect all p-tags from recent events
const mentions = new Set<string>();
for (const event of userEvents) {
event.tags
.filter(tag => tag[0] === 'p')
.forEach(tag => mentions.add(tag[1]));
}
if (mentions.size > 0) {
console.log(`Found ${mentions.size} potential contacts from recent events`);
contacts = Array.from(mentions);
}
} catch (err) {
console.error("Error fetching recent events:", err);
}
}
// If all else fails and we recognize this user, use hardcoded values (for testing only)
if (contacts.length === 0 && currentUser?.pubkey === "55127fc9e1c03c6b459a3bab72fdb99def1644c5f239bdd09f3e5fb401ed9b21") {
console.log("Using hardcoded follows for known user");
contacts = [
"3129509e23d3a6125e1451a5912dbe01099e151726c4766b44e1ecb8c846f506",
"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52",
"0c776e95521742beaf102523a8505c483e8c014ee0d3bd6457bb249034e5ff04",
"2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884",
"55127fc9e1c03c6b459a3bab72fdb99def1644c5f239bdd09f3e5fb401ed9b21"
];
}
// Always include self to ensure self-created content is visible
if (currentUser.pubkey && !contacts.includes(currentUser.pubkey)) {
contacts.push(currentUser.pubkey);
}
// Add POWR account to followed users if not already there
if (POWR_PUBKEY_HEX && !contacts.includes(POWR_PUBKEY_HEX)) {
contacts.push(POWR_PUBKEY_HEX);
}
console.log("Final contact list count:", contacts.length);
setFollowedUsers(contacts);
} catch (error) {
console.error("Error fetching contact list:", error);
} finally {
setIsLoadingContacts(false);
}
};
fetchContactList();
}, [ndk, currentUser?.pubkey]);
// Create filters with the correct follows
const followingFilters = useMemo<NDKFilter[]>(() => {
if (followedUsers.length === 0) {
console.log("No users to follow, not creating filters");
return [];
}
console.log("Creating filters for", followedUsers.length, "followed users");
console.log("Sample follows:", followedUsers.slice(0, 3));
return [
{
kinds: [1] as any[], // Social posts
authors: followedUsers,
'#t': ['workout', 'fitness', 'exercise', 'powr', 'gym'], // Only workout-related posts
limit: 30
},
{
kinds: [30023] as any[], // Articles
authors: followedUsers,
limit: 20
},
{
kinds: [1301, 33401, 33402] as any[], // Workout-specific content
authors: followedUsers,
limit: 30
}
];
}, [followedUsers]);
// Use feed events hook - only enable if we have follows
const feed = useFeedEvents(
followedUsers.length > 0 ? followingFilters : false,
{
subId: 'following-feed',
enabled: followedUsers.length > 0,
feedType: 'following',
...options
}
);
// Feed monitor for auto-refresh
const monitor = useFeedMonitor({
onRefresh: async () => {
return feed.resetFeed();
}
});
return {
...feed,
...monitor,
hasFollows: followedUsers.length > 0,
followCount: followedUsers.length,
followedUsers: followedUsers, // Make this available for debugging
isLoadingContacts
};
}
/**
* Hook for the POWR tab in the social feed
* Shows official POWR content and featured content
*/
export function usePOWRFeed(options: FeedOptions = {}) {
// Create filters for POWR content
const powrFilters = useMemo<NDKFilter[]>(() => {
if (!POWR_PUBKEY_HEX) return [];
return [
{
kinds: [1, 30023, 30024] as any[], // Social posts and articles (including drafts)
authors: [POWR_PUBKEY_HEX],
limit: 25
},
{
kinds: [1301, 33401, 33402] as any[], // Workout-specific content
authors: [POWR_PUBKEY_HEX],
limit: 25
}
];
}, []);
// Filter function to ensure we don't show duplicates
const filterPOWRContent = useCallback((entry: AnyFeedEntry) => {
// Always show POWR content
return true;
}, []);
// Use feed events hook
const feed = useFeedEvents(
POWR_PUBKEY_HEX ? powrFilters : false,
{
subId: 'powr-feed',
feedType: 'powr',
filterFn: filterPOWRContent,
...options
}
);
// Feed monitor for auto-refresh
const monitor = useFeedMonitor({
onRefresh: async () => {
return feed.resetFeed();
}
});
return {
...feed,
...monitor
};
}
/**
* Hook for the Global tab in the social feed
* Shows all workout-related content
*/
export function useGlobalFeed(options: FeedOptions = {}) {
// Global filters - focus on workout content
const globalFilters = useMemo<NDKFilter[]>(() => [
{
kinds: [1301] as any[], // Workout records
limit: 20
},
{
kinds: [1] as any[], // Social posts
'#t': ['workout', 'fitness', 'powr', 'gym'], // With relevant tags
limit: 20
},
{
kinds: [33401, 33402] as any[], // Exercise templates and workout templates
limit: 20
}
], []);
// Use feed events hook
const feed = useFeedEvents(
globalFilters,
{
subId: 'global-feed',
feedType: 'global',
...options
}
);
// Feed monitor for auto-refresh
const monitor = useFeedMonitor({
onRefresh: async () => {
return feed.resetFeed();
}
});
return {
...feed,
...monitor
};
}

View File

@ -0,0 +1,86 @@
// lib/hooks/useFeedMonitor.ts
import { useState, useEffect, useRef, useCallback } from 'react';
import { AppState, AppStateStatus } from 'react-native';
interface FeedMonitorOptions {
enabled?: boolean;
visibilityThreshold?: number;
refreshInterval?: number;
onRefresh?: () => Promise<void>;
}
export function useFeedMonitor(options: FeedMonitorOptions = {}) {
const {
enabled = true,
visibilityThreshold = 60000, // 1 minute
refreshInterval = 300000, // 5 minutes
onRefresh
} = options;
const [isVisible, setIsVisible] = useState(true);
const lastVisibleTimestampRef = useRef(Date.now());
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null);
// Handle app state changes
const handleAppStateChange = useCallback((nextAppState: AppStateStatus) => {
if (nextAppState === 'active') {
const now = Date.now();
const lastVisible = lastVisibleTimestampRef.current;
const timeSinceLastVisible = now - lastVisible;
// If the app was in background for longer than threshold, refresh
if (timeSinceLastVisible > visibilityThreshold && onRefresh) {
onRefresh();
}
setIsVisible(true);
lastVisibleTimestampRef.current = now;
} else if (nextAppState === 'background') {
setIsVisible(false);
}
}, [visibilityThreshold, onRefresh]);
// Set up app state monitoring
useEffect(() => {
if (!enabled) return;
const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => {
subscription.remove();
};
}, [enabled, handleAppStateChange]);
// Set up periodic refresh
useEffect(() => {
if (!enabled || !onRefresh) return;
const startRefreshTimer = () => {
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
}
refreshTimerRef.current = setTimeout(async () => {
if (isVisible) {
await onRefresh();
}
startRefreshTimer();
}, refreshInterval);
};
startRefreshTimer();
return () => {
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = null;
}
};
}, [enabled, isVisible, refreshInterval, onRefresh]);
return {
isVisible,
refresh: onRefresh
};
}

61
lib/hooks/useFeedState.ts Normal file
View File

@ -0,0 +1,61 @@
// lib/hooks/useFeedState.ts
import { useState, useRef, useCallback } from 'react';
import { AnyFeedEntry } from '@/types/feed';
export function useFeedState(initialSortFn = (a: AnyFeedEntry, b: AnyFeedEntry) => b.timestamp - a.timestamp) {
// Main entries state
const [entries, setEntries] = useState<AnyFeedEntry[]>([]);
const [newEntries, setNewEntries] = useState<AnyFeedEntry[]>([]);
// Reference to actual entries for stable access without re-renders
const entriesRef = useRef<Record<string, AnyFeedEntry>>({});
// Track seen events to avoid duplicates
const seenEventsRef = useRef<Set<string>>(new Set());
// Add or update an entry
const upsertEntry = useCallback((entry: AnyFeedEntry) => {
if (!entry.id || !entry.eventId) return;
// Skip if we've already seen this event
if (seenEventsRef.current.has(entry.eventId)) return;
seenEventsRef.current.add(entry.eventId);
// Store in reference map for efficient lookup
entriesRef.current[entry.id] = entry;
// Convert to array for display
const entriesArray = Object.values(entriesRef.current).sort(initialSortFn);
setEntries(entriesArray);
}, [initialSortFn]);
// Add to new entries
const addNewEntry = useCallback((entry: AnyFeedEntry) => {
setNewEntries(prev => [...prev, entry]);
}, []);
// Clear new entries
const clearNewEntries = useCallback(() => {
setNewEntries([]);
}, []);
// Reset feed state
const resetFeed = useCallback(() => {
entriesRef.current = {};
seenEventsRef.current.clear();
setEntries([]);
setNewEntries([]);
return Promise.resolve();
}, []);
return {
entries,
newEntries,
upsertEntry,
addNewEntry,
clearNewEntries,
resetFeed,
// Expose refs for advanced usage
entriesRef,
seenEventsRef
};
}

View File

@ -1,5 +1,5 @@
// lib/hooks/useProfile.ts
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { NDKUser, NDKUserProfile } from '@nostr-dev-kit/ndk-mobile';
import { useNDK } from './useNDK';
@ -10,12 +10,25 @@ export function useProfile(pubkey: string | undefined) {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
// Reference to track if component is mounted
const isMountedRef = useRef(true);
// Reset mounted ref when component unmounts
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
useEffect(() => {
if (!ndk || !pubkey) {
setIsLoading(false);
return;
}
let isEffectActive = true;
const fetchProfile = async () => {
try {
setIsLoading(true);
@ -35,21 +48,32 @@ export function useProfile(pubkey: string | undefined) {
}
}
setUser(ndkUser);
setProfile(ndkUser.profile || null);
setIsLoading(false);
// Only update state if component is still mounted and effect is active
if (isMountedRef.current && isEffectActive) {
setUser(ndkUser);
setProfile(ndkUser.profile || null);
setIsLoading(false);
}
} catch (err) {
console.error('Error fetching profile:', err);
setError(err instanceof Error ? err : new Error('Failed to fetch profile'));
setIsLoading(false);
// Only update state if component is still mounted and effect is active
if (isMountedRef.current && isEffectActive) {
setError(err instanceof Error ? err : new Error('Failed to fetch profile'));
setIsLoading(false);
}
}
};
fetchProfile();
// Cleanup function to prevent state updates if the effect is cleaned up
return () => {
isEffectActive = false;
};
}, [ndk, pubkey]);
const refreshProfile = async () => {
if (!ndk || !pubkey) return;
if (!ndk || !pubkey || !isMountedRef.current) return;
try {
setIsLoading(true);
@ -58,12 +82,19 @@ export function useProfile(pubkey: string | undefined) {
const ndkUser = ndk.getUser({ pubkey });
await ndkUser.fetchProfile();
setUser(ndkUser);
setProfile(ndkUser.profile || null);
// Only update state if component is still mounted
if (isMountedRef.current) {
setUser(ndkUser);
setProfile(ndkUser.profile || null);
}
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to refresh profile'));
if (isMountedRef.current) {
setError(err instanceof Error ? err : new Error('Failed to refresh profile'));
}
} finally {
setIsLoading(false);
if (isMountedRef.current) {
setIsLoading(false);
}
}
};

398
lib/hooks/useSocialFeed.ts Normal file
View File

@ -0,0 +1,398 @@
// hooks/useSocialFeed.ts
import { useState, useEffect, useRef, useCallback } from 'react';
import { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
import { nip19 } from 'nostr-tools';
import { SocialFeedService } from '@/lib/social/socialFeedService';
import { useNDK } from '@/lib/hooks/useNDK';
import {
parseWorkoutRecord,
parseExerciseTemplate,
parseWorkoutTemplate,
parseSocialPost,
parseLongformContent,
POWR_EVENT_KINDS,
ParsedWorkoutRecord,
ParsedExerciseTemplate,
ParsedWorkoutTemplate,
ParsedSocialPost,
ParsedLongformContent
} from '@/types/nostr-workout';
import { POWR_PUBKEY_HEX } from './useFeedHooks';
export type FeedItem = {
id: string;
type: 'workout' | 'exercise' | 'template' | 'social' | 'article';
originalEvent: NDKEvent;
parsedContent: ParsedWorkoutRecord | ParsedExerciseTemplate | ParsedWorkoutTemplate | ParsedSocialPost | ParsedLongformContent;
createdAt: number;
};
export function useSocialFeed(
options: {
feedType: 'following' | 'powr' | 'global';
since?: number;
until?: number;
limit?: number;
authors?: string[];
kinds?: number[];
}
) {
const { ndk } = useNDK();
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true);
const [oldestTimestamp, setOldestTimestamp] = useState<number | null>(null);
// Keep track of seen events to prevent duplicates
const seenEvents = useRef(new Set<string>());
const quotedEvents = useRef(new Set<string>());
const subscriptionRef = useRef<{ unsubscribe: () => void } | null>(null);
const socialServiceRef = useRef<SocialFeedService | null>(null);
// Process event and add to feed
const processEvent = useCallback((event: NDKEvent) => {
// Skip if we've seen this event before or event has no ID
if (!event.id || seenEvents.current.has(event.id)) return;
console.log(`Processing event ${event.id}, kind ${event.kind} from ${event.pubkey}`);
// Check if this event is quoted by another event we've already seen
// Skip unless it's from the POWR account (always show POWR content)
if (
quotedEvents.current.has(event.id) &&
event.pubkey !== POWR_PUBKEY_HEX
) {
console.log(`Event ${event.id} filtered out: quoted=${true}, pubkey=${event.pubkey}`);
return;
}
// Track any events this quotes to avoid showing them separately
if (event.kind === 1) {
// Check e-tags (direct quotes)
event.tags
.filter(tag => tag[0] === 'e')
.forEach(tag => {
if (tag[1]) quotedEvents.current.add(tag[1]);
});
// Check a-tags (addressable events)
event.tags
.filter(tag => tag[0] === 'a')
.forEach(tag => {
const parts = tag[1]?.split(':');
if (parts && parts.length >= 3) {
const [kind, pubkey, identifier] = parts;
// We track the identifier so we can match it with the d-tag
// of addressable events (kinds 30023, 33401, 33402, etc.)
if (pubkey && identifier) {
quotedEvents.current.add(`${pubkey}:${identifier}`);
}
}
});
// Also check for quoted content using NIP-27 nostr: URI mentions
if (event.content) {
const nostrUriMatches = event.content.match(/nostr:(note1|nevent1|naddr1)[a-z0-9]+/g);
if (nostrUriMatches) {
nostrUriMatches.forEach(uri => {
try {
const decoded = nip19.decode(uri.replace('nostr:', ''));
if (decoded.type === 'note' || decoded.type === 'nevent') {
quotedEvents.current.add(decoded.data as string);
} else if (decoded.type === 'naddr') {
// For addressable content, add to tracking using pubkey:identifier format
const data = decoded.data as any;
quotedEvents.current.add(`${data.pubkey}:${data.identifier}`);
}
} catch (e) {
// Ignore invalid nostr URIs
}
});
}
}
}
// Mark as seen
seenEvents.current.add(event.id);
// Parse event based on kind
let feedItem: FeedItem | null = null;
try {
const timestamp = event.created_at || Math.floor(Date.now() / 1000);
switch (event.kind) {
case POWR_EVENT_KINDS.WORKOUT_RECORD: // 1301
feedItem = {
id: event.id,
type: 'workout',
originalEvent: event,
parsedContent: parseWorkoutRecord(event),
createdAt: timestamp
};
break;
case POWR_EVENT_KINDS.EXERCISE_TEMPLATE: // 33401
feedItem = {
id: event.id,
type: 'exercise',
originalEvent: event,
parsedContent: parseExerciseTemplate(event),
createdAt: timestamp
};
break;
case POWR_EVENT_KINDS.WORKOUT_TEMPLATE: // 33402
feedItem = {
id: event.id,
type: 'template',
originalEvent: event,
parsedContent: parseWorkoutTemplate(event),
createdAt: timestamp
};
break;
case POWR_EVENT_KINDS.SOCIAL_POST: // 1
// Parse social post
const parsedSocialPost = parseSocialPost(event);
feedItem = {
id: event.id,
type: 'social',
originalEvent: event,
parsedContent: parsedSocialPost,
createdAt: timestamp
};
// If it has quoted content, resolve it asynchronously
const quotedContent = parsedSocialPost.quotedContent;
if (quotedContent && socialServiceRef.current) {
socialServiceRef.current.getReferencedContent(quotedContent.id, quotedContent.kind)
.then(referencedEvent => {
if (!referencedEvent) return;
// Parse the referenced event
let resolvedContent: any = null;
switch (referencedEvent.kind) {
case POWR_EVENT_KINDS.WORKOUT_RECORD:
resolvedContent = parseWorkoutRecord(referencedEvent);
break;
case POWR_EVENT_KINDS.EXERCISE_TEMPLATE:
resolvedContent = parseExerciseTemplate(referencedEvent);
break;
case POWR_EVENT_KINDS.WORKOUT_TEMPLATE:
resolvedContent = parseWorkoutTemplate(referencedEvent);
break;
case 30023:
case 30024:
resolvedContent = parseLongformContent(referencedEvent);
break;
}
if (resolvedContent) {
// Update the feed item with the referenced content
setFeedItems(current => {
return current.map(item => {
if (item.id !== event.id) return item;
// Add the resolved content to the social post
const updatedContent = {
...(item.parsedContent as ParsedSocialPost),
quotedContent: {
...quotedContent,
resolved: resolvedContent
}
};
return {
...item,
parsedContent: updatedContent
};
});
});
}
})
.catch(error => {
console.error('Error fetching referenced content:', error);
});
}
break;
case 30023: // Published long-form content
feedItem = {
id: event.id,
type: 'article',
originalEvent: event,
parsedContent: parseLongformContent(event),
createdAt: timestamp
};
break;
case 30024: // Draft long-form content - only show from POWR account
if (event.pubkey === POWR_PUBKEY_HEX && options.feedType === 'powr') {
feedItem = {
id: event.id,
type: 'article',
originalEvent: event,
parsedContent: parseLongformContent(event),
createdAt: timestamp
};
}
break;
default:
// Ignore other event kinds
return;
}
// For addressable events (those with d-tags), also check if they're quoted
if (
(event.kind >= 30000 || event.kind === 1301) &&
event.pubkey !== POWR_PUBKEY_HEX
) {
const dTags = event.getMatchingTags('d');
if (dTags.length > 0) {
const identifier = dTags[0][1];
if (identifier && quotedEvents.current.has(`${event.pubkey}:${identifier}`)) {
// This addressable event is quoted, so we'll skip it
console.log(`Addressable event ${event.id} filtered out: quoted as ${event.pubkey}:${identifier}`);
return;
}
}
}
// Add to feed items if we were able to parse it
if (feedItem) {
console.log(`Adding event ${event.id} to feed as ${feedItem.type}`);
setFeedItems(current => {
const newItems = [...current, feedItem as FeedItem];
// Sort by created_at (most recent first)
return newItems.sort((a, b) => b.createdAt - a.createdAt);
});
// Update oldest timestamp for pagination
if (!oldestTimestamp || (timestamp && timestamp < oldestTimestamp)) {
setOldestTimestamp(timestamp);
}
}
} catch (error) {
console.error('Error processing event:', error, event);
}
}, [oldestTimestamp, options.feedType]);
// Load feed data
const loadFeed = useCallback(async () => {
if (!ndk) return;
setLoading(true);
// Initialize social service if not already done
if (!socialServiceRef.current) {
socialServiceRef.current = new SocialFeedService(ndk);
}
// Clean up any existing subscription
if (subscriptionRef.current) {
subscriptionRef.current.unsubscribe();
subscriptionRef.current = null;
}
try {
console.log(`Loading ${options.feedType} feed with authors:`, options.authors);
// Subscribe to feed
const subscription = await socialServiceRef.current.subscribeFeed({
feedType: options.feedType,
since: options.since,
until: options.until,
limit: options.limit || 30,
authors: options.authors,
kinds: options.kinds,
onEvent: processEvent,
onEose: () => {
setLoading(false);
}
});
subscriptionRef.current = subscription;
} catch (error) {
console.error('Error loading feed:', error);
setLoading(false);
}
}, [ndk, options.feedType, options.since, options.until, options.limit, options.authors, options.kinds, processEvent]);
// Refresh feed (clear events and reload)
const refresh = useCallback(async () => {
console.log(`Refreshing ${options.feedType} feed`);
setFeedItems([]);
seenEvents.current.clear();
quotedEvents.current.clear(); // Also reset quoted events
setOldestTimestamp(null);
setHasMore(true);
await loadFeed();
}, [loadFeed, options.feedType]);
// Load more (pagination)
const loadMore = useCallback(async () => {
if (loading || !hasMore || !oldestTimestamp || !ndk || !socialServiceRef.current) return;
try {
setLoading(true);
// Keep track of the current count of seen events
const initialCount = seenEvents.current.size;
// Subscribe with oldest timestamp - 1 second
const subscription = await socialServiceRef.current.subscribeFeed({
feedType: options.feedType,
since: options.since,
until: oldestTimestamp - 1, // Load events older than our oldest event
limit: options.limit || 30,
authors: options.authors,
kinds: options.kinds,
onEvent: processEvent,
onEose: () => {
setLoading(false);
// Check if we got any new events
if (seenEvents.current.size === initialCount) {
// No new events were added, so we've likely reached the end
setHasMore(false);
}
}
});
// Clean up this subscription after we get the events
setTimeout(() => {
subscription.unsubscribe();
}, 5000);
} catch (error) {
console.error('Error loading more:', error);
setLoading(false);
}
}, [loading, hasMore, oldestTimestamp, ndk, options.feedType, options.since, options.limit, options.authors, options.kinds, processEvent]);
// Load feed on mount or when dependencies change
useEffect(() => {
if (ndk) {
loadFeed();
}
// Clean up subscription on unmount
return () => {
if (subscriptionRef.current) {
subscriptionRef.current.unsubscribe();
}
};
}, [ndk, loadFeed]);
return {
feedItems,
loading,
refresh,
loadMore,
hasMore,
socialService: socialServiceRef.current
};
}

View File

@ -0,0 +1,350 @@
// lib/social/SocialFeedService.ts
import NDK, { NDKEvent, NDKFilter, NDKSubscription } from '@nostr-dev-kit/ndk-mobile';
import { POWR_EVENT_KINDS } from '@/types/nostr-workout';
import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService';
import { Workout } from '@/types/workout';
export class SocialFeedService {
private ndk: NDK;
constructor(ndk: NDK) {
this.ndk = ndk;
}
/**
* Subscribe to a feed of workout-related events
* @param options Subscription options
* @returns Subscription object with unsubscribe method
*/
subscribeFeed(options: {
feedType: 'following' | 'powr' | 'global';
since?: number;
until?: number;
limit?: number;
authors?: string[];
kinds?: number[];
onEvent: (event: NDKEvent) => void;
onEose?: () => void;
}): Promise<{ unsubscribe: () => void }> {
const { feedType, since, until, limit, authors, kinds, onEvent, onEose } = options;
// Default to events in the last 24 hours if no since provided
const defaultSince = Math.floor(Date.now() / 1000) - 24 * 60 * 60;
// Create filters based on feedType
const filters: NDKFilter[] = [];
// Workout content filter
if (!kinds || kinds.some(k => [1301, 33401, 33402].includes(k))) {
const workoutFilter: NDKFilter = {
kinds: [1301, 33401, 33402].filter(k => !kinds || kinds.includes(k)) as any[],
since: since || defaultSince,
limit: limit || 20,
};
if (until) {
workoutFilter.until = until;
}
if (feedType === 'following' || feedType === 'powr') {
if (Array.isArray(authors) && authors.length > 0) {
workoutFilter.authors = authors;
}
}
filters.push(workoutFilter);
}
// Social post filter
if (!kinds || kinds.includes(1)) {
const socialPostFilter: NDKFilter = {
kinds: [1] as any[],
since: since || defaultSince,
limit: limit || 20,
};
if (until) {
socialPostFilter.until = until;
}
if (feedType === 'following' || feedType === 'powr') {
if (Array.isArray(authors) && authors.length > 0) {
socialPostFilter.authors = authors;
}
} else if (feedType === 'global') {
// For global feed, add some relevant tags for filtering
socialPostFilter['#t'] = ['workout', 'fitness', 'powr'];
}
filters.push(socialPostFilter);
}
// Article filter
if (!kinds || kinds.includes(30023)) {
const articleFilter: NDKFilter = {
kinds: [30023] as any[],
since: since || defaultSince,
limit: limit || 20,
};
if (until) {
articleFilter.until = until;
}
if (feedType === 'following' || feedType === 'powr') {
if (Array.isArray(authors) && authors.length > 0) {
articleFilter.authors = authors;
}
}
filters.push(articleFilter);
}
// Special case for POWR feed - also include draft articles
if (feedType === 'powr' && (!kinds || kinds.includes(30024))) {
const draftFilter: NDKFilter = {
kinds: [30024] as any[],
since: since || defaultSince,
limit: limit || 20,
};
if (until) {
draftFilter.until = until;
}
if (Array.isArray(authors) && authors.length > 0) {
draftFilter.authors = authors;
}
filters.push(draftFilter);
}
// Create subscriptions
const subscriptions: NDKSubscription[] = [];
// Create a subscription for each filter
for (const filter of filters) {
console.log(`Subscribing with filter:`, filter);
const subscription = this.ndk.subscribe(filter);
subscription.on('event', (event: NDKEvent) => {
onEvent(event);
});
subscription.on('eose', () => {
if (onEose) onEose();
});
subscriptions.push(subscription);
}
// Return a Promise with the unsubscribe object
return Promise.resolve({
unsubscribe: () => {
subscriptions.forEach(sub => {
sub.stop();
});
}
});
}
/**
* Get comments for an event
* @param eventId Event ID to get comments for
* @returns Array of comment events
*/
async getComments(eventId: string): Promise<NDKEvent[]> {
const filter: NDKFilter = {
kinds: [POWR_EVENT_KINDS.COMMENT],
"#e": [eventId],
};
const events = await this.ndk.fetchEvents(filter);
return Array.from(events);
}
/**
* Post a comment on an event
* @param parentEvent Parent event to comment on
* @param content Comment text
* @param replyTo Optional comment to reply to
* @returns The created comment event
*/
async postComment(
parentEvent: NDKEvent,
content: string,
replyTo?: NDKEvent
): Promise<NDKEvent> {
const comment = new NDKEvent(this.ndk);
comment.kind = POWR_EVENT_KINDS.COMMENT;
comment.content = content;
// Add tag for the root event
comment.tags.push(['e', parentEvent.id, '', 'root']);
// If this is a reply to another comment, add that reference
if (replyTo) {
comment.tags.push(['e', replyTo.id, '', 'reply']);
}
// Add author reference
comment.tags.push(['p', parentEvent.pubkey]);
// Sign and publish
await comment.sign();
await comment.publish();
return comment;
}
/**
* React to an event (like, etc.)
* @param event Event to react to
* @param reaction Reaction content ('+' for like)
* @returns The created reaction event
*/
async reactToEvent(event: NDKEvent, reaction: string = '+'): Promise<NDKEvent> {
const reactionEvent = new NDKEvent(this.ndk);
reactionEvent.kind = POWR_EVENT_KINDS.REACTION;
reactionEvent.content = reaction;
// Add event and author references
reactionEvent.tags.push(['e', event.id]);
reactionEvent.tags.push(['p', event.pubkey]);
// Sign and publish
await reactionEvent.sign();
await reactionEvent.publish();
return reactionEvent;
}
/**
* Get the referenced content for a social post
* @param eventId ID of the referenced event
* @param kind Kind of the referenced event
* @returns The referenced event or null
*/
async getReferencedContent(eventId: string, kind: number): Promise<NDKEvent | null> {
// Handle addressable content (a-tag references)
if (eventId.includes(':')) {
const parts = eventId.split(':');
if (parts.length >= 3) {
// Format is kind:pubkey:identifier
const filter: NDKFilter = {
kinds: [parseInt(parts[0])],
authors: [parts[1]],
"#d": [parts[2]],
};
const events = await this.ndk.fetchEvents(filter);
return events.size > 0 ? Array.from(events)[0] : null;
}
}
// Standard event reference (direct ID)
const filter: NDKFilter = {
ids: [eventId],
kinds: [kind],
};
const events = await this.ndk.fetchEvents(filter);
return events.size > 0 ? Array.from(events)[0] : null;
}
/**
* Resolve quoted content in a social post
* @param event Social post event
* @returns Referenced event or null
*/
async resolveQuotedContent(event: NDKEvent): Promise<NDKEvent | null> {
if (event.kind !== POWR_EVENT_KINDS.SOCIAL_POST) return null;
// Find the quoted event ID
const quoteTag = event.tags.find(tag => tag[0] === 'q');
if (!quoteTag) return null;
// Find the kind tag
const kindTag = event.tags.find(tag =>
tag[0] === 'k' &&
['1301', '33401', '33402', '30023'].includes(tag[1])
);
if (!kindTag) return null;
const quotedEventId = quoteTag[1];
const quotedEventKind = parseInt(kindTag[1]);
return this.getReferencedContent(quotedEventId, quotedEventKind);
}
/**
* Publish a workout record to Nostr
* @param workout Workout data
* @param options Publishing options
* @returns The published event
*/
async publishWorkoutRecord(
workout: Workout,
options: {
shareAsSocialPost?: boolean;
socialText?: string;
limited?: boolean;
} = {}
): Promise<NDKEvent> {
// Get appropriate event data from NostrWorkoutService
const eventData = options.limited
? NostrWorkoutService.createLimitedWorkoutEvent(workout)
: NostrWorkoutService.createCompleteWorkoutEvent(workout);
// Create and publish the event
const event = new NDKEvent(this.ndk);
event.kind = eventData.kind;
event.content = eventData.content;
event.tags = eventData.tags || [];
event.created_at = eventData.created_at;
await event.sign();
await event.publish();
// Create social share if requested
if (options.shareAsSocialPost && options.socialText) {
const socialEventData = NostrWorkoutService.createSocialShareEvent(
event.id,
options.socialText
);
await this.publishEvent(socialEventData);
}
return event;
}
/**
* Helper to publish a generic event
* @param eventData Event data to publish
* @returns Published event
*/
async publishEvent(eventData: any): Promise<NDKEvent> {
const event = new NDKEvent(this.ndk);
event.kind = eventData.kind;
event.content = eventData.content;
event.tags = eventData.tags || [];
event.created_at = eventData.created_at;
await event.sign();
await event.publish();
return event;
}
/**
* Get POWR team pubkeys - to be replaced with actual pubkeys
* @returns Array of POWR team pubkeys
*/
private getPOWRTeamPubkeys(): string[] {
// Replace with actual POWR team pubkeys
return [
// TODO: Add actual POWR team pubkeys
'55127fc9e1c03c6b459a3bab72fdb99def1644c5f239bdd09f3e5fb401ed9b21',
];
}
}

98
package-lock.json generated
View File

@ -74,6 +74,7 @@
"react-native": "0.76.7",
"react-native-gesture-handler": "~2.20.2",
"react-native-get-random-values": "~1.11.0",
"react-native-markdown-display": "^7.0.2",
"react-native-pager-view": "6.5.1",
"react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0",
@ -10567,6 +10568,15 @@
"node": ">= 6"
}
},
"node_modules/camelize": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001699",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001699.tgz",
@ -11164,6 +11174,15 @@
"node": ">=8"
}
},
"node_modules/css-color-keywords": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
"integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
"license": "ISC",
"engines": {
"node": ">=4"
}
},
"node_modules/css-in-js-utils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
@ -11189,6 +11208,17 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-to-react-native": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
"integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
"license": "MIT",
"dependencies": {
"camelize": "^1.0.0",
"css-color-keywords": "^1.0.0",
"postcss-value-parser": "^4.0.2"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
@ -15600,6 +15630,15 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/linkify-it": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz",
"integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==",
"license": "MIT",
"dependencies": {
"uc.micro": "^1.0.1"
}
},
"node_modules/loader-runner": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
@ -15794,6 +15833,28 @@
"tmpl": "1.0.5"
}
},
"node_modules/markdown-it": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-10.0.0.tgz",
"integrity": "sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"entities": "~2.0.0",
"linkify-it": "^2.0.0",
"mdurl": "^1.0.1",
"uc.micro": "^1.0.5"
},
"bin": {
"markdown-it": "bin/markdown-it.js"
}
},
"node_modules/markdown-it/node_modules/entities": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.0.3.tgz",
"integrity": "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ==",
"license": "BSD-2-Clause"
},
"node_modules/marky": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/marky/-/marky-1.2.5.tgz",
@ -15841,6 +15902,12 @@
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/mdurl": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
"integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==",
"license": "MIT"
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@ -18043,6 +18110,15 @@
"node": ">=10"
}
},
"node_modules/react-native-fit-image": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz",
"integrity": "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg==",
"license": "Beerware",
"dependencies": {
"prop-types": "^15.5.10"
}
},
"node_modules/react-native-gesture-handler": {
"version": "2.20.2",
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.20.2.tgz",
@ -18095,6 +18171,22 @@
"react-native": ">=0.73.0"
}
},
"node_modules/react-native-markdown-display": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/react-native-markdown-display/-/react-native-markdown-display-7.0.2.tgz",
"integrity": "sha512-Mn4wotMvMfLAwbX/huMLt202W5DsdpMO/kblk+6eUs55S57VVNni1gzZCh5qpznYLjIQELNh50VIozEfY6fvaQ==",
"license": "MIT",
"dependencies": {
"css-to-react-native": "^3.0.0",
"markdown-it": "^10.0.0",
"prop-types": "^15.7.2",
"react-native-fit-image": "^1.5.5"
},
"peerDependencies": {
"react": ">=16.2.0",
"react-native": ">=0.50.4"
}
},
"node_modules/react-native-pager-view": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/react-native-pager-view/-/react-native-pager-view-6.5.1.tgz",
@ -20253,6 +20345,12 @@
"node": "*"
}
},
"node_modules/uc.micro": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==",
"license": "MIT"
},
"node_modules/undici": {
"version": "6.21.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz",

View File

@ -64,6 +64,7 @@
"clsx": "^2.1.0",
"date-fns": "^4.1.0",
"expo": "^52.0.35",
"expo-av": "~15.0.2",
"expo-crypto": "~14.0.2",
"expo-dev-client": "~5.0.12",
"expo-file-system": "~18.0.10",
@ -87,6 +88,7 @@
"react-native": "0.76.7",
"react-native-gesture-handler": "~2.20.2",
"react-native-get-random-values": "~1.11.0",
"react-native-markdown-display": "^7.0.2",
"react-native-pager-view": "6.5.1",
"react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0",
@ -97,8 +99,7 @@
"tailwind-merge": "^2.2.1",
"tailwindcss": "3.3.5",
"tailwindcss-animate": "^1.0.7",
"zustand": "^4.5.6",
"expo-av": "~15.0.2"
"zustand": "^4.5.6"
},
"devDependencies": {
"@babel/core": "^7.26.0",

82
types/feed.ts Normal file
View File

@ -0,0 +1,82 @@
// types/feed.ts
import { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
import {
ParsedWorkoutRecord,
ParsedExerciseTemplate,
ParsedWorkoutTemplate,
ParsedSocialPost,
ParsedLongformContent
} from '@/types/nostr-workout';
// Base feed entry interface
export interface FeedEntry {
id: string;
eventId: string;
event?: NDKEvent;
timestamp: number;
seen?: boolean;
updated?: number;
}
// Workout-specific feed entry
export interface WorkoutFeedEntry extends FeedEntry {
type: 'workout';
content?: ParsedWorkoutRecord;
}
// Exercise template feed entry
export interface ExerciseFeedEntry extends FeedEntry {
type: 'exercise';
content?: ParsedExerciseTemplate;
}
// Workout template feed entry
export interface TemplateFeedEntry extends FeedEntry {
type: 'template';
content?: ParsedWorkoutTemplate;
}
// Social post feed entry
export interface SocialFeedEntry extends FeedEntry {
type: 'social';
content?: ParsedSocialPost;
}
// Article feed entry
export interface ArticleFeedEntry extends FeedEntry {
type: 'article';
content?: ParsedLongformContent;
}
// Union type for all feed entries
export type AnyFeedEntry =
| WorkoutFeedEntry
| ExerciseFeedEntry
| TemplateFeedEntry
| SocialFeedEntry
| ArticleFeedEntry;
// Function signature for updating entries
export type UpdateEntryFn = (id: string, updater: (entry: AnyFeedEntry) => AnyFeedEntry) => void;
// Feed filter options
export interface FeedFilterOptions {
feedType: 'following' | 'powr' | 'global';
since?: number;
until?: number;
limit?: number;
authors?: string[];
kinds?: number[];
}
// Feed entry filter function
export type FeedEntryFilterFn = (entry: AnyFeedEntry) => boolean;
// Feed options
export interface FeedOptions {
subId?: string;
enabled?: boolean;
filterFn?: FeedEntryFilterFn;
sortFn?: (a: AnyFeedEntry, b: AnyFeedEntry) => number;
feedType?: 'following' | 'powr' | 'global'; // Added this property
}

399
types/nostr-workout.ts Normal file
View File

@ -0,0 +1,399 @@
// types/nostr-workout.ts
import { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
// Event kind definitions for POWR app
export const POWR_EVENT_KINDS = {
EXERCISE_TEMPLATE: 33401,
WORKOUT_TEMPLATE: 33402,
WORKOUT_RECORD: 1301,
SOCIAL_POST: 1,
COMMENT: 1111,
REACTION: 7,
LONG_FORM_CONTENT: 30023,
DRAFT_CONTENT: 30024,
};
// Interface for parsed workout record
export interface ParsedWorkoutRecord {
id: string;
title: string;
type: string;
startTime: number;
endTime?: number;
completed: boolean;
exercises: ParsedExercise[];
templateReference?: string;
notes: string;
author: string;
createdAt: number;
}
// Interface for parsed exercise
export interface ParsedExercise {
id: string;
name: string;
weight?: number;
reps?: number;
rpe?: number;
setType?: string;
}
// Interface for parsed exercise template
export interface ParsedExerciseTemplate {
id: string;
title: string;
equipment?: string;
difficulty?: string;
description: string;
format: string[];
formatUnits: string[];
tags: string[];
author: string;
createdAt: number;
}
// Interface for parsed workout template
export interface ParsedWorkoutTemplate {
id: string;
title: string;
type: string;
description: string;
rounds?: number;
duration?: number;
interval?: number;
restBetweenRounds?: number;
exercises: ParsedTemplateExercise[];
tags: string[];
author: string;
createdAt: number;
}
// Interface for exercise reference in template
export interface ParsedTemplateExercise {
reference: string;
params: string[];
name?: string; // Resolved name if available
}
// Interface for parsed social post
export interface ParsedSocialPost {
id: string;
content: string;
author: string;
quotedContent?: {
id: string;
kind: number;
type: 'workout' | 'exercise' | 'template' | 'article';
resolved?: ParsedWorkoutRecord | ParsedExerciseTemplate | ParsedWorkoutTemplate | ParsedLongformContent;
};
tags: string[];
createdAt: number;
}
// Interface for parsed long-form content (NIP-23)
export interface ParsedLongformContent {
id: string;
title: string;
content: string;
summary?: string;
image?: string;
publishedAt: number;
tags: string[];
author: string;
createdAt: number;
}
// Helper function to extract tag value from event
export function findTagValue(tags: string[][], name: string): string | undefined {
const tag = tags.find(t => t[0] === name);
return tag ? tag[1] : undefined;
}
// Function to parse workout record event
export function parseWorkoutRecord(event: NDKEvent): ParsedWorkoutRecord {
const title = findTagValue(event.tags, 'title') || 'Untitled Workout';
const type = findTagValue(event.tags, 'type') || 'strength';
const startTimeStr = findTagValue(event.tags, 'start');
const endTimeStr = findTagValue(event.tags, 'end');
const completedStr = findTagValue(event.tags, 'completed');
const startTime = startTimeStr ? parseInt(startTimeStr) * 1000 : Date.now();
const endTime = endTimeStr && endTimeStr !== '' ? parseInt(endTimeStr) * 1000 : undefined;
const completed = completedStr === 'true';
// Extract exercises from tags
const exercises: ParsedExercise[] = event.tags
.filter(tag => tag[0] === 'exercise')
.map(tag => {
// Format: ['exercise', '<kind>:<pubkey>:<d-tag>', '<relay-url>', '<weight>', '<reps>', '<rpe>', '<set_type>']
const reference = tag[1] || '';
const parts = reference.split(':');
return {
id: parts.length > 2 ? parts[2] : reference,
name: extractExerciseName(reference),
weight: tag[3] ? parseFloat(tag[3]) : undefined,
reps: tag[4] ? parseInt(tag[4]) : undefined,
rpe: tag[5] ? parseFloat(tag[5]) : undefined,
setType: tag[6] || 'normal'
};
});
// Get template reference if available
const templateTag = event.tags.find(tag => tag[0] === 'template');
const templateReference = templateTag ? templateTag[1] : undefined;
// Extract tags
const tags = event.tags
.filter(tag => tag[0] === 't')
.map(tag => tag[1]);
return {
id: event.id || '',
title,
type,
startTime,
endTime,
completed,
exercises,
templateReference,
notes: event.content,
author: event.pubkey || '',
createdAt: event.created_at ? event.created_at * 1000 : Date.now()
};
}
// Function to parse exercise template event
export function parseExerciseTemplate(event: NDKEvent): ParsedExerciseTemplate {
const title = findTagValue(event.tags, 'title') || 'Untitled Exercise';
const equipment = findTagValue(event.tags, 'equipment');
const difficulty = findTagValue(event.tags, 'difficulty');
// Parse format and format units
const formatTag = event.tags.find(tag => tag[0] === 'format');
const formatUnitsTag = event.tags.find(tag => tag[0] === 'format_units');
const format = formatTag ? formatTag.slice(1) : [];
const formatUnits = formatUnitsTag ? formatUnitsTag.slice(1) : [];
// Extract tags
const tags = event.tags
.filter(tag => tag[0] === 't')
.map(tag => tag[1]);
return {
id: event.id || '',
title,
equipment,
difficulty,
description: event.content,
format,
formatUnits,
tags,
author: event.pubkey || '',
createdAt: event.created_at ? event.created_at * 1000 : Date.now()
};
}
// Function to parse workout template event
export function parseWorkoutTemplate(event: NDKEvent): ParsedWorkoutTemplate {
const title = findTagValue(event.tags, 'title') || 'Untitled Template';
const type = findTagValue(event.tags, 'type') || 'strength';
const roundsStr = findTagValue(event.tags, 'rounds');
const durationStr = findTagValue(event.tags, 'duration');
const intervalStr = findTagValue(event.tags, 'interval');
const restStr = findTagValue(event.tags, 'rest_between_rounds');
// Parse numeric values
const rounds = roundsStr ? parseInt(roundsStr) : undefined;
const duration = durationStr ? parseInt(durationStr) : undefined;
const interval = intervalStr ? parseInt(intervalStr) : undefined;
const restBetweenRounds = restStr ? parseInt(restStr) : undefined;
// Extract exercise references
const exercises = event.tags
.filter(tag => tag[0] === 'exercise')
.map(tag => {
// Format: ['exercise', '<kind>:<pubkey>:<d-tag>', '<relay-url>', '<param1>', '<param2>', ...]
return {
reference: tag[1] || '',
params: tag.slice(3),
name: extractExerciseName(tag[1])
};
});
// Extract tags
const tags = event.tags
.filter(tag => tag[0] === 't')
.map(tag => tag[1]);
return {
id: event.id || '',
title,
type,
description: event.content,
rounds,
duration,
interval,
restBetweenRounds,
exercises,
tags,
author: event.pubkey || '',
createdAt: event.created_at ? event.created_at * 1000 : Date.now()
};
}
// Function to parse social post that may quote workout content
export function parseSocialPost(event: NDKEvent): ParsedSocialPost {
// Get basic post info
const content = event.content;
const author = event.pubkey || '';
// Extract tags
const tags = event.tags
.filter(tag => tag[0] === 't')
.map(tag => tag[1]);
// Find quoted content
const quoteTag = event.tags.find(tag => tag[0] === 'q');
const kindTag = event.tags.find(tag =>
tag[0] === 'k' &&
['1301', '33401', '33402', '30023'].includes(tag[1])
);
let quotedContent = undefined;
if (quoteTag && kindTag) {
const quotedEventId = quoteTag[1];
const quotedEventKind = parseInt(kindTag[1]);
// Determine the type of quoted content
let contentType: 'workout' | 'exercise' | 'template' | 'article';
switch (quotedEventKind) {
case 1301:
contentType = 'workout';
break;
case 33401:
contentType = 'exercise';
break;
case 33402:
contentType = 'template';
break;
case 30023:
case 30024:
contentType = 'article';
break;
default:
contentType = 'workout'; // Default fallback
}
quotedContent = {
id: quotedEventId,
kind: quotedEventKind,
type: contentType,
};
}
// Also check for a-tags which can reference addressable content
if (!quotedContent) {
const aTag = event.tags.find(tag =>
tag[0] === 'a' &&
tag[1] &&
(tag[1].startsWith('30023:') ||
tag[1].startsWith('33401:') ||
tag[1].startsWith('33402:') ||
tag[1].startsWith('1301:'))
);
if (aTag && aTag[1]) {
const parts = aTag[1].split(':');
if (parts.length >= 3) {
const quotedEventKind = parseInt(parts[0]);
const quotedId = aTag[1]; // Use the full reference for addressable events
// Determine the type of quoted content
let contentType: 'workout' | 'exercise' | 'template' | 'article';
switch (quotedEventKind) {
case 1301:
contentType = 'workout';
break;
case 33401:
contentType = 'exercise';
break;
case 33402:
contentType = 'template';
break;
case 30023:
case 30024:
contentType = 'article';
break;
default:
contentType = 'workout'; // Default fallback
}
quotedContent = {
id: quotedId,
kind: quotedEventKind,
type: contentType,
};
}
}
}
return {
id: event.id || '',
content,
author,
quotedContent,
tags,
createdAt: event.created_at ? event.created_at * 1000 : Date.now()
};
}
// Function to parse long-form content (NIP-23)
export function parseLongformContent(event: NDKEvent): ParsedLongformContent {
// Extract title from tags
const title = findTagValue(event.tags, 'title') || 'Untitled Article';
// Extract image URL if available
const image = findTagValue(event.tags, 'image');
// Extract summary if available
const summary = findTagValue(event.tags, 'summary');
// Extract published date (or use created_at)
const publishedAtTag = findTagValue(event.tags, 'published_at');
const publishedAt = publishedAtTag ? parseInt(publishedAtTag) :
(event.created_at || Math.floor(Date.now() / 1000));
// Extract hashtags
const tags = event.tags
.filter(tag => tag[0] === 't')
.map(tag => tag[1]);
return {
id: event.id || '',
title,
content: event.content,
summary,
image,
publishedAt,
tags,
author: event.pubkey || '',
createdAt: event.created_at ? event.created_at : Math.floor(Date.now() / 1000)
};
}
// Extract exercise name from reference - this should be replaced with lookup from your database
function extractExerciseName(reference: string): string {
// This is a placeholder function
// In production, you would look up the exercise name from your database
// For now, just return a formatted version of the reference
const parts = reference.split(':');
if (parts.length > 2) {
return `Exercise ${parts[2].substring(0, 6)}`;
}
return 'Unknown Exercise';
}

78
utils/feedUtils.ts Normal file
View File

@ -0,0 +1,78 @@
// lib/utils/feedUtils.ts
import { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
import { AnyFeedEntry } from '@/types/feed';
/**
* Process events into feed entries in batches
*/
export function processBatchedEvents(
events: NDKEvent[],
processor: (event: NDKEvent) => AnyFeedEntry | null,
batchSize = 10
): AnyFeedEntry[] {
const results: AnyFeedEntry[] = [];
// Process in batches to avoid blocking the main thread
for (let i = 0; i < events.length; i += batchSize) {
const batch = events.slice(i, i + batchSize);
for (const event of batch) {
try {
const entry = processor(event);
if (entry) results.push(entry);
} catch (error) {
console.error('Error processing event:', error);
}
}
}
return results;
}
/**
* Check if an event is a fitness-related post
*/
export function isFitnessRelatedPost(event: NDKEvent): boolean {
// Check event tags
const hasFitnessTags = event.tags.some(tag =>
tag[0] === 't' &&
['workout', 'fitness', 'exercise', 'powr', 'gym'].includes(tag[1])
);
// If it has fitness tags, it's relevant
if (hasFitnessTags) return true;
// For kind 1 posts, check content for fitness keywords
if (event.kind === 1 && event.content) {
const fitnessKeywords = [
'workout', 'exercise', 'gym', 'fitness',
'training', 'strength', 'cardio', 'running',
'lifting', 'powr'
];
const content = event.content.toLowerCase();
return fitnessKeywords.some(keyword => content.includes(keyword));
}
// For specific workout kinds, always return true
// Fix: Check if event.kind exists before using includes
if (event.kind !== undefined && [1301, 33401, 33402].includes(event.kind)) {
return true;
}
return false;
}
/**
* Convert entries to a format compatible with legacy components
*/
export function convertToLegacyFeedItem(entry: AnyFeedEntry) {
// Use nullish coalescing to handle undefined timestamps
return {
id: entry.eventId,
type: entry.type,
originalEvent: entry.event!,
parsedContent: entry.content!,
createdAt: ((entry.timestamp ?? Date.now()) / 1000)
};
}