mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
fix(acquisition): close invite privacy gaps
This commit is contained in:
1 parent
1c7084c033
commit
4a57f31615
15 files changed
+213
-70
No files matched your search
@@ -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<Text>(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 <GradientBackground><View style={styles.container}><ActivityIndicator size="large" color={colors.primary} /></View></GradientBackground>;
|
||||
}
|
||||
if (error) {
|
||||
return <GradientBackground><View style={styles.container}><Text style={styles.title}>We couldn't load your shared discoveries</Text><Text style={styles.body}>Check your connection and try again.</Text><GlassButton fullWidth onPress={() => { setReady(false); void fetchMatches(true).finally(() => setReady(true)); }} testID="paired-retry">Try again</GlassButton></View></GradientBackground>;
|
||||
if (!loadedSuccessfully) {
|
||||
return <GradientBackground><View style={styles.container}><Text style={styles.title}>We couldn't load your shared discoveries</Text><Text style={styles.body}>Check your connection and try again.</Text><GlassButton fullWidth onPress={() => { setReady(false); void fetchMatches(true).then((success) => { setLoadedSuccessfully(success); setReady(true); }); }} testID="paired-retry">Try again</GlassButton></View></GradientBackground>;
|
||||
}
|
||||
return <GradientBackground><View style={styles.container}>
|
||||
<LinearGradient colors={gradients.primary as [string, string]} style={styles.icon}><Ionicons name="heart" size={42} color={colors.text} /></LinearGradient>
|
||||
|
||||
@@ -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() {
|
||||
<GlassButton onPress={handleCancelOwnAndPrefill} variant="danger" fullWidth disabled={isSubmitting} testID="pairing-conflict-cancel-and-join">
|
||||
Cancel mine and join
|
||||
</GlassButton>
|
||||
<TouchableOpacity onPress={() => { setIsIncomingConflictDismissed(true); void clearPendingInviteCode(); }} style={styles.cancelButton} testID="pairing-conflict-keep-own">
|
||||
<TouchableOpacity onPress={() => { setIsIncomingConflictDismissed(true); void clearPendingInviteCode(user?.id); }} style={styles.cancelButton} testID="pairing-conflict-keep-own">
|
||||
<Text style={styles.cancelButtonText}>Keep my invite</Text>
|
||||
</TouchableOpacity>
|
||||
</GlassCard>
|
||||
@@ -400,7 +400,7 @@ export default function PairingScreen() {
|
||||
>
|
||||
<GlassCard variant="elevated">
|
||||
<Text style={styles.sealedCountText} testID="pairing-sealed-count">
|
||||
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.
|
||||
</Text>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator size="large" color="#e94560" />
|
||||
@@ -64,7 +64,7 @@ export default function Index() {
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPendingInvite) {
|
||||
if (pendingInviteCheck?.hasInvite) {
|
||||
return <Redirect href="/(app)/pairing" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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))];
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -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<T>(operation: () => Promise<T>): Promise<T> {
|
||||
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<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_KEY, normalizeInviteCode(code));
|
||||
const parsed = JSON.parse(value) as Partial<PendingInviteCode>;
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
export async function getPendingInviteCode(userId: string): Promise<string | null> {
|
||||
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<void> {
|
||||
export async function clearPendingInviteCode(userId?: string): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ export const useAuthStore = create<AuthState>((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<AuthState>((set, get) => ({
|
||||
});
|
||||
// Clear other stores when user signs out
|
||||
if (user === null) {
|
||||
void clearPendingInviteCode();
|
||||
void clearPendingInviteCode(priorUserId);
|
||||
if (priorUserId) {
|
||||
void clearPairedUnlockSeen(priorUserId, priorCoupleId);
|
||||
}
|
||||
|
||||
@@ -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<string, number>();
|
||||
const activeInFlight = new Map<string, { generation: number; promise: Promise<boolean> }>();
|
||||
|
||||
// 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<void>;
|
||||
fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<boolean>;
|
||||
markAsSeen: (matchId: string) => Promise<void>;
|
||||
markAllAsSeen: () => Promise<void>;
|
||||
addMatch: (match: Match) => void;
|
||||
@@ -130,18 +131,20 @@ export const useMatchStore = create<MatchState>((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<MatchState>((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<boolean> => {
|
||||
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<MatchState>((set, get) => ({
|
||||
} else {
|
||||
set({ isLoadingMore: false, hasMore: false });
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const sortedData = nonArchivedMatches;
|
||||
@@ -208,14 +211,19 @@ export const useMatchStore = create<MatchState>((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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<main className="min-h-screen bg-gradient-to-b from-background-light to-background">
|
||||
<main className="min-h-screen bg-gradient-to-b from-background-light to-background" data-ph-no-capture data-ph-mask>
|
||||
<header className="py-6 px-6 border-b border-white/5">
|
||||
<div className="max-w-4xl mx-auto flex items-center gap-4">
|
||||
<Link href="/" className="flex items-center gap-2 text-white/70 hover:text-white transition-colors">
|
||||
@@ -150,7 +150,7 @@ export function JoinClient({ code }: { code: string }) {
|
||||
<h1 className="text-3xl sm:text-4xl font-bold mb-3">You've been invited to compare answers</h1>
|
||||
<p className="text-white/70 mb-10">{desktopCopy}</p>
|
||||
|
||||
<section className="surface-solid p-6 sm:p-8 mb-6" aria-labelledby="invite-code-heading">
|
||||
<section className="surface-solid p-6 sm:p-8 mb-6" aria-labelledby="invite-code-heading" data-ph-no-capture data-ph-mask>
|
||||
<h2 id="invite-code-heading" className="text-white/70 text-sm font-medium mb-3">Your invite code</h2>
|
||||
<p
|
||||
className="text-4xl sm:text-5xl font-bold tracking-[0.3em] text-primary mb-6"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const providerPath = new URL('./providers.tsx', import.meta.url)
|
||||
|
||||
describe('PostHogProvider invite privacy', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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}</>
|
||||
|
||||
Reference in new issue
Block a user