mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
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:
1 parent
595d6bd394
commit
b0f1700e40
57 files changed
+8359
-1661
No files matched your search
@@ -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';
|
||||
@@ -25,6 +25,7 @@ export interface Match {
|
||||
responses?: Array<{
|
||||
user_id: string;
|
||||
answer: string;
|
||||
created_at: string;
|
||||
profiles?: {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user