mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 18:19:44 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
953bd7c03d | ||
|
|
4b8f4315e9 | ||
|
|
0a919f06fc |
No files matched your search
@@ -25,13 +25,14 @@ import Animated, {
|
|||||||
import { GradientBackground } from '../../src/components/ui/GradientBackground';
|
import { GradientBackground } from '../../src/components/ui/GradientBackground';
|
||||||
import { GlassCard } from '../../src/components/ui/GlassCard';
|
import { GlassCard } from '../../src/components/ui/GlassCard';
|
||||||
import { GlassButton } from '../../src/components/ui/GlassButton';
|
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 { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme';
|
||||||
import { useAuthStore } from '../../src/store';
|
import { useAuthStore } from '../../src/store';
|
||||||
import { supabase } from '../../src/lib/supabase';
|
import { supabase } from '../../src/lib/supabase';
|
||||||
import { getProfileError } from '../../src/lib/errors';
|
import { getProfileError } from '../../src/lib/errors';
|
||||||
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
|
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
|
||||||
import { Events } from '../../src/lib/analytics';
|
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 }[] = [
|
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
||||||
{ value: 'male', label: 'Male', icon: 'male' },
|
{ 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' },
|
{ 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';
|
type Stage = 'name' | 'gender' | 'purpose' | 'explicit' | 'notifications';
|
||||||
|
|
||||||
export default function OnboardingScreen() {
|
export default function OnboardingScreen() {
|
||||||
@@ -59,7 +73,7 @@ export default function OnboardingScreen() {
|
|||||||
const [name, setName] = useState(user?.name || '');
|
const [name, setName] = useState(user?.name || '');
|
||||||
const [gender, setGender] = useState<Gender | null>(user?.gender || null);
|
const [gender, setGender] = useState<Gender | null>(user?.gender || null);
|
||||||
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
|
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
|
||||||
const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? true);
|
const [maxIntensity, setMaxIntensity] = useState<IntensityLevel>(() => getInitialIntensity(user));
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
@@ -107,7 +121,7 @@ export default function OnboardingScreen() {
|
|||||||
Events.onboardingStageComplete('purpose');
|
Events.onboardingStageComplete('purpose');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExplicitContinue = () => {
|
const handleIntensityContinue = () => {
|
||||||
setStage('notifications');
|
setStage('notifications');
|
||||||
Events.onboardingStageComplete('explicit');
|
Events.onboardingStageComplete('explicit');
|
||||||
};
|
};
|
||||||
@@ -139,13 +153,16 @@ export default function OnboardingScreen() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const showExplicitContent = maxIntensity >= 3;
|
||||||
|
|
||||||
const { error: updateError } = await supabase
|
const { error: updateError } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.update({
|
.update({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
gender,
|
gender,
|
||||||
usage_reason: usageReason,
|
usage_reason: usageReason,
|
||||||
show_explicit_content: showExplicit,
|
max_intensity: maxIntensity,
|
||||||
|
show_explicit_content: showExplicitContent,
|
||||||
onboarding_completed: true,
|
onboarding_completed: true,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
@@ -159,7 +176,7 @@ export default function OnboardingScreen() {
|
|||||||
console.log('[Onboarding] Profile updated successfully, fetching user...');
|
console.log('[Onboarding] Profile updated successfully, fetching user...');
|
||||||
await fetchUser();
|
await fetchUser();
|
||||||
console.log('[Onboarding] User fetched, navigating to home...');
|
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();
|
Events.onboardingComplete();
|
||||||
router.replace('/');
|
router.replace('/');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -379,81 +396,22 @@ export default function OnboardingScreen() {
|
|||||||
colors={gradients.primary as [string, string]}
|
colors={gradients.primary as [string, string]}
|
||||||
style={styles.iconContainer}
|
style={styles.iconContainer}
|
||||||
>
|
>
|
||||||
<Ionicons name="flame" size={40} color={colors.text} />
|
<Ionicons name="heart-circle" size={44} color={colors.text} />
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
<Text style={styles.title}>Almost there!</Text>
|
<Text style={styles.title}>Set Your Comfort Zone</Text>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>
|
||||||
Shall we keep it clean or show you everything?
|
Choose the intimacy level you're comfortable with
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<GlassCard style={styles.card}>
|
<GlassCard style={styles.card}>
|
||||||
<Pressable
|
<IntensitySlider
|
||||||
style={styles.explicitOption}
|
value={maxIntensity}
|
||||||
onPress={() => setShowExplicit(true)}
|
onValueChange={setMaxIntensity}
|
||||||
>
|
|
||||||
<View style={[
|
|
||||||
styles.explicitIcon,
|
|
||||||
showExplicit && styles.explicitIconSelected
|
|
||||||
]}>
|
|
||||||
<Ionicons
|
|
||||||
name="flame"
|
|
||||||
size={32}
|
|
||||||
color={showExplicit ? colors.primary : colors.textSecondary}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
<Text style={styles.intensityNote}>
|
||||||
<View style={styles.explicitText}>
|
Start where you're comfortable — you can always adjust later
|
||||||
<Text style={[
|
|
||||||
styles.explicitTitle,
|
|
||||||
showExplicit && styles.explicitTitleSelected
|
|
||||||
]}>
|
|
||||||
Show me everything
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.explicitDescription}>
|
|
||||||
Include spicy question packs
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={[
|
|
||||||
styles.radioOuter,
|
|
||||||
showExplicit && styles.radioOuterSelected
|
|
||||||
]}>
|
|
||||||
{showExplicit && <View style={styles.radioInner} />}
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
style={styles.explicitOption}
|
|
||||||
onPress={() => setShowExplicit(false)}
|
|
||||||
>
|
|
||||||
<View style={[
|
|
||||||
styles.explicitIcon,
|
|
||||||
!showExplicit && styles.explicitIconSelected
|
|
||||||
]}>
|
|
||||||
<Ionicons
|
|
||||||
name="heart"
|
|
||||||
size={32}
|
|
||||||
color={!showExplicit ? colors.primary : colors.textSecondary}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={styles.explicitText}>
|
|
||||||
<Text style={[
|
|
||||||
styles.explicitTitle,
|
|
||||||
!showExplicit && styles.explicitTitleSelected
|
|
||||||
]}>
|
|
||||||
Keep it clean
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.explicitDescription}>
|
|
||||||
Only show SFW content
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={[
|
|
||||||
styles.radioOuter,
|
|
||||||
!showExplicit && styles.radioOuterSelected
|
|
||||||
]}>
|
|
||||||
{!showExplicit && <View style={styles.radioInner} />}
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<View style={styles.errorContainer}>
|
<View style={styles.errorContainer}>
|
||||||
<Ionicons name="alert-circle" size={16} color={colors.error} />
|
<Ionicons name="alert-circle" size={16} color={colors.error} />
|
||||||
@@ -464,7 +422,7 @@ export default function OnboardingScreen() {
|
|||||||
|
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<GlassButton
|
<GlassButton
|
||||||
onPress={handleExplicitContinue}
|
onPress={handleIntensityContinue}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@@ -762,42 +720,11 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.text,
|
color: colors.text,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
explicitOption: {
|
intensityNote: {
|
||||||
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: {
|
|
||||||
...typography.caption1,
|
...typography.caption1,
|
||||||
color: colors.textTertiary,
|
color: colors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: spacing.md,
|
||||||
},
|
},
|
||||||
radioOuter: {
|
radioOuter: {
|
||||||
width: 24,
|
width: 24,
|
||||||
|
|||||||
@@ -24,6 +24,35 @@ const HEADER_MAX_HEIGHT = 180;
|
|||||||
const HEADER_MIN_HEIGHT = 100;
|
const HEADER_MIN_HEIGHT = 100;
|
||||||
const HEADER_SCROLL_DISTANCE = HEADER_MAX_HEIGHT - HEADER_MIN_HEIGHT;
|
const HEADER_SCROLL_DISTANCE = HEADER_MAX_HEIGHT - HEADER_MIN_HEIGHT;
|
||||||
|
|
||||||
|
const INTENSITY_LABELS: Record<number, string> = {
|
||||||
|
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 {
|
interface PackRowProps {
|
||||||
pack: QuestionPack;
|
pack: QuestionPack;
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
@@ -36,6 +65,8 @@ interface PackRowProps {
|
|||||||
|
|
||||||
function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPress, onInfoPress }: PackRowProps) {
|
function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPress, onInfoPress }: PackRowProps) {
|
||||||
const questionCount = pack.questions?.[0]?.count || 0;
|
const questionCount = pack.questions?.[0]?.count || 0;
|
||||||
|
const intensityDisplay = getIntensityDisplay(pack);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={onPress} style={styles.packRow}>
|
<Pressable onPress={onPress} style={styles.packRow}>
|
||||||
@@ -62,7 +93,21 @@ function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPre
|
|||||||
{questionCount} {questionCount === 1 ? "question" : "questions"}
|
{questionCount} {questionCount === 1 ? "question" : "questions"}
|
||||||
{isEnabled && <Text style={styles.packMetaEnabled}> · Enabled</Text>}
|
{isEnabled && <Text style={styles.packMetaEnabled}> · Enabled</Text>}
|
||||||
</Text>
|
</Text>
|
||||||
|
<View style={styles.packIntensityRow}>
|
||||||
|
<View style={styles.packIntensityFlames}>
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<Ionicons
|
||||||
|
key={`intensity-${pack.id}-${index}`}
|
||||||
|
name={index < intensityDisplay.flameCount ? "flame" : "flame-outline"}
|
||||||
|
size={12}
|
||||||
|
color={index < intensityDisplay.flameCount ? colors.primary : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
<Text style={styles.packIntensityText}>{intensityDisplay.label}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={isExpanded ? "chevron-up" : "chevron-down"}
|
name={isExpanded ? "chevron-up" : "chevron-down"}
|
||||||
@@ -673,6 +718,21 @@ const styles = StyleSheet.create({
|
|||||||
packMetaEnabled: {
|
packMetaEnabled: {
|
||||||
color: colors.primary,
|
color: colors.primary,
|
||||||
},
|
},
|
||||||
|
packIntensityRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
},
|
||||||
|
packIntensityFlames: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
packIntensityText: {
|
||||||
|
...typography.caption2,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
|
||||||
// Expanded Pack
|
// Expanded Pack
|
||||||
packExpanded: {
|
packExpanded: {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { View, ActivityIndicator, StyleSheet } from "react-native";
|
|||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { isAuthenticated, isLoading, user } = useAuthStore();
|
const { isAuthenticated, isLoading, user } = useAuthStore();
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state while auth is being determined
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -24,12 +24,8 @@ export default function Index() {
|
|||||||
return <Redirect href="/(app)/onboarding" />;
|
return <Redirect href="/(app)/onboarding" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user has no couple, go to pairing
|
// Go to home/dashboard - users can pair from within the app
|
||||||
if (!user?.couple_id) {
|
// No forced redirect to pairing for users without a couple
|
||||||
return <Redirect href="/(app)/pairing" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise go to home
|
|
||||||
return <Redirect href="/(app)" />;
|
return <Redirect href="/(app)" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ describe('InputBar', () => {
|
|||||||
<InputBar
|
<InputBar
|
||||||
inputText=""
|
inputText=""
|
||||||
uploading={false}
|
uploading={false}
|
||||||
|
secureReady={true}
|
||||||
onChangeText={onChangeText}
|
onChangeText={onChangeText}
|
||||||
onSend={jest.fn()}
|
onSend={jest.fn()}
|
||||||
onPickMedia={jest.fn()}
|
onPickMedia={jest.fn()}
|
||||||
@@ -29,6 +30,7 @@ describe('InputBar', () => {
|
|||||||
<InputBar
|
<InputBar
|
||||||
inputText=""
|
inputText=""
|
||||||
uploading={false}
|
uploading={false}
|
||||||
|
secureReady={true}
|
||||||
onChangeText={jest.fn()}
|
onChangeText={jest.fn()}
|
||||||
onSend={onSend}
|
onSend={onSend}
|
||||||
onPickMedia={jest.fn()}
|
onPickMedia={jest.fn()}
|
||||||
@@ -48,6 +50,7 @@ describe('InputBar', () => {
|
|||||||
<InputBar
|
<InputBar
|
||||||
inputText=""
|
inputText=""
|
||||||
uploading={false}
|
uploading={false}
|
||||||
|
secureReady={true}
|
||||||
onChangeText={jest.fn()}
|
onChangeText={jest.fn()}
|
||||||
onSend={onSend}
|
onSend={onSend}
|
||||||
onPickMedia={jest.fn()}
|
onPickMedia={jest.fn()}
|
||||||
@@ -60,6 +63,7 @@ describe('InputBar', () => {
|
|||||||
<InputBar
|
<InputBar
|
||||||
inputText="hi"
|
inputText="hi"
|
||||||
uploading={false}
|
uploading={false}
|
||||||
|
secureReady={true}
|
||||||
onChangeText={jest.fn()}
|
onChangeText={jest.fn()}
|
||||||
onSend={onSend}
|
onSend={onSend}
|
||||||
onPickMedia={jest.fn()}
|
onPickMedia={jest.fn()}
|
||||||
@@ -72,6 +76,26 @@ describe('InputBar', () => {
|
|||||||
expect(onSend).toHaveBeenCalledTimes(1);
|
expect(onSend).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('disables send when secure messaging is not ready', () => {
|
||||||
|
const onSend = jest.fn();
|
||||||
|
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<InputBar
|
||||||
|
inputText="hello"
|
||||||
|
uploading={false}
|
||||||
|
secureReady={false}
|
||||||
|
onChangeText={jest.fn()}
|
||||||
|
onSend={onSend}
|
||||||
|
onPickMedia={jest.fn()}
|
||||||
|
onTakePhoto={jest.fn()}
|
||||||
|
onRecordVideo={jest.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.press(getByTestId('chat-input-send'));
|
||||||
|
expect(onSend).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('calls media actions from menu buttons', () => {
|
it('calls media actions from menu buttons', () => {
|
||||||
const onPickMedia = jest.fn();
|
const onPickMedia = jest.fn();
|
||||||
const onTakePhoto = jest.fn();
|
const onTakePhoto = jest.fn();
|
||||||
@@ -81,6 +105,7 @@ describe('InputBar', () => {
|
|||||||
<InputBar
|
<InputBar
|
||||||
inputText="hello"
|
inputText="hello"
|
||||||
uploading={false}
|
uploading={false}
|
||||||
|
secureReady={true}
|
||||||
onChangeText={jest.fn()}
|
onChangeText={jest.fn()}
|
||||||
onSend={jest.fn()}
|
onSend={jest.fn()}
|
||||||
onPickMedia={onPickMedia}
|
onPickMedia={onPickMedia}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ function createThenableQuery(result: any) {
|
|||||||
const query: any = {
|
const query: any = {
|
||||||
select: jest.fn(() => query),
|
select: jest.fn(() => query),
|
||||||
eq: jest.fn(() => query),
|
eq: jest.fn(() => query),
|
||||||
|
or: jest.fn(() => query),
|
||||||
order: jest.fn(() => query),
|
order: jest.fn(() => query),
|
||||||
upsert: jest.fn(() => query),
|
upsert: jest.fn(() => query),
|
||||||
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
|
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
|
||||||
@@ -17,10 +18,10 @@ describe('packsStore', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
usePacksStore.setState({ packs: [], categories: [], enabledPackIds: [], isLoading: false } as any);
|
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 categoriesQuery = createThenableQuery({ data: [{ id: 'cat1' }] });
|
||||||
const packsQuery = createThenableQuery({ data: [{ id: 'pack1', is_explicit: false }] });
|
const packsQuery = createThenableQuery({ data: [{ id: 'pack1', is_explicit: false }] });
|
||||||
const enabledQuery = createThenableQuery({ data: [{ pack_id: 'pack1' }] });
|
const enabledQuery = createThenableQuery({ data: [{ pack_id: 'pack1' }] });
|
||||||
@@ -32,8 +33,7 @@ describe('packsStore', () => {
|
|||||||
|
|
||||||
await usePacksStore.getState().fetchPacks();
|
await usePacksStore.getState().fetchPacks();
|
||||||
|
|
||||||
// Ensure explicit filter applied
|
expect(packsQuery.or).toHaveBeenCalledWith('max_intensity.is.null,max_intensity.lte.2');
|
||||||
expect(packsQuery.eq).toHaveBeenCalledWith('is_explicit', false);
|
|
||||||
|
|
||||||
const state = usePacksStore.getState();
|
const state = usePacksStore.getState();
|
||||||
expect(state.categories).toEqual([{ id: 'cat1' }]);
|
expect(state.categories).toEqual([{ id: 'cat1' }]);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Platform } from 'react-native';
|
|||||||
import { useEncryptionKeys } from '../hooks';
|
import { useEncryptionKeys } from '../hooks';
|
||||||
import { useAuthStore } from '../store';
|
import { useAuthStore } from '../store';
|
||||||
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
|
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
|
||||||
import { triggerKeyRotation } from '../lib/encryption';
|
import { triggerKeyRotation, getAdminPublicKey } from '../lib/encryption';
|
||||||
import { LoadingOverlay } from './ui/LoadingOverlay';
|
import { LoadingOverlay } from './ui/LoadingOverlay';
|
||||||
import { colors } from '../theme';
|
import { colors } from '../theme';
|
||||||
|
|
||||||
@@ -97,6 +97,16 @@ export function EncryptionKeyInitializer(): JSX.Element | null {
|
|||||||
|
|
||||||
console.log('[E2EE Init] Encryption keys initialized and uploaded');
|
console.log('[E2EE Init] Encryption keys initialized and uploaded');
|
||||||
|
|
||||||
|
// Pre-fetch admin key to warm the cache before user reaches chat
|
||||||
|
// This reduces the delay before secureReady becomes true
|
||||||
|
try {
|
||||||
|
await getAdminPublicKey();
|
||||||
|
console.log('[E2EE Init] Admin key pre-fetched successfully');
|
||||||
|
} catch (adminKeyError) {
|
||||||
|
// Non-fatal: admin key will be fetched again when needed
|
||||||
|
console.warn('[E2EE Init] Failed to pre-fetch admin key:', adminKeyError);
|
||||||
|
}
|
||||||
|
|
||||||
// Track if keys were regenerated (for rotation decision)
|
// Track if keys were regenerated (for rotation decision)
|
||||||
if (result.wasRegenerated) {
|
if (result.wasRegenerated) {
|
||||||
console.log('[E2EE Init] Keys were regenerated this session');
|
console.log('[E2EE Init] Keys were regenerated this session');
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<View style={[styles.container, disabled && styles.containerDisabled]}>
|
||||||
|
{/* Progress track */}
|
||||||
|
<View style={styles.trackContainer}>
|
||||||
|
<View style={styles.track}>
|
||||||
|
<Animated.View style={[styles.progressFill, progressStyle]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#9b59b6', HEAT_COLORS[value - 1]]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Level indicators */}
|
||||||
|
<View style={styles.levelsRow}>
|
||||||
|
{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 (
|
||||||
|
<Pressable
|
||||||
|
key={level.level}
|
||||||
|
style={styles.levelButton}
|
||||||
|
onPress={() => handleSelect(level.level)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<View style={styles.indicatorWrapper}>
|
||||||
|
{/* Partner indicator ring */}
|
||||||
|
{isPartnerLevel && !isSelected && (
|
||||||
|
<View style={styles.partnerRing}>
|
||||||
|
{partnerAvatar ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: partnerAvatar }}
|
||||||
|
style={styles.partnerAvatarSmall}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Ionicons name="heart" size={8} color={colors.secondary} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={[
|
||||||
|
styles.levelIndicator,
|
||||||
|
isActive && styles.levelIndicatorActive,
|
||||||
|
isSelected && {
|
||||||
|
backgroundColor: HEAT_COLORS[level.level - 1],
|
||||||
|
borderColor: HEAT_COLORS[level.level - 1],
|
||||||
|
},
|
||||||
|
isPartnerLevel && !isSelected && styles.levelIndicatorPartner,
|
||||||
|
]}>
|
||||||
|
{isSelected ? (
|
||||||
|
<Text style={styles.levelEmoji}>{level.emoji}</Text>
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name={isActive ? 'flame' : 'flame-outline'}
|
||||||
|
size={16}
|
||||||
|
color={isActive ? colors.text : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={[
|
||||||
|
styles.levelLabel,
|
||||||
|
isSelected && styles.levelLabelActive,
|
||||||
|
isPartnerLevel && !isSelected && styles.levelLabelPartner,
|
||||||
|
]}>
|
||||||
|
{level.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Current level card */}
|
||||||
|
<View style={[styles.currentCard, levelsDiffer && userIsHigher && styles.currentCardWarning]}>
|
||||||
|
<View style={styles.currentHeader}>
|
||||||
|
<Text style={styles.currentEmoji}>{current.emoji}</Text>
|
||||||
|
<Text style={styles.currentLabel}>{current.label}</Text>
|
||||||
|
{hasPartner && !levelsDiffer && (
|
||||||
|
<Pressable
|
||||||
|
style={styles.matchBadge}
|
||||||
|
onPress={() => setShowInfoModal(true)}
|
||||||
|
>
|
||||||
|
<Ionicons name="checkmark-circle" size={14} color={colors.success} />
|
||||||
|
<Text style={styles.matchBadgeText}>Matched</Text>
|
||||||
|
<Ionicons name="information-circle-outline" size={12} color={colors.success} style={{ marginLeft: 2 }} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.currentDescription}>{current.description}</Text>
|
||||||
|
|
||||||
|
{/* Mismatch message */}
|
||||||
|
{levelsDiffer && (
|
||||||
|
<Pressable
|
||||||
|
style={styles.mismatchRow}
|
||||||
|
onPress={() => setShowInfoModal(true)}
|
||||||
|
>
|
||||||
|
{partnerAvatar ? (
|
||||||
|
<Image source={{ uri: partnerAvatar }} style={styles.partnerAvatarTiny} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.partnerIconTiny}>
|
||||||
|
<Ionicons name="person" size={10} color={colors.secondary} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={[
|
||||||
|
styles.mismatchText,
|
||||||
|
userIsHigher && styles.mismatchTextWarning,
|
||||||
|
]}>
|
||||||
|
{getMismatchMessage()}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-forward"
|
||||||
|
size={14}
|
||||||
|
color={userIsHigher ? colors.warning : colors.textSecondary}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Info Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showInfoModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowInfoModal(false)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={styles.modalOverlay}
|
||||||
|
onPress={() => setShowInfoModal(false)}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.modalContent} onPress={e => e.stopPropagation()}>
|
||||||
|
{Platform.OS === 'ios' ? (
|
||||||
|
<BlurView intensity={40} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
|
) : null}
|
||||||
|
<View style={styles.modalInner}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
{levelsDiffer ? (
|
||||||
|
<LinearGradient
|
||||||
|
colors={userIsHigher ? ['#F39C12', '#E67E22'] : [colors.secondary, colors.primary]}
|
||||||
|
style={styles.modalIconGradient}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={userIsHigher ? "alert-circle" : "information-circle"}
|
||||||
|
size={28}
|
||||||
|
color={colors.text}
|
||||||
|
/>
|
||||||
|
</LinearGradient>
|
||||||
|
) : (
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colors.success, '#27AE60']}
|
||||||
|
style={styles.modalIconGradient}
|
||||||
|
>
|
||||||
|
<Ionicons name="heart" size={28} color={colors.text} />
|
||||||
|
</LinearGradient>
|
||||||
|
)}
|
||||||
|
<Text style={styles.modalTitle}>
|
||||||
|
{levelsDiffer
|
||||||
|
? (userIsHigher ? "Different Comfort Zones" : "You're in Sync")
|
||||||
|
: "You're Perfectly Matched"
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Comparison visual */}
|
||||||
|
{hasPartner && (
|
||||||
|
<View style={styles.comparisonContainer}>
|
||||||
|
<View style={styles.comparisonPerson}>
|
||||||
|
<View style={[styles.comparisonAvatar, { backgroundColor: HEAT_COLORS[value - 1] }]}>
|
||||||
|
<Text style={styles.comparisonEmoji}>{current.emoji}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.comparisonName}>You</Text>
|
||||||
|
<Text style={styles.comparisonLevel}>{current.label}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.comparisonDivider}>
|
||||||
|
<Ionicons
|
||||||
|
name={levelsDiffer ? "swap-horizontal" : "heart"}
|
||||||
|
size={20}
|
||||||
|
color={levelsDiffer ? colors.textTertiary : colors.success}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.comparisonPerson}>
|
||||||
|
{partnerAvatar ? (
|
||||||
|
<Image source={{ uri: partnerAvatar }} style={styles.comparisonAvatarImage} />
|
||||||
|
) : (
|
||||||
|
<View style={[styles.comparisonAvatar, { backgroundColor: HEAT_COLORS[(partnerValue ?? 1) - 1] }]}>
|
||||||
|
<Text style={styles.comparisonEmoji}>{partnerLevel?.emoji}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={styles.comparisonName}>{displayPartnerName}</Text>
|
||||||
|
<Text style={styles.comparisonLevel}>{partnerLevel?.label}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Explanation */}
|
||||||
|
<View style={styles.modalExplanation}>
|
||||||
|
{!levelsDiffer ? (
|
||||||
|
<>
|
||||||
|
<Text style={styles.modalText}>
|
||||||
|
You and {displayPartnerName} have the same comfort zone setting.
|
||||||
|
You'll both see the same question packs and can match on everything!
|
||||||
|
</Text>
|
||||||
|
<View style={styles.modalTip}>
|
||||||
|
<Ionicons name="sparkles" size={16} color={colors.success} />
|
||||||
|
<Text style={styles.modalTipText}>
|
||||||
|
Perfect alignment means more matches
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : userIsHigher ? (
|
||||||
|
<>
|
||||||
|
<Text style={styles.modalText}>
|
||||||
|
Your comfort zone is set higher than {displayPartnerName}'s.
|
||||||
|
This means some questions you see won't appear for them.
|
||||||
|
</Text>
|
||||||
|
<View style={[styles.modalTip, styles.modalTipWarning]}>
|
||||||
|
<Ionicons name="information-circle" size={16} color={colors.warning} />
|
||||||
|
<Text style={[styles.modalTipText, styles.modalTipTextWarning]}>
|
||||||
|
Swipes on {current.label} content won't match since {displayPartnerName} is at {partnerLevel?.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalSubtext}>
|
||||||
|
Consider aligning your levels, or enjoy different paces — it's your choice!
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.modalText}>
|
||||||
|
{displayPartnerName}'s comfort zone is higher than yours.
|
||||||
|
They might swipe on questions you don't see.
|
||||||
|
</Text>
|
||||||
|
<View style={styles.modalTip}>
|
||||||
|
<Ionicons name="shield-checkmark" size={16} color={colors.secondary} />
|
||||||
|
<Text style={styles.modalTipText}>
|
||||||
|
You'll only see content within your comfort zone
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Close button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalCloseButton}
|
||||||
|
onPress={() => setShowInfoModal(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.modalCloseText}>Got it</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -2,6 +2,7 @@ export { GradientBackground } from './GradientBackground';
|
|||||||
export { GlassCard } from './GlassCard';
|
export { GlassCard } from './GlassCard';
|
||||||
export { GlassButton } from './GlassButton';
|
export { GlassButton } from './GlassButton';
|
||||||
export { GlassInput } from './GlassInput';
|
export { GlassInput } from './GlassInput';
|
||||||
|
export { IntensitySlider } from './IntensitySlider';
|
||||||
export { ShimmerEffect } from './ShimmerEffect';
|
export { ShimmerEffect } from './ShimmerEffect';
|
||||||
export { DecorativeSeparator } from './DecorativeSeparator';
|
export { DecorativeSeparator } from './DecorativeSeparator';
|
||||||
export type { DecorativeSeparatorProps, SeparatorVariant } from './DecorativeSeparator';
|
export type { DecorativeSeparatorProps, SeparatorVariant } from './DecorativeSeparator';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { View, StyleSheet, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, Modal, Dimensions, Alert } from "react-native";
|
import { View, Text, StyleSheet, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, Modal, Dimensions, Alert } from "react-native";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { Video, ResizeMode } from "expo-av";
|
import { Video, ResizeMode } from "expo-av";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
@@ -19,7 +19,7 @@ import { useAuthStore, useMessageStore } from "../../store";
|
|||||||
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useEncryptedSend, useMediaSaver } from "../../hooks";
|
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useEncryptedSend, useMediaSaver } from "../../hooks";
|
||||||
import { Database } from "../../types/supabase";
|
import { Database } from "../../types/supabase";
|
||||||
import { GradientBackground } from "../../components/ui";
|
import { GradientBackground } from "../../components/ui";
|
||||||
import { colors, gradients, spacing } from "../../theme";
|
import { colors, gradients, radius, spacing, typography } from "../../theme";
|
||||||
import { Events } from "../../lib/analytics";
|
import { Events } from "../../lib/analytics";
|
||||||
|
|
||||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||||
@@ -75,7 +75,8 @@ export const ChatScreen: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// E2EE encryption hook for sending messages
|
// E2EE encryption hook for sending messages
|
||||||
const { encryptMessage, isE2EEAvailable } = useEncryptedSend();
|
const { encryptMessage, isE2EEAvailable, isAdminKeyReady, isEncrypting } = useEncryptedSend();
|
||||||
|
const secureReady = isE2EEAvailable && isAdminKeyReady;
|
||||||
|
|
||||||
// Message actions hook for deletion and reporting
|
// Message actions hook for deletion and reporting
|
||||||
const { showDeleteOptions, deleteForSelf } = useMessageActions({
|
const { showDeleteOptions, deleteForSelf } = useMessageActions({
|
||||||
@@ -181,22 +182,23 @@ export const ChatScreen: React.FC = () => {
|
|||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!inputText.trim()) return;
|
if (!inputText.trim()) return;
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
if (!secureReady) return;
|
||||||
|
if (isEncrypting) return;
|
||||||
|
|
||||||
const content = inputText.trim();
|
const content = inputText.trim();
|
||||||
setInputText("");
|
setInputText("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to encrypt the message if E2EE is available
|
// Encrypt the message (secure-only)
|
||||||
console.log(`[E2EE Send] isE2EEAvailable=${isE2EEAvailable}`);
|
console.log(`[E2EE Send] isE2EEAvailable=${isE2EEAvailable}`);
|
||||||
const encryptedPayload = isE2EEAvailable ? await encryptMessage(content) : null;
|
const encryptedPayload = await encryptMessage(content);
|
||||||
console.log(`[E2EE Send] encryptedPayload=`, encryptedPayload ? {
|
console.log(`[E2EE Send] encryptedPayload=`, {
|
||||||
version: encryptedPayload.version,
|
version: encryptedPayload.version,
|
||||||
encrypted_content_length: encryptedPayload.encrypted_content.length,
|
encrypted_content_length: encryptedPayload.encrypted_content.length,
|
||||||
encryption_iv_length: encryptedPayload.encryption_iv.length,
|
encryption_iv_length: encryptedPayload.encryption_iv.length,
|
||||||
keys_metadata: encryptedPayload.keys_metadata,
|
keys_metadata: encryptedPayload.keys_metadata,
|
||||||
} : null);
|
});
|
||||||
|
|
||||||
if (encryptedPayload) {
|
|
||||||
// Send encrypted message (v2)
|
// Send encrypted message (v2)
|
||||||
const { error, data } = await supabase.from("messages").insert({
|
const { error, data } = await supabase.from("messages").insert({
|
||||||
match_id: matchId,
|
match_id: matchId,
|
||||||
@@ -216,21 +218,6 @@ export const ChatScreen: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Events.messageSent();
|
Events.messageSent();
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Fallback to plaintext (v1) if E2EE not available
|
|
||||||
const { error } = await supabase.from("messages").insert({
|
|
||||||
match_id: matchId,
|
|
||||||
user_id: user.id,
|
|
||||||
content: content,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
Alert.alert("Error", "Failed to send message");
|
|
||||||
setInputText(content);
|
|
||||||
} else {
|
|
||||||
Events.messageSent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error sending message:", err);
|
console.error("Error sending message:", err);
|
||||||
// Show the actual error message if it's user-friendly (from encryption)
|
// Show the actual error message if it's user-friendly (from encryption)
|
||||||
@@ -252,6 +239,7 @@ export const ChatScreen: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePickMedia = async () => {
|
const handlePickMedia = async () => {
|
||||||
|
if (!secureReady) return;
|
||||||
const result = await pickMedia();
|
const result = await pickMedia();
|
||||||
if (result) {
|
if (result) {
|
||||||
uploadMedia(result.uri, result.mediaType);
|
uploadMedia(result.uri, result.mediaType);
|
||||||
@@ -259,6 +247,7 @@ export const ChatScreen: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTakePhoto = async () => {
|
const handleTakePhoto = async () => {
|
||||||
|
if (!secureReady) return;
|
||||||
const result = await takePhoto();
|
const result = await takePhoto();
|
||||||
if (result) {
|
if (result) {
|
||||||
uploadMedia(result.uri, 'image');
|
uploadMedia(result.uri, 'image');
|
||||||
@@ -266,6 +255,7 @@ export const ChatScreen: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRecordVideo = async () => {
|
const handleRecordVideo = async () => {
|
||||||
|
if (!secureReady) return;
|
||||||
const result = await recordVideo();
|
const result = await recordVideo();
|
||||||
if (result) {
|
if (result) {
|
||||||
uploadMedia(result.uri, 'video');
|
uploadMedia(result.uri, 'video');
|
||||||
@@ -358,9 +348,17 @@ export const ChatScreen: React.FC = () => {
|
|||||||
{/* Top border glow */}
|
{/* Top border glow */}
|
||||||
<View style={styles.inputWrapperBorder} />
|
<View style={styles.inputWrapperBorder} />
|
||||||
|
|
||||||
|
{!secureReady && (
|
||||||
|
<View style={styles.secureStatus}>
|
||||||
|
<Ionicons name="lock-closed-outline" size={14} color={colors.textSecondary} />
|
||||||
|
<Text style={styles.secureStatusText}>Finishing security setup...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<InputBar
|
<InputBar
|
||||||
inputText={inputText}
|
inputText={inputText}
|
||||||
uploading={uploading}
|
uploading={uploading}
|
||||||
|
secureReady={secureReady}
|
||||||
onChangeText={handleTyping}
|
onChangeText={handleTyping}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onPickMedia={handlePickMedia}
|
onPickMedia={handlePickMedia}
|
||||||
@@ -565,4 +563,22 @@ const styles = StyleSheet.create({
|
|||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: `${ACCENT_RGBA}0.15)`,
|
backgroundColor: `${ACCENT_RGBA}0.15)`,
|
||||||
},
|
},
|
||||||
|
secureStatus: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
marginHorizontal: spacing.md,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
secureStatusText: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -14,6 +14,7 @@ import { colors, gradients, spacing, radius, typography } from '../../../theme';
|
|||||||
export interface InputBarProps {
|
export interface InputBarProps {
|
||||||
inputText: string;
|
inputText: string;
|
||||||
uploading: boolean;
|
uploading: boolean;
|
||||||
|
secureReady: boolean;
|
||||||
onChangeText: (text: string) => void;
|
onChangeText: (text: string) => void;
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
onPickMedia: () => void;
|
onPickMedia: () => void;
|
||||||
@@ -30,6 +31,7 @@ const ACCENT = colors.premium.gold;
|
|||||||
export function InputBar({
|
export function InputBar({
|
||||||
inputText,
|
inputText,
|
||||||
uploading,
|
uploading,
|
||||||
|
secureReady,
|
||||||
onChangeText,
|
onChangeText,
|
||||||
onSend,
|
onSend,
|
||||||
onPickMedia,
|
onPickMedia,
|
||||||
@@ -63,12 +65,17 @@ export function InputBar({
|
|||||||
transform: [{ rotate: `${buttonRotation.value}deg` }],
|
transform: [{ rotate: `${buttonRotation.value}deg` }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const sendDisabled = !inputText.trim() || !secureReady;
|
||||||
|
const mediaDisabled = uploading || !secureReady;
|
||||||
|
// Determine if send is disabled specifically because of security (not empty input)
|
||||||
|
const waitingForSecurity = !secureReady && inputText.trim().length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Plus button to toggle media menu */}
|
{/* Plus button to toggle media menu */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={toggleMenu}
|
onPress={toggleMenu}
|
||||||
disabled={uploading}
|
disabled={mediaDisabled}
|
||||||
style={styles.attachButton}
|
style={styles.attachButton}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Toggle media menu"
|
accessibilityLabel="Toggle media menu"
|
||||||
@@ -89,6 +96,7 @@ export function InputBar({
|
|||||||
onPress={() => handleMediaAction(onTakePhoto)}
|
onPress={() => handleMediaAction(onTakePhoto)}
|
||||||
style={styles.mediaMenuItem}
|
style={styles.mediaMenuItem}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
disabled={mediaDisabled}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Take photo"
|
accessibilityLabel="Take photo"
|
||||||
testID="chat-input-take-photo"
|
testID="chat-input-take-photo"
|
||||||
@@ -102,6 +110,7 @@ export function InputBar({
|
|||||||
onPress={() => handleMediaAction(onRecordVideo)}
|
onPress={() => handleMediaAction(onRecordVideo)}
|
||||||
style={styles.mediaMenuItem}
|
style={styles.mediaMenuItem}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
disabled={mediaDisabled}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Record video"
|
accessibilityLabel="Record video"
|
||||||
testID="chat-input-record-video"
|
testID="chat-input-record-video"
|
||||||
@@ -115,6 +124,7 @@ export function InputBar({
|
|||||||
onPress={() => handleMediaAction(onPickMedia)}
|
onPress={() => handleMediaAction(onPickMedia)}
|
||||||
style={styles.mediaMenuItem}
|
style={styles.mediaMenuItem}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
disabled={mediaDisabled}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Pick media"
|
accessibilityLabel="Pick media"
|
||||||
testID="chat-input-pick-media"
|
testID="chat-input-pick-media"
|
||||||
@@ -140,20 +150,21 @@ export function InputBar({
|
|||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onSend}
|
onPress={onSend}
|
||||||
disabled={!inputText.trim()}
|
disabled={sendDisabled}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Send message"
|
accessibilityLabel={waitingForSecurity ? "Send message - securing connection" : "Send message"}
|
||||||
|
accessibilityHint={waitingForSecurity ? "Please wait while we secure your connection" : undefined}
|
||||||
testID="chat-input-send"
|
testID="chat-input-send"
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={inputText.trim() ? gradients.primary as [string, string] : ['rgba(22, 33, 62, 0.6)', 'rgba(22, 33, 62, 0.6)']}
|
colors={sendDisabled ? ['rgba(22, 33, 62, 0.6)', 'rgba(22, 33, 62, 0.6)'] : gradients.primary as [string, string]}
|
||||||
style={styles.sendButton}
|
style={styles.sendButton}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 1, y: 1 }}
|
end={{ x: 1, y: 1 }}
|
||||||
>
|
>
|
||||||
{/* Silk highlight */}
|
{/* Silk highlight */}
|
||||||
{inputText.trim() && (
|
{!sendDisabled && (
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['rgba(255, 255, 255, 0.2)', 'transparent']}
|
colors={['rgba(255, 255, 255, 0.2)', 'transparent']}
|
||||||
style={styles.sendButtonHighlight}
|
style={styles.sendButtonHighlight}
|
||||||
@@ -162,9 +173,9 @@ export function InputBar({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="send"
|
name={waitingForSecurity ? "lock-closed" : "send"}
|
||||||
size={18}
|
size={waitingForSecurity ? 16 : 18}
|
||||||
color={inputText.trim() ? colors.text : colors.textTertiary}
|
color={sendDisabled ? colors.textTertiary : colors.text}
|
||||||
/>
|
/>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { Alert, Platform } from 'react-native';
|
import { Alert, Platform } from 'react-native';
|
||||||
import * as FileSystem from 'expo-file-system';
|
import * as FileSystem from 'expo-file-system';
|
||||||
import { Video as VideoCompressor } from 'react-native-compressor';
|
import { Video as VideoCompressor } from 'react-native-compressor';
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
MESSAGE_VERSION_E2EE,
|
MESSAGE_VERSION_E2EE,
|
||||||
isValidPublicKeyJwk,
|
isValidPublicKeyJwk,
|
||||||
sanitizePublicKeyJwk,
|
sanitizePublicKeyJwk,
|
||||||
|
verifyKeyPair,
|
||||||
} from '../../../lib/encryption';
|
} from '../../../lib/encryption';
|
||||||
import type { RSAPublicKeyJWK, EncryptedMediaPayload } from '../../../lib/encryption';
|
import type { RSAPublicKeyJWK, EncryptedMediaPayload } from '../../../lib/encryption';
|
||||||
import { useEncryptionKeys } from '../../../hooks';
|
import { useEncryptionKeys } from '../../../hooks';
|
||||||
@@ -22,12 +23,16 @@ import { useEncryptionKeys } from '../../../hooks';
|
|||||||
export function useMediaUpload(matchId: string, userId: string | undefined) {
|
export function useMediaUpload(matchId: string, userId: string | undefined) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [uploadStatus, setUploadStatus] = useState<UploadStatus>(null);
|
const [uploadStatus, setUploadStatus] = useState<UploadStatus>(null);
|
||||||
|
const verifiedKeysVersionRef = useRef<number | null>(null);
|
||||||
|
const partnerRefreshRef = useRef(0);
|
||||||
|
const PARTNER_REFRESH_TTL_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
const refreshPartner = useAuthStore((s) => s.refreshPartner);
|
const refreshPartner = useAuthStore((s) => s.refreshPartner);
|
||||||
const { publicKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
|
const { publicKeyJwk, privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
|
||||||
|
|
||||||
// Check if E2EE is available for media
|
// Check if E2EE is available for media
|
||||||
const isE2EEAvailable = Platform.OS !== 'web' && hasKeys && !keysLoading && !!publicKeyJwk;
|
const isE2EEAvailable =
|
||||||
|
Platform.OS !== 'web' && hasKeys && !keysLoading && !!publicKeyJwk && !!privateKeyJwk;
|
||||||
|
|
||||||
const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => {
|
const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
@@ -35,10 +40,17 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
|||||||
setUploadStatus({ mediaType, status: mediaType === 'video' ? 'compressing' : 'uploading', thumbnailUri: uri });
|
setUploadStatus({ mediaType, status: mediaType === 'video' ? 'compressing' : 'uploading', thumbnailUri: uri });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Block media uploads until encryption is ready (secure-only)
|
||||||
|
if (!isE2EEAvailable || Platform.OS === 'web') {
|
||||||
|
const readyError = new Error('Media security not ready. Please check your connection.');
|
||||||
|
console.warn('[E2EE Media Upload] E2EE not available, blocking upload');
|
||||||
|
throw readyError;
|
||||||
|
}
|
||||||
|
|
||||||
let fileUri = uri;
|
let fileUri = uri;
|
||||||
let fileBody;
|
let fileBody;
|
||||||
let ext = mediaType === 'video' ? 'mp4' : 'jpg';
|
let ext = mediaType === 'video' ? 'mp4' : 'jpg';
|
||||||
let encryptedPayload: EncryptedMediaPayload | null = null;
|
let encryptedPayload: EncryptedMediaPayload;
|
||||||
|
|
||||||
// Compress videos on native platforms before upload
|
// Compress videos on native platforms before upload
|
||||||
if (mediaType === 'video' && Platform.OS !== 'web') {
|
if (mediaType === 'video' && Platform.OS !== 'web') {
|
||||||
@@ -61,27 +73,80 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to encrypt the media if E2EE is available
|
|
||||||
if (isE2EEAvailable && Platform.OS !== 'web') {
|
|
||||||
try {
|
|
||||||
// CRITICAL: Read keys directly from global store at encryption time
|
// CRITICAL: Read keys directly from global store at encryption time
|
||||||
// This ensures we always use the most current keys, not potentially stale hook state
|
// This ensures we always use the most current keys, not potentially stale hook state
|
||||||
const currentKeys = useAuthStore.getState().encryptionKeys;
|
const currentKeys = useAuthStore.getState().encryptionKeys;
|
||||||
|
|
||||||
if (!currentKeys.hasKeys || !currentKeys.publicKeyJwk) {
|
if (!currentKeys.hasKeys || !currentKeys.publicKeyJwk || !currentKeys.privateKeyJwk) {
|
||||||
console.warn('[E2EE Media Upload] No keys available in global store at encryption time');
|
console.warn('[E2EE Media Upload] Keys not fully available in global store at encryption time');
|
||||||
throw new Error('No encryption keys available');
|
throw new Error('Media security not ready. Please check your connection.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const senderPublicKey = currentKeys.publicKeyJwk;
|
const senderPublicKey = currentKeys.publicKeyJwk;
|
||||||
|
const senderPrivateKey = currentKeys.privateKeyJwk;
|
||||||
|
|
||||||
|
// SAFETY CHECK: Verify the key pair works together before encrypting
|
||||||
|
if (verifiedKeysVersionRef.current !== currentKeys.keysVersion) {
|
||||||
|
console.log(
|
||||||
|
`[E2EE Media Upload] Verifying sender key pair (version ${currentKeys.keysVersion})...`
|
||||||
|
);
|
||||||
|
const isValidKeyPair = await verifyKeyPair(
|
||||||
|
senderPublicKey as RSAPublicKeyJWK,
|
||||||
|
senderPrivateKey
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValidKeyPair) {
|
||||||
|
console.error('[E2EE Media Upload] CRITICAL: Sender key pair verification failed!');
|
||||||
|
throw new Error('Security check failed. Please restart the app.');
|
||||||
|
}
|
||||||
|
|
||||||
|
verifiedKeysVersionRef.current = currentKeys.keysVersion;
|
||||||
|
console.log('[E2EE Media Upload] Sender key pair verified successfully');
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[E2EE Media Upload] Using sender public key from global store, version:', currentKeys.keysVersion);
|
console.log('[E2EE Media Upload] Using sender public key from global store, version:', currentKeys.keysVersion);
|
||||||
|
|
||||||
// Refresh partner's profile to get their latest public key
|
// Refresh partner's profile only when key is missing/invalid or TTL expires
|
||||||
|
const resolvePartnerProfile = async () => {
|
||||||
|
const cachedPartner = useAuthStore.getState().partner;
|
||||||
|
const cachedKey = cachedPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
|
||||||
|
const hasValidKey = cachedKey ? isValidPublicKeyJwk(cachedKey) : false;
|
||||||
|
const now = Date.now();
|
||||||
|
const shouldRefresh = !hasValidKey || (now - partnerRefreshRef.current) > PARTNER_REFRESH_TTL_MS;
|
||||||
|
|
||||||
|
if (!shouldRefresh) {
|
||||||
|
return cachedPartner;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[E2EE Media Upload] Refreshing partner profile...');
|
console.log('[E2EE Media Upload] Refreshing partner profile...');
|
||||||
|
try {
|
||||||
const freshPartner = await refreshPartner();
|
const freshPartner = await refreshPartner();
|
||||||
|
partnerRefreshRef.current = Date.now();
|
||||||
|
return freshPartner ?? useAuthStore.getState().partner;
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.warn('[E2EE Media Upload] Partner refresh failed, using cached key:', refreshError);
|
||||||
|
partnerRefreshRef.current = Date.now();
|
||||||
|
return cachedPartner;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const freshPartner = await resolvePartnerProfile();
|
||||||
|
|
||||||
|
let adminPublicKey: RSAPublicKeyJWK;
|
||||||
|
let adminKeyId: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
adminPublicKey = await getAdminPublicKey();
|
||||||
|
adminKeyId = await getAdminKeyId();
|
||||||
|
|
||||||
|
if (!isValidPublicKeyJwk(adminPublicKey)) {
|
||||||
|
throw new Error('Admin public key is invalid');
|
||||||
|
}
|
||||||
|
} catch (adminKeyError) {
|
||||||
|
console.error('[E2EE Media Upload] Failed to fetch admin key:', adminKeyError);
|
||||||
|
throw new Error('Connection issue. Please try again.');
|
||||||
|
}
|
||||||
|
|
||||||
const adminPublicKey = await getAdminPublicKey();
|
|
||||||
const adminKeyId = await getAdminKeyId();
|
|
||||||
let partnerPublicKey = freshPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
|
let partnerPublicKey = freshPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
|
||||||
|
|
||||||
// Validate and sanitize partner's public key
|
// Validate and sanitize partner's public key
|
||||||
@@ -109,11 +174,6 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
|||||||
fileUri = encryptedPayload.encryptedFileUri;
|
fileUri = encryptedPayload.encryptedFileUri;
|
||||||
ext = 'enc'; // Encrypted files use .enc extension
|
ext = 'enc'; // Encrypted files use .enc extension
|
||||||
console.log('Media encrypted successfully');
|
console.log('Media encrypted successfully');
|
||||||
} catch (encryptError) {
|
|
||||||
console.warn('Media encryption failed, uploading unencrypted:', encryptError);
|
|
||||||
encryptedPayload = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Platform.OS === 'web') {
|
if (Platform.OS === 'web') {
|
||||||
const response = await fetch(fileUri);
|
const response = await fetch(fileUri);
|
||||||
@@ -135,21 +195,10 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
|||||||
const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 });
|
const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 });
|
||||||
fileBody = decode(base64);
|
fileBody = decode(base64);
|
||||||
|
|
||||||
if (mediaType !== 'video' && !encryptedPayload) {
|
|
||||||
// Only use URI extension for non-videos and non-encrypted (videos are always mp4 after compression)
|
|
||||||
const uriExt = fileUri.split('.').pop();
|
|
||||||
if (uriExt && uriExt !== fileUri) {
|
|
||||||
ext = uriExt.toLowerCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
|
const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
|
||||||
const contentType = encryptedPayload
|
const contentType = 'application/octet-stream';
|
||||||
? 'application/octet-stream' // Encrypted files are binary
|
|
||||||
: mediaType === 'video'
|
|
||||||
? `video/${ext === 'mov' ? 'quicktime' : ext}`
|
|
||||||
: `image/${ext === 'jpg' ? 'jpeg' : ext}`;
|
|
||||||
|
|
||||||
const { error: uploadError } = await supabase.storage
|
const { error: uploadError } = await supabase.storage
|
||||||
.from("chat-media")
|
.from("chat-media")
|
||||||
@@ -169,8 +218,7 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the message with encryption metadata if encrypted
|
// Store the message with encryption metadata
|
||||||
if (encryptedPayload) {
|
|
||||||
await supabase.from("messages").insert({
|
await supabase.from("messages").insert({
|
||||||
match_id: matchId,
|
match_id: matchId,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
@@ -182,17 +230,6 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
|||||||
encryption_iv: encryptedPayload.encryption_iv,
|
encryption_iv: encryptedPayload.encryption_iv,
|
||||||
keys_metadata: encryptedPayload.keys_metadata as unknown as Record<string, unknown>,
|
keys_metadata: encryptedPayload.keys_metadata as unknown as Record<string, unknown>,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
// Unencrypted upload (v1)
|
|
||||||
await supabase.from("messages").insert({
|
|
||||||
match_id: matchId,
|
|
||||||
user_id: userId,
|
|
||||||
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
|
|
||||||
media_path: fileName,
|
|
||||||
media_type: mediaType,
|
|
||||||
media_expired: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Events.mediaUploaded();
|
Events.mediaUploaded();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { Ionicons } from '@expo/vector-icons';
|
||||||
import Constants from 'expo-constants';
|
import Constants from 'expo-constants';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -10,13 +10,12 @@ import Animated, {
|
|||||||
interpolate,
|
interpolate,
|
||||||
Extrapolation
|
Extrapolation
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
|
|
||||||
import { GradientBackground, GlassCard } from '../../components/ui';
|
import { GradientBackground } from '../../components/ui';
|
||||||
import { FeedbackModal } from '../../components/feedback';
|
import { FeedbackModal } from '../../components/feedback';
|
||||||
import { Paywall } from '../../components/paywall';
|
import { Paywall } from '../../components/paywall';
|
||||||
import { colors, featureColors, spacing, typography, radius, shadows } from '../../theme';
|
import { colors, featureColors, spacing, typography, radius } from '../../theme';
|
||||||
import { useAuthStore, useSubscriptionStore } from '../../store';
|
import { useAuthStore, useSubscriptionStore } from '../../store';
|
||||||
import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen';
|
import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen';
|
||||||
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
|
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
|
||||||
@@ -27,10 +26,9 @@ import { clearKeys } from '../../lib/encryption';
|
|||||||
import { useProfileSettings, useCoupleManagement } from './hooks';
|
import { useProfileSettings, useCoupleManagement } from './hooks';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings, DangerZone, ResetProgress, SettingsSection, MenuItem } from './components';
|
import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings, DangerZone, SettingsSection, MenuItem, SubscriptionCard } from './components';
|
||||||
|
|
||||||
const MAX_CONTENT_WIDTH = 500;
|
const MAX_CONTENT_WIDTH = 500;
|
||||||
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
|
||||||
const NAV_BAR_HEIGHT = 44;
|
const NAV_BAR_HEIGHT = 44;
|
||||||
const STATUS_BAR_HEIGHT = 60;
|
const STATUS_BAR_HEIGHT = 60;
|
||||||
const HEADER_SCROLL_DISTANCE = 100;
|
const HEADER_SCROLL_DISTANCE = 100;
|
||||||
@@ -188,77 +186,16 @@ export function ProfileScreen() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Subscription Section */}
|
{/* Subscription Section */}
|
||||||
<SettingsSection title="Subscription" delay={350}>
|
<SubscriptionCard
|
||||||
{hasPremiumAccess ? (
|
hasPremiumAccess={hasPremiumAccess}
|
||||||
<View style={styles.rowContainer}>
|
isOwnSubscription={!!isOwnSubscription}
|
||||||
<View style={styles.rowLeft}>
|
expirationDate={formatExpirationDate(subscription.expirationDate)}
|
||||||
<LinearGradient
|
onUpgradePress={() => settings.setShowPaywall(true)}
|
||||||
colors={ACCENT_GRADIENT}
|
onManagePress={settings.handleManageSubscription}
|
||||||
style={styles.partnerIconGradient}
|
onRestorePress={settings.handleRestorePurchases}
|
||||||
start={{ x: 0, y: 0 }}
|
isRestoring={settings.isPurchasing}
|
||||||
end={{ x: 1, y: 1 }}
|
delay={350}
|
||||||
>
|
/>
|
||||||
<Ionicons name="star" size={20} color={colors.text} />
|
|
||||||
</LinearGradient>
|
|
||||||
<View style={styles.rowTextContainer}>
|
|
||||||
<Text style={styles.rowValue}>Pro Member</Text>
|
|
||||||
<Text style={styles.rowLabel}>
|
|
||||||
{isOwnSubscription
|
|
||||||
? `Renews ${formatExpirationDate(subscription.expirationDate)}`
|
|
||||||
: "Via partner's subscription"
|
|
||||||
}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{isOwnSubscription && (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.manageButton}
|
|
||||||
onPress={settings.handleManageSubscription}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Text style={styles.manageButtonText}>Manage</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.rowContainer}
|
|
||||||
onPress={() => settings.setShowPaywall(true)}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<View style={styles.rowLeft}>
|
|
||||||
<View style={styles.emptyPartnerIcon}>
|
|
||||||
<Ionicons name="star-outline" size={20} color={colors.textTertiary} />
|
|
||||||
</View>
|
|
||||||
<View style={styles.rowTextContainer}>
|
|
||||||
<Text style={styles.rowValueMuted}>Free Plan</Text>
|
|
||||||
<Text style={styles.rowLabel}>Upgrade to unlock all packs</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<LinearGradient
|
|
||||||
colors={ACCENT_GRADIENT}
|
|
||||||
style={styles.upgradeButton}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
>
|
|
||||||
<Text style={styles.upgradeButtonText}>Upgrade</Text>
|
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Restore Purchases Link */}
|
|
||||||
{!hasPremiumAccess && (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.restoreLink}
|
|
||||||
onPress={settings.handleRestorePurchases}
|
|
||||||
disabled={settings.isPurchasing}
|
|
||||||
>
|
|
||||||
<Text style={styles.restoreLinkText}>
|
|
||||||
{settings.isPurchasing ? "Restoring..." : "Restore Purchases"}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<NotificationSettings
|
<NotificationSettings
|
||||||
@@ -269,14 +206,17 @@ export function ProfileScreen() {
|
|||||||
|
|
||||||
{/* Preferences */}
|
{/* Preferences */}
|
||||||
<PrivacySettings
|
<PrivacySettings
|
||||||
showExplicit={settings.showExplicit}
|
maxIntensity={settings.maxIntensity}
|
||||||
isUpdatingExplicit={settings.isUpdatingExplicit}
|
isUpdatingIntensity={settings.isUpdatingIntensity}
|
||||||
onExplicitToggle={settings.handleExplicitToggle}
|
onIntensityChange={settings.handleIntensityChange}
|
||||||
biometricAvailable={settings.biometricAvailable}
|
biometricAvailable={settings.biometricAvailable}
|
||||||
biometricEnabled={settings.biometricEnabled}
|
biometricEnabled={settings.biometricEnabled}
|
||||||
biometricType={settings.biometricType}
|
biometricType={settings.biometricType}
|
||||||
isUpdatingBiometric={settings.isUpdatingBiometric}
|
isUpdatingBiometric={settings.isUpdatingBiometric}
|
||||||
onBiometricToggle={settings.handleBiometricToggle}
|
onBiometricToggle={settings.handleBiometricToggle}
|
||||||
|
partnerIntensity={settings.partnerIntensity}
|
||||||
|
partnerName={settings.partnerName}
|
||||||
|
partnerAvatar={settings.partnerAvatar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Account */}
|
{/* Account */}
|
||||||
@@ -298,13 +238,20 @@ export function ProfileScreen() {
|
|||||||
description="Report bugs or request features"
|
description="Report bugs or request features"
|
||||||
onPress={() => setShowFeedbackModal(true)}
|
onPress={() => setShowFeedbackModal(true)}
|
||||||
/>
|
/>
|
||||||
|
<View style={styles.preferencesDivider} />
|
||||||
|
<MenuItem
|
||||||
|
icon="shield-checkmark-outline"
|
||||||
|
label="Privacy Policy"
|
||||||
|
onPress={() => Linking.openURL('https://sauci.app/privacy')}
|
||||||
|
/>
|
||||||
|
<View style={styles.preferencesDivider} />
|
||||||
|
<MenuItem
|
||||||
|
icon="document-text-outline"
|
||||||
|
label="Terms of Service"
|
||||||
|
onPress={() => Linking.openURL('https://sauci.app/terms')}
|
||||||
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Reset Progress */}
|
|
||||||
{couple && (
|
|
||||||
<ResetProgress onResetProgress={handleResetProgress} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Debug Section */}
|
{/* Debug Section */}
|
||||||
{__DEV__ && (
|
{__DEV__ && (
|
||||||
<SettingsSection title="Debug" delay={550}>
|
<SettingsSection title="Debug" delay={550}>
|
||||||
@@ -375,6 +322,7 @@ export function ProfileScreen() {
|
|||||||
|
|
||||||
{/* Danger Zone */}
|
{/* Danger Zone */}
|
||||||
<DangerZone
|
<DangerZone
|
||||||
|
onResetProgress={couple ? handleResetProgress : undefined}
|
||||||
onDeleteRelationship={couple ? handleDeleteRelationship : undefined}
|
onDeleteRelationship={couple ? handleDeleteRelationship : undefined}
|
||||||
onDeleteAccount={handleDeleteAccount}
|
onDeleteAccount={handleDeleteAccount}
|
||||||
hasRelationship={!!couple}
|
hasRelationship={!!couple}
|
||||||
@@ -483,90 +431,11 @@ const styles = StyleSheet.create({
|
|||||||
marginHorizontal: spacing.sm,
|
marginHorizontal: spacing.sm,
|
||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
},
|
},
|
||||||
// Shared row styles
|
|
||||||
rowContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
},
|
|
||||||
rowLeft: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
partnerIconGradient: {
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 22,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
emptyPartnerIcon: {
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 22,
|
|
||||||
backgroundColor: colors.glass.background,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
rowTextContainer: {
|
|
||||||
marginLeft: spacing.md,
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
rowValue: {
|
|
||||||
...typography.body,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.text,
|
|
||||||
},
|
|
||||||
rowValueMuted: {
|
|
||||||
...typography.body,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.textSecondary,
|
|
||||||
},
|
|
||||||
rowLabel: {
|
|
||||||
...typography.caption1,
|
|
||||||
color: colors.textTertiary,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
preferencesDivider: {
|
preferencesDivider: {
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: colors.glass.border,
|
backgroundColor: colors.glass.border,
|
||||||
marginVertical: spacing.md,
|
marginVertical: spacing.md,
|
||||||
},
|
},
|
||||||
// Subscription styles
|
|
||||||
manageButton: {
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
backgroundColor: colors.glass.background,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.glass.border,
|
|
||||||
},
|
|
||||||
manageButtonText: {
|
|
||||||
...typography.subhead,
|
|
||||||
color: colors.secondary,
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
upgradeButton: {
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
},
|
|
||||||
upgradeButtonText: {
|
|
||||||
...typography.subhead,
|
|
||||||
color: colors.text,
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
restoreLink: {
|
|
||||||
alignItems: "center",
|
|
||||||
marginTop: spacing.sm,
|
|
||||||
padding: spacing.sm,
|
|
||||||
},
|
|
||||||
restoreLinkText: {
|
|
||||||
...typography.caption1,
|
|
||||||
color: colors.textTertiary,
|
|
||||||
},
|
|
||||||
// Version Badge
|
// Version Badge
|
||||||
versionContainer: {
|
versionContainer: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { SettingsSection } from './SettingsSection';
|
import { SettingsSection } from './SettingsSection';
|
||||||
import { colors, featureColors, spacing, typography } from '../../../theme';
|
import { colors, featureColors, spacing, typography, radius } from '../../../theme';
|
||||||
import { Profile, Couple } from '../../../types';
|
import { Profile, Couple } from '../../../types';
|
||||||
|
|
||||||
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
||||||
@@ -15,30 +16,79 @@ interface CoupleStatusProps {
|
|||||||
onPairingPress: () => void;
|
onPairingPress: () => 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<CoupleStatusProps> = ({
|
export const CoupleStatus: React.FC<CoupleStatusProps> = ({
|
||||||
partner,
|
partner,
|
||||||
couple,
|
couple,
|
||||||
onUnpair,
|
onUnpair,
|
||||||
onPairingPress,
|
onPairingPress,
|
||||||
}) => {
|
}) => {
|
||||||
|
const pairedLabel = formatPairedDate(couple?.created_at);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title="Partner" delay={300}>
|
<SettingsSection title="Partner" delay={300}>
|
||||||
{partner ? (
|
{partner ? (
|
||||||
<View style={styles.rowContainer}>
|
<View style={styles.rowContainer}>
|
||||||
<View style={styles.rowLeft}>
|
<View style={styles.rowLeft}>
|
||||||
|
{partner.avatar_url ? (
|
||||||
|
<View style={styles.partnerAvatarContainer}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={ACCENT_GRADIENT}
|
||||||
|
style={styles.partnerAvatarGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: partner.avatar_url }}
|
||||||
|
style={styles.partnerAvatar}
|
||||||
|
cachePolicy="disk"
|
||||||
|
transition={200}
|
||||||
|
/>
|
||||||
|
</LinearGradient>
|
||||||
|
<View style={styles.connectedBadge}>
|
||||||
|
<Ionicons name="heart" size={10} color={colors.text} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={ACCENT_GRADIENT}
|
colors={ACCENT_GRADIENT}
|
||||||
style={styles.partnerIconGradient}
|
style={styles.partnerIconGradient}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 1, y: 1 }}
|
end={{ x: 1, y: 1 }}
|
||||||
>
|
>
|
||||||
<Ionicons name="heart" size={20} color={colors.text} />
|
<Text style={styles.partnerInitial}>
|
||||||
|
{(partner.name?.[0] || partner.email?.[0] || 'P').toUpperCase()}
|
||||||
|
</Text>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
|
)}
|
||||||
<View style={styles.rowTextContainer}>
|
<View style={styles.rowTextContainer}>
|
||||||
<Text style={styles.rowValue}>
|
<Text style={styles.rowValue}>
|
||||||
{partner.name || partner.email || 'Your partner'}
|
{partner.name || partner.email || 'Your partner'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.rowLabel}>Connected</Text>
|
<Text style={styles.rowLabel}>
|
||||||
|
{pairedLabel || 'Connected'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -108,20 +158,58 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
partnerIconGradient: {
|
partnerAvatarContainer: {
|
||||||
width: 44,
|
position: 'relative',
|
||||||
height: 44,
|
},
|
||||||
borderRadius: 22,
|
partnerAvatarGradient: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 2,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
emptyPartnerIcon: {
|
partnerAvatar: {
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
borderRadius: 22,
|
borderRadius: 22,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
connectedBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -2,
|
||||||
|
right: -2,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: colors.success,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.backgroundLight,
|
||||||
|
},
|
||||||
|
partnerIconGradient: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
partnerInitial: {
|
||||||
|
...typography.title3,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
emptyPartnerIcon: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
backgroundColor: colors.glass.background,
|
backgroundColor: colors.glass.background,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
borderStyle: 'dashed',
|
||||||
},
|
},
|
||||||
rowTextContainer: {
|
rowTextContainer: {
|
||||||
marginLeft: spacing.md,
|
marginLeft: spacing.md,
|
||||||
|
|||||||
@@ -7,29 +7,54 @@ import { GlassCard, GlassButton } from '../../../components/ui';
|
|||||||
import { colors, spacing, radius, typography } from '../../../theme';
|
import { colors, spacing, radius, typography } from '../../../theme';
|
||||||
|
|
||||||
interface DangerZoneProps {
|
interface DangerZoneProps {
|
||||||
|
onResetProgress?: () => Promise<void>;
|
||||||
onDeleteRelationship?: () => Promise<void>;
|
onDeleteRelationship?: () => Promise<void>;
|
||||||
onDeleteAccount: () => Promise<void>;
|
onDeleteAccount: () => Promise<void>;
|
||||||
hasRelationship?: boolean;
|
hasRelationship?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DangerZone: React.FC<DangerZoneProps> = ({
|
export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||||
|
onResetProgress,
|
||||||
onDeleteRelationship,
|
onDeleteRelationship,
|
||||||
onDeleteAccount,
|
onDeleteAccount,
|
||||||
hasRelationship = false
|
hasRelationship = false
|
||||||
}) => {
|
}) => {
|
||||||
|
const [showResetModal, setShowResetModal] = useState(false);
|
||||||
const [showDeleteRelationshipModal, setShowDeleteRelationshipModal] = useState(false);
|
const [showDeleteRelationshipModal, setShowDeleteRelationshipModal] = useState(false);
|
||||||
const [showDeleteAccountModal, setShowDeleteAccountModal] = 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 [isDeletingRelationship, setIsDeletingRelationship] = useState(false);
|
||||||
const [isDeletingAccount, setIsDeletingAccount] = 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 () => {
|
const handleDeleteRelationship = async () => {
|
||||||
if (deleteConfirmText !== "DELETE" || !onDeleteRelationship) return;
|
if (confirmText !== "DELETE" || !onDeleteRelationship) return;
|
||||||
setIsDeletingRelationship(true);
|
setIsDeletingRelationship(true);
|
||||||
try {
|
try {
|
||||||
await onDeleteRelationship();
|
await onDeleteRelationship();
|
||||||
setShowDeleteRelationshipModal(false);
|
setShowDeleteRelationshipModal(false);
|
||||||
setDeleteConfirmText("");
|
setConfirmText("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Error handling relies on parent throwing or managing its own alerts
|
// Error handling relies on parent throwing or managing its own alerts
|
||||||
} finally {
|
} finally {
|
||||||
@@ -38,7 +63,7 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAccount = async () => {
|
const handleDeleteAccount = async () => {
|
||||||
if (deleteConfirmText !== "DELETE") return;
|
if (confirmText !== "DELETE") return;
|
||||||
setIsDeletingAccount(true);
|
setIsDeletingAccount(true);
|
||||||
try {
|
try {
|
||||||
await onDeleteAccount();
|
await onDeleteAccount();
|
||||||
@@ -52,14 +77,14 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
const closeRelationshipModal = () => {
|
const closeRelationshipModal = () => {
|
||||||
if (!isDeletingRelationship) {
|
if (!isDeletingRelationship) {
|
||||||
setShowDeleteRelationshipModal(false);
|
setShowDeleteRelationshipModal(false);
|
||||||
setDeleteConfirmText("");
|
setConfirmText("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeAccountModal = () => {
|
const closeAccountModal = () => {
|
||||||
if (!isDeletingAccount) {
|
if (!isDeletingAccount) {
|
||||||
setShowDeleteAccountModal(false);
|
setShowDeleteAccountModal(false);
|
||||||
setDeleteConfirmText("");
|
setConfirmText("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,18 +96,45 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
>
|
>
|
||||||
<Text style={styles.dangerSectionTitle}>Danger Zone</Text>
|
<Text style={styles.dangerSectionTitle}>Danger Zone</Text>
|
||||||
|
|
||||||
|
{/* Reset Progress - only show if in a relationship */}
|
||||||
|
{hasRelationship && onResetProgress && (
|
||||||
|
<GlassCard style={styles.warningCard}>
|
||||||
|
<View style={styles.dangerContent}>
|
||||||
|
<View style={styles.dangerInfo}>
|
||||||
|
<View style={styles.warningIconContainer}>
|
||||||
|
<Ionicons name="refresh" size={20} color={colors.warning} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.dangerTextContainer}>
|
||||||
|
<Text style={styles.dangerTitle}>Reset Progress</Text>
|
||||||
|
<Text style={styles.dangerDescription}>
|
||||||
|
Start fresh by clearing all swipes, matches, and chats. Your partner connection stays.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.warningButton}
|
||||||
|
onPress={() => setShowResetModal(true)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons name="refresh-outline" size={16} color={colors.warning} />
|
||||||
|
<Text style={styles.warningButtonText}>Reset</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete Relationship - only show if in a relationship */}
|
{/* Delete Relationship - only show if in a relationship */}
|
||||||
{hasRelationship && onDeleteRelationship && (
|
{hasRelationship && onDeleteRelationship && (
|
||||||
<GlassCard style={styles.dangerCard}>
|
<GlassCard style={StyleSheet.flatten([styles.dangerCard, styles.dangerCardSpacing]) as ViewStyle}>
|
||||||
<View style={styles.dangerContent}>
|
<View style={styles.dangerContent}>
|
||||||
<View style={styles.dangerInfo}>
|
<View style={styles.dangerInfo}>
|
||||||
<View style={styles.dangerIconContainer}>
|
<View style={styles.dangerIconContainer}>
|
||||||
<Ionicons name="heart-dislike" size={20} color={colors.error} />
|
<Ionicons name="heart-dislike" size={20} color={colors.error} />
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.dangerTextContainer}>
|
<View style={styles.dangerTextContainer}>
|
||||||
<Text style={styles.dangerTitle}>Delete Relationship Data</Text>
|
<Text style={styles.dangerTitle}>Delete Relationship</Text>
|
||||||
<Text style={styles.dangerDescription}>
|
<Text style={styles.dangerDescription}>
|
||||||
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.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -100,7 +152,7 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete Account - always show */}
|
{/* Delete Account - always show */}
|
||||||
<GlassCard style={hasRelationship ? StyleSheet.flatten([styles.dangerCard, styles.dangerCardSpacing]) as ViewStyle : styles.dangerCard}>
|
<GlassCard style={StyleSheet.flatten([styles.dangerCard, (hasRelationship ? styles.dangerCardSpacing : {})]) as ViewStyle}>
|
||||||
<View style={styles.dangerContent}>
|
<View style={styles.dangerContent}>
|
||||||
<View style={styles.dangerInfo}>
|
<View style={styles.dangerInfo}>
|
||||||
<View style={styles.dangerIconContainer}>
|
<View style={styles.dangerIconContainer}>
|
||||||
@@ -109,7 +161,7 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
<View style={styles.dangerTextContainer}>
|
<View style={styles.dangerTextContainer}>
|
||||||
<Text style={styles.dangerTitle}>Delete Account</Text>
|
<Text style={styles.dangerTitle}>Delete Account</Text>
|
||||||
<Text style={styles.dangerDescription}>
|
<Text style={styles.dangerDescription}>
|
||||||
Permanently delete your account and all associated data. This cannot be undone.
|
Permanently delete your account and all data. This cannot be undone.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -126,6 +178,105 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
</GlassCard>
|
</GlassCard>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Reset Progress Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showResetModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={closeResetModal}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={Platform.OS === 'ios' ? 20 : 0}
|
||||||
|
tint="dark"
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
style={styles.modalOverlay}
|
||||||
|
>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<View style={styles.warningModalIconContainer}>
|
||||||
|
<Ionicons name="refresh" size={32} color={colors.warning} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalTitle}>Reset Progress?</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.modalDescription}>
|
||||||
|
This will permanently delete:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.warningList}>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="swap-horizontal" size={16} color={colors.warning} />
|
||||||
|
<Text style={styles.deleteListText}>All your swipes</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="heart" size={16} color={colors.warning} />
|
||||||
|
<Text style={styles.deleteListText}>All matches</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="chatbubbles" size={16} color={colors.warning} />
|
||||||
|
<Text style={styles.deleteListText}>All chat messages</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.keepList}>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
|
||||||
|
<Text style={styles.deleteListText}>Your partner connection stays</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.confirmLabel}>
|
||||||
|
Type <Text style={styles.warningKeyword}>RESET</Text> to confirm:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.warningInput}
|
||||||
|
value={confirmText}
|
||||||
|
onChangeText={setConfirmText}
|
||||||
|
placeholder="Type RESET"
|
||||||
|
placeholderTextColor={colors.textTertiary}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoCorrect={false}
|
||||||
|
editable={!isResetting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.modalButtons}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.cancelButton}
|
||||||
|
onPress={closeResetModal}
|
||||||
|
disabled={isResetting}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.warningActionButton,
|
||||||
|
(confirmText !== "RESET" || isResetting) && styles.deleteButtonDisabled,
|
||||||
|
]}
|
||||||
|
onPress={handleResetProgress}
|
||||||
|
disabled={confirmText !== "RESET" || isResetting}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{isResetting ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.text} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name="refresh" size={16} color={colors.text} />
|
||||||
|
<Text style={styles.deleteButtonText}>Reset</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</BlurView>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Delete Relationship Modal */}
|
{/* Delete Relationship Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
visible={showDeleteRelationshipModal}
|
visible={showDeleteRelationshipModal}
|
||||||
@@ -179,8 +330,8 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.confirmInput}
|
style={styles.confirmInput}
|
||||||
value={deleteConfirmText}
|
value={confirmText}
|
||||||
onChangeText={setDeleteConfirmText}
|
onChangeText={setConfirmText}
|
||||||
placeholder="Type DELETE"
|
placeholder="Type DELETE"
|
||||||
placeholderTextColor={colors.textTertiary}
|
placeholderTextColor={colors.textTertiary}
|
||||||
autoCapitalize="characters"
|
autoCapitalize="characters"
|
||||||
@@ -201,10 +352,10 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
styles.deleteButton,
|
styles.deleteButton,
|
||||||
(deleteConfirmText !== "DELETE" || isDeletingRelationship) && styles.deleteButtonDisabled,
|
(confirmText !== "DELETE" || isDeletingRelationship) && styles.deleteButtonDisabled,
|
||||||
]}
|
]}
|
||||||
onPress={handleDeleteRelationship}
|
onPress={handleDeleteRelationship}
|
||||||
disabled={deleteConfirmText !== "DELETE" || isDeletingRelationship}
|
disabled={confirmText !== "DELETE" || isDeletingRelationship}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
{isDeletingRelationship ? (
|
{isDeletingRelationship ? (
|
||||||
@@ -283,8 +434,8 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.confirmInput}
|
style={styles.confirmInput}
|
||||||
value={deleteConfirmText}
|
value={confirmText}
|
||||||
onChangeText={setDeleteConfirmText}
|
onChangeText={setConfirmText}
|
||||||
placeholder="Type DELETE"
|
placeholder="Type DELETE"
|
||||||
placeholderTextColor={colors.textTertiary}
|
placeholderTextColor={colors.textTertiary}
|
||||||
autoCapitalize="characters"
|
autoCapitalize="characters"
|
||||||
@@ -305,10 +456,10 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
styles.deleteButton,
|
styles.deleteButton,
|
||||||
(deleteConfirmText !== "DELETE" || isDeletingAccount) && styles.deleteButtonDisabled,
|
(confirmText !== "DELETE" || isDeletingAccount) && styles.deleteButtonDisabled,
|
||||||
]}
|
]}
|
||||||
onPress={handleDeleteAccount}
|
onPress={handleDeleteAccount}
|
||||||
disabled={deleteConfirmText !== "DELETE" || isDeletingAccount}
|
disabled={confirmText !== "DELETE" || isDeletingAccount}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
{isDeletingAccount ? (
|
{isDeletingAccount ? (
|
||||||
@@ -342,6 +493,36 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
marginLeft: spacing.xs,
|
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: {
|
dangerCard: {
|
||||||
borderColor: 'rgba(231, 76, 60, 0.3)',
|
borderColor: 'rgba(231, 76, 60, 0.3)',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -431,6 +612,58 @@ const styles = StyleSheet.create({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: 'rgba(231, 76, 60, 0.2)',
|
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: {
|
deleteListItem: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -1,40 +1,76 @@
|
|||||||
import React from 'react';
|
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 { SettingsSection } from './SettingsSection';
|
||||||
import { SwitchItem } from './SwitchItem';
|
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 {
|
interface PrivacySettingsProps {
|
||||||
showExplicit: boolean;
|
maxIntensity: IntensityLevel;
|
||||||
isUpdatingExplicit: boolean;
|
isUpdatingIntensity: boolean;
|
||||||
onExplicitToggle: (value: boolean) => void;
|
onIntensityChange: (value: IntensityLevel) => void;
|
||||||
biometricAvailable: boolean;
|
biometricAvailable: boolean;
|
||||||
biometricEnabled: boolean;
|
biometricEnabled: boolean;
|
||||||
biometricType: string;
|
biometricType: string;
|
||||||
isUpdatingBiometric: boolean;
|
isUpdatingBiometric: boolean;
|
||||||
onBiometricToggle: (value: boolean) => void;
|
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<PrivacySettingsProps> = ({
|
export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
||||||
showExplicit,
|
maxIntensity,
|
||||||
isUpdatingExplicit,
|
isUpdatingIntensity,
|
||||||
onExplicitToggle,
|
onIntensityChange,
|
||||||
biometricAvailable,
|
biometricAvailable,
|
||||||
biometricEnabled,
|
biometricEnabled,
|
||||||
biometricType,
|
biometricType,
|
||||||
isUpdatingBiometric,
|
isUpdatingBiometric,
|
||||||
onBiometricToggle,
|
onBiometricToggle,
|
||||||
|
partnerIntensity,
|
||||||
|
partnerName,
|
||||||
|
partnerAvatar,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<SettingsSection title="Preferences" delay={375}>
|
<SettingsSection title="Preferences" delay={375}>
|
||||||
<SwitchItem
|
{/* Comfort Zone Header */}
|
||||||
icon="flame"
|
<View style={styles.comfortHeader}>
|
||||||
label="Spicy Content"
|
<LinearGradient
|
||||||
description="Show spicy question packs"
|
colors={ACCENT_GRADIENT}
|
||||||
value={showExplicit}
|
style={styles.comfortIcon}
|
||||||
onValueChange={onExplicitToggle}
|
start={{ x: 0, y: 0 }}
|
||||||
disabled={isUpdatingExplicit}
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="heart-circle" size={22} color={colors.text} />
|
||||||
|
</LinearGradient>
|
||||||
|
<View style={styles.comfortText}>
|
||||||
|
<Text style={styles.comfortLabel}>Comfort Zone</Text>
|
||||||
|
<Text style={styles.comfortDescription}>
|
||||||
|
Set the intimacy level you're comfortable with
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Intensity Slider */}
|
||||||
|
<View style={styles.sliderContainer}>
|
||||||
|
<IntensitySlider
|
||||||
|
value={maxIntensity}
|
||||||
|
onValueChange={onIntensityChange}
|
||||||
|
disabled={isUpdatingIntensity}
|
||||||
|
partnerValue={partnerIntensity}
|
||||||
|
partnerName={partnerName}
|
||||||
|
partnerAvatar={partnerAvatar}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Biometric Toggle */}
|
||||||
{biometricAvailable && (
|
{biometricAvailable && (
|
||||||
<>
|
<>
|
||||||
<View style={styles.divider} />
|
<View style={styles.divider} />
|
||||||
@@ -53,9 +89,37 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
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: {
|
divider: {
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: colors.glass.border,
|
backgroundColor: colors.glass.border,
|
||||||
marginVertical: spacing.md,
|
marginVertical: spacing.lg,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -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 (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(delay).duration(500)}
|
||||||
|
style={styles.container}
|
||||||
|
>
|
||||||
|
<ProMemberCard
|
||||||
|
isOwnSubscription={isOwnSubscription}
|
||||||
|
expirationDate={expirationDate}
|
||||||
|
onManagePress={onManagePress}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(delay).duration(500)}
|
||||||
|
style={styles.container}
|
||||||
|
>
|
||||||
|
<FreeUserCard
|
||||||
|
onUpgradePress={onUpgradePress}
|
||||||
|
onRestorePress={onRestorePress}
|
||||||
|
isRestoring={isRestoring}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Premium member card - celebratory gold treatment */
|
||||||
|
function ProMemberCard({
|
||||||
|
isOwnSubscription,
|
||||||
|
expirationDate,
|
||||||
|
onManagePress,
|
||||||
|
}: {
|
||||||
|
isOwnSubscription: boolean;
|
||||||
|
expirationDate: string;
|
||||||
|
onManagePress: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.proCard}>
|
||||||
|
{/* Gold gradient border effect */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colors.premium.gold, colors.premium.goldDark]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.proBorderGradient}
|
||||||
|
/>
|
||||||
|
<View style={styles.proCardInner}>
|
||||||
|
{/* Header with badge */}
|
||||||
|
<View style={styles.proHeader}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.premiumGold as [string, string]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.proBadge}
|
||||||
|
>
|
||||||
|
<Ionicons name="star" size={16} color={colors.background} />
|
||||||
|
</LinearGradient>
|
||||||
|
<View style={styles.proHeaderText}>
|
||||||
|
<Text style={styles.proTitle}>Pro Member</Text>
|
||||||
|
<Text style={styles.proSubtitle}>
|
||||||
|
{isOwnSubscription
|
||||||
|
? `Renews ${expirationDate}`
|
||||||
|
: "Via partner's subscription"
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isOwnSubscription && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.manageButton}
|
||||||
|
onPress={onManagePress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.manageButtonText}>Manage</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Status row */}
|
||||||
|
<View style={styles.proStatusRow}>
|
||||||
|
<View style={styles.proStatusItem}>
|
||||||
|
<Ionicons name="checkmark-circle" size={16} color={colors.premium.gold} />
|
||||||
|
<Text style={styles.proStatusText}>All Packs Unlocked</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.proStatusItem}>
|
||||||
|
<Ionicons name="infinite" size={16} color={colors.premium.gold} />
|
||||||
|
<Text style={styles.proStatusText}>Unlimited Access</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Free user card - premium upsell with shimmer */
|
||||||
|
function FreeUserCard({
|
||||||
|
onUpgradePress,
|
||||||
|
onRestorePress,
|
||||||
|
isRestoring,
|
||||||
|
}: {
|
||||||
|
onUpgradePress: () => void;
|
||||||
|
onRestorePress: () => void;
|
||||||
|
isRestoring: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ShimmerEffect
|
||||||
|
shimmerColor={colors.premium.gold}
|
||||||
|
duration={5000}
|
||||||
|
style={styles.shimmerContainer}
|
||||||
|
>
|
||||||
|
<View style={styles.freeCard}>
|
||||||
|
{/* Gold gradient border */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={[`${colors.premium.gold}60`, `${colors.premium.goldDark}40`]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.freeBorderGradient}
|
||||||
|
/>
|
||||||
|
<View style={styles.freeCardInner}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.freeHeader}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.premiumGold as [string, string]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.freeHeaderBadge}
|
||||||
|
>
|
||||||
|
<Ionicons name="sparkles" size={14} color={colors.background} />
|
||||||
|
<Text style={styles.freeHeaderBadgeText}>SAUCI PRO</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Features list */}
|
||||||
|
<View style={styles.featuresList}>
|
||||||
|
{PREMIUM_FEATURES.map((feature, index) => (
|
||||||
|
<View key={index} style={styles.featureItem}>
|
||||||
|
<View style={styles.featureIconContainer}>
|
||||||
|
<Ionicons
|
||||||
|
name={feature.icon}
|
||||||
|
size={16}
|
||||||
|
color={colors.premium.gold}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.featureText}>{feature.text}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* CTA Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onUpgradePress}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
style={styles.ctaButtonContainer}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.premiumGold as [string, string]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.ctaButton}
|
||||||
|
>
|
||||||
|
<Ionicons name="star" size={18} color={colors.background} />
|
||||||
|
<Text style={styles.ctaButtonText}>Upgrade to Pro</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Restore link */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.restoreLink}
|
||||||
|
onPress={onRestorePress}
|
||||||
|
disabled={isRestoring}
|
||||||
|
>
|
||||||
|
<Text style={styles.restoreLinkText}>
|
||||||
|
{isRestoring ? "Restoring..." : "Restore Purchases"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ShimmerEffect>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -16,3 +16,5 @@ export { SwitchItem } from './SwitchItem';
|
|||||||
export type { SwitchItemProps } from './SwitchItem';
|
export type { SwitchItemProps } from './SwitchItem';
|
||||||
export { MenuItem } from './MenuItem';
|
export { MenuItem } from './MenuItem';
|
||||||
export type { MenuItemProps } from './MenuItem';
|
export type { MenuItemProps } from './MenuItem';
|
||||||
|
export { SubscriptionCard } from './SubscriptionCard';
|
||||||
|
export type { SubscriptionCardProps } from './SubscriptionCard';
|
||||||
@@ -6,6 +6,7 @@ import { decode } from 'base64-arraybuffer';
|
|||||||
import { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../store';
|
import { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../store';
|
||||||
import { supabase } from '../../../lib/supabase';
|
import { supabase } from '../../../lib/supabase';
|
||||||
import { Events } from '../../../lib/analytics';
|
import { Events } from '../../../lib/analytics';
|
||||||
|
import type { IntensityLevel } from '../../../types';
|
||||||
import {
|
import {
|
||||||
registerForPushNotificationsAsync,
|
registerForPushNotificationsAsync,
|
||||||
savePushToken,
|
savePushToken,
|
||||||
@@ -18,11 +19,27 @@ import {
|
|||||||
getBiometricType,
|
getBiometricType,
|
||||||
} from '../../../lib/biometricAuth';
|
} 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() {
|
export function useProfileSettings() {
|
||||||
const { user, fetchUser } = useAuthStore();
|
const { user, partner, fetchUser } = useAuthStore();
|
||||||
const { fetchPacks } = usePacksStore();
|
const { fetchPacks } = usePacksStore();
|
||||||
const { restorePurchases, isPurchasing } = useSubscriptionStore();
|
const { restorePurchases, isPurchasing } = useSubscriptionStore();
|
||||||
|
|
||||||
|
// Partner's intensity level (for showing on slider)
|
||||||
|
const partnerIntensity = partner ? getProfileMaxIntensity(partner) : null;
|
||||||
|
|
||||||
// Name Editing
|
// Name Editing
|
||||||
const [isEditingName, setIsEditingName] = useState(false);
|
const [isEditingName, setIsEditingName] = useState(false);
|
||||||
const [newName, setNewName] = useState(user?.name || "");
|
const [newName, setNewName] = useState(user?.name || "");
|
||||||
@@ -32,8 +49,8 @@ export function useProfileSettings() {
|
|||||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
||||||
|
|
||||||
// Preferences
|
// Preferences
|
||||||
const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? true);
|
const [maxIntensity, setMaxIntensity] = useState<IntensityLevel>(() => getProfileMaxIntensity(user));
|
||||||
const [isUpdatingExplicit, setIsUpdatingExplicit] = useState(false);
|
const [isUpdatingIntensity, setIsUpdatingIntensity] = useState(false);
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
const [pushEnabled, setPushEnabled] = useState(!!user?.push_token);
|
const [pushEnabled, setPushEnabled] = useState(!!user?.push_token);
|
||||||
@@ -53,9 +70,7 @@ export function useProfileSettings() {
|
|||||||
if (user?.name) {
|
if (user?.name) {
|
||||||
setNewName(user.name);
|
setNewName(user.name);
|
||||||
}
|
}
|
||||||
if (user?.show_explicit_content !== undefined) {
|
setMaxIntensity(getProfileMaxIntensity(user));
|
||||||
setShowExplicit(user.show_explicit_content);
|
|
||||||
}
|
|
||||||
setPushEnabled(!!user?.push_token);
|
setPushEnabled(!!user?.push_token);
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
@@ -256,17 +271,21 @@ export function useProfileSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Preference Actions
|
// Preference Actions
|
||||||
const handleExplicitToggle = async (value: boolean) => {
|
const handleIntensityChange = async (value: IntensityLevel) => {
|
||||||
if (!user?.id) return;
|
if (!user?.id) return;
|
||||||
|
|
||||||
setShowExplicit(value);
|
const previous = maxIntensity;
|
||||||
setIsUpdatingExplicit(true);
|
setMaxIntensity(value);
|
||||||
|
setIsUpdatingIntensity(true);
|
||||||
|
|
||||||
|
const showExplicitContent = value >= 3;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.update({
|
.update({
|
||||||
show_explicit_content: value,
|
max_intensity: value,
|
||||||
|
show_explicit_content: showExplicitContent,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.eq('id', user.id);
|
.eq('id', user.id);
|
||||||
@@ -276,13 +295,13 @@ export function useProfileSettings() {
|
|||||||
// Refresh user data and packs to reflect the change
|
// Refresh user data and packs to reflect the change
|
||||||
await fetchUser();
|
await fetchUser();
|
||||||
await fetchPacks();
|
await fetchPacks();
|
||||||
Events.profileUpdated(["show_explicit_content"]);
|
Events.profileUpdated(["max_intensity", "show_explicit_content"]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Revert on error
|
// Revert on error
|
||||||
setShowExplicit(!value);
|
setMaxIntensity(previous);
|
||||||
Alert.alert("Error", "Failed to update preference. Please try again.");
|
Alert.alert("Error", "Failed to update preference. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsUpdatingExplicit(false);
|
setIsUpdatingIntensity(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -380,8 +399,8 @@ export function useProfileSettings() {
|
|||||||
setNewName,
|
setNewName,
|
||||||
isUpdatingName,
|
isUpdatingName,
|
||||||
isUploadingAvatar,
|
isUploadingAvatar,
|
||||||
showExplicit,
|
maxIntensity,
|
||||||
isUpdatingExplicit,
|
isUpdatingIntensity,
|
||||||
pushEnabled,
|
pushEnabled,
|
||||||
isUpdatingPush,
|
isUpdatingPush,
|
||||||
biometricAvailable,
|
biometricAvailable,
|
||||||
@@ -392,11 +411,16 @@ export function useProfileSettings() {
|
|||||||
setShowPaywall,
|
setShowPaywall,
|
||||||
isPurchasing,
|
isPurchasing,
|
||||||
|
|
||||||
|
// Partner info for comfort zone comparison
|
||||||
|
partnerIntensity,
|
||||||
|
partnerName: partner?.name ?? undefined,
|
||||||
|
partnerAvatar: partner?.avatar_url ?? undefined,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
handleUpdateName,
|
handleUpdateName,
|
||||||
handleCancelEditName,
|
handleCancelEditName,
|
||||||
handleAvatarPress,
|
handleAvatarPress,
|
||||||
handleExplicitToggle,
|
handleIntensityChange,
|
||||||
handlePushToggle,
|
handlePushToggle,
|
||||||
handleBiometricToggle,
|
handleBiometricToggle,
|
||||||
handleRestorePurchases,
|
handleRestorePurchases,
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMess
|
|||||||
// Import shared cache from mediaCache to avoid circular dependency
|
// Import shared cache from mediaCache to avoid circular dependency
|
||||||
import { decryptedMediaCache, clearDecryptedMediaCache } from '../lib/mediaCache';
|
import { decryptedMediaCache, clearDecryptedMediaCache } from '../lib/mediaCache';
|
||||||
|
|
||||||
|
const AUTO_RETRY_BASE_DELAY_MS = 3000;
|
||||||
|
const MAX_AUTO_RETRIES = 2;
|
||||||
|
const autoRetryCountsByMediaKey = new Map<string, number>();
|
||||||
|
|
||||||
export type DecryptedMediaErrorCode =
|
export type DecryptedMediaErrorCode =
|
||||||
| 'E2EE_NOT_SUPPORTED'
|
| 'E2EE_NOT_SUPPORTED'
|
||||||
| 'E2EE_KEYS_UNAVAILABLE'
|
| 'E2EE_KEYS_UNAVAILABLE'
|
||||||
@@ -86,6 +90,7 @@ export function useDecryptedMedia({
|
|||||||
|
|
||||||
const repairAttemptedByMessageId = useRef(new Set<string>());
|
const repairAttemptedByMessageId = useRef(new Set<string>());
|
||||||
const [retryNonce, setRetryNonce] = useState(0);
|
const [retryNonce, setRetryNonce] = useState(0);
|
||||||
|
const autoRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const retry = useCallback(() => {
|
const retry = useCallback(() => {
|
||||||
repairAttemptedByMessageId.current.delete(messageId);
|
repairAttemptedByMessageId.current.delete(messageId);
|
||||||
@@ -391,6 +396,57 @@ export function useDecryptedMedia({
|
|||||||
};
|
};
|
||||||
}, [decryptMedia, retryNonce]);
|
}, [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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
isEncrypted,
|
isEncrypted,
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import { supabase } from '../lib/supabase';
|
|||||||
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
|
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
|
||||||
import { useEncryptionKeys } from './useEncryptionKeys';
|
import { useEncryptionKeys } from './useEncryptionKeys';
|
||||||
|
|
||||||
|
const AUTO_RETRY_BASE_DELAY_MS = 3000;
|
||||||
|
const MAX_AUTO_RETRIES = 2;
|
||||||
|
const autoRetryCountsByMessageId = new Map<string, number>();
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
@@ -68,6 +72,7 @@ export function useDecryptedMessage({
|
|||||||
// Track per-message repair attempts to avoid tight retry loops.
|
// Track per-message repair attempts to avoid tight retry loops.
|
||||||
const repairAttemptedByMessageId = useRef(new Set<string>());
|
const repairAttemptedByMessageId = useRef(new Set<string>());
|
||||||
const [retryNonce, setRetryNonce] = useState(0);
|
const [retryNonce, setRetryNonce] = useState(0);
|
||||||
|
const autoRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const retry = useCallback(() => {
|
const retry = useCallback(() => {
|
||||||
repairAttemptedByMessageId.current.delete(message.id);
|
repairAttemptedByMessageId.current.delete(message.id);
|
||||||
@@ -360,5 +365,54 @@ export function useDecryptedMessage({
|
|||||||
decrypt();
|
decrypt();
|
||||||
}, [decrypt, retryNonce]);
|
}, [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 };
|
return { ...state, retry };
|
||||||
}
|
}
|
||||||
@@ -8,10 +8,11 @@
|
|||||||
* consistency with all other encryption operations.
|
* consistency with all other encryption operations.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useState, useRef } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
import {
|
import {
|
||||||
encryptTextMessage,
|
encryptTextMessage,
|
||||||
|
decryptTextMessage,
|
||||||
getAdminPublicKey,
|
getAdminPublicKey,
|
||||||
getAdminKeyId,
|
getAdminKeyId,
|
||||||
isValidPublicKeyJwk,
|
isValidPublicKeyJwk,
|
||||||
@@ -21,12 +22,15 @@ import {
|
|||||||
import type { RSAPublicKeyJWK, EncryptedMessagePayload } from '../lib/encryption';
|
import type { RSAPublicKeyJWK, EncryptedMessagePayload } from '../lib/encryption';
|
||||||
import { useEncryptionKeys } from './useEncryptionKeys';
|
import { useEncryptionKeys } from './useEncryptionKeys';
|
||||||
import { useAuthStore } from '../store';
|
import { useAuthStore } from '../store';
|
||||||
|
import { captureError } from '../lib/sentry';
|
||||||
|
|
||||||
interface UseEncryptedSendReturn {
|
interface UseEncryptedSendReturn {
|
||||||
/** Encrypt a text message for sending */
|
/** Encrypt a text message for sending */
|
||||||
encryptMessage: (plaintext: string) => Promise<EncryptedMessagePayload | null>;
|
encryptMessage: (plaintext: string) => Promise<EncryptedMessagePayload>;
|
||||||
/** Whether E2EE is available (keys loaded, not on web) */
|
/** Whether E2EE is available (keys loaded, not on web) */
|
||||||
isE2EEAvailable: boolean;
|
isE2EEAvailable: boolean;
|
||||||
|
/** Whether the admin key is available and valid */
|
||||||
|
isAdminKeyReady: boolean;
|
||||||
/** Whether encryption is currently in progress */
|
/** Whether encryption is currently in progress */
|
||||||
isEncrypting: boolean;
|
isEncrypting: boolean;
|
||||||
/** Last encryption error, if any */
|
/** Last encryption error, if any */
|
||||||
@@ -36,31 +40,93 @@ interface UseEncryptedSendReturn {
|
|||||||
/**
|
/**
|
||||||
* Hook for encrypting messages before sending
|
* Hook for encrypting messages before sending
|
||||||
*
|
*
|
||||||
* Returns null if E2EE is not available (web platform, no keys).
|
* Throws if E2EE is not available or keys are not ready.
|
||||||
* In that case, the caller should send plaintext (v1) messages.
|
|
||||||
*/
|
*/
|
||||||
export function useEncryptedSend(): UseEncryptedSendReturn {
|
export function useEncryptedSend(): UseEncryptedSendReturn {
|
||||||
const [isEncrypting, setIsEncrypting] = useState(false);
|
const [isEncrypting, setIsEncrypting] = useState(false);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const [isAdminKeyReady, setIsAdminKeyReady] = useState(false);
|
||||||
|
|
||||||
// Track the version of keys we have verified to handle key rotation/regeneration
|
// Track the version of keys we have verified to handle key rotation/regeneration
|
||||||
const verifiedKeysVersionRef = useRef<number | null>(null);
|
const verifiedKeysVersionRef = useRef<number | null>(null);
|
||||||
|
const partnerRefreshRef = useRef(0);
|
||||||
|
const adminKeyRetryRef = useRef(0);
|
||||||
|
const PARTNER_REFRESH_TTL_MS = 2 * 60 * 1000;
|
||||||
|
const ADMIN_KEY_RETRY_DELAYS = [500, 1000, 2000, 4000]; // Exponential backoff delays in ms
|
||||||
|
|
||||||
const { publicKeyJwk, hasKeys, isLoading } = useEncryptionKeys();
|
const { publicKeyJwk, privateKeyJwk, hasKeys, isLoading } = useEncryptionKeys();
|
||||||
const refreshPartner = useAuthStore((s) => s.refreshPartner);
|
const refreshPartner = useAuthStore((s) => s.refreshPartner);
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
// E2EE is available if:
|
// E2EE is available if:
|
||||||
// - Not on web platform
|
// - Not on web platform
|
||||||
// - Keys are loaded and available
|
// - Keys are loaded and available
|
||||||
const isE2EEAvailable =
|
const isE2EEAvailable =
|
||||||
Platform.OS !== 'web' && hasKeys && !isLoading && !!publicKeyJwk;
|
Platform.OS !== 'web' && hasKeys && !isLoading && !!publicKeyJwk && !!privateKeyJwk;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
let retryTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const checkAdminKey = async (retryCount = 0) => {
|
||||||
|
if (!isE2EEAvailable) {
|
||||||
|
if (isMounted) {
|
||||||
|
setIsAdminKeyReady(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const adminPublicKey = await getAdminPublicKey();
|
||||||
|
const adminKeyId = await getAdminKeyId();
|
||||||
|
|
||||||
|
if (!adminKeyId || !isValidPublicKeyJwk(adminPublicKey)) {
|
||||||
|
throw new Error('Admin public key is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setIsAdminKeyReady(true);
|
||||||
|
adminKeyRetryRef.current = 0; // Reset retry count on success
|
||||||
|
}
|
||||||
|
} catch (adminKeyError) {
|
||||||
|
console.warn(`[E2EE Send] Admin key not ready (attempt ${retryCount + 1}):`, adminKeyError);
|
||||||
|
if (isMounted) {
|
||||||
|
setIsAdminKeyReady(false);
|
||||||
|
|
||||||
|
// Retry with exponential backoff if we haven't exhausted retries
|
||||||
|
if (retryCount < ADMIN_KEY_RETRY_DELAYS.length) {
|
||||||
|
const delay = ADMIN_KEY_RETRY_DELAYS[retryCount];
|
||||||
|
console.log(`[E2EE Send] Retrying admin key fetch in ${delay}ms...`);
|
||||||
|
retryTimeoutId = setTimeout(() => {
|
||||||
|
if (isMounted) {
|
||||||
|
checkAdminKey(retryCount + 1);
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
} else {
|
||||||
|
console.warn('[E2EE Send] Admin key fetch failed after all retries');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAdminKey();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
if (retryTimeoutId) {
|
||||||
|
clearTimeout(retryTimeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isE2EEAvailable]);
|
||||||
|
|
||||||
const encryptMessage = useCallback(
|
const encryptMessage = useCallback(
|
||||||
async (plaintext: string): Promise<EncryptedMessagePayload | null> => {
|
async (plaintext: string): Promise<EncryptedMessagePayload> => {
|
||||||
// Return null if E2EE not available - caller will send plaintext
|
// Block sending until E2EE is ready
|
||||||
if (!isE2EEAvailable) {
|
if (!isE2EEAvailable) {
|
||||||
console.log('[E2EE Send] E2EE not available, will send plaintext');
|
const readyError = new Error('Message security not ready. Please check your connection.');
|
||||||
return null;
|
console.warn('[E2EE Send] E2EE not available');
|
||||||
|
setError(readyError);
|
||||||
|
throw readyError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRITICAL: Read keys directly from the global store at encryption time
|
// CRITICAL: Read keys directly from the global store at encryption time
|
||||||
@@ -111,10 +177,31 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Always refresh partner's profile to get their latest public key
|
// Refresh partner's profile only when key is missing/invalid or TTL expires
|
||||||
// This ensures we don't miss their key if they just generated it
|
const resolvePartnerProfile = async () => {
|
||||||
|
const cachedPartner = useAuthStore.getState().partner;
|
||||||
|
const cachedKey = cachedPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
|
||||||
|
const hasValidKey = cachedKey ? isValidPublicKeyJwk(cachedKey) : false;
|
||||||
|
const now = Date.now();
|
||||||
|
const shouldRefresh = !hasValidKey || (now - partnerRefreshRef.current) > PARTNER_REFRESH_TTL_MS;
|
||||||
|
|
||||||
|
if (!shouldRefresh) {
|
||||||
|
return cachedPartner;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[E2EE Send] Refreshing partner profile to get latest public key...');
|
console.log('[E2EE Send] Refreshing partner profile to get latest public key...');
|
||||||
|
try {
|
||||||
const freshPartner = await refreshPartner();
|
const freshPartner = await refreshPartner();
|
||||||
|
partnerRefreshRef.current = Date.now();
|
||||||
|
return freshPartner ?? useAuthStore.getState().partner;
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.warn('[E2EE Send] Partner refresh failed, using cached key:', refreshError);
|
||||||
|
partnerRefreshRef.current = Date.now();
|
||||||
|
return cachedPartner;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const freshPartner = await resolvePartnerProfile();
|
||||||
|
|
||||||
// Get admin key for moderation access
|
// Get admin key for moderation access
|
||||||
// SAFETY: Validate the admin key before using it
|
// SAFETY: Validate the admin key before using it
|
||||||
@@ -167,8 +254,10 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
|
|||||||
|
|
||||||
console.log('[E2EE Send] Partner public key available:', !!partnerPublicKey);
|
console.log('[E2EE Send] Partner public key available:', !!partnerPublicKey);
|
||||||
|
|
||||||
// Encrypt the message using the key from global store
|
let lastPayload: EncryptedMessagePayload | null = null;
|
||||||
const encryptedPayload = await encryptTextMessage(
|
|
||||||
|
const encryptAndVerify = async (): Promise<EncryptedMessagePayload> => {
|
||||||
|
const payload = await encryptTextMessage(
|
||||||
plaintext,
|
plaintext,
|
||||||
senderPublicKey as RSAPublicKeyJWK,
|
senderPublicKey as RSAPublicKeyJWK,
|
||||||
partnerPublicKey ?? null,
|
partnerPublicKey ?? null,
|
||||||
@@ -176,6 +265,65 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
|
|||||||
adminKeyId
|
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');
|
console.log('[E2EE Send] Message encrypted successfully');
|
||||||
|
|
||||||
setIsEncrypting(false);
|
setIsEncrypting(false);
|
||||||
@@ -194,6 +342,7 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
|
|||||||
return {
|
return {
|
||||||
encryptMessage,
|
encryptMessage,
|
||||||
isE2EEAvailable,
|
isE2EEAvailable,
|
||||||
|
isAdminKeyReady,
|
||||||
isEncrypting,
|
isEncrypting,
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,22 @@ import { Events } from "../lib/analytics";
|
|||||||
import type { QuestionPack, Category } from "@/types";
|
import type { QuestionPack, Category } from "@/types";
|
||||||
import { useAuthStore } from "./authStore";
|
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 {
|
interface PacksState {
|
||||||
packs: QuestionPack[];
|
packs: QuestionPack[];
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
@@ -25,8 +41,9 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
|||||||
fetchPacks: async () => {
|
fetchPacks: async () => {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
|
|
||||||
// Get user's explicit content preference
|
// Get user's max intensity preference
|
||||||
const showExplicit = useAuthStore.getState().user?.show_explicit_content ?? true;
|
const maxIntensity = getUserMaxIntensity();
|
||||||
|
|
||||||
|
|
||||||
// Fetch categories
|
// Fetch categories
|
||||||
const { data: categories } = await supabase
|
const { data: categories } = await supabase
|
||||||
@@ -41,11 +58,12 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
|||||||
.eq("is_public", true)
|
.eq("is_public", true)
|
||||||
.order("sort_order");
|
.order("sort_order");
|
||||||
|
|
||||||
// Filter out explicit packs if user doesn't want to see them
|
// Filter out packs above user's intensity preference
|
||||||
if (!showExplicit) {
|
if (maxIntensity < 5) {
|
||||||
query = query.eq("is_explicit", false);
|
query = query.or(`max_intensity.is.null,max_intensity.lte.${maxIntensity}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const { data: packs } = await query;
|
const { data: packs } = await query;
|
||||||
|
|
||||||
// Also fetch enabled packs if logged in
|
// Also fetch enabled packs if logged in
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// User profile (extends Supabase auth.users)
|
// User profile (extends Supabase auth.users)
|
||||||
export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
||||||
|
export type IntensityLevel = 1 | 2 | 3 | 4 | 5;
|
||||||
|
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,6 +12,7 @@ export interface Profile {
|
|||||||
couple_id: string | null;
|
couple_id: string | null;
|
||||||
gender: Gender | null;
|
gender: Gender | null;
|
||||||
show_explicit_content: boolean;
|
show_explicit_content: boolean;
|
||||||
|
max_intensity: IntensityLevel;
|
||||||
onboarding_completed: boolean;
|
onboarding_completed: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -44,6 +46,9 @@ export interface QuestionPack {
|
|||||||
is_premium: boolean;
|
is_premium: boolean;
|
||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
is_explicit: boolean;
|
is_explicit: boolean;
|
||||||
|
min_intensity?: number;
|
||||||
|
max_intensity?: number;
|
||||||
|
avg_intensity?: number;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
category_id: string | null;
|
category_id: string | null;
|
||||||
category?: Category;
|
category?: Category;
|
||||||
|
|||||||
@@ -67,14 +67,14 @@ Deno.serve(async (req) => {
|
|||||||
return json({ error: "Missing encryption metadata" }, 400);
|
return json({ error: "Missing encryption metadata" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminPrivateKeyJwkStr = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
|
const adminPrivateKeyJwk = getAdminPrivateKey(keysMetadata.admin_key_id);
|
||||||
if (!adminPrivateKeyJwkStr) {
|
if (!adminPrivateKeyJwk) {
|
||||||
return json({ error: "Admin decryption key not configured" }, 500);
|
return json({ error: "Admin decryption key not configured" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawAesKey = await unwrapWithAdminKey(
|
const rawAesKey = await unwrapWithAdminKey(
|
||||||
keysMetadata.admin_wrapped_key,
|
keysMetadata.admin_wrapped_key,
|
||||||
JSON.parse(adminPrivateKeyJwkStr)
|
adminPrivateKeyJwk
|
||||||
);
|
);
|
||||||
|
|
||||||
const aesKey = await crypto.subtle.importKey(
|
const aesKey = await crypto.subtle.importKey(
|
||||||
@@ -166,3 +166,28 @@ async function assertSuperAdmin(
|
|||||||
throw new Error("Access denied");
|
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;
|
||||||
|
}
|
||||||
@@ -183,6 +183,28 @@ Deno.serve(async (req) => {
|
|||||||
.update({ couple_id: couple.id })
|
.update({ couple_id: couple.id })
|
||||||
.eq("id", user.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(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -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;
|
||||||
Generated
+7
@@ -313,6 +313,7 @@
|
|||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -449,6 +450,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -865,6 +867,7 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -1175,6 +1178,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -1367,6 +1371,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -1379,6 +1384,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -1674,6 +1680,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user