41 KiB
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
// 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
// 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
// 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<NDKEvent[]> {
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<NDKEvent> {
const comment = new NDKEvent(this.ndk);
comment.kind = POWR_EVENT_KINDS.COMMENT;
comment.content = content;
// Add tag for the root event
comment.tags.push(['e', parentEvent.id, '', 'root']);
// If this is a reply to another comment, add that reference
if (replyTo) {
comment.tags.push(['e', replyTo.id, '', 'reply']);
}
// Add author reference
comment.tags.push(['p', parentEvent.pubkey]);
await comment.sign();
await comment.publish();
return comment;
}
// Get referenced content for kind:1 posts
async getReferencedContent(event: NDKEvent): Promise<NDKEvent | null> {
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
// 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<NDKEvent> {
// 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<NDKEvent> {
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<NDKEvent> {
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
// 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<NDKEvent[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true);
const [oldestTimestamp, setOldestTimestamp] = useState<number | null>(null);
// Keep track of seen events to prevent duplicates
const seenEvents = useRef(new Set<string>());
const 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
// 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
// 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
// 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 <NostrLoginPrompt message="Log in to see posts from people you follow" />;
}
if (posts.length === 0 && !loading) {
return <EmptyFeed message="You're not following anyone yet. Discover people to follow in the POWR or Global feeds." />;
}
return (
<FlatList
data={posts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <SocialPost post={item} />}
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refresh} />
}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
contentContainerStyle={{ flexGrow: 1 }}
ListEmptyComponent={
loading ? (
<View className="flex-1 items-center justify-center p-8">
<Text>Loading posts...</Text>
</View>
) : null
}
/>
);
}
POWR Tab
// 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 (
<FlatList
data={posts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <SocialPost post={item} />}
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refresh} />
}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListHeaderComponent={
<>
{/* POWR Welcome Section - Maintain existing UI */}
<View className="p-4 border-b border-border bg-primary/5">
<View className="flex-row items-center mb-2">
<Zap size={20} className="mr-2 text-primary" fill="currentColor" />
<Text className="text-lg font-bold">POWR Community</Text>
</View>
<Text className="text-muted-foreground">
Official updates, featured content, and community highlights from the POWR team.
</Text>
</View>
{/* POWR Packs Section - Maintain existing component */}
<POWRPackSection />
</>
}
ListEmptyComponent={
loading ? (
<View className="flex-1 items-center justify-center p-8">
<Text>Loading POWR content...</Text>
</View>
) : (
<View className="flex-1 items-center justify-center p-8">
<Text>No POWR content found</Text>
</View>
)
}
/>
);
}
Global Tab
// 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 (
<FlatList
data={posts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <SocialPost post={item} />}
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refresh} />
}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListEmptyComponent={
loading ? (
<View className="flex-1 items-center justify-center p-8">
<Text>Loading global content...</Text>
</View>
) : (
<View className="flex-1 items-center justify-center p-8">
<Text>No global content found</Text>
</View>
)
}
/>
);
}
Enhanced SocialPost Component
The existing SocialPost component needs updates to handle Nostr-based content:
// 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 (
<View className="p-4 border-b border-border">
{/* Author info */}
<View className="flex-row items-center mb-3">
<Avatar
className="h-10 w-10 mr-3"
source={{ uri: post.author.avatar }}
/>
<View className="flex-1">
<View className="flex-row items-center">
<Text className="font-semibold">{post.author.name}</Text>
{post.author.verified && (
<View className="ml-1 bg-primary rounded-full h-4 w-4 items-center justify-center">
<Text className="text-white text-xs">✓</Text>
</View>
)}
</View>
<Text className="text-muted-foreground text-xs">
{formatTimestamp(post.createdAt)}
</Text>
</View>
</View>
{/* Post content */}
{post.content && (
<Text className="mb-3">{post.content}</Text>
)}
{/* Workout/Exercise/Template content */}
{post.workout && (
<WorkoutContent
workout={post.workout}
className="mb-3 bg-muted/30 p-3 rounded-lg"
/>
)}
{post.workoutTemplate && (
<TemplateContent
template={post.workoutTemplate}
className="mb-3 bg-muted/30 p-3 rounded-lg"
/>
)}
{post.exerciseTemplate && (
<ExerciseContent
exercise={post.exerciseTemplate}
className="mb-3 bg-muted/30 p-3 rounded-lg"
/>
)}
{/* Referenced content (for kind:1 posts) */}
{referencedContent && (
<View className="mb-3 bg-muted/30 p-3 rounded-lg">
{/* Render based on content type */}
{referencedContent.type === 'workout' && (
<WorkoutContent workout={referencedContent.data} />
)}
{referencedContent.type === 'template' && (
<TemplateContent template={referencedContent.data} />
)}
{referencedContent.type === 'exercise' && (
<ExerciseContent exercise={referencedContent.data} />
)}
</View>
)}
{/* Interaction buttons */}
<View className="flex-row items-center justify-between mt-2">
<Button
variant="ghost"
size="sm"
className="flex-row items-center"
onPress={handleLike}
>
<Heart
size={18}
className={isLiked ? "text-destructive" : "text-muted-foreground"}
fill={isLiked ? "currentColor" : "none"}
/>
<Text className="ml-1">{likes}</Text>
</Button>
<Button
variant="ghost"
size="sm"
className="flex-row items-center"
onPress={handleComment}
>
<MessageCircle
size={18}
className="text-muted-foreground"
/>
<Text className="ml-1">{comments}</Text>
</Button>
<Button
variant="ghost"
size="sm"
className="flex-row items-center"
>
<Repeat
size={18}
className="text-muted-foreground"
/>
</Button>
<Button
variant="ghost"
size="sm"
className="flex-row items-center"
>
<Share
size={18}
className="text-muted-foreground"
/>
</Button>
</View>
{/* Comments section */}
{showComments && (
<CommentSection
eventId={post.eventId}
onNewComment={() => setComments(prev => prev + 1)}
/>
)}
</View>
);
}
Comments System
// 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 (
<View className="mt-3 p-3">
<Text>Loading comments...</Text>
</View>
);
}
return (
<View className="mt-3">
{/* Comment list */}
<View className="mb-3">
{comments.length === 0 ? (
<Text className="text-muted-foreground p-3">No comments yet. Be the first!</Text>
) : (
comments.map(comment => (
<CommentItem
key={comment.id}
comment={comment}
eventId={eventId}
onNewReply={onNewComment}
/>
))
)}
</View>
{/* Comment input */}
<View className="flex-row border-t border-border pt-3">
<TextInput
value={commentText}
onChangeText={setCommentText}
placeholder="Add a comment..."
className="flex-1 mr-2"
/>
<Button
disabled={!commentText.trim() || submitting}
onPress={handleSubmitComment}
>
{submitting ? 'Posting...' : 'Post'}
</Button>
</View>
</View>
);
}
// 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 (
<View
className="py-2"
style={{ marginLeft: depth * 16 }}
>
{/* Comment header */}
<View className="flex-row items-center mb-1">
<Avatar
className="h-6 w-6 mr-2"
source={{ uri: comment.author.avatar }}
/>
<Text className="font-medium mr-2">{comment.author.name}</Text>
<Text className="text-xs text-muted-foreground">
{formatTimestamp(comment.createdAt)}
</Text>
</View>
{/* Comment content */}
<Text className="mb-1">{comment.content}</Text>
{/* Reply button */}
<Pressable
onPress={() => setShowReplyInput(!showReplyInput)}
className="mb-2"
>
<Text className="text-xs text-primary">Reply</Text>
</Pressable>
{/* Reply input */}
{showReplyInput && (
<View className="flex-row mb-3">
<TextInput
value={replyText}
onChangeText={setReplyText}
placeholder="Write a reply..."
className="flex-1 mr-2"
/>
<Button
size="sm"
disabled={!replyText.trim() || submitting}
onPress={handleSubmitReply}
>
{submitting ? '...' : 'Reply'}
</Button>
</View>
)}
{/* Replies */}
{comment.replies?.map(reply => (
<CommentItem
key={reply.id}
comment={reply}
eventId={eventId}
depth={depth + 1}
onNewReply={onNewReply}
/>
))}
</View>
);
}
Content Sharing Integration
// 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 (
<View className="bg-card rounded-lg p-4">
<Text className="text-lg font-semibold mb-2">Share Your Workout</Text>
{/* Preview */}
<View className="mb-4 bg-muted/20 p-3 rounded-lg">
<WorkoutContent workout={workout} />
</View>
{/* Text input */}
<TextInput
value={socialText}
onChangeText={setSocialText}
placeholder="Add a message with your workout..."
multiline
className="mb-4 min-h-[100px]"
/>
{/* Share button */}
<Button
disabled={sharing}
onPress={handleShare}
className="w-full"
>
{sharing ? 'Sharing...' : 'Share to Nostr'}
</Button>
</View>
);
}
Implementation Timeline
-
Week 1: Infrastructure Setup
- Configure NDK Mobile with SQLite adapter
- Implement core services (SocialFeedService, ContentPublisher)
- Set up data model and event type definitions
-
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
-
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
-
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
-
Authentication Integration
- The social feed should work seamlessly with existing authentication
- Show appropriate prompts for unauthenticated users
- Handle authentication state changes gracefully
-
Performance Optimization
- Use FlatList instead of ScrollView for better performance
- Implement proper pagination with infinite scroll
- Optimize data fetching to reduce unnecessary requests
-
Offline Support
- Leverage SQLite adapter for caching events
- Implement offline detection and appropriate UI feedback
- Queue interactions (likes, comments) when offline for later submission
-
Error Handling
- Gracefully handle network errors
- Provide clear feedback on publishing failures
- Implement retry mechanisms for failed operations
-
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.