28 Commits
Author SHA1 Message Date
jjbrunton 7d99192dbe fix(ota): guard RevenueCat production exports 2026-09-21 22:09:03 +01:00
jjbrunton e417f7f79a test(dev-local): cover native locale forwarding 2026-09-21 21:41:45 +01:00
jjbrunton 15c7894bec fix(web): redact invite replay requests 2026-09-06 20:54:19 +01:00
jjbrunton 57c7ecebe6 test(api): isolate drizzle migration ledger 2026-09-06 20:14:13 +01:00
jjbrunton 17372a9bf0 fix(mobile): stop guest warning spam 2026-09-06 19:19:56 +01:00
jjbrunton 0f9c9c2e15 fix(acquisition): use recognized privacy markers 2026-09-06 13:24:39 +01:00
jjbrunton 4a57f31615 fix(acquisition): close invite privacy gaps 2026-09-06 13:18:11 +01:00
jjbrunton 1c7084c033 merge: use safe invite cancellation 2026-09-06 12:56:31 +01:00
jjbrunton 0dfe4435b0 merge: add atomic invite cancellation 2026-09-06 12:56:31 +01:00
jjbrunton 5ac4843095 docs(couples): clarify invite cancellation retention 2026-09-06 12:56:11 +01:00
jjbrunton 8e48467414 fix(mobile): preserve joined invites on cancellation 2026-09-06 12:56:10 +01:00
jjbrunton 1fed348072 fix(couples): protect joined invites from cancellation 2026-09-06 12:54:40 +01:00
jjbrunton a1b62c3b47 merge: correct mobile invite disclosure 2026-09-06 12:50:13 +01:00
jjbrunton 4415dcf29f fix(mobile): keep pairing discoveries truthful 2026-09-06 12:49:57 +01:00
jjbrunton 95162d1889 merge: harden invite analytics redaction 2026-09-06 12:49:50 +01:00
jjbrunton dd1a47ecfa fix(web): scrub invite tokens before analytics 2026-09-06 12:49:32 +01:00
jjbrunton a02e9d531b merge: correct invite privacy copy 2026-09-06 12:47:48 +01:00
jjbrunton 49a9cd270f fix(web): clarify open answer visibility 2026-09-06 12:47:29 +01:00
jjbrunton 077f5433e1 merge: redact invite analytics URLs 2026-09-06 12:45:46 +01:00
jjbrunton ade2c42d51 fix(web): redact invite analytics URLs 2026-09-06 12:45:28 +01:00
jjbrunton daefdc6da9 merge: fix paired state cleanup tests 2026-09-06 12:41:47 +01:00
jjbrunton 029a90b338 fix(mobile): statically load paired unlock state 2026-09-06 12:41:23 +01:00
jjbrunton bb0c712ede merge: fix invite journey routing 2026-09-06 12:39:51 +01:00
jjbrunton 1c4ec2b412 fix(mobile): resolve pairing payoff route types 2026-09-06 12:39:38 +01:00
jjbrunton 21006cd041 merge: improve mobile invite journey 2026-09-06 12:38:57 +01:00
jjbrunton f9481054de feat(mobile): improve pairing invite journey 2026-09-06 12:38:16 +01:00
jjbrunton 922066f492 merge: improve web invite journey 2026-09-06 12:35:44 +01:00
jjbrunton ff2cf09170 feat(web): improve invite landing recovery 2026-09-06 12:31:48 +01:00
48 changed files with 1527 additions and 312 deletions

No files matched your search

