diff --git a/apps/mobile/app/(app)/paired.tsx b/apps/mobile/app/(app)/paired.tsx index 769bb13..625f052 100644 --- a/apps/mobile/app/(app)/paired.tsx +++ b/apps/mobile/app/(app)/paired.tsx @@ -14,24 +14,31 @@ const bucket = (n: number) => n === 0 ? "0" : n <= 2 ? "1-2" : n <= 5 ? "3-5" : export default function PairedScreen() { const { user, couple, partner } = useAuthStore(); - const { newMatchesCount, fetchMatches, error } = useMatchStore(); + const { newMatchesCount, fetchMatches } = useMatchStore(); const [ready, setReady] = useState(false); + const [loadedSuccessfully, setLoadedSuccessfully] = useState(false); const { role: rawRole } = useLocalSearchParams<{ role?: "inviter" | "joiner" }>(); const heading = useRef(null); const role = rawRole === "inviter" ? "inviter" : "joiner"; useEffect(() => { - void fetchMatches(true).finally(() => setReady(true)); + let cancelled = false; + void fetchMatches(true).then((success) => { + if (cancelled) return; + setLoadedSuccessfully(success); + setReady(true); + }); + return () => { cancelled = true; }; }, [fetchMatches]); useEffect(() => { - if (!ready || error || !user || !couple) return; + if (!ready || !loadedSuccessfully || !user || !couple) return; void markPairedUnlockSeen(user.id, couple.id); Events.pairingUnlockViewed(role, bucket(newMatchesCount)); const timer = setTimeout(() => { if (heading.current) AccessibilityInfo.setAccessibilityFocus(heading.current as never); }, 250); return () => clearTimeout(timer); - }, [ready, error, user, couple, newMatchesCount]); + }, [ready, loadedSuccessfully, user, couple, newMatchesCount]); const partnerName = partner?.name?.split(" ")[0] || "your partner"; const viewMatches = () => { Events.pairingUnlockCtaTapped(role, "view_matches"); router.replace("/(app)/matches"); }; @@ -40,8 +47,8 @@ export default function PairedScreen() { if (!ready) { return ; } - if (error) { - return We couldn't load your shared discoveriesCheck your connection and try again. { setReady(false); void fetchMatches(true).finally(() => setReady(true)); }} testID="paired-retry">Try again; + if (!loadedSuccessfully) { + return We couldn't load your shared discoveriesCheck your connection and try again. { setReady(false); void fetchMatches(true).then((success) => { setLoadedSuccessfully(success); setReady(true); }); }} testID="paired-retry">Try again; } return diff --git a/apps/mobile/app/(app)/pairing.tsx b/apps/mobile/app/(app)/pairing.tsx index 8122d75..43e402f 100755 --- a/apps/mobile/app/(app)/pairing.tsx +++ b/apps/mobile/app/(app)/pairing.tsx @@ -52,7 +52,7 @@ export default function PairingScreen() { let cancelled = false; void (async () => { if (typeof params.incomingCode === "string") { - await clearPendingInviteCode(); + await clearPendingInviteCode(user.id); if (!cancelled) { Alert.alert("You're already paired", "That invite is for a different account. You're already paired with your partner.", [{ text: "OK", onPress: () => router.replace("/(app)") }]); } @@ -86,15 +86,15 @@ export default function PairingScreen() { const routeCode = typeof params.code === "string" ? normalizeInviteCode(params.code) : ""; if (isValidInviteCode(routeCode)) { await applyPrefill(routeCode); - await clearPendingInviteCode(); + await clearPendingInviteCode(user?.id); if (!cancelled) setPrefillAttempted(true); return; } - const stashedCode = await getPendingInviteCode(); - if (stashedCode) { - await applyPrefill(stashedCode); - await clearPendingInviteCode(); + const stashedCode = user?.id ? await getPendingInviteCode(user.id) : null; + if (stashedCode) { + await applyPrefill(stashedCode); + await clearPendingInviteCode(user?.id); if (!cancelled) setPrefillAttempted(true); return; } @@ -109,7 +109,7 @@ export default function PairingScreen() { return () => { cancelled = true; }; - }, [couple, params.code, prefillAttempted]); + }, [couple, params.code, prefillAttempted, user?.id]); const acceptClipboardOffer = () => { if (!clipboardOfferCode) return; @@ -312,7 +312,7 @@ export default function PairingScreen() { "Cancel this invite?", "Your code will stop working, and the answers you've given since you created it will be deleted. Answers you gave before you created the code are kept.", [ - { text: "Keep my invite", style: "cancel", onPress: () => void clearPendingInviteCode() }, + { text: "Keep my invite", style: "cancel", onPress: () => void clearPendingInviteCode(user?.id) }, { text: "Cancel mine and join", style: "destructive", onPress: async () => { setIsSubmitting(true); @@ -320,7 +320,7 @@ export default function PairingScreen() { await cancelInvite(async () => { setInviteCode(incomingCode); setWasPrefilled(true); - await clearPendingInviteCode(); + await clearPendingInviteCode(user?.id); }); } finally { setIsSubmitting(false); @@ -369,7 +369,7 @@ export default function PairingScreen() { Cancel mine and join - { setIsIncomingConflictDismissed(true); void clearPendingInviteCode(); }} style={styles.cancelButton} testID="pairing-conflict-keep-own"> + { setIsIncomingConflictDismissed(true); void clearPendingInviteCode(user?.id); }} style={styles.cancelButton} testID="pairing-conflict-keep-own"> Keep my invite @@ -400,7 +400,7 @@ export default function PairingScreen() { > - You've answered at least {sealedCount} question{sealedCount === 1 ? "" : "s"} about you two. When you both answer, shared discoveries are visible to you both. + You have saved at least {sealedCount} private question{sealedCount === 1 ? "" : "s"}. Vote-style questions show shared outcomes; open text, audio, photo, and who-likely responses can be visible to you both after you each answer. diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 15e74fc..acbb491 100755 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -134,12 +134,12 @@ export default function RootLayout() { const { isAuthenticated, user } = useAuthStore.getState(); const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version); if (user?.couple_id) { - await import("../src/lib/pendingInviteCode").then(({ clearPendingInviteCode }) => clearPendingInviteCode()); + await import("../src/lib/pendingInviteCode").then(({ clearPendingInviteCode }) => clearPendingInviteCode(user.id)); if (user.couple_id) { router.push({ pathname: "/(app)/pairing", params: { incomingCode: inviteLink.code } }); } } else { - await stashPendingInviteCode(inviteLink.code); + await stashPendingInviteCode(inviteLink.code, user?.id ?? null); if (isAuthenticated && !onboardingPending) { router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } }); } diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx index eb70f74..0afc5be 100755 --- a/apps/mobile/app/index.tsx +++ b/apps/mobile/app/index.tsx @@ -7,8 +7,7 @@ import { getPendingInviteCode } from "../src/lib/pendingInviteCode"; export default function Index() { const { isAuthenticated, isLoading, user } = useAuthStore(); - const [pendingCodeChecked, setPendingCodeChecked] = useState(false); - const [hasPendingInvite, setHasPendingInvite] = useState(false); + const [pendingInviteCheck, setPendingInviteCheck] = useState<{ userId: string; hasInvite: boolean } | null>(null); // Check once, after auth resolves, whether an invite code is waiting to be // applied (e.g. the user tapped a join link before signing in). @@ -18,16 +17,17 @@ export default function Index() { } let cancelled = false; - getPendingInviteCode().then((code) => { + if (!user?.id) return; + setPendingInviteCheck(null); + getPendingInviteCode(user.id).then((code) => { if (cancelled) return; - setHasPendingInvite(!!code); - setPendingCodeChecked(true); + setPendingInviteCheck({ userId: user.id, hasInvite: !!code }); }); return () => { cancelled = true; }; - }, [isLoading, isAuthenticated, user?.couple_id]); + }, [isLoading, isAuthenticated, user?.couple_id, user?.id]); // Show loading state while auth is being determined if (isLoading) { @@ -56,7 +56,7 @@ export default function Index() { // Wait for the one-time pending invite code check before deciding the // route, so a stashed code from a join link is applied without an extra // trip through the home screen. - if (!pendingCodeChecked) { + if (pendingInviteCheck?.userId !== user?.id) { return ( @@ -64,7 +64,7 @@ export default function Index() { ); } - if (hasPendingInvite) { + if (pendingInviteCheck?.hasInvite) { return ; } diff --git a/apps/mobile/src/__tests__/lib/inviteShareCopy.test.ts b/apps/mobile/src/__tests__/lib/inviteShareCopy.test.ts index aef1fd3..594ea2c 100644 --- a/apps/mobile/src/__tests__/lib/inviteShareCopy.test.ts +++ b/apps/mobile/src/__tests__/lib/inviteShareCopy.test.ts @@ -5,7 +5,8 @@ describe("buildInviteShareMessage", () => { const message = buildInviteShareMessage("AB12CD34", 3); expect(message).toContain("https://sauci.app/join/AB12CD34"); expect(message).toContain("enter code AB12CD34 in Sauci"); - expect(message).toContain("compare our answers together"); + expect(message).toContain("Vote-style questions show shared outcomes"); + expect(message).toContain("open answers can be visible to us both after we each answer"); }); it("does not claim a sealed outcome before answers exist", () => { diff --git a/apps/mobile/src/__tests__/lib/pendingInviteCode.test.ts b/apps/mobile/src/__tests__/lib/pendingInviteCode.test.ts index 1e227b3..44cff3c 100644 --- a/apps/mobile/src/__tests__/lib/pendingInviteCode.test.ts +++ b/apps/mobile/src/__tests__/lib/pendingInviteCode.test.ts @@ -10,20 +10,40 @@ describe("pendingInviteCode", () => { await AsyncStorage.clear(); }); - it("stashes, retrieves, and clears a valid code", async () => { - await stashPendingInviteCode("abcd1234"); - await expect(getPendingInviteCode()).resolves.toBe("ABCD1234"); + it("stashes, retrieves, and clears a valid code for its owner", async () => { + await stashPendingInviteCode("abcd1234", "user-a"); + await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234"); - await clearPendingInviteCode(); - await expect(getPendingInviteCode()).resolves.toBeNull(); + await clearPendingInviteCode("user-a"); + await expect(getPendingInviteCode("user-a")).resolves.toBeNull(); }); it("returns null when nothing has been stashed", async () => { - await expect(getPendingInviteCode()).resolves.toBeNull(); + await expect(getPendingInviteCode("user-a")).resolves.toBeNull(); }); it("ignores a stashed value that no longer matches the invite code shape", async () => { await AsyncStorage.setItem("pending_invite_code", "not-a-code"); - await expect(getPendingInviteCode()).resolves.toBeNull(); + await expect(getPendingInviteCode("user-a")).resolves.toBeNull(); + }); + + it("does not leak a claimed invite to a later account", async () => { + await stashPendingInviteCode("abcd1234"); + await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234"); + await expect(getPendingInviteCode("user-b")).resolves.toBeNull(); + + await clearPendingInviteCode("user-b"); + await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234"); + }); + + it("lets only one concurrently arriving account claim a pre-auth invite", async () => { + await stashPendingInviteCode("abcd1234"); + + const [first, second] = await Promise.all([ + getPendingInviteCode("user-a"), + getPendingInviteCode("user-b"), + ]); + + expect([first, second].filter(Boolean)).toHaveLength(1); }); }); diff --git a/apps/mobile/src/__tests__/stores/matchStore.test.ts b/apps/mobile/src/__tests__/stores/matchStore.test.ts index 9514648..9c4f97d 100644 --- a/apps/mobile/src/__tests__/stores/matchStore.test.ts +++ b/apps/mobile/src/__tests__/stores/matchStore.test.ts @@ -50,10 +50,11 @@ describe('matchStore API behavior', () => { 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 }); + const overlapping = useMatchStore.getState().fetchMatches(true, { silent: true }); expect(apiClient.get).toHaveBeenCalledTimes(1); release({ matches: [], totalCount: 0 }); await inFlight; + await overlapping; (apiClient.get as jest.Mock).mockClear(); useMatchStore.setState({ hasMore: false }); @@ -61,6 +62,21 @@ describe('matchStore API behavior', () => { expect(apiClient.get).not.toHaveBeenCalled(); }); + it('makes overlapping callers await the same failed refresh', async () => { + jest.spyOn(console, 'error').mockImplementation(() => undefined); + let reject!: (error: Error) => void; + (apiClient.get as jest.Mock).mockImplementationOnce(() => new Promise((_, rejectRequest) => { reject = rejectRequest; })); + + const first = useMatchStore.getState().fetchMatches(true); + const second = useMatchStore.getState().fetchMatches(true, { silent: true }); + expect(apiClient.get).toHaveBeenCalledTimes(1); + + reject(new Error('network')); + await expect(first).resolves.toBe(false); + await expect(second).resolves.toBe(false); + expect(useMatchStore.getState().error).toBe('Failed to load matches'); + }); + it('appends and deduplicates later pages', async () => { useMatchStore.setState({ matches: [match('existing')], page: 1, hasMore: true, newMatchesCount: 1, totalCount: 25 }); const page = [match('existing'), ...Array.from({ length: 19 }, (_, index) => match(`m${index}`, false))]; diff --git a/apps/mobile/src/lib/inviteShareCopy.ts b/apps/mobile/src/lib/inviteShareCopy.ts index 03c3008..0921d6f 100644 --- a/apps/mobile/src/lib/inviteShareCopy.ts +++ b/apps/mobile/src/lib/inviteShareCopy.ts @@ -8,7 +8,7 @@ export function buildInviteShareMessage(inviteCode: string, sealedCount: number) const link = `https://sauci.app/join/${inviteCode}`; if (sealedCount > 0) { const noun = sealedCount === 1 ? "question" : "questions"; - return `Join me on Sauci! I've answered ${sealedCount} ${noun} about us. Join to compare our answers together. ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`; + return `Join me on Sauci! I've saved ${sealedCount} ${noun} for us to compare. Vote-style questions show shared outcomes; open answers can be visible to us both after we each answer. ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`; } return `Join me on Sauci! ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`; } diff --git a/apps/mobile/src/lib/pendingInviteCode.ts b/apps/mobile/src/lib/pendingInviteCode.ts index e697eff..1c5dc71 100644 --- a/apps/mobile/src/lib/pendingInviteCode.ts +++ b/apps/mobile/src/lib/pendingInviteCode.ts @@ -6,31 +6,73 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { isValidInviteCode, normalizeInviteCode } from "./inviteLink"; const STORAGE_KEY = "pending_invite_code"; +let storageOperation = Promise.resolve(); + +function withStorageLock(operation: () => Promise): Promise { + const next = storageOperation.then(operation, operation); + storageOperation = next.then(() => undefined, () => undefined); + return next; +} + +interface PendingInviteCode { + code: string; + /** The account that received or first claimed this deferred invite. */ + userId: string | null; +} + +function parsePendingInviteCode(value: string | null): PendingInviteCode | null { + if (!value) return null; + + // Treat legacy, unscoped values as a pre-auth hand-off. The first account + // that intentionally enters the pairing flow claims it below. + if (isValidInviteCode(value)) return { code: normalizeInviteCode(value), userId: null }; -export async function stashPendingInviteCode(code: string): Promise { try { - await AsyncStorage.setItem(STORAGE_KEY, normalizeInviteCode(code)); + const parsed = JSON.parse(value) as Partial; + if (typeof parsed.code !== "string" || !isValidInviteCode(parsed.code)) return null; + if (parsed.userId !== null && typeof parsed.userId !== "string") return null; + return { code: normalizeInviteCode(parsed.code), userId: parsed.userId ?? null }; + } catch { + return null; + } +} + +export async function stashPendingInviteCode(code: string, userId: string | null = null): Promise { + try { + await withStorageLock(() => AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ code: normalizeInviteCode(code), userId } satisfies PendingInviteCode))); } catch (error) { console.error("Error stashing pending invite code:", error); } } -export async function getPendingInviteCode(): Promise { +export async function getPendingInviteCode(userId: string): Promise { try { - const value = await AsyncStorage.getItem(STORAGE_KEY); - if (value && isValidInviteCode(value)) { - return normalizeInviteCode(value); - } - return null; + return await withStorageLock(async () => { + const pending = parsePendingInviteCode(await AsyncStorage.getItem(STORAGE_KEY)); + if (!pending || (pending.userId && pending.userId !== userId)) return null; + + // A hand-off made before sign-in becomes owned by the account that + // reaches pairing. Later account switches cannot consume it. + if (!pending.userId) { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ ...pending, userId })); + } + return pending.code; + }); } catch (error) { console.error("Error reading pending invite code:", error); return null; } } -export async function clearPendingInviteCode(): Promise { +export async function clearPendingInviteCode(userId?: string): Promise { try { - await AsyncStorage.removeItem(STORAGE_KEY); + await withStorageLock(async () => { + if (userId) { + const pending = parsePendingInviteCode(await AsyncStorage.getItem(STORAGE_KEY)); + if (pending?.userId && pending.userId !== userId) return; + } + await AsyncStorage.removeItem(STORAGE_KEY); + }); } catch (error) { console.error("Error clearing pending invite code:", error); } diff --git a/apps/mobile/src/store/authStore.ts b/apps/mobile/src/store/authStore.ts index cfff194..4ddcd74 100644 --- a/apps/mobile/src/store/authStore.ts +++ b/apps/mobile/src/store/authStore.ts @@ -210,7 +210,7 @@ export const useAuthStore = create((set, get) => ({ if (unlockUserId) { await clearPairedUnlockSeen(unlockUserId, unlockCoupleId); } - await clearPendingInviteCode(); + await clearPendingInviteCode(unlockUserId); // Clear local state FIRST to ensure UI updates even if Supabase call fails set({ @@ -259,7 +259,7 @@ export const useAuthStore = create((set, get) => ({ }); // Clear other stores when user signs out if (user === null) { - void clearPendingInviteCode(); + void clearPendingInviteCode(priorUserId); if (priorUserId) { void clearPairedUnlockSeen(priorUserId, priorCoupleId); } diff --git a/apps/mobile/src/store/matchStore.ts b/apps/mobile/src/store/matchStore.ts index c282d41..b0088de 100644 --- a/apps/mobile/src/store/matchStore.ts +++ b/apps/mobile/src/store/matchStore.ts @@ -28,6 +28,7 @@ export interface FetchOptions { silent?: boolean } // 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(); +const activeInFlight = new Map }>(); // Rate limit: 12 hours in milliseconds const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000; @@ -80,7 +81,7 @@ interface MatchState { */ generation: number; // Methods - fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise; + fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise; markAsSeen: (matchId: string) => Promise; markAllAsSeen: () => Promise; addMatch: (match: Match) => void; @@ -130,18 +131,20 @@ export const useMatchStore = create((set, get) => ({ isNudging: false, generation: 0, - fetchMatches: async (refresh = false, options = {}) => { + fetchMatches: (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, isRefreshing: false }); - return; + return Promise.resolve(false); } const state = get(); - if (inFlight.get('active') === myGeneration || (state.isLoadingMore && !refresh)) return; + const activeRequest = activeInFlight.get('active'); + if (activeRequest?.generation === myGeneration) return activeRequest.promise; + if (state.isLoadingMore && !refresh) return Promise.resolve(false); // 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. @@ -149,17 +152,17 @@ export const useMatchStore = create((set, get) => ({ if (refresh) { set({ isLoading: initial, isRefreshing: !initial && !options.silent, error: null, page: 0, hasMore: true }); } else { - if (!state.hasMore) return; + if (!state.hasMore) return Promise.resolve(false); set({ isLoadingMore: true }); } - inFlight.set('active', myGeneration); - try { + const request = (async (): Promise => { + try { const currentPage = refresh ? 0 : state.page; 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; + if (get().generation !== myGeneration) return false; const nonArchivedMatches = result.matches; const totalCount = result.totalCount; const archivedMatchIds = state.archivedMatchIds; @@ -174,7 +177,7 @@ export const useMatchStore = create((set, get) => ({ } else { set({ isLoadingMore: false, hasMore: false }); } - return; + return true; } const sortedData = nonArchivedMatches; @@ -208,14 +211,19 @@ export const useMatchStore = create((set, get) => ({ loadedAt, }; }); + return true; } catch (err) { - if (get().generation !== myGeneration) return; + if (get().generation !== myGeneration) return false; console.error("Error fetching matches:", err); set({ error: "Failed to load matches", isLoading: false, isRefreshing: false, isLoadingMore: false }); + return false; } finally { // An old generation's cleanup must not delete a newer generation's guard. - if (inFlight.get('active') === myGeneration) inFlight.delete('active'); + if (activeInFlight.get('active')?.generation === myGeneration) activeInFlight.delete('active'); } + })(); + activeInFlight.set('active', { generation: myGeneration, promise: request }); + return request; }, markAsSeen: async (matchId) => { @@ -296,6 +304,7 @@ clearMatches: () => { // 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(); + activeInFlight.clear(); }, // Archive methods diff --git a/apps/web/app/join/[code]/JoinClient.test.ts b/apps/web/app/join/[code]/JoinClient.test.ts index 40b1e47..aa2e0ae 100644 --- a/apps/web/app/join/[code]/JoinClient.test.ts +++ b/apps/web/app/join/[code]/JoinClient.test.ts @@ -65,13 +65,21 @@ describe('/join/[code] JoinClient', () => { expect(source).not.toContain('glass-light') }) - it('describes open-answer visibility truthfully', async () => { + it('describes comparison and open-answer visibility truthfully', async () => { const source = await readFile(componentPath, 'utf8') - expect(source).toContain('For open-answer questions, each response can be visible after you have both answered.') + expect(source).toContain('Vote-style questions reveal shared outcomes') + expect(source).toContain('open text, audio, photo, and who-likely responses can be visible to you both after you have each answered.') expect(source).not.toContain('Neither of you sees the other’s individual answers.') }) + it('marks the invite-code surface out of PostHog DOM capture and replay', async () => { + const source = await readFile(componentPath, 'utf8') + + expect(source).toContain('data-ph-no-capture') + expect(source).toContain('data-ph-mask') + }) + it('shows a clear fallback message for a malformed invite code', async () => { const source = await readFile(componentPath, 'utf8') diff --git a/apps/web/app/join/[code]/JoinClient.tsx b/apps/web/app/join/[code]/JoinClient.tsx index e211847..734f3bb 100644 --- a/apps/web/app/join/[code]/JoinClient.tsx +++ b/apps/web/app/join/[code]/JoinClient.tsx @@ -124,10 +124,10 @@ export function JoinClient({ code }: { code: string }) { const storeUnavailableTitle = isOnline ? undefined : 'Reconnect to open the app store.' const desktopCopy = platform === 'desktop' ? 'Sauci is a phone app. Copy your code, then open this link on your phone or install from the store there.' - : 'They have already answered questions about you two. Join them to discover what you have in common. For open-answer questions, each response can be visible after you have both answered.' + : 'Join to compare answers together. Vote-style questions reveal shared outcomes; open text, audio, photo, and who-likely responses can be visible to you both after you have each answered.' return ( -
+
@@ -150,7 +150,7 @@ export function JoinClient({ code }: { code: string }) {

You've been invited to compare answers

{desktopCopy}

-
+

Your invite code

{ + it('disables DOM autocapture and session recording on invite routes', async () => { + const source = await readFile(providerPath, 'utf8') + + expect(source).toContain("pathname?.startsWith('/join/')") + expect(source).toContain("window.location.pathname.startsWith('/join/')") + expect(source).toContain('autocapture: !isInvitePage') + expect(source).toContain('disable_session_recording: isInvitePage') + expect(source).toContain('mask_all_text: isInvitePage') + }) +}) diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index c601846..1347779 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -4,6 +4,7 @@ import posthog from 'posthog-js' import { PostHogProvider as PHProvider } from 'posthog-js/react' import type { CaptureResult } from 'posthog-js' import { useEffect } from 'react' +import { usePathname } from 'next/navigation' const INVITE_PATH_PATTERN = /\/join\/[^/?#\s]+/g @@ -38,18 +39,41 @@ export function redactInvitePathsBeforeSend(event: CaptureResult | null): Captur } export function PostHogProvider({ children }: { children: React.ReactNode }) { - useEffect(() => { - if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_POSTHOG_KEY) { - posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { + const pathname = usePathname() + + useEffect(() => { + if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_POSTHOG_KEY) { + const isInvitePage = window.location.pathname.startsWith('/join/') + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.i.posthog.com', person_profiles: 'identified_only', capture_pageview: false, capture_pageleave: false, get_current_url: redactInviteUrl, before_send: redactInvitePathsBeforeSend, - }) + // This must be part of initialization, not only the navigation effect + // below, so an initially loaded invite page is protected before its DOM + // can be considered for capture or recording. + autocapture: !isInvitePage, + disable_session_recording: isInvitePage, + mask_all_text: isInvitePage, + }) } - }, []) + }, []) + + useEffect(() => { + if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return + + // Invite codes are credentials. Do not let their page enter PostHog's DOM + // autocapture or session-recording pipeline, even though event properties + // and URLs are separately redacted below. + const isInvitePage = pathname?.startsWith('/join/') + posthog.set_config({ + autocapture: !isInvitePage, + disable_session_recording: isInvitePage, + mask_all_text: isInvitePage, + }) + }, [pathname]) if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) { return <>{children}