diff --git a/CHANGELOG.md b/CHANGELOG.md
index c5d9b33..6f79218 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,60 @@ All notable changes to the POWR project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+# Changelog - March 20, 2025
+
+## Improved
+- Enhanced Social Feed UI
+ - Redesigned feed posts with divider-based layout instead of cards
+ - Implemented edge-to-edge content display with hairline separators
+ - Optimized post spacing for more compact, Twitter-like appearance
+ - Reduced vertical padding between post elements
+ - Tightened spacing between content and action buttons
+ - Fixed image loading for POWR Pack images
+ - Enhanced overall feed performance with component memoization
+ - Improved empty state messaging
+ - Fixed infinite loop issues in feed subscription management
+ - Added proper feed reset and refresh functionality
+ - Enhanced debugging tools for relay connection troubleshooting
+ - Improved feed state management with proper lifecycle handling
+ - Optimized rendering for long lists with virtualized FlatList
+ - Added scrollToTop functionality for new content
+
+# Changelog - March 19, 2025
+
+## Added
+- Social Feed Integration
+ - Implemented tabbed social feed with Following, POWR, and Global tabs
+ - Created EnhancedSocialPost component for rendering workout events
+ - Added support for viewing workout records, exercise templates, and workout templates
+ - Implemented post interaction features (likes, comments)
+ - Added workout detail screen for viewing complete workout information
+ - Integrated with Nostr protocol for decentralized social content
+ - Created SocialFeedService for fetching and managing social content
+ - Implemented useFollowingFeed, usePOWRFeed, and useGlobalFeed hooks
+ - Added user profile integration with avatar display
+ - Created POWRPackSection for discovering shared workout templates
+
+## Improved
+- Enhanced profile handling
+ - Added robust error management for profile image loading
+ - Implemented proper state management to prevent infinite update loops
+ - Better memory management with cleanup on component unmount
+- Workout content display
+ - Created rich workout event cards with detailed exercise information
+ - Added support for displaying workout duration, exercises, and performance metrics
+ - Implemented proper text handling for React Native
+- Nostr integration
+ - Added support for exercise, template, and workout event kinds
+ - Implemented event parsing for different content types
+ - Created useSocialFeed hook with pagination support
+ - Enhanced NDK integration with better error handling
+- UI/UX enhancements
+ - Added pull-to-refresh for feed updates
+ - Implemented load more functionality for pagination
+ - Created skeleton loading states for better loading experience
+ - Enhanced navigation between feed and detail screens
+
# Changelog - March 12, 2025
## Added
diff --git a/app/(social)/_layout.tsx b/app/(social)/_layout.tsx
new file mode 100644
index 0000000..4e87700
--- /dev/null
+++ b/app/(social)/_layout.tsx
@@ -0,0 +1,17 @@
+// app/(social)/_layout.tsx
+import React from 'react';
+import { Stack } from 'expo-router';
+
+export default function SocialLayout() {
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/(social)/workout/[id].tsx b/app/(social)/workout/[id].tsx
new file mode 100644
index 0000000..17de62e
--- /dev/null
+++ b/app/(social)/workout/[id].tsx
@@ -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(null);
+ const [workout, setWorkout] = useState(null);
+ const [comments, setComments] = useState([]);
+ 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 (
+ <>
+ (
+
+
+
+ ),
+ }}
+ />
+
+ {loading ? (
+
+
+ Loading workout...
+
+ ) : !workout ? (
+
+ Workout not found
+
+
+ ) : (
+
+
+ {/* Workout header */}
+
+ {workout.title}
+
+ {/* Author info */}
+
+
+ {profile?.image ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {profile?.name || 'Nostr User'}
+
+ {profile?.nip05 && (
+
+ )}
+
+
+
+ {/* Time and date */}
+
+
+
+ {formatDate(workout.startTime)}
+
+
+
+
+ {formatTime(workout.startTime)}
+
+
+ {workout.endTime && (
+
+
+
+ {formatDuration(workout.startTime, workout.endTime)}
+
+
+ )}
+
+
+ {/* Workout type */}
+
+ {workout.type}
+
+
+
+ {/* Workout notes */}
+ {workout.notes && (
+
+
+ Notes
+
+
+ {workout.notes}
+
+
+ )}
+
+ {/* Exercise list */}
+
+
+ Exercises
+
+
+ {workout.exercises.length === 0 ? (
+ No exercises recorded
+ ) : (
+ workout.exercises.map((exercise: any, index: number) => (
+
+ {exercise.name}
+
+
+ {exercise.weight && (
+
+ {exercise.weight}kg
+
+ )}
+
+ {exercise.reps && (
+
+ {exercise.reps} reps
+
+ )}
+
+ {exercise.rpe && (
+
+ RPE {exercise.rpe}
+
+ )}
+
+ {exercise.setType && (
+
+ {exercise.setType}
+
+ )}
+
+
+ ))
+ )}
+
+
+
+ {/* Interactions */}
+
+
+ Interactions
+
+
+
+
+
+
+
+
+
+
+ {/* Comments section */}
+
+
+ Comments
+
+
+ {/* Comment input */}
+
+
+
+
+
+ {/* Comments list */}
+ {comments.length === 0 ? (
+ No comments yet. Be the first to comment!
+ ) : (
+ comments.map((comment, index) => (
+
+ {/* Comment author */}
+
+
+
+
+
+
+
+ {comment.pubkey.slice(0, 8)}...
+
+
+ {formatCommentTime(comment.created_at)}
+
+
+
+ {/* Comment content */}
+ {comment.content}
+
+ ))
+ )}
+
+
+
+ {/* Back button at bottom for additional usability */}
+
+
+
+ )}
+ >
+ );
+}
\ No newline at end of file
diff --git a/app/(tabs)/social/following.tsx b/app/(tabs)/social/following.tsx
index 2068d52..3e5bd7e 100644
--- a/app/(tabs)/social/following.tsx
+++ b/app/(tabs)/social/following.tsx
@@ -1,90 +1,264 @@
// app/(tabs)/social/following.tsx
-import React from 'react';
-import { View, ScrollView, RefreshControl } from 'react-native';
+import React, { useCallback, useState, useRef } from 'react';
+import { View, FlatList, RefreshControl, TouchableOpacity } from 'react-native';
import { Text } from '@/components/ui/text';
-import { Button } from '@/components/ui/button';
-import SocialPost from '@/components/social/SocialPost';
-import { useNDKCurrentUser } from '@/lib/hooks/useNDK';
+import EnhancedSocialPost from '@/components/social/EnhancedSocialPost';
+import { router } from 'expo-router';
import NostrLoginPrompt from '@/components/social/NostrLoginPrompt';
-import EmptyFeed from '@/components/social/EmptyFeed';
+import { useNDKCurrentUser, useNDK } from '@/lib/hooks/useNDK';
+import { useFollowingFeed } from '@/lib/hooks/useFeedHooks';
+import { ChevronUp, Bug } from 'lucide-react-native';
+import { AnyFeedEntry } from '@/types/feed';
-// Sample mock data for posts
-const MOCK_POSTS = [
- {
- id: '1',
- author: {
- name: 'Jane Fitness',
- handle: 'janefitness',
- avatar: 'https://randomuser.me/api/portraits/women/32.jpg',
- pubkey: 'npub1q8s7vw...'
- },
- content: 'Just crushed this leg workout! New PR on squat 💪 #fitness #legday',
- createdAt: new Date(Date.now() - 3600000 * 2), // 2 hours ago
- metrics: {
- likes: 24,
- comments: 5,
- reposts: 3
- },
- workout: {
- title: 'Leg Day Destroyer',
- exercises: ['Squats', 'Lunges', 'Leg Press'],
- duration: 45
- }
- },
- {
- id: '2',
- author: {
- name: 'Mark Strong',
- handle: 'markstrong',
- avatar: 'https://randomuser.me/api/portraits/men/45.jpg',
- pubkey: 'npub1z92r3...'
- },
- content: 'Morning cardio session complete! 5K run in 22 minutes. Starting the day right! #running #cardio',
- createdAt: new Date(Date.now() - 3600000 * 5), // 5 hours ago
- metrics: {
- likes: 18,
- comments: 2,
- reposts: 1
- },
- workout: {
- title: 'Morning Cardio',
- exercises: ['Running'],
- duration: 22
- }
- }
-];
+// Define the conversion function here to avoid import issues
+function convertToLegacyFeedItem(entry: AnyFeedEntry) {
+ return {
+ id: entry.eventId,
+ type: entry.type,
+ originalEvent: entry.event!,
+ parsedContent: entry.content!,
+ createdAt: (entry.timestamp || Date.now()) / 1000
+ };
+}
export default function FollowingScreen() {
- const { isAuthenticated } = useNDKCurrentUser();
- const [refreshing, setRefreshing] = React.useState(false);
- const [posts, setPosts] = React.useState(MOCK_POSTS);
-
- const onRefresh = React.useCallback(() => {
- setRefreshing(true);
- // Simulate fetch - in a real app, this would be a call to load posts
- setTimeout(() => {
- setRefreshing(false);
- }, 1500);
- }, []);
-
+ const { isAuthenticated, currentUser } = useNDKCurrentUser();
+ const { ndk } = useNDK();
+ const {
+ entries,
+ newEntries,
+ loading,
+ resetFeed,
+ clearNewEntries,
+ hasFollows,
+ followCount,
+ followedUsers,
+ isLoadingContacts
+ } = useFollowingFeed();
+
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [showNewButton, setShowNewButton] = useState(false);
+ const [showDebug, setShowDebug] = useState(false);
+
+ // Use ref for FlatList to scroll to top
+ const listRef = useRef(null);
+
+ // Show new entries button when we have new content
+ React.useEffect(() => {
+ if (newEntries.length > 0) {
+ setShowNewButton(true);
+ }
+ }, [newEntries.length]); // Depend on length, not array reference
+
+ // If not authenticated, show login prompt
if (!isAuthenticated) {
return ;
}
- if (posts.length === 0) {
- return ;
- }
+ // Handle showing new entries
+ const handleShowNewEntries = useCallback(() => {
+ clearNewEntries();
+ setShowNewButton(false);
+ // Scroll to top
+ listRef.current?.scrollToOffset({ offset: 0, animated: true });
+ }, [clearNewEntries]);
+
+ // Handle refresh - updated with proper reset handling
+ const handleRefresh = useCallback(async () => {
+ setIsRefreshing(true);
+ try {
+ await resetFeed();
+ // Add a slight delay to ensure the UI updates
+ await new Promise(resolve => setTimeout(resolve, 300));
+ } catch (error) {
+ console.error('Error refreshing feed:', error);
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, [resetFeed]);
+ // Check relay connections
+ const checkRelayConnections = useCallback(() => {
+ if (!ndk) return;
+
+ console.log("=== RELAY CONNECTION STATUS ===");
+ if (ndk.pool && ndk.pool.relays) {
+ console.log(`Connected to ${ndk.pool.relays.size} relays:`);
+ ndk.pool.relays.forEach((relay) => {
+ console.log(`- ${relay.url}: ${relay.status}`);
+ });
+ } else {
+ console.log("No relay pool or connections available");
+ }
+ console.log("===============================");
+ }, [ndk]);
+
+ // Handle post selection - simplified for testing
+ const handlePostPress = useCallback((entry: AnyFeedEntry) => {
+ // Just show an alert with the entry info for testing
+ alert(`Selected ${entry.type} with ID: ${entry.eventId}`);
+
+ // Alternatively, log to console for debugging
+ console.log(`Selected ${entry.type}:`, entry);
+ }, []);
+
+ // Memoize render item function
+ const renderItem = useCallback(({ item }: { item: AnyFeedEntry }) => (
+ handlePostPress(item)}
+ />
+ ), [handlePostPress]);
+
+ // Debug controls component - memoized
+ const DebugControls = useCallback(() => (
+
+ Debug Info:
+ User: {currentUser?.pubkey?.substring(0, 8)}...
+ Following Count: {followCount || 0}
+ Feed Items: {entries.length}
+ Loading: {loading ? "Yes" : "No"}
+ Loading Contacts: {isLoadingContacts ? "Yes" : "No"}
+
+ {followedUsers && followedUsers.length > 0 && (
+
+ Followed Users:
+ {followedUsers.slice(0, 3).map((pubkey, idx) => (
+ {idx+1}. {pubkey.substring(0, 12)}...
+ ))}
+ {followedUsers.length > 3 && (
+ ...and {followedUsers.length - 3} more
+ )}
+
+ )}
+
+
+
+ Check Relays
+
+
+
+ Force Refresh
+
+
+
+ ), [currentUser?.pubkey, followCount, entries.length, loading, isLoadingContacts, followedUsers, checkRelayConnections, handleRefresh]);
+
+ // If user doesn't follow anyone
+ if (isAuthenticated && !hasFollows) {
+ return (
+
+
+ You're not following anyone yet. Find and follow other users to see their content here.
+
+
+ {/* Debug toggle */}
+ setShowDebug(!showDebug)}
+ >
+ {showDebug ? "Hide" : "Show"} Debug Info
+
+
+ {showDebug && (
+
+ User pubkey: {currentUser?.pubkey?.substring(0, 12)}...
+ Authenticated: {isAuthenticated ? "Yes" : "No"}
+ Follow count: {followCount || 0}
+ Has NDK follows: {currentUser?.follows ? "Yes" : "No"}
+ 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'
+ }
+ Loading contacts: {isLoadingContacts ? "Yes" : "No"}
+
+ {/* Toggle relays button */}
+
+ Check Relay Connections
+
+
+ {/* Manual refresh */}
+
+ Force Refresh Feed
+
+
+ )}
+
+ );
+ }
+
return (
-
- }
- >
- {posts.map(post => (
-
- ))}
-
+
+ {/* Debug toggle button */}
+ setShowDebug(!showDebug)}
+ >
+
+
+
+ {/* Debug panel */}
+ {showDebug && }
+
+ {showNewButton && (
+
+
+
+ New Posts ({newEntries.length})
+
+
+ )}
+
+ item.id}
+ renderItem={renderItem}
+ refreshControl={
+
+ }
+ ListEmptyComponent={
+ loading || isLoadingContacts ? (
+
+ Loading followed content...
+
+ ) : (
+
+ No posts from followed users found
+
+ You're following {followCount || 0} users, but no content was found.
+
+
+ This could be because they haven't posted recently,
+ or their content is not available on connected relays.
+
+
+ )
+ }
+ contentContainerStyle={{ paddingVertical: 0 }} // Changed from paddingVertical: 8
+ />
+
);
}
\ No newline at end of file
diff --git a/app/(tabs)/social/global.tsx b/app/(tabs)/social/global.tsx
index d97c719..7f6dd0f 100644
--- a/app/(tabs)/social/global.tsx
+++ b/app/(tabs)/social/global.tsx
@@ -1,114 +1,130 @@
// app/(tabs)/social/global.tsx
-import React from 'react';
-import { View, ScrollView, RefreshControl } from 'react-native';
+import React, { useCallback, useState, useRef } from 'react';
+import { View, FlatList, RefreshControl, TouchableOpacity } from 'react-native';
import { Text } from '@/components/ui/text';
-import SocialPost from '@/components/social/SocialPost';
+import EnhancedSocialPost from '@/components/social/EnhancedSocialPost';
+import { useGlobalFeed } from '@/lib/hooks/useFeedHooks';
+import { router } from 'expo-router';
+import { ChevronUp } from 'lucide-react-native';
+import { AnyFeedEntry } from '@/types/feed';
-// Sample mock data for global feed - more diverse content
-const GLOBAL_POSTS = [
- {
- id: '1',
- author: {
- name: 'Strength Coach',
- handle: 'strengthcoach',
- avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
- pubkey: 'npub1q8s7vw...'
- },
- content: 'Form tip: When squatting, make sure your knees track in line with your toes. This helps protect your knees and ensures proper muscle engagement. #squatform #technique',
- createdAt: new Date(Date.now() - 3600000 * 3), // 3 hours ago
- metrics: {
- likes: 132,
- comments: 28,
- reposts: 45
- }
- },
- {
- id: '2',
- author: {
- name: 'Marathon Runner',
- handle: 'marathoner',
- avatar: 'https://randomuser.me/api/portraits/women/28.jpg',
- pubkey: 'npub1z92r3...'
- },
- content: 'Just finished my 10th marathon this year! Boston Marathon was an amazing experience. Thanks for all the support! #marathon #running #endurance',
- createdAt: new Date(Date.now() - 3600000 * 14), // 14 hours ago
- metrics: {
- likes: 214,
- comments: 38,
- reposts: 22
- },
- workout: {
- title: 'Boston Marathon',
- exercises: ['Running'],
- duration: 218 // 3:38 marathon
- }
- },
- {
- id: '3',
- author: {
- name: 'PowerLifter',
- handle: 'liftsheavy',
- avatar: 'https://randomuser.me/api/portraits/men/85.jpg',
- pubkey: 'npub1xne8q...'
- },
- content: 'NEW PR ALERT! 💪 Just hit 500lbs on deadlift after 3 years of consistent training. Proof that patience and consistency always win. #powerlifting #deadlift #pr',
- createdAt: new Date(Date.now() - 3600000 * 36), // 36 hours ago
- metrics: {
- likes: 347,
- comments: 72,
- reposts: 41
- },
- workout: {
- title: 'Deadlift Day',
- exercises: ['Deadlifts', 'Back Accessories'],
- duration: 65
- }
- },
- {
- id: '4',
- author: {
- name: 'Yoga Master',
- handle: 'yogalife',
- avatar: 'https://randomuser.me/api/portraits/women/50.jpg',
- pubkey: 'npub1r72df...'
- },
- content: 'Morning yoga flow to start the day centered and grounded. Remember that flexibility isn\'t just physical - it\'s mental too. #yoga #morningroutine #wellness',
- createdAt: new Date(Date.now() - 3600000 * 48), // 2 days ago
- metrics: {
- likes: 183,
- comments: 12,
- reposts: 25
- },
- workout: {
- title: 'Morning Yoga Flow',
- exercises: ['Yoga'],
- duration: 30
- }
- }
-];
+// Define the conversion function here to avoid import issues
+function convertToLegacyFeedItem(entry: AnyFeedEntry) {
+ return {
+ id: entry.eventId,
+ type: entry.type,
+ originalEvent: entry.event!,
+ parsedContent: entry.content!,
+ createdAt: (entry.timestamp || Date.now()) / 1000
+ };
+}
export default function GlobalScreen() {
- const [refreshing, setRefreshing] = React.useState(false);
- const [posts, setPosts] = React.useState(GLOBAL_POSTS);
-
- const onRefresh = React.useCallback(() => {
- setRefreshing(true);
- // Simulate fetch
- setTimeout(() => {
- setRefreshing(false);
- }, 1500);
+ const {
+ entries,
+ newEntries,
+ loading,
+ resetFeed,
+ clearNewEntries
+ } = useGlobalFeed();
+
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [showNewButton, setShowNewButton] = useState(false);
+
+ // Use ref for FlatList to scroll to top
+ const listRef = useRef(null);
+
+ // Show new entries button when we have new content
+ React.useEffect(() => {
+ if (newEntries.length > 0) {
+ setShowNewButton(true);
+ }
+ }, [newEntries.length]); // Depend on length, not array reference
+
+ // Handle showing new entries
+ const handleShowNewEntries = useCallback(() => {
+ clearNewEntries();
+ setShowNewButton(false);
+ // Scroll to top
+ listRef.current?.scrollToOffset({ offset: 0, animated: true });
+ }, [clearNewEntries]);
+
+ // Handle refresh
+ const handleRefresh = useCallback(async () => {
+ setIsRefreshing(true);
+ try {
+ await resetFeed();
+ // Add a slight delay to ensure the UI updates
+ await new Promise(resolve => setTimeout(resolve, 300));
+ } catch (error) {
+ console.error('Error refreshing feed:', error);
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, [resetFeed]);
+
+ // Handle post selection - simplified for testing
+ const handlePostPress = useCallback((entry: AnyFeedEntry) => {
+ // Just show an alert with the entry info for testing
+ alert(`Selected ${entry.type} with ID: ${entry.eventId}`);
+
+ // Alternatively, log to console for debugging
+ console.log(`Selected ${entry.type}:`, entry);
}, []);
+ // Memoize render item function
+ const renderItem = useCallback(({ item }: { item: AnyFeedEntry }) => (
+ handlePostPress(item)}
+ />
+ ), [handlePostPress]);
+
+ // Memoize empty component
+ const renderEmptyComponent = useCallback(() => (
+ loading ? (
+
+ Loading global content...
+
+ ) : (
+
+ No global content found
+
+ Try connecting to more relays or check back later.
+
+
+ )
+ ), [loading]);
+
return (
-
- }
- >
- {posts.map(post => (
-
- ))}
-
+
+ {showNewButton && (
+
+
+
+ New Posts ({newEntries.length})
+
+
+ )}
+
+ item.id}
+ renderItem={renderItem}
+ refreshControl={
+
+ }
+ ListEmptyComponent={renderEmptyComponent}
+ contentContainerStyle={{ paddingVertical: 0 }}
+ />
+
);
}
\ No newline at end of file
diff --git a/app/(tabs)/social/powr.tsx b/app/(tabs)/social/powr.tsx
index 948502d..b516a05 100644
--- a/app/(tabs)/social/powr.tsx
+++ b/app/(tabs)/social/powr.tsx
@@ -1,93 +1,105 @@
// app/(tabs)/social/powr.tsx
-import React from 'react';
-import { View, ScrollView, RefreshControl } from 'react-native';
+import React, { useState, useCallback, useEffect, useRef } from 'react';
+import { View, FlatList, RefreshControl, TouchableOpacity } from 'react-native';
import { Text } from '@/components/ui/text';
-import SocialPost from '@/components/social/SocialPost';
-import { Zap } from 'lucide-react-native';
-import POWRPackSection from '@/components/social/POWRPackSection'; // Add this import
+import EnhancedSocialPost from '@/components/social/EnhancedSocialPost';
+import { ChevronUp, Zap } from 'lucide-react-native';
+import POWRPackSection from '@/components/social/POWRPackSection';
+import { usePOWRFeed } from '@/lib/hooks/useFeedHooks';
+import { router } from 'expo-router';
+import { AnyFeedEntry } from '@/types/feed';
-// Sample mock data for posts from POWR team/recommendations
-const POWR_POSTS = [
- {
- id: '1',
- author: {
- name: 'POWR Team',
- handle: 'powrteam',
- avatar: 'https://i.pravatar.cc/150?img=12',
- pubkey: 'npub1q8s7vw...',
- verified: true
- },
- content: 'Welcome to the new social feed in POWR! Share your workouts, follow friends and get inspired by the global fitness community. #powrapp',
- createdAt: new Date(Date.now() - 3600000 * 48), // 2 days ago
- metrics: {
- likes: 158,
- comments: 42,
- reposts: 27
- },
- featured: true
- },
- {
- id: '2',
- author: {
- name: 'Sarah Trainer',
- handle: 'sarahfitness',
- avatar: 'https://randomuser.me/api/portraits/women/44.jpg',
- pubkey: 'npub1z92r3...'
- },
- content: 'Just released my new 30-day strength program! Check it out in my profile and let me know what you think. #strengthtraining #30daychallenge',
- createdAt: new Date(Date.now() - 3600000 * 24), // 1 day ago
- metrics: {
- likes: 84,
- comments: 15,
- reposts: 12
- },
- workout: {
- title: '30-Day Strength Builder',
- exercises: ['Full Program'],
- isProgramPreview: true
- }
- },
- {
- id: '3',
- author: {
- name: 'POWR Team',
- handle: 'powrteam',
- avatar: 'https://i.pravatar.cc/150?img=12',
- pubkey: 'npub1q8s7vw...',
- verified: true
- },
- content: 'New features alert! You can now track your rest periods automatically and share your PRs directly to your feed. Update to the latest version to try it out!',
- createdAt: new Date(Date.now() - 3600000 * 72), // 3 days ago
- metrics: {
- likes: 207,
- comments: 31,
- reposts: 45
- },
- featured: true
- }
-];
+// Define the conversion function here to avoid import issues
+function convertToLegacyFeedItem(entry: AnyFeedEntry) {
+ return {
+ id: entry.eventId,
+ type: entry.type,
+ originalEvent: entry.event!,
+ parsedContent: entry.content!,
+ createdAt: (entry.timestamp || Date.now()) / 1000
+ };
+}
export default function PowerScreen() {
- const [refreshing, setRefreshing] = React.useState(false);
- const [posts, setPosts] = React.useState(POWR_POSTS);
-
- const onRefresh = React.useCallback(() => {
- setRefreshing(true);
- // Simulate fetch
- setTimeout(() => {
- setRefreshing(false);
- }, 1500);
+ const {
+ entries,
+ newEntries,
+ loading,
+ resetFeed,
+ clearNewEntries
+ } = usePOWRFeed();
+
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [showNewButton, setShowNewButton] = useState(false);
+
+ // Use ref for list to scroll to top
+ const listRef = useRef(null);
+
+ // Break the dependency cycle by using proper effect
+ useEffect(() => {
+ // Only set the button if we have new entries
+ if (newEntries.length > 0) {
+ setShowNewButton(true);
+ }
+ }, [newEntries.length]); // Depend only on length change, not the array itself
+
+ // Handle showing new entries
+ const handleShowNewEntries = useCallback(() => {
+ clearNewEntries();
+ setShowNewButton(false);
+ // Scroll to top
+ listRef.current?.scrollToOffset({ offset: 0, animated: true });
+ }, [clearNewEntries]);
+
+ // Handle refresh - updated with proper reset handling
+ const handleRefresh = useCallback(async () => {
+ setIsRefreshing(true);
+ try {
+ await resetFeed();
+ // Add a slight delay to ensure the UI updates
+ await new Promise(resolve => setTimeout(resolve, 300));
+ } catch (error) {
+ console.error('Error refreshing feed:', error);
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, [resetFeed]);
+
+ // Handle post selection - simplified for testing
+ const handlePostPress = useCallback((entry: AnyFeedEntry) => {
+ // Just show an alert with the entry info for testing
+ alert(`Selected ${entry.type} with ID: ${entry.eventId}`);
+
+ // Alternatively, log to console for debugging
+ console.log(`Selected ${entry.type}:`, entry);
}, []);
+
+ // Memoize render item to prevent re-renders
+ const renderItem = useCallback(({ item }: { item: AnyFeedEntry }) => (
+ handlePostPress(item)}
+ />
+ ), [handlePostPress]);
+
+ // Memoize empty component
+ const renderEmptyComponent = useCallback(() => (
+ loading ? (
+
+ Loading POWR content...
+
+ ) : (
+
+ No POWR content found
+
+ )
+ ), [loading]);
- return (
-
- }
- >
+ // Header component
+ const renderHeaderComponent = useCallback(() => (
+ <>
{/* POWR Welcome Section */}
-
+
POWR Community
@@ -97,13 +109,41 @@ export default function PowerScreen() {
- {/* POWR Packs Section - Add this */}
+ {/* POWR Packs Section */}
-
- {/* Posts */}
- {posts.map(post => (
-
- ))}
-
+ >
+ ), []);
+
+ return (
+
+ {showNewButton && (
+
+
+
+ New Posts ({newEntries.length})
+
+
+ )}
+
+ item.id}
+ renderItem={renderItem}
+ refreshControl={
+
+ }
+ ListHeaderComponent={renderHeaderComponent}
+ ListEmptyComponent={renderEmptyComponent}
+ contentContainerStyle={{ flexGrow: 1, paddingBottom: 16 }}
+ />
+
);
}
\ No newline at end of file
diff --git a/components/social/EnhancedSocialPost.tsx b/components/social/EnhancedSocialPost.tsx
new file mode 100644
index 0000000..349d918
--- /dev/null
+++ b/components/social/EnhancedSocialPost.tsx
@@ -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 ;
+ case 'exercise':
+ return ;
+ case 'template':
+ return ;
+ case 'social':
+ return ;
+ case 'article':
+ return ;
+ default:
+ return null;
+ }
+ };
+
+ // Memoize the author name to prevent unnecessary re-renders
+ const authorName = useMemo(() => {
+ return profile?.name || 'Nostr User';
+ }, [profile?.name]);
+
+ return (
+
+
+
+
+ {profile?.image && !imageError ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ {authorName}
+ {profile?.nip05 && (
+
+ )}
+
+
+ {formatTimestamp(item.createdAt)}
+
+
+
+
+
+ {renderContent()}
+
+
+ {/* Reduced space between content and action buttons */}
+
+
+
+ {likeCount > 0 && (
+
+ {likeCount}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Hairline divider */}
+
+
+ );
+}
+
+// Component for workout records
+function WorkoutContent({ workout }: { workout: ParsedWorkoutRecord }) {
+ return (
+
+ {workout.title}
+
+ {workout.notes && (
+ {workout.notes}
+ )}
+
+
+
+
+ {workout.type} workout
+
+ {workout.startTime && workout.endTime && (
+
+
+
+ {formatDuration(workout.endTime - workout.startTime)}
+
+
+ )}
+
+
+ {workout.exercises.length > 0 && (
+
+ Exercises:
+ {workout.exercises.slice(0, 3).map((exercise, index) => (
+
+ • {exercise.name}
+ {exercise.weight ? ` - ${exercise.weight}kg` : ''}
+ {exercise.reps ? ` × ${exercise.reps}` : ''}
+ {exercise.rpe ? ` @ RPE ${exercise.rpe}` : ''}
+
+ ))}
+ {workout.exercises.length > 3 && (
+
+ +{workout.exercises.length - 3} more exercises
+
+ )}
+
+ )}
+
+
+ );
+}
+
+// Component for exercise templates
+function ExerciseContent({ exercise }: { exercise: ParsedExerciseTemplate }) {
+ return (
+
+ {exercise.title}
+
+ {exercise.description && (
+ {exercise.description}
+ )}
+
+
+
+ {exercise.equipment && (
+
+ {exercise.equipment}
+
+ )}
+
+ {exercise.difficulty && (
+
+ {exercise.difficulty}
+
+ )}
+
+
+ {exercise.format.length > 0 && (
+
+ Tracks:
+
+ {exercise.format.map((format, index) => (
+
+ {format}
+
+ ))}
+
+
+ )}
+
+ {exercise.tags.length > 0 && (
+
+ {exercise.tags.map((tag, index) => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+// Component for workout templates
+function TemplateContent({ template }: { template: ParsedWorkoutTemplate }) {
+ return (
+
+ {template.title}
+
+ {template.description && (
+ {template.description}
+ )}
+
+
+
+
+ {template.type} template
+
+ {template.duration && (
+
+
+
+ {formatMinutes(template.duration / 60)} {/* Convert seconds to minutes */}
+
+
+ )}
+
+
+ {template.rounds && (
+
+ {template.rounds} {template.rounds === 1 ? 'round' : 'rounds'}
+
+ )}
+
+ {template.exercises.length > 0 && (
+
+ Exercises:
+ {template.exercises.slice(0, 3).map((exercise, index) => (
+
+ • {exercise.name || 'Exercise ' + (index + 1)}
+
+ ))}
+ {template.exercises.length > 3 && (
+
+ +{template.exercises.length - 3} more exercises
+
+ )}
+
+ )}
+
+ {template.tags.length > 0 && (
+
+ {template.tags.map((tag, index) => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+// Component for social posts
+function SocialContent({ post }: { post: ParsedSocialPost }) {
+ // Render the social post content
+ const renderMainContent = () => (
+ {post.content}
+ );
+
+ // Render quoted content if available
+ const renderQuotedContent = () => {
+ if (!post.quotedContent || !post.quotedContent.resolved) return null;
+
+ const { type, resolved } = post.quotedContent;
+
+ return (
+
+
+ {type === 'workout' ? 'Workout' :
+ type === 'exercise' ? 'Exercise' :
+ type === 'template' ? 'Workout Template' :
+ type === 'article' ? 'Article' : 'Post'}:
+
+
+ {type === 'workout' && }
+ {type === 'exercise' && }
+ {type === 'template' && }
+ {type === 'article' && }
+
+ );
+ };
+
+ return (
+
+ {renderMainContent()}
+ {renderQuotedContent()}
+
+ );
+}
+
+// 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 (
+
+
+
+ Article
+
+
+ {article.title}
+
+ {article.image && (
+
+ )}
+
+ {article.summary ? (
+ {article.summary}
+ ) : (
+
+
+ {hasFullContent ? article.content.substring(0, previewLength) + '...' : article.content}
+
+
+ )}
+
+ {article.publishedAt && (
+
+ Published: {formatTimestamp(article.publishedAt)}
+
+ )}
+
+ {article.tags.length > 0 && (
+
+ {article.tags.map((tag, index) => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+ );
+}
+
+// Add ArticleQuote component for quoted articles
+function ArticleQuote({ article }: { article: ParsedLongformContent }) {
+ return (
+
+ {article.title}
+ {article.summary ? (
+ {article.summary}
+ ) : (
+
+ {article.content ? article.content.substring(0, 100) + '...' : 'No content'}
+
+ )}
+
+ );
+}
+
+// Simplified versions of content for quoted posts
+
+function WorkoutQuote({ workout }: { workout: ParsedWorkoutRecord }) {
+ return (
+
+ {workout.title}
+
+ {workout.exercises.length} exercises • {
+ workout.startTime && workout.endTime ?
+ formatDuration(workout.endTime - workout.startTime) :
+ 'Duration N/A'
+ }
+
+
+ );
+}
+
+function ExerciseQuote({ exercise }: { exercise: ParsedExerciseTemplate }) {
+ return (
+
+ {exercise.title}
+ {exercise.equipment && (
+
+ {exercise.equipment} • {exercise.difficulty || 'Any level'}
+
+ )}
+
+ );
+}
+
+function TemplateQuote({ template }: { template: ParsedWorkoutTemplate }) {
+ return (
+
+ {template.title}
+
+ {template.type} • {template.exercises.length} exercises
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/social/POWRPackSection.tsx b/components/social/POWRPackSection.tsx
index de1cead..c9e49c8 100644
--- a/components/social/POWRPackSection.tsx
+++ b/components/social/POWRPackSection.tsx
@@ -188,14 +188,7 @@ export default function POWRPackSection() {
return () => clearTimeout(timer);
}
}, [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 (
@@ -240,10 +233,10 @@ export default function POWRPackSection() {
) : featuredPacks.length > 0 ? (
// Pack cards
featuredPacks.map((pack, idx) => {
- console.log(`Rendering pack ${idx}, tags exist:`, pack.tags ? 'yes' : 'no');
const title = findTagValue(pack.tags || [], 'name') || 'Unnamed Pack';
const description = findTagValue(pack.tags || [], 'about') || '';
- const image = findTagValue(pack.tags || [], 'image') || null;
+ const image = findTagValue(pack.tags || [], 'image') ||
+ findTagValue(pack.tags || [], 'picture') || null;
// Add fallback for tags
const tags = pack.tags || [];
@@ -265,7 +258,13 @@ export default function POWRPackSection() {
{image ? (
-
+ {
+ console.error(`Failed to load image: ${image}`, error.nativeEvent.error);
+ }}
+ />
) : (
diff --git a/docs/design/Social/ImplementationPlan.md b/docs/design/Social/ImplementationPlan.md
new file mode 100644
index 0000000..b8d0761
--- /dev/null
+++ b/docs/design/Social/ImplementationPlan.md
@@ -0,0 +1,1438 @@
+# POWR Social Feed Implementation Plan
+
+## Overview
+
+This document outlines the implementation strategy for integrating Nostr-powered social feed functionality into the POWR fitness app while maintaining the existing UI structure and design patterns. The social feed will display workout records, exercise templates, workout templates, and related social posts from the Nostr network.
+
+## Event Types and Data Model
+
+```typescript
+// types/nostr.ts
+export const POWR_EVENT_KINDS = {
+ EXERCISE_TEMPLATE: 33401, // Exercise definitions
+ WORKOUT_TEMPLATE: 33402, // Workout plans
+ WORKOUT_RECORD: 1301, // Completed workouts
+ SOCIAL_POST: 1, // Regular notes referencing workout content
+ COMMENT: 1111, // Replies to content
+};
+```
+
+## Core Infrastructure
+
+### NDK Setup
+
+```typescript
+// lib/ndk/setup.ts
+import { NDKProvider } from '@nostr-dev-kit/ndk-mobile';
+import { SQLiteAdapter } from '@nostr-dev-kit/ndk-mobile/cache-adapter';
+
+export const setupNDK = () => {
+ const ndk = new NDKProvider({
+ explicitRelayUrls: [
+ 'wss://relay.damus.io',
+ 'wss://relay.nostr.band',
+ 'wss://nos.lol',
+ ],
+ enableOutboxModel: true,
+ });
+
+ const cacheAdapter = new SQLiteAdapter();
+ ndk.cacheAdapter = cacheAdapter;
+
+ return ndk;
+};
+```
+
+## Services Layer
+
+### Social Feed Service
+
+```typescript
+// lib/social/feed-service.ts
+import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk-mobile';
+import { POWR_EVENT_KINDS } from '../../types/nostr';
+
+export class SocialFeedService {
+ constructor(private ndk: NDKProvider) {}
+
+ async subscribeFeed(options: {
+ feedType: 'following' | 'powr' | 'global',
+ since?: number,
+ limit?: number,
+ authors?: string[],
+ onEvent: (event: NDKEvent) => void,
+ }) {
+ // Base workout content filter
+ const workoutFilter: NDKFilter = {
+ kinds: [
+ POWR_EVENT_KINDS.WORKOUT_RECORD,
+ POWR_EVENT_KINDS.EXERCISE_TEMPLATE,
+ POWR_EVENT_KINDS.WORKOUT_TEMPLATE,
+ ],
+ since: options.since || Math.floor(Date.now() / 1000) - 24 * 60 * 60,
+ limit: options.limit || 20,
+ };
+
+ // Base social post filter for posts referencing workout content
+ const socialPostFilter: NDKFilter = {
+ kinds: [POWR_EVENT_KINDS.SOCIAL_POST],
+ '#k': [
+ POWR_EVENT_KINDS.WORKOUT_RECORD.toString(),
+ POWR_EVENT_KINDS.EXERCISE_TEMPLATE.toString(),
+ POWR_EVENT_KINDS.WORKOUT_TEMPLATE.toString(),
+ ],
+ since: options.since || Math.floor(Date.now() / 1000) - 24 * 60 * 60,
+ limit: options.limit || 20,
+ };
+
+ // Apply tab-specific filtering
+ if (options.feedType === 'following' && options.authors?.length) {
+ // Only include posts from followed authors
+ workoutFilter.authors = options.authors;
+ socialPostFilter.authors = options.authors;
+ } else if (options.feedType === 'powr') {
+ // Include official POWR team content and featured content
+ // This could use specific pubkeys or tags to identify official content
+ const powrTeamPubkeys = getPOWRTeamPubkeys(); // Implement this helper
+ const officialTag = ['t', 'powr-official'];
+
+ // Add these to filter (advanced filtering would use "#t" for tag search)
+ if (powrTeamPubkeys.length > 0) {
+ workoutFilter.authors = powrTeamPubkeys;
+ socialPostFilter.authors = powrTeamPubkeys;
+ }
+ }
+ // 'global' uses the default filters with no additional constraints
+
+ // Create subscriptions
+ const workoutSub = this.ndk.subscribe(workoutFilter);
+ const socialSub = this.ndk.subscribe(socialPostFilter);
+
+ // Handle events from both subscriptions
+ workoutSub.on('event', (event: NDKEvent) => {
+ options.onEvent(event);
+ });
+
+ socialSub.on('event', (event: NDKEvent) => {
+ options.onEvent(event);
+ });
+
+ return {
+ unsubscribe: () => {
+ workoutSub.unsubscribe();
+ socialSub.unsubscribe();
+ }
+ };
+ }
+
+ // Get comments for an event
+ async getComments(eventId: string): Promise {
+ const filter: NDKFilter = {
+ kinds: [POWR_EVENT_KINDS.COMMENT],
+ '#e': [eventId],
+ };
+ return Array.from(await this.ndk.fetchEvents(filter));
+ }
+
+ // Post a comment on an event
+ async postComment(
+ parentEvent: NDKEvent,
+ content: string,
+ replyTo?: NDKEvent
+ ): Promise {
+ 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]);
+
+ await comment.sign();
+ await comment.publish();
+ return comment;
+ }
+
+ // Get referenced content for kind:1 posts
+ async getReferencedContent(event: NDKEvent): Promise {
+ if (event.kind !== POWR_EVENT_KINDS.SOCIAL_POST) return null;
+
+ // Find the referenced event ID
+ const eventRef = event.tags.find(tag => tag[0] === 'e');
+ if (!eventRef) return null;
+
+ // Find the kind tag that indicates what type of content is referenced
+ const kTag = event.tags.find(tag =>
+ tag[0] === 'k' &&
+ [
+ POWR_EVENT_KINDS.WORKOUT_RECORD.toString(),
+ POWR_EVENT_KINDS.EXERCISE_TEMPLATE.toString(),
+ POWR_EVENT_KINDS.WORKOUT_TEMPLATE.toString()
+ ].includes(tag[1])
+ );
+
+ if (!kTag) return null;
+
+ const filter: NDKFilter = {
+ ids: [eventRef[1]],
+ kinds: [parseInt(kTag[1])],
+ };
+
+ const events = await this.ndk.fetchEvents(filter);
+ return events.size > 0 ? Array.from(events)[0] : null;
+ }
+}
+```
+
+### Content Publisher Service
+
+```typescript
+// lib/social/publisher-service.ts
+import { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
+import { POWR_EVENT_KINDS } from '../../types/nostr';
+
+export class ContentPublisher {
+ constructor(private ndk: NDKProvider) {}
+
+ // Publish a workout record to Nostr
+ async publishWorkoutRecord(
+ workout: any, // Your app's workout type
+ options: {
+ shareAsSocialPost?: boolean;
+ socialText?: string;
+ } = {}
+ ): Promise {
+ // Convert workout to Nostr event format
+ const event = new NDKEvent(this.ndk);
+ event.kind = POWR_EVENT_KINDS.WORKOUT_RECORD;
+ event.content = options.socialText || '';
+
+ // Add required tags
+ event.tags.push(['d', generateUUID()]); // Unique identifier
+ event.tags.push(['title', workout.title]);
+ event.tags.push(['type', workout.type]);
+
+ // Add start/end time tags
+ event.tags.push(['start', Math.floor(workout.startTime / 1000).toString()]);
+ if (workout.endTime) {
+ event.tags.push(['end', Math.floor(workout.endTime / 1000).toString()]);
+ }
+
+ // Add exercise tags
+ workout.exercises.forEach(exercise => {
+ const exerciseTag = ['exercise', exercise.title];
+
+ // Add exercise details if available
+ if (exercise.sets && exercise.sets.length > 0) {
+ exercise.sets.forEach(set => {
+ if (set.weight) exerciseTag.push(`${set.weight}kg`);
+ if (set.reps) exerciseTag.push(`${set.reps} reps`);
+ });
+ }
+
+ event.tags.push(exerciseTag);
+ });
+
+ // Add completion status
+ event.tags.push(['completed', workout.isCompleted ? 'true' : 'false']);
+
+ // Sign and publish
+ await event.sign();
+ await event.publish();
+
+ // Optionally create a social post referencing this workout
+ if (options.shareAsSocialPost) {
+ await this.createSocialShare(event, options.socialText);
+ }
+
+ return event;
+ }
+
+ // Create a social post referencing a workout or template
+ private async createSocialShare(
+ event: NDKEvent,
+ text?: string
+ ): Promise {
+ const post = new NDKEvent(this.ndk);
+ post.kind = POWR_EVENT_KINDS.SOCIAL_POST;
+ post.tags = [
+ ['e', event.id],
+ ['k', event.kind.toString()],
+ ];
+ post.content = text || 'Check out my workout!';
+
+ await post.sign();
+ await post.publish();
+
+ return post;
+ }
+
+ // Like/react to a post
+ async reactToEvent(
+ event: NDKEvent,
+ reaction: string = '+'
+ ): Promise {
+ const reactionEvent = new NDKEvent(this.ndk);
+ reactionEvent.kind = 7; // Reaction
+ reactionEvent.content = reaction;
+ reactionEvent.tags = [
+ ['e', event.id],
+ ['p', event.pubkey]
+ ];
+
+ await reactionEvent.sign();
+ await reactionEvent.publish();
+
+ return reactionEvent;
+ }
+}
+
+// Helper function to generate UUIDs for d-tags
+function generateUUID(): string {
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
+ const r = Math.random() * 16 | 0;
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
+ return v.toString(16);
+ });
+}
+```
+
+## Custom Hooks
+
+### Base Social Feed Hook
+
+```typescript
+// hooks/useSocialFeed.ts
+import { useState, useEffect, useRef } from 'react';
+import { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
+import { SocialFeedService } from '../lib/social/feed-service';
+
+export function useSocialFeed(
+ ndk: any,
+ options: {
+ feedType: 'following' | 'powr' | 'global',
+ since?: number,
+ limit?: number,
+ authors?: string[],
+ }
+) {
+ const [events, setEvents] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [hasMore, setHasMore] = useState(true);
+ const [oldestTimestamp, setOldestTimestamp] = useState(null);
+
+ // Keep track of seen events to prevent duplicates
+ const seenEvents = useRef(new Set());
+ const subscriptionRef = useRef<{ unsubscribe: () => void } | null>(null);
+
+ // Add event to state, avoiding duplicates
+ const addEvent = (event: NDKEvent) => {
+ if (seenEvents.current.has(event.id)) return;
+
+ seenEvents.current.add(event.id);
+ setEvents(prev => {
+ const newEvents = [...prev, event];
+ // Sort by created_at (most recent first)
+ return newEvents.sort((a, b) => b.created_at - a.created_at);
+ });
+
+ // Update oldest timestamp for pagination
+ if (!oldestTimestamp || event.created_at < oldestTimestamp) {
+ setOldestTimestamp(event.created_at);
+ }
+ };
+
+ // Load initial feed data
+ const loadFeed = async () => {
+ if (!ndk) return;
+
+ setLoading(true);
+
+ // Clean up any existing subscription
+ if (subscriptionRef.current) {
+ subscriptionRef.current.unsubscribe();
+ subscriptionRef.current = null;
+ }
+
+ try {
+ const socialService = new SocialFeedService(ndk);
+
+ // Create subscription
+ const subscription = await socialService.subscribeFeed({
+ feedType: options.feedType,
+ since: options.since,
+ limit: options.limit || 30,
+ authors: options.authors,
+ onEvent: addEvent,
+ });
+
+ subscriptionRef.current = subscription;
+ } catch (error) {
+ console.error('Error loading feed:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Refresh feed (clear events and reload)
+ const refresh = async () => {
+ setEvents([]);
+ seenEvents.current.clear();
+ setOldestTimestamp(null);
+ setHasMore(true);
+ await loadFeed();
+ };
+
+ // Load more (pagination)
+ const loadMore = async () => {
+ if (loading || !hasMore || !oldestTimestamp) return;
+
+ try {
+ setLoading(true);
+
+ const socialService = new SocialFeedService(ndk);
+ const moreEvents = await socialService.subscribeFeed({
+ feedType: options.feedType,
+ // Use oldest timestamp minus 1 second as the "until" parameter
+ since: oldestTimestamp - 1,
+ limit: options.limit || 30,
+ authors: options.authors,
+ onEvent: addEvent,
+ });
+
+ // If we got fewer events than requested, there are probably no more
+ if (moreEvents.length < (options.limit || 30)) {
+ setHasMore(false);
+ }
+ } catch (error) {
+ console.error('Error loading more events:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Initial load on mount and when ndk or options change
+ useEffect(() => {
+ loadFeed();
+
+ // Clean up subscription on unmount
+ return () => {
+ if (subscriptionRef.current) {
+ subscriptionRef.current.unsubscribe();
+ }
+ };
+ }, [ndk, options.feedType, JSON.stringify(options.authors)]);
+
+ return {
+ events,
+ loading,
+ refresh,
+ loadMore,
+ hasMore,
+ };
+}
+```
+
+### Tab-Specific Hooks
+
+```typescript
+// hooks/useFollowingFeed.ts
+import { useSocialFeed } from './useSocialFeed';
+import { useNDK } from './useNDK';
+import { useFollowList } from './useFollowList';
+
+export function useFollowingFeed() {
+ const ndk = useNDK();
+ const { followedUsers } = useFollowList();
+
+ return useSocialFeed(ndk, {
+ feedType: 'following',
+ authors: followedUsers,
+ });
+}
+
+// hooks/usePOWRFeed.ts
+import { useSocialFeed } from './useSocialFeed';
+import { useNDK } from './useNDK';
+
+export function usePOWRFeed() {
+ const ndk = useNDK();
+
+ return useSocialFeed(ndk, {
+ feedType: 'powr',
+ });
+}
+
+// hooks/useGlobalFeed.ts
+import { useSocialFeed } from './useSocialFeed';
+import { useNDK } from './useNDK';
+
+export function useGlobalFeed() {
+ const ndk = useNDK();
+
+ return useSocialFeed(ndk, {
+ feedType: 'global',
+ });
+}
+```
+
+## Data Transformation Utilities
+
+```typescript
+// utils/eventTransformers.ts
+import { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
+import { POWR_EVENT_KINDS } from '../types/nostr';
+import { useProfileStore } from '../stores/profileStore';
+
+// Transform a Nostr event to the format expected by SocialPost component
+export function eventToPost(event: NDKEvent) {
+ // Get profile information for the event author
+ const authorProfile = useProfileStore.getState().getProfile(event.pubkey);
+
+ // Base post object
+ const basePost = {
+ id: event.id,
+ author: {
+ name: authorProfile?.name || 'Unknown',
+ handle: authorProfile?.name?.toLowerCase().replace(/\s/g, '') || 'unknown',
+ avatar: authorProfile?.picture || '',
+ pubkey: event.pubkey,
+ verified: isPOWRTeamMember(event.pubkey)
+ },
+ content: event.content,
+ createdAt: new Date(event.created_at * 1000),
+ metrics: {
+ likes: 0, // These will be filled in later
+ comments: 0,
+ reposts: 0
+ }
+ };
+
+ // Adapt based on event kind
+ switch(event.kind) {
+ case POWR_EVENT_KINDS.WORKOUT_RECORD:
+ return {
+ ...basePost,
+ workout: extractWorkoutData(event)
+ };
+
+ case POWR_EVENT_KINDS.EXERCISE_TEMPLATE:
+ return {
+ ...basePost,
+ exerciseTemplate: extractExerciseTemplateData(event)
+ };
+
+ case POWR_EVENT_KINDS.WORKOUT_TEMPLATE:
+ return {
+ ...basePost,
+ workoutTemplate: extractWorkoutTemplateData(event)
+ };
+
+ case POWR_EVENT_KINDS.SOCIAL_POST:
+ // For social posts, we need to check if they reference workout content
+ return {
+ ...basePost,
+ // This will be filled in asynchronously
+ referencedContent: null
+ };
+
+ default:
+ return basePost;
+ }
+}
+
+// Extract workout data from a workout record event
+function extractWorkoutData(event: NDKEvent) {
+ const title = getEventTag(event, 'title') || 'Untitled Workout';
+ const type = getEventTag(event, 'type') || 'strength';
+
+ // Get exercise tags
+ const exerciseTags = event.tags.filter(tag => tag[0] === 'exercise');
+ const exercises = exerciseTags.map(tag => {
+ return {
+ title: tag[1] || 'Unknown Exercise',
+ // Extract other exercise data from tags if available
+ sets: tag[2] ? parseInt(tag[2]) : null,
+ reps: tag[3] ? parseInt(tag[3]) : null,
+ };
+ });
+
+ // Calculate duration if start/end times are available
+ const startTag = getEventTag(event, 'start');
+ const endTag = getEventTag(event, 'end');
+ let duration = null;
+
+ if (startTag && endTag) {
+ const startTime = parseInt(startTag);
+ const endTime = parseInt(endTag);
+ if (!isNaN(startTime) && !isNaN(endTime)) {
+ duration = Math.floor((endTime - startTime) / 60); // Duration in minutes
+ }
+ }
+
+ return {
+ title,
+ type,
+ exercises,
+ duration
+ };
+}
+
+// Extract exercise template data from an event
+function extractExerciseTemplateData(event: NDKEvent) {
+ const title = getEventTag(event, 'title') || 'Untitled Exercise';
+ const equipment = getEventTag(event, 'equipment');
+ const difficulty = getEventTag(event, 'difficulty');
+
+ // Get format information
+ const formatTag = event.tags.find(tag => tag[0] === 'format');
+ const formatUnitsTag = event.tags.find(tag => tag[0] === 'format_units');
+
+ // Get tags (like muscle groups)
+ const tags = event.tags
+ .filter(tag => tag[0] === 't')
+ .map(tag => tag[1]);
+
+ return {
+ title,
+ equipment,
+ difficulty,
+ format: formatTag ? formatTag.slice(1) : [],
+ formatUnits: formatUnitsTag ? formatUnitsTag.slice(1) : [],
+ tags
+ };
+}
+
+// Extract workout template data from an event
+function extractWorkoutTemplateData(event: NDKEvent) {
+ const title = getEventTag(event, 'title') || 'Untitled Template';
+ const type = getEventTag(event, 'type') || 'strength';
+
+ // Get exercise references
+ const exerciseTags = event.tags.filter(tag => tag[0] === 'exercise');
+ const exercises = exerciseTags.map(tag => {
+ return {
+ reference: tag[1] || '',
+ // Extract parameter data if available
+ params: tag.slice(2)
+ };
+ });
+
+ // Get other metadata
+ const rounds = getEventTag(event, 'rounds');
+ const duration = getEventTag(event, 'duration');
+ const interval = getEventTag(event, 'interval');
+
+ // Get tags (like workout category)
+ const tags = event.tags
+ .filter(tag => tag[0] === 't')
+ .map(tag => tag[1]);
+
+ return {
+ title,
+ type,
+ exercises,
+ rounds: rounds ? parseInt(rounds) : null,
+ duration: duration ? parseInt(duration) : null,
+ interval: interval ? parseInt(interval) : null,
+ tags
+ };
+}
+
+// Helper to get a tag value
+function getEventTag(event: NDKEvent, tagName: string): string | null {
+ const tag = event.tags.find(t => t[0] === tagName);
+ return tag ? tag[1] : null;
+}
+
+// Check if the pubkey belongs to the POWR team
+function isPOWRTeamMember(pubkey: string): boolean {
+ const powrTeamPubkeys = [
+ // Add POWR team public keys here
+ ];
+ return powrTeamPubkeys.includes(pubkey);
+}
+```
+
+## Updated Screen Components
+
+### Following Tab
+
+```typescript
+// app/(tabs)/social/following.tsx
+import React, { useMemo } from 'react';
+import { View, FlatList, RefreshControl } from 'react-native';
+import { Text } from '@/components/ui/text';
+import SocialPost from '@/components/social/SocialPost';
+import { useNDKCurrentUser } from '@/lib/hooks/useNDK';
+import NostrLoginPrompt from '@/components/social/NostrLoginPrompt';
+import EmptyFeed from '@/components/social/EmptyFeed';
+import { useFollowingFeed } from '@/hooks/useFollowingFeed';
+import { eventToPost } from '@/utils/eventTransformers';
+
+export default function FollowingScreen() {
+ const { isAuthenticated } = useNDKCurrentUser();
+ const { events, loading, refresh, loadMore } = useFollowingFeed();
+
+ // Transform Nostr events to the format expected by SocialPost
+ const posts = useMemo(() =>
+ events.map(eventToPost).filter(Boolean),
+ [events]
+ );
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ if (posts.length === 0 && !loading) {
+ return ;
+ }
+
+ return (
+ item.id}
+ renderItem={({ item }) => }
+ refreshControl={
+
+ }
+ onEndReached={loadMore}
+ onEndReachedThreshold={0.5}
+ contentContainerStyle={{ flexGrow: 1 }}
+ ListEmptyComponent={
+ loading ? (
+
+ Loading posts...
+
+ ) : null
+ }
+ />
+ );
+}
+```
+
+### POWR Tab
+
+```typescript
+// app/(tabs)/social/powr.tsx
+import React, { useMemo } from 'react';
+import { View, FlatList, RefreshControl } from 'react-native';
+import { Text } from '@/components/ui/text';
+import SocialPost from '@/components/social/SocialPost';
+import { Zap } from 'lucide-react-native';
+import POWRPackSection from '@/components/social/POWRPackSection';
+import { usePOWRFeed } from '@/hooks/usePOWRFeed';
+import { eventToPost } from '@/utils/eventTransformers';
+
+export default function PowerScreen() {
+ const { events, loading, refresh, loadMore } = usePOWRFeed();
+
+ // Transform Nostr events to the format expected by SocialPost
+ const posts = useMemo(() =>
+ events.map(eventToPost).filter(Boolean),
+ [events]
+ );
+
+ return (
+ item.id}
+ renderItem={({ item }) => }
+ refreshControl={
+
+ }
+ onEndReached={loadMore}
+ onEndReachedThreshold={0.5}
+ ListHeaderComponent={
+ <>
+ {/* POWR Welcome Section - Maintain existing UI */}
+
+
+
+ POWR Community
+
+
+ Official updates, featured content, and community highlights from the POWR team.
+
+
+
+ {/* POWR Packs Section - Maintain existing component */}
+
+ >
+ }
+ ListEmptyComponent={
+ loading ? (
+
+ Loading POWR content...
+
+ ) : (
+
+ No POWR content found
+
+ )
+ }
+ />
+ );
+}
+```
+
+### Global Tab
+
+```typescript
+// app/(tabs)/social/global.tsx
+import React, { useMemo } from 'react';
+import { View, FlatList, RefreshControl } from 'react-native';
+import { Text } from '@/components/ui/text';
+import SocialPost from '@/components/social/SocialPost';
+import { useGlobalFeed } from '@/hooks/useGlobalFeed';
+import { eventToPost } from '@/utils/eventTransformers';
+
+export default function GlobalScreen() {
+ const { events, loading, refresh, loadMore } = useGlobalFeed();
+
+ // Transform Nostr events to the format expected by SocialPost
+ const posts = useMemo(() =>
+ events.map(eventToPost).filter(Boolean),
+ [events]
+ );
+
+ return (
+ item.id}
+ renderItem={({ item }) => }
+ refreshControl={
+
+ }
+ onEndReached={loadMore}
+ onEndReachedThreshold={0.5}
+ ListEmptyComponent={
+ loading ? (
+
+ Loading global content...
+
+ ) : (
+
+ No global content found
+
+ )
+ }
+ />
+ );
+}
+```
+
+## Enhanced SocialPost Component
+
+The existing SocialPost component needs updates to handle Nostr-based content:
+
+```typescript
+// components/social/SocialPost.tsx
+import React, { useState, useEffect } from 'react';
+import { View, Pressable } from 'react-native';
+import { Text } from '@/components/ui/text';
+import { Avatar } from '@/components/ui/avatar';
+import { Button } from '@/components/ui/button';
+import { Heart, MessageCircle, Repeat, Share } from 'lucide-react-native';
+import { useNDK } from '@/lib/hooks/useNDK';
+import { SocialFeedService } from '@/lib/social/feed-service';
+import { ContentPublisher } from '@/lib/social/publisher-service';
+import { CommentSection } from './CommentSection';
+import WorkoutContent from './content/WorkoutContent';
+import TemplateContent from './content/TemplateContent';
+import ExerciseContent from './content/ExerciseContent';
+
+export default function SocialPost({ post }) {
+ const ndk = useNDK();
+ const [showComments, setShowComments] = useState(false);
+ const [isLiked, setIsLiked] = useState(false);
+ const [likes, setLikes] = useState(post.metrics?.likes || 0);
+ const [comments, setComments] = useState(post.metrics?.comments || 0);
+ const [referencedContent, setReferencedContent] = useState(post.referencedContent);
+
+ // Fetch referenced content if needed (for kind:1 posts that reference workout content)
+ useEffect(() => {
+ if (post.eventId && post.eventKind === 1 && !referencedContent && ndk) {
+ const fetchReferencedContent = async () => {
+ try {
+ const socialService = new SocialFeedService(ndk);
+ const content = await socialService.getReferencedContent(post.eventId);
+ if (content) {
+ setReferencedContent(content);
+ }
+ } catch (error) {
+ console.error('Error fetching referenced content:', error);
+ }
+ };
+
+ fetchReferencedContent();
+ }
+ }, [post.eventId, post.eventKind, referencedContent, ndk]);
+
+ // Handle like button press
+ const handleLike = async () => {
+ if (!ndk) return;
+
+ try {
+ const contentPublisher = new ContentPublisher(ndk);
+ await contentPublisher.reactToEvent(post.eventId, '+');
+
+ // Update UI state
+ setIsLiked(true);
+ setLikes(prev => prev + 1);
+ } catch (error) {
+ console.error('Error liking post:', error);
+ }
+ };
+
+ // Handle comment button press
+ const handleComment = () => {
+ setShowComments(!showComments);
+ };
+
+ // Format timestamp
+ const formatTimestamp = (date) => {
+ const now = new Date();
+ const diffInSeconds = Math.floor((now - date) / 1000);
+
+ if (diffInSeconds < 60) return `${diffInSeconds}s`;
+ if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m`;
+ if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h`;
+
+ return date.toLocaleDateString();
+ };
+
+ return (
+
+ {/* Author info */}
+
+
+
+
+ {post.author.name}
+ {post.author.verified && (
+
+ ✓
+
+ )}
+
+
+ {formatTimestamp(post.createdAt)}
+
+
+
+
+ {/* Post content */}
+ {post.content && (
+ {post.content}
+ )}
+
+ {/* Workout/Exercise/Template content */}
+ {post.workout && (
+
+ )}
+
+ {post.workoutTemplate && (
+
+ )}
+
+ {post.exerciseTemplate && (
+
+ )}
+
+ {/* Referenced content (for kind:1 posts) */}
+ {referencedContent && (
+
+ {/* Render based on content type */}
+ {referencedContent.type === 'workout' && (
+
+ )}
+ {referencedContent.type === 'template' && (
+
+ )}
+ {referencedContent.type === 'exercise' && (
+
+ )}
+
+ )}
+
+ {/* Interaction buttons */}
+
+
+
+
+
+
+
+
+
+
+ {/* Comments section */}
+ {showComments && (
+ setComments(prev => prev + 1)}
+ />
+ )}
+
+ );
+}
+```
+
+## Comments System
+
+```typescript
+// components/social/CommentSection.tsx
+import React, { useState, useEffect } from 'react';
+import { View, FlatList } from 'react-native';
+import { Text } from '@/components/ui/text';
+import { TextInput } from '@/components/ui/text-input';
+import { Button } from '@/components/ui/button';
+import { useNDK } from '@/lib/hooks/useNDK';
+import { SocialFeedService } from '@/lib/social/feed-service';
+import CommentItem from './CommentItem';
+
+export default function CommentSection({ eventId, onNewComment }) {
+ const ndk = useNDK();
+ const [comments, setComments] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [commentText, setCommentText] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+
+ // Load comments
+ useEffect(() => {
+ const loadComments = async () => {
+ if (!ndk || !eventId) return;
+
+ setLoading(true);
+ try {
+ const socialService = new SocialFeedService(ndk);
+ const fetchedComments = await socialService.getComments(eventId);
+
+ // Convert to format needed by the UI
+ const formattedComments = buildCommentTree(fetchedComments);
+ setComments(formattedComments);
+ } catch (error) {
+ console.error('Error loading comments:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ loadComments();
+ }, [eventId, ndk]);
+
+ // Submit a new comment
+ const handleSubmitComment = async () => {
+ if (!commentText.trim() || !ndk || !eventId || submitting) return;
+
+ setSubmitting(true);
+ try {
+ const socialService = new SocialFeedService(ndk);
+ const comment = await socialService.postComment(eventId, commentText.trim());
+
+ // Add new comment to the list
+ setComments(prev => [...prev, {
+ id: comment.id,
+ content: commentText.trim(),
+ createdAt: new Date(),
+ author: { /* get current user info */ },
+ replies: []
+ }]);
+
+ // Clear input
+ setCommentText('');
+
+ // Notify parent
+ onNewComment?.();
+ } catch (error) {
+ console.error('Error posting comment:', error);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ // Build threaded comment structure
+ const buildCommentTree = (comments) => {
+ const commentMap = new Map();
+ const rootComments = [];
+
+ // Create all comment nodes
+ comments.forEach(comment => {
+ commentMap.set(comment.id, {
+ id: comment.id,
+ content: comment.content,
+ createdAt: new Date(comment.created_at * 1000),
+ author: {
+ name: 'User', // This should be filled in from profiles
+ avatar: '',
+ pubkey: comment.pubkey
+ },
+ replies: []
+ });
+ });
+
+ // Build tree structure
+ comments.forEach(comment => {
+ const replyToTag = comment.tags.find(tag =>
+ tag[0] === 'e' && tag[3] === 'reply'
+ );
+
+ if (replyToTag) {
+ const parentId = replyToTag[1];
+ const parent = commentMap.get(parentId);
+ const node = commentMap.get(comment.id);
+
+ if (parent && node) {
+ parent.replies.push(node);
+ } else {
+ rootComments.push(commentMap.get(comment.id));
+ }
+ } else {
+ rootComments.push(commentMap.get(comment.id));
+ }
+ });
+
+ return rootComments;
+ };
+
+ if (loading) {
+ return (
+
+ Loading comments...
+
+ );
+ }
+
+ return (
+
+ {/* Comment list */}
+
+ {comments.length === 0 ? (
+ No comments yet. Be the first!
+ ) : (
+ comments.map(comment => (
+
+ ))
+ )}
+
+
+ {/* Comment input */}
+
+
+
+
+
+ );
+}
+
+// components/social/CommentItem.tsx
+import React, { useState } from 'react';
+import { View, Pressable } from 'react-native';
+import { Text } from '@/components/ui/text';
+import { Avatar } from '@/components/ui/avatar';
+import { Button } from '@/components/ui/button';
+import { useNDK } from '@/lib/hooks/useNDK';
+import { SocialFeedService } from '@/lib/social/feed-service';
+import { TextInput } from '@/components/ui/text-input';
+
+export default function CommentItem({ comment, eventId, depth = 0, onNewReply }) {
+ const ndk = useNDK();
+ const [showReplyInput, setShowReplyInput] = useState(false);
+ const [replyText, setReplyText] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+
+ // Format timestamp
+ const formatTimestamp = (date) => {
+ const now = new Date();
+ const diffInSeconds = Math.floor((now - date) / 1000);
+
+ if (diffInSeconds < 60) return `${diffInSeconds}s`;
+ if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m`;
+ if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h`;
+
+ return date.toLocaleDateString();
+ };
+
+ // Submit a reply
+ const handleSubmitReply = async () => {
+ if (!replyText.trim() || !ndk || !eventId || submitting) return;
+
+ setSubmitting(true);
+ try {
+ const socialService = new SocialFeedService(ndk);
+ await socialService.postComment(eventId, replyText.trim(), comment.id);
+
+ // Clear input and hide reply field
+ setReplyText('');
+ setShowReplyInput(false);
+
+ // Notify parent
+ onNewReply?.();
+ } catch (error) {
+ console.error('Error posting reply:', error);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+ {/* Comment header */}
+
+
+ {comment.author.name}
+
+ {formatTimestamp(comment.createdAt)}
+
+
+
+ {/* Comment content */}
+ {comment.content}
+
+ {/* Reply button */}
+ setShowReplyInput(!showReplyInput)}
+ className="mb-2"
+ >
+ Reply
+
+
+ {/* Reply input */}
+ {showReplyInput && (
+
+
+
+
+ )}
+
+ {/* Replies */}
+ {comment.replies?.map(reply => (
+
+ ))}
+
+ );
+}
+```
+
+## Content Sharing Integration
+
+```typescript
+// components/social/ShareWorkout.tsx
+import React, { useState } from 'react';
+import { View } from 'react-native';
+import { Text } from '@/components/ui/text';
+import { TextInput } from '@/components/ui/text-input';
+import { Button } from '@/components/ui/button';
+import { useNDK } from '@/lib/hooks/useNDK';
+import { ContentPublisher } from '@/lib/social/publisher-service';
+import WorkoutContent from './content/WorkoutContent';
+
+export default function ShareWorkout({ workout, onShare }) {
+ const ndk = useNDK();
+ const [socialText, setSocialText] = useState('');
+ const [sharing, setSharing] = useState(false);
+
+ const handleShare = async () => {
+ if (!ndk || sharing) return;
+
+ setSharing(true);
+ try {
+ const publisher = new ContentPublisher(ndk);
+ await publisher.publishWorkoutRecord(workout, {
+ shareAsSocialPost: true,
+ socialText,
+ });
+
+ // Notify parent
+ onShare?.();
+ } catch (error) {
+ console.error('Error sharing workout:', error);
+ } finally {
+ setSharing(false);
+ }
+ };
+
+ return (
+
+ Share Your Workout
+
+ {/* Preview */}
+
+
+
+
+ {/* Text input */}
+
+
+ {/* Share button */}
+
+
+ );
+}
+```
+
+## Implementation Timeline
+
+1. **Week 1: Infrastructure Setup**
+ - Configure NDK Mobile with SQLite adapter
+ - Implement core services (SocialFeedService, ContentPublisher)
+ - Set up data model and event type definitions
+
+2. **Week 2: Data Fetching & Transformation**
+ - Implement useSocialFeed hook
+ - Create tab-specific hooks (useFollowingFeed, usePOWRFeed, useGlobalFeed)
+ - Build data transformation utilities
+ - Test social feed fetching with mock UI
+
+3. **Week 3: UI Components**
+ - Update SocialPost component to handle Nostr events
+ - Implement Comment system components
+ - Build content renderers for different event types
+ - Integrate with existing UI components
+
+4. **Week 4: Social Interactions & Polish**
+ - Implement like/comment functionality
+ - Build workout sharing component
+ - Add profile integration
+ - Optimize performance
+ - Implement error handling and loading states
+
+## Key Considerations
+
+1. **Authentication Integration**
+ - The social feed should work seamlessly with existing authentication
+ - Show appropriate prompts for unauthenticated users
+ - Handle authentication state changes gracefully
+
+2. **Performance Optimization**
+ - Use FlatList instead of ScrollView for better performance
+ - Implement proper pagination with infinite scroll
+ - Optimize data fetching to reduce unnecessary requests
+
+3. **Offline Support**
+ - Leverage SQLite adapter for caching events
+ - Implement offline detection and appropriate UI feedback
+ - Queue interactions (likes, comments) when offline for later submission
+
+4. **Error Handling**
+ - Gracefully handle network errors
+ - Provide clear feedback on publishing failures
+ - Implement retry mechanisms for failed operations
+
+5. **UI Consistency**
+ - Maintain existing styling patterns
+ - Preserve custom components like POWRPackSection
+ - Follow established interaction patterns
+
+This implementation plan maintains the look and feel of your existing social feed UI while integrating Nostr as the backend data source. The implementation focuses on adapting the data from Nostr events to fit your existing UI components, rather than replacing them with new ones.
\ No newline at end of file
diff --git a/ios/powr.xcodeproj/project.pbxproj b/ios/powr.xcodeproj/project.pbxproj
index 43a3993..947c171 100644
--- a/ios/powr.xcodeproj/project.pbxproj
+++ b/ios/powr.xcodeproj/project.pbxproj
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
- objectVersion = 46;
+ objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@@ -30,7 +30,7 @@
7A4D352CD337FB3A3BF06240 /* Pods-powr.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-powr.release.xcconfig"; path = "Target Support Files/Pods-powr/Pods-powr.release.xcconfig"; sourceTree = ""; };
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 = ""; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = powr/SplashScreen.storyboard; sourceTree = ""; };
- AF7B76EF4E55CFEE116289C3 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = powr/PrivacyInfo.xcprivacy; sourceTree = ""; };
+ AF7B76EF4E55CFEE116289C3 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = powr/PrivacyInfo.xcprivacy; sourceTree = ""; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; };
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 = ""; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
@@ -349,14 +349,20 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = powr/powr.entitlements;
CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 944AF56S27;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"FB_SONARKIT_ENABLED=1",
);
INFOPLIST_FILE = powr/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = POWR;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
- LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
@@ -364,7 +370,7 @@
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
- PRODUCT_BUNDLE_IDENTIFIER = com.powr.app;
+ PRODUCT_BUNDLE_IDENTIFIER = io.proofofworkout.powr;
PRODUCT_NAME = powr;
SWIFT_OBJC_BRIDGING_HEADER = "powr/powr-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -382,9 +388,15 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = powr/powr.entitlements;
CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 944AF56S27;
INFOPLIST_FILE = powr/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = POWR;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
- LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
@@ -392,7 +404,7 @@
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
- PRODUCT_BUNDLE_IDENTIFIER = com.powr.app;
+ PRODUCT_BUNDLE_IDENTIFIER = io.proofofworkout.powr;
PRODUCT_NAME = powr;
SWIFT_OBJC_BRIDGING_HEADER = "powr/powr-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -449,14 +461,14 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
- LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
+ LD_RUNPATH_SEARCH_PATHS = (
+ /usr/lib/swift,
+ "$(inherited)",
+ );
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
- OTHER_LDFLAGS = (
- "$(inherited)",
- " ",
- );
+ OTHER_LDFLAGS = "$(inherited) ";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
@@ -505,13 +517,13 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
- LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
+ LD_RUNPATH_SEARCH_PATHS = (
+ /usr/lib/swift,
+ "$(inherited)",
+ );
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO;
- OTHER_LDFLAGS = (
- "$(inherited)",
- " ",
- );
+ OTHER_LDFLAGS = "$(inherited) ";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = true;
diff --git a/ios/powr/Info.plist b/ios/powr/Info.plist
index 6509462..445ca7b 100644
--- a/ios/powr/Info.plist
+++ b/ios/powr/Info.plist
@@ -1,81 +1,81 @@
-
- CADisableMinimumFrameDurationOnPhone
-
- CFBundleDevelopmentRegion
- $(DEVELOPMENT_LANGUAGE)
- CFBundleDisplayName
- powr
- CFBundleExecutable
- $(EXECUTABLE_NAME)
- CFBundleIdentifier
- $(PRODUCT_BUNDLE_IDENTIFIER)
- CFBundleInfoDictionaryVersion
- 6.0
- CFBundleName
- $(PRODUCT_NAME)
- CFBundlePackageType
- $(PRODUCT_BUNDLE_PACKAGE_TYPE)
- CFBundleShortVersionString
- 1.0.0
- CFBundleSignature
- ????
- CFBundleURLTypes
-
-
- CFBundleURLSchemes
-
- myapp
- com.powr.app
-
-
-
- CFBundleVersion
- 1
- LSMinimumSystemVersion
- 12.0
- LSRequiresIPhoneOS
-
- NSAppTransportSecurity
-
- NSAllowsArbitraryLoads
-
- NSAllowsLocalNetworking
-
-
- NSUserActivityTypes
-
- $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route
-
- UIFileSharingEnabled
-
- UILaunchStoryboardName
- SplashScreen
- UIRequiredDeviceCapabilities
-
- arm64
-
- UIRequiresFullScreen
-
- UIStatusBarStyle
- UIStatusBarStyleDefault
- UISupportedInterfaceOrientations
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
-
- UISupportedInterfaceOrientations~ipad
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
- UIUserInterfaceStyle
- Automatic
- UIViewControllerBasedStatusBarAppearance
-
-
-
\ No newline at end of file
+
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ powr
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ 1.0.0
+ CFBundleSignature
+ ????
+ CFBundleURLTypes
+
+
+ CFBundleURLSchemes
+
+ myapp
+ com.powr.app
+
+
+
+ CFBundleVersion
+ 1
+ LSMinimumSystemVersion
+ 12.0
+ LSRequiresIPhoneOS
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+ NSAllowsLocalNetworking
+
+
+ NSUserActivityTypes
+
+ $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route
+
+ UIFileSharingEnabled
+
+ UILaunchStoryboardName
+ SplashScreen
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ UIRequiresFullScreen
+
+ UIStatusBarStyle
+ UIStatusBarStyleDefault
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UIUserInterfaceStyle
+ Automatic
+ UIViewControllerBasedStatusBarAppearance
+
+
+
diff --git a/lib/hooks/useContactList.ts b/lib/hooks/useContactList.ts
new file mode 100644
index 0000000..9a70e34
--- /dev/null
+++ b/lib/hooks/useContactList.ts
@@ -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([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(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 = 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
+ };
+}
\ No newline at end of file
diff --git a/lib/hooks/useFeedEvents.ts b/lib/hooks/useFeedEvents.ts
new file mode 100644
index 0000000..705f064
--- /dev/null
+++ b/lib/hooks/useFeedEvents.ts
@@ -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([]);
+ const [newEntries, setNewEntries] = useState([]);
+ 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>({});
+ const subscriptionRef = useRef(null);
+ const seenEventsRef = useRef>(new Set());
+ const quotedEventsRef = useRef>(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
+ };
+}
\ No newline at end of file
diff --git a/lib/hooks/useFeedHooks.ts b/lib/hooks/useFeedHooks.ts
new file mode 100644
index 0000000..dbe2f88
--- /dev/null
+++ b/lib/hooks/useFeedHooks.ts
@@ -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([]);
+ 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();
+ 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(() => {
+ 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(() => {
+ 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(() => [
+ {
+ 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
+ };
+}
\ No newline at end of file
diff --git a/lib/hooks/useFeedMonitor.ts b/lib/hooks/useFeedMonitor.ts
new file mode 100644
index 0000000..e6d8c86
--- /dev/null
+++ b/lib/hooks/useFeedMonitor.ts
@@ -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;
+}
+
+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(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
+ };
+}
\ No newline at end of file
diff --git a/lib/hooks/useFeedState.ts b/lib/hooks/useFeedState.ts
new file mode 100644
index 0000000..96ff87c
--- /dev/null
+++ b/lib/hooks/useFeedState.ts
@@ -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([]);
+ const [newEntries, setNewEntries] = useState([]);
+
+ // Reference to actual entries for stable access without re-renders
+ const entriesRef = useRef>({});
+ // Track seen events to avoid duplicates
+ const seenEventsRef = useRef>(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
+ };
+}
\ No newline at end of file
diff --git a/lib/hooks/useProfile.ts b/lib/hooks/useProfile.ts
index 3a54e95..2b4ed4c 100644
--- a/lib/hooks/useProfile.ts
+++ b/lib/hooks/useProfile.ts
@@ -1,5 +1,5 @@
// lib/hooks/useProfile.ts
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef } from 'react';
import { NDKUser, NDKUserProfile } from '@nostr-dev-kit/ndk-mobile';
import { useNDK } from './useNDK';
@@ -10,12 +10,25 @@ export function useProfile(pubkey: string | undefined) {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
+ // Reference to track if component is mounted
+ const isMountedRef = useRef(true);
+
+ // Reset mounted ref when component unmounts
+ useEffect(() => {
+ isMountedRef.current = true;
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, []);
+
useEffect(() => {
if (!ndk || !pubkey) {
setIsLoading(false);
return;
}
+ let isEffectActive = true;
+
const fetchProfile = async () => {
try {
setIsLoading(true);
@@ -35,21 +48,32 @@ export function useProfile(pubkey: string | undefined) {
}
}
- setUser(ndkUser);
- setProfile(ndkUser.profile || null);
- setIsLoading(false);
+ // Only update state if component is still mounted and effect is active
+ if (isMountedRef.current && isEffectActive) {
+ setUser(ndkUser);
+ setProfile(ndkUser.profile || null);
+ setIsLoading(false);
+ }
} catch (err) {
console.error('Error fetching profile:', err);
- setError(err instanceof Error ? err : new Error('Failed to fetch profile'));
- setIsLoading(false);
+ // Only update state if component is still mounted and effect is active
+ if (isMountedRef.current && isEffectActive) {
+ setError(err instanceof Error ? err : new Error('Failed to fetch profile'));
+ setIsLoading(false);
+ }
}
};
fetchProfile();
+
+ // Cleanup function to prevent state updates if the effect is cleaned up
+ return () => {
+ isEffectActive = false;
+ };
}, [ndk, pubkey]);
const refreshProfile = async () => {
- if (!ndk || !pubkey) return;
+ if (!ndk || !pubkey || !isMountedRef.current) return;
try {
setIsLoading(true);
@@ -58,12 +82,19 @@ export function useProfile(pubkey: string | undefined) {
const ndkUser = ndk.getUser({ pubkey });
await ndkUser.fetchProfile();
- setUser(ndkUser);
- setProfile(ndkUser.profile || null);
+ // Only update state if component is still mounted
+ if (isMountedRef.current) {
+ setUser(ndkUser);
+ setProfile(ndkUser.profile || null);
+ }
} catch (err) {
- setError(err instanceof Error ? err : new Error('Failed to refresh profile'));
+ if (isMountedRef.current) {
+ setError(err instanceof Error ? err : new Error('Failed to refresh profile'));
+ }
} finally {
- setIsLoading(false);
+ if (isMountedRef.current) {
+ setIsLoading(false);
+ }
}
};
diff --git a/lib/hooks/useSocialFeed.ts b/lib/hooks/useSocialFeed.ts
new file mode 100644
index 0000000..d3af76e
--- /dev/null
+++ b/lib/hooks/useSocialFeed.ts
@@ -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([]);
+ const [loading, setLoading] = useState(true);
+ const [hasMore, setHasMore] = useState(true);
+ const [oldestTimestamp, setOldestTimestamp] = useState(null);
+
+ // Keep track of seen events to prevent duplicates
+ const seenEvents = useRef(new Set());
+ const quotedEvents = useRef(new Set());
+ const subscriptionRef = useRef<{ unsubscribe: () => void } | null>(null);
+ const socialServiceRef = useRef(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
+ };
+}
\ No newline at end of file
diff --git a/lib/social/socialFeedService.ts b/lib/social/socialFeedService.ts
new file mode 100644
index 0000000..2c4f4e3
--- /dev/null
+++ b/lib/social/socialFeedService.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ // 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 {
+ 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 {
+ // 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 {
+ 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',
+ ];
+}
+}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index fa34105..5aba305 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -74,6 +74,7 @@
"react-native": "0.76.7",
"react-native-gesture-handler": "~2.20.2",
"react-native-get-random-values": "~1.11.0",
+ "react-native-markdown-display": "^7.0.2",
"react-native-pager-view": "6.5.1",
"react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0",
@@ -10567,6 +10568,15 @@
"node": ">= 6"
}
},
+ "node_modules/camelize": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
+ "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001699",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001699.tgz",
@@ -11164,6 +11174,15 @@
"node": ">=8"
}
},
+ "node_modules/css-color-keywords": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
+ "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/css-in-js-utils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
@@ -11189,6 +11208,17 @@
"url": "https://github.com/sponsors/fb55"
}
},
+ "node_modules/css-to-react-native": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
+ "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "camelize": "^1.0.0",
+ "css-color-keywords": "^1.0.0",
+ "postcss-value-parser": "^4.0.2"
+ }
+ },
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
@@ -15600,6 +15630,15 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
+ "node_modules/linkify-it": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz",
+ "integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==",
+ "license": "MIT",
+ "dependencies": {
+ "uc.micro": "^1.0.1"
+ }
+ },
"node_modules/loader-runner": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
@@ -15794,6 +15833,28 @@
"tmpl": "1.0.5"
}
},
+ "node_modules/markdown-it": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-10.0.0.tgz",
+ "integrity": "sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg==",
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^1.0.7",
+ "entities": "~2.0.0",
+ "linkify-it": "^2.0.0",
+ "mdurl": "^1.0.1",
+ "uc.micro": "^1.0.5"
+ },
+ "bin": {
+ "markdown-it": "bin/markdown-it.js"
+ }
+ },
+ "node_modules/markdown-it/node_modules/entities": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-2.0.3.tgz",
+ "integrity": "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/marky": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/marky/-/marky-1.2.5.tgz",
@@ -15841,6 +15902,12 @@
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
+ "node_modules/mdurl": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
+ "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==",
+ "license": "MIT"
+ },
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -18043,6 +18110,15 @@
"node": ">=10"
}
},
+ "node_modules/react-native-fit-image": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz",
+ "integrity": "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg==",
+ "license": "Beerware",
+ "dependencies": {
+ "prop-types": "^15.5.10"
+ }
+ },
"node_modules/react-native-gesture-handler": {
"version": "2.20.2",
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.20.2.tgz",
@@ -18095,6 +18171,22 @@
"react-native": ">=0.73.0"
}
},
+ "node_modules/react-native-markdown-display": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/react-native-markdown-display/-/react-native-markdown-display-7.0.2.tgz",
+ "integrity": "sha512-Mn4wotMvMfLAwbX/huMLt202W5DsdpMO/kblk+6eUs55S57VVNni1gzZCh5qpznYLjIQELNh50VIozEfY6fvaQ==",
+ "license": "MIT",
+ "dependencies": {
+ "css-to-react-native": "^3.0.0",
+ "markdown-it": "^10.0.0",
+ "prop-types": "^15.7.2",
+ "react-native-fit-image": "^1.5.5"
+ },
+ "peerDependencies": {
+ "react": ">=16.2.0",
+ "react-native": ">=0.50.4"
+ }
+ },
"node_modules/react-native-pager-view": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/react-native-pager-view/-/react-native-pager-view-6.5.1.tgz",
@@ -20253,6 +20345,12 @@
"node": "*"
}
},
+ "node_modules/uc.micro": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
+ "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==",
+ "license": "MIT"
+ },
"node_modules/undici": {
"version": "6.21.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz",
diff --git a/package.json b/package.json
index 8508592..16359e8 100644
--- a/package.json
+++ b/package.json
@@ -64,6 +64,7 @@
"clsx": "^2.1.0",
"date-fns": "^4.1.0",
"expo": "^52.0.35",
+ "expo-av": "~15.0.2",
"expo-crypto": "~14.0.2",
"expo-dev-client": "~5.0.12",
"expo-file-system": "~18.0.10",
@@ -87,6 +88,7 @@
"react-native": "0.76.7",
"react-native-gesture-handler": "~2.20.2",
"react-native-get-random-values": "~1.11.0",
+ "react-native-markdown-display": "^7.0.2",
"react-native-pager-view": "6.5.1",
"react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0",
@@ -97,8 +99,7 @@
"tailwind-merge": "^2.2.1",
"tailwindcss": "3.3.5",
"tailwindcss-animate": "^1.0.7",
- "zustand": "^4.5.6",
- "expo-av": "~15.0.2"
+ "zustand": "^4.5.6"
},
"devDependencies": {
"@babel/core": "^7.26.0",
diff --git a/types/feed.ts b/types/feed.ts
new file mode 100644
index 0000000..f2dac62
--- /dev/null
+++ b/types/feed.ts
@@ -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
+}
\ No newline at end of file
diff --git a/types/nostr-workout.ts b/types/nostr-workout.ts
new file mode 100644
index 0000000..b654110
--- /dev/null
+++ b/types/nostr-workout.ts
@@ -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', '::', '', '', '', '', '']
+ 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', '::', '', '', '', ...]
+ 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';
+}
\ No newline at end of file
diff --git a/utils/feedUtils.ts b/utils/feedUtils.ts
new file mode 100644
index 0000000..12d5707
--- /dev/null
+++ b/utils/feedUtils.ts
@@ -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)
+ };
+ }
\ No newline at end of file