diff --git a/apps/mobile/app/(app)/matches.tsx b/apps/mobile/app/(app)/matches.tsx index 527d82e..4811723 100755 --- a/apps/mobile/app/(app)/matches.tsx +++ b/apps/mobile/app/(app)/matches.tsx @@ -69,10 +69,12 @@ export default function MatchesScreen() { checkNudgeCooldown, } = useMatchStore(); const { user, couple, partner } = useAuthStore(); + const userId = user?.id; const router = useRouter(); const { width } = useWindowDimensions(); const isWideScreen = width > MAX_CONTENT_WIDTH; const [showTutorial, setShowTutorial] = useState(false); + const tutorialDismissedRef = useRef(false); const [nudgeFeedback, setNudgeFeedback] = useState(null); const [headerHeight, setHeaderHeight] = useState(130); @@ -104,21 +106,30 @@ export default function MatchesScreen() { // Check if tutorial should be shown when screen is focused or matches change useFocusEffect( useCallback(() => { + let isActive = true; + const checkTutorial = async () => { - if (matches.length > 0) { - const seen = await hasSeenMatchesTutorial(); - if (!seen) { + if (matches.length > 0 && userId) { + const seen = await hasSeenMatchesTutorial(userId); + if (isActive && !tutorialDismissedRef.current && !seen) { setShowTutorial(true); } } }; - checkTutorial(); - }, [matches.length]) + void checkTutorial(); + + return () => { + isActive = false; + }; + }, [matches.length, userId]) ); const handleTutorialComplete = async () => { - await markMatchesTutorialSeen(); + if (!userId) return; + + tutorialDismissedRef.current = true; setShowTutorial(false); + await markMatchesTutorialSeen(userId); }; // Load whichever view is showing, once. Returning to this tab reuses what is diff --git a/apps/mobile/src/__tests__/lib/matchesTutorialSeen.test.ts b/apps/mobile/src/__tests__/lib/matchesTutorialSeen.test.ts new file mode 100644 index 0000000..15ac230 --- /dev/null +++ b/apps/mobile/src/__tests__/lib/matchesTutorialSeen.test.ts @@ -0,0 +1,45 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import * as SecureStore from "expo-secure-store"; +import { + hasSeenMatchesTutorial, + markMatchesTutorialSeen, + resetMatchesTutorial, +} from "@/lib/matchesTutorialSeen"; + +jest.mock("expo-secure-store", () => ({ + getItemAsync: jest.fn(), +})); + +describe("matchesTutorialSeen", () => { + beforeEach(async () => { + await AsyncStorage.clear(); + jest.mocked(SecureStore.getItemAsync).mockReset(); + jest.mocked(SecureStore.getItemAsync).mockResolvedValue(null); + }); + + it("persists a dismissal for the signed-in account", async () => { + await markMatchesTutorialSeen("user-1"); + + await expect(hasSeenMatchesTutorial("user-1")).resolves.toBe(true); + }); + + it("does not dismiss the tutorial for another account on the same device", async () => { + await markMatchesTutorialSeen("user-1"); + + await expect(hasSeenMatchesTutorial("user-2")).resolves.toBe(false); + }); + + it("migrates an existing legacy dismissal into durable account storage", async () => { + jest.mocked(SecureStore.getItemAsync).mockResolvedValue("true"); + + await expect(hasSeenMatchesTutorial("user-1")).resolves.toBe(true); + await expect(AsyncStorage.getItem("matches_tutorial_seen_user-1")).resolves.toBe("true"); + }); + + it("lets the development reset show the tutorial for the current account again", async () => { + await markMatchesTutorialSeen("user-1"); + await resetMatchesTutorial("user-1"); + + await expect(hasSeenMatchesTutorial("user-1")).resolves.toBe(false); + }); +}); diff --git a/apps/mobile/src/features/profile/ProfileScreen.tsx b/apps/mobile/src/features/profile/ProfileScreen.tsx index 6593636..fbeddb8 100644 --- a/apps/mobile/src/features/profile/ProfileScreen.tsx +++ b/apps/mobile/src/features/profile/ProfileScreen.tsx @@ -223,7 +223,8 @@ export function ProfileScreen() { label="Reset Matches Tutorial" description="Show the tutorial again" onPress={async () => { - await resetMatchesTutorial(); + if (!user?.id) return; + await resetMatchesTutorial(user.id); Alert.alert("Success", "Matches tutorial has been reset."); }} /> diff --git a/apps/mobile/src/lib/matchesTutorialSeen.ts b/apps/mobile/src/lib/matchesTutorialSeen.ts index 4ff4d20..f18ee3f 100644 --- a/apps/mobile/src/lib/matchesTutorialSeen.ts +++ b/apps/mobile/src/lib/matchesTutorialSeen.ts @@ -1,51 +1,70 @@ -import * as SecureStore from "expo-secure-store"; -import { Platform } from "react-native"; - -const STORAGE_KEY = "matches_tutorial_seen"; - -async function getStorage(): Promise { - try { - let data: string | null = null; - - if (Platform.OS === "web") { - if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") { - data = window.localStorage.getItem(STORAGE_KEY); - } - } else { - data = await SecureStore.getItemAsync(STORAGE_KEY); - } - - return data === "true"; - } catch (error) { - console.error("Error reading matches tutorial state:", error); - } - return false; -} - -async function setStorage(seen: boolean): Promise { - try { - const data = seen ? "true" : "false"; - - if (Platform.OS === "web") { - if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") { - window.localStorage.setItem(STORAGE_KEY, data); - } - } else { - await SecureStore.setItemAsync(STORAGE_KEY, data); - } - } catch (error) { - console.error("Error saving matches tutorial state:", error); - } -} - -export async function hasSeenMatchesTutorial(): Promise { - return getStorage(); -} - -export async function markMatchesTutorialSeen(): Promise { - await setStorage(true); -} - -export async function resetMatchesTutorial(): Promise { - await setStorage(false); -} +import AsyncStorage from "@react-native-async-storage/async-storage"; +import * as SecureStore from "expo-secure-store"; +import { Platform } from "react-native"; + +const STORAGE_KEY_PREFIX = "matches_tutorial_seen"; +const LEGACY_STORAGE_KEY = "matches_tutorial_seen"; + +const buildKey = (userId: string): string => `${STORAGE_KEY_PREFIX}_${userId}`; + +async function getStorage(userId: string): Promise { + try { + const key = buildKey(userId); + + if (Platform.OS === "web") { + if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") { + return window.localStorage.getItem(key) === "true"; + } + + return false; + } + + const storedValue = await AsyncStorage.getItem(key); + if (storedValue !== null) { + return storedValue === "true"; + } + + // Keep dismissals made before the account-scoped AsyncStorage key was + // introduced. Future reads use the durable app storage key above. + const legacyValue = await SecureStore.getItemAsync(LEGACY_STORAGE_KEY); + if (legacyValue === "true") { + await AsyncStorage.setItem(key, "true"); + return true; + } + + return false; + } catch (error) { + console.error("Error reading matches tutorial state:", error); + } + return false; +} + +async function setStorage(userId: string, seen: boolean): Promise { + try { + const data = seen ? "true" : "false"; + const key = buildKey(userId); + + if (Platform.OS === "web") { + if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") { + window.localStorage.setItem(key, data); + } + } else { + await AsyncStorage.setItem(key, data); + } + } catch (error) { + console.error("Error saving matches tutorial state:", error); + } +} + +/** The Matches tutorial is shown at most once per signed-in account. */ +export async function hasSeenMatchesTutorial(userId: string): Promise { + return getStorage(userId); +} + +export async function markMatchesTutorialSeen(userId: string): Promise { + await setStorage(userId, true); +} + +export async function resetMatchesTutorial(userId: string): Promise { + await setStorage(userId, false); +}