import { useState, useEffect, useRef, useCallback } from "react"; import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, Alert, Modal, Dimensions } from "react-native"; import { Image } from "expo-image"; import { Video, ResizeMode, AVPlaybackStatus } from "expo-av"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useFocusEffect } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Ionicons } from "@expo/vector-icons"; import { LinearGradient } from "expo-linear-gradient"; import Animated, { FadeIn, FadeInUp, FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, interpolate, Easing, } from "react-native-reanimated"; import { supabase } from "../../../src/lib/supabase"; import { useAuthStore, useMessageStore } from "../../../src/store"; import * as ImagePicker from "expo-image-picker"; import * as FileSystem from 'expo-file-system'; import { Video as VideoCompressor } from 'react-native-compressor'; import { decode } from 'base64-arraybuffer'; import { Database } from "../../../src/types/supabase"; import { GradientBackground } from "../../../src/components/ui"; import { colors, gradients, spacing, radius, typography, shadows } from "../../../src/theme"; import { Events } from "../../../src/lib/analytics"; import { getCachedSignedUrl, getStoragePath, getVideoCachedUri, cacheVideo } from "../../../src/lib/imageCache"; type Message = Database["public"]["Tables"]["messages"]["Row"]; // Upload status for skeleton display type UploadStatus = { mediaType: 'image' | 'video'; status: 'compressing' | 'uploading'; thumbnailUri?: string; } | null; // Premium color palette for Chat (matches feature uses gold) const ACCENT = colors.premium.gold; const ACCENT_DARK = colors.premium.goldDark; const ACCENT_RGBA = 'rgba(212, 175, 55, '; const ROSE = colors.premium.rose; const ROSE_RGBA = 'rgba(232, 164, 174, '; export default function ChatScreen() { const { id } = useLocalSearchParams(); const matchId = id as string; const router = useRouter(); const insets = useSafeAreaInsets(); const { user, partner } = useAuthStore(); const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore(); const [messages, setMessages] = useState([]); const [match, setMatch] = useState(null); const [inputText, setInputText] = useState(""); const [uploading, setUploading] = useState(false); const [uploadStatus, setUploadStatus] = useState(null); const [partnerTyping, setPartnerTyping] = useState(false); const [fullScreenImage, setFullScreenImage] = useState(null); const [fullScreenImageLoading, setFullScreenImageLoading] = useState(true); const [fullScreenVideo, setFullScreenVideo] = useState(null); const flatListRef = useRef(null); const typingTimeoutRef = useRef(null); const isFocusedRef = useRef(false); // Ambient orb breathing animations const orbBreathing1 = useSharedValue(0); const orbBreathing2 = useSharedValue(0); const orbDrift = useSharedValue(0); useEffect(() => { // Primary orb breathing - 6 second cycle orbBreathing1.value = withRepeat( withSequence( withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.sin) }), withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.sin) }) ), -1, true ); // Secondary orb breathing - offset timing for variation orbBreathing2.value = withRepeat( withSequence( withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) }), withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }), withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) }) ), -1, true ); // Subtle vertical drift - 8 second cycle orbDrift.value = withRepeat( withSequence( withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }), withTiming(0, { duration: 4000, easing: Easing.inOut(Easing.sin) }) ), -1, true ); }, []); const orbStyle1 = useAnimatedStyle(() => ({ opacity: interpolate(orbBreathing1.value, [0, 1], [0.2, 0.4]), transform: [ { translateY: interpolate(orbDrift.value, [0, 1], [0, -20]) }, { scale: interpolate(orbBreathing1.value, [0, 1], [1, 1.1]) }, ], })); const orbStyle2 = useAnimatedStyle(() => ({ opacity: interpolate(orbBreathing2.value, [0, 1], [0.15, 0.35]), transform: [ { translateY: interpolate(orbDrift.value, [0, 1], [20, 0]) }, { scale: interpolate(orbBreathing2.value, [0, 1], [1, 1.1]) }, ], })); // Track active chat to prevent notifications for current chat // Use useFocusEffect because screens don't unmount on navigation useFocusEffect( useCallback(() => { isFocusedRef.current = true; if (matchId) { setActiveMatchId(matchId); markMatchMessagesAsRead(matchId); Events.matchViewed(); } return () => { isFocusedRef.current = false; setActiveMatchId(null); }; }, [matchId, setActiveMatchId, markMatchMessagesAsRead]) ); useEffect(() => { if (!matchId) return; const fetchMatch = async () => { const { data } = await supabase .from("matches") .select("*, question:questions(*)") .eq("id", matchId) .single(); if (data) { const { data: responses } = await supabase .from("responses") .select("*, profiles(name)") .eq("question_id", data.question_id) .eq("couple_id", data.couple_id); setMatch({ ...data, responses: responses || [] }); } }; fetchMatch(); const fetchMessagesAndMarkRead = async () => { const { data } = await supabase .from("messages") .select("*") .eq("match_id", matchId) .order("created_at", { ascending: false }); if (data) { setMessages(data); const now = new Date().toISOString(); // Get partner messages that need to be marked as delivered or read const partnerMessages = data.filter(m => m.user_id !== user?.id); const undeliveredIds = partnerMessages.filter(m => !m.delivered_at).map(m => m.id); const unreadIds = partnerMessages.filter(m => !m.read_at).map(m => m.id); // Mark as both delivered and read (since user is viewing the chat) if (unreadIds.length > 0) { await supabase .from("messages") .update({ delivered_at: now, read_at: now }) .in("id", unreadIds); setMessages(prev => prev.map(m => unreadIds.includes(m.id) ? { ...m, delivered_at: now, read_at: now } : m )); } else if (undeliveredIds.length > 0) { // Only mark as delivered if already read but not delivered (edge case) await supabase .from("messages") .update({ delivered_at: now }) .in("id", undeliveredIds); setMessages(prev => prev.map(m => undeliveredIds.includes(m.id) ? { ...m, delivered_at: now } : m )); } } }; fetchMessagesAndMarkRead(); const channel = supabase .channel(`chat:${matchId}`) .on( "postgres_changes", { event: "INSERT", schema: "public", table: "messages", filter: `match_id=eq.${matchId}`, }, async (payload) => { const newMessage = payload.new as Message; const now = new Date().toISOString(); if (newMessage.user_id !== user?.id && isFocusedRef.current) { // Mark as both delivered and read (only if screen is focused) await supabase .from("messages") .update({ delivered_at: now, read_at: now }) .eq("id", newMessage.id); newMessage.delivered_at = now; newMessage.read_at = now; } setMessages((prev) => [newMessage, ...prev]); setPartnerTyping(false); } ) .on( "postgres_changes", { event: "UPDATE", schema: "public", table: "messages", filter: `match_id=eq.${matchId}`, }, (payload) => { const updatedMessage = payload.new as Message; setMessages((prev) => prev.map(m => m.id === updatedMessage.id ? updatedMessage : m) ); } ) .on("broadcast", { event: "typing" }, (payload) => { if (payload.payload.userId !== user?.id) { setPartnerTyping(true); if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); typingTimeoutRef.current = setTimeout(() => { setPartnerTyping(false); }, 3000); } }) .subscribe(); return () => { supabase.removeChannel(channel); if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); }; }, [matchId]); const handleTyping = (text: string) => { setInputText(text); if (text.length > 0) { supabase.channel(`chat:${matchId}`).send({ type: "broadcast", event: "typing", payload: { userId: user?.id }, }); } }; const handleSend = async () => { if (!inputText.trim()) return; if (!user) return; const content = inputText.trim(); setInputText(""); const { error } = await supabase.from("messages").insert({ match_id: matchId, user_id: user.id, content: content, }); if (error) { Alert.alert("Error", "Failed to send message"); setInputText(content); } else { Events.messageSent(); } }; const handlePickMedia = async () => { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.All, quality: 0.7, videoMaxDuration: 300, // 5 min max }); if (!result.canceled) { const asset = result.assets[0]; const isVideo = asset.type === 'video'; uploadMedia(asset.uri, isVideo ? 'video' : 'image'); } }; const handleTakePhoto = async () => { const { status } = await ImagePicker.requestCameraPermissionsAsync(); if (status !== 'granted') { Alert.alert( "Camera Permission", "Please allow camera access to take photos." ); return; } const result = await ImagePicker.launchCameraAsync({ quality: 0.7, }); if (!result.canceled) { uploadMedia(result.assets[0].uri, 'image'); } }; const handleRecordVideo = async () => { const { status } = await ImagePicker.requestCameraPermissionsAsync(); if (status !== 'granted') { Alert.alert( "Camera Permission", "Please allow camera access to record videos." ); return; } const result = await ImagePicker.launchCameraAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Videos, quality: 0.7, videoMaxDuration: 60, // 1 min for camera recording }); if (!result.canceled) { uploadMedia(result.assets[0].uri, 'video'); } }; const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => { if (!user) return; setUploading(true); setUploadStatus({ mediaType, status: mediaType === 'video' ? 'compressing' : 'uploading', thumbnailUri: uri }); try { let fileUri = uri; let fileBody; let ext = mediaType === 'video' ? 'mp4' : 'jpg'; // Compress videos on native platforms before upload if (mediaType === 'video' && Platform.OS !== 'web') { try { console.log('Compressing video...'); fileUri = await VideoCompressor.compress(uri, { compressionMethod: 'auto', maxSize: 720, // Max 720p resolution minimumFileSizeForCompress: 0, // Always compress }); console.log('Video compressed successfully'); ext = 'mp4'; // Compressed videos are always mp4 // Update status to uploading after compression setUploadStatus({ mediaType, status: 'uploading', thumbnailUri: uri }); } catch (compressError) { console.warn('Video compression failed, uploading original:', compressError); // Fall back to original if compression fails fileUri = uri; setUploadStatus({ mediaType, status: 'uploading', thumbnailUri: uri }); } } if (Platform.OS === 'web') { const response = await fetch(fileUri); const blob = await response.blob(); fileBody = blob; // Detect extension from blob type if (mediaType === 'video') { if (blob.type === 'video/mp4') ext = 'mp4'; else if (blob.type === 'video/quicktime') ext = 'mov'; else if (blob.type === 'video/webm') ext = 'webm'; } else { if (blob.type === 'image/png') ext = 'png'; else if (blob.type === 'image/jpeg' || blob.type === 'image/jpg') ext = 'jpg'; else if (blob.type === 'image/gif') ext = 'gif'; else if (blob.type === 'image/webp') ext = 'webp'; } } else { const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 }); fileBody = decode(base64); if (mediaType !== 'video') { // Only use URI extension for non-videos (videos are always mp4 after compression) const uriExt = fileUri.split('.').pop(); if (uriExt && uriExt !== fileUri) { ext = uriExt.toLowerCase(); } } } const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`; const contentType = mediaType === 'video' ? `video/${ext === 'mov' ? 'quicktime' : ext}` : `image/${ext === 'jpg' ? 'jpeg' : ext}`; const { error: uploadError } = await supabase.storage .from("chat-media") .upload(fileName, fileBody, { contentType, upsert: false }); if (uploadError) throw uploadError; // Store just the path, not the full URL (bucket is private) await supabase.from("messages").insert({ match_id: matchId, user_id: user.id, media_path: fileName, media_type: mediaType, }); Events.mediaUploaded(); } catch (error) { Alert.alert("Error", `Failed to upload ${mediaType}`); console.error(error); } finally { setUploading(false); setUploadStatus(null); } }; const revealMessage = async (messageId: string) => { const message = messages.find(m => m.id === messageId); const now = new Date(); // Set expiration 30 days from now for videos only const expiresAt = message?.media_type === 'video' ? new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString() : null; setMessages(prev => prev.map(m => m.id === messageId ? { ...m, media_viewed_at: now.toISOString(), media_expires_at: expiresAt } : m )); await supabase .from("messages") .update({ media_viewed_at: now.toISOString(), media_expires_at: expiresAt }) .eq("id", messageId); }; const renderMessage = ({ item, index }: { item: Message; index: number }) => { const isMe = item.user_id === user?.id; return ( {isMe ? ( {/* Silk highlight */} ) : ( {/* Subtle gradient background */} {/* Silk highlight */} )} ); }; const isYesYes = match?.match_type === "yes_yes"; return ( {/* Ambient Orbs - Gold/Rose for matches feature */} {/* Premium Header */} {/* Gradient background */} {/* Top silk highlight */} router.back()} style={styles.backButton} activeOpacity={0.7} > {partner?.avatar_url ? ( {/* Avatar ring */} ) : ( {partner?.name?.[0]?.toUpperCase() || "P"} )} CONVERSATION WITH {partner?.name || "Partner"} {/* Bottom border */} {/* Premium Match Card */} {match?.question && ( {/* Card gradient background */} {/* Top glow for YES+YES */} {isYesYes && ( )} {/* Match Label */} {isYesYes ? "PERFECT MATCH" : "SOFT MATCH"} {/* Decorative Separator */} {/* Question Text */} "{match.question.text}" {match.question.partner_text && ( "{match.question.partner_text}" )} {/* Response Pills */} {match.responses && match.responses.length > 0 && ( {match.responses.map((response: any) => { const isUser = response.user_id === user?.id; const isYes = response.answer === 'yes'; return ( {isYes ? ( {isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')} ) : ( {isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')} )} ); })} )} {/* Premium border */} )} {/* Messages List */} item.id} inverted style={styles.messageList} contentContainerStyle={styles.listContent} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} ListHeaderComponent={ <> {/* Show uploading skeleton when media is being uploaded */} {uploadStatus && } {/* Show typing indicator when partner is typing */} {partnerTyping && ( {/* Subtle gradient */} )} } ListEmptyComponent={ {/* Premium icon */} {/* Decorative separator */} Start the Conversation Share your thoughts about this match } /> {/* Premium Input Bar */} {/* Gradient background */} {/* Top border glow */} {/* Full Screen Image Modal */} setFullScreenImage(null)} > setFullScreenImage(null)} activeOpacity={0.8} > {fullScreenImageLoading && ( )} {fullScreenImage && ( setFullScreenImageLoading(true)} onLoadEnd={() => setFullScreenImageLoading(false)} /> )} {/* Full Screen Video Modal */} setFullScreenVideo(null)} > setFullScreenVideo(null)} activeOpacity={0.8} > {fullScreenVideo && ( ); } // Animated typing dot component function TypingDot({ delay }: { delay: number }) { const opacity = useSharedValue(0.4); useEffect(() => { opacity.value = withRepeat( withSequence( withTiming(1, { duration: 300 }), withTiming(0.4, { duration: 300 }) ), -1, true ); }, []); const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value, })); return ( 0 ? 4 : 0 }]}> ); } // Uploading skeleton component function UploadingSkeleton({ uploadStatus }: { uploadStatus: UploadStatus }) { const shimmer = useSharedValue(0); useEffect(() => { shimmer.value = withRepeat( withTiming(1, { duration: 1500, easing: Easing.inOut(Easing.ease) }), -1, false ); }, []); const shimmerStyle = useAnimatedStyle(() => ({ opacity: interpolate(shimmer.value, [0, 0.5, 1], [0.3, 0.6, 0.3]), })); if (!uploadStatus) return null; const isVideo = uploadStatus.mediaType === 'video'; const statusText = uploadStatus.status === 'compressing' ? 'Compressing video...' : `Uploading ${isVideo ? 'video' : 'image'}...`; return ( {/* Silk highlight */} {/* Skeleton media placeholder */} {uploadStatus.thumbnailUri && !isVideo ? ( ) : ( )} {/* Overlay with spinner and status */} {statusText} ); } // Video player component with native-like tap behavior function ChatVideoPlayer({ signedUrl, storagePath, urlError, onFullScreen, }: { signedUrl: string | null; storagePath: string; urlError: boolean; onFullScreen: (uri: string) => void; }) { const videoRef = useRef