import { useState, useEffect, useRef } from "react"; import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, Image, ActivityIndicator, Alert } from "react-native"; import { useLocalSearchParams, useRouter, Stack } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import { BlurView } from "expo-blur"; import { LinearGradient } from "expo-linear-gradient"; import Animated, { FadeIn, FadeInUp } from "react-native-reanimated"; import { supabase } from "../../../src/lib/supabase"; import { useAuthStore } 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, GlassCard } from "../../../src/components/ui"; import { colors, gradients, spacing, radius, typography, blur, shadows } 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 { user } = useAuthStore(); const [messages, setMessages] = useState([]); const [match, setMatch] = useState(null); const [inputText, setInputText] = useState(""); const [uploading, setUploading] = useState(false); const [partnerTyping, setPartnerTyping] = useState(false); const flatListRef = useRef(null); const typingTimeoutRef = useRef(null); 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 unreadIds = data .filter(m => m.user_id !== user?.id && !m.read_at) .map(m => m.id); if (unreadIds.length > 0) { await supabase .from("messages") .update({ read_at: new Date().toISOString() }) .in("id", unreadIds); setMessages(prev => prev.map(m => unreadIds.includes(m.id) ? { ...m, read_at: new Date().toISOString() } : 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; if (newMessage.user_id !== user?.id) { await supabase .from("messages") .update({ read_at: new Date().toISOString() }) .eq("id", newMessage.id); newMessage.read_at = new Date().toISOString(); } 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; const { data: { publicUrl } } = supabase.storage .from("chat-media") .getPublicUrl(fileName); await supabase.from("messages").insert({ match_id: matchId, user_id: user.id, media_path: publicUrl, }); } 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 ? ( ) : ( )} ); }; return ( {/* Sticky top bar with question and answers */} {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 || 'Partner')}: {response.answer} ))} )} )} item.id} inverted contentContainerStyle={styles.listContent} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} ListHeaderComponent={ partnerTyping ? ( Partner is typing... ) : null } /> {/* Input bar with glass effect */} {Platform.OS === 'ios' ? ( ) : ( )} ); } function MessageContent({ item, isMe, revealMessage, }: { item: Message; isMe: boolean; revealMessage: (id: string) => void; }) { if (item.media_path) { return ( {(!isMe && !item.media_viewed_at) && ( revealMessage(item.id)} > Tap to reveal )} ); } return ( <> {item.content} ); } function MessageMeta({ item, isMe }: { item: Message; isMe: boolean }) { return ( {new Date(item.created_at!).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {isMe && ( )} ); } 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, }, listContent: { padding: spacing.md, paddingBottom: spacing.lg, }, messageRow: { marginBottom: spacing.sm, flexDirection: "row", }, myMessageRow: { justifyContent: "flex-end", }, theirMessageRow: { justifyContent: "flex-start", }, bubble: { maxWidth: "78%", padding: spacing.md, borderRadius: radius.xl, ...shadows.sm, }, myBubble: { borderBottomRightRadius: radius.xs, }, theirBubble: { backgroundColor: colors.glass.backgroundLight, borderWidth: 1, borderColor: colors.glass.border, borderBottomLeftRadius: radius.xs, }, messageText: { ...typography.body, color: colors.text, }, 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.6)", }, readIcon: { marginLeft: spacing.xs, }, inputWrapper: { borderTopWidth: 1, borderTopColor: colors.glass.border, }, inputWrapperAndroid: { backgroundColor: colors.glass.backgroundLight, }, inputContainer: { flexDirection: "row", alignItems: "flex-end", padding: spacing.sm, paddingBottom: Platform.OS === "ios" ? spacing.xl : spacing.sm, }, attachButton: { padding: spacing.sm, marginBottom: spacing.xs, }, inputFieldWrapper: { flex: 1, backgroundColor: colors.glass.background, borderRadius: radius.xl, borderWidth: 1, borderColor: colors.glass.border, marginHorizontal: spacing.sm, }, input: { paddingHorizontal: spacing.md, paddingVertical: spacing.sm, color: colors.text, fontSize: 16, maxHeight: 100, }, sendButton: { width: 40, height: 40, borderRadius: 20, backgroundColor: colors.glass.background, justifyContent: "center", alignItems: "center", marginBottom: spacing.xs, }, sendButtonActive: { backgroundColor: colors.primary, }, typingContainer: { paddingHorizontal: spacing.md, paddingVertical: spacing.sm, }, typingText: { ...typography.caption1, color: colors.textTertiary, fontStyle: "italic", }, revealOverlay: { ...StyleSheet.absoluteFillObject, justifyContent: "center", alignItems: "center", backgroundColor: "rgba(0,0,0,0.4)", 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, }, stickyTopBar: { marginHorizontal: spacing.md, marginTop: spacing.sm, marginBottom: spacing.xs, }, stickyContent: { padding: spacing.md, }, stickyQuestionText: { ...typography.subhead, color: colors.text, fontWeight: "600", textAlign: "center", marginBottom: spacing.sm, }, stickyAnswersContainer: { flexDirection: "row", justifyContent: "center", gap: spacing.sm, }, answerBadge: { backgroundColor: colors.primaryLight, paddingHorizontal: spacing.sm, paddingVertical: spacing.xs, borderRadius: radius.sm, }, stickyAnswerText: { ...typography.caption1, color: colors.text, }, });