Files
Sauci/apps/mobile/src/features/chat/components/ChatMessages.tsx
T
jjbrunton 1daf386765 Add question types, nudge feature, and share modal
- Add new question card components (photo, audio, text, who-likely)
- Add share preview modal for matches
- Add nudge notification system with rate limiting
- Add session-based notification improvements
- Add match confetti celebration animation
- Update onboarding flow with paywall tracking
- Remove deprecated swipe card and tutorial components
- Update GlassCard/GlassInput to use solid backgrounds
- Fix routing issues after packs screen removal
2026-01-25 19:17:39 +00:00

200 lines
6.8 KiB
TypeScript

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';
import { MessageBubble } from './MessageBubble';
import { MessageContent } from './MessageContent';
import { DateSeparator } from './DateSeparator';
import { UploadProgress } from './UploadProgress';
import { TypingIndicator } from './TypingIndicator';
import { MatchCard } from './MatchCard';
import { DecorativeSeparator } from '../../../components/ui';
import { colors, spacing, typography } from '../../../theme';
import { Database } from '../../../types/supabase';
import { UploadStatus, Match } from '../types';
type Message = Database['public']['Tables']['messages']['Row'];
interface ChatMessagesProps {
messages: Message[];
userId: string | undefined;
userName: string | undefined;
partnerName: string | undefined;
match: Match | null;
uploadStatus: UploadStatus | null;
partnerTyping: boolean;
onImagePress: (uri: string) => void;
onVideoFullScreen: (uri: string) => void;
revealMessage: (messageId: string) => void;
/** Called when a message bubble is long pressed */
onMessageLongPress: (message: Message, isMe: boolean) => void;
}
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
/**
* Check if two dates are on the same calendar day
*/
function isSameDay(date1: Date, date2: Date): boolean {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
);
}
const ChatMessagesComponent: React.FC<ChatMessagesProps> = ({
messages,
userId,
userName,
partnerName,
match,
uploadStatus,
partnerTyping,
onImagePress,
onVideoFullScreen,
revealMessage,
onMessageLongPress,
}) => {
const flatListRef = useRef<FlatList>(null);
// 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!);
// Determine if we should show a date separator above this message
// In an inverted list, index 0 is newest (bottom), higher indices are older (top)
// Show separator when this message's date differs from the next (older) message
const nextMessage = messages[index + 1];
const showDateSeparator = !nextMessage || (
nextMessage.created_at &&
!isSameDay(messageDate, new Date(nextMessage.created_at))
);
return (
<View>
<MessageBubble
isMe={isMe}
index={index}
onLongPress={() => onMessageLongPress(item, isMe)}
>
<MessageContent
item={item}
isMe={isMe}
currentUserId={userId || ''}
revealMessage={revealMessage}
onImagePress={onImagePress}
onVideoFullScreen={onVideoFullScreen}
/>
</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} userName={userName} partnerName={partnerName} onImagePress={onImagePress} />
</View>
), [match, userObject, userName, partnerName, onImagePress]);
// 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={keyExtractor}
inverted
style={styles.messageList}
contentContainerStyle={styles.listContent}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
showsVerticalScrollIndicator={false}
// 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,
},
listContent: {
flexGrow: 1,
padding: spacing.md,
},
footerContainer: {
paddingBottom: spacing.md,
},
emptyChat: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.xxl,
},
emptyIconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: `${ACCENT_RGBA}0.1)`,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.md,
},
emptyChatTitle: {
...typography.headline,
color: colors.text,
marginBottom: spacing.xs,
},
emptyChatSubtitle: {
...typography.subhead,
color: colors.textSecondary,
},
});