import { useState, useEffect, useRef, useCallback } from "react"; import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, Image, ActivityIndicator, Alert, Modal, Dimensions } from "react-native"; 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 } 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 { decode } from 'base64-arraybuffer'; import { Database } from "../../../src/types/supabase"; import { GradientBackground } from "../../../src/components/ui"; import { colors, gradients, spacing, radius, typography } from "../../../src/theme"; type Message = Database["public"]["Tables"]["messages"]["Row"]; export default function ChatScreen() { const { id } = useLocalSearchParams(); const matchId = id as string; const router = useRouter(); const insets = useSafeAreaInsets(); const { user } = useAuthStore(); const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore(); const [messages, setMessages] = useState([]); const [match, setMatch] = useState(null); const [inputText, setInputText] = useState(""); const [uploading, setUploading] = useState(false); const [partnerTyping, setPartnerTyping] = useState(false); const [fullScreenImage, setFullScreenImage] = useState(null); const [fullScreenImageLoading, setFullScreenImageLoading] = useState(true); const flatListRef = useRef(null); const typingTimeoutRef = useRef(null); const isFocusedRef = useRef(false); // 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); } 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); } }; const handlePickImage = async () => { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: true, quality: 0.7, }); if (!result.canceled) { uploadImage(result.assets[0].uri); } }; const uploadImage = async (uri: string) => { if (!user) return; setUploading(true); try { let fileBody; let ext = "jpg"; if (Platform.OS === 'web') { const response = await fetch(uri); const blob = await response.blob(); fileBody = blob; 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(uri, { encoding: FileSystem.EncodingType.Base64 }); fileBody = decode(base64); const uriExt = uri.split('.').pop(); if (uriExt && uriExt !== uri) { ext = uriExt.toLowerCase(); } } const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`; const contentType = `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, }); } catch (error) { Alert.alert("Error", "Failed to upload image"); console.error(error); } finally { setUploading(false); } }; const revealMessage = async (messageId: string) => { setMessages(prev => prev.map(m => m.id === messageId ? { ...m, media_viewed_at: new Date().toISOString() } : m )); await supabase .from("messages") .update({ media_viewed_at: new Date().toISOString() }) .eq("id", messageId); }; const renderMessage = ({ item, index }: { item: Message; index: number }) => { const isMe = item.user_id === user?.id; return ( {isMe ? ( ) : ( )} ); }; const isYesYes = match?.match_type === "yes_yes"; return ( {/* Custom Header */} router.back()} style={styles.backButton} activeOpacity={0.7} > Chat {/* Question Header */} {match?.question && ( {match.question.text} {match.question.partner_text ? ` / ${match.question.partner_text}` : ""} {match.responses && match.responses.length > 0 && ( {match.responses.map((response: any) => ( {response.user_id === user?.id ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')} {response.answer} ))} )} )} {/* Messages List */} item.id} inverted style={styles.messageList} contentContainerStyle={styles.listContent} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} ListHeaderComponent={ partnerTyping ? ( ) : null } ListEmptyComponent={ Start the conversation! } /> {/* Input Bar */} {/* Full Screen Image Modal */} setFullScreenImage(null)} > setFullScreenImage(null)} activeOpacity={0.8} > {fullScreenImageLoading && ( )} {fullScreenImage && ( setFullScreenImageLoading(true)} onLoadEnd={() => setFullScreenImageLoading(false)} /> )} ); } // Extract storage path from media_path (handles both old full URLs and new path-only format) function getStoragePath(mediaPath: string): string { if (mediaPath.startsWith('http')) { // Old format: extract path from full URL const match = mediaPath.match(/\/chat-media\/(.+)$/); return match ? match[1] : mediaPath; } return mediaPath; } function MessageContent({ item, isMe, revealMessage, onImagePress, }: { item: Message; isMe: boolean; revealMessage: (id: string) => void; onImagePress: (uri: string) => void; }) { const [signedUrl, setSignedUrl] = useState(null); const [urlError, setUrlError] = useState(false); useEffect(() => { if (!item.media_path) return; const fetchSignedUrl = async () => { const storagePath = getStoragePath(item.media_path!); const { data, error } = await supabase.storage .from('chat-media') .createSignedUrl(storagePath, 3600); // 1 hour expiry if (error) { console.error('Failed to get signed URL:', error); setUrlError(true); return; } if (data?.signedUrl) { setSignedUrl(data.signedUrl); } else { setUrlError(true); } }; fetchSignedUrl(); }, [item.media_path]); if (item.media_path) { const isViewed = !!item.media_viewed_at; const canOpenFullScreen = isMe || isViewed; return ( canOpenFullScreen && signedUrl && onImagePress(signedUrl)} disabled={!canOpenFullScreen || !signedUrl} > {signedUrl ? ( ) : urlError ? ( Image unavailable ) : ( )} {/* Reveal overlay for recipient */} {(!isMe && !isViewed) && ( revealMessage(item.id)} > Tap to reveal )} {/* Viewed indicator for sender */} {isMe && isViewed && ( Viewed )} ); } return ( <> {item.content} ); } function MessageMeta({ item, isMe }: { item: Message; isMe: boolean }) { // 3-state read receipts: // - Single tick (grey) = sent to server // - Double tick (grey) = delivered to partner's device // - Double tick (blue) = read by partner const isDelivered = !!item.delivered_at; const isRead = !!item.read_at; const getStatusIcon = () => { if (isRead) { // Double tick blue - read return ; } else if (isDelivered) { // Double tick grey - delivered but not read return ; } else { // Single tick grey - sent but not delivered return ; } }; return ( {new Date(item.created_at!).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {isMe && ( {getStatusIcon()} )} ); } function InputBar({ inputText, uploading, onChangeText, onSend, onPickImage, }: { inputText: string; uploading: boolean; onChangeText: (text: string) => void; onSend: () => void; onPickImage: () => void; }) { return ( {uploading ? ( ) : ( )} ); } const styles = StyleSheet.create({ keyboardAvoiding: { flex: 1, }, header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: spacing.sm, paddingBottom: spacing.sm, backgroundColor: colors.backgroundLight, borderBottomWidth: 1, borderBottomColor: colors.glass.border, }, backButton: { width: 40, height: 40, borderRadius: 20, justifyContent: 'center', alignItems: 'center', }, headerTitle: { ...typography.headline, color: colors.text, flex: 1, textAlign: 'center', }, headerSpacer: { width: 40, }, questionHeader: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: spacing.md, paddingVertical: spacing.sm, backgroundColor: colors.glass.backgroundLight, borderBottomWidth: 1, borderBottomColor: colors.glass.border, }, messageList: { flex: 1, }, questionIconGradient: { width: 32, height: 32, borderRadius: 16, justifyContent: 'center', alignItems: 'center', marginRight: spacing.sm, }, questionContent: { flex: 1, }, questionText: { ...typography.subhead, color: colors.text, fontWeight: '500', lineHeight: 20, }, answersRow: { flexDirection: 'row', gap: spacing.md, marginTop: spacing.xs, }, answerChip: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, }, answerName: { ...typography.caption2, color: colors.textSecondary, }, answerDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.textTertiary, }, answerDotYes: { backgroundColor: colors.success, }, answerDotMaybe: { backgroundColor: colors.warning, }, answerText: { ...typography.caption2, color: colors.textSecondary, textTransform: 'capitalize', }, listContent: { flexGrow: 1, padding: spacing.md, }, messageRow: { marginBottom: spacing.sm, flexDirection: "row", }, myMessageRow: { justifyContent: "flex-end", }, theirMessageRow: { justifyContent: "flex-start", }, bubble: { maxWidth: "78%", padding: spacing.md, borderRadius: radius.lg, }, myBubble: { borderBottomRightRadius: radius.xs, }, theirBubble: { backgroundColor: colors.glass.backgroundLight, borderWidth: 1, borderColor: colors.glass.border, borderBottomLeftRadius: radius.xs, }, messageText: { ...typography.body, color: colors.text, lineHeight: 22, }, messageImage: { width: 200, height: 200, borderRadius: radius.md, backgroundColor: colors.glass.background, }, metaContainer: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", marginTop: spacing.xs, }, timestamp: { ...typography.caption2, color: "rgba(255, 255, 255, 0.5)", }, readStatus: { marginLeft: spacing.xs, }, viewedBadge: { flexDirection: 'row', alignItems: 'center', gap: 4, position: 'absolute', top: spacing.sm, left: spacing.sm, backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: spacing.sm, paddingVertical: 3, borderRadius: radius.sm, }, viewedText: { ...typography.caption2, color: colors.success, fontWeight: '600', }, emptyChat: { alignItems: 'center', justifyContent: 'center', paddingVertical: spacing.xxl, }, emptyChatIcon: { width: 56, height: 56, borderRadius: 28, justifyContent: 'center', alignItems: 'center', marginBottom: spacing.md, }, emptyChatText: { ...typography.subhead, color: colors.textSecondary, }, inputWrapper: { backgroundColor: colors.backgroundLight, borderTopWidth: 1, borderTopColor: colors.glass.border, }, inputContainer: { flexDirection: "row", alignItems: "flex-end", padding: spacing.sm, gap: spacing.sm, }, attachButton: { width: 40, height: 40, borderRadius: 20, backgroundColor: colors.glass.background, justifyContent: 'center', alignItems: 'center', }, inputFieldWrapper: { flex: 1, backgroundColor: colors.glass.background, borderRadius: radius.lg, borderWidth: 1, borderColor: colors.glass.border, }, input: { paddingHorizontal: spacing.md, paddingVertical: spacing.sm, color: colors.text, fontSize: 16, maxHeight: 100, }, sendButton: { width: 40, height: 40, borderRadius: 20, justifyContent: "center", alignItems: "center", }, typingContainer: { paddingHorizontal: spacing.sm, paddingVertical: spacing.xs, }, typingBubble: { backgroundColor: colors.glass.backgroundLight, borderWidth: 1, borderColor: colors.glass.border, borderRadius: radius.lg, borderBottomLeftRadius: radius.xs, paddingHorizontal: spacing.md, paddingVertical: spacing.sm, alignSelf: 'flex-start', }, typingDots: { flexDirection: 'row', gap: 4, }, typingDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: colors.textTertiary, }, typingDot1: { opacity: 0.4, }, typingDot2: { opacity: 0.6, }, typingDot3: { opacity: 0.8, }, revealOverlay: { ...StyleSheet.absoluteFillObject, justifyContent: "center", alignItems: "center", backgroundColor: "rgba(0,0,0,0.5)", borderRadius: radius.md, }, revealContent: { alignItems: "center", backgroundColor: "rgba(0,0,0,0.6)", padding: spacing.md, borderRadius: radius.md, }, revealText: { ...typography.caption1, color: colors.text, fontWeight: "600", marginTop: spacing.xs, }, fullScreenOverlay: { flex: 1, backgroundColor: "rgba(0, 0, 0, 0.95)", justifyContent: "center", alignItems: "center", }, fullScreenCloseButton: { position: "absolute", top: 50, right: 20, width: 44, height: 44, borderRadius: 22, backgroundColor: "rgba(255, 255, 255, 0.15)", justifyContent: "center", alignItems: "center", zIndex: 10, }, fullScreenImage: { width: Dimensions.get("window").width, height: Dimensions.get("window").height * 0.8, }, fullScreenSpinner: { position: "absolute", }, });