@@ -33,6 +33,7 @@ export interface CoupleRepository {
getState(userId: string): Promise<CoupleStateResponse>;
create(userId: string, coupleId: string, inviteCode: string): Promise<Couple>;
join(userId: string, inviteCode: string): Promise<Couple>;
cancelInvite(userId: string): Promise<void>;
cancel(userId: string): Promise<void>;
close(): Promise<void>;
}
@@ -268,6 +269,43 @@ export class PostgresCoupleRepository implements CoupleRepository {
});
}
async cancelInvite(userId: string): Promise<void> {
await transaction(this.pool, async (client) => {
// Lock in the same order as join(): the caller's profile first, then the
// couple. A concurrent join therefore either completes first and makes
// this a safe no-op error, or observes that the invite no longer exists.
const profile = await profileForUpdate(client, userId);
if (!profile.couple_id) {
throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400);
}
const coupleResult = await client.query<{ id: string }>(
'select id from couples where id = $1 for update',
[profile.couple_id],
);
if (!coupleResult.rows[0]) {
throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400);
}
const members = await client.query<{ id: string }>(
'select id from profiles where couple_id = $1',
[profile.couple_id],
);
if (members.rows.length > 1) {
throw new CoupleError('invite_already_joined', 'This invite has already been joined', 409);
}
if (members.rows.length !== 1 || members.rows[0]?.id !== userId) {
throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400);
}
// At this point the couple has precisely the authenticated creator as a
// member. Removing it cannot delete paired data. Answers made before
// invite creation stay sealed; answers made while waiting are coupled
// and are deleted with the cancelled invite.
await client.query('delete from couples where id = $1', [profile.couple_id]);
});
}
async close(): Promise<void> {
await closeResolvedPool(this.pool, this.ownsPool);
}
+5
View File
@@ -50,4 +50,9 @@ export function registerCoupleRoutes(app: AuthenticatedApp, service: CoupleServi
const result = await route(() => service.cancel(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
});
app.delete('/v1/couple/invite', async (c) => {
const result = await route(() => service.cancelInvite(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
});
}
+5
View File
@@ -41,4 +41,9 @@ export class CoupleService {
await this.repository.cancel(userId);
return { success: true, couple_id: null };
}
async cancelInvite(userId: string): Promise<CoupleMutationResponse> {
await this.repository.cancelInvite(userId);
return { success: true, couple_id: null };
}
}
+1
View File
@@ -19,6 +19,7 @@ export type CoupleErrorCode =
| 'invalid_invite_code'
| 'couple_full'
| 'not_paired'
| 'invite_already_joined'
| 'invite_code_collision';
export class CoupleError extends Error {
@@ -68,6 +68,56 @@ describe.skipIf(!databaseUrl || !localDatabase)('PostgresCoupleRepository', () =
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0);
});
it('cancels a waiting invite only for its creator', async () => {
const coupleId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
const pack = 'abababab-abab-4bab-8bab-abababababab';
const sealedQuestion = 'acacacac-acac-4cac-8cac-acacacacacac';
const waitingQuestion = 'adadadad-adad-4dad-8dad-adadadadadad';
await pool.query('insert into question_packs(id, name) values ($1, $2)', [pack, 'Invite cancellation']);
await pool.query(
'insert into questions(id, pack_id, text) values ($1, $3, $2), ($4, $3, $5)',
[sealedQuestion, 'Sealed before invite', pack, waitingQuestion, 'Answered while waiting'],
);
await pool.query(
"insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, null, 'yes')",
[randomUUID(), charlie, sealedQuestion],
);
await repository.create(charlie, coupleId, 'WAIT2345');
await pool.query(
"insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, $4, 'no')",
[randomUUID(), charlie, waitingQuestion, coupleId],
);
await repository.cancelInvite(charlie);
await expect(repository.getState(charlie)).resolves.toEqual({ couple: null, partner: null, sealed_count: 1 });
expect((await pool.query('select count(*)::int as count from couples where id = $1', [coupleId])).rows[0].count).toBe(0);
expect((await pool.query('select count(*)::int as count from responses where question_id = $1', [sealedQuestion])).rows[0].count).toBe(1);
expect((await pool.query('select count(*)::int as count from responses where question_id = $1', [waitingQuestion])).rows[0].count).toBe(0);
});
it('never deletes a joined couple through the invite cancellation path', async () => {
const coupleId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
await repository.create(alice, coupleId, 'SAFE2345');
await repository.join(bob, 'SAFE2345');
const before = await pool.query<{ id: string; couple_id: string | null }>(
'select id, couple_id from profiles where id in ($1, $2) order by id',
[alice, bob],
);
await expect(repository.cancelInvite(alice)).rejects.toMatchObject({
code: 'invite_already_joined',
status: 409,
});
const after = await pool.query<{ id: string; couple_id: string | null }>(
'select id, couple_id from profiles where id in ($1, $2) order by id',
[alice, bob],
);
expect(after.rows).toEqual(before.rows);
expect((await pool.query('select count(*)::int as count from couples where id = $1', [coupleId])).rows[0].count).toBe(1);
});
it('reports a solo answerer\'s sealed count before any couple exists', async () => {
const pack = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
const solo = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
+26
View File
@@ -4,6 +4,7 @@ import type { AuthIdentity } from '../src/auth.js';
import type { CoupleRepository } from '../src/domains/couples/repository.js';
import { registerCoupleRoutes } from '../src/domains/couples/routes.js';
import { CoupleService } from '../src/domains/couples/service.js';
import { CoupleError } from '../src/domains/couples/types.js';
const identity: AuthIdentity = {
id: '11111111-1111-4111-8111-111111111111',
@@ -25,6 +26,7 @@ function setup() {
invite_code: inviteCode,
created_at: '2026-08-27T00:00:00.000Z',
})),
cancelInvite: vi.fn(async () => undefined),
cancel: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
};
@@ -80,4 +82,28 @@ describe('couple routes', () => {
expect(await response.json()).toEqual({ success: true, couple_id: null });
expect(repository.cancel).toHaveBeenCalledWith(identity.id);
});
it('cancels only the authenticated user\'s waiting invite on the dedicated route', async () => {
const { app, repository } = setup();
const response = await app.request('/v1/couple/invite', { method: 'DELETE' });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ success: true, couple_id: null });
expect(repository.cancelInvite).toHaveBeenCalledWith(identity.id);
expect(repository.cancel).not.toHaveBeenCalled();
});
it('does not turn a joined invite into relationship deletion', async () => {
const { app, repository } = setup();
vi.mocked(repository.cancelInvite).mockRejectedValueOnce(
new CoupleError('invite_already_joined', 'This invite has already been joined', 409),
);
const response = await app.request('/v1/couple/invite', { method: 'DELETE' });
expect(response.status).toBe(409);
expect(await response.json()).toEqual({
error: { code: 'invite_already_joined', message: 'This invite has already been joined' },
});
expect(repository.cancel).not.toHaveBeenCalled();
});
});
+10
View File
@@ -15,6 +15,7 @@ function repository(overrides: Partial<CoupleRepository> = {}): CoupleRepository
getState: vi.fn(async () => ({ couple: null, partner: null, sealed_count: 0 })),
create: vi.fn(async () => couple),
join: vi.fn(async () => couple),
cancelInvite: vi.fn(async () => undefined),
cancel: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
...overrides,
@@ -61,4 +62,13 @@ describe('CoupleService', () => {
expect(create).toHaveBeenCalledTimes(2);
expect(create.mock.calls.every(([userId]) => userId === 'user-one')).toBe(true);
});
it('uses the invite-only cancellation operation for a waiting creator', async () => {
const repo = repository();
const service = new CoupleService(repo);
await expect(service.cancelInvite('user-one')).resolves.toEqual({ success: true, couple_id: null });
expect(repo.cancelInvite).toHaveBeenCalledWith('user-one');
expect(repo.cancel).not.toHaveBeenCalled();
});
});
+4 -1
View File
@@ -179,7 +179,10 @@ describe.skipIf(!url || !local)('dares + PostgreSQL', () => {
const dir = new URL('../drizzle/', import.meta.url);
try {
await migrate(drizzle(migrationPool), { migrationsFolder: fileURLToPath(dir) });
await migrate(drizzle(migrationPool), {
migrationsFolder: fileURLToPath(dir),
migrationsSchema: migrationSchema,
});
const discovered = await migrationPool.query<{ count: string }>(
"select count(*)::text as count from dares where id between '1f5e0000-0000-4000-8000-000000000001'::uuid and '1f5e0000-0000-4000-8000-000000000150'::uuid",
);
+3 -1
View File
@@ -76,10 +76,11 @@ export default function AppLayout() {
const segmentStrings = segments as string[];
const isOnOnboarding = segmentStrings.includes("onboarding");
const isOnPairing = segmentStrings.includes("pairing");
const isOnPaired = segmentStrings.includes("paired");
const isOnChat = segmentStrings.includes("chat");
const isOnLiveDraw = segmentStrings.includes("live-draw");
const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2;
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnLiveDraw || isOnSettingsSubscreen;
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnPaired || isOnChat || isOnLiveDraw || isOnSettingsSubscreen;
// Redirect to login when not authenticated, or to onboarding if not completed/outdated
useEffect(() => {
@@ -367,6 +368,7 @@ export default function AppLayout() {
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen name="paired" options={{ href: null }} />
<Tabs.Screen
name="chat/[id]"
options={{
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useRef, useState } from "react";
import { AccessibilityInfo, ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { router, useLocalSearchParams } from "expo-router";
import { GradientBackground, GlassButton } from "../../src/components/ui";
import { colors, gradients, spacing, typography } from "../../src/theme";
import { useAuthStore, useMatchStore } from "../../src/store";
import { markPairedUnlockSeen } from "../../src/lib/pairedUnlockSeen";
import { Events } from "../../src/lib/analytics";
export const POST_PAIR_ENTRY = "/(app)/swipe" as const;
const bucket = (n: number) => n === 0 ? "0" : n <= 2 ? "1-2" : n <= 5 ? "3-5" : n <= 10 ? "6-10" : n <= 25 ? "11-25" : "26+";
export default function PairedScreen() {
const { user, couple, partner } = useAuthStore();
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(() => {
let cancelled = false;
void fetchMatches(true).then((success) => {
if (cancelled) return;
setLoadedSuccessfully(success);
setReady(true);
});
return () => { cancelled = true; };
}, [fetchMatches]);
useEffect(() => {
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, loadedSuccessfully, user, couple, newMatchesCount]);
const partnerName = partner?.name?.split(" ")[0] || "your partner";
const viewMatches = () => { Events.pairingUnlockCtaTapped(role, "view_matches"); router.replace("/(app)/matches"); };
const start = () => { Events.pairingUnlockCtaTapped(role, "start_session"); router.replace(POST_PAIR_ENTRY); };
const dismiss = () => { Events.pairingUnlockCtaTapped(role, "dismiss"); router.replace("/(app)"); };
if (!ready) {
return <GradientBackground><View style={styles.container}><ActivityIndicator size="large" color={colors.primary} /></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>
<Text ref={heading} accessibilityRole="header" style={styles.title}>You're paired with {partnerName}</Text>
{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>
</View></GradientBackground>;
}
const styles = StyleSheet.create({ container:{flex:1,justifyContent:"center",padding:spacing.xl,gap:spacing.lg,alignItems:"center"}, icon:{width:88,height:88,borderRadius:44,alignItems:"center",justifyContent:"center"}, title:{...typography.largeTitle,color:colors.text,textAlign:"center"}, count:{...typography.title2,color:colors.premium.rose,textAlign:"center"}, body:{...typography.callout,color:colors.textSecondary,textAlign:"center"}, dismiss:{minHeight:44,justifyContent:"center",paddingHorizontal:spacing.lg}, dismissText:{...typography.subhead,color:colors.textSecondary} });
+216 -79
View File
@@ -10,7 +10,8 @@ import {
Linking,
KeyboardAvoidingView,
Platform,
TouchableOpacity,
TouchableOpacity,
AccessibilityInfo,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
@@ -26,27 +27,45 @@ import { GradientBackground, GlassCard, GlassButton } from "../../src/components
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
import { isValidInviteCode, normalizeInviteCode } from "../../src/lib/inviteLink";
import { getPendingInviteCode, clearPendingInviteCode } from "../../src/lib/pendingInviteCode";
import { checkClipboardForInviteCode } from "../../src/lib/clipboardInviteOffer";
import { checkClipboardForInviteCode } from "../../src/lib/clipboardInviteOffer";
import { ApiError } from "../../src/lib/apiClient";
import { hasSeenPairedUnlock } from "../../src/lib/pairedUnlockSeen";
import { PAIRING_PRIVACY_SUMMARY } from "../../src/lib/pairingPrivacyCopy";
export default function PairingScreen() {
const { fetchCouple, fetchUser, couple, partner, sealedCount, isLoading: isAuthLoading } = useAuthStore();
const params = useLocalSearchParams<{ code?: string }>();
const { fetchCouple, fetchUser, couple, partner, sealedCount, user, isLoading: isAuthLoading } = useAuthStore();
const params = useLocalSearchParams<{ code?: string; incomingCode?: string }>();
const [inviteCode, setInviteCode] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [wasPrefilled, setWasPrefilled] = useState(false);
const [clipboardOfferCode, setClipboardOfferCode] = useState<string | null>(null);
const [prefillAttempted, setPrefillAttempted] = useState(false);
const [prefillAttempted, setPrefillAttempted] = useState(false);
const [joinError, setJoinError] = useState<string | null>(null);
const [isPollOffline, setIsPollOffline] = useState(false);
const [isIncomingConflictDismissed, setIsIncomingConflictDismissed] = useState(false);
// Redirect if already paired. Pairing claims both members' sealed answers and
// computes matches server-side, so the matches store must refetch here too:
// whichever partner lands on this redirect first would otherwise show stale,
// pre-pairing matches until some unrelated screen happened to refresh it.
useEffect(() => {
if (couple && partner) {
void useMatchStore.getState().fetchMatches(true);
router.replace("/(app)");
}
}, [couple, partner]);
useEffect(() => {
if (!couple || !partner || !user) return;
let cancelled = false;
void (async () => {
if (typeof params.incomingCode === "string") {
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)") }]);
}
return;
}
const seen = await hasSeenPairedUnlock(user.id, couple.id);
if (!cancelled) {
router.replace(seen ? "/(app)" : "/(app)/paired" as any);
}
})();
return () => { cancelled = true; };
}, [couple, partner, user, params.incomingCode]);
// One-time prefill: apply a code carried by a join link (route param), then
// a stashed code from before sign-in, and only otherwise offer a clipboard
@@ -68,15 +87,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;
}
@@ -91,7 +110,7 @@ export default function PairingScreen() {
return () => {
cancelled = true;
};
}, [couple, params.code, prefillAttempted]);
}, [couple, params.code, prefillAttempted, user?.id]);
const acceptClipboardOffer = () => {
if (!clipboardOfferCode) return;
@@ -113,7 +132,7 @@ export default function PairingScreen() {
// Poll every 5 seconds as fallback
const pollInterval = setInterval(() => {
fetchCouple();
void fetchCouple().then(() => setIsPollOffline(false)).catch(() => setIsPollOffline(true));
}, 5000);
return () => {
@@ -140,11 +159,12 @@ export default function PairingScreen() {
// Validate invite code format (8 alphanumeric characters)
const sanitizedCode = inviteCode.trim().toUpperCase();
if (!/^[A-Z0-9]{8}$/.test(sanitizedCode)) {
Alert.alert("Invalid Code", "Please enter a valid 8-character invite code.");
return;
setJoinError("That code isn't the right shape. Codes are 8 letters and numbers.");
return;
}
setIsSubmitting(true);
setIsSubmitting(true);
setJoinError(null);
try {
await coupleApi.join(sanitizedCode);
@@ -152,14 +172,26 @@ export default function PairingScreen() {
await fetchCouple(); // Fetch couple data
// Joining claims both members' sealed answers and computes matches
// server-side, so the joiner needs a fresh matches list too.
void useMatchStore.getState().fetchMatches(true);
await useMatchStore.getState().fetchMatches(true);
Events.coupleJoined();
Alert.alert("Success", "You are now paired!", [
{ text: "Let's Go", onPress: () => router.replace("/(app)") }
]);
} catch (error: any) {
Alert.alert("Error", getPairingError(error));
router.replace("/(app)/paired" as any);
} catch (error: any) {
const details = error instanceof ApiError ? error.details as { error?: { code?: string } } : undefined;
const code = details?.error?.code;
const message = code === "couple_full"
? "That code is already in use by two people."
: code === "already_paired"
? "You're already paired."
: code === "invalid_invite_code" && error.status === 404
? "We can't find that code. It may have been cancelled. Ask them to send a new invite."
: code === "invalid_invite_code"
? "That code isn't the right shape. Codes are 8 letters and numbers."
: /network|fetch|timeout/i.test(error?.message ?? "")
? "Can't reach Sauci. Check your connection and try again."
: getPairingError(error);
setJoinError(message);
AccessibilityInfo.announceForAccessibility(message);
} finally {
setIsSubmitting(false);
}
@@ -169,13 +201,14 @@ export default function PairingScreen() {
if (couple?.invite_code) {
await Clipboard.setStringAsync(couple.invite_code);
Events.inviteCodeCopied();
Alert.alert("Copied", "Invite code copied to clipboard");
Alert.alert("Copied", "Invite code copied to clipboard");
}
};
const handleInviteCodeChange = (value: string) => {
setWasPrefilled(false);
setInviteCode(value);
const handleInviteCodeChange = (value: string) => {
setWasPrefilled(false);
setInviteCode(value);
setJoinError(null);
};
const shareMessage = couple?.invite_code
@@ -204,39 +237,100 @@ export default function PairingScreen() {
}
};
const shareViaSMS = async () => {
const url = `sms:&body=${encodeURIComponent(shareMessage)}`;
await Linking.openURL(url);
Events.codeShared();
};
const shareViaSMS = async () => {
const url = `sms:&body=${encodeURIComponent(shareMessage)}`;
await Linking.openURL(url);
Events.codeShared();
};
const completeJoinedInvite = async () => {
await fetchUser();
await fetchCouple();
await useMatchStore.getState().fetchMatches(true);
router.replace("/(app)/paired" as any);
};
const cancelInvite = async (afterCancel?: () => Promise<void>) => {
try {
await coupleApi.cancelInvite();
await fetchUser();
await fetchCouple();
if (afterCancel) await afterCancel();
Events.pairingCancelled();
return true;
} catch (error: any) {
const apiCode = error instanceof ApiError
? (error.details as { error?: { code?: string } } | undefined)?.error?.code
: undefined;
if (apiCode === "invite_already_joined") {
await completeJoinedInvite();
return false;
}
Alert.alert(
"Couldn't cancel invite",
"Your invite may still be active. Check your connection and try again.",
[{ text: "Not now", style: "cancel" }, { text: "Try again", onPress: () => { void cancelInvite(afterCancel); } }],
);
return false;
}
};
const copyInviteLink = async () => {
if (couple?.invite_code) {
await Clipboard.setStringAsync(`https://sauci.app/join/${couple.invite_code}`);
}
};
const handleCancelPairing = async () => {
const handleCancelPairing = async () => {
Alert.alert(
"Cancel Pairing",
"Are you sure you want to cancel? Your invite code will be deleted.",
"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 Waiting", style: "cancel" },
{ text: "Keep invite", style: "cancel" },
{
text: "Cancel Pairing",
style: "destructive",
onPress: async () => {
setIsSubmitting(true);
try {
await coupleApi.cancel();
await fetchUser();
await fetchCouple();
Events.pairingCancelled();
} catch (error: any) {
Alert.alert("Error", getPairingError(error));
} finally {
text: "Cancel invite",
style: "destructive",
onPress: async () => {
setIsSubmitting(true);
try {
await cancelInvite();
} finally {
setIsSubmitting(false);
}
},
},
]
);
};
};
const incomingCode = typeof params.incomingCode === "string" && isValidInviteCode(normalizeInviteCode(params.incomingCode))
? normalizeInviteCode(params.incomingCode)
: null;
const handleCancelOwnAndPrefill = () => {
if (!incomingCode) return;
Alert.alert(
"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(user?.id) },
{
text: "Cancel mine and join", style: "destructive", onPress: async () => {
setIsSubmitting(true);
try {
await cancelInvite(async () => {
setInviteCode(incomingCode);
setWasPrefilled(true);
await clearPendingInviteCode(user?.id);
});
} finally {
setIsSubmitting(false);
}
},
},
],
);
};
if (isAuthLoading) {
return (
@@ -265,11 +359,23 @@ export default function PairingScreen() {
>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={styles.title}>Partner Code</Text>
<Text style={styles.title}>Your invite</Text>
</Animated.View>
<View style={styles.content}>
{/* Icon */}
<View style={styles.content}>
{incomingCode && !isIncomingConflictDismissed && (
<GlassCard variant="elevated">
<Text style={styles.conflictTitle}>Use this code instead?</Text>
<Text style={styles.conflictBody}>You have your own invite out. To join theirs, cancel yours first. Your code will stop working, and answers you've given since you created it will be deleted.</Text>
<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(user?.id); }} style={styles.cancelButton} testID="pairing-conflict-keep-own">
<Text style={styles.cancelButtonText}>Keep my invite</Text>
</TouchableOpacity>
</GlassCard>
)}
{/* Icon */}
<Animated.View
entering={FadeInDown.delay(200).duration(500)}
style={styles.iconSection}
@@ -283,7 +389,7 @@ export default function PairingScreen() {
<Ionicons name="heart" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.subtitle}>
Share this code with your partner to link your accounts
Your answers are ready to compare when you both answer
</Text>
</Animated.View>
@@ -295,8 +401,7 @@ export default function PairingScreen() {
>
<GlassCard variant="elevated">
<Text style={styles.sealedCountText} testID="pairing-sealed-count">
You have already answered {sealedCount} question{sealedCount === 1 ? "" : "s"} about us.
They will unlock the moment your partner joins.
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>
@@ -313,12 +418,12 @@ export default function PairingScreen() {
onPress={copyToClipboard}
activeOpacity={0.7}
>
<Text style={styles.code}>{couple.invite_code.toUpperCase()}</Text>
<Text style={styles.code} accessibilityLabel={`Invite code ${couple.invite_code.toUpperCase().split("").join(" ")}`}>{couple.invite_code.toUpperCase()}</Text>
<View style={styles.copyIcon}>
<Ionicons name="copy-outline" size={20} color={colors.textSecondary} />
</View>
</TouchableOpacity>
<Text style={styles.tapToCopy}>Tap to copy</Text>
<Text style={styles.tapToCopy}>Tap to copy your code</Text>
</GlassCard>
</Animated.View>
@@ -341,7 +446,7 @@ export default function PairingScreen() {
size="lg"
icon={<Ionicons name="share-outline" size={22} color={colors.text} />}
>
Share Invite Code
Share invite
</GlassButton>
</Animated.View>
@@ -354,10 +459,14 @@ export default function PairingScreen() {
<Ionicons name="chatbubble-outline" size={20} color={colors.text} />
<Text style={styles.quickShareLabel}>Messages</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.quickShareButton} onPress={shareViaWhatsApp} activeOpacity={0.7}>
<TouchableOpacity style={styles.quickShareButton} onPress={shareViaWhatsApp} activeOpacity={0.7}>
<Ionicons name="logo-whatsapp" size={20} color={colors.text} />
<Text style={styles.quickShareLabel}>WhatsApp</Text>
</TouchableOpacity>
</TouchableOpacity>
<TouchableOpacity style={styles.quickShareButton} onPress={copyInviteLink} activeOpacity={0.7} testID="pairing-copy-link" accessibilityLabel="Copy invite link">
<Ionicons name="link-outline" size={20} color={colors.text} />
<Text style={styles.quickShareLabel}>Copy link</Text>
</TouchableOpacity>
</Animated.View>
{/* Waiting indicator */}
@@ -367,8 +476,9 @@ export default function PairingScreen() {
>
<View style={styles.waitingBadge}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.waitingText}>Waiting for your partner to join...</Text>
</View>
<Text style={styles.waitingText}>Waiting for them to join</Text>
</View>
{isPollOffline && <Text style={styles.warningText} accessibilityLiveRegion="polite">Can't reach Sauci right now. Your code still works.</Text>}
</Animated.View>
{/* Cancel Button */}
@@ -382,7 +492,7 @@ export default function PairingScreen() {
style={styles.cancelButton}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel Pairing</Text>
<Text style={styles.cancelButtonText}>Cancel invite</Text>
</TouchableOpacity>
</Animated.View>
</View>
@@ -427,7 +537,7 @@ export default function PairingScreen() {
<Ionicons name="link" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.subtitle}>
Link with your partner to start matching
{PAIRING_PRIVACY_SUMMARY}
</Text>
</Animated.View>
@@ -481,16 +591,21 @@ export default function PairingScreen() {
/>
{wasPrefilled && (
<Text style={styles.prefilledCaption}>
Code applied from your invite link
</Text>
)}
Code from your invite link
</Text>
)}
{joinError && (
<Text style={styles.joinError} accessibilityLiveRegion="polite" testID="pairing-join-error">
{joinError}
</Text>
)}
<GlassButton
onPress={handleJoinCouple}
disabled={isSubmitting}
loading={isSubmitting}
fullWidth
>
Join Partner
Join your partner
</GlassButton>
</GlassCard>
</Animated.View>
@@ -516,7 +631,7 @@ export default function PairingScreen() {
disabled={isSubmitting}
fullWidth
>
Create New Code
Create invite code
</GlassButton>
</Animated.View>
</View>
@@ -668,7 +783,7 @@ const styles = StyleSheet.create({
textAlign: "center",
maxWidth: 280,
},
sealedCountText: {
sealedCountText: {
...typography.body,
color: colors.text,
textAlign: "center",
@@ -719,10 +834,32 @@ const styles = StyleSheet.create({
marginTop: spacing.xl,
},
waitingText: {
...typography.subhead,
color: colors.textTertiary,
},
waitingText: {
...typography.subhead,
color: colors.textTertiary,
},
conflictTitle: {
...typography.title3,
color: colors.text,
marginBottom: spacing.sm,
},
conflictBody: {
...typography.callout,
color: colors.textSecondary,
marginBottom: spacing.md,
},
warningText: {
...typography.caption1,
color: colors.warning,
textAlign: "center",
marginTop: spacing.sm,
},
joinError: {
...typography.callout,
color: colors.error,
marginBottom: spacing.md,
textAlign: "center",
},
cancelSection: {
alignItems: "center",
marginTop: spacing.xl,
+33 -46
View File
@@ -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
@@ -37,7 +37,8 @@ if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
};
}
import { Stack, router, usePathname } from "expo-router";
import { Stack, router, usePathname } from "expo-router";
import { ReduceMotion, ReducedMotionConfig } from "react-native-reanimated";
import { StatusBar } from "expo-status-bar";
import { Ionicons } from "@expo/vector-icons";
// GestureHandlerRootView is only needed on native platforms
@@ -50,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";
@@ -58,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
@@ -89,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) => {
@@ -129,14 +113,20 @@ export default function RootLayout() {
// are handled separately from the Supabase auth deep link flow below.
const inviteLink = parseInviteLinkCode(url);
if (inviteLink) {
Events.inviteLinkOpened(inviteLink.source);
await stashPendingInviteCode(inviteLink.code);
const { isAuthenticated, user } = useAuthStore.getState();
const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version);
if (isAuthenticated && !onboardingPending && !user?.couple_id) {
router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } });
}
Events.inviteLinkOpened(inviteLink.source);
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(user.id));
if (user.couple_id) {
router.push({ pathname: "/(app)/pairing", params: { incomingCode: inviteLink.code } });
}
} else {
await stashPendingInviteCode(inviteLink.code, user?.id ?? null);
if (isAuthenticated && !onboardingPending) {
router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } });
}
}
return;
}
@@ -264,7 +254,8 @@ export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<GestureHandlerRootView style={{ flex: 1 }}>
<GestureHandlerRootView style={{ flex: 1 }}>
<ReducedMotionConfig mode={ReduceMotion.System} />
<StatusBar style="light" />
<Stack
screenOptions={{
@@ -305,22 +296,18 @@ export default function RootLayout() {
<View style={styles.guestWarningActions}>
<TouchableOpacity
style={styles.guestWarningPrimary}
onPress={async () => {
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}
>
<Text style={styles.guestWarningPrimaryText}>Save account</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.guestWarningSecondary}
onPress={async () => {
await markGuestAccountWarningSeen();
setShowGuestWarning(false);
}}
style={styles.guestWarningSecondary}
onPress={dismissGuestWarning}
activeOpacity={0.8}
>
<Text style={styles.guestWarningSecondaryText}>Not now</Text>
+8 -8
View File
@@ -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" />;
}
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { isAbsolute } from 'node:path';
const requiredProductionRevenueCatConfig = [
['EXPO_PUBLIC_REVENUECAT_IOS_API_KEY', 'appl_'],
['EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY', 'goog_'],
['EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID', null],
];
function fail(message) {
console.error(`EOAS Expo export guard: ${message}`);
process.exit(1);
}
for (const [key, prefix] of requiredProductionRevenueCatConfig) {
const value = process.env[key];
const expected = process.env[`SAUCI_EOAS_EXPECTED_${key}`];
if (typeof value !== 'string' || !value.trim()) fail(`${key} is required`);
if (prefix && !value.startsWith(prefix)) fail(`${key} must be a valid RevenueCat public key`);
if (value !== expected) fail(`${key} does not match the resolved production profile`);
}
const npx = process.env.SAUCI_EOAS_NPX;
if (!npx || !isAbsolute(npx)) fail('missing resolved npx executable');
const result = spawnSync(npx, process.argv.slice(2), { env: process.env, stdio: 'inherit' });
if (result.error) throw result.error;
process.exit(result.status ?? 1);
+103 -9
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { accessSync, constants, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { delimiter, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
@@ -27,6 +27,13 @@ const requiredPublicKeys = [
'EXPO_PUBLIC_SUPABASE_URL',
'EXPO_PUBLIC_SUPABASE_ANON_KEY',
];
const requiredProductionRevenueCatConfig = [
['EXPO_PUBLIC_REVENUECAT_IOS_API_KEY', 'appl_'],
['EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY', 'goog_'],
['EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID', null],
];
const EOAS_PACKAGE_RUNNER = 'sauci-eoas-npx';
const EOAS_RUNNER_BIN_DIR = join(projectDir, 'scripts', 'bin');
function fail(message) {
throw new Error(`OTA publish preflight: ${message}`);
@@ -67,9 +74,87 @@ export function resolvePublishEnvironment(target, environment = process.env, con
requireCanonicalHttpsRoot(env.EXPO_PUBLIC_SUPABASE_URL, profile.authOrigin, `${target} Auth URL`);
requireCanonicalHttpsRoot(env.EXPO_PUBLIC_API_URL, profile.apiOrigin, `${target} API URL`);
if (target === 'production') {
for (const [key, prefix] of requiredProductionRevenueCatConfig) {
const value = env[key];
if (typeof value !== 'string' || !value.trim()) fail(`${key} is required for production export`);
if (prefix && !value.startsWith(prefix)) {
fail(`${key} must be a valid ${prefix} RevenueCat public key`);
}
}
}
return { branch: profile.branch, env };
}
function exportFiles(outputDir) {
return readdirSync(outputDir, { withFileTypes: true }).flatMap((entry) => {
const entryPath = join(outputDir, entry.name);
return entry.isDirectory() ? exportFiles(entryPath) : [entryPath];
});
}
/**
* Expo inlines EXPO_PUBLIC_* values into the JavaScript bundle. Verify the
* isolated production export rather than trusting only the process environment:
* a missing key otherwise becomes an empty string on installed devices.
*/
export function assertProductionExportContainsRevenueCatConfig(outputDir, environment) {
const files = exportFiles(outputDir).filter((filePath) => statSync(filePath).isFile());
for (const [key] of requiredProductionRevenueCatConfig) {
const expectedValue = environment[key];
const present = files.some((filePath) => readFileSync(filePath).includes(expectedValue));
if (!present) fail(`production export does not contain ${key}`);
}
}
function resolveExecutable(command, pathValue) {
for (const directory of pathValue.split(delimiter)) {
if (!directory) continue;
const candidate = join(directory, command);
try {
accessSync(candidate, constants.X_OK);
return candidate;
} catch {
// Continue looking through PATH.
}
}
fail(`could not resolve ${command} before configuring the EOAS package runner`);
}
/**
* EOAS starts its own Expo subprocess with EXPO_NO_DOTENV=1. Pin its package
* runner to our checked-in wrapper so the actual EOAS export fails before it
* starts if its inherited RevenueCat configuration differs from this profile.
*/
export function buildEoasEnvironment(environment) {
const originalPath = environment.PATH || process.env.PATH || '';
const expected = Object.fromEntries(requiredProductionRevenueCatConfig.map(([key]) => [
`SAUCI_EOAS_EXPECTED_${key}`,
environment[key],
]));
return {
...environment,
EXPO_NO_DOTENV: '1',
EOAS_PACKAGE_RUNNER,
PATH: `${EOAS_RUNNER_BIN_DIR}${delimiter}${originalPath}`,
SAUCI_EOAS_NPX: resolveExecutable('npx', originalPath),
...expected,
};
}
export function buildEoasPublishArgs(branch, rawPublishArgs, useProductionGuard = false) {
const args = [
'eoas',
'publish',
'--branch',
branch,
...parsePublishArgs(rawPublishArgs),
];
if (useProductionGuard) args.splice(4, 0, '--packageRunner', EOAS_PACKAGE_RUNNER);
return args;
}
export function parsePublishArgs(args) {
const parsed = [];
for (let index = 0; index < args.length; index += 1) {
@@ -112,6 +197,18 @@ function run(command, args, env) {
if (result.status !== 0) process.exit(result.status ?? 1);
}
function exportAndVerify(target, environment) {
const outputDir = mkdtempSync(join(tmpdir(), `sauci-ota-${target}-`));
try {
run('npx', ['expo', 'export', '--output-dir', outputDir], environment);
if (target === 'production') {
assertProductionExportContainsRevenueCatConfig(outputDir, environment);
}
} finally {
rmSync(outputDir, { recursive: true, force: true });
}
}
export function main(args = process.argv.slice(2), environment = process.env) {
const [target, ...remaining] = args;
const preflight = remaining.includes('--preflight');
@@ -122,12 +219,7 @@ export function main(args = process.argv.slice(2), environment = process.env) {
const { branch, env } = resolvePublishEnvironment(target, environment);
const exportEnv = { ...env, EXPO_NO_DOTENV: '1' };
if (preflight) {
const outputDir = mkdtempSync(join(tmpdir(), `sauci-ota-${target}-`));
try {
run('npx', ['expo', 'export', '--output-dir', outputDir], exportEnv);
} finally {
rmSync(outputDir, { recursive: true, force: true });
}
exportAndVerify(target, exportEnv);
console.log(`OTA ${target} export preflight passed`);
return;
}
@@ -138,7 +230,9 @@ export function main(args = process.argv.slice(2), environment = process.env) {
return;
}
run('npx', ['eoas', 'publish', '--branch', branch, ...parsePublishArgs(rawPublishArgs)], exportEnv);
exportAndVerify(target, exportEnv);
const eoasEnvironment = target === 'production' ? buildEoasEnvironment(exportEnv) : exportEnv;
run('npx', buildEoasPublishArgs(branch, rawPublishArgs, target === 'production'), eoasEnvironment);
}
// Compare real filesystem paths. A URL pathname percent-encodes spaces, so
+88 -1
View File
@@ -1,6 +1,17 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parsePublishArgs, resolvePublishEnvironment } from './ota-publish.mjs';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
assertProductionExportContainsRevenueCatConfig,
buildEoasEnvironment,
buildEoasPublishArgs,
parsePublishArgs,
resolvePublishEnvironment,
} from './ota-publish.mjs';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const config = {
build: {
@@ -9,6 +20,9 @@ const config = {
EXPO_PUBLIC_API_URL: 'https://api.sauci.app',
EXPO_PUBLIC_SUPABASE_URL: 'https://ckjcrkjpmhqhiucifukx.supabase.co',
EXPO_PUBLIC_SUPABASE_ANON_KEY: 'production-anon-key',
EXPO_PUBLIC_REVENUECAT_IOS_API_KEY: 'appl_test_ios_public_key',
EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY: 'goog_test_android_public_key',
EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID: 'Sauci Pro',
},
},
preview: {
@@ -28,6 +42,79 @@ test('production export uses the checked-in production profile', () => {
assert.equal(env.EXPO_PUBLIC_API_URL, 'https://api.sauci.app');
});
test('production export fails closed without valid RevenueCat public configuration', () => {
for (const [key, value] of [
['EXPO_PUBLIC_REVENUECAT_IOS_API_KEY', ''],
['EXPO_PUBLIC_REVENUECAT_IOS_API_KEY', 'goog_wrong_platform_key'],
['EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY', ''],
['EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY', 'appl_wrong_platform_key'],
['EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID', ''],
]) {
const productionEnv = { ...config.build.production.env, [key]: value };
assert.throws(
() => resolvePublishEnvironment('production', {}, {
build: { ...config.build, production: { env: productionEnv } },
}),
new RegExp(key),
);
}
});
test('production preflight proves the compiled bundle contains RevenueCat configuration', () => {
const outputDir = mkdtempSync(join(tmpdir(), 'sauci-ota-export-test-'));
const environment = config.build.production.env;
try {
writeFileSync(
join(outputDir, 'index.js'),
[
environment.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY,
environment.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY,
environment.EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID,
].join(','),
);
assert.doesNotThrow(() => assertProductionExportContainsRevenueCatConfig(outputDir, environment));
writeFileSync(join(outputDir, 'index.js'), environment.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY);
assert.throws(
() => assertProductionExportContainsRevenueCatConfig(outputDir, environment),
/EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY/,
);
} finally {
rmSync(outputDir, { recursive: true, force: true });
}
});
test('EOAS uses a guarded package runner that receives the resolved production environment', () => {
const eoasEnvironment = buildEoasEnvironment({ ...config.build.production.env, PATH: process.env.PATH });
assert.equal(eoasEnvironment.EOAS_PACKAGE_RUNNER, 'sauci-eoas-npx');
assert.match(eoasEnvironment.PATH, /apps\/mobile\/scripts\/bin/);
assert.equal(eoasEnvironment.SAUCI_EOAS_EXPECTED_EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID, 'Sauci Pro');
const runnerPath = fileURLToPath(new URL('./bin/sauci-eoas-npx', import.meta.url));
const successful = spawnSync(process.execPath, [runnerPath, 'expo', 'export'], {
env: { ...eoasEnvironment, SAUCI_EOAS_NPX: '/usr/bin/true' },
encoding: 'utf8',
});
assert.equal(successful.status, 0, successful.stderr);
const missingKey = spawnSync(process.execPath, [runnerPath, 'expo', 'export'], {
env: { ...eoasEnvironment, EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY: '', SAUCI_EOAS_NPX: '/usr/bin/true' },
encoding: 'utf8',
});
assert.equal(missingKey.status, 1);
assert.match(missingKey.stderr, /EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY/);
assert.doesNotMatch(missingKey.stderr, /goog_test_android_public_key/);
assert.deepEqual(
buildEoasPublishArgs('production', ['--platform', 'all'], true),
['eoas', 'publish', '--branch', 'production', '--packageRunner', 'sauci-eoas-npx', '--platform', 'all'],
);
assert.deepEqual(
buildEoasPublishArgs('staging', ['--platform', 'ios']),
['eoas', 'publish', '--branch', 'staging', '--platform', 'ios'],
);
});
test('staging export requires the designated non-production Auth configuration', () => {
const environment = {
EXPO_PUBLIC_SUPABASE_URL: 'https://itbzhrvlgvdmzbnhzhyx.supabase.co',
@@ -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<void>((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<void> | 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));
});
});
@@ -12,12 +12,14 @@ describe("coupleApi", () => {
await coupleApi.getState();
await coupleApi.create();
await coupleApi.join("ABCD2345");
await coupleApi.cancelInvite();
await coupleApi.cancel();
expect(request.mock.calls).toEqual([
["/v1/couple"],
["/v1/couple", { method: "POST", body: {} }],
["/v1/couple", { method: "POST", body: { invite_code: "ABCD2345" } }],
["/v1/couple/invite", { method: "DELETE" }],
["/v1/couple", { method: "DELETE" }],
]);
});
@@ -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);
});
});
@@ -1,22 +1,15 @@
import { buildInviteShareMessage } from "../../lib/inviteShareCopy";
describe("buildInviteShareMessage", () => {
it("leads with a plain invite when there are no sealed answers", () => {
const message = buildInviteShareMessage("ABCD1234", 0);
expect(message).toBe(
"Join me on Sauci! Tap this link to pair up instantly: https://sauci.app/join/ABCD1234 (or enter code ABCD1234 in the app)"
);
it("keeps a recoverable bare code in a separate sentence", () => {
const message = buildInviteShareMessage("AB12CD34", 3);
expect(message).toContain("https://sauci.app/join/AB12CD34");
expect(message).toContain("enter code AB12CD34 in Sauci");
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("leads with earned value and singular wording for exactly one sealed answer", () => {
const message = buildInviteShareMessage("ABCD1234", 1);
expect(message).toBe(
"Join me on Sauci! I have already answered 1 question about us. Tap this link to unlock them: https://sauci.app/join/ABCD1234 (or enter code ABCD1234 in the app)"
);
});
it("pluralises for more than one sealed answer", () => {
const message = buildInviteShareMessage("ABCD1234", 5);
expect(message).toContain("I have already answered 5 questions about us.");
it("does not claim a sealed outcome before answers exist", () => {
expect(buildInviteShareMessage("AB12CD34", 0)).not.toContain("unlock");
});
});
@@ -0,0 +1,18 @@
jest.mock("@react-native-async-storage/async-storage", () => ({
getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(),
}));
import AsyncStorage from "@react-native-async-storage/async-storage";
import { clearPairedUnlockSeen, hasSeenPairedUnlock, markPairedUnlockSeen } from "../../lib/pairedUnlockSeen";
describe("paired unlock state", () => {
it("is scoped to both account and couple", async () => {
(AsyncStorage.getItem as jest.Mock).mockResolvedValue("true");
await hasSeenPairedUnlock("user-a", "couple-a");
await markPairedUnlockSeen("user-a", "couple-a");
await clearPairedUnlockSeen("user-a", "couple-a");
expect(AsyncStorage.getItem).toHaveBeenCalledWith("paired_unlock_seen_user-a_couple-a");
expect(AsyncStorage.setItem).toHaveBeenCalledWith("paired_unlock_seen_user-a_couple-a", "true");
expect(AsyncStorage.removeItem).toHaveBeenCalledWith("paired_unlock_seen_user-a_couple-a");
});
});
@@ -0,0 +1,8 @@
import { PAIRING_PRIVACY_SUMMARY } from "../../lib/pairingPrivacyCopy";
describe("PAIRING_PRIVACY_SUMMARY", () => {
it("explains what each response style reveals before a user answers", () => {
expect(PAIRING_PRIVACY_SUMMARY).toContain("Vote-style answers reveal shared outcomes");
expect(PAIRING_PRIVACY_SUMMARY).toContain("Text, audio, photo, and who-likely responses are shown after you both answer");
});
});
@@ -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);
});
});
@@ -8,6 +8,7 @@ import { useMessageStore } from "@/store/messageStore";
import { usePacksStore } from "@/store/packsStore";
import { useSubscriptionStore } from "@/store/subscriptionStore";
import type { Profile } from "@/types";
import AsyncStorage from "@react-native-async-storage/async-storage";
const profile = {
id: "me",
@@ -259,6 +260,7 @@ describe("authStore", () => {
it("clears sealed answers during an explicit sign-out", async () => {
useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any);
await AsyncStorage.setItem("pending_invite_code", "ABCD1234");
await useAuthStore.getState().signOut();
@@ -267,6 +269,7 @@ describe("authStore", () => {
sealedCount: 0,
isAuthenticated: false,
});
await expect(AsyncStorage.getItem("pending_invite_code")).resolves.toBeNull();
});
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', () => {
useAuthStore.getState().setPendingAppleDisplayName({ userId: 'apple-user', name: 'Ada Lovelace' });
useAuthStore.getState().clearPendingAppleDisplayName('other-user');
@@ -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))];
@@ -21,11 +21,13 @@ export const SwipeSealedAnswersBanner = ({ sealedCount, onInvitePress }: SwipeSe
onPress={onInvitePress}
activeOpacity={0.8}
testID="swipe-sealed-answers-banner"
accessibilityLabel={`Invite your partner to compare ${sealedCount} sealed answers`}
>
<Ionicons name="lock-closed" size={16} color={colors.premium.rose} />
<Text style={styles.text}>
{sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed. Your partner unlocks {sealedCount === 1 ? "it" : "them"} when they join.
</Text>
<View style={styles.copy}>
<Text style={styles.title}>{sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed for your partner</Text>
<Text style={styles.text}>When you both answer, your shared discoveries are ready to compare.</Text>
</View>
<Text style={styles.cta}>Invite</Text>
</TouchableOpacity>
);
@@ -38,11 +40,15 @@ const styles = StyleSheet.create({
gap: spacing.sm,
marginHorizontal: spacing.md,
marginBottom: spacing.sm,
paddingVertical: spacing.sm,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
borderRadius: radius.full,
borderRadius: radius.lg,
backgroundColor: colors.backgroundLight,
borderWidth: 1,
borderColor: colors.border,
},
copy: { flex: 1, gap: 2 },
title: { ...typography.subhead, color: colors.text, fontWeight: "700" },
text: {
...typography.caption1,
color: colors.textSecondary,
@@ -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<string>());
const dismissedUserIds = useRef(new Set<string>());
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 };
}
+12
View File
@@ -281,6 +281,18 @@ export const Events = {
inviteLinkOpened: (source: "universal_link" | "scheme" | "clipboard") =>
logEvent("invite_link_opened", { source }),
pairingCodePrefilled: () => logEvent("pairing_code_prefilled"),
invitePromptViewed: (surface: "swipe_banner" | "pairing_waiting" | "swipe_no_partner", sealedBucket: string) => logEvent("invite_prompt_viewed", { surface, sealed_bucket: sealedBucket }),
inviteShareOpened: (surface: "pairing_waiting", channel: "system" | "sms" | "whatsapp" | "copy_link" | "copy_code") => logEvent("invite_share_opened", { surface, channel }),
pairingJoinAttempted: (codeSource: "link" | "stash" | "clipboard" | "manual") => logEvent("pairing_join_attempted", { code_source: codeSource }),
pairingJoinFailed: (reason: "invalid_shape" | "code_not_found" | "couple_full" | "already_paired" | "network" | "unknown", codeSource: "link" | "stash" | "clipboard" | "manual") => logEvent("pairing_join_failed", { reason, code_source: codeSource }),
pairingCompleted: (role: "inviter" | "joiner", codeSource: "link" | "stash" | "clipboard" | "manual", hadSealed: boolean) => logEvent("pairing_completed", { role, code_source: codeSource, had_sealed: hadSealed }),
pairingUnlockViewed: (role: "inviter" | "joiner", unlockedBucket: string) => logEvent("pairing_unlock_viewed", { role, unlocked_bucket: unlockedBucket }),
pairingUnlockCtaTapped: (role: "inviter" | "joiner", cta: "view_matches" | "start_session" | "dismiss") => logEvent("pairing_unlock_cta_tapped", { role, cta }),
pairingCancelPrompted: (surface: "waiting" | "conflict") => logEvent("pairing_cancel_prompted", { surface }),
clipboardInviteOfferShown: () => logEvent("clipboard_invite_offer_shown"),
clipboardInviteOfferResolved: (outcome: "accepted" | "dismissed") => logEvent("clipboard_invite_offer_resolved", { outcome }),
inviteConflictShown: (state: "already_paired" | "waiting_own_code") => logEvent("invite_conflict_shown", { state }),
inviteConflictResolved: (state: "already_paired" | "waiting_own_code", choice: "cancel_and_join" | "keep_own" | "acknowledged") => logEvent("invite_conflict_resolved", { state, choice }),
// Quiz events
quizStarted: () => logEvent("quiz_started"),
+1
View File
@@ -24,4 +24,5 @@ export const coupleApi = {
body: { invite_code: inviteCode },
}),
cancel: () => apiRequest<CoupleMutationResponse>("/v1/couple", { method: "DELETE" }),
cancelInvite: () => apiRequest<CoupleMutationResponse>("/v1/couple/invite", { method: "DELETE" }),
};
+13 -29
View File
@@ -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<boolean> {
const buildKey = (userId: string): string => `${STORAGE_KEY_PREFIX}_${userId}`;
async function getStorage(key: string): Promise<boolean> {
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<void> {
async function setStorage(key: string, seen: boolean): Promise<void> {
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<boolean> {
return getStorage();
/** The recovery warning is shown at most once for each anonymous account. */
export async function hasSeenGuestAccountWarning(userId: string): Promise<boolean> {
return getStorage(buildKey(userId));
}
export async function markGuestAccountWarningSeen(): Promise<void> {
await setStorage(true);
export async function markGuestAccountWarningSeen(userId: string): Promise<void> {
await setStorage(buildKey(userId), true);
}
+3 -3
View File
@@ -5,10 +5,10 @@
* the recipient a concrete reason to join right away.
*/
export function buildInviteShareMessage(inviteCode: string, sealedCount: number): string {
const link = `https://sauci.app/join/${inviteCode} (or enter code ${inviteCode} in the app)`;
const link = `https://sauci.app/join/${inviteCode}`;
if (sealedCount > 0) {
const noun = sealedCount === 1 ? "question" : "questions";
return `Join me on Sauci! I have already answered ${sealedCount} ${noun} about us. Tap this link to unlock them: ${link}`;
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! Tap this link to pair up instantly: ${link}`;
return `Join me on Sauci! ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`;
}
+15
View File
@@ -0,0 +1,15 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
const keyFor = (userId: string, coupleId: string) => `paired_unlock_seen_${userId}_${coupleId}`;
export async function hasSeenPairedUnlock(userId: string, coupleId: string): Promise<boolean> {
return (await AsyncStorage.getItem(keyFor(userId, coupleId))) === "true";
}
export async function markPairedUnlockSeen(userId: string, coupleId: string): Promise<void> {
await AsyncStorage.setItem(keyFor(userId, coupleId), "true");
}
export async function clearPairedUnlockSeen(userId: string, coupleId: string | null): Promise<void> {
if (coupleId) await AsyncStorage.removeItem(keyFor(userId, coupleId));
}
@@ -0,0 +1 @@
export const PAIRING_PRIVACY_SUMMARY = "Vote-style answers reveal shared outcomes. Text, audio, photo, and who-likely responses are shown after you both answer.";
+52 -10
View File
@@ -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);
}
+17
View File
@@ -5,6 +5,8 @@ import { coupleApi } from "../lib/coupleApi";
import { profileSettingsApi } from "../lib/profileSettingsApi";
import { syncTimezone } from "../lib/reportedTimezone";
import { Events } from "../lib/analytics";
import { clearPairedUnlockSeen } from "../lib/pairedUnlockSeen";
import { clearPendingInviteCode } from "../lib/pendingInviteCode";
import type { Profile, Couple } from "@/types";
export interface AuthSessionSnapshot {
@@ -201,6 +203,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
signOut: async () => {
Events.signOut();
// The paired-unlock presentation is scoped to this account and current
// relationship. Remove it before local identity state is cleared.
const unlockUserId = get().user?.id;
const unlockCoupleId = get().couple?.id ?? get().user?.couple_id ?? null;
if (unlockUserId) {
await clearPairedUnlockSeen(unlockUserId, unlockCoupleId);
}
await clearPendingInviteCode(unlockUserId);
// Clear local state FIRST to ensure UI updates even if Supabase call fails
set({
user: null,
@@ -236,6 +247,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
setUser: (user) => {
const priorUserId = user === null ? get().user?.id : undefined;
const priorCoupleId = user === null ? (get().couple?.id ?? get().user?.couple_id ?? null) : null;
set({
user,
isAuthenticated: !!user,
@@ -246,6 +259,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
});
// Clear other stores when user signs out
if (user === null) {
void clearPendingInviteCode(priorUserId);
if (priorUserId) {
void clearPairedUnlockSeen(priorUserId, priorCoupleId);
}
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
+20 -11
View File
@@ -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
+76
View File
@@ -0,0 +1,76 @@
import { readFile } from 'node:fs/promises'
import { describe, expect, it } from 'vitest'
import type { CapturedNetworkRequest } from 'posthog-js'
import { redactInviteNetworkRequest, redactInvitePaths } from './providers'
const pageViewPath = new URL('./PostHogPageView.tsx', import.meta.url)
const providerPath = new URL('./providers.tsx', import.meta.url)
describe('PostHog invite privacy boundary', () => {
it('does not send a global page view for invite routes', async () => {
const source = await readFile(pageViewPath, 'utf8')
expect(source).toContain("pathname?.startsWith('/join/')")
expect(source.indexOf("pathname?.startsWith('/join/')")).toBeLessThan(source.indexOf("posthog.capture('$pageview'"))
})
it('disables automatic page-leave capture and redacts join URLs', async () => {
const source = await readFile(providerPath, 'utf8')
expect(source).toContain('capture_pageview: false')
expect(source).toContain('capture_pageleave: false')
expect(source).toContain('get_current_url: redactInviteUrl')
expect(source).toContain('before_send: redactInvitePathsBeforeSend')
expect(source).toContain('maskCapturedNetworkRequestFn: redactInviteNetworkRequest')
expect(source).toContain("url.pathname.startsWith('/join/')")
expect(source).toContain('/join/[invite]')
})
it('removes an invite code from every serialized URL or path property', () => {
const inviteCode = 'A4K9BT2Q'
const event = {
event: 'join_page_viewed',
properties: {
$current_url: `https://sauci.app/join/${inviteCode}`,
$pathname: `/join/${inviteCode}`,
$referrer: `https://sauci.app/join/${inviteCode}?source=message`,
$session_entry_url: `https://sauci.app/join/${inviteCode}`,
nested: { previous_path: `/join/${inviteCode}` },
},
}
const serialized = JSON.stringify(redactInvitePaths(event))
expect(serialized).not.toContain(inviteCode)
expect(serialized).toContain('/join/[invite]')
expect(serialized).toContain('join_page_viewed')
})
it('redacts invite codes from replay network request URLs, headers, and bodies without mutating the capture', () => {
const inviteCode = 'A4K9BT2Q'
const request = {
name: `https://sauci.app/join/${inviteCode}`,
entryType: 'resource',
startTime: 1,
duration: 2,
requestHeaders: {
referer: `https://sauci.app/join/${inviteCode}`,
},
requestBody: JSON.stringify({ invite_path: `/join/${inviteCode}` }),
responseHeaders: {
location: `https://sauci.app/join/${inviteCode}`,
},
responseBody: JSON.stringify({ referrer: `https://sauci.app/join/${inviteCode}` }),
} as CapturedNetworkRequest
const redacted = redactInviteNetworkRequest(request)
const serialized = JSON.stringify(redacted)
expect(redacted).not.toBe(request)
expect(redacted.requestHeaders).not.toBe(request.requestHeaders)
expect(redacted.responseHeaders).not.toBe(request.responseHeaders)
expect(serialized).not.toContain(inviteCode)
expect(serialized).toContain('/join/[invite]')
expect(JSON.stringify(request)).toContain(inviteCode)
})
})
+8 -4
View File
@@ -7,10 +7,14 @@ import { usePostHog } from 'posthog-js/react'
function PostHogPageViewInner() {
const pathname = usePathname()
const searchParams = useSearchParams()
const posthog = usePostHog()
useEffect(() => {
if (pathname && posthog) {
const posthog = usePostHog()
useEffect(() => {
// Invite codes are bearer-like tokens. The join route emits its own
// aggregate-only event, so it must never produce a URL-bearing page view.
if (pathname?.startsWith('/join/')) return
if (pathname && posthog) {
let url = window.origin + pathname
if (searchParams.toString()) {
url = url + '?' + searchParams.toString()
+9 -2
View File
@@ -62,13 +62,20 @@ body {
border-radius: 16px;
}
.glass-light {
.glass-light {
background: rgba(22, 33, 62, 0.6);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
}
}
/* Solid pairing surface. Join links must not rely on glass or blur. */
.surface-solid {
background: #17171c;
border: 1px solid #303036;
border-radius: 16px;
}
/* Gradient text */
.gradient-text {
+44 -3
View File
@@ -12,12 +12,13 @@ describe('/join/[code] JoinClient', () => {
expect(source).not.toMatch(/fetch\(/)
})
it('attempts the app scheme link and shows the raw code fallback', async () => {
it('only attempts the app scheme link after an explicit Open in Sauci action', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain('buildInviteSchemeUrl')
expect(source).toContain('Open in Sauci')
expect(source.indexOf('const handleOpenInApp')).toBeLessThan(source.indexOf('window.location.href = buildInviteSchemeUrl'))
expect(source).toContain('invite-code-display')
expect(source).toContain('Copy code')
})
it('copies only the raw code, not a share message', async () => {
@@ -37,13 +38,53 @@ describe('/join/[code] JoinClient', () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain("posthog?.capture('join_page_viewed'")
expect(source).toContain("posthog?.capture('join_page_code_copied')")
expect(source).toContain("posthog?.capture('join_page_code_copied'")
expect(source).toContain("posthog?.capture('join_page_store_button_clicked'")
expect(source).toContain("posthog?.capture('join_page_open_app_clicked'")
expect(source).toContain("posthog?.capture('join_page_open_app_fallback_shown'")
expect(source).toContain("posthog?.capture('join_page_offline_shown'")
})
it('does not put an invite code in a PostHog payload', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).not.toMatch(/posthog\?\.capture\([^)]*normalizedCode/)
expect(source).not.toMatch(/posthog\?\.capture\([^)]*\bcode\s*:/)
})
it('uses the required recovery and accessibility states', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain('Copy this before you install')
expect(source).toContain('setTimeout(() =>')
expect(source).toContain('}, 1500)')
expect(source).toContain("document.visibilityState === 'visible'")
expect(source).toContain("aria-live=\"polite\"")
expect(source).toContain('navigator.onLine')
expect(source).toContain('surface-solid')
expect(source).not.toContain('glass-light')
})
it('describes comparison and open-answer visibility truthfully', async () => {
const source = await readFile(componentPath, 'utf8')
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('uses PostHog-recognized selectors to exclude the invite-code DOM from capture and replay', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain('ph-no-capture ph-mask')
expect(source).not.toContain('data-ph-no-capture')
expect(source).not.toContain('data-ph-mask')
})
it('shows a clear fallback message for a malformed invite code', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain('Invite link not recognized')
expect(source).not.toContain('has expired')
})
})
+149 -57
View File
@@ -7,35 +7,66 @@ import { usePostHog } from 'posthog-js/react'
import { isValidInviteCode, normalizeInviteCode } from '../../../lib/inviteCode'
import { APP_STORE_URL, PLAY_STORE_URL, buildInviteSchemeUrl } from '../../../lib/appLinks'
type Platform = 'ios' | 'android' | 'desktop' | 'other'
function getPlatform(): Platform {
if (typeof navigator === 'undefined') return 'other'
const userAgent = navigator.userAgent
if (/iPad|iPhone|iPod/i.test(userAgent)) return 'ios'
if (/Android/i.test(userAgent)) return 'android'
if (/Macintosh|Windows|Linux/i.test(userAgent)) return 'desktop'
return 'other'
}
export function JoinClient({ code }: { code: string }) {
const posthog = usePostHog()
const [copied, setCopied] = useState(false)
const hasAttemptedOpen = useRef(false)
const [isOnline, setIsOnline] = useState(true)
const [openAppFallbackVisible, setOpenAppFallbackVisible] = useState(false)
const [platform, setPlatform] = useState<Platform>('other')
const fallbackTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const hasCopiedCode = useRef(false)
const isValid = isValidInviteCode(code)
const normalizedCode = useMemo(() => (isValid ? normalizeInviteCode(code) : ''), [isValid, code])
// Track the page view once. Fires regardless of code validity so we can
// see broken/expired links in the funnel too.
useEffect(() => {
posthog?.capture('join_page_viewed', { code_valid: isValid })
const detectedPlatform = getPlatform()
setPlatform(detectedPlatform)
posthog?.capture('join_page_viewed', { code_valid: isValid, platform: detectedPlatform })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Attempt to open the app via the custom scheme once. If the app is
// installed and registered for the scheme, this hands off to it; if not,
// nothing visible happens and the fallback UI below remains in view.
useEffect(() => {
if (!isValid || hasAttemptedOpen.current) return
hasAttemptedOpen.current = true
window.location.href = buildInviteSchemeUrl(normalizedCode)
}, [isValid, normalizedCode])
const updateOnlineStatus = () => setIsOnline(navigator.onLine)
const handleCopy = async () => {
updateOnlineStatus()
window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)
return () => {
window.removeEventListener('online', updateOnlineStatus)
window.removeEventListener('offline', updateOnlineStatus)
}
}, [])
useEffect(() => {
if (!isOnline) posthog?.capture('join_page_offline_shown')
}, [isOnline, posthog])
useEffect(() => () => {
if (fallbackTimer.current) clearTimeout(fallbackTimer.current)
}, [])
const handleCopy = async (trigger: 'code_block' | 'fallback_note') => {
try {
await navigator.clipboard.writeText(normalizedCode)
hasCopiedCode.current = true
setCopied(true)
posthog?.capture('join_page_code_copied')
posthog?.capture('join_page_code_copied', { trigger })
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy invite code:', err)
@@ -43,60 +74,95 @@ export function JoinClient({ code }: { code: string }) {
}
const handleStoreClick = (store: 'app_store' | 'play_store') => {
posthog?.capture('join_page_store_button_clicked', { store })
if (!isOnline) return
posthog?.capture('join_page_store_button_clicked', { store, code_copied_first: hasCopiedCode.current })
}
const handleOpenInApp = () => {
setOpenAppFallbackVisible(false)
posthog?.capture('join_page_open_app_clicked', { platform })
window.location.href = buildInviteSchemeUrl(normalizedCode)
if (fallbackTimer.current) clearTimeout(fallbackTimer.current)
fallbackTimer.current = setTimeout(() => {
if (document.visibilityState === 'visible') {
setOpenAppFallbackVisible(true)
posthog?.capture('join_page_open_app_fallback_shown', { platform })
}
}, 1500)
}
if (!isValid) {
const malformedStoreUrl = platform === 'android' ? PLAY_STORE_URL : APP_STORE_URL
return (
<main className="min-h-screen bg-gradient-to-b from-background-light to-background flex items-center justify-center p-6">
<div className="glass-light p-8 sm:p-12 max-w-md w-full text-center">
<div className="surface-solid p-8 sm:p-12 max-w-md w-full text-center">
<h1 className="text-2xl sm:text-3xl font-bold mb-3">Invite link not recognized</h1>
<p className="text-white/60 mb-8">
This invite link looks incomplete or has expired. Ask your partner to send a new one from the Sauci app.
<p className="text-white/70 mb-8">
This invite link looks incomplete. Ask your partner to send a new one from the Sauci app.
</p>
<Link href="/" className="btn-primary inline-block">
Back to Home
</Link>
<div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
<Link href="/" className="btn-primary inline-block">
Back to Home
</Link>
<a
href={malformedStoreUrl}
target="_blank"
rel="noopener noreferrer"
className="btn-secondary inline-block"
>
Get Sauci
</a>
</div>
</div>
</main>
)
}
const copyAnnouncement = copied ? 'Invite code copied.' : ''
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.'
: '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">
{/* Header */}
<main className="min-h-screen bg-gradient-to-b from-background-light to-background ph-no-capture 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/60 hover:text-white transition-colors">
<Link href="/" className="flex items-center gap-2 text-white/70 hover:text-white transition-colors">
<ArrowLeft className="w-5 h-5" />
<span>Back to Home</span>
</Link>
</div>
</header>
{/* Content */}
<div className="max-w-md mx-auto px-6 py-12 sm:py-20 text-center">
<div className="w-20 h-20 rounded-full bg-primary/20 flex items-center justify-center mx-auto mb-8">
<Heart className="w-10 h-10 text-primary" />
{!isOnline && (
<p className="surface-solid mb-6 px-4 py-3 text-sm text-white/70" role="status">
You&apos;re offline. Your code is below, and it still works.
</p>
)}
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary to-secondary flex items-center justify-center mx-auto mb-8 shadow-[0_8px_28px_rgba(233,69,96,0.28)]">
<Heart className="w-10 h-10 text-white" />
</div>
<h1 className="text-3xl sm:text-4xl font-bold mb-3">You&apos;ve been invited to Sauci</h1>
<p className="text-white/60 mb-10">
Open the app to pair up instantly. If it doesn&apos;t open automatically, copy your code below and
enter it after you install Sauci.
</p>
<h1 className="text-3xl sm:text-4xl font-bold mb-3">You&apos;ve been invited to compare answers</h1>
<p className="text-white/70 mb-10">{desktopCopy}</p>
<div className="glass-light p-6 sm:p-8 mb-8">
<p className="text-white/60 text-sm mb-3">Your invite code</p>
<section className="surface-solid p-6 sm:p-8 mb-6 ph-no-capture ph-mask" aria-labelledby="invite-code-heading">
<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"
data-testid="invite-code-display"
aria-label={normalizedCode.split('').join(' ')}
>
{normalizedCode}
</p>
<p className="text-white/70 text-sm mb-5">Copy this before you install. You&apos;ll enter it in the app.</p>
<button
type="button"
onClick={handleCopy}
onClick={() => void handleCopy('code_block')}
className="btn-secondary w-full flex items-center justify-center gap-2"
>
{copied ? (
@@ -111,33 +177,59 @@ export function JoinClient({ code }: { code: string }) {
</>
)}
</button>
</div>
<span className="sr-only" role="status" aria-live="polite">{copyAnnouncement}</span>
</section>
<p className="text-white/40 text-sm mb-6">Don&apos;t have Sauci yet? Get the app first:</p>
<button type="button" onClick={handleOpenInApp} className="btn-primary w-full mb-2">
Open in Sauci
</button>
<p className="text-white/60 text-sm mb-6">Already installed? This opens the app with your code.</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<a
href={APP_STORE_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => handleStoreClick('app_store')}
className="btn-primary w-full sm:w-auto"
>
Download on the App Store
</a>
<a
href={PLAY_STORE_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => handleStoreClick('play_store')}
className="btn-secondary w-full sm:w-auto"
>
Get it on Google Play
</a>
</div>
{openAppFallbackVisible && (
<p className="surface-solid mb-6 px-4 py-3 text-sm text-white/70" role="status" aria-live="polite">
Nothing opened? You probably don&apos;t have Sauci yet.{' '}
<button type="button" className="underline font-medium text-white" onClick={() => void handleCopy('fallback_note')}>
Copy your code
</button>
, then install below.
</p>
)}
<section aria-labelledby="install-heading">
<h2 id="install-heading" className="text-white/70 text-sm font-medium mb-4">Get Sauci</h2>
<div className="flex flex-col gap-3">
<a
href={APP_STORE_URL}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => {
if (!isOnline) event.preventDefault()
handleStoreClick('app_store')
}}
aria-disabled={!isOnline}
title={storeUnavailableTitle}
className={`${platform === 'ios' ? 'btn-primary' : 'btn-secondary'} w-full ${!isOnline ? 'opacity-50 cursor-not-allowed' : ''}`}
>
Get it on the App Store
</a>
<a
href={PLAY_STORE_URL}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => {
if (!isOnline) event.preventDefault()
handleStoreClick('play_store')
}}
aria-disabled={!isOnline}
title={storeUnavailableTitle}
className={`${platform === 'android' ? 'btn-primary' : 'btn-secondary'} w-full ${!isOnline ? 'opacity-50 cursor-not-allowed' : ''}`}
>
Get it on Google Play
</a>
</div>
</section>
</div>
{/* Footer */}
<footer className="py-8 px-6 border-t border-white/5 mt-auto">
<div className="max-w-4xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
<span className="text-xl font-bold gradient-text">Sauci</span>
+2 -2
View File
@@ -2,8 +2,8 @@ import type { Metadata } from 'next'
import { JoinClient } from './JoinClient'
export const metadata: Metadata = {
title: 'Join your partner on Sauci',
description: 'Open Sauci and pair up instantly with your invite code.',
title: 'Compare answers together on Sauci',
description: 'Copy your invite code, then join Sauci to make shared discoveries together.',
}
export default async function JoinPage({
+16
View File
@@ -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')
})
})
+87 -14
View File
@@ -1,20 +1,93 @@
'use client'
import posthog from 'posthog-js'
import { PostHogProvider as PHProvider } from 'posthog-js/react'
import { useEffect } from 'react'
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, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.i.posthog.com',
person_profiles: 'identified_only',
capture_pageview: false, // We'll capture pageviews manually for better control
capture_pageleave: true,
})
import posthog from 'posthog-js'
import { PostHogProvider as PHProvider } from 'posthog-js/react'
import type { CapturedNetworkRequest, CaptureResult } from 'posthog-js'
import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
const INVITE_PATH_PATTERN = /\/join\/[^/?#\s]+/g
function redactInviteUrl(defaultUrl: string): string {
const url = new URL(defaultUrl)
if (url.pathname.startsWith('/join/')) {
return `${url.origin}/join/[invite]`
}
return defaultUrl
}
export function redactInvitePaths<T>(value: T): T {
if (typeof value === 'string') {
return value.replace(INVITE_PATH_PATTERN, '/join/[invite]') as T
}
if (Array.isArray(value)) {
return value.map(redactInvitePaths) as T
}
if (value && typeof value === 'object' && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) {
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, redactInvitePaths(entry)])) as T
}
return value
}
export function redactInvitePathsBeforeSend(event: CaptureResult | null): CaptureResult | null {
return event ? redactInvitePaths(event) : null
}
export function redactInviteNetworkRequest(request: CapturedNetworkRequest): CapturedNetworkRequest {
return {
...request,
name: redactInvitePaths(request.name),
requestHeaders: request.requestHeaders ? redactInvitePaths({ ...request.requestHeaders }) : request.requestHeaders,
requestBody: redactInvitePaths(request.requestBody),
responseHeaders: request.responseHeaders ? redactInvitePaths({ ...request.responseHeaders }) : request.responseHeaders,
responseBody: redactInvitePaths(request.responseBody),
}
}
export function PostHogProvider({ children }: { children: React.ReactNode }) {
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,
session_recording: {
maskCapturedNetworkRequestFn: redactInviteNetworkRequest,
},
// 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}</>
+35
View File
@@ -115,6 +115,23 @@ Leaves current couple (sets `couple_id` to null).
- All messages
- All couple_packs settings
### Standalone API invite cancellation (DELETE)
`DELETE /v1/couple/invite` cancels an unjoined invite for the authenticated
creator only. It is intentionally separate from `DELETE /v1/couple`, which
continues to end an existing relationship for legacy clients.
The server locks the authenticated profile and then the couple row in one
transaction. It deletes the invite only when that caller is the sole member.
If another member has joined, it returns `409 invite_already_joined` and makes
no changes. This serializes safely with a concurrent `POST /v1/couple` join:
one operation completes first, while the other observes the resulting state.
```javascript
// Response on a cancelled waiting invite
{ success: true, couple_id: null }
```
## Database Schema
### Couple Record
@@ -178,6 +195,18 @@ This prevents race conditions where multiple users try to join simultaneously.
**Waiting State:**
```
The invite journey leads with the shared outcome, then shows the recoverable
eight-character code. It offers both a bare-code copy and a full
`https://sauci.app/join/{code}` link copy. Cancelling an invite warns that it
removes only an unjoined invite. Sealed answers made before invite creation
remain, while answers made while waiting are deleted with the invite. The
five-second poll surfaces an inline offline state but continues retrying.
When pairing succeeds, both members are routed once per account and couple to
the `/(app)/paired` payoff screen. It reads only `newMatchesCount`: it never
reveals unmatched or individual answers. Existing swipe remains the named
post-pair question entry until Quick Spark is implemented.
┌─────────────────────────────┐
│ ❤️ Partner Code │
│ │
@@ -360,6 +389,11 @@ The code is never applied automatically; the user must accept the offer.
`join_page_viewed` / `join_page_code_copied` / `join_page_store_button_clicked`
(web, PostHog).
The mobile invite funnel also records aggregate-only prompt, share, join,
failure, completion, unlock, cancellation, clipboard, and conflict events.
Counts use fixed buckets. Invite codes, user IDs, partner IDs, questions, and
answers are never sent as analytics properties.
## Cascade Behavior
When a couple is deleted, foreign key cascades clean up:
@@ -381,4 +415,5 @@ Storage (chat-media) is manually cleaned before the cascade.
| Already paired | User tries to create/join while in couple | "You are already in a couple" |
| Invalid code | Invite code doesn't exist | "Invalid invite code" |
| Couple full | Couple already has 2 members | "This couple already has two partners" |
| Invite already joined | Creator tries to cancel after a partner joins | "This invite has already been joined" |
| DB trigger | Race condition caught | "A couple can only have 2 members" |
+11 -4
View File
@@ -51,7 +51,7 @@ Channels. The client cannot create them.
`apps/mobile/app.config.js` sets an explicit runtime string:
```js
runtimeVersion: '1.0.7'
runtimeVersion: '1.0.8'
```
The release version script updates this string with `expo.version` and the
@@ -91,7 +91,7 @@ Resolved configuration:
| `updates.requestHeaders['expo-channel-name']` | `process.env.RELEASE_CHANNEL`, defaulting to `development` |
| `updates.requestHeaders['expo-app-id']` | `process.env.XPREM_APP_ID` |
| `updates.requestHeaders['xprem-branch']` | `''` |
| `runtimeVersion` | Explicit public version, currently `1.0.7` |
| `runtimeVersion` | Explicit public version, currently `1.0.8` |
`expo-updates` only sends request headers that were declared at build time, so
`xprem-branch` is declared even though it is empty. Removing it would not disable
@@ -176,6 +176,8 @@ dashboard and supplied as `EOO_TOKEN`; it is a secret and must not be committed.
cd apps/mobile
# Verify the exact export environment without contacting the update server.
# Production preflight fails closed unless the compiled bundle contains both
# RevenueCat platform public keys and the entitlement identifier.
npm run ota:preflight:production
# Staging: consumed by builds made with the EAS `preview` profile.
@@ -189,7 +191,12 @@ The scripts resolve the matching EAS build profile into EOAS's export subprocess
EOAS sets `EXPO_NO_DOTENV=1`, so invoking `eoas publish` directly does not load
profile values from `eas.json`. The wrapper injects the selected profile's public
configuration, including the required API and hosted Auth values, sets the
matching `RELEASE_CHANNEL`, and fixes the target branch. It does not print
matching `RELEASE_CHANNEL`, and fixes the target branch. Before each production
publish, it makes an isolated Expo export and fails unless that compiled bundle
contains the iOS and Android RevenueCat public SDK keys plus the entitlement
identifier. EOAS is also forced through a checked-in package-runner guard, which
receives the resolved profile environment and rejects a missing, malformed, or
changed RevenueCat value before EOAS runs its own Expo export. It does not print
configuration values.
Production public API/Auth values come from the checked-in production profile.
@@ -230,7 +237,7 @@ production deploy and none of the review latency.
curl -sD - "https://ota.apps.jbrunton.co.uk/manifest" \
-H "expo-app-id: <xprem-app-id>" \
-H "expo-channel-name: production" \
-H "expo-runtime-version: 1.0.7" \
-H "expo-runtime-version: 1.0.8" \
-H "expo-platform: ios"
```
+5
View File
@@ -216,6 +216,11 @@ The `get_recommended_questions()` function also filters premium packs:
> `isProUser: false`, and paying users silently appear unsubscribed with no error.
> Confirm the variable is set on every iOS and Android build profile.
Production OTA publishes also fail before upload unless an isolated compiled
Expo bundle contains both platform public SDK keys and this entitlement
identifier. Run `npm run ota:preflight:production` from `apps/mobile`; it
validates presence only and never prints those values.
## Subscription Flow Diagrams
### Purchase Flow
+13
View File
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const launcherPath = fileURLToPath(new URL('./dev-local.sh', import.meta.url));
test('native launcher retains a UTF-8 locale after sanitizing its environment', async () => {
const launcher = await readFile(launcherPath, 'utf8');
const nativeCommand = launcher.slice(launcher.indexOf('cmd_native() {'), launcher.indexOf('\ncase "${1:-up}" in'));
assert.match(nativeCommand, /env -i[\s\S]*LANG=en_US\.UTF-8 LC_ALL=en_US\.UTF-8/);
});