From 43fa7033dcae98b0e065ba890ab9c5fba04ef907 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Thu, 8 Jan 2026 15:34:25 +0000 Subject: [PATCH] Add pack icon system and E2EE removal plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/mobile/app/(app)/packs/questions.tsx | 1371 +++++++++-------- apps/mobile/app/pack/[id].tsx | 10 +- apps/mobile/src/components/PackTeaser.tsx | 10 +- apps/mobile/src/features/chat/ChatScreen.tsx | 43 +- apps/mobile/src/hooks/useDecryptedMedia.ts | 182 ++- apps/mobile/src/hooks/useDecryptedMessage.ts | 244 +-- apps/mobile/src/hooks/useEncryptionKeys.ts | 77 + apps/mobile/src/lib/analytics.ts | 6 + apps/mobile/src/lib/encryption/index.ts | 2 +- .../src/lib/encryption/triggerKeyRotation.ts | 134 +- apps/mobile/src/lib/packIcons.ts | 61 + apps/mobile/src/store/authStore.ts | 128 +- docs/pack-icons-admin-first.md | 279 ++++ docs/remove-e2ee-plan.md | 427 +++++ 14 files changed, 2013 insertions(+), 961 deletions(-) create mode 100644 apps/mobile/src/lib/packIcons.ts create mode 100644 docs/pack-icons-admin-first.md create mode 100644 docs/remove-e2ee-plan.md diff --git a/apps/mobile/app/(app)/packs/questions.tsx b/apps/mobile/app/(app)/packs/questions.tsx index 7bc4ff3..ca96a79 100644 --- a/apps/mobile/app/(app)/packs/questions.tsx +++ b/apps/mobile/app/(app)/packs/questions.tsx @@ -1,25 +1,26 @@ -import { View, Text, StyleSheet, RefreshControl, Platform, Alert, Pressable } from "react-native"; -import { usePacksStore, useAuthStore, useSubscriptionStore } from "../../../src/store"; -import { Ionicons } from "@expo/vector-icons"; -import { router } from "expo-router"; -import { useMemo, useState, useCallback } from "react"; -import Animated, { - FadeIn, - FadeOut, - useAnimatedStyle, - useSharedValue, - withTiming, - useAnimatedScrollHandler, - interpolate, - Extrapolation, -} from "react-native-reanimated"; -import { BlurView } from "expo-blur"; -import { GradientBackground } from "../../../src/components/ui"; -import { Paywall } from "../../../src/components/paywall"; -import { PackTeaser } from "../../../src/components/PackTeaser"; -import { colors, spacing, radius, typography } from "../../../src/theme"; -import type { QuestionPack, Category } from "../../../src/types"; - +import { View, Text, StyleSheet, RefreshControl, Platform, Alert, Pressable } from "react-native"; +import { usePacksStore, useAuthStore, useSubscriptionStore } from "../../../src/store"; +import { Ionicons } from "@expo/vector-icons"; +import { router } from "expo-router"; +import { useMemo, useState, useCallback } from "react"; +import Animated, { + FadeIn, + FadeOut, + useAnimatedStyle, + useSharedValue, + withTiming, + useAnimatedScrollHandler, + interpolate, + Extrapolation, +} from "react-native-reanimated"; +import { BlurView } from "expo-blur"; +import { GradientBackground } from "../../../src/components/ui"; +import { Paywall } from "../../../src/components/paywall"; +import { PackTeaser } from "../../../src/components/PackTeaser"; +import { colors, spacing, radius, typography } from "../../../src/theme"; +import { getPackIconName } from "../../../src/lib/packIcons"; +import type { QuestionPack, Category } from "../../../src/types"; + const HEADER_MAX_HEIGHT = 180; const HEADER_MIN_HEIGHT = 100; const HEADER_SCROLL_DISTANCE = HEADER_MAX_HEIGHT - HEADER_MIN_HEIGHT; @@ -52,30 +53,34 @@ const getIntensityDisplay = (pack: QuestionPack) => { return { flameCount, label }; }; - -interface PackRowProps { - pack: QuestionPack; - isEnabled: boolean; - isPremiumLocked: boolean; - isExpanded: boolean; - onToggle: () => void; - onPress: () => void; - onInfoPress: () => void; -} - + +interface PackRowProps { + pack: QuestionPack; + isEnabled: boolean; + isPremiumLocked: boolean; + isExpanded: boolean; + onToggle: () => void; + onPress: () => void; + onInfoPress: () => void; +} + function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPress, onInfoPress }: PackRowProps) { const questionCount = pack.questions?.[0]?.count || 0; const intensityDisplay = getIntensityDisplay(pack); - - return ( - - - - {pack.icon || "📦"} - - - + + return ( + + + + + + + {pack.name} {pack.is_premium && ( @@ -108,581 +113,577 @@ function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPre - - - - - {isExpanded && ( - - {pack.description && ( - {pack.description} - )} - - - - Preview - - - - {isPremiumLocked ? ( - <> - - - Unlock - - - ) : isEnabled ? ( - <> - - - Enabled - - - ) : ( - <> - - Enable - - )} - - - - )} - - ); -} - -interface CategorySectionProps { - category: Category; - packs: QuestionPack[]; - enabledPackIds: string[]; - hasPremiumAccess: boolean; - expandedPackId: string | null; - onPackPress: (packId: string) => void; - onPackToggle: (pack: QuestionPack) => void; - onPackInfo: (pack: QuestionPack) => void; -} - -function CategorySection({ - category, - packs, - enabledPackIds, - hasPremiumAccess, - expandedPackId, - onPackPress, - onPackToggle, - onPackInfo, -}: CategorySectionProps) { - const [isCollapsed, setIsCollapsed] = useState(false); - const enabledCount = packs.filter(p => enabledPackIds.includes(p.id)).length; - - const rotateAnim = useSharedValue(0); - - const chevronStyle = useAnimatedStyle(() => ({ - transform: [{ rotate: `${rotateAnim.value}deg` }], - })); - - const handleToggleCollapse = () => { - rotateAnim.value = withTiming(isCollapsed ? 0 : -90, { duration: 200 }); - setIsCollapsed(!isCollapsed); - }; - - return ( - - - - {category.icon && ( - {category.icon} - )} - {category.name} - {enabledCount > 0 && ( - - {enabledCount} - - )} - - - - - - - {!isCollapsed && ( - - {packs.map((pack) => ( - onPackPress(pack.id)} - onToggle={() => onPackToggle(pack)} - onInfoPress={() => onPackInfo(pack)} - /> - ))} - - )} - - ); -} - -export default function QuestionPacksScreen() { - const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore(); - const { user, partner } = useAuthStore(); - const { subscription } = useSubscriptionStore(); - - const [expandedPackId, setExpandedPackId] = useState(null); - const [showPaywall, setShowPaywall] = useState(false); - const [showTeaser, setShowTeaser] = useState(false); - const [teaserPack, setTeaserPack] = useState(null); - - const scrollY = useSharedValue(0); - - const scrollHandler = useAnimatedScrollHandler({ - onScroll: (event) => { - scrollY.value = event.contentOffset.y; - }, - }); - - const hasPremiumAccess = useMemo(() => { - return user?.is_premium || partner?.is_premium || subscription.isProUser; - }, [user?.is_premium, partner?.is_premium, subscription.isProUser]); - - const packsByCategory = useMemo(() => { - const grouped = new Map(); - categories.forEach(cat => grouped.set(cat.id, [])); - grouped.set("uncategorized", []); - packs.forEach(pack => { - const catId = pack.category_id || "uncategorized"; - const existing = grouped.get(catId) || []; - existing.push(pack); - grouped.set(catId, existing); - }); - return grouped; - }, [packs, categories]); - - const handlePackPress = useCallback((packId: string) => { - setExpandedPackId(current => current === packId ? null : packId); - }, []); - - const handlePackToggle = useCallback(async (pack: QuestionPack) => { - if (pack.is_premium && !hasPremiumAccess) { - setTeaserPack(pack); - setShowTeaser(true); - return; - } - const result = await togglePack(pack.id); - if (!result.success && result.reason === "no_couple") { - Alert.alert( - "Pair with Partner", - "You need to pair with your partner before you can customise question packs.", - [ - { text: "Later", style: "cancel" }, - { text: "Pair Now", onPress: () => router.push("/pairing") } - ] - ); - } - }, [hasPremiumAccess, togglePack]); - - const handlePackInfo = useCallback((pack: QuestionPack) => { - const isPremiumLocked = pack.is_premium && !hasPremiumAccess; - if (isPremiumLocked) { - setTeaserPack(pack); - setShowTeaser(true); - } else { - router.push(`/pack/${pack.id}`); - } - }, [hasPremiumAccess]); - - const handleTeaserUnlock = useCallback(() => { - setShowTeaser(false); - setShowPaywall(true); - }, []); - - const handleTeaserClose = useCallback(() => { - setShowTeaser(false); - setTeaserPack(null); - }, []); - - const enabledPackCount = enabledPackIds.length; - const totalQuestions = packs - .filter(p => enabledPackIds.includes(p.id)) - .reduce((sum, p) => sum + (p.questions?.[0]?.count || 0), 0); - - // Animated styles for collapsing header - const largeHeaderStyle = useAnimatedStyle(() => { - const opacity = interpolate( - scrollY.value, - [0, HEADER_SCROLL_DISTANCE * 0.5], - [1, 0], - Extrapolation.CLAMP - ); - const translateY = interpolate( - scrollY.value, - [0, HEADER_SCROLL_DISTANCE], - [0, -20], - Extrapolation.CLAMP - ); - return { opacity, transform: [{ translateY }] }; - }); - - const compactHeaderStyle = useAnimatedStyle(() => { - const opacity = interpolate( - scrollY.value, - [HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE], - [0, 1], - Extrapolation.CLAMP - ); - return { opacity }; - }); - - const navBarBackgroundStyle = useAnimatedStyle(() => { - const opacity = interpolate( - scrollY.value, - [0, HEADER_SCROLL_DISTANCE * 0.8], - [0, 1], - Extrapolation.CLAMP - ); - return { opacity }; - }); - - return ( - - - {/* Fixed Nav Bar */} - - {/* Nav bar background with blur */} - - {Platform.OS === "ios" ? ( - - ) : ( - - )} - - - {/* Back button */} - router.back()} - hitSlop={8} - > - - - - {/* Compact title (fades in on scroll) */} - - Question Packs - - - {/* Spacer for alignment */} - - - - {/* Scrollable Content */} - - } - contentContainerStyle={styles.scrollContent} - > - {/* Large Header (fades out on scroll) */} - - COLLECTION - Question Packs - - - - - - - - - - - {totalQuestions} {totalQuestions === 1 ? "question" : "questions"} from {enabledPackCount} {enabledPackCount === 1 ? "pack" : "packs"} - - - - - {/* Categories */} - - {categories.map(category => { - const categoryPacks = packsByCategory.get(category.id) || []; - if (categoryPacks.length === 0) return null; - - return ( - - ); - })} - - {(packsByCategory.get("uncategorized")?.length || 0) > 0 && ( - - )} - - - - {/* Modals */} - {teaserPack && ( - - )} - - setShowPaywall(false)} - onSuccess={() => fetchPacks()} - /> - - - ); -} - -const NAV_BAR_HEIGHT = 44; -const STATUS_BAR_HEIGHT = 60; // Match other pages - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - - // Fixed Nav Bar - navBar: { - position: "absolute", - top: 0, - left: 0, - right: 0, - height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT, - flexDirection: "row", - alignItems: "center", - paddingTop: STATUS_BAR_HEIGHT - 10, - paddingHorizontal: spacing.md, - zIndex: 100, - }, - navBarBackground: { - ...StyleSheet.absoluteFillObject, - borderBottomWidth: 1, - borderBottomColor: "rgba(255, 255, 255, 0.1)", - overflow: "hidden", - }, - backButton: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: "rgba(255, 255, 255, 0.1)", - justifyContent: "center", - alignItems: "center", - }, - navBarTitle: { - flex: 1, - ...typography.headline, - color: colors.text, - textAlign: "center", - marginRight: 40, // Balance with back button - }, - navBarSpacer: { - width: 0, - }, - - // Large Header - largeHeader: { - alignItems: "center", - paddingBottom: spacing.lg, - }, - label: { - ...typography.caption1, - fontWeight: "600", - letterSpacing: 3, - color: colors.primary, - textAlign: "center", - marginBottom: spacing.xs, - }, - title: { - ...typography.title1, - color: colors.text, - textAlign: "center", - }, - separator: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - marginVertical: spacing.md, - width: 140, - }, - separatorLine: { - flex: 1, - height: 1, - backgroundColor: "rgba(233, 69, 96, 0.3)", - }, - separatorDiamond: { - width: 6, - height: 6, - backgroundColor: colors.primary, - transform: [{ rotate: "45deg" }], - marginHorizontal: spacing.md, - opacity: 0.6, - }, - statsBadge: { - flexDirection: "row", - alignItems: "center", - backgroundColor: "rgba(233, 69, 96, 0.1)", - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - borderRadius: radius.full, - borderWidth: 1, - borderColor: "rgba(233, 69, 96, 0.2)", - gap: spacing.sm, - }, - statsBadgeText: { - ...typography.caption1, - color: colors.textSecondary, - fontWeight: "500", - }, - - // Scroll Content - scrollContent: { - paddingTop: 60, // Match other pages - paddingBottom: Platform.OS === "ios" ? 120 : 100, - }, - categoriesContainer: { - paddingHorizontal: spacing.lg, - }, - - // Category Section - categorySection: { - marginBottom: spacing.lg, - }, - categoryHeader: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingVertical: spacing.sm, - marginBottom: spacing.xs, - }, - categoryHeaderLeft: { - flexDirection: "row", - alignItems: "center", - gap: spacing.sm, - }, - categoryIcon: { - fontSize: 18, - }, - categoryName: { - ...typography.headline, - color: colors.text, - textTransform: "uppercase", - letterSpacing: 1, - fontSize: 13, - }, - categoryBadge: { - backgroundColor: colors.primary, - paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 10, - minWidth: 20, - alignItems: "center", - }, - categoryBadgeText: { - ...typography.caption2, - color: colors.text, - fontWeight: "600", - }, - packsList: { - backgroundColor: "rgba(255, 255, 255, 0.03)", - borderRadius: radius.lg, - borderWidth: 1, - borderColor: colors.glass.border, - overflow: "hidden", - }, - - // Pack Row - packRow: { - borderBottomWidth: 1, - borderBottomColor: colors.glass.border, - }, - packRowMain: { - flexDirection: "row", - alignItems: "center", - padding: spacing.md, - gap: spacing.md, - }, - packIcon: { - width: 44, - height: 44, - borderRadius: 12, - backgroundColor: "rgba(255, 255, 255, 0.05)", - justifyContent: "center", - alignItems: "center", - borderWidth: 1, - borderColor: "rgba(255, 255, 255, 0.08)", - }, - packIconEnabled: { - backgroundColor: "rgba(233, 69, 96, 0.1)", - borderColor: "rgba(233, 69, 96, 0.2)", - }, - packIconText: { - fontSize: 20, - }, - packContent: { - flex: 1, - }, - packTitleRow: { - flexDirection: "row", - alignItems: "center", - gap: spacing.xs, - }, + + + + + {isExpanded && ( + + {pack.description && ( + {pack.description} + )} + + + + Preview + + + + {isPremiumLocked ? ( + <> + + + Unlock + + + ) : isEnabled ? ( + <> + + + Enabled + + + ) : ( + <> + + Enable + + )} + + + + )} + + ); +} + +interface CategorySectionProps { + category: Category; + packs: QuestionPack[]; + enabledPackIds: string[]; + hasPremiumAccess: boolean; + expandedPackId: string | null; + onPackPress: (packId: string) => void; + onPackToggle: (pack: QuestionPack) => void; + onPackInfo: (pack: QuestionPack) => void; +} + +function CategorySection({ + category, + packs, + enabledPackIds, + hasPremiumAccess, + expandedPackId, + onPackPress, + onPackToggle, + onPackInfo, +}: CategorySectionProps) { + const [isCollapsed, setIsCollapsed] = useState(false); + const enabledCount = packs.filter(p => enabledPackIds.includes(p.id)).length; + + const rotateAnim = useSharedValue(0); + + const chevronStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotateAnim.value}deg` }], + })); + + const handleToggleCollapse = () => { + rotateAnim.value = withTiming(isCollapsed ? 0 : -90, { duration: 200 }); + setIsCollapsed(!isCollapsed); + }; + + return ( + + + + + {category.name} + {enabledCount > 0 && ( + + {enabledCount} + + )} + + + + + + + {!isCollapsed && ( + + {packs.map((pack) => ( + onPackPress(pack.id)} + onToggle={() => onPackToggle(pack)} + onInfoPress={() => onPackInfo(pack)} + /> + ))} + + )} + + ); +} + +export default function QuestionPacksScreen() { + const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore(); + const { user, partner } = useAuthStore(); + const { subscription } = useSubscriptionStore(); + + const [expandedPackId, setExpandedPackId] = useState(null); + const [showPaywall, setShowPaywall] = useState(false); + const [showTeaser, setShowTeaser] = useState(false); + const [teaserPack, setTeaserPack] = useState(null); + + const scrollY = useSharedValue(0); + + const scrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + scrollY.value = event.contentOffset.y; + }, + }); + + const hasPremiumAccess = useMemo(() => { + return user?.is_premium || partner?.is_premium || subscription.isProUser; + }, [user?.is_premium, partner?.is_premium, subscription.isProUser]); + + const packsByCategory = useMemo(() => { + const grouped = new Map(); + categories.forEach(cat => grouped.set(cat.id, [])); + grouped.set("uncategorized", []); + packs.forEach(pack => { + const catId = pack.category_id || "uncategorized"; + const existing = grouped.get(catId) || []; + existing.push(pack); + grouped.set(catId, existing); + }); + return grouped; + }, [packs, categories]); + + const handlePackPress = useCallback((packId: string) => { + setExpandedPackId(current => current === packId ? null : packId); + }, []); + + const handlePackToggle = useCallback(async (pack: QuestionPack) => { + if (pack.is_premium && !hasPremiumAccess) { + setTeaserPack(pack); + setShowTeaser(true); + return; + } + const result = await togglePack(pack.id); + if (!result.success && result.reason === "no_couple") { + Alert.alert( + "Pair with Partner", + "You need to pair with your partner before you can customise question packs.", + [ + { text: "Later", style: "cancel" }, + { text: "Pair Now", onPress: () => router.push("/pairing") } + ] + ); + } + }, [hasPremiumAccess, togglePack]); + + const handlePackInfo = useCallback((pack: QuestionPack) => { + const isPremiumLocked = pack.is_premium && !hasPremiumAccess; + if (isPremiumLocked) { + setTeaserPack(pack); + setShowTeaser(true); + } else { + router.push(`/pack/${pack.id}`); + } + }, [hasPremiumAccess]); + + const handleTeaserUnlock = useCallback(() => { + setShowTeaser(false); + setShowPaywall(true); + }, []); + + const handleTeaserClose = useCallback(() => { + setShowTeaser(false); + setTeaserPack(null); + }, []); + + const enabledPackCount = enabledPackIds.length; + const totalQuestions = packs + .filter(p => enabledPackIds.includes(p.id)) + .reduce((sum, p) => sum + (p.questions?.[0]?.count || 0), 0); + + // Animated styles for collapsing header + const largeHeaderStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollY.value, + [0, HEADER_SCROLL_DISTANCE * 0.5], + [1, 0], + Extrapolation.CLAMP + ); + const translateY = interpolate( + scrollY.value, + [0, HEADER_SCROLL_DISTANCE], + [0, -20], + Extrapolation.CLAMP + ); + return { opacity, transform: [{ translateY }] }; + }); + + const compactHeaderStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollY.value, + [HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE], + [0, 1], + Extrapolation.CLAMP + ); + return { opacity }; + }); + + const navBarBackgroundStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollY.value, + [0, HEADER_SCROLL_DISTANCE * 0.8], + [0, 1], + Extrapolation.CLAMP + ); + return { opacity }; + }); + + return ( + + + {/* Fixed Nav Bar */} + + {/* Nav bar background with blur */} + + {Platform.OS === "ios" ? ( + + ) : ( + + )} + + + {/* Back button */} + router.back()} + hitSlop={8} + > + + + + {/* Compact title (fades in on scroll) */} + + Question Packs + + + {/* Spacer for alignment */} + + + + {/* Scrollable Content */} + + } + contentContainerStyle={styles.scrollContent} + > + {/* Large Header (fades out on scroll) */} + + COLLECTION + Question Packs + + + + + + + + + + + {totalQuestions} {totalQuestions === 1 ? "question" : "questions"} from {enabledPackCount} {enabledPackCount === 1 ? "pack" : "packs"} + + + + + {/* Categories */} + + {categories.map(category => { + const categoryPacks = packsByCategory.get(category.id) || []; + if (categoryPacks.length === 0) return null; + + return ( + + ); + })} + + {(packsByCategory.get("uncategorized")?.length || 0) > 0 && ( + + )} + + + + {/* Modals */} + {teaserPack && ( + + )} + + setShowPaywall(false)} + onSuccess={() => fetchPacks()} + /> + + + ); +} + +const NAV_BAR_HEIGHT = 44; +const STATUS_BAR_HEIGHT = 60; // Match other pages + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + + // Fixed Nav Bar + navBar: { + position: "absolute", + top: 0, + left: 0, + right: 0, + height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT, + flexDirection: "row", + alignItems: "center", + paddingTop: STATUS_BAR_HEIGHT - 10, + paddingHorizontal: spacing.md, + zIndex: 100, + }, + navBarBackground: { + ...StyleSheet.absoluteFillObject, + borderBottomWidth: 1, + borderBottomColor: "rgba(255, 255, 255, 0.1)", + overflow: "hidden", + }, + backButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: "rgba(255, 255, 255, 0.1)", + justifyContent: "center", + alignItems: "center", + }, + navBarTitle: { + flex: 1, + ...typography.headline, + color: colors.text, + textAlign: "center", + marginRight: 40, // Balance with back button + }, + navBarSpacer: { + width: 0, + }, + + // Large Header + largeHeader: { + alignItems: "center", + paddingBottom: spacing.lg, + }, + label: { + ...typography.caption1, + fontWeight: "600", + letterSpacing: 3, + color: colors.primary, + textAlign: "center", + marginBottom: spacing.xs, + }, + title: { + ...typography.title1, + color: colors.text, + textAlign: "center", + }, + separator: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + marginVertical: spacing.md, + width: 140, + }, + separatorLine: { + flex: 1, + height: 1, + backgroundColor: "rgba(233, 69, 96, 0.3)", + }, + separatorDiamond: { + width: 6, + height: 6, + backgroundColor: colors.primary, + transform: [{ rotate: "45deg" }], + marginHorizontal: spacing.md, + opacity: 0.6, + }, + statsBadge: { + flexDirection: "row", + alignItems: "center", + backgroundColor: "rgba(233, 69, 96, 0.1)", + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.full, + borderWidth: 1, + borderColor: "rgba(233, 69, 96, 0.2)", + gap: spacing.sm, + }, + statsBadgeText: { + ...typography.caption1, + color: colors.textSecondary, + fontWeight: "500", + }, + + // Scroll Content + scrollContent: { + paddingTop: 60, // Match other pages + paddingBottom: Platform.OS === "ios" ? 120 : 100, + }, + categoriesContainer: { + paddingHorizontal: spacing.lg, + }, + + // Category Section + categorySection: { + marginBottom: spacing.lg, + }, + categoryHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: spacing.sm, + marginBottom: spacing.xs, + }, + categoryHeaderLeft: { + flexDirection: "row", + alignItems: "center", + gap: spacing.sm, + }, + categoryName: { + ...typography.headline, + color: colors.text, + textTransform: "uppercase", + letterSpacing: 1, + fontSize: 13, + }, + categoryBadge: { + backgroundColor: colors.primary, + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 10, + minWidth: 20, + alignItems: "center", + }, + categoryBadgeText: { + ...typography.caption2, + color: colors.text, + fontWeight: "600", + }, + packsList: { + backgroundColor: "rgba(255, 255, 255, 0.03)", + borderRadius: radius.lg, + borderWidth: 1, + borderColor: colors.glass.border, + overflow: "hidden", + }, + + // Pack Row + packRow: { + borderBottomWidth: 1, + borderBottomColor: colors.glass.border, + }, + packRowMain: { + flexDirection: "row", + alignItems: "center", + padding: spacing.md, + gap: spacing.md, + }, + packIcon: { + width: 44, + height: 44, + borderRadius: 12, + backgroundColor: "rgba(255, 255, 255, 0.05)", + justifyContent: "center", + alignItems: "center", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.08)", + }, + packIconEnabled: { + backgroundColor: "rgba(233, 69, 96, 0.1)", + borderColor: "rgba(233, 69, 96, 0.2)", + }, + packContent: { + flex: 1, + }, + packTitleRow: { + flexDirection: "row", + alignItems: "center", + gap: spacing.xs, + }, packName: { ...typography.body, color: colors.text, @@ -710,11 +711,11 @@ const styles = StyleSheet.create({ fontWeight: "700", letterSpacing: 0.6, }, - packMeta: { - ...typography.caption1, - color: colors.textTertiary, - marginTop: 2, - }, + packMeta: { + ...typography.caption1, + color: colors.textTertiary, + marginTop: 2, + }, packMetaEnabled: { color: colors.primary, }, @@ -735,66 +736,66 @@ const styles = StyleSheet.create({ }, // Expanded Pack - packExpanded: { - paddingHorizontal: spacing.md, - paddingBottom: spacing.md, - paddingTop: 0, - }, - packDescription: { - ...typography.subhead, - color: colors.textSecondary, - lineHeight: 20, - marginBottom: spacing.md, - }, - packActions: { - flexDirection: "row", - gap: spacing.sm, - }, - packInfoButton: { - flexDirection: "row", - alignItems: "center", - gap: spacing.xs, - paddingVertical: spacing.sm, - paddingHorizontal: spacing.md, - borderRadius: radius.sm, - backgroundColor: "rgba(255, 255, 255, 0.05)", - borderWidth: 1, - borderColor: colors.glass.border, - }, - packInfoButtonText: { - ...typography.subhead, - color: colors.textSecondary, - }, - packToggleButton: { - flex: 1, - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - gap: spacing.xs, - paddingVertical: spacing.sm, - paddingHorizontal: spacing.md, - borderRadius: radius.sm, - backgroundColor: "rgba(255, 255, 255, 0.08)", - borderWidth: 1, - borderColor: "rgba(255, 255, 255, 0.12)", - }, - packToggleButtonEnabled: { - backgroundColor: "rgba(233, 69, 96, 0.1)", - borderColor: "rgba(233, 69, 96, 0.3)", - }, - packToggleButtonLocked: { - backgroundColor: "rgba(212, 175, 55, 0.1)", - borderColor: "rgba(212, 175, 55, 0.3)", - }, - packToggleButtonText: { - ...typography.subhead, - color: colors.text, - fontWeight: "500", - }, - packToggleButtonTextEnabled: { - color: colors.primary, - }, - packToggleButtonTextLocked: { - color: colors.premium.gold, - }, -}); + packExpanded: { + paddingHorizontal: spacing.md, + paddingBottom: spacing.md, + paddingTop: 0, + }, + packDescription: { + ...typography.subhead, + color: colors.textSecondary, + lineHeight: 20, + marginBottom: spacing.md, + }, + packActions: { + flexDirection: "row", + gap: spacing.sm, + }, + packInfoButton: { + flexDirection: "row", + alignItems: "center", + gap: spacing.xs, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: radius.sm, + backgroundColor: "rgba(255, 255, 255, 0.05)", + borderWidth: 1, + borderColor: colors.glass.border, + }, + packInfoButtonText: { + ...typography.subhead, + color: colors.textSecondary, + }, + packToggleButton: { + flex: 1, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: spacing.xs, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: radius.sm, + backgroundColor: "rgba(255, 255, 255, 0.08)", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.12)", + }, + packToggleButtonEnabled: { + backgroundColor: "rgba(233, 69, 96, 0.1)", + borderColor: "rgba(233, 69, 96, 0.3)", + }, + packToggleButtonLocked: { + backgroundColor: "rgba(212, 175, 55, 0.1)", + borderColor: "rgba(212, 175, 55, 0.3)", + }, + packToggleButtonText: { + ...typography.subhead, + color: colors.text, + fontWeight: "500", + }, + packToggleButtonTextEnabled: { + color: colors.primary, + }, + packToggleButtonTextLocked: { + color: colors.premium.gold, + }, +}); diff --git a/apps/mobile/app/pack/[id].tsx b/apps/mobile/app/pack/[id].tsx index 857dca0..1205f7f 100644 --- a/apps/mobile/app/pack/[id].tsx +++ b/apps/mobile/app/pack/[id].tsx @@ -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} /> )} - {pack?.icon || "📦"} + {/* 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, diff --git a/apps/mobile/src/components/PackTeaser.tsx b/apps/mobile/src/components/PackTeaser.tsx index 0c173ae..95017d9 100644 --- a/apps/mobile/src/components/PackTeaser.tsx +++ b/apps/mobile/src/components/PackTeaser.tsx @@ -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} /> - {packIcon || "📦"} + {/* Crown badge */} @@ -370,9 +375,6 @@ const styles = StyleSheet.create({ shadowRadius: 4, elevation: 4, }, - packEmoji: { - fontSize: 40, - }, title: { ...typography.title1, color: colors.text, diff --git a/apps/mobile/src/features/chat/ChatScreen.tsx b/apps/mobile/src/features/chat/ChatScreen.tsx index 27c52d4..d21e7e0 100644 --- a/apps/mobile/src/features/chat/ChatScreen.tsx +++ b/apps/mobile/src/features/chat/ChatScreen.tsx @@ -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(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 && ( + + + {recoveryMessage} + + + )} + s.beginEncryptionRecovery); + const endEncryptionRecovery = useAuthStore((s) => s.endEncryptionRecovery); const isMounted = useRef(true); const repairAttemptedByMessageId = useRef(new Set()); @@ -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(() => { diff --git a/apps/mobile/src/hooks/useDecryptedMessage.ts b/apps/mobile/src/hooks/useDecryptedMessage.ts index bd95627..2f61924 100644 --- a/apps/mobile/src/hooks/useDecryptedMessage.ts +++ b/apps/mobile/src/hooks/useDecryptedMessage.ts @@ -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()); @@ -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(() => { diff --git a/apps/mobile/src/hooks/useEncryptionKeys.ts b/apps/mobile/src/hooks/useEncryptionKeys.ts index b76d48f..c0b6360 100644 --- a/apps/mobile/src/hooks/useEncryptionKeys.ts +++ b/apps/mobile/src/hooks/useEncryptionKeys.ts @@ -40,6 +40,8 @@ interface UseEncryptionKeysReturn { initializeAndUploadKeys: () => Promise<{ wasRegenerated: boolean }>; /** Refresh the key state from storage */ refresh: () => Promise; + /** Ensure local keys are uploaded to database. Blocks until complete. */ + ensureKeysUploaded: () => Promise; } // 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 => { + 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, }; } diff --git a/apps/mobile/src/lib/analytics.ts b/apps/mobile/src/lib/analytics.ts index d63f25a..9746128 100644 --- a/apps/mobile/src/lib/analytics.ts +++ b/apps/mobile/src/lib/analytics.ts @@ -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"; diff --git a/apps/mobile/src/lib/encryption/index.ts b/apps/mobile/src/lib/encryption/index.ts index f1bc6c0..9f0ff63 100644 --- a/apps/mobile/src/lib/encryption/index.ts +++ b/apps/mobile/src/lib/encryption/index.ts @@ -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'; diff --git a/apps/mobile/src/lib/encryption/triggerKeyRotation.ts b/apps/mobile/src/lib/encryption/triggerKeyRotation.ts index 8f6c0fb..e0bce94 100644 --- a/apps/mobile/src/lib/encryption/triggerKeyRotation.ts +++ b/apps/mobile/src/lib/encryption/triggerKeyRotation.ts @@ -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 | null = null; +let inFlight: Promise | null = null; +let autoRotationInFlight: Promise | null = null; +let lastAutoRotationAt = 0; + +const AUTO_ROTATION_MIN_INTERVAL_MS = 60_000; + -export async function triggerKeyRotation(): Promise { - // 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 { + // 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 | 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; +} + diff --git a/apps/mobile/src/lib/packIcons.ts b/apps/mobile/src/lib/packIcons.ts new file mode 100644 index 0000000..2bc541a --- /dev/null +++ b/apps/mobile/src/lib/packIcons.ts @@ -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 = { + // 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; +} diff --git a/apps/mobile/src/store/authStore.ts b/apps/mobile/src/store/authStore.ts index 716377c..4e35971 100644 --- a/apps/mobile/src/store/authStore.ts +++ b/apps/mobile/src/store/authStore.ts @@ -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; setUser: (user: Profile | null) => void; - // Encryption key actions - setEncryptionKeys: (keys: Partial) => void; - clearEncryptionKeys: () => void; + // Encryption key actions + setEncryptionKeys: (keys: Partial) => 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((set, get) => ({ user: null, @@ -235,23 +251,55 @@ export const useAuthStore = create((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 }); + }, +})); + diff --git a/docs/pack-icons-admin-first.md b/docs/pack-icons-admin-first.md new file mode 100644 index 0000000..46d105c --- /dev/null +++ b/docs/pack-icons-admin-first.md @@ -0,0 +1,279 @@ +# Pack Icons: Admin-First Approach + +## Problem Statement + +The current pack icon system uses emojis stored in the database, which creates a "AI slop" appearance. We've added a frontend mapping from emojis to Ionicons, but this is fragile: + +- New packs require manual code changes to add mappings +- The mapping is hidden from admins +- It's a maintenance burden + +## Solution: Admin-First Icon Selection + +Allow admins to select Ionicons directly in the admin UI, storing the Ionicon name (e.g., `"briefcase-outline"`) in the database instead of emojis. + +## Backwards Compatibility + +**Important**: We must maintain backwards compatibility with the app currently in App Store review. + +| App Version | DB has emoji "💼" | DB has "briefcase-outline" | +|-------------|-------------------|---------------------------| +| Old (in review) | Shows emoji | Shows literal text (broken) | +| New (with mapping) | Maps to Ionicon | Uses directly | + +### Strategy + +1. **Don't migrate existing data** - existing packs keep their emojis +2. **New packs use Ionicon names** - admin UI stores Ionicon names +3. **Mobile handles both formats** - detect format and handle appropriately + +This ensures: +- Old app continues working with existing emoji packs +- New packs show default icon on old app (acceptable degradation) +- New app handles everything correctly + +--- + +## Implementation Tasks + +### Task 1: Create Ionicon Picker Component (Admin) + +**File**: `apps/admin/src/components/ui/icon-picker.tsx` (new file) + +Create a new component that displays a grid of available Ionicons for selection. + +**Requirements**: +- Display commonly used Ionicons in a grid/dropdown +- Show icon preview with name +- Search/filter functionality (optional) +- Return the Ionicon name string (e.g., `"briefcase-outline"`) + +**Suggested Icons to Include**: +```typescript +const AVAILABLE_ICONS = [ + // Relationships & Love + 'heart-outline', 'heart-half-outline', 'heart-circle-outline', + + // Communication + 'chatbubbles-outline', 'chatbox-outline', 'mail-outline', + + // Romance & Dates + 'flower-outline', 'wine-outline', 'restaurant-outline', 'cafe-outline', + + // Adventure & Travel + 'airplane-outline', 'car-outline', 'compass-outline', 'map-outline', + + // Home & Family + 'home-outline', 'people-outline', 'person-outline', + + // Mystery & Secrets + 'eye-off-outline', 'key-outline', 'lock-closed-outline', + + // Fun & Games + 'dice-outline', 'gift-outline', 'sparkles-outline', 'star-outline', + + // Intimacy + 'flame-outline', 'flash-outline', 'moon-outline', 'sunny-outline', + + // Goals & Planning + 'flag-outline', 'calendar-outline', 'checkbox-outline', 'trophy-outline', + + // General + 'layers-outline', 'cube-outline', 'folder-outline', 'bookmark-outline', + 'bulb-outline', 'color-wand-outline', 'sync-outline', 'refresh-outline', +]; +``` + +**Component Interface**: +```typescript +interface IconPickerProps { + value: string; + onChange: (iconName: string) => void; +} +``` + +--- + +### Task 2: Update Pack Form Dialog (Admin) + +**File**: `apps/admin/src/components/content/PackFormDialog.tsx` + +**Changes**: +1. Import new `IconPicker` component +2. Replace `EmojiPicker` with `IconPicker` for the icon field + +**Current code (line ~101-104)**: +```tsx + setField('icon', emoji)} +/> +``` + +**New code**: +```tsx + setField('icon', iconName)} +/> +``` + +3. Update default icon in `initialFormData` (line ~41): +```typescript +// Change from '💕' to: +icon: 'heart-outline', +``` + +--- + +### Task 3: Update Category Form (Admin) + +**File**: `apps/admin/src/pages/content/CategoriesPage.tsx` + +Same changes as Task 2 - replace `EmojiPicker` with `IconPicker` for category icons. + +--- + +### Task 4: Update AI Pack Generator (Admin) + +**File**: `apps/admin/src/lib/ai/generators/packs.ts` + +Update the AI prompt to suggest Ionicon names instead of emojis. + +**Current (line ~147)**: +```typescript +- icon: A single descriptive emoji +``` + +**New**: +```typescript +- icon: An Ionicon name from this list: heart-outline, flame-outline, sparkles-outline, gift-outline, wine-outline, airplane-outline, home-outline, key-outline, flash-outline, sunny-outline, flower-outline, star-outline, dice-outline, compass-outline, bulb-outline +``` + +--- + +### Task 5: Update Mobile Icon Utility + +**File**: `apps/mobile/src/lib/packIcons.ts` + +Update `getPackIconName()` to handle both Ionicon names and legacy emojis. + +**New implementation**: +```typescript +import type { Ionicons } from "@expo/vector-icons"; + +type IoniconsName = keyof typeof Ionicons.glyphMap; + +// Legacy emoji mappings (for backwards compatibility with existing packs) +const EMOJI_TO_IONICON: Record = { + "💼": "briefcase-outline", + "🔗": "heart-half-outline", + "❤️": "heart-outline", + "💫": "sparkles-outline", + "🤫": "eye-off-outline", + "🗝️": "key-outline", + "🌹": "flower-outline", + "🍷": "wine-outline", + "✨": "sparkles-outline", + "✈️": "airplane-outline", + "🚗": "car-outline", + "🏡": "home-outline", + "🎭": "color-wand-outline", + "🎲": "dice-outline", + "🎁": "gift-outline", + "🔄": "sync-outline", + "😈": "flash-outline", + "🔥": "flame-outline", + "🎯": "flag-outline", + "☀️": "sunny-outline", + "📦": "cube-outline", + "📁": "folder-outline", +}; + +export const DEFAULT_PACK_ICON: IoniconsName = "layers-outline"; + +// List of valid Ionicon names (subset we support) +const VALID_IONICONS = new Set([ + 'heart-outline', 'heart-half-outline', 'heart-circle-outline', + 'chatbubbles-outline', 'chatbox-outline', 'mail-outline', + 'flower-outline', 'wine-outline', 'restaurant-outline', 'cafe-outline', + 'airplane-outline', 'car-outline', 'compass-outline', 'map-outline', + 'home-outline', 'people-outline', 'person-outline', + 'eye-off-outline', 'key-outline', 'lock-closed-outline', + 'dice-outline', 'gift-outline', 'sparkles-outline', 'star-outline', + 'flame-outline', 'flash-outline', 'moon-outline', 'sunny-outline', + 'flag-outline', 'calendar-outline', 'checkbox-outline', 'trophy-outline', + 'layers-outline', 'cube-outline', 'folder-outline', 'bookmark-outline', + 'bulb-outline', 'color-wand-outline', 'sync-outline', 'refresh-outline', + 'briefcase-outline', +]); + +/** + * Converts an icon value to an Ionicons name. + * Handles both: + * - Ionicon names directly (new packs): "briefcase-outline" -> "briefcase-outline" + * - Legacy emojis (existing packs): "💼" -> "briefcase-outline" + */ +export function getPackIconName(icon: string | null | undefined): IoniconsName { + if (!icon) return DEFAULT_PACK_ICON; + + // Check if it's already a valid Ionicon name + if (VALID_IONICONS.has(icon)) { + return icon as IoniconsName; + } + + // Fall back to emoji mapping for legacy data + return EMOJI_TO_IONICON[icon] || DEFAULT_PACK_ICON; +} +``` + +--- + +### Task 6: Add Ionicons Package to Admin (if needed) + +**File**: `apps/admin/package.json` + +The admin app may need the Ionicons package to display icon previews. + +```bash +cd apps/admin +npm install react-icons +``` + +Then use `IoIcons` from `react-icons/io5` which matches Ionicons naming. + +Alternatively, create simple SVG components or use icon font directly. + +--- + +## File Summary + +| File | Action | Priority | +|------|--------|----------| +| `apps/admin/src/components/ui/icon-picker.tsx` | Create | High | +| `apps/admin/src/components/content/PackFormDialog.tsx` | Modify | High | +| `apps/admin/src/pages/content/CategoriesPage.tsx` | Modify | Medium | +| `apps/admin/src/lib/ai/generators/packs.ts` | Modify | Low | +| `apps/mobile/src/lib/packIcons.ts` | Modify | High | +| `apps/admin/package.json` | Modify (maybe) | Medium | + +--- + +## Testing Checklist + +- [ ] Create a new pack in admin with Ionicon - verify it saves correctly +- [ ] Edit an existing pack (with emoji) - verify emoji still displays in picker +- [ ] Mobile app displays new Ionicon packs correctly +- [ ] Mobile app displays legacy emoji packs correctly (via mapping) +- [ ] Old app version (if testable) shows default icon for new Ionicon packs + +--- + +## Future Considerations + +Once the new app version is widely deployed: +1. **Optional**: Run a database migration to convert remaining emojis to Ionicons +2. **Optional**: Remove emoji mapping code from mobile app +3. **Optional**: Add migration script admins can run per-pack + +For now, the backwards-compatible approach means no database migrations and no breaking changes. diff --git a/docs/remove-e2ee-plan.md b/docs/remove-e2ee-plan.md new file mode 100644 index 0000000..b0d9e5a --- /dev/null +++ b/docs/remove-e2ee-plan.md @@ -0,0 +1,427 @@ +# Plan: Remove E2EE and Switch to Server-Side Security + +## Overview + +This document outlines the plan to remove end-to-end encryption (E2EE) from Sauci's chat system and rely on Supabase's built-in security instead. + +### Current State + +- Messages are encrypted client-side using AES-256-GCM with triple-wrapped RSA keys +- Each message has sender, recipient, and admin wrapped keys +- Complex key management: generation, storage, rotation, recovery +- ~2500+ lines of encryption code across 21 files +- Edge functions handle key rotation and pending message re-encryption + +### Target State + +- Messages stored as plaintext in Supabase (protected by RLS policies) +- TLS encryption in transit (Supabase default) +- Encryption at rest (Supabase infrastructure) +- No client-side key management +- Simplified chat code + +### Why Remove E2EE? + +1. **Complexity** - Triple-wrapped keys, device recovery, key rotation adds significant maintenance burden +2. **Overkill** - For a couples intimacy app, server-side security is sufficient +3. **User Experience** - E2EE adds latency, recovery flows, and potential failure modes +4. **Development Velocity** - Simpler code = faster iteration + +### Security Model After Removal + +- **In Transit**: TLS 1.3 (Supabase default) +- **At Rest**: Supabase encrypts data at rest +- **Access Control**: RLS policies restrict access to couple's own messages +- **Admin Access**: Admins can read messages (same as current E2EE with admin key) + +--- + +## Migration Strategy for Existing Messages + +Existing v2 (encrypted) messages need handling. Options: + +### Option A: Decrypt and Convert (Recommended) + +Run a one-time migration to decrypt all v2 messages using admin key and store as v1 plaintext. + +- Pros: Clean slate, no legacy code needed +- Cons: Requires admin private key, one-time effort + +### Option B: Leave v2 Messages As-Is + +Keep encrypted messages in database but stop creating new ones. Old messages become unreadable. + +- Pros: Simpler migration +- Cons: Users lose access to old messages + +### Option C: Hybrid Display + +Keep decryption code for reading old v2 messages, but send new messages as v1. + +- Pros: No data loss +- Cons: Maintains complexity, defeats purpose of removal + +**Recommendation**: Option A - decrypt existing messages during migration window. + +--- + +## Tasks + +### Phase 1: Database Migration (Estimated: 2-3 hours) + +#### Task 1.1: Create Migration to Decrypt Existing v2 Messages + +Create an edge function that: + +1. Fetches all messages with `version = 2` +2. Decrypts each using admin private key +3. Updates to `version = 1`, moves decrypted content to `content` column +4. Clears `encrypted_content`, `encryption_iv`, `keys_metadata` + +**Files to create:** + +- `apps/supabase/functions/migrate-e2ee-to-plaintext/index.ts` + +#### Task 1.2: Run Migration + +Execute the migration function against both non-prod and prod databases. + +#### Task 1.3: Verify Migration + +Query to confirm no v2 messages remain: + +```sql +SELECT COUNT(*) FROM messages WHERE version = 2; +``` + +--- + +### Phase 2: Remove Client-Side Encryption Code (Estimated: 4-6 hours) + +#### Task 2.1: Remove Encryption Library + +Delete the entire encryption directory: + +- `apps/mobile/src/lib/encryption/` (14 files) + +**Files to delete:** + +- `adminKeys.ts` +- `constants.ts` +- `crypto.ts` +- `index.ts` +- `keyManager.ts` +- `mediaDecryption.ts` +- `mediaEncryption.ts` +- `messageDecryption.ts` +- `messageEncryption.ts` +- `reencryptPendingMessages.ts` +- `repairStaleKey.ts` +- `triggerKeyRotation.ts` +- `types.ts` + +#### Task 2.2: Remove Encryption Hooks + +Delete the E2EE-specific hooks: + +**Files to delete:** + +- `apps/mobile/src/hooks/useDecryptedMessage.ts` +- `apps/mobile/src/hooks/useDecryptedMedia.ts` +- `apps/mobile/src/hooks/useEncryptedSend.ts` + +#### Task 2.3: Simplify useEncryptionKeys Hook + +Either delete entirely or convert to a no-op stub that returns `{ hasKeys: true }` for compatibility. + +**File to modify/delete:** + +- `apps/mobile/src/hooks/useEncryptionKeys.ts` + +#### Task 2.4: Remove EncryptionKeyInitializer Component + +Delete the component that initializes E2EE keys on app startup. + +**File to delete:** + +- `apps/mobile/src/components/EncryptionKeyInitializer.tsx` + +#### Task 2.5: Remove from App Layout + +Remove `EncryptionKeyInitializer` from the app layout. + +**File to modify:** + +- `apps/mobile/app/(app)/_layout.tsx` + +--- + +### Phase 3: Simplify Chat Components (Estimated: 3-4 hours) + +#### Task 3.1: Simplify ChatScreen.tsx + +Remove: + +- `useEncryptedSend` hook usage +- `encryptionKeys` from store +- `secureReady` checks +- Recovery UI (`encryptionKeys.isRecovering`) +- Encryption readiness blocking + +Change message sending to use plaintext directly. + +**File to modify:** + +- `apps/mobile/src/features/chat/ChatScreen.tsx` + +#### Task 3.2: Simplify MessageContent.tsx + +Remove: + +- `useDecryptedMessage` hook usage +- `useDecryptedMedia` hook usage +- Decryption loading states +- Decryption error states and retry buttons + +Display `message.content` directly. + +**File to modify:** + +- `apps/mobile/src/features/chat/MessageContent.tsx` + +#### Task 3.3: Simplify Media Upload + +Remove encryption from media upload flow. + +**File to modify:** + +- `apps/mobile/src/hooks/useMediaUpload.ts` + +Changes: + +- Remove `encryptMediaFile` call +- Upload files directly without `.enc` extension +- Remove E2EE readiness checks + +#### Task 3.4: Simplify InputBar (if applicable) + +Remove any "security not ready" blocking logic. + +**File to modify:** + +- `apps/mobile/src/features/chat/InputBar.tsx` + +--- + +### Phase 4: Clean Up State Management (Estimated: 1-2 hours) + +#### Task 4.1: Remove encryptionKeys from Auth Store + +Remove the entire `encryptionKeys` state object and related actions. + +**File to modify:** + +- `apps/mobile/src/store/authStore.ts` + +Remove: + +- `EncryptionKeyState` interface +- `encryptionKeys` state +- `setEncryptionKeys` action +- `beginEncryptionRecovery` / `endEncryptionRecovery` actions +- Reset logic in logout + +--- + +### Phase 5: Remove Edge Functions (Estimated: 1 hour) + +#### Task 5.1: Delete E2EE Edge Functions + +Remove the edge functions that handle key rotation and re-encryption. + +**Files to delete:** + +- `apps/supabase/functions/handle-key-rotation/` +- `apps/supabase/functions/reencrypt-pending-messages/` + +#### Task 5.2: Remove from Supabase Dashboard + +If deployed, remove these functions from Supabase dashboard or let them remain (they won't be called). + +--- + +### Phase 6: Database Cleanup (Optional) (Estimated: 1 hour) + +#### Task 6.1: Remove E2EE Columns (Optional) + +Create migration to drop unused columns. Can be deferred. + +```sql +-- Optional cleanup migration +ALTER TABLE messages + DROP COLUMN IF EXISTS encrypted_content, + DROP COLUMN IF EXISTS encryption_iv, + DROP COLUMN IF EXISTS keys_metadata; + +ALTER TABLE messages + DROP CONSTRAINT IF EXISTS messages_e2ee_fields_check; + +ALTER TABLE profiles + DROP COLUMN IF EXISTS public_key_jwk; + +DROP TABLE IF EXISTS master_keys; +``` + +**Note**: Can leave columns in place - they'll just be NULL for new messages. + +#### Task 6.2: Update Message Version Default + +Ensure new messages use version 1 (or remove version concept entirely). + +--- + +### Phase 7: Cleanup and Testing (Estimated: 2-3 hours) + +#### Task 7.1: Remove Unused Dependencies + +Check if `react-native-quick-crypto` can be removed (may be needed for other crypto). + +**File to modify:** + +- `apps/mobile/package.json` + +#### Task 7.2: Update CLAUDE.md + +Remove E2EE documentation section. + +**File to modify:** + +- `CLAUDE.md` + +#### Task 7.3: Delete E2EE Documentation + +Remove the E2EE implementation docs. + +**File to delete:** + +- `docs/e2ee-implementation.md` + +#### Task 7.4: Update iOS Permission Strings + +Remove "encrypted" from iOS permission descriptions. These strings mention encryption: + +**File to modify:** + +- `apps/mobile/app.json` + +**Current strings (lines 26-27):** + +```json +"NSCameraUsageDescription": "Sauci uses your camera to take photos and videos that you can share privately with your partner in encrypted chat messages.", +"NSPhotoLibraryUsageDescription": "Sauci uses your photo library so you can select photos and videos to share privately with your partner in encrypted chat messages.", +``` + +**Updated strings:** + +```json +"NSCameraUsageDescription": "Sauci uses your camera to take photos and videos that you can share privately with your partner.", +"NSPhotoLibraryUsageDescription": "Sauci uses your photo library so you can select photos and videos to share privately with your partner.", +``` + +After updating `app.json`, run `npx expo prebuild --clean` to regenerate `ios/Sauci/Info.plist`. + +#### Task 7.5: Full Testing + +Test all chat functionality: + +- [ ] Send text message +- [ ] Receive text message +- [ ] Send image +- [ ] Receive image +- [ ] Send video +- [ ] Receive video +- [ ] View old messages (post-migration) +- [ ] New user signup and chat +- [ ] Device change / reinstall + +--- + +## File Summary + +### Files to Delete (19 files) + +```text +apps/mobile/src/lib/encryption/adminKeys.ts +apps/mobile/src/lib/encryption/constants.ts +apps/mobile/src/lib/encryption/crypto.ts +apps/mobile/src/lib/encryption/index.ts +apps/mobile/src/lib/encryption/keyManager.ts +apps/mobile/src/lib/encryption/mediaDecryption.ts +apps/mobile/src/lib/encryption/mediaEncryption.ts +apps/mobile/src/lib/encryption/messageDecryption.ts +apps/mobile/src/lib/encryption/messageEncryption.ts +apps/mobile/src/lib/encryption/reencryptPendingMessages.ts +apps/mobile/src/lib/encryption/repairStaleKey.ts +apps/mobile/src/lib/encryption/triggerKeyRotation.ts +apps/mobile/src/lib/encryption/types.ts +apps/mobile/src/hooks/useDecryptedMessage.ts +apps/mobile/src/hooks/useDecryptedMedia.ts +apps/mobile/src/hooks/useEncryptedSend.ts +apps/mobile/src/hooks/useEncryptionKeys.ts +apps/mobile/src/components/EncryptionKeyInitializer.tsx +docs/e2ee-implementation.md +``` + +### Files to Modify (8 files) + +```text +apps/mobile/src/features/chat/ChatScreen.tsx +apps/mobile/src/features/chat/MessageContent.tsx +apps/mobile/src/features/chat/InputBar.tsx +apps/mobile/src/hooks/useMediaUpload.ts +apps/mobile/src/store/authStore.ts +apps/mobile/app/(app)/_layout.tsx +apps/mobile/app.json +CLAUDE.md +``` + +### Edge Functions to Delete (2 functions) + +```text +apps/supabase/functions/handle-key-rotation/ +apps/supabase/functions/reencrypt-pending-messages/ +``` + +--- + +## Estimated Total Effort + +| Phase | Estimated Time | +| ------------------------------- | ---------------- | +| Phase 1: Database Migration | 2-3 hours | +| Phase 2: Remove Encryption Code | 4-6 hours | +| Phase 3: Simplify Chat Components | 3-4 hours | +| Phase 4: Clean Up State | 1-2 hours | +| Phase 5: Remove Edge Functions | 1 hour | +| Phase 6: Database Cleanup | 1 hour (optional)| +| Phase 7: Testing | 2-3 hours | +| **Total** | **14-20 hours** | + +--- + +## Rollback Plan + +If issues arise after deployment: + +1. **Messages are preserved** - Content column still has plaintext for v1, migration preserves decrypted content +2. **Re-enable E2EE** - Git revert the client changes +3. **No data loss** - The migration only adds data (decrypts), doesn't delete + +--- + +## Open Questions + +1. **Timeline**: When to execute the migration? During maintenance window? +2. **Notification**: Should users be notified about the security model change? +3. **Privacy Policy**: Does the privacy policy mention E2EE? May need update. +4. **App Store**: Any App Store claims about E2EE that need updating?