diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index acbb491..75873d9 100755 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -21,7 +21,7 @@ import { handleNotificationResponse, } from "../src/lib/notifications"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef } from "react"; import { Platform, View, AppState, AppStateStatus, Modal, Text, StyleSheet, Pressable, TouchableOpacity } from "react-native"; // Suppress useLayoutEffect warning on server-side rendering @@ -51,7 +51,7 @@ import * as Linking from "expo-linking"; import { useAuthStore } from "../src/store"; import { authClient } from "../src/lib/authClient"; import { colors, spacing, radius, typography } from "../src/theme"; -import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen"; +import { useGuestAccountWarning } from "../src/hooks/useGuestAccountWarning"; import { parseInviteLinkCode } from "../src/lib/inviteLink"; import { stashPendingInviteCode } from "../src/lib/pendingInviteCode"; import { needsOnboarding } from "../src/constants/onboarding"; @@ -59,18 +59,17 @@ import { needsOnboarding } from "../src/constants/onboarding"; const queryClient = new QueryClient(); export default function RootLayout() { - const { fetchUser, setUser, isAuthenticated, isAnonymous } = useAuthStore(); + const { fetchUser, setUser, user, isAuthenticated, isAnonymous } = useAuthStore(); const pathname = usePathname(); const appState = useRef(AppState.currentState); const analyticsInitialized = useRef(false); - const [showGuestWarning, setShowGuestWarning] = useState(false); - const [checkingGuestWarning, setCheckingGuestWarning] = useState(false); - - const dismissGuestWarning = async () => { - await markGuestAccountWarningSeen(); - setShowGuestWarning(false); - }; + const { visible: showGuestWarning, dismiss: dismissGuestWarning } = useGuestAccountWarning({ + isAuthenticated, + isAnonymous, + userId: user?.id, + pathname, + }); // Initialize analytics after native modules are ready @@ -90,22 +89,6 @@ export default function RootLayout() { } }, [pathname]); - // Warn guest users (first time only) that accounts are not recoverable - useEffect(() => { - if (!isAuthenticated || !isAnonymous) return; - if (checkingGuestWarning) return; - if (pathname?.startsWith("/(auth)")) return; - - setCheckingGuestWarning(true); - hasSeenGuestAccountWarning() - .then((seen) => { - if (!seen) { - setShowGuestWarning(true); - } - }) - .finally(() => setCheckingGuestWarning(false)); - }, [isAuthenticated, isAnonymous, pathname, checkingGuestWarning]); - // Track warm starts when app comes back from background useEffect(() => { const subscription = AppState.addEventListener("change", (nextAppState: AppStateStatus) => { @@ -313,22 +296,18 @@ export default function RootLayout() { { - await markGuestAccountWarningSeen(); - setShowGuestWarning(false); - router.push("/(app)/settings/save-account" as any); + style={styles.guestWarningPrimary} + onPress={async () => { + await dismissGuestWarning(); + router.push("/(app)/settings/save-account" as any); }} activeOpacity={0.8} > Save account { - await markGuestAccountWarningSeen(); - setShowGuestWarning(false); - }} + style={styles.guestWarningSecondary} + onPress={dismissGuestWarning} activeOpacity={0.8} > Not now diff --git a/apps/mobile/src/__tests__/hooks/useGuestAccountWarning.test.ts b/apps/mobile/src/__tests__/hooks/useGuestAccountWarning.test.ts new file mode 100644 index 0000000..d8939fd --- /dev/null +++ b/apps/mobile/src/__tests__/hooks/useGuestAccountWarning.test.ts @@ -0,0 +1,68 @@ +import { act, renderHook, waitFor } from "@testing-library/react-native"; +import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "@/lib/guestAccountWarningSeen"; +import { useGuestAccountWarning } from "@/hooks/useGuestAccountWarning"; + +jest.mock("@/lib/guestAccountWarningSeen", () => ({ + hasSeenGuestAccountWarning: jest.fn(), + markGuestAccountWarningSeen: jest.fn(), +})); + +const options = { + isAuthenticated: true, + isAnonymous: true, + userId: "guest-1", + pathname: "/(app)/pairing", +}; + +describe("useGuestAccountWarning", () => { + beforeEach(() => { + jest.mocked(hasSeenGuestAccountWarning).mockReset(); + jest.mocked(markGuestAccountWarningSeen).mockReset(); + }); + + it("does not reopen after a dismissal while the durable write is pending", async () => { + let finishMarking: (() => void) | undefined; + jest.mocked(hasSeenGuestAccountWarning).mockResolvedValue(false); + jest.mocked(markGuestAccountWarningSeen).mockImplementation(() => new Promise((resolve) => { + finishMarking = resolve; + })); + + const { result, rerender } = renderHook((props: typeof options) => useGuestAccountWarning(props), { initialProps: options }); + + await waitFor(() => expect(result.current.visible).toBe(true)); + + let dismissal: Promise | undefined; + act(() => { + dismissal = result.current.dismiss(); + }); + + expect(result.current.visible).toBe(false); + + rerender({ ...options, pathname: "/(app)/settings" }); + expect(result.current.visible).toBe(false); + expect(hasSeenGuestAccountWarning).toHaveBeenCalledTimes(1); + + finishMarking?.(); + await act(async () => { + await dismissal; + }); + }); + + it("checks once per anonymous account and can warn a later guest account", async () => { + jest.mocked(hasSeenGuestAccountWarning).mockResolvedValue(false); + + const { result, rerender } = renderHook((props: typeof options) => useGuestAccountWarning(props), { initialProps: options }); + + await waitFor(() => expect(result.current.visible).toBe(true)); + rerender({ ...options, pathname: "/(app)/settings" }); + expect(hasSeenGuestAccountWarning).toHaveBeenCalledTimes(1); + + await act(async () => { + await result.current.dismiss(); + }); + + rerender({ ...options, userId: "guest-2" }); + await waitFor(() => expect(hasSeenGuestAccountWarning).toHaveBeenCalledWith("guest-2")); + await waitFor(() => expect(result.current.visible).toBe(true)); + }); +}); diff --git a/apps/mobile/src/__tests__/lib/guestAccountWarningSeen.test.ts b/apps/mobile/src/__tests__/lib/guestAccountWarningSeen.test.ts new file mode 100644 index 0000000..2568924 --- /dev/null +++ b/apps/mobile/src/__tests__/lib/guestAccountWarningSeen.test.ts @@ -0,0 +1,20 @@ +import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "@/lib/guestAccountWarningSeen"; +import AsyncStorage from "@react-native-async-storage/async-storage"; + +describe("guestAccountWarningSeen", () => { + beforeEach(() => { + return AsyncStorage.clear(); + }); + + it("persists a dismissal for that anonymous account", async () => { + await markGuestAccountWarningSeen("guest-1"); + + await expect(hasSeenGuestAccountWarning("guest-1")).resolves.toBe(true); + }); + + it("does not suppress the recovery warning for a later guest account", async () => { + await markGuestAccountWarningSeen("guest-1"); + + await expect(hasSeenGuestAccountWarning("guest-2")).resolves.toBe(false); + }); +}); diff --git a/apps/mobile/src/hooks/useGuestAccountWarning.ts b/apps/mobile/src/hooks/useGuestAccountWarning.ts new file mode 100644 index 0000000..8853aec --- /dev/null +++ b/apps/mobile/src/hooks/useGuestAccountWarning.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../lib/guestAccountWarningSeen"; + +type GuestAccountWarningOptions = { + isAuthenticated: boolean; + isAnonymous: boolean; + userId: string | null | undefined; + pathname: string | null; +}; + +/** + * Shows the account recovery warning at most once for each anonymous account + * during a mounted app session. A dismissal is applied in memory before the + * durable write completes, so a slow storage operation cannot reopen it. + */ +export function useGuestAccountWarning({ + isAuthenticated, + isAnonymous, + userId, + pathname, +}: GuestAccountWarningOptions) { + const [visible, setVisible] = useState(false); + const checkedUserIds = useRef(new Set()); + const dismissedUserIds = useRef(new Set()); + + const isEligible = Boolean( + isAuthenticated + && isAnonymous + && userId + && !pathname?.startsWith("/(auth)"), + ); + + useEffect(() => { + if (!isEligible || !userId) { + return; + } + + if (checkedUserIds.current.has(userId) || dismissedUserIds.current.has(userId)) { + return; + } + + checkedUserIds.current.add(userId); + let isCurrent = true; + + void hasSeenGuestAccountWarning(userId).then((seen) => { + if (isCurrent && !seen && !dismissedUserIds.current.has(userId)) { + setVisible(true); + } + }); + + return () => { + isCurrent = false; + }; + }, [isEligible, userId]); + + const dismiss = useCallback(async () => { + if (!userId) { + setVisible(false); + return; + } + + dismissedUserIds.current.add(userId); + setVisible(false); + await markGuestAccountWarningSeen(userId); + }, [userId]); + + return { visible: isEligible && visible, dismiss }; +} diff --git a/apps/mobile/src/lib/guestAccountWarningSeen.ts b/apps/mobile/src/lib/guestAccountWarningSeen.ts index cf160a7..ceda87b 100644 --- a/apps/mobile/src/lib/guestAccountWarningSeen.ts +++ b/apps/mobile/src/lib/guestAccountWarningSeen.ts @@ -1,47 +1,31 @@ -import * as SecureStore from "expo-secure-store"; -import { Platform } from "react-native"; +import AsyncStorage from "@react-native-async-storage/async-storage"; -const STORAGE_KEY = "guest_account_recovery_warning_seen"; +const STORAGE_KEY_PREFIX = "guest_account_recovery_warning_seen"; -async function getStorage(): Promise { +const buildKey = (userId: string): string => `${STORAGE_KEY_PREFIX}_${userId}`; + +async function getStorage(key: string): 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"; + return (await AsyncStorage.getItem(key)) === "true"; } catch (error) { console.error("Error reading guest warning state:", error); } return false; } -async function setStorage(seen: boolean): Promise { +async function setStorage(key: string, 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); - } + await AsyncStorage.setItem(key, seen ? "true" : "false"); } catch (error) { console.error("Error saving guest warning state:", error); } } -export async function hasSeenGuestAccountWarning(): Promise { - return getStorage(); +/** The recovery warning is shown at most once for each anonymous account. */ +export async function hasSeenGuestAccountWarning(userId: string): Promise { + return getStorage(buildKey(userId)); } -export async function markGuestAccountWarningSeen(): Promise { - await setStorage(true); +export async function markGuestAccountWarningSeen(userId: string): Promise { + await setStorage(buildKey(userId), true); }