mirror of
https://github.com/DocNR/POWR.git
synced 2025-05-13 01:35:53 +00:00
565 lines
17 KiB
Markdown
565 lines
17 KiB
Markdown
# Social Feed Implementation Details
|
||
|
||
**Last Updated:** 2025-03-26
|
||
**Status:** Active
|
||
**Related To:** Social Features, NDK Integration, Feed Implementation
|
||
|
||
## Purpose
|
||
|
||
This document provides detailed technical implementation specifications for the social feed in the POWR app, including data flow architecture, event structures, component implementations, and integration points with the existing codebase.
|
||
|
||
## Technical Considerations
|
||
|
||
### Data Flow
|
||
|
||
The data flow for the social feed will leverage your existing NDK integration:
|
||
|
||
1. **Subscription Management**
|
||
- Use your existing `useSubscribe` hook with appropriate filters for different feed types
|
||
- Implement efficient subscription handling to minimize relay connections
|
||
- Use NDK's subscription grouping for better relay performance
|
||
|
||
2. **Event Processing**
|
||
- Parse Nostr events into app-specific data structures using the parser functions
|
||
- Integrate with your existing workout data models
|
||
- Handle event validation and error cases
|
||
|
||
3. **UI Rendering**
|
||
- Use React Native's `FlatList` for efficient rendering of feed items
|
||
- Implement proper list virtualization for performance
|
||
- Use memoization to prevent unnecessary re-renders
|
||
|
||
### NDK Outbox Model
|
||
|
||
Your `initNDK.ts` already configures the outbox model. This is important for optimizing event delivery:
|
||
|
||
1. **Relay Management**
|
||
- The outbox model helps route events to the most appropriate relays
|
||
- Configure relay preferences based on event types and content
|
||
- Optimize relay selection for event delivery
|
||
|
||
2. **Event Publishing**
|
||
- Use the outbox model to ensure events reach appropriate relays
|
||
- Configure fallback relays for critical events
|
||
- Monitor delivery status using NDK's built-in mechanisms
|
||
|
||
### Offline Support
|
||
|
||
For true offline support:
|
||
|
||
1. **Local Event Storage**
|
||
- Implement a pending events table in your SQLite database
|
||
- Store unsent events when offline
|
||
- Provide retry logic for failed publications
|
||
|
||
2. **UI Indicators**
|
||
- Show visual indicators for pending publications
|
||
- Implement status tracking for shared content
|
||
- Allow users to manually retry failed shares
|
||
|
||
### Performance Optimizations
|
||
|
||
1. **Feed Rendering**
|
||
- Use windowed lists for performance
|
||
- Implement paged loading for long feeds
|
||
- Cache rendered components to minimize re-renders
|
||
|
||
2. **Profile Caching**
|
||
- Cache user profiles to reduce relay requests
|
||
- Implement TTL-based caching for profile data
|
||
- Prefetch profiles for visible feed items
|
||
|
||
3. **Media Handling**
|
||
- Implement lazy loading for images
|
||
- Use proper caching for media content
|
||
- Consider progressive loading for large media
|
||
|
||
## Event Type Structures
|
||
|
||
### Workout Record (kind: 1301)
|
||
```json
|
||
{
|
||
"kind": 1301,
|
||
"content": "<workout notes>",
|
||
"tags": [
|
||
["d", "<UUID>"],
|
||
["title", "<workout name>"],
|
||
["type", "<workout type>"],
|
||
["rounds_completed", "<number of rounds completed>"],
|
||
["start", "<Unix timestamp in seconds>"],
|
||
["end", "<Unix timestamp in seconds>"],
|
||
["exercise", "<kind>:<pubkey>:<d-tag>", "<relay-url>", "<weight>", "<reps>", "<rpe>", "<set_type>"],
|
||
["exercise", "<kind>:<pubkey>:<d-tag>", "<relay-url>", "<weight>", "<reps>", "<rpe>", "<set_type>"],
|
||
["template", "<kind>:<pubkey>:<d-tag>", "<relay-url>"],
|
||
["pr", "<kind>:<pubkey>:<d-tag>,<metric>,<value>"],
|
||
["completed", "<true/false>"],
|
||
["t", "<hashtag>"],
|
||
["t", "<hashtag>"]
|
||
]
|
||
}
|
||
```
|
||
|
||
### Exercise Template (kind: 33401)
|
||
```json
|
||
{
|
||
"kind": 33401,
|
||
"content": "<detailed form instructions and notes>",
|
||
"tags": [
|
||
["d", "<UUID>"],
|
||
["title", "<exercise name>"],
|
||
["format", "<parameter>", "<parameter>", "<parameter>", "<parameter>"],
|
||
["format_units", "<unit>", "<unit>", "<unit>", "<unit>"],
|
||
["equipment", "<equipment type>"],
|
||
["difficulty", "<skill level>"],
|
||
["imeta",
|
||
"url <url to demonstration media>",
|
||
"m <media type>",
|
||
"dim <dimensions>",
|
||
"alt <alt text>"
|
||
],
|
||
["t", "<hashtag>"],
|
||
["t", "<hashtag>"]
|
||
]
|
||
}
|
||
```
|
||
|
||
### Workout Template (kind: 33402)
|
||
```json
|
||
{
|
||
"kind": 33402,
|
||
"content": "<workout notes and instructions>",
|
||
"tags": [
|
||
["d", "<UUID>"],
|
||
["title", "<workout name>"],
|
||
["type", "<workout type>"],
|
||
["rounds", "<number of rounds>"],
|
||
["duration", "<duration in seconds>"],
|
||
["interval", "<interval in seconds>"],
|
||
["rest_between_rounds", "<rest time in seconds>"],
|
||
["exercise", "<kind>:<pubkey>:<d-tag>", "<relay-url>", "<param1>", "<param2>", "<param3>", "<param4>"],
|
||
["exercise", "<kind>:<pubkey>:<d-tag>", "<relay-url>", "<param1>", "<param2>", "<param3>", "<param4>"],
|
||
["t", "<hashtag>"],
|
||
["t", "<hashtag>"]
|
||
]
|
||
}
|
||
```
|
||
|
||
## Integration with Existing Types
|
||
|
||
To ensure compatibility with your existing codebase, we'll need to extend your current types:
|
||
|
||
```typescript
|
||
// Add to types/nostr.ts
|
||
export interface NostrWorkoutRecord extends NostrEvent {
|
||
kind: NostrEventKind.WORKOUT;
|
||
tags: string[][];
|
||
}
|
||
|
||
export interface NostrExerciseTemplate extends NostrEvent {
|
||
kind: NostrEventKind.EXERCISE;
|
||
tags: string[][];
|
||
}
|
||
|
||
export interface NostrWorkoutTemplate extends NostrEvent {
|
||
kind: NostrEventKind.TEMPLATE;
|
||
tags: string[][];
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// Interface for parsed exercise
|
||
export interface ParsedExercise {
|
||
id: string;
|
||
name: string;
|
||
weight?: number;
|
||
reps?: number;
|
||
rpe?: number;
|
||
setType: string;
|
||
}
|
||
```
|
||
|
||
## Code Examples
|
||
|
||
### Enhanced NDK Integration
|
||
|
||
```typescript
|
||
// Enhancement to useSubscribe.ts to support workout-specific events
|
||
export function useWorkoutFeed(
|
||
feedType: 'following' | 'powr' | 'global'
|
||
) {
|
||
const { ndk } = useNDK();
|
||
const { currentUser } = useNDKCurrentUser();
|
||
const [feedItems, setFeedItems] = useState<NDKEvent[]>([]);
|
||
|
||
// Define filters based on your existing pattern but with workout event kinds
|
||
const getFilters = useCallback(() => {
|
||
const baseFilters = [{
|
||
kinds: [1, 1301, 33401, 33402], // Notes, Workouts, Exercise Templates, Workout Templates
|
||
limit: 20
|
||
}];
|
||
|
||
// Customize based on feed type
|
||
switch (feedType) {
|
||
case 'following':
|
||
// Use your existing pattern for following users
|
||
return baseFilters.map(filter => ({
|
||
...filter,
|
||
// Add authors filter based on who the user follows
|
||
'#p': currentUser?.follows || []
|
||
}));
|
||
case 'powr':
|
||
return baseFilters.map(filter => ({
|
||
...filter,
|
||
authors: ['npub1p0wer69rpkraqs02l5v8rutagfh6g9wxn2dgytkv44ysz7avt8nsusvpjk']
|
||
}));
|
||
default:
|
||
return baseFilters;
|
||
}
|
||
}, [feedType, currentUser]);
|
||
|
||
// Use your existing useSubscribe hook
|
||
const { events, isLoading, eose, resubscribe } = useSubscribe(
|
||
getFilters(),
|
||
{ enabled: !!ndk }
|
||
);
|
||
|
||
// Process events into feed items
|
||
useEffect(() => {
|
||
if (events.length) {
|
||
// Convert NDK events to feed items with proper parsing
|
||
const processedItems = events.map(event => {
|
||
switch (event.kind) {
|
||
case 1301: // Workout records
|
||
return processWorkoutEvent(event);
|
||
case 33401: // Exercise templates
|
||
return processExerciseTemplateEvent(event);
|
||
case 33402: // Workout templates
|
||
return processWorkoutTemplateEvent(event);
|
||
default: // Standard posts
|
||
return processStandardPost(event);
|
||
}
|
||
});
|
||
|
||
setFeedItems(processedItems);
|
||
}
|
||
}, [events]);
|
||
|
||
return {
|
||
feedItems,
|
||
isLoading,
|
||
refreshFeed: resubscribe
|
||
};
|
||
}
|
||
```
|
||
|
||
### Event Type Parsers
|
||
|
||
```typescript
|
||
// Add to utils/nostr-utils.ts
|
||
export function processWorkoutEvent(event: NDKEvent): WorkoutFeedItem {
|
||
const title = findTagValue(event.tags, 'title') || 'Workout';
|
||
const type = findTagValue(event.tags, 'type') || 'strength';
|
||
const startTime = parseInt(findTagValue(event.tags, 'start') || '0');
|
||
const endTime = parseInt(findTagValue(event.tags, 'end') || '0');
|
||
|
||
// Extract exercises from tags
|
||
const exercises = event.tags
|
||
.filter(tag => tag[0] === 'exercise')
|
||
.map(tag => parseExerciseTag(tag));
|
||
|
||
// Map to your existing feed item structure
|
||
return {
|
||
id: event.id,
|
||
type: 'workout',
|
||
author: event.pubkey,
|
||
createdAt: event.created_at,
|
||
content: event.content,
|
||
title,
|
||
workoutType: type,
|
||
duration: endTime - startTime,
|
||
exercises,
|
||
// Map other properties as needed
|
||
};
|
||
}
|
||
|
||
export function parseExerciseTag(tag: string[]): ExerciseData {
|
||
// Format: ['exercise', '<kind>:<pubkey>:<d-tag>', '<relay-url>', '<weight>', '<reps>', '<rpe>', '<set_type>']
|
||
if (tag.length < 7) {
|
||
// Handle incomplete tags
|
||
return {
|
||
id: tag[1] || '',
|
||
name: 'Unknown Exercise',
|
||
weight: null,
|
||
reps: null,
|
||
rpe: null,
|
||
setType: 'normal'
|
||
};
|
||
}
|
||
|
||
// Extract exercise ID parts (kind:pubkey:d-tag)
|
||
const idParts = tag[1].split(':');
|
||
const exerciseId = idParts.length > 2 ? idParts[2] : tag[1];
|
||
|
||
return {
|
||
id: exerciseId,
|
||
name: exerciseId, // Placeholder - should be resolved from your exercise database
|
||
weight: tag[3] ? parseFloat(tag[3]) : null,
|
||
reps: tag[4] ? parseInt(tag[4]) : null,
|
||
rpe: tag[5] ? parseFloat(tag[5]) : null,
|
||
setType: tag[6] || 'normal'
|
||
};
|
||
}
|
||
|
||
export function processExerciseTemplateEvent(event: NDKEvent): ExerciseFeedItem {
|
||
const title = findTagValue(event.tags, 'title') || 'Exercise';
|
||
const equipment = findTagValue(event.tags, 'equipment');
|
||
const difficulty = findTagValue(event.tags, 'difficulty');
|
||
|
||
// Parse format data
|
||
const formatTag = event.tags.find(tag => tag[0] === 'format');
|
||
const formatUnitsTag = event.tags.find(tag => tag[0] === 'format_units');
|
||
|
||
// Get tags for categorization
|
||
const categories = event.tags
|
||
.filter(tag => tag[0] === 't')
|
||
.map(tag => tag[1]);
|
||
|
||
// Extract media if present
|
||
const mediaTag = event.tags.find(tag => tag[0] === 'imeta');
|
||
let media = null;
|
||
|
||
if (mediaTag && mediaTag.length > 1) {
|
||
const urlPart = mediaTag[1].split(' ');
|
||
if (urlPart.length > 1) {
|
||
media = {
|
||
url: urlPart[1],
|
||
mimeType: mediaTag[2]?.split(' ')[1] || '',
|
||
altText: mediaTag[4]?.substring(4) || ''
|
||
};
|
||
}
|
||
}
|
||
|
||
return {
|
||
id: event.id,
|
||
type: 'exerciseTemplate',
|
||
author: event.pubkey,
|
||
createdAt: event.created_at,
|
||
content: event.content,
|
||
title,
|
||
equipment,
|
||
difficulty,
|
||
categories,
|
||
media,
|
||
// Map other properties as needed
|
||
};
|
||
}
|
||
```
|
||
|
||
### Enhanced Social Post Component
|
||
|
||
```tsx
|
||
// Enhancement to components/social/SocialPost.tsx
|
||
interface WorkoutPostProps {
|
||
event: NDKEvent;
|
||
parsed: WorkoutFeedItem;
|
||
}
|
||
|
||
function WorkoutPost({ event, parsed }: WorkoutPostProps) {
|
||
return (
|
||
<Card className="mb-4">
|
||
<CardHeader className="pb-2">
|
||
<UserAvatar pubkey={event.pubkey} />
|
||
<View>
|
||
<Text className="font-semibold">{parsed.displayName || 'Athlete'}</Text>
|
||
<Text className="text-xs text-muted-foreground">
|
||
completed a {parsed.workoutType} workout • {timeAgo(parsed.createdAt)}
|
||
</Text>
|
||
</View>
|
||
</CardHeader>
|
||
|
||
<CardContent className="py-2">
|
||
<Text className="font-medium text-lg mb-1">{parsed.title}</Text>
|
||
|
||
{parsed.exercises.length > 0 && (
|
||
<View className="mt-2">
|
||
<Text className="font-medium mb-1">Exercises:</Text>
|
||
{parsed.exercises.slice(0, 3).map((exercise, index) => (
|
||
<Text key={index} className="text-sm">
|
||
• {exercise.name} {exercise.weight ? `${exercise.weight}kg` : ''}
|
||
{exercise.reps ? ` × ${exercise.reps}` : ''}
|
||
</Text>
|
||
))}
|
||
{parsed.exercises.length > 3 && (
|
||
<Text className="text-sm text-muted-foreground">
|
||
+{parsed.exercises.length - 3} more exercises
|
||
</Text>
|
||
)}
|
||
</View>
|
||
)}
|
||
|
||
{parsed.content && (
|
||
<Text className="mt-2">{parsed.content}</Text>
|
||
)}
|
||
</CardContent>
|
||
|
||
<CardFooter className="pt-1">
|
||
<InteractionButtons event={event} />
|
||
</CardFooter>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
// Enhanced SocialPost component
|
||
export default function SocialPost({ event }: { event: NDKEvent }) {
|
||
// Parse event based on kind
|
||
const parsed = useMemo(() => {
|
||
switch (event.kind) {
|
||
case 1301:
|
||
return processWorkoutEvent(event);
|
||
case 33401:
|
||
return processExerciseTemplateEvent(event);
|
||
case 33402:
|
||
return processWorkoutTemplateEvent(event);
|
||
default:
|
||
return processStandardPost(event);
|
||
}
|
||
}, [event]);
|
||
|
||
// Render different components based on event kind
|
||
switch (event.kind) {
|
||
case 1301:
|
||
return <WorkoutPost event={event} parsed={parsed} />;
|
||
case 33401:
|
||
return <ExerciseTemplatePost event={event} parsed={parsed} />;
|
||
case 33402:
|
||
return <WorkoutTemplatePost event={event} parsed={parsed} />;
|
||
default:
|
||
return <StandardPost event={event} parsed={parsed} />;
|
||
}
|
||
}
|
||
```
|
||
|
||
### Workout Sharing Integration
|
||
|
||
```tsx
|
||
// Enhancement to components/workout/WorkoutCompletionFlow.tsx
|
||
// Add to your existing implementation
|
||
const handleShareWorkout = async () => {
|
||
if (!ndk || !currentUser || !workout) return;
|
||
|
||
// Create a workout record event
|
||
const workoutEvent = new NDKEvent(ndk);
|
||
workoutEvent.kind = 1301; // Workout Record
|
||
workoutEvent.content = notes || '';
|
||
|
||
// Add tags based on completed workout
|
||
const tags = [
|
||
['d', workout.id], // Use your UUID
|
||
['title', workout.title],
|
||
['type', workout.type],
|
||
['start', workout.startTime.toString()],
|
||
['end', workout.endTime.toString()],
|
||
['completed', 'true']
|
||
];
|
||
|
||
// Add exercise tags
|
||
workout.exercises.forEach(exercise => {
|
||
// Format: exercise, reference, relay, weight, reps, rpe, set_type
|
||
tags.push([
|
||
'exercise',
|
||
`33401:${exercise.id}`,
|
||
'',
|
||
exercise.weight?.toString() || '',
|
||
exercise.reps?.toString() || '',
|
||
exercise.rpe?.toString() || '',
|
||
'normal'
|
||
]);
|
||
});
|
||
|
||
// Add template reference if used
|
||
if (workout.templateId) {
|
||
tags.push(['template', `33402:${workout.templateId}`, '']);
|
||
}
|
||
|
||
// Add hashtags
|
||
tags.push(['t', 'workout']);
|
||
tags.push(['t', 'powrapp']);
|
||
|
||
workoutEvent.tags = tags;
|
||
|
||
try {
|
||
await workoutEvent.publish();
|
||
// Show success message
|
||
showToast('Workout shared successfully!');
|
||
} catch (error) {
|
||
console.error('Error sharing workout:', error);
|
||
showToast('Failed to share workout');
|
||
}
|
||
};
|
||
```
|
||
|
||
## Security Considerations
|
||
|
||
1. **Data Privacy**
|
||
- Allow users to control which workouts are shared
|
||
- Provide clear privacy indicators
|
||
- Support event deletion (NIP-09)
|
||
|
||
2. **Content Moderation**
|
||
- Implement user blocking
|
||
- Add reporting mechanisms
|
||
- Support muting functionality
|
||
|
||
3. **User Safety**
|
||
- Protect sensitive health data
|
||
- Allow selective sharing
|
||
- Provide education on privacy implications
|
||
|
||
## Implementation Roadmap
|
||
|
||
### Phase 1: Enhanced NDK Integration (2-3 weeks)
|
||
- Complete NDK integration with proper caching
|
||
- Implement event parsers for workout-specific event kinds
|
||
- Enhance existing subscription mechanisms
|
||
|
||
### Phase 2: Core Social Feed Components (3-4 weeks)
|
||
- Enhance SocialPost component for different event types
|
||
- Implement detailed WorkoutPost, TemplatePost, and ExercisePost components
|
||
- Add interaction components (likes, comments, reposts)
|
||
|
||
### Phase 3: Workout Sharing Functionality (2-3 weeks)
|
||
- Integrate with existing workout completion flow
|
||
- Implement bidirectional sync between local and Nostr data
|
||
- Add ability to share templates and exercises
|
||
|
||
### Phase 4: Detail Screens & Advanced Features (3-4 weeks)
|
||
- Create detail screens for different content types
|
||
- Implement comments and replies functionality
|
||
- Add profile enhancements to show user's content
|
||
|
||
### Phase 5: Polish & Optimization (2 weeks)
|
||
- Optimize performance for large feeds
|
||
- Enhance offline support
|
||
- Add media handling for workout photos
|
||
- Implement pull-to-refresh and infinite scrolling
|
||
|
||
## Related Documentation
|
||
|
||
- [Social Architecture](./architecture.md) - Overall social features architecture
|
||
- [Feed Filtering](./feed_filtering.md) - Rules for filtering content in social feeds
|
||
- [Cache Implementation](./cache_implementation.md) - Details on the social feed caching system
|
||
- [Implementation Plan](./implementation_plan.md) - High-level implementation plan
|
||
- [NDK Comprehensive Guide](../../technical/ndk/comprehensive_guide.md) - Reference for NDK implementation
|
||
- [Nostr Exercise NIP](../../technical/nostr/exercise_nip.md) - Technical specification for workout event format
|