mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Bump expo, react, react-native, and all expo-* / react-native-* peers to the SDK 56 line via expo install --fix, adding expo-splash-screen and expo-system-ui for config plugins the SDK split out of core. Bump @bacons/apple-targets, react-native-view-shot, and other native modules to versions compatible with React Native 0.85 and the new architecture. Remove @react-navigation/* direct dependencies and route their hooks through expo-router, which is no longer compatible with react-navigation as of SDK 56. Fix RN 0.85's removed StyleSheet.absoluteFillObject (use absoluteFill) and react-native-view-shot 5.x's ViewShotRef type across call sites. Downgrade eslint-plugin-react-hooks v7's new React Compiler era rules to warnings pending a dedicated refactor, and repair Jest fallout from the jest-expo/reanimated/worklets/expo-media-library bumps. Regenerate the committed ios/ and android/ native projects with `expo prebuild --clean` (required for @bacons/apple-targets 5.x) against the production release channel, and document that requirement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
275 lines
8.8 KiB
TypeScript
275 lines
8.8 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
Modal,
|
|
Platform,
|
|
Share,
|
|
StyleSheet,
|
|
Text,
|
|
TouchableOpacity,
|
|
View,
|
|
useWindowDimensions,
|
|
} from "react-native";
|
|
import { BlurView } from "expo-blur";
|
|
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import ViewShot, { type ViewShotRef } from "react-native-view-shot";
|
|
import * as Sharing from "expo-sharing";
|
|
import { colors, spacing, typography, blur } from "../../../theme";
|
|
import { shareToInstagramStories } from "../../../lib/instagramShare";
|
|
import { Events } from "../../../lib/analytics";
|
|
import { QuizResultsShareCard } from "./QuizResultsShareCard";
|
|
|
|
const MAX_CARD_WIDTH = 340;
|
|
|
|
interface QuizShareModalProps {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
scorePercent: number;
|
|
}
|
|
|
|
export function QuizShareModal({ visible, onClose, scorePercent }: QuizShareModalProps) {
|
|
const insets = useSafeAreaInsets();
|
|
const { width: screenWidth } = useWindowDimensions();
|
|
const cardWidth = Math.min(screenWidth - 64, MAX_CARD_WIDTH);
|
|
const shareViewRef = useRef<ViewShotRef>(null);
|
|
const [capturedUri, setCapturedUri] = useState<string | null>(null);
|
|
const [isCapturing, setIsCapturing] = useState(false);
|
|
|
|
const shareMessage = `We matched on ${scorePercent}% on our couple quiz! Discover more on sauci.app`;
|
|
|
|
useEffect(() => {
|
|
if (visible && shareViewRef.current) {
|
|
setIsCapturing(true);
|
|
const timer = setTimeout(async () => {
|
|
try {
|
|
const uri = await shareViewRef.current?.capture?.();
|
|
if (uri) setCapturedUri(uri);
|
|
} catch (error) {
|
|
console.error("Error capturing quiz share image:", error);
|
|
} finally {
|
|
setIsCapturing(false);
|
|
}
|
|
}, 100);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [visible]);
|
|
|
|
useEffect(() => {
|
|
if (!visible) setCapturedUri(null);
|
|
}, [visible]);
|
|
|
|
const handleInstagramShare = async () => {
|
|
if (!capturedUri) return;
|
|
|
|
const shared = await shareToInstagramStories(capturedUri);
|
|
if (shared) {
|
|
Events.quizShared();
|
|
} else {
|
|
// Instagram Stories sharing unavailable - fall back to the share sheet
|
|
await handleMoreShare();
|
|
}
|
|
};
|
|
|
|
const handleMessagesShare = async () => {
|
|
if (!capturedUri) {
|
|
await Share.share({ message: shareMessage });
|
|
Events.quizShared();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (Platform.OS === "ios") {
|
|
await Sharing.shareAsync(capturedUri, { mimeType: "image/png", UTI: "public.png" });
|
|
Events.quizShared();
|
|
} else {
|
|
await handleMoreShare();
|
|
}
|
|
} catch (error) {
|
|
console.error("Messages share error:", error);
|
|
await Share.share({ message: shareMessage });
|
|
Events.quizShared();
|
|
}
|
|
};
|
|
|
|
const handleMoreShare = async () => {
|
|
if (!capturedUri) {
|
|
await Share.share({ message: shareMessage });
|
|
Events.quizShared();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const isAvailable = await Sharing.isAvailableAsync();
|
|
if (isAvailable) {
|
|
await Sharing.shareAsync(capturedUri, { mimeType: "image/png", dialogTitle: "Share your quiz score" });
|
|
} else {
|
|
await Share.share({ message: shareMessage });
|
|
}
|
|
Events.quizShared();
|
|
} catch (error) {
|
|
console.error("Share error:", error);
|
|
}
|
|
};
|
|
|
|
const useBlur = Platform.OS === "ios";
|
|
|
|
return (
|
|
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
|
<View style={styles.overlay}>
|
|
<Animated.View
|
|
entering={FadeIn.duration(200)}
|
|
exiting={FadeOut.duration(200)}
|
|
style={StyleSheet.absoluteFill}
|
|
>
|
|
{useBlur ? (
|
|
<BlurView intensity={blur.heavy} tint="dark" style={StyleSheet.absoluteFill} />
|
|
) : (
|
|
<View style={styles.backdropFallback} />
|
|
)}
|
|
</Animated.View>
|
|
|
|
<Animated.View
|
|
entering={SlideInDown.springify().damping(20)}
|
|
exiting={SlideOutDown.duration(200)}
|
|
style={[styles.content, { paddingTop: insets.top }]}
|
|
>
|
|
<View style={styles.header}>
|
|
<TouchableOpacity
|
|
style={styles.closeButton}
|
|
onPress={onClose}
|
|
hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }}
|
|
testID="quiz-share-close"
|
|
>
|
|
<Ionicons name="close" size={28} color={colors.text} />
|
|
</TouchableOpacity>
|
|
<Text style={styles.title}>Share your score</Text>
|
|
<View style={styles.headerSpacer} />
|
|
</View>
|
|
|
|
<View style={styles.previewContainer}>
|
|
<ViewShot ref={shareViewRef} options={{ format: "png", quality: 1, result: "tmpfile" }}>
|
|
<QuizResultsShareCard scorePercent={scorePercent} cardWidth={cardWidth} />
|
|
</ViewShot>
|
|
</View>
|
|
|
|
<View style={[styles.actionsContainer, { paddingBottom: insets.bottom + spacing.lg }]}>
|
|
<ShareActionButton
|
|
icon="logo-instagram"
|
|
label="Instagram"
|
|
onPress={handleInstagramShare}
|
|
disabled={isCapturing}
|
|
/>
|
|
<ShareActionButton
|
|
icon="chatbubble-ellipses"
|
|
label="Messages"
|
|
onPress={handleMessagesShare}
|
|
disabled={isCapturing}
|
|
/>
|
|
<ShareActionButton
|
|
icon="share-outline"
|
|
label="More"
|
|
onPress={handleMoreShare}
|
|
disabled={isCapturing}
|
|
/>
|
|
</View>
|
|
</Animated.View>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function ShareActionButton({
|
|
icon,
|
|
label,
|
|
onPress,
|
|
disabled,
|
|
}: {
|
|
icon: string;
|
|
label: string;
|
|
onPress: () => void;
|
|
disabled?: boolean;
|
|
}) {
|
|
return (
|
|
<TouchableOpacity
|
|
style={styles.actionButton}
|
|
onPress={onPress}
|
|
disabled={disabled}
|
|
activeOpacity={0.7}
|
|
testID={`quiz-share-${icon}`}
|
|
>
|
|
<View style={styles.actionIconContainer}>
|
|
<Ionicons name={icon as any} size={28} color={colors.text} />
|
|
</View>
|
|
<Text style={styles.actionLabel}>{label}</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
overlay: {
|
|
flex: 1,
|
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
},
|
|
backdropFallback: {
|
|
flex: 1,
|
|
backgroundColor: "rgba(14, 14, 17, 0.95)",
|
|
},
|
|
content: {
|
|
flex: 1,
|
|
},
|
|
header: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.md,
|
|
},
|
|
closeButton: {
|
|
width: 44,
|
|
height: 44,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
},
|
|
title: {
|
|
...typography.headline,
|
|
color: colors.text,
|
|
fontWeight: "600",
|
|
},
|
|
headerSpacer: {
|
|
width: 44,
|
|
},
|
|
previewContainer: {
|
|
flex: 1,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
paddingHorizontal: spacing.lg,
|
|
},
|
|
actionsContainer: {
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
gap: spacing.xl,
|
|
paddingTop: spacing.xl,
|
|
paddingHorizontal: spacing.lg,
|
|
},
|
|
actionButton: {
|
|
alignItems: "center",
|
|
gap: spacing.sm,
|
|
},
|
|
actionIconContainer: {
|
|
width: 64,
|
|
height: 64,
|
|
borderRadius: 32,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
backgroundColor: colors.backgroundLight,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
},
|
|
actionLabel: {
|
|
...typography.caption1,
|
|
color: colors.text,
|
|
fontWeight: "500",
|
|
},
|
|
});
|