merge: correct mobile invite disclosure

This commit is contained in:
jjbrunton committed 2026-09-06 12:50:13 +01:00
commit a1b62c3b47
7 files changed
+27 -10

No files matched your search

+6 -3
View File
@@ -14,7 +14,7 @@ const bucket = (n: number) => n === 0 ? "0" : n <= 2 ? "1-2" : n <= 5 ? "3-5" :
export default function PairedScreen() { export default function PairedScreen() {
const { user, couple, partner } = useAuthStore(); const { user, couple, partner } = useAuthStore();
const { newMatchesCount, fetchMatches } = useMatchStore(); const { newMatchesCount, fetchMatches, error } = useMatchStore();
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const { role: rawRole } = useLocalSearchParams<{ role?: "inviter" | "joiner" }>(); const { role: rawRole } = useLocalSearchParams<{ role?: "inviter" | "joiner" }>();
const heading = useRef<Text>(null); const heading = useRef<Text>(null);
@@ -24,7 +24,7 @@ export default function PairedScreen() {
void fetchMatches(true).finally(() => setReady(true)); void fetchMatches(true).finally(() => setReady(true));
}, [fetchMatches]); }, [fetchMatches]);
useEffect(() => { useEffect(() => {
if (!ready || !user || !couple) return; if (!ready || error || !user || !couple) return;
void markPairedUnlockSeen(user.id, couple.id); void markPairedUnlockSeen(user.id, couple.id);
Events.pairingUnlockViewed(role, bucket(newMatchesCount)); Events.pairingUnlockViewed(role, bucket(newMatchesCount));
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -40,10 +40,13 @@ export default function PairedScreen() {
if (!ready) { if (!ready) {
return <GradientBackground><View style={styles.container}><ActivityIndicator size="large" color={colors.primary} /></View></GradientBackground>; 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>;
}
return <GradientBackground><View style={styles.container}> 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> <LinearGradient colors={gradients.primary as [string, string]} style={styles.icon}><Ionicons name="heart" size={42} color={colors.text} /></LinearGradient>
<Text ref={heading} accessibilityRole="header" style={styles.title}>You're paired with {partnerName}</Text> <Text ref={heading} accessibilityRole="header" style={styles.title}>You're paired with {partnerName}</Text>
{newMatchesCount > 0 ? <><Text style={styles.count}>{newMatchesCount} thing{newMatchesCount === 1 ? "" : "s"} you already agree on</Text><Text style={styles.body}>From answers you both gave before now.</Text><GlassButton fullWidth onPress={viewMatches} testID="paired-view-matches">See what you agree on</GlassButton><GlassButton variant="secondary" fullWidth onPress={start} testID="paired-start-session">Start a session</GlassButton></> : <><Text style={styles.body}>Nothing to compare yet. Answer a few questions and anything you agree on shows up here.</Text><GlassButton fullWidth onPress={start} testID="paired-start-session">Answer your first questions</GlassButton></>} {newMatchesCount > 0 ? <><Text style={styles.count}>{newMatchesCount} shared discover{newMatchesCount === 1 ? "y" : "ies"} ready to explore</Text><Text style={styles.body}>From answers you both gave before now.</Text><GlassButton fullWidth onPress={viewMatches} testID="paired-view-matches">See shared discoveries</GlassButton><GlassButton variant="secondary" fullWidth onPress={start} testID="paired-start-session">Start a session</GlassButton></> : <><Text style={styles.body}>Nothing is ready to compare yet. Answer a few questions and shared discoveries will appear here.</Text><GlassButton fullWidth onPress={start} testID="paired-start-session">Answer your first questions</GlassButton></>}
<TouchableOpacity onPress={dismiss} testID="paired-not-now" style={styles.dismiss}><Text style={styles.dismissText}>Not now</Text></TouchableOpacity> <TouchableOpacity onPress={dismiss} testID="paired-not-now" style={styles.dismiss}><Text style={styles.dismissText}>Not now</Text></TouchableOpacity>
</View></GradientBackground>; </View></GradientBackground>;
} }
+3 -3
View File
@@ -363,7 +363,7 @@ export default function PairingScreen() {
<Ionicons name="heart" size={40} color={colors.text} /> <Ionicons name="heart" size={40} color={colors.text} />
</LinearGradient> </LinearGradient>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
They unlock your answers when they join Your answers are ready to compare when you both answer
</Text> </Text>
</Animated.View> </Animated.View>
@@ -375,7 +375,7 @@ export default function PairingScreen() {
> >
<GlassCard variant="elevated"> <GlassCard variant="elevated">
<Text style={styles.sealedCountText} testID="pairing-sealed-count"> <Text style={styles.sealedCountText} testID="pairing-sealed-count">
You've answered at least {sealedCount} question{sealedCount === 1 ? "" : "s"} about you two. As soon as they join, everything you both agree on appears for both of you. You've answered at least {sealedCount} question{sealedCount === 1 ? "" : "s"} about you two. When you both answer, shared discoveries are visible to you both.
</Text> </Text>
</GlassCard> </GlassCard>
</Animated.View> </Animated.View>
@@ -511,7 +511,7 @@ export default function PairingScreen() {
<Ionicons name="link" size={40} color={colors.text} /> <Ionicons name="link" size={40} color={colors.text} />
</LinearGradient> </LinearGradient>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
Answer privately. See what you agree on together. Answer privately. Compare your shared discoveries together.
</Text> </Text>
</Animated.View> </Animated.View>
@@ -5,7 +5,7 @@ describe("buildInviteShareMessage", () => {
const message = buildInviteShareMessage("AB12CD34", 3); const message = buildInviteShareMessage("AB12CD34", 3);
expect(message).toContain("https://sauci.app/join/AB12CD34"); expect(message).toContain("https://sauci.app/join/AB12CD34");
expect(message).toContain("enter code AB12CD34 in Sauci"); expect(message).toContain("enter code AB12CD34 in Sauci");
expect(message).toContain("we'll both see what we agree on"); expect(message).toContain("compare our answers together");
}); });
it("does not claim a sealed outcome before answers exist", () => { it("does not claim a sealed outcome before answers exist", () => {
@@ -8,6 +8,7 @@ import { useMessageStore } from "@/store/messageStore";
import { usePacksStore } from "@/store/packsStore"; import { usePacksStore } from "@/store/packsStore";
import { useSubscriptionStore } from "@/store/subscriptionStore"; import { useSubscriptionStore } from "@/store/subscriptionStore";
import type { Profile } from "@/types"; import type { Profile } from "@/types";
import AsyncStorage from "@react-native-async-storage/async-storage";
const profile = { const profile = {
id: "me", id: "me",
@@ -259,6 +260,7 @@ describe("authStore", () => {
it("clears sealed answers during an explicit sign-out", async () => { it("clears sealed answers during an explicit sign-out", async () => {
useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any); useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any);
await AsyncStorage.setItem("pending_invite_code", "ABCD1234");
await useAuthStore.getState().signOut(); await useAuthStore.getState().signOut();
@@ -267,6 +269,7 @@ describe("authStore", () => {
sealedCount: 0, sealedCount: 0,
isAuthenticated: false, isAuthenticated: false,
}); });
await expect(AsyncStorage.getItem("pending_invite_code")).resolves.toBeNull();
}); });
it("clears sealed answers when the auth listener clears the user", () => { it("clears sealed answers when the auth listener clears the user", () => {
@@ -281,6 +284,14 @@ describe("authStore", () => {
}); });
}); });
it("clears a pending invite when the auth listener clears the user", async () => {
await AsyncStorage.setItem("pending_invite_code", "ABCD1234");
useAuthStore.setState({ user: profile, isAuthenticated: true } as any);
useAuthStore.getState().setUser(null);
await new Promise((resolve) => setTimeout(resolve, 0));
await expect(AsyncStorage.getItem("pending_invite_code")).resolves.toBeNull();
});
it('keeps a pending Apple name scoped to its original subject', () => { it('keeps a pending Apple name scoped to its original subject', () => {
useAuthStore.getState().setPendingAppleDisplayName({ userId: 'apple-user', name: 'Ada Lovelace' }); useAuthStore.getState().setPendingAppleDisplayName({ userId: 'apple-user', name: 'Ada Lovelace' });
useAuthStore.getState().clearPendingAppleDisplayName('other-user'); useAuthStore.getState().clearPendingAppleDisplayName('other-user');
@@ -21,12 +21,12 @@ export const SwipeSealedAnswersBanner = ({ sealedCount, onInvitePress }: SwipeSe
onPress={onInvitePress} onPress={onInvitePress}
activeOpacity={0.8} activeOpacity={0.8}
testID="swipe-sealed-answers-banner" testID="swipe-sealed-answers-banner"
accessibilityLabel={`Invite your partner to unlock ${sealedCount} sealed answers`} accessibilityLabel={`Invite your partner to compare ${sealedCount} sealed answers`}
> >
<Ionicons name="lock-closed" size={16} color={colors.premium.rose} /> <Ionicons name="lock-closed" size={16} color={colors.premium.rose} />
<View style={styles.copy}> <View style={styles.copy}>
<Text style={styles.title}>{sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed for your partner</Text> <Text style={styles.title}>{sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed for your partner</Text>
<Text style={styles.text}>You'll both see what you agree on the moment they join.</Text> <Text style={styles.text}>When you both answer, your shared discoveries are ready to compare.</Text>
</View> </View>
<Text style={styles.cta}>Invite</Text> <Text style={styles.cta}>Invite</Text>
</TouchableOpacity> </TouchableOpacity>
+1 -1
View File
@@ -8,7 +8,7 @@ export function buildInviteShareMessage(inviteCode: string, sealedCount: number)
const link = `https://sauci.app/join/${inviteCode}`; const link = `https://sauci.app/join/${inviteCode}`;
if (sealedCount > 0) { if (sealedCount > 0) {
const noun = sealedCount === 1 ? "question" : "questions"; const noun = sealedCount === 1 ? "question" : "questions";
return `Join me on Sauci! I've answered ${sealedCount} ${noun} about us. Join and we'll both see what we agree on. ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`; 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! ${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.`;
} }
+3
View File
@@ -6,6 +6,7 @@ import { profileSettingsApi } from "../lib/profileSettingsApi";
import { syncTimezone } from "../lib/reportedTimezone"; import { syncTimezone } from "../lib/reportedTimezone";
import { Events } from "../lib/analytics"; import { Events } from "../lib/analytics";
import { clearPairedUnlockSeen } from "../lib/pairedUnlockSeen"; import { clearPairedUnlockSeen } from "../lib/pairedUnlockSeen";
import { clearPendingInviteCode } from "../lib/pendingInviteCode";
import type { Profile, Couple } from "@/types"; import type { Profile, Couple } from "@/types";
export interface AuthSessionSnapshot { export interface AuthSessionSnapshot {
@@ -209,6 +210,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (unlockUserId) { if (unlockUserId) {
await clearPairedUnlockSeen(unlockUserId, unlockCoupleId); await clearPairedUnlockSeen(unlockUserId, unlockCoupleId);
} }
await clearPendingInviteCode();
// Clear local state FIRST to ensure UI updates even if Supabase call fails // Clear local state FIRST to ensure UI updates even if Supabase call fails
set({ set({
@@ -257,6 +259,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}); });
// Clear other stores when user signs out // Clear other stores when user signs out
if (user === null) { if (user === null) {
void clearPendingInviteCode();
if (priorUserId) { if (priorUserId) {
void clearPairedUnlockSeen(priorUserId, priorCoupleId); void clearPairedUnlockSeen(priorUserId, priorCoupleId);
} }