Improve mobile data retrieval and state management

- Add race protection and packId normalization to swipe screen
- Add loading/error states to matchStore with couple_id filter
- Sync per-match unread counts in realtime
- Use useFocusEffect for data freshness on Home screen
- Wire RefreshControl to actual loading state on Matches

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
jjbrunton committed 2026-01-03 20:10:58 +00:00
1 parent 994dd2a295
commit ae91602921
21 files changed
+2453 -237

No files matched your search

@@ -18,8 +18,8 @@ function createThenableQuery(result: any) {
describe('matchStore', () => {
beforeEach(() => {
jest.clearAllMocks();
useMatchStore.setState({ matches: [], newMatchesCount: 0 } as any);
useAuthStore.setState({ user: { id: 'me' } } as any);
useMatchStore.setState({ matches: [], newMatchesCount: 0, isLoading: false, error: null } as any);
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
});
it('sorts matches with unread messages first', async () => {
@@ -42,12 +42,14 @@ describe('messageStore', () => {
});
it('markMatchMessagesAsRead updates Supabase and refetches unread count', async () => {
const unreadCountQuery = createThenableQuery({ count: 2 }); // count before marking
const updateQuery = createThenableQuery({});
const countQuery = createThenableQuery({ count: 0 });
const finalCountQuery = createThenableQuery({ count: 0 }); // count after marking
(supabase.from as jest.Mock)
.mockReturnValueOnce(updateQuery)
.mockReturnValueOnce(countQuery);
.mockReturnValueOnce(unreadCountQuery) // count unread messages first
.mockReturnValueOnce(updateQuery) // update messages
.mockReturnValueOnce(finalCountQuery); // fetchUnreadCount
await useMessageStore.getState().markMatchMessagesAsRead('m1');
@@ -0,0 +1,204 @@
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
TouchableWithoutFeedback,
Platform,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { BlurView } from "expo-blur";
import Animated, { FadeIn, FadeOut, ZoomIn, ZoomOut } from "react-native-reanimated";
import { colors, spacing, typography, radius } from "../../theme";
interface DeleteMatchConfirmModalProps {
visible: boolean;
messageCount: number;
onConfirm: () => void;
onCancel: () => void;
}
export function DeleteMatchConfirmModal({
visible,
messageCount,
onConfirm,
onCancel,
}: DeleteMatchConfirmModalProps) {
const messageText =
messageCount === 0
? "This will delete your match."
: messageCount === 1
? "This will delete your match and 1 message."
: `This will delete your match and ${messageCount} messages.`;
return (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onCancel}
statusBarTranslucent
>
<Animated.View
style={styles.overlay}
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
>
<TouchableWithoutFeedback onPress={onCancel}>
<View style={styles.backdrop} />
</TouchableWithoutFeedback>
<Animated.View
style={styles.modalContainer}
entering={ZoomIn.springify().damping(20)}
exiting={ZoomOut.duration(150)}
>
{Platform.OS === "ios" ? (
<BlurView intensity={80} tint="dark" style={styles.modal}>
<ModalContent
messageText={messageText}
onConfirm={onConfirm}
onCancel={onCancel}
/>
</BlurView>
) : (
<View style={[styles.modal, styles.modalAndroid]}>
<ModalContent
messageText={messageText}
onConfirm={onConfirm}
onCancel={onCancel}
/>
</View>
)}
</Animated.View>
</Animated.View>
</Modal>
);
}
function ModalContent({
messageText,
onConfirm,
onCancel,
}: {
messageText: string;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<>
{/* Warning icon */}
<View style={styles.iconContainer}>
<Ionicons name="warning" size={32} color={colors.error} />
</View>
{/* Title */}
<Text style={styles.title}>Delete Match?</Text>
{/* Message */}
<Text style={styles.message}>
Changing your answer to "No" will remove this match permanently.{"\n\n"}
{messageText}
</Text>
{/* Buttons */}
<View style={styles.buttons}>
<TouchableOpacity style={styles.cancelButton} onPress={onCancel} activeOpacity={0.7}>
<Text style={styles.cancelButtonText}>Keep Match</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.deleteButton} onPress={onConfirm} activeOpacity={0.7}>
<LinearGradient
colors={[colors.error, "#c0392b"]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Text style={styles.deleteButtonText}>Delete Match</Text>
</TouchableOpacity>
</View>
</>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.7)",
},
modalContainer: {
width: "100%",
maxWidth: 340,
},
modal: {
borderRadius: radius.xl,
overflow: "hidden",
padding: spacing.xl,
alignItems: "center",
},
modalAndroid: {
backgroundColor: colors.backgroundLight,
},
iconContainer: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: "rgba(231, 76, 60, 0.15)",
borderWidth: 1,
borderColor: "rgba(231, 76, 60, 0.25)",
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
},
title: {
...typography.title2,
color: colors.text,
marginBottom: spacing.sm,
},
message: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
marginBottom: spacing.xl,
lineHeight: 22,
},
buttons: {
width: "100%",
gap: spacing.sm,
},
cancelButton: {
width: "100%",
paddingVertical: spacing.md,
borderRadius: radius.md,
backgroundColor: "rgba(255, 255, 255, 0.1)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.15)",
alignItems: "center",
},
cancelButtonText: {
...typography.callout,
color: colors.text,
fontWeight: "600",
},
deleteButton: {
width: "100%",
paddingVertical: spacing.md,
borderRadius: radius.md,
overflow: "hidden",
alignItems: "center",
justifyContent: "center",
},
deleteButtonText: {
...typography.callout,
color: colors.text,
fontWeight: "700",
},
});
@@ -0,0 +1,397 @@
import { useState, useEffect } from "react";
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
TouchableWithoutFeedback,
ActivityIndicator,
Platform,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { BlurView } from "expo-blur";
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated";
import type { ResponseWithQuestion, UpdateResponseResult } from "../../store";
import { useResponsesStore } from "../../store";
import type { AnswerType } from "@/types";
import { colors, spacing, typography, radius } from "../../theme";
import { DeleteMatchConfirmModal } from "./DeleteMatchConfirmModal";
// Premium color palette
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = "rgba(212, 175, 55, ";
// Answer button config
const ANSWER_CONFIG = {
yes: {
color: colors.success,
rgba: "rgba(46, 204, 113, ",
icon: "checkmark-circle" as const,
label: "Yes",
},
maybe: {
color: colors.warning,
rgba: "rgba(243, 156, 18, ",
icon: "help-circle" as const,
label: "Maybe",
},
no: {
color: colors.error,
rgba: "rgba(231, 76, 60, ",
icon: "close-circle" as const,
label: "No",
},
};
interface EditResponseSheetProps {
visible: boolean;
response: ResponseWithQuestion | null;
onClose: () => void;
onSuccess: () => void;
}
export function EditResponseSheet({ visible, response, onClose, onSuccess }: EditResponseSheetProps) {
const { updateResponse } = useResponsesStore();
const [isLoading, setIsLoading] = useState(false);
const [pendingAnswer, setPendingAnswer] = useState<AnswerType | null>(null);
const [confirmModalData, setConfirmModalData] = useState<{
matchId: string;
messageCount: number;
} | null>(null);
// Reset state when modal closes
useEffect(() => {
if (!visible) {
setIsLoading(false);
setPendingAnswer(null);
setConfirmModalData(null);
}
}, [visible]);
if (!response) return null;
const handleAnswerPress = async (newAnswer: AnswerType) => {
if (newAnswer === response.answer || isLoading) return;
setIsLoading(true);
setPendingAnswer(newAnswer);
const result = await updateResponse(response.question_id, newAnswer, false);
if (result.requires_confirmation && result.match_id) {
// Show confirmation modal
setConfirmModalData({
matchId: result.match_id,
messageCount: result.message_count || 0,
});
setIsLoading(false);
} else if (result.success) {
// Success - close sheet
setIsLoading(false);
onSuccess();
onClose();
} else {
// Error
setIsLoading(false);
setPendingAnswer(null);
// Could show an error toast here
console.error("Failed to update response:", result.error);
}
};
const handleConfirmDelete = async () => {
if (!pendingAnswer) return;
setIsLoading(true);
setConfirmModalData(null);
const result = await updateResponse(response.question_id, pendingAnswer, true);
setIsLoading(false);
if (result.success) {
onSuccess();
onClose();
} else {
setPendingAnswer(null);
console.error("Failed to delete match:", result.error);
}
};
const handleCancelDelete = () => {
setConfirmModalData(null);
setPendingAnswer(null);
};
const AnswerButton = ({ answer }: { answer: AnswerType }) => {
const config = ANSWER_CONFIG[answer];
const isSelected = response.answer === answer;
const isPending = pendingAnswer === answer;
return (
<TouchableOpacity
style={[
styles.answerButton,
isSelected && {
backgroundColor: `${config.rgba}0.2)`,
borderColor: `${config.rgba}0.4)`,
},
]}
onPress={() => handleAnswerPress(answer)}
disabled={isLoading}
activeOpacity={0.7}
>
{isPending && isLoading ? (
<ActivityIndicator color={config.color} size="small" />
) : (
<Ionicons
name={config.icon}
size={28}
color={isSelected ? config.color : colors.textSecondary}
/>
)}
<Text
style={[
styles.answerButtonText,
isSelected && { color: config.color, fontWeight: "700" },
]}
>
{config.label}
</Text>
{isSelected && (
<View style={styles.selectedIndicator}>
<Ionicons name="checkmark" size={14} color={config.color} />
</View>
)}
</TouchableOpacity>
);
};
return (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onClose}
statusBarTranslucent
>
<Animated.View style={styles.overlay} entering={FadeIn.duration(200)} exiting={FadeOut.duration(200)}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop} />
</TouchableWithoutFeedback>
<Animated.View
style={styles.sheetContainer}
entering={SlideInDown.springify().damping(20)}
exiting={SlideOutDown.duration(200)}
>
{Platform.OS === "ios" ? (
<BlurView intensity={80} tint="dark" style={styles.sheet}>
<SheetContent
response={response}
AnswerButton={AnswerButton}
onClose={onClose}
/>
</BlurView>
) : (
<View style={[styles.sheet, styles.sheetAndroid]}>
<SheetContent
response={response}
AnswerButton={AnswerButton}
onClose={onClose}
/>
</View>
)}
</Animated.View>
</Animated.View>
{/* Confirmation Modal */}
<DeleteMatchConfirmModal
visible={confirmModalData !== null}
messageCount={confirmModalData?.messageCount || 0}
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
</Modal>
);
}
// Sheet content extracted for reuse between iOS blur and Android solid background
function SheetContent({
response,
AnswerButton,
onClose,
}: {
response: ResponseWithQuestion;
AnswerButton: React.FC<{ answer: AnswerType }>;
onClose: () => void;
}) {
return (
<>
{/* Handle */}
<View style={styles.handle} />
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Edit Answer</Text>
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Ionicons name="close" size={20} color={colors.textSecondary} />
</TouchableOpacity>
</View>
{/* Question text */}
<View style={styles.questionContainer}>
<Text style={styles.questionText}>{response.question.text}</Text>
<View style={styles.packBadge}>
<Ionicons name="layers-outline" size={12} color={colors.textTertiary} />
<Text style={styles.packText}>{response.question.pack.name}</Text>
</View>
</View>
{/* Warning for matched questions */}
{response.has_match && (
<View style={styles.warningBanner}>
<Ionicons name="warning" size={16} color={colors.warning} />
<Text style={styles.warningText}>
This question has a match. Changing to "No" will delete the match and
all messages.
</Text>
</View>
)}
{/* Answer buttons */}
<View style={styles.answerButtons}>
<AnswerButton answer="yes" />
<AnswerButton answer="maybe" />
<AnswerButton answer="no" />
</View>
{/* Bottom padding for safe area */}
<View style={styles.bottomPadding} />
</>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: "flex-end",
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.6)",
},
sheetContainer: {
maxHeight: "80%",
},
sheet: {
borderTopLeftRadius: radius.xl,
borderTopRightRadius: radius.xl,
overflow: "hidden",
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
},
sheetAndroid: {
backgroundColor: colors.backgroundLight,
},
handle: {
width: 40,
height: 4,
backgroundColor: "rgba(255, 255, 255, 0.3)",
borderRadius: 2,
alignSelf: "center",
marginBottom: spacing.md,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginBottom: spacing.lg,
},
headerTitle: {
...typography.title3,
color: colors.text,
},
closeButton: {
position: "absolute",
right: 0,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: "rgba(255, 255, 255, 0.1)",
justifyContent: "center",
alignItems: "center",
},
questionContainer: {
marginBottom: spacing.lg,
},
questionText: {
...typography.body,
color: colors.text,
textAlign: "center",
marginBottom: spacing.sm,
},
packBadge: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
},
packText: {
...typography.caption1,
color: colors.textTertiary,
},
warningBanner: {
flexDirection: "row",
alignItems: "flex-start",
backgroundColor: "rgba(243, 156, 18, 0.1)",
borderWidth: 1,
borderColor: "rgba(243, 156, 18, 0.2)",
borderRadius: radius.md,
padding: spacing.md,
marginBottom: spacing.lg,
gap: spacing.sm,
},
warningText: {
...typography.footnote,
color: colors.warning,
flex: 1,
},
answerButtons: {
flexDirection: "row",
gap: spacing.md,
marginBottom: spacing.lg,
},
answerButton: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: spacing.lg,
borderRadius: radius.lg,
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
gap: spacing.xs,
},
answerButtonText: {
...typography.callout,
color: colors.textSecondary,
fontWeight: "600",
},
selectedIndicator: {
position: "absolute",
top: spacing.sm,
right: spacing.sm,
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: "rgba(255, 255, 255, 0.1)",
justifyContent: "center",
alignItems: "center",
},
bottomPadding: {
height: Platform.OS === "ios" ? 34 : 20,
},
});
@@ -0,0 +1,252 @@
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInRight } from "react-native-reanimated";
import type { ResponseWithQuestion } from "../../store";
import { colors, spacing, typography, radius } from "../../theme";
// Premium color palette
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = "rgba(212, 175, 55, ";
const ROSE = colors.premium.rose;
const ROSE_RGBA = "rgba(232, 164, 174, ";
// Answer colors (semantic)
const ANSWER_COLORS = {
yes: {
color: colors.success,
rgba: "rgba(46, 204, 113, ",
label: "YES",
},
no: {
color: colors.error,
rgba: "rgba(231, 76, 60, ",
label: "NO",
},
maybe: {
color: colors.warning,
rgba: "rgba(243, 156, 18, ",
label: "MAYBE",
},
};
interface ResponseCardProps {
response: ResponseWithQuestion;
index: number;
onEditPress: () => void;
onChatPress?: () => void;
}
export function ResponseCard({ response, index, onEditPress, onChatPress }: ResponseCardProps) {
const answerStyle = ANSWER_COLORS[response.answer];
return (
<Animated.View entering={FadeInRight.delay(index * 30).duration(250)}>
<View style={styles.card}>
{/* Subtle gradient background */}
<LinearGradient
colors={["rgba(22, 33, 62, 0.6)", "rgba(13, 13, 26, 0.8)"]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[`${answerStyle.rgba}0.06)`, "transparent"]}
style={styles.cardSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={styles.row}>
{/* Answer badge */}
<View
style={[
styles.answerBadge,
{ backgroundColor: `${answerStyle.rgba}0.15)`, borderColor: `${answerStyle.rgba}0.3)` },
]}
>
<Ionicons
name={
response.answer === "yes"
? "checkmark"
: response.answer === "no"
? "close"
: "help"
}
size={18}
color={answerStyle.color}
/>
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.questionText} numberOfLines={2}>
{response.question.text}
</Text>
<View style={styles.metaRow}>
{/* Answer tag */}
<View
style={[
styles.tag,
{
backgroundColor: `${answerStyle.rgba}0.1)`,
borderColor: `${answerStyle.rgba}0.2)`,
},
]}
>
<Text style={[styles.tagText, { color: answerStyle.color }]}>
{answerStyle.label}
</Text>
</View>
{/* Pack name */}
<View style={styles.packBadge}>
<Ionicons name="layers-outline" size={10} color={colors.textTertiary} />
<Text style={styles.packText} numberOfLines={1}>
{response.question.pack.name}
</Text>
</View>
{/* Partner status indicator */}
{response.partner_answered && (
<View style={styles.partnerBadge}>
<Ionicons name="person" size={10} color={colors.textTertiary} />
</View>
)}
</View>
</View>
{/* Right section */}
<View style={styles.rightSection}>
{/* Match indicator with chat link */}
{response.has_match && onChatPress && (
<TouchableOpacity style={styles.matchButton} onPress={onChatPress}>
<Ionicons name="heart" size={14} color={colors.primary} />
</TouchableOpacity>
)}
{/* Edit button */}
<TouchableOpacity style={styles.editButton} onPress={onEditPress}>
<Ionicons name="pencil" size={14} color={ACCENT} />
</TouchableOpacity>
</View>
</View>
{/* Premium border */}
<View style={styles.cardBorder} pointerEvents="none" />
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
card: {
marginBottom: spacing.sm,
borderRadius: radius.lg,
overflow: "hidden",
padding: spacing.md,
},
cardSilkHighlight: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 50,
},
cardBorder: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.08)",
},
row: {
flexDirection: "row",
alignItems: "center",
},
answerBadge: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
marginRight: spacing.md,
},
content: {
flex: 1,
},
questionText: {
...typography.subhead,
color: colors.text,
fontWeight: "500",
marginBottom: spacing.xs,
lineHeight: 20,
},
metaRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
flexWrap: "wrap",
},
tag: {
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.sm,
borderWidth: 1,
},
tagText: {
...typography.caption2,
fontWeight: "700",
letterSpacing: 1,
},
packBadge: {
flexDirection: "row",
alignItems: "center",
gap: 4,
maxWidth: 120,
},
packText: {
...typography.caption2,
color: colors.textTertiary,
},
partnerBadge: {
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: "rgba(255, 255, 255, 0.08)",
justifyContent: "center",
alignItems: "center",
},
rightSection: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
marginLeft: spacing.sm,
},
matchButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: "rgba(233, 69, 96, 0.15)",
borderWidth: 1,
borderColor: "rgba(233, 69, 96, 0.25)",
justifyContent: "center",
alignItems: "center",
},
editButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: `${ACCENT_RGBA}0.1)`,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
justifyContent: "center",
alignItems: "center",
},
});
@@ -0,0 +1,3 @@
export { ResponseCard } from "./ResponseCard";
export { EditResponseSheet } from "./EditResponseSheet";
export { DeleteMatchConfirmModal } from "./DeleteMatchConfirmModal";
@@ -52,6 +52,13 @@ const STEPS: TutorialStep[] = [
icon: "images",
gradient: gradients.premiumRose as [string, string],
},
{
key: "edit",
title: "Change Your Mind?",
description: "Go to My Answers to review and edit any of your responses. Changed your mind? No problem — update your answer anytime.",
icon: "create-outline",
gradient: gradients.premiumGold as [string, string],
},
];
export default function MatchesTutorial({ onComplete }: Props) {
@@ -216,6 +223,40 @@ export default function MatchesTutorial({ onComplete }: Props) {
</View>
</View>
)}
{/* Edit answers demo - show on fourth step */}
{currentStep === 3 && (
<View style={styles.editDemo}>
<View style={styles.answerCard}>
<View style={styles.answerCardContent}>
<Text style={styles.answerCardQuestion} numberOfLines={1}>
"Try something new together"
</Text>
<View style={styles.answerCardBadge}>
<Ionicons name="checkmark" size={12} color={colors.text} />
<Text style={styles.answerCardBadgeText}>YES</Text>
</View>
</View>
<View style={styles.answerCardEdit}>
<Ionicons name="create-outline" size={18} color={colors.premium.gold} />
</View>
</View>
<View style={styles.answerArrow}>
<Ionicons name="arrow-down" size={20} color={colors.textTertiary} />
</View>
<View style={[styles.answerCard, styles.answerCardUpdated]}>
<View style={styles.answerCardContent}>
<Text style={styles.answerCardQuestion} numberOfLines={1}>
"Try something new together"
</Text>
<View style={[styles.answerCardBadge, styles.answerCardBadgeMaybe]}>
<Ionicons name="help" size={12} color={colors.warning} />
<Text style={[styles.answerCardBadgeText, styles.answerCardBadgeTextMaybe]}>MAYBE</Text>
</View>
</View>
</View>
</View>
)}
</Animated.View>
</View>
@@ -425,6 +466,68 @@ const styles = StyleSheet.create({
color: colors.success,
fontWeight: "600",
},
editDemo: {
marginTop: spacing.lg,
alignItems: "center",
gap: spacing.xs,
},
answerCard: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: colors.glass.border,
borderRadius: radius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
width: 260,
},
answerCardUpdated: {
borderColor: colors.premium.gold,
backgroundColor: "rgba(212, 175, 55, 0.08)",
},
answerCardContent: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.sm,
},
answerCardQuestion: {
...typography.footnote,
color: colors.text,
fontStyle: "italic",
flex: 1,
},
answerCardBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.success,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.full,
gap: 2,
},
answerCardBadgeMaybe: {
backgroundColor: "rgba(243, 156, 18, 0.2)",
borderWidth: 1,
borderColor: colors.warning,
},
answerCardBadgeText: {
...typography.caption2,
fontWeight: "700",
color: colors.text,
},
answerCardBadgeTextMaybe: {
color: colors.warning,
},
answerCardEdit: {
marginLeft: spacing.sm,
padding: spacing.xs,
},
answerArrow: {
padding: spacing.xs,
},
textContainer: {
alignItems: "center",
marginBottom: spacing.xl,
@@ -137,51 +137,64 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
marginVertical={spacing.md}
/>
{/* Question Text */}
<Text style={styles.matchQuestionText}>
"{match.question.text}"
</Text>
{match.question.partner_text && (
<Text style={styles.matchQuestionPartnerText}>
"{match.question.partner_text}"
</Text>
)}
{/* Response Pills */}
{match.responses && match.responses.length > 0 && (
<View style={styles.responsePillsRow}>
{match.responses.map((response: any) => {
const isUser = response.user_id === user?.id;
const isYes = response.answer === 'yes';
return (
<View key={response.user_id} style={styles.responsePillWrapper}>
{isYes ? (
<LinearGradient
colors={isYesYes
? gradients.premiumGold as [string, string]
: [colors.success, '#27ae60']
}
style={styles.responsePill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="checkmark" size={12} color={colors.text} />
<Text style={styles.responsePillText}>
{isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
</Text>
</LinearGradient>
) : (
<View style={styles.responsePillMaybe}>
<Ionicons name="help" size={12} color={colors.warning} />
<Text style={styles.responsePillTextMaybe}>
{isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
</Text>
</View>
)}
</View>
{/* Question and Responses - Show who answered what */}
{match.responses && match.responses.length > 0 ? (
<View style={styles.responsesContainer}>
{(() => {
// Sort responses by created_at to determine who answered first
const sortedResponses = [...match.responses].sort(
(a: any, b: any) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
})}
const isTwoPart = !!match.question.partner_text;
return sortedResponses.map((response: any, index: number) => {
const isUser = response.user_id === user?.id;
const isYes = response.answer === 'yes';
const name = isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner');
// First responder sees main text, second sees partner_text
const questionText = isTwoPart && index === 1
? match.question.partner_text
: match.question.text;
return (
<View key={response.user_id} style={styles.responseCard}>
{/* Name and Answer Badge */}
<View style={styles.responseHeader}>
<Text style={styles.responseName}>{name}</Text>
{isYes ? (
<LinearGradient
colors={isYesYes
? gradients.premiumGold as [string, string]
: [colors.success, '#27ae60']
}
style={styles.answerBadge}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="checkmark" size={10} color={colors.text} />
<Text style={styles.answerBadgeText}>YES</Text>
</LinearGradient>
) : (
<View style={styles.answerBadgeMaybe}>
<Ionicons name="help" size={10} color={colors.warning} />
<Text style={styles.answerBadgeTextMaybe}>MAYBE</Text>
</View>
)}
</View>
{/* Question they saw */}
<Text style={styles.responseQuestionText}>
"{questionText}"
</Text>
</View>
);
});
})()}
</View>
) : (
<Text style={styles.matchQuestionText}>
"{match.question.text}"
</Text>
)}
{/* Premium border */}
@@ -322,50 +335,66 @@ const styles = StyleSheet.create({
fontStyle: 'italic',
lineHeight: 20,
},
matchQuestionPartnerText: {
...typography.caption1,
color: colors.textSecondary,
textAlign: 'center',
fontStyle: 'italic',
marginTop: 2,
},
responsePillsRow: {
flexDirection: 'row',
justifyContent: 'center',
responsesContainer: {
width: '100%',
gap: spacing.sm,
marginTop: spacing.sm,
},
responsePillWrapper: {
overflow: 'hidden',
borderRadius: radius.full,
},
responsePill: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: 4,
gap: 4,
},
responsePillText: {
...typography.caption2,
fontWeight: '600',
color: colors.text,
},
responsePillMaybe: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: 4,
gap: 4,
backgroundColor: `rgba(243, 156, 18, 0.15)`,
responseCard: {
backgroundColor: 'rgba(255, 255, 255, 0.03)',
borderRadius: radius.md,
padding: spacing.sm,
borderWidth: 1,
borderColor: `rgba(243, 156, 18, 0.3)`,
borderColor: 'rgba(255, 255, 255, 0.06)',
},
responseHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.xs,
},
responseName: {
...typography.caption1,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 1,
},
answerBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: 2,
gap: 3,
borderRadius: radius.full,
},
responsePillTextMaybe: {
answerBadgeText: {
...typography.caption2,
fontWeight: '600',
fontWeight: '700',
color: colors.text,
letterSpacing: 0.5,
},
answerBadgeMaybe: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: 2,
gap: 3,
backgroundColor: 'rgba(243, 156, 18, 0.15)',
borderWidth: 1,
borderColor: 'rgba(243, 156, 18, 0.3)',
borderRadius: radius.full,
},
answerBadgeTextMaybe: {
...typography.caption2,
fontWeight: '700',
color: colors.warning,
letterSpacing: 0.5,
},
responseQuestionText: {
...typography.footnote,
color: colors.text,
fontStyle: 'italic',
lineHeight: 18,
},
matchCardBorder: {
position: 'absolute',
+2
View File
@@ -4,3 +4,5 @@ export { useMatchStore } from "./matchStore";
export { usePacksStore } from "./packsStore";
export { useMessageStore } from "./messageStore";
export { useSubscriptionStore } from "./subscriptionStore";
export { useResponsesStore, groupResponses } from "./responsesStore";
export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption } from "./responsesStore";
+91 -53
View File
@@ -6,77 +6,105 @@ import { useAuthStore } from "./authStore";
interface MatchState {
matches: Match[];
newMatchesCount: number;
isLoading: boolean;
error: string | null;
fetchMatches: () => Promise<void>;
markAsSeen: (matchId: string) => Promise<void>;
markAllAsSeen: () => Promise<void>;
addMatch: (match: Match) => void;
updateMatchUnreadCount: (matchId: string, delta: number) => void;
clearMatches: () => void;
}
export const useMatchStore = create<MatchState>((set, get) => ({
matches: [],
newMatchesCount: 0,
isLoading: false,
error: null,
fetchMatches: async () => {
const userId = useAuthStore.getState().user?.id;
const coupleId = useAuthStore.getState().user?.couple_id;
const { data: matches } = await supabase
.from("matches")
.select(`
*,
question:questions(*)
`)
.order("created_at", { ascending: false });
if (!matches) return;
// Fetch all responses for these questions to determine who answered first
const questionIds = matches.map(m => m.question_id);
const { data: responses } = await supabase
.from("responses")
.select("*")
.in("question_id", questionIds);
// Fetch unread message counts per match
const matchIds = matches.map(m => m.id);
let unreadCounts: Record<string, number> = {};
if (userId && matchIds.length > 0) {
const { data: unreadMessages, error } = await supabase
.from("messages")
.select("match_id")
.in("match_id", matchIds)
.neq("user_id", userId)
.is("read_at", null);
if (error) {
console.error("Error fetching unread messages:", error);
}
// Count unread messages per match
unreadMessages?.forEach(msg => {
unreadCounts[msg.match_id] = (unreadCounts[msg.match_id] || 0) + 1;
});
// Early return if no couple - user isn't paired yet
if (!coupleId) {
set({ matches: [], newMatchesCount: 0, isLoading: false });
return;
}
const data = matches.map(match => ({
...match,
responses: responses?.filter(r => r.question_id === match.question_id) || [],
unreadCount: unreadCounts[match.id] || 0
}));
set({ isLoading: true, error: null });
// Sort: unread messages first, then by most recent
const sortedData = data.sort((a, b) => {
// First priority: matches with unread messages
if (a.unreadCount > 0 && b.unreadCount === 0) return -1;
if (a.unreadCount === 0 && b.unreadCount > 0) return 1;
try {
const { data: matches, error: matchError } = await supabase
.from("matches")
.select(`
*,
question:questions(*)
`)
.eq("couple_id", coupleId)
.order("created_at", { ascending: false });
// Second priority: most recent first
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
if (matchError) throw matchError;
if (!matches) {
set({ matches: [], newMatchesCount: 0, isLoading: false });
return;
}
const newCount = sortedData.filter((m) => m.is_new).length;
set({ matches: sortedData, newMatchesCount: newCount });
// Fetch all responses for these questions to determine who answered first
const questionIds = matches.map(m => m.question_id);
let responses: any[] = [];
if (questionIds.length > 0) {
const { data } = await supabase
.from("responses")
.select("*")
.in("question_id", questionIds);
responses = data ?? [];
}
// Fetch unread message counts per match
const matchIds = matches.map(m => m.id);
let unreadCounts: Record<string, number> = {};
if (userId && matchIds.length > 0) {
const { data: unreadMessages, error } = await supabase
.from("messages")
.select("match_id")
.in("match_id", matchIds)
.neq("user_id", userId)
.is("read_at", null);
if (error) {
console.error("Error fetching unread messages:", error);
}
// Count unread messages per match
unreadMessages?.forEach(msg => {
unreadCounts[msg.match_id] = (unreadCounts[msg.match_id] || 0) + 1;
});
}
const data = matches.map(match => ({
...match,
responses: responses.filter(r => r.question_id === match.question_id),
unreadCount: unreadCounts[match.id] || 0
}));
// Sort: unread messages first, then by most recent
const sortedData = data.sort((a, b) => {
// First priority: matches with unread messages
if (a.unreadCount > 0 && b.unreadCount === 0) return -1;
if (a.unreadCount === 0 && b.unreadCount > 0) return 1;
// Second priority: most recent first
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
const newCount = sortedData.filter((m) => m.is_new).length;
set({ matches: sortedData, newMatchesCount: newCount, isLoading: false });
} catch (err) {
console.error("Error fetching matches:", err);
set({ error: "Failed to load matches", isLoading: false });
}
},
markAsSeen: async (matchId) => {
@@ -115,7 +143,17 @@ export const useMatchStore = create<MatchState>((set, get) => ({
}));
},
updateMatchUnreadCount: (matchId, delta) => {
set((state) => ({
matches: state.matches.map((m) =>
m.id === matchId
? { ...m, unreadCount: Math.max(0, (m.unreadCount || 0) + delta) }
: m
),
}));
},
clearMatches: () => {
set({ matches: [], newMatchesCount: 0 });
set({ matches: [], newMatchesCount: 0, isLoading: false, error: null });
},
}));
+15 -1
View File
@@ -2,6 +2,7 @@ import { create } from "zustand";
import { supabase } from "../lib/supabase";
import type { Database } from "@/types/supabase";
import { useAuthStore } from "./authStore";
import { useMatchStore } from "./matchStore";
type Message = Database["public"]["Tables"]["messages"]["Row"];
@@ -72,6 +73,14 @@ export const useMessageStore = create<MessageState>((set, get) => ({
const userId = useAuthStore.getState().user?.id;
if (!userId) return;
// Get count of unread messages before marking them read
const { count: unreadInMatch } = await supabase
.from("messages")
.select("*", { count: "exact", head: true })
.eq("match_id", matchId)
.neq("user_id", userId)
.is("read_at", null);
await supabase
.from("messages")
.update({ read_at: new Date().toISOString() })
@@ -79,8 +88,13 @@ export const useMessageStore = create<MessageState>((set, get) => ({
.neq("user_id", userId)
.is("read_at", null);
// Refetch unread count
// Refetch global unread count
await get().fetchUnreadCount();
// Sync per-match unread count in matchStore
if (unreadInMatch && unreadInMatch > 0) {
useMatchStore.getState().updateMatchUnreadCount(matchId, -unreadInMatch);
}
},
clearMessages: () => {
+11
View File
@@ -11,6 +11,7 @@ interface PacksState {
isLoading: boolean;
fetchPacks: () => Promise<void>;
fetchEnabledPacks: () => Promise<void>;
ensureEnabledPacksLoaded: () => Promise<void>;
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
clearPacks: () => void;
}
@@ -74,6 +75,16 @@ export const usePacksStore = create<PacksState>((set, get) => ({
set({ enabledPackIds: ids });
},
ensureEnabledPacksLoaded: async () => {
// Only fetch if not already loaded - lightweight check for swipe screen
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId) return;
if (get().enabledPackIds.length === 0) {
await get().fetchEnabledPacks();
}
},
togglePack: async (packId: string): Promise<{ success: boolean; reason?: string }> => {
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId) {
+278
View File
@@ -0,0 +1,278 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import { invokeWithAuthRetry } from "../lib/authErrorHandler";
import type { Question, QuestionPack, AnswerType } from "@/types";
import { useAuthStore } from "./authStore";
import { useMatchStore } from "./matchStore";
// Response with joined question and pack data
export interface ResponseWithQuestion {
id: string;
question_id: string;
answer: AnswerType;
created_at: string;
question: Question & {
pack: Pick<QuestionPack, "id" | "name" | "icon">;
};
has_match: boolean;
match_id?: string;
partner_answered: boolean;
}
// Result from update-response edge function
export interface UpdateResponseResult {
success: boolean;
requires_confirmation?: boolean;
match_id?: string;
message_count?: number;
new_match?: Record<string, unknown> | null;
match_deleted?: boolean;
match_type_updated?: boolean;
error?: string;
}
export type GroupByOption = "pack" | "date" | "answer";
interface ResponsesState {
responses: ResponseWithQuestion[];
isLoading: boolean;
groupBy: GroupByOption;
fetchResponses: () => Promise<void>;
updateResponse: (
questionId: string,
newAnswer: AnswerType,
confirmDelete?: boolean
) => Promise<UpdateResponseResult>;
setGroupBy: (groupBy: GroupByOption) => void;
clearResponses: () => void;
}
export const useResponsesStore = create<ResponsesState>((set, get) => ({
responses: [],
isLoading: false,
groupBy: "pack",
fetchResponses: async () => {
const userId = useAuthStore.getState().user?.id;
const coupleId = useAuthStore.getState().user?.couple_id;
if (!userId || !coupleId) return;
set({ isLoading: true });
try {
// Fetch all user's responses with questions and packs
const { data: responses, error: responsesError } = await supabase
.from("responses")
.select(`
id,
question_id,
answer,
created_at,
question:questions(
id,
text,
partner_text,
intensity,
pack_id,
pack:question_packs(id, name, icon)
)
`)
.eq("user_id", userId)
.order("created_at", { ascending: false });
if (responsesError) {
console.error("Error fetching responses:", responsesError);
set({ isLoading: false });
return;
}
if (!responses) {
set({ responses: [], isLoading: false });
return;
}
// Get question IDs for match lookup
const questionIds = responses.map((r) => r.question_id);
// Fetch matches for these questions
const { data: matches } = await supabase
.from("matches")
.select("id, question_id")
.eq("couple_id", coupleId)
.in("question_id", questionIds);
// Create a map of question_id -> match_id
const matchMap = new Map<string, string>();
matches?.forEach((m) => matchMap.set(m.question_id, m.id));
// Fetch partner's responses to check if they've answered
const { data: partnerResponses } = await supabase
.from("responses")
.select("question_id")
.eq("couple_id", coupleId)
.neq("user_id", userId)
.in("question_id", questionIds);
// Create a set of question IDs that partner has answered
const partnerAnsweredSet = new Set<string>(
partnerResponses?.map((r) => r.question_id) || []
);
// Transform responses with match and partner info
const transformedResponses: ResponseWithQuestion[] = responses
.filter((r) => {
// Supabase returns single relations as objects, but TypeScript may infer arrays
const question = r.question as any;
return question && question.pack;
})
.map((r) => {
// Cast to any to handle Supabase's nested select typing
const question = r.question as any;
const pack = question.pack as any;
return {
id: r.id,
question_id: r.question_id,
answer: r.answer as AnswerType,
created_at: r.created_at,
question: {
id: question.id,
text: question.text,
partner_text: question.partner_text,
intensity: question.intensity,
pack_id: question.pack_id,
created_at: question.created_at || "",
pack: {
id: pack.id,
name: pack.name,
icon: pack.icon,
},
},
has_match: matchMap.has(r.question_id),
match_id: matchMap.get(r.question_id),
partner_answered: partnerAnsweredSet.has(r.question_id),
};
});
set({ responses: transformedResponses, isLoading: false });
} catch (error) {
console.error("Error in fetchResponses:", error);
set({ isLoading: false });
}
},
updateResponse: async (
questionId: string,
newAnswer: AnswerType,
confirmDelete = false
): Promise<UpdateResponseResult> => {
const { data, error } = await invokeWithAuthRetry("update-response", {
body: {
question_id: questionId,
new_answer: newAnswer,
confirm_delete_match: confirmDelete,
},
});
if (error) {
console.error("Error updating response:", error);
return { success: false, error: error.message || "Failed to update response" };
}
const result = data as UpdateResponseResult;
// If update was successful (not just requiring confirmation), update local state
if (result.success && !result.requires_confirmation) {
// Update local response state
set((state) => ({
responses: state.responses.map((r) => {
if (r.question_id === questionId) {
return {
...r,
answer: newAnswer,
// Update match status based on result
has_match: result.match_deleted
? false
: result.new_match
? true
: r.has_match,
match_id: result.match_deleted
? undefined
: result.new_match
? (result.new_match.id as string)
: r.match_id,
};
}
return r;
}),
}));
// Refresh matches store if match was created, deleted, or type updated
if (result.match_deleted || result.new_match || result.match_type_updated) {
useMatchStore.getState().fetchMatches();
}
}
return result;
},
setGroupBy: (groupBy: GroupByOption) => {
set({ groupBy });
},
clearResponses: () => {
set({ responses: [], isLoading: false, groupBy: "pack" });
},
}));
// Helper function to group responses
export function groupResponses(
responses: ResponseWithQuestion[],
groupBy: GroupByOption
): { title: string; data: ResponseWithQuestion[] }[] {
if (groupBy === "pack") {
// Group by pack name
const groups = new Map<string, ResponseWithQuestion[]>();
responses.forEach((r) => {
const packName = r.question.pack.name;
if (!groups.has(packName)) {
groups.set(packName, []);
}
groups.get(packName)!.push(r);
});
return Array.from(groups.entries()).map(([title, data]) => ({ title, data }));
}
if (groupBy === "answer") {
// Group by answer type
const yesResponses = responses.filter((r) => r.answer === "yes");
const maybeResponses = responses.filter((r) => r.answer === "maybe");
const noResponses = responses.filter((r) => r.answer === "no");
const sections = [];
if (yesResponses.length > 0) sections.push({ title: "Yes", data: yesResponses });
if (maybeResponses.length > 0) sections.push({ title: "Maybe", data: maybeResponses });
if (noResponses.length > 0) sections.push({ title: "No", data: noResponses });
return sections;
}
if (groupBy === "date") {
// Group by date (day)
const groups = new Map<string, ResponseWithQuestion[]>();
responses.forEach((r) => {
const date = new Date(r.created_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
if (!groups.has(date)) {
groups.set(date, []);
}
groups.get(date)!.push(r);
});
return Array.from(groups.entries()).map(([title, data]) => ({ title, data }));
}
return [{ title: "All Responses", data: responses }];
}
+17 -16
View File
@@ -1,22 +1,22 @@
// User profile (extends Supabase auth.users)
export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
export interface Profile {
id: string;
name: string | null;
email: string | null;
avatar_url: string | null;
push_token: string | null;
is_premium: boolean;
couple_id: string | null;
gender: Gender | null;
show_explicit_content: boolean;
onboarding_completed: boolean;
created_at: string;
updated_at: string;
/** RSA public key for E2EE (JWK format) */
public_key_jwk?: Record<string, unknown> | null;
}
export interface Profile {
id: string;
name: string | null;
email: string | null;
avatar_url: string | null;
push_token: string | null;
is_premium: boolean;
couple_id: string | null;
gender: Gender | null;
show_explicit_content: boolean;
onboarding_completed: boolean;
created_at: string;
updated_at: string;
/** RSA public key for E2EE (JWK format) */
public_key_jwk?: Record<string, unknown> | null;
}
// Couple pairing
export interface Couple {
@@ -87,6 +87,7 @@ export interface Match {
match_type: MatchType;
is_new: boolean;
created_at: string;
unreadCount?: number;
}
// Extended match with question details