diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx index b65c07b..c0e616b 100755 --- a/apps/mobile/app/(app)/_layout.tsx +++ b/apps/mobile/app/(app)/_layout.tsx @@ -50,21 +50,21 @@ function MessageToastPreview({ currentUserId, }); - if (isDecrypting) { - return ( - - Loading... - - ); - } - - if (error) { - return ( - - Failed to load message - - ); - } + if (isDecrypting) { + return ( + + Waiting for message... + + ); + } + + if (error) { + return ( + + Encrypted message + + ); + } return ( diff --git a/apps/mobile/src/__tests__/lib/encryption.test.ts b/apps/mobile/src/__tests__/lib/encryption.test.ts new file mode 100644 index 0000000..79f333c --- /dev/null +++ b/apps/mobile/src/__tests__/lib/encryption.test.ts @@ -0,0 +1,330 @@ +import { + generateAESKey, + generateIV, + encryptAES, + decryptAES, + exportAESKey, + importAESKey, + wrapAESKey, + unwrapAESKey, + verifyKeyPair, +} from '../../lib/encryption/crypto'; +import { + generateAndStoreKeyPair, + getPrivateKey, + clearKeys, + sanitizePublicKeyJwk, + isValidPublicKeyJwk, +} from '../../lib/encryption/keyManager'; +import { encryptTextMessage } from '../../lib/encryption/messageEncryption'; +import { decryptTextMessage } from '../../lib/encryption/messageDecryption'; +import { RSAPublicKeyJWK, RSAPrivateKeyJWK } from '../../lib/encryption/types'; + +// Mock dependencies +jest.mock('expo-secure-store', () => { + let store: Record = {}; + return { + setItemAsync: jest.fn(async (key, value) => { + store[key] = value; + }), + getItemAsync: jest.fn(async (key) => { + return store[key] || null; + }), + deleteItemAsync: jest.fn(async (key) => { + delete store[key]; + }), + _clear: () => { + store = {}; + }, + }; +}); + +jest.mock('react-native', () => ({ + Platform: { + OS: 'ios', // Mock as native platform to allow key generation + }, +})); + +// Mock react-native-quick-crypto's polyfill behavior if needed, +// but Node.js 20+ has global.crypto.subtle. +// If tests fail due to missing crypto, we will need to polyfill it. +if (!global.crypto) { + // @ts-ignore + global.crypto = require('crypto').webcrypto; +} + +describe('E2EE Encryption Library', () => { + // 1. Low-Level Crypto Primitives + describe('Crypto Primitives (AES-GCM)', () => { + it('should generate a valid AES-256 key', async () => { + const key = await generateAESKey(); + expect(key.algorithm.name).toBe('AES-GCM'); + // @ts-ignore - length is present in Web Crypto keys + expect(key.algorithm.length).toBe(256); + expect(key.extractable).toBe(true); + }); + + it('should generate a valid 12-byte IV', () => { + const iv = generateIV(); + expect(iv.length).toBe(12); + expect(iv).toBeInstanceOf(Uint8Array); + }); + + it('should correctly round-trip AES encryption/decryption', async () => { + const plaintext = 'Hello, Secret World!'; + const key = await generateAESKey(); + const iv = generateIV(); + + const ciphertext = await encryptAES(plaintext, key, iv); + const decryptedBuffer = await decryptAES(ciphertext, key, iv); + const decryptedText = new TextDecoder().decode(decryptedBuffer); + + expect(decryptedText).toBe(plaintext); + }); + + it('should export and import AES keys correctly', async () => { + const originalKey = await generateAESKey(); + const rawKey = await exportAESKey(originalKey); + + expect(rawKey.byteLength).toBe(32); // 256 bits = 32 bytes + + const importedKey = await importAESKey(rawKey); + + expect(importedKey.algorithm.name).toBe('AES-GCM'); + // @ts-ignore + expect(importedKey.algorithm.length).toBe(256); + }); + }); + + // 2. RSA Key Wrapping + describe('RSA Key Wrapping', () => { + let aliceKeyPair: CryptoKeyPair; + let alicePublicKeyJwk: RSAPublicKeyJWK; + let alicePrivateKeyJwk: RSAPrivateKeyJWK; + + beforeAll(async () => { + // Generate a real keypair for testing using Web Crypto directly + // simulating what generateAndStoreKeyPair does internally + aliceKeyPair = await crypto.subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['encrypt', 'decrypt'] + ); + + alicePublicKeyJwk = (await crypto.subtle.exportKey('jwk', aliceKeyPair.publicKey)) as RSAPublicKeyJWK; + alicePrivateKeyJwk = (await crypto.subtle.exportKey('jwk', aliceKeyPair.privateKey)) as RSAPrivateKeyJWK; + }); + + it('should wrap and unwrap an AES key using RSA keys', async () => { + const aesKey = await generateAESKey(); + const rawAesKey = await exportAESKey(aesKey); + + // Wrap with Public Key + const wrappedKeyBase64 = await wrapAESKey(rawAesKey, alicePublicKeyJwk); + expect(typeof wrappedKeyBase64).toBe('string'); + + // Unwrap with Private Key + const unwrappedRawKey = await unwrapAESKey(wrappedKeyBase64, alicePrivateKeyJwk); + + // Compare bytes + const originalBytes = new Uint8Array(rawAesKey); + const unwrappedBytes = new Uint8Array(unwrappedRawKey); + expect(unwrappedBytes).toEqual(originalBytes); + }); + + it('should verify matching key pairs', async () => { + const isMatch = await verifyKeyPair(alicePublicKeyJwk, alicePrivateKeyJwk); + expect(isMatch).toBe(true); + }); + + it('should reject mismatched key pairs', async () => { + // Suppress expected console error + const originalError = console.error; + console.error = jest.fn(); + + try { + // Generate a second, different keypair + const bobKeyPair = await crypto.subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['encrypt', 'decrypt'] + ); + const bobPrivateKeyJwk = (await crypto.subtle.exportKey('jwk', bobKeyPair.privateKey)) as RSAPrivateKeyJWK; + + // Check mismatch (Alice's Public + Bob's Private) + const isMatch = await verifyKeyPair(alicePublicKeyJwk, bobPrivateKeyJwk); + expect(isMatch).toBe(false); + } finally { + // Restore console error + console.error = originalError; + } + }); + }); + + // 3. Key Manager (with mocked SecureStore) + describe('Key Manager', () => { + beforeEach(() => { + // Clear mocked store + require('expo-secure-store')._clear(); + }); + + it('should generate and store keys correctly', async () => { + const publicKey = await generateAndStoreKeyPair(); + + expect(publicKey.kty).toBe('RSA'); + expect(publicKey.alg).toBe('RSA-OAEP-256'); + + // Check SecureStore interaction + const storedPrivateKey = await getPrivateKey(); + expect(storedPrivateKey).toBeDefined(); + expect(storedPrivateKey?.d).toBeDefined(); // 'd' is the private exponent + + // Verify they match + const isMatch = await verifyKeyPair(publicKey, storedPrivateKey!); + expect(isMatch).toBe(true); + }); + + it('should clear keys', async () => { + await generateAndStoreKeyPair(); + await clearKeys(); + + const storedPrivateKey = await getPrivateKey(); + expect(storedPrivateKey).toBeNull(); + }); + + it('should validate and sanitize public keys', () => { + // Valid key + const validKey: RSAPublicKeyJWK = { + kty: 'RSA', + n: 'some_base64_string', + e: 'AQAB', + alg: 'RSA-OAEP-256' + }; + expect(isValidPublicKeyJwk(validKey)).toBe(true); + + // Invalid key (missing fields) + expect(isValidPublicKeyJwk({} as any)).toBe(false); + + // Key with trailing dots (sanitization test) + const dirtyKey: RSAPublicKeyJWK = { + ...validKey, + n: 'some_base64_string...', + e: 'AQAB.' + }; + + const cleanKey = sanitizePublicKeyJwk(dirtyKey); + expect(cleanKey.n).toBe('some_base64_string'); + expect(cleanKey.e).toBe('AQAB'); + expect(isValidPublicKeyJwk(dirtyKey)).toBe(true); // Should pass because it sanitizes internally + }); + }); + + // 4. High-Level Message Flow (Alice -> Bob) + describe('Full Message Flow (Alice -> Bob)', () => { + let aliceKeys: { pub: RSAPublicKeyJWK, priv: RSAPrivateKeyJWK }; + let bobKeys: { pub: RSAPublicKeyJWK, priv: RSAPrivateKeyJWK }; + let adminKeys: { pub: RSAPublicKeyJWK, priv: RSAPrivateKeyJWK }; + + beforeAll(async () => { + const genKey = async () => { + const kp = await crypto.subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['encrypt', 'decrypt'] + ); + return { + pub: (await crypto.subtle.exportKey('jwk', kp.publicKey)) as RSAPublicKeyJWK, + priv: (await crypto.subtle.exportKey('jwk', kp.privateKey)) as RSAPrivateKeyJWK, + }; + }; + + aliceKeys = await genKey(); + bobKeys = await genKey(); + adminKeys = await genKey(); + }); + + it('should encrypt and decrypt a message successfully', async () => { + const plaintext = "Meet me at the secret base."; + const adminKeyId = "admin-key-uuid"; + + // 1. Alice Encrypts + const encryptedPayload = await encryptTextMessage( + plaintext, + aliceKeys.pub, + bobKeys.pub, + adminKeys.pub, + adminKeyId + ); + + expect(encryptedPayload.version).toBe(2); + expect(encryptedPayload.keys_metadata.algorithm).toBe('AES-256-GCM'); + expect(encryptedPayload.keys_metadata.sender_wrapped_key).toBeDefined(); + expect(encryptedPayload.keys_metadata.recipient_wrapped_key).toBeDefined(); + expect(encryptedPayload.keys_metadata.admin_wrapped_key).toBeDefined(); + + // 2. Bob Decrypts (as Recipient) + const decryptedByBob = await decryptTextMessage( + encryptedPayload.encrypted_content, + encryptedPayload.encryption_iv, + encryptedPayload.keys_metadata, + bobKeys.priv, + true // isRecipient + ); + + expect(decryptedByBob).toBe(plaintext); + + // 3. Alice Decrypts (as Sender) - e.g. for history + const decryptedByAlice = await decryptTextMessage( + encryptedPayload.encrypted_content, + encryptedPayload.encryption_iv, + encryptedPayload.keys_metadata, + aliceKeys.priv, + false // isRecipient = false + ); + + expect(decryptedByAlice).toBe(plaintext); + }); + + it('should handle pending recipient (no recipient key)', async () => { + const plaintext = "Waiting for you to join..."; + const adminKeyId = "admin-key-uuid"; + + // Encrypt with NO recipient key (null) + const encryptedPayload = await encryptTextMessage( + plaintext, + aliceKeys.pub, + null, // No recipient key + adminKeys.pub, + adminKeyId + ); + + expect(encryptedPayload.keys_metadata.recipient_wrapped_key).toBeUndefined(); + expect(encryptedPayload.keys_metadata.pending_recipient).toBe(true); + + // Sender (Alice) can still decrypt + const decryptedByAlice = await decryptTextMessage( + encryptedPayload.encrypted_content, + encryptedPayload.encryption_iv, + encryptedPayload.keys_metadata, + aliceKeys.priv, + false + ); + expect(decryptedByAlice).toBe(plaintext); + }); + }); +}); diff --git a/apps/mobile/src/components/EncryptionKeyInitializer.tsx b/apps/mobile/src/components/EncryptionKeyInitializer.tsx index df29cb3..d6f7aac 100644 --- a/apps/mobile/src/components/EncryptionKeyInitializer.tsx +++ b/apps/mobile/src/components/EncryptionKeyInitializer.tsx @@ -48,7 +48,7 @@ export function EncryptionKeyInitializer(): JSX.Element | null { // Show loading overlay when we are initializing keys (generating new ones takes time) // or when rotation is in progress const [showLoading, setShowLoading] = useState(false); - const [loadingStatus, setLoadingStatus] = useState('Securing your app...'); + const [loadingStatus, setLoadingStatus] = useState('Connecting...'); // Effect 1: Initialize encryption keys when user is authenticated useEffect(() => { @@ -87,9 +87,9 @@ export function EncryptionKeyInitializer(): JSX.Element | null { try { // Mark as initializing to prevent re-entry isInitializing.current = true; - setShowLoading(true); - setLoadingStatus('Securing your app...'); - + // Only show loading if we are generating keys (which might take a second) + // Standard loading is fast enough to not need a full screen blocker + console.log('[E2EE Init] Starting key initialization...'); // Initialize keys and upload in one operation @@ -147,7 +147,9 @@ export function EncryptionKeyInitializer(): JSX.Element | null { const runRotation = async () => { try { hasTriggeredRotation.current = true; - setLoadingStatus('Updating message security...'); + // Only show loading for rotation which is a heavier operation + setShowLoading(true); + setLoadingStatus('Updating security...'); // Trigger key rotation - re-wraps message keys for the new public key console.log('[E2EE Init] Keys were regenerated, triggering key rotation...'); diff --git a/apps/mobile/src/components/feedback/FeedbackModal.tsx b/apps/mobile/src/components/feedback/FeedbackModal.tsx index 0d3e848..4d78e1f 100644 --- a/apps/mobile/src/components/feedback/FeedbackModal.tsx +++ b/apps/mobile/src/components/feedback/FeedbackModal.tsx @@ -518,12 +518,12 @@ const styles = StyleSheet.create({ }, cancelButton: { flex: 1, - backgroundColor: colors.glass.background, + backgroundColor: colors.glass.backgroundLight, paddingVertical: spacing.md, borderRadius: radius.md, alignItems: 'center', borderWidth: 1, - borderColor: colors.glass.border, + borderColor: colors.glass.borderLight, }, cancelButtonText: { ...typography.body, diff --git a/apps/mobile/src/components/feedback/QuestionFeedbackModal.tsx b/apps/mobile/src/components/feedback/QuestionFeedbackModal.tsx index e0cd471..1cdf72c 100644 --- a/apps/mobile/src/components/feedback/QuestionFeedbackModal.tsx +++ b/apps/mobile/src/components/feedback/QuestionFeedbackModal.tsx @@ -303,12 +303,12 @@ const styles = StyleSheet.create({ }, cancelButton: { flex: 1, - backgroundColor: colors.glass.background, + backgroundColor: colors.glass.backgroundLight, paddingVertical: spacing.md, borderRadius: radius.md, alignItems: 'center', borderWidth: 1, - borderColor: colors.glass.border, + borderColor: colors.glass.borderLight, }, cancelButtonText: { ...typography.body, diff --git a/apps/mobile/src/components/ui/GlassButton.tsx b/apps/mobile/src/components/ui/GlassButton.tsx index dbc2eea..9b70db0 100644 --- a/apps/mobile/src/components/ui/GlassButton.tsx +++ b/apps/mobile/src/components/ui/GlassButton.tsx @@ -156,9 +156,9 @@ const variants = { secondary: { gradient: undefined, textColor: colors.text, - background: colors.glass.background, + background: colors.glass.backgroundLight, borderWidth: 1, - borderColor: colors.glass.border, + borderColor: colors.glass.borderLight, }, ghost: { gradient: undefined, diff --git a/apps/mobile/src/features/chat/ChatScreen.tsx b/apps/mobile/src/features/chat/ChatScreen.tsx index 05ade07..32a63b3 100644 --- a/apps/mobile/src/features/chat/ChatScreen.tsx +++ b/apps/mobile/src/features/chat/ChatScreen.tsx @@ -3,7 +3,7 @@ import { View, StyleSheet, TouchableOpacity, KeyboardAvoidingView, Platform, Act import { Image } from "expo-image"; import { Video, ResizeMode } from "expo-av"; import { useLocalSearchParams, useRouter } from "expo-router"; -import { useFocusEffect } from "@react-navigation/native"; +import { useFocusEffect, useIsFocused } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Ionicons } from "@expo/vector-icons"; import { LinearGradient } from "expo-linear-gradient"; @@ -56,10 +56,13 @@ export const ChatScreen: React.FC = () => { cameraVideoMaxDuration: 60, // 1 min for camera recording }); + const isFocused = useIsFocused(); + // Typing indicator hook const { partnerTyping, sendTypingEvent, clearTypingIndicator } = useTypingIndicator({ channelName: `chat:${matchId}`, userId: user?.id, + isFocused, }); // Message subscription hook @@ -188,7 +191,16 @@ export const ChatScreen: React.FC = () => { const errorMessage = err instanceof Error && err.message ? err.message : "Failed to send message"; - Alert.alert("Error", errorMessage); + + // Map technical/unknown errors to friendly ones + let friendlyMessage = errorMessage; + if (errorMessage.includes('Failed to send') && !errorMessage.includes('connection')) { + friendlyMessage = "Couldn't send secure message. Please check your connection."; + } else if (errorMessage.includes('verification failed')) { + friendlyMessage = "Security check failed. Please restart the app."; + } + + Alert.alert("Delivery Failed", friendlyMessage); setInputText(content); } }; diff --git a/apps/mobile/src/features/chat/components/MessageContent.tsx b/apps/mobile/src/features/chat/components/MessageContent.tsx index 47d0351..e298a7c 100644 --- a/apps/mobile/src/features/chat/components/MessageContent.tsx +++ b/apps/mobile/src/features/chat/components/MessageContent.tsx @@ -189,7 +189,6 @@ export function MessageContent({ ) : ( - Loading... )} {/* Viewed indicator for sender */} @@ -272,7 +271,6 @@ export function MessageContent({ ) : ( - Loading... )} @@ -323,7 +321,6 @@ export function MessageContent({ return ( - Loading... ); } diff --git a/apps/mobile/src/features/profile/ProfileScreen.tsx b/apps/mobile/src/features/profile/ProfileScreen.tsx index 05d469f..a0c7c8c 100644 --- a/apps/mobile/src/features/profile/ProfileScreen.tsx +++ b/apps/mobile/src/features/profile/ProfileScreen.tsx @@ -254,22 +254,17 @@ export function ProfileScreen() { {__DEV__ && ( { await resetSwipeTutorial(); Alert.alert("Success", "Swipe tutorial has been reset."); }} - variant="danger" - // Using danger just for yellow/warning color simulation via props? No, MenuItem uses error color for danger. - // I'll stick to default and standard styling or custom if needed. - // The original used warning color (yellow). - // I will use default for now. /> { @@ -319,7 +314,6 @@ export function ProfileScreen() { ] ); }} - variant="danger" /> )} diff --git a/apps/mobile/src/features/profile/components/DangerZone.tsx b/apps/mobile/src/features/profile/components/DangerZone.tsx index 29e954b..c67a5db 100644 --- a/apps/mobile/src/features/profile/components/DangerZone.tsx +++ b/apps/mobile/src/features/profile/components/DangerZone.tsx @@ -1,8 +1,9 @@ import React, { useState } from 'react'; -import { View, Text, TouchableOpacity, StyleSheet, Modal, TextInput, ActivityIndicator } from 'react-native'; +import { View, Text, StyleSheet, Modal, Platform, KeyboardAvoidingView, TouchableOpacity, ActivityIndicator, TextInput } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import Animated, { FadeInDown } from 'react-native-reanimated'; -import { GlassCard } from '../../../components/ui'; +import { BlurView } from 'expo-blur'; +import { GlassCard, GlassButton } from '../../../components/ui'; import { colors, spacing, radius, typography } from '../../../theme'; interface DangerZoneProps { @@ -23,8 +24,6 @@ export const DangerZone: React.FC = ({ onDeleteRelationship }) setDeleteConfirmText(""); } catch (error) { // Error handling relies on parent throwing or managing its own alerts - // If parent throws, we stay on modal or close it? - // Usually parent alerts. } finally { setIsDeleting(false); } @@ -50,14 +49,15 @@ export const DangerZone: React.FC = ({ onDeleteRelationship }) - setShowDeleteModal(true)} - activeOpacity={0.7} + icon={} + style={styles.dangerButton} > - - Delete - + Delete + @@ -68,87 +68,96 @@ export const DangerZone: React.FC = ({ onDeleteRelationship }) animationType="fade" onRequestClose={() => !isDeleting && setShowDeleteModal(false)} > - - - - - + + + + + + + + Delete All Data? - Delete All Data? - - - This action cannot be undone. This will permanently delete: - + + This action cannot be undone. This will permanently delete: + - - - - Your couple connection + + + + Your couple connection + + + + All matches and responses + + + + All chat messages + + + + All shared photos + - - - All matches and responses - - - - All chat messages - - - - All shared photos + + + Type DELETE to confirm: + + + + + + { + setShowDeleteModal(false); + setDeleteConfirmText(""); + }} + disabled={isDeleting} + activeOpacity={0.7} + > + Cancel + + + + {isDeleting ? ( + + ) : ( + <> + + Delete + + )} + - - - Type DELETE to confirm: - - - - - - { - setShowDeleteModal(false); - setDeleteConfirmText(""); - }} - disabled={isDeleting} - activeOpacity={0.7} - > - Cancel - - - - {isDeleting ? ( - - ) : ( - <> - - Delete - - )} - - - - + + ); @@ -201,39 +210,21 @@ const styles = StyleSheet.create({ lineHeight: 20, }, dangerButton: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - backgroundColor: colors.errorLight, - paddingVertical: spacing.sm, - paddingHorizontal: spacing.md, - borderRadius: radius.md, - borderWidth: 1, - borderColor: 'rgba(231, 76, 60, 0.3)', - gap: spacing.xs, alignSelf: "flex-end", }, - dangerButtonText: { - ...typography.subhead, - color: colors.error, - fontWeight: "600", - }, // Modal styles modalOverlay: { flex: 1, - backgroundColor: "rgba(0, 0, 0, 0.8)", + backgroundColor: "rgba(0, 0, 0, 0.7)", justifyContent: "center", alignItems: "center", padding: spacing.lg, }, modalContent: { - backgroundColor: colors.backgroundLight, - borderRadius: radius.xl, - padding: spacing.xl, width: "100%", maxWidth: 400, - borderWidth: 1, - borderColor: colors.glass.border, + padding: spacing.xl, + backgroundColor: colors.backgroundLight, }, modalHeader: { alignItems: "center", @@ -260,11 +251,13 @@ const styles = StyleSheet.create({ textAlign: "center", }, deleteList: { - backgroundColor: colors.glass.background, + backgroundColor: "rgba(231, 76, 60, 0.1)", borderRadius: radius.lg, padding: spacing.md, gap: spacing.sm, marginBottom: spacing.xl, + borderWidth: 1, + borderColor: 'rgba(231, 76, 60, 0.2)', }, deleteListItem: { flexDirection: "row", @@ -285,16 +278,11 @@ const styles = StyleSheet.create({ fontWeight: "bold", color: colors.error, }, - confirmInput: { - backgroundColor: colors.background, - borderRadius: radius.md, - padding: spacing.md, - color: colors.text, - fontSize: 16, - borderWidth: 1, - borderColor: colors.error, + inputContainer: { marginBottom: spacing.xl, - textAlign: "center", + }, + confirmInput: { + textAlign: 'center', }, modalButtons: { flexDirection: "row", @@ -302,33 +290,32 @@ const styles = StyleSheet.create({ }, cancelButton: { flex: 1, + backgroundColor: colors.glass.backgroundLight, paddingVertical: spacing.md, borderRadius: radius.md, - backgroundColor: colors.glass.background, alignItems: "center", borderWidth: 1, - borderColor: colors.glass.border, + borderColor: colors.glass.borderLight, }, cancelButtonText: { ...typography.body, - color: colors.text, + color: colors.textSecondary, fontWeight: "600", }, - confirmDeleteButton: { + deleteButton: { flex: 1, - flexDirection: "row", + backgroundColor: colors.error, paddingVertical: spacing.md, borderRadius: radius.md, - backgroundColor: colors.error, alignItems: "center", + flexDirection: "row", justifyContent: "center", gap: spacing.xs, }, - confirmDeleteButtonDisabled: { + deleteButtonDisabled: { opacity: 0.5, - backgroundColor: colors.errorLight, }, - confirmDeleteButtonText: { + deleteButtonText: { ...typography.body, color: colors.text, fontWeight: "600", diff --git a/apps/mobile/src/features/profile/components/ProfileHeader.tsx b/apps/mobile/src/features/profile/components/ProfileHeader.tsx index 7b4ab02..3a5453e 100644 --- a/apps/mobile/src/features/profile/components/ProfileHeader.tsx +++ b/apps/mobile/src/features/profile/components/ProfileHeader.tsx @@ -139,6 +139,7 @@ export const ProfileHeader: React.FC = ({ const styles = StyleSheet.create({ profileSection: { marginBottom: spacing.xl, + paddingHorizontal: spacing.lg, }, profileContent: { flexDirection: 'row', diff --git a/apps/mobile/src/features/profile/components/SettingsSection.tsx b/apps/mobile/src/features/profile/components/SettingsSection.tsx index cfca30c..05f99c5 100644 --- a/apps/mobile/src/features/profile/components/SettingsSection.tsx +++ b/apps/mobile/src/features/profile/components/SettingsSection.tsx @@ -33,6 +33,7 @@ export function SettingsSection({ title, delay = 200, children }: SettingsSectio const styles = StyleSheet.create({ container: { marginBottom: spacing.lg, + paddingHorizontal: spacing.lg, }, title: { ...typography.caption1, diff --git a/apps/mobile/src/hooks/useDecryptedMessage.ts b/apps/mobile/src/hooks/useDecryptedMessage.ts index 98df602..c3e0e55 100644 --- a/apps/mobile/src/hooks/useDecryptedMessage.ts +++ b/apps/mobile/src/hooks/useDecryptedMessage.ts @@ -105,7 +105,7 @@ export function useDecryptedMessage({ content: null, mediaUri: null, isDecrypting: false, - error: new Error('Failed to load message'), + error: new Error('Encrypted message'), errorCode: 'E2EE_NOT_SUPPORTED', }); return; @@ -126,7 +126,7 @@ export function useDecryptedMessage({ content: null, mediaUri: null, isDecrypting: false, - error: new Error('Failed to load message'), + error: new Error('Waiting for keys...'), errorCode: 'E2EE_KEYS_UNAVAILABLE', }); return; @@ -141,7 +141,7 @@ export function useDecryptedMessage({ content: null, mediaUri: null, isDecrypting: false, - error: new Error('Failed to load message'), + error: new Error('Message unavailable'), errorCode: 'E2EE_MISSING_FIELDS', }); return; @@ -326,7 +326,7 @@ export function useDecryptedMessage({ content: null, mediaUri: null, isDecrypting: false, - error: new Error('Failed to load message'), + error: new Error('Waiting for message...'), errorCode: 'E2EE_DECRYPT_FAILED', }); return; @@ -336,7 +336,7 @@ export function useDecryptedMessage({ content: null, mediaUri: null, isDecrypting: false, - error: new Error('Failed to load message'), + error: new Error('Waiting for message...'), errorCode: 'E2EE_DECRYPT_FAILED', }); } diff --git a/apps/mobile/src/hooks/useEncryptedSend.ts b/apps/mobile/src/hooks/useEncryptedSend.ts index 490b92c..33f8ccb 100644 --- a/apps/mobile/src/hooks/useEncryptedSend.ts +++ b/apps/mobile/src/hooks/useEncryptedSend.ts @@ -43,8 +43,8 @@ export function useEncryptedSend(): UseEncryptedSendReturn { const [isEncrypting, setIsEncrypting] = useState(false); const [error, setError] = useState(null); - // Track whether we've verified the key pair this session - const keyPairVerifiedRef = useRef(false); + // Track the version of keys we have verified to handle key rotation/regeneration + const verifiedKeysVersionRef = useRef(null); const { publicKeyJwk, hasKeys, isLoading } = useEncryptionKeys(); const refreshPartner = useAuthStore((s) => s.refreshPartner); @@ -68,7 +68,8 @@ export function useEncryptedSend(): UseEncryptedSendReturn { const currentKeys = useAuthStore.getState().encryptionKeys; if (!currentKeys.hasKeys || !currentKeys.publicKeyJwk || !currentKeys.privateKeyJwk) { - const keysError = new Error('Encryption keys not ready. Please wait a moment and try again.'); + // User-friendly error + const keysError = new Error('Message security not ready. Please check your connection.'); console.warn('[E2EE Send] Keys not fully available in global store'); setError(keysError); throw keysError; @@ -78,22 +79,26 @@ export function useEncryptedSend(): UseEncryptedSendReturn { const senderPrivateKey = currentKeys.privateKeyJwk; // SAFETY CHECK: Verify the key pair works together before encrypting - // This prevents creating unrecoverable messages if keys are mismatched - if (!keyPairVerifiedRef.current) { - console.log('[E2EE Send] Verifying sender key pair...'); + // This prevents creating unrecoverable messages if keys are mismatched. + // We check if the current keys version matches what we last verified. + if (verifiedKeysVersionRef.current !== currentKeys.keysVersion) { + console.log( + `[E2EE Send] Verifying sender key pair (version ${currentKeys.keysVersion})...` + ); const isValidKeyPair = await verifyKeyPair( senderPublicKey as RSAPublicKeyJWK, senderPrivateKey ); if (!isValidKeyPair) { - const keyError = new Error('Encryption key verification failed. Please restart the app and try again.'); + // User-friendly error + const keyError = new Error('Security check failed. Please restart the app.'); console.error('[E2EE Send] CRITICAL: Sender key pair verification failed!'); setError(keyError); throw keyError; } - keyPairVerifiedRef.current = true; + verifiedKeysVersionRef.current = currentKeys.keysVersion; console.log('[E2EE Send] Sender key pair verified successfully'); } @@ -122,7 +127,7 @@ export function useEncryptedSend(): UseEncryptedSendReturn { // Validate admin key structure if (!isValidPublicKeyJwk(adminPublicKey)) { - const adminError = new Error('Unable to secure message. Please check your connection and try again.'); + const adminError = new Error('Connection issue. Please try again.'); console.error('[E2EE Send] Admin public key is invalid'); setIsEncrypting(false); setError(adminError); @@ -130,10 +135,13 @@ export function useEncryptedSend(): UseEncryptedSendReturn { } } catch (adminKeyError) { // Re-throw if already a user-facing error - if (adminKeyError instanceof Error && adminKeyError.message.includes('Unable to secure')) { + if (adminKeyError instanceof Error && ( + adminKeyError.message.includes('Connection issue') || + adminKeyError.message.includes('Unable to secure') + )) { throw adminKeyError; } - const fetchError = new Error('Unable to secure message. Please check your connection and try again.'); + const fetchError = new Error('Connection issue. Please try again.'); console.error('[E2EE Send] Failed to fetch admin key:', adminKeyError); setIsEncrypting(false); setError(fetchError); diff --git a/apps/mobile/src/hooks/useEncryptionKeys.ts b/apps/mobile/src/hooks/useEncryptionKeys.ts index ee03919..4bf42a6 100644 --- a/apps/mobile/src/hooks/useEncryptionKeys.ts +++ b/apps/mobile/src/hooks/useEncryptionKeys.ts @@ -41,6 +41,9 @@ interface UseEncryptionKeysReturn { refresh: () => Promise; } +// Global promise to track in-flight key loading across all hook instances +let globalLoadKeysPromise: Promise | null = null; + /** * Hook for accessing and managing encryption keys. * @@ -73,59 +76,79 @@ export function useEncryptionKeys(): UseEncryptionKeysReturn { return null; } - // Prevent concurrent loads + // If keys are already loaded in global store, return them + if (encryptionKeys.hasKeys && encryptionKeys.publicKeyJwk) { + return encryptionKeys.publicKeyJwk; + } + + // If there's an in-flight global load, return that promise + if (globalLoadKeysPromise) { + console.log('[E2EE Global] Joining existing key load operation'); + return globalLoadKeysPromise; + } + + // Prevent concurrent loads (local check) if (isLoadingRef.current) { return encryptionKeys.publicKeyJwk; } - try { - isLoadingRef.current = true; - setEncryptionKeys({ isLoadingKeys: true, keysError: null }); + const task = async () => { + try { + isLoadingRef.current = true; + setEncryptionKeys({ isLoadingKeys: true, keysError: null }); - const keysExist = await hasKeyPair(); - if (keysExist) { - const [privateKey, publicKey] = await Promise.all([ - getPrivateKey(), - getLocalPublicKey(), - ]); + const keysExist = await hasKeyPair(); + if (keysExist) { + const [privateKey, publicKey] = await Promise.all([ + getPrivateKey(), + getLocalPublicKey(), + ]); - console.log('[E2EE Global] Keys loaded from storage'); + console.log('[E2EE Global] Keys loaded from storage'); - setEncryptionKeys({ - privateKeyJwk: privateKey, - publicKeyJwk: publicKey, - isLoadingKeys: false, - hasKeys: true, - keysError: null, - }); + setEncryptionKeys({ + privateKeyJwk: privateKey, + publicKeyJwk: publicKey, + isLoadingKeys: false, + hasKeys: true, + keysError: null, + }); - hasLoadedRef.current = true; - return publicKey; - } else { - console.log('[E2EE Global] No keys in storage'); + hasLoadedRef.current = true; + return publicKey; + } else { + console.log('[E2EE Global] No keys in storage'); + setEncryptionKeys({ + privateKeyJwk: null, + publicKeyJwk: null, + isLoadingKeys: false, + hasKeys: false, + keysError: null, + }); + return null; + } + } catch (error) { + console.error('[E2EE Global] Failed to load encryption keys:', error); setEncryptionKeys({ privateKeyJwk: null, publicKeyJwk: null, isLoadingKeys: false, hasKeys: false, - keysError: null, + keysError: error as Error, }); return null; + } finally { + isLoadingRef.current = false; } - } catch (error) { - console.error('[E2EE Global] Failed to load encryption keys:', error); - setEncryptionKeys({ - privateKeyJwk: null, - publicKeyJwk: null, - isLoadingKeys: false, - hasKeys: false, - keysError: error as Error, - }); - return null; - } finally { - isLoadingRef.current = false; - } - }, [encryptionKeys.publicKeyJwk, setEncryptionKeys]); + }; + + // Assign to global promise + globalLoadKeysPromise = task().finally(() => { + globalLoadKeysPromise = null; + }); + + return globalLoadKeysPromise; + }, [encryptionKeys.publicKeyJwk, encryptionKeys.hasKeys, setEncryptionKeys]); /** * Initialize encryption keys and upload to Supabase in one operation. diff --git a/apps/mobile/src/hooks/useTypingIndicator.ts b/apps/mobile/src/hooks/useTypingIndicator.ts index 8f9486b..1a12229 100644 --- a/apps/mobile/src/hooks/useTypingIndicator.ts +++ b/apps/mobile/src/hooks/useTypingIndicator.ts @@ -8,6 +8,8 @@ export interface UseTypingIndicatorConfig { userId: string | undefined; /** Timeout in ms before partner typing indicator disappears. Default: 3000 */ typingTimeout?: number; + /** Whether the screen is currently focused */ + isFocused?: boolean; } export interface UseTypingIndicatorReturn { @@ -31,10 +33,23 @@ const DEFAULT_TYPING_TIMEOUT = 3000; export const useTypingIndicator = ( config: UseTypingIndicatorConfig ): UseTypingIndicatorReturn => { - const { channelName, userId, typingTimeout = DEFAULT_TYPING_TIMEOUT } = config; + const { channelName, userId, typingTimeout = DEFAULT_TYPING_TIMEOUT, isFocused = true } = config; const [partnerTyping, setPartnerTyping] = useState(false); const typingTimeoutRef = useRef(null); + const isFocusedRef = useRef(isFocused); + + // Update ref when focus changes and clear state if blurred + useEffect(() => { + isFocusedRef.current = isFocused; + if (!isFocused) { + setPartnerTyping(false); + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + typingTimeoutRef.current = null; + } + } + }, [isFocused]); // Clear timeout on cleanup useEffect(() => { @@ -52,6 +67,9 @@ export const useTypingIndicator = ( const channel = supabase.channel(channelName); channel.on('broadcast', { event: 'typing' }, (payload) => { + // Only process events if the screen is focused + if (!isFocusedRef.current) return; + if (payload.payload.userId !== userId) { setPartnerTyping(true);