mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Add pack icon system and E2EE removal plan
- Replace emoji icons with Ionicons for professional appearance - Improve E2EE key recovery with better diagnostics - Add analytics for pack icon events - Document E2EE removal plan for future simplification 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
953bd7c03d
commit
43fa7033dc
14 files changed
+2013
-961
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -16,6 +16,7 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
||||
import { colors, gradients, spacing, radius, typography } from "../../src/theme";
|
||||
import { getPackIconName } from "../../src/lib/packIcons";
|
||||
import { Question } from "../../src/types";
|
||||
|
||||
type TooltipType = 'initiator' | 'coupleType' | null;
|
||||
@@ -259,7 +260,11 @@ export default function PackDetailsScreen() {
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
)}
|
||||
<Text style={styles.emoji}>{pack?.icon || "📦"}</Text>
|
||||
<Ionicons
|
||||
name={getPackIconName(pack?.icon)}
|
||||
size={32}
|
||||
color={pack?.is_premium ? colors.premium.gold : colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
{/* Crown for premium */}
|
||||
{pack?.is_premium && (
|
||||
@@ -575,9 +580,6 @@ const styles = StyleSheet.create({
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
},
|
||||
emoji: {
|
||||
fontSize: 36,
|
||||
},
|
||||
title: {
|
||||
...typography.largeTitle,
|
||||
color: colors.text,
|
||||
|
||||
@@ -19,6 +19,7 @@ import Animated, {
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import { getPackIconName } from "../lib/packIcons";
|
||||
import { colors, gradients, spacing, radius, typography } from "../theme";
|
||||
|
||||
interface PackTeaserProps {
|
||||
@@ -159,7 +160,11 @@ export function PackTeaser({
|
||||
colors={['rgba(212, 175, 55, 0.15)', 'rgba(184, 134, 11, 0.1)']}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<Text style={styles.packEmoji}>{packIcon || "📦"}</Text>
|
||||
<Ionicons
|
||||
name={getPackIconName(packIcon)}
|
||||
size={36}
|
||||
color={colors.premium.gold}
|
||||
/>
|
||||
</View>
|
||||
{/* Crown badge */}
|
||||
<View style={styles.crownBadge}>
|
||||
@@ -370,9 +375,6 @@ const styles = StyleSheet.create({
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
},
|
||||
packEmoji: {
|
||||
fontSize: 40,
|
||||
},
|
||||
title: {
|
||||
...typography.title1,
|
||||
color: colors.text,
|
||||
|
||||
@@ -34,7 +34,7 @@ export const ChatScreen: React.FC = () => {
|
||||
const matchId = id as string;
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { user, partner } = useAuthStore();
|
||||
const { user, partner, encryptionKeys } = useAuthStore();
|
||||
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
|
||||
const [match, setMatch] = useState<any>(null);
|
||||
const [inputText, setInputText] = useState("");
|
||||
@@ -77,6 +77,20 @@ export const ChatScreen: React.FC = () => {
|
||||
// E2EE encryption hook for sending messages
|
||||
const { encryptMessage, isE2EEAvailable, isAdminKeyReady, isEncrypting } = useEncryptedSend();
|
||||
const secureReady = isE2EEAvailable && isAdminKeyReady;
|
||||
const recoveryMessage = (() => {
|
||||
if (!encryptionKeys.isRecovering) return null;
|
||||
switch (encryptionKeys.recoveryReason) {
|
||||
case 'pending-recipient':
|
||||
return 'Finishing security setup for older messages...';
|
||||
case 'keys-unavailable':
|
||||
return 'Setting up secure keys...';
|
||||
case 'stale-key':
|
||||
case 'rotation':
|
||||
return 'Updating security for this device...';
|
||||
default:
|
||||
return 'Updating security...';
|
||||
}
|
||||
})();
|
||||
|
||||
// Message actions hook for deletion and reporting
|
||||
const { showDeleteOptions, deleteForSelf } = useMessageActions({
|
||||
@@ -321,6 +335,14 @@ export const ChatScreen: React.FC = () => {
|
||||
onBack={() => router.push('/(app)/matches')}
|
||||
/>
|
||||
|
||||
{recoveryMessage && (
|
||||
<View style={styles.securityBanner}>
|
||||
<Ionicons name="shield-checkmark-outline" size={16} color={ACCENT} />
|
||||
<Text style={styles.securityBannerText}>{recoveryMessage}</Text>
|
||||
<ActivityIndicator size="small" color={ACCENT} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
userId={user?.id}
|
||||
@@ -563,6 +585,25 @@ const styles = StyleSheet.create({
|
||||
height: 1,
|
||||
backgroundColor: `${ACCENT_RGBA}0.15)`,
|
||||
},
|
||||
securityBanner: {
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
backgroundColor: 'rgba(212, 175, 55, 0.12)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(212, 175, 55, 0.25)',
|
||||
},
|
||||
securityBannerText: {
|
||||
flex: 1,
|
||||
color: colors.textSecondary,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
secureStatus: {
|
||||
alignSelf: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -8,12 +8,19 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { decryptMediaFile, cleanupDecryptedMedia, MESSAGE_VERSION_E2EE, repairStaleKey } from '../lib/encryption';
|
||||
import {
|
||||
decryptMediaFile,
|
||||
cleanupDecryptedMedia,
|
||||
MESSAGE_VERSION_E2EE,
|
||||
repairStaleKey,
|
||||
triggerAutoKeyRotation,
|
||||
} from '../lib/encryption';
|
||||
import type { KeysMetadata, RSAPrivateKeyJWK } from '../lib/encryption';
|
||||
import { useEncryptionKeys } from './useEncryptionKeys';
|
||||
import { getCachedSignedUrl, getStoragePath } from '../lib/imageCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
// Import shared cache from mediaCache to avoid circular dependency
|
||||
import { decryptedMediaCache, clearDecryptedMediaCache } from '../lib/mediaCache';
|
||||
|
||||
@@ -86,6 +93,8 @@ export function useDecryptedMedia({
|
||||
});
|
||||
|
||||
const { privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
|
||||
const beginEncryptionRecovery = useAuthStore((s) => s.beginEncryptionRecovery);
|
||||
const endEncryptionRecovery = useAuthStore((s) => s.endEncryptionRecovery);
|
||||
const isMounted = useRef(true);
|
||||
|
||||
const repairAttemptedByMessageId = useRef(new Set<string>());
|
||||
@@ -216,26 +225,31 @@ export function useDecryptedMedia({
|
||||
);
|
||||
|
||||
if (!isMe) {
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: true,
|
||||
error: null,
|
||||
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
|
||||
});
|
||||
beginEncryptionRecovery('pending-recipient');
|
||||
try {
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: true,
|
||||
error: null,
|
||||
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
|
||||
});
|
||||
|
||||
if (!repairAttemptedByMessageId.current.has(messageId)) {
|
||||
repairAttemptedByMessageId.current.add(messageId);
|
||||
await reencryptPendingMessages();
|
||||
if (!repairAttemptedByMessageId.current.has(messageId)) {
|
||||
repairAttemptedByMessageId.current.add(messageId);
|
||||
await reencryptPendingMessages();
|
||||
}
|
||||
|
||||
const { data: refreshed } = await supabase
|
||||
.from('messages')
|
||||
.select('keys_metadata')
|
||||
.eq('id', messageId)
|
||||
.maybeSingle();
|
||||
|
||||
effectiveKeysMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
|
||||
wrappedKey = getWrappedKey(effectiveKeysMetadata);
|
||||
} finally {
|
||||
endEncryptionRecovery();
|
||||
}
|
||||
|
||||
const { data: refreshed } = await supabase
|
||||
.from('messages')
|
||||
.select('keys_metadata')
|
||||
.eq('id', messageId)
|
||||
.maybeSingle();
|
||||
|
||||
effectiveKeysMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
|
||||
wrappedKey = getWrappedKey(effectiveKeysMetadata);
|
||||
}
|
||||
|
||||
if (!wrappedKey) {
|
||||
@@ -298,67 +312,80 @@ export function useDecryptedMedia({
|
||||
|
||||
// Attempt stale key repair if not already attempted
|
||||
if (isStaleKeyFailure && !repairAttemptedByMessageId.current.has(messageId)) {
|
||||
console.log(`[E2EE Media] Message ${messageId}: Attempting stale key repair...`);
|
||||
|
||||
if (isMounted.current) {
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: true,
|
||||
error: null,
|
||||
errorCode: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
repairAttemptedByMessageId.current.add(messageId);
|
||||
beginEncryptionRecovery('stale-key');
|
||||
const autoRotationPromise = triggerAutoKeyRotation();
|
||||
let didDecrypt = false;
|
||||
|
||||
try {
|
||||
const repairResult = await repairStaleKey(messageId);
|
||||
console.log(`[E2EE Media] Message ${messageId}: Attempting stale key repair...`);
|
||||
|
||||
if (repairResult.success) {
|
||||
console.log(`[E2EE Media] Message ${messageId}: Stale key repaired, retrying decryption...`);
|
||||
|
||||
// Fetch updated message and retry
|
||||
const { data: refreshed } = await supabase
|
||||
.from('messages')
|
||||
.select('keys_metadata')
|
||||
.eq('id', messageId)
|
||||
.maybeSingle();
|
||||
|
||||
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
|
||||
|
||||
if (refreshedMetadata && encryptionIv && mediaPath) {
|
||||
const storagePath = getStoragePath(mediaPath);
|
||||
const encryptedUrl = await getCachedSignedUrl(storagePath);
|
||||
|
||||
if (encryptedUrl) {
|
||||
const decryptedUri = await decryptMediaFile(
|
||||
encryptedUrl,
|
||||
encryptionIv,
|
||||
refreshedMetadata,
|
||||
privateKeyJwk as RSAPrivateKeyJWK,
|
||||
!isMe,
|
||||
mediaType,
|
||||
);
|
||||
|
||||
const cacheKey = `${messageId}-${mediaPath}`;
|
||||
decryptedMediaCache.set(cacheKey, decryptedUri);
|
||||
|
||||
if (isMounted.current) {
|
||||
setState({
|
||||
uri: decryptedUri,
|
||||
isDecrypting: false,
|
||||
error: null,
|
||||
errorCode: undefined,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(`[E2EE Media] Message ${messageId}: Stale key repair failed:`, repairResult.error);
|
||||
if (isMounted.current) {
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: true,
|
||||
error: null,
|
||||
errorCode: undefined,
|
||||
});
|
||||
}
|
||||
} catch (repairErr) {
|
||||
console.error(`[E2EE Media] Message ${messageId}: Stale key repair threw:`, repairErr);
|
||||
|
||||
repairAttemptedByMessageId.current.add(messageId);
|
||||
|
||||
try {
|
||||
const repairResult = await repairStaleKey(messageId);
|
||||
|
||||
if (repairResult.success) {
|
||||
console.log(`[E2EE Media] Message ${messageId}: Stale key repaired, retrying decryption...`);
|
||||
|
||||
// Fetch updated message and retry
|
||||
const { data: refreshed } = await supabase
|
||||
.from('messages')
|
||||
.select('keys_metadata')
|
||||
.eq('id', messageId)
|
||||
.maybeSingle();
|
||||
|
||||
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
|
||||
|
||||
if (refreshedMetadata && encryptionIv && mediaPath) {
|
||||
const storagePath = getStoragePath(mediaPath);
|
||||
const encryptedUrl = await getCachedSignedUrl(storagePath);
|
||||
|
||||
if (encryptedUrl) {
|
||||
const decryptedUri = await decryptMediaFile(
|
||||
encryptedUrl,
|
||||
encryptionIv,
|
||||
refreshedMetadata,
|
||||
privateKeyJwk as RSAPrivateKeyJWK,
|
||||
!isMe,
|
||||
mediaType,
|
||||
);
|
||||
|
||||
const cacheKey = `${messageId}-${mediaPath}`;
|
||||
decryptedMediaCache.set(cacheKey, decryptedUri);
|
||||
|
||||
if (isMounted.current) {
|
||||
setState({
|
||||
uri: decryptedUri,
|
||||
isDecrypting: false,
|
||||
error: null,
|
||||
errorCode: undefined,
|
||||
});
|
||||
}
|
||||
didDecrypt = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(`[E2EE Media] Message ${messageId}: Stale key repair failed:`, repairResult.error);
|
||||
}
|
||||
} catch (repairErr) {
|
||||
console.error(`[E2EE Media] Message ${messageId}: Stale key repair threw:`, repairErr);
|
||||
}
|
||||
} finally {
|
||||
await autoRotationPromise;
|
||||
if (autoRotationPromise && !didDecrypt) {
|
||||
retry();
|
||||
}
|
||||
endEncryptionRecovery();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,6 +412,9 @@ export function useDecryptedMedia({
|
||||
hasKeys,
|
||||
keysLoading,
|
||||
retryNonce,
|
||||
beginEncryptionRecovery,
|
||||
endEncryptionRecovery,
|
||||
retry,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
MESSAGE_VERSION_PLAINTEXT,
|
||||
MESSAGE_VERSION_E2EE,
|
||||
repairStaleKey,
|
||||
triggerAutoKeyRotation,
|
||||
} from '../lib/encryption';
|
||||
import type {
|
||||
DecryptedMessageState,
|
||||
@@ -21,6 +22,7 @@ import type {
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
|
||||
import { useEncryptionKeys } from './useEncryptionKeys';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const AUTO_RETRY_BASE_DELAY_MS = 3000;
|
||||
const MAX_AUTO_RETRIES = 2;
|
||||
@@ -67,7 +69,9 @@ export function useDecryptedMessage({
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
|
||||
const { privateKeyJwk, hasKeys, isLoading: keysLoading, ensureKeysUploaded } = useEncryptionKeys();
|
||||
const beginEncryptionRecovery = useAuthStore((s) => s.beginEncryptionRecovery);
|
||||
const endEncryptionRecovery = useAuthStore((s) => s.endEncryptionRecovery);
|
||||
|
||||
// Track per-message repair attempts to avoid tight retry loops.
|
||||
const repairAttemptedByMessageId = useRef(new Set<string>());
|
||||
@@ -215,99 +219,40 @@ export function useDecryptedMessage({
|
||||
!repairAttemptedByMessageId.current.has(message.id);
|
||||
|
||||
if (shouldAttemptPendingRepair) {
|
||||
setState({
|
||||
content: null,
|
||||
mediaUri: null,
|
||||
isDecrypting: true,
|
||||
error: null,
|
||||
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
|
||||
});
|
||||
|
||||
if (!repairAttemptedByMessageId.current.has(message.id)) {
|
||||
repairAttemptedByMessageId.current.add(message.id);
|
||||
await reencryptPendingMessages();
|
||||
}
|
||||
|
||||
// Fetch the latest keys_metadata and retry once locally.
|
||||
const { data: refreshed } = await supabase
|
||||
.from('messages')
|
||||
.select('encrypted_content, encryption_iv, keys_metadata, user_id')
|
||||
.eq('id', message.id)
|
||||
.maybeSingle();
|
||||
|
||||
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
|
||||
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
|
||||
const refreshedIv = refreshed?.encryption_iv ?? null;
|
||||
|
||||
if (refreshedMetadata && refreshedEncryptedContent && refreshedIv) {
|
||||
try {
|
||||
const decryptedContent = await decryptTextMessage(
|
||||
refreshedEncryptedContent,
|
||||
refreshedIv,
|
||||
refreshedMetadata,
|
||||
privateKeyJwk as RSAPrivateKeyJWK,
|
||||
true, // isRecipient
|
||||
);
|
||||
|
||||
setState({
|
||||
content: decryptedContent,
|
||||
mediaUri: null,
|
||||
isDecrypting: false,
|
||||
error: null,
|
||||
errorCode: undefined,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// Still pending (or repair failed); show the placeholder instead of a hard error.
|
||||
}
|
||||
}
|
||||
|
||||
setState({
|
||||
content: null,
|
||||
mediaUri: null,
|
||||
isDecrypting: false,
|
||||
error: null,
|
||||
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldAttemptStaleKeyRepair) {
|
||||
console.log(`[E2EE Decrypt] Message ${message.id}: Attempting stale key repair...`);
|
||||
setState({
|
||||
content: null,
|
||||
mediaUri: null,
|
||||
isDecrypting: true,
|
||||
error: null,
|
||||
errorCode: 'E2EE_REPAIRING_STALE_KEY',
|
||||
});
|
||||
|
||||
repairAttemptedByMessageId.current.add(message.id);
|
||||
|
||||
beginEncryptionRecovery('pending-recipient');
|
||||
try {
|
||||
const repairResult = await repairStaleKey(message.id);
|
||||
setState({
|
||||
content: null,
|
||||
mediaUri: null,
|
||||
isDecrypting: true,
|
||||
error: null,
|
||||
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
|
||||
});
|
||||
|
||||
if (repairResult.success) {
|
||||
console.log(`[E2EE Decrypt] Message ${message.id}: Stale key repaired, retrying decryption...`);
|
||||
if (!repairAttemptedByMessageId.current.has(message.id)) {
|
||||
repairAttemptedByMessageId.current.add(message.id);
|
||||
await reencryptPendingMessages();
|
||||
}
|
||||
|
||||
// Fetch updated message and retry
|
||||
const { data: refreshed } = await supabase
|
||||
.from('messages')
|
||||
.select('encrypted_content, encryption_iv, keys_metadata, user_id')
|
||||
.eq('id', message.id)
|
||||
.maybeSingle();
|
||||
// Fetch the latest keys_metadata and retry once locally.
|
||||
const { data: refreshed } = await supabase
|
||||
.from('messages')
|
||||
.select('encrypted_content, encryption_iv, keys_metadata, user_id')
|
||||
.eq('id', message.id)
|
||||
.maybeSingle();
|
||||
|
||||
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
|
||||
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
|
||||
const refreshedIv = refreshed?.encryption_iv ?? null;
|
||||
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
|
||||
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
|
||||
const refreshedIv = refreshed?.encryption_iv ?? null;
|
||||
|
||||
if (refreshedMetadata && refreshedEncryptedContent && refreshedIv) {
|
||||
if (refreshedMetadata && refreshedEncryptedContent && refreshedIv) {
|
||||
try {
|
||||
const decryptedContent = await decryptTextMessage(
|
||||
refreshedEncryptedContent,
|
||||
refreshedIv,
|
||||
refreshedMetadata,
|
||||
privateKeyJwk as RSAPrivateKeyJWK,
|
||||
!isMe, // isRecipient
|
||||
true, // isRecipient
|
||||
);
|
||||
|
||||
setState({
|
||||
@@ -318,23 +263,122 @@ export function useDecryptedMessage({
|
||||
errorCode: undefined,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// Still pending (or repair failed); show the placeholder instead of a hard error.
|
||||
}
|
||||
} else {
|
||||
console.error(`[E2EE Decrypt] Message ${message.id}: Stale key repair failed:`, repairResult.error);
|
||||
}
|
||||
} catch (repairErr) {
|
||||
console.error(`[E2EE Decrypt] Message ${message.id}: Stale key repair threw:`, repairErr);
|
||||
}
|
||||
|
||||
// Repair failed or retry failed
|
||||
setState({
|
||||
content: null,
|
||||
mediaUri: null,
|
||||
isDecrypting: false,
|
||||
error: new Error('Waiting for message...'),
|
||||
errorCode: 'E2EE_DECRYPT_FAILED',
|
||||
});
|
||||
return;
|
||||
setState({
|
||||
content: null,
|
||||
mediaUri: null,
|
||||
isDecrypting: false,
|
||||
error: null,
|
||||
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
endEncryptionRecovery();
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldAttemptStaleKeyRepair) {
|
||||
beginEncryptionRecovery('stale-key');
|
||||
let didDecrypt = false;
|
||||
|
||||
try {
|
||||
console.log(`[E2EE Decrypt] Message ${message.id}: Attempting stale key repair...`);
|
||||
setState({
|
||||
content: null,
|
||||
mediaUri: null,
|
||||
isDecrypting: true,
|
||||
error: null,
|
||||
errorCode: 'E2EE_REPAIRING_STALE_KEY',
|
||||
});
|
||||
|
||||
repairAttemptedByMessageId.current.add(message.id);
|
||||
|
||||
// CRITICAL: Ensure local keys are uploaded to database BEFORE repair.
|
||||
// The repair edge function uses the database public key to re-wrap.
|
||||
// If the database has a stale key, repair will "succeed" but decryption will fail.
|
||||
console.log(`[E2EE Decrypt] Message ${message.id}: Ensuring keys are synced before repair...`);
|
||||
const keysUploaded = await ensureKeysUploaded();
|
||||
console.log(`[E2EE Decrypt] Message ${message.id}: Keys sync result: ${keysUploaded}`);
|
||||
|
||||
// Run auto key rotation to re-wrap recent messages BEFORE individual repair.
|
||||
// This ensures handle-key-rotation uses the correct database key.
|
||||
const autoRotationResult = await triggerAutoKeyRotation();
|
||||
if (autoRotationResult) {
|
||||
console.log(`[E2EE Decrypt] Message ${message.id}: Auto rotation completed: ${autoRotationResult.updated} messages updated`);
|
||||
}
|
||||
|
||||
try {
|
||||
const repairResult = await repairStaleKey(message.id);
|
||||
|
||||
if (repairResult.success) {
|
||||
console.log(`[E2EE Decrypt] Message ${message.id}: Stale key repaired, retrying decryption...`);
|
||||
|
||||
// Fetch updated message and retry
|
||||
const { data: refreshed } = await supabase
|
||||
.from('messages')
|
||||
.select('encrypted_content, encryption_iv, keys_metadata, user_id')
|
||||
.eq('id', message.id)
|
||||
.maybeSingle();
|
||||
|
||||
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
|
||||
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
|
||||
const refreshedIv = refreshed?.encryption_iv ?? null;
|
||||
|
||||
// Log key metadata for debugging
|
||||
console.log(`[E2EE Decrypt] Message ${message.id}: Refreshed metadata:`, {
|
||||
hasSenderKey: !!refreshedMetadata?.sender_wrapped_key,
|
||||
hasRecipientKey: !!refreshedMetadata?.recipient_wrapped_key,
|
||||
adminKeyId: refreshedMetadata?.admin_key_id,
|
||||
isRecipient: !isMe,
|
||||
});
|
||||
|
||||
if (refreshedMetadata && refreshedEncryptedContent && refreshedIv) {
|
||||
const decryptedContent = await decryptTextMessage(
|
||||
refreshedEncryptedContent,
|
||||
refreshedIv,
|
||||
refreshedMetadata,
|
||||
privateKeyJwk as RSAPrivateKeyJWK,
|
||||
!isMe, // isRecipient
|
||||
);
|
||||
|
||||
setState({
|
||||
content: decryptedContent,
|
||||
mediaUri: null,
|
||||
isDecrypting: false,
|
||||
error: null,
|
||||
errorCode: undefined,
|
||||
});
|
||||
didDecrypt = true;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.error(`[E2EE Decrypt] Message ${message.id}: Stale key repair failed:`, repairResult.error);
|
||||
}
|
||||
} catch (repairErr) {
|
||||
// Note: This catch includes decryption errors after successful repair
|
||||
console.error(`[E2EE Decrypt] Message ${message.id}: Decryption retry failed after repair:`, repairErr);
|
||||
}
|
||||
|
||||
// Repair failed or retry failed
|
||||
setState({
|
||||
content: null,
|
||||
mediaUri: null,
|
||||
isDecrypting: false,
|
||||
error: new Error('Waiting for message...'),
|
||||
errorCode: 'E2EE_DECRYPT_FAILED',
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
if (!didDecrypt) {
|
||||
// Schedule another retry after a delay - keys may sync in the background
|
||||
retry();
|
||||
}
|
||||
endEncryptionRecovery();
|
||||
}
|
||||
}
|
||||
|
||||
setState({
|
||||
@@ -359,6 +403,10 @@ export function useDecryptedMessage({
|
||||
hasKeys,
|
||||
keysLoading,
|
||||
retryNonce,
|
||||
beginEncryptionRecovery,
|
||||
endEncryptionRecovery,
|
||||
retry,
|
||||
ensureKeysUploaded,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -40,6 +40,8 @@ interface UseEncryptionKeysReturn {
|
||||
initializeAndUploadKeys: () => Promise<{ wasRegenerated: boolean }>;
|
||||
/** Refresh the key state from storage */
|
||||
refresh: () => Promise<void>;
|
||||
/** Ensure local keys are uploaded to database. Blocks until complete. */
|
||||
ensureKeysUploaded: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
// Global promise to track in-flight key loading across all hook instances
|
||||
@@ -274,6 +276,11 @@ export function useEncryptionKeys(): UseEncryptionKeysReturn {
|
||||
|
||||
// Upload if remote key is missing/invalid or doesn't match local.
|
||||
// If we just regenerated the key, we MUST upload it regardless
|
||||
if (publicKey && localValid && !sameKey) {
|
||||
// Treat a mismatch as regeneration so we trigger key rotation later.
|
||||
wasRegenerated = true;
|
||||
}
|
||||
|
||||
if ((publicKey && localValid && !sameKey) || (publicKey && wasRegenerated)) {
|
||||
console.log('[E2EE Global] Uploading public key to profile...');
|
||||
|
||||
@@ -323,6 +330,75 @@ export function useEncryptionKeys(): UseEncryptionKeysReturn {
|
||||
await loadKeys();
|
||||
}, [loadKeys]);
|
||||
|
||||
/**
|
||||
* Ensure local keys are uploaded to database.
|
||||
* Blocks until upload is complete or fails.
|
||||
* This is critical for E2EE repair flows where the edge function needs
|
||||
* the current public key to re-wrap message AES keys.
|
||||
*/
|
||||
const ensureKeysUploaded = useCallback(async (): Promise<boolean> => {
|
||||
if (Platform.OS === 'web') {
|
||||
console.log('[E2EE] ensureKeysUploaded: Web platform, skipping');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!user?.id) {
|
||||
console.log('[E2EE] ensureKeysUploaded: No user ID, skipping');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if local key matches database key
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('public_key_jwk')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (profileError) {
|
||||
console.error('[E2EE] ensureKeysUploaded: Failed to fetch profile:', profileError);
|
||||
// Fall through to initializeAndUploadKeys which will handle this
|
||||
}
|
||||
|
||||
const remoteKey = (profile?.public_key_jwk ?? null) as RSAPublicKeyJWK | null;
|
||||
const localKey = encryptionKeys.publicKeyJwk;
|
||||
|
||||
if (!localKey) {
|
||||
// No local key, need to initialize
|
||||
console.log('[E2EE] ensureKeysUploaded: No local key, initializing...');
|
||||
await initializeAndUploadKeys();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Compare keys (sanitize both for accurate comparison)
|
||||
const sanitizedLocal = sanitizePublicKeyJwk(localKey);
|
||||
const sanitizedRemote = remoteKey ? sanitizePublicKeyJwk(remoteKey) : null;
|
||||
|
||||
const keysMatch = sanitizedRemote &&
|
||||
sanitizedLocal.n === sanitizedRemote.n &&
|
||||
sanitizedLocal.e === sanitizedRemote.e;
|
||||
|
||||
if (!keysMatch) {
|
||||
// Mismatch - upload local key
|
||||
console.log('[E2EE] ensureKeysUploaded: Key mismatch detected, uploading local key...');
|
||||
await initializeAndUploadKeys();
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('[E2EE] ensureKeysUploaded: Keys already synced');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[E2EE] ensureKeysUploaded: Error:', error);
|
||||
// Try to initialize anyway as a fallback
|
||||
try {
|
||||
await initializeAndUploadKeys();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}, [user?.id, encryptionKeys.publicKeyJwk, initializeAndUploadKeys]);
|
||||
|
||||
// Load keys on mount if not already loaded
|
||||
useEffect(() => {
|
||||
if (!hasLoadedRef.current && !isLoadingRef.current && !encryptionKeys.hasKeys) {
|
||||
@@ -345,5 +421,6 @@ export function useEncryptionKeys(): UseEncryptionKeysReturn {
|
||||
error: encryptionKeys.keysError,
|
||||
initializeAndUploadKeys,
|
||||
refresh,
|
||||
ensureKeysUploaded,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,12 @@ export async function initAnalytics() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip initialization in non-RN environments (e.g., during tsc/Node.js)
|
||||
// AsyncStorage requires window to be defined
|
||||
if (typeof window === "undefined" && typeof global !== "undefined" && !("__BUNDLE_START_TIME__" in global)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = process.env.EXPO_PUBLIC_POSTHOG_API_KEY;
|
||||
const host = process.env.EXPO_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com";
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export {
|
||||
} from './utils';
|
||||
|
||||
// Key Rotation
|
||||
export { triggerKeyRotation } from './triggerKeyRotation';
|
||||
export { triggerKeyRotation, triggerAutoKeyRotation } from './triggerKeyRotation';
|
||||
|
||||
// Stale Key Repair
|
||||
export { repairStaleKey } from './repairStaleKey';
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
* the user's new public key.
|
||||
*/
|
||||
|
||||
import { supabase } from '../supabase';
|
||||
import { supabase } from '../supabase';
|
||||
import { reencryptPendingMessages } from './reencryptPendingMessages';
|
||||
|
||||
|
||||
export interface KeyRotationResult {
|
||||
success: boolean;
|
||||
@@ -18,55 +20,83 @@ export interface KeyRotationResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let inFlight: Promise<KeyRotationResult | null> | null = null;
|
||||
let inFlight: Promise<KeyRotationResult | null> | null = null;
|
||||
let autoRotationInFlight: Promise<KeyRotationResult | null> | null = null;
|
||||
let lastAutoRotationAt = 0;
|
||||
|
||||
const AUTO_ROTATION_MIN_INTERVAL_MS = 60_000;
|
||||
|
||||
|
||||
export async function triggerKeyRotation(): Promise<KeyRotationResult | null> {
|
||||
// Prevent concurrent calls
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
|
||||
if (!baseUrl) {
|
||||
return { success: false, updated: 0, error: 'Missing EXPO_PUBLIC_SUPABASE_URL' };
|
||||
}
|
||||
|
||||
console.log('[E2EE] Triggering key rotation for regenerated keys...');
|
||||
|
||||
const response = await fetch(`${baseUrl}/functions/v1/handle-key-rotation`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error('[E2EE] Key rotation failed:', text);
|
||||
return { success: false, updated: 0, error: text || 'Failed to rotate keys' };
|
||||
}
|
||||
|
||||
const result = (await response.json()) as KeyRotationResult;
|
||||
console.log(`[E2EE] Key rotation complete: ${result.updated} messages updated`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('[E2EE] Key rotation error:', err);
|
||||
return {
|
||||
success: false,
|
||||
updated: 0,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
};
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return inFlight;
|
||||
}
|
||||
export async function triggerKeyRotation(): Promise<KeyRotationResult | null> {
|
||||
// Prevent concurrent calls
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
|
||||
if (!baseUrl) {
|
||||
return { success: false, updated: 0, error: 'Missing EXPO_PUBLIC_SUPABASE_URL' };
|
||||
}
|
||||
|
||||
console.log('[E2EE] Triggering key rotation for regenerated keys...');
|
||||
|
||||
const response = await fetch(`${baseUrl}/functions/v1/handle-key-rotation`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error('[E2EE] Key rotation failed:', text);
|
||||
return { success: false, updated: 0, error: text || 'Failed to rotate keys' };
|
||||
}
|
||||
|
||||
const result = (await response.json()) as KeyRotationResult;
|
||||
console.log(`[E2EE] Key rotation complete: ${result.updated} messages updated`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('[E2EE] Key rotation error:', err);
|
||||
return {
|
||||
success: false,
|
||||
updated: 0,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
};
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
export function triggerAutoKeyRotation(): Promise<KeyRotationResult | null> | null {
|
||||
const now = Date.now();
|
||||
|
||||
if (autoRotationInFlight) return autoRotationInFlight;
|
||||
if (now - lastAutoRotationAt < AUTO_ROTATION_MIN_INTERVAL_MS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
lastAutoRotationAt = now;
|
||||
|
||||
autoRotationInFlight = (async () => {
|
||||
console.log('[E2EE] Auto key rotation triggered by decryption failure.');
|
||||
const result = await triggerKeyRotation();
|
||||
await reencryptPendingMessages({ force: true });
|
||||
return result;
|
||||
})().finally(() => {
|
||||
autoRotationInFlight = null;
|
||||
});
|
||||
|
||||
return autoRotationInFlight;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
type IoniconsName = keyof typeof Ionicons.glyphMap;
|
||||
|
||||
/**
|
||||
* Maps emoji icons to Ionicons for a more professional, consistent appearance.
|
||||
* This eliminates the "AI slop" feel of heavy emoji usage while maintaining
|
||||
* visual differentiation between packs.
|
||||
*/
|
||||
export const PACK_ICON_MAP: Record<string, IoniconsName> = {
|
||||
// Career & Professional
|
||||
"💼": "briefcase-outline",
|
||||
|
||||
// Relationships & Connection
|
||||
"🔗": "heart-half-outline",
|
||||
"❤️": "heart-outline",
|
||||
"💫": "sparkles-outline",
|
||||
|
||||
// Secrets & Mystery
|
||||
"🤫": "eye-off-outline",
|
||||
"🗝️": "key-outline",
|
||||
|
||||
// Romance & Dates
|
||||
"🌹": "flower-outline",
|
||||
"🍷": "wine-outline",
|
||||
"✨": "sparkles-outline",
|
||||
|
||||
// Adventure & Travel
|
||||
"✈️": "airplane-outline",
|
||||
"🚗": "car-outline",
|
||||
"🏡": "home-outline",
|
||||
|
||||
// Playful & Games
|
||||
"🎭": "color-wand-outline",
|
||||
"🎲": "dice-outline",
|
||||
"🎁": "gift-outline",
|
||||
|
||||
// Intimacy & Physical
|
||||
"🔄": "sync-outline",
|
||||
"😈": "flash-outline",
|
||||
"🔥": "flame-outline",
|
||||
|
||||
// Goals & Planning
|
||||
"🎯": "flag-outline",
|
||||
"☀️": "sunny-outline",
|
||||
|
||||
// Default fallbacks
|
||||
"📦": "cube-outline",
|
||||
"📁": "folder-outline",
|
||||
};
|
||||
|
||||
export const DEFAULT_PACK_ICON: IoniconsName = "layers-outline";
|
||||
|
||||
/**
|
||||
* Converts an emoji icon string to an Ionicons name.
|
||||
* Falls back to DEFAULT_PACK_ICON if no mapping exists.
|
||||
*/
|
||||
export function getPackIconName(emoji: string | null | undefined): IoniconsName {
|
||||
if (!emoji) return DEFAULT_PACK_ICON;
|
||||
return PACK_ICON_MAP[emoji] || DEFAULT_PACK_ICON;
|
||||
}
|
||||
@@ -10,15 +10,23 @@ import type { RSAPublicKeyJWK, RSAPrivateKeyJWK } from "../lib/encryption/types"
|
||||
* Encryption key state - stored globally to ensure all components
|
||||
* share the same key state and avoid race conditions during encryption.
|
||||
*/
|
||||
interface EncryptionKeyState {
|
||||
privateKeyJwk: RSAPrivateKeyJWK | null;
|
||||
publicKeyJwk: RSAPublicKeyJWK | null;
|
||||
hasKeys: boolean;
|
||||
isLoadingKeys: boolean;
|
||||
keysError: Error | null;
|
||||
/** Incremented when keys change - consumers can use this to trigger re-renders */
|
||||
keysVersion: number;
|
||||
}
|
||||
type EncryptionRecoveryReason = 'stale-key' | 'pending-recipient' | 'rotation' | 'keys-unavailable';
|
||||
|
||||
interface EncryptionKeyState {
|
||||
privateKeyJwk: RSAPrivateKeyJWK | null;
|
||||
publicKeyJwk: RSAPublicKeyJWK | null;
|
||||
hasKeys: boolean;
|
||||
isLoadingKeys: boolean;
|
||||
keysError: Error | null;
|
||||
/** Incremented when keys change - consumers can use this to trigger re-renders */
|
||||
keysVersion: number;
|
||||
/** Background recovery status for E2EE issues */
|
||||
isRecovering: boolean;
|
||||
recoveryReason: EncryptionRecoveryReason | null;
|
||||
recoveryCount: number;
|
||||
recoveryUpdatedAt: number | null;
|
||||
}
|
||||
|
||||
|
||||
interface AuthState {
|
||||
user: Profile | null;
|
||||
@@ -37,9 +45,12 @@ interface AuthState {
|
||||
signOut: () => Promise<void>;
|
||||
setUser: (user: Profile | null) => void;
|
||||
|
||||
// Encryption key actions
|
||||
setEncryptionKeys: (keys: Partial<EncryptionKeyState>) => void;
|
||||
clearEncryptionKeys: () => void;
|
||||
// Encryption key actions
|
||||
setEncryptionKeys: (keys: Partial<EncryptionKeyState>) => void;
|
||||
clearEncryptionKeys: () => void;
|
||||
beginEncryptionRecovery: (reason: EncryptionRecoveryReason) => void;
|
||||
endEncryptionRecovery: () => void;
|
||||
|
||||
}
|
||||
|
||||
// Import other stores lazily to avoid circular dependency issues
|
||||
@@ -51,14 +62,19 @@ const getOtherStores = () => {
|
||||
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore };
|
||||
};
|
||||
|
||||
const initialEncryptionKeyState: EncryptionKeyState = {
|
||||
privateKeyJwk: null,
|
||||
publicKeyJwk: null,
|
||||
hasKeys: false,
|
||||
isLoadingKeys: true,
|
||||
keysError: null,
|
||||
keysVersion: 0,
|
||||
};
|
||||
const initialEncryptionKeyState: EncryptionKeyState = {
|
||||
privateKeyJwk: null,
|
||||
publicKeyJwk: null,
|
||||
hasKeys: false,
|
||||
isLoadingKeys: true,
|
||||
keysError: null,
|
||||
keysVersion: 0,
|
||||
isRecovering: false,
|
||||
recoveryReason: null,
|
||||
recoveryCount: 0,
|
||||
recoveryUpdatedAt: null,
|
||||
};
|
||||
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
@@ -235,23 +251,55 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
* Update encryption key state. This is the ONLY way to update keys
|
||||
* to ensure all consumers see the same state.
|
||||
*/
|
||||
setEncryptionKeys: (keys) => {
|
||||
set((state) => ({
|
||||
encryptionKeys: {
|
||||
...state.encryptionKeys,
|
||||
...keys,
|
||||
// Increment version when keys actually change
|
||||
keysVersion: (keys.publicKeyJwk !== undefined || keys.privateKeyJwk !== undefined)
|
||||
? state.encryptionKeys.keysVersion + 1
|
||||
: state.encryptionKeys.keysVersion,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear encryption keys (called on sign out or when leaving couple)
|
||||
*/
|
||||
clearEncryptionKeys: () => {
|
||||
set({ encryptionKeys: initialEncryptionKeyState });
|
||||
},
|
||||
}));
|
||||
setEncryptionKeys: (keys) => {
|
||||
set((state) => ({
|
||||
encryptionKeys: {
|
||||
...state.encryptionKeys,
|
||||
...keys,
|
||||
// Increment version when keys actually change
|
||||
keysVersion: (keys.publicKeyJwk !== undefined || keys.privateKeyJwk !== undefined)
|
||||
? state.encryptionKeys.keysVersion + 1
|
||||
: state.encryptionKeys.keysVersion,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
beginEncryptionRecovery: (reason) => {
|
||||
set((state) => {
|
||||
const nextCount = state.encryptionKeys.recoveryCount + 1;
|
||||
return {
|
||||
encryptionKeys: {
|
||||
...state.encryptionKeys,
|
||||
recoveryCount: nextCount,
|
||||
isRecovering: true,
|
||||
recoveryReason: reason,
|
||||
recoveryUpdatedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
endEncryptionRecovery: () => {
|
||||
set((state) => {
|
||||
const nextCount = Math.max(0, state.encryptionKeys.recoveryCount - 1);
|
||||
const isRecovering = nextCount > 0;
|
||||
return {
|
||||
encryptionKeys: {
|
||||
...state.encryptionKeys,
|
||||
recoveryCount: nextCount,
|
||||
isRecovering,
|
||||
recoveryReason: isRecovering ? state.encryptionKeys.recoveryReason : null,
|
||||
recoveryUpdatedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear encryption keys (called on sign out or when leaving couple)
|
||||
*/
|
||||
clearEncryptionKeys: () => {
|
||||
set({ encryptionKeys: initialEncryptionKeyState });
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in new issue
Block a user