NuvioStreaming_backup_24-10-25/src/components/metadata/CommentsSection.tsx
2025-10-05 18:40:06 +05:30

913 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useState } from 'react';
import {
View,
Text,
StyleSheet,
ActivityIndicator,
TouchableOpacity,
FlatList,
Dimensions,
Modal,
Alert,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../../contexts/ThemeContext';
import { TraktContentComment } from '../../services/traktService';
import { logger } from '../../utils/logger';
import { useTraktComments } from '../../hooks/useTraktComments';
const { width } = Dimensions.get('window');
interface CommentsSectionProps {
imdbId: string;
type: 'movie' | 'show';
season?: number;
episode?: number;
}
interface CommentItemProps {
comment: TraktContentComment;
theme: any;
}
// Compact comment card for horizontal scrolling
const CompactCommentCard: React.FC<{
comment: TraktContentComment;
theme: any;
onPress: () => void;
isSpoilerRevealed: boolean;
onSpoilerPress: () => void;
}> = ({ comment, theme, onPress, isSpoilerRevealed, onSpoilerPress }) => {
// Safety check - ensure comment data exists
if (!comment || !comment.comment) {
return null;
}
// Handle missing user data gracefully
const user = comment.user || {};
const username = user.name || user.username || 'Anonymous';
// Handle spoiler content
const hasSpoiler = comment.spoiler;
const shouldBlurContent = hasSpoiler && !isSpoilerRevealed;
const truncatedComment = comment.comment.length > 100
? comment.comment.substring(0, 100) + '...'
: comment.comment;
// Format relative time
const formatRelativeTime = (dateString: string) => {
try {
const now = new Date();
const commentDate = new Date(dateString);
const diffMs = now.getTime() - commentDate.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) return 'now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
// For older dates, show month/day
return commentDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
});
} catch {
return '';
}
};
// Render stars for rating (convert 1-10 rating to 1-5 stars)
const renderCompactStars = (rating: number) => {
const stars = [];
const fullStars = Math.floor(rating / 2); // Convert 10-point scale to 5 stars
const hasHalfStar = rating % 2 >= 1;
// Add full stars
for (let i = 0; i < fullStars; i++) {
stars.push(
<MaterialIcons key={`full-${i}`} name="star" size={10} color="#FFD700" />
);
}
// Add half star if needed
if (hasHalfStar) {
stars.push(
<MaterialIcons key="half" name="star-half" size={10} color="#FFD700" />
);
}
// Add empty stars to make 5 total
const filledStars = fullStars + (hasHalfStar ? 1 : 0);
const emptyStars = 5 - filledStars;
for (let i = 0; i < emptyStars; i++) {
stars.push(
<MaterialIcons key={`empty-${i}`} name="star-border" size={10} color="#FFD700" />
);
}
return stars;
};
const handlePress = () => {
if (hasSpoiler && !isSpoilerRevealed) {
onSpoilerPress();
} else {
onPress();
}
};
return (
<TouchableOpacity
style={[styles.compactCard, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }]}
onPress={handlePress}
activeOpacity={0.7}
>
{/* Header Section - Fixed at top */}
<View style={styles.compactHeader}>
<Text style={[styles.compactUsername, { color: theme.colors.highEmphasis }]}>
{username}
</Text>
{user.vip && (
<View style={styles.miniVipBadge}>
<Text style={styles.miniVipText}>VIP</Text>
</View>
)}
</View>
{/* Rating - Show stars */}
{comment.user_stats?.rating && (
<View style={styles.compactRating}>
{renderCompactStars(comment.user_stats.rating)}
<Text style={[styles.compactRatingText, { color: theme.colors.mediumEmphasis }]}>
{comment.user_stats.rating}/10
</Text>
</View>
)}
{/* Comment Preview - Flexible area that fills space */}
<View style={[styles.commentContainer, shouldBlurContent ? styles.blurredContent : undefined]}>
<Text
style={[styles.compactComment, { color: theme.colors.highEmphasis }]}
numberOfLines={shouldBlurContent ? 3 : undefined}
ellipsizeMode="tail"
>
{shouldBlurContent ? '⚠️ This comment contains spoilers. Tap to reveal.' : truncatedComment}
</Text>
</View>
{/* Meta Info - Fixed at bottom */}
<View style={styles.compactMeta}>
<View style={styles.compactBadges}>
{comment.review && (
<View style={[styles.miniReviewBadgeContainer, { backgroundColor: theme.colors.primary }]}>
<Text style={styles.miniBadgeText}>Review</Text>
</View>
)}
{comment.spoiler && (
<View style={[styles.miniSpoilerBadgeContainer, { backgroundColor: theme.colors.error }]}>
<Text style={styles.miniBadgeText}>Spoiler</Text>
</View>
)}
</View>
<View style={styles.compactStats}>
<Text style={[styles.compactTime, { color: theme.colors.mediumEmphasis }]}>
{formatRelativeTime(comment.created_at)}
</Text>
{comment.likes > 0 && (
<Text style={[styles.compactStat, { color: theme.colors.mediumEmphasis }]}>
👍 {comment.likes}
</Text>
)}
{comment.replies > 0 && (
<Text style={[styles.compactStat, { color: theme.colors.mediumEmphasis }]}>
💬 {comment.replies}
</Text>
)}
</View>
</View>
</TouchableOpacity>
);
};
// Expanded comment modal
const ExpandedCommentModal: React.FC<{
comment: TraktContentComment | null;
visible: boolean;
onClose: () => void;
theme: any;
isSpoilerRevealed: boolean;
onSpoilerPress: () => void;
}> = ({ comment, visible, onClose, theme, isSpoilerRevealed, onSpoilerPress }) => {
if (!comment) return null;
const user = comment.user || {};
const username = user.name || user.username || 'Anonymous User';
const hasSpoiler = comment.spoiler;
const shouldBlurModalContent = hasSpoiler && !isSpoilerRevealed;
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return 'Unknown date';
}
};
const renderStars = (rating: number | null) => {
if (rating === null) return null;
const stars = [];
const fullStars = Math.floor(rating / 2);
const hasHalfStar = rating % 2 >= 1;
for (let i = 0; i < fullStars; i++) {
stars.push(
<MaterialIcons key={`full-${i}`} name="star" size={16} color="#FFD700" />
);
}
if (hasHalfStar) {
stars.push(
<MaterialIcons key="half" name="star-half" size={16} color="#FFD700" />
);
}
const emptyStars = 5 - Math.ceil(rating / 2);
for (let i = 0; i < emptyStars; i++) {
stars.push(
<MaterialIcons key={`empty-${i}`} name="star-border" size={16} color="#FFD700" />
);
}
return stars;
};
return (
<Modal
visible={visible}
transparent={true}
animationType="fade"
onRequestClose={onClose}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={onClose}
>
<TouchableOpacity
style={[styles.modalContent, {
backgroundColor: theme.colors.darkGray || '#0A0C0C',
borderColor: theme.colors.border || '#CCCCCC',
borderWidth: 1
}]}
activeOpacity={1}
onPress={() => {}} // Prevent closing when clicking on modal content
>
{/* Close Button */}
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<MaterialIcons name="close" size={24} color={theme.colors.highEmphasis} />
</TouchableOpacity>
{/* User Info */}
<View style={styles.modalHeader}>
<View style={styles.userInfo}>
<Text style={[styles.modalUsername, { color: theme.colors.highEmphasis }]}>
{username}
</Text>
{user.vip && (
<View style={styles.vipBadge}>
<Text style={styles.vipText}>VIP</Text>
</View>
)}
</View>
<Text style={[styles.modalDate, { color: theme.colors.mediumEmphasis }]}>
{formatDate(comment.created_at)}
</Text>
</View>
{/* Rating */}
{comment.user_stats?.rating && (
<View style={styles.modalRating}>
{renderStars(comment.user_stats.rating)}
<Text style={[styles.modalRatingText, { color: theme.colors.mediumEmphasis }]}>
{comment.user_stats.rating}/10
</Text>
</View>
)}
{/* Full Comment */}
{shouldBlurModalContent ? (
<View style={styles.spoilerContainer}>
<Text style={[styles.spoilerWarning, { color: theme.colors.error }]}>
This comment contains spoilers
</Text>
<TouchableOpacity
style={[styles.revealButton, { backgroundColor: theme.colors.primary }]}
onPress={onSpoilerPress}
>
<Text style={[styles.revealButtonText, { color: theme.colors.white }]}>
Reveal Spoilers
</Text>
</TouchableOpacity>
</View>
) : (
<Text style={[styles.modalComment, { color: theme.colors.highEmphasis }]}>
{comment.comment}
</Text>
)}
{/* Comment Meta */}
<View style={styles.modalMeta}>
{comment.review && (
<Text style={[styles.reviewBadge, { color: theme.colors.primary }]}>Review</Text>
)}
{comment.spoiler && (
<Text style={[styles.spoilerBadge, { color: theme.colors.error }]}>Spoiler</Text>
)}
<View style={styles.modalStats}>
{comment.likes > 0 && (
<View style={styles.likesContainer}>
<MaterialIcons name="thumb-up" size={16} color={theme.colors.mediumEmphasis} />
<Text style={[styles.likesText, { color: theme.colors.mediumEmphasis }]}>
{comment.likes}
</Text>
</View>
)}
{comment.replies > 0 && (
<View style={styles.repliesContainer}>
<MaterialIcons name="chat-bubble-outline" size={16} color={theme.colors.mediumEmphasis} />
<Text style={[styles.repliesText, { color: theme.colors.mediumEmphasis }]}>
{comment.replies}
</Text>
</View>
)}
</View>
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
);
};
export const CommentsSection: React.FC<CommentsSectionProps> = ({
imdbId,
type,
season,
episode,
}) => {
const { currentTheme } = useTheme();
const [selectedComment, setSelectedComment] = useState<TraktContentComment | null>(null);
const [modalVisible, setModalVisible] = useState(false);
const [revealedSpoilers, setRevealedSpoilers] = useState<Set<string>>(new Set());
const {
comments,
loading,
error,
hasMore,
isAuthenticated,
loadMore,
refresh,
} = useTraktComments({
imdbId,
type: type === 'show' ? (season !== undefined && episode !== undefined ? 'episode' :
season !== undefined ? 'season' : 'show') : 'movie',
season,
episode,
enabled: true,
});
const handleCommentPress = useCallback((comment: TraktContentComment) => {
setSelectedComment(comment);
setModalVisible(true);
}, []);
const handleModalClose = useCallback(() => {
setModalVisible(false);
setSelectedComment(null);
}, []);
const handleSpoilerPress = useCallback((comment: TraktContentComment) => {
Alert.alert(
'Spoiler Warning',
'This comment contains spoilers. Are you sure you want to reveal it?',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Reveal Spoilers',
style: 'destructive',
onPress: () => {
setRevealedSpoilers(prev => new Set([...prev, comment.id.toString()]));
},
},
]
);
}, []);
const renderComment = useCallback(({ item }: { item: TraktContentComment }) => (
<CompactCommentCard
comment={item}
theme={currentTheme}
onPress={() => handleCommentPress(item)}
isSpoilerRevealed={revealedSpoilers.has(item.id.toString())}
onSpoilerPress={() => handleSpoilerPress(item)}
/>
), [currentTheme, handleCommentPress, revealedSpoilers, handleSpoilerPress]);
const renderEmpty = useCallback(() => {
if (loading) return null;
return (
<View style={styles.emptyContainer}>
<MaterialIcons name="chat-bubble-outline" size={48} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}>
{error ? 'Comments unavailable' : 'No comments yet'}
</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.disabled }]}>
{error
? 'This content may not be in Trakt\'s database yet'
: 'Be the first to comment on Trakt.tv'
}
</Text>
</View>
);
}, [loading, error, currentTheme]);
// Don't show section if not authenticated
if (!isAuthenticated) {
return null;
}
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>
Trakt Comments
</Text>
</View>
{error && (
<View style={[styles.errorContainer, { backgroundColor: currentTheme.colors.card }]}>
<MaterialIcons name="error-outline" size={20} color={currentTheme.colors.error} />
<Text style={[styles.errorText, { color: currentTheme.colors.error }]}>
{error}
</Text>
<TouchableOpacity
style={[styles.retryButton, { borderColor: currentTheme.colors.error }]}
onPress={refresh}
>
<Text style={[styles.retryButtonText, { color: currentTheme.colors.error }]}>
Retry
</Text>
</TouchableOpacity>
</View>
)}
{loading && comments.length === 0 && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.loadingText, { color: currentTheme.colors.mediumEmphasis }]}>
Loading comments...
</Text>
</View>
)}
{comments.length === 0 ? (
renderEmpty()
) : (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={comments}
keyExtractor={(item) => item?.id?.toString() || Math.random().toString()}
renderItem={renderComment}
contentContainerStyle={styles.horizontalList}
onEndReached={() => {
if (hasMore && !loading) {
loadMore();
}
}}
onEndReachedThreshold={0.5}
ListFooterComponent={
hasMore ? (
<View style={styles.loadMoreContainer}>
<TouchableOpacity
style={[styles.loadMoreButton, { backgroundColor: currentTheme.colors.card }]}
onPress={loadMore}
disabled={loading}
>
{loading ? (
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
) : (
<>
<Text style={[styles.loadMoreText, { color: currentTheme.colors.primary }]}>
Load More
</Text>
<MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.primary} />
</>
)}
</TouchableOpacity>
</View>
) : null
}
/>
)}
{/* Expanded Comment Modal */}
<ExpandedCommentModal
comment={selectedComment}
visible={modalVisible}
onClose={handleModalClose}
theme={currentTheme}
isSpoilerRevealed={selectedComment ? revealedSpoilers.has(selectedComment.id.toString()) : false}
onSpoilerPress={() => selectedComment && handleSpoilerPress(selectedComment)}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
padding: 16,
marginBottom: 24,
},
header: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
title: {
fontSize: 20,
fontWeight: '600',
flex: 1,
},
horizontalList: {
paddingRight: 16,
},
compactCard: {
width: 280,
height: 160,
padding: 12,
marginRight: 12,
borderRadius: 12,
borderWidth: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
flexDirection: 'column',
},
compactHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
compactUsername: {
fontSize: 16,
fontWeight: '600',
flex: 1,
},
miniVipBadge: {
backgroundColor: '#FFD700',
paddingHorizontal: 4,
paddingVertical: 1,
borderRadius: 6,
marginLeft: 6,
},
miniVipText: {
fontSize: 9,
fontWeight: '700',
color: '#000',
},
compactRating: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
gap: 2,
},
compactRatingText: {
fontSize: 14,
fontWeight: '600',
marginLeft: 4,
},
commentContainer: {
flex: 1,
justifyContent: 'flex-start',
marginBottom: 8,
},
compactComment: {
fontSize: 14,
lineHeight: 20,
},
blurredContent: {
opacity: 0.3,
backgroundColor: 'rgba(0, 0, 0, 0.1)',
padding: 8,
borderRadius: 4,
},
compactMeta: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
compactBadges: {
flexDirection: 'row',
gap: 4,
},
miniReviewBadgeContainer: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
},
miniSpoilerBadgeContainer: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
},
miniBadgeText: {
fontSize: 9,
fontWeight: '700',
color: '#FFFFFF',
},
compactStats: {
flexDirection: 'row',
gap: 8,
},
compactStat: {
fontSize: 12,
},
compactTime: {
fontSize: 11,
fontWeight: '500',
},
loadMoreContainer: {
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 16,
},
commentItem: {
padding: 16,
marginBottom: 12,
borderRadius: 12,
borderWidth: 1,
},
commentHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
userInfo: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
username: {
fontSize: 16,
fontWeight: '600',
flex: 1,
},
vipBadge: {
backgroundColor: '#FFD700',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 10,
marginLeft: 8,
},
vipText: {
fontSize: 10,
fontWeight: '700',
color: '#000',
},
date: {
fontSize: 12,
},
contentTitle: {
fontSize: 14,
fontWeight: '500',
marginBottom: 4,
},
ratingContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
ratingText: {
fontSize: 12,
marginLeft: 4,
},
commentText: {
fontSize: 15,
lineHeight: 22,
marginBottom: 12,
},
commentMeta: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
reviewBadge: {
fontSize: 12,
fontWeight: '600',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 12,
backgroundColor: 'rgba(33, 150, 243, 0.1)',
},
spoilerBadge: {
fontSize: 12,
fontWeight: '600',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 12,
backgroundColor: 'rgba(244, 67, 54, 0.1)',
marginLeft: 8,
},
metaRight: {
flexDirection: 'row',
alignItems: 'center',
},
likesContainer: {
flexDirection: 'row',
alignItems: 'center',
marginRight: 12,
},
likesText: {
fontSize: 12,
marginLeft: 4,
},
repliesContainer: {
flexDirection: 'row',
alignItems: 'center',
},
repliesText: {
fontSize: 12,
marginLeft: 4,
},
loadMoreButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
marginTop: 8,
marginBottom: 16,
},
loadMoreText: {
fontSize: 14,
fontWeight: '600',
marginRight: 8,
},
emptyContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 40,
},
emptyText: {
fontSize: 16,
fontWeight: '500',
marginTop: 12,
},
emptySubtext: {
fontSize: 14,
marginTop: 4,
textAlign: 'center',
},
emptyList: {
flexGrow: 1,
justifyContent: 'center',
},
loadingContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 40,
},
loadingText: {
fontSize: 14,
marginTop: 12,
},
errorContainer: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderRadius: 8,
marginBottom: 16,
},
errorText: {
fontSize: 14,
marginLeft: 8,
flex: 1,
},
retryButton: {
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 6,
marginLeft: 12,
},
retryButtonText: {
fontSize: 12,
fontWeight: '600',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalContent: {
width: '90%',
maxWidth: 400,
maxHeight: '80%',
borderRadius: 16,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.3,
shadowRadius: 10,
},
closeButton: {
position: 'absolute',
top: 12,
right: 12,
padding: 8,
borderRadius: 20,
backgroundColor: 'rgba(0, 0, 0, 0.1)',
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 16,
paddingTop: 20,
},
modalUsername: {
fontSize: 18,
fontWeight: '600',
flex: 1,
},
modalDate: {
fontSize: 12,
marginTop: 4,
},
modalRating: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
modalRatingText: {
fontSize: 14,
fontWeight: '600',
marginLeft: 8,
},
modalComment: {
fontSize: 16,
lineHeight: 24,
marginBottom: 16,
},
spoilerContainer: {
alignItems: 'center',
paddingVertical: 20,
},
spoilerWarning: {
fontSize: 16,
fontWeight: '600',
textAlign: 'center',
marginBottom: 16,
},
revealButton: {
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 8,
},
revealButtonText: {
fontSize: 16,
fontWeight: '600',
},
modalMeta: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
modalStats: {
flexDirection: 'row',
gap: 12,
},
});