Implement E2EE and message content moderation

This commit is contained in:
Jamie Brunton committed 2026-01-02 20:49:11 +00:00
1 parent e1ff87dc03
commit 198cd80dfd
74 files changed
+12939 -2178

No files matched your search

+110 -9
View File
@@ -11,10 +11,11 @@ import Animated, { FadeInDown, useAnimatedStyle } from "react-native-reanimated"
import { InputBar, ChatHeader, ChatMessages } from "./components";
import { useMediaUpload } from "./hooks/useMediaUpload";
import { useMessageActions } from "./hooks/useMessageActions";
import { supabase } from "../../lib/supabase";
import { useAuthStore, useMessageStore } from "../../store";
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription } from "../../hooks";
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useEncryptedSend, useMediaSaver } from "../../hooks";
import { Database } from "../../types/supabase";
import { GradientBackground } from "../../components/ui";
import { colors, gradients, spacing } from "../../theme";
@@ -37,6 +38,7 @@ export const ChatScreen: React.FC = () => {
const [match, setMatch] = useState<any>(null);
const [inputText, setInputText] = useState("");
const { uploading, uploadStatus, uploadMedia } = useMediaUpload(matchId, user?.id);
const { saveMedia, saving: mediaSaving } = useMediaSaver();
const [fullScreenImage, setFullScreenImage] = useState<string | null>(null);
const [fullScreenImageLoading, setFullScreenImageLoading] = useState(true);
const [fullScreenVideo, setFullScreenVideo] = useState<string | null>(null);
@@ -67,6 +69,19 @@ export const ChatScreen: React.FC = () => {
onNewMessage: clearTypingIndicator,
});
// E2EE encryption hook for sending messages
const { encryptMessage, isE2EEAvailable } = useEncryptedSend();
// Message actions hook for deletion
const { showDeleteOptions } = useMessageActions({ userId: user?.id });
// Handle long press on message to show delete options
const handleMessageLongPress = useCallback((message: Message, isMe: boolean) => {
// Don't show options for already deleted messages
if (message.deleted_at) return;
showDeleteOptions(message, isMe);
}, [showDeleteOptions]);
// Track active chat to prevent notifications for current chat
useFocusEffect(
useCallback(() => {
@@ -121,17 +136,56 @@ export const ChatScreen: React.FC = () => {
const content = inputText.trim();
setInputText("");
const { error } = await supabase.from("messages").insert({
match_id: matchId,
user_id: user.id,
content: content,
});
try {
// Try to encrypt the message if E2EE is available
console.log(`[E2EE Send] isE2EEAvailable=${isE2EEAvailable}`);
const encryptedPayload = isE2EEAvailable ? await encryptMessage(content) : null;
console.log(`[E2EE Send] encryptedPayload=`, encryptedPayload ? {
version: encryptedPayload.version,
encrypted_content_length: encryptedPayload.encrypted_content.length,
encryption_iv_length: encryptedPayload.encryption_iv.length,
keys_metadata: encryptedPayload.keys_metadata,
} : null);
if (error) {
if (encryptedPayload) {
// Send encrypted message (v2)
const { error, data } = await supabase.from("messages").insert({
match_id: matchId,
user_id: user.id,
content: null,
version: encryptedPayload.version,
encrypted_content: encryptedPayload.encrypted_content,
encryption_iv: encryptedPayload.encryption_iv,
keys_metadata: encryptedPayload.keys_metadata as unknown as Record<string, unknown>,
}).select();
console.log(`[E2EE Send] Insert result:`, { error, data });
if (error) {
Alert.alert("Error", "Failed to send message");
setInputText(content);
} else {
Events.messageSent();
}
} else {
// Fallback to plaintext (v1) if E2EE not available
const { error } = await supabase.from("messages").insert({
match_id: matchId,
user_id: user.id,
content: content,
});
if (error) {
Alert.alert("Error", "Failed to send message");
setInputText(content);
} else {
Events.messageSent();
}
}
} catch (err) {
console.error("Error sending message:", err);
Alert.alert("Error", "Failed to send message");
setInputText(content);
} else {
Events.messageSent();
}
};
@@ -223,6 +277,7 @@ export const ChatScreen: React.FC = () => {
onImagePress={setFullScreenImage}
onVideoFullScreen={setFullScreenVideo}
revealMessage={revealMessage}
onMessageLongPress={handleMessageLongPress}
/>
{/* Premium Input Bar */}
@@ -260,6 +315,26 @@ export const ChatScreen: React.FC = () => {
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)}
@@ -298,6 +373,26 @@ export const ChatScreen: React.FC = () => {
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)}
@@ -365,6 +460,12 @@ const styles = StyleSheet.create({
right: 20,
zIndex: 10,
},
fullScreenSaveButton: {
position: "absolute",
top: 50,
left: 20,
zIndex: 10,
},
fullScreenCloseGradient: {
width: 44,
height: 44,
@@ -22,6 +22,8 @@ interface ChatMessagesProps {
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;
@@ -35,16 +37,22 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
onImagePress,
onVideoFullScreen,
revealMessage,
onMessageLongPress,
}) => {
const flatListRef = useRef<FlatList>(null);
const renderMessage = ({ item, index }: { item: Message; index: number }) => {
const isMe = item.user_id === userId;
return (
<MessageBubble isMe={isMe} index={index}>
<MessageBubble
isMe={isMe}
index={index}
onLongPress={() => onMessageLongPress(item, isMe)}
>
<MessageContent
item={item}
isMe={isMe}
currentUserId={userId || ''}
revealMessage={revealMessage}
onImagePress={onImagePress}
onVideoFullScreen={onVideoFullScreen}
@@ -38,9 +38,26 @@ export function ChatVideoPlayer({
// Use video cache hook
const { cachedUri, cacheVideoFile } = useVideoCache(storagePath, signedUrl);
// For encrypted media, signedUrl is already the decrypted local file URI
// Skip the cache lookup for local file URIs
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) return;
if (!status.isLoaded) {
// Log error if load failed
if ('error' in status && status.error) {
console.error(`[ChatVideoPlayer] Video load error:`, status.error);
setIsLoading(false);
}
return;
}
setIsLoading(false);
setIsPlaying(status.isPlaying);
@@ -49,12 +66,12 @@ export function ChatVideoPlayer({
setHasEnded(true);
setIsPlaying(false);
// Cache in background if not already cached
if (signedUrl && !cachedUri) {
// Cache in background if not already cached (only for remote URLs)
if (signedUrl && !cachedUri && !isLocalFile) {
cacheVideoFile();
}
}
}, [signedUrl, storagePath, cachedUri]);
}, [signedUrl, storagePath, cachedUri, isLocalFile]);
// Toggle play/pause with native-like behavior
const handleTapToPlay = useCallback(async () => {
@@ -90,8 +107,6 @@ export function ChatVideoPlayer({
}
}, [cachedUri, signedUrl, isPlaying, onFullScreen]);
const videoSource = cachedUri || signedUrl;
if (urlError || !videoSource) {
return (
<View style={[styles.video, styles.errorContainer]}>
@@ -3,7 +3,7 @@
* Applies gradient styling for sender vs receiver messages.
*/
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { View, StyleSheet, TouchableOpacity } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { colors, gradients, spacing, radius } from '../../../theme';
@@ -14,16 +14,23 @@ export interface MessageBubbleProps {
isMe: boolean;
index: number;
children: React.ReactNode;
/** Called when the message bubble is long pressed */
onLongPress?: () => void;
}
export function MessageBubble({ isMe, index, children }: MessageBubbleProps) {
export function MessageBubble({ isMe, index, children, onLongPress }: MessageBubbleProps) {
return (
<Animated.View
entering={FadeInUp.delay(index * 30).duration(200)}
style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}
>
{isMe ? (
<View style={styles.myBubbleContainer}>
<TouchableOpacity
onLongPress={onLongPress}
activeOpacity={0.9}
delayLongPress={300}
style={styles.myBubbleContainer}
>
<LinearGradient
colors={gradients.primary as [string, string]}
style={[styles.bubble, styles.myBubble]}
@@ -39,9 +46,14 @@ export function MessageBubble({ isMe, index, children }: MessageBubbleProps) {
/>
{children}
</LinearGradient>
</View>
</TouchableOpacity>
) : (
<View style={styles.theirBubbleContainer}>
<TouchableOpacity
onLongPress={onLongPress}
activeOpacity={0.9}
delayLongPress={300}
style={styles.theirBubbleContainer}
>
<View style={[styles.bubble, styles.theirBubble]}>
{/* Subtle gradient background */}
<LinearGradient
@@ -59,7 +71,7 @@ export function MessageBubble({ isMe, index, children }: MessageBubbleProps) {
/>
{children}
</View>
</View>
</TouchableOpacity>
)}
</Animated.View>
);
@@ -1,16 +1,18 @@
/**
* MessageContent - Renders message content (text, images, videos)
* Handles media reveal logic, expired media, and loading states.
* Supports both v1 (plaintext) and v2 (E2EE encrypted) messages.
*/
import React, { useState, useEffect } from 'react';
import { View, TouchableOpacity, Text, StyleSheet, ActivityIndicator } from 'react-native';
import React from 'react';
import { View, TouchableOpacity, Text, StyleSheet, ActivityIndicator, Alert } from 'react-native';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { colors, gradients, spacing, radius } from '../../../theme';
import { getCachedSignedUrl, getStoragePath } from '../../../lib/imageCache';
import { MessageMeta } from './MessageMeta';
import { ChatVideoPlayer } from './ChatVideoPlayer';
import { useDecryptedMessage, useDecryptedMedia } from '../../../hooks';
import type { KeysMetadata } from '../../../lib/encryption';
import type { Message } from '../types';
const ACCENT = colors.premium.gold;
@@ -18,6 +20,7 @@ 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;
@@ -26,51 +29,82 @@ export interface MessageContentProps {
export function MessageContent({
item,
isMe,
currentUserId,
revealMessage,
onImagePress,
onVideoFullScreen,
}: MessageContentProps) {
const [signedUrl, setSignedUrl] = useState<string | null>(null);
const [urlError, setUrlError] = useState(false);
// 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>
);
}
// Use decryption hook for E2EE text messages
const {
content: decryptedContent,
isDecrypting,
error: decryptError,
errorCode: decryptErrorCode,
retry: retryDecrypt,
} = useDecryptedMessage({
message: {
id: item.id,
content: item.content,
version: item.version,
encrypted_content: item.encrypted_content,
encryption_iv: item.encryption_iv,
keys_metadata: item.keys_metadata as KeysMetadata | null,
user_id: item.user_id,
},
currentUserId,
});
const isVideo = (item as any).media_type === 'video';
const isViewed = !!item.media_viewed_at;
const isRecipientHidden = !isMe && !isViewed;
useEffect(() => {
if (!item.media_path) {
setSignedUrl(null);
setUrlError(false);
return;
}
// Use decryption hook for E2EE media (images/videos)
const {
uri: mediaUri,
isDecrypting: isDecryptingMedia,
error: mediaError,
errorCode: mediaErrorCode,
retry: retryMedia,
isEncrypted: isMediaEncrypted,
} = useDecryptedMedia({
messageId: item.id,
mediaPath: item.media_path ?? null,
version: item.version,
encryptionIv: item.encryption_iv,
keysMetadata: item.keys_metadata as KeysMetadata | null,
isMe,
mediaType: isVideo ? 'video' : 'image',
shouldFetch: !!item.media_path && !isRecipientHidden,
});
if (isRecipientHidden) {
setSignedUrl(null);
setUrlError(false);
return;
}
const isPendingMedia = mediaErrorCode === 'E2EE_PENDING_RECIPIENT_KEY';
let isMounted = true;
const fetchSignedUrl = async () => {
const storagePath = getStoragePath(item.media_path!);
const url = await getCachedSignedUrl(storagePath);
if (!isMounted) return;
if (url) {
setSignedUrl(url);
} else {
setUrlError(true);
}
};
fetchSignedUrl();
return () => {
isMounted = false;
};
}, [item.media_path, isRecipientHidden]);
const showSecureMediaInfo = () => {
Alert.alert(
'Secure media',
"To keep your chats private, photos and videos are locked to each person’s secure key.\n\nThis media was sent before your secure key was ready, so it’s still locked. We’re now securely updating the lock for your device (without exposing the media) so you can open it.\n\nIf it doesn’t unlock in a few seconds, tap “Try again”.",
[
{ text: 'OK', style: 'cancel' },
{ text: 'Try again', onPress: () => retryMedia() },
]
);
};
// Handle expired videos
const isExpired = !!(item as any).media_expired;
@@ -99,51 +133,63 @@ export function MessageContent({
// Handle media (images and videos)
if (item.media_path || isExpired) {
const canOpenFullScreen = (isMe || isViewed) && !isVideo; // Only images can go fullscreen
const hasMediaError = !!mediaError;
const isLoading = isDecryptingMedia || (!mediaUri && !hasMediaError && !isRecipientHidden);
// Video content
if (isVideo) {
return (
<View>
{isRecipientHidden ? (
// Blurred placeholder with reveal overlay for unrevealed videos
<View>
<View style={[styles.messageVideo, styles.videoBlurred]}>
// 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>
) : isPendingMedia ? (
<TouchableOpacity activeOpacity={0.85} onPress={showSecureMediaInfo}>
<View style={[styles.messageVideo, styles.secureMediaPlaceholder]}>
<LinearGradient
colors={['rgba(22, 33, 62, 0.9)', 'rgba(13, 13, 26, 0.95)']}
colors={['rgba(22, 33, 62, 0.85)', 'rgba(13, 13, 26, 0.95)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Ionicons name="videocam" size={40} color={colors.textTertiary} />
<Ionicons name="lock-closed-outline" size={32} color={colors.textSecondary} />
<Text style={styles.secureMediaPlaceholderText}>Secure video</Text>
<Text style={styles.secureMediaPlaceholderSubtext}>Finishing security setup • Tap for info</Text>
{isDecryptingMedia && <ActivityIndicator color={ACCENT} />}
</View>
<TouchableOpacity
style={styles.revealOverlay}
activeOpacity={0.8}
onPress={() => revealMessage(item.id)}
>
<View style={styles.revealContent}>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.revealIconContainer}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="eye-off-outline" size={24} color={colors.text} />
</LinearGradient>
<Text style={styles.revealText}>Tap to reveal video</Text>
</View>
</TouchableOpacity>
</View>
) : signedUrl || urlError ? (
</TouchableOpacity>
) : mediaUri || hasMediaError ? (
<ChatVideoPlayer
signedUrl={signedUrl}
signedUrl={mediaUri}
storagePath={item.media_path!}
urlError={urlError}
urlError={hasMediaError}
onFullScreen={onVideoFullScreen}
/>
) : (
<View style={[styles.messageVideo, styles.messageImageLoading]}>
<ActivityIndicator color={ACCENT} />
<Text style={styles.decryptingText}>Loading...</Text>
</View>
)}
{/* Viewed indicator for sender */}
@@ -162,50 +208,30 @@ export function MessageContent({
return (
<View>
<TouchableOpacity
activeOpacity={canOpenFullScreen ? 0.8 : 1}
onPress={() => canOpenFullScreen && signedUrl && onImagePress(signedUrl)}
disabled={!canOpenFullScreen || !signedUrl}
activeOpacity={isPendingMedia || canOpenFullScreen ? 0.8 : 1}
onPress={() => {
if (isPendingMedia) {
showSecureMediaInfo();
return;
}
if (canOpenFullScreen && mediaUri) {
onImagePress(mediaUri);
}
}}
disabled={!(isPendingMedia || (canOpenFullScreen && !!mediaUri))}
>
{isRecipientHidden ? (
<View style={[styles.messageImage, styles.unrevealedPlaceholder]}>
<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 }}
/>
<View style={styles.unrevealedIconContainer}>
<Ionicons name="image-outline" size={28} color={colors.text} />
</View>
<Text style={styles.unrevealedText}>Photo hidden</Text>
</View>
) : signedUrl ? (
<Image
source={{ uri: signedUrl }}
style={styles.messageImage}
blurRadius={0}
cachePolicy="disk"
transition={200}
/>
) : urlError ? (
<View style={[styles.messageImage, styles.messageImageError]}>
<Ionicons name="image-outline" size={32} color={colors.textSecondary} />
<Text style={styles.messageImageErrorText}>Image unavailable</Text>
</View>
) : (
<View style={[styles.messageImage, styles.messageImageLoading]}>
<ActivityIndicator color={ACCENT} />
</View>
)}
</TouchableOpacity>
{/* Reveal overlay for recipient */}
{isRecipientHidden && (
<TouchableOpacity
style={styles.revealOverlay}
activeOpacity={0.8}
onPress={() => revealMessage(item.id)}
>
<View style={styles.revealContent}>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.revealIconContainer}
@@ -214,10 +240,42 @@ export function MessageContent({
>
<Ionicons name="eye-off-outline" size={24} color={colors.text} />
</LinearGradient>
<Text style={styles.revealText}>Tap to reveal</Text>
<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}
/>
) : isPendingMedia ? (
<View style={[styles.messageImage, styles.secureMediaPlaceholder]}>
<LinearGradient
colors={['rgba(22, 33, 62, 0.85)', 'rgba(13, 13, 26, 0.95)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Ionicons name="lock-closed-outline" size={32} color={colors.textSecondary} />
<Text style={styles.secureMediaPlaceholderText}>Secure photo</Text>
<Text style={styles.secureMediaPlaceholderSubtext}>Finishing security setup • Tap for info</Text>
{isDecryptingMedia && <ActivityIndicator color={ACCENT} />}
</View>
</TouchableOpacity>
)}
) : hasMediaError ? (
<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} />
<Text style={styles.decryptingText}>Loading...</Text>
</View>
)}
</TouchableOpacity>
{/* Viewed indicator for sender */}
{isMe && isViewed && (
<View style={styles.viewedBadge}>
@@ -231,26 +289,139 @@ export function MessageContent({
}
// Text message
const version = item.version ?? 1;
const isV2 = version === 2;
const showSecureMessageInfo = () => {
Alert.alert(
'Secure message',
"To keep your chats private, each message is locked to your device’s secure key.\n\nThis message was sent before your secure key was ready, so it’s still locked. We’re now securely updating the lock for your device (without exposing the message content) so you can read it.\n\nIf it doesn’t unlock in a few seconds, tap “Try again”.",
[
{ text: 'OK', style: 'cancel' },
{ text: 'Try again', onPress: () => retryDecrypt() },
]
);
};
const renderText = () => {
if (decryptErrorCode === 'E2EE_PENDING_RECIPIENT_KEY') {
return (
<TouchableOpacity activeOpacity={0.85} onPress={showSecureMessageInfo}>
<View style={styles.securePlaceholder}>
<View style={styles.securePlaceholderRow}>
<Ionicons name="lock-closed-outline" size={16} color={colors.textSecondary} />
<Text style={styles.securePlaceholderTitle}>Secure message</Text>
{isDecrypting && <ActivityIndicator size="small" color={ACCENT} />}
</View>
<Text style={styles.securePlaceholderSubtext}>Finishing security setup • Tap for info</Text>
</View>
</TouchableOpacity>
);
}
if (isDecrypting) {
return (
<View style={styles.decryptingContainer}>
<ActivityIndicator size="small" color={ACCENT} />
<Text style={styles.decryptingText}>Loading...</Text>
</View>
);
}
if (decryptError) {
return (
<TouchableOpacity activeOpacity={0.85} onPress={retryDecrypt}>
<Text style={[styles.messageText, styles.errorText]}>Couldn’t open secure message • Tap to retry</Text>
</TouchableOpacity>
);
}
if (isV2) {
return <Text style={styles.messageText}>{decryptedContent ?? 'Secure message'}</Text>;
}
return <Text style={styles.messageText}>{item.content ?? 'Failed to load message'}</Text>;
};
return (
<>
<Text style={styles.messageText}>{item.content}</Text>
{renderText()}
<MessageMeta item={item} isMe={isMe} />
</>
);
}
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,
},
securePlaceholder: {
gap: spacing.xs,
},
securePlaceholderRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
securePlaceholderTitle: {
color: colors.textSecondary,
fontSize: 14,
fontWeight: '600',
},
securePlaceholderSubtext: {
color: colors.textTertiary,
fontSize: 12,
},
decryptingContainer: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
justifyContent: 'center',
alignItems: 'center',
},
decryptingText: {
color: colors.textSecondary,
fontSize: 11,
marginTop: spacing.xs,
},
errorText: {
color: colors.textSecondary,
fontStyle: 'italic',
},
messageImage: {
width: 200,
height: 200,
borderRadius: radius.md,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
secureMediaPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
gap: spacing.sm,
overflow: 'hidden',
},
secureMediaPlaceholderText: {
color: colors.textSecondary,
fontSize: 12,
fontWeight: '600',
},
secureMediaPlaceholderSubtext: {
color: colors.textTertiary,
fontSize: 11,
textAlign: 'center',
paddingHorizontal: spacing.md,
},
messageImageLoading: {
justifyContent: 'center',
alignItems: 'center',
@@ -270,21 +441,15 @@ const styles = StyleSheet.create({
gap: spacing.sm,
overflow: 'hidden',
},
unrevealedIconContainer: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.15)',
justifyContent: 'center',
alignItems: 'center',
},
unrevealedText: {
color: colors.textSecondary,
fontSize: 12,
color: colors.text,
fontSize: 13,
fontWeight: '600',
letterSpacing: 0.3,
},
revealHint: {
color: gradients.premiumGold[0],
fontSize: 12,
fontWeight: '500',
},
messageVideo: {
width: 200,
@@ -293,20 +458,6 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(255, 255, 255, 0.05)',
overflow: 'hidden',
},
videoBlurred: {
justifyContent: 'center',
alignItems: 'center',
},
revealOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
borderRadius: radius.md,
},
revealContent: {
alignItems: 'center',
gap: spacing.sm,
},
revealIconContainer: {
width: 48,
height: 48,
@@ -314,11 +465,6 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
revealText: {
color: colors.text,
fontSize: 13,
fontWeight: '600',
},
viewedBadge: {
flexDirection: 'row',
alignItems: 'center',
@@ -7,10 +7,25 @@ import { decode } from 'base64-arraybuffer';
import { supabase } from '../../../lib/supabase';
import { Events } from '../../../lib/analytics';
import { UploadStatus } from '../types';
import { useAuthStore } from '../../../store';
import {
encryptMediaFile,
getAdminPublicKey,
getAdminKeyId,
MESSAGE_VERSION_E2EE,
} from '../../../lib/encryption';
import type { RSAPublicKeyJWK, EncryptedMediaPayload } from '../../../lib/encryption';
import { useEncryptionKeys } from '../../../hooks';
export function useMediaUpload(matchId: string, userId: string | undefined) {
const [uploading, setUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState<UploadStatus>(null);
const refreshPartner = useAuthStore((s) => s.refreshPartner);
const { publicKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
// Check if E2EE is available for media
const isE2EEAvailable = Platform.OS !== 'web' && hasKeys && !keysLoading && !!publicKeyJwk;
const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => {
if (!userId) return;
@@ -21,6 +36,7 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
let fileUri = uri;
let fileBody;
let ext = mediaType === 'video' ? 'mp4' : 'jpg';
let encryptedPayload: EncryptedMediaPayload | null = null;
// Compress videos on native platforms before upload
if (mediaType === 'video' && Platform.OS !== 'web') {
@@ -43,6 +59,49 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
}
}
// Try to encrypt the media if E2EE is available
if (isE2EEAvailable && Platform.OS !== 'web') {
try {
// CRITICAL: Read keys directly from global store at encryption time
// This ensures we always use the most current keys, not potentially stale hook state
const currentKeys = useAuthStore.getState().encryptionKeys;
if (!currentKeys.hasKeys || !currentKeys.publicKeyJwk) {
console.warn('[E2EE Media Upload] No keys available in global store at encryption time');
throw new Error('No encryption keys available');
}
const senderPublicKey = currentKeys.publicKeyJwk;
console.log('[E2EE Media Upload] Using sender public key from global store, version:', currentKeys.keysVersion);
// Refresh partner's profile to get their latest public key
console.log('[E2EE Media Upload] Refreshing partner profile...');
const freshPartner = await refreshPartner();
const adminPublicKey = await getAdminPublicKey();
const adminKeyId = await getAdminKeyId();
const partnerPublicKey = freshPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
console.log('[E2EE Media Upload] Partner public key available:', !!partnerPublicKey);
encryptedPayload = await encryptMediaFile(
fileUri,
senderPublicKey as RSAPublicKeyJWK,
partnerPublicKey ?? null,
adminPublicKey,
adminKeyId
);
// Use the encrypted file for upload
fileUri = encryptedPayload.encryptedFileUri;
ext = 'enc'; // Encrypted files use .enc extension
console.log('Media encrypted successfully');
} catch (encryptError) {
console.warn('Media encryption failed, uploading unencrypted:', encryptError);
encryptedPayload = null;
}
}
if (Platform.OS === 'web') {
const response = await fetch(fileUri);
const blob = await response.blob();
@@ -63,8 +122,8 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 });
fileBody = decode(base64);
if (mediaType !== 'video') {
// Only use URI extension for non-videos (videos are always mp4 after compression)
if (mediaType !== 'video' && !encryptedPayload) {
// Only use URI extension for non-videos and non-encrypted (videos are always mp4 after compression)
const uriExt = fileUri.split('.').pop();
if (uriExt && uriExt !== fileUri) {
ext = uriExt.toLowerCase();
@@ -73,9 +132,11 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
}
const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
const contentType = mediaType === 'video'
? `video/${ext === 'mov' ? 'quicktime' : ext}`
: `image/${ext === 'jpg' ? 'jpeg' : ext}`;
const contentType = encryptedPayload
? 'application/octet-stream' // Encrypted files are binary
: mediaType === 'video'
? `video/${ext === 'mov' ? 'quicktime' : ext}`
: `image/${ext === 'jpg' ? 'jpeg' : ext}`;
const { error: uploadError } = await supabase.storage
.from("chat-media")
@@ -86,15 +147,39 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
if (uploadError) throw uploadError;
// Store just the path, not the full URL (bucket is private)
await supabase.from("messages").insert({
match_id: matchId,
user_id: userId,
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
media_path: fileName,
media_type: mediaType,
media_expired: false,
});
// Clean up encrypted temp file
if (encryptedPayload && encryptedPayload.encryptedFileUri) {
try {
await FileSystem.deleteAsync(encryptedPayload.encryptedFileUri, { idempotent: true });
} catch (cleanupError) {
console.warn('Failed to clean up encrypted temp file:', cleanupError);
}
}
// Store the message with encryption metadata if encrypted
if (encryptedPayload) {
await supabase.from("messages").insert({
match_id: matchId,
user_id: userId,
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
media_path: fileName,
media_type: mediaType,
media_expired: false,
version: MESSAGE_VERSION_E2EE,
encryption_iv: encryptedPayload.encryption_iv,
keys_metadata: encryptedPayload.keys_metadata as unknown as Record<string, unknown>,
});
} else {
// Unencrypted upload (v1)
await supabase.from("messages").insert({
match_id: matchId,
user_id: userId,
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
media_path: fileName,
media_type: mediaType,
media_expired: false,
});
}
Events.mediaUploaded();
} catch (error) {
@@ -0,0 +1,102 @@
/**
* useMessageActions - Hook for message deletion actions
* Handles "delete for self" and "delete for everyone" functionality.
*/
import { Platform, ActionSheetIOS, Alert } from 'react-native';
import * as Haptics from 'expo-haptics';
import { supabase } from '../../../lib/supabase';
import type { Message } from '../types';
interface UseMessageActionsConfig {
userId: string | undefined;
}
export function useMessageActions({ userId }: UseMessageActionsConfig) {
/**
* Delete a message for the current user only.
* Message will still be visible to the partner.
*/
const deleteForSelf = async (messageId: string) => {
if (!userId) return;
const { error } = await supabase
.from('message_deletions')
.insert({ message_id: messageId, user_id: userId });
if (error) {
Alert.alert('Error', 'Failed to delete message');
console.error('Delete for self error:', error);
}
};
/**
* Delete a message for everyone (author only).
* Message will show as "deleted" to both users.
*/
const deleteForEveryone = async (messageId: string) => {
const { error } = await supabase
.from('messages')
.update({ deleted_at: new Date().toISOString() })
.eq('id', messageId);
if (error) {
Alert.alert('Error', 'Failed to delete message');
console.error('Delete for everyone error:', error);
}
};
/**
* Show delete options for a message.
* Author gets both options, non-author only gets "delete for me".
*/
const showDeleteOptions = (message: Message, isMe: boolean) => {
// Don't show options for already deleted messages
if (message.deleted_at) return;
// Haptic feedback
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
if (Platform.OS === 'ios') {
const options = ['Cancel', 'Delete for me'];
if (isMe) options.push('Delete for everyone');
ActionSheetIOS.showActionSheetWithOptions(
{
options,
destructiveButtonIndex: isMe ? 2 : 1,
cancelButtonIndex: 0,
title: 'Delete message?',
},
(buttonIndex) => {
if (buttonIndex === 1) {
deleteForSelf(message.id);
} else if (buttonIndex === 2 && isMe) {
deleteForEveryone(message.id);
}
}
);
} else {
// Android fallback using Alert
const buttons: Array<{ text: string; style?: 'cancel' | 'default' | 'destructive'; onPress?: () => void }> = [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete for me',
style: 'destructive',
onPress: () => deleteForSelf(message.id),
},
];
if (isMe) {
buttons.push({
text: 'Delete for everyone',
style: 'destructive',
onPress: () => deleteForEveryone(message.id),
});
}
Alert.alert('Delete message?', undefined, buttons);
}
};
return { showDeleteOptions, deleteForSelf, deleteForEveryone };
}
@@ -12,6 +12,7 @@ import { useAuthStore, useSubscriptionStore } from '../../store';
import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen';
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
import { supabase } from '../../lib/supabase'; // Needed for debug reset
import { clearKeys } from '../../lib/encryption';
// Hooks
import { useProfileSettings, useCoupleManagement } from './hooks';
@@ -296,6 +297,30 @@ export function ProfileScreen() {
}
}}
/>
<View style={styles.preferencesDivider} />
<MenuItem
icon="key-outline"
label="Clear Private Key"
description="Remove E2EE keys from device"
onPress={() => {
Alert.alert(
"Clear Keys",
"Are you sure? You will lose access to encrypted messages until keys are regenerated.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Clear",
style: "destructive",
onPress: async () => {
await clearKeys();
Alert.alert("Success", "Keys cleared. They will be regenerated on next launch/login.");
}
}
]
);
}}
variant="danger"
/>
</SettingsSection>
)}