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?