diff --git a/.gitignore b/.gitignore
index 28ff76f7..70fdd630 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,3 +63,4 @@ src/screens/xavio.md
toast.md
ffmpegreadme.md
sliderreadme.md
+bottomsheet.md
diff --git a/package-lock.json b/package-lock.json
index 3b98bc07..72a9b015 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"@expo/env": "^2.0.7",
"@expo/metro-runtime": "~4.0.1",
"@expo/vector-icons": "~14.0.4",
+ "@gorhom/bottom-sheet": "^5.2.6",
"@lottiefiles/dotlottie-react": "^0.6.5",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/blur": "^4.4.1",
@@ -3098,6 +3099,45 @@
"@babel/highlight": "^7.10.4"
}
},
+ "node_modules/@gorhom/bottom-sheet": {
+ "version": "5.2.6",
+ "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.6.tgz",
+ "integrity": "sha512-vmruJxdiUGDg+ZYcDmS30XDhq/h/+QkINOI5LY/uGjx8cPGwgJW0H6AB902gNTKtccbiKe/rr94EwdmIEz+LAQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@gorhom/portal": "1.0.14",
+ "invariant": "^2.2.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-native": "*",
+ "react": "*",
+ "react-native": "*",
+ "react-native-gesture-handler": ">=2.16.1",
+ "react-native-reanimated": ">=3.16.0 || >=4.0.0-"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@gorhom/portal": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/@gorhom/portal/-/portal-1.0.14.tgz",
+ "integrity": "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==",
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.1"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/@ide/backoff": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz",
@@ -4066,7 +4106,7 @@
"version": "0.72.8",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz",
"integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4",
@@ -5185,7 +5225,7 @@
"version": "0.72.8",
"resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.72.8.tgz",
"integrity": "sha512-St6xA7+EoHN5mEYfdWnfYt0e8u6k2FR0P9s2arYgakQGFgU1f9FlPrIEcj0X24pLCF5c5i3WVuLCUdiCYHmOoA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@react-native/virtualized-lists": "^0.72.4",
diff --git a/package.json b/package.json
index ab730331..47e1c4f1 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"@expo/env": "^2.0.7",
"@expo/metro-runtime": "~4.0.1",
"@expo/vector-icons": "~14.0.4",
+ "@gorhom/bottom-sheet": "^5.2.6",
"@lottiefiles/dotlottie-react": "^0.6.5",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/blur": "^4.4.1",
diff --git a/src/components/metadata/CommentsSection.tsx b/src/components/metadata/CommentsSection.tsx
index d94b8566..6664c88a 100644
--- a/src/components/metadata/CommentsSection.tsx
+++ b/src/components/metadata/CommentsSection.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useState } from 'react';
+import React, { useCallback, useState, useRef } from 'react';
import {
View,
Text,
@@ -7,14 +7,15 @@ import {
TouchableOpacity,
FlatList,
Dimensions,
- Modal,
Alert,
+ ScrollView,
} 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';
+import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet';
const { width } = Dimensions.get('window');
@@ -23,6 +24,7 @@ interface CommentsSectionProps {
type: 'movie' | 'show';
season?: number;
episode?: number;
+ onCommentPress?: (comment: TraktContentComment) => void;
}
interface CommentItemProps {
@@ -38,6 +40,8 @@ const CompactCommentCard: React.FC<{
isSpoilerRevealed: boolean;
onSpoilerPress: () => void;
}> = ({ comment, theme, onPress, isSpoilerRevealed, onSpoilerPress }) => {
+ const [isPressed, setIsPressed] = useState(false);
+
// Safety check - ensure comment data exists
if (!comment || !comment.comment) {
return null;
@@ -112,18 +116,22 @@ const CompactCommentCard: React.FC<{
return stars;
};
- const handlePress = () => {
- if (hasSpoiler && !isSpoilerRevealed) {
- onSpoilerPress();
- } else {
- onPress();
- }
- };
-
return (
setIsPressed(true)}
+ onPressOut={() => setIsPressed(false)}
+ onPress={() => {
+ console.log('CompactCommentCard: TouchableOpacity pressed for comment:', comment.id);
+ onPress();
+ }}
activeOpacity={0.7}
>
{/* Header Section - Fixed at top */}
@@ -162,15 +170,8 @@ const CompactCommentCard: React.FC<{
{/* Meta Info - Fixed at bottom */}
- {comment.review && (
-
- Review
-
- )}
{comment.spoiler && (
-
- Spoiler
-
+ Spoiler
)}
@@ -193,8 +194,8 @@ const CompactCommentCard: React.FC<{
);
};
-// Expanded comment modal
-const ExpandedCommentModal: React.FC<{
+// Expanded comment bottom sheet
+const ExpandedCommentBottomSheet: React.FC<{
comment: TraktContentComment | null;
visible: boolean;
onClose: () => void;
@@ -202,6 +203,17 @@ const ExpandedCommentModal: React.FC<{
isSpoilerRevealed: boolean;
onSpoilerPress: () => void;
}> = ({ comment, visible, onClose, theme, isSpoilerRevealed, onSpoilerPress }) => {
+ const bottomSheetRef = useRef(null);
+
+ // Handle visibility changes - always call this hook
+ React.useEffect(() => {
+ if (visible && comment) {
+ bottomSheetRef.current?.expand();
+ } else {
+ bottomSheetRef.current?.close();
+ }
+ }, [visible, comment]);
+
if (!comment) return null;
const user = comment.user || {};
@@ -209,18 +221,21 @@ const ExpandedCommentModal: React.FC<{
const hasSpoiler = comment.spoiler;
const shouldBlurModalContent = hasSpoiler && !isSpoilerRevealed;
- const formatDate = (dateString: string) => {
+ const formatDateParts = (dateString: string) => {
try {
const date = new Date(dateString);
- return date.toLocaleDateString('en-US', {
+ const datePart = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
+ });
+ const timePart = date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
+ return { datePart, timePart };
} catch {
- return 'Unknown date';
+ return { datePart: 'Unknown date', timePart: '' };
}
};
@@ -254,26 +269,29 @@ const ExpandedCommentModal: React.FC<{
};
return (
- {
+ if (index === -1) {
+ onClose();
+ }
+ }}
+ index={-1}
+ snapPoints={[200, '50%', '90%']}
+ enablePanDownToClose={true}
+ animateOnMount={true}
+ backgroundStyle={{
+ backgroundColor: theme.colors.darkGray || '#0A0C0C',
+ borderTopLeftRadius: 16,
+ borderTopRightRadius: 16,
+ }}
+ handleIndicatorStyle={{
+ backgroundColor: theme.colors.mediumEmphasis || '#CCCCCC',
+ }}
>
-
- {}} // Prevent closing when clicking on modal content
- >
+
{/* Close Button */}
@@ -291,9 +309,21 @@ const ExpandedCommentModal: React.FC<{
)}
-
- {formatDate(comment.created_at)}
-
+ {(() => {
+ const { datePart, timePart } = formatDateParts(comment.created_at);
+ return (
+
+
+ {datePart}
+
+ {!!timePart && (
+
+ {timePart}
+
+ )}
+
+ );
+ })()}
{/* Rating */}
@@ -322,18 +352,21 @@ const ExpandedCommentModal: React.FC<{
) : (
-
- {comment.comment}
-
+
+
+ {comment.comment}
+
+
)}
{/* Comment Meta */}
- {comment.review && (
- Review
- )}
{comment.spoiler && (
- Spoiler
+ Spoiler
)}
{comment.likes > 0 && (
@@ -354,9 +387,8 @@ const ExpandedCommentModal: React.FC<{
)}
-
-
-
+
+
);
};
@@ -365,11 +397,9 @@ export const CommentsSection: React.FC = ({
type,
season,
episode,
+ onCommentPress,
}) => {
const { currentTheme } = useTheme();
- const [selectedComment, setSelectedComment] = useState(null);
- const [modalVisible, setModalVisible] = useState(false);
- const [revealedSpoilers, setRevealedSpoilers] = useState>(new Set());
const {
comments,
@@ -388,45 +418,36 @@ export const CommentsSection: React.FC = ({
enabled: true,
});
- const handleCommentPress = useCallback((comment: TraktContentComment) => {
- setSelectedComment(comment);
- setModalVisible(true);
- }, []);
+ // Debug logging
+ console.log('CommentsSection: Comments data:', comments);
+ console.log('CommentsSection: Comments length:', comments?.length);
+ console.log('CommentsSection: Loading:', loading);
+ console.log('CommentsSection: Error:', error);
- const handleModalClose = useCallback(() => {
- setModalVisible(false);
- setSelectedComment(null);
- }, []);
+ const renderComment = useCallback(({ item }: { item: TraktContentComment }) => {
+ // Safety check for null/undefined items
+ if (!item || !item.id) {
+ console.log('CommentsSection: Invalid comment item:', item);
+ return 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()]));
- },
- },
- ]
+ console.log('CommentsSection: Rendering comment:', item.id);
+
+ return (
+ {
+ console.log('CommentsSection: Comment pressed:', item.id);
+ onCommentPress?.(item);
+ }}
+ isSpoilerRevealed={true}
+ onSpoilerPress={() => {
+ // Do nothing for now - spoilers are handled by parent
+ }}
+ />
);
- }, []);
-
- const renderComment = useCallback(({ item }: { item: TraktContentComment }) => (
- handleCommentPress(item)}
- isSpoilerRevealed={revealedSpoilers.has(item.id.toString())}
- onSpoilerPress={() => handleSpoilerPress(item)}
- />
- ), [currentTheme, handleCommentPress, revealedSpoilers, handleSpoilerPress]);
+ }, [currentTheme, onCommentPress]);
const renderEmpty = useCallback(() => {
if (loading) return null;
@@ -496,6 +517,12 @@ export const CommentsSection: React.FC = ({
keyExtractor={(item) => item?.id?.toString() || Math.random().toString()}
renderItem={renderComment}
contentContainerStyle={styles.horizontalList}
+ removeClippedSubviews={false}
+ getItemLayout={(data, index) => ({
+ length: 292, // width + marginRight
+ offset: 292 * index,
+ index,
+ })}
onEndReached={() => {
if (hasMore && !loading) {
loadMore();
@@ -527,19 +554,202 @@ export const CommentsSection: React.FC = ({
/>
)}
- {/* Expanded Comment Modal */}
- selectedComment && handleSpoilerPress(selectedComment)}
- />
);
};
+// BottomSheet component that should be rendered at a higher level
+export const CommentBottomSheet: React.FC<{
+ comment: TraktContentComment | null;
+ visible: boolean;
+ onClose: () => void;
+ theme: any;
+ isSpoilerRevealed: boolean;
+ onSpoilerPress: () => void;
+}> = ({ comment, visible, onClose, theme, isSpoilerRevealed, onSpoilerPress }) => {
+ const bottomSheetRef = useRef(null);
+
+ console.log('CommentBottomSheet: Rendered with visible:', visible, 'comment:', comment?.id);
+
+ // Calculate the index based on visibility - start at medium height (50%)
+ const sheetIndex = visible && comment ? 1 : -1;
+
+ console.log('CommentBottomSheet: Calculated sheetIndex:', sheetIndex);
+
+ 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 formatDateParts = (dateString: string) => {
+ try {
+ const date = new Date(dateString);
+ const datePart = date.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ });
+ const timePart = date.toLocaleTimeString('en-US', {
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ return { datePart, timePart };
+ } catch {
+ return { datePart: 'Unknown date', timePart: '' };
+ }
+ };
+
+ 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(
+
+ );
+ }
+
+ if (hasHalfStar) {
+ stars.push(
+
+ );
+ }
+
+ const emptyStars = 5 - Math.ceil(rating / 2);
+ for (let i = 0; i < emptyStars; i++) {
+ stars.push(
+
+ );
+ }
+
+ return stars;
+ };
+
+ return (
+ {
+ console.log('CommentBottomSheet: onChange called with index:', index);
+ if (index === -1) {
+ onClose();
+ }
+ }}
+ index={sheetIndex}
+ snapPoints={[200, '50%']}
+ enablePanDownToClose={true}
+ animateOnMount={true}
+ backgroundStyle={{
+ backgroundColor: theme.colors.darkGray || '#0A0C0C',
+ borderTopLeftRadius: 16,
+ borderTopRightRadius: 16,
+ }}
+ handleIndicatorStyle={{
+ backgroundColor: theme.colors.mediumEmphasis || '#CCCCCC',
+ }}
+ >
+
+ {/* User Info */}
+
+
+
+ {username}
+
+ {user.vip && (
+
+ VIP
+
+ )}
+
+ {(() => {
+ const { datePart, timePart } = formatDateParts(comment.created_at);
+ return (
+
+
+ {datePart}
+
+ {!!timePart && (
+
+ {timePart}
+
+ )}
+
+ );
+ })()}
+
+
+ {/* Rating */}
+ {comment.user_stats?.rating && (
+
+ {renderStars(comment.user_stats.rating)}
+
+ {comment.user_stats.rating}/10
+
+
+ )}
+
+ {/* Full Comment */}
+ {shouldBlurModalContent ? (
+
+
+ ⚠️ This comment contains spoilers
+
+
+
+ Reveal Spoilers
+
+
+
+ ) : (
+
+
+ {comment.comment}
+
+
+ )}
+
+ {/* Comment Meta */}
+
+ {comment.spoiler && (
+ Spoiler
+ )}
+
+ {comment.likes > 0 && (
+
+
+
+ {comment.likes}
+
+
+ )}
+ {comment.replies > 0 && (
+
+
+
+ {comment.replies}
+
+
+ )}
+
+
+
+
+ );
+};
+
const styles = StyleSheet.create({
container: {
padding: 16,
@@ -629,20 +839,9 @@ const styles = StyleSheet.create({
flexDirection: 'row',
gap: 4,
},
- miniReviewBadgeContainer: {
- paddingHorizontal: 6,
- paddingVertical: 2,
- borderRadius: 8,
- },
- miniSpoilerBadgeContainer: {
- paddingHorizontal: 6,
- paddingVertical: 2,
- borderRadius: 8,
- },
- miniBadgeText: {
- fontSize: 9,
+ spoilerMiniText: {
+ fontSize: 11,
fontWeight: '700',
- color: '#FFFFFF',
},
compactStats: {
flexDirection: 'row',
@@ -721,22 +920,10 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'space-between',
},
- reviewBadge: {
+ spoilerText: {
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,
+ marginRight: 8,
},
metaRight: {
flexDirection: 'row',
@@ -825,24 +1012,10 @@ const styles = StyleSheet.create({
fontSize: 12,
fontWeight: '600',
},
- modalOverlay: {
+ bottomSheetContent: {
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,
@@ -867,6 +1040,13 @@ const styles = StyleSheet.create({
fontSize: 12,
marginTop: 4,
},
+ modalTime: {
+ fontSize: 12,
+ marginTop: 2,
+ },
+ dateTimeContainer: {
+ alignItems: 'flex-end',
+ },
modalRating: {
flexDirection: 'row',
alignItems: 'center',
@@ -882,6 +1062,12 @@ const styles = StyleSheet.create({
lineHeight: 24,
marginBottom: 16,
},
+ modalCommentScroll: {
+ // Constrain height so only text area scrolls, not the entire modal
+ maxHeight: 400,
+ marginBottom: 16,
+ flexShrink: 1,
+ },
spoilerContainer: {
alignItems: 'center',
paddingVertical: 20,
diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx
index 65df88b2..4a71492c 100644
--- a/src/screens/MetadataScreen.tsx
+++ b/src/screens/MetadataScreen.tsx
@@ -10,6 +10,7 @@ import {
InteractionManager,
BackHandler,
Platform,
+ Alert,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRoute, useNavigation, useFocusEffect } from '@react-navigation/native';
@@ -24,7 +25,7 @@ import { SeriesContent } from '../components/metadata/SeriesContent';
import { MovieContent } from '../components/metadata/MovieContent';
import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
import { RatingsSection } from '../components/metadata/RatingsSection';
-import { CommentsSection } from '../components/metadata/CommentsSection';
+import { CommentsSection, CommentBottomSheet } from '../components/metadata/CommentsSection';
import { RouteParams, Episode } from '../types/metadata';
import Animated, {
useAnimatedStyle,
@@ -88,6 +89,20 @@ const MetadataScreen: React.FC = () => {
const transitionOpacity = useSharedValue(1);
const interactionComplete = useRef(false);
+ // Comment bottom sheet state
+ const [commentBottomSheetVisible, setCommentBottomSheetVisible] = useState(false);
+ const [selectedComment, setSelectedComment] = useState(null);
+ const [revealedSpoilers, setRevealedSpoilers] = useState>(new Set());
+
+ // Debug state changes
+ React.useEffect(() => {
+ console.log('MetadataScreen: commentBottomSheetVisible changed to:', commentBottomSheetVisible);
+ }, [commentBottomSheetVisible]);
+
+ React.useEffect(() => {
+ console.log('MetadataScreen: selectedComment changed to:', selectedComment?.id);
+ }, [selectedComment]);
+
const {
metadata,
loading,
@@ -527,6 +542,43 @@ const MetadataScreen: React.FC = () => {
setShowCastModal(true);
}, [isScreenFocused]);
+ const handleCommentPress = useCallback((comment: any) => {
+ console.log('MetadataScreen: handleCommentPress called with comment:', comment?.id);
+ if (!isScreenFocused) {
+ console.log('MetadataScreen: Screen not focused, ignoring');
+ return;
+ }
+ console.log('MetadataScreen: Setting selected comment and opening bottomsheet');
+ setSelectedComment(comment);
+ setCommentBottomSheetVisible(true);
+ console.log('MetadataScreen: State should be updated now');
+ }, [isScreenFocused]);
+
+ const handleCommentBottomSheetClose = useCallback(() => {
+ setCommentBottomSheetVisible(false);
+ setSelectedComment(null);
+ }, []);
+
+ const handleSpoilerPress = useCallback((comment: any) => {
+ 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()]));
+ },
+ },
+ ]
+ );
+ }, []);
+
// Source switching removed
// Ultra-optimized animated styles - minimal calculations with conditional updates
@@ -678,8 +730,8 @@ const MetadataScreen: React.FC = () => {
{shouldLoadSecondaryData && imdbId && (
0 ? 'show' : 'movie'}
+ onCommentPress={handleCommentPress}
/>
)}
@@ -718,6 +770,16 @@ const MetadataScreen: React.FC = () => {
castMember={selectedCastMember}
/>
)}
+
+ {/* Comment Bottom Sheet - Memoized */}
+ selectedComment && handleSpoilerPress(selectedComment)}
+ />
);