1
0
mirror of https://github.com/DocNR/POWR.git synced 2025-05-24 02:42:07 +00:00
POWR/components/social/EnhancedSocialPost.tsx

538 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 { Badge } from '@/components/ui/badge';
import { Heart, MessageCircle, Repeat, Share, Clock, Dumbbell, CheckCircle, FileText, User } from 'lucide-react-native';
import UserAvatar from '@/components/UserAvatar';
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 { 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);
}
};
// Render based on feed item type
const renderContent = () => {
switch (item.type) {
case 'workout':
return <WorkoutContent workout={item.parsedContent as ParsedWorkoutRecord} />;
case 'exercise':
return <ExerciseContent exercise={item.parsedContent as ParsedExerciseTemplate} />;
case 'template':
return <TemplateContent template={item.parsedContent as ParsedWorkoutTemplate} />;
case 'social':
return <SocialContent post={item.parsedContent as ParsedSocialPost} />;
case 'article':
// Only show ArticleContent for published articles (kind 30023)
// Never show draft articles (kind 30024)
if (item.originalEvent.kind === 30023) {
return <ArticleContent article={item.parsedContent as ParsedLongformContent} />;
} else {
// For any other kinds, render as a social post
// Create a proper ParsedSocialPost object with all required fields
return <SocialContent post={{
id: item.id,
content: (item.parsedContent as ParsedLongformContent).title ||
(item.parsedContent as ParsedLongformContent).content ||
'Post content',
author: item.originalEvent.pubkey || '',
tags: [],
createdAt: item.createdAt,
quotedContent: undefined
}} />;
}
default:
return null;
}
};
// Memoize the author name to prevent unnecessary re-renders
const authorName = useMemo(() => {
return profile?.name || 'Nostr User';
}, [profile?.name]);
return (
<TouchableOpacity activeOpacity={0.7} onPress={onPress}>
<View className="py-3 px-4">
<View className="flex-row">
<UserAvatar
uri={profile?.image}
size="md"
fallback={profile?.name?.[0] || 'U'}
className="mr-3"
/>
<View className="flex-1">
<View className="flex-row items-center">
<Text className="font-semibold">{authorName}</Text>
{profile?.nip05 && (
<CheckCircle size={14} className="text-primary ml-1" />
)}
</View>
<Text className="text-xs text-muted-foreground">
{formatTimestamp(item.createdAt)}
</Text>
</View>
</View>
<View className="mt-3">
{renderContent()}
</View>
{/* Reduced space between content and action buttons */}
<View className="flex-row justify-between items-center mt-2">
<TouchableOpacity
className="flex-row items-center"
activeOpacity={0.7}
onPress={handleLike}
disabled={liked}
>
<Heart
size={18}
className={liked ? "text-red-500" : "text-muted-foreground"}
fill={liked ? "#ef4444" : "none"}
/>
{likeCount > 0 && (
<Text className="ml-1 text-xs">
{likeCount}
</Text>
)}
</TouchableOpacity>
<TouchableOpacity className="flex-row items-center" activeOpacity={0.7}>
<MessageCircle size={18} className="text-muted-foreground" />
</TouchableOpacity>
<TouchableOpacity className="flex-row items-center" activeOpacity={0.7}>
<Repeat size={18} className="text-muted-foreground" />
</TouchableOpacity>
<TouchableOpacity className="flex-row items-center" activeOpacity={0.7}>
<Share size={18} className="text-muted-foreground" />
</TouchableOpacity>
</View>
</View>
{/* Hairline divider */}
<View className="h-px bg-border w-full" />
</TouchableOpacity>
);
}
// Component for workout records
function WorkoutContent({ workout }: { workout: ParsedWorkoutRecord }) {
return (
<View>
<Text className="text-lg font-semibold mb-2">{workout.title}</Text>
{workout.notes && (
<Text className="mb-2">{workout.notes}</Text>
)}
<View className="bg-muted/30 p-3 rounded-lg mb-0">
<View className="flex-row items-center mb-2">
<Dumbbell size={16} className="text-primary mr-2" />
<Text className="font-medium">{workout.type} workout</Text>
{workout.startTime && workout.endTime && (
<View className="flex-row items-center ml-auto">
<Clock size={14} className="text-muted-foreground mr-1" />
<Text className="text-sm text-muted-foreground">
{formatDuration(workout.endTime - workout.startTime)}
</Text>
</View>
)}
</View>
{workout.exercises.length > 0 && (
<View>
<Text className="font-medium mb-1">Exercises:</Text>
{workout.exercises.slice(0, 3).map((exercise, index) => (
<Text key={index} className="text-sm">
{exercise.name}
{exercise.weight ? ` - ${exercise.weight}kg` : ''}
{exercise.reps ? ` × ${exercise.reps}` : ''}
{exercise.rpe ? ` @ RPE ${exercise.rpe}` : ''}
</Text>
))}
{workout.exercises.length > 3 && (
<Text className="text-xs text-muted-foreground mt-1">
+{workout.exercises.length - 3} more exercises
</Text>
)}
</View>
)}
</View>
</View>
);
}
// Component for exercise templates
function ExerciseContent({ exercise }: { exercise: ParsedExerciseTemplate }) {
return (
<View>
<Text className="text-lg font-semibold mb-2">{exercise.title}</Text>
{exercise.description && (
<Text className="mb-2">{exercise.description}</Text>
)}
<View className="bg-muted/30 p-3 rounded-lg mb-0">
<View className="flex-row flex-wrap mb-2">
{exercise.equipment && (
<Badge variant="outline" className="mr-2 mb-1">
<Text>{exercise.equipment}</Text>
</Badge>
)}
{exercise.difficulty && (
<Badge variant="outline" className="mr-2 mb-1">
<Text>{exercise.difficulty}</Text>
</Badge>
)}
</View>
{exercise.format.length > 0 && (
<View className="mb-2">
<Text className="font-medium mb-1">Tracks:</Text>
<View className="flex-row flex-wrap">
{exercise.format.map((format, index) => (
<Badge key={index} className="mr-2 mb-1">
<Text>{format}</Text>
</Badge>
))}
</View>
</View>
)}
{exercise.tags.length > 0 && (
<View className="flex-row flex-wrap">
{exercise.tags.map((tag, index) => (
<Text key={index} className="text-xs text-primary mr-2">
#{tag}
</Text>
))}
</View>
)}
</View>
</View>
);
}
// Component for workout templates
function TemplateContent({ template }: { template: ParsedWorkoutTemplate }) {
return (
<View>
<Text className="text-lg font-semibold mb-2">{template.title}</Text>
{template.description && (
<Text className="mb-2">{template.description}</Text>
)}
<View className="bg-muted/30 p-3 rounded-lg mb-0">
<View className="flex-row items-center mb-2">
<Dumbbell size={16} className="text-primary mr-2" />
<Text className="font-medium">{template.type} template</Text>
{template.duration && (
<View className="flex-row items-center ml-auto">
<Clock size={14} className="text-muted-foreground mr-1" />
<Text className="text-sm text-muted-foreground">
{formatMinutes(template.duration / 60)} {/* Convert seconds to minutes */}
</Text>
</View>
)}
</View>
{template.rounds && (
<Text className="text-sm mb-1">
{template.rounds} {template.rounds === 1 ? 'round' : 'rounds'}
</Text>
)}
{template.exercises.length > 0 && (
<View>
<Text className="font-medium mb-1">Exercises:</Text>
{template.exercises.slice(0, 3).map((exercise, index) => (
<Text key={index} className="text-sm">
{exercise.name || 'Exercise ' + (index + 1)}
</Text>
))}
{template.exercises.length > 3 && (
<Text className="text-xs text-muted-foreground mt-1">
+{template.exercises.length - 3} more exercises
</Text>
)}
</View>
)}
{template.tags.length > 0 && (
<View className="flex-row flex-wrap mt-2">
{template.tags.map((tag, index) => (
<Text key={index} className="text-xs text-primary mr-2">
#{tag}
</Text>
))}
</View>
)}
</View>
</View>
);
}
// Component for social posts
function SocialContent({ post }: { post: ParsedSocialPost }) {
// Render the social post content
const renderMainContent = () => (
<Text className="mb-2">{post.content}</Text>
);
// Render quoted content if available
const renderQuotedContent = () => {
if (!post.quotedContent || !post.quotedContent.resolved) return null;
const { type, resolved } = post.quotedContent;
return (
<View className="bg-muted/30 p-3 rounded-lg">
<Text className="text-sm font-medium mb-1">
{type === 'workout' ? 'Workout' :
type === 'exercise' ? 'Exercise' :
type === 'template' ? 'Workout Template' :
type === 'article' ? 'Article' : 'Post'}:
</Text>
{type === 'workout' && <WorkoutQuote workout={resolved as ParsedWorkoutRecord} />}
{type === 'exercise' && <ExerciseQuote exercise={resolved as ParsedExerciseTemplate} />}
{type === 'template' && <TemplateQuote template={resolved as ParsedWorkoutTemplate} />}
{type === 'article' && <ArticleQuote article={resolved as ParsedLongformContent} />}
</View>
);
};
return (
<View>
{renderMainContent()}
{renderQuotedContent()}
</View>
);
}
// Component for long-form content
function ArticleContent({ article }: { article: ParsedLongformContent }) {
// Limit content preview to a reasonable length
const previewLength = 200;
const hasFullContent = article.content && article.content.length > previewLength;
return (
<View>
<View className="flex-row items-center mb-2">
<FileText size={16} className="text-primary mr-2" />
<Text className="font-medium">Article</Text>
</View>
<Text className="text-lg font-semibold mb-2">{article.title}</Text>
{article.image && (
<Image
source={{ uri: article.image }}
className="w-full h-48 rounded-md mb-2"
resizeMode="cover"
/>
)}
{article.summary ? (
<Text className="mb-0">{article.summary}</Text>
) : (
<ScrollView style={{ maxHeight: 200 }}>
<Markdown>
{hasFullContent ? article.content.substring(0, previewLength) + '...' : article.content}
</Markdown>
</ScrollView>
)}
{article.publishedAt && (
<Text className="text-xs text-muted-foreground mt-1">
Published: {formatTimestamp(article.publishedAt)}
</Text>
)}
{article.tags.length > 0 && (
<View className="flex-row flex-wrap mt-1">
{article.tags.map((tag, index) => (
<Text key={index} className="text-xs text-primary mr-2">
#{tag}
</Text>
))}
</View>
)}
</View>
);
}
// Add ArticleQuote component for quoted articles
function ArticleQuote({ article }: { article: ParsedLongformContent }) {
return (
<View>
<Text className="font-medium">{article.title}</Text>
{article.summary ? (
<Text className="text-sm text-muted-foreground">{article.summary}</Text>
) : (
<Text className="text-sm text-muted-foreground">
{article.content ? article.content.substring(0, 100) + '...' : 'No content'}
</Text>
)}
</View>
);
}
// Simplified versions of content for quoted posts
function WorkoutQuote({ workout }: { workout: ParsedWorkoutRecord }) {
return (
<View>
<Text className="font-medium">{workout.title}</Text>
<Text className="text-sm text-muted-foreground">
{workout.exercises.length} exercises {
workout.startTime && workout.endTime ?
formatDuration(workout.endTime - workout.startTime) :
'Duration N/A'
}
</Text>
</View>
);
}
function ExerciseQuote({ exercise }: { exercise: ParsedExerciseTemplate }) {
return (
<View>
<Text className="font-medium">{exercise.title}</Text>
{exercise.equipment && (
<Text className="text-sm text-muted-foreground">
{exercise.equipment} {exercise.difficulty || 'Any level'}
</Text>
)}
</View>
);
}
function TemplateQuote({ template }: { template: ParsedWorkoutTemplate }) {
return (
<View>
<Text className="font-medium">{template.title}</Text>
<Text className="text-sm text-muted-foreground">
{template.type} {template.exercises.length} exercises
</Text>
</View>
);
}