import React, { useRef, useState, useEffect } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { Image } from 'expo-image'; import { Audio } from 'expo-av'; import Animated, { FadeInDown } from 'react-native-reanimated'; import { DecorativeSeparator } from '../../../components/ui'; import { colors, gradients, spacing, radius, typography } from '../../../theme'; import { Match } from '../types'; import { ResponseData } from '@sauci/shared'; import { getCachedSignedUrl, getStoragePath } from '../../../lib/imageCache'; // Premium color palette const ACCENT = colors.premium.gold; const ACCENT_RGBA = 'rgba(212, 175, 55, '; interface MatchCardProps { match: Match | null; user: { id: string } | null; userName?: string; partnerName?: string; onImagePress?: (uri: string) => void; } /** * Helper function to get display text for who_likely choices */ function getChoiceDisplay(chosenUserId: string, userId: string, userName: string, partnerName: string): string { return chosenUserId === userId ? userName : partnerName; } /** * Component to render text_answer response summary */ const TextAnswerDisplay: React.FC<{ responseSummary: Record; userId: string; partnerName: string; }> = ({ responseSummary, userId, partnerName }) => { const entries = Object.entries(responseSummary); return ( {entries.map(([responderId, data]) => { if (data.type !== 'text_answer') return null; const isUser = responderId === userId; const name = isUser ? 'You' : partnerName; return ( {name} "{data.text}" ); })} ); }; /** * Component to render photo response summary */ const PhotoDisplay: React.FC<{ responseSummary: Record; userId: string; partnerName: string; onImagePress?: (uri: string) => void; }> = ({ responseSummary, userId, partnerName, onImagePress }) => { const [photoUrls, setPhotoUrls] = useState>({}); const [loadingUrls, setLoadingUrls] = useState(true); const [loadingImages, setLoadingImages] = useState>({}); useEffect(() => { const fetchPhotoUrls = async () => { // Fetch all signed URLs in parallel for faster loading const photoEntries: Array<[string, { type: 'photo'; media_path: string }]> = []; for (const [responderId, data] of Object.entries(responseSummary)) { if (data.type === 'photo') { photoEntries.push([responderId, data]); } } const urlPromises = photoEntries.map(async ([responderId, data]) => { try { const storagePath = getStoragePath(data.media_path); const signedUrl = await getCachedSignedUrl(storagePath, 'response-media'); return { responderId, signedUrl }; } catch (err) { console.error('Failed to get photo URL:', err); return { responderId, signedUrl: null }; } }); const results = await Promise.all(urlPromises); const urls: Record = {}; // Collect URLs and prefetch images const prefetchPromises: Promise[] = []; for (const { responderId, signedUrl } of results) { if (signedUrl) { urls[responderId] = signedUrl; // Prefetch image for faster display prefetchPromises.push(Image.prefetch(signedUrl)); } } // Set URLs immediately, prefetch in background setPhotoUrls(urls); setLoadingUrls(false); // Initialize loading state for each image const initialLoading: Record = {}; for (const responderId of Object.keys(urls)) { initialLoading[responderId] = true; } setLoadingImages(initialLoading); // Wait for prefetch to complete (non-blocking) Promise.all(prefetchPromises).catch(() => { // Prefetch failures are non-critical }); }; fetchPhotoUrls(); }, [responseSummary]); const handleImageLoad = (responderId: string) => { setLoadingImages(prev => ({ ...prev, [responderId]: false })); }; const entries = Object.entries(responseSummary); return ( {entries.map(([responderId, data]) => { if (data.type !== 'photo') return null; const isUser = responderId === userId; const name = isUser ? 'You' : partnerName; const photoUrl = photoUrls[responderId]; const isImageLoading = loadingImages[responderId]; return ( {name} photoUrl && onImagePress?.(photoUrl)} disabled={!photoUrl} activeOpacity={0.8} accessibilityRole="button" accessibilityLabel={`View ${name}'s photo full screen`} > {loadingUrls ? ( ) : photoUrl ? ( <> handleImageLoad(responderId)} /> {isImageLoading && ( )} ) : ( )} ); })} ); }; /** * Component to render who_likely response summary */ const WhoLikelyDisplay: React.FC<{ responseSummary: Record; userId: string; userName: string; partnerName: string; }> = ({ responseSummary, userId, userName, partnerName }) => { const entries = Object.entries(responseSummary); return ( {entries.map(([responderId, data]) => { if (data.type !== 'who_likely') return null; const isUser = responderId === userId; const voterName = isUser ? 'You' : partnerName; const choiceName = getChoiceDisplay(data.chosen_user_id, userId, userName, partnerName); return ( {voterName} chose: {choiceName} ); })} ); }; /** * Component to render audio response summary */ const AudioDisplay: React.FC<{ responseSummary: Record; userId: string; partnerName: string; }> = ({ responseSummary, userId, partnerName }) => { const [audioUrls, setAudioUrls] = useState>({}); const [playingId, setPlayingId] = useState(null); const [sounds, setSounds] = useState>({}); useEffect(() => { const fetchAudioUrls = async () => { const urls: Record = {}; for (const [responderId, data] of Object.entries(responseSummary)) { if (data.type === 'audio' && data.media_path) { try { const storagePath = getStoragePath(data.media_path); const signedUrl = await getCachedSignedUrl(storagePath, 'response-media'); if (signedUrl) { urls[responderId] = signedUrl; } } catch (err) { console.error('Failed to get audio URL:', err); } } } setAudioUrls(urls); }; fetchAudioUrls(); // Cleanup sounds on unmount return () => { Object.values(sounds).forEach(sound => { sound.unloadAsync(); }); }; }, [responseSummary]); const formatDuration = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }; const handlePlayPause = async (responderId: string) => { const audioUrl = audioUrls[responderId]; if (!audioUrl) return; // If already playing this one, pause it if (playingId === responderId && sounds[responderId]) { await sounds[responderId].pauseAsync(); setPlayingId(null); return; } // Stop any currently playing audio if (playingId && sounds[playingId]) { await sounds[playingId].stopAsync(); } // Play the selected audio try { let sound = sounds[responderId]; if (!sound) { const { sound: newSound } = await Audio.Sound.createAsync( { uri: audioUrl }, { shouldPlay: false }, (status) => { if (status.isLoaded && status.didJustFinish) { setPlayingId(null); } } ); sound = newSound; setSounds(prev => ({ ...prev, [responderId]: sound })); } await sound.playFromPositionAsync(0); setPlayingId(responderId); } catch (err) { console.error('Failed to play audio:', err); } }; const entries = Object.entries(responseSummary); return ( {entries.map(([responderId, data]) => { if (data.type !== 'audio') return null; const isUser = responderId === userId; const name = isUser ? 'You' : partnerName; const audioUrl = audioUrls[responderId]; const isPlaying = playingId === responderId; return ( {name} handlePlayPause(responderId)} disabled={!audioUrl} > {[...Array(5)].map((_, i) => ( ))} {formatDuration(data.duration_seconds)} ); })} ); }; const MatchCardComponent: React.FC = ({ match, user, userName = 'You', partnerName = 'Partner', onImagePress }) => { // Track if animation has already played to prevent re-animation on re-renders const hasAnimatedRef = useRef(false); const shouldAnimate = !hasAnimatedRef.current; // Mark as animated after first render if (!hasAnimatedRef.current) { hasAnimatedRef.current = true; } if (!match?.question) return null; const isYesYes = match?.match_type === 'yes_yes'; const isBothAnswered = match?.match_type === 'both_answered'; // Determine the response type from response_summary const getResponseType = (): ResponseData['type'] | null => { if (!match.response_summary) return null; const firstResponse = Object.values(match.response_summary)[0]; return firstResponse?.type || null; }; const responseType = getResponseType(); // Only animate on first render to prevent re-animation when list re-renders const enteringAnimation = shouldAnimate ? FadeInDown.delay(100).duration(400).springify() : undefined; return ( {/* Card gradient background - removed for flat style */} {/* Top glow for YES+YES - removed {isYesYes && ( )} */} {/* Match Label */} {isYesYes ? "PERFECT MATCH" : isBothAnswered ? "BOTH ANSWERED" : "SOFT MATCH"} {/* Decorative Separator */} {/* Question and Responses - Show based on match type and response type */} {isBothAnswered && match.response_summary && responseType ? ( // Render response summary for non-swipe question types {/* Show the question text */} "{match.question.text}" {/* Render based on response type */} {responseType === 'text_answer' && ( )} {responseType === 'photo' && ( )} {responseType === 'audio' && ( )} {responseType === 'who_likely' && ( )} ) : match.responses && match.responses.length > 0 ? ( {(() => { // Sort responses by created_at to determine who answered first const sortedResponses = [...match.responses].sort( (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() ); const isTwoPart = !!match.question?.partner_text; return sortedResponses.map((response, index) => { const isUser = response.user_id === user?.id; const isYes = response.answer === 'yes'; const name = isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner'); // First responder sees main text, second sees partner_text const questionText = isTwoPart && index === 1 ? match.question?.partner_text : match.question?.text; return ( {/* Name and Answer Badge */} {name} {isYes ? ( YES ) : ( MAYBE )} {/* Question they saw */} "{questionText}" ); }); })()} ) : ( "{match.question.text}" )} {/* Premium border removed */} ); }; // ... const styles = StyleSheet.create({ matchCardContainer: { marginHorizontal: spacing.md, marginTop: spacing.md, borderRadius: radius.lg, overflow: 'hidden', borderWidth: 1, borderColor: colors.border, backgroundColor: colors.backgroundLight, }, matchCard: { borderRadius: radius.lg, paddingVertical: spacing.md, paddingHorizontal: spacing.md, alignItems: 'center', overflow: 'hidden', backgroundColor: colors.backgroundLight, }, matchCardGlow: { display: 'none', }, // ... responseCard: { backgroundColor: colors.background, // Nested borderRadius: radius.md, padding: spacing.sm, borderWidth: 1, borderColor: colors.border, }, // ... matchCardBorder: { display: 'none', }, matchCardBorderYesYes: { display: 'none', }, // ... textBubbleUser: { backgroundColor: colors.background, borderWidth: 1, borderColor: colors.primary, alignSelf: 'flex-end', }, textBubblePartner: { backgroundColor: colors.background, borderWidth: 1, borderColor: colors.border, alignSelf: 'flex-start', }, // ... photoPlaceholder: { width: '100%', height: '100%', backgroundColor: colors.background, justifyContent: 'center', alignItems: 'center', }, // ... whoLikelyCard: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: colors.background, borderRadius: radius.md, padding: spacing.sm, borderWidth: 1, borderColor: colors.border, }, // ... audioPlayer: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.background, borderRadius: radius.lg, padding: spacing.sm, gap: spacing.sm, borderWidth: 1, borderColor: colors.border, }, audioPlayButton: { width: 36, height: 36, borderRadius: 18, backgroundColor: colors.backgroundLight, justifyContent: 'center', alignItems: 'center', borderWidth: 1, borderColor: colors.border, }, matchLabel: { ...typography.caption2, fontWeight: '700', letterSpacing: 2, color: colors.premium.rose, marginBottom: spacing.xs, }, matchLabelYesYes: { color: ACCENT, }, matchQuestionText: { ...typography.subhead, color: colors.text, textAlign: 'center', fontStyle: 'italic', lineHeight: 20, }, responsesContainer: { width: '100%', gap: spacing.sm, }, responseHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: spacing.xs, }, responseName: { ...typography.caption1, fontWeight: '600', color: colors.textSecondary, textTransform: 'uppercase', letterSpacing: 1, }, answerBadge: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: spacing.sm, paddingVertical: 2, gap: 3, borderRadius: radius.full, }, answerBadgeText: { ...typography.caption2, fontWeight: '700', color: colors.text, letterSpacing: 0.5, }, answerBadgeMaybe: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: spacing.sm, paddingVertical: 2, gap: 3, backgroundColor: 'rgba(243, 156, 18, 0.15)', borderWidth: 1, borderColor: 'rgba(243, 156, 18, 0.3)', borderRadius: radius.full, }, answerBadgeTextMaybe: { ...typography.caption2, fontWeight: '700', color: colors.warning, letterSpacing: 0.5, }, responseQuestionText: { ...typography.footnote, color: colors.text, fontStyle: 'italic', lineHeight: 18, }, // Response summary styles for non-swipe question types responseSummaryWrapper: { width: '100%', alignItems: 'center', }, responseSummaryContainer: { width: '100%', gap: spacing.sm, }, // Text answer styles textResponseCard: { width: '100%', gap: spacing.xs, }, textResponseName: { ...typography.caption2, fontWeight: '600', color: colors.textSecondary, textTransform: 'uppercase', letterSpacing: 1, }, textBubble: { paddingHorizontal: spacing.md, paddingVertical: spacing.sm, borderRadius: radius.md, maxWidth: '100%', }, textBubbleText: { ...typography.subhead, color: colors.text, fontStyle: 'italic', lineHeight: 20, }, // Photo styles photoContainer: { flexDirection: 'row', justifyContent: 'center', gap: spacing.md, width: '100%', }, photoCard: { alignItems: 'center', gap: spacing.xs, flex: 1, maxWidth: 120, }, photoName: { ...typography.caption2, fontWeight: '600', color: colors.textSecondary, textTransform: 'uppercase', letterSpacing: 1, }, photoThumbnailContainer: { width: 80, height: 80, borderRadius: radius.md, overflow: 'hidden', borderWidth: 2, borderColor: 'rgba(232, 164, 174, 0.3)', }, photoThumbnail: { width: '100%', height: '100%', }, photoLoadingOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0, 0, 0, 0.3)', justifyContent: 'center', alignItems: 'center', }, // Who likely styles whoLikelyContainer: { width: '100%', gap: spacing.sm, }, whoLikelyVoter: { ...typography.caption1, fontWeight: '500', color: colors.textSecondary, }, whoLikelyChoiceBadge: { paddingHorizontal: spacing.md, paddingVertical: spacing.xs, borderRadius: radius.full, }, whoLikelyChoiceText: { ...typography.caption1, fontWeight: '700', color: colors.text, letterSpacing: 0.5, }, // Audio display styles audioContainer: { width: '100%', gap: spacing.md, }, audioCard: { width: '100%', gap: spacing.xs, }, audioName: { ...typography.caption1, fontWeight: '600', color: colors.textSecondary, textAlign: 'center', }, audioPlayButtonActive: { backgroundColor: colors.primary, }, audioWaveform: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 3, height: 32, }, audioBar: { width: 4, backgroundColor: 'rgba(232, 164, 174, 0.3)', borderRadius: radius.full, }, audioBarActive: { backgroundColor: colors.primary, }, audioDuration: { ...typography.caption1, color: colors.textTertiary, fontVariant: ['tabular-nums'], minWidth: 35, textAlign: 'right', }, }); export { MatchCardComponent as MatchCard };