Add match archiving, chat enhancements, and daily limits

- Match archives with swipe-to-archive and unread filtering
- Chat settings sheet and full-screen media modals
- Match notification modal for new matches
- Admin AI settings with model/provider configuration
- Daily response limit feature with recommended questions
- New test coverage for stores, hooks, and components
- Claude Code agent and command configurations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Opus 4.5 committed 2026-01-09 20:18:34 +00:00
1 parent 595d6bd394
commit b0f1700e40
57 files changed
+8359 -1661

No files matched your search

+105 -172
View File
@@ -1,21 +1,18 @@
import { useState, useEffect, useCallback } from "react";
import { View, Text, StyleSheet, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, Modal, Dimensions, Alert } from "react-native";
import { Image } from "expo-image";
import { Video, ResizeMode } from "expo-av";
import { View, StyleSheet, KeyboardAvoidingView, Platform, Alert } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useFocusEffect, useIsFocused } 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 Animated, { FadeInDown } from "react-native-reanimated";
import { InputBar, ChatHeader, ChatMessages, ReportMessageSheet } from "./components";
import { InputBar, ChatHeader, ChatMessages, ReportMessageSheet, ChatSettingsSheet, FullScreenImageModal, FullScreenVideoModal } from "./components";
import { useMediaUpload } from "./hooks/useMediaUpload";
import { useMessageActions } from "./hooks/useMessageActions";
import type { ReportReason } from "./types";
import type { ReportReason, Match } from "./types";
import { supabase } from "../../lib/supabase";
import { useAuthStore, useMessageStore } from "../../store";
import { useAuthStore, useMessageStore, useMatchStore } from "../../store";
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useMediaSaver } from "../../hooks";
import { Database } from "../../types/supabase";
import { GradientBackground } from "../../components/ui";
@@ -25,7 +22,6 @@ 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, ';
@@ -36,8 +32,10 @@ export const ChatScreen: React.FC = () => {
const insets = useSafeAreaInsets();
const { user, partner } = useAuthStore();
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
const [match, setMatch] = useState<any>(null);
const { archiveMatch, unarchiveMatch, isMatchArchived } = useMatchStore();
const [match, setMatch] = useState<Match | null>(null);
const [inputText, setInputText] = useState("");
const [showSettingsSheet, setShowSettingsSheet] = useState(false);
const { uploading, uploadStatus, uploadMedia } = useMediaUpload(matchId, user?.id);
const { saveMedia, saving: mediaSaving } = useMediaSaver();
const [fullScreenImage, setFullScreenImage] = useState<string | null>(null);
@@ -81,6 +79,62 @@ export const ChatScreen: React.FC = () => {
onReport: (message) => setReportingMessage(message),
});
// Memoized navigation callbacks
const handleBack = useCallback(() => {
router.push('/(app)/matches');
}, [router]);
const handleOpenSettings = useCallback(() => {
setShowSettingsSheet(true);
}, []);
const handleCloseSettings = useCallback(() => {
setShowSettingsSheet(false);
}, []);
// Memoized modal callbacks
const handleCloseImage = useCallback(() => {
setFullScreenImage(null);
}, []);
const handleSaveImage = useCallback(() => {
if (fullScreenImage) {
saveMedia(fullScreenImage, 'image');
}
}, [fullScreenImage, saveMedia]);
const handleImageLoadStart = useCallback(() => {
setFullScreenImageLoading(true);
}, []);
const handleImageLoadEnd = useCallback(() => {
setFullScreenImageLoading(false);
}, []);
const handleCloseVideo = useCallback(() => {
setFullScreenVideo(null);
}, []);
const handleSaveVideo = useCallback(() => {
if (fullScreenVideo) {
saveMedia(fullScreenVideo, 'video');
}
}, [fullScreenVideo, saveMedia]);
const handleCloseReport = useCallback(() => {
setReportingMessage(null);
}, []);
// Memoized archive/unarchive callbacks
const handleArchive = useCallback(async () => {
await archiveMatch(matchId);
router.push('/(app)/matches');
}, [archiveMatch, matchId, router]);
const handleUnarchive = useCallback(async () => {
await unarchiveMatch(matchId);
}, [unarchiveMatch, matchId]);
// Handle long press on message to show options
const handleMessageLongPress = useCallback((message: Message, isMe: boolean) => {
// Don't show options for already deleted messages
@@ -89,7 +143,7 @@ export const ChatScreen: React.FC = () => {
}, [showDeleteOptions]);
// Handle report submission
const handleSubmitReport = async (reason: ReportReason) => {
const handleSubmitReport = useCallback(async (reason: ReportReason) => {
if (!reportingMessage || !user?.id) return;
try {
@@ -127,7 +181,7 @@ export const ChatScreen: React.FC = () => {
Alert.alert('Error', 'Failed to submit report');
setReportingMessage(null);
}
};
}, [reportingMessage, user?.id, deleteForSelf, setMessages]);
// Track active chat to prevent notifications for current chat
useFocusEffect(
@@ -169,15 +223,15 @@ export const ChatScreen: React.FC = () => {
fetchMatch();
}, [matchId]);
const handleTyping = (text: string) => {
const handleTyping = useCallback((text: string) => {
setInputText(text);
if (text.length > 0) {
sendTypingEvent();
}
};
}, [sendTypingEvent]);
const handleSend = async () => {
const handleSend = useCallback(async () => {
if (!inputText.trim()) return;
if (!user) return;
@@ -204,30 +258,30 @@ export const ChatScreen: React.FC = () => {
Alert.alert("Delivery Failed", "Couldn't send message. Please check your connection.");
setInputText(content);
}
};
}, [inputText, user, matchId]);
const handlePickMedia = async () => {
const handlePickMedia = useCallback(async () => {
const result = await pickMedia();
if (result) {
uploadMedia(result.uri, result.mediaType);
}
};
}, [pickMedia, uploadMedia]);
const handleTakePhoto = async () => {
const handleTakePhoto = useCallback(async () => {
const result = await takePhoto();
if (result) {
uploadMedia(result.uri, 'image');
}
};
}, [takePhoto, uploadMedia]);
const handleRecordVideo = async () => {
const handleRecordVideo = useCallback(async () => {
const result = await recordVideo();
if (result) {
uploadMedia(result.uri, 'video');
}
};
}, [recordVideo, uploadMedia]);
const revealMessage = async (messageId: string) => {
const revealMessage = useCallback(async (messageId: string) => {
const message = messages.find(m => m.id === messageId);
const now = new Date();
// Set expiration 30 days from now for videos only
@@ -250,7 +304,7 @@ export const ChatScreen: React.FC = () => {
media_expires_at: expiresAt
})
.eq("id", messageId);
};
}, [messages, setMessages]);
return (
<GradientBackground>
@@ -280,10 +334,9 @@ export const ChatScreen: React.FC = () => {
<ChatHeader
partner={partner}
user={user}
match={match}
insets={insets}
onBack={() => router.push('/(app)/matches')}
onBack={handleBack}
onSettingsPress={handleOpenSettings}
/>
@@ -327,124 +380,41 @@ export const ChatScreen: React.FC = () => {
</KeyboardAvoidingView>
{/* Full Screen Image Modal */}
<Modal
<FullScreenImageModal
uri={fullScreenImage}
visible={!!fullScreenImage}
transparent
animationType="fade"
onRequestClose={() => setFullScreenImage(null)}
>
<View style={styles.fullScreenOverlay}>
<TouchableOpacity
style={styles.fullScreenSaveButton}
onPress={() => fullScreenImage && saveMedia(fullScreenImage, 'image')}
activeOpacity={0.8}
disabled={mediaSaving}
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.fullScreenCloseGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{mediaSaving ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<Ionicons name="download-outline" size={24} color={colors.text} />
)}
</LinearGradient>
</TouchableOpacity>
<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>
loading={fullScreenImageLoading}
saving={mediaSaving}
onClose={handleCloseImage}
onSave={handleSaveImage}
onLoadStart={handleImageLoadStart}
onLoadEnd={handleImageLoadEnd}
/>
{/* Full Screen Video Modal */}
<Modal
<FullScreenVideoModal
uri={fullScreenVideo}
visible={!!fullScreenVideo}
transparent
animationType="fade"
onRequestClose={() => setFullScreenVideo(null)}
>
<View style={styles.fullScreenOverlay}>
<TouchableOpacity
style={styles.fullScreenSaveButton}
onPress={() => fullScreenVideo && saveMedia(fullScreenVideo, 'video')}
activeOpacity={0.8}
disabled={mediaSaving}
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.fullScreenCloseGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{mediaSaving ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<Ionicons name="download-outline" size={24} color={colors.text} />
)}
</LinearGradient>
</TouchableOpacity>
<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>
saving={mediaSaving}
onClose={handleCloseVideo}
onSave={handleSaveVideo}
/>
{/* Report Message Sheet */}
<ReportMessageSheet
visible={reportingMessage !== null}
onClose={() => setReportingMessage(null)}
onClose={handleCloseReport}
onSubmit={handleSubmitReport}
/>
{/* Chat Settings Sheet */}
<ChatSettingsSheet
visible={showSettingsSheet}
onClose={handleCloseSettings}
isArchived={isMatchArchived(matchId)}
onArchive={handleArchive}
onUnarchive={handleUnarchive}
/>
</GradientBackground>
);
};
@@ -473,43 +443,6 @@ const styles = StyleSheet.create({
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,
},
fullScreenSaveButton: {
position: "absolute",
top: 50,
left: 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',
},
@@ -15,16 +15,16 @@ const ACCENT_RGBA = 'rgba(212, 175, 55, ';
interface ChatHeaderProps {
partner: Profile | null;
user: { id: string } | null;
match: any; // Kept for compatibility but unused
insets: EdgeInsets;
onBack: () => void;
onSettingsPress?: () => void;
}
export const ChatHeader: React.FC<ChatHeaderProps> = ({
const ChatHeaderComponent: React.FC<ChatHeaderProps> = ({
partner,
insets,
onBack,
onSettingsPress,
}) => {
return (
<Animated.View
@@ -50,6 +50,8 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
onPress={onBack}
style={styles.backButton}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Go back to matches"
>
<View style={styles.backButtonInner}>
<Ionicons name="chevron-back" size={20} color={ACCENT} />
@@ -86,7 +88,21 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
</View>
</View>
<View style={styles.headerSpacer} />
{onSettingsPress ? (
<TouchableOpacity
onPress={onSettingsPress}
style={styles.settingsButton}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Chat settings"
>
<View style={styles.backButtonInner}>
<Ionicons name="ellipsis-horizontal" size={20} color={ACCENT} />
</View>
</TouchableOpacity>
) : (
<View style={styles.headerSpacer} />
)}
{/* Bottom border */}
<View style={styles.headerBorderBottom} />
@@ -94,6 +110,9 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
);
};
// Wrap with React.memo for performance
export const ChatHeader = React.memo(ChatHeaderComponent);
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
@@ -179,6 +198,12 @@ const styles = StyleSheet.create({
headerSpacer: {
width: 40,
},
settingsButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
headerBorderBottom: {
position: 'absolute',
bottom: 0,
@@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useRef, useCallback, useMemo } from 'react';
import { View, Text, FlatList, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeIn } from 'react-native-reanimated';
@@ -12,14 +12,14 @@ import { MatchCard } from './MatchCard';
import { DecorativeSeparator } from '../../../components/ui';
import { colors, spacing, typography } from '../../../theme';
import { Database } from '../../../types/supabase';
import { UploadStatus } from '../types';
import { UploadStatus, Match } from '../types';
type Message = Database['public']['Tables']['messages']['Row'];
interface ChatMessagesProps {
messages: Message[];
userId: string | undefined;
match: any;
match: Match | null;
uploadStatus: UploadStatus | null;
partnerTyping: boolean;
onImagePress: (uri: string) => void;
@@ -43,7 +43,7 @@ function isSameDay(date1: Date, date2: Date): boolean {
);
}
export const ChatMessages: React.FC<ChatMessagesProps> = ({
const ChatMessagesComponent: React.FC<ChatMessagesProps> = ({
messages,
userId,
match,
@@ -56,7 +56,14 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
}) => {
const flatListRef = useRef<FlatList>(null);
const renderMessage = ({ item, index }: { item: Message; index: number }) => {
// Memoize keyExtractor to prevent recreation
const keyExtractor = useCallback((item: Message) => item.id, []);
// Memoize user object for MatchCard to prevent re-renders
const userObject = useMemo(() => ({ id: userId || '' }), [userId]);
// Memoize renderMessage to prevent recreation on every render
const renderMessage = useCallback(({ item, index }: { item: Message; index: number }) => {
const isMe = item.user_id === userId;
const messageDate = new Date(item.created_at!);
@@ -70,7 +77,7 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
);
return (
<>
<View>
<MessageBubble
isMe={isMe}
index={index}
@@ -86,58 +93,68 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
/>
</MessageBubble>
{showDateSeparator && <DateSeparator date={messageDate} />}
</>
</View>
);
};
}, [messages, userId, onMessageLongPress, revealMessage, onImagePress, onVideoFullScreen]);
// Memoize ListHeaderComponent to prevent recreation
const listHeaderComponent = useMemo(() => (
<View>
{uploadStatus && <UploadProgress uploadStatus={uploadStatus} />}
{partnerTyping && <TypingIndicator />}
</View>
), [uploadStatus, partnerTyping]);
// Memoize ListFooterComponent to prevent recreation
const listFooterComponent = useMemo(() => (
<View style={styles.footerContainer}>
<MatchCard match={match} user={userObject} />
</View>
), [match, userObject]);
// Memoize ListEmptyComponent to prevent recreation
const listEmptyComponent = useMemo(() => (
<Animated.View
entering={FadeIn.delay(200).duration(400)}
style={styles.emptyChat}
>
<View style={styles.emptyIconContainer}>
<Ionicons name="chatbubbles-outline" size={32} color={ACCENT} />
</View>
<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>
), []);
return (
<FlatList
ref={flatListRef}
data={messages}
renderItem={renderMessage}
keyExtractor={(item) => item.id}
keyExtractor={keyExtractor}
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 />}
</>
}
ListFooterComponent={
<View style={{ paddingBottom: spacing.md }}>
<MatchCard match={match} user={{ id: userId || '' }} />
</View>
}
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>
}
// Performance optimizations
removeClippedSubviews={true}
maxToRenderPerBatch={15}
windowSize={10}
initialNumToRender={20}
ListHeaderComponent={listHeaderComponent}
ListFooterComponent={listFooterComponent}
ListEmptyComponent={listEmptyComponent}
/>
);
};
// Wrap with React.memo for performance
export const ChatMessages = React.memo(ChatMessagesComponent);
const styles = StyleSheet.create({
messageList: {
flex: 1,
@@ -146,6 +163,9 @@ const styles = StyleSheet.create({
flexGrow: 1,
padding: spacing.md,
},
footerContainer: {
paddingBottom: spacing.md,
},
emptyChat: {
alignItems: 'center',
justifyContent: 'center',
@@ -0,0 +1,247 @@
import { useState } from "react";
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
TouchableWithoutFeedback,
ActivityIndicator,
Platform,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated";
import { colors, spacing, typography, radius } from "../../../theme";
interface ChatSettingsSheetProps {
visible: boolean;
onClose: () => void;
isArchived: boolean;
onArchive: () => Promise<void>;
onUnarchive: () => Promise<void>;
}
const ACCENT = colors.premium.gold;
export function ChatSettingsSheet({
visible,
onClose,
isArchived,
onArchive,
onUnarchive,
}: ChatSettingsSheetProps) {
const [isLoading, setIsLoading] = useState(false);
const handleArchiveToggle = async () => {
if (isLoading) return;
setIsLoading(true);
try {
if (isArchived) {
await onUnarchive();
} else {
await onArchive();
}
onClose();
} finally {
setIsLoading(false);
}
};
return (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onClose}
statusBarTranslucent
>
<Animated.View style={styles.overlay} entering={FadeIn.duration(200)} exiting={FadeOut.duration(200)}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop} />
</TouchableWithoutFeedback>
<Animated.View
style={styles.sheetContainer}
entering={SlideInDown.springify().damping(20)}
exiting={SlideOutDown.duration(200)}
>
{Platform.OS === "ios" ? (
<BlurView intensity={80} tint="dark" style={styles.sheet}>
<SheetContent
isArchived={isArchived}
isLoading={isLoading}
onClose={onClose}
onArchiveToggle={handleArchiveToggle}
/>
</BlurView>
) : (
<View style={[styles.sheet, styles.sheetAndroid]}>
<SheetContent
isArchived={isArchived}
isLoading={isLoading}
onClose={onClose}
onArchiveToggle={handleArchiveToggle}
/>
</View>
)}
</Animated.View>
</Animated.View>
</Modal>
);
}
function SheetContent({
isArchived,
isLoading,
onClose,
onArchiveToggle,
}: {
isArchived: boolean;
isLoading: boolean;
onClose: () => void;
onArchiveToggle: () => void;
}) {
return (
<>
{/* Handle */}
<View style={styles.handle} />
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Chat Settings</Text>
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Ionicons name="close" size={20} color={colors.textSecondary} />
</TouchableOpacity>
</View>
{/* Archive option */}
<TouchableOpacity
style={styles.optionButton}
onPress={onArchiveToggle}
disabled={isLoading}
activeOpacity={0.7}
>
<View style={styles.optionContent}>
<View style={styles.optionIconContainer}>
<Ionicons
name={isArchived ? "arrow-undo" : "archive"}
size={22}
color={ACCENT}
/>
</View>
<View style={styles.optionTextContainer}>
<Text style={styles.optionTitle}>
{isArchived ? "Unarchive Chat" : "Archive Chat"}
</Text>
<Text style={styles.optionDescription}>
{isArchived
? "Move this chat back to your main list"
: "Hide this chat from your main list"}
</Text>
</View>
{isLoading && (
<ActivityIndicator size="small" color={ACCENT} style={styles.optionLoader} />
)}
</View>
</TouchableOpacity>
{/* Bottom padding for safe area */}
<View style={styles.bottomPadding} />
</>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: "flex-end",
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.6)",
},
sheetContainer: {
maxHeight: "80%",
},
sheet: {
borderTopLeftRadius: radius.xl,
borderTopRightRadius: radius.xl,
overflow: "hidden",
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
},
sheetAndroid: {
backgroundColor: colors.backgroundLight,
},
handle: {
width: 40,
height: 4,
backgroundColor: "rgba(255, 255, 255, 0.3)",
borderRadius: 2,
alignSelf: "center",
marginBottom: spacing.md,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginBottom: spacing.lg,
},
headerTitle: {
...typography.title3,
color: colors.text,
},
closeButton: {
position: "absolute",
right: 0,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: "rgba(255, 255, 255, 0.1)",
justifyContent: "center",
alignItems: "center",
},
optionButton: {
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
borderRadius: radius.lg,
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
marginBottom: spacing.md,
},
optionContent: {
flexDirection: "row",
alignItems: "center",
},
optionIconContainer: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: `rgba(212, 175, 55, 0.1)`,
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
},
optionTextContainer: {
flex: 1,
},
optionTitle: {
...typography.callout,
color: colors.text,
fontWeight: "600",
marginBottom: 2,
},
optionDescription: {
...typography.caption1,
color: colors.textSecondary,
},
optionLoader: {
marginLeft: spacing.sm,
},
bottomPadding: {
height: Platform.OS === "ios" ? 34 : 20,
},
});
@@ -24,12 +24,12 @@ const ACCENT = colors.premium.gold;
* Video player component with native-like tap behavior.
* Supports play/pause, fullscreen, caching, and loading states.
*/
export function ChatVideoPlayer({
const ChatVideoPlayerComponent: React.FC<ChatVideoPlayerProps> = ({
signedUrl,
storagePath,
urlError,
onFullScreen,
}: ChatVideoPlayerProps) {
}) => {
const videoRef = useRef<Video>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [hasEnded, setHasEnded] = useState(false);
@@ -43,11 +43,6 @@ export function ChatVideoPlayer({
const isLocalFile = signedUrl?.startsWith('file://');
const videoSource = isLocalFile ? signedUrl : (cachedUri || signedUrl);
// Debug logging
useEffect(() => {
console.log(`[ChatVideoPlayer] isLocalFile=${isLocalFile}, storagePath=${storagePath}, signedUrl=${signedUrl?.substring(0, 60)}..., videoSource=${videoSource?.substring(0, 60)}...`);
}, [storagePath, signedUrl, videoSource, isLocalFile]);
// Handle playback status updates
const handlePlaybackStatusUpdate = useCallback(async (status: AVPlaybackStatus) => {
if (!status.isLoaded) {
@@ -71,7 +66,7 @@ export function ChatVideoPlayer({
cacheVideoFile();
}
}
}, [signedUrl, storagePath, cachedUri, isLocalFile]);
}, [signedUrl, cachedUri, isLocalFile, cacheVideoFile]);
// Toggle play/pause with native-like behavior
const handleTapToPlay = useCallback(async () => {
@@ -93,7 +88,7 @@ export function ChatVideoPlayer({
cacheVideoFile();
}
}
}, [isPlaying, hasEnded, signedUrl, storagePath, cachedUri]);
}, [isPlaying, hasEnded, signedUrl, cachedUri, cacheVideoFile]);
// Handle full screen
const handleFullScreen = useCallback(() => {
@@ -180,7 +175,10 @@ export function ChatVideoPlayer({
)}
</TouchableOpacity>
);
}
};
// Wrap with React.memo for performance
export const ChatVideoPlayer = React.memo(ChatVideoPlayerComponent);
const styles = StyleSheet.create({
container: {
@@ -245,5 +243,3 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
});
export default ChatVideoPlayer;
@@ -0,0 +1,133 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity, Modal, ActivityIndicator, useWindowDimensions } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors, gradients } from '../../../theme';
const ACCENT = colors.premium.gold;
interface FullScreenImageModalProps {
uri: string | null;
visible: boolean;
loading: boolean;
saving: boolean;
onClose: () => void;
onSave: () => void;
onLoadStart: () => void;
onLoadEnd: () => void;
}
const FullScreenImageModalComponent: React.FC<FullScreenImageModalProps> = ({
uri,
visible,
loading,
saving,
onClose,
onSave,
onLoadStart,
onLoadEnd,
}) => {
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const insets = useSafeAreaInsets();
// Use safe area insets for button positioning to avoid notch/dynamic island
const buttonTop = insets.top + 10;
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<View style={styles.overlay}>
<TouchableOpacity
style={[styles.saveButton, { top: buttonTop }]}
onPress={onSave}
activeOpacity={0.8}
disabled={saving}
accessibilityRole="button"
accessibilityLabel="Save image to device"
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.buttonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{saving ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<Ionicons name="download-outline" size={24} color={colors.text} />
)}
</LinearGradient>
</TouchableOpacity>
<TouchableOpacity
style={[styles.closeButton, { top: buttonTop }]}
onPress={onClose}
activeOpacity={0.8}
accessibilityRole="button"
accessibilityLabel="Close full screen image"
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.buttonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="close" size={24} color={colors.text} />
</LinearGradient>
</TouchableOpacity>
{loading && (
<ActivityIndicator size="large" color={ACCENT} style={styles.spinner} />
)}
{uri && (
<Image
source={{ uri }}
style={{ width: windowWidth, height: windowHeight * 0.8 }}
contentFit="contain"
cachePolicy="disk"
onLoadStart={onLoadStart}
onLoadEnd={onLoadEnd}
/>
)}
</View>
</Modal>
);
};
export const FullScreenImageModal = React.memo(FullScreenImageModalComponent);
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.95)',
justifyContent: 'center',
alignItems: 'center',
},
closeButton: {
position: 'absolute',
right: 20,
zIndex: 10,
},
saveButton: {
position: 'absolute',
left: 20,
zIndex: 10,
},
buttonGradient: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
spinner: {
position: 'absolute',
},
});
@@ -0,0 +1,118 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity, Modal, ActivityIndicator, useWindowDimensions } from 'react-native';
import { Video, ResizeMode } from 'expo-av';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors, gradients } from '../../../theme';
interface FullScreenVideoModalProps {
uri: string | null;
visible: boolean;
saving: boolean;
onClose: () => void;
onSave: () => void;
}
const FullScreenVideoModalComponent: React.FC<FullScreenVideoModalProps> = ({
uri,
visible,
saving,
onClose,
onSave,
}) => {
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const insets = useSafeAreaInsets();
// Use safe area insets for button positioning to avoid notch/dynamic island
const buttonTop = insets.top + 10;
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<View style={styles.overlay}>
<TouchableOpacity
style={[styles.saveButton, { top: buttonTop }]}
onPress={onSave}
activeOpacity={0.8}
disabled={saving}
accessibilityRole="button"
accessibilityLabel="Save video to device"
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.buttonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{saving ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<Ionicons name="download-outline" size={24} color={colors.text} />
)}
</LinearGradient>
</TouchableOpacity>
<TouchableOpacity
style={[styles.closeButton, { top: buttonTop }]}
onPress={onClose}
activeOpacity={0.8}
accessibilityRole="button"
accessibilityLabel="Close full screen video"
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.buttonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="close" size={24} color={colors.text} />
</LinearGradient>
</TouchableOpacity>
{uri && (
<Video
source={{ uri }}
style={{ width: windowWidth, height: windowHeight * 0.8 }}
resizeMode={ResizeMode.CONTAIN}
useNativeControls
shouldPlay={visible}
isLooping={false}
/>
)}
</View>
</Modal>
);
};
export const FullScreenVideoModal = React.memo(FullScreenVideoModalComponent);
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.95)',
justifyContent: 'center',
alignItems: 'center',
},
closeButton: {
position: 'absolute',
right: 20,
zIndex: 10,
},
saveButton: {
position: 'absolute',
left: 20,
zIndex: 10,
},
buttonGradient: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
});
@@ -27,7 +27,7 @@ const ACCENT = colors.premium.gold;
* Chat input bar with expandable media menu.
* Includes text input, send button, and media action buttons.
*/
export function InputBar({
const InputBarComponent: React.FC<InputBarProps> = ({
inputText,
uploading,
onChangeText,
@@ -35,7 +35,7 @@ export function InputBar({
onPickMedia,
onTakePhoto,
onRecordVideo,
}: InputBarProps) {
}) => {
const [menuExpanded, setMenuExpanded] = useState(false);
const menuWidth = useSharedValue(0);
const buttonRotation = useSharedValue(0);
@@ -176,7 +176,10 @@ export function InputBar({
</TouchableOpacity>
</View>
);
}
};
// Wrap with React.memo for performance
export const InputBar = React.memo(InputBarComponent);
const styles = StyleSheet.create({
container: {
@@ -261,5 +264,3 @@ const styles = StyleSheet.create({
height: 20,
},
});
export default InputBar;
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
@@ -6,24 +6,38 @@ import Animated, { FadeInDown } from 'react-native-reanimated';
import { DecorativeSeparator } from '../../../components/ui';
import { colors, gradients, spacing, radius, typography } from '../../../theme';
import { Match } from '../types';
// Premium color palette
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
interface MatchCardProps {
match: any;
match: Match | null;
user: { id: string } | null;
}
export const MatchCard: React.FC<MatchCardProps> = ({ match, user }) => {
const MatchCardComponent: React.FC<MatchCardProps> = ({ match, user }) => {
// 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';
// Only animate on first render to prevent re-animation when list re-renders
const enteringAnimation = shouldAnimate
? FadeInDown.delay(100).duration(400).springify()
: undefined;
return (
<Animated.View
entering={FadeInDown.delay(100).duration(400).springify()}
entering={enteringAnimation}
style={styles.matchCardContainer}
>
<View style={styles.matchCard}>
@@ -66,19 +80,19 @@ export const MatchCard: React.FC<MatchCardProps> = ({ match, user }) => {
{(() => {
// Sort responses by created_at to determine who answered first
const sortedResponses = [...match.responses].sort(
(a: any, b: any) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
const isTwoPart = !!match.question.partner_text;
const isTwoPart = !!match.question?.partner_text;
return sortedResponses.map((response: any, index: number) => {
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;
? match.question?.partner_text
: match.question?.text;
return (
<View key={response.user_id} style={styles.responseCard}>
@@ -130,6 +144,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({ match, user }) => {
);
};
// Wrap with React.memo for performance
export const MatchCard = React.memo(MatchCardComponent);
const styles = StyleSheet.create({
matchCardContainer: {
marginHorizontal: spacing.md,
@@ -10,6 +10,9 @@ import { colors, gradients, spacing, radius } from '../../../theme';
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
// Only animate the first few messages to avoid performance issues on large lists
const MAX_ANIMATED_INDEX = 5;
export interface MessageBubbleProps {
isMe: boolean;
index: number;
@@ -18,10 +21,18 @@ export interface MessageBubbleProps {
onLongPress?: () => void;
}
export function MessageBubble({ isMe, index, children, onLongPress }: MessageBubbleProps) {
const MessageBubbleComponent = ({ isMe, index, children, onLongPress }: MessageBubbleProps) => {
// Only animate recent messages (low index in inverted list) to avoid performance issues
const shouldAnimate = index < MAX_ANIMATED_INDEX;
const enteringAnimation = shouldAnimate ? FadeInUp.duration(200) : undefined;
const accessibilityLabel = isMe
? 'Your message. Long press for options'
: "Partner's message. Long press for options";
return (
<Animated.View
entering={FadeInUp.delay(index * 30).duration(200)}
entering={enteringAnimation}
style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}
>
{isMe ? (
@@ -30,6 +41,9 @@ export function MessageBubble({ isMe, index, children, onLongPress }: MessageBub
activeOpacity={0.9}
delayLongPress={300}
style={styles.myBubbleContainer}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint="Long press to delete or report"
>
<LinearGradient
colors={gradients.primary as [string, string]}
@@ -53,6 +67,9 @@ export function MessageBubble({ isMe, index, children, onLongPress }: MessageBub
activeOpacity={0.9}
delayLongPress={300}
style={styles.theirBubbleContainer}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint="Long press to report"
>
<View style={[styles.bubble, styles.theirBubble]}>
{/* Subtle gradient background */}
@@ -75,7 +92,10 @@ export function MessageBubble({ isMe, index, children, onLongPress }: MessageBub
)}
</Animated.View>
);
}
};
// Wrap with React.memo for performance
export const MessageBubble = React.memo(MessageBubbleComponent);
const styles = StyleSheet.create({
messageRow: {
@@ -116,5 +136,3 @@ const styles = StyleSheet.create({
height: 30,
},
});
export default MessageBubble;
@@ -22,18 +22,16 @@ export interface MessageContentProps {
revealMessage: (id: string) => void;
onImagePress: (uri: string) => void;
onVideoFullScreen: (uri: string) => void;
onLongPress?: () => void;
}
export function MessageContent({
const MessageContentComponent: React.FC<MessageContentProps> = ({
item,
isMe,
currentUserId,
revealMessage,
onImagePress,
onVideoFullScreen,
onLongPress,
}: MessageContentProps) {
}) => {
// Check if message was deleted for everyone
if (item.deleted_at) {
return (
@@ -50,7 +48,7 @@ export function MessageContent({
);
}
const isVideo = (item as any).media_type === 'video';
const isVideo = item.media_type === 'video';
const isViewed = !!item.media_viewed_at;
const isRecipientHidden = !isMe && !isViewed;
@@ -87,7 +85,7 @@ export function MessageContent({
}, [item.media_path, isRecipientHidden]);
// Handle expired videos
const isExpired = !!(item as any).media_expired;
const isExpired = !!item.media_expired;
if (isExpired && isVideo) {
return (
@@ -240,7 +238,10 @@ export function MessageContent({
<MessageMeta item={item} isMe={isMe} />
</>
);
}
};
// Wrap with React.memo for performance
export const MessageContent = React.memo(MessageContentComponent);
const styles = StyleSheet.create({
deletedContainer: {
@@ -347,5 +348,3 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
},
});
export default MessageContent;
@@ -12,7 +12,7 @@ export interface MessageMetaProps {
/**
* Message metadata component showing timestamp and read receipts.
*/
export function MessageMeta({ item, isMe }: MessageMetaProps) {
const MessageMetaComponent: React.FC<MessageMetaProps> = ({ item, isMe }) => {
const status = getReceiptStatus(item.delivered_at, item.read_at);
return (
@@ -27,7 +27,10 @@ export function MessageMeta({ item, isMe }: MessageMetaProps) {
)}
</View>
);
}
};
// Wrap with React.memo for performance
export const MessageMeta = React.memo(MessageMetaComponent);
const styles = StyleSheet.create({
container: {
@@ -43,5 +46,3 @@ const styles = StyleSheet.create({
marginLeft: spacing.xs,
},
});
export default MessageMeta;
@@ -15,7 +15,7 @@ interface TypingDotProps {
delay: number;
}
function TypingDot({ delay }: TypingDotProps) {
const TypingDot = React.memo(function TypingDot({ delay }: TypingDotProps) {
const opacity = useSharedValue(0.4);
useEffect(() => {
@@ -43,13 +43,13 @@ function TypingDot({ delay }: TypingDotProps) {
/>
</Animated.View>
);
}
});
/**
* Typing indicator component showing three animated dots
* when partner is typing.
*/
export function TypingIndicator() {
const TypingIndicatorComponent: React.FC = () => {
return (
<Animated.View
entering={FadeIn.duration(200)}
@@ -71,7 +71,10 @@ export function TypingIndicator() {
</View>
</Animated.View>
);
}
};
// Wrap with React.memo for performance
export const TypingIndicator = React.memo(TypingIndicatorComponent);
const styles = StyleSheet.create({
container: {
@@ -104,5 +107,3 @@ const styles = StyleSheet.create({
borderRadius: 4,
},
});
export default TypingIndicator;
@@ -16,3 +16,6 @@ export { ChatHeader } from './ChatHeader';
export { ChatMessages } from './ChatMessages';
export { MatchCard } from './MatchCard';
export { ReportMessageSheet } from './ReportMessageSheet';
export { ChatSettingsSheet } from './ChatSettingsSheet';
export { FullScreenImageModal } from './FullScreenImageModal';
export { FullScreenVideoModal } from './FullScreenVideoModal';
+1
View File
@@ -25,6 +25,7 @@ export interface Match {
responses?: Array<{
user_id: string;
answer: string;
created_at: string;
profiles?: {
name?: string;
};