mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-05 16:52:07 +00:00
restructure and optimization of social tab
This commit is contained in:
parent
24e7f53ac3
commit
58194c0eb3
54
CHANGELOG.md
54
CHANGELOG.md
@ -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
17
app/(social)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
420
app/(social)/workout/[id].tsx
Normal file
420
app/(social)/workout/[id].tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
532
components/social/EnhancedSocialPost.tsx
Normal file
532
components/social/EnhancedSocialPost.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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" />
|
||||
|
1438
docs/design/Social/ImplementationPlan.md
Normal file
1438
docs/design/Social/ImplementationPlan.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
|
@ -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
117
lib/hooks/useContactList.ts
Normal 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
353
lib/hooks/useFeedEvents.ts
Normal 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
327
lib/hooks/useFeedHooks.ts
Normal 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
|
||||
};
|
||||
}
|
86
lib/hooks/useFeedMonitor.ts
Normal file
86
lib/hooks/useFeedMonitor.ts
Normal 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
61
lib/hooks/useFeedState.ts
Normal 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
|
||||
};
|
||||
}
|
@ -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
398
lib/hooks/useSocialFeed.ts
Normal 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
|
||||
};
|
||||
}
|
350
lib/social/socialFeedService.ts
Normal file
350
lib/social/socialFeedService.ts
Normal 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
98
package-lock.json
generated
@ -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",
|
||||
|
@ -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
82
types/feed.ts
Normal 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
399
types/nostr-workout.ts
Normal 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
78
utils/feedUtils.ts
Normal 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)
|
||||
};
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user