diff --git a/apps/mobile/DATA_RETRIEVAL_TASKS.md b/apps/mobile/DATA_RETRIEVAL_TASKS.md
new file mode 100644
index 0000000..d8b3347
--- /dev/null
+++ b/apps/mobile/DATA_RETRIEVAL_TASKS.md
@@ -0,0 +1,120 @@
+# Mobile data retrieval review: task list
+
+This task list targets the **Home**, **Swipe**, and **Matches** flows in `apps/mobile`, focusing on correctness, performance, and realtime consistency.
+
+## Overview (current state)
+
+- Data fetching is split between Zustand stores (`useMatchStore`, `usePacksStore`) and screen-local Supabase RPC calls (notably `SwipeScreen`).
+- Realtime updates are centralized in `apps/mobile/app/(app)/_layout.tsx` (matches/messages/profile/pack-settings subscriptions).
+- Main risks today: missing query filters & error/loading states, redundant fetches, state races on param changes, and unread-count drift between realtime and the matches list.
+
+## Scope
+
+- Screens
+ - Home: `apps/mobile/app/(app)/index.tsx`
+ - Swipe: `apps/mobile/app/(app)/swipe.tsx`
+ - Matches: `apps/mobile/app/(app)/matches.tsx`
+- Stores
+ - Matches: `apps/mobile/src/store/matchStore.ts`
+ - Packs: `apps/mobile/src/store/packsStore.ts`
+ - Messages: `apps/mobile/src/store/messageStore.ts`
+- Realtime orchestration
+ - `apps/mobile/app/(app)/_layout.tsx`
+
+## Priority 0 — correctness (must fix)
+
+- [ ] **Home: fix packs count source-of-truth**
+ - Current: `enabledPacksCount = packs.length` (counts available packs).
+ - Decide + implement:
+ - If label means *enabled*: use `enabledPackIds.length`.
+ - If label means *available*: rename the variable + label.
+ - Acceptance: packs number matches user expectations and store state.
+
+- [ ] **Swipe: normalize `packId` and reset state on param changes**
+ - Normalize `packId` from `useLocalSearchParams()` (`string | string[]`) before sending to RPC.
+ - Reset `currentIndex`, `isLoading`, and any derived UI state when `packId` changes.
+ - Acceptance: switching packs never shows stale index/loading/UI.
+
+- [ ] **Swipe: add race protection for overlapping async fetches**
+ - Use a request-id guard (or abort pattern) so older `fetchQuestions()` results cannot overwrite newer results.
+ - Acceptance: rapid navigation/focus changes never clobber newer state.
+
+- [ ] **Swipe: prevent stale “blocked” state on answer-gap errors**
+ - Ensure `checkAnswerGap()` clears/updates `isBlocked` + `gapInfo` deterministically even when RPC errors.
+ - Acceptance: blocked UI never “sticks” due to transient failures.
+
+## Priority 1 — UX and data freshness
+
+- [ ] **Home: ensure data freshness on revisit**
+ - Add `useFocusEffect` or a simple staleness TTL in stores.
+ - Acceptance: counts (matches/new/packs) refresh when returning to Home.
+
+- [ ] **Home: align “Recent Matches” semantics**
+ - `useMatchStore.fetchMatches()` sorts unread first; Home slices top 3.
+ - Decide:
+ - True recent by `created_at`, or
+ - “Top matches” by unread-first.
+ - Acceptance: section name and behavior match.
+
+- [ ] **Matches screen: wire up pull-to-refresh correctly**
+ - Add `isLoading`/`isRefreshing` + `error` to `useMatchStore`.
+ - Use those flags for `RefreshControl.refreshing`.
+ - Acceptance: pull-to-refresh shows spinner and recovers from errors.
+
+## Priority 2 — performance and query efficiency
+
+- [ ] **MatchStore: add explicit filtering and guard empty `in(...)` lists**
+ - Filter matches by `couple_id` (in addition to RLS) for clarity/perf.
+ - If no matches, avoid calling `.in('question_id', [])`.
+ - Acceptance: fewer noisy requests; no empty-list edge behavior.
+
+- [ ] **MatchStore: reduce unread-count work**
+ - Current approach fetches one row per unread message and counts on client.
+ - Options:
+ - Add RPC/view that returns per-match unread counts.
+ - Or store a denormalized `unread_count` per match (if acceptable).
+ - Acceptance: fetching matches scales with large message histories.
+
+- [ ] **Swipe: remove heavy dependency on `fetchPacks()`**
+ - Swipe often only needs `enabledPackIds`.
+ - Add lightweight `ensureEnabledPacksLoaded()` or call `fetchEnabledPacks()` directly.
+ - Acceptance: less data fetched when entering Swipe.
+
+- [ ] **Swipe: replace random comparator sort**
+ - Replace `Array.sort()` comparator using `Math.random()` with a stable score/shuffle pass.
+ - Acceptance: consistent ordering logic; avoids comparator contract violations.
+
+## Priority 3 — realtime correctness and consistency
+
+- [ ] **Realtime messages: tighten subscription filter**
+ - Current `messages` subscription listens without a `filter`.
+ - Improve by filtering to the user’s couple scope (schema permitting) or alternative channel strategy.
+ - Acceptance: avoids noise; reduces risk of unintended cross-couple events.
+
+- [ ] **Sync per-match unread counts with realtime events**
+ - Currently global unread count updates live; per-match counts update only on `fetchMatches()`.
+ - Options:
+ - Update `useMatchStore` unreadCount per match when realtime message arrives.
+ - Or trigger a targeted refresh when new partner message arrives.
+ - Acceptance: Matches list ordering/badges update as messages arrive.
+
+## Optional: structural improvements (bigger refactors)
+
+- [ ] Consolidate fetching into a query layer (e.g., TanStack Query) with caching, invalidation, retries, and focus refetch.
+- [ ] Create a single “matches list” RPC that returns:
+ - match + question
+ - who-answered-first info (for two-part display)
+ - per-match unread counts
+ - new match flag
+- [ ] Add lightweight integration tests around:
+ - `packId` parsing
+ - request race guards
+ - unread count updates
+
+## Suggested implementation order
+
+1. P0 correctness (Home packs count, Swipe param/reset/races, gap-state)
+2. Matches loading/refresh UX
+3. Query efficiency (couple filters, unread-count optimization)
+4. Realtime tightening + unread sync
+5. Optional refactors
diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx
index c0e616b..6bbbea3 100755
--- a/apps/mobile/app/(app)/_layout.tsx
+++ b/apps/mobile/app/(app)/_layout.tsx
@@ -50,21 +50,21 @@ function MessageToastPreview({
currentUserId,
});
- if (isDecrypting) {
- return (
-
- Waiting for message...
-
- );
- }
-
- if (error) {
- return (
-
- Encrypted message
-
- );
- }
+ if (isDecrypting) {
+ return (
+
+ Waiting for message...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ Encrypted message
+
+ );
+ }
return (
@@ -175,7 +175,7 @@ export default function AppLayout() {
const router = useRouter();
const segments = useSegments();
const { isAuthenticated, isLoading, user, signOut } = useAuthStore();
- const { matches, newMatchesCount, addMatch } = useMatchStore();
+ const { matches, newMatchesCount, addMatch, updateMatchUnreadCount } = useMatchStore();
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
const { fetchEnabledPacks } = usePacksStore();
const { initializeRevenueCat } = useSubscriptionStore();
@@ -477,6 +477,9 @@ export default function AppLayout() {
delivered_at: newMessage.delivered_at || new Date().toISOString(),
match: { id: match.id, question: { text: questionText || "" } },
});
+
+ // Sync per-match unread count for realtime updates
+ updateMatchUnreadCount(match.id, 1);
}
}
)
@@ -697,6 +700,12 @@ export default function AppLayout() {
href: null, // Hide from tab bar
}}
/>
+
{/* Radial Menu Overlay */}
diff --git a/apps/mobile/app/(app)/index.tsx b/apps/mobile/app/(app)/index.tsx
index ec3c777..d6b66a1 100755
--- a/apps/mobile/app/(app)/index.tsx
+++ b/apps/mobile/app/(app)/index.tsx
@@ -1,4 +1,4 @@
-import { useEffect } from "react";
+import { useEffect, useCallback } from "react";
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, useWindowDimensions } from "react-native";
import Animated, {
FadeInDown,
@@ -13,7 +13,7 @@ import Animated, {
import { LinearGradient } from "expo-linear-gradient";
import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store";
import { Ionicons } from "@expo/vector-icons";
-import { router } from "expo-router";
+import { router, useFocusEffect } from "expo-router";
import { GradientBackground, GlassCard } from "../../src/components/ui";
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
@@ -23,17 +23,20 @@ const ACCENT_COLOR = colors.primary;
export default function HomeScreen() {
const { user, partner, couple } = useAuthStore();
const { matches, newMatchesCount, fetchMatches } = useMatchStore();
- const { packs, fetchPacks } = usePacksStore();
+ const { enabledPackIds, fetchPacks } = usePacksStore();
const { width } = useWindowDimensions();
- useEffect(() => {
- fetchMatches();
- fetchPacks();
- }, []);
+ // Refresh data when screen gains focus for freshness
+ useFocusEffect(
+ useCallback(() => {
+ fetchMatches();
+ fetchPacks();
+ }, [])
+ );
- const recentMatches = matches.slice(0, 3);
+ const topMatches = matches.slice(0, 3);
const isWideScreen = width > MAX_CONTENT_WIDTH;
- const enabledPacksCount = packs.length;
+ const enabledPacksCount = enabledPackIds.length;
// Subtle pulsating animation for the CTA icon
const pulseScale = useSharedValue(1);
@@ -219,8 +222,8 @@ export default function HomeScreen() {
- {/* Recent Matches */}
- {recentMatches.length > 0 && (
+ {/* Top Matches (sorted by unread first, then recent) */}
+ {topMatches.length > 0 && (
- Recent Matches
+ Top Matches
router.push("/(app)/matches")}
@@ -240,7 +243,7 @@ export default function HomeScreen() {
- {recentMatches.map((match, index) => (
+ {topMatches.map((match, index) => (
+ {/* My Answers button */}
+ router.push("/(app)/my-answers")}
+ >
+
+
+
{/* Premium label */}
DISCOVER
@@ -229,7 +237,7 @@ export default function MatchesScreen() {
showsVerticalScrollIndicator={false}
refreshControl={
MAX_CONTENT_WIDTH;
+
+ // State for edit sheet
+ const [selectedResponse, setSelectedResponse] = useState(null);
+ const [isEditSheetVisible, setIsEditSheetVisible] = useState(false);
+
+ // Ambient orb breathing animations
+ const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
+
+ // Fetch responses on focus
+ useFocusEffect(
+ useCallback(() => {
+ fetchResponses();
+ }, [])
+ );
+
+ const sections = groupResponses(responses, groupBy);
+
+ const handleEditPress = (response: ResponseWithQuestion) => {
+ setSelectedResponse(response);
+ setIsEditSheetVisible(true);
+ };
+
+ const handleEditClose = () => {
+ setIsEditSheetVisible(false);
+ setSelectedResponse(null);
+ };
+
+ const handleEditSuccess = () => {
+ // Refetch responses to get updated data
+ fetchResponses();
+ };
+
+ const renderSectionHeader = ({ section }: { section: { title: string } }) => (
+
+ {section.title}
+
+ );
+
+ const renderItem = ({ item, index }: { item: ResponseWithQuestion; index: number }) => (
+ handleEditPress(item)}
+ onChatPress={item.match_id ? () => router.push(`/chat/${item.match_id}`) : undefined}
+ />
+ );
+
+ const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => (
+ setGroupBy(option)}
+ >
+
+ {label}
+
+
+ );
+
+ if (!user) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Ambient Orbs */}
+
+
+
+
+
+
+
+
+ {/* Header */}
+
+ {/* Back button */}
+ router.back()}>
+
+
+
+
+ REVIEW
+ My Answers
+
+
+ {/* Count badge */}
+
+
+
+ {responses.length} {responses.length === 1 ? "ANSWER" : "ANSWERS"}
+
+
+
+
+ {/* Group by buttons */}
+
+
+
+
+
+
+
+ {isLoading && responses.length === 0 ? (
+
+
+
+ ) : (
+ item.id}
+ contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
+ showsVerticalScrollIndicator={false}
+ stickySectionHeadersEnabled={false}
+ refreshControl={
+
+ }
+ ListEmptyComponent={
+
+
+
+
+ No Answers Yet
+
+ Start swiping on questions to build your answer history. You can
+ come back here to review and change your answers anytime.
+
+ router.push("/(app)/swipe")}
+ style={{ marginTop: spacing.lg }}
+ >
+ Start Swiping
+
+
+ }
+ />
+ )}
+
+
+ {/* Edit Response Sheet */}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ loadingContainer: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ // Ambient orbs
+ ambientOrb: {
+ position: "absolute",
+ width: 300,
+ height: 300,
+ borderRadius: 150,
+ },
+ orbTopRight: {
+ top: 60,
+ right: -40,
+ },
+ orbBottomLeft: {
+ bottom: 180,
+ left: -40,
+ },
+ orbGradient: {
+ width: "100%",
+ height: "100%",
+ borderRadius: 150,
+ },
+ // Header
+ header: {
+ paddingTop: 60,
+ paddingHorizontal: spacing.lg,
+ paddingBottom: spacing.md,
+ },
+ headerWide: {
+ alignSelf: "center",
+ width: "100%",
+ maxWidth: MAX_CONTENT_WIDTH,
+ },
+ backButton: {
+ position: "absolute",
+ top: 60,
+ left: spacing.md,
+ zIndex: 10,
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ backgroundColor: "rgba(255, 255, 255, 0.1)",
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ headerContent: {
+ alignItems: "center",
+ },
+ headerLabel: {
+ ...typography.caption2,
+ fontWeight: "600",
+ letterSpacing: 3,
+ color: ACCENT,
+ marginBottom: spacing.xs,
+ },
+ headerTitle: {
+ ...typography.largeTitle,
+ color: colors.text,
+ textAlign: "center",
+ },
+ countBadgePremium: {
+ flexDirection: "row",
+ alignItems: "center",
+ backgroundColor: `${ACCENT_RGBA}0.1)`,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ borderRadius: radius.full,
+ borderWidth: 1,
+ borderColor: `${ACCENT_RGBA}0.2)`,
+ gap: spacing.xs,
+ },
+ countTextPremium: {
+ ...typography.caption2,
+ fontWeight: "600",
+ letterSpacing: 2,
+ color: ACCENT,
+ },
+ // Group by buttons
+ groupByContainer: {
+ flexDirection: "row",
+ justifyContent: "center",
+ gap: spacing.sm,
+ marginTop: spacing.md,
+ },
+ groupByButton: {
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ borderRadius: radius.full,
+ backgroundColor: "rgba(255, 255, 255, 0.05)",
+ borderWidth: 1,
+ borderColor: "rgba(255, 255, 255, 0.1)",
+ },
+ groupByButtonActive: {
+ backgroundColor: `${ACCENT_RGBA}0.15)`,
+ borderColor: `${ACCENT_RGBA}0.3)`,
+ },
+ groupByText: {
+ ...typography.caption1,
+ color: colors.textSecondary,
+ fontWeight: "500",
+ },
+ groupByTextActive: {
+ color: ACCENT,
+ fontWeight: "600",
+ },
+ // Section header
+ sectionHeader: {
+ paddingVertical: spacing.sm,
+ paddingHorizontal: spacing.xs,
+ marginTop: spacing.md,
+ marginBottom: spacing.xs,
+ },
+ sectionTitle: {
+ ...typography.title3,
+ color: colors.text,
+ },
+ // List
+ list: {
+ padding: spacing.lg,
+ paddingTop: spacing.sm,
+ paddingBottom: Platform.OS === "ios" ? 120 : 100,
+ },
+ listWide: {
+ alignSelf: "center",
+ width: "100%",
+ maxWidth: MAX_CONTENT_WIDTH,
+ },
+ // Empty state
+ emptyContent: {
+ width: "100%",
+ alignItems: "center",
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.xxl,
+ },
+ emptyIcon: {
+ width: 80,
+ height: 80,
+ borderRadius: 40,
+ backgroundColor: `${ACCENT_RGBA}0.1)`,
+ justifyContent: "center",
+ alignItems: "center",
+ marginBottom: spacing.lg,
+ },
+ emptyTitle: {
+ ...typography.title2,
+ color: colors.text,
+ marginBottom: spacing.sm,
+ },
+ emptyDescription: {
+ ...typography.body,
+ color: colors.textSecondary,
+ textAlign: "center",
+ paddingHorizontal: spacing.md,
+ },
+});
diff --git a/apps/mobile/app/(app)/swipe.tsx b/apps/mobile/app/(app)/swipe.tsx
index 3a90c74..352e35f 100755
--- a/apps/mobile/app/(app)/swipe.tsx
+++ b/apps/mobile/app/(app)/swipe.tsx
@@ -1,6 +1,6 @@
-import { useState, useEffect, useRef } from "react";
+import { useState, useEffect, useRef, useCallback } from "react";
import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native";
-import { useLocalSearchParams, router } from "expo-router";
+import { useLocalSearchParams, router, useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import Animated, {
FadeIn,
@@ -20,25 +20,28 @@ import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuesti
import { hasSeenSwipeTutorial, markSwipeTutorialSeen } from "../../src/lib/swipeTutorialSeen";
import { invokeWithAuthRetry } from "../../src/lib/authErrorHandler";
// import SwipeCard from "../../src/components/SwipeCard";
-import { SwipeCardPremium as SwipeCard } from "../../src/components/swipe"; // PoC: Premium styling
-import { SwipeTutorial } from "../../src/components/tutorials";
-import { QuestionFeedbackModal } from "../../src/components/feedback";
-import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
+import { SwipeCardPremium as SwipeCard } from "../../src/components/swipe"; // PoC: Premium styling
+import { SwipeTutorial } from "../../src/components/tutorials";
+import { QuestionFeedbackModal } from "../../src/components/feedback";
+import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
import { Events } from "../../src/lib/analytics";
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
export default function SwipeScreen() {
- const { packId } = useLocalSearchParams();
- const [questions, setQuestions] = useState([]);
- const [isLoading, setIsLoading] = useState(true);
- const [currentIndex, setCurrentIndex] = useState(0);
- const [showTutorial, setShowTutorial] = useState(false);
- const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null);
- const [isBlocked, setIsBlocked] = useState(false);
- const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
- const { enabledPackIds, fetchPacks } = usePacksStore();
+ const params = useLocalSearchParams();
+ // Normalize packId - useLocalSearchParams can return string | string[]
+ const packId = Array.isArray(params.packId) ? params.packId[0] : params.packId;
+ const [questions, setQuestions] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [currentIndex, setCurrentIndex] = useState(0);
+ const [showTutorial, setShowTutorial] = useState(false);
+ const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null);
+ const [isBlocked, setIsBlocked] = useState(false);
+ const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
+ const { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore();
const { partner, couple } = useAuthStore();
const hasTrackedExhausted = useRef(false);
+ const fetchRequestId = useRef(0);
// Ambient orb breathing animations
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
@@ -61,12 +64,34 @@ export default function SwipeScreen() {
],
}));
+ const isFirstMount = useRef(true);
+
useEffect(() => {
- fetchPacks().then(() => fetchQuestions());
+ // Reset state when pack changes
+ setCurrentIndex(0);
+ setIsLoading(true);
+ setIsBlocked(false);
+ setGapInfo(null);
+ hasTrackedExhausted.current = false;
+
+ // Use lightweight ensureEnabledPacksLoaded instead of full fetchPacks
+ ensureEnabledPacksLoaded().then(() => fetchQuestions());
checkTutorial();
- hasTrackedExhausted.current = false; // Reset when pack changes
}, [packId]);
+ // Refresh questions when screen gains focus (handles partner answering while away)
+ useFocusEffect(
+ useCallback(() => {
+ // Skip on first mount since useEffect already fetches
+ if (isFirstMount.current) {
+ isFirstMount.current = false;
+ return;
+ }
+ // Silently refetch to get updated partner_answered status
+ fetchQuestions();
+ }, [packId])
+ );
+
// Check if user is too far ahead of partner (called when questions empty or after swipe)
const checkAnswerGap = async (): Promise => {
if (!couple || !partner) {
@@ -80,6 +105,9 @@ export default function SwipeScreen() {
if (error) {
console.error('Error checking answer gap:', error);
+ // Clear blocked state on error so user isn't stuck
+ setIsBlocked(false);
+ setGapInfo(null);
return false;
}
@@ -97,10 +125,18 @@ export default function SwipeScreen() {
}
return result.is_blocked;
}
+
+ // No data returned - clear blocked state
+ setIsBlocked(false);
+ setGapInfo(null);
+ return false;
} catch (error) {
console.error('Failed to check answer gap:', error);
+ // Clear blocked state on error so user isn't stuck
+ setIsBlocked(false);
+ setGapInfo(null);
+ return false;
}
- return false;
};
// Calculate effective total questions (accounting for gap limit)
@@ -150,7 +186,20 @@ export default function SwipeScreen() {
}
}, [enabledPackIds]);
+ // Fisher-Yates shuffle for stable randomization
+ const shuffleArray = (array: T[]): T[] => {
+ const shuffled = [...array];
+ for (let i = shuffled.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
+ }
+ return shuffled;
+ };
+
const fetchQuestions = async () => {
+ // Race protection: increment request ID and track current request
+ const currentRequestId = ++fetchRequestId.current;
+
try {
const [{ data, error }, skippedIds] = await Promise.all([
supabase.rpc("get_recommended_questions", {
@@ -159,6 +208,11 @@ export default function SwipeScreen() {
getSkippedQuestionIds()
]);
+ // Race guard: if a newer request started, discard this result
+ if (currentRequestId !== fetchRequestId.current) {
+ return;
+ }
+
if (error) {
console.error("Error fetching recommended questions:", error);
throw error;
@@ -177,9 +231,11 @@ export default function SwipeScreen() {
setGapInfo(null);
}
- const sorted = filtered.sort((a: any, b: any) => {
- let scoreA = Math.random();
- let scoreB = Math.random();
+ // First shuffle to randomize, then stable sort by priority scores
+ const shuffled = shuffleArray(filtered);
+ const sorted = shuffled.sort((a: any, b: any) => {
+ let scoreA = 0;
+ let scoreB = 0;
if (a.partner_answered && a.is_two_part) scoreA += 1.5;
else if (a.partner_answered) scoreA += 0.7;
@@ -192,11 +248,18 @@ export default function SwipeScreen() {
return scoreB - scoreA;
});
- setQuestions(sorted);
+ // Only update state if still the current request
+ if (currentRequestId === fetchRequestId.current) {
+ setQuestions(sorted);
+ }
} catch (error) {
- console.error(error);
+ if (currentRequestId === fetchRequestId.current) {
+ console.error(error);
+ }
} finally {
- setIsLoading(false);
+ if (currentRequestId === fetchRequestId.current) {
+ setIsLoading(false);
+ }
}
};
@@ -574,17 +637,17 @@ export default function SwipeScreen() {
)}
- {/* Active card */}
- setFeedbackQuestion({
- id: questions[currentIndex].id,
- text: questions[currentIndex].text
- })}
- />
-
+ {/* Active card */}
+ setFeedbackQuestion({
+ id: questions[currentIndex].id,
+ text: questions[currentIndex].text
+ })}
+ />
+
{/* Hint - Premium styling */}
- {/* Tutorial Overlay */}
- {showTutorial && (
-
- )}
-
- {/* Question Feedback Modal */}
- setFeedbackQuestion(null)}
- questionId={feedbackQuestion?.id || ''}
- questionText={feedbackQuestion?.text || ''}
- />
-
- );
-}
+ {/* Tutorial Overlay */}
+ {showTutorial && (
+
+ )}
+
+ {/* Question Feedback Modal */}
+ setFeedbackQuestion(null)}
+ questionId={feedbackQuestion?.id || ''}
+ questionText={feedbackQuestion?.text || ''}
+ />
+
+ );
+}
const styles = StyleSheet.create({
container: {
diff --git a/apps/mobile/src/__tests__/stores/matchStore.test.ts b/apps/mobile/src/__tests__/stores/matchStore.test.ts
index 9f6798d..19331fe 100644
--- a/apps/mobile/src/__tests__/stores/matchStore.test.ts
+++ b/apps/mobile/src/__tests__/stores/matchStore.test.ts
@@ -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 () => {
diff --git a/apps/mobile/src/__tests__/stores/messageStore.test.ts b/apps/mobile/src/__tests__/stores/messageStore.test.ts
index fdadec9..2f4afee 100644
--- a/apps/mobile/src/__tests__/stores/messageStore.test.ts
+++ b/apps/mobile/src/__tests__/stores/messageStore.test.ts
@@ -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');
diff --git a/apps/mobile/src/components/responses/DeleteMatchConfirmModal.tsx b/apps/mobile/src/components/responses/DeleteMatchConfirmModal.tsx
new file mode 100644
index 0000000..2ad7cfc
--- /dev/null
+++ b/apps/mobile/src/components/responses/DeleteMatchConfirmModal.tsx
@@ -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 (
+
+
+
+
+
+
+
+ {Platform.OS === "ios" ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ );
+}
+
+function ModalContent({
+ messageText,
+ onConfirm,
+ onCancel,
+}: {
+ messageText: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+}) {
+ return (
+ <>
+ {/* Warning icon */}
+
+
+
+
+ {/* Title */}
+ Delete Match?
+
+ {/* Message */}
+
+ Changing your answer to "No" will remove this match permanently.{"\n\n"}
+ {messageText}
+
+
+ {/* Buttons */}
+
+
+ Keep Match
+
+
+
+
+ Delete Match
+
+
+ >
+ );
+}
+
+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",
+ },
+});
diff --git a/apps/mobile/src/components/responses/EditResponseSheet.tsx b/apps/mobile/src/components/responses/EditResponseSheet.tsx
new file mode 100644
index 0000000..79e916c
--- /dev/null
+++ b/apps/mobile/src/components/responses/EditResponseSheet.tsx
@@ -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(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 (
+ handleAnswerPress(answer)}
+ disabled={isLoading}
+ activeOpacity={0.7}
+ >
+ {isPending && isLoading ? (
+
+ ) : (
+
+ )}
+
+ {config.label}
+
+ {isSelected && (
+
+
+
+ )}
+
+ );
+ };
+
+ return (
+
+
+
+
+
+
+
+ {Platform.OS === "ios" ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {/* Confirmation 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 */}
+
+
+ {/* Header */}
+
+ Edit Answer
+
+
+
+
+
+ {/* Question text */}
+
+ {response.question.text}
+
+
+ {response.question.pack.name}
+
+
+
+ {/* Warning for matched questions */}
+ {response.has_match && (
+
+
+
+ This question has a match. Changing to "No" will delete the match and
+ all messages.
+
+
+ )}
+
+ {/* Answer buttons */}
+
+
+
+
+
+
+ {/* Bottom padding for safe area */}
+
+ >
+ );
+}
+
+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,
+ },
+});
diff --git a/apps/mobile/src/components/responses/ResponseCard.tsx b/apps/mobile/src/components/responses/ResponseCard.tsx
new file mode 100644
index 0000000..1146b68
--- /dev/null
+++ b/apps/mobile/src/components/responses/ResponseCard.tsx
@@ -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 (
+
+
+ {/* Subtle gradient background */}
+
+ {/* Top silk highlight */}
+
+
+
+ {/* Answer badge */}
+
+
+
+
+ {/* Content */}
+
+
+ {response.question.text}
+
+
+
+ {/* Answer tag */}
+
+
+ {answerStyle.label}
+
+
+
+ {/* Pack name */}
+
+
+
+ {response.question.pack.name}
+
+
+
+ {/* Partner status indicator */}
+ {response.partner_answered && (
+
+
+
+ )}
+
+
+
+ {/* Right section */}
+
+ {/* Match indicator with chat link */}
+ {response.has_match && onChatPress && (
+
+
+
+ )}
+
+ {/* Edit button */}
+
+
+
+
+
+
+ {/* Premium border */}
+
+
+
+ );
+}
+
+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",
+ },
+});
diff --git a/apps/mobile/src/components/responses/index.ts b/apps/mobile/src/components/responses/index.ts
new file mode 100644
index 0000000..7bf1fad
--- /dev/null
+++ b/apps/mobile/src/components/responses/index.ts
@@ -0,0 +1,3 @@
+export { ResponseCard } from "./ResponseCard";
+export { EditResponseSheet } from "./EditResponseSheet";
+export { DeleteMatchConfirmModal } from "./DeleteMatchConfirmModal";
diff --git a/apps/mobile/src/components/tutorials/MatchesTutorial.tsx b/apps/mobile/src/components/tutorials/MatchesTutorial.tsx
index 64b542f..cb80f65 100644
--- a/apps/mobile/src/components/tutorials/MatchesTutorial.tsx
+++ b/apps/mobile/src/components/tutorials/MatchesTutorial.tsx
@@ -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) {
)}
+
+ {/* Edit answers demo - show on fourth step */}
+ {currentStep === 3 && (
+
+
+
+
+ "Try something new together"
+
+
+
+ YES
+
+
+
+
+
+
+
+
+
+
+
+
+ "Try something new together"
+
+
+
+ MAYBE
+
+
+
+
+ )}
@@ -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,
diff --git a/apps/mobile/src/features/chat/components/ChatHeader.tsx b/apps/mobile/src/features/chat/components/ChatHeader.tsx
index e85c9dc..1920411 100644
--- a/apps/mobile/src/features/chat/components/ChatHeader.tsx
+++ b/apps/mobile/src/features/chat/components/ChatHeader.tsx
@@ -137,51 +137,64 @@ export const ChatHeader: React.FC = ({
marginVertical={spacing.md}
/>
- {/* Question Text */}
-
- "{match.question.text}"
-
- {match.question.partner_text && (
-
- "{match.question.partner_text}"
-
- )}
-
- {/* Response Pills */}
- {match.responses && match.responses.length > 0 && (
-
- {match.responses.map((response: any) => {
- const isUser = response.user_id === user?.id;
- const isYes = response.answer === 'yes';
- return (
-
- {isYes ? (
-
-
-
- {isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
-
-
- ) : (
-
-
-
- {isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
-
-
- )}
-
+ {/* Question and Responses - Show who answered what */}
+ {match.responses && match.responses.length > 0 ? (
+
+ {(() => {
+ // 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 (
+
+ {/* Name and Answer Badge */}
+
+ {name}
+ {isYes ? (
+
+
+ YES
+
+ ) : (
+
+
+ MAYBE
+
+ )}
+
+ {/* Question they saw */}
+
+ "{questionText}"
+
+
+ );
+ });
+ })()}
+ ) : (
+
+ "{match.question.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',
diff --git a/apps/mobile/src/store/index.ts b/apps/mobile/src/store/index.ts
index 52480a5..638407d 100755
--- a/apps/mobile/src/store/index.ts
+++ b/apps/mobile/src/store/index.ts
@@ -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";
diff --git a/apps/mobile/src/store/matchStore.ts b/apps/mobile/src/store/matchStore.ts
index 0bb8d6d..31d2131 100644
--- a/apps/mobile/src/store/matchStore.ts
+++ b/apps/mobile/src/store/matchStore.ts
@@ -6,77 +6,105 @@ import { useAuthStore } from "./authStore";
interface MatchState {
matches: Match[];
newMatchesCount: number;
+ isLoading: boolean;
+ error: string | null;
fetchMatches: () => Promise;
markAsSeen: (matchId: string) => Promise;
markAllAsSeen: () => Promise;
addMatch: (match: Match) => void;
+ updateMatchUnreadCount: (matchId: string, delta: number) => void;
clearMatches: () => void;
}
export const useMatchStore = create((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 = {};
-
- 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 = {};
+
+ 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((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 });
},
}));
diff --git a/apps/mobile/src/store/messageStore.ts b/apps/mobile/src/store/messageStore.ts
index 1192f99..82244b1 100644
--- a/apps/mobile/src/store/messageStore.ts
+++ b/apps/mobile/src/store/messageStore.ts
@@ -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((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((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: () => {
diff --git a/apps/mobile/src/store/packsStore.ts b/apps/mobile/src/store/packsStore.ts
index 8a3ce30..d1ba87f 100644
--- a/apps/mobile/src/store/packsStore.ts
+++ b/apps/mobile/src/store/packsStore.ts
@@ -11,6 +11,7 @@ interface PacksState {
isLoading: boolean;
fetchPacks: () => Promise;
fetchEnabledPacks: () => Promise;
+ ensureEnabledPacksLoaded: () => Promise;
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
clearPacks: () => void;
}
@@ -74,6 +75,16 @@ export const usePacksStore = create((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) {
diff --git a/apps/mobile/src/store/responsesStore.ts b/apps/mobile/src/store/responsesStore.ts
new file mode 100644
index 0000000..cb30705
--- /dev/null
+++ b/apps/mobile/src/store/responsesStore.ts
@@ -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;
+ };
+ 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 | 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;
+ updateResponse: (
+ questionId: string,
+ newAnswer: AnswerType,
+ confirmDelete?: boolean
+ ) => Promise;
+ setGroupBy: (groupBy: GroupByOption) => void;
+ clearResponses: () => void;
+}
+
+export const useResponsesStore = create((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();
+ 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(
+ 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 => {
+ 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();
+ 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();
+ 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 }];
+}
diff --git a/apps/mobile/src/types/index.ts b/apps/mobile/src/types/index.ts
index e3bd1a6..2d726f3 100755
--- a/apps/mobile/src/types/index.ts
+++ b/apps/mobile/src/types/index.ts
@@ -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 | 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 | 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
diff --git a/apps/supabase/functions/update-response/index.ts b/apps/supabase/functions/update-response/index.ts
new file mode 100644
index 0000000..2b65524
--- /dev/null
+++ b/apps/supabase/functions/update-response/index.ts
@@ -0,0 +1,279 @@
+import "jsr:@supabase/functions-js/edge-runtime.d.ts";
+import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
+
+const corsHeaders = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
+};
+
+type Answer = "yes" | "no" | "maybe";
+type MatchType = "yes_yes" | "yes_maybe" | "maybe_maybe";
+
+interface UpdateResponseBody {
+ question_id: string;
+ new_answer: Answer;
+ confirm_delete_match?: boolean;
+}
+
+interface UpdateResponseResult {
+ success: boolean;
+ requires_confirmation?: boolean;
+ match_id?: string;
+ message_count?: number;
+ new_match?: Record | null;
+ match_deleted?: boolean;
+ match_type_updated?: boolean;
+}
+
+function calculateMatch(a1: Answer, a2: Answer): MatchType | null {
+ if (a1 === "no" || a2 === "no") return null;
+ if (a1 === "yes" && a2 === "yes") return "yes_yes";
+ if (a1 === "maybe" && a2 === "maybe") return "maybe_maybe";
+ return "yes_maybe";
+}
+
+Deno.serve(async (req) => {
+ // Handle CORS preflight
+ if (req.method === "OPTIONS") {
+ return new Response("ok", { headers: corsHeaders });
+ }
+
+ try {
+ const supabase = createClient(
+ Deno.env.get("SUPABASE_URL")!,
+ Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
+ );
+
+ // Get user from JWT
+ const authHeader = req.headers.get("Authorization");
+ if (!authHeader) {
+ return new Response(
+ JSON.stringify({ error: "Missing authorization header" }),
+ { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
+ );
+ }
+
+ const { data: { user }, error: authError } = await supabase.auth.getUser(
+ authHeader.replace("Bearer ", "")
+ );
+
+ if (authError || !user) {
+ return new Response(
+ JSON.stringify({ error: "Invalid token" }),
+ { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
+ );
+ }
+
+ // Parse request body
+ const { question_id, new_answer, confirm_delete_match }: UpdateResponseBody = await req.json();
+
+ if (!question_id || !new_answer) {
+ return new Response(
+ JSON.stringify({ error: "Missing question_id or new_answer" }),
+ { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
+ );
+ }
+
+ // Validate answer
+ if (!["yes", "no", "maybe"].includes(new_answer)) {
+ return new Response(
+ JSON.stringify({ error: "Invalid answer. Must be yes, no, or maybe" }),
+ { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
+ );
+ }
+
+ // Get user's profile and couple
+ const { data: profile, error: profileError } = await supabase
+ .from("profiles")
+ .select("couple_id")
+ .eq("id", user.id)
+ .single();
+
+ if (profileError || !profile?.couple_id) {
+ return new Response(
+ JSON.stringify({ error: "User is not in a couple" }),
+ { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
+ );
+ }
+
+ // Get current response
+ const { data: currentResponse, error: currentResponseError } = await supabase
+ .from("responses")
+ .select("answer")
+ .eq("user_id", user.id)
+ .eq("question_id", question_id)
+ .maybeSingle();
+
+ if (currentResponseError) {
+ return new Response(
+ JSON.stringify({ error: "Failed to get current response" }),
+ { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
+ );
+ }
+
+ if (!currentResponse) {
+ return new Response(
+ JSON.stringify({ error: "No existing response found for this question" }),
+ { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
+ );
+ }
+
+ // If answer hasn't changed, return early
+ if (currentResponse.answer === new_answer) {
+ return new Response(
+ JSON.stringify({ success: true, message: "Answer unchanged" } as UpdateResponseResult),
+ { headers: { ...corsHeaders, "Content-Type": "application/json" } }
+ );
+ }
+
+ // Check if a match exists for this question
+ const { data: existingMatch } = await supabase
+ .from("matches")
+ .select("id")
+ .eq("couple_id", profile.couple_id)
+ .eq("question_id", question_id)
+ .maybeSingle();
+
+ // Get partner's response
+ const { data: partnerResponse } = await supabase
+ .from("responses")
+ .select("answer")
+ .eq("couple_id", profile.couple_id)
+ .eq("question_id", question_id)
+ .neq("user_id", user.id)
+ .maybeSingle();
+
+ const result: UpdateResponseResult = { success: true };
+
+ // Case 1: Changing to "no" AND match exists - requires confirmation
+ if (new_answer === "no" && existingMatch) {
+ // Get message count for confirmation dialog
+ const { count: messageCount } = await supabase
+ .from("messages")
+ .select("*", { count: "exact", head: true })
+ .eq("match_id", existingMatch.id);
+
+ if (!confirm_delete_match) {
+ // Return confirmation required
+ return new Response(
+ JSON.stringify({
+ success: false,
+ requires_confirmation: true,
+ match_id: existingMatch.id,
+ message_count: messageCount || 0,
+ } as UpdateResponseResult),
+ { headers: { ...corsHeaders, "Content-Type": "application/json" } }
+ );
+ }
+
+ // User confirmed deletion - delete media from storage
+ const { data: files } = await supabase.storage
+ .from("chat-media")
+ .list(existingMatch.id);
+
+ if (files && files.length > 0) {
+ const filePaths = files.map(f => `${existingMatch.id}/${f.name}`);
+ await supabase.storage
+ .from("chat-media")
+ .remove(filePaths);
+ }
+
+ // Delete the match (messages cascade automatically via FK)
+ const { error: deleteMatchError } = await supabase
+ .from("matches")
+ .delete()
+ .eq("id", existingMatch.id);
+
+ if (deleteMatchError) {
+ console.error("Failed to delete match:", deleteMatchError);
+ return new Response(
+ JSON.stringify({ error: "Failed to delete match" }),
+ { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
+ );
+ }
+
+ result.match_deleted = true;
+ }
+
+ // Case 2: Changing FROM "no" to yes/maybe - may create a new match
+ if (currentResponse.answer === "no" && new_answer !== "no" && partnerResponse) {
+ const newMatchType = calculateMatch(new_answer, partnerResponse.answer as Answer);
+
+ if (newMatchType) {
+ // Create new match
+ const { data: newMatch, error: matchError } = await supabase
+ .from("matches")
+ .upsert(
+ {
+ couple_id: profile.couple_id,
+ question_id,
+ match_type: newMatchType,
+ is_new: true,
+ },
+ { onConflict: "couple_id,question_id" }
+ )
+ .select(`*, question:questions(*)`)
+ .single();
+
+ if (!matchError && newMatch) {
+ result.new_match = newMatch;
+
+ // Trigger push notification for new match
+ try {
+ await supabase.functions.invoke("send-notification", {
+ body: { couple_id: profile.couple_id, match_id: newMatch.id },
+ });
+ } catch (notifyError) {
+ console.error("Failed to send notification:", notifyError);
+ }
+ }
+ }
+ }
+
+ // Case 3: Changing between yes/maybe - may update match type
+ if (
+ currentResponse.answer !== "no" &&
+ new_answer !== "no" &&
+ existingMatch &&
+ partnerResponse
+ ) {
+ const newMatchType = calculateMatch(new_answer, partnerResponse.answer as Answer);
+
+ if (newMatchType) {
+ const { error: updateMatchError } = await supabase
+ .from("matches")
+ .update({ match_type: newMatchType })
+ .eq("id", existingMatch.id);
+
+ if (!updateMatchError) {
+ result.match_type_updated = true;
+ }
+ }
+ }
+
+ // Update the response
+ const { error: updateError } = await supabase
+ .from("responses")
+ .update({ answer: new_answer })
+ .eq("user_id", user.id)
+ .eq("question_id", question_id);
+
+ if (updateError) {
+ return new Response(
+ JSON.stringify({ error: "Failed to update response", details: updateError }),
+ { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
+ );
+ }
+
+ return new Response(
+ JSON.stringify(result),
+ { headers: { ...corsHeaders, "Content-Type": "application/json" } }
+ );
+ } catch (error) {
+ console.error("Error:", error);
+ return new Response(
+ JSON.stringify({ error: "Internal server error" }),
+ { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
+ );
+ }
+});