Refactor profile screen and organize project structure

- Extract ProfileScreen and components to features/profile
- Move shared hooks and stores to top-level
- Add RLS policies for admin permissions
- Add RELEASING.md guide
- Update .gitignore
This commit is contained in:
Jamie Brunton committed 2026-01-01 23:03:43 +00:00
1 parent df4c4d486b
commit 8f8c6e401d
79 files changed
+6689 -4944

No files matched your search

@@ -0,0 +1,397 @@
import { useState, useEffect, useCallback } from "react";
import { View, StyleSheet, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, Modal, Dimensions, Alert } from "react-native";
import { Image } from "expo-image";
import { Video, ResizeMode } 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, { FadeInDown, useAnimatedStyle } from "react-native-reanimated";
import { InputBar, ChatHeader, ChatMessages } from "./components";
import { useMediaUpload } from "./hooks/useMediaUpload";
import { supabase } from "../../lib/supabase";
import { useAuthStore, useMessageStore } from "../../store";
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription } from "../../hooks";
import { Database } from "../../types/supabase";
import { GradientBackground } from "../../components/ui";
import { colors, gradients, spacing } from "../../theme";
import { Events } from "../../lib/analytics";
type Message = Database["public"]["Tables"]["messages"]["Row"];
// Premium color palette for Chat
const ACCENT = colors.premium.gold;
const ROSE_RGBA = 'rgba(232, 164, 174, ';
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
export const ChatScreen: React.FC = () => {
const { id } = useLocalSearchParams();
const matchId = id as string;
const router = useRouter();
const insets = useSafeAreaInsets();
const { user, partner } = useAuthStore();
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
const [match, setMatch] = useState<any>(null);
const [inputText, setInputText] = useState("");
const { uploading, uploadStatus, uploadMedia } = useMediaUpload(matchId, user?.id);
const [fullScreenImage, setFullScreenImage] = useState<string | null>(null);
const [fullScreenImageLoading, setFullScreenImageLoading] = useState(true);
const [fullScreenVideo, setFullScreenVideo] = useState<string | null>(null);
// Ambient orb breathing animations
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation({
opacityRange1: [0.2, 0.4],
opacityRange2: [0.15, 0.35],
});
// Media picker hook
const { pickMedia, takePhoto, recordVideo } = useMediaPicker({
imageQuality: 0.7,
libraryVideoMaxDuration: 300, // 5 min max
cameraVideoMaxDuration: 60, // 1 min for camera recording
});
// Typing indicator hook
const { partnerTyping, sendTypingEvent, clearTypingIndicator } = useTypingIndicator({
channelName: `chat:${matchId}`,
userId: user?.id,
});
// Message subscription hook
const { messages, setMessages, isFocusedRef } = useMessageSubscription({
matchId,
userId: user?.id,
onNewMessage: clearTypingIndicator,
});
// Track active chat to prevent notifications for current chat
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();
}, [matchId]);
const handleTyping = (text: string) => {
setInputText(text);
if (text.length > 0) {
sendTypingEvent();
}
};
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 pickMedia();
if (result) {
uploadMedia(result.uri, result.mediaType);
}
};
const handleTakePhoto = async () => {
const result = await takePhoto();
if (result) {
uploadMedia(result.uri, 'image');
}
};
const handleRecordVideo = async () => {
const result = await recordVideo();
if (result) {
uploadMedia(result.uri, 'video');
}
};
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);
};
return (
<GradientBackground>
{/* Ambient Orbs */}
<Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none">
<LinearGradient
colors={[colors.premium.goldGlow, 'transparent']}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 1, y: 1 }}
/>
</Animated.View>
<Animated.View style={[styles.ambientOrb, styles.orbBottomLeft, orbStyle2]} pointerEvents="none">
<LinearGradient
colors={[`${ROSE_RGBA}0.2)`, 'transparent']}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 0, y: 0 }}
/>
</Animated.View>
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === "ios" ? "padding" : undefined}
keyboardVerticalOffset={0}
>
<ChatHeader
partner={partner}
user={user}
match={match}
insets={insets}
onBack={() => router.back()}
/>
<ChatMessages
messages={messages}
userId={user?.id}
uploadStatus={uploadStatus}
partnerTyping={partnerTyping}
onImagePress={setFullScreenImage}
onVideoFullScreen={setFullScreenVideo}
revealMessage={revealMessage}
/>
{/* Premium Input Bar */}
<Animated.View
entering={FadeInDown.duration(300)}
style={[styles.inputWrapper, { paddingBottom: insets.bottom || spacing.sm }]}
>
{/* Gradient background */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.9)', 'rgba(13, 13, 26, 0.95)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* Top border glow */}
<View style={styles.inputWrapperBorder} />
<InputBar
inputText={inputText}
uploading={uploading}
onChangeText={handleTyping}
onSend={handleSend}
onPickMedia={handlePickMedia}
onTakePhoto={handleTakePhoto}
onRecordVideo={handleRecordVideo}
/>
</Animated.View>
</KeyboardAvoidingView>
{/* Full Screen Image Modal */}
<Modal
visible={!!fullScreenImage}
transparent
animationType="fade"
onRequestClose={() => setFullScreenImage(null)}
>
<View style={styles.fullScreenOverlay}>
<TouchableOpacity
style={styles.fullScreenCloseButton}
onPress={() => setFullScreenImage(null)}
activeOpacity={0.8}
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.fullScreenCloseGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="close" size={24} color={colors.text} />
</LinearGradient>
</TouchableOpacity>
{fullScreenImageLoading && (
<ActivityIndicator size="large" color={ACCENT} style={styles.fullScreenSpinner} />
)}
{fullScreenImage && (
<Image
source={{ uri: fullScreenImage }}
style={styles.fullScreenImage}
contentFit="contain"
cachePolicy="disk"
onLoadStart={() => setFullScreenImageLoading(true)}
onLoadEnd={() => setFullScreenImageLoading(false)}
/>
)}
</View>
</Modal>
{/* Full Screen Video Modal */}
<Modal
visible={!!fullScreenVideo}
transparent
animationType="fade"
onRequestClose={() => setFullScreenVideo(null)}
>
<View style={styles.fullScreenOverlay}>
<TouchableOpacity
style={styles.fullScreenCloseButton}
onPress={() => setFullScreenVideo(null)}
activeOpacity={0.8}
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.fullScreenCloseGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="close" size={24} color={colors.text} />
</LinearGradient>
</TouchableOpacity>
{fullScreenVideo && (
<Video
source={{ uri: fullScreenVideo }}
style={styles.fullScreenVideo}
resizeMode={ResizeMode.CONTAIN}
useNativeControls
shouldPlay
isLooping={false}
/>
)}
</View>
</Modal>
</GradientBackground>
);
};
const styles = StyleSheet.create({
keyboardAvoiding: {
flex: 1,
},
// Ambient orbs
ambientOrb: {
position: 'absolute',
width: 280,
height: 280,
borderRadius: 140,
},
orbTopRight: {
top: 80,
right: -60,
},
orbBottomLeft: {
bottom: 200,
left: -60,
},
orbGradient: {
width: '100%',
height: '100%',
borderRadius: 140,
},
// Full Screen Modal
fullScreenOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.95)",
justifyContent: "center",
alignItems: "center",
},
fullScreenCloseButton: {
position: "absolute",
top: 50,
right: 20,
zIndex: 10,
},
fullScreenCloseGradient: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
fullScreenImage: {
width: Dimensions.get("window").width,
height: Dimensions.get("window").height * 0.8,
},
fullScreenVideo: {
width: Dimensions.get("window").width,
height: Dimensions.get("window").height * 0.8,
},
fullScreenSpinner: {
position: "absolute",
},
inputWrapper: {
overflow: 'hidden',
},
inputWrapperBorder: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 1,
backgroundColor: `${ACCENT_RGBA}0.15)`,
},
});
@@ -0,0 +1,383 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeIn, FadeInDown } from 'react-native-reanimated';
import { EdgeInsets } from 'react-native-safe-area-context';
import { DecorativeSeparator } from '../../../components/ui';
import { colors, gradients, spacing, radius, typography } from '../../../theme';
import { Profile } from '../../../types';
// Premium color palette for Chat
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
interface ChatHeaderProps {
partner: Profile | null;
user: { id: string } | null;
match: any; // Using any for match as it has joined properties (question, responses)
insets: EdgeInsets;
onBack: () => void;
}
export const ChatHeader: React.FC<ChatHeaderProps> = ({
partner,
user,
match,
insets,
onBack,
}) => {
const isYesYes = match?.match_type === 'yes_yes';
return (
<>
<Animated.View
entering={FadeIn.duration(300)}
style={[styles.header, { paddingTop: insets.top + spacing.sm }]}
>
{/* Gradient background */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.8)', 'rgba(13, 13, 26, 0.6)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[`${ACCENT_RGBA}0.1)`, 'transparent']}
style={styles.headerSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<TouchableOpacity
onPress={onBack}
style={styles.backButton}
activeOpacity={0.7}
>
<View style={styles.backButtonInner}>
<Ionicons name="chevron-back" size={20} color={ACCENT} />
</View>
</TouchableOpacity>
<View style={styles.headerCenter}>
{partner?.avatar_url ? (
<View style={styles.headerAvatarContainer}>
<Image
source={{ uri: partner.avatar_url }}
style={styles.headerAvatar}
cachePolicy="disk"
transition={200}
/>
{/* Avatar ring */}
<View style={styles.avatarRing} />
</View>
) : (
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.headerAvatarGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Text style={styles.headerAvatarText}>
{partner?.name?.[0]?.toUpperCase() || "P"}
</Text>
</LinearGradient>
)}
<View style={styles.headerTextContainer}>
<Text style={styles.headerLabel}>CONVERSATION WITH</Text>
<Text style={styles.headerTitle}>{partner?.name || "Partner"}</Text>
</View>
</View>
<View style={styles.headerSpacer} />
{/* Bottom border */}
<View style={styles.headerBorderBottom} />
</Animated.View>
{/* Premium Match Card */}
{match?.question && (
<Animated.View
entering={FadeInDown.delay(100).duration(400).springify()}
style={styles.matchCard}
>
{/* Card gradient background */}
<LinearGradient
colors={isYesYes
? ['rgba(212, 175, 55, 0.12)', 'rgba(184, 134, 11, 0.08)']
: ['rgba(232, 164, 174, 0.1)', 'rgba(22, 33, 62, 0.6)']
}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* Top glow for YES+YES */}
{isYesYes && (
<LinearGradient
colors={[`${ACCENT_RGBA}0.15)`, 'transparent']}
style={styles.matchCardGlow}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
)}
{/* Match Label */}
<Text style={[styles.matchLabel, isYesYes && styles.matchLabelYesYes]}>
{isYesYes ? "PERFECT MATCH" : "SOFT MATCH"}
</Text>
{/* Decorative Separator */}
<DecorativeSeparator
variant={isYesYes ? "gold" : "rose"}
width="60%"
marginVertical={spacing.md}
/>
{/* Question Text */}
<Text style={styles.matchQuestionText}>
"{match.question.text}"
</Text>
{match.question.partner_text && (
<Text style={styles.matchQuestionPartnerText}>
"{match.question.partner_text}"
</Text>
)}
{/* Response Pills */}
{match.responses && match.responses.length > 0 && (
<View style={styles.responsePillsRow}>
{match.responses.map((response: any) => {
const isUser = response.user_id === user?.id;
const isYes = response.answer === 'yes';
return (
<View key={response.user_id} style={styles.responsePillWrapper}>
{isYes ? (
<LinearGradient
colors={isYesYes
? gradients.premiumGold as [string, string]
: [colors.success, '#27ae60']
}
style={styles.responsePill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="checkmark" size={12} color={colors.text} />
<Text style={styles.responsePillText}>
{isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
</Text>
</LinearGradient>
) : (
<View style={styles.responsePillMaybe}>
<Ionicons name="help" size={12} color={colors.warning} />
<Text style={styles.responsePillTextMaybe}>
{isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
</Text>
</View>
)}
</View>
);
})}
</View>
)}
{/* Premium border */}
<View style={[
styles.matchCardBorder,
isYesYes && styles.matchCardBorderYesYes
]} pointerEvents="none" />
</Animated.View>
)}
</>
);
};
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingBottom: spacing.md,
overflow: 'hidden',
},
headerSilkHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 60,
},
backButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
backButtonInner: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: `${ACCENT_RGBA}0.1)`,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
justifyContent: 'center',
alignItems: 'center',
},
headerCenter: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
},
headerAvatarContainer: {
position: 'relative',
},
headerAvatar: {
width: 40,
height: 40,
borderRadius: 20,
},
avatarRing: {
position: 'absolute',
top: -2,
left: -2,
right: -2,
bottom: -2,
borderRadius: 22,
borderWidth: 2,
borderColor: `${ACCENT_RGBA}0.3)`,
},
headerAvatarGradient: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
},
headerAvatarText: {
fontSize: 16,
fontWeight: 'bold',
color: colors.text,
},
headerTextContainer: {
alignItems: 'flex-start',
},
headerLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: ACCENT,
opacity: 0.8,
},
headerTitle: {
...typography.headline,
color: colors.text,
},
headerSpacer: {
width: 40,
},
headerBorderBottom: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 1,
backgroundColor: `${ACCENT_RGBA}0.15)`,
},
matchCard: {
marginHorizontal: spacing.md,
marginTop: spacing.sm,
marginBottom: spacing.xs,
borderRadius: radius.lg,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
alignItems: 'center',
overflow: 'hidden',
},
matchCardGlow: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 60,
},
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,
},
matchQuestionPartnerText: {
...typography.caption1,
color: colors.textSecondary,
textAlign: 'center',
fontStyle: 'italic',
marginTop: 2,
},
responsePillsRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.sm,
marginTop: spacing.sm,
},
responsePillWrapper: {
overflow: 'hidden',
borderRadius: radius.full,
},
responsePill: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: 4,
gap: 4,
},
responsePillText: {
...typography.caption2,
fontWeight: '600',
color: colors.text,
},
responsePillMaybe: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: 4,
gap: 4,
backgroundColor: `rgba(243, 156, 18, 0.15)`,
borderWidth: 1,
borderColor: `rgba(243, 156, 18, 0.3)`,
borderRadius: radius.full,
},
responsePillTextMaybe: {
...typography.caption2,
fontWeight: '600',
color: colors.warning,
},
matchCardBorder: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: `rgba(232, 164, 174, 0.2)`,
},
matchCardBorderYesYes: {
borderColor: `${ACCENT_RGBA}0.25)`,
},
});
@@ -0,0 +1,132 @@
import React, { useRef } from 'react';
import { View, Text, FlatList, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeIn } from 'react-native-reanimated';
import { MessageBubble } from './MessageBubble';
import { MessageContent } from './MessageContent';
import { UploadProgress } from './UploadProgress';
import { TypingIndicator } from './TypingIndicator';
import { DecorativeSeparator } from '../../../components/ui';
import { colors, spacing, typography } from '../../../theme';
import { Database } from '../../../types/supabase';
import { UploadStatus } from '../types';
type Message = Database['public']['Tables']['messages']['Row'];
interface ChatMessagesProps {
messages: Message[];
userId: string | undefined;
uploadStatus: UploadStatus | null;
partnerTyping: boolean;
onImagePress: (uri: string) => void;
onVideoFullScreen: (uri: string) => void;
revealMessage: (messageId: string) => void;
}
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
export const ChatMessages: React.FC<ChatMessagesProps> = ({
messages,
userId,
uploadStatus,
partnerTyping,
onImagePress,
onVideoFullScreen,
revealMessage,
}) => {
const flatListRef = useRef<FlatList>(null);
const renderMessage = ({ item, index }: { item: Message; index: number }) => {
const isMe = item.user_id === userId;
return (
<MessageBubble isMe={isMe} index={index}>
<MessageContent
item={item}
isMe={isMe}
revealMessage={revealMessage}
onImagePress={onImagePress}
onVideoFullScreen={onVideoFullScreen}
/>
</MessageBubble>
);
};
return (
<FlatList
ref={flatListRef}
data={messages}
renderItem={renderMessage}
keyExtractor={(item) => item.id}
inverted
style={styles.messageList}
contentContainerStyle={styles.listContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<>
{/* Show uploading status when media is being uploaded */}
{uploadStatus && <UploadProgress uploadStatus={uploadStatus} />}
{/* Show typing indicator when partner is typing */}
{partnerTyping && <TypingIndicator />}
</>
}
ListEmptyComponent={
<Animated.View
entering={FadeIn.delay(200).duration(400)}
style={styles.emptyChat}
>
{/* Premium icon */}
<View style={styles.emptyIconContainer}>
<Ionicons name="chatbubbles-outline" size={32} color={ACCENT} />
</View>
{/* Decorative separator */}
<DecorativeSeparator variant="gold" width={100} marginVertical={spacing.md} />
<Text style={styles.emptyChatTitle}>Start the Conversation</Text>
<Text style={styles.emptyChatSubtitle}>
Share your thoughts about this match
</Text>
</Animated.View>
}
/>
);
};
const styles = StyleSheet.create({
messageList: {
flex: 1,
},
listContent: {
flexGrow: 1,
padding: spacing.md,
},
emptyChat: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.xxl,
},
emptyIconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: `${ACCENT_RGBA}0.1)`,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.md,
},
emptyChatTitle: {
...typography.headline,
color: colors.text,
marginBottom: spacing.xs,
},
emptyChatSubtitle: {
...typography.subhead,
color: colors.textSecondary,
},
});
@@ -0,0 +1,234 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { View, Text, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
import { Video, ResizeMode, AVPlaybackStatus } from 'expo-av';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { colors, gradients, spacing, radius } from '../../../theme';
import { useVideoCache } from '../hooks';
export interface ChatVideoPlayerProps {
/** Signed URL for the video */
signedUrl: string | null;
/** Storage path for caching */
storagePath: string;
/** Whether there was an error loading the URL */
urlError: boolean;
/** Callback when fullscreen is requested */
onFullScreen: (uri: string) => void;
}
const ACCENT = colors.premium.gold;
/**
* Video player component with native-like tap behavior.
* Supports play/pause, fullscreen, caching, and loading states.
*/
export function ChatVideoPlayer({
signedUrl,
storagePath,
urlError,
onFullScreen,
}: ChatVideoPlayerProps) {
const videoRef = useRef<Video>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [hasEnded, setHasEnded] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// Use video cache hook
const { cachedUri, cacheVideoFile } = useVideoCache(storagePath, signedUrl);
// Handle playback status updates
const handlePlaybackStatusUpdate = useCallback(async (status: AVPlaybackStatus) => {
if (!status.isLoaded) return;
setIsLoading(false);
setIsPlaying(status.isPlaying);
if (status.didJustFinish) {
setHasEnded(true);
setIsPlaying(false);
// Cache in background if not already cached
if (signedUrl && !cachedUri) {
cacheVideoFile();
}
}
}, [signedUrl, storagePath, cachedUri]);
// Toggle play/pause with native-like behavior
const handleTapToPlay = useCallback(async () => {
if (!videoRef.current) return;
if (isPlaying) {
await videoRef.current.pauseAsync();
setIsPlaying(false);
} else {
if (hasEnded) {
await videoRef.current.setPositionAsync(0);
setHasEnded(false);
}
await videoRef.current.playAsync();
setIsPlaying(true);
// Start caching when playback starts
if (signedUrl && !cachedUri) {
cacheVideoFile();
}
}
}, [isPlaying, hasEnded, signedUrl, storagePath, cachedUri]);
// Handle full screen
const handleFullScreen = useCallback(() => {
const uri = cachedUri || signedUrl;
if (uri) {
if (videoRef.current && isPlaying) {
videoRef.current.pauseAsync();
setIsPlaying(false);
}
onFullScreen(uri);
}
}, [cachedUri, signedUrl, isPlaying, onFullScreen]);
const videoSource = cachedUri || signedUrl;
if (urlError || !videoSource) {
return (
<View style={[styles.video, styles.errorContainer]}>
<Ionicons name="videocam-outline" size={32} color={colors.textSecondary} />
<Text style={styles.errorText}>Video unavailable</Text>
</View>
);
}
return (
<TouchableOpacity
activeOpacity={0.9}
onPress={handleTapToPlay}
onLongPress={handleFullScreen}
delayLongPress={300}
style={styles.container}
>
<Video
ref={videoRef}
source={{ uri: videoSource }}
style={styles.video}
resizeMode={ResizeMode.COVER}
isLooping={false}
shouldPlay={false}
positionMillis={0}
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
/>
{/* Loading indicator */}
{isLoading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator color={ACCENT} size="small" />
</View>
)}
{/* Play/Replay overlay */}
{!isPlaying && !isLoading && (
<View style={styles.playOverlay}>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.playButton}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons
name={hasEnded ? 'refresh' : 'play'}
size={24}
color={colors.text}
style={hasEnded ? undefined : { marginLeft: 3 }}
/>
</LinearGradient>
</View>
)}
{/* Full screen button */}
{!isPlaying && !isLoading && (
<TouchableOpacity
style={styles.fullScreenButton}
onPress={handleFullScreen}
activeOpacity={0.7}
>
<Ionicons name="expand-outline" size={16} color={colors.text} />
</TouchableOpacity>
)}
{/* Cached indicator */}
{cachedUri && !isPlaying && (
<View style={styles.cachedBadge}>
<Ionicons name="download-outline" size={10} color={colors.textSecondary} />
</View>
)}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
width: '100%',
aspectRatio: 16 / 9,
borderRadius: radius.lg,
overflow: 'hidden',
},
video: {
width: '100%',
height: '100%',
backgroundColor: colors.background,
},
errorContainer: {
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
errorText: {
color: colors.textSecondary,
fontSize: 12,
marginTop: spacing.xs,
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
playOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
},
playButton: {
width: 56,
height: 56,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
},
fullScreenButton: {
position: 'absolute',
bottom: spacing.sm,
right: spacing.sm,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
cachedBadge: {
position: 'absolute',
top: spacing.xs,
left: spacing.xs,
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
});
export default ChatVideoPlayer;
@@ -0,0 +1,240 @@
import React, { useState } from 'react';
import { View, TextInput, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
interpolate,
Easing,
} from 'react-native-reanimated';
import { colors, gradients, spacing, radius, typography } from '../../../theme';
export interface InputBarProps {
inputText: string;
uploading: boolean;
onChangeText: (text: string) => void;
onSend: () => void;
onPickMedia: () => void;
onTakePhoto: () => void;
onRecordVideo: () => void;
}
const ACCENT = colors.premium.gold;
/**
* Chat input bar with expandable media menu.
* Includes text input, send button, and media action buttons.
*/
export function InputBar({
inputText,
uploading,
onChangeText,
onSend,
onPickMedia,
onTakePhoto,
onRecordVideo,
}: InputBarProps) {
const [menuExpanded, setMenuExpanded] = useState(false);
const menuWidth = useSharedValue(0);
const buttonRotation = useSharedValue(0);
const toggleMenu = () => {
const newExpanded = !menuExpanded;
setMenuExpanded(newExpanded);
menuWidth.value = withTiming(newExpanded ? 132 : 0, { duration: 200, easing: Easing.out(Easing.ease) });
buttonRotation.value = withTiming(newExpanded ? 45 : 0, { duration: 200, easing: Easing.out(Easing.ease) });
};
const handleMediaAction = (action: () => void) => {
setMenuExpanded(false);
menuWidth.value = withTiming(0, { duration: 150 });
buttonRotation.value = withTiming(0, { duration: 150 });
action();
};
const menuAnimatedStyle = useAnimatedStyle(() => ({
width: menuWidth.value,
opacity: interpolate(menuWidth.value, [0, 132], [0, 1]),
}));
const plusButtonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${buttonRotation.value}deg` }],
}));
return (
<View style={styles.container}>
{/* Plus button to toggle media menu */}
<TouchableOpacity onPress={toggleMenu} disabled={uploading} style={styles.attachButton}>
{uploading ? (
<ActivityIndicator color={ACCENT} size="small" />
) : (
<Animated.View style={[styles.attachButtonInner, styles.plusButton, plusButtonAnimatedStyle]}>
<Ionicons name="add" size={24} color={ACCENT} />
</Animated.View>
)}
</TouchableOpacity>
{/* Expandable media options */}
<Animated.View style={[styles.mediaMenuContainer, menuAnimatedStyle]}>
<TouchableOpacity
onPress={() => handleMediaAction(onTakePhoto)}
style={styles.mediaMenuItem}
activeOpacity={0.7}
>
<View style={styles.mediaMenuItemInner}>
<Ionicons name="camera-outline" size={20} color={colors.textSecondary} />
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleMediaAction(onRecordVideo)}
style={styles.mediaMenuItem}
activeOpacity={0.7}
>
<View style={styles.mediaMenuItemInner}>
<Ionicons name="videocam-outline" size={20} color={colors.textSecondary} />
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleMediaAction(onPickMedia)}
style={styles.mediaMenuItem}
activeOpacity={0.7}
>
<View style={styles.mediaMenuItemInner}>
<Ionicons name="image-outline" size={20} color={colors.textSecondary} />
</View>
</TouchableOpacity>
</Animated.View>
<View style={styles.inputFieldWrapper}>
{/* Subtle gradient border effect */}
<View style={styles.inputFieldBorder} />
<TextInput
style={styles.input}
value={inputText}
onChangeText={onChangeText}
placeholder="Type a message..."
placeholderTextColor={colors.textTertiary}
multiline
/>
</View>
<TouchableOpacity
onPress={onSend}
disabled={!inputText.trim()}
activeOpacity={0.8}
>
<LinearGradient
colors={inputText.trim() ? gradients.primary as [string, string] : ['rgba(22, 33, 62, 0.6)', 'rgba(22, 33, 62, 0.6)']}
style={styles.sendButton}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{/* Silk highlight */}
{inputText.trim() && (
<LinearGradient
colors={['rgba(255, 255, 255, 0.2)', 'transparent']}
style={styles.sendButtonHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
)}
<Ionicons
name="send"
size={18}
color={inputText.trim() ? colors.text : colors.textTertiary}
/>
</LinearGradient>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'flex-end',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
gap: spacing.sm,
},
attachButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
attachButtonInner: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: 'center',
alignItems: 'center',
},
plusButton: {
backgroundColor: 'rgba(212, 175, 55, 0.1)',
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
},
mediaMenuContainer: {
flexDirection: 'row',
overflow: 'hidden',
gap: spacing.xs,
},
mediaMenuItem: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
mediaMenuItemInner: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
},
inputFieldWrapper: {
flex: 1,
position: 'relative',
},
inputFieldBorder: {
...StyleSheet.absoluteFillObject,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)',
},
input: {
...typography.body,
color: colors.text,
backgroundColor: 'rgba(22, 33, 62, 0.6)',
borderRadius: radius.xl,
paddingHorizontal: spacing.md,
paddingTop: 12,
paddingBottom: 12,
maxHeight: 100,
},
sendButton: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
},
sendButtonHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 20,
},
});
export default InputBar;
@@ -0,0 +1,108 @@
/**
* MessageBubble - Wrapper component for chat message bubbles
* Applies gradient styling for sender vs receiver messages.
*/
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { colors, gradients, spacing, radius } from '../../../theme';
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
export interface MessageBubbleProps {
isMe: boolean;
index: number;
children: React.ReactNode;
}
export function MessageBubble({ isMe, index, children }: MessageBubbleProps) {
return (
<Animated.View
entering={FadeInUp.delay(index * 30).duration(200)}
style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}
>
{isMe ? (
<View style={styles.myBubbleContainer}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={[styles.bubble, styles.myBubble]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{/* Silk highlight */}
<LinearGradient
colors={['rgba(255, 255, 255, 0.15)', 'transparent']}
style={styles.bubbleSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{children}
</LinearGradient>
</View>
) : (
<View style={styles.theirBubbleContainer}>
<View style={[styles.bubble, styles.theirBubble]}>
{/* Subtle gradient background */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.6)', 'rgba(13, 13, 26, 0.8)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* Silk highlight */}
<LinearGradient
colors={[`${ACCENT_RGBA}0.08)`, 'transparent']}
style={styles.bubbleSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{children}
</View>
</View>
)}
</Animated.View>
);
}
const styles = StyleSheet.create({
messageRow: {
flexDirection: 'row',
marginBottom: spacing.sm,
paddingHorizontal: spacing.md,
},
myMessageRow: {
justifyContent: 'flex-end',
},
theirMessageRow: {
justifyContent: 'flex-start',
},
myBubbleContainer: {
maxWidth: '80%',
},
theirBubbleContainer: {
maxWidth: '80%',
},
bubble: {
padding: spacing.md,
borderRadius: radius.lg,
overflow: 'hidden',
},
myBubble: {
borderTopRightRadius: radius.xs,
},
theirBubble: {
borderTopLeftRadius: radius.xs,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.15)`,
},
bubbleSilkHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 30,
},
});
export default MessageBubble;
@@ -0,0 +1,316 @@
/**
* MessageContent - Renders message content (text, images, videos)
* Handles media reveal logic, expired media, and loading states.
*/
import React, { useState, useEffect } from 'react';
import { View, TouchableOpacity, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { colors, gradients, spacing, radius } from '../../../theme';
import { getCachedSignedUrl, getStoragePath } from '../../../lib/imageCache';
import { MessageMeta } from './MessageMeta';
import { ChatVideoPlayer } from './ChatVideoPlayer';
import type { Message } from '../types';
const ACCENT = colors.premium.gold;
export interface MessageContentProps {
item: Message;
isMe: boolean;
revealMessage: (id: string) => void;
onImagePress: (uri: string) => void;
onVideoFullScreen: (uri: string) => void;
}
export function MessageContent({
item,
isMe,
revealMessage,
onImagePress,
onVideoFullScreen,
}: MessageContentProps) {
const [signedUrl, setSignedUrl] = useState<string | null>(null);
const [urlError, setUrlError] = useState(false);
useEffect(() => {
if (!item.media_path) return;
let isMounted = true;
const fetchSignedUrl = async () => {
const storagePath = getStoragePath(item.media_path!);
const url = await getCachedSignedUrl(storagePath);
if (!isMounted) return;
if (url) {
setSignedUrl(url);
} else {
setUrlError(true);
}
};
fetchSignedUrl();
return () => {
isMounted = false;
};
}, [item.media_path]);
// Handle expired videos
const isExpired = !!(item as any).media_expired;
const isVideo = (item as any).media_type === 'video';
if (isExpired && isVideo) {
return (
<View>
<View style={styles.expiredMedia}>
<LinearGradient
colors={['rgba(22, 33, 62, 0.8)', 'rgba(13, 13, 26, 0.9)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.expiredIconContainer}>
<Ionicons name="videocam-off-outline" size={28} color={colors.textTertiary} />
</View>
<Text style={styles.expiredText}>Video expired</Text>
<Text style={styles.expiredSubtext}>Videos are deleted 30 days after viewing</Text>
</View>
<MessageMeta item={item} isMe={isMe} />
</View>
);
}
// Handle media (images and videos)
if (item.media_path || isExpired) {
const isViewed = !!item.media_viewed_at;
const canOpenFullScreen = (isMe || isViewed) && !isVideo; // Only images can go fullscreen
// Video content
if (isVideo) {
return (
<View>
{(!isMe && !isViewed) ? (
// Blurred placeholder with reveal overlay for unrevealed videos
<View>
<View style={[styles.messageVideo, styles.videoBlurred]}>
<LinearGradient
colors={['rgba(22, 33, 62, 0.9)', 'rgba(13, 13, 26, 0.95)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Ionicons name="videocam" size={40} color={colors.textTertiary} />
</View>
<TouchableOpacity
style={styles.revealOverlay}
activeOpacity={0.8}
onPress={() => revealMessage(item.id)}
>
<View style={styles.revealContent}>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.revealIconContainer}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="eye-off-outline" size={24} color={colors.text} />
</LinearGradient>
<Text style={styles.revealText}>Tap to reveal video</Text>
</View>
</TouchableOpacity>
</View>
) : signedUrl || urlError ? (
<ChatVideoPlayer
signedUrl={signedUrl}
storagePath={item.media_path!}
urlError={urlError}
onFullScreen={onVideoFullScreen}
/>
) : (
<View style={[styles.messageVideo, styles.messageImageLoading]}>
<ActivityIndicator color={ACCENT} />
</View>
)}
{/* Viewed indicator for sender */}
{isMe && isViewed && (
<View style={styles.viewedBadge}>
<Ionicons name="eye" size={12} color={colors.success} />
<Text style={styles.viewedText}>Viewed</Text>
</View>
)}
<MessageMeta item={item} isMe={isMe} />
</View>
);
}
// Image content
return (
<View>
<TouchableOpacity
activeOpacity={canOpenFullScreen ? 0.8 : 1}
onPress={() => canOpenFullScreen && signedUrl && onImagePress(signedUrl)}
disabled={!canOpenFullScreen || !signedUrl}
>
{signedUrl ? (
<Image
source={{ uri: signedUrl }}
style={styles.messageImage}
blurRadius={(!isMe && !isViewed) ? 20 : 0}
cachePolicy="disk"
transition={200}
/>
) : urlError ? (
<View style={[styles.messageImage, styles.messageImageError]}>
<Ionicons name="image-outline" size={32} color={colors.textSecondary} />
<Text style={styles.messageImageErrorText}>Image unavailable</Text>
</View>
) : (
<View style={[styles.messageImage, styles.messageImageLoading]}>
<ActivityIndicator color={ACCENT} />
</View>
)}
</TouchableOpacity>
{/* Reveal overlay for recipient */}
{(!isMe && !isViewed) && (
<TouchableOpacity
style={styles.revealOverlay}
activeOpacity={0.8}
onPress={() => revealMessage(item.id)}
>
<View style={styles.revealContent}>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.revealIconContainer}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="eye-off-outline" size={24} color={colors.text} />
</LinearGradient>
<Text style={styles.revealText}>Tap to reveal</Text>
</View>
</TouchableOpacity>
)}
{/* Viewed indicator for sender */}
{isMe && isViewed && (
<View style={styles.viewedBadge}>
<Ionicons name="eye" size={12} color={colors.success} />
<Text style={styles.viewedText}>Viewed</Text>
</View>
)}
<MessageMeta item={item} isMe={isMe} />
</View>
);
}
// Text message
return (
<>
<Text style={styles.messageText}>{item.content}</Text>
<MessageMeta item={item} isMe={isMe} />
</>
);
}
const styles = StyleSheet.create({
messageText: {
color: colors.text,
fontSize: 16,
lineHeight: 22,
},
messageImage: {
width: 200,
height: 200,
borderRadius: radius.md,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
messageImageLoading: {
justifyContent: 'center',
alignItems: 'center',
},
messageImageError: {
justifyContent: 'center',
alignItems: 'center',
gap: spacing.sm,
},
messageImageErrorText: {
color: colors.textSecondary,
fontSize: 12,
},
messageVideo: {
width: 200,
height: 200,
borderRadius: radius.md,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
overflow: 'hidden',
},
videoBlurred: {
justifyContent: 'center',
alignItems: 'center',
},
revealOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
borderRadius: radius.md,
},
revealContent: {
alignItems: 'center',
gap: spacing.sm,
},
revealIconContainer: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
},
revealText: {
color: colors.text,
fontSize: 13,
fontWeight: '600',
},
viewedBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: spacing.xs,
},
viewedText: {
color: colors.success,
fontSize: 11,
fontWeight: '500',
},
expiredMedia: {
width: 200,
height: 150,
borderRadius: radius.md,
overflow: 'hidden',
justifyContent: 'center',
alignItems: 'center',
gap: spacing.sm,
},
expiredIconContainer: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
justifyContent: 'center',
alignItems: 'center',
},
expiredText: {
color: colors.textSecondary,
fontSize: 14,
fontWeight: '600',
},
expiredSubtext: {
color: colors.textTertiary,
fontSize: 11,
textAlign: 'center',
paddingHorizontal: spacing.md,
},
});
export default MessageContent;
@@ -0,0 +1,47 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ReadReceipt, getReceiptStatus } from '../../../components/ui';
import { colors, typography, spacing } from '../../../theme';
import { Message } from '../types';
export interface MessageMetaProps {
item: Message;
isMe: boolean;
}
/**
* Message metadata component showing timestamp and read receipts.
*/
export function MessageMeta({ item, isMe }: MessageMetaProps) {
const status = getReceiptStatus(item.delivered_at, item.read_at);
return (
<View style={styles.container}>
<Text style={styles.timestamp}>
{new Date(item.created_at!).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Text>
{isMe && (
<View style={styles.readStatus}>
<ReadReceipt status={status} />
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.xs,
},
timestamp: {
...typography.caption2,
color: 'rgba(255, 255, 255, 0.5)',
},
readStatus: {
marginLeft: spacing.xs,
},
});
export default MessageMeta;
@@ -0,0 +1,108 @@
import React, { useEffect } from 'react';
import { View, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withSequence,
withTiming,
FadeIn,
} from 'react-native-reanimated';
import { gradients, colors, radius, spacing } from '../../../theme';
interface TypingDotProps {
delay: number;
}
function TypingDot({ delay }: TypingDotProps) {
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 (
<Animated.View style={[styles.typingDot, animatedStyle, { marginLeft: delay > 0 ? 4 : 0 }]}>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.typingDotGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
</Animated.View>
);
}
/**
* Typing indicator component showing three animated dots
* when partner is typing.
*/
export function TypingIndicator() {
return (
<Animated.View
entering={FadeIn.duration(200)}
style={styles.container}
>
<View style={styles.bubble}>
{/* Subtle gradient */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.6)', 'rgba(13, 13, 26, 0.8)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.dots}>
<TypingDot delay={0} />
<TypingDot delay={150} />
<TypingDot delay={300} />
</View>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'flex-start',
marginBottom: spacing.sm,
},
bubble: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)',
backgroundColor: 'rgba(22, 33, 62, 0.6)',
overflow: 'hidden',
},
dots: {
flexDirection: 'row',
alignItems: 'center',
},
typingDot: {
width: 8,
height: 8,
borderRadius: 4,
overflow: 'hidden',
},
typingDotGradient: {
flex: 1,
borderRadius: 4,
},
});
export default TypingIndicator;
@@ -0,0 +1,151 @@
import React, { useEffect } from 'react';
import { View, Text, Image, ActivityIndicator, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import Animated, {
FadeIn,
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
interpolate,
Easing,
} from 'react-native-reanimated';
import { colors, gradients, spacing, radius } from '../../../theme';
import { UploadStatus } from '../types';
export interface UploadProgressProps {
uploadStatus: UploadStatus;
}
const ACCENT = colors.premium.gold;
/**
* Upload progress skeleton component showing when media is being uploaded.
* Displays a shimmer effect with thumbnail preview for images.
*/
export function UploadProgress({ uploadStatus }: UploadProgressProps) {
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 (
<Animated.View
entering={FadeIn.duration(200)}
style={styles.container}
>
<View style={styles.bubbleContainer}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.bubble}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{/* Silk highlight */}
<LinearGradient
colors={['rgba(255, 255, 255, 0.15)', 'transparent']}
style={styles.silkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Skeleton media placeholder */}
<Animated.View style={[styles.mediaSkeleton, shimmerStyle]}>
{uploadStatus.thumbnailUri && !isVideo ? (
<Image
source={{ uri: uploadStatus.thumbnailUri }}
style={styles.thumbnail}
blurRadius={10}
/>
) : (
<View style={styles.iconContainer}>
<Ionicons
name={isVideo ? 'videocam' : 'image'}
size={32}
color={colors.textTertiary}
/>
</View>
)}
{/* Overlay with spinner and status */}
<View style={styles.overlay}>
<ActivityIndicator color={ACCENT} size="small" />
<Text style={styles.statusText}>{statusText}</Text>
</View>
</Animated.View>
</LinearGradient>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'flex-end',
marginBottom: spacing.sm,
paddingHorizontal: spacing.md,
},
bubbleContainer: {
maxWidth: '80%',
},
bubble: {
borderRadius: radius.xl,
borderTopRightRadius: radius.sm,
overflow: 'hidden',
minWidth: 200,
},
silkHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 40,
},
mediaSkeleton: {
height: 180,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.2)',
},
thumbnail: {
...StyleSheet.absoluteFillObject,
},
iconContainer: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
gap: spacing.sm,
},
statusText: {
color: colors.textSecondary,
fontSize: 13,
},
});
export default UploadProgress;
@@ -0,0 +1,16 @@
// Chat feature components
export { TypingIndicator } from './TypingIndicator';
export { InputBar } from './InputBar';
export type { InputBarProps } from './InputBar';
export { MessageMeta } from './MessageMeta';
export type { MessageMetaProps } from './MessageMeta';
export { UploadProgress } from './UploadProgress';
export type { UploadProgressProps } from './UploadProgress';
export { ChatVideoPlayer } from './ChatVideoPlayer';
export type { ChatVideoPlayerProps } from './ChatVideoPlayer';
export { MessageContent } from './MessageContent';
export type { MessageContentProps } from './MessageContent';
export { MessageBubble } from './MessageBubble';
export type { MessageBubbleProps } from './MessageBubble';
export { ChatHeader } from './ChatHeader';
export { ChatMessages } from './ChatMessages';
@@ -0,0 +1,3 @@
export * from './useMediaUpload';
export * from './useChatMessages';
export * from './useVideoCache';
@@ -0,0 +1 @@
export { useMessageSubscription as useChatMessages } from '../../../hooks';
@@ -0,0 +1,110 @@
import { useState } from 'react';
import { Alert, Platform } from 'react-native';
import * as FileSystem from 'expo-file-system';
import { Video as VideoCompressor } from 'react-native-compressor';
import { decode } from 'base64-arraybuffer';
import { supabase } from '../../../lib/supabase';
import { Events } from '../../../lib/analytics';
import { UploadStatus } from '../types';
export function useMediaUpload(matchId: string, userId: string | undefined) {
const [uploading, setUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState<UploadStatus>(null);
const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => {
if (!userId) 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: userId,
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
media_path: fileName,
media_type: mediaType,
media_expired: false,
});
Events.mediaUploaded();
} catch (error) {
Alert.alert("Error", `Failed to upload ${mediaType}`);
console.error(error);
} finally {
setUploading(false);
setUploadStatus(null);
}
};
return { uploading, uploadStatus, uploadMedia };
}
@@ -0,0 +1,43 @@
import { useState, useEffect } from 'react';
import { getStoragePath, getVideoCachedUri, cacheVideo } from '../../../lib/imageCache';
export function useVideoCache(storagePath: string, signedUrl: string | null) {
const [cachedUri, setCachedUri] = useState<string | null>(null);
// Check cache on mount or when storagePath changes
useEffect(() => {
let mounted = true;
const checkCache = async () => {
if (!storagePath) return;
const path = getStoragePath(storagePath);
const cached = await getVideoCachedUri(path);
if (mounted && cached) {
setCachedUri(cached);
}
};
checkCache();
return () => {
mounted = false;
};
}, [storagePath]);
// Function to trigger caching securely
const cacheVideoFile = async () => {
if (!signedUrl || cachedUri) return cachedUri;
const path = getStoragePath(storagePath);
const uri = await cacheVideo(path, signedUrl);
if (uri) {
setCachedUri(uri);
}
return uri;
};
return { cachedUri, cacheVideoFile };
}
+5
View File
@@ -0,0 +1,5 @@
// Chat feature exports
export * from './components';
export * from './hooks';
export * from './types';
export { ChatScreen } from './ChatScreen';
+41
View File
@@ -0,0 +1,41 @@
import { Database } from '../../types/supabase';
/** Message type from database */
export type Message = Database['public']['Tables']['messages']['Row'];
/** Upload status for skeleton display */
export type UploadStatus = {
mediaType: 'image' | 'video';
status: 'compressing' | 'uploading';
thumbnailUri?: string;
} | null;
/** Match type with question and responses */
export interface Match {
id: string;
question_id: string;
couple_id: string;
match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe';
created_at: string;
question?: {
id: string;
text: string;
partner_text?: string;
};
responses?: Array<{
user_id: string;
answer: string;
profiles?: {
name?: string;
};
}>;
}
/** Chat color palette constants */
export const CHAT_COLORS = {
ACCENT: 'rgb(212, 175, 55)', // colors.premium.gold
ACCENT_DARK: 'rgb(184, 134, 11)',
ACCENT_RGBA: 'rgba(212, 175, 55, ',
ROSE: 'rgb(232, 164, 174)',
ROSE_RGBA: 'rgba(232, 164, 174, ',
} as const;