# 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.