mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 02:19:43 +00:00
Implement E2EE and message content moderation
This commit is contained in:
1 parent
e1ff87dc03
commit
198cd80dfd
74 files changed
+12939
-2178
No files matched your search
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in new issue
Block a user