Compare commits

Author SHA1 Message Date
jjbruntonandClaude Opus 5 63b8808a2a fix(mobile): run OTA publish entrypoint from paths with spaces
The entrypoint guard compared process.argv[1] against the pathname of
import.meta.url. A URL pathname percent-encodes spaces, so in any checkout
whose path contains a space the comparison never matched and main() never
ran: every ota:* script exited 0 having done nothing, which is
indistinguishable from a successful publish.

Compare resolved filesystem paths via fileURLToPath instead. Matches the
equivalent guard in the DotActive OTA script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 11:04:55 +01:00
jjbrunton 2f05e9ae87 Merge pull request #21 from jjbrunton/staging
chore(release): publish v1.0.7 metadata
2026-08-31 01:29:58 +01:00
5 changed files with 29 additions and 101 deletions

No files matched your search

+6 -17
View File
@@ -69,12 +69,10 @@ export default function MatchesScreen() {
checkNudgeCooldown, checkNudgeCooldown,
} = useMatchStore(); } = useMatchStore();
const { user, couple, partner } = useAuthStore(); const { user, couple, partner } = useAuthStore();
const userId = user?.id;
const router = useRouter(); const router = useRouter();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH; const isWideScreen = width > MAX_CONTENT_WIDTH;
const [showTutorial, setShowTutorial] = useState(false); const [showTutorial, setShowTutorial] = useState(false);
const tutorialDismissedRef = useRef(false);
const [nudgeFeedback, setNudgeFeedback] = useState<string | null>(null); const [nudgeFeedback, setNudgeFeedback] = useState<string | null>(null);
const [headerHeight, setHeaderHeight] = useState(130); const [headerHeight, setHeaderHeight] = useState(130);
@@ -106,30 +104,21 @@ export default function MatchesScreen() {
// Check if tutorial should be shown when screen is focused or matches change // Check if tutorial should be shown when screen is focused or matches change
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
let isActive = true;
const checkTutorial = async () => { const checkTutorial = async () => {
if (matches.length > 0 && userId) { if (matches.length > 0) {
const seen = await hasSeenMatchesTutorial(userId); const seen = await hasSeenMatchesTutorial();
if (isActive && !tutorialDismissedRef.current && !seen) { if (!seen) {
setShowTutorial(true); setShowTutorial(true);
} }
} }
}; };
void checkTutorial(); checkTutorial();
}, [matches.length])
return () => {
isActive = false;
};
}, [matches.length, userId])
); );
const handleTutorialComplete = async () => { const handleTutorialComplete = async () => {
if (!userId) return; await markMatchesTutorialSeen();
tutorialDismissedRef.current = true;
setShowTutorial(false); setShowTutorial(false);
await markMatchesTutorialSeen(userId);
}; };
// Load whichever view is showing, once. Returning to this tab reuses what is // Load whichever view is showing, once. Returning to this tab reuses what is
+5 -1
View File
@@ -3,6 +3,7 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
const projectDir = resolve(import.meta.dirname, '..'); const projectDir = resolve(import.meta.dirname, '..');
@@ -140,4 +141,7 @@ export function main(args = process.argv.slice(2), environment = process.env) {
run('npx', ['eoas', 'publish', '--branch', branch, ...parsePublishArgs(rawPublishArgs)], exportEnv); run('npx', ['eoas', 'publish', '--branch', branch, ...parsePublishArgs(rawPublishArgs)], exportEnv);
} }
if (process.argv[1] === new URL(import.meta.url).pathname) main(); // Compare real filesystem paths: a URL pathname percent-encodes spaces, so
// comparing it to process.argv[1] silently skips main() under any checkout
// whose path contains a space.
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
@@ -1,45 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as SecureStore from "expo-secure-store";
import {
hasSeenMatchesTutorial,
markMatchesTutorialSeen,
resetMatchesTutorial,
} from "@/lib/matchesTutorialSeen";
jest.mock("expo-secure-store", () => ({
getItemAsync: jest.fn(),
}));
describe("matchesTutorialSeen", () => {
beforeEach(async () => {
await AsyncStorage.clear();
jest.mocked(SecureStore.getItemAsync).mockReset();
jest.mocked(SecureStore.getItemAsync).mockResolvedValue(null);
});
it("persists a dismissal for the signed-in account", async () => {
await markMatchesTutorialSeen("user-1");
await expect(hasSeenMatchesTutorial("user-1")).resolves.toBe(true);
});
it("does not dismiss the tutorial for another account on the same device", async () => {
await markMatchesTutorialSeen("user-1");
await expect(hasSeenMatchesTutorial("user-2")).resolves.toBe(false);
});
it("migrates an existing legacy dismissal into durable account storage", async () => {
jest.mocked(SecureStore.getItemAsync).mockResolvedValue("true");
await expect(hasSeenMatchesTutorial("user-1")).resolves.toBe(true);
await expect(AsyncStorage.getItem("matches_tutorial_seen_user-1")).resolves.toBe("true");
});
it("lets the development reset show the tutorial for the current account again", async () => {
await markMatchesTutorialSeen("user-1");
await resetMatchesTutorial("user-1");
await expect(hasSeenMatchesTutorial("user-1")).resolves.toBe(false);
});
});
@@ -223,8 +223,7 @@ export function ProfileScreen() {
label="Reset Matches Tutorial" label="Reset Matches Tutorial"
description="Show the tutorial again" description="Show the tutorial again"
onPress={async () => { onPress={async () => {
if (!user?.id) return; await resetMatchesTutorial();
await resetMatchesTutorial(user.id);
Alert.alert("Success", "Matches tutorial has been reset."); Alert.alert("Success", "Matches tutorial has been reset.");
}} }}
/> />
+17 -36
View File
@@ -1,70 +1,51 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as SecureStore from "expo-secure-store"; import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native"; import { Platform } from "react-native";
const STORAGE_KEY_PREFIX = "matches_tutorial_seen"; const STORAGE_KEY = "matches_tutorial_seen";
const LEGACY_STORAGE_KEY = "matches_tutorial_seen";
const buildKey = (userId: string): string => `${STORAGE_KEY_PREFIX}_${userId}`; async function getStorage(): Promise<boolean> {
async function getStorage(userId: string): Promise<boolean> {
try { try {
const key = buildKey(userId); let data: string | null = null;
if (Platform.OS === "web") { if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") { if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
return window.localStorage.getItem(key) === "true"; data = window.localStorage.getItem(STORAGE_KEY);
}
} else {
data = await SecureStore.getItemAsync(STORAGE_KEY);
} }
return false; return data === "true";
}
const storedValue = await AsyncStorage.getItem(key);
if (storedValue !== null) {
return storedValue === "true";
}
// Keep dismissals made before the account-scoped AsyncStorage key was
// introduced. Future reads use the durable app storage key above.
const legacyValue = await SecureStore.getItemAsync(LEGACY_STORAGE_KEY);
if (legacyValue === "true") {
await AsyncStorage.setItem(key, "true");
return true;
}
return false;
} catch (error) { } catch (error) {
console.error("Error reading matches tutorial state:", error); console.error("Error reading matches tutorial state:", error);
} }
return false; return false;
} }
async function setStorage(userId: string, seen: boolean): Promise<void> { async function setStorage(seen: boolean): Promise<void> {
try { try {
const data = seen ? "true" : "false"; const data = seen ? "true" : "false";
const key = buildKey(userId);
if (Platform.OS === "web") { if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") { if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(key, data); window.localStorage.setItem(STORAGE_KEY, data);
} }
} else { } else {
await AsyncStorage.setItem(key, data); await SecureStore.setItemAsync(STORAGE_KEY, data);
} }
} catch (error) { } catch (error) {
console.error("Error saving matches tutorial state:", error); console.error("Error saving matches tutorial state:", error);
} }
} }
/** The Matches tutorial is shown at most once per signed-in account. */ export async function hasSeenMatchesTutorial(): Promise<boolean> {
export async function hasSeenMatchesTutorial(userId: string): Promise<boolean> { return getStorage();
return getStorage(userId);
} }
export async function markMatchesTutorialSeen(userId: string): Promise<void> { export async function markMatchesTutorialSeen(): Promise<void> {
await setStorage(userId, true); await setStorage(true);
} }
export async function resetMatchesTutorial(userId: string): Promise<void> { export async function resetMatchesTutorial(): Promise<void> {
await setStorage(userId, false); await setStorage(false);
} }