diff --git a/apps/mobile/app/(app)/onboarding.tsx b/apps/mobile/app/(app)/onboarding.tsx index 5a1fe02..2ddad7b 100644 --- a/apps/mobile/app/(app)/onboarding.tsx +++ b/apps/mobile/app/(app)/onboarding.tsx @@ -25,13 +25,14 @@ import Animated, { import { GradientBackground } from '../../src/components/ui/GradientBackground'; import { GlassCard } from '../../src/components/ui/GlassCard'; import { GlassButton } from '../../src/components/ui/GlassButton'; +import { IntensitySlider } from '../../src/components/ui/IntensitySlider'; import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme'; import { useAuthStore } from '../../src/store'; import { supabase } from '../../src/lib/supabase'; import { getProfileError } from '../../src/lib/errors'; import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications'; import { Events } from '../../src/lib/analytics'; -import type { Gender } from '../../src/types'; +import type { Gender, IntensityLevel } from '../../src/types'; const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [ { value: 'male', label: 'Male', icon: 'male' }, @@ -50,6 +51,19 @@ const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [ { value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' }, ]; +const DEFAULT_MAX_INTENSITY: IntensityLevel = 2; + +const normalizeIntensity = (value: number | null | undefined): IntensityLevel | null => { + if (value === 1 || value === 2 || value === 3 || value === 4 || value === 5) return value; + return null; +}; + +const getInitialIntensity = (profile?: { max_intensity?: number | null; show_explicit_content?: boolean | null } | null): IntensityLevel => { + const normalized = normalizeIntensity(profile?.max_intensity); + if (normalized) return normalized; + return profile?.show_explicit_content ? 5 : DEFAULT_MAX_INTENSITY; +}; + type Stage = 'name' | 'gender' | 'purpose' | 'explicit' | 'notifications'; export default function OnboardingScreen() { @@ -59,7 +73,7 @@ export default function OnboardingScreen() { const [name, setName] = useState(user?.name || ''); const [gender, setGender] = useState(user?.gender || null); const [usageReason, setUsageReason] = useState(null); - const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? true); + const [maxIntensity, setMaxIntensity] = useState(() => getInitialIntensity(user)); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const inputRef = useRef(null); @@ -107,7 +121,7 @@ export default function OnboardingScreen() { Events.onboardingStageComplete('purpose'); }; - const handleExplicitContinue = () => { + const handleIntensityContinue = () => { setStage('notifications'); Events.onboardingStageComplete('explicit'); }; @@ -139,13 +153,16 @@ export default function OnboardingScreen() { setError(null); try { + const showExplicitContent = maxIntensity >= 3; + const { error: updateError } = await supabase .from('profiles') .update({ name: name.trim(), gender, usage_reason: usageReason, - show_explicit_content: showExplicit, + max_intensity: maxIntensity, + show_explicit_content: showExplicitContent, onboarding_completed: true, updated_at: new Date().toISOString(), }) @@ -159,7 +176,7 @@ export default function OnboardingScreen() { console.log('[Onboarding] Profile updated successfully, fetching user...'); await fetchUser(); console.log('[Onboarding] User fetched, navigating to home...'); - Events.profileUpdated(["name", "gender", "usage_reason", "show_explicit_content"]); + Events.profileUpdated(["name", "gender", "usage_reason", "max_intensity", "show_explicit_content"]); Events.onboardingComplete(); router.replace('/'); } catch (err: any) { @@ -379,81 +396,22 @@ export default function OnboardingScreen() { colors={gradients.primary as [string, string]} style={styles.iconContainer} > - + - Almost there! + Set Your Comfort Zone - Shall we keep it clean or show you everything? + Choose the intimacy level you're comfortable with - setShowExplicit(true)} - > - - - - - - Show me everything - - - Include spicy question packs - - - - {showExplicit && } - - - - setShowExplicit(false)} - > - - - - - - Keep it clean - - - Only show SFW content - - - - {!showExplicit && } - - - + + + Start where you're comfortable โ€” you can always adjust later + {error && ( @@ -464,7 +422,7 @@ export default function OnboardingScreen() { @@ -762,42 +720,11 @@ const styles = StyleSheet.create({ color: colors.text, fontWeight: '600', }, - explicitOption: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.glass.background, - borderRadius: radius.lg, - borderWidth: 2, - borderColor: colors.glass.border, - padding: spacing.md, - marginBottom: spacing.sm, - }, - explicitIcon: { - width: 56, - height: 56, - borderRadius: 28, - backgroundColor: colors.glass.background, - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.md, - }, - explicitIconSelected: { - backgroundColor: colors.primaryLight, - }, - explicitText: { - flex: 1, - }, - explicitTitle: { - ...typography.headline, - color: colors.textSecondary, - marginBottom: spacing.xs, - }, - explicitTitleSelected: { - color: colors.text, - }, - explicitDescription: { + intensityNote: { ...typography.caption1, - color: colors.textTertiary, + color: colors.textSecondary, + textAlign: 'center', + marginTop: spacing.md, }, radioOuter: { width: 24, diff --git a/apps/mobile/app/(app)/packs/questions.tsx b/apps/mobile/app/(app)/packs/questions.tsx index c012276..7bc4ff3 100644 --- a/apps/mobile/app/(app)/packs/questions.tsx +++ b/apps/mobile/app/(app)/packs/questions.tsx @@ -20,9 +20,38 @@ import { PackTeaser } from "../../../src/components/PackTeaser"; import { colors, spacing, radius, typography } from "../../../src/theme"; 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; +const HEADER_MAX_HEIGHT = 180; +const HEADER_MIN_HEIGHT = 100; +const HEADER_SCROLL_DISTANCE = HEADER_MAX_HEIGHT - HEADER_MIN_HEIGHT; + +const INTENSITY_LABELS: Record = { + 1: "Light", + 2: "Mild", + 3: "Moderate", + 4: "Spicy", + 5: "Intense", +}; + +const clampIntensity = (value: number) => Math.min(5, Math.max(1, Math.round(value))); + +const isValidIntensity = (value?: number | null): value is number => + typeof value === "number" && value >= 1 && value <= 5; + +const getIntensityDisplay = (pack: QuestionPack) => { + const min = isValidIntensity(pack.min_intensity) ? pack.min_intensity : null; + const max = isValidIntensity(pack.max_intensity) ? pack.max_intensity : null; + const avg = typeof pack.avg_intensity === "number" ? clampIntensity(pack.avg_intensity) : null; + + const flameCount = max ?? avg ?? (pack.is_explicit ? 5 : 2); + const label = min && max + ? min === max + ? INTENSITY_LABELS[min] + : `${INTENSITY_LABELS[min]} - ${INTENSITY_LABELS[max]}` + : INTENSITY_LABELS[flameCount]; + + return { flameCount, label }; +}; + interface PackRowProps { pack: QuestionPack; @@ -34,8 +63,10 @@ interface PackRowProps { onInfoPress: () => void; } -function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPress, onInfoPress }: PackRowProps) { - const questionCount = pack.questions?.[0]?.count || 0; +function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPress, onInfoPress }: PackRowProps) { + const questionCount = pack.questions?.[0]?.count || 0; + const intensityDisplay = getIntensityDisplay(pack); + return ( @@ -58,11 +89,25 @@ function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPre )} - - {questionCount} {questionCount === 1 ? "question" : "questions"} - {isEnabled && ยท Enabled} - - + + {questionCount} {questionCount === 1 ? "question" : "questions"} + {isEnabled && ยท Enabled} + + + + {Array.from({ length: 5 }).map((_, index) => ( + + ))} + + {intensityDisplay.label} + + + query), eq: jest.fn(() => query), + or: jest.fn(() => query), order: jest.fn(() => query), upsert: jest.fn(() => query), then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject), @@ -17,10 +18,10 @@ describe('packsStore', () => { beforeEach(() => { jest.clearAllMocks(); usePacksStore.setState({ packs: [], categories: [], enabledPackIds: [], isLoading: false } as any); - useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', show_explicit_content: false } } as any); + useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', max_intensity: 2, show_explicit_content: false } } as any); }); - it('fetches categories/packs and filters explicit when preference disabled', async () => { + it('fetches categories/packs and filters by max intensity', async () => { const categoriesQuery = createThenableQuery({ data: [{ id: 'cat1' }] }); const packsQuery = createThenableQuery({ data: [{ id: 'pack1', is_explicit: false }] }); const enabledQuery = createThenableQuery({ data: [{ pack_id: 'pack1' }] }); @@ -32,8 +33,7 @@ describe('packsStore', () => { await usePacksStore.getState().fetchPacks(); - // Ensure explicit filter applied - expect(packsQuery.eq).toHaveBeenCalledWith('is_explicit', false); + expect(packsQuery.or).toHaveBeenCalledWith('max_intensity.is.null,max_intensity.lte.2'); const state = usePacksStore.getState(); expect(state.categories).toEqual([{ id: 'cat1' }]); diff --git a/apps/mobile/src/components/ui/IntensitySlider.tsx b/apps/mobile/src/components/ui/IntensitySlider.tsx new file mode 100644 index 0000000..5aa5297 --- /dev/null +++ b/apps/mobile/src/components/ui/IntensitySlider.tsx @@ -0,0 +1,646 @@ +import React, { useEffect, useState } from 'react'; +import { View, Text, StyleSheet, Pressable, Platform, Image, Modal, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import { BlurView } from 'expo-blur'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, + FadeIn, + FadeOut, +} from 'react-native-reanimated'; +import { colors, spacing, radius, typography } from '../../theme'; +import type { IntensityLevel } from '@/types'; + +export interface IntensitySliderProps { + value: IntensityLevel; + onValueChange: (value: IntensityLevel) => void; + disabled?: boolean; + /** Partner's intensity level (shows as a ghost indicator) */ + partnerValue?: IntensityLevel | null; + /** Partner's name for the mismatch message */ + partnerName?: string; + /** Partner's avatar URL */ + partnerAvatar?: string | null; +} + +const INTENSITY_LEVELS: Array<{ level: IntensityLevel; label: string; emoji: string; description: string }> = [ + { level: 1, label: 'Gentle', emoji: '๐Ÿ’ญ', description: 'Conversations & emotional connection' }, + { level: 2, label: 'Warm', emoji: '๐Ÿ’•', description: 'Cuddling, kisses & flirty moments' }, + { level: 3, label: 'Playful', emoji: '๐Ÿ˜', description: 'Making out & suggestive content' }, + { level: 4, label: 'Steamy', emoji: '๐Ÿ”ฅ', description: 'Intimate activities & explicit content' }, + { level: 5, label: 'Intense', emoji: '๐ŸŒถ๏ธ', description: 'Advanced intimacy & fantasies' }, +]; + +const HEAT_COLORS = [ + '#9b59b6', // Purple - gentle + '#e94560', // Rose - warm + '#ff6b6b', // Coral - playful + '#ff4757', // Red - steamy + '#ff3333', // Bright red - intense +]; + +const triggerHaptic = async () => { + if (Platform.OS === 'web') return; + const Haptics = await import('expo-haptics'); + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); +}; + +export function IntensitySlider({ + value, + onValueChange, + disabled = false, + partnerValue, + partnerName, + partnerAvatar, +}: IntensitySliderProps) { + const current = INTENSITY_LEVELS[value - 1] ?? INTENSITY_LEVELS[1]; + const progress = useSharedValue((value - 1) / 4); + const [showInfoModal, setShowInfoModal] = useState(false); + + const hasPartner = partnerValue != null; + const partnerLevel = hasPartner ? INTENSITY_LEVELS[partnerValue - 1] : null; + const levelsDiffer = hasPartner && value !== partnerValue; + const userIsHigher = hasPartner && value > partnerValue; + const displayPartnerName = partnerName || 'Your partner'; + + useEffect(() => { + progress.value = withSpring((value - 1) / 4, { damping: 15, stiffness: 150 }); + }, [value]); + + const handleSelect = async (nextValue: IntensityLevel) => { + if (disabled || nextValue === value) return; + await triggerHaptic(); + onValueChange(nextValue); + }; + + const progressStyle = useAnimatedStyle(() => ({ + width: `${progress.value * 100}%`, + })); + + const getMismatchMessage = () => { + if (!levelsDiffer || !partnerLevel) return null; + if (userIsHigher) { + return `${displayPartnerName} is at ${partnerLevel.label} โ€” tap for details`; + } + return `${displayPartnerName} is at ${partnerLevel.label}`; + }; + + return ( + + {/* Progress track */} + + + + + + + + + {/* Level indicators */} + + {INTENSITY_LEVELS.map((level) => { + const isSelected = value === level.level; + const isPassed = value > level.level; + const isActive = isSelected || isPassed; + const isPartnerLevel = hasPartner && partnerValue === level.level; + + return ( + handleSelect(level.level)} + disabled={disabled} + > + + {/* Partner indicator ring */} + {isPartnerLevel && !isSelected && ( + + {partnerAvatar ? ( + + ) : ( + + )} + + )} + + {isSelected ? ( + {level.emoji} + ) : ( + + )} + + + + {level.label} + + + ); + })} + + + {/* Current level card */} + + + {current.emoji} + {current.label} + {hasPartner && !levelsDiffer && ( + setShowInfoModal(true)} + > + + Matched + + + )} + + {current.description} + + {/* Mismatch message */} + {levelsDiffer && ( + setShowInfoModal(true)} + > + {partnerAvatar ? ( + + ) : ( + + + + )} + + {getMismatchMessage()} + + + + )} + + + {/* Info Modal */} + setShowInfoModal(false)} + > + setShowInfoModal(false)} + > + e.stopPropagation()}> + {Platform.OS === 'ios' ? ( + + ) : null} + + {/* Header */} + + {levelsDiffer ? ( + + + + ) : ( + + + + )} + + {levelsDiffer + ? (userIsHigher ? "Different Comfort Zones" : "You're in Sync") + : "You're Perfectly Matched" + } + + + + {/* Comparison visual */} + {hasPartner && ( + + + + {current.emoji} + + You + {current.label} + + + + + + + + {partnerAvatar ? ( + + ) : ( + + {partnerLevel?.emoji} + + )} + {displayPartnerName} + {partnerLevel?.label} + + + )} + + {/* Explanation */} + + {!levelsDiffer ? ( + <> + + You and {displayPartnerName} have the same comfort zone setting. + You'll both see the same question packs and can match on everything! + + + + + Perfect alignment means more matches + + + + ) : userIsHigher ? ( + <> + + Your comfort zone is set higher than {displayPartnerName}'s. + This means some questions you see won't appear for them. + + + + + Swipes on {current.label} content won't match since {displayPartnerName} is at {partnerLevel?.label} + + + + Consider aligning your levels, or enjoy different paces โ€” it's your choice! + + + ) : ( + <> + + {displayPartnerName}'s comfort zone is higher than yours. + They might swipe on questions you don't see. + + + + + You'll only see content within your comfort zone + + + + )} + + + {/* Close button */} + setShowInfoModal(false)} + > + Got it + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + gap: spacing.md, + }, + containerDisabled: { + opacity: 0.6, + }, + trackContainer: { + paddingHorizontal: spacing.lg, + }, + track: { + height: 4, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + borderRadius: 2, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + borderRadius: 2, + overflow: 'hidden', + }, + levelsRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: spacing.xs, + }, + levelButton: { + alignItems: 'center', + flex: 1, + }, + indicatorWrapper: { + position: 'relative', + marginBottom: spacing.xs, + }, + levelIndicator: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderWidth: 2, + borderColor: 'rgba(255, 255, 255, 0.1)', + }, + levelIndicatorActive: { + backgroundColor: 'rgba(233, 69, 96, 0.15)', + borderColor: 'rgba(233, 69, 96, 0.3)', + }, + levelIndicatorPartner: { + borderColor: colors.secondary, + borderStyle: 'dashed', + }, + partnerRing: { + position: 'absolute', + top: -6, + right: -6, + width: 18, + height: 18, + borderRadius: 9, + backgroundColor: colors.secondary, + alignItems: 'center', + justifyContent: 'center', + zIndex: 1, + borderWidth: 2, + borderColor: colors.backgroundLight, + }, + partnerAvatarSmall: { + width: 14, + height: 14, + borderRadius: 7, + }, + levelEmoji: { + fontSize: 16, + }, + levelLabel: { + ...typography.caption2, + color: colors.textTertiary, + textAlign: 'center', + }, + levelLabelActive: { + color: colors.text, + fontWeight: '600', + }, + levelLabelPartner: { + color: colors.secondary, + }, + currentCard: { + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderRadius: radius.md, + padding: spacing.md, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.08)', + }, + currentCardWarning: { + borderColor: 'rgba(243, 156, 18, 0.3)', + backgroundColor: 'rgba(243, 156, 18, 0.05)', + }, + currentHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginBottom: spacing.xs, + }, + currentEmoji: { + fontSize: 20, + }, + currentLabel: { + ...typography.subhead, + fontWeight: '600', + color: colors.text, + }, + matchBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + marginLeft: 'auto', + backgroundColor: 'rgba(46, 204, 113, 0.15)', + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderRadius: radius.full, + }, + matchBadgeText: { + ...typography.caption2, + color: colors.success, + fontWeight: '600', + }, + currentDescription: { + ...typography.caption1, + color: colors.textSecondary, + lineHeight: 18, + }, + mismatchRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginTop: spacing.sm, + paddingTop: spacing.sm, + borderTopWidth: 1, + borderTopColor: 'rgba(255, 255, 255, 0.08)', + }, + partnerAvatarTiny: { + width: 20, + height: 20, + borderRadius: 10, + }, + partnerIconTiny: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: 'rgba(155, 89, 182, 0.2)', + alignItems: 'center', + justifyContent: 'center', + }, + mismatchText: { + ...typography.caption1, + color: colors.textSecondary, + flex: 1, + }, + mismatchTextWarning: { + color: colors.warning, + }, + // Modal styles + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.7)', + justifyContent: 'center', + alignItems: 'center', + padding: spacing.lg, + }, + modalContent: { + width: '100%', + maxWidth: 360, + borderRadius: radius.xl, + overflow: 'hidden', + backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.backgroundLight, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + }, + modalInner: { + padding: spacing.lg, + backgroundColor: Platform.OS === 'ios' ? 'rgba(26, 26, 46, 0.85)' : 'transparent', + }, + modalHeader: { + alignItems: 'center', + marginBottom: spacing.lg, + }, + modalIconGradient: { + width: 56, + height: 56, + borderRadius: 28, + alignItems: 'center', + justifyContent: 'center', + marginBottom: spacing.md, + }, + modalTitle: { + ...typography.title3, + color: colors.text, + textAlign: 'center', + }, + comparisonContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderRadius: radius.lg, + padding: spacing.md, + marginBottom: spacing.lg, + }, + comparisonPerson: { + alignItems: 'center', + flex: 1, + }, + comparisonAvatar: { + width: 48, + height: 48, + borderRadius: 24, + alignItems: 'center', + justifyContent: 'center', + marginBottom: spacing.xs, + }, + comparisonAvatarImage: { + width: 48, + height: 48, + borderRadius: 24, + marginBottom: spacing.xs, + }, + comparisonEmoji: { + fontSize: 24, + }, + comparisonName: { + ...typography.caption1, + color: colors.text, + fontWeight: '600', + }, + comparisonLevel: { + ...typography.caption2, + color: colors.textSecondary, + }, + comparisonDivider: { + width: 40, + alignItems: 'center', + }, + modalExplanation: { + marginBottom: spacing.lg, + }, + modalText: { + ...typography.subhead, + color: colors.textSecondary, + textAlign: 'center', + lineHeight: 22, + marginBottom: spacing.md, + }, + modalSubtext: { + ...typography.caption1, + color: colors.textTertiary, + textAlign: 'center', + marginTop: spacing.sm, + }, + modalTip: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(46, 204, 113, 0.1)', + borderRadius: radius.md, + padding: spacing.sm, + gap: spacing.xs, + }, + modalTipWarning: { + backgroundColor: 'rgba(243, 156, 18, 0.1)', + }, + modalTipText: { + ...typography.caption1, + color: colors.success, + flex: 1, + }, + modalTipTextWarning: { + color: colors.warning, + }, + modalCloseButton: { + backgroundColor: colors.glass.background, + borderRadius: radius.md, + paddingVertical: spacing.md, + alignItems: 'center', + borderWidth: 1, + borderColor: colors.glass.border, + }, + modalCloseText: { + ...typography.subhead, + color: colors.text, + fontWeight: '600', + }, +}); + +export default IntensitySlider; diff --git a/apps/mobile/src/components/ui/index.ts b/apps/mobile/src/components/ui/index.ts index 664f3dc..d9fa1ff 100644 --- a/apps/mobile/src/components/ui/index.ts +++ b/apps/mobile/src/components/ui/index.ts @@ -1,8 +1,9 @@ export { GradientBackground } from './GradientBackground'; export { GlassCard } from './GlassCard'; export { GlassButton } from './GlassButton'; -export { GlassInput } from './GlassInput'; -export { ShimmerEffect } from './ShimmerEffect'; +export { GlassInput } from './GlassInput'; +export { IntensitySlider } from './IntensitySlider'; +export { ShimmerEffect } from './ShimmerEffect'; export { DecorativeSeparator } from './DecorativeSeparator'; export type { DecorativeSeparatorProps, SeparatorVariant } from './DecorativeSeparator'; export { LoadingOverlay } from './LoadingOverlay'; diff --git a/apps/mobile/src/features/profile/ProfileScreen.tsx b/apps/mobile/src/features/profile/ProfileScreen.tsx index 84bd6b9..1011c38 100644 --- a/apps/mobile/src/features/profile/ProfileScreen.tsx +++ b/apps/mobile/src/features/profile/ProfileScreen.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert } from 'react-native'; +import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert, Linking } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import Constants from 'expo-constants'; import Animated, { @@ -27,7 +27,7 @@ import { clearKeys } from '../../lib/encryption'; import { useProfileSettings, useCoupleManagement } from './hooks'; // Components -import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings, DangerZone, ResetProgress, SettingsSection, MenuItem } from './components'; +import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings, DangerZone, SettingsSection, MenuItem } from './components'; const MAX_CONTENT_WIDTH = 500; const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string]; @@ -269,14 +269,17 @@ export function ProfileScreen() { {/* Preferences */} {/* Account */} @@ -298,13 +301,20 @@ export function ProfileScreen() { description="Report bugs or request features" onPress={() => setShowFeedbackModal(true)} /> + + Linking.openURL('https://sauci.app/privacy')} + /> + + Linking.openURL('https://sauci.app/terms')} + /> - {/* Reset Progress */} - {couple && ( - - )} - {/* Debug Section */} {__DEV__ && ( @@ -375,6 +385,7 @@ export function ProfileScreen() { {/* Danger Zone */} void; } +const formatPairedDate = (date: string | null | undefined): string | null => { + if (!date) return null; + const paired = new Date(date); + const now = new Date(); + const diffMs = now.getTime() - paired.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return 'Paired today'; + if (diffDays === 1) return 'Paired yesterday'; + if (diffDays < 7) return `Paired ${diffDays} days ago`; + if (diffDays < 30) { + const weeks = Math.floor(diffDays / 7); + return `Paired ${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`; + } + if (diffDays < 365) { + const months = Math.floor(diffDays / 30); + return `Paired ${months} ${months === 1 ? 'month' : 'months'} ago`; + } + const years = Math.floor(diffDays / 365); + return `Paired ${years} ${years === 1 ? 'year' : 'years'} ago`; +}; + export const CoupleStatus: React.FC = ({ partner, couple, onUnpair, onPairingPress, }) => { + const pairedLabel = formatPairedDate(couple?.created_at); + return ( {partner ? ( - - - + {partner.avatar_url ? ( + + + + + + + + + ) : ( + + + {(partner.name?.[0] || partner.email?.[0] || 'P').toUpperCase()} + + + )} {partner.name || partner.email || 'Your partner'} - Connected + + {pairedLabel || 'Connected'} + Promise; onDeleteRelationship?: () => Promise; onDeleteAccount: () => Promise; hasRelationship?: boolean; } export const DangerZone: React.FC = ({ + onResetProgress, onDeleteRelationship, onDeleteAccount, hasRelationship = false }) => { + const [showResetModal, setShowResetModal] = useState(false); const [showDeleteRelationshipModal, setShowDeleteRelationshipModal] = useState(false); const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false); - const [deleteConfirmText, setDeleteConfirmText] = useState(""); + const [confirmText, setConfirmText] = useState(""); + const [isResetting, setIsResetting] = useState(false); const [isDeletingRelationship, setIsDeletingRelationship] = useState(false); const [isDeletingAccount, setIsDeletingAccount] = useState(false); + const handleResetProgress = async () => { + if (confirmText !== "RESET" || !onResetProgress) return; + setIsResetting(true); + try { + await onResetProgress(); + setShowResetModal(false); + setConfirmText(""); + } catch (error) { + // Error handling relies on parent + } finally { + setIsResetting(false); + } + }; + + const closeResetModal = () => { + if (!isResetting) { + setShowResetModal(false); + setConfirmText(""); + } + }; + const handleDeleteRelationship = async () => { - if (deleteConfirmText !== "DELETE" || !onDeleteRelationship) return; + if (confirmText !== "DELETE" || !onDeleteRelationship) return; setIsDeletingRelationship(true); try { await onDeleteRelationship(); setShowDeleteRelationshipModal(false); - setDeleteConfirmText(""); + setConfirmText(""); } catch (error) { // Error handling relies on parent throwing or managing its own alerts } finally { @@ -38,7 +63,7 @@ export const DangerZone: React.FC = ({ }; const handleDeleteAccount = async () => { - if (deleteConfirmText !== "DELETE") return; + if (confirmText !== "DELETE") return; setIsDeletingAccount(true); try { await onDeleteAccount(); @@ -52,14 +77,14 @@ export const DangerZone: React.FC = ({ const closeRelationshipModal = () => { if (!isDeletingRelationship) { setShowDeleteRelationshipModal(false); - setDeleteConfirmText(""); + setConfirmText(""); } }; const closeAccountModal = () => { if (!isDeletingAccount) { setShowDeleteAccountModal(false); - setDeleteConfirmText(""); + setConfirmText(""); } }; @@ -71,18 +96,45 @@ export const DangerZone: React.FC = ({ > Danger Zone + {/* Reset Progress - only show if in a relationship */} + {hasRelationship && onResetProgress && ( + + + + + + + + Reset Progress + + Start fresh by clearing all swipes, matches, and chats. Your partner connection stays. + + + + setShowResetModal(true)} + activeOpacity={0.7} + > + + Reset + + + + )} + {/* Delete Relationship - only show if in a relationship */} {hasRelationship && onDeleteRelationship && ( - + - Delete Relationship Data + Delete Relationship - Delete your relationship including all matches, chats, and shared photos. Your account will remain. + End your connection and delete all shared data. Your account will remain. @@ -100,7 +152,7 @@ export const DangerZone: React.FC = ({ )} {/* Delete Account - always show */} - + @@ -109,7 +161,7 @@ export const DangerZone: React.FC = ({ Delete Account - Permanently delete your account and all associated data. This cannot be undone. + Permanently delete your account and all data. This cannot be undone. @@ -126,6 +178,105 @@ export const DangerZone: React.FC = ({ + {/* Reset Progress Modal */} + + + + + + + + + Reset Progress? + + + + This will permanently delete: + + + + + + All your swipes + + + + All matches + + + + All chat messages + + + + + + + Your partner connection stays + + + + + Type RESET to confirm: + + + + + + + Cancel + + + + {isResetting ? ( + + ) : ( + <> + + Reset + + )} + + + + + + + {/* Delete Relationship Modal */} = ({ = ({ {isDeletingRelationship ? ( @@ -283,8 +434,8 @@ export const DangerZone: React.FC = ({ = ({ {isDeletingAccount ? ( @@ -342,6 +493,36 @@ const styles = StyleSheet.create({ marginBottom: spacing.sm, marginLeft: spacing.xs, }, + warningCard: { + borderColor: 'rgba(243, 156, 18, 0.3)', + borderWidth: 1, + }, + warningIconContainer: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: 'rgba(243, 156, 18, 0.15)', + justifyContent: "center", + alignItems: "center", + }, + warningButton: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + backgroundColor: 'rgba(243, 156, 18, 0.15)', + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: radius.md, + borderWidth: 1, + borderColor: 'rgba(243, 156, 18, 0.3)', + gap: spacing.xs, + alignSelf: "flex-end", + }, + warningButtonText: { + ...typography.subhead, + color: colors.warning, + fontWeight: "600", + }, dangerCard: { borderColor: 'rgba(231, 76, 60, 0.3)', borderWidth: 1, @@ -431,6 +612,58 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: 'rgba(231, 76, 60, 0.2)', }, + warningList: { + backgroundColor: "rgba(243, 156, 18, 0.1)", + borderRadius: radius.lg, + padding: spacing.md, + gap: spacing.sm, + marginBottom: spacing.md, + borderWidth: 1, + borderColor: 'rgba(243, 156, 18, 0.2)', + }, + keepList: { + backgroundColor: "rgba(46, 204, 113, 0.1)", + borderRadius: radius.lg, + padding: spacing.md, + gap: spacing.sm, + marginBottom: spacing.xl, + borderWidth: 1, + borderColor: 'rgba(46, 204, 113, 0.2)', + }, + warningModalIconContainer: { + width: 64, + height: 64, + borderRadius: 32, + backgroundColor: 'rgba(243, 156, 18, 0.15)', + justifyContent: "center", + alignItems: "center", + marginBottom: spacing.md, + }, + warningKeyword: { + fontWeight: "bold", + color: colors.warning, + }, + warningInput: { + backgroundColor: colors.glass.background, + borderRadius: radius.md, + borderWidth: 1, + borderColor: colors.warning, + padding: spacing.md, + ...typography.body, + color: colors.text, + textAlign: 'center', + marginBottom: spacing.xl, + }, + warningActionButton: { + flex: 1, + backgroundColor: colors.warning, + paddingVertical: spacing.md, + borderRadius: radius.md, + alignItems: "center", + flexDirection: "row", + justifyContent: "center", + gap: spacing.xs, + }, deleteListItem: { flexDirection: "row", alignItems: "center", diff --git a/apps/mobile/src/features/profile/components/PrivacySettings.tsx b/apps/mobile/src/features/profile/components/PrivacySettings.tsx index c93fc8f..bae53dd 100644 --- a/apps/mobile/src/features/profile/components/PrivacySettings.tsx +++ b/apps/mobile/src/features/profile/components/PrivacySettings.tsx @@ -1,40 +1,76 @@ import React from 'react'; -import { View, StyleSheet } from 'react-native'; +import { View, Text, StyleSheet } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Ionicons } from '@expo/vector-icons'; import { SettingsSection } from './SettingsSection'; import { SwitchItem } from './SwitchItem'; -import { colors, spacing } from '../../../theme'; +import { IntensitySlider } from '../../../components/ui/IntensitySlider'; +import { colors, spacing, radius, typography, featureColors } from '../../../theme'; +import type { IntensityLevel } from '../../../types'; interface PrivacySettingsProps { - showExplicit: boolean; - isUpdatingExplicit: boolean; - onExplicitToggle: (value: boolean) => void; + maxIntensity: IntensityLevel; + isUpdatingIntensity: boolean; + onIntensityChange: (value: IntensityLevel) => void; biometricAvailable: boolean; biometricEnabled: boolean; biometricType: string; isUpdatingBiometric: boolean; onBiometricToggle: (value: boolean) => void; + // Partner info for slider comparison + partnerIntensity?: IntensityLevel | null; + partnerName?: string; + partnerAvatar?: string | null; } +const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string]; + export const PrivacySettings: React.FC = ({ - showExplicit, - isUpdatingExplicit, - onExplicitToggle, + maxIntensity, + isUpdatingIntensity, + onIntensityChange, biometricAvailable, biometricEnabled, biometricType, isUpdatingBiometric, onBiometricToggle, + partnerIntensity, + partnerName, + partnerAvatar, }) => { return ( - + {/* Comfort Zone Header */} + + + + + + Comfort Zone + + Set the intimacy level you're comfortable with + + + + + {/* Intensity Slider */} + + + + + {/* Biometric Toggle */} {biometricAvailable && ( <> @@ -53,9 +89,37 @@ export const PrivacySettings: React.FC = ({ }; const styles = StyleSheet.create({ + comfortHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.lg, + }, + comfortIcon: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.md, + }, + comfortText: { + flex: 1, + }, + comfortLabel: { + ...typography.headline, + color: colors.text, + }, + comfortDescription: { + ...typography.caption1, + color: colors.textSecondary, + marginTop: 2, + }, + sliderContainer: { + marginBottom: spacing.sm, + }, divider: { height: 1, backgroundColor: colors.glass.border, - marginVertical: spacing.md, + marginVertical: spacing.lg, }, }); diff --git a/apps/mobile/src/features/profile/components/SubscriptionCard.tsx b/apps/mobile/src/features/profile/components/SubscriptionCard.tsx new file mode 100644 index 0000000..5aca151 --- /dev/null +++ b/apps/mobile/src/features/profile/components/SubscriptionCard.tsx @@ -0,0 +1,389 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import Animated, { FadeInDown } from 'react-native-reanimated'; + +import { ShimmerEffect } from '../../../components/ui'; +import { colors, gradients, spacing, typography, radius, shadows } from '../../../theme'; + +export interface SubscriptionCardProps { + /** Whether user has premium access (own or partner's) */ + hasPremiumAccess: boolean; + /** Whether this is the user's own subscription (vs partner's) */ + isOwnSubscription: boolean; + /** Formatted expiration/renewal date */ + expirationDate: string; + /** Callback when upgrade button is pressed */ + onUpgradePress: () => void; + /** Callback when manage subscription is pressed */ + onManagePress: () => void; + /** Callback when restore purchases is pressed */ + onRestorePress: () => void; + /** Whether restore is in progress */ + isRestoring?: boolean; + /** Animation delay */ + delay?: number; +} + +const PREMIUM_FEATURES = [ + { icon: 'layers' as const, text: '50+ Premium Packs' }, + { icon: 'flame' as const, text: 'All Intensity Levels' }, + { icon: 'sparkles' as const, text: 'New Packs Weekly' }, +]; + +export function SubscriptionCard({ + hasPremiumAccess, + isOwnSubscription, + expirationDate, + onUpgradePress, + onManagePress, + onRestorePress, + isRestoring = false, + delay = 350, +}: SubscriptionCardProps) { + if (hasPremiumAccess) { + return ( + + + + ); + } + + return ( + + + + ); +} + +/** Premium member card - celebratory gold treatment */ +function ProMemberCard({ + isOwnSubscription, + expirationDate, + onManagePress, +}: { + isOwnSubscription: boolean; + expirationDate: string; + onManagePress: () => void; +}) { + return ( + + {/* Gold gradient border effect */} + + + {/* Header with badge */} + + + + + + Pro Member + + {isOwnSubscription + ? `Renews ${expirationDate}` + : "Via partner's subscription" + } + + + {isOwnSubscription && ( + + Manage + + )} + + + {/* Status row */} + + + + All Packs Unlocked + + + + Unlimited Access + + + + + ); +} + +/** Free user card - premium upsell with shimmer */ +function FreeUserCard({ + onUpgradePress, + onRestorePress, + isRestoring, +}: { + onUpgradePress: () => void; + onRestorePress: () => void; + isRestoring: boolean; +}) { + return ( + + + {/* Gold gradient border */} + + + {/* Header */} + + + + SAUCI PRO + + + + {/* Features list */} + + {PREMIUM_FEATURES.map((feature, index) => ( + + + + + {feature.text} + + ))} + + + {/* CTA Button */} + + + + Upgrade to Pro + + + + {/* Restore link */} + + + {isRestoring ? "Restoring..." : "Restore Purchases"} + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + marginBottom: spacing.lg, + paddingHorizontal: spacing.lg, + }, + shimmerContainer: { + borderRadius: radius.lg, + }, + + // Pro Member Card Styles + proCard: { + borderRadius: radius.lg, + overflow: 'hidden', + }, + proBorderGradient: { + ...StyleSheet.absoluteFillObject, + opacity: 0.3, + }, + proCardInner: { + backgroundColor: 'rgba(22, 33, 62, 0.8)', + margin: 1, + borderRadius: radius.lg - 1, + padding: spacing.md, + }, + proHeader: { + flexDirection: 'row', + alignItems: 'center', + }, + proBadge: { + width: 36, + height: 36, + borderRadius: 18, + justifyContent: 'center', + alignItems: 'center', + ...shadows.glow(colors.premium.gold), + }, + proHeaderText: { + flex: 1, + marginLeft: spacing.md, + }, + proTitle: { + ...typography.headline, + color: colors.premium.champagne, + fontWeight: '700', + }, + proSubtitle: { + ...typography.caption1, + color: colors.textTertiary, + marginTop: 2, + }, + manageButton: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.md, + backgroundColor: colors.glass.background, + borderWidth: 1, + borderColor: `${colors.premium.gold}40`, + }, + manageButtonText: { + ...typography.subhead, + color: colors.premium.gold, + fontWeight: '600', + }, + proStatusRow: { + flexDirection: 'row', + marginTop: spacing.md, + paddingTop: spacing.md, + borderTopWidth: 1, + borderTopColor: colors.glass.border, + gap: spacing.lg, + }, + proStatusItem: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + proStatusText: { + ...typography.caption1, + color: colors.textSecondary, + }, + + // Free User Card Styles + freeCard: { + borderRadius: radius.lg, + overflow: 'hidden', + }, + freeBorderGradient: { + ...StyleSheet.absoluteFillObject, + }, + freeCardInner: { + backgroundColor: 'rgba(22, 33, 62, 0.85)', + margin: 1, + borderRadius: radius.lg - 1, + padding: spacing.lg, + }, + freeHeader: { + alignItems: 'center', + marginBottom: spacing.lg, + }, + freeHeaderBadge: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.full, + gap: spacing.xs, + }, + freeHeaderBadgeText: { + ...typography.caption1, + fontWeight: '800', + letterSpacing: 2, + color: colors.background, + }, + featuresList: { + marginBottom: spacing.lg, + }, + featureItem: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.sm, + }, + featureIconContainer: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: colors.premium.goldLight, + justifyContent: 'center', + alignItems: 'center', + marginRight: spacing.md, + }, + featureText: { + ...typography.body, + color: colors.text, + }, + ctaButtonContainer: { + borderRadius: radius.md, + overflow: 'hidden', + ...shadows.glow(colors.premium.gold), + }, + ctaButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.md, + gap: spacing.sm, + }, + ctaButtonText: { + ...typography.headline, + color: colors.background, + fontWeight: '700', + }, + restoreLink: { + alignItems: 'center', + marginTop: spacing.md, + padding: spacing.sm, + }, + restoreLinkText: { + ...typography.caption1, + color: colors.textTertiary, + }, +}); + +export default SubscriptionCard; diff --git a/apps/mobile/src/features/profile/hooks/useProfileSettings.ts b/apps/mobile/src/features/profile/hooks/useProfileSettings.ts index 1d90152..60caef6 100644 --- a/apps/mobile/src/features/profile/hooks/useProfileSettings.ts +++ b/apps/mobile/src/features/profile/hooks/useProfileSettings.ts @@ -6,6 +6,7 @@ import { decode } from 'base64-arraybuffer'; import { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../store'; import { supabase } from '../../../lib/supabase'; import { Events } from '../../../lib/analytics'; +import type { IntensityLevel } from '../../../types'; import { registerForPushNotificationsAsync, savePushToken, @@ -18,11 +19,27 @@ import { getBiometricType, } from '../../../lib/biometricAuth'; +const DEFAULT_MAX_INTENSITY: IntensityLevel = 2; + +const normalizeIntensity = (value: number | null | undefined): IntensityLevel | null => { + if (value === 1 || value === 2 || value === 3 || value === 4 || value === 5) return value; + return null; +}; + +const getProfileMaxIntensity = (profile?: { max_intensity?: number | null; show_explicit_content?: boolean | null } | null): IntensityLevel => { + const normalized = normalizeIntensity(profile?.max_intensity); + if (normalized) return normalized; + return profile?.show_explicit_content ? 5 : DEFAULT_MAX_INTENSITY; +}; + export function useProfileSettings() { - const { user, fetchUser } = useAuthStore(); + const { user, partner, fetchUser } = useAuthStore(); const { fetchPacks } = usePacksStore(); const { restorePurchases, isPurchasing } = useSubscriptionStore(); + // Partner's intensity level (for showing on slider) + const partnerIntensity = partner ? getProfileMaxIntensity(partner) : null; + // Name Editing const [isEditingName, setIsEditingName] = useState(false); const [newName, setNewName] = useState(user?.name || ""); @@ -32,8 +49,8 @@ export function useProfileSettings() { const [isUploadingAvatar, setIsUploadingAvatar] = useState(false); // Preferences - const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? true); - const [isUpdatingExplicit, setIsUpdatingExplicit] = useState(false); + const [maxIntensity, setMaxIntensity] = useState(() => getProfileMaxIntensity(user)); + const [isUpdatingIntensity, setIsUpdatingIntensity] = useState(false); // Notifications const [pushEnabled, setPushEnabled] = useState(!!user?.push_token); @@ -53,9 +70,7 @@ export function useProfileSettings() { if (user?.name) { setNewName(user.name); } - if (user?.show_explicit_content !== undefined) { - setShowExplicit(user.show_explicit_content); - } + setMaxIntensity(getProfileMaxIntensity(user)); setPushEnabled(!!user?.push_token); }, [user]); @@ -256,17 +271,21 @@ export function useProfileSettings() { }; // Preference Actions - const handleExplicitToggle = async (value: boolean) => { + const handleIntensityChange = async (value: IntensityLevel) => { if (!user?.id) return; - setShowExplicit(value); - setIsUpdatingExplicit(true); + const previous = maxIntensity; + setMaxIntensity(value); + setIsUpdatingIntensity(true); + + const showExplicitContent = value >= 3; try { const { error } = await supabase .from('profiles') .update({ - show_explicit_content: value, + max_intensity: value, + show_explicit_content: showExplicitContent, updated_at: new Date().toISOString(), }) .eq('id', user.id); @@ -276,13 +295,13 @@ export function useProfileSettings() { // Refresh user data and packs to reflect the change await fetchUser(); await fetchPacks(); - Events.profileUpdated(["show_explicit_content"]); + Events.profileUpdated(["max_intensity", "show_explicit_content"]); } catch (error) { // Revert on error - setShowExplicit(!value); + setMaxIntensity(previous); Alert.alert("Error", "Failed to update preference. Please try again."); } finally { - setIsUpdatingExplicit(false); + setIsUpdatingIntensity(false); } }; @@ -380,8 +399,8 @@ export function useProfileSettings() { setNewName, isUpdatingName, isUploadingAvatar, - showExplicit, - isUpdatingExplicit, + maxIntensity, + isUpdatingIntensity, pushEnabled, isUpdatingPush, biometricAvailable, @@ -392,11 +411,16 @@ export function useProfileSettings() { setShowPaywall, isPurchasing, + // Partner info for comfort zone comparison + partnerIntensity, + partnerName: partner?.name ?? undefined, + partnerAvatar: partner?.avatar_url ?? undefined, + // Actions handleUpdateName, handleCancelEditName, handleAvatarPress, - handleExplicitToggle, + handleIntensityChange, handlePushToggle, handleBiometricToggle, handleRestorePurchases, diff --git a/apps/mobile/src/hooks/useDecryptedMedia.ts b/apps/mobile/src/hooks/useDecryptedMedia.ts index 6efe135..f09c0a0 100644 --- a/apps/mobile/src/hooks/useDecryptedMedia.ts +++ b/apps/mobile/src/hooks/useDecryptedMedia.ts @@ -17,6 +17,10 @@ import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMess // Import shared cache from mediaCache to avoid circular dependency import { decryptedMediaCache, clearDecryptedMediaCache } from '../lib/mediaCache'; +const AUTO_RETRY_BASE_DELAY_MS = 3000; +const MAX_AUTO_RETRIES = 2; +const autoRetryCountsByMediaKey = new Map(); + export type DecryptedMediaErrorCode = | 'E2EE_NOT_SUPPORTED' | 'E2EE_KEYS_UNAVAILABLE' @@ -86,6 +90,7 @@ export function useDecryptedMedia({ const repairAttemptedByMessageId = useRef(new Set()); const [retryNonce, setRetryNonce] = useState(0); + const autoRetryTimerRef = useRef | null>(null); const retry = useCallback(() => { repairAttemptedByMessageId.current.delete(messageId); @@ -391,6 +396,57 @@ export function useDecryptedMedia({ }; }, [decryptMedia, retryNonce]); + useEffect(() => { + return () => { + if (autoRetryTimerRef.current) { + clearTimeout(autoRetryTimerRef.current); + autoRetryTimerRef.current = null; + } + }; + }, [messageId, mediaPath]); + + useEffect(() => { + const autoRetryKey = `${messageId}:${mediaPath ?? 'none'}`; + + if (!state.errorCode) { + autoRetryCountsByMediaKey.delete(autoRetryKey); + if (autoRetryTimerRef.current) { + clearTimeout(autoRetryTimerRef.current); + autoRetryTimerRef.current = null; + } + return; + } + + const shouldRetry = + state.errorCode === 'E2EE_PENDING_RECIPIENT_KEY' || + state.errorCode === 'E2EE_KEYS_UNAVAILABLE'; + + if (!shouldRetry) { + return; + } + + const attempts = autoRetryCountsByMediaKey.get(autoRetryKey) ?? 0; + if (attempts >= MAX_AUTO_RETRIES) { + return; + } + + const nextAttempt = attempts + 1; + const delayMs = AUTO_RETRY_BASE_DELAY_MS * nextAttempt; + + autoRetryCountsByMediaKey.set(autoRetryKey, nextAttempt); + + autoRetryTimerRef.current = setTimeout(() => { + retry(); + }, delayMs); + + return () => { + if (autoRetryTimerRef.current) { + clearTimeout(autoRetryTimerRef.current); + autoRetryTimerRef.current = null; + } + }; + }, [messageId, mediaPath, state.errorCode, retry]); + return { ...state, isEncrypted, diff --git a/apps/mobile/src/hooks/useDecryptedMessage.ts b/apps/mobile/src/hooks/useDecryptedMessage.ts index c3e0e55..bd95627 100644 --- a/apps/mobile/src/hooks/useDecryptedMessage.ts +++ b/apps/mobile/src/hooks/useDecryptedMessage.ts @@ -22,6 +22,10 @@ import { supabase } from '../lib/supabase'; import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages'; import { useEncryptionKeys } from './useEncryptionKeys'; +const AUTO_RETRY_BASE_DELAY_MS = 3000; +const MAX_AUTO_RETRIES = 2; +const autoRetryCountsByMessageId = new Map(); + interface Message { id: string; content: string | null; @@ -68,6 +72,7 @@ export function useDecryptedMessage({ // Track per-message repair attempts to avoid tight retry loops. const repairAttemptedByMessageId = useRef(new Set()); const [retryNonce, setRetryNonce] = useState(0); + const autoRetryTimerRef = useRef | null>(null); const retry = useCallback(() => { repairAttemptedByMessageId.current.delete(message.id); @@ -360,5 +365,54 @@ export function useDecryptedMessage({ decrypt(); }, [decrypt, retryNonce]); + useEffect(() => { + return () => { + if (autoRetryTimerRef.current) { + clearTimeout(autoRetryTimerRef.current); + autoRetryTimerRef.current = null; + } + }; + }, [message.id]); + + useEffect(() => { + if (!state.errorCode) { + autoRetryCountsByMessageId.delete(message.id); + if (autoRetryTimerRef.current) { + clearTimeout(autoRetryTimerRef.current); + autoRetryTimerRef.current = null; + } + return; + } + + const shouldRetry = + state.errorCode === 'E2EE_PENDING_RECIPIENT_KEY' || + state.errorCode === 'E2EE_KEYS_UNAVAILABLE'; + + if (!shouldRetry) { + return; + } + + const attempts = autoRetryCountsByMessageId.get(message.id) ?? 0; + if (attempts >= MAX_AUTO_RETRIES) { + return; + } + + const nextAttempt = attempts + 1; + const delayMs = AUTO_RETRY_BASE_DELAY_MS * nextAttempt; + + autoRetryCountsByMessageId.set(message.id, nextAttempt); + + autoRetryTimerRef.current = setTimeout(() => { + retry(); + }, delayMs); + + return () => { + if (autoRetryTimerRef.current) { + clearTimeout(autoRetryTimerRef.current); + autoRetryTimerRef.current = null; + } + }; + }, [message.id, state.errorCode, retry]); + return { ...state, retry }; } diff --git a/apps/mobile/src/hooks/useEncryptedSend.ts b/apps/mobile/src/hooks/useEncryptedSend.ts index 33f8ccb..637d2a1 100644 --- a/apps/mobile/src/hooks/useEncryptedSend.ts +++ b/apps/mobile/src/hooks/useEncryptedSend.ts @@ -12,6 +12,7 @@ import { useCallback, useState, useRef } from 'react'; import { Platform } from 'react-native'; import { encryptTextMessage, + decryptTextMessage, getAdminPublicKey, getAdminKeyId, isValidPublicKeyJwk, @@ -21,6 +22,7 @@ import { import type { RSAPublicKeyJWK, EncryptedMessagePayload } from '../lib/encryption'; import { useEncryptionKeys } from './useEncryptionKeys'; import { useAuthStore } from '../store'; +import { captureError } from '../lib/sentry'; interface UseEncryptedSendReturn { /** Encrypt a text message for sending */ @@ -48,6 +50,7 @@ export function useEncryptedSend(): UseEncryptedSendReturn { const { publicKeyJwk, hasKeys, isLoading } = useEncryptionKeys(); const refreshPartner = useAuthStore((s) => s.refreshPartner); + const user = useAuthStore((s) => s.user); // E2EE is available if: // - Not on web platform @@ -167,14 +170,75 @@ export function useEncryptedSend(): UseEncryptedSendReturn { console.log('[E2EE Send] Partner public key available:', !!partnerPublicKey); - // Encrypt the message using the key from global store - const encryptedPayload = await encryptTextMessage( - plaintext, - senderPublicKey as RSAPublicKeyJWK, - partnerPublicKey ?? null, - adminPublicKey, - adminKeyId - ); + let lastPayload: EncryptedMessagePayload | null = null; + + const encryptAndVerify = async (): Promise => { + const payload = await encryptTextMessage( + plaintext, + senderPublicKey as RSAPublicKeyJWK, + partnerPublicKey ?? null, + adminPublicKey, + adminKeyId + ); + + lastPayload = payload; + + const decrypted = await decryptTextMessage( + payload.encrypted_content, + payload.encryption_iv, + payload.keys_metadata, + senderPrivateKey, + false + ); + + if (decrypted !== plaintext) { + throw new Error('E2EE payload verification mismatch'); + } + + return payload; + }; + + const maxAttempts = 2; + let encryptedPayload: EncryptedMessagePayload | null = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + encryptedPayload = await encryptAndVerify(); + break; + } catch (verifyError) { + console.warn(`[E2EE Send] Payload verification failed (attempt ${attempt})`, verifyError); + + if (attempt === maxAttempts) { + const errorInstance = + verifyError instanceof Error + ? verifyError + : new Error('E2EE payload verification failed'); + + const lastPayloadData = lastPayload as EncryptedMessagePayload | null; + + captureError(errorInstance, { + stage: 'encrypt_verify', + attempt, + max_attempts: maxAttempts, + plaintext_length: plaintext.length, + encrypted_content_length: lastPayloadData?.encrypted_content.length ?? null, + encryption_iv_length: lastPayloadData?.encryption_iv.length ?? null, + admin_key_id: lastPayloadData?.keys_metadata?.admin_key_id ?? null, + pending_recipient: lastPayloadData?.keys_metadata?.pending_recipient ?? null, + has_partner_key: !!partnerPublicKey, + keys_version: currentKeys.keysVersion, + platform: Platform.OS, + user_id: user?.id ?? null, + }); + + throw new Error('Security check failed. Please try again.'); + } + } + } + + if (!encryptedPayload) { + throw new Error('Security check failed. Please try again.'); + } console.log('[E2EE Send] Message encrypted successfully'); diff --git a/apps/mobile/src/store/packsStore.ts b/apps/mobile/src/store/packsStore.ts index d1ba87f..b65e28b 100644 --- a/apps/mobile/src/store/packsStore.ts +++ b/apps/mobile/src/store/packsStore.ts @@ -2,9 +2,25 @@ import { create } from "zustand"; import { supabase } from "../lib/supabase"; import { Events } from "../lib/analytics"; import type { QuestionPack, Category } from "@/types"; -import { useAuthStore } from "./authStore"; - -interface PacksState { +import { useAuthStore } from "./authStore"; + +const DEFAULT_MAX_INTENSITY = 2; + +const normalizeIntensity = (value?: number | null) => { + if (typeof value !== "number" || Number.isNaN(value)) return null; + const rounded = Math.round(value); + if (rounded < 1 || rounded > 5) return null; + return rounded; +}; + +const getUserMaxIntensity = () => { + const user = useAuthStore.getState().user; + const normalized = normalizeIntensity(user?.max_intensity ?? null); + if (normalized) return normalized; + return user?.show_explicit_content ? 5 : DEFAULT_MAX_INTENSITY; +}; + +interface PacksState { packs: QuestionPack[]; categories: Category[]; enabledPackIds: string[]; @@ -25,8 +41,9 @@ export const usePacksStore = create((set, get) => ({ fetchPacks: async () => { set({ isLoading: true }); - // Get user's explicit content preference - const showExplicit = useAuthStore.getState().user?.show_explicit_content ?? true; + // Get user's max intensity preference + const maxIntensity = getUserMaxIntensity(); + // Fetch categories const { data: categories } = await supabase @@ -41,10 +58,11 @@ export const usePacksStore = create((set, get) => ({ .eq("is_public", true) .order("sort_order"); - // Filter out explicit packs if user doesn't want to see them - if (!showExplicit) { - query = query.eq("is_explicit", false); - } + // Filter out packs above user's intensity preference + if (maxIntensity < 5) { + query = query.or(`max_intensity.is.null,max_intensity.lte.${maxIntensity}`); + } + const { data: packs } = await query; diff --git a/apps/mobile/src/types/index.ts b/apps/mobile/src/types/index.ts index 2d726f3..5deb119 100755 --- a/apps/mobile/src/types/index.ts +++ b/apps/mobile/src/types/index.ts @@ -1,7 +1,8 @@ // User profile (extends Supabase auth.users) -export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say'; - -export interface Profile { +export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say'; +export type IntensityLevel = 1 | 2 | 3 | 4 | 5; + +export interface Profile { id: string; name: string | null; email: string | null; @@ -9,9 +10,10 @@ export interface Profile { push_token: string | null; is_premium: boolean; couple_id: string | null; - gender: Gender | null; - show_explicit_content: boolean; - onboarding_completed: boolean; + gender: Gender | null; + show_explicit_content: boolean; + max_intensity: IntensityLevel; + onboarding_completed: boolean; created_at: string; updated_at: string; /** RSA public key for E2EE (JWK format) */ @@ -42,9 +44,12 @@ export interface QuestionPack { description: string | null; icon: string | null; is_premium: boolean; - is_public: boolean; - is_explicit: boolean; - sort_order: number; + is_public: boolean; + is_explicit: boolean; + min_intensity?: number; + max_intensity?: number; + avg_intensity?: number; + sort_order: number; category_id: string | null; category?: Category; questions?: { count: number }[]; diff --git a/apps/supabase/functions/admin-decrypt-message/index.ts b/apps/supabase/functions/admin-decrypt-message/index.ts index e448c64..bcf8a74 100644 --- a/apps/supabase/functions/admin-decrypt-message/index.ts +++ b/apps/supabase/functions/admin-decrypt-message/index.ts @@ -67,14 +67,14 @@ Deno.serve(async (req) => { return json({ error: "Missing encryption metadata" }, 400); } - const adminPrivateKeyJwkStr = Deno.env.get("ADMIN_PRIVATE_KEY_JWK"); - if (!adminPrivateKeyJwkStr) { + const adminPrivateKeyJwk = getAdminPrivateKey(keysMetadata.admin_key_id); + if (!adminPrivateKeyJwk) { return json({ error: "Admin decryption key not configured" }, 500); } const rawAesKey = await unwrapWithAdminKey( keysMetadata.admin_wrapped_key, - JSON.parse(adminPrivateKeyJwkStr) + adminPrivateKeyJwk ); const aesKey = await crypto.subtle.importKey( @@ -166,3 +166,28 @@ async function assertSuperAdmin( throw new Error("Access denied"); } } + +function getAdminPrivateKey(keyId?: string | null): JsonWebKey | null { + const keysJson = Deno.env.get("ADMIN_KEYS_JSON"); + if (keysJson && keyId) { + try { + const keys = JSON.parse(keysJson); + if (keys[keyId]) { + return keys[keyId]; + } + } catch { + console.error("Failed to parse ADMIN_KEYS_JSON"); + } + } + + const singleKeyJson = Deno.env.get("ADMIN_PRIVATE_KEY_JWK"); + if (singleKeyJson) { + try { + return JSON.parse(singleKeyJson); + } catch { + console.error("Failed to parse ADMIN_PRIVATE_KEY_JWK"); + } + } + + return null; +} diff --git a/apps/supabase/functions/manage-couple/index.ts b/apps/supabase/functions/manage-couple/index.ts index dd87c5a..1fd01aa 100755 --- a/apps/supabase/functions/manage-couple/index.ts +++ b/apps/supabase/functions/manage-couple/index.ts @@ -177,20 +177,42 @@ Deno.serve(async (req) => { ); } - // Update user's profile with couple_id - await supabase - .from("profiles") - .update({ couple_id: couple.id }) - .eq("id", user.id); - - return new Response( - JSON.stringify({ - success: true, - couple_id: couple.id, - invite_code: couple.invite_code - }), - { headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); + // Update user's profile with couple_id + await supabase + .from("profiles") + .update({ couple_id: couple.id }) + .eq("id", user.id); + + const { data: safePacks, error: safePacksError } = await supabase + .from("question_packs") + .select("id") + .lte("avg_intensity", 2) + .eq("is_public", true); + + if (safePacksError) { + console.error("Failed to fetch safe packs:", safePacksError); + } else if (safePacks?.length) { + const { error: insertError } = await supabase + .from("couple_packs") + .insert(safePacks.map((pack: { id: string }) => ({ + couple_id: couple.id, + pack_id: pack.id, + enabled: true, + }))); + + if (insertError) { + console.error("Failed to enable safe packs:", insertError); + } + } + + return new Response( + JSON.stringify({ + success: true, + couple_id: couple.id, + invite_code: couple.invite_code + }), + { headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); } } diff --git a/apps/supabase/migrations/20260108000000_add_intensity_levels.sql b/apps/supabase/migrations/20260108000000_add_intensity_levels.sql new file mode 100644 index 0000000..2d8c150 --- /dev/null +++ b/apps/supabase/migrations/20260108000000_add_intensity_levels.sql @@ -0,0 +1,88 @@ +-- Add intensity levels for content balance + +ALTER TABLE profiles +ADD COLUMN IF NOT EXISTS max_intensity INTEGER DEFAULT 2 +CHECK (max_intensity >= 1 AND max_intensity <= 5); + +UPDATE profiles +SET max_intensity = CASE + WHEN show_explicit_content IS TRUE THEN 5 + ELSE 2 +END +WHERE max_intensity IS NULL; + +ALTER TABLE question_packs +ADD COLUMN IF NOT EXISTS min_intensity INTEGER, +ADD COLUMN IF NOT EXISTS max_intensity INTEGER, +ADD COLUMN IF NOT EXISTS avg_intensity NUMERIC(3,2); + +CREATE OR REPLACE FUNCTION update_question_pack_intensity_stats(target_pack_id UUID) +RETURNS void +LANGUAGE plpgsql +SET search_path = 'public' +AS $$ +BEGIN + UPDATE question_packs qp + SET min_intensity = stats.min_intensity, + max_intensity = stats.max_intensity, + avg_intensity = stats.avg_intensity + FROM ( + SELECT pack_id, + MIN(intensity) AS min_intensity, + MAX(intensity) AS max_intensity, + ROUND(AVG(intensity)::numeric, 2) AS avg_intensity + FROM questions + WHERE pack_id = target_pack_id + GROUP BY pack_id + ) stats + WHERE qp.id = stats.pack_id; + + IF NOT FOUND THEN + UPDATE question_packs + SET min_intensity = NULL, + max_intensity = NULL, + avg_intensity = NULL + WHERE id = target_pack_id; + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION handle_question_intensity_change() +RETURNS TRIGGER +LANGUAGE plpgsql +SET search_path = 'public' +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + PERFORM update_question_pack_intensity_stats(OLD.pack_id); + RETURN OLD; + END IF; + + IF TG_OP = 'UPDATE' AND NEW.pack_id IS DISTINCT FROM OLD.pack_id THEN + PERFORM update_question_pack_intensity_stats(OLD.pack_id); + END IF; + + PERFORM update_question_pack_intensity_stats(NEW.pack_id); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS question_pack_intensity_stats ON questions; + +CREATE TRIGGER question_pack_intensity_stats +AFTER INSERT OR UPDATE OR DELETE ON questions +FOR EACH ROW EXECUTE FUNCTION handle_question_intensity_change(); + +UPDATE question_packs qp +SET min_intensity = stats.min_intensity, + max_intensity = stats.max_intensity, + avg_intensity = stats.avg_intensity +FROM ( + SELECT pack_id, + MIN(intensity) AS min_intensity, + MAX(intensity) AS max_intensity, + ROUND(AVG(intensity)::numeric, 2) AS avg_intensity + FROM questions + GROUP BY pack_id +) stats +WHERE qp.id = stats.pack_id; diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index dda6454..cec301d 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -313,6 +313,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -449,6 +450,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -865,6 +867,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -1175,6 +1178,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -1367,6 +1371,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -1379,6 +1384,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -1674,6 +1680,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" },