mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3659cc8c2f | ||
|
|
4b227ad62c | ||
|
|
7c8d91c89c | ||
|
|
6883befff3 | ||
|
|
636811e66a | ||
|
|
d825e3d80c | ||
|
|
13094267a6 | ||
|
|
ecc31a13a2 | ||
|
|
7a76936829 |
No files matched your search
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
id: 2026-08-31-expo-prebuild-native-regression
|
||||||
|
date: 2026-08-31
|
||||||
|
status: promoted
|
||||||
|
scope: apps/mobile native projects (ios/, android/) and expo prebuild
|
||||||
|
evidence: v1.0.8 SDK 54 to 56 upgrade, commits 604eec3, 7aca39a, 6bd04a6, 79233f5, b8cb2add
|
||||||
|
destination: apps/mobile/scripts/release-preflight.mjs, apps/mobile/scripts/release-preflight.test.mjs, and docs/releasing.md
|
||||||
|
review_after: 2026-11-30
|
||||||
|
---
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
`expo prebuild --clean` during the SDK 54 to 56 upgrade (and the
|
||||||
|
@bacons/apple-targets 0.1 to 5.0 bump) silently regressed four checked-in
|
||||||
|
native invariants. Each regression was caught only by a release gate or by
|
||||||
|
manual diffing against the last shipped tag (3fc6f8a, v1.0.7), not by
|
||||||
|
prebuild itself.
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
Run `expo prebuild --clean` in apps/mobile against the upgraded SDK and diff
|
||||||
|
the regenerated ios/ and android/ projects against the previously committed
|
||||||
|
projects.
|
||||||
|
|
||||||
|
## Cause
|
||||||
|
|
||||||
|
1. Widget identity: apple-targets 5.x derives PRODUCT_BUNDLE_IDENTIFIER from
|
||||||
|
the target folder name, changing com.sauci.app.LiveDrawWidget to
|
||||||
|
com.sauci.app.widget, and repointed CODE_SIGN_ENTITLEMENTS to the
|
||||||
|
gitignored ios/.targets/LiveDrawWidget/generated.entitlements.
|
||||||
|
2. Android manifest lost the tools:node="remove" attributes on
|
||||||
|
READ_MEDIA_IMAGES and READ_MEDIA_VIDEO, so the built AAB regained
|
||||||
|
Play-forbidden permissions (verified via bundletool dump on the rejected
|
||||||
|
build 50 AAB).
|
||||||
|
3. iOS Info.plist gained dev-launcher-only keys NSBonjourServices
|
||||||
|
(_expo._tcp), NSLocalNetworkUsageDescription, and RCTMetroPort.
|
||||||
|
4. android/gradle.properties reset to stock -Xmx2048m
|
||||||
|
-XX:MaxMetaspaceSize=512m, causing Metaspace OOM in kspReleaseKotlin/CMake
|
||||||
|
under RN 0.85.
|
||||||
|
|
||||||
|
Separately, a package absent from the new SDK's bundledNativeModules
|
||||||
|
(expo-av) has no compatible release and fails the iOS archive (EXAV.h
|
||||||
|
imports removed ExpoModulesCore headers).
|
||||||
|
|
||||||
|
## Remediation
|
||||||
|
|
||||||
|
Fixed by restoring widget identity and tracking
|
||||||
|
targets/widget/generated.entitlements with a pinned bundleIdentifier
|
||||||
|
(604eec3); restoring manifest tools:node="remove" attributes (7aca39a);
|
||||||
|
removing the dev-launcher-only Info.plist keys (6bd04a6); raising
|
||||||
|
gradle.properties memory to 4096m/1024m (79233f5); and migrating expo-av to
|
||||||
|
expo-video/expo-audio (b8cb2add).
|
||||||
|
|
||||||
|
Extend apps/mobile/scripts/release-preflight.mjs with executable checks for
|
||||||
|
items 2 and 3 (item 1 is already covered by the existing LiveDrawWidget
|
||||||
|
preflight check; item 4 hard-fails the build by itself). Add an "After
|
||||||
|
native regeneration" checklist to docs/releasing.md.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
`node apps/mobile/scripts/release-preflight.mjs` fails if the tracked
|
||||||
|
AndroidManifest.xml lacks tools:node="remove" on READ_MEDIA_IMAGES or
|
||||||
|
READ_MEDIA_VIDEO, or if ios/Sauci/Info.plist contains NSBonjourServices,
|
||||||
|
NSLocalNetworkUsageDescription, or RCTMetroPort.
|
||||||
|
`node --test apps/mobile/scripts/release-preflight.test.mjs` covers the new
|
||||||
|
checks.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
id: 2026-08-31-local-eas-build-preconditions
|
||||||
|
date: 2026-08-31
|
||||||
|
status: promoted
|
||||||
|
scope: apps/mobile local EAS release builds
|
||||||
|
evidence: v1.0.8 local EAS build session, commit d8bedea
|
||||||
|
destination: docs/releasing.md
|
||||||
|
review_after: 2026-11-30
|
||||||
|
---
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
Local EAS release builds failed before native compilation for environment
|
||||||
|
reasons unrelated to the app source, and a successful iOS build left the
|
||||||
|
tracked source in a state that fails release-preflight afterward.
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
Run `eas build --local` from a non-interactive shell without first exporting
|
||||||
|
ANDROID_HOME/ANDROID_SDK_ROOT/JAVA_HOME: fails with "SDK location not
|
||||||
|
found" because non-interactive shells do not load the user profile. Run it
|
||||||
|
with a non-empty EAS_LOCAL_BUILD_WORKINGDIR: fails with "Workingdir is not
|
||||||
|
empty". Run it outside apps/mobile: eas cannot find the project. Pipe the
|
||||||
|
build log through `tail`: the full log needed for diagnosis is lost. After a
|
||||||
|
successful iOS build, EAS auto-increment updates both Info.plists but not
|
||||||
|
the widget target's CURRENT_PROJECT_VERSION in the pbxproj, so
|
||||||
|
release-preflight fails until it is synced.
|
||||||
|
|
||||||
|
## Cause
|
||||||
|
|
||||||
|
`eas` must be installed as a global CLI (scripts call bare `eas`; eas-cli
|
||||||
|
23.0.0 verified). Non-interactive shells used for automation do not source
|
||||||
|
the profile that normally exports the Android/Java environment variables.
|
||||||
|
EAS_LOCAL_BUILD_WORKINGDIR must not exist or be empty. EAS iOS
|
||||||
|
auto-increment writes plist versions but does not touch the widget target's
|
||||||
|
pbxproj CURRENT_PROJECT_VERSION, so it drifts from the app's build number by
|
||||||
|
one increment after every successful build.
|
||||||
|
|
||||||
|
## Remediation
|
||||||
|
|
||||||
|
Before a local EAS build: verify `eas` is on PATH, explicitly export
|
||||||
|
ANDROID_HOME, ANDROID_SDK_ROOT, and JAVA_HOME (JDK 17), ensure
|
||||||
|
EAS_LOCAL_BUILD_WORKINGDIR does not exist or is empty, and run from
|
||||||
|
apps/mobile. Capture the full build log to a file rather than piping through
|
||||||
|
`tail`. After a successful build, sync the widget's CURRENT_PROJECT_VERSION
|
||||||
|
to the new build number (commit d8bedea, sed 46 to 47) and sync
|
||||||
|
ios/Podfile.lock from the successful build's working directory when
|
||||||
|
dependencies changed, since the archive's pod install resolution is
|
||||||
|
authoritative and local CocoaPods here is broken.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
docs/releasing.md's local-build section documents the environment
|
||||||
|
preconditions and the post-build widget CURRENT_PROJECT_VERSION and
|
||||||
|
Podfile.lock sync steps; `npm run release:preflight` passes after the sync.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
id: 2026-08-31-npm-lockfile-divergence
|
||||||
|
date: 2026-08-31
|
||||||
|
status: promoted
|
||||||
|
scope: root package.json, package-lock.json, apps/web dependencies, CI
|
||||||
|
evidence: v1.0.8 lockfile investigation, commits 90cde4a, 9609a90
|
||||||
|
destination: docs/development/dependencies.md
|
||||||
|
review_after: 2026-11-30
|
||||||
|
---
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
CI and the API Docker image (node 20.19.4/npm 10.8.2, per .nvmrc) broke
|
||||||
|
invisibly while a lockfile regenerated on this dev machine (node 26/npm
|
||||||
|
11.19) passed local `npm ci`. Pushes to staging bypass required status
|
||||||
|
checks, so the red CI ran unnoticed for hours.
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
Regenerate package-lock.json with npm 11 on a wildcard-range dependency
|
||||||
|
(react-native-worklets depends on `@react-native/metro-config: "*"`), then
|
||||||
|
run `npm ci` under node 20.19.4/npm 10.8.2. npm 10 requires 0.85.3 at that
|
||||||
|
tree position; npm 11 resolves/hoists 0.87.x there. `npm ci` fails with
|
||||||
|
"Invalid: lock file's ... does not satisfy". The reverse direction also
|
||||||
|
fails: a lock regenerated by npm 10 fails `npm ci` under npm 11 with missing
|
||||||
|
platform-optional binaries (@next/swc-linux-x64-gnu breaking `next build`,
|
||||||
|
@rollup/rollup-linux-x64-gnu breaking the API Docker tsup build, lightningcss
|
||||||
|
variants breaking local npm ci), matching the npm/cli#4828-style
|
||||||
|
optional-dependency resolution gap.
|
||||||
|
|
||||||
|
## Cause
|
||||||
|
|
||||||
|
Two different npm major versions in active use (CI/Docker on npm 10, this
|
||||||
|
dev machine on npm 11) resolve wildcard/range dependencies to different
|
||||||
|
versions at the same tree position, and each npm's own platform-optional
|
||||||
|
binary selection is not portable to the other npm's lockfile.
|
||||||
|
|
||||||
|
## Remediation
|
||||||
|
|
||||||
|
Pin the wildcard-range dependency via a root package.json override
|
||||||
|
(`"@react-native/metro-config": "0.85.3"`, alongside the existing
|
||||||
|
rollup/react-native/expo pins). Pin apps/web `@next/swc` linux/darwin
|
||||||
|
binaries as direct optionalDependencies (must be bumped together with any
|
||||||
|
`next` version bump). Regenerate the lock and validate it both ways: local
|
||||||
|
`npm ci` plus `npm run verify:fast`, and
|
||||||
|
`docker run --platform linux/amd64 node:20.19.4 ... npm ci` (the
|
||||||
|
`--platform` flag is mandatory; Apple Silicon Docker otherwise runs arm64
|
||||||
|
and the platform-binary check is meaningless). After any push that bypassed
|
||||||
|
required status checks, check `gh run list --branch staging`.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
commits 90cde4a and 9609a90 add the override and optionalDependencies pins
|
||||||
|
and record a lockfile that passes both the amd64-emulated `npm ci` under
|
||||||
|
node 20.19.4 and local `npm ci` plus `npm run verify:fast`.
|
||||||
@@ -76,6 +76,7 @@ environment limitation; do not call it a passing product check.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| System boundaries and data flow | `docs/architecture/system.md` |
|
| System boundaries and data flow | `docs/architecture/system.md` |
|
||||||
| Local services, ports, fixtures | `docs/development/local-stack.md` |
|
| Local services, ports, fixtures | `docs/development/local-stack.md` |
|
||||||
|
| Dependency and lockfile discipline | `docs/development/dependencies.md` |
|
||||||
| Test layers and required gates | `docs/testing/strategy.md` |
|
| Test layers and required gates | `docs/testing/strategy.md` |
|
||||||
| Database migration safety | `docs/operations/database-migrations.md` |
|
| Database migration safety | `docs/operations/database-migrations.md` |
|
||||||
| Agent roles and effort routing | `docs/agents/routing.md` |
|
| Agent roles and effort routing | `docs/agents/routing.md` |
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ native iOS/Android targets.
|
|||||||
- Add stable accessibility labels or `testID` values for critical interactions.
|
- Add stable accessibility labels or `testID` values for critical interactions.
|
||||||
- Verify focused Jest tests, typecheck, lint, then native/Expo behavior as risk
|
- Verify focused Jest tests, typecheck, lint, then native/Expo behavior as risk
|
||||||
requires. UI work needs observable simulator evidence.
|
requires. UI work needs observable simulator evidence.
|
||||||
|
- Before and after any `expo prebuild`, follow the "After native regeneration"
|
||||||
|
checklist in `docs/releasing.md`.
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"group.com.sauci.app"
|
"group.com.sauci.app"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"buildNumber": "47",
|
"buildNumber": "49",
|
||||||
"usesAppleSignIn": true,
|
"usesAppleSignIn": true,
|
||||||
"googleServicesFile": "./GoogleService-Info.plist",
|
"googleServicesFile": "./GoogleService-Info.plist",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
|
|||||||
@@ -69,10 +69,12 @@ 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);
|
||||||
|
|
||||||
@@ -104,21 +106,30 @@ 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) {
|
if (matches.length > 0 && userId) {
|
||||||
const seen = await hasSeenMatchesTutorial();
|
const seen = await hasSeenMatchesTutorial(userId);
|
||||||
if (!seen) {
|
if (isActive && !tutorialDismissedRef.current && !seen) {
|
||||||
setShowTutorial(true);
|
setShowTutorial(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
checkTutorial();
|
void checkTutorial();
|
||||||
}, [matches.length])
|
|
||||||
|
return () => {
|
||||||
|
isActive = false;
|
||||||
|
};
|
||||||
|
}, [matches.length, userId])
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTutorialComplete = async () => {
|
const handleTutorialComplete = async () => {
|
||||||
await markMatchesTutorialSeen();
|
if (!userId) return;
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -9,14 +9,12 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Image,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
FadeIn,
|
FadeIn,
|
||||||
FadeInUp,
|
|
||||||
FadeOut,
|
FadeOut,
|
||||||
SlideInRight,
|
SlideInRight,
|
||||||
SlideOutLeft,
|
SlideOutLeft,
|
||||||
@@ -32,9 +30,9 @@ import { getProfileError } from '../../src/lib/errors';
|
|||||||
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
|
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
|
||||||
import { Events } from '../../src/lib/analytics';
|
import { Events } from '../../src/lib/analytics';
|
||||||
import { hasSeenOnboardingPaywall, markOnboardingPaywallSeen } from '../../src/lib/onboardingPaywallSeen';
|
import { hasSeenOnboardingPaywall, markOnboardingPaywallSeen } from '../../src/lib/onboardingPaywallSeen';
|
||||||
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker';
|
import { REQUIRED_ONBOARDING_VERSION, ONBOARDING_OFFERING_ID } from '../../src/constants/onboarding';
|
||||||
import { REQUIRED_ONBOARDING_VERSION, ONBOARDING_OFFERING_ID } from '../../src/constants/onboarding';
|
import { activeOnboardingStage, initialOnboardingStage, previousOnboardingStage, type OnboardingStage } from '../../src/lib/onboardingStages';
|
||||||
import type { Gender, UsageReason } from '../../src/types';
|
import type { Gender, UsageReason } from '../../src/types';
|
||||||
|
|
||||||
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
||||||
{ value: 'male', label: 'Male', icon: 'male' },
|
{ value: 'male', label: 'Male', icon: 'male' },
|
||||||
@@ -51,15 +49,17 @@ const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [
|
|||||||
{ value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' },
|
{ value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' },
|
||||||
];
|
];
|
||||||
|
|
||||||
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'notifications';
|
type Stage = OnboardingStage;
|
||||||
|
|
||||||
export default function OnboardingScreen() {
|
export default function OnboardingScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// Only subscribe to stable values to prevent re-renders from background fetchUser calls
|
// Only subscribe to stable values to prevent re-renders from background fetchUser calls
|
||||||
const userId = useAuthStore((s) => s.user?.id);
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
|
const persistedUserName = useAuthStore((s) => s.user?.name);
|
||||||
|
const pendingAppleDisplayName = useAuthStore((s) => s.pendingAppleDisplayName);
|
||||||
const fetchUser = useAuthStore((s) => s.fetchUser);
|
const fetchUser = useAuthStore((s) => s.fetchUser);
|
||||||
const initialUser = useRef(useAuthStore.getState().user);
|
const initialUser = useRef(useAuthStore.getState().user);
|
||||||
const [stage, setStage] = useState<Stage>('avatar');
|
const [stage, setStage] = useState<Stage>(() => initialOnboardingStage(initialUser.current?.name));
|
||||||
const [name, setName] = useState(initialUser.current?.name || '');
|
const [name, setName] = useState(initialUser.current?.name || '');
|
||||||
const [gender, setGender] = useState<Gender | null>(initialUser.current?.gender || null);
|
const [gender, setGender] = useState<Gender | null>(initialUser.current?.gender || null);
|
||||||
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
|
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
|
||||||
@@ -67,38 +67,18 @@ export default function OnboardingScreen() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showPaywall, setShowPaywall] = useState(false);
|
const [showPaywall, setShowPaywall] = useState(false);
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const paywallNavigationRef = useRef(false);
|
const paywallNavigationRef = useRef(false);
|
||||||
|
|
||||||
const {
|
const pendingDisplayName = pendingAppleDisplayName?.userId === userId ? pendingAppleDisplayName?.name : undefined;
|
||||||
avatarUri,
|
const displayName = persistedUserName?.trim() || pendingDisplayName || name;
|
||||||
isUploading: isUploadingAvatar,
|
const hasStoredDisplayName = Boolean(persistedUserName?.trim() || pendingDisplayName);
|
||||||
showPicker: handleAvatarPress,
|
const activeStage = activeOnboardingStage(stage, persistedUserName, pendingDisplayName);
|
||||||
uploadAvatar,
|
const previousStage = previousOnboardingStage(activeStage, hasStoredDisplayName);
|
||||||
} = useAvatarPicker({
|
|
||||||
userId,
|
|
||||||
onSelect: () => setError(null),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Events.onboardingStart();
|
Events.onboardingStart();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAvatarContinue = () => {
|
|
||||||
if (!avatarUri) {
|
|
||||||
setError('Please add a profile photo to continue');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStage('name');
|
|
||||||
Events.onboardingStageComplete('avatar');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAvatarSkip = () => {
|
|
||||||
setError(null);
|
|
||||||
setStage('name');
|
|
||||||
Events.avatarSkipped();
|
|
||||||
Events.onboardingStageComplete('avatar');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNameSubmit = () => {
|
const handleNameSubmit = () => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setError('Please enter your name');
|
setError('Please enter your name');
|
||||||
@@ -154,7 +134,7 @@ export default function OnboardingScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleComplete = async () => {
|
const handleComplete = async () => {
|
||||||
console.log('[Onboarding] handleComplete called, user:', userId, 'name:', name);
|
console.log('[Onboarding] handleComplete called, user:', userId, 'name:', displayName);
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
console.error('[Onboarding] No user ID available');
|
console.error('[Onboarding] No user ID available');
|
||||||
setError('Not logged in. Please restart the app.');
|
setError('Not logged in. Please restart the app.');
|
||||||
@@ -165,19 +145,10 @@ export default function OnboardingScreen() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Upload avatar if one was selected
|
|
||||||
let avatarUrl: string | null = null;
|
|
||||||
if (avatarUri) {
|
|
||||||
avatarUrl = await uploadAvatar();
|
|
||||||
if (avatarUrl) {
|
|
||||||
Events.avatarUploaded();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"];
|
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"];
|
||||||
|
|
||||||
const updateData = {
|
const updateData = {
|
||||||
name: name.trim(),
|
name: displayName.trim(),
|
||||||
gender,
|
gender,
|
||||||
usage_reason: usageReason,
|
usage_reason: usageReason,
|
||||||
// Preserve the safest legacy profile values. Catalogue
|
// Preserve the safest legacy profile values. Catalogue
|
||||||
@@ -191,10 +162,6 @@ export default function OnboardingScreen() {
|
|||||||
|
|
||||||
await profileSettingsApi.updateProfile(updateData);
|
await profileSettingsApi.updateProfile(updateData);
|
||||||
|
|
||||||
if (avatarUrl) {
|
|
||||||
updatedFields.push("avatar_url");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[Onboarding] Profile updated successfully, fetching user...');
|
console.log('[Onboarding] Profile updated successfully, fetching user...');
|
||||||
await fetchUser();
|
await fetchUser();
|
||||||
console.log('[Onboarding] User fetched, navigating to home...');
|
console.log('[Onboarding] User fetched, navigating to home...');
|
||||||
@@ -231,72 +198,8 @@ export default function OnboardingScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderStage = () => {
|
const renderStage = () => {
|
||||||
switch (stage) {
|
switch (activeStage) {
|
||||||
case 'avatar':
|
case 'name':
|
||||||
return (
|
|
||||||
<Animated.View
|
|
||||||
key="avatar"
|
|
||||||
entering={FadeInUp.duration(500)}
|
|
||||||
exiting={SlideOutLeft.duration(300)}
|
|
||||||
style={styles.stageContainer}
|
|
||||||
>
|
|
||||||
<View style={styles.header}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={gradients.primary as [string, string]}
|
|
||||||
style={styles.iconContainer}
|
|
||||||
>
|
|
||||||
<Ionicons name="heart" size={40} color={colors.text} />
|
|
||||||
</LinearGradient>
|
|
||||||
<Text style={styles.title}>Welcome to Sauci</Text>
|
|
||||||
<Text style={styles.subtitle}>
|
|
||||||
Add a profile photo so your partner knows it's you
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<GlassCard style={styles.card}>
|
|
||||||
<View style={styles.avatarSection}>
|
|
||||||
<Pressable onPress={handleAvatarPress} style={styles.avatarTouchable}>
|
|
||||||
{avatarUri ? (
|
|
||||||
<Image source={{ uri: avatarUri }} style={styles.avatarImage} />
|
|
||||||
) : (
|
|
||||||
<View
|
|
||||||
style={styles.avatarPlaceholder}
|
|
||||||
>
|
|
||||||
<Ionicons name="camera" size={40} color={colors.textSecondary} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<View style={styles.avatarBadge}>
|
|
||||||
<Ionicons name={avatarUri ? "pencil" : "add"} size={16} color={colors.text} />
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
<Text style={styles.avatarHint}>
|
|
||||||
{avatarUri ? 'Tap to change photo' : 'Tap to add a photo'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{error && (
|
|
||||||
<View style={styles.errorContainer}>
|
|
||||||
<Ionicons name="alert-circle" size={16} color={colors.error} />
|
|
||||||
<Text style={styles.errorText}>{error}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</GlassCard>
|
|
||||||
|
|
||||||
<View style={styles.footer}>
|
|
||||||
<GlassButton onPress={handleAvatarContinue} fullWidth size="lg">
|
|
||||||
Continue
|
|
||||||
</GlassButton>
|
|
||||||
<Pressable
|
|
||||||
style={styles.skipButton}
|
|
||||||
onPress={handleAvatarSkip}
|
|
||||||
testID="avatar-skip-button"
|
|
||||||
>
|
|
||||||
<Text style={styles.skipText}>Skip for now</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'name':
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
key="name"
|
key="name"
|
||||||
@@ -305,16 +208,12 @@ export default function OnboardingScreen() {
|
|||||||
style={styles.stageContainer}
|
style={styles.stageContainer}
|
||||||
>
|
>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
{avatarUri ? (
|
<LinearGradient
|
||||||
<Image source={{ uri: avatarUri }} style={styles.headerAvatar} />
|
colors={gradients.primary as [string, string]}
|
||||||
) : (
|
style={styles.iconContainer}
|
||||||
<LinearGradient
|
>
|
||||||
colors={gradients.primary as [string, string]}
|
<Ionicons name="person" size={40} color={colors.text} />
|
||||||
style={styles.iconContainer}
|
</LinearGradient>
|
||||||
>
|
|
||||||
<Ionicons name="person" size={40} color={colors.text} />
|
|
||||||
</LinearGradient>
|
|
||||||
)}
|
|
||||||
<Text style={styles.title}>What can we call you?</Text>
|
<Text style={styles.title}>What can we call you?</Text>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>
|
||||||
Nicknames and pet names are welcome too!
|
Nicknames and pet names are welcome too!
|
||||||
@@ -368,7 +267,7 @@ export default function OnboardingScreen() {
|
|||||||
style={styles.stageContainer}
|
style={styles.stageContainer}
|
||||||
>
|
>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.greeting}>Hey, {name.trim()}!</Text>
|
<Text style={styles.greeting}>Hey, {displayName.trim()}!</Text>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>
|
||||||
This helps us show you the right questions
|
This helps us show you the right questions
|
||||||
</Text>
|
</Text>
|
||||||
@@ -576,28 +475,24 @@ export default function OnboardingScreen() {
|
|||||||
>
|
>
|
||||||
{/* Header with back button and progress dots */}
|
{/* Header with back button and progress dots */}
|
||||||
<Animated.View entering={FadeIn.duration(600)} style={styles.headerBar}>
|
<Animated.View entering={FadeIn.duration(600)} style={styles.headerBar}>
|
||||||
{stage !== 'avatar' ? (
|
{previousStage ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.backButton}
|
style={styles.backButton}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (stage === 'notifications') setStage('purpose');
|
setStage(previousStage);
|
||||||
else if (stage === 'purpose') setStage('gender');
|
}}
|
||||||
else if (stage === 'gender') setStage('name');
|
|
||||||
else if (stage === 'name') setStage('avatar');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.backButtonPlaceholder} />
|
<View style={styles.backButtonPlaceholder} />
|
||||||
)}
|
)}
|
||||||
<View style={styles.progressContainer}>
|
<View style={styles.progressContainer}>
|
||||||
<View style={[styles.progressDot, stage === 'avatar' && styles.progressDotActive]} />
|
<View style={[styles.progressDot, activeStage === 'name' && styles.progressDotActive]} />
|
||||||
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
|
<View style={[styles.progressDot, activeStage === 'gender' && styles.progressDotActive]} />
|
||||||
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
|
<View style={[styles.progressDot, activeStage === 'purpose' && styles.progressDotActive]} />
|
||||||
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} />
|
<View style={[styles.progressDot, activeStage === 'notifications' && styles.progressDotActive]} />
|
||||||
<View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
|
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.backButtonPlaceholder} />
|
<View style={styles.backButtonPlaceholder} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -653,18 +548,6 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: colors.border,
|
backgroundColor: colors.border,
|
||||||
},
|
},
|
||||||
// ...
|
// ...
|
||||||
avatarPlaceholder: {
|
|
||||||
width: 120,
|
|
||||||
height: 120,
|
|
||||||
borderRadius: 60,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: colors.border,
|
|
||||||
borderStyle: 'dashed',
|
|
||||||
backgroundColor: colors.backgroundLight,
|
|
||||||
},
|
|
||||||
// ...
|
|
||||||
inputContainer: {
|
inputContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -757,47 +640,6 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: spacing.lg,
|
marginBottom: spacing.lg,
|
||||||
...shadows.lg,
|
...shadows.lg,
|
||||||
},
|
},
|
||||||
headerAvatar: {
|
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
borderRadius: 40,
|
|
||||||
marginBottom: spacing.lg,
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: colors.primary,
|
|
||||||
},
|
|
||||||
avatarSection: {
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingVertical: spacing.lg,
|
|
||||||
},
|
|
||||||
avatarTouchable: {
|
|
||||||
position: 'relative',
|
|
||||||
},
|
|
||||||
avatarImage: {
|
|
||||||
width: 120,
|
|
||||||
height: 120,
|
|
||||||
borderRadius: 60,
|
|
||||||
borderWidth: 3,
|
|
||||||
borderColor: colors.primary,
|
|
||||||
},
|
|
||||||
|
|
||||||
avatarBadge: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 4,
|
|
||||||
right: 4,
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: 16,
|
|
||||||
backgroundColor: colors.primary,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: colors.background,
|
|
||||||
},
|
|
||||||
avatarHint: {
|
|
||||||
...typography.caption1,
|
|
||||||
color: colors.textSecondary,
|
|
||||||
marginTop: spacing.md,
|
|
||||||
},
|
|
||||||
title: {
|
title: {
|
||||||
...typography.largeTitle,
|
...typography.largeTitle,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ if (Platform.OS === "android") {
|
|||||||
import { authClient } from "../../src/lib/authClient";
|
import { authClient } from "../../src/lib/authClient";
|
||||||
import { getAuthError } from "../../src/lib/errors";
|
import { getAuthError } from "../../src/lib/errors";
|
||||||
import { Events } from "../../src/lib/analytics";
|
import { Events } from "../../src/lib/analytics";
|
||||||
|
import { apiRequestWithAccessToken } from "../../src/lib/apiClient";
|
||||||
|
import { formatAppleProfileName } from "../../src/lib/appleProfileName";
|
||||||
|
import { persistAppleProfileName } from "../../src/lib/appleProfileNamePersistence";
|
||||||
|
import { profileSettingsApi } from "../../src/lib/profileSettingsApi";
|
||||||
|
import type { Profile } from "../../src/types";
|
||||||
import { useAuthStore } from "../../src/store";
|
import { useAuthStore } from "../../src/store";
|
||||||
import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui";
|
import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui";
|
||||||
import { colors, spacing, radius, typography } from "../../src/theme";
|
import { colors, spacing, radius, typography } from "../../src/theme";
|
||||||
@@ -290,7 +295,7 @@ export default function LoginScreen() {
|
|||||||
console.log("[Apple Sign In] Got credential:", !!credential, "identityToken:", !!credential?.identityToken);
|
console.log("[Apple Sign In] Got credential:", !!credential, "identityToken:", !!credential?.identityToken);
|
||||||
|
|
||||||
if (credential.identityToken) {
|
if (credential.identityToken) {
|
||||||
const { error } = await authClient.auth.signInWithIdToken({
|
const { data, error } = await authClient.auth.signInWithIdToken({
|
||||||
provider: "apple",
|
provider: "apple",
|
||||||
token: credential.identityToken,
|
token: credential.identityToken,
|
||||||
});
|
});
|
||||||
@@ -299,6 +304,36 @@ export default function LoginScreen() {
|
|||||||
console.log("[Apple Sign In] Supabase error:", error.message);
|
console.log("[Apple Sign In] Supabase error:", error.message);
|
||||||
Alert.alert("Error", getAuthError(error));
|
Alert.alert("Error", getAuthError(error));
|
||||||
} else {
|
} else {
|
||||||
|
const appleProfileName = formatAppleProfileName(credential.fullName);
|
||||||
|
const userId = data.user?.id;
|
||||||
|
const accessToken = data.session?.access_token;
|
||||||
|
if (appleProfileName && userId && accessToken) {
|
||||||
|
const authStore = useAuthStore.getState();
|
||||||
|
authStore.setPendingAppleDisplayName({ userId, name: appleProfileName });
|
||||||
|
try {
|
||||||
|
await persistAppleProfileName(
|
||||||
|
{ userId, accessToken },
|
||||||
|
appleProfileName,
|
||||||
|
{
|
||||||
|
getProfile: (token) => apiRequestWithAccessToken<{ profile: Profile }>("/v1/me", token),
|
||||||
|
updateProfileName: (token, name) => profileSettingsApi.updateProfileWithAccessToken(token, { name }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await authStore.fetchUser({
|
||||||
|
userId,
|
||||||
|
accessToken,
|
||||||
|
isAnonymous: !!(data.user as { is_anonymous?: boolean } | null)?.is_anonymous,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// The account session remains valid. Clear the subject-keyed
|
||||||
|
// temporary name so onboarding safely falls back to its name step.
|
||||||
|
console.warn("[Apple Sign In] Could not persist supplied name");
|
||||||
|
} finally {
|
||||||
|
useAuthStore.getState().clearPendingAppleDisplayName(userId);
|
||||||
|
}
|
||||||
|
} else if (appleProfileName) {
|
||||||
|
Alert.alert("Error", "Apple sign in did not return an authenticated session. Please try again.");
|
||||||
|
}
|
||||||
Events.signIn("apple");
|
Events.signIn("apple");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -844,7 +844,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 47;
|
CURRENT_PROJECT_VERSION = 49;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
DEVELOPMENT_TEAM = BXVPBATDZY;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
@@ -885,7 +885,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 47;
|
CURRENT_PROJECT_VERSION = 49;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
DEVELOPMENT_TEAM = BXVPBATDZY;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>47</string>
|
<string>49</string>
|
||||||
<key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key>
|
<key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export function validateReleaseSources({ root = repoRoot, isTracked } = {}) {
|
|||||||
const androidGradle = file('apps/mobile/android/app/build.gradle');
|
const androidGradle = file('apps/mobile/android/app/build.gradle');
|
||||||
const androidStrings = file('apps/mobile/android/app/src/main/res/values/strings.xml');
|
const androidStrings = file('apps/mobile/android/app/src/main/res/values/strings.xml');
|
||||||
const xcode = file('apps/mobile/ios/Sauci.xcodeproj/project.pbxproj');
|
const xcode = file('apps/mobile/ios/Sauci.xcodeproj/project.pbxproj');
|
||||||
|
const androidManifest = file('apps/mobile/android/app/src/main/AndroidManifest.xml');
|
||||||
const iosInfo = file('apps/mobile/ios/Sauci/Info.plist');
|
const iosInfo = file('apps/mobile/ios/Sauci/Info.plist');
|
||||||
const widgetInfo = file('apps/mobile/targets/widget/Info.plist');
|
const widgetInfo = file('apps/mobile/targets/widget/Info.plist');
|
||||||
const iosExpo = file('apps/mobile/ios/Sauci/Supporting/Expo.plist');
|
const iosExpo = file('apps/mobile/ios/Sauci/Supporting/Expo.plist');
|
||||||
@@ -70,6 +71,17 @@ export function validateReleaseSources({ root = repoRoot, isTracked } = {}) {
|
|||||||
fail(`iOS app, widget, and effective widget Xcode build numbers disagree: app ${appBuild}; widget plist ${widgetBuild}; widget settings ${widgetBuildSettings.join(', ')}`);
|
fail(`iOS app, widget, and effective widget Xcode build numbers disagree: app ${appBuild}; widget plist ${widgetBuild}; widget settings ${widgetBuildSettings.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const permission of ['READ_MEDIA_IMAGES', 'READ_MEDIA_VIDEO']) {
|
||||||
|
const removed = new RegExp(`<uses-permission[^>]*android:name="android\\.permission\\.${permission}"[^>]*tools:node="remove"`).test(androidManifest);
|
||||||
|
if (!removed) fail(`AndroidManifest.xml must keep tools:node="remove" on ${permission} (expo prebuild regresses this)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const forbiddenPlistKeys = ['NSBonjourServices', 'NSLocalNetworkUsageDescription', 'RCTMetroPort'];
|
||||||
|
const foundDevLauncherKeys = forbiddenPlistKeys.filter((key) => iosInfo.includes(key));
|
||||||
|
if (foundDevLauncherKeys.length) {
|
||||||
|
fail(`ios/Sauci/Info.plist must not contain dev-launcher-only keys reintroduced by expo prebuild: ${foundDevLauncherKeys.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
const tracked = isTracked ?? ((relative) => gitStatus(['ls-files', '--error-unmatch', relative], root) === 0);
|
const tracked = isTracked ?? ((relative) => gitStatus(['ls-files', '--error-unmatch', relative], root) === 0);
|
||||||
if (!tracked(certificatePath)) fail(`${certificatePath} must be tracked`);
|
if (!tracked(certificatePath)) fail(`${certificatePath} must be tracked`);
|
||||||
const archiveIgnore = existsSync(join(root, '.easignore')) ? '.easignore' : '.gitignore';
|
const archiveIgnore = existsSync(join(root, '.easignore')) ? '.easignore' : '.gitignore';
|
||||||
|
|||||||
@@ -67,6 +67,38 @@ test('rejects an OTA certificate without its root archive allowlist', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rejects an Android manifest that lost the media permission removal (expo prebuild regression)', () => {
|
||||||
|
const root = fixture();
|
||||||
|
try {
|
||||||
|
const manifest = 'apps/mobile/android/app/src/main/AndroidManifest.xml';
|
||||||
|
replace(
|
||||||
|
root,
|
||||||
|
manifest,
|
||||||
|
'<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>',
|
||||||
|
'<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>',
|
||||||
|
);
|
||||||
|
assert.throws(() => validate(root), /READ_MEDIA_IMAGES \(expo prebuild regresses this\)/);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects an iOS Info.plist that regained dev-launcher-only keys (expo prebuild regression)', () => {
|
||||||
|
const root = fixture();
|
||||||
|
try {
|
||||||
|
const infoPlist = 'apps/mobile/ios/Sauci/Info.plist';
|
||||||
|
replace(
|
||||||
|
root,
|
||||||
|
infoPlist,
|
||||||
|
'</dict>\n</plist>',
|
||||||
|
'<key>NSBonjourServices</key>\n<array><string>_expo._tcp</string></array>\n</dict>\n</plist>',
|
||||||
|
);
|
||||||
|
assert.throws(() => validate(root), /dev-launcher-only keys reintroduced by expo prebuild: NSBonjourServices/);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('requires the local EAS plugin executable rather than its package directory', () => {
|
test('requires the local EAS plugin executable rather than its package directory', () => {
|
||||||
const root = mkdtempSync(join(tmpdir(), 'sauci-eas-plugin-'));
|
const root = mkdtempSync(join(tmpdir(), 'sauci-eas-plugin-'));
|
||||||
const executable = join(root, 'bin-run');
|
const executable = join(root, 'bin-run');
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { act, renderHook } from '@testing-library/react-native';
|
import { act, renderHook } from '@testing-library/react-native';
|
||||||
import { ActionSheetIOS, Alert, Platform } from 'react-native';
|
import { ActionSheetIOS, Alert, Linking, Platform } from 'react-native';
|
||||||
import * as FileSystem from 'expo-file-system/legacy';
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import { useAvatarPicker } from '@/hooks/useAvatarPicker';
|
import { useAvatarPicker } from '@/hooks/useAvatarPicker';
|
||||||
|
|
||||||
|
jest.mock('@/lib/mediaApi', () => ({
|
||||||
|
uploadMedia: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
jest.mock('expo-file-system/legacy', () => ({
|
jest.mock('expo-file-system/legacy', () => ({
|
||||||
getInfoAsync: jest.fn(),
|
getInfoAsync: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('expo-image-picker', () => ({
|
jest.mock('expo-image-picker', () => ({
|
||||||
|
getCameraPermissionsAsync: jest.fn(),
|
||||||
requestCameraPermissionsAsync: jest.fn(),
|
requestCameraPermissionsAsync: jest.fn(),
|
||||||
requestMediaLibraryPermissionsAsync: jest.fn(),
|
requestMediaLibraryPermissionsAsync: jest.fn(),
|
||||||
launchCameraAsync: jest.fn(),
|
launchCameraAsync: jest.fn(),
|
||||||
@@ -23,7 +28,10 @@ describe('useAvatarPicker', () => {
|
|||||||
Object.defineProperty(Platform, 'OS', { configurable: true, value: 'android' });
|
Object.defineProperty(Platform, 'OS', { configurable: true, value: 'android' });
|
||||||
jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
|
jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
|
||||||
jest.spyOn(ActionSheetIOS, 'showActionSheetWithOptions').mockImplementation(() => undefined);
|
jest.spyOn(ActionSheetIOS, 'showActionSheetWithOptions').mockImplementation(() => undefined);
|
||||||
|
jest.spyOn(Linking, 'openSettings').mockResolvedValue(undefined);
|
||||||
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValue({ canceled: true, assets: [] });
|
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValue({ canceled: true, assets: [] });
|
||||||
|
(ImagePicker.getCameraPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'undetermined', canAskAgain: true });
|
||||||
|
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
|
||||||
(FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true });
|
(FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,4 +72,41 @@ describe('useAvatarPicker', () => {
|
|||||||
expect(ImagePicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalledTimes(1);
|
expect(ImagePicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalledTimes(1);
|
||||||
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledTimes(1);
|
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not show a custom alert immediately after a camera permission denial', async () => {
|
||||||
|
const { result } = renderHook(() => useAvatarPicker());
|
||||||
|
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied', canAskAgain: false });
|
||||||
|
|
||||||
|
act(() => result.current.showPicker());
|
||||||
|
const takePhoto = (Alert.alert as jest.Mock).mock.calls[0][2].find(
|
||||||
|
(option: { text: string }) => option.text === 'Take Photo'
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => takePhoto.onPress());
|
||||||
|
|
||||||
|
expect(Alert.alert).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ImagePicker.launchCameraAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers Settings only for a later camera attempt that iOS cannot prompt again', async () => {
|
||||||
|
(ImagePicker.getCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied', canAskAgain: false });
|
||||||
|
const { result } = renderHook(() => useAvatarPicker());
|
||||||
|
|
||||||
|
act(() => result.current.showPicker());
|
||||||
|
const takePhoto = (Alert.alert as jest.Mock).mock.calls[0][2].find(
|
||||||
|
(option: { text: string }) => option.text === 'Take Photo'
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => takePhoto.onPress());
|
||||||
|
|
||||||
|
const settingsAlert = (Alert.alert as jest.Mock).mock.calls[1];
|
||||||
|
expect(settingsAlert[0]).toBe('Camera Access');
|
||||||
|
expect(settingsAlert[2]).toEqual([
|
||||||
|
{ text: 'Not Now', style: 'cancel' },
|
||||||
|
expect.objectContaining({ text: 'Open Settings' }),
|
||||||
|
]);
|
||||||
|
await act(async () => settingsAlert[2][1].onPress());
|
||||||
|
expect(Linking.openSettings).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ImagePicker.requestCameraPermissionsAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiError, apiRequest } from "@/lib/apiClient";
|
import { ApiError, apiRequest, apiRequestWithAccessToken } from "@/lib/apiClient";
|
||||||
import { authClient } from "@/lib/authClient";
|
import { authClient } from "@/lib/authClient";
|
||||||
|
|
||||||
function response(status: number, body?: unknown) {
|
function response(status: number, body?: unknown) {
|
||||||
@@ -40,6 +40,17 @@ describe("apiClient", () => {
|
|||||||
expect(fetchMock).not.toHaveBeenCalled();
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses an explicitly captured bearer without reading a mutable auth session", async () => {
|
||||||
|
fetchMock.mockResolvedValue(response(200, { profile: { id: "user-1" } }));
|
||||||
|
(authClient.auth.getSession as jest.Mock).mockClear();
|
||||||
|
|
||||||
|
await apiRequestWithAccessToken("/v1/me", "captured-token");
|
||||||
|
|
||||||
|
const headers = fetchMock.mock.calls[0][1].headers as Headers;
|
||||||
|
expect(headers.get("Authorization")).toBe("Bearer captured-token");
|
||||||
|
expect(authClient.auth.getSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves API status and error details", async () => {
|
it("preserves API status and error details", async () => {
|
||||||
fetchMock.mockResolvedValue(response(403, {
|
fetchMock.mockResolvedValue(response(403, {
|
||||||
error: { message: "Couple access denied", code: "FORBIDDEN" },
|
error: { message: "Couple access denied", code: "FORBIDDEN" },
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { formatAppleProfileName } from '@/lib/appleProfileName';
|
||||||
|
|
||||||
|
describe('formatAppleProfileName', () => {
|
||||||
|
it('combines Apple name parts into the profile display name', () => {
|
||||||
|
expect(formatAppleProfileName({
|
||||||
|
givenName: ' Ada ',
|
||||||
|
middleName: ' Lovelace',
|
||||||
|
familyName: ' Byron ',
|
||||||
|
})).toBe('Ada Lovelace Byron');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when Apple has not supplied a usable name', () => {
|
||||||
|
expect(formatAppleProfileName({ givenName: ' \n\t ' })).toBeNull();
|
||||||
|
expect(formatAppleProfileName(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes control characters and stays within the profile contract limit', () => {
|
||||||
|
expect(formatAppleProfileName({ givenName: 'Ada\u0000 Lovelace' })).toBe('Ada Lovelace');
|
||||||
|
expect(formatAppleProfileName({ givenName: 'a'.repeat(101) })).toHaveLength(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { persistAppleProfileName } from '@/lib/appleProfileNamePersistence';
|
||||||
|
|
||||||
|
describe('persistAppleProfileName', () => {
|
||||||
|
const session = { userId: 'apple-user', accessToken: 'apple-token' };
|
||||||
|
const getProfile = jest.fn();
|
||||||
|
const updateProfileName = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a supplied name only after a matching blank profile is read with the captured bearer', async () => {
|
||||||
|
getProfile.mockResolvedValue({ profile: { id: 'apple-user', name: null } });
|
||||||
|
updateProfileName.mockResolvedValue({ updated: true });
|
||||||
|
|
||||||
|
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
|
||||||
|
.resolves.toBe('updated');
|
||||||
|
expect(getProfile).toHaveBeenCalledWith('apple-token');
|
||||||
|
expect(updateProfileName).toHaveBeenCalledWith('apple-token', 'Ada Lovelace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a returning user nickname', async () => {
|
||||||
|
getProfile.mockResolvedValue({ profile: { id: 'apple-user', name: 'Ada B' } });
|
||||||
|
|
||||||
|
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
|
||||||
|
.resolves.toBe('existing');
|
||||||
|
expect(updateProfileName).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed on a subject mismatch before any profile write', async () => {
|
||||||
|
getProfile.mockResolvedValue({ profile: { id: 'different-user', name: null } });
|
||||||
|
|
||||||
|
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
|
||||||
|
.rejects.toThrow('subject did not match');
|
||||||
|
expect(updateProfileName).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates API failure so the caller can clear its pending onboarding name', async () => {
|
||||||
|
getProfile.mockRejectedValue(new Error('offline'));
|
||||||
|
|
||||||
|
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
|
||||||
|
.rejects.toThrow('offline');
|
||||||
|
expect(updateProfileName).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { activeOnboardingStage, initialOnboardingStage, previousOnboardingStage } from '@/lib/onboardingStages';
|
||||||
|
|
||||||
|
describe('initialOnboardingStage', () => {
|
||||||
|
it('starts at gender when a profile already has an Apple-supplied name', () => {
|
||||||
|
expect(initialOnboardingStage('Ada Lovelace')).toBe('gender');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts at name as a fallback when no name is available', () => {
|
||||||
|
expect(initialOnboardingStage(' ')).toBe('name');
|
||||||
|
expect(initialOnboardingStage(null)).toBe('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not provide a back destination before the first reachable stage', () => {
|
||||||
|
expect(previousOnboardingStage('name', false)).toBeNull();
|
||||||
|
expect(previousOnboardingStage('gender', true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows backwards navigation only through reachable stages', () => {
|
||||||
|
expect(previousOnboardingStage('gender', false)).toBe('name');
|
||||||
|
expect(previousOnboardingStage('purpose', true)).toBe('gender');
|
||||||
|
expect(previousOnboardingStage('notifications', false)).toBe('purpose');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps first-auth onboarding out of the name screen only while its subject-keyed name is pending', () => {
|
||||||
|
expect(activeOnboardingStage('name', null, 'Ada Lovelace')).toBe('gender');
|
||||||
|
expect(activeOnboardingStage('name', null, undefined)).toBe('name');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiClient } from '../../lib/apiClient';
|
import { apiClient, apiRequestWithAccessToken } from '../../lib/apiClient';
|
||||||
import { profileSettingsApi } from '../../lib/profileSettingsApi';
|
import { profileSettingsApi } from '../../lib/profileSettingsApi';
|
||||||
|
|
||||||
jest.mock('../../lib/apiClient', () => ({
|
jest.mock('../../lib/apiClient', () => ({
|
||||||
@@ -7,6 +7,7 @@ jest.mock('../../lib/apiClient', () => ({
|
|||||||
post: jest.fn(),
|
post: jest.fn(),
|
||||||
patch: jest.fn(),
|
patch: jest.fn(),
|
||||||
},
|
},
|
||||||
|
apiRequestWithAccessToken: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('profileSettingsApi', () => {
|
describe('profileSettingsApi', () => {
|
||||||
@@ -27,4 +28,14 @@ describe('profileSettingsApi', () => {
|
|||||||
type: 'bug', title: 'Title', description: 'Details',
|
type: 'bug', title: 'Title', description: 'Details',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can bind a profile update to a captured authentication bearer', async () => {
|
||||||
|
await profileSettingsApi.updateProfileWithAccessToken('captured-token', { name: 'Ada' });
|
||||||
|
|
||||||
|
expect(apiRequestWithAccessToken).toHaveBeenCalledWith(
|
||||||
|
'/v1/me/profile',
|
||||||
|
'captured-token',
|
||||||
|
{ method: 'PATCH', body: { name: 'Ada' } },
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -30,10 +30,14 @@ const profile = {
|
|||||||
describe("authStore", () => {
|
describe("authStore", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
(authClient.auth.getSession as jest.Mock).mockResolvedValue({
|
||||||
|
data: { session: { user: { id: "me" }, access_token: "token-me" } },
|
||||||
|
});
|
||||||
// fetchUser always fetches couple state now, since it also carries the
|
// fetchUser always fetches couple state now, since it also carries the
|
||||||
// sealed answer count for a user who has no couple yet. Individual tests
|
// sealed answer count for a user who has no couple yet. Individual tests
|
||||||
// override this when they care about the couple/partner shape.
|
// override this when they care about the couple/partner shape.
|
||||||
jest.spyOn(coupleApi, "getState").mockResolvedValue({ couple: null, partner: null, sealed_count: 0 });
|
jest.spyOn(coupleApi, "getState").mockResolvedValue({ couple: null, partner: null, sealed_count: 0 });
|
||||||
|
jest.spyOn(coupleApi, "getStateWithAccessToken").mockResolvedValue({ couple: null, partner: null, sealed_count: 0 });
|
||||||
useAuthStore.setState({
|
useAuthStore.setState({
|
||||||
user: null,
|
user: null,
|
||||||
couple: null,
|
couple: null,
|
||||||
@@ -42,6 +46,7 @@ describe("authStore", () => {
|
|||||||
isLoading: true,
|
isLoading: true,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isAnonymous: false,
|
isAnonymous: false,
|
||||||
|
pendingAppleDisplayName: null,
|
||||||
} as any);
|
} as any);
|
||||||
useMatchStore.setState({ matches: [{ id: "m1" }], newMatchesCount: 1 } as any);
|
useMatchStore.setState({ matches: [{ id: "m1" }], newMatchesCount: 1 } as any);
|
||||||
usePacksStore.setState({ enabledPackIds: ["p1"] } as any);
|
usePacksStore.setState({ enabledPackIds: ["p1"] } as any);
|
||||||
@@ -64,13 +69,13 @@ describe("authStore", () => {
|
|||||||
|
|
||||||
it("bootstraps and reads the profile through GET /v1/me", async () => {
|
it("bootstraps and reads the profile through GET /v1/me", async () => {
|
||||||
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({
|
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({
|
||||||
data: { session: { user: { id: "me", is_anonymous: true } } },
|
data: { session: { user: { id: "me", is_anonymous: true }, access_token: "token-me" } },
|
||||||
});
|
});
|
||||||
const getSpy = jest.spyOn(apiClient, "get").mockResolvedValueOnce({ profile });
|
const getSpy = jest.spyOn(apiClient, "getWithAccessToken").mockResolvedValueOnce({ profile });
|
||||||
|
|
||||||
await useAuthStore.getState().fetchUser();
|
await useAuthStore.getState().fetchUser();
|
||||||
|
|
||||||
expect(getSpy).toHaveBeenCalledWith("/v1/me");
|
expect(getSpy).toHaveBeenCalledWith("/v1/me", "token-me");
|
||||||
expect(useAuthStore.getState()).toMatchObject({
|
expect(useAuthStore.getState()).toMatchObject({
|
||||||
user: profile,
|
user: profile,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
@@ -114,9 +119,9 @@ describe("authStore", () => {
|
|||||||
it("clears user-scoped stores and hosted auth when the API rejects the session", async () => {
|
it("clears user-scoped stores and hosted auth when the API rejects the session", async () => {
|
||||||
useAuthStore.setState({ sealedCount: 3 } as any);
|
useAuthStore.setState({ sealedCount: 3 } as any);
|
||||||
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({
|
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({
|
||||||
data: { session: { user: { id: "me" } } },
|
data: { session: { user: { id: "me" }, access_token: "token-me" } },
|
||||||
});
|
});
|
||||||
jest.spyOn(apiClient, "get").mockRejectedValueOnce(new ApiError("Unauthorized", 401));
|
jest.spyOn(apiClient, "getWithAccessToken").mockRejectedValueOnce(new ApiError("Unauthorized", 401));
|
||||||
(authClient.auth.signOut as jest.Mock).mockResolvedValueOnce({ error: null });
|
(authClient.auth.signOut as jest.Mock).mockResolvedValueOnce({ error: null });
|
||||||
|
|
||||||
await useAuthStore.getState().fetchUser();
|
await useAuthStore.getState().fetchUser();
|
||||||
@@ -135,6 +140,123 @@ describe("authStore", () => {
|
|||||||
expect(authClient.auth.signOut).toHaveBeenCalled();
|
expect(authClient.auth.signOut).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not let an older session response overwrite a newer profile", async () => {
|
||||||
|
let currentSession = { user: { id: "user-a" }, access_token: "token-a" };
|
||||||
|
(authClient.auth.getSession as jest.Mock).mockImplementation(async () => ({ data: { session: currentSession } }));
|
||||||
|
|
||||||
|
let resolveA!: (value: { profile: Profile }) => void;
|
||||||
|
let resolveB!: (value: { profile: Profile }) => void;
|
||||||
|
const getSpy = jest.spyOn(apiClient, "getWithAccessToken")
|
||||||
|
.mockImplementationOnce(() => new Promise((resolve) => { resolveA = resolve; }))
|
||||||
|
.mockImplementationOnce(() => new Promise((resolve) => { resolveB = resolve; }));
|
||||||
|
|
||||||
|
const firstFetch = useAuthStore.getState().fetchUser();
|
||||||
|
currentSession = { user: { id: "user-b" }, access_token: "token-b" };
|
||||||
|
const secondFetch = useAuthStore.getState().fetchUser();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
resolveB({ profile: { ...profile, id: "user-b", name: "Newer Name" } as Profile });
|
||||||
|
await secondFetch;
|
||||||
|
resolveA({ profile: { ...profile, id: "user-a", name: "Older Name" } as Profile });
|
||||||
|
await firstFetch;
|
||||||
|
|
||||||
|
expect(getSpy).toHaveBeenNthCalledWith(1, "/v1/me", "token-a");
|
||||||
|
expect(getSpy).toHaveBeenNthCalledWith(2, "/v1/me", "token-b");
|
||||||
|
expect(useAuthStore.getState()).toMatchObject({
|
||||||
|
user: expect.objectContaining({ id: "user-b", name: "Newer Name" }),
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let an older couple response overwrite a newer session", async () => {
|
||||||
|
let currentSession = { user: { id: "user-a" }, access_token: "token-a" };
|
||||||
|
(authClient.auth.getSession as jest.Mock).mockImplementation(async () => ({ data: { session: currentSession } }));
|
||||||
|
jest.spyOn(apiClient, "getWithAccessToken")
|
||||||
|
.mockResolvedValueOnce({ profile: { ...profile, id: "user-a", name: "Older Name" } })
|
||||||
|
.mockResolvedValueOnce({ profile: { ...profile, id: "user-b", name: "Newer Name" } });
|
||||||
|
|
||||||
|
let resolveCoupleA!: (value: { couple: null; partner: null; sealed_count: number }) => void;
|
||||||
|
jest.spyOn(coupleApi, "getStateWithAccessToken")
|
||||||
|
.mockImplementationOnce(() => new Promise((resolve) => { resolveCoupleA = resolve; }))
|
||||||
|
.mockResolvedValueOnce({ couple: null, partner: null, sealed_count: 7 });
|
||||||
|
|
||||||
|
const firstFetch = useAuthStore.getState().fetchUser();
|
||||||
|
for (let attempt = 0; attempt < 10 && !resolveCoupleA; attempt += 1) {
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
expect(resolveCoupleA).toBeDefined();
|
||||||
|
currentSession = { user: { id: "user-b" }, access_token: "token-b" };
|
||||||
|
const secondFetch = useAuthStore.getState().fetchUser();
|
||||||
|
await secondFetch;
|
||||||
|
resolveCoupleA({ couple: null, partner: null, sealed_count: 1 });
|
||||||
|
await firstFetch;
|
||||||
|
|
||||||
|
expect(useAuthStore.getState()).toMatchObject({
|
||||||
|
user: expect.objectContaining({ id: "user-b", name: "Newer Name" }),
|
||||||
|
sealedCount: 7,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a mismatched API profile without signing out the active session", async () => {
|
||||||
|
useAuthStore.setState({
|
||||||
|
user: profile,
|
||||||
|
couple: { id: "prior-couple", invite_code: "PRIOR123", created_at: profile.created_at },
|
||||||
|
partner: { ...profile, id: "prior-partner", name: "Prior Partner" },
|
||||||
|
sealedCount: 4,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isAnonymous: true,
|
||||||
|
pendingAppleDisplayName: { userId: "me", name: "Prior Name" },
|
||||||
|
} as any);
|
||||||
|
jest.spyOn(apiClient, "getWithAccessToken").mockResolvedValueOnce({
|
||||||
|
profile: { ...profile, id: "different-user", name: "Wrong Subject" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await useAuthStore.getState().fetchUser();
|
||||||
|
|
||||||
|
expect(useAuthStore.getState()).toMatchObject({
|
||||||
|
user: null,
|
||||||
|
couple: null,
|
||||||
|
partner: null,
|
||||||
|
sealedCount: 0,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isAnonymous: false,
|
||||||
|
isLoading: false,
|
||||||
|
pendingAppleDisplayName: null,
|
||||||
|
});
|
||||||
|
expect(useMatchStore.getState().matches).toEqual([]);
|
||||||
|
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
|
||||||
|
expect(useMessageStore.getState().unreadCount).toBe(0);
|
||||||
|
expect(useSubscriptionStore.getState().subscription).toMatchObject({ isProUser: false });
|
||||||
|
expect(authClient.auth.signOut).not.toHaveBeenCalled();
|
||||||
|
expect(coupleApi.getStateWithAccessToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not sign out a newer session when an older request is rejected", async () => {
|
||||||
|
let currentSession = { user: { id: "user-a" }, access_token: "token-a" };
|
||||||
|
(authClient.auth.getSession as jest.Mock).mockImplementation(async () => ({ data: { session: currentSession } }));
|
||||||
|
|
||||||
|
let rejectA!: (error: Error) => void;
|
||||||
|
const getSpy = jest.spyOn(apiClient, "getWithAccessToken")
|
||||||
|
.mockImplementationOnce(() => new Promise((_, reject) => { rejectA = reject; }))
|
||||||
|
.mockResolvedValueOnce({ profile: { ...profile, id: "user-b", name: "Newer Name" } });
|
||||||
|
|
||||||
|
const firstFetch = useAuthStore.getState().fetchUser();
|
||||||
|
currentSession = { user: { id: "user-b" }, access_token: "token-b" };
|
||||||
|
const secondFetch = useAuthStore.getState().fetchUser();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await secondFetch;
|
||||||
|
rejectA(new ApiError("Unauthorized", 401));
|
||||||
|
await firstFetch;
|
||||||
|
|
||||||
|
expect(getSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(authClient.auth.signOut).not.toHaveBeenCalled();
|
||||||
|
expect(useAuthStore.getState().user).toMatchObject({ id: "user-b", name: "Newer Name" });
|
||||||
|
});
|
||||||
|
|
||||||
it("clears sealed answers during an explicit sign-out", async () => {
|
it("clears sealed answers during an explicit sign-out", async () => {
|
||||||
useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any);
|
useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any);
|
||||||
|
|
||||||
@@ -158,4 +280,14 @@ describe("authStore", () => {
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
expect(useAuthStore.getState().pendingAppleDisplayName).toEqual({ userId: 'apple-user', name: 'Ada Lovelace' });
|
||||||
|
|
||||||
|
useAuthStore.getState().clearPendingAppleDisplayName('apple-user');
|
||||||
|
expect(useAuthStore.getState().pendingAppleDisplayName).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -223,7 +223,8 @@ 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 () => {
|
||||||
await resetMatchesTutorial();
|
if (!user?.id) return;
|
||||||
|
await resetMatchesTutorial(user.id);
|
||||||
Alert.alert("Success", "Matches tutorial has been reset.");
|
Alert.alert("Success", "Matches tutorial has been reset.");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -41,9 +41,14 @@ if (Platform.OS === "android") {
|
|||||||
import { GradientBackground, GlassButton, GlassCard, GlassInput } from "../../../components/ui";
|
import { GradientBackground, GlassButton, GlassCard, GlassInput } from "../../../components/ui";
|
||||||
import { colors, spacing, radius, typography } from "../../../theme";
|
import { colors, spacing, radius, typography } from "../../../theme";
|
||||||
import { authClient } from "../../../lib/authClient";
|
import { authClient } from "../../../lib/authClient";
|
||||||
|
import { apiRequestWithAccessToken } from "../../../lib/apiClient";
|
||||||
|
import { formatAppleProfileName } from "../../../lib/appleProfileName";
|
||||||
|
import { persistAppleProfileName } from "../../../lib/appleProfileNamePersistence";
|
||||||
import { getAuthError } from "../../../lib/errors";
|
import { getAuthError } from "../../../lib/errors";
|
||||||
|
import { profileSettingsApi } from "../../../lib/profileSettingsApi";
|
||||||
import { useAuthStore } from "../../../store";
|
import { useAuthStore } from "../../../store";
|
||||||
import { ScreenHeader } from "../components";
|
import { ScreenHeader } from "../components";
|
||||||
|
import type { Profile } from "../../../types";
|
||||||
|
|
||||||
type SaveMode = "magic-link" | "password";
|
type SaveMode = "magic-link" | "password";
|
||||||
|
|
||||||
@@ -182,28 +187,40 @@ export function SaveAccountScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apple only provides name/email on first auth grant
|
const appleProfileName = formatAppleProfileName(credential.fullName);
|
||||||
if (credential.fullName) {
|
if (appleProfileName) {
|
||||||
const nameParts = [
|
try {
|
||||||
credential.fullName.givenName,
|
const { data: { session } } = await authClient.auth.getSession();
|
||||||
credential.fullName.middleName,
|
if (!session?.user.id || !session.access_token) {
|
||||||
credential.fullName.familyName,
|
throw new Error("Apple account link did not retain an authenticated session");
|
||||||
].filter(Boolean);
|
}
|
||||||
const fullName = nameParts.join(" ");
|
|
||||||
|
|
||||||
if (fullName) {
|
await persistAppleProfileName(
|
||||||
await authClient.auth.updateUser({
|
{ userId: session.user.id, accessToken: session.access_token },
|
||||||
data: {
|
appleProfileName,
|
||||||
full_name: fullName,
|
{
|
||||||
given_name: credential.fullName.givenName,
|
getProfile: (token) => apiRequestWithAccessToken<{ profile: Profile }>("/v1/me", token),
|
||||||
family_name: credential.fullName.familyName,
|
updateProfileName: (token, name) => profileSettingsApi.updateProfileWithAccessToken(token, { name }),
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
} catch {
|
||||||
|
// Identity linking succeeded. Do not retain the raw Apple name in Auth
|
||||||
|
// metadata if the separate API profile update is temporarily unavailable.
|
||||||
|
console.warn("[Apple Link] Could not persist supplied name");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh user/profile
|
// Refresh user/profile
|
||||||
await fetchUser();
|
const { data: { session } } = await authClient.auth.getSession();
|
||||||
|
if (session?.user.id && session.access_token) {
|
||||||
|
await fetchUser({
|
||||||
|
userId: session.user.id,
|
||||||
|
accessToken: session.access_token,
|
||||||
|
isAnonymous: !!(session.user as { is_anonymous?: boolean }).is_anonymous,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await fetchUser();
|
||||||
|
}
|
||||||
|
|
||||||
showSuccess("Account saved with Apple.");
|
showSuccess("Account saved with Apple.");
|
||||||
router.navigate("/(app)/profile" as any);
|
router.navigate("/(app)/profile" as any);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import { Alert, Platform, ActionSheetIOS } from 'react-native';
|
import { Alert, Platform, ActionSheetIOS, Linking } from 'react-native';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import * as FileSystem from 'expo-file-system/legacy';
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import { uploadMedia } from '../lib/mediaApi';
|
import { uploadMedia } from '../lib/mediaApi';
|
||||||
@@ -85,14 +85,26 @@ export function useAvatarPicker(options?: UseAvatarPickerOptions): UseAvatarPick
|
|||||||
const currentOptions = optionsRef.current;
|
const currentOptions = optionsRef.current;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result: ImagePicker.ImagePickerResult;
|
let result: ImagePicker.ImagePickerResult;
|
||||||
|
|
||||||
if (source === 'camera') {
|
if (source === 'camera') {
|
||||||
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
const existingPermission = await ImagePicker.getCameraPermissionsAsync();
|
||||||
if (status !== 'granted') {
|
if (existingPermission.status !== 'granted' && !existingPermission.canAskAgain) {
|
||||||
Alert.alert('Permission Denied', 'Camera access is required to take a photo.');
|
Alert.alert(
|
||||||
return;
|
'Camera Access',
|
||||||
}
|
'To take a photo, allow camera access in Settings.',
|
||||||
|
[
|
||||||
|
{ text: 'Not Now', style: 'cancel' },
|
||||||
|
{ text: 'Open Settings', onPress: () => Linking.openSettings() },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
||||||
|
if (status !== 'granted') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
result = await ImagePicker.launchCameraAsync({
|
result = await ImagePicker.launchCameraAsync({
|
||||||
mediaTypes: ['images'],
|
mediaTypes: ['images'],
|
||||||
allowsEditing: true,
|
allowsEditing: true,
|
||||||
|
|||||||
@@ -37,14 +37,11 @@ async function parseResponse(response: Response): Promise<unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
|
export async function apiRequestWithAccessToken<T>(
|
||||||
const { data, error } = await authClient.auth.getSession();
|
path: string,
|
||||||
const accessToken = data.session?.access_token;
|
accessToken: string,
|
||||||
|
options: ApiRequestOptions = {},
|
||||||
if (error || !accessToken) {
|
): Promise<T> {
|
||||||
throw new ApiError("An authenticated session is required", 401, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
headers.set("Accept", "application/json");
|
headers.set("Accept", "application/json");
|
||||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||||
@@ -83,6 +80,17 @@ export async function apiRequest<T>(path: string, options: ApiRequestOptions = {
|
|||||||
return responseBody as T;
|
return responseBody as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
|
||||||
|
const { data, error } = await authClient.auth.getSession();
|
||||||
|
const accessToken = data.session?.access_token;
|
||||||
|
|
||||||
|
if (error || !accessToken) {
|
||||||
|
throw new ApiError("An authenticated session is required", 401, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiRequestWithAccessToken(path, accessToken, options);
|
||||||
|
}
|
||||||
|
|
||||||
export async function authenticatedFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
export async function authenticatedFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||||
const { data, error } = await authClient.auth.getSession();
|
const { data, error } = await authClient.auth.getSession();
|
||||||
const accessToken = data.session?.access_token;
|
const accessToken = data.session?.access_token;
|
||||||
@@ -94,6 +102,7 @@ export async function authenticatedFetch(path: string, init: RequestInit = {}):
|
|||||||
|
|
||||||
export const apiClient = {
|
export const apiClient = {
|
||||||
get: <T>(path: string) => apiRequest<T>(path),
|
get: <T>(path: string) => apiRequest<T>(path),
|
||||||
|
getWithAccessToken: <T>(path: string, accessToken: string) => apiRequestWithAccessToken<T>(path, accessToken),
|
||||||
post: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "POST", body }),
|
post: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "POST", body }),
|
||||||
put: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "PUT", body }),
|
put: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "PUT", body }),
|
||||||
patch: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "PATCH", body }),
|
patch: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "PATCH", body }),
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export interface AppleProfileName {
|
||||||
|
givenName?: string | null;
|
||||||
|
middleName?: string | null;
|
||||||
|
familyName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_PROFILE_NAME_LENGTH = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apple supplies its name fields only on the first authorization. Keep that
|
||||||
|
* user-provided value as the Sauci display name without retaining email or
|
||||||
|
* other Apple credential fields outside the existing profile contract.
|
||||||
|
*/
|
||||||
|
export function formatAppleProfileName(fullName: AppleProfileName | null | undefined): string | null {
|
||||||
|
const name = [fullName?.givenName, fullName?.middleName, fullName?.familyName]
|
||||||
|
.filter((part): part is string => typeof part === "string")
|
||||||
|
.join(" ")
|
||||||
|
.normalize("NFKC")
|
||||||
|
.replace(/[\u0000-\u001F\u007F-\u009F]/g, "")
|
||||||
|
.replace(/\s+/gu, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return name.length > 0 ? name.slice(0, MAX_PROFILE_NAME_LENGTH) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Profile } from '../types';
|
||||||
|
|
||||||
|
export interface AppleProfileNameSession {
|
||||||
|
userId: string;
|
||||||
|
accessToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppleProfileNamePersistenceDependencies {
|
||||||
|
getProfile: (accessToken: string) => Promise<{ profile: Pick<Profile, 'id' | 'name'> }>;
|
||||||
|
updateProfileName: (accessToken: string, name: string) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppleProfileNamePersistenceResult = 'updated' | 'existing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a first-authorization Apple display name only for the exact subject
|
||||||
|
* and bearer that completed the authorization. The profile read protects an
|
||||||
|
* existing Sauci nickname from being overwritten.
|
||||||
|
*/
|
||||||
|
export async function persistAppleProfileName(
|
||||||
|
session: AppleProfileNameSession,
|
||||||
|
name: string,
|
||||||
|
dependencies: AppleProfileNamePersistenceDependencies,
|
||||||
|
): Promise<AppleProfileNamePersistenceResult> {
|
||||||
|
const { profile } = await dependencies.getProfile(session.accessToken);
|
||||||
|
if (profile.id !== session.userId) {
|
||||||
|
throw new Error('Apple profile subject did not match the authenticated user');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.name?.trim()) {
|
||||||
|
return 'existing';
|
||||||
|
}
|
||||||
|
|
||||||
|
await dependencies.updateProfileName(session.accessToken, name);
|
||||||
|
return 'updated';
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Couple, Profile } from "@/types";
|
import type { Couple, Profile } from "@/types";
|
||||||
import { apiRequest } from "./apiClient";
|
import { apiRequest, apiRequestWithAccessToken } from "./apiClient";
|
||||||
|
|
||||||
export interface CoupleStateResponse {
|
export interface CoupleStateResponse {
|
||||||
couple: Couple | null;
|
couple: Couple | null;
|
||||||
@@ -16,6 +16,8 @@ export interface CoupleMutationResponse {
|
|||||||
|
|
||||||
export const coupleApi = {
|
export const coupleApi = {
|
||||||
getState: () => apiRequest<CoupleStateResponse>("/v1/couple"),
|
getState: () => apiRequest<CoupleStateResponse>("/v1/couple"),
|
||||||
|
getStateWithAccessToken: (accessToken: string) =>
|
||||||
|
apiRequestWithAccessToken<CoupleStateResponse>("/v1/couple", accessToken),
|
||||||
create: () => apiRequest<CoupleMutationResponse>("/v1/couple", { method: "POST", body: {} }),
|
create: () => apiRequest<CoupleMutationResponse>("/v1/couple", { method: "POST", body: {} }),
|
||||||
join: (inviteCode: string) => apiRequest<CoupleMutationResponse>("/v1/couple", {
|
join: (inviteCode: string) => apiRequest<CoupleMutationResponse>("/v1/couple", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,51 +1,70 @@
|
|||||||
import * as SecureStore from "expo-secure-store";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { Platform } from "react-native";
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
import { Platform } from "react-native";
|
||||||
const STORAGE_KEY = "matches_tutorial_seen";
|
|
||||||
|
const STORAGE_KEY_PREFIX = "matches_tutorial_seen";
|
||||||
async function getStorage(): Promise<boolean> {
|
const LEGACY_STORAGE_KEY = "matches_tutorial_seen";
|
||||||
try {
|
|
||||||
let data: string | null = null;
|
const buildKey = (userId: string): string => `${STORAGE_KEY_PREFIX}_${userId}`;
|
||||||
|
|
||||||
if (Platform.OS === "web") {
|
async function getStorage(userId: string): Promise<boolean> {
|
||||||
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
|
try {
|
||||||
data = window.localStorage.getItem(STORAGE_KEY);
|
const key = buildKey(userId);
|
||||||
}
|
|
||||||
} else {
|
if (Platform.OS === "web") {
|
||||||
data = await SecureStore.getItemAsync(STORAGE_KEY);
|
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
|
||||||
}
|
return window.localStorage.getItem(key) === "true";
|
||||||
|
}
|
||||||
return data === "true";
|
|
||||||
} catch (error) {
|
return false;
|
||||||
console.error("Error reading matches tutorial state:", error);
|
}
|
||||||
}
|
|
||||||
return false;
|
const storedValue = await AsyncStorage.getItem(key);
|
||||||
}
|
if (storedValue !== null) {
|
||||||
|
return storedValue === "true";
|
||||||
async function setStorage(seen: boolean): Promise<void> {
|
}
|
||||||
try {
|
|
||||||
const data = seen ? "true" : "false";
|
// Keep dismissals made before the account-scoped AsyncStorage key was
|
||||||
|
// introduced. Future reads use the durable app storage key above.
|
||||||
if (Platform.OS === "web") {
|
const legacyValue = await SecureStore.getItemAsync(LEGACY_STORAGE_KEY);
|
||||||
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
|
if (legacyValue === "true") {
|
||||||
window.localStorage.setItem(STORAGE_KEY, data);
|
await AsyncStorage.setItem(key, "true");
|
||||||
}
|
return true;
|
||||||
} else {
|
}
|
||||||
await SecureStore.setItemAsync(STORAGE_KEY, data);
|
|
||||||
}
|
return false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving matches tutorial state:", error);
|
console.error("Error reading matches tutorial state:", error);
|
||||||
}
|
}
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
export async function hasSeenMatchesTutorial(): Promise<boolean> {
|
|
||||||
return getStorage();
|
async function setStorage(userId: string, seen: boolean): Promise<void> {
|
||||||
}
|
try {
|
||||||
|
const data = seen ? "true" : "false";
|
||||||
export async function markMatchesTutorialSeen(): Promise<void> {
|
const key = buildKey(userId);
|
||||||
await setStorage(true);
|
|
||||||
}
|
if (Platform.OS === "web") {
|
||||||
|
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
|
||||||
export async function resetMatchesTutorial(): Promise<void> {
|
window.localStorage.setItem(key, data);
|
||||||
await setStorage(false);
|
}
|
||||||
}
|
} else {
|
||||||
|
await AsyncStorage.setItem(key, data);
|
||||||
|
}
|
||||||
|
} catch (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(userId: string): Promise<boolean> {
|
||||||
|
return getStorage(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markMatchesTutorialSeen(userId: string): Promise<void> {
|
||||||
|
await setStorage(userId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetMatchesTutorial(userId: string): Promise<void> {
|
||||||
|
await setStorage(userId, false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export type OnboardingStage = "name" | "gender" | "purpose" | "notifications";
|
||||||
|
|
||||||
|
/** A stored display name lets onboarding begin at the next required question. */
|
||||||
|
export function initialOnboardingStage(name: string | null | undefined): OnboardingStage {
|
||||||
|
return name?.trim() ? "gender" : "name";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pending first-auth name is valid only for the matching signed-in subject. */
|
||||||
|
export function activeOnboardingStage(
|
||||||
|
stage: OnboardingStage,
|
||||||
|
storedDisplayName: string | null | undefined,
|
||||||
|
pendingDisplayName: string | null | undefined,
|
||||||
|
): OnboardingStage {
|
||||||
|
return stage === "name" && (storedDisplayName?.trim() || pendingDisplayName?.trim()) ? "gender" : stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first reachable stage has no back action. */
|
||||||
|
export function previousOnboardingStage(
|
||||||
|
stage: OnboardingStage,
|
||||||
|
hasStoredDisplayName: boolean,
|
||||||
|
): OnboardingStage | null {
|
||||||
|
switch (stage) {
|
||||||
|
case "name":
|
||||||
|
return null;
|
||||||
|
case "gender":
|
||||||
|
return hasStoredDisplayName ? null : "name";
|
||||||
|
case "purpose":
|
||||||
|
return "gender";
|
||||||
|
case "notifications":
|
||||||
|
return "purpose";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiClient } from './apiClient';
|
import { apiClient, apiRequestWithAccessToken } from './apiClient';
|
||||||
import type { Gender } from '../types';
|
import type { Gender } from '../types';
|
||||||
import type { NotificationPreferences } from '../store/notificationPreferencesStore';
|
import type { NotificationPreferences } from '../store/notificationPreferencesStore';
|
||||||
|
|
||||||
@@ -20,6 +20,8 @@ type PreferenceKey = keyof Omit<NotificationPreferences, 'user_id' | 'created_at
|
|||||||
|
|
||||||
export const profileSettingsApi = {
|
export const profileSettingsApi = {
|
||||||
updateProfile: (update: ProfileUpdate) => apiClient.patch<{ updated: true }>('/v1/me/profile', update),
|
updateProfile: (update: ProfileUpdate) => apiClient.patch<{ updated: true }>('/v1/me/profile', update),
|
||||||
|
updateProfileWithAccessToken: (accessToken: string, update: ProfileUpdate) =>
|
||||||
|
apiRequestWithAccessToken<{ updated: true }>('/v1/me/profile', accessToken, { method: 'PATCH', body: update }),
|
||||||
updateLastActive: () => apiClient.post<void>('/v1/me/activity'),
|
updateLastActive: () => apiClient.post<void>('/v1/me/activity'),
|
||||||
getNotificationPreferences: () => apiClient.get<NotificationPreferences>('/v1/me/notification-preferences'),
|
getNotificationPreferences: () => apiClient.get<NotificationPreferences>('/v1/me/notification-preferences'),
|
||||||
updateNotificationPreference: (key: PreferenceKey, value: boolean) =>
|
updateNotificationPreference: (key: PreferenceKey, value: boolean) =>
|
||||||
|
|||||||
@@ -7,6 +7,22 @@ import { syncTimezone } from "../lib/reportedTimezone";
|
|||||||
import { Events } from "../lib/analytics";
|
import { Events } from "../lib/analytics";
|
||||||
import type { Profile, Couple } from "@/types";
|
import type { Profile, Couple } from "@/types";
|
||||||
|
|
||||||
|
export interface AuthSessionSnapshot {
|
||||||
|
userId: string;
|
||||||
|
accessToken: string;
|
||||||
|
isAnonymous: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fetchUserGeneration = 0;
|
||||||
|
|
||||||
|
const snapshotSession = (session: { user?: { id: string; is_anonymous?: boolean }; access_token?: string } | null): AuthSessionSnapshot | null => {
|
||||||
|
if (!session?.user?.id || !session.access_token) return null;
|
||||||
|
return {
|
||||||
|
userId: session.user.id,
|
||||||
|
accessToken: session.access_token,
|
||||||
|
isAnonymous: !!session.user.is_anonymous,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: Profile | null;
|
user: Profile | null;
|
||||||
@@ -17,14 +33,18 @@ interface AuthState {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isAnonymous: boolean;
|
isAnonymous: boolean;
|
||||||
|
/** First-authorization Apple name held only until the API profile reflects it. */
|
||||||
|
pendingAppleDisplayName: { userId: string; name: string } | null;
|
||||||
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
fetchUser: () => Promise<void>;
|
fetchUser: (expectedSession?: AuthSessionSnapshot) => Promise<void>;
|
||||||
fetchCouple: () => Promise<void>;
|
fetchCouple: () => Promise<void>;
|
||||||
refreshPartner: () => Promise<Profile | null>;
|
refreshPartner: () => Promise<Profile | null>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
setUser: (user: Profile | null) => void;
|
setUser: (user: Profile | null) => void;
|
||||||
|
setPendingAppleDisplayName: (pending: { userId: string; name: string } | null) => void;
|
||||||
|
clearPendingAppleDisplayName: (userId: string) => void;
|
||||||
updateLastActive: () => Promise<void>;
|
updateLastActive: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,45 +70,94 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
isLoading: true,
|
isLoading: true,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isAnonymous: false,
|
isAnonymous: false,
|
||||||
|
pendingAppleDisplayName: null,
|
||||||
|
|
||||||
|
fetchUser: async (expectedSession) => {
|
||||||
|
const generation = ++fetchUserGeneration;
|
||||||
|
let snapshot: AuthSessionSnapshot | null = expectedSession ?? null;
|
||||||
|
const isCurrentSession = async (snapshot: AuthSessionSnapshot): Promise<boolean> => {
|
||||||
|
if (generation !== fetchUserGeneration) return false;
|
||||||
|
|
||||||
fetchUser: async () => {
|
|
||||||
try {
|
|
||||||
// First check if we have a session in storage
|
|
||||||
const { data: { session } } = await authClient.auth.getSession();
|
const { data: { session } } = await authClient.auth.getSession();
|
||||||
|
return generation === fetchUserGeneration
|
||||||
|
&& session?.user?.id === snapshot.userId
|
||||||
|
&& session.access_token === snapshot.accessToken;
|
||||||
|
};
|
||||||
|
|
||||||
if (!session?.user) {
|
try {
|
||||||
set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
|
// Capture the session that owns this refresh. All following API calls
|
||||||
|
// and state writes remain bound to it, never the ambient auth session.
|
||||||
|
snapshot = expectedSession ?? snapshotSession((await authClient.auth.getSession()).data.session);
|
||||||
|
|
||||||
|
if (!snapshot) {
|
||||||
|
if (generation !== fetchUserGeneration) return;
|
||||||
|
set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false, pendingAppleDisplayName: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This endpoint verifies the Supabase access token and creates the
|
// This endpoint verifies the Supabase access token and creates the
|
||||||
// product profile on first use, keeping profile data out of Supabase.
|
// product profile on first use, keeping profile data out of Supabase.
|
||||||
const { profile } = await apiClient.get<{ profile: Profile }>("/v1/me");
|
const { profile } = await apiClient.getWithAccessToken<{ profile: Profile }>("/v1/me", snapshot.accessToken);
|
||||||
|
|
||||||
const isAnonymous = !!(session.user as { is_anonymous?: boolean }).is_anonymous;
|
if (!(await isCurrentSession(snapshot))) return;
|
||||||
|
|
||||||
|
if (profile.id !== snapshot.userId) {
|
||||||
|
console.error("[Auth] API profile subject did not match the authenticated session");
|
||||||
|
// Fail closed rather than leaving the prior account visible when
|
||||||
|
// an API response cannot be proven to belong to this session.
|
||||||
|
set({ user: null, couple: null, partner: null, sealedCount: 0, isAuthenticated: false, isAnonymous: false, isLoading: false, pendingAppleDisplayName: null });
|
||||||
|
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
|
||||||
|
useMatchStore.getState().clearMatches();
|
||||||
|
usePacksStore.getState().clearPacks();
|
||||||
|
useMessageStore.getState().clearMessages();
|
||||||
|
useSubscriptionStore.getState().clearSubscription();
|
||||||
|
useNotificationPreferencesStore.getState().clearPreferences();
|
||||||
|
useStreakStore.getState().clearStreak();
|
||||||
|
useResponsesStore.getState().clearResponses();
|
||||||
|
useQuizStore.getState().clearQuiz();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAnonymous = snapshot.isAnonymous;
|
||||||
|
|
||||||
// Only update user if data actually changed to avoid unnecessary re-renders
|
// Only update user if data actually changed to avoid unnecessary re-renders
|
||||||
const currentUser = get().user;
|
const currentUser = get().user;
|
||||||
const userChanged = !currentUser || JSON.stringify(currentUser) !== JSON.stringify(profile);
|
const userChanged = !currentUser || JSON.stringify(currentUser) !== JSON.stringify(profile);
|
||||||
|
// Always fetch couple state: it reports the sealed answer count for a
|
||||||
|
// solo user even before any couple exists, not just once paired.
|
||||||
|
const { couple, partner, sealed_count } = await coupleApi.getStateWithAccessToken(snapshot.accessToken);
|
||||||
|
|
||||||
|
if (!(await isCurrentSession(snapshot))) return;
|
||||||
|
|
||||||
if (userChanged) {
|
if (userChanged) {
|
||||||
set({
|
set({
|
||||||
user: profile,
|
user: profile,
|
||||||
|
couple,
|
||||||
|
partner,
|
||||||
|
sealedCount: sealed_count,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isAnonymous,
|
isAnonymous,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
couple,
|
||||||
|
partner,
|
||||||
|
sealedCount: sealed_count,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isAnonymous,
|
||||||
|
isLoading: false,
|
||||||
});
|
});
|
||||||
} else if (!get().isAuthenticated) {
|
|
||||||
set({ isAuthenticated: true, isAnonymous });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always fetch couple state: it reports the sealed answer count for a
|
|
||||||
// solo user even before any couple exists, not just once paired.
|
|
||||||
await get().fetchCouple();
|
|
||||||
|
|
||||||
set({ isLoading: false });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation !== fetchUserGeneration) return;
|
||||||
|
|
||||||
|
const currentSnapshot = snapshot;
|
||||||
|
if (currentSnapshot && !(await isCurrentSession(currentSnapshot))) return;
|
||||||
|
|
||||||
if (error instanceof ApiError && error.status === 401) {
|
if (error instanceof ApiError && error.status === 401) {
|
||||||
console.log("[Auth] Session rejected by API, signing out");
|
console.log("[Auth] Session rejected by API, signing out");
|
||||||
set({ user: null, couple: null, partner: null, sealedCount: 0, isAuthenticated: false, isAnonymous: false, isLoading: false });
|
set({ user: null, couple: null, partner: null, sealedCount: 0, isAuthenticated: false, isAnonymous: false, isLoading: false, pendingAppleDisplayName: null });
|
||||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
|
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
|
||||||
useMatchStore.getState().clearMatches();
|
useMatchStore.getState().clearMatches();
|
||||||
usePacksStore.getState().clearPacks();
|
usePacksStore.getState().clearPacks();
|
||||||
@@ -141,6 +210,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isAnonymous: false,
|
isAnonymous: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
pendingAppleDisplayName: null,
|
||||||
});
|
});
|
||||||
// Clear other stores
|
// Clear other stores
|
||||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
|
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
|
||||||
@@ -169,7 +239,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
set({
|
set({
|
||||||
user,
|
user,
|
||||||
isAuthenticated: !!user,
|
isAuthenticated: !!user,
|
||||||
...(user === null && { isAnonymous: false }),
|
...(user === null && { isAnonymous: false, pendingAppleDisplayName: null }),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
// Clear couple/partner when user is null (signed out)
|
// Clear couple/partner when user is null (signed out)
|
||||||
...(user === null && { couple: null, partner: null, sealedCount: 0 })
|
...(user === null && { couple: null, partner: null, sealedCount: 0 })
|
||||||
@@ -191,6 +261,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setPendingAppleDisplayName: (pending) => set({ pendingAppleDisplayName: pending }),
|
||||||
|
|
||||||
|
clearPendingAppleDisplayName: (userId) => {
|
||||||
|
if (get().pendingAppleDisplayName?.userId === userId) {
|
||||||
|
set({ pendingAppleDisplayName: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the user's last_active_at timestamp.
|
* Update the user's last_active_at timestamp.
|
||||||
* Called when the app comes to the foreground to track user activity.
|
* Called when the app comes to the foreground to track user activity.
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ jest.mock('@react-native-async-storage/async-storage', () =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
jest.mock('expo-image-picker', () => ({
|
jest.mock('expo-image-picker', () => ({
|
||||||
|
getCameraPermissionsAsync: jest.fn(async () => ({ status: 'undetermined', canAskAgain: true })),
|
||||||
requestCameraPermissionsAsync: jest.fn(async () => ({ status: 'granted' })),
|
requestCameraPermissionsAsync: jest.fn(async () => ({ status: 'granted' })),
|
||||||
launchImageLibraryAsync: jest.fn(async () => ({ canceled: true, assets: [] })),
|
launchImageLibraryAsync: jest.fn(async () => ({ canceled: true, assets: [] })),
|
||||||
launchCameraAsync: jest.fn(async () => ({ canceled: true, assets: [] })),
|
launchCameraAsync: jest.fn(async () => ({ canceled: true, assets: [] })),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<string>com.apple.widgetkit-extension</string>
|
<string>com.apple.widgetkit-extension</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>47</string>
|
<string>49</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.8</string>
|
<string>1.0.8</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -87,6 +87,22 @@ const { error } = await supabase.auth.signInWithIdToken({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Apple provides the selected full name only on the first native authorization.
|
||||||
|
The mobile client formats it and reads the API-owned profile with the exact
|
||||||
|
subject and bearer returned by that authorization. It writes the display name
|
||||||
|
only when that matching profile name is blank, preserving existing nicknames.
|
||||||
|
While this first-auth write is pending, the name is held only in subject-keyed
|
||||||
|
mobile state so onboarding does not briefly require it again; failure clears
|
||||||
|
that value and safely returns to the normal name step. Apple full/given/family
|
||||||
|
names and email are not copied into Supabase user metadata.
|
||||||
|
|
||||||
|
Profile refreshes bind both `/v1/me` and couple-state reads to the session
|
||||||
|
subject and bearer captured at their start. A response is applied only while
|
||||||
|
that exact session remains current, and a returned profile must have the same
|
||||||
|
subject. This prevents an older request from replacing a newer account's local
|
||||||
|
profile, relationship state, or loading state. A subject mismatch clears the
|
||||||
|
in-memory user-scoped state rather than leaving a prior account visible.
|
||||||
|
|
||||||
**OAuth Fallback (web, Expo Go):**
|
**OAuth Fallback (web, Expo Go):**
|
||||||
```typescript
|
```typescript
|
||||||
const { error } = await supabase.auth.signInWithOAuth({
|
const { error } = await supabase.auth.signInWithOAuth({
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Dependency and lockfile discipline
|
||||||
|
|
||||||
|
## Two npm realities
|
||||||
|
|
||||||
|
CI runners and the API Docker image use the node version pinned in
|
||||||
|
`.nvmrc` (node 20.19.4 with npm 10.8.2). A developer machine can run a newer
|
||||||
|
npm (for example npm 11). npm 10 and npm 11 resolve wildcard or range
|
||||||
|
dependencies differently at the same place in the dependency tree, and each
|
||||||
|
npm's platform-optional binary selection is not portable to a lockfile
|
||||||
|
regenerated by the other npm. A lockfile that passes `npm ci` under one npm
|
||||||
|
major version can fail it under the other with either an "Invalid: lock
|
||||||
|
file's ... does not satisfy" error or missing platform-optional binaries
|
||||||
|
(for example `@next/swc-linux-x64-gnu` breaking `next build`,
|
||||||
|
`@rollup/rollup-linux-x64-gnu` breaking the API Docker `tsup` build, or a
|
||||||
|
missing `lightningcss` variant breaking local `npm ci`).
|
||||||
|
|
||||||
|
## Pin wildcard-range dependencies with root overrides
|
||||||
|
|
||||||
|
When a transitive dependency declares a wildcard or wide range (for example
|
||||||
|
`react-native-worklets` depending on `@react-native/metro-config: "*"`), pin
|
||||||
|
the exact version the app needs in the root `package.json` `overrides` block
|
||||||
|
rather than leaving it to whichever npm resolved the lockfile last:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"overrides": {
|
||||||
|
"rollup": "4.54.0",
|
||||||
|
"react-native": "0.85.3",
|
||||||
|
"expo": "~56.0.0",
|
||||||
|
"@react-native/metro-config": "0.85.3"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pin platform-optional binaries that must travel with the framework version
|
||||||
|
|
||||||
|
`apps/web/package.json` pins the `@next/swc` linux and darwin binaries as
|
||||||
|
direct `optionalDependencies` so npm does not have to infer them per
|
||||||
|
platform:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@next/swc-linux-x64-gnu": "15.5.24",
|
||||||
|
"@next/swc-linux-x64-musl": "15.5.24",
|
||||||
|
"@next/swc-darwin-arm64": "15.5.24"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
These versions must be bumped together with any `next` version bump, or the
|
||||||
|
pinned binaries will drift from the framework version and `npm ci` or
|
||||||
|
`next build` can fail on the platform that needs the stale binary.
|
||||||
|
|
||||||
|
## Validate a regenerated lockfile both ways
|
||||||
|
|
||||||
|
After changing `overrides` or any dependency version, regenerate
|
||||||
|
`package-lock.json` and validate it under both npm majors before committing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Local npm (matches this machine's npm major)
|
||||||
|
npm ci
|
||||||
|
npm run verify:fast
|
||||||
|
|
||||||
|
# The CI/Docker npm major, emulated on Apple Silicon
|
||||||
|
docker run --rm --platform linux/amd64 -v "$PWD:/repo" -w /repo node:20.19.4 npm ci
|
||||||
|
```
|
||||||
|
|
||||||
|
The `--platform linux/amd64` flag is mandatory on Apple Silicon: without it,
|
||||||
|
Docker runs the arm64 image and the platform-binary check proves nothing.
|
||||||
|
|
||||||
|
## After any bypassed push
|
||||||
|
|
||||||
|
Pushes to `staging` bypass required status checks. After any such push, check
|
||||||
|
`gh run list --branch staging` to confirm CI is actually green rather than
|
||||||
|
assuming it ran and passed.
|
||||||
|
|
||||||
|
Do not pipe `npm` commands through `tail` when diagnosing a lockfile or build
|
||||||
|
failure; it can swallow the command's exit code and the detail needed to find
|
||||||
|
the root cause.
|
||||||
@@ -8,6 +8,7 @@ historical notes. Agents should load only the section relevant to their task.
|
|||||||
- [System architecture](architecture/system.md)
|
- [System architecture](architecture/system.md)
|
||||||
- [Client data freshness](architecture/client-data-freshness.md)
|
- [Client data freshness](architecture/client-data-freshness.md)
|
||||||
- [Local full-stack development](development/local-stack.md)
|
- [Local full-stack development](development/local-stack.md)
|
||||||
|
- [Dependency and lockfile discipline](development/dependencies.md)
|
||||||
- [Testing strategy](testing/strategy.md)
|
- [Testing strategy](testing/strategy.md)
|
||||||
- [Database migration safety](operations/database-migrations.md)
|
- [Database migration safety](operations/database-migrations.md)
|
||||||
- [Backend deployment and branching](operations/backend-deployments.md)
|
- [Backend deployment and branching](operations/backend-deployments.md)
|
||||||
|
|||||||
@@ -249,6 +249,23 @@ and dependency installation failures occur before native compilation: fix and
|
|||||||
preflight those inputs without starting another build. Make one meaningful build
|
preflight those inputs without starting another build. Make one meaningful build
|
||||||
per newly proven input state.
|
per newly proven input state.
|
||||||
|
|
||||||
|
Before starting: confirm `eas` is installed and on PATH (scripts call bare
|
||||||
|
`eas`); explicitly export `ANDROID_HOME`, `ANDROID_SDK_ROOT`, and `JAVA_HOME`
|
||||||
|
(JDK 17) rather than relying on a shell profile, because non-interactive shells
|
||||||
|
used for automation do not load it ("SDK location not found" otherwise); ensure
|
||||||
|
`EAS_LOCAL_BUILD_WORKINGDIR` does not exist or is empty ("Workingdir is not
|
||||||
|
empty" otherwise); and run every command from `apps/mobile`. Capture the full
|
||||||
|
build log to a file; do not pipe it through `tail`, which drops the detail
|
||||||
|
needed to diagnose a failure.
|
||||||
|
|
||||||
|
After a successful iOS build, EAS auto-increment updates both app and widget
|
||||||
|
Info.plists but not the widget target's `CURRENT_PROJECT_VERSION` in the
|
||||||
|
pbxproj, so `release:preflight` fails until it is synced to the new build
|
||||||
|
number (for example `sed -i '' 's/CURRENT_PROJECT_VERSION = 46;/CURRENT_PROJECT_VERSION = 47;/' ios/Sauci.xcodeproj/project.pbxproj`
|
||||||
|
for both Debug and Release). If native dependencies changed, also sync
|
||||||
|
`ios/Podfile.lock` from the successful build's working directory: the archive's
|
||||||
|
`pod install` resolution is authoritative when local CocoaPods is broken.
|
||||||
|
|
||||||
Report provider progress by phase: packaging, native compilation, submission
|
Report provider progress by phase: packaging, native compilation, submission
|
||||||
queue, or store processing. Do not describe every failure as a rebuild.
|
queue, or store processing. Do not describe every failure as a rebuild.
|
||||||
|
|
||||||
@@ -292,6 +309,24 @@ verify it, and commit it before starting the release from a clean worktree. Use
|
|||||||
5.x, a non-clean prebuild against an already-generated iOS project fails while
|
5.x, a non-clean prebuild against an already-generated iOS project fails while
|
||||||
updating the widget's Xcode target.
|
updating the widget's Xcode target.
|
||||||
|
|
||||||
|
**After native regeneration:** `expo prebuild --clean` can silently regress
|
||||||
|
checked-in native invariants (confirmed during the SDK 54 to 56 upgrade). Before
|
||||||
|
committing a regenerated project:
|
||||||
|
|
||||||
|
- Diff pbxproj signing and identity keys (PRODUCT_BUNDLE_IDENTIFIER,
|
||||||
|
CODE_SIGN_ENTITLEMENTS) against the last release tag; apple-targets can derive
|
||||||
|
a widget's bundle identifier from its folder name instead of the pinned config.
|
||||||
|
- Semantic-diff Info.plist and entitlements files against the last release tag;
|
||||||
|
prebuild can reintroduce dev-launcher-only keys (NSBonjourServices,
|
||||||
|
NSLocalNetworkUsageDescription, RCTMetroPort) and drop widget entitlements.
|
||||||
|
- Check android/gradle.properties for a reset `org.gradle.jvmargs` (prebuild
|
||||||
|
resets it to the stock -Xmx2048m/512m, which OOMs release Kotlin/CMake tasks).
|
||||||
|
- Run `npm run release:preflight`; it enforces the Android manifest media
|
||||||
|
permission removal and rejects the dev-launcher-only Info.plist keys.
|
||||||
|
- Treat `expo-doctor` unmaintained-package warnings as release blockers on an
|
||||||
|
SDK upgrade: a package absent from the new SDK's bundledNativeModules can fail
|
||||||
|
the iOS archive with no compatible release available.
|
||||||
|
|
||||||
**Out of memory:**
|
**Out of memory:**
|
||||||
```bash
|
```bash
|
||||||
# Increase Gradle memory
|
# Increase Gradle memory
|
||||||
|
|||||||
Reference in new issue
Block a user