import { useState, useEffect, useCallback } from 'react'; import { useParams, Link } from 'react-router-dom'; import { supabase, supabaseConfig } from '@/config'; import { useAuth } from '@/contexts/AuthContext'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ArrowLeft, Check, CheckCheck, Eye, Video as VideoIcon } from 'lucide-react'; import { format, isToday, isYesterday } from 'date-fns'; import { cn } from '@/lib/utils'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { PaginationControls } from '@/components/ui/pagination'; interface Profile { id: string; name: string | null; avatar_url: string | null; } interface Match { id: string; match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe'; question: { id: string; text: string; }; } interface Message { id: string; user_id: string; content: string | null; media_path: string | null; media_type: 'image' | 'video' | null; created_at: string; read_at: string | null; media_viewed_at: string | null; version?: number | null; encrypted_content?: string | null; encryption_iv?: string | null; keys_metadata?: Record | null; } const matchTypeLabels = { yes_yes: 'Both Yes!', yes_maybe: 'Yes + Maybe', maybe_maybe: 'Both Maybe', }; function formatMessageTime(date: Date): string { if (isToday(date)) { return format(date, 'h:mm a'); } else if (isYesterday(date)) { return 'Yesterday ' + format(date, 'h:mm a'); } return format(date, 'MMM d, h:mm a'); } function ChatImage({ messageId }: { messageId: string }) { const { session, isSuperAdmin } = useAuth(); const [url, setUrl] = useState(null); const [error, setError] = useState(null); useEffect(() => { if (!messageId) return; if (!isSuperAdmin) { setError('Decryption requires super admin'); return; } if (!session?.access_token) { setError('Not authenticated'); return; } let cancelled = false; let objectUrl: string | null = null; const load = async () => { try { setError(null); setUrl(null); const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, { method: 'POST', headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}`, }, body: JSON.stringify({ messageId }), }); if (!res.ok) { const raw = await res.text().catch(() => ''); let message = raw; try { const parsed = JSON.parse(raw) as { error?: unknown }; if (typeof parsed?.error === 'string') message = parsed.error; } catch { // ignore } throw new Error(message || `Failed to load media (${res.status})`); } const blob = await res.blob(); objectUrl = URL.createObjectURL(blob); if (cancelled) return; setUrl(objectUrl); } catch (err) { if (cancelled) return; setError(err instanceof Error ? err.message : 'Failed to load'); } }; load(); return () => { cancelled = true; if (objectUrl) URL.revokeObjectURL(objectUrl); }; }, [messageId, isSuperAdmin, session?.access_token]); if (error) { return (
Failed to load {error}
); } if (!url) return ; return ( Shared media window.open(url, '_blank')} /> ); } function ChatVideo({ messageId }: { messageId: string }) { const { session, isSuperAdmin } = useAuth(); const [url, setUrl] = useState(null); const [error, setError] = useState(null); useEffect(() => { if (!messageId) return; if (!isSuperAdmin) { setError('Decryption requires super admin'); return; } if (!session?.access_token) { setError('Not authenticated'); return; } let cancelled = false; let objectUrl: string | null = null; const load = async () => { try { setError(null); setUrl(null); const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, { method: 'POST', headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}`, }, body: JSON.stringify({ messageId }), }); if (!res.ok) { const raw = await res.text().catch(() => ''); let message = raw; try { const parsed = JSON.parse(raw) as { error?: unknown }; if (typeof parsed?.error === 'string') message = parsed.error; } catch { // ignore } throw new Error(message || `Failed to load media (${res.status})`); } const blob = await res.blob(); objectUrl = URL.createObjectURL(blob); if (cancelled) return; setUrl(objectUrl); } catch (err) { if (cancelled) return; setError(err instanceof Error ? err.message : 'Failed to load'); } }; load(); return () => { cancelled = true; if (objectUrl) URL.revokeObjectURL(objectUrl); }; }, [messageId, isSuperAdmin, session?.access_token]); if (error) { return (
Failed to load video {error}
); } if (!url) { return (
); } return (
); } function DecryptedMessageText({ message }: { message: Message }) { const { isSuperAdmin } = useAuth(); const [text, setText] = useState(null); const [isDecrypting, setIsDecrypting] = useState(false); useEffect(() => { const version = message.version ?? 1; // v1: legacy plaintext if (version !== 2) { setText(message.content ?? null); setIsDecrypting(false); return; } // v2: encrypted if (!message.encrypted_content) { setText(null); setIsDecrypting(false); return; } if (!isSuperAdmin) { setText('[Encrypted]'); setIsDecrypting(false); return; } let cancelled = false; const decrypt = async () => { setIsDecrypting(true); try { const { data, error } = await supabase.functions.invoke('admin-decrypt-message', { body: { messageId: message.id }, }); if (cancelled) return; if (error) { setText('[Unable to decrypt]'); return; } setText((data as { content?: string | null } | null)?.content ?? null); } catch (err) { if (cancelled) return; setText('[Unable to decrypt]'); } finally { if (!cancelled) setIsDecrypting(false); } }; decrypt(); return () => { cancelled = true; }; }, [isSuperAdmin, message.content, message.encrypted_content, message.id, message.version]); const shouldRender = Boolean(message.content || message.encrypted_content); if (!shouldRender) return null; return (

{isDecrypting ? 'Loading...' : (text ?? '')}

); } export function MatchChatPage() { const { userId, matchId } = useParams<{ userId: string; matchId: string }>(); const [user, setUser] = useState(null); const [partner, setPartner] = useState(null); const [match, setMatch] = useState(null); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(50); const [totalCount, setTotalCount] = useState(0); const fetchData = useCallback(async () => { if (!userId || !matchId) return; setLoading(true); try { // Fetch user profile const { data: userData } = await supabase .from('profiles') .select('id, name, avatar_url, couple_id') .eq('id', userId) .single(); setUser(userData); // Fetch partner if (userData?.couple_id) { const { data: partnerData } = await supabase .from('profiles') .select('id, name, avatar_url') .eq('couple_id', userData.couple_id) .neq('id', userId) .single(); setPartner(partnerData); } else { setPartner(null); } // Fetch match with question const { data: matchData } = await supabase .from('matches') .select(` id, match_type, question:questions (id, text) `) .eq('id', matchId) .single(); setMatch(matchData as unknown as Match); const from = (page - 1) * pageSize; const to = from + pageSize - 1; // Fetch messages const { data: messageData, error: messagesError, count } = await supabase .from('messages') .select('*', { count: 'exact' }) .eq('match_id', matchId) .order('created_at', { ascending: false }) .range(from, to); if (messagesError) throw messagesError; setTotalCount(count || 0); const orderedMessages = (messageData || []).slice().reverse(); setMessages(orderedMessages); } catch (error) { console.error('Failed to load chat data:', error); } finally { setLoading(false); } }, [userId, matchId, page, pageSize]); // Real-time subscription for messages in this chat const { status: realtimeStatus } = useRealtimeSubscription({ table: 'messages', filter: matchId ? `match_id=eq.${matchId}` : undefined, enabled: !!matchId, onInsert: useCallback(() => { fetchData(); }, [fetchData]), onUpdate: useCallback(() => { fetchData(); }, [fetchData]), onDelete: useCallback(() => { fetchData(); }, [fetchData]), }); useEffect(() => { fetchData(); }, [fetchData]); useEffect(() => { const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); if (page > totalPages) { setPage(totalPages); } }, [page, pageSize, totalCount]); useEffect(() => { setPage(1); }, [matchId]); const getProfile = (senderId: string): Profile | null => { if (senderId === user?.id) return user; if (senderId === partner?.id) return partner; return null; }; if (loading) { return (
); } if (!match || !user) { return (

Chat not found

); } return (
{/* Header */}

Match Chat

{matchTypeLabels[match.match_type]}

{match.question?.text}

{/* Participants */} Participants
{user.name?.charAt(0).toUpperCase() || 'U'}

{user.name || 'Unnamed'}

Current User

{partner && (
{partner.name?.charAt(0).toUpperCase() || 'P'}

{partner.name || 'Unnamed'}

View Profile
)}
{/* Messages */} Messages ({totalCount}) {messages.length === 0 ? (

No messages in this chat yet

) : (
{messages.map((message) => { const sender = getProfile(message.user_id); const isUserMessage = message.user_id === userId; return (
{sender?.name?.charAt(0).toUpperCase() || '?'}
{message.media_path && (
{message.media_type === 'video' ? ( ) : ( )} {message.media_viewed_at && ( Viewed )}
)}
{formatMessageTime(new Date(message.created_at))} {isUserMessage && ( message.read_at ? ( ) : ( ) )}
); })}
)} {totalCount > 0 && (
{ setPage(1); setPageSize(size); }} />
)}
); }