mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
- 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>
351 lines
12 KiB
TypeScript
351 lines
12 KiB
TypeScript
/**
|
|
* MessageContent - Renders message content (text, images, videos)
|
|
* Handles media reveal logic, expired media, and loading states.
|
|
*/
|
|
import React, { useState, useEffect } from 'react';
|
|
import { View, TouchableOpacity, Text, StyleSheet, ActivityIndicator } from 'react-native';
|
|
import { Image } from 'expo-image';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { colors, gradients, spacing, radius } from '../../../theme';
|
|
import { MessageMeta } from './MessageMeta';
|
|
import { ChatVideoPlayer } from './ChatVideoPlayer';
|
|
import { supabase } from '../../../lib/supabase';
|
|
import type { Message } from '../types';
|
|
|
|
const ACCENT = colors.premium.gold;
|
|
|
|
export interface MessageContentProps {
|
|
item: Message;
|
|
isMe: boolean;
|
|
currentUserId: string;
|
|
revealMessage: (id: string) => void;
|
|
onImagePress: (uri: string) => void;
|
|
onVideoFullScreen: (uri: string) => void;
|
|
}
|
|
|
|
const MessageContentComponent: React.FC<MessageContentProps> = ({
|
|
item,
|
|
isMe,
|
|
currentUserId,
|
|
revealMessage,
|
|
onImagePress,
|
|
onVideoFullScreen,
|
|
}) => {
|
|
// Check if message was deleted for everyone
|
|
if (item.deleted_at) {
|
|
return (
|
|
<View style={styles.deletedContainer}>
|
|
<Ionicons
|
|
name="ban-outline"
|
|
size={14}
|
|
color={colors.textTertiary}
|
|
/>
|
|
<Text style={styles.deletedText}>
|
|
{isMe ? 'You deleted this message' : 'This message was deleted'}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const isVideo = item.media_type === 'video';
|
|
const isViewed = !!item.media_viewed_at;
|
|
const isRecipientHidden = !isMe && !isViewed;
|
|
|
|
// Get signed URL for media
|
|
const [mediaUri, setMediaUri] = useState<string | null>(null);
|
|
const [mediaLoading, setMediaLoading] = useState(false);
|
|
const [mediaError, setMediaError] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!item.media_path || isRecipientHidden) {
|
|
setMediaUri(null);
|
|
return;
|
|
}
|
|
|
|
const fetchMediaUrl = async () => {
|
|
setMediaLoading(true);
|
|
setMediaError(false);
|
|
try {
|
|
const { data, error } = await supabase.storage
|
|
.from('chat-media')
|
|
.createSignedUrl(item.media_path!, 3600); // 1 hour expiry
|
|
|
|
if (error) throw error;
|
|
setMediaUri(data.signedUrl);
|
|
} catch (err) {
|
|
console.error('Failed to get media URL:', err);
|
|
setMediaError(true);
|
|
} finally {
|
|
setMediaLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchMediaUrl();
|
|
}, [item.media_path, isRecipientHidden]);
|
|
|
|
// Handle expired videos
|
|
const isExpired = !!item.media_expired;
|
|
|
|
if (isExpired && isVideo) {
|
|
return (
|
|
<View>
|
|
<View style={styles.expiredMedia}>
|
|
<LinearGradient
|
|
colors={['rgba(22, 33, 62, 0.8)', 'rgba(13, 13, 26, 0.9)']}
|
|
style={StyleSheet.absoluteFill}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
/>
|
|
<View style={styles.expiredIconContainer}>
|
|
<Ionicons name="videocam-off-outline" size={28} color={colors.textTertiary} />
|
|
</View>
|
|
<Text style={styles.expiredText}>Video expired</Text>
|
|
<Text style={styles.expiredSubtext}>Videos are deleted 30 days after viewing</Text>
|
|
</View>
|
|
<MessageMeta item={item} isMe={isMe} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Handle media (images and videos)
|
|
if (item.media_path || isExpired) {
|
|
const canOpenFullScreen = (isMe || isViewed) && !isVideo; // Only images can go fullscreen
|
|
const isLoading = mediaLoading || (!mediaUri && !mediaError && !isRecipientHidden);
|
|
|
|
// Video content
|
|
if (isVideo) {
|
|
return (
|
|
<View>
|
|
{isRecipientHidden ? (
|
|
// Clean reveal UI for unrevealed videos
|
|
<TouchableOpacity
|
|
style={[styles.messageVideo, styles.unrevealedPlaceholder]}
|
|
activeOpacity={0.8}
|
|
onPress={() => revealMessage(item.id)}
|
|
>
|
|
<LinearGradient
|
|
colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
|
|
style={StyleSheet.absoluteFill}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
/>
|
|
<LinearGradient
|
|
colors={gradients.premiumGold as [string, string]}
|
|
style={styles.revealIconContainer}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
>
|
|
<Ionicons name="eye-off-outline" size={24} color={colors.text} />
|
|
</LinearGradient>
|
|
<Text style={styles.unrevealedText}>Video hidden</Text>
|
|
<Text style={styles.revealHint}>Tap to reveal</Text>
|
|
</TouchableOpacity>
|
|
) : mediaUri || mediaError ? (
|
|
<ChatVideoPlayer
|
|
signedUrl={mediaUri}
|
|
storagePath={item.media_path!}
|
|
urlError={mediaError}
|
|
onFullScreen={onVideoFullScreen}
|
|
/>
|
|
) : (
|
|
<View style={[styles.messageVideo, styles.messageImageLoading]}>
|
|
<ActivityIndicator color={ACCENT} />
|
|
</View>
|
|
)}
|
|
{/* Viewed indicator for sender */}
|
|
{isMe && isViewed && (
|
|
<View style={styles.viewedBadge}>
|
|
<Ionicons name="eye" size={12} color={colors.success} />
|
|
<Text style={styles.viewedText}>Viewed</Text>
|
|
</View>
|
|
)}
|
|
<MessageMeta item={item} isMe={isMe} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Image content
|
|
return (
|
|
<View>
|
|
<TouchableOpacity
|
|
activeOpacity={canOpenFullScreen ? 0.8 : 1}
|
|
onPress={() => {
|
|
if (canOpenFullScreen && mediaUri) {
|
|
onImagePress(mediaUri);
|
|
}
|
|
}}
|
|
disabled={!(canOpenFullScreen && !!mediaUri)}
|
|
>
|
|
{isRecipientHidden ? (
|
|
<TouchableOpacity
|
|
style={[styles.messageImage, styles.unrevealedPlaceholder]}
|
|
activeOpacity={0.8}
|
|
onPress={() => revealMessage(item.id)}
|
|
>
|
|
<LinearGradient
|
|
colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
|
|
style={StyleSheet.absoluteFill}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
/>
|
|
<LinearGradient
|
|
colors={gradients.premiumGold as [string, string]}
|
|
style={styles.revealIconContainer}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
>
|
|
<Ionicons name="eye-off-outline" size={24} color={colors.text} />
|
|
</LinearGradient>
|
|
<Text style={styles.unrevealedText}>Photo hidden</Text>
|
|
<Text style={styles.revealHint}>Tap to reveal</Text>
|
|
</TouchableOpacity>
|
|
) : mediaUri ? (
|
|
<Image
|
|
source={{ uri: mediaUri }}
|
|
style={styles.messageImage}
|
|
blurRadius={0}
|
|
cachePolicy="disk"
|
|
transition={200}
|
|
/>
|
|
) : mediaError ? (
|
|
<View style={[styles.messageImage, styles.messageImageError]}>
|
|
<Ionicons name="image-outline" size={32} color={colors.textSecondary} />
|
|
<Text style={styles.messageImageErrorText}>Failed to load image</Text>
|
|
</View>
|
|
) : (
|
|
<View style={[styles.messageImage, styles.messageImageLoading]}>
|
|
<ActivityIndicator color={ACCENT} />
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
{/* Viewed indicator for sender */}
|
|
{isMe && isViewed && (
|
|
<View style={styles.viewedBadge}>
|
|
<Ionicons name="eye" size={12} color={colors.success} />
|
|
<Text style={styles.viewedText}>Viewed</Text>
|
|
</View>
|
|
)}
|
|
<MessageMeta item={item} isMe={isMe} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Text message - display content directly
|
|
return (
|
|
<>
|
|
<Text style={styles.messageText}>{item.content ?? 'Message unavailable'}</Text>
|
|
<MessageMeta item={item} isMe={isMe} />
|
|
</>
|
|
);
|
|
};
|
|
|
|
// Wrap with React.memo for performance
|
|
export const MessageContent = React.memo(MessageContentComponent);
|
|
|
|
const styles = StyleSheet.create({
|
|
deletedContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.xs,
|
|
},
|
|
deletedText: {
|
|
color: colors.textTertiary,
|
|
fontSize: 14,
|
|
fontStyle: 'italic',
|
|
},
|
|
messageText: {
|
|
color: colors.text,
|
|
fontSize: 16,
|
|
lineHeight: 22,
|
|
},
|
|
messageImage: {
|
|
width: 200,
|
|
height: 200,
|
|
borderRadius: radius.md,
|
|
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
|
},
|
|
messageImageLoading: {
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
messageImageError: {
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
},
|
|
messageImageErrorText: {
|
|
color: colors.textSecondary,
|
|
fontSize: 12,
|
|
},
|
|
unrevealedPlaceholder: {
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
overflow: 'hidden',
|
|
},
|
|
unrevealedText: {
|
|
color: colors.text,
|
|
fontSize: 13,
|
|
fontWeight: '600',
|
|
},
|
|
revealHint: {
|
|
color: gradients.premiumGold[0],
|
|
fontSize: 12,
|
|
fontWeight: '500',
|
|
},
|
|
messageVideo: {
|
|
width: 200,
|
|
height: 200,
|
|
borderRadius: radius.md,
|
|
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
|
overflow: 'hidden',
|
|
},
|
|
revealIconContainer: {
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: 24,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
viewedBadge: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
marginTop: spacing.xs,
|
|
},
|
|
viewedText: {
|
|
color: colors.success,
|
|
fontSize: 11,
|
|
fontWeight: '500',
|
|
},
|
|
expiredMedia: {
|
|
width: 200,
|
|
height: 150,
|
|
borderRadius: radius.md,
|
|
overflow: 'hidden',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
},
|
|
expiredIconContainer: {
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: 24,
|
|
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
expiredText: {
|
|
color: colors.textSecondary,
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
},
|
|
expiredSubtext: {
|
|
color: colors.textTertiary,
|
|
fontSize: 11,
|
|
textAlign: 'center',
|
|
paddingHorizontal: spacing.md,
|
|
},
|
|
});
|