perf(app): reduce redundant data fetching

Share bounded database pools across API repositories, consolidate sync and chat queries, and make mobile refreshes cache-aware and foreground-aware.

Add focused regression coverage, client freshness documentation, and repository agent role configuration.
This commit is contained in:
jjbrunton committed 2026-08-29 21:47:27 +01:00
1 parent 9957fa43f2
commit c7ee8a4851
81 files changed
+4456 -1400

No files matched your search

+481 -495
View File
@@ -1,509 +1,495 @@
import { Tabs, useRouter, useSegments } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState } from "react-native";
import { BlurView } from "expo-blur";
import { useEffect, useState, useCallback, useRef } from "react";
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore, useStreakStore } from "../../src/store";
import { colors, radius, spacing, typography, shadows } from "../../src/theme";
import { Tabs, useRouter, useSegments } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState } from "react-native";
import { BlurView } from "expo-blur";
import { useEffect, useState, useCallback, useRef } from "react";
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, useStreakStore } from "../../src/store";
import { colors, radius, spacing, typography, shadows } from "../../src/theme";
import { authClient } from "../../src/lib/authClient";
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
import { syncBadgeCount } from "../../src/lib/badge";
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
import { Events } from "../../src/lib/analytics";
import { needsOnboarding } from "../../src/constants/onboarding";
import type { Database } from "../../src/types/supabase";
type Message = Database["public"]["Tables"]["messages"]["Row"];
function MessageToastPreview({
message,
}: {
message: Message;
}) {
if (message.media_path) {
const fallback = message.media_type === 'video' ? 'Sent a video' : 'Sent an image';
return (
<Text style={styles.messageToastBody} numberOfLines={1}>
{fallback}
</Text>
);
}
return (
<Text style={styles.messageToastBody} numberOfLines={1}>
{message.content ?? 'New message'}
</Text>
);
}
function TabBarBackground() {
// Only use BlurView on iOS - Android experimental blur causes white overlay artifacts
// The solid rgba background in tabBarStyle provides the visual effect on Android
if (Platform.OS === 'ios') {
return (
<>
<BlurView
intensity={80}
tint="dark"
style={StyleSheet.absoluteFill}
/>
<View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(14, 14, 17, 0.85)' }]} />
</>
);
}
return null;
}
export default function AppLayout() {
const router = useRouter();
const segments = useSegments();
const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore();
const { newMatchesCount, pendingQuestions, fetchPendingQuestions, fetchMatches } = useMatchStore();
const { unreadCount, lastMessage, fetchUnreadCount, clearLastMessage } = useMessageStore();
const { fetchEnabledPacks } = usePacksStore();
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
import { useCoupleSync } from "../../src/hooks/useCoupleSync";
import { syncBadgeCount } from "../../src/lib/badge";
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
import { Events } from "../../src/lib/analytics";
import { needsOnboarding } from "../../src/constants/onboarding";
import type { Database } from "../../src/types/supabase";
type Message = Database["public"]["Tables"]["messages"]["Row"];
function MessageToastPreview({
message,
}: {
message: Message;
}) {
if (message.media_path) {
const fallback = message.media_type === 'video' ? 'Sent a video' : 'Sent an image';
return (
<Text style={styles.messageToastBody} numberOfLines={1}>
{fallback}
</Text>
);
}
return (
<Text style={styles.messageToastBody} numberOfLines={1}>
{message.content ?? 'New message'}
</Text>
);
}
function TabBarBackground() {
// Only use BlurView on iOS - Android experimental blur causes white overlay artifacts
// The solid rgba background in tabBarStyle provides the visual effect on Android
if (Platform.OS === 'ios') {
return (
<>
<BlurView
intensity={80}
tint="dark"
style={StyleSheet.absoluteFill}
/>
<View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(14, 14, 17, 0.85)' }]} />
</>
);
}
return null;
}
export default function AppLayout() {
const router = useRouter();
const segments = useSegments();
const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore();
const { newMatchesCount, pendingQuestions, matches, fetchPendingQuestions, fetchMatches } = useMatchStore();
const { unreadCount, lastMessage, clearLastMessage } = useMessageStore();
const { initializeRevenueCat, refreshSubscriptionStatus } = useSubscriptionStore();
const { fetchStreak } = useStreakStore();
const messageToastAnim = useRef(new Animated.Value(-100)).current;
const { fetchStreak } = useStreakStore();
const messageToastAnim = useRef(new Animated.Value(-100)).current;
const messageTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isLocked, setIsLocked] = useState(false);
const hasCheckedInitialBiometric = useRef(false);
const wentToBackgroundAt = useRef<number | null>(null);
const [isLocked, setIsLocked] = useState(false);
const hasCheckedInitialBiometric = useRef(false);
const wentToBackgroundAt = useRef<number | null>(null);
const knownMatchIdsRef = useRef<Set<string>>(new Set());
const matchPollInitializedRef = useRef(false);
// Check if we're on screens that should hide the tab bar
const segmentStrings = segments as string[];
const isOnOnboarding = segmentStrings.includes("onboarding");
const isOnPairing = segmentStrings.includes("pairing");
const isOnChat = segmentStrings.includes("chat");
const isOnLiveDraw = segmentStrings.includes("live-draw");
const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2;
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnLiveDraw || isOnSettingsSubscreen;
// Redirect to login when not authenticated, or to onboarding if not completed/outdated
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.replace("/(auth)/login");
} else if (!isLoading && isAuthenticated && user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding) {
router.replace("/(app)/onboarding");
}
}, [isLoading, isAuthenticated, user, router, isOnOnboarding]);
// Check session validity on mount and when app comes to foreground
useEffect(() => {
const checkSession = async () => {
// Check if we're on screens that should hide the tab bar
const segmentStrings = segments as string[];
const isOnOnboarding = segmentStrings.includes("onboarding");
const isOnPairing = segmentStrings.includes("pairing");
const isOnChat = segmentStrings.includes("chat");
const isOnLiveDraw = segmentStrings.includes("live-draw");
const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2;
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnLiveDraw || isOnSettingsSubscreen;
// Redirect to login when not authenticated, or to onboarding if not completed/outdated
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.replace("/(auth)/login");
} else if (!isLoading && isAuthenticated && user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding) {
router.replace("/(app)/onboarding");
}
}, [isLoading, isAuthenticated, user, router, isOnOnboarding]);
// Check session validity on mount and when app comes to foreground
useEffect(() => {
const checkSession = async () => {
const { data: { session } } = await authClient.auth.getSession();
if (!session) {
// No valid session - sign out to clear local state
signOut();
}
};
// Check immediately
checkSession();
// Update last active timestamp on mount
updateLastActive();
// Check when app comes to foreground
const subscription = AppState.addEventListener('change', (nextAppState) => {
if (nextAppState === 'active') {
checkSession();
// Update last active timestamp when app comes to foreground
updateLastActive();
if (!session) {
// No valid session - sign out to clear local state
signOut();
}
};
// Check immediately
checkSession();
// Update last active timestamp on mount
updateLastActive();
// Check when app comes to foreground
const subscription = AppState.addEventListener('change', (nextAppState) => {
if (nextAppState === 'active') {
checkSession();
// Update last active timestamp when app comes to foreground
updateLastActive();
if (user?.id && Platform.OS !== "web") {
void refreshSubscriptionStatus();
}
}
});
return () => {
subscription.remove();
};
}
});
return () => {
subscription.remove();
};
}, [signOut, updateLastActive, user?.id, refreshSubscriptionStatus]);
// Handle biometric lock when app goes to background/foreground
useEffect(() => {
// Check if biometric should be shown on initial mount
const checkInitialBiometric = async () => {
if (hasCheckedInitialBiometric.current) return;
hasCheckedInitialBiometric.current = true;
const enabled = await isBiometricEnabled();
if (enabled && isAuthenticated) {
setIsLocked(true);
}
};
if (isAuthenticated && !isLoading) {
checkInitialBiometric();
}
const subscription = AppState.addEventListener('change', async (nextAppState) => {
// Track when app goes to background (not just inactive)
if (nextAppState === 'background') {
wentToBackgroundAt.current = Date.now();
}
// When app becomes active, check if we should lock
if (nextAppState === 'active' && isAuthenticated && !isLocked) {
// Only lock if we actually went to background (not just inactive from modal/control center)
// and we were in background for at least 1 second
if (wentToBackgroundAt.current) {
const timeInBackground = Date.now() - wentToBackgroundAt.current;
wentToBackgroundAt.current = null;
if (timeInBackground > 1000) {
const enabled = await isBiometricEnabled();
if (enabled) {
setIsLocked(true);
}
}
}
}
});
return () => {
subscription.remove();
};
}, [isAuthenticated, isLoading, isLocked]);
const handleBiometricUnlock = useCallback(() => {
setIsLocked(false);
}, []);
// Fetch unread message count on mount
useEffect(() => {
if (user?.id) {
fetchUnreadCount();
}
}, [user?.id, fetchUnreadCount]);
// Fetch streak data when user is in a couple
useEffect(() => {
if (user?.couple_id) {
fetchStreak();
}
}, [user?.couple_id, fetchStreak]);
// Fetch pending questions (Your Turn) when user is in a couple
useEffect(() => {
if (user?.couple_id) {
fetchPendingQuestions();
}
}, [user?.couple_id, fetchPendingQuestions]);
// Sync app icon badge count whenever matches, messages, or pending questions change
useEffect(() => {
syncBadgeCount(newMatchesCount + pendingQuestions.length, unreadCount);
}, [newMatchesCount, unreadCount, pendingQuestions.length]);
// Initialize RevenueCat for subscription management (iOS and Android only)
useEffect(() => {
if (user?.id && Platform.OS !== "web") {
initializeRevenueCat(user.id);
}
}, [user?.id, initializeRevenueCat]);
// Check and register push token for this device
// This handles the case where user onboarded on a different device
useEffect(() => {
if (user?.id && user?.onboarding_completed) {
checkAndRegisterPushToken(user.id);
}
}, [user?.id, user?.onboarding_completed]);
// Poll the self-hosted API for profile, match, pending-question and pack changes.
// This replaces the former Supabase Postgres change subscriptions.
useEffect(() => {
if (!user?.id) return;
let cancelled = false;
// Handle biometric lock when app goes to background/foreground
useEffect(() => {
// Check if biometric should be shown on initial mount
const checkInitialBiometric = async () => {
if (hasCheckedInitialBiometric.current) return;
hasCheckedInitialBiometric.current = true;
const enabled = await isBiometricEnabled();
if (enabled && isAuthenticated) {
setIsLocked(true);
}
};
if (isAuthenticated && !isLoading) {
checkInitialBiometric();
}
const subscription = AppState.addEventListener('change', async (nextAppState) => {
// Track when app goes to background (not just inactive)
if (nextAppState === 'background') {
wentToBackgroundAt.current = Date.now();
}
// When app becomes active, check if we should lock
if (nextAppState === 'active' && isAuthenticated && !isLocked) {
// Only lock if we actually went to background (not just inactive from modal/control center)
// and we were in background for at least 1 second
if (wentToBackgroundAt.current) {
const timeInBackground = Date.now() - wentToBackgroundAt.current;
wentToBackgroundAt.current = null;
if (timeInBackground > 1000) {
const enabled = await isBiometricEnabled();
if (enabled) {
setIsLocked(true);
}
}
}
}
});
return () => {
subscription.remove();
};
}, [isAuthenticated, isLoading, isLocked]);
const handleBiometricUnlock = useCallback(() => {
setIsLocked(false);
}, []);
// The unread badge arrives with the first sync summary, so it needs no request
// of its own here.
// Fetch streak data when user is in a couple
useEffect(() => {
if (user?.couple_id) {
fetchStreak();
}
}, [user?.couple_id, fetchStreak]);
// Fetch pending questions (Your Turn) and matches when user is in a couple.
// Both feed tab badges, so they load once here and are kept current by the
// sync summary rather than by re-fetching on a timer.
useEffect(() => {
if (user?.couple_id) {
void fetchPendingQuestions();
void fetchMatches(true);
}
}, [user?.couple_id, fetchPendingQuestions, fetchMatches]);
// Sync app icon badge count whenever matches, messages, or pending questions change
useEffect(() => {
syncBadgeCount(newMatchesCount + pendingQuestions.length, unreadCount);
}, [newMatchesCount, unreadCount, pendingQuestions.length]);
// Initialize RevenueCat for subscription management (iOS and Android only)
useEffect(() => {
if (user?.id && Platform.OS !== "web") {
initializeRevenueCat(user.id);
}
}, [user?.id, initializeRevenueCat]);
// Check and register push token for this device
// This handles the case where user onboarded on a different device
useEffect(() => {
if (user?.id && user?.onboarding_completed) {
checkAndRegisterPushToken(user.id);
}
}, [user?.id, user?.onboarding_completed]);
// One small change summary drives every domain refresh. It replaces a
// five-second refetch of profile, matches, pending questions and packs plus a
// three-second unread poll, and only asks a store to reload when the server
// reports that its data actually moved.
useCoupleSync(user?.id, user?.couple_id);
// Match analytics are derived from the store rather than from a poll response,
// so they fire once per genuinely new match however it arrived.
useEffect(() => {
matchPollInitializedRef.current = false;
knownMatchIdsRef.current = new Set();
}, [user?.id, user?.couple_id]);
const poll = async () => {
await Promise.allSettled([
useAuthStore.getState().fetchUser(),
...(user.couple_id ? [fetchMatches(true), fetchPendingQuestions(), fetchEnabledPacks()] : []),
]);
if (cancelled || !user.couple_id) return;
const refreshed = useMatchStore.getState().matches;
if (matchPollInitializedRef.current) {
const added = refreshed.filter(item => !knownMatchIdsRef.current.has(item.id));
for (const item of added) Events.matchCreated(item.match_type || "unknown");
const previousCount = knownMatchIdsRef.current.size;
if (previousCount === 0 && added.length > 0) Events.firstMatch();
else if (added.length > 0 && refreshed.length % 10 === 0) Events.milestoneMatch(refreshed.length);
useEffect(() => {
if (!user?.couple_id) return;
const known = knownMatchIdsRef.current;
const added = matches.filter(item => !known.has(item.id));
if (matchPollInitializedRef.current && added.length > 0) {
for (const item of added) Events.matchCreated(item.match_type || "unknown");
if (known.size === 0) Events.firstMatch();
else if (matches.length % 10 === 0) Events.milestoneMatch(matches.length);
}
knownMatchIdsRef.current = new Set(matches.map(item => item.id));
matchPollInitializedRef.current = true;
}, [matches, user?.couple_id]);
// Animate message toast when lastMessage changes
useEffect(() => {
if (lastMessage) {
// Clear any existing timeout
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
knownMatchIdsRef.current = new Set(refreshed.map(item => item.id));
matchPollInitializedRef.current = true;
};
void poll();
const timer = setInterval(() => void poll(), 5_000);
return () => { cancelled = true; clearInterval(timer); };
}, [user?.id, user?.couple_id, fetchMatches, fetchPendingQuestions, fetchEnabledPacks]);
// Poll the self-hosted API for unread chat state. Chat screens perform their
// own message polling; this keeps the global badge current without Supabase Realtime.
useEffect(() => {
if (!user?.couple_id) return;
const timer = setInterval(() => void fetchUnreadCount(), 3_000);
return () => clearInterval(timer);
}, [user?.couple_id, fetchUnreadCount]);
// Animate message toast when lastMessage changes
useEffect(() => {
if (lastMessage) {
// Clear any existing timeout
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
// Slide in
Animated.spring(messageToastAnim, {
toValue: 0,
useNativeDriver: true,
tension: 80,
friction: 10,
}).start();
// Auto-dismiss after 4 seconds
messageTimeoutRef.current = setTimeout(() => {
dismissMessageToast();
}, 4000);
}
return () => {
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
};
}, [lastMessage]);
const dismissMessageToast = useCallback(() => {
Animated.timing(messageToastAnim, {
toValue: -100,
duration: 200,
useNativeDriver: true,
}).start(() => {
clearLastMessage();
});
}, [messageToastAnim, clearLastMessage]);
const handleMessageToastPress = useCallback(() => {
if (lastMessage?.match_id) {
dismissMessageToast();
router.push(`/(app)/chat/${lastMessage.match_id}`);
}
}, [lastMessage, dismissMessageToast, router]);
// Show loading state while checking authentication or redirecting to onboarding
if (isLoading || !isAuthenticated || (user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding)) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
return (
<>
<Tabs
screenOptions={{
headerShown: false,
tabBarBackground: () => <TabBarBackground />,
tabBarStyle: shouldHideTabBar ? { display: 'none' } : {
position: 'absolute',
backgroundColor: 'rgba(14, 14, 17, 0.85)',
borderTopColor: 'rgba(233, 69, 96, 0.2)', // primary brand color
borderTopWidth: 1,
paddingTop: 8,
paddingBottom: Platform.OS === 'ios' ? 28 : 8,
height: Platform.OS === 'ios' ? 88 : 64,
elevation: 0,
},
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textSecondary,
tabBarLabelStyle: {
fontSize: 10,
fontWeight: "500",
letterSpacing: 0.5,
},
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="swipe"
options={{
href: null, // Hide from tab bar - accessed from home screen
}}
/>
<Tabs.Screen
name="matches"
options={{
title: "Matches",
tabBarIcon: ({ color, size }) => (
<Ionicons name="heart" size={size} color={color} />
),
tabBarBadge: (newMatchesCount + unreadCount + pendingQuestions.length) > 0 ? (newMatchesCount + unreadCount + pendingQuestions.length) : undefined,
tabBarBadgeStyle: {
backgroundColor: colors.premium.rose,
fontSize: 9,
fontWeight: '600',
minWidth: 16,
height: 16,
borderRadius: 8,
},
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "Settings",
tabBarIcon: ({ color, size }) => (
<Ionicons name="settings" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="pairing"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="chat/[id]"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="onboarding"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="dares"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="quiz"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="my-answers"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="settings"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="live-draw"
options={{
href: null, // Hide from tab bar
}}
/>
</Tabs>
{/* Message Toast Notification */}
{lastMessage && (
<Animated.View
style={[
styles.messageToast,
{ transform: [{ translateY: messageToastAnim }] },
]}
>
<Pressable style={styles.messageToastContent} onPress={handleMessageToastPress}>
<View style={styles.messageToastIcon}>
<Ionicons name="chatbubble" size={20} color={colors.primary} />
</View>
<View style={styles.messageToastText}>
<Text style={styles.messageToastTitle} numberOfLines={1}>
New message
</Text>
<MessageToastPreview message={lastMessage} />
</View>
<Pressable onPress={dismissMessageToast} hitSlop={8}>
<Ionicons name="close" size={20} color={colors.textTertiary} />
</Pressable>
</Pressable>
</Animated.View>
)}
{/* Biometric Lock Screen */}
<BiometricLockScreen
visible={isLocked}
onUnlock={handleBiometricUnlock}
/>
</>
);
}
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: colors.background,
},
messageToast: {
position: 'absolute',
top: Platform.OS === 'ios' ? 50 : 20,
left: spacing.md,
right: spacing.md,
zIndex: 1000,
},
messageToastContent: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.backgroundLight,
borderRadius: radius.lg,
padding: spacing.md,
borderWidth: 1,
borderColor: colors.glass.border,
...shadows.lg,
},
messageToastIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primaryLight,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
messageToastText: {
flex: 1,
marginRight: spacing.sm,
},
messageToastTitle: {
...typography.subhead,
color: colors.text,
fontWeight: '600',
},
messageToastBody: {
...typography.caption1,
color: colors.textSecondary,
marginTop: 2,
},
});
// Slide in
Animated.spring(messageToastAnim, {
toValue: 0,
useNativeDriver: true,
tension: 80,
friction: 10,
}).start();
// Auto-dismiss after 4 seconds
messageTimeoutRef.current = setTimeout(() => {
dismissMessageToast();
}, 4000);
}
return () => {
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
};
}, [lastMessage]);
const dismissMessageToast = useCallback(() => {
Animated.timing(messageToastAnim, {
toValue: -100,
duration: 200,
useNativeDriver: true,
}).start(() => {
clearLastMessage();
});
}, [messageToastAnim, clearLastMessage]);
const handleMessageToastPress = useCallback(() => {
if (lastMessage?.match_id) {
dismissMessageToast();
router.push(`/(app)/chat/${lastMessage.match_id}`);
}
}, [lastMessage, dismissMessageToast, router]);
// Show loading state while checking authentication or redirecting to onboarding
if (isLoading || !isAuthenticated || (user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding)) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
return (
<>
<Tabs
screenOptions={{
headerShown: false,
tabBarBackground: () => <TabBarBackground />,
tabBarStyle: shouldHideTabBar ? { display: 'none' } : {
position: 'absolute',
backgroundColor: 'rgba(14, 14, 17, 0.85)',
borderTopColor: 'rgba(233, 69, 96, 0.2)', // primary brand color
borderTopWidth: 1,
paddingTop: 8,
paddingBottom: Platform.OS === 'ios' ? 28 : 8,
height: Platform.OS === 'ios' ? 88 : 64,
elevation: 0,
},
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textSecondary,
tabBarLabelStyle: {
fontSize: 10,
fontWeight: "500",
letterSpacing: 0.5,
},
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="swipe"
options={{
href: null, // Hide from tab bar - accessed from home screen
}}
/>
<Tabs.Screen
name="matches"
options={{
title: "Matches",
tabBarIcon: ({ color, size }) => (
<Ionicons name="heart" size={size} color={color} />
),
tabBarBadge: (newMatchesCount + unreadCount + pendingQuestions.length) > 0 ? (newMatchesCount + unreadCount + pendingQuestions.length) : undefined,
tabBarBadgeStyle: {
backgroundColor: colors.premium.rose,
fontSize: 9,
fontWeight: '600',
minWidth: 16,
height: 16,
borderRadius: 8,
},
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "Settings",
tabBarIcon: ({ color, size }) => (
<Ionicons name="settings" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="pairing"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="chat/[id]"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="onboarding"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="dares"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="quiz"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="my-answers"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="settings"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="live-draw"
options={{
href: null, // Hide from tab bar
}}
/>
</Tabs>
{/* Message Toast Notification */}
{lastMessage && (
<Animated.View
style={[
styles.messageToast,
{ transform: [{ translateY: messageToastAnim }] },
]}
>
<Pressable style={styles.messageToastContent} onPress={handleMessageToastPress}>
<View style={styles.messageToastIcon}>
<Ionicons name="chatbubble" size={20} color={colors.primary} />
</View>
<View style={styles.messageToastText}>
<Text style={styles.messageToastTitle} numberOfLines={1}>
New message
</Text>
<MessageToastPreview message={lastMessage} />
</View>
<Pressable onPress={dismissMessageToast} hitSlop={8}>
<Ionicons name="close" size={20} color={colors.textTertiary} />
</Pressable>
</Pressable>
</Animated.View>
)}
{/* Biometric Lock Screen */}
<BiometricLockScreen
visible={isLocked}
onUnlock={handleBiometricUnlock}
/>
</>
);
}
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: colors.background,
},
messageToast: {
position: 'absolute',
top: Platform.OS === 'ios' ? 50 : 20,
left: spacing.md,
right: spacing.md,
zIndex: 1000,
},
messageToastContent: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.backgroundLight,
borderRadius: radius.lg,
padding: spacing.md,
borderWidth: 1,
borderColor: colors.glass.border,
...shadows.lg,
},
messageToastIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primaryLight,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
messageToastText: {
flex: 1,
marginRight: spacing.sm,
},
messageToastTitle: {
...typography.subhead,
color: colors.text,
fontWeight: '600',
},
messageToastBody: {
...typography.caption1,
color: colors.textSecondary,
marginTop: 2,
},
});
+7 -7
View File
@@ -4,7 +4,7 @@ import { useFocusEffect } from 'expo-router';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { useAuthStore, usePacksStore, useStreakStore, useSubscriptionStore } from '../../src/store';
import { useAuthStore, usePacksStore, useSubscriptionStore } from '../../src/store';
import { GradientBackground } from '../../src/components/ui';
import { Paywall } from '../../src/components/paywall';
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery';
@@ -68,8 +68,7 @@ function groupPacksByCategory(
export default function DiscoveryScreen() {
const { user, partner, couple } = useAuthStore();
const { packs, categories, fetchPacks, getPackProgress } = usePacksStore();
const { fetchStreak } = useStreakStore();
const { packs, categories, fetchPacks, ensurePacksLoaded, getPackProgress } = usePacksStore();
const { subscription } = useSubscriptionStore();
const { width } = useWindowDimensions();
const [showPaywall, setShowPaywall] = useState(false);
@@ -79,12 +78,13 @@ export default function DiscoveryScreen() {
const isPremiumUser = user?.is_premium || partner?.is_premium || subscription.isProUser;
const isWideScreen = width > MAX_CONTENT_WIDTH;
// Refresh data when screen gains focus
// Load the catalog once. Returning to this tab shows what is cached; answering a
// question invalidates it, and StreakDisplay owns the streak, so neither needs a
// request here.
useFocusEffect(
useCallback(() => {
fetchPacks();
fetchStreak();
}, [fetchStreak])
void ensurePacksLoaded();
}, [ensurePacksLoaded])
);
// Group packs by category
+18 -8
View File
@@ -39,6 +39,7 @@ export default function MatchesScreen() {
fetchMatches,
markAllAsSeen,
isLoading,
isRefreshing,
hasMore,
isLoadingMore,
totalCount,
@@ -58,7 +59,9 @@ export default function MatchesScreen() {
fetchTheirTurnQuestions,
currentView,
setCurrentView,
ensureViewLoaded,
fetchPendingQuestions,
fetchArchivedMatches,
// Nudge state and methods
nudgeCooldownUntil,
isNudging,
@@ -118,14 +121,12 @@ export default function MatchesScreen() {
setShowTutorial(false);
};
// Load whichever view is showing, once. Returning to this tab reuses what is
// already cached; the sync poll refreshes it when the server reports a change,
// and pull-to-refresh remains the explicit way to force one.
useFocusEffect(
useCallback(() => {
fetchMatches(true);
// Also refresh pending questions when focused
fetchPendingQuestions();
fetchTheirTurnQuestions();
// Check nudge cooldown
checkNudgeCooldown();
ensureViewLoaded();
// Cleanup: mark matches as seen when leaving the screen (if on Complete tab)
return () => {
@@ -134,9 +135,15 @@ export default function MatchesScreen() {
state.markAllAsSeen();
}
};
}, [])
}, [ensureViewLoaded])
);
// The nudge cooldown only moves when this device sends a nudge, and sendNudge
// already records the new cooldown, so one read on mount is enough.
useEffect(() => {
void checkNudgeCooldown();
}, [checkNudgeCooldown]);
// Format cooldown remaining time
const formatCooldownTime = (cooldownUntil: Date): string => {
const now = new Date();
@@ -633,13 +640,16 @@ export default function MatchesScreen() {
refreshing={
currentView === 'pending' ? isLoadingPending :
currentView === 'their_turn' ? isLoadingTheirTurn :
isLoading
currentView === 'archived' ? isLoadingArchived :
isRefreshing
}
onRefresh={() => {
if (currentView === 'pending') {
fetchPendingQuestions();
} else if (currentView === 'their_turn') {
fetchTheirTurnQuestions();
} else if (currentView === 'archived') {
fetchArchivedMatches();
} else {
fetchMatches(true);
}
+6 -5
View File
@@ -47,7 +47,7 @@ const AnimatedSectionList = Animated.createAnimatedComponent(SectionList);
type GroupByOption = "pack" | "date" | "answer";
export default function MyAnswersScreen() {
const { responses, isLoading, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore();
const { responses, isLoading, isRefreshing, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, ensureResponsesLoaded, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore();
const { user, partner } = useAuthStore();
const router = useRouter();
const params = useLocalSearchParams();
@@ -110,11 +110,12 @@ export default function MyAnswersScreen() {
// Ambient orb breathing animations
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
// Fetch responses on focus
// Load once, then keep showing what is cached. Answering or editing a response
// invalidates it, and pull-to-refresh is always available.
useFocusEffect(
useCallback(() => {
fetchResponses(true);
}, [])
void ensureResponsesLoaded();
}, [ensureResponsesLoaded])
);
const handleLoadMore = useCallback(() => {
@@ -271,7 +272,7 @@ export default function MyAnswersScreen() {
scrollEventThrottle={16}
refreshControl={
<RefreshControl
refreshing={isLoading}
refreshing={isRefreshing}
onRefresh={() => fetchResponses(true)}
tintColor={colors.primary}
colors={[colors.primary]}
+2 -4
View File
@@ -44,7 +44,7 @@ const AnimatedFlatList = Animated.createAnimatedComponent(FlatList<Question>);
export default function PackDetailsScreen() {
const { id } = useLocalSearchParams();
const router = useRouter();
const { packs, fetchPacks } = usePacksStore();
const { packs, ensurePacksLoaded } = usePacksStore();
const { subscription } = useSubscriptionStore();
const { user, partner } = useAuthStore();
const [questions, setQuestions] = useState<Question[]>([]);
@@ -61,9 +61,7 @@ export default function PackDetailsScreen() {
const category = categories.find(c => c.id === pack?.category_id);
useEffect(() => {
if (packs.length === 0) {
fetchPacks();
}
void ensurePacksLoaded();
fetchPackDetails();
}, [id]);
@@ -17,13 +17,19 @@ jest.mock("expo-router", () => ({
jest.mock("@/store", () => {
const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" } };
const packs = { enabledPackIds: ["pack-1"], ensureEnabledPacksLoaded: async () => undefined, packs: [] };
const packState = { ...packs, invalidatePacks: jest.fn() };
const responses = { invalidateResponses: jest.fn() };
const useAuthStore = () => auth;
useAuthStore.getState = () => auth.user;
const usePacksStore = () => packs;
usePacksStore.getState = () => packState;
const useResponsesStore = () => responses;
useResponsesStore.getState = () => responses;
// Read lazily: the factory runs before this module's own consts are initialised.
const streak = { fetchStreak: () => mockFetchStreak() };
const useStreakStore = () => streak;
useStreakStore.getState = () => streak;
return { useAuthStore, usePacksStore: () => packs, useStreakStore };
return { useAuthStore, usePacksStore, useResponsesStore, useStreakStore };
});
jest.mock("@/lib/skippedQuestions", () => ({
@@ -45,17 +51,19 @@ const questions = [
{ id: "q2", pack_id: "pack-1", question_type: "swipe", text: "Two" },
];
const futureResetAt = "2099-08-29T00:00:00.000Z";
const openLimit = {
responses_today: 4,
limit_value: 10,
remaining: 6,
reset_at: "2026-08-29T00:00:00.000Z",
reset_at: futureResetAt,
is_blocked: false,
};
const dailyLimit429 = () => new ApiError("Daily response limit reached", 429, {
error: { code: "daily_limit", message: "Daily response limit reached" },
details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: "2026-08-29T00:00:00.000Z" },
details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: futureResetAt },
});
// clearMocks wipes implementations between tests, so re-arm the whole surface each time.
@@ -92,7 +100,7 @@ describe("useSwipeScreen daily limit", () => {
responses_today: 10,
limit_value: 10,
remaining: 0,
reset_at: "2026-08-29T00:00:00.000Z",
reset_at: futureResetAt,
is_blocked: true,
});
});
@@ -0,0 +1,313 @@
import { act, renderHook } from '@testing-library/react-native';
import { useCoupleSync, SYNC_INTERVAL_MS } from '@/hooks/useCoupleSync';
import { appDataApi, type SyncSummary } from '@/lib/appDataApi';
import { useAuthStore } from '@/store/authStore';
import { useMatchStore } from '@/store/matchStore';
import { useMessageStore } from '@/store/messageStore';
import { usePacksStore } from '@/store/packsStore';
import { useStreakStore } from '@/store/streakStore';
jest.mock('@/lib/appDataApi', () => ({ appDataApi: { syncSummary: jest.fn() } }));
const mockForeground = { value: true };
const mockListeners = new Set<(foreground: boolean) => void>();
jest.mock('@/lib/appForeground', () => ({
isForeground: () => mockForeground.value,
subscribeToForeground: (listener: (foreground: boolean) => void) => {
mockListeners.add(listener);
return () => { mockListeners.delete(listener); };
},
}));
const baseline: SyncSummary = {
server_time: '2026-08-27T12:00:00.000Z',
couple_id: 'couple',
profile_updated_at: '2026-08-01T00:00:00.000Z',
partner_id: 'partner',
partner_updated_at: '2026-08-01T00:00:00.000Z',
match_count: 3,
new_match_count: 0,
latest_match_at: '2026-08-20T00:00:00.000Z',
pending_yours: 1,
pending_theirs: 2,
unread_total: 0,
enabled_packs_fingerprint: 'digest-a',
match_state_fingerprint: 'state-a',
match_unread_fingerprint: 'unread-a',
streak_updated_at: '2026-08-01T00:00:00.000Z',
};
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
const tick = async () => {
await flush();
await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS); });
await flush();
};
/** Replaces every domain refresh the sync can dispatch with a counter. */
function stubRefreshers() {
const fetchUser = jest.fn(async () => undefined);
const fetchCouple = jest.fn(async () => undefined);
const fetchMatches = jest.fn(async () => undefined);
const fetchPendingQuestions = jest.fn(async () => undefined);
const fetchTheirTurnQuestions = jest.fn(async () => undefined);
const fetchEnabledPacks = jest.fn(async () => undefined);
const refreshStreak = jest.fn(async () => undefined);
useAuthStore.setState({ fetchUser, fetchCouple } as never);
useMatchStore.setState({ fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions } as never);
usePacksStore.setState({ fetchEnabledPacks } as never);
useStreakStore.setState({ refreshStreak } as never);
return { fetchUser, fetchCouple, fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions, fetchEnabledPacks, refreshStreak };
}
describe('useCoupleSync', () => {
let refreshers: ReturnType<typeof stubRefreshers>;
const originals = {
auth: { fetchUser: useAuthStore.getState().fetchUser, fetchCouple: useAuthStore.getState().fetchCouple },
match: {
fetchMatches: useMatchStore.getState().fetchMatches,
fetchPendingQuestions: useMatchStore.getState().fetchPendingQuestions,
fetchTheirTurnQuestions: useMatchStore.getState().fetchTheirTurnQuestions,
},
packs: { fetchEnabledPacks: usePacksStore.getState().fetchEnabledPacks },
streak: { refreshStreak: useStreakStore.getState().refreshStreak },
};
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
mockForeground.value = true;
mockListeners.clear();
useMessageStore.setState({ unreadCount: 0 });
// Every view starts loaded: this hook must refresh only what is on screen,
// and the "not loaded" case is asserted separately below.
useMatchStore.setState({ loadedAt: { active: 1, pending: 1, their_turn: 1 } });
usePacksStore.setState({ enabledPacksLoadedAt: Date.now() });
refreshers = stubRefreshers();
});
afterEach(() => {
jest.useRealTimers();
useAuthStore.setState(originals.auth as never);
useMatchStore.setState({ ...originals.match, loadedAt: {} } as never);
usePacksStore.setState({ ...originals.packs, enabledPacksLoadedAt: null } as never);
useStreakStore.setState(originals.streak as never);
});
const answer = (...summaries: SyncSummary[]) => {
const mock = appDataApi.syncSummary as jest.Mock;
mock.mockReset();
for (const summary of summaries) mock.mockResolvedValueOnce(summary);
mock.mockResolvedValue(summaries[summaries.length - 1]);
};
it('spends one request per interval and refreshes nothing while the markers hold still', async () => {
answer(baseline);
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
await tick();
await tick();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(3);
// Three polls, three requests in total: the baseline poll must not trigger
// the refresh of five domains that the old five-second sweep performed.
for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled();
});
it('applies the unread total from the summary without a second request', async () => {
answer(baseline, { ...baseline, unread_total: 4 });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(useMessageStore.getState().unreadCount).toBe(4);
});
it('refreshes only the domain whose marker moved', async () => {
answer(baseline, { ...baseline, latest_match_at: '2026-08-28T00:00:00.000Z', match_count: 4 });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled();
expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled();
expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled();
expect(refreshers.fetchUser).not.toHaveBeenCalled();
expect(refreshers.fetchCouple).not.toHaveBeenCalled();
});
it('routes each remaining marker to its own domain', async () => {
answer(baseline, {
...baseline,
pending_yours: 2,
pending_theirs: 3,
enabled_packs_fingerprint: 'digest-b',
partner_updated_at: '2026-08-29T00:00:00.000Z',
streak_updated_at: '2026-08-29T00:00:00.000Z',
});
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchPendingQuestions).toHaveBeenCalledWith({ silent: true });
expect(refreshers.fetchTheirTurnQuestions).toHaveBeenCalledWith({ silent: true });
expect(refreshers.fetchEnabledPacks).toHaveBeenCalledTimes(1);
expect(refreshers.refreshStreak).toHaveBeenCalledTimes(1);
// A partner profile change is a couple-level read; the whole user is only
// re-read when the user's own profile or couple membership moved.
expect(refreshers.fetchCouple).toHaveBeenCalledTimes(1);
expect(refreshers.fetchUser).not.toHaveBeenCalled();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
});
it('refreshes a loaded Matches view when the match-state fingerprint moves in place', async () => {
// Counts and latest-created-at do not move for an in-place match_type/
// response_summary edit; only the state fingerprint does.
answer(baseline, { ...baseline, match_state_fingerprint: 'state-b' });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
});
it('refreshes a loaded Matches view when unread redistributes without changing the total', async () => {
// One match is read while another receives a message: unread_total holds
// steady but the per-match fingerprint (and unread-first ordering) moves.
answer(baseline, { ...baseline, unread_total: baseline.unread_total, match_unread_fingerprint: 'unread-b' });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
});
it('does not refresh enabled packs when that domain has never been opened', async () => {
usePacksStore.setState({ enabledPacksLoadedAt: null });
answer(baseline, { ...baseline, enabled_packs_fingerprint: 'digest-b' });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled();
});
it('resets the baseline when the account identity changes, instead of diffing against the prior account', async () => {
answer({ ...baseline, match_count: 3 });
const { rerender } = renderHook(
({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId),
{ initialProps: { userId: 'alice', coupleId: 'couple-a' } },
);
await flush();
await tick(); // establishes alice's baseline (match_count: 3)
// Switch accounts: bob's couple has a completely different match_count.
// Without a baseline reset this would look like a 12-count jump and
// trigger a refresh purely from comparing across accounts.
answer({ ...baseline, couple_id: 'couple-b', match_count: 15 });
rerender({ userId: 'bob', coupleId: 'couple-b' });
await flush();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
await tick();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
});
it('re-reads the user once when the profile moves, instead of the couple as well', async () => {
answer(baseline, {
...baseline,
profile_updated_at: '2026-08-29T00:00:00.000Z',
partner_updated_at: '2026-08-29T00:00:00.000Z',
});
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchUser).toHaveBeenCalledTimes(1);
expect(refreshers.fetchCouple).not.toHaveBeenCalled();
});
it('leaves views the user has never opened unloaded', async () => {
useMatchStore.setState({ loadedAt: {} });
answer(baseline, {
...baseline,
match_count: 9,
latest_match_at: '2026-08-29T00:00:00.000Z',
pending_yours: 7,
pending_theirs: 8,
});
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled();
expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled();
});
it('stops entirely for a signed-out user and while the app is backgrounded', async () => {
answer(baseline);
const { unmount } = renderHook(() => useCoupleSync(undefined, null));
await flush();
await tick();
expect(appDataApi.syncSummary).not.toHaveBeenCalled();
unmount();
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
await act(async () => {
mockForeground.value = false;
for (const listener of [...mockListeners]) listener(false);
});
await tick();
await tick();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
});
it('discards a summary that lands after the account it belongs to was switched away', async () => {
// Alice's poll is still open when Bob signs in. Its answer describes a
// couple Bob is not in, so none of it may land: not his badge, not the
// baseline his next poll is diffed against, not a refresh decision.
let answerAlice: (summary: SyncSummary) => void = () => undefined;
const mock = appDataApi.syncSummary as jest.Mock;
mock.mockReset();
mock.mockReturnValueOnce(new Promise<SyncSummary>(resolve => { answerAlice = resolve; }));
const { rerender } = renderHook(
({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId),
{ initialProps: { userId: 'alice', coupleId: 'couple-a' } },
);
await flush();
expect(mock).toHaveBeenCalledTimes(1);
const bobBaseline: SyncSummary = { ...baseline, couple_id: 'couple-b', unread_total: 1, match_count: 2 };
mock.mockResolvedValue(bobBaseline);
rerender({ userId: 'bob', coupleId: 'couple-b' });
await flush();
expect(useMessageStore.getState().unreadCount).toBe(1);
// Alice's request finally answers, long after she stopped being the user.
await act(async () => {
answerAlice({ ...baseline, couple_id: 'couple-a', unread_total: 9, match_count: 42 });
await Promise.resolve();
});
await flush();
expect(useMessageStore.getState().unreadCount).toBe(1);
// Bob's baseline is still Bob's: a poll identical to his own first one has
// nothing to refresh. Had Alice's answer been recorded as the baseline,
// this would read as a 40-match jump and refresh the Matches view.
await tick();
expect(useMessageStore.getState().unreadCount).toBe(1);
for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled();
});
it('never runs two summaries at once', async () => {
let release: (summary: SyncSummary) => void = () => undefined;
(appDataApi.syncSummary as jest.Mock).mockReset();
(appDataApi.syncSummary as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
(appDataApi.syncSummary as jest.Mock).mockResolvedValue(baseline);
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS * 4); });
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
await act(async () => { release(baseline); await Promise.resolve(); });
await tick();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(2);
});
});
@@ -1,6 +1,7 @@
import { act, renderHook, waitFor } from "@testing-library/react-native";
import { useDares } from "@/features/dares/hooks/useDares";
import { ApiError, apiClient } from "@/lib/apiClient";
import type { UseDaresReturn } from "@/features/dares/hooks/useDares";
import type { SentDare } from "@/features/dares/types";
const incoming: SentDare = {
@@ -90,3 +91,80 @@ describe("useDares", () => {
expect(result.current.error).toBe("offline");
});
});
describe("useDares polling cost", () => {
/** Counts requests per endpoint and lets the active payload be swapped mid-test. */
function mockCounting(active: () => SentDare[]) {
const calls = { active: 0, history: 0, catalog: 0, stats: 0 };
jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => {
if (path.startsWith("/v1/dares?filter=active")) { calls.active += 1; return { dares: active() } as never; }
if (path.startsWith("/v1/dares?filter=history")) { calls.history += 1; return { dares: [] } as never; }
if (path === "/v1/dares/packs") { calls.catalog += 1; return catalog as never; }
if (path === "/v1/dares/stats") { calls.stats += 1; return stats as never; }
throw new Error(`unexpected path ${path}`);
});
return calls;
}
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
const tick = async () => {
await flush();
await act(async () => { jest.advanceTimersByTime(5_000); });
await flush();
};
beforeEach(() => { jest.restoreAllMocks(); jest.useFakeTimers(); });
afterEach(() => jest.useRealTimers());
it("polls the active list alone and re-reads the static endpoints only when it changes", async () => {
let current: SentDare[] = [incoming];
const calls = mockCounting(() => current);
const { result } = renderHook(() => useDares({ isFocused: true }));
await flush();
expect(result.current.loading).toBe(false);
// The first pass is a full load: nothing is cached yet.
expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 });
await tick();
await tick();
// Two quiet polls cost one request each, not four. The catalogue and the
// stats cannot move while the active set is byte-for-byte identical.
expect(calls).toEqual({ active: 3, history: 1, catalog: 1, stats: 1 });
current = [{ ...incoming, status: "active" }];
await tick();
expect(calls).toEqual({ active: 4, history: 2, catalog: 2, stats: 2 });
expect(result.current.active[0]?.status).toBe("active");
await tick();
expect(calls).toEqual({ active: 5, history: 2, catalog: 2, stats: 2 });
});
it("issues nothing at all while the screen is not on top", async () => {
const calls = mockCounting(() => [incoming]);
const { rerender } = renderHook<UseDaresReturn, { isFocused: boolean }>(
({ isFocused }) => useDares({ isFocused }),
{ initialProps: { isFocused: false } },
);
await tick();
await tick();
expect(calls).toEqual({ active: 0, history: 0, catalog: 0, stats: 0 });
rerender({ isFocused: true });
await flush();
expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 });
});
it("never overlaps two polls", async () => {
let release: (payload: { dares: SentDare[] }) => void = () => undefined;
jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => {
if (path.startsWith("/v1/dares?filter=active")) return new Promise(resolve => { release = resolve; }) as never;
return { dares: [], ...catalog, ...stats } as never;
});
renderHook(() => useDares({ isFocused: true }));
await flush();
await act(async () => { jest.advanceTimersByTime(20_000); });
expect((apiClient.get as jest.Mock).mock.calls.filter(([path]) => String(path).includes("filter=active"))).toHaveLength(1);
await act(async () => { release({ dares: [] }); await Promise.resolve(); });
});
});
@@ -48,6 +48,60 @@ describe('useDrawingSync API polling', () => {
});
});
describe('useDrawingSync focus gating', () => {
const handlers = {
onStrokeStart: jest.fn(), onStrokeContinue: jest.fn(), onStrokeEnd: jest.fn(),
onClearCanvas: jest.fn(), onUndo: jest.fn(), onRedo: jest.fn(),
};
beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); });
afterEach(() => jest.useRealTimers());
it('reads the canvas once on mount and then polls only while the screen is on top', async () => {
(appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId });
const onLoadStrokes = jest.fn();
const { rerender } = renderHook<unknown, { isFocused: boolean }>(({ isFocused }) => useDrawingSync({
coupleId: 'couple', userId, onLoadStrokes, isFocused, ...handlers,
}), { initialProps: { isFocused: false } });
await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1));
// A canvas nobody is looking at costs nothing beyond the state it loaded with.
await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1);
rerender({ isFocused: true });
await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
rerender({ isFocused: false });
await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
});
it('never polls over its own write in flight', async () => {
(appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId });
let release: (state: unknown) => void = () => undefined;
(appDataApi.putLiveDraw as jest.Mock).mockReturnValue(new Promise(resolve => { release = resolve; }));
const onLoadStrokes = jest.fn();
const { result } = renderHook(() => useDrawingSync({
coupleId: 'couple', userId, onLoadStrokes, isFocused: true, ...handlers,
}));
await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1));
const pending = result.current.persistStrokes([stroke]);
await act(async () => { jest.advanceTimersByTime(750 * 4); await Promise.resolve(); });
// The write returns the authoritative state; reading over the top of it would
// only race a revision we are about to replace.
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1);
await act(async () => {
release({ strokes: [stroke], revision: 2, updated_at: 'two', updated_by: userId });
await pending;
});
await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
});
});
describe('useDrawingSync stroke deduplication', () => {
const at = (id: string, points: number, userId = 'u1') => ({
id, userId, points: Array.from({ length: points }, (_, i) => ({ x: i, y: i })),
@@ -4,85 +4,183 @@ import { chatApi } from '@/lib/chatApi';
jest.mock('@/lib/chatApi', () => ({ chatApi: { listMessages: jest.fn(), markRead: jest.fn() } }));
const message = (id: string, user_id = 'partner') => ({ id, match_id: 'match1', user_id, content: id } as any);
const message = (id: string, user_id = 'partner', overrides: Record<string, unknown> = {}) => ({
id, match_id: 'match1', user_id, content: id,
created_at: '2026-08-27T12:00:00Z', read_at: null, ...overrides,
} as any);
describe('useMessageSubscription API polling', () => {
const snapshot = (messages: any[], server_time = 'cursor-1') => ({ messages, removed_ids: [], server_time, complete: true });
const delta = (messages: any[], options: { removed?: string[]; server_time?: string; typing?: boolean } = {}) => ({
messages,
removed_ids: options.removed ?? [],
server_time: options.server_time ?? 'cursor-2',
complete: false,
...(options.typing === undefined ? {} : { typing: { typing: options.typing, expires_at: null } }),
});
const listMessages = chatApi.listMessages as jest.Mock;
const markRead = chatApi.markRead as jest.Mock;
describe('useMessageSubscription incremental polling', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [] });
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: 'now' });
listMessages.mockResolvedValue(snapshot([]));
markRead.mockResolvedValue({ updated: 0, read_at: 'now' });
});
afterEach(() => jest.useRealTimers());
it('fails closed without both a match and authenticated user', async () => {
const { result, rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string | undefined; userId: string | undefined }>(
const mount = (config: Partial<Parameters<typeof useMessageSubscription>[0]> = {}) =>
renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, ...config }));
it('fails closed without both match and authenticated user', () => {
const { rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string | undefined; userId: string | undefined }>(
({ matchId, userId }) => useMessageSubscription({ matchId, userId }),
{ initialProps: { matchId: undefined as string | undefined, userId: 'me' as string | undefined } },
);
expect(result.current.loading).toBe(false);
expect(chatApi.listMessages).not.toHaveBeenCalled();
expect(listMessages).not.toHaveBeenCalled();
rerender({ matchId: 'match1', userId: undefined });
expect(chatApi.listMessages).not.toHaveBeenCalled();
expect(listMessages).not.toHaveBeenCalled();
});
it('loads initial messages without treating them as newly arrived', async () => {
it('loads a snapshot first, then requests only changes since the server cursor', async () => {
listMessages.mockResolvedValueOnce(snapshot([message('m1')], 'cursor-1'));
const { result } = mount();
await waitFor(() => expect(result.current.loading).toBe(false));
expect(listMessages).toHaveBeenNthCalledWith(1, 'match1', { typing: true });
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
expect(listMessages).toHaveBeenNthCalledWith(2, 'match1', { since: 'cursor-1', typing: true });
});
it('keeps cached messages, and their identity, when a delta carries nothing', async () => {
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })]));
listMessages.mockResolvedValue(delta([]));
const { result } = mount();
await waitFor(() => expect(result.current.messages).toHaveLength(1));
const cached = result.current.messages;
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
expect(result.current.messages).toBe(cached);
expect(result.current.loading).toBe(false);
});
it('merges delta rows by id, applies self-deletions, and keeps newest first', async () => {
listMessages.mockResolvedValueOnce(snapshot([
message('m2', 'me', { created_at: '2026-08-27T12:00:02Z' }),
message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen' }),
]));
listMessages.mockResolvedValueOnce(delta([
message('m3', 'partner', { created_at: '2026-08-27T12:00:03Z', read_at: 'seen' }),
message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen', deleted_at: 'gone' }),
], { removed: ['m2'] }));
const { result } = mount();
await waitFor(() => expect(result.current.messages).toHaveLength(2));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(result.current.messages).toHaveLength(2));
expect(result.current.messages.map(m => m.id)).toEqual(['m3', 'm1']);
expect(result.current.messages[1]?.deleted_at).toBe('gone');
});
it('marks read only when the response holds an unread partner message', async () => {
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner')]));
listMessages.mockResolvedValueOnce(delta([message('m2', 'me')]));
listMessages.mockResolvedValueOnce(delta([message('m1', 'partner', { read_at: 'seen' })]));
const { result } = mount();
await waitFor(() => expect(markRead).toHaveBeenCalledTimes(1));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(3));
expect(markRead).toHaveBeenCalledTimes(1);
expect(result.current.messages).toHaveLength(2);
});
it('issues no request at all while the chat is not focused', async () => {
const { rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { focused: boolean }>(
({ focused }) => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, isFocused: focused }),
{ initialProps: { focused: false } },
);
await act(async () => { jest.advanceTimersByTime(2_000); });
expect(listMessages).not.toHaveBeenCalled();
expect(markRead).not.toHaveBeenCalled();
rerender({ focused: true });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(1));
rerender({ focused: false });
const callsAtBlur = listMessages.mock.calls.length;
await act(async () => { jest.advanceTimersByTime(2_000); });
expect(listMessages).toHaveBeenCalledTimes(callsAtBlur);
});
it('reports partner arrivals once, and never for the initial load or own messages', async () => {
const onNewMessage = jest.fn();
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [message('m1')] });
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage }));
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })]));
listMessages.mockResolvedValueOnce(delta([message('mine', 'me')]));
listMessages.mockResolvedValueOnce(delta([message('m2', 'partner')]));
listMessages.mockResolvedValueOnce(delta([message('m2', 'partner', { read_at: 'seen' })]));
const { result } = mount({ onNewMessage });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages).toEqual([message('m1')]);
expect(onNewMessage).not.toHaveBeenCalled();
expect(chatApi.markRead).not.toHaveBeenCalled();
});
it('refreshes on the polling interval, marks focused messages read, and reports new partner messages', async () => {
const onNewMessage = jest.fn();
(chatApi.listMessages as jest.Mock)
.mockResolvedValueOnce({ messages: [message('m1')] })
.mockResolvedValueOnce({ messages: [message('m2'), message('m1')] });
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 }));
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => { result.current.isFocusedRef.current = true; });
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
await waitFor(() => expect(result.current.messages[0]?.id).toBe('m2'));
expect(chatApi.markRead).toHaveBeenCalledWith('match1');
for (const expected of [2, 3, 4]) {
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(expected));
}
expect(onNewMessage).toHaveBeenCalledTimes(1);
});
it('does not notify for the current user own newly polled message', async () => {
const onNewMessage = jest.fn();
(chatApi.listMessages as jest.Mock)
.mockResolvedValueOnce({ messages: [] })
.mockResolvedValueOnce({ messages: [message('mine', 'me')] });
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 }));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
expect(onNewMessage).not.toHaveBeenCalled();
it('reads partner typing state out of the message response', async () => {
listMessages.mockResolvedValueOnce({ ...snapshot([]), typing: { typing: true, expires_at: 'later' } });
const { result } = mount();
await waitFor(() => expect(result.current.partnerTyping).toBe(true));
listMessages.mockResolvedValueOnce(delta([], { typing: false }));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(result.current.partnerTyping).toBe(false));
});
it('surfaces loading completion and preserves a usable empty state after an API error', async () => {
(chatApi.listMessages as jest.Mock).mockRejectedValueOnce(new Error('forbidden'));
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' }));
listMessages.mockRejectedValueOnce(new Error('forbidden'));
const { result } = mount();
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages).toEqual([]);
});
it('prevents overlapping polls and clears the interval on unmount', async () => {
let resolveFirst!: (value: { messages: any[] }) => void;
(chatApi.listMessages as jest.Mock).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; }));
const { unmount } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500 }));
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
act(() => { jest.advanceTimersByTime(1_500); });
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
await act(async () => resolveFirst({ messages: [] }));
it('prevents overlapping polls and stops on unmount', async () => {
let resolveFirst!: (value: unknown) => void;
listMessages.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; }));
const { unmount } = mount();
expect(listMessages).toHaveBeenCalledTimes(1);
await act(async () => { jest.advanceTimersByTime(1_500); });
expect(listMessages).toHaveBeenCalledTimes(1);
await act(async () => { resolveFirst(snapshot([])); });
unmount();
act(() => { jest.advanceTimersByTime(2_000); });
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
await act(async () => { jest.advanceTimersByTime(2_000); });
expect(listMessages).toHaveBeenCalledTimes(1);
});
it('restarts immediately, and without a stale cursor, for a different match', async () => {
listMessages.mockResolvedValue(snapshot([message('m1')], 'cursor-1'));
const { result, rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string }>(
({ matchId }) => useMessageSubscription({ matchId, userId: 'me', pollInterval: 500 }),
{ initialProps: { matchId: 'match1' } },
);
await waitFor(() => expect(result.current.messages).toHaveLength(1));
rerender({ matchId: 'match2' });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
expect(listMessages).toHaveBeenNthCalledWith(2, 'match2', { typing: true });
});
it('supports optimistic external updates', async () => {
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' }));
const { result } = mount();
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => result.current.setMessages([message('optimistic', 'me')]));
expect(result.current.messages[0]?.id).toBe('optimistic');
@@ -0,0 +1,141 @@
import { act, renderHook } from '@testing-library/react-native';
import { usePolling } from '@/hooks/usePolling';
const mockForeground = { value: true };
const mockListeners = new Set<(foreground: boolean) => void>();
jest.mock('@/lib/appForeground', () => ({
isForeground: () => mockForeground.value,
subscribeToForeground: (listener: (foreground: boolean) => void) => {
mockListeners.add(listener);
return () => { mockListeners.delete(listener); };
},
}));
const setForeground = async (value: boolean) => {
await act(async () => {
mockForeground.value = value;
for (const listener of [...mockListeners]) listener(value);
});
};
// The next run is scheduled once the previous one settles, so let pending
// promises land before and after moving the clock.
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
const tick = async (ms: number) => {
await flush();
await act(async () => { jest.advanceTimersByTime(ms); });
await flush();
};
describe('usePolling', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
mockForeground.value = true;
mockListeners.clear();
});
afterEach(() => jest.useRealTimers());
it('runs once immediately and then on the interval', async () => {
const task = jest.fn(async () => undefined);
renderHook(() => usePolling(task, { intervalMs: 100 }));
expect(task).toHaveBeenCalledTimes(1);
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
await tick(100);
expect(task).toHaveBeenCalledTimes(3);
});
it('skips the leading run when asked to', async () => {
const task = jest.fn(async () => undefined);
renderHook(() => usePolling(task, { intervalMs: 100, leading: false }));
expect(task).not.toHaveBeenCalled();
await tick(100);
expect(task).toHaveBeenCalledTimes(1);
});
it('issues nothing while disabled and starts once enabled', async () => {
const task = jest.fn(async () => undefined);
const { rerender } = renderHook<void, { enabled: boolean }>(({ enabled }) => usePolling(task, { intervalMs: 100, enabled }), {
initialProps: { enabled: false },
});
await tick(1_000);
expect(task).not.toHaveBeenCalled();
rerender({ enabled: true });
expect(task).toHaveBeenCalledTimes(1);
});
it('stops while the app is backgrounded and catches up on return', async () => {
const task = jest.fn(async () => undefined);
renderHook(() => usePolling(task, { intervalMs: 100 }));
expect(task).toHaveBeenCalledTimes(1);
await setForeground(false);
await tick(1_000);
expect(task).toHaveBeenCalledTimes(1);
await setForeground(true);
expect(task).toHaveBeenCalledTimes(2);
await tick(100);
expect(task).toHaveBeenCalledTimes(3);
});
it('never runs two tasks concurrently', async () => {
let release!: () => void;
const task = jest.fn(() => new Promise<void>(resolve => { release = resolve; }));
renderHook(() => usePolling(task, { intervalMs: 100 }));
expect(task).toHaveBeenCalledTimes(1);
await tick(500);
expect(task).toHaveBeenCalledTimes(1);
await act(async () => { release(); });
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
});
it('backs off after consecutive failures and recovers after a success', async () => {
const task = jest.fn(async (): Promise<void> => { throw new Error('offline'); });
renderHook(() => usePolling(task, { intervalMs: 100, maxIntervalMs: 400 }));
expect(task).toHaveBeenCalledTimes(1);
await tick(100);
expect(task).toHaveBeenCalledTimes(1);
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
await tick(399);
expect(task).toHaveBeenCalledTimes(2);
await tick(1);
expect(task).toHaveBeenCalledTimes(3);
task.mockImplementation(async () => undefined);
await tick(400);
expect(task).toHaveBeenCalledTimes(4);
await tick(100);
expect(task).toHaveBeenCalledTimes(5);
});
it('restarts immediately when the reset key changes', async () => {
const task = jest.fn(async () => undefined);
const { rerender } = renderHook<void, { resetKey: string }>(({ resetKey }) => usePolling(task, { intervalMs: 100, resetKey }), {
initialProps: { resetKey: 'a' },
});
expect(task).toHaveBeenCalledTimes(1);
rerender({ resetKey: 'b' });
expect(task).toHaveBeenCalledTimes(2);
await tick(50);
expect(task).toHaveBeenCalledTimes(2);
});
it('stops and unsubscribes on unmount', async () => {
const task = jest.fn(async () => undefined);
const { unmount } = renderHook(() => usePolling(task, { intervalMs: 100 }));
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
unmount();
expect(mockListeners.size).toBe(0);
await tick(1_000);
expect(task).toHaveBeenCalledTimes(2);
});
});
@@ -76,4 +76,22 @@ describe('useTypingIndicator API polling', () => {
act(() => { jest.advanceTimersByTime(2_000); });
expect(chatApi.getTyping).toHaveBeenCalledTimes(calls);
});
it('stops polling and mirrors typing state supplied by the message poll', async () => {
const { result, rerender } = renderHook<ReturnType<typeof useTypingIndicator>, { externalTyping: boolean | undefined }>(
({ externalTyping }) => useTypingIndicator({ channelName: 'chat:match1', userId: 'me', pollInterval: 500, externalTyping }),
{ initialProps: { externalTyping: undefined as boolean | undefined } },
);
await act(async () => { await Promise.resolve(); });
const callsBeforeFolding = (chatApi.getTyping as jest.Mock).mock.calls.length;
rerender({ externalTyping: true });
expect(result.current.partnerTyping).toBe(true);
await act(async () => { jest.advanceTimersByTime(5_000); });
expect(chatApi.getTyping).toHaveBeenCalledTimes(callsBeforeFolding);
act(() => result.current.clearTypingIndicator());
expect(result.current.partnerTyping).toBe(false);
rerender({ externalTyping: false });
expect(result.current.partnerTyping).toBe(false);
});
});
@@ -0,0 +1,36 @@
import { AppState } from 'react-native';
import { isForeground, isForegroundState, subscribeToForeground } from '@/lib/appForeground';
describe('appForeground', () => {
afterEach(() => jest.restoreAllMocks());
it('treats only background and inactive as away', () => {
expect(isForegroundState('active')).toBe(true);
expect(isForegroundState('background')).toBe(false);
expect(isForegroundState('inactive')).toBe(false);
// Unknown states — including whatever a test harness leaves behind — must
// fail towards running, never towards disabling recurring work forever.
expect(isForegroundState(undefined)).toBe(true);
expect(isForegroundState('extension')).toBe(true);
});
it('reads the current state and reports transitions until unsubscribed', () => {
let emit: (state: string) => void = () => undefined;
const remove = jest.fn();
jest.spyOn(AppState, 'addEventListener').mockImplementation(((_: string, handler: (state: string) => void) => {
emit = handler;
return { remove } as never;
}) as never);
expect(isForeground()).toBe(isForegroundState(AppState.currentState));
const seen: boolean[] = [];
const unsubscribe = subscribeToForeground(value => seen.push(value));
emit('background');
emit('active');
expect(seen).toEqual([false, true]);
unsubscribe();
expect(remove).toHaveBeenCalled();
});
});
@@ -24,6 +24,18 @@ describe('chatApi', () => {
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match%2Fid/typing');
});
it('encodes the delta cursor and folded typing request into the message query', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ messages: [], removed_ids: [], server_time: 'now', complete: false });
await chatApi.listMessages('match1', { typing: true });
await chatApi.listMessages('match1', { since: '2026-08-27T12:00:00.000+01:00' });
await chatApi.listMessages('match1', { since: '2026-08-27T12:00:00.000Z', typing: true });
expect(apiClient.get).toHaveBeenNthCalledWith(1, '/v1/matches/match1/messages?typing=true');
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match1/messages?since=2026-08-27T12%3A00%3A00.000%2B01%3A00');
expect(apiClient.get).toHaveBeenNthCalledWith(3, '/v1/matches/match1/messages?since=2026-08-27T12%3A00%3A00.000Z&typing=true');
});
it('uses authenticated unread, receipt, deletion and reporting routes', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ total: 0, by_match: {} });
(apiClient.post as jest.Mock).mockResolvedValue({});
@@ -45,9 +45,18 @@ describe('matchStore API behavior', () => {
});
it('guards concurrent loads and exhausted pagination', async () => {
useMatchStore.setState({ isLoading: true });
await useMatchStore.getState().fetchMatches(true);
useMatchStore.setState({ isLoading: false, hasMore: false });
// Overlap protection cannot key off the loading flags any more: a silent
// refresh raises none of them and still must not issue a second request.
let release: (page: { matches: unknown[]; totalCount: number }) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const inFlight = useMatchStore.getState().fetchMatches(true);
await useMatchStore.getState().fetchMatches(true, { silent: true });
expect(apiClient.get).toHaveBeenCalledTimes(1);
release({ matches: [], totalCount: 0 });
await inFlight;
(apiClient.get as jest.Mock).mockClear();
useMatchStore.setState({ hasMore: false });
await useMatchStore.getState().fetchMatches(false);
expect(apiClient.get).not.toHaveBeenCalled();
});
@@ -147,7 +156,10 @@ describe('matchStore API behavior', () => {
it('toggles archived visibility and triggers an initial fetch only', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ matches: [], totalCount: 0 });
useMatchStore.getState().toggleShowArchived();
await Promise.resolve();
// Let the floating fetch settle: its in-flight guard is module-scoped and
// is only released in a finally, so a half-run request would leak into
// the next test and silently suppress its archived fetch.
await jest.runOnlyPendingTimersAsync();
expect(useMatchStore.getState().showArchived).toBe(true);
expect(apiClient.get).toHaveBeenCalledTimes(1);
useMatchStore.getState().toggleShowArchived();
@@ -178,11 +190,19 @@ describe('matchStore API behavior', () => {
});
it('updates current view and delegates an empty view fetch', () => {
const real = useMatchStore.getState().fetchArchivedMatches;
const fetchArchivedMatches = jest.fn(async () => undefined);
useMatchStore.setState({ fetchArchivedMatches });
useMatchStore.getState().setCurrentView('archived');
expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true });
expect(fetchArchivedMatches).toHaveBeenCalled();
try {
useMatchStore.getState().setCurrentView('archived');
expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true });
expect(fetchArchivedMatches).toHaveBeenCalled();
} finally {
// Actions live in the same store as the data, and clearMatches does not
// reset them: leaving the stub in place would silence every later test's
// archived fetch.
useMatchStore.setState({ fetchArchivedMatches: real });
}
});
});
@@ -225,6 +245,222 @@ describe('matchStore API behavior', () => {
});
});
describe('view switching and cache freshness', () => {
/** Answers each of the four view endpoints with an empty payload. */
const emptyViews = () => (apiClient.get as jest.Mock).mockImplementation(async (path: string) => {
if (path.startsWith('/v1/matches?archived=true')) return { matches: [], totalCount: 0 };
if (path.startsWith('/v1/matches')) return { matches: [], totalCount: 0, hasMore: false };
return { questions: [] };
});
it('switches between loaded views without a single extra request, even when every view is empty', async () => {
emptyViews();
const views = ['pending', 'their_turn', 'active', 'archived'] as const;
// First visit to each view loads it once.
for (const view of views) {
useMatchStore.getState().setCurrentView(view);
await jest.runOnlyPendingTimersAsync();
}
expect(apiClient.get).toHaveBeenCalledTimes(views.length);
expect(useMatchStore.getState()).toMatchObject({
matches: [], pendingQuestions: [], theirTurnQuestions: [], archivedMatches: [],
});
// Every subsequent switch is served from cache. An empty view is loaded,
// not unloaded: this is the bug where `array.length === 0` meant "never
// fetched" and made every visit pay for the same empty answer.
(apiClient.get as jest.Mock).mockClear();
for (const view of [...views, ...views].reverse()) {
useMatchStore.getState().setCurrentView(view);
await jest.runOnlyPendingTimersAsync();
}
expect(apiClient.get).not.toHaveBeenCalled();
});
it('keeps cached rows and raises no foreground spinner during a silent refresh', async () => {
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('cached', false)], totalCount: 1, hasMore: false });
await useMatchStore.getState().fetchMatches(true);
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const refreshing = useMatchStore.getState().fetchMatches(true, { silent: true });
// Mid-flight: the user still sees the rows they had, and neither the
// full-screen loader nor the pull-to-refresh spinner is showing.
expect(useMatchStore.getState()).toMatchObject({ isLoading: false, isRefreshing: false });
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
release({ matches: [match('fresh', false)], totalCount: 1, hasMore: false });
await refreshing;
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['fresh']);
});
it('shows the pull-to-refresh spinner for a user-initiated refresh but never the initial loader', async () => {
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('cached', false)], totalCount: 1, hasMore: false });
await useMatchStore.getState().fetchMatches(true);
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const refreshing = useMatchStore.getState().fetchMatches(true);
expect(useMatchStore.getState()).toMatchObject({ isLoading: false, isRefreshing: true });
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
release({ matches: [], totalCount: 0, hasMore: false });
await refreshing;
expect(useMatchStore.getState()).toMatchObject({ isRefreshing: false });
});
it('keeps a silent list refresh out of the pending and their-turn loaders', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ questions: [pending('p1')] });
await useMatchStore.getState().fetchPendingQuestions();
await useMatchStore.getState().fetchTheirTurnQuestions();
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValue(new Promise(resolve => { release = resolve; }));
const quiet = Promise.all([
useMatchStore.getState().fetchPendingQuestions({ silent: true }),
useMatchStore.getState().fetchTheirTurnQuestions({ silent: true }),
]);
expect(useMatchStore.getState()).toMatchObject({ isLoadingPending: false, isLoadingTheirTurn: false });
expect(useMatchStore.getState().pendingQuestions).toHaveLength(1);
release({ questions: [] });
await quiet;
});
});
describe('account isolation (generation tokens)', () => {
it('does not let a stale fetchMatches response populate the store after sign-out, and lets the next account start immediately', async () => {
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = useMatchStore.getState().fetchMatches(true);
// Simulates sign-out/account switch while the request is still in flight.
useMatchStore.getState().clearMatches();
release({ matches: [match('stale')], totalCount: 1, hasMore: false });
await staleFetch;
expect(useMatchStore.getState().matches).toEqual([]);
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('fresh')], totalCount: 1, hasMore: false });
await useMatchStore.getState().fetchMatches(true);
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['fresh']);
});
it('does not let a stale seen-marker write over the next account\'s list', async () => {
useMatchStore.setState({ matches: [match('theirs')], newMatchesCount: 1 });
let release: () => void = () => undefined;
(apiClient.patch as jest.Mock).mockReturnValueOnce(new Promise<void>(resolve => { release = () => resolve(); }));
const stale = useMatchStore.getState().markAsSeen('theirs');
useMatchStore.getState().clearMatches();
useMatchStore.setState({ matches: [match('mine')], newMatchesCount: 1 });
const untouched = useMatchStore.getState().matches;
release();
await stale;
// Not merely "the same ids": the previous account's response must not
// rewrite the array at all, because the recount it derives is only true
// of the rows that account had loaded.
expect(useMatchStore.getState().matches).toBe(untouched);
expect(useMatchStore.getState().newMatchesCount).toBe(1);
});
it('does not let a stale mark-all-seen clear the next account\'s new badges', async () => {
useMatchStore.setState({ matches: [match('theirs')], newMatchesCount: 1 });
let release: () => void = () => undefined;
(apiClient.patch as jest.Mock).mockReturnValueOnce(new Promise<void>(resolve => { release = () => resolve(); }));
const stale = useMatchStore.getState().markAllAsSeen();
useMatchStore.getState().clearMatches();
useMatchStore.setState({ matches: [match('mine', true), match('mine2', true)], newMatchesCount: 2 });
release();
await stale;
expect(useMatchStore.getState().matches.every(item => item.is_new)).toBe(true);
expect(useMatchStore.getState().newMatchesCount).toBe(2);
});
it('does not roll a failed archive or unarchive back into the next account', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
let fail: (error: Error) => void = () => undefined;
(apiClient.put as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
useMatchStore.setState({ matches: [match('theirs')], archivedMatches: [], archivedMatchIds: new Set<string>(), totalCount: 1 });
const staleArchive = useMatchStore.getState().archiveMatch('theirs');
useMatchStore.getState().clearMatches();
useMatchStore.setState({ matches: [match('mine')], totalCount: 1 });
fail(new Error('forbidden'));
await staleArchive;
// The revert would otherwise put one couple's match into another's list.
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['mine']);
expect(useMatchStore.getState().totalCount).toBe(1);
(apiClient.put as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
useMatchStore.setState({ archivedMatches: [match('theirsArchived')], archivedMatchIds: new Set(['theirsArchived']) });
const staleUnarchive = useMatchStore.getState().unarchiveMatch('theirsArchived');
useMatchStore.getState().clearMatches();
useMatchStore.setState({ archivedMatches: [match('mineArchived')], archivedMatchIds: new Set(['mineArchived']), totalCount: 0 });
fail(new Error('forbidden'));
await staleUnarchive;
expect(useMatchStore.getState().archivedMatches.map(item => item.id)).toEqual(['mineArchived']);
expect(useMatchStore.getState().totalCount).toBe(0);
});
it('does not carry a nudge cooldown across an account switch', async () => {
let release: (data: unknown) => void = () => undefined;
(appDataApi.sendNudge as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleSend = useMatchStore.getState().sendNudge();
useMatchStore.getState().clearMatches();
release({ success: true, notification_sent: true, next_nudge_available_at: '2026-08-28T00:00:00Z' });
await expect(staleSend).resolves.toEqual({ success: false, notificationSent: false });
expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull();
// The 429 branch writes a cooldown too, and must be gated the same way.
let fail: (error: Error) => void = () => undefined;
(appDataApi.sendNudge as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
const staleLimit = useMatchStore.getState().sendNudge();
useMatchStore.getState().clearMatches();
fail(new ApiError('rate limited', 429, { cooldown_remaining_seconds: 60 }));
await staleLimit;
expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull();
});
it('does not let a stale cooldown read clear the next account\'s cooldown', async () => {
let release: (profile: unknown) => void = () => undefined;
(appDataApi.nudgeStatus as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const stale = useMatchStore.getState().checkNudgeCooldown();
useMatchStore.getState().clearMatches();
const mine = new Date('2026-08-27T18:00:00Z');
useMatchStore.setState({ nudgeCooldownUntil: mine });
// The previous account not being in cooldown says nothing about this one.
release({ last_nudge_sent_at: null });
await stale;
expect(useMatchStore.getState().nudgeCooldownUntil).toEqual(mine);
});
it('does not let a stale fetchPendingQuestions response populate the store after sign-out, and lets the next account start immediately', async () => {
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = useMatchStore.getState().fetchPendingQuestions();
useMatchStore.getState().clearMatches();
release({ questions: [pending('stale')] });
await staleFetch;
expect(useMatchStore.getState().pendingQuestions).toEqual([]);
(apiClient.get as jest.Mock).mockResolvedValueOnce({ questions: [pending('fresh')] });
await useMatchStore.getState().fetchPendingQuestions();
expect(useMatchStore.getState().pendingQuestions.map(item => item.id)).toEqual(['fresh']);
});
});
it('clearMatches resets all user-scoped state', () => {
useMatchStore.setState({ matches: [match('m1')], archivedMatches: [match('m2')], pendingQuestions: [pending('p')], theirTurnQuestions: [pending('t')], currentView: 'archived', showArchived: true, isNudging: true });
useMatchStore.getState().clearMatches();
@@ -47,15 +47,25 @@ describe('messageStore API state', () => {
expect(useMessageStore.getState()).toMatchObject({ unreadCount: 1, lastMessage: { id: 'new' } });
});
it('marks partner messages read then reconciles global and per-match counts', async () => {
it('marks partner messages read and subtracts the cleared count without re-reading the total', async () => {
useMessageStore.setState({ unreadCount: 3 });
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 2, read_at: '2026-08-27T00:00:00.000Z' });
(chatApi.unread as jest.Mock).mockResolvedValue({ total: 1, by_match: { m2: 1 } });
await useMessageStore.getState().markMatchMessagesAsRead('m1');
expect(chatApi.markRead).toHaveBeenCalledWith('m1');
expect(chatApi.unread).not.toHaveBeenCalled();
expect(useMessageStore.getState().unreadCount).toBe(1);
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(1);
});
it('spends no request and touches no count when the read API cleared nothing', async () => {
useMessageStore.setState({ unreadCount: 3 });
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: null });
await useMessageStore.getState().markMatchMessagesAsRead('m1');
expect(chatApi.unread).not.toHaveBeenCalled();
expect(useMessageStore.getState().unreadCount).toBe(3);
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3);
});
it('does not mark read without an authenticated user', async () => {
useAuthStore.setState({ user: null } as any);
await useMessageStore.getState().markMatchMessagesAsRead('m1');
@@ -69,6 +79,42 @@ describe('messageStore API state', () => {
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3);
});
describe('account isolation (generation tokens)', () => {
it('does not let a stale unread total become the next account\'s badge', async () => {
let release: (value: unknown) => void = () => undefined;
(chatApi.unread as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = useMessageStore.getState().fetchUnreadCount();
// Sign-out or leaving a couple, while the request is still open.
useMessageStore.getState().clearMessages();
useMessageStore.setState({ unreadCount: 2 });
release({ total: 9, by_match: { m1: 9 } });
await staleFetch;
expect(useMessageStore.getState().unreadCount).toBe(2);
});
it('does not subtract a stale read receipt from the next account\'s badge', async () => {
useMessageStore.setState({ unreadCount: 3 });
let release: (value: unknown) => void = () => undefined;
(chatApi.markRead as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleRead = useMessageStore.getState().markMatchMessagesAsRead('m1');
useMessageStore.getState().clearMessages();
useMessageStore.setState({ unreadCount: 2 });
useMatchStore.setState({ matches: [{ id: 'm1', unreadCount: 2 } as any] });
release({ updated: 3, read_at: '2026-08-27T00:00:00.000Z' });
await staleRead;
// The badge is wrong whatever the match id; reusing 'm1' also makes the
// second half of the damage — the per-match count — visible.
expect(useMessageStore.getState().unreadCount).toBe(2);
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(2);
});
});
it('clears transient message state', () => {
useMessageStore.setState({ unreadCount: 3, lastMessage: { id: 'last' } as any, activeMatchId: 'm1' });
useMessageStore.getState().clearLastMessage();
@@ -64,4 +64,69 @@ describe('packsStore standalone API integration', () => {
expect(getSpy).not.toHaveBeenCalled();
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
});
describe('account isolation (generation tokens)', () => {
it('does not let a stale fetchEnabledPacks response populate the store after clearPacks, and lets the next account start immediately', async () => {
let release: (page: unknown) => void = () => undefined;
const getSpy = jest.spyOn(apiClient, 'get').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = usePacksStore.getState().fetchEnabledPacks();
// Simulates sign-out/account switch while the request is still in flight.
usePacksStore.getState().clearPacks();
release({ enabledPackIds: ['stale-pack'] });
await staleFetch;
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
getSpy.mockResolvedValueOnce({ enabledPackIds: ['fresh-pack'] });
await usePacksStore.getState().fetchEnabledPacks();
expect(usePacksStore.getState().enabledPackIds).toEqual(['fresh-pack']);
});
it('does not let a stale fetchPackProgress response populate the store after clearPacks, and lets the next account start immediately', async () => {
let release: (page: unknown) => void = () => undefined;
const getSpy = jest.spyOn(apiClient, 'get').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = usePacksStore.getState().fetchPackProgress();
usePacksStore.getState().clearPacks();
release({ progress: [{ packId: 'stale', totalQuestions: 1, answeredQuestions: 1 }] });
await staleFetch;
expect(usePacksStore.getState().packProgress.size).toBe(0);
getSpy.mockResolvedValueOnce({ progress: [{ packId: 'fresh', totalQuestions: 2, answeredQuestions: 1 }] });
await usePacksStore.getState().fetchPackProgress();
expect(usePacksStore.getState().packProgress.get('fresh')).toEqual({ totalQuestions: 2, answeredQuestions: 1 });
});
it('does not let a stale toggle write the previous couple\'s pack selection', async () => {
usePacksStore.setState({ enabledPackIds: [] } as any);
let release: (value: unknown) => void = () => undefined;
jest.spyOn(apiClient, 'put').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleToggle = usePacksStore.getState().togglePack('pack1');
usePacksStore.getState().clearPacks();
usePacksStore.setState({ enabledPackIds: ['next-couple-pack'] } as any);
release({ enabledPackIds: ['pack1'] });
await expect(staleToggle).resolves.toEqual({ success: false, reason: 'stale' });
expect(usePacksStore.getState().enabledPackIds).toEqual(['next-couple-pack']);
});
it('does not roll a failed toggle back into the next couple\'s selection', async () => {
usePacksStore.setState({ enabledPackIds: ['pack1'] } as any);
let reject: (reason: unknown) => void = () => undefined;
jest.spyOn(apiClient, 'put').mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail; }));
jest.spyOn(console, 'error').mockImplementation(() => undefined);
const staleToggle = usePacksStore.getState().togglePack('pack1');
usePacksStore.getState().clearPacks();
usePacksStore.setState({ enabledPackIds: ['next-couple-pack'] } as any);
reject(new Error('offline'));
await expect(staleToggle).resolves.toEqual({ success: false, reason: 'stale' });
// The rollback would have re-added pack1, which this couple never enabled.
expect(usePacksStore.getState().enabledPackIds).toEqual(['next-couple-pack']);
});
});
});
@@ -265,6 +265,53 @@ describe('responsesStore', () => {
});
});
describe('account isolation (generation tokens)', () => {
it('does not let a stale fetchResponses response populate the store after clearResponses, and lets the next account start immediately', async () => {
let resolve!: (value: unknown) => void;
apiGet.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
const staleFetch = useResponsesStore.getState().fetchResponses(true);
// Simulates sign-out/account switch while the request is still in flight.
useResponsesStore.getState().clearResponses();
resolve({ responses: [response({ id: 'stale' })], totalCount: 1 });
await staleFetch;
expect(useResponsesStore.getState().responses).toEqual([]);
apiGet.mockResolvedValueOnce({ responses: [response({ id: 'fresh' })], totalCount: 1 });
await useResponsesStore.getState().fetchResponses(true);
expect(useResponsesStore.getState().responses.map((r) => r.id)).toEqual(['fresh']);
});
it('does not let a stale edit rewrite the next account\'s row or refresh its matches', async () => {
// Question ids come from the shared catalogue, so the same id names a real
// row in both accounts' lists: a stale edit would land on one of them.
const fetchMatches = jest.fn();
useMatchStore.setState({ fetchMatches } as any);
useResponsesStore.setState({
responses: [response({ id: 'alice', answer: 'maybe', has_match: false, match_id: undefined })],
});
let resolve!: (value: unknown) => void;
apiPatch.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
const staleEdit = useResponsesStore.getState().updateResponse('q1', 'yes');
useResponsesStore.getState().clearResponses();
const next = [response({ id: 'bob', answer: 'no', has_match: false, match_id: undefined })];
useResponsesStore.setState({ responses: next });
resolve({ success: true, new_match: { id: 'm2' } });
// The caller still hears what the server did for the account that asked.
await expect(staleEdit).resolves.toMatchObject({ success: true });
expect(useResponsesStore.getState().responses).toBe(next);
expect(useResponsesStore.getState().responses[0]).toMatchObject({
id: 'bob', answer: 'no', has_match: false,
});
expect(fetchMatches).not.toHaveBeenCalled();
});
});
describe('state controls', () => {
it('sets and toggles grouping options', () => {
useResponsesStore.getState().setGroupBy('pack');
@@ -0,0 +1,58 @@
import { apiClient } from '@/lib/apiClient';
import { useAuthStore } from '@/store/authStore';
import { type CoupleStreak, useStreakStore } from '@/store/streakStore';
jest.mock('@/lib/apiClient', () => ({ apiClient: { get: jest.fn() } }));
const apiGet = apiClient.get as jest.Mock;
function streak(overrides: Partial<CoupleStreak> = {}): CoupleStreak {
return {
couple_id: 'couple1', current_streak: 1, longest_streak: 1,
last_active_date: '2026-08-27', last_completed_date: '2026-08-27',
you_answered_today: true, partner_answered_today: false,
partner_name: 'Partner', timezone: 'UTC', streak_celebrated_at: 0,
created_at: '2026-08-01T00:00:00.000Z', updated_at: '2026-08-27T00:00:00.000Z',
...overrides,
};
}
describe('streakStore account isolation (generation tokens)', () => {
beforeEach(() => {
jest.clearAllMocks();
useStreakStore.setState({ streak: null, isLoading: false, error: null, loadedAt: null, generation: 0 });
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
});
it('does not let a stale fetchStreak response populate the store after clearStreak, and lets the next account start immediately', async () => {
let resolve!: (value: unknown) => void;
apiGet.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
const staleFetch = useStreakStore.getState().fetchStreak();
// Simulates sign-out/account switch while the request is still in flight.
useStreakStore.getState().clearStreak();
resolve({ streak: streak({ couple_id: 'stale-couple' }) });
await staleFetch;
expect(useStreakStore.getState().streak).toBeNull();
expect(useStreakStore.getState().loadedAt).toBeNull();
apiGet.mockResolvedValueOnce({ streak: streak({ couple_id: 'fresh-couple' }) });
await useStreakStore.getState().fetchStreak();
expect(useStreakStore.getState().streak?.couple_id).toBe('fresh-couple');
expect(useStreakStore.getState().loadedAt).not.toBeNull();
});
it('refreshStreak silently reloads only an already-loaded streak', async () => {
await useStreakStore.getState().refreshStreak();
expect(apiGet).not.toHaveBeenCalled();
apiGet.mockResolvedValueOnce({ streak: streak() });
await useStreakStore.getState().fetchStreak();
expect(useStreakStore.getState().loadedAt).not.toBeNull();
apiGet.mockResolvedValueOnce({ streak: streak({ current_streak: 2 }) });
await useStreakStore.getState().refreshStreak();
expect(useStreakStore.getState().streak?.current_streak).toBe(2);
});
});
+227 -227
View File
@@ -1,227 +1,227 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, Easing } from 'react-native-reanimated';
import { useStreakStore } from '../store';
import type { CoupleStreak } from '../store';
import { colors, spacing, radius, typography, shadows } from '../theme';
interface StreakDisplayProps {
/** Whether to show the "longest streak" indicator */
showLongest?: boolean;
/** Animation delay for entry animation */
delay?: number;
/** Compact mode for smaller displays */
compact?: boolean;
}
/**
* The count is deliberately framed as a couple's, not a personal score: a shared streak
* is the thing worth keeping. The status line is the working half of that framing - it
* says who the day is waiting on - and the copy stays an invitation, because guilt
* between partners is the failure mode for this mechanic rather than the goal.
*/
export function streakStatus(streak: CoupleStreak): { headline: string; status: string } {
const named = streak.partner_name?.trim() || null;
// The same fallback reads wrong in both positions, so keep a sentence-start form.
const subject = named ?? 'Your partner';
const object = named ?? 'your partner';
const days = streak.current_streak;
const both = streak.you_answered_today && streak.partner_answered_today;
const headline = days > 0
? `You and ${object} — ${days} day${days === 1 ? '' : 's'} in a row`
: `You and ${object}`;
if (both) return { headline, status: days > 0 ? "You've both answered today" : 'Answered together today' };
if (streak.partner_answered_today) return { headline, status: `${subject} has answered today — your turn` };
if (streak.you_answered_today) return { headline, status: `Answered. Waiting on ${object}` };
return { headline, status: days > 0 ? 'One question each keeps it going' : 'Answer together to start a streak' };
}
/**
* Displays the couple's shared streak. The flame pulses while the streak is alive.
*/
export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) {
const { streak, fetchStreak } = useStreakStore();
// Fetch streak on mount
useEffect(() => {
fetchStreak();
}, [fetchStreak]);
// Flame pulse animation
const scale = useSharedValue(1);
const hasStreak = (streak?.current_streak ?? 0) > 0;
useEffect(() => {
if (hasStreak) {
scale.value = withRepeat(
withSequence(
withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) })
),
-1,
true
);
} else {
scale.value = 1;
}
}, [hasStreak, scale]);
const flameStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
if (!streak) {
return null;
}
// A dormant couple gets no zero-state card. Showing "0 days" to a pair who simply
// have not played yet reads as a scolding for a streak they never started.
if (!hasStreak && !streak.you_answered_today && !streak.partner_answered_today) {
return null;
}
const { headline, status } = streakStatus(streak);
const currentStreak = streak.current_streak;
const longestStreak = streak.longest_streak;
// The partner having moved first is the one state the couple can still act on today.
const awaitingYou = streak.partner_answered_today && !streak.you_answered_today;
if (compact) {
return (
<Animated.View entering={FadeInDown.delay(delay).duration(400)}>
<View style={[styles.compactContainer, awaitingYou && styles.compactAwaiting]}>
<Animated.View style={flameStyle}>
<Ionicons
name="flame"
size={16}
color={hasStreak ? colors.warning : colors.textTertiary}
/>
</Animated.View>
<Text style={[styles.compactCount, !hasStreak && styles.inactiveText]}>
{currentStreak}
</Text>
</View>
</Animated.View>
);
}
return (
<Animated.View entering={FadeInDown.delay(delay).duration(500)}>
<View style={styles.container}>
<View style={[styles.card, shadows.sm, awaitingYou && styles.cardAwaiting]}>
<View style={styles.content}>
<Animated.View style={[styles.iconContainer, flameStyle]}>
<Ionicons
name="flame"
size={28}
color={hasStreak ? colors.warning : colors.textTertiary}
/>
</Animated.View>
<View style={styles.info}>
<Text style={styles.headline} numberOfLines={2}>
{headline}
</Text>
<Text style={[styles.status, awaitingYou && styles.statusAwaiting]} numberOfLines={2}>
{status}
</Text>
</View>
</View>
{showLongest && longestStreak > 0 && (
<View style={styles.longest}>
<Ionicons name="trophy-outline" size={12} color={colors.textTertiary} />
<Text style={styles.longestText}>
Best together: {longestStreak} day{longestStreak !== 1 ? 's' : ''}
</Text>
</View>
)}
</View>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
marginVertical: spacing.sm,
},
card: {
backgroundColor: colors.surface,
borderRadius: radius.lg,
padding: spacing.md,
borderWidth: 1,
borderColor: colors.border,
},
cardAwaiting: {
borderColor: colors.warning,
},
content: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
iconContainer: {
width: 44,
height: 44,
borderRadius: radius.full,
backgroundColor: colors.warningLight,
justifyContent: 'center',
alignItems: 'center',
},
info: {
flex: 1,
},
headline: {
...typography.headline,
color: colors.text,
},
status: {
...typography.subhead,
color: colors.textSecondary,
marginTop: 2,
},
statusAwaiting: {
color: colors.warning,
},
inactiveText: {
color: colors.textTertiary,
},
longest: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
longestText: {
...typography.caption1,
color: colors.textTertiary,
},
// Compact styles
compactContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.warningLight,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.full,
},
compactAwaiting: {
borderWidth: 1,
borderColor: colors.warning,
},
compactCount: {
...typography.caption1,
fontWeight: '600',
color: colors.warning,
},
});
export default StreakDisplay;
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, Easing } from 'react-native-reanimated';
import { useStreakStore } from '../store';
import type { CoupleStreak } from '../store';
import { colors, spacing, radius, typography, shadows } from '../theme';
interface StreakDisplayProps {
/** Whether to show the "longest streak" indicator */
showLongest?: boolean;
/** Animation delay for entry animation */
delay?: number;
/** Compact mode for smaller displays */
compact?: boolean;
}
/**
* The count is deliberately framed as a couple's, not a personal score: a shared streak
* is the thing worth keeping. The status line is the working half of that framing - it
* says who the day is waiting on - and the copy stays an invitation, because guilt
* between partners is the failure mode for this mechanic rather than the goal.
*/
export function streakStatus(streak: CoupleStreak): { headline: string; status: string } {
const named = streak.partner_name?.trim() || null;
// The same fallback reads wrong in both positions, so keep a sentence-start form.
const subject = named ?? 'Your partner';
const object = named ?? 'your partner';
const days = streak.current_streak;
const both = streak.you_answered_today && streak.partner_answered_today;
const headline = days > 0
? `You and ${object} — ${days} day${days === 1 ? '' : 's'} in a row`
: `You and ${object}`;
if (both) return { headline, status: days > 0 ? "You've both answered today" : 'Answered together today' };
if (streak.partner_answered_today) return { headline, status: `${subject} has answered today — your turn` };
if (streak.you_answered_today) return { headline, status: `Answered. Waiting on ${object}` };
return { headline, status: days > 0 ? 'One question each keeps it going' : 'Answer together to start a streak' };
}
/**
* Displays the couple's shared streak. The flame pulses while the streak is alive.
*/
export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) {
const { streak, ensureStreakLoaded } = useStreakStore();
// Fetch streak on mount
useEffect(() => {
void ensureStreakLoaded();
}, [ensureStreakLoaded]);
// Flame pulse animation
const scale = useSharedValue(1);
const hasStreak = (streak?.current_streak ?? 0) > 0;
useEffect(() => {
if (hasStreak) {
scale.value = withRepeat(
withSequence(
withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) })
),
-1,
true
);
} else {
scale.value = 1;
}
}, [hasStreak, scale]);
const flameStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
if (!streak) {
return null;
}
// A dormant couple gets no zero-state card. Showing "0 days" to a pair who simply
// have not played yet reads as a scolding for a streak they never started.
if (!hasStreak && !streak.you_answered_today && !streak.partner_answered_today) {
return null;
}
const { headline, status } = streakStatus(streak);
const currentStreak = streak.current_streak;
const longestStreak = streak.longest_streak;
// The partner having moved first is the one state the couple can still act on today.
const awaitingYou = streak.partner_answered_today && !streak.you_answered_today;
if (compact) {
return (
<Animated.View entering={FadeInDown.delay(delay).duration(400)}>
<View style={[styles.compactContainer, awaitingYou && styles.compactAwaiting]}>
<Animated.View style={flameStyle}>
<Ionicons
name="flame"
size={16}
color={hasStreak ? colors.warning : colors.textTertiary}
/>
</Animated.View>
<Text style={[styles.compactCount, !hasStreak && styles.inactiveText]}>
{currentStreak}
</Text>
</View>
</Animated.View>
);
}
return (
<Animated.View entering={FadeInDown.delay(delay).duration(500)}>
<View style={styles.container}>
<View style={[styles.card, shadows.sm, awaitingYou && styles.cardAwaiting]}>
<View style={styles.content}>
<Animated.View style={[styles.iconContainer, flameStyle]}>
<Ionicons
name="flame"
size={28}
color={hasStreak ? colors.warning : colors.textTertiary}
/>
</Animated.View>
<View style={styles.info}>
<Text style={styles.headline} numberOfLines={2}>
{headline}
</Text>
<Text style={[styles.status, awaitingYou && styles.statusAwaiting]} numberOfLines={2}>
{status}
</Text>
</View>
</View>
{showLongest && longestStreak > 0 && (
<View style={styles.longest}>
<Ionicons name="trophy-outline" size={12} color={colors.textTertiary} />
<Text style={styles.longestText}>
Best together: {longestStreak} day{longestStreak !== 1 ? 's' : ''}
</Text>
</View>
)}
</View>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
marginVertical: spacing.sm,
},
card: {
backgroundColor: colors.surface,
borderRadius: radius.lg,
padding: spacing.md,
borderWidth: 1,
borderColor: colors.border,
},
cardAwaiting: {
borderColor: colors.warning,
},
content: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
iconContainer: {
width: 44,
height: 44,
borderRadius: radius.full,
backgroundColor: colors.warningLight,
justifyContent: 'center',
alignItems: 'center',
},
info: {
flex: 1,
},
headline: {
...typography.headline,
color: colors.text,
},
status: {
...typography.subhead,
color: colors.textSecondary,
marginTop: 2,
},
statusAwaiting: {
color: colors.warning,
},
inactiveText: {
color: colors.textTertiary,
},
longest: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
longestText: {
...typography.caption1,
color: colors.textTertiary,
},
// Compact styles
compactContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.warningLight,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.full,
},
compactAwaiting: {
borderWidth: 1,
borderColor: colors.warning,
},
compactCount: {
...typography.caption1,
fontWeight: '600',
color: colors.warning,
},
});
export default StreakDisplay;
+20 -13
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { View, StyleSheet, KeyboardAvoidingView, Platform, Alert } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
@@ -60,20 +60,31 @@ export const ChatScreen: React.FC = () => {
const isFocused = useIsFocused();
// Typing indicator hook
// Each hook needs a value the other produces: the message poll reports typing
// state, and an arriving message clears the indicator. The ref breaks the cycle.
const clearTypingRef = useRef<() => void>(() => undefined);
const handleNewMessage = useCallback(() => clearTypingRef.current(), []);
// Message subscription hook
const { messages, setMessages, partnerTyping: syncedTyping } = useMessageSubscription({
matchId,
userId: user?.id,
onNewMessage: handleNewMessage,
isFocused,
});
// Typing indicator hook, reading the state the message poll already fetched
const { partnerTyping, sendTypingEvent, clearTypingIndicator } = useTypingIndicator({
channelName: `chat:${matchId}`,
matchId,
userId: user?.id,
isFocused,
externalTyping: syncedTyping,
});
// Message subscription hook
const { messages, setMessages, isFocusedRef } = useMessageSubscription({
matchId,
userId: user?.id,
onNewMessage: clearTypingIndicator,
});
useEffect(() => {
clearTypingRef.current = clearTypingIndicator;
}, [clearTypingIndicator]);
// Message actions hook for deletion and reporting
@@ -174,16 +185,12 @@ export const ChatScreen: React.FC = () => {
// Track active chat to prevent notifications for current chat
useFocusEffect(
useCallback(() => {
isFocusedRef.current = true;
if (matchId) {
setActiveMatchId(matchId);
markMatchMessagesAsRead(matchId);
Events.matchViewed();
}
return () => {
isFocusedRef.current = false;
setActiveMatchId(null);
};
return () => setActiveMatchId(null);
}, [matchId, setActiveMatchId, markMatchMessagesAsRead])
);
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useIsFocused } from "@react-navigation/native";
import Animated, { FadeIn } from "react-native-reanimated";
import { GradientBackground } from "../../components/ui";
import { Paywall } from "../../components/paywall";
@@ -20,7 +21,8 @@ const TABS: { key: Tab; label: string }[] = [
];
export function DaresScreen() {
const dares = useDares();
const isFocused = useIsFocused();
const dares = useDares({ isFocused });
const [tab, setTab] = useState<Tab>("active");
const [sheetPack, setSheetPack] = useState<DarePack | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
@@ -1,11 +1,21 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { daresApi } from "../../../lib/daresApi";
import { ApiError } from "../../../lib/apiClient";
import { usePolling } from "../../../hooks/usePolling";
import type { DareCatalog, DareStats, SendDarePayload, SentDare } from "../types";
/** Matches the chat poll cadence; the API has no realtime channel. */
const POLL_INTERVAL_MS = 5_000;
export interface UseDaresConfig {
/**
* Supplied by the screen rather than read from navigation here, so the hook
* stays renderable without a navigator. Polling stops when the screen is not
* on top; `usePolling` additionally stops it when the app backgrounds.
*/
isFocused?: boolean;
}
export interface UseDaresReturn {
active: SentDare[];
history: SentDare[];
@@ -25,7 +35,19 @@ export interface UseDaresReturn {
cancel: (dareId: string) => Promise<void>;
}
export function useDares(): UseDaresReturn {
/**
* Identity of the active set as the user experiences it. History, stats and the
* remaining-send allowance only move when a dare enters or leaves this set or
* changes status, so this is the signal that says the slower endpoints are worth
* re-reading.
*/
const activeSignature = (dares: SentDare[]): string =>
dares
.map(dare => `${dare.id}:${dare.status}`)
.sort()
.join("|");
export function useDares({ isFocused = true }: UseDaresConfig = {}): UseDaresReturn {
const [active, setActive] = useState<SentDare[]>([]);
const [history, setHistory] = useState<SentDare[]>([]);
const [catalog, setCatalog] = useState<DareCatalog | null>(null);
@@ -36,42 +58,63 @@ export function useDares(): UseDaresReturn {
const [paywallReason, setPaywallReason] = useState<string | null>(null);
const fetchingRef = useRef(false);
const mountedRef = useRef(true);
const signatureRef = useRef<string | null>(null);
const refresh = useCallback(async () => {
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
/**
* `full` reads everything; the polling path reads only the active list and
* follows up on the rest when that list actually changed. A failure updates
* the UI's error state either way, but is only rethrown for the poll path
* (`throwOnError`): `usePolling` needs the rejection to drive its backoff,
* while pull-to-refresh and post-mutation callers already surface the error
* themselves and must not turn it into an unhandled rejection.
*/
const load = useCallback(async (full: boolean, throwOnError = false) => {
if (fetchingRef.current) return;
fetchingRef.current = true;
try {
const [activeResult, historyResult, catalogResult, statsResult] = await Promise.all([
daresApi.list("active"),
daresApi.list("history"),
daresApi.catalog(),
daresApi.stats(),
]);
const activeResult = await daresApi.list("active");
if (!mountedRef.current) return;
setActive(activeResult.dares);
setHistory(historyResult.dares);
setCatalog(catalogResult);
setStats(statsResult);
const signature = activeSignature(activeResult.dares);
const changed = signature !== signatureRef.current;
signatureRef.current = signature;
if (full || changed) {
const [historyResult, catalogResult, statsResult] = await Promise.all([
daresApi.list("history"),
daresApi.catalog(),
daresApi.stats(),
]);
if (!mountedRef.current) return;
setHistory(historyResult.dares);
setCatalog(catalogResult);
setStats(statsResult);
}
setError(null);
} catch (cause) {
if (mountedRef.current) {
setError(cause instanceof ApiError ? cause.message : "Could not load dares");
}
if (throwOnError) throw cause;
} finally {
fetchingRef.current = false;
if (mountedRef.current) setLoading(false);
}
}, []);
useEffect(() => {
mountedRef.current = true;
void refresh();
const timer = setInterval(() => void refresh(), POLL_INTERVAL_MS);
return () => {
mountedRef.current = false;
clearInterval(timer);
};
}, [refresh]);
/** Pull-to-refresh and post-mutation reloads always read the slow endpoints too. */
const refresh = useCallback(() => load(true), [load]);
const poll = useCallback(() => load(false, true), [load]);
usePolling(poll, { intervalMs: POLL_INTERVAL_MS, enabled: isFocused });
/** Premium refusals steer to the paywall; everything else surfaces inline. */
const handleFailure = useCallback((cause: unknown) => {
@@ -8,6 +8,7 @@ import {
useWindowDimensions,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useIsFocused } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
@@ -35,6 +36,7 @@ export const LiveDrawScreen: React.FC = () => {
const insets = useSafeAreaInsets();
const { user } = useAuthStore();
const coupleId = coupleIdParam || user?.couple_id;
const isFocused = useIsFocused();
const { width: screenWidth } = useWindowDimensions();
const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[0]);
@@ -95,6 +97,7 @@ export const LiveDrawScreen: React.FC = () => {
const sync = useDrawingSync({
coupleId: coupleId!,
userId: user?.id,
isFocused,
onStrokeStart: handleStrokeStartFromPartner,
onStrokeContinue: handleStrokeContinueFromPartner,
onStrokeEnd: handleStrokeEndFromPartner,
@@ -1,9 +1,13 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { ApiError } from '../../../lib/apiClient';
import { appDataApi, type LiveDrawState } from '../../../lib/appDataApi';
import { usePolling } from '../../../hooks/usePolling';
import type { StrokeSegment, StrokePoint } from '../types';
import { updateWidget } from '../utils/widgetBridge';
/** Fast enough for a shared canvas to feel live, and only while one is on screen. */
const POLL_INTERVAL_MS = 750;
interface UseDrawingSyncConfig {
coupleId: string;
userId: string | undefined;
@@ -14,6 +18,12 @@ interface UseDrawingSyncConfig {
onUndo: (strokeId: string) => void;
onRedo: (stroke: StrokeSegment) => void;
onLoadStrokes: (strokes: StrokeSegment[]) => void;
/**
* Supplied by the screen rather than read from navigation here, so the hook
* stays renderable without a navigator. A canvas nobody is looking at polls
* nothing; `usePolling` also stops it when the app goes to the background.
*/
isFocused?: boolean;
}
interface UseDrawingSyncReturn {
@@ -52,7 +62,7 @@ export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegmen
}
export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncReturn {
const { coupleId, userId, onLoadStrokes } = config;
const { coupleId, userId, onLoadStrokes, isFocused = true } = config;
const revisionRef = useRef(0);
const baselineRef = useRef<StrokeSegment[]>([]);
const workingRef = useRef<StrokeSegment[]>([]);
@@ -132,19 +142,25 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu
void appDataApi.getLiveDraw().then(acceptState).catch(error => console.error('Failed to load live drawing:', error));
}, [coupleId, acceptState]);
useEffect(() => {
if (!coupleId || !userId) return;
let cancelled = false;
const poll = async () => {
if (pendingWritesRef.current > 0) return;
try {
const state = await appDataApi.getLiveDraw();
if (!cancelled && state.revision > revisionRef.current) acceptState(state);
} catch (error) { console.error('Failed to poll live drawing:', error); }
};
const timer = setInterval(() => void poll(), 750);
return () => { cancelled = true; clearInterval(timer); if (throttleRef.current) clearTimeout(throttleRef.current); };
}, [coupleId, userId, acceptState]);
// Our own writes already return the authoritative state, so reading over the top
// of one in flight would only race it back to a revision we are about to replace.
const poll = useCallback(async () => {
if (pendingWritesRef.current > 0) return;
const state = await appDataApi.getLiveDraw();
if (state.revision > revisionRef.current) acceptState(state);
}, [acceptState]);
// `leading: false` because the mount effect above already read the initial state;
// usePolling adds the overlap guard, backoff and background pause the raw
// interval had none of.
usePolling(poll, {
intervalMs: POLL_INTERVAL_MS,
enabled: Boolean(coupleId) && Boolean(userId) && isFocused,
leading: false,
resetKey: `${coupleId}:${userId ?? ''}`,
});
useEffect(() => () => { if (throttleRef.current) clearTimeout(throttleRef.current); }, []);
const persistStrokes = useCallback((strokes: StrokeSegment[]) => {
if (throttleRef.current) clearTimeout(throttleRef.current);
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAuthStore, usePacksStore, useStreakStore } from "../../../store";
import { useAuthStore, usePacksStore, useResponsesStore, useStreakStore } from "../../../store";
import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions";
import { ApiError, apiClient } from "../../../lib/apiClient";
import { Events } from "../../../lib/analytics";
@@ -352,6 +352,12 @@ export const useSwipeScreen = () => {
// wrong partner state for the rest of the session. Refreshing it must never
// interfere with recording the answer, so it stays off the critical path.
void useStreakStore.getState().fetchStreak().catch(() => undefined);
// This answer changed pack progress and the answer history. Marking their
// caches stale costs no request: whichever screen the user opens next
// reloads once, and the ones they do not open stay quiet.
usePacksStore.getState().invalidatePacks();
useResponsesStore.getState().invalidateResponses();
} catch (error) {
// A 429 means the server rejected this answer outright. Do not advance the
// index, or the question is silently skipped and lost for the day.
+3
View File
@@ -1,4 +1,7 @@
export { useAmbientOrbAnimation } from './useAmbientOrbAnimation';
export { usePolling } from './usePolling';
export type { UsePollingOptions } from './usePolling';
export { useCoupleSync, SYNC_INTERVAL_MS } from './useCoupleSync';
export type { AmbientOrbConfig } from './useAmbientOrbAnimation';
export { useFeatureInterest } from './useFeatureInterest';
export { useMediaPicker } from './useMediaPicker';
+116
View File
@@ -0,0 +1,116 @@
import { useCallback, useRef } from 'react';
import { appDataApi, type SyncSummary } from '../lib/appDataApi';
import { useAuthStore } from '../store/authStore';
import { useMatchStore } from '../store/matchStore';
import { useMessageStore } from '../store/messageStore';
import { usePacksStore } from '../store/packsStore';
import { useStreakStore } from '../store/streakStore';
import { usePolling } from './usePolling';
/**
* Slower than the five-second full refresh it replaces, because the request it
* makes is a change summary rather than five payloads: the app reacts to a
* partner's action within this window, but pays one small query for it.
*/
export const SYNC_INTERVAL_MS = 15_000;
/**
* Keeps cached domain data honest without re-fetching it. Each poll reads one
* summary of the couple's state and refreshes only the domains whose markers
* moved — and only the ones already on screen, so a view nobody has opened is
* not loaded in the background.
*/
export function useCoupleSync(userId: string | undefined, coupleId: string | null | undefined): void {
const previousRef = useRef<SyncSummary | null>(null);
const identityRef = useRef<string>('');
// `sync` is memoized on a stable callback, so `taskRef` inside usePolling
// never has to restart the schedule on every render; it reads the current
// identity through this ref instead of closing over the arguments from
// whichever render created it.
const identityValuesRef = useRef({ userId, coupleId });
identityValuesRef.current = { userId, coupleId };
const latestIdentity = useCallback(() => {
const { userId, coupleId } = identityValuesRef.current;
return `${userId ?? ''}:${coupleId ?? ''}`;
}, []);
const sync = useCallback(async () => {
const identity = latestIdentity();
if (identity !== identityRef.current) {
// A new account's first summary must not be diffed against the prior
// account's markers, or every domain would silently "refresh" on login.
identityRef.current = identity;
previousRef.current = null;
}
const summary = await appDataApi.syncSummary();
// The account can change while this request is in flight. The response
// describes the identity that asked for it, so once that is no longer the
// current one it may not touch anything: not the badge, not the baseline
// the next account will diff against, and not a single refresh decision.
// Both checks matter — `identityRef` catches a newer sync that has already
// started, and `latestIdentity()` catches a switch that has only rendered.
if (identityRef.current !== identity || latestIdentity() !== identity) return;
const previous = previousRef.current;
previousRef.current = summary;
// The badge count comes straight out of the summary, so keeping it current
// costs nothing beyond this request.
if (summary.unread_total !== previous?.unread_total) {
useMessageStore.getState().setUnreadCount(summary.unread_total);
}
// The first poll only establishes the baseline: every screen loads its own
// data on mount, and refreshing it again here would undo the saving.
if (!previous) return;
const auth = useAuthStore.getState();
const matchStore = useMatchStore.getState();
const refreshes: Promise<unknown>[] = [];
if (summary.profile_updated_at !== previous.profile_updated_at || summary.couple_id !== previous.couple_id) {
// fetchUser also refreshes the couple when one is set.
refreshes.push(auth.fetchUser());
} else if (summary.partner_id !== previous.partner_id || summary.partner_updated_at !== previous.partner_updated_at) {
refreshes.push(auth.fetchCouple());
}
if (matchStore.loadedAt.active !== undefined && (
summary.match_count !== previous.match_count ||
summary.new_match_count !== previous.new_match_count ||
summary.latest_match_at !== previous.latest_match_at ||
// Neither count moves when a partner edits an existing match's type or
// response summary through response editing.
summary.match_state_fingerprint !== previous.match_state_fingerprint ||
// `unread_total` can hold steady while unread redistributes between
// matches (one read, another receives a message), which reorders the
// unread-first list without moving the total.
summary.match_unread_fingerprint !== previous.match_unread_fingerprint)) {
refreshes.push(matchStore.fetchMatches(true, { silent: true }));
}
if (matchStore.loadedAt.pending !== undefined && summary.pending_yours !== previous.pending_yours) {
refreshes.push(matchStore.fetchPendingQuestions({ silent: true }));
}
if (matchStore.loadedAt.their_turn !== undefined && summary.pending_theirs !== previous.pending_theirs) {
refreshes.push(matchStore.fetchTheirTurnQuestions({ silent: true }));
}
// Only refresh a domain the user has actually opened, matching the rule
// that unopened domains stay unloaded.
if (usePacksStore.getState().enabledPacksLoadedAt !== null
&& summary.enabled_packs_fingerprint !== previous.enabled_packs_fingerprint) {
refreshes.push(usePacksStore.getState().fetchEnabledPacks());
}
if (summary.streak_updated_at !== previous.streak_updated_at) {
refreshes.push(useStreakStore.getState().refreshStreak());
}
await Promise.allSettled(refreshes);
}, [latestIdentity]);
usePolling(sync, {
intervalMs: SYNC_INTERVAL_MS,
enabled: Boolean(userId),
resetKey: `${userId ?? ''}:${coupleId ?? ''}`,
});
}
+97 -32
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { chatApi } from '../lib/chatApi';
import { chatApi, type MessagesPage } from '../lib/chatApi';
import { usePolling } from './usePolling';
import type { Message } from '../features/chat/types';
export interface UseMessageSubscriptionConfig {
@@ -7,51 +8,115 @@ export interface UseMessageSubscriptionConfig {
userId: string | undefined;
onNewMessage?: () => void;
pollInterval?: number;
/** Whether the chat is on screen. Polling and read receipts stop when it is not. */
isFocused?: boolean;
}
export interface UseMessageSubscriptionReturn {
messages: Message[];
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
loading: boolean;
isFocusedRef: React.MutableRefObject<boolean>;
/** Partner typing state folded into the message poll; undefined until the server reports it. */
partnerTyping: boolean | undefined;
/** Fetches the next delta now, e.g. straight after sending. */
refresh: () => Promise<void>;
}
/** Authenticated API polling replaces Supabase Realtime for self-hosted chat. */
export const useMessageSubscription = ({ matchId, userId, onNewMessage, pollInterval = 1_500 }: UseMessageSubscriptionConfig): UseMessageSubscriptionReturn => {
const timeOf = (message: Message): number => {
const parsed = message.created_at ? Date.parse(message.created_at) : Number.NaN;
return Number.isNaN(parsed) ? 0 : parsed;
};
/** Newest first, matching the order the API returns a snapshot in. */
const byNewest = (a: Message, b: Message): number => {
const delta = timeOf(b) - timeOf(a);
if (delta !== 0) return delta;
return a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
};
/**
* Returns the previous array unchanged when a delta carries nothing, so an idle
* poll re-renders neither this hook's consumers nor the message list.
*/
export const mergeMessagePage = (previous: Message[], page: MessagesPage): Message[] => {
if (page.complete !== false) return page.messages;
const removed = page.removed_ids ?? [];
if (page.messages.length === 0 && removed.length === 0) return previous;
const byId = new Map(previous.map(message => [message.id, message]));
for (const message of page.messages) byId.set(message.id, message);
for (const id of removed) byId.delete(id);
return [...byId.values()].sort(byNewest);
};
/**
* Authenticated API polling replaces Supabase Realtime for self-hosted chat.
*
* Each poll asks only for what changed since the cursor the server issued last
* time, so an idle conversation costs an all-but-empty response instead of the
* five hundred messages the screen already has. Read receipts are written only
* when the response actually contains an unread message from the partner, which
* on an idle chat is never.
*/
export const useMessageSubscription = ({
matchId,
userId,
onNewMessage,
pollInterval = 2_000,
isFocused = true,
}: UseMessageSubscriptionConfig): UseMessageSubscriptionReturn => {
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const isFocusedRef = useRef(false);
const [loading, setLoading] = useState(Boolean(matchId && userId));
const [partnerTyping, setPartnerTyping] = useState<boolean | undefined>(undefined);
const sinceRef = useRef<string | null>(null);
const knownIdsRef = useRef<Set<string>>(new Set());
const fetchingRef = useRef(false);
const loadingRef = useRef(true);
const notifyRef = useRef(onNewMessage);
notifyRef.current = onNewMessage;
// A different conversation shares none of this one's state, and the poll below
// restarts on the same key so the new one loads immediately.
useEffect(() => {
sinceRef.current = null;
knownIdsRef.current = new Set();
loadingRef.current = Boolean(matchId && userId);
setMessages([]);
setPartnerTyping(undefined);
setLoading(loadingRef.current);
}, [matchId, userId]);
const refresh = useCallback(async (initial = false) => {
if (!matchId || !userId || fetchingRef.current) return;
fetchingRef.current = true;
const refresh = useCallback(async () => {
if (!matchId || !userId) return;
const since = sinceRef.current;
try {
const response = await chatApi.listMessages(matchId);
const partnerArrived = !initial && response.messages.some(message => message.user_id !== userId && !knownIdsRef.current.has(message.id));
knownIdsRef.current = new Set(response.messages.map(message => message.id));
setMessages(response.messages);
if (isFocusedRef.current) await chatApi.markRead(matchId);
if (partnerArrived) onNewMessage?.();
const page = await chatApi.listMessages(matchId, { ...(since ? { since } : {}), typing: true });
// Without a cursor the server cannot answer with deltas, so keep asking
// for snapshots rather than silently freezing on a stale page.
sinceRef.current = page.server_time ?? null;
if (page.typing) setPartnerTyping(page.typing.typing);
const arrivals = page.messages.filter(message => message.user_id !== userId && !knownIdsRef.current.has(message.id));
for (const message of page.messages) knownIdsRef.current.add(message.id);
for (const id of page.removed_ids ?? []) knownIdsRef.current.delete(id);
setMessages(previous => mergeMessagePage(previous, page));
if (isFocused && page.messages.some(message => message.user_id !== userId && !message.read_at)) {
await chatApi.markRead(matchId);
}
if (!loadingRef.current && arrivals.length > 0) notifyRef.current?.();
} finally {
fetchingRef.current = false;
if (initial) setLoading(false);
if (loadingRef.current) {
loadingRef.current = false;
setLoading(false);
}
}
}, [matchId, userId, onNewMessage]);
}, [matchId, userId, isFocused]);
useEffect(() => {
if (!matchId || !userId) {
setMessages([]);
setLoading(false);
return;
}
setLoading(true);
knownIdsRef.current = new Set();
void refresh(true).catch(() => undefined);
const timer = setInterval(() => void refresh(false).catch(() => undefined), pollInterval);
return () => clearInterval(timer);
}, [matchId, userId, pollInterval, refresh]);
usePolling(refresh, {
intervalMs: pollInterval,
enabled: Boolean(matchId && userId && isFocused),
resetKey: `${matchId ?? ''}:${userId ?? ''}`,
});
return { messages, setMessages, loading, isFocusedRef };
return { messages, setMessages, loading, partnerTyping, refresh };
};
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useRef } from 'react';
import { isForeground, subscribeToForeground } from '../lib/appForeground';
export interface UsePollingOptions {
intervalMs: number;
/** Caller-owned relevance: screen focus, a loaded id, a paired couple. */
enabled?: boolean;
/** Run once as soon as polling becomes active. Default true. */
leading?: boolean;
/** Ceiling for the exponential backoff applied after consecutive failures. */
maxIntervalMs?: number;
/**
* Restarts the schedule when it changes, so a caller that switches subject —
* a different chat, a different couple — gets an immediate run instead of
* waiting out the interval left over from the previous one.
*/
resetKey?: string | number;
}
/**
* A recurring task with the three properties every poller in this app needs and
* none of them had: it stops while the app is backgrounded, it never runs twice
* concurrently, and it backs off instead of hammering an API that is failing.
*
* The next run is scheduled after the previous one settles rather than on a fixed
* `setInterval`, so a slow response delays the next request instead of queueing
* one behind it. `task` is read through a ref, so a caller may pass a fresh
* closure every render without restarting the schedule.
*/
export function usePolling(task: () => Promise<void>, options: UsePollingOptions): void {
const { intervalMs, enabled = true, leading = true, maxIntervalMs = intervalMs * 8, resetKey } = options;
const taskRef = useRef(task);
taskRef.current = task;
useEffect(() => {
if (!enabled) return;
let cancelled = false;
let running = false;
let failures = 0;
let foreground = isForeground();
let timer: ReturnType<typeof setTimeout> | null = null;
const clear = () => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
};
const schedule = () => {
if (cancelled || !foreground || timer !== null) return;
const delay = Math.min(intervalMs * 2 ** failures, maxIntervalMs);
timer = setTimeout(() => {
timer = null;
void run();
}, delay);
};
const run = async () => {
if (cancelled || running || !foreground) return;
running = true;
try {
await taskRef.current();
failures = 0;
} catch {
failures += 1;
} finally {
running = false;
schedule();
}
};
const unsubscribe = subscribeToForeground((next) => {
if (next === foreground) return;
foreground = next;
if (!foreground) {
clear();
return;
}
// Back from the background: the cached data is as stale as the absence
// was long, so catch up now rather than after another full interval.
failures = 0;
void run();
});
if (leading) void run();
else schedule();
return () => {
cancelled = true;
clear();
unsubscribe();
};
}, [enabled, intervalMs, leading, maxIntervalMs, resetKey]);
}
+38 -19
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { chatApi } from '../lib/chatApi';
import { usePolling } from './usePolling';
export interface UseTypingIndicatorConfig {
channelName: string;
@@ -9,6 +10,12 @@ export interface UseTypingIndicatorConfig {
isFocused?: boolean;
throttleInterval?: number;
pollInterval?: number;
/**
* Typing state already known from another request. When supplied this hook
* stops polling for it, which is how the chat screen gets typing state for
* free out of the message poll instead of a second request every interval.
*/
externalTyping?: boolean | undefined;
}
export interface UseTypingIndicatorReturn {
@@ -17,31 +24,43 @@ export interface UseTypingIndicatorReturn {
clearTypingIndicator: () => void;
}
export const useTypingIndicator = ({ channelName, userId, matchId = channelName.replace(/^chat:/, ''), isFocused = true, throttleInterval = 2_000, pollInterval = 1_500 }: UseTypingIndicatorConfig): UseTypingIndicatorReturn => {
export const useTypingIndicator = ({
channelName,
userId,
matchId = channelName.replace(/^chat:/, ''),
isFocused = true,
throttleInterval = 2_000,
pollInterval = 1_500,
externalTyping,
}: UseTypingIndicatorConfig): UseTypingIndicatorReturn => {
const [partnerTyping, setPartnerTyping] = useState(false);
const lastTypingEventRef = useRef(0);
const external = externalTyping !== undefined;
useEffect(() => {
if (!matchId || !userId || !isFocused) {
if (externalTyping !== undefined) setPartnerTyping(externalTyping);
}, [externalTyping]);
// Losing the subject stops both sources of truth, so drop the stale indicator
// rather than leaving the partner typing forever in a chat nobody is watching.
useEffect(() => {
if (!matchId || !userId || !isFocused) setPartnerTyping(false);
}, [matchId, userId, isFocused]);
const poll = useCallback(async () => {
try {
const state = await chatApi.getTyping(matchId);
setPartnerTyping(state.typing);
} catch {
setPartnerTyping(false);
return;
}
let active = true;
const poll = async () => {
try {
const state = await chatApi.getTyping(matchId);
if (active) setPartnerTyping(state.typing);
} catch {
if (active) setPartnerTyping(false);
}
};
void poll();
const timer = setInterval(() => void poll(), pollInterval);
return () => {
active = false;
clearInterval(timer);
};
}, [matchId, userId, isFocused, pollInterval]);
}, [matchId]);
usePolling(poll, {
intervalMs: pollInterval,
enabled: !external && Boolean(matchId) && Boolean(userId) && isFocused,
resetKey: matchId,
});
const sendTypingEvent = useCallback(() => {
if (!matchId || !userId) return;
+29
View File
@@ -10,7 +10,36 @@ export interface LiveDrawState {
updated_by: string | null;
}
/**
* A change summary, not a payload: markers the client compares against the ones
* it saw last time to decide which domain, if any, is worth refetching. It
* replaces a periodic full refresh of profile, couple, matches, pending
* questions, packs and unread counts with one small request.
*/
export interface SyncSummary {
server_time: string;
couple_id: string | null;
profile_updated_at: string | null;
partner_id: string | null;
partner_updated_at: string | null;
match_count: number;
new_match_count: number;
latest_match_at: string | null;
pending_yours: number;
pending_theirs: number;
unread_total: number;
/** couple_packs carries no timestamp, so equal-and-opposite toggles are caught by a digest. */
enabled_packs_fingerprint: string | null;
/** Digest of each visible match's type and response summary; moves on an in-place response edit that the count/latest-at markers miss. */
match_state_fingerprint: string;
/** Digest of each visible match's unread count; moves when unread redistributes between matches without changing the total. */
match_unread_fingerprint: string;
/** couple_streaks.updated_at, so a partner's answer can silently refresh an already-loaded streak. */
streak_updated_at: string | null;
}
export const appDataApi = {
syncSummary: () => apiClient.get<SyncSummary>('/v1/me/sync'),
packContext: (packId: string) => apiClient.get<{ pack: { id: string; name: string; icon: string | null } }>(`/v1/packs/${encodeURIComponent(packId)}/context`),
packQuestions: (packId: string) => apiClient.get<{ questions: Question[] }>(`/v1/packs/${encodeURIComponent(packId)}/questions`),
packTeaser: (packId: string) => apiClient.get<{ questions: Array<{ id: string; text: string; intensity: number }> }>(`/v1/packs/${encodeURIComponent(packId)}/teaser`),
+21
View File
@@ -0,0 +1,21 @@
import { AppState, type AppStateStatus } from 'react-native';
/**
* `background` is a real suspension and `inactive` is the moment behind the app
* switcher, a call, or a system prompt. Anything else — including states a test
* harness never sets — counts as foreground, so recurring work fails towards
* running rather than towards being silently disabled forever.
*/
export function isForegroundState(state: AppStateStatus | string | undefined): boolean {
return state !== 'background' && state !== 'inactive';
}
export function isForeground(): boolean {
return isForegroundState(AppState.currentState as AppStateStatus | undefined);
}
/** Reports the new foreground flag on every transition. Returns an unsubscribe. */
export function subscribeToForeground(onChange: (foreground: boolean) => void): () => void {
const subscription = AppState.addEventListener('change', (state) => onChange(isForegroundState(state)));
return () => subscription.remove();
}
+34 -2
View File
@@ -1,8 +1,40 @@
import { apiClient } from './apiClient';
import type { Message, ReportReason } from '../features/chat/types';
export interface PartnerTyping {
typing: boolean;
expires_at: string | null;
}
/**
* A snapshot when `complete`, otherwise only what changed since the cursor the
* previous page handed back. Delta rows are whole messages, not patches, so a
* caller merges them by id without a second shape to understand.
*/
export interface MessagesPage {
messages: Message[];
/** Messages the caller deleted for themselves since the cursor. */
removed_ids?: string[];
/** Cursor to send as `since` on the next poll. */
server_time?: string;
complete?: boolean;
/** Present when requested, saving a separate typing request. */
typing?: PartnerTyping;
}
export interface ListMessagesOptions {
since?: string;
typing?: boolean;
}
export const chatApi = {
listMessages: (matchId: string) => apiClient.get<{ messages: Message[] }>(`/v1/matches/${encodeURIComponent(matchId)}/messages`),
listMessages: (matchId: string, options: ListMessagesOptions = {}) => {
const query: string[] = [];
if (options.since) query.push(`since=${encodeURIComponent(options.since)}`);
if (options.typing) query.push('typing=true');
const suffix = query.length > 0 ? `?${query.join('&')}` : '';
return apiClient.get<MessagesPage>(`/v1/matches/${encodeURIComponent(matchId)}/messages${suffix}`);
},
sendText: (matchId: string, content: string) => apiClient.post<{ message: Message }>(`/v1/matches/${encodeURIComponent(matchId)}/messages`, { content }),
unread: () => apiClient.get<{ total: number; by_match: Record<string, number> }>('/v1/chat/unread'),
markRead: (matchId: string) => apiClient.post<{ updated: number; read_at: string }>(`/v1/matches/${encodeURIComponent(matchId)}/read`),
@@ -11,5 +43,5 @@ export const chatApi = {
deleteForEveryone: (messageId: string) => apiClient.delete<{ deleted: true; message: Message }>(`/v1/messages/${encodeURIComponent(messageId)}?scope=everyone`),
report: (messageId: string, reason: ReportReason) => apiClient.post<{ reported: true }>(`/v1/messages/${encodeURIComponent(messageId)}/reports`, { reason }),
setTyping: (matchId: string) => apiClient.put<{ typing: true }>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
getTyping: (matchId: string) => apiClient.get<{ typing: boolean; expires_at: string | null }>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
getTyping: (matchId: string) => apiClient.get<PartnerTyping>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
};
+175 -171
View File
@@ -4,104 +4,106 @@ import { authClient } from "../lib/authClient";
import { coupleApi } from "../lib/coupleApi";
import { profileSettingsApi } from "../lib/profileSettingsApi";
import { syncTimezone } from "../lib/reportedTimezone";
import { Events } from "../lib/analytics";
import type { Profile, Couple } from "@/types";
interface AuthState {
user: Profile | null;
couple: Couple | null;
partner: Profile | null;
isLoading: boolean;
isAuthenticated: boolean;
isAnonymous: boolean;
// Actions
fetchUser: () => Promise<void>;
fetchCouple: () => Promise<void>;
refreshPartner: () => Promise<Profile | null>;
signOut: () => Promise<void>;
setUser: (user: Profile | null) => void;
updateLastActive: () => Promise<void>;
}
// Import other stores lazily to avoid circular dependency issues
const getOtherStores = () => {
const { useMatchStore } = require("./matchStore");
const { usePacksStore } = require("./packsStore");
const { useMessageStore } = require("./messageStore");
const { useSubscriptionStore } = require("./subscriptionStore");
const { useNotificationPreferencesStore } = require("./notificationPreferencesStore");
const { useStreakStore } = require("./streakStore");
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore };
};
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
couple: null,
partner: null,
isLoading: true,
isAuthenticated: false,
isAnonymous: false,
fetchUser: async () => {
try {
// First check if we have a session in storage
import { Events } from "../lib/analytics";
import type { Profile, Couple } from "@/types";
interface AuthState {
user: Profile | null;
couple: Couple | null;
partner: Profile | null;
isLoading: boolean;
isAuthenticated: boolean;
isAnonymous: boolean;
// Actions
fetchUser: () => Promise<void>;
fetchCouple: () => Promise<void>;
refreshPartner: () => Promise<Profile | null>;
signOut: () => Promise<void>;
setUser: (user: Profile | null) => void;
updateLastActive: () => Promise<void>;
}
// Import other stores lazily to avoid circular dependency issues
const getOtherStores = () => {
const { useMatchStore } = require("./matchStore");
const { usePacksStore } = require("./packsStore");
const { useMessageStore } = require("./messageStore");
const { useSubscriptionStore } = require("./subscriptionStore");
const { useNotificationPreferencesStore } = require("./notificationPreferencesStore");
const { useStreakStore } = require("./streakStore");
const { useResponsesStore } = require("./responsesStore");
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore };
};
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
couple: null,
partner: null,
isLoading: true,
isAuthenticated: false,
isAnonymous: false,
fetchUser: async () => {
try {
// First check if we have a session in storage
const { data: { session } } = await authClient.auth.getSession();
if (!session?.user) {
set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
return;
}
if (!session?.user) {
set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
return;
}
// This endpoint verifies the Supabase access token and creates the
// product profile on first use, keeping profile data out of Supabase.
const { profile } = await apiClient.get<{ profile: Profile }>("/v1/me");
const isAnonymous = !!(session.user as { is_anonymous?: boolean }).is_anonymous;
// Only update user if data actually changed to avoid unnecessary re-renders
const currentUser = get().user;
const userChanged = !currentUser || JSON.stringify(currentUser) !== JSON.stringify(profile);
if (userChanged) {
set({
user: profile,
isAuthenticated: true,
isAnonymous,
});
} else if (!get().isAuthenticated) {
set({ isAuthenticated: true, isAnonymous });
}
// If user has a couple, fetch couple data; otherwise clear couple/partner
if (profile?.couple_id) {
await get().fetchCouple();
} else {
set({ couple: null, partner: null });
}
set({ isLoading: false });
// Only update user if data actually changed to avoid unnecessary re-renders
const currentUser = get().user;
const userChanged = !currentUser || JSON.stringify(currentUser) !== JSON.stringify(profile);
if (userChanged) {
set({
user: profile,
isAuthenticated: true,
isAnonymous,
});
} else if (!get().isAuthenticated) {
set({ isAuthenticated: true, isAnonymous });
}
// If user has a couple, fetch couple data; otherwise clear couple/partner
if (profile?.couple_id) {
await get().fetchCouple();
} else {
set({ couple: null, partner: null });
}
set({ isLoading: false });
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
console.log("[Auth] Session rejected by API, signing out");
set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
useResponsesStore.getState().clearResponses();
await authClient.auth.signOut();
return;
}
console.error("Error fetching user:", error);
set({ isLoading: false });
}
},
}
},
fetchCouple: async () => {
const user = get().user;
if (!user?.couple_id) {
@@ -111,100 +113,102 @@ export const useAuthStore = create<AuthState>((set, get) => ({
const { couple, partner } = await coupleApi.getState();
set({ couple, partner });
},
/**
* Refresh partner's profile data (useful to get latest public_key_jwk for E2EE)
* Returns the updated partner profile or null if not found
*/
refreshPartner: async () => {
const user = get().user;
if (!user?.couple_id) return null;
},
/**
* Refresh partner's profile data (useful to get latest public_key_jwk for E2EE)
* Returns the updated partner profile or null if not found
*/
refreshPartner: async () => {
const user = get().user;
if (!user?.couple_id) return null;
const { partner } = await coupleApi.getState();
if (partner) {
set({ partner });
}
return partner;
},
signOut: async () => {
Events.signOut();
// Clear local state FIRST to ensure UI updates even if Supabase call fails
set({
user: null,
couple: null,
partner: null,
isAuthenticated: false,
isAnonymous: false,
isLoading: false,
});
// Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
// Clear badge on sign out
const { clearBadge } = require("../lib/badge");
await clearBadge();
// Then try to sign out from Supabase (don't block on this)
try {
if (partner) {
set({ partner });
}
return partner;
},
signOut: async () => {
Events.signOut();
// Clear local state FIRST to ensure UI updates even if Supabase call fails
set({
user: null,
couple: null,
partner: null,
isAuthenticated: false,
isAnonymous: false,
isLoading: false,
});
// Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
useResponsesStore.getState().clearResponses();
// Clear badge on sign out
const { clearBadge } = require("../lib/badge");
await clearBadge();
// Then try to sign out from Supabase (don't block on this)
try {
await authClient.auth.signOut();
} catch (error) {
console.error("Supabase signOut error:", error);
}
},
setUser: (user) => {
set({
user,
isAuthenticated: !!user,
...(user === null && { isAnonymous: false }),
isLoading: false,
// Clear couple/partner when user is null (signed out)
...(user === null && { couple: null, partner: null })
});
// Clear other stores when user signs out
if (user === null) {
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
// Clear badge on sign out
const { clearBadge } = require("../lib/badge");
clearBadge();
}
},
/**
* Update the user's last_active_at timestamp.
* Called when the app comes to the foreground to track user activity.
* This helps prevent sending notifications to users who are already in the app.
*/
updateLastActive: async () => {
const userId = get().user?.id;
if (!userId) return;
try {
} catch (error) {
console.error("Supabase signOut error:", error);
}
},
setUser: (user) => {
set({
user,
isAuthenticated: !!user,
...(user === null && { isAnonymous: false }),
isLoading: false,
// Clear couple/partner when user is null (signed out)
...(user === null && { couple: null, partner: null })
});
// Clear other stores when user signs out
if (user === null) {
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
useResponsesStore.getState().clearResponses();
// Clear badge on sign out
const { clearBadge } = require("../lib/badge");
clearBadge();
}
},
/**
* Update the user's last_active_at timestamp.
* Called when the app comes to the foreground to track user activity.
* This helps prevent sending notifications to users who are already in the app.
*/
updateLastActive: async () => {
const userId = get().user?.id;
if (!userId) return;
try {
await profileSettingsApi.updateLastActive();
} catch (error) {
// Silently fail - this is not critical
console.error("Failed to update last_active_at:", error);
}
// Keep the server's idea of the user's timezone current so the daily
// response limit resets at their local midnight, not 00:00 UTC.
void syncTimezone(userId);
},
}));
} catch (error) {
// Silently fail - this is not critical
console.error("Failed to update last_active_at:", error);
}
// Keep the server's idea of the user's timezone current so the daily
// response limit resets at their local midnight, not 00:00 UTC.
void syncTimezone(userId);
},
}));
+148 -53
View File
@@ -15,6 +15,20 @@ export interface PendingQuestion {
type MatchViewType = 'active' | 'archived' | 'pending' | 'their_turn';
/**
* A refresh that must not disturb what the user is looking at: no spinner, and
* the cached rows stay on screen until the replacement arrives.
*/
export interface FetchOptions { silent?: boolean }
// Overlap protection lives outside the store because the loading flags are now
// presentational — a silent refresh sets none of them and still must not run twice.
// Keyed to the generation that started the request (see `generation` below) rather
// than a bare presence check: a stale generation's entry must not block the next
// account's request for the same key, and a stale generation's `finally` must not
// clear an entry a newer generation just claimed.
const inFlight = new Map<string, number>();
// Rate limit: 12 hours in milliseconds
const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000;
@@ -32,6 +46,8 @@ interface MatchState {
newMatchesCount: number;
totalCount: number | null;
isLoading: boolean;
/** A user-initiated refresh over data already on screen; drives pull-to-refresh only. */
isRefreshing: boolean;
isLoadingMore: boolean;
page: number;
hasMore: boolean;
@@ -48,11 +64,23 @@ interface MatchState {
theirTurnQuestions: PendingQuestion[];
isLoadingTheirTurn: boolean;
currentView: MatchViewType;
/**
* When each view last loaded successfully. Tracked separately from the row
* arrays because an empty view is still a loaded view, and revisiting one
* must not refetch.
*/
loadedAt: Partial<Record<MatchViewType, number>>;
// Nudge state
nudgeCooldownUntil: Date | null;
isNudging: boolean;
/**
* Bumped on every clear (sign-out/account switch). A fetch captures this before
* it starts and checks it again before writing its result, so a response that
* outlives the clear cannot land in the next account's store.
*/
generation: number;
// Methods
fetchMatches: (refresh?: boolean) => Promise<void>;
fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<void>;
markAsSeen: (matchId: string) => Promise<void>;
markAllAsSeen: () => Promise<void>;
addMatch: (match: Match) => void;
@@ -61,13 +89,14 @@ interface MatchState {
// Archive methods
archiveMatch: (matchId: string) => Promise<void>;
unarchiveMatch: (matchId: string) => Promise<void>;
fetchArchivedMatches: () => Promise<void>;
fetchArchivedMatches: (options?: FetchOptions) => Promise<void>;
toggleShowArchived: () => void;
isMatchArchived: (matchId: string) => boolean;
// Pending methods
fetchPendingQuestions: () => Promise<void>;
fetchTheirTurnQuestions: () => Promise<void>;
fetchPendingQuestions: (options?: FetchOptions) => Promise<void>;
fetchTheirTurnQuestions: (options?: FetchOptions) => Promise<void>;
setCurrentView: (view: MatchViewType) => void;
ensureViewLoaded: (view?: MatchViewType) => void;
// Nudge methods
sendNudge: () => Promise<{ success: boolean; notificationSent: boolean }>;
checkNudgeCooldown: () => Promise<void>;
@@ -78,6 +107,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
newMatchesCount: 0,
totalCount: null,
isLoading: false,
isRefreshing: false,
isLoadingMore: false,
page: 0,
hasMore: true,
@@ -94,40 +124,53 @@ export const useMatchStore = create<MatchState>((set, get) => ({
theirTurnQuestions: [],
isLoadingTheirTurn: false,
currentView: 'pending' as MatchViewType,
loadedAt: {},
// Nudge state
nudgeCooldownUntil: null,
isNudging: false,
generation: 0,
fetchMatches: async (refresh = false) => {
const userId = useAuthStore.getState().user?.id;
fetchMatches: async (refresh = false, options = {}) => {
const coupleId = useAuthStore.getState().user?.couple_id;
const myGeneration = get().generation;
// Early return if no couple - user isn't paired yet
if (!coupleId) {
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false });
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false, isRefreshing: false });
return;
}
const state = get();
if (state.isLoading || (state.isLoadingMore && !refresh)) return;
if (inFlight.get('active') === myGeneration || (state.isLoadingMore && !refresh)) return;
// Nothing on screen yet is the only case that warrants blanking the list
// for a spinner; every other refresh leaves the cached rows in place.
const initial = state.loadedAt.active === undefined;
if (refresh) {
set({ isLoading: true, error: null, page: 0, hasMore: true });
set({ isLoading: initial, isRefreshing: !initial && !options.silent, error: null, page: 0, hasMore: true });
} else {
if (!state.hasMore) return;
set({ isLoadingMore: true });
}
inFlight.set('active', myGeneration);
try {
const currentPage = refresh ? 0 : state.page;
const result = await apiClient.get<{ matches: Match[]; totalCount: number }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`);
const result = await apiClient.get<{ matches: Match[]; totalCount: number | null; hasMore?: boolean }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`);
// A sign-out/account switch that happened while this request was in
// flight must not let its response populate the next account's store.
if (get().generation !== myGeneration) return;
const nonArchivedMatches = result.matches;
const totalCount = result.totalCount;
const archivedMatchIds = state.archivedMatchIds;
// The API reports this by reading one row past the page; the length
// comparison is only a fallback for an older server.
const hasMore = result.hasMore ?? nonArchivedMatches.length === BATCH_SIZE;
const loadedAt = { ...state.loadedAt, active: Date.now() };
if (nonArchivedMatches.length === 0) {
if (refresh) {
set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false, archivedMatchIds });
set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, isRefreshing: false, hasMore: false, archivedMatchIds, loadedAt });
} else {
set({ isLoadingMore: false, hasMore: false });
}
@@ -139,7 +182,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
// Calculate new matches count - this is tricky with pagination
// Ideally we should have a separate query for count, but we'll just count in current batch
const newMatchesInBatch = sortedData.filter((m) => m.is_new).length;
set((state) => {
// Deduplicate matches by ID when appending new pages
const mergedMatches = refresh
@@ -154,22 +197,34 @@ export const useMatchStore = create<MatchState>((set, get) => ({
return {
matches: mergedMatches,
newMatchesCount: refresh ? newMatchesInBatch : state.newMatchesCount + newMatchesInBatch,
totalCount: refresh ? totalCount : state.totalCount,
// Later pages no longer carry a total, so keep the one the refresh recorded.
totalCount: refresh ? totalCount ?? state.totalCount : state.totalCount,
isLoading: false,
isRefreshing: false,
isLoadingMore: false,
page: currentPage + 1,
hasMore: nonArchivedMatches.length === BATCH_SIZE,
hasMore,
archivedMatchIds: refresh ? archivedMatchIds : state.archivedMatchIds,
loadedAt,
};
});
} catch (err) {
if (get().generation !== myGeneration) return;
console.error("Error fetching matches:", err);
set({ error: "Failed to load matches", isLoading: false, isLoadingMore: false });
set({ error: "Failed to load matches", isLoading: false, isRefreshing: false, isLoadingMore: false });
} finally {
// An old generation's cleanup must not delete a newer generation's guard.
if (inFlight.get('active') === myGeneration) inFlight.delete('active');
}
},
markAsSeen: async (matchId) => {
const myGeneration = get().generation;
await apiClient.patch("/v1/matches/seen", { ids: [matchId] });
// A clear while this was in flight leaves an empty list behind, and the
// recount below would write that emptiness — and a zero badge — over
// whatever the next account has already loaded.
if (get().generation !== myGeneration) return;
const matches = get().matches.map((m) =>
m.id === matchId ? { ...m, is_new: false } : m
@@ -179,11 +234,13 @@ export const useMatchStore = create<MatchState>((set, get) => ({
},
markAllAsSeen: async () => {
const myGeneration = get().generation;
const newMatches = get().matches.filter((m) => m.is_new);
if (newMatches.length === 0) return;
const newMatchIds = newMatches.map((m) => m.id);
await apiClient.patch("/v1/matches/seen", { ids: newMatchIds });
if (get().generation !== myGeneration) return;
const matches = get().matches.map((m) =>
m.is_new ? { ...m, is_new: false } : m
@@ -210,11 +267,12 @@ export const useMatchStore = create<MatchState>((set, get) => ({
},
clearMatches: () => {
set({
set((state) => ({
matches: [],
newMatchesCount: 0,
totalCount: null,
isLoading: false,
isRefreshing: false,
error: null,
page: 0,
hasMore: true,
@@ -228,9 +286,16 @@ clearMatches: () => {
theirTurnQuestions: [],
isLoadingTheirTurn: false,
currentView: 'pending',
loadedAt: {},
nudgeCooldownUntil: null,
isNudging: false,
});
// Invalidates any request already in flight: its generation check will
// fail and it will not be able to write into the next account's store.
generation: state.generation + 1,
}));
// A request belonging to the signed-out user must not suppress the next
// one: the guard is a duplicate-request check, not a lock on the data.
inFlight.clear();
},
// Archive methods
@@ -240,6 +305,7 @@ clearMatches: () => {
// Optimistically update UI
const state = get();
const myGeneration = state.generation;
const matchToArchive = state.matches.find(m => m.id === matchId);
if (matchToArchive) {
@@ -258,7 +324,11 @@ clearMatches: () => {
await apiClient.put(`/v1/matches/${matchId}/archive`, { archived: true });
} catch (error) {
console.error("Error archiving match:", error);
// Revert on error
// Revert on error — but only into the store the optimistic update was
// applied to. After an account switch the row it would restore belongs
// to the previous couple, and putting it back would show one couple's
// match inside another's list.
if (get().generation !== myGeneration) return;
if (matchToArchive) {
const revertArchivedIds = new Set(get().archivedMatchIds);
revertArchivedIds.delete(matchId);
@@ -279,6 +349,7 @@ clearMatches: () => {
// Optimistically update UI
const state = get();
const myGeneration = state.generation;
const matchToUnarchive = state.archivedMatches.find(m => m.id === matchId);
if (matchToUnarchive) {
@@ -297,7 +368,8 @@ clearMatches: () => {
await apiClient.put(`/v1/matches/${matchId}/archive`, { archived: false });
} catch (error) {
console.error("Error unarchiving match:", error);
// Revert on error
// Revert on error, and only for the account that started it.
if (get().generation !== myGeneration) return;
if (matchToUnarchive) {
const revertArchivedIds = new Set(get().archivedMatchIds);
revertArchivedIds.add(matchId);
@@ -312,34 +384,35 @@ clearMatches: () => {
}
},
fetchArchivedMatches: async () => {
fetchArchivedMatches: async (options = {}) => {
const userId = useAuthStore.getState().user?.id;
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId || !userId) return;
const myGeneration = get().generation;
const state = get();
if (state.isLoadingArchived) return;
set({ isLoadingArchived: true });
if (inFlight.get('archived') === myGeneration) return;
if (!options.silent || state.loadedAt.archived === undefined) set({ isLoadingArchived: true });
inFlight.set('archived', myGeneration);
try {
const result = await apiClient.get<{matches:Match[];totalCount:number}>(`/v1/matches?archived=true&limit=100`);
const result = await apiClient.get<{matches:Match[];totalCount:number | null}>(`/v1/matches?archived=true&limit=100`);
if (get().generation !== myGeneration) return;
const archivedMatches = result.matches;
const archivedIds = archivedMatches.map(match => match.id);
if (archivedMatches.length === 0) {
set({ archivedMatches: [], isLoadingArchived: false });
return;
}
set({
set((current) => ({
archivedMatches,
isLoadingArchived: false,
archivedMatchIds: new Set(archivedIds),
});
loadedAt: { ...current.loadedAt, archived: Date.now() },
}));
} catch (err) {
if (get().generation !== myGeneration) return;
console.error("Error fetching archived matches:", err);
set({ isLoadingArchived: false });
} finally {
if (inFlight.get('archived') === myGeneration) inFlight.delete('archived');
}
},
@@ -350,8 +423,8 @@ clearMatches: () => {
set({ showArchived: newShowArchived });
// Fetch archived matches if switching to archived view and not yet loaded
if (newShowArchived && state.archivedMatches.length === 0) {
get().fetchArchivedMatches();
if (newShowArchived && state.loadedAt.archived === undefined) {
void get().fetchArchivedMatches();
}
},
@@ -360,7 +433,7 @@ clearMatches: () => {
},
// Pending (Your Turn) methods
fetchPendingQuestions: async () => {
fetchPendingQuestions: async (options = {}) => {
const userId = useAuthStore.getState().user?.id;
const coupleId = useAuthStore.getState().user?.couple_id;
@@ -369,22 +442,27 @@ clearMatches: () => {
return;
}
const myGeneration = get().generation;
const state = get();
if (state.isLoadingPending) return;
set({ isLoadingPending: true });
if (inFlight.get('pending') === myGeneration) return;
if (!options.silent || state.loadedAt.pending === undefined) set({ isLoadingPending: true });
inFlight.set('pending', myGeneration);
try {
const { questions: pendingQuestions } = await apiClient.get<{questions:PendingQuestion[]}>("/v1/questions/pending?direction=partner");
set({ pendingQuestions, isLoadingPending: false });
if (get().generation !== myGeneration) return;
set((current) => ({ pendingQuestions, isLoadingPending: false, loadedAt: { ...current.loadedAt, pending: Date.now() } }));
} catch (err) {
if (get().generation !== myGeneration) return;
console.error("Error fetching pending questions:", err);
set({ isLoadingPending: false });
} finally {
if (inFlight.get('pending') === myGeneration) inFlight.delete('pending');
}
},
// Their Turn methods (user answered, partner hasn't yet)
fetchTheirTurnQuestions: async () => {
fetchTheirTurnQuestions: async (options = {}) => {
const userId = useAuthStore.getState().user?.id;
const coupleId = useAuthStore.getState().user?.couple_id;
@@ -393,35 +471,43 @@ clearMatches: () => {
return;
}
const myGeneration = get().generation;
const state = get();
if (state.isLoadingTheirTurn) return;
set({ isLoadingTheirTurn: true });
if (inFlight.get('their_turn') === myGeneration) return;
if (!options.silent || state.loadedAt.their_turn === undefined) set({ isLoadingTheirTurn: true });
inFlight.set('their_turn', myGeneration);
try {
const { questions: theirTurnQuestions } = await apiClient.get<{questions:PendingQuestion[]}>("/v1/questions/pending?direction=mine");
set({ theirTurnQuestions, isLoadingTheirTurn: false });
if (get().generation !== myGeneration) return;
set((current) => ({ theirTurnQuestions, isLoadingTheirTurn: false, loadedAt: { ...current.loadedAt, their_turn: Date.now() } }));
} catch (err) {
if (get().generation !== myGeneration) return;
console.error("Error fetching their turn questions:", err);
set({ isLoadingTheirTurn: false });
} finally {
if (inFlight.get('their_turn') === myGeneration) inFlight.delete('their_turn');
}
},
setCurrentView: (view: MatchViewType) => {
const state = get();
// Update showArchived for backwards compatibility
const showArchived = view === 'archived';
set({ currentView: view, showArchived });
// Fetch data for the selected view if not already loaded
if (view === 'archived' && state.archivedMatches.length === 0) {
get().fetchArchivedMatches();
} else if (view === 'pending' && state.pendingQuestions.length === 0) {
get().fetchPendingQuestions();
} else if (view === 'their_turn' && state.theirTurnQuestions.length === 0) {
get().fetchTheirTurnQuestions();
}
get().ensureViewLoaded(view);
},
/**
* Loads a view once. Emptiness is not evidence of never having loaded, so
* returning to a legitimately empty view costs no request at all.
*/
ensureViewLoaded: (view = get().currentView) => {
if (get().loadedAt[view] !== undefined) return;
if (view === 'archived') void get().fetchArchivedMatches();
else if (view === 'pending') void get().fetchPendingQuestions();
else if (view === 'their_turn') void get().fetchTheirTurnQuestions();
else void get().fetchMatches(true);
},
// Nudge methods
@@ -436,10 +522,14 @@ clearMatches: () => {
return { success: false, notificationSent: false };
}
const myGeneration = state.generation;
set({ isNudging: true });
try {
const data = await appDataApi.sendNudge();
// The cooldown is per-account. Recording the previous account's window
// would either silence the new account's nudge or clear a real cooldown.
if (get().generation !== myGeneration) return { success: false, notificationSent: false };
// Success - update cooldown
const cooldownUntil = data.next_nudge_available_at
@@ -453,6 +543,7 @@ clearMatches: () => {
notificationSent: data.notification_sent ?? false,
};
} catch (err) {
if (get().generation !== myGeneration) return { success: false, notificationSent: false };
if (err instanceof ApiError && err.status === 429) {
const details = err.details as NudgeResponse | undefined;
const cooldownUntil = details?.next_nudge_available_at
@@ -471,8 +562,12 @@ clearMatches: () => {
const userId = useAuthStore.getState().user?.id;
if (!userId) return;
const myGeneration = get().generation;
try {
const profile = await appDataApi.nudgeStatus();
// Read for the previous account: neither its cooldown nor its absence
// says anything about whether this one may nudge.
if (get().generation !== myGeneration) return;
if (!profile.last_nudge_sent_at) {
set({ nudgeCooldownUntil: null });
+106 -81
View File
@@ -1,85 +1,110 @@
import { create } from "zustand";
import { create } from "zustand";
import { chatApi } from "../lib/chatApi";
import type { Database } from "@/types/supabase";
import { useAuthStore } from "./authStore";
import { useMatchStore } from "./matchStore";
type Message = Database["public"]["Tables"]["messages"]["Row"];
interface MessageWithMatch extends Message {
match?: {
id: string;
question: {
text: string;
};
};
}
interface MessageState {
unreadCount: number;
lastMessage: MessageWithMatch | null;
activeMatchId: string | null; // Track which chat is currently open
fetchUnreadCount: () => Promise<void>;
addMessage: (message: MessageWithMatch) => void;
clearLastMessage: () => void;
setActiveMatchId: (matchId: string | null) => void;
markMatchMessagesAsRead: (matchId: string) => Promise<void>;
clearMessages: () => void;
}
export const useMessageStore = create<MessageState>((set, get) => ({
unreadCount: 0,
lastMessage: null,
activeMatchId: null,
fetchUnreadCount: async () => {
const userId = useAuthStore.getState().user?.id;
if (!userId) {
set({ unreadCount: 0 });
return;
}
import type { Database } from "@/types/supabase";
import { useAuthStore } from "./authStore";
import { useMatchStore } from "./matchStore";
type Message = Database["public"]["Tables"]["messages"]["Row"];
interface MessageWithMatch extends Message {
match?: {
id: string;
question: {
text: string;
};
};
}
interface MessageState {
unreadCount: number;
lastMessage: MessageWithMatch | null;
activeMatchId: string | null; // Track which chat is currently open
/**
* Bumped by `clearMessages` on sign-out or a couple change, so a reply that
* outlives the account that asked for it cannot write into the next one's badge.
*/
generation: number;
fetchUnreadCount: () => Promise<void>;
/** Applies a count the sync summary already reported, without a second request. */
setUnreadCount: (total: number) => void;
addMessage: (message: MessageWithMatch) => void;
clearLastMessage: () => void;
setActiveMatchId: (matchId: string | null) => void;
markMatchMessagesAsRead: (matchId: string) => Promise<void>;
clearMessages: () => void;
}
export const useMessageStore = create<MessageState>((set, get) => ({
unreadCount: 0,
lastMessage: null,
activeMatchId: null,
generation: 0,
fetchUnreadCount: async () => {
const userId = useAuthStore.getState().user?.id;
if (!userId) {
set({ unreadCount: 0 });
return;
}
const myGeneration = get().generation;
const { total } = await chatApi.unread();
// This total counts one account's messages; after a clear it would show
// up as the next account's badge.
if (get().generation !== myGeneration) return;
set({ unreadCount: total });
},
addMessage: (message) => {
const userId = useAuthStore.getState().user?.id;
const activeMatchId = get().activeMatchId;
// Only show notification if message is from partner and not in the active chat
if (message.user_id !== userId && message.match_id !== activeMatchId) {
set((state) => ({
unreadCount: state.unreadCount + 1,
lastMessage: message,
}));
}
},
clearLastMessage: () => {
set({ lastMessage: null });
},
setActiveMatchId: (matchId) => {
set({ activeMatchId: matchId });
},
markMatchMessagesAsRead: async (matchId) => {
const userId = useAuthStore.getState().user?.id;
if (!userId) return;
},
setUnreadCount: (total) => {
set({ unreadCount: Math.max(0, total) });
},
addMessage: (message) => {
const userId = useAuthStore.getState().user?.id;
const activeMatchId = get().activeMatchId;
// Only show notification if message is from partner and not in the active chat
if (message.user_id !== userId && message.match_id !== activeMatchId) {
set((state) => ({
unreadCount: state.unreadCount + 1,
lastMessage: message,
}));
}
},
clearLastMessage: () => {
set({ lastMessage: null });
},
setActiveMatchId: (matchId) => {
set({ activeMatchId: matchId });
},
markMatchMessagesAsRead: async (matchId) => {
const userId = useAuthStore.getState().user?.id;
if (!userId) return;
const myGeneration = get().generation;
const { updated: unreadInMatch } = await chatApi.markRead(matchId);
// Refetch global unread count
await get().fetchUnreadCount();
// Sync per-match unread count in matchStore
if (unreadInMatch > 0) {
useMatchStore.getState().updateMatchUnreadCount(matchId, -unreadInMatch);
}
},
clearMessages: () => {
set({ unreadCount: 0, lastMessage: null, activeMatchId: null });
},
}));
if (unreadInMatch <= 0) return;
// Those messages belonged to a couple that is no longer loaded, and the
// match they were in is not in the current list, so subtracting them now
// would take the old badge off the new account's.
if (get().generation !== myGeneration) return;
// The server just told us exactly how many it cleared, so subtract instead
// of spending a request to re-read the total; the sync summary corrects
// any drift on its next pass.
set((state) => ({ unreadCount: Math.max(0, state.unreadCount - unreadInMatch) }));
useMatchStore.getState().updateMatchUnreadCount(matchId, -unreadInMatch);
},
clearMessages: () => {
set((state) => ({
unreadCount: 0, lastMessage: null, activeMatchId: null,
// Invalidates any request already in flight for the account being left.
generation: state.generation + 1,
}));
},
}));
+68 -6
View File
@@ -4,6 +4,12 @@ import { Events } from "../lib/analytics";
import type { QuestionPack, Category } from "@/types";
import { useAuthStore } from "./authStore";
// The catalog load fans out into three requests, so a second caller arriving mid-flight
// must wait for the first rather than triple the cost. Keyed to the generation that
// started the request: a stale generation's entry must not block the next account's
// request for the same key, and its `finally` must not clear a newer generation's guard.
const inFlight = new Map<string, number>();
// Progress data for a pack
export interface PackProgressData {
totalQuestions: number;
@@ -16,10 +22,23 @@ interface PacksState {
enabledPackIds: string[];
packProgress: Map<string, PackProgressData>;
isLoading: boolean;
/**
* When the catalog, enabled set and progress were last read together. Null means
* never — which is not the same as "empty", so a couple with no packs enabled
* stops refetching after the first load.
*/
catalogLoadedAt: number | null;
/** Tracked separately because the swipe screen loads the enabled set on its own. */
enabledPacksLoadedAt: number | null;
/** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */
generation: number;
fetchPacks: () => Promise<void>;
fetchEnabledPacks: () => Promise<void>;
fetchPackProgress: () => Promise<void>;
ensurePacksLoaded: () => Promise<void>;
ensureEnabledPacksLoaded: () => Promise<void>;
/** Marks the cached catalog stale without spending a request; the next ensure reloads it. */
invalidatePacks: () => void;
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
clearPacks: () => void;
getPackProgress: (packId: string) => PackProgressData | undefined;
@@ -31,38 +50,61 @@ export const usePacksStore = create<PacksState>((set, get) => ({
enabledPackIds: [],
packProgress: new Map(),
isLoading: false,
catalogLoadedAt: null,
enabledPacksLoadedAt: null,
generation: 0,
fetchPacks: async () => {
const myGeneration = get().generation;
if (inFlight.get('catalog') === myGeneration) return;
inFlight.set('catalog', myGeneration);
set({ isLoading: true });
try {
const catalog = await apiClient.get<{ categories: Category[]; packs: QuestionPack[] }>(
'/v1/packs?showAllIntensities=false',
);
if (get().generation !== myGeneration) return;
set({ packs: catalog.packs, categories: catalog.categories });
await get().fetchEnabledPacks();
await get().fetchPackProgress();
if (get().generation !== myGeneration) return;
set({ catalogLoadedAt: Date.now() });
} finally {
set({ isLoading: false });
if (inFlight.get('catalog') === myGeneration) inFlight.delete('catalog');
if (get().generation === myGeneration) set({ isLoading: false });
}
},
ensurePacksLoaded: async () => {
if (get().catalogLoadedAt !== null) return;
await get().fetchPacks();
},
invalidatePacks: () => {
set({ catalogLoadedAt: null });
},
fetchEnabledPacks: async () => {
const coupleId = useAuthStore.getState().user?.couple_id;
const myGeneration = get().generation;
if (!coupleId) {
set({ enabledPackIds: [] });
set({ enabledPackIds: [], enabledPacksLoadedAt: null });
return;
}
const result = await apiClient.get<{ enabledPackIds: string[] }>("/v1/me/enabled-packs");
set({ enabledPackIds: result.enabledPackIds });
if (get().generation !== myGeneration) return;
set({ enabledPackIds: result.enabledPackIds, enabledPacksLoadedAt: Date.now() });
},
ensureEnabledPacksLoaded: async () => {
// Only fetch if not already loaded - lightweight check for swipe screen
// Only fetch if not already loaded - lightweight check for swipe screen.
// Keyed off the load marker, not the array: "no packs enabled" is a real
// answer and must not be retried on every visit.
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId) return;
if (get().enabledPackIds.length === 0) {
if (get().enabledPacksLoadedAt === null) {
await get().fetchEnabledPacks();
}
},
@@ -73,6 +115,7 @@ export const usePacksStore = create<PacksState>((set, get) => ({
return { success: false, reason: "no_couple" };
}
const myGeneration = get().generation;
const isEnabled = get().enabledPackIds.includes(packId);
const newValue = !isEnabled;
@@ -88,9 +131,16 @@ export const usePacksStore = create<PacksState>((set, get) => ({
`/v1/me/enabled-packs/${packId}`,
{ enabled: newValue },
);
// These ids belong to the couple that asked; a clear while the request
// was open means they would land in a different couple's selection.
if (get().generation !== myGeneration) return { success: false, reason: "stale" };
set({ enabledPackIds: result.enabledPackIds });
} catch (error) {
console.error("Error toggling pack:", error);
// The optimistic update this reverts was cleared along with everything
// else, so reverting now would only push a stale pack id into the next
// couple's list.
if (get().generation !== myGeneration) return { success: false, reason: "stale" };
set({ enabledPackIds: isEnabled
? [...new Set([...get().enabledPackIds, packId])]
: get().enabledPackIds.filter(id => id !== packId) });
@@ -108,6 +158,7 @@ export const usePacksStore = create<PacksState>((set, get) => ({
},
fetchPackProgress: async () => {
const myGeneration = get().generation;
if (!useAuthStore.getState().user?.id) {
set({ packProgress: new Map() });
return;
@@ -115,6 +166,7 @@ export const usePacksStore = create<PacksState>((set, get) => ({
const result = await apiClient.get<{ progress: Array<PackProgressData & { packId: string }> }>(
"/v1/me/pack-progress",
);
if (get().generation !== myGeneration) return;
const progressMap = new Map<string, PackProgressData>();
result.progress.forEach(({ packId, totalQuestions, answeredQuestions }) => {
progressMap.set(packId, { totalQuestions, answeredQuestions });
@@ -128,6 +180,16 @@ export const usePacksStore = create<PacksState>((set, get) => ({
},
clearPacks: () => {
set({ enabledPackIds: [], packProgress: new Map() });
set((state) => ({
enabledPackIds: [],
packProgress: new Map(),
catalogLoadedAt: null,
enabledPacksLoadedAt: null,
// Invalidates any request already in flight for the signed-out user.
generation: state.generation + 1,
}));
// See matchStore.clearMatches: the previous user's in-flight catalog read
// must not block the next user's.
inFlight.clear();
},
}));
+63 -7
View File
@@ -36,16 +36,33 @@ export type DateSortOrder = "newest" | "oldest";
const BATCH_SIZE = 20;
// Overlap protection independent of the presentational flags: a refresh over cached
// rows raises `isRefreshing`, not `isLoading`, and still must not run twice. Keyed to
// the generation that started the request: a stale generation's entry must not block
// the next account's request, and its `finally` must not clear a newer generation's guard.
const inFlight = new Map<string, number>();
interface ResponsesState {
responses: ResponseWithQuestion[];
/** First load only. A refresh over rows already on screen must not blank the list. */
isLoading: boolean;
/** Refresh over cached rows; drives pull-to-refresh only. */
isRefreshing: boolean;
isLoadingMore: boolean;
groupBy: GroupByOption;
dateSortOrder: DateSortOrder;
hasMore: boolean;
page: number;
totalCount: number | null;
/** Null until the first successful read; an empty history is a loaded answer. */
loadedAt: number | null;
/** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */
generation: number;
fetchResponses: (refresh?: boolean) => Promise<void>;
/** Loads the first page once; revisiting the screen reuses what is cached. */
ensureResponsesLoaded: () => Promise<void>;
/** Marks the cache stale without spending a request; the next ensure reloads it. */
invalidateResponses: () => void;
updateResponse: (
questionId: string,
newAnswer: AnswerType,
@@ -61,12 +78,15 @@ interface ResponsesState {
export const useResponsesStore = create<ResponsesState>((set, get) => ({
responses: [],
isLoading: false,
isRefreshing: false,
isLoadingMore: false,
groupBy: "date",
dateSortOrder: "newest",
hasMore: true,
page: 0,
totalCount: null,
loadedAt: null,
generation: 0,
fetchResponses: async (refresh = false) => {
const userId = useAuthStore.getState().user?.id;
@@ -74,25 +94,31 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
if (!userId || !coupleId) return;
const myGeneration = get().generation;
const state = get();
if (state.isLoading || (state.isLoadingMore && !refresh)) return;
if (inFlight.get('responses') === myGeneration || (state.isLoadingMore && !refresh)) return;
// Nothing on screen yet is the only case that warrants a full-screen spinner;
// every other refresh leaves the cached rows in place until replacements land.
const initial = state.loadedAt === null;
if (refresh) {
set({ isLoading: true, page: 0, hasMore: true });
set({ isLoading: initial, isRefreshing: !initial, page: 0, hasMore: true });
} else {
if (!state.hasMore) return;
set({ isLoadingMore: true });
}
inFlight.set('responses', myGeneration);
try {
const currentPage = refresh ? 0 : state.page;
const result = await apiClient.get<{responses: ResponseWithQuestion[];totalCount:number}>(`/v1/me/responses?page=${currentPage}&limit=${BATCH_SIZE}`);
if (get().generation !== myGeneration) return;
const responses = result.responses;
const totalCount = result.totalCount;
if (!responses || responses.length === 0) {
if (refresh) {
set({ responses: [], totalCount: totalCount ?? 0, isLoading: false, hasMore: false });
set({ responses: [], totalCount: totalCount ?? 0, isLoading: false, isRefreshing: false, hasMore: false, loadedAt: Date.now() });
} else {
set({ isLoadingMore: false, hasMore: false });
}
@@ -100,29 +126,44 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
}
const transformedResponses = responses;
set((state) => ({
responses: refresh
? transformedResponses
: [...state.responses, ...transformedResponses],
totalCount: refresh ? totalCount : state.totalCount,
isLoading: false,
isRefreshing: false,
isLoadingMore: false,
page: currentPage + 1,
hasMore: responses.length === BATCH_SIZE
hasMore: responses.length === BATCH_SIZE,
loadedAt: Date.now(),
}));
} catch (error) {
if (get().generation !== myGeneration) return;
console.error("Error in fetchResponses:", error);
set({ isLoading: false, isLoadingMore: false });
set({ isLoading: false, isRefreshing: false, isLoadingMore: false });
} finally {
if (inFlight.get('responses') === myGeneration) inFlight.delete('responses');
}
},
ensureResponsesLoaded: async () => {
if (get().loadedAt !== null) return;
await get().fetchResponses(true);
},
invalidateResponses: () => {
set({ loadedAt: null });
},
updateResponse: async (
questionId: string,
newAnswer: AnswerType,
confirmDelete = false,
responseData?: ResponseData | null
): Promise<UpdateResponseResult> => {
const myGeneration = get().generation;
try {
const result = await apiClient.patch<UpdateResponseResult>(`/v1/responses/${questionId}`, {
new_answer: newAnswer,
@@ -130,6 +171,13 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
response_data: responseData,
});
// Rows are keyed by a question id from the shared catalogue, so once the
// store has been cleared that same id can name a different account's
// answer. Hand the caller the server's result — it is true of whoever
// asked — but write nothing, and refresh no matches, for the account
// that is loaded now.
if (get().generation !== myGeneration) return result;
// If update was successful (not just requiring confirmation), update local state
if (result.success && !result.requires_confirmation) {
// Update local response state
@@ -191,7 +239,15 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
},
clearResponses: () => {
set({ responses: [], isLoading: false, groupBy: "date", dateSortOrder: "newest", page: 0, hasMore: true, isLoadingMore: false, totalCount: null });
set((state) => ({
responses: [], isLoading: false, isRefreshing: false, groupBy: "date", dateSortOrder: "newest",
page: 0, hasMore: true, isLoadingMore: false, totalCount: null, loadedAt: null,
// Invalidates any request already in flight for the signed-out user.
generation: state.generation + 1,
}));
// See matchStore.clearMatches: the previous user's in-flight page must not
// block the next user's.
inFlight.clear();
},
}));
+109 -61
View File
@@ -1,62 +1,110 @@
import { create } from "zustand";
import { create } from "zustand";
import { apiClient } from "../lib/apiClient";
import { useAuthStore } from "./authStore";
/**
* Resolved from the signed-in partner's point of view by the API. The stored row is
* positional (user1/user2 by member id), which a client cannot act on without
* re-deriving that ordering, so the endpoint answers in you/partner terms instead.
*/
export interface CoupleStreak {
couple_id: string;
current_streak: number;
longest_streak: number;
last_active_date: string | null;
last_completed_date: string | null;
you_answered_today: boolean;
partner_answered_today: boolean;
partner_name: string | null;
/** The couple's shared IANA zone, which is the day boundary the streak is counted in. */
timezone: string;
streak_celebrated_at: number;
created_at: string;
updated_at: string;
}
interface StreakState {
streak: CoupleStreak | null;
isLoading: boolean;
error: string | null;
// Actions
fetchStreak: () => Promise<void>;
clearStreak: () => void;
}
export const useStreakStore = create<StreakState>((set) => ({
streak: null,
isLoading: false,
error: null,
fetchStreak: async () => {
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId) {
set({ streak: null, isLoading: false });
return;
}
set({ isLoading: true, error: null });
try {
const { streak } = await apiClient.get<{streak: CoupleStreak | null}>("/v1/me/streak");
set({ streak, isLoading: false });
} catch (error) {
console.error("Error fetching streak:", error);
set({ error: "Failed to load streak", isLoading: false });
}
},
clearStreak: () => {
set({ streak: null, isLoading: false, error: null });
},
}));
import { useAuthStore } from "./authStore";
/**
* Resolved from the signed-in partner's point of view by the API. The stored row is
* positional (user1/user2 by member id), which a client cannot act on without
* re-deriving that ordering, so the endpoint answers in you/partner terms instead.
*/
export interface CoupleStreak {
couple_id: string;
current_streak: number;
longest_streak: number;
last_active_date: string | null;
last_completed_date: string | null;
you_answered_today: boolean;
partner_answered_today: boolean;
partner_name: string | null;
/** The couple's shared IANA zone, which is the day boundary the streak is counted in. */
timezone: string;
streak_celebrated_at: number;
created_at: string;
updated_at: string;
}
interface StreakState {
streak: CoupleStreak | null;
isLoading: boolean;
error: string | null;
/**
* Null until the first successful read. A couple with no streak yet resolves to a
* null `streak`, which is a loaded answer rather than a reason to ask again.
*/
loadedAt: number | null;
/** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */
generation: number;
// Actions
fetchStreak: () => Promise<void>;
/** Loads the streak once; later callers reuse the cached value. */
ensureStreakLoaded: () => Promise<void>;
/** Silent re-read of an already-loaded streak; leaves the cached value on screen if it fails. */
refreshStreak: () => Promise<void>;
clearStreak: () => void;
}
// The streak is rendered by a badge, the home screen and the swipe screen, so
// concurrent mounts must share one request rather than each issuing their own.
// Tagged with the generation that started it: clearing/signing out bumps the
// generation, so a stale response can no longer write into the next account's
// store, and a stale `finally` cannot clear a newer generation's guard.
let inFlight: { generation: number; promise: Promise<void> } | null = null;
export const useStreakStore = create<StreakState>((set, get) => ({
streak: null,
isLoading: false,
error: null,
loadedAt: null,
generation: 0,
fetchStreak: async () => {
const coupleId = useAuthStore.getState().user?.couple_id;
const myGeneration = get().generation;
if (!coupleId) {
set({ streak: null, isLoading: false, loadedAt: null });
return;
}
if (inFlight && inFlight.generation === myGeneration) return inFlight.promise;
set({ isLoading: true, error: null });
const promise = (async () => {
try {
const { streak } = await apiClient.get<{streak: CoupleStreak | null}>("/v1/me/streak");
if (get().generation !== myGeneration) return;
set({ streak, isLoading: false, loadedAt: Date.now() });
} catch (error) {
if (get().generation !== myGeneration) return;
console.error("Error fetching streak:", error);
set({ error: "Failed to load streak", isLoading: false });
} finally {
if (inFlight?.generation === myGeneration) inFlight = null;
}
})();
inFlight = { generation: myGeneration, promise };
return promise;
},
ensureStreakLoaded: async () => {
if (get().loadedAt !== null) return;
await get().fetchStreak();
},
refreshStreak: async () => {
// Only silently refresh a streak the UI has actually loaded; an unopened
// screen has nothing cached to keep honest.
if (get().loadedAt === null) return;
await get().fetchStreak();
},
clearStreak: () => {
set((state) => ({
streak: null, isLoading: false, error: null, loadedAt: null,
// Invalidates any request already in flight for the signed-out user.
generation: state.generation + 1,
}));
},
}));