mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-06 18:31:03 +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/),
|
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).
|
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
|
# Changelog - March 12, 2025
|
||||||
|
|
||||||
## Added
|
## 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
|
// app/(tabs)/social/following.tsx
|
||||||
import React from 'react';
|
import React, { useCallback, useState, useRef } from 'react';
|
||||||
import { View, ScrollView, RefreshControl } from 'react-native';
|
import { View, FlatList, RefreshControl, TouchableOpacity } from 'react-native';
|
||||||
import { Text } from '@/components/ui/text';
|
import { Text } from '@/components/ui/text';
|
||||||
import { Button } from '@/components/ui/button';
|
import EnhancedSocialPost from '@/components/social/EnhancedSocialPost';
|
||||||
import SocialPost from '@/components/social/SocialPost';
|
import { router } from 'expo-router';
|
||||||
import { useNDKCurrentUser } from '@/lib/hooks/useNDK';
|
|
||||||
import NostrLoginPrompt from '@/components/social/NostrLoginPrompt';
|
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
|
// Define the conversion function here to avoid import issues
|
||||||
const MOCK_POSTS = [
|
function convertToLegacyFeedItem(entry: AnyFeedEntry) {
|
||||||
{
|
return {
|
||||||
id: '1',
|
id: entry.eventId,
|
||||||
author: {
|
type: entry.type,
|
||||||
name: 'Jane Fitness',
|
originalEvent: entry.event!,
|
||||||
handle: 'janefitness',
|
parsedContent: entry.content!,
|
||||||
avatar: 'https://randomuser.me/api/portraits/women/32.jpg',
|
createdAt: (entry.timestamp || Date.now()) / 1000
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function FollowingScreen() {
|
export default function FollowingScreen() {
|
||||||
const { isAuthenticated } = useNDKCurrentUser();
|
const { isAuthenticated, currentUser } = useNDKCurrentUser();
|
||||||
const [refreshing, setRefreshing] = React.useState(false);
|
const { ndk } = useNDK();
|
||||||
const [posts, setPosts] = React.useState(MOCK_POSTS);
|
const {
|
||||||
|
entries,
|
||||||
|
newEntries,
|
||||||
|
loading,
|
||||||
|
resetFeed,
|
||||||
|
clearNewEntries,
|
||||||
|
hasFollows,
|
||||||
|
followCount,
|
||||||
|
followedUsers,
|
||||||
|
isLoadingContacts
|
||||||
|
} = useFollowingFeed();
|
||||||
|
|
||||||
const onRefresh = React.useCallback(() => {
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
setRefreshing(true);
|
const [showNewButton, setShowNewButton] = useState(false);
|
||||||
// Simulate fetch - in a real app, this would be a call to load posts
|
const [showDebug, setShowDebug] = useState(false);
|
||||||
setTimeout(() => {
|
|
||||||
setRefreshing(false);
|
|
||||||
}, 1500);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
// 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) {
|
if (!isAuthenticated) {
|
||||||
return <NostrLoginPrompt message="Log in to see posts from people you follow" />;
|
return <NostrLoginPrompt message="Log in to see posts from people you follow" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (posts.length === 0) {
|
// Handle showing new entries
|
||||||
return <EmptyFeed message="You're not following anyone yet. Discover people to follow in the POWR or Global feeds." />;
|
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 (
|
return (
|
||||||
<ScrollView
|
<View className="flex-1">
|
||||||
className="flex-1"
|
{/* Debug toggle button */}
|
||||||
refreshControl={
|
<TouchableOpacity
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
className="absolute top-2 right-2 z-10 bg-gray-200 p-2 rounded-full"
|
||||||
}
|
onPress={() => setShowDebug(!showDebug)}
|
||||||
>
|
>
|
||||||
{posts.map(post => (
|
<Bug size={16} color="#666" />
|
||||||
<SocialPost key={post.id} post={post} />
|
</TouchableOpacity>
|
||||||
))}
|
|
||||||
</ScrollView>
|
{/* 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
|
// app/(tabs)/social/global.tsx
|
||||||
import React from 'react';
|
import React, { useCallback, useState, useRef } from 'react';
|
||||||
import { View, ScrollView, RefreshControl } from 'react-native';
|
import { View, FlatList, RefreshControl, TouchableOpacity } from 'react-native';
|
||||||
import { Text } from '@/components/ui/text';
|
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
|
// Define the conversion function here to avoid import issues
|
||||||
const GLOBAL_POSTS = [
|
function convertToLegacyFeedItem(entry: AnyFeedEntry) {
|
||||||
{
|
return {
|
||||||
id: '1',
|
id: entry.eventId,
|
||||||
author: {
|
type: entry.type,
|
||||||
name: 'Strength Coach',
|
originalEvent: entry.event!,
|
||||||
handle: 'strengthcoach',
|
parsedContent: entry.content!,
|
||||||
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
|
createdAt: (entry.timestamp || Date.now()) / 1000
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function GlobalScreen() {
|
export default function GlobalScreen() {
|
||||||
const [refreshing, setRefreshing] = React.useState(false);
|
const {
|
||||||
const [posts, setPosts] = React.useState(GLOBAL_POSTS);
|
entries,
|
||||||
|
newEntries,
|
||||||
|
loading,
|
||||||
|
resetFeed,
|
||||||
|
clearNewEntries
|
||||||
|
} = useGlobalFeed();
|
||||||
|
|
||||||
const onRefresh = React.useCallback(() => {
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
setRefreshing(true);
|
const [showNewButton, setShowNewButton] = useState(false);
|
||||||
// Simulate fetch
|
|
||||||
setTimeout(() => {
|
// Use ref for FlatList to scroll to top
|
||||||
setRefreshing(false);
|
const listRef = useRef<FlatList>(null);
|
||||||
}, 1500);
|
|
||||||
|
// 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 (
|
return (
|
||||||
<ScrollView
|
<View className="flex-1">
|
||||||
className="flex-1"
|
{showNewButton && (
|
||||||
refreshControl={
|
<TouchableOpacity
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
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}
|
||||||
>
|
>
|
||||||
{posts.map(post => (
|
<ChevronUp size={16} color="white" />
|
||||||
<SocialPost key={post.id} post={post} />
|
<Text className="text-white font-medium ml-1">
|
||||||
))}
|
New Posts ({newEntries.length})
|
||||||
</ScrollView>
|
</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
|
// app/(tabs)/social/powr.tsx
|
||||||
import React from 'react';
|
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { View, ScrollView, RefreshControl } from 'react-native';
|
import { View, FlatList, RefreshControl, TouchableOpacity } from 'react-native';
|
||||||
import { Text } from '@/components/ui/text';
|
import { Text } from '@/components/ui/text';
|
||||||
import SocialPost from '@/components/social/SocialPost';
|
import EnhancedSocialPost from '@/components/social/EnhancedSocialPost';
|
||||||
import { Zap } from 'lucide-react-native';
|
import { ChevronUp, Zap } from 'lucide-react-native';
|
||||||
import POWRPackSection from '@/components/social/POWRPackSection'; // Add this import
|
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
|
// Define the conversion function here to avoid import issues
|
||||||
const POWR_POSTS = [
|
function convertToLegacyFeedItem(entry: AnyFeedEntry) {
|
||||||
{
|
return {
|
||||||
id: '1',
|
id: entry.eventId,
|
||||||
author: {
|
type: entry.type,
|
||||||
name: 'POWR Team',
|
originalEvent: entry.event!,
|
||||||
handle: 'powrteam',
|
parsedContent: entry.content!,
|
||||||
avatar: 'https://i.pravatar.cc/150?img=12',
|
createdAt: (entry.timestamp || Date.now()) / 1000
|
||||||
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
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function PowerScreen() {
|
export default function PowerScreen() {
|
||||||
const [refreshing, setRefreshing] = React.useState(false);
|
const {
|
||||||
const [posts, setPosts] = React.useState(POWR_POSTS);
|
entries,
|
||||||
|
newEntries,
|
||||||
|
loading,
|
||||||
|
resetFeed,
|
||||||
|
clearNewEntries
|
||||||
|
} = usePOWRFeed();
|
||||||
|
|
||||||
const onRefresh = React.useCallback(() => {
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
setRefreshing(true);
|
const [showNewButton, setShowNewButton] = useState(false);
|
||||||
// Simulate fetch
|
|
||||||
setTimeout(() => {
|
// Use ref for list to scroll to top
|
||||||
setRefreshing(false);
|
const listRef = useRef<FlatList>(null);
|
||||||
}, 1500);
|
|
||||||
|
// 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 (
|
// Memoize render item to prevent re-renders
|
||||||
<ScrollView
|
const renderItem = useCallback(({ item }: { item: AnyFeedEntry }) => (
|
||||||
className="flex-1"
|
<EnhancedSocialPost
|
||||||
refreshControl={
|
item={convertToLegacyFeedItem(item)}
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
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 */}
|
{/* 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">
|
<View className="flex-row items-center mb-2">
|
||||||
<Zap size={20} className="mr-2 text-primary" fill="currentColor" />
|
<Zap size={20} className="mr-2 text-primary" fill="currentColor" />
|
||||||
<Text className="text-lg font-bold">POWR Community</Text>
|
<Text className="text-lg font-bold">POWR Community</Text>
|
||||||
@ -97,13 +109,41 @@ export default function PowerScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* POWR Packs Section - Add this */}
|
{/* POWR Packs Section */}
|
||||||
<POWRPackSection />
|
<POWRPackSection />
|
||||||
|
</>
|
||||||
|
), []);
|
||||||
|
|
||||||
{/* Posts */}
|
return (
|
||||||
{posts.map(post => (
|
<View className="flex-1">
|
||||||
<SocialPost key={post.id} post={post} />
|
{showNewButton && (
|
||||||
))}
|
<TouchableOpacity
|
||||||
</ScrollView>
|
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]);
|
}, [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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
@ -240,10 +233,10 @@ export default function POWRPackSection() {
|
|||||||
) : featuredPacks.length > 0 ? (
|
) : featuredPacks.length > 0 ? (
|
||||||
// Pack cards
|
// Pack cards
|
||||||
featuredPacks.map((pack, idx) => {
|
featuredPacks.map((pack, idx) => {
|
||||||
console.log(`Rendering pack ${idx}, tags exist:`, pack.tags ? 'yes' : 'no');
|
|
||||||
const title = findTagValue(pack.tags || [], 'name') || 'Unnamed Pack';
|
const title = findTagValue(pack.tags || [], 'name') || 'Unnamed Pack';
|
||||||
const description = findTagValue(pack.tags || [], 'about') || '';
|
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
|
// Add fallback for tags
|
||||||
const tags = pack.tags || [];
|
const tags = pack.tags || [];
|
||||||
@ -265,7 +258,13 @@ export default function POWRPackSection() {
|
|||||||
<Card style={styles.packCard}>
|
<Card style={styles.packCard}>
|
||||||
<CardContent style={styles.cardContent}>
|
<CardContent style={styles.cardContent}>
|
||||||
{image ? (
|
{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}>
|
<View style={styles.placeholderImage}>
|
||||||
<PackageOpen size={32} color="#6b7280" />
|
<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;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 46;
|
objectVersion = 54;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = powr/powr.entitlements;
|
CODE_SIGN_ENTITLEMENTS = powr/powr.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 944AF56S27;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"FB_SONARKIT_ENABLED=1",
|
"FB_SONARKIT_ENABLED=1",
|
||||||
);
|
);
|
||||||
INFOPLIST_FILE = powr/Info.plist;
|
INFOPLIST_FILE = powr/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = POWR;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
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;
|
MARKETING_VERSION = 1.0;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@ -364,7 +370,7 @@
|
|||||||
"-lc++",
|
"-lc++",
|
||||||
);
|
);
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.powr.app;
|
PRODUCT_BUNDLE_IDENTIFIER = io.proofofworkout.powr;
|
||||||
PRODUCT_NAME = powr;
|
PRODUCT_NAME = powr;
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "powr/powr-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "powr/powr-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@ -382,9 +388,15 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = powr/powr.entitlements;
|
CODE_SIGN_ENTITLEMENTS = powr/powr.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 944AF56S27;
|
||||||
INFOPLIST_FILE = powr/Info.plist;
|
INFOPLIST_FILE = powr/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = POWR;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
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;
|
MARKETING_VERSION = 1.0;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@ -392,7 +404,7 @@
|
|||||||
"-lc++",
|
"-lc++",
|
||||||
);
|
);
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.powr.app;
|
PRODUCT_BUNDLE_IDENTIFIER = io.proofofworkout.powr;
|
||||||
PRODUCT_NAME = powr;
|
PRODUCT_NAME = powr;
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "powr/powr-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "powr/powr-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@ -449,14 +461,14 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
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)\"";
|
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = "$(inherited) ";
|
||||||
"$(inherited)",
|
|
||||||
" ",
|
|
||||||
);
|
|
||||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||||
@ -505,13 +517,13 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
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)\"";
|
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = "$(inherited) ";
|
||||||
"$(inherited)",
|
|
||||||
" ",
|
|
||||||
);
|
|
||||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
USE_HERMES = true;
|
USE_HERMES = true;
|
||||||
|
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
|
// 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 { NDKUser, NDKUserProfile } from '@nostr-dev-kit/ndk-mobile';
|
||||||
import { useNDK } from './useNDK';
|
import { useNDK } from './useNDK';
|
||||||
|
|
||||||
@ -10,12 +10,25 @@ export function useProfile(pubkey: string | undefined) {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!ndk || !pubkey) {
|
if (!ndk || !pubkey) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let isEffectActive = true;
|
||||||
|
|
||||||
const fetchProfile = async () => {
|
const fetchProfile = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@ -35,21 +48,32 @@ export function useProfile(pubkey: string | undefined) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only update state if component is still mounted and effect is active
|
||||||
|
if (isMountedRef.current && isEffectActive) {
|
||||||
setUser(ndkUser);
|
setUser(ndkUser);
|
||||||
setProfile(ndkUser.profile || null);
|
setProfile(ndkUser.profile || null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching profile:', err);
|
console.error('Error fetching profile:', err);
|
||||||
|
// 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'));
|
setError(err instanceof Error ? err : new Error('Failed to fetch profile'));
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchProfile();
|
fetchProfile();
|
||||||
|
|
||||||
|
// Cleanup function to prevent state updates if the effect is cleaned up
|
||||||
|
return () => {
|
||||||
|
isEffectActive = false;
|
||||||
|
};
|
||||||
}, [ndk, pubkey]);
|
}, [ndk, pubkey]);
|
||||||
|
|
||||||
const refreshProfile = async () => {
|
const refreshProfile = async () => {
|
||||||
if (!ndk || !pubkey) return;
|
if (!ndk || !pubkey || !isMountedRef.current) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@ -58,13 +82,20 @@ export function useProfile(pubkey: string | undefined) {
|
|||||||
const ndkUser = ndk.getUser({ pubkey });
|
const ndkUser = ndk.getUser({ pubkey });
|
||||||
await ndkUser.fetchProfile();
|
await ndkUser.fetchProfile();
|
||||||
|
|
||||||
|
// Only update state if component is still mounted
|
||||||
|
if (isMountedRef.current) {
|
||||||
setUser(ndkUser);
|
setUser(ndkUser);
|
||||||
setProfile(ndkUser.profile || null);
|
setProfile(ndkUser.profile || null);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isMountedRef.current) {
|
||||||
setError(err instanceof Error ? err : new Error('Failed to refresh profile'));
|
setError(err instanceof Error ? err : new Error('Failed to refresh profile'));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (isMountedRef.current) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
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": "0.76.7",
|
||||||
"react-native-gesture-handler": "~2.20.2",
|
"react-native-gesture-handler": "~2.20.2",
|
||||||
"react-native-get-random-values": "~1.11.0",
|
"react-native-get-random-values": "~1.11.0",
|
||||||
|
"react-native-markdown-display": "^7.0.2",
|
||||||
"react-native-pager-view": "6.5.1",
|
"react-native-pager-view": "6.5.1",
|
||||||
"react-native-reanimated": "~3.16.1",
|
"react-native-reanimated": "~3.16.1",
|
||||||
"react-native-safe-area-context": "4.12.0",
|
"react-native-safe-area-context": "4.12.0",
|
||||||
@ -10567,6 +10568,15 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001699",
|
"version": "1.0.30001699",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001699.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001699.tgz",
|
||||||
@ -11164,6 +11174,15 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/css-in-js-utils": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
|
"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"
|
"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": {
|
"node_modules/css-tree": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
|
||||||
@ -15600,6 +15630,15 @@
|
|||||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/loader-runner": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
|
||||||
@ -15794,6 +15833,28 @@
|
|||||||
"tmpl": "1.0.5"
|
"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": {
|
"node_modules/marky": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/marky/-/marky-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/marky/-/marky-1.2.5.tgz",
|
||||||
@ -15841,6 +15902,12 @@
|
|||||||
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
|
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
|
||||||
"license": "CC0-1.0"
|
"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": {
|
"node_modules/memoize-one": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||||
@ -18043,6 +18110,15 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/react-native-gesture-handler": {
|
||||||
"version": "2.20.2",
|
"version": "2.20.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.20.2.tgz",
|
"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"
|
"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": {
|
"node_modules/react-native-pager-view": {
|
||||||
"version": "6.5.1",
|
"version": "6.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-pager-view/-/react-native-pager-view-6.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-pager-view/-/react-native-pager-view-6.5.1.tgz",
|
||||||
@ -20253,6 +20345,12 @@
|
|||||||
"node": "*"
|
"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": {
|
"node_modules/undici": {
|
||||||
"version": "6.21.1",
|
"version": "6.21.1",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz",
|
||||||
|
@ -64,6 +64,7 @@
|
|||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"expo": "^52.0.35",
|
"expo": "^52.0.35",
|
||||||
|
"expo-av": "~15.0.2",
|
||||||
"expo-crypto": "~14.0.2",
|
"expo-crypto": "~14.0.2",
|
||||||
"expo-dev-client": "~5.0.12",
|
"expo-dev-client": "~5.0.12",
|
||||||
"expo-file-system": "~18.0.10",
|
"expo-file-system": "~18.0.10",
|
||||||
@ -87,6 +88,7 @@
|
|||||||
"react-native": "0.76.7",
|
"react-native": "0.76.7",
|
||||||
"react-native-gesture-handler": "~2.20.2",
|
"react-native-gesture-handler": "~2.20.2",
|
||||||
"react-native-get-random-values": "~1.11.0",
|
"react-native-get-random-values": "~1.11.0",
|
||||||
|
"react-native-markdown-display": "^7.0.2",
|
||||||
"react-native-pager-view": "6.5.1",
|
"react-native-pager-view": "6.5.1",
|
||||||
"react-native-reanimated": "~3.16.1",
|
"react-native-reanimated": "~3.16.1",
|
||||||
"react-native-safe-area-context": "4.12.0",
|
"react-native-safe-area-context": "4.12.0",
|
||||||
@ -97,8 +99,7 @@
|
|||||||
"tailwind-merge": "^2.2.1",
|
"tailwind-merge": "^2.2.1",
|
||||||
"tailwindcss": "3.3.5",
|
"tailwindcss": "3.3.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zustand": "^4.5.6",
|
"zustand": "^4.5.6"
|
||||||
"expo-av": "~15.0.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.26.0",
|
"@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