diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index dfea0bd..1e294ed 100755 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -134,7 +134,7 @@ function App() { + } diff --git a/apps/admin/src/components/layout/Sidebar.tsx b/apps/admin/src/components/layout/Sidebar.tsx index 641b2ff..68238b8 100644 --- a/apps/admin/src/components/layout/Sidebar.tsx +++ b/apps/admin/src/components/layout/Sidebar.tsx @@ -71,7 +71,7 @@ const usersNav: NavItem[] = [ label: 'Feedback', href: '/feedback', icon: , - requiredPermission: PERMISSION_KEYS.VIEW_USERS, + requiredPermission: PERMISSION_KEYS.MANAGE_FEEDBACK, }, ]; diff --git a/apps/admin/src/contexts/AuthContext.tsx b/apps/admin/src/contexts/AuthContext.tsx index 656ff68..443d703 100644 --- a/apps/admin/src/contexts/AuthContext.tsx +++ b/apps/admin/src/contexts/AuthContext.tsx @@ -19,6 +19,7 @@ export const PERMISSION_KEYS = { VIEW_MATCHES: 'view_matches', VIEW_RESPONSES: 'view_responses', VIEW_ACTIVITY: 'view_activity', + MANAGE_FEEDBACK: 'manage_feedback', // System permissions MANAGE_CODES: 'manage_codes', MANAGE_ADMINS: 'manage_admins', @@ -40,6 +41,7 @@ export const PERMISSION_METADATA: RecordMessage Context Time + Actions {loading.messages ? ( - +
) : messages.length === 0 ? ( - + No messages yet. @@ -567,8 +572,11 @@ export function UserActivityPage() { {item.media_path ? ( - - [Media] + {item.media_type === 'video' ? ( + <> [Video] + ) : ( + <> [Image] + )} ) : ( @@ -584,6 +592,19 @@ export function UserActivityPage() { {formatDistanceToNow(new Date(item.created_at), { addSuffix: true })} + + {item.match && item.profile ? ( + + + View Chat + + ) : ( + - + )} + )) )} diff --git a/apps/admin/src/pages/users/MatchChatPage.tsx b/apps/admin/src/pages/users/MatchChatPage.tsx index 6c8134b..c5d4c74 100644 --- a/apps/admin/src/pages/users/MatchChatPage.tsx +++ b/apps/admin/src/pages/users/MatchChatPage.tsx @@ -6,7 +6,7 @@ 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 } from 'lucide-react'; +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'; @@ -32,6 +32,7 @@ interface Message { 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; @@ -117,6 +118,79 @@ function ChatImage({ mediaPath }: { mediaPath: string }) { } +function ChatVideo({ mediaPath }: { mediaPath: string }) { + const [url, setUrl] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!mediaPath) return; + const getUrl = async () => { + try { + // Handle legacy full URLs + let path = mediaPath; + if (path.startsWith('http')) { + const parts = path.split('/chat-media/'); + if (parts.length > 1) { + path = decodeURIComponent(parts[1]); + } + } + + const { data, error } = await supabase.storage + .from('chat-media') + .createSignedUrl(path, 3600); + + if (error) { + console.error('Error creating signed URL for video:', error); + setError(error.message); + return; + } + + if (data?.signedUrl) { + setUrl(data.signedUrl); + } + } catch (err) { + console.error('Unexpected error loading video:', err); + setError('Failed to load'); + } + }; + getUrl(); + }, [mediaPath]); + + if (error) { + return ( +
+ + Failed to load video + + {error} + +
+ ); + } + + if (!url) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); +} + + export function MatchChatPage() { const { userId, matchId } = useParams<{ userId: string; matchId: string }>(); const [user, setUser] = useState(null); @@ -337,7 +411,11 @@ export function MatchChatPage() { {message.media_path && (
- + {message.media_type === 'video' ? ( + + ) : ( + + )} {message.media_viewed_at && ( +MYAPP_UPLOAD_STORE_PASSWORD= +MYAPP_UPLOAD_KEY_PASSWORD= +``` + +**Important:** Never commit these credentials to git. + +### Step 3: Prebuild (if needed) + +If the `android/` folder doesn't exist or native dependencies changed: + +```bash +npx expo prebuild --platform android --clean +``` + +### Step 4: Build the Release Bundle + +```bash +cd android +./gradlew bundleRelease +``` + +The AAB file will be at: +``` +android/app/build/outputs/bundle/release/app-release.aab +``` + +### Step 5: Upload to Play Store + +1. Go to [Google Play Console](https://play.google.com/console) +2. Select **Sauci** app +3. Go to **Production** (or testing track) +4. **Create new release** +5. Upload the `.aab` file +6. Add release notes and submit + +### Verifying Keystore Fingerprint + +To verify you're using the correct keystore: + +```bash +keytool -list -v -keystore android/sauci-release.keystore -storepass +``` + +Expected SHA1: `C3:70:C5:8C:85:E5:B5:92:97:82:A9:B0:47:98:40:99:AC:C8:47:43` + +--- + +## iOS Release Build + +### Step 1: Prebuild (if needed) + +```bash +npx expo prebuild --platform ios --clean +``` + +### Step 2: Install Pods + +```bash +cd ios +pod install +cd .. +``` + +### Step 3: Build Archive in Xcode + +1. Open `ios/Sauci.xcworkspace` in Xcode +2. Select **Any iOS Device (arm64)** as the build target +3. Go to **Product** → **Archive** +4. Wait for the build to complete + +### Step 4: Upload to App Store + +1. In Xcode Organizer (Window → Organizer), select the archive +2. Click **Distribute App** +3. Select **App Store Connect** → **Upload** +4. Follow the prompts to upload + +### Step 5: Submit in App Store Connect + +1. Go to [App Store Connect](https://appstoreconnect.apple.com) +2. Select **Sauci** +3. Create a new version or select the uploaded build +4. Fill in release notes and submit for review + +--- + +## Version Management + +Version is managed by EAS with `appVersionSource: "remote"` in `eas.json`. + +To sync or bump versions: + +```bash +# Check current version +eas build:version:get + +# Set version manually +eas build:version:set --platform android --version 1.0.1 +eas build:version:set --platform ios --version 1.0.1 +``` + +For local builds, you may need to update `app.json` version manually: + +```json +{ + "expo": { + "version": "1.0.1" + } +} +``` + +And for Android, update `android/app/build.gradle`: + +```gradle +versionCode 2 +versionName "1.0.1" +``` + +--- + +## Troubleshooting + +### Wrong Keystore Error + +If Play Store rejects with "signed with wrong key": + +1. Run `eas credentials --platform android` +2. Download the correct keystore +3. Verify fingerprint matches expected SHA1 +4. Replace your local keystore file + +### Build Failures After Dependency Changes + +```bash +# Clean and rebuild +cd android && ./gradlew clean && cd .. +npx expo prebuild --platform android --clean +cd android && ./gradlew bundleRelease +``` + +### iOS Signing Issues + +Ensure your Apple Developer certificates and provisioning profiles are up to date in Xcode: +- **Xcode** → **Settings** → **Accounts** → Download Manual Profiles diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx index 681ef1a..3d388e0 100755 --- a/apps/mobile/app/(app)/_layout.tsx +++ b/apps/mobile/app/(app)/_layout.tsx @@ -604,9 +604,9 @@ export default function AppLayout() { ( - + ), }} /> @@ -793,8 +793,8 @@ export default function AppLayout() { {matchNotification?.match_type === 'yes_yes' ? 'YES + YES' : - matchNotification?.match_type === 'yes_maybe' ? 'YES + MAYBE' : - 'MAYBE + MAYBE'} + matchNotification?.match_type === 'yes_maybe' ? 'YES + MAYBE' : + 'MAYBE + MAYBE'} diff --git a/apps/mobile/app/(app)/chat/[id].tsx b/apps/mobile/app/(app)/chat/[id].tsx index bcb6087..4b3ec75 100644 --- a/apps/mobile/app/(app)/chat/[id].tsx +++ b/apps/mobile/app/(app)/chat/[id].tsx @@ -1,2213 +1,3 @@ -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"; +import { ChatScreen } from "../../../src/features/chat"; -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