From 7ce455aa3a3e1b64e0c08129970ccfbf993887a5 Mon Sep 17 00:00:00 2001 From: Jonathan Gan Date: Mon, 10 Aug 2026 02:48:55 -0400 Subject: [PATCH 01/39] Checking --- docs/APP-REVIEW-NOTES.md | 153 +++++++++++-------- docs/DEMO-RECORDING-SCRIPT.md | 15 +- docs/DEPLOYMENT.md | 7 +- docs/DEVICE-VERIFICATION.md | 7 + docs/ROADMAP-1.0.md | 6 +- eas.json | 5 + package.json | 2 +- security/audit-exceptions.json | 2 +- src/app/(onboarding)/privacy.tsx | 119 ++++++++++++++- src/app/(onboarding)/review.tsx | 24 ++- src/app/settings/terms.tsx | 4 +- src/features/onboarding/onboarding-draft.tsx | 13 ++ src/lib/storage/index.ts | 17 +++ 13 files changed, 293 insertions(+), 81 deletions(-) diff --git a/docs/APP-REVIEW-NOTES.md b/docs/APP-REVIEW-NOTES.md index c92bc39..0b2b0d4 100644 --- a/docs/APP-REVIEW-NOTES.md +++ b/docs/APP-REVIEW-NOTES.md @@ -22,16 +22,12 @@ section does not apply. Say so explicitly in the reply rather than omitting it. ## 2. Devices and operating systems tested -> **Confirm this list before sending.** Only fill in what was actually tested. +Only devices with a completed physical-device walkthrough belong in this +table. Add the connected 11-inch iPad Pro after its 0.1.1 walkthrough passes. | Device | OS | Notes | |---|---|---| -| iPhone Air (iPhone18,4) | iOS 26.6 | Physical device — primary test target | -| iPhone 17 | iOS 26.x Simulator | Development | - -Add any additional physical devices used. Apple asks specifically for physical -devices; listing simulator-only coverage as if it were device testing is worse -than listing a shorter honest list. +| Samsung SM-A166U1 | Android 16 | Physical device — onboarding and discovery verified | ## 3. Purpose and target audience @@ -64,19 +60,21 @@ no email/password login and no external account system. The reviewer creates a working account inside the app in about a minute. 1. Launch the app. Tap **Create Account** on the welcome screen. -2. The app generates an account on the device and shows a recovery key. Tap - through to continue. (Reviewers do not need to save the key.) -3. Enter a display name, an age of 18 or over, and a gender. -4. Choose what you are looking for, and set match preferences. -5. **Allow location when prompted.** This is required for discovery to return +2. The app generates an account and stores its recovery key securely on the + device. The recovery key is not displayed during account creation. +3. Read and accept the Terms of Service and Community Standards. +4. Enter a display name, an age of 18 or over, and a gender. +5. Choose what you are looking for, and set match preferences. +6. **Allow location when prompted.** This is required for discovery to return results. Choosing "Don't Allow" leaves the discovery deck empty — that is expected behavior, not a bug. -6. Add at least one photo. Allow photo library or camera access when prompted. -7. Review the profile and tap to finish. The account is now live on the network. -8. **Discover** shows nearby people; swipe or use the buttons. **Matches** lists +7. Add at least two photos, or choose **Add photos later**. For the fullest + review flow, add two photos and allow photo-library access when prompted. +8. Review the profile and tap to finish. The account is now live on the network. +9. **Discover** shows nearby people; swipe or use the buttons. **Matches** lists mutual likes. Tap a match to open an encrypted chat. -9. Report and block are available from any profile and from the chat screen. -10. Delete the account at **Profile → Settings → Account → Delete Account**. +10. Report and block are available from any profile and from the chat screen. +11. Delete the account at **Profile → Settings → Account → Delete Account**. **Important for discovery results.** The app shows real people in the reviewer's coarse area on a live network. If the review location has no other users, the @@ -89,12 +87,13 @@ review region. | Service | Role | Notes | |---|---|---| | OpenDating relay (Cloudflare Workers) | Sole backend. Profile storage, discovery matching, match/like routing, encrypted message transport, reports, blocks, account deletion. | Operated by the developer at `wss://opendating-relay.jonathang132298.workers.dev`. Service endpoints are discovered at runtime from the relay's public service document. | +| Cloudflare Workers AI | Automated safety screening for profile display names and bios before publication. | The developer-operated relay sends this profile text to Cloudflare's `@cf/meta/llama-3.2-3b-instruct` model. It does not process encrypted messages. | | Expo Application Services (EAS) | Build and submission tooling only. | Build-time only. Not contacted by the shipped app at runtime. | **Not used:** no payment processor, no in-app purchases or subscriptions, no -advertising SDK, no third-party analytics or crash reporting, no AI or machine -learning service, no third-party authentication provider, no data broker, and no -App Tracking Transparency prompt (the app does not track). +advertising SDK, no third-party analytics or crash reporting, no third-party +authentication provider, no data broker, and no App Tracking Transparency +prompt (the app does not track). The app's privacy manifest declares `NSPrivacyTracking: false` with no tracking domains. @@ -122,8 +121,10 @@ media. Profile photos and message content are created and uploaded by users. enforced at a minimum of 18, and the App Store age rating is set to 17+. **User-generated content controls,** as required by Guideline 1.2: -- Terms of use and community standards are shown in-app at Settings → Terms, and - creating an account constitutes agreement to them. +- Terms of Service and Community Standards are shown during onboarding and at + Settings → Terms. Profile creation is disabled until the member explicitly + accepts the August 9, 2026 policy version; version and acceptance time are + recorded locally for the account. - Any profile can be reported from the profile screen or from a chat. - Any user can be blocked; blocks take effect immediately on-device and are enforced server-side. @@ -137,53 +138,75 @@ enforced at a minimum of 18, and the App Store age rating is set to 17+. ## Reviewer Notes — paste this into App Store Connect ```text -ACCOUNT ACCESS -No demo account is needed and none exists. OpenDating has no email/password -login and no third-party sign-in. The reviewer creates an account on-device in -about one minute: tap "Create Account" on the welcome screen, then complete -onboarding (name, age 18+, gender, preferences, location permission, at least -one photo). The account is fully functional immediately. - -WHAT THE APP DOES -OpenDating is a privacy-focused dating app for adults 18+. Exact GPS never -leaves the device; discovery sends only a coarse area of about 5 km. Direct -messages between matches are end-to-end encrypted. There is no advertising SDK, -no third-party analytics, and no data sale. - -PERMISSIONS -- Location (when in use): required for discovery. Denying it leaves the - discovery deck empty, which is expected behavior. -- Photo library / camera: required to add profile photos. -- No App Tracking Transparency prompt. The app does not track users. - -CORE FLOWS TO REVIEW -- Account creation: welcome screen -> Create Account -> onboarding. -- Discovery: the Discover tab, swipe or use the like/pass buttons. -- Matching and messaging: the Matches tab, tap a match to open an encrypted chat. -- Reporting: available from any profile and from any chat. -- Blocking: available from any profile and from any chat; takes effect - immediately. -- Account deletion: Profile -> Settings -> Account -> Delete Account. - -NOTE ON DISCOVERY RESULTS -The app runs against a live network and shows real nearby users. If there are no -other users in the reviewer's coarse area, the discovery deck will be empty. -This is correct behavior rather than a defect. If a populated deck is needed for -review, please contact us and we will coordinate test accounts in the review -region. - -PURCHASES -The app is free. There is no paid content, no in-app purchase, and no -subscription. +SCREEN RECORDING +A physical-device recording on the latest iPadOS is attached to the App Review +reply. It begins with app launch and shows account access and registration, +policy consent, permission prompts, discovery, reporting, blocking, matching, +encrypted messaging, and account deletion. There are no purchases or +subscriptions to demonstrate. + +DEVICES TESTED +- Samsung SM-A166U1, Android 16: physical-device onboarding and discovery. +The physical iPad model and iPadOS result shown in the recording will be added +here after the final 0.1.1 walkthrough passes. + +PURPOSE AND AUDIENCE +OpenDating is a privacy-focused dating app for adults 18+ seeking dating and +relationships. It provides ordinary discovery, matching, and chat while +collecting less sensitive data: exact GPS never leaves the device, discovery +uses only a coarse area of about 5 km, and messages between matches are +end-to-end encrypted. It particularly serves privacy-conscious adults. There +is no advertising SDK, third-party analytics, or data sale. + +ACCOUNT ACCESS AND SETUP +No demo credentials exist because OpenDating has no email/password login or +third-party sign-in. Existing members tap "I already have an account" and enter +their recovery key in a masked field. A reviewer can create a new account: +1. Tap "Create Account." A recovery key is generated and stored securely on + the device; it is not displayed. +2. Read and accept the Terms of Service and Community Standards. +3. Enter name, age 18+, gender, preferences, intent, and bio. +4. Add at least two photos and allow photo-library access, or choose "Add + photos later." +5. Allow location while using the app. Denying it leaves discovery empty. +6. Review the profile and tap "Create Profile." +Report and Block are available from profiles and chats. Delete Account is at +Profile -> Settings -> Account -> Delete Account. + +DISCOVERY RESULTS +The app uses a live network and shows real people in the reviewer's coarse +area. If no other users are nearby, discovery can legitimately be empty. +Contact us and we will coordinate test accounts in the review region. + +PERMISSIONS AND PURCHASES +- Location while using the app: converts the device location to a coarse area + for nearby discovery; exact GPS is not transmitted. +- Photo library: adds profile photos. +- No App Tracking Transparency prompt; the app does not track. +The app is free with no paid content, purchase, or subscription. EXTERNAL SERVICES -The only backend is the OpenDating relay, operated by the developer on -Cloudflare Workers. No payment processor, advertising network, analytics -provider, AI service, or third-party authentication service is used. +- Developer-operated OpenDating relay on Cloudflare Workers: profile storage, + discovery, likes, matching, encrypted-message transport, reports, blocks, + and deletion. +- Cloudflare Workers AI (`@cf/meta/llama-3.2-3b-instruct`): safety screening + of profile display names and bios before publication. It does not process + encrypted messages. +- Expo Application Services: build and submission tooling only; not contacted + by the shipped app at runtime. +No payment processor, advertising network, analytics provider, data broker, or +third-party authentication service is used. REGIONS -The app behaves identically in all regions. There is no region-locked content, -feature gating, or regional pricing. +The app behaves consistently across all regions. There is no region-locked +content, feature gating, regional pricing, or in-app availability restriction. + +REGULATED SERVICES AND THIRD-PARTY MATERIAL +OpenDating is a dating/social-networking app, not a financial, medical, legal, +gambling, or other regulated service. Brand assets and shipped content are +original developer-owned work. Profile photos and text are user-generated. +The app is 18+, the App Store age rating is 17+, users must explicitly accept +the Terms and Community Standards, and profiles support reporting and blocking. CONTACT jonny2298@live.com diff --git a/docs/DEMO-RECORDING-SCRIPT.md b/docs/DEMO-RECORDING-SCRIPT.md index 6b3579a..c2fd19f 100644 --- a/docs/DEMO-RECORDING-SCRIPT.md +++ b/docs/DEMO-RECORDING-SCRIPT.md @@ -6,8 +6,8 @@ is the most credible evidence that the app works. ## Before you record -- [ ] **Use a physical device.** Apple asked for one specifically. The iPhone Air - on iOS 26.6 is the right target. +- [ ] **Use a physical device.** Apple asked for one specifically. The connected + 11-inch iPad Pro (3rd generation) on iPadOS 26.6 is the test target. - [ ] **Use a real build, not the screenshot build.** `EXPO_PUBLIC_SCREENSHOT_MODE` must be unset or `false`. The `screenshot` EAS profile sets it to `true`, which replaces discovery, matches, and profile with fabricated demo data. @@ -29,6 +29,11 @@ Timings are guidance, not targets. Pause on each screen long enough to read it. Home screen, tap the OpenDating icon. Let the splash screen and welcome screen load without cutting. +From the welcome screen, briefly open **I already have an account** to show the +existing-account access path. The recovery-key field is masked by default. Do +not paste, reveal, or record a real recovery key. Go back and continue with a +new disposable account. There is no email/password or third-party login. + ### 2. Account registration (0:15–1:45) Apple explicitly asked for registration, login, and deletion. This app has no separate login step — the account is created on-device and persists. Narrate @@ -37,13 +42,13 @@ that, or note it in the reply text. | Screen | What to show | |---|---| | Welcome | Tap **Create Account** | -| Create account | Continue promptly without lingering on the generated recovery key. Use a disposable account created only for this recording, and delete it before the recording ends | -| Privacy | Scroll the privacy summary, then continue | +| Create account | Create a disposable account for this recording. The recovery key is generated and stored securely but is not displayed | +| Privacy | Scroll the privacy summary, open the Terms and Community Standards, return, check the consent box, then continue | | Basics | Type a display name, an age of 18+, pick a gender | | Preferences | Set match preferences | | Intent | Pick what you are looking for | | About | Type a short bio | -| Photos | **Permission prompt fires here.** Tap Allow, pick a photo | +| Photos | **Permission prompt fires here.** Tap Allow and pick at least two photos | | Location | **Permission prompt fires here.** Tap Allow While Using App. Pause on the coarse-area readout — it shows the ~5 km region, which demonstrates that exact GPS is not used | | Review | Scroll the assembled profile | | Finish | Tap through into the app | diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index f5be666..6f5baf1 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -13,8 +13,11 @@ Release 0.1.1 is approved for iOS and Android. The existing iOS 0.1.0 build 4 an The production build and submit scripts call `scripts/release/assert-release-ready.mjs`. They refuse to run unless: 1. the release manifest status is `approved`; -2. the manifest's git SHA equals the exact checked-out commit; and -3. an authorized release manager explicitly sets the production approval variable. +2. the manifest's git SHA identifies the reviewed source-candidate commit and + that commit is an ancestor of the checked-out approval commit; +3. only `release/manifest.json` differs between the source candidate and the + approval commit; and +4. an authorized release manager explicitly sets the production approval variable. Do not bypass this guard. A successful EAS build is an artifact, not approval to ship. diff --git a/docs/DEVICE-VERIFICATION.md b/docs/DEVICE-VERIFICATION.md index a623068..18c9151 100644 --- a/docs/DEVICE-VERIFICATION.md +++ b/docs/DEVICE-VERIFICATION.md @@ -79,3 +79,10 @@ affect them, and every backend path behind them is verified by geohash, so the coarse permission alone would match both the behaviour and the privacy positioning, and would drop the "Precise" option from the OS prompt. + +## iOS/iPadOS review target — pending walkthrough + +Connected and detected on 2026-08-09: iPad Pro (11-inch) (3rd generation), +model identifier iPad13,4, running iPadOS 26.6. Apple lists iPadOS 26.6 as the +latest release. Do not add it to the App Review tested-device list until the +full 0.1.1 physical-device walkthrough and recording pass. diff --git a/docs/ROADMAP-1.0.md b/docs/ROADMAP-1.0.md index 25dbfc0..8fbf2bf 100644 --- a/docs/ROADMAP-1.0.md +++ b/docs/ROADMAP-1.0.md @@ -2,7 +2,11 @@ This is the implementation ledger for the marketplace-competitive 1.0 program. It is intentionally stricter than a feature checklist: a phase is complete only when its code, operational, legal, security, and marketplace exit gates are supported by evidence from an exact release commit. -**Current state:** 0.1.1 is approved for iOS and Android launch in `release/manifest.json`. Phases 1–6 remain the path to a marketplace-competitive 1.0 and are not complete. +**Current state:** 0.1.1 is a proposed launch candidate, but launch approval is +not effective until Phase 0 release-containment gates pass and +`release/manifest.json` records the reviewed source SHA, artifact IDs, build +numbers, and checksums. Phases 1–6 remain the path to a +marketplace-competitive 1.0 and are not complete. ## Release policy diff --git a/eas.json b/eas.json index 643c08a..b6aa232 100644 --- a/eas.json +++ b/eas.json @@ -15,6 +15,11 @@ "distribution": "internal", "ios": { "simulator": false + }, + "env": { + "EXPO_PUBLIC_OPENDATING_RELAY_URL": "wss://opendating-relay.jonathang132298.workers.dev", + "EXPO_PUBLIC_OPENDATING_INFO_URL": "https://opendating-relay.jonathang132298.workers.dev", + "EXPO_PUBLIC_OPENDATING_PROTOCOL_VERSION": "0.1" } }, "screenshot": { diff --git a/package.json b/package.json index e61473b..7a514c2 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "postinstall": "node scripts/check-duplicate-runtimes.mjs" }, "private": true, - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", + "packageManager": "npm@10.9.2", "overrides": { "uuid": ">=14.0.1" } diff --git a/security/audit-exceptions.json b/security/audit-exceptions.json index 049455f..e415ab1 100644 --- a/security/audit-exceptions.json +++ b/security/audit-exceptions.json @@ -1,6 +1,6 @@ { "expires": "2026-09-30", - "rationale": "Expo SDK 57 and React Native 0.86 currently resolve framework and build-tool advisories whose automated fixes propose incompatible downgrades. These are build-time and CLI dependencies that do not ship in the app binary; upstream-compatible fixes are tracked for a follow-up release.", + "rationale": "Expo SDK 57 and React Native 0.86 currently resolve advisories whose automated fixes propose incompatible downgrades. Expo, React Native, Reanimated, and Worklets include production runtime code, but the flagged advisory path is through their Metro, CLI, and image-inspection build subgraph; the shipped app does not expose those tooling APIs to user input. Exact versions are lockfile-pinned, CI runs the production audit and mobile test suite, and these time-limited exceptions must be removed or re-evidenced before expiry when upstream-compatible fixes are available.", "highSeverityExceptions": [ { "package": "@expo/cli", diff --git a/src/app/(onboarding)/privacy.tsx b/src/app/(onboarding)/privacy.tsx index cd63ae8..b1d9e26 100644 --- a/src/app/(onboarding)/privacy.tsx +++ b/src/app/(onboarding)/privacy.tsx @@ -2,9 +2,13 @@ // A trust-building screen before the user shares any profile data. import React from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; import { useRouter } from 'expo-router'; import { OnboardingScreen } from '@/components/onboarding/onboarding-screen'; +import { + CURRENT_POLICY_VERSION, + useOnboardingDraft, +} from '@/features/onboarding/onboarding-draft'; import { useTheme } from '@/state/theme-context'; import type { ThemeColors } from '@/theme/colors'; import { typography } from '@/theme/typography'; @@ -37,7 +41,22 @@ const PRIVACY_POINTS: { title: string; detail: string }[] = [ export default function PrivacyScreen() { const router = useRouter(); const { colors } = useTheme(); + const { draft, update } = useOnboardingDraft(); const styles = makeStyles(colors); + const hasAcceptedPolicies = + draft.policyAcceptance?.version === CURRENT_POLICY_VERSION; + + const togglePolicyAcceptance = () => { + update( + 'policyAcceptance', + hasAcceptedPolicies + ? null + : { + version: CURRENT_POLICY_VERSION, + acceptedAt: new Date().toISOString(), + } + ); + }; return ( router.push('/(onboarding)/basics')} + primaryDisabled={!hasAcceptedPolicies} > {PRIVACY_POINTS.map((point, index) => ( @@ -72,10 +92,67 @@ export default function PrivacyScreen() { ))} - + Other OpenDating members can discover the profile details you choose to share. Your exact location and private likes stay hidden. + + + [ + styles.consentRow, + pressed && styles.pressed, + ]} + > + + {hasAcceptedPolicies ? ( + + ) : null} + + + I agree to the Terms of Service and Community Standards. + + + router.push('/settings/terms')} + hitSlop={spacing.sm} + style={({ pressed }) => [styles.policyLink, pressed && styles.pressed]} + > + + Read the Terms and Community Standards + + + + Effective August 9, 2026. You must accept before creating a profile. + + ); } @@ -105,5 +182,43 @@ function makeStyles(colors: ThemeColors) { alignItems: 'center', justifyContent: 'center', }, + consentCard: { + gap: spacing.md, + marginTop: spacing.xl, + borderRadius: radius.lg, + backgroundColor: colors.surface, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + padding: spacing.lg, + }, + consentRow: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.md, + }, + checkbox: { + width: 24, + height: 24, + borderRadius: radius.sm, + borderWidth: 1, + alignItems: 'center', + justifyContent: 'center', + }, + checkmark: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '700', + lineHeight: 20, + }, + consentText: { + flex: 1, + }, + policyLink: { + alignSelf: 'flex-start', + paddingVertical: spacing.xs, + }, + pressed: { + opacity: 0.7, + }, }); } diff --git a/src/app/(onboarding)/review.tsx b/src/app/(onboarding)/review.tsx index 594e20f..4dfdd9d 100644 --- a/src/app/(onboarding)/review.tsx +++ b/src/app/(onboarding)/review.tsx @@ -11,6 +11,7 @@ import { OnboardingScreen, } from '@/components/onboarding/onboarding-screen'; import { + CURRENT_POLICY_VERSION, GENDER_OPTIONS, INTENT_OPTIONS, useOnboardingDraft, @@ -43,6 +44,14 @@ export default function ReviewScreen() { const handleSubmit = async () => { if (submitting) return; + const policyAcceptance = draft.policyAcceptance; + if (policyAcceptance?.version !== CURRENT_POLICY_VERSION) { + setError( + 'Go back to the privacy step and accept the Terms of Service and Community Standards.' + ); + return; + } + if (isScreenshotMode) { router.replace('/(onboarding)/finish'); return; @@ -62,6 +71,10 @@ export default function ReviewScreen() { // Mirror the bootstrap sequence: connect → capabilities → profile. await client.connect(); await client.fetchCapabilities(); + await storage.savePolicyAcceptance({ + ...policyAcceptance, + pubkey: resolvedPubkey, + }); await client.createProfile(); // Publish what the user actually filled in. Without this the profile @@ -151,10 +164,17 @@ export default function ReviewScreen() { return () => { active = false; }; }, [draft.pubkey]); - const canSubmit = resolvedPubkey !== null && draft.displayName.length > 0; + const hasAcceptedPolicies = + draft.policyAcceptance?.version === CURRENT_POLICY_VERSION; + const canSubmit = + resolvedPubkey !== null && + draft.displayName.length > 0 && + hasAcceptedPolicies; // A dead button with no explanation is the worst possible last step. const blockedReason = !canSubmit - ? draft.displayName.length === 0 + ? !hasAcceptedPolicies + ? 'Go back to "Your privacy comes first" and accept the Terms and Community Standards.' + : draft.displayName.length === 0 ? 'Go back to "About you" and add a display name to finish.' : 'Still setting up your account — go back and create one first.' : null; diff --git a/src/app/settings/terms.tsx b/src/app/settings/terms.tsx index cb5f3ef..5353905 100644 --- a/src/app/settings/terms.tsx +++ b/src/app/settings/terms.tsx @@ -15,7 +15,7 @@ const TERMS = [ body: 'You are responsible for protecting your recovery key and for activity signed by your account. Recovery is self-custodied; OpenDating cannot restore a lost key.', }, { - title: 'Acceptable use', + title: 'Community Standards', body: 'Be truthful, respectful, and lawful. Harassment, threats, scams, impersonation, sexual exploitation, content involving minors, non-consensual intimate content, hate, spam, scraping, and attempts to bypass safety controls are prohibited.', }, { @@ -59,7 +59,7 @@ export default function TermsScreen() { - These terms describe the rules for using OpenDating. By creating an account you agree to them and to the Community Standards. + These Terms of Service include the Community Standards below. During onboarding, you must explicitly accept this August 9, 2026 version before a profile can be created. {TERMS.map((term) => ( { + await secureSet(STORAGE_KEYS.POLICY_ACCEPTANCE, JSON.stringify(acceptance)); + }, + async getPolicyAcceptance(): Promise { + const raw = await secureGet(STORAGE_KEYS.POLICY_ACCEPTANCE); + if (!raw) return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; + } + }, + // Onboarding async setOnboardingComplete(): Promise { await secureSet(STORAGE_KEYS.ONBOARDING_COMPLETE, 'true'); From 540202b2b0b218c22542acd2be2d14836360cfa8 Mon Sep 17 00:00:00 2001 From: Jonathan Gan Date: Mon, 10 Aug 2026 03:06:47 -0400 Subject: [PATCH 02/39] Disclose safety screening in-app and harden policy consent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.1.1 consent gate disclosed Cloudflare Workers AI screening of display names and bios to Apple, but not to members — including on the onboarding screen where they now must accept the Terms. Disclose it on that screen, at Settings > Privacy, and in docs/PRIVACY.md. Extract CURRENT_POLICY_VERSION and an isCurrentPolicy() type guard into src/lib/policy.ts so a settings screen can check consent without importing onboarding state, and so "accepted" has one definition rather than four repeated version comparisons. Settings > Terms now reads the stored record and shows which version the account accepted and when; previously getPolicyAcceptance() had no callers. Type savePolicyAcceptance as StoredPolicyAcceptance instead of object, which surfaced a pubkey that could be written null. Guard it, validate shape on read so a truncated record reads as no acceptance, and fail the acceptance write with an actionable message. The write stays fatal: a profile must not go live without a durable consent record. Align DEPLOYMENT.md and CLAUDE.md with ROADMAP-1.0.md, which no longer treats 0.1.1 as approved. The manifest still has a null gitSha and null artifact IDs, so assert-release-ready.mjs refuses production builds. The iOS tested-device table stays Android-only pending a real iPad walkthrough; it must not list untested devices. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 2 +- docs/APP-REVIEW-NOTES.md | 7 +-- docs/DEPLOYMENT.md | 2 +- docs/PRIVACY.md | 21 ++++++++ src/app/(onboarding)/privacy.tsx | 19 ++++--- src/app/(onboarding)/review.tsx | 31 +++++++++--- src/app/settings/privacy.tsx | 4 ++ src/app/settings/terms.tsx | 52 +++++++++++++++++++- src/features/onboarding/onboarding-draft.tsx | 10 ++-- src/lib/__tests__/policy.test.ts | 50 +++++++++++++++++++ src/lib/policy.ts | 44 +++++++++++++++++ src/lib/storage/index.ts | 20 ++++++-- 12 files changed, 232 insertions(+), 30 deletions(-) create mode 100644 src/lib/__tests__/policy.test.ts create mode 100644 src/lib/policy.ts diff --git a/CLAUDE.md b/CLAUDE.md index e8ca2bd..01d2c75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Project Identity -OpenDating Mobile is the first reference client for the [OpenDating](https://github.com/jongan69/OpenDating) protocol. It is an Expo React Native app under active security and reliability work, approved for iOS and Android launch. Production builds and submissions read their gate state from `release/manifest.json`. +OpenDating Mobile is the first reference client for the [OpenDating](https://github.com/jongan69/OpenDating) protocol. It is an Expo React Native app under active security and reliability work. 0.1.1 is the proposed iOS and Android launch candidate; approval is not effective until `release/manifest.json` records the reviewed source SHA and artifact metadata. Production builds and submissions read their gate state from `release/manifest.json`. ## Critical Rules diff --git a/docs/APP-REVIEW-NOTES.md b/docs/APP-REVIEW-NOTES.md index 0b2b0d4..0414861 100644 --- a/docs/APP-REVIEW-NOTES.md +++ b/docs/APP-REVIEW-NOTES.md @@ -87,7 +87,7 @@ review region. | Service | Role | Notes | |---|---|---| | OpenDating relay (Cloudflare Workers) | Sole backend. Profile storage, discovery matching, match/like routing, encrypted message transport, reports, blocks, account deletion. | Operated by the developer at `wss://opendating-relay.jonathang132298.workers.dev`. Service endpoints are discovered at runtime from the relay's public service document. | -| Cloudflare Workers AI | Automated safety screening for profile display names and bios before publication. | The developer-operated relay sends this profile text to Cloudflare's `@cf/meta/llama-3.2-3b-instruct` model. It does not process encrypted messages. | +| Cloudflare Workers AI | Automated safety screening for profile display names and bios before publication. | The developer-operated relay sends this profile text to Cloudflare's `@cf/meta/llama-3.2-3b-instruct` model. Photos are not sent, and it does not process encrypted messages. Disclosed in-app on the onboarding privacy screen and at Settings → Privacy. | | Expo Application Services (EAS) | Build and submission tooling only. | Build-time only. Not contacted by the shipped app at runtime. | **Not used:** no payment processor, no in-app purchases or subscriptions, no @@ -190,8 +190,9 @@ EXTERNAL SERVICES discovery, likes, matching, encrypted-message transport, reports, blocks, and deletion. - Cloudflare Workers AI (`@cf/meta/llama-3.2-3b-instruct`): safety screening - of profile display names and bios before publication. It does not process - encrypted messages. + of profile display names and bios before publication. Photos are not sent to + it and it does not process encrypted messages. This is disclosed to the user + in-app on the onboarding privacy screen and at Settings -> Privacy. - Expo Application Services: build and submission tooling only; not contacted by the shipped app at runtime. No payment processor, advertising network, analytics provider, data broker, or diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 6f5baf1..67b061b 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -8,7 +8,7 @@ ## Release gate -Release 0.1.1 is approved for iOS and Android. The existing iOS 0.1.0 build 4 and Android 0.1.0 build 3 predate required fixes and must not be released; ship a fresh 0.1.1 build. See [Release Status](RELEASE-STATUS.md), [1.0 Execution Roadmap](ROADMAP-1.0.md), and `release/manifest.json`. +Release 0.1.1 is the proposed launch candidate for iOS and Android, but approval is **not yet effective**: `release/manifest.json` still carries a null `gitSha` and null artifact IDs, so `assert-release-ready.mjs` refuses production builds until the reviewed source SHA, build numbers, and checksums are recorded. The existing iOS 0.1.0 build 4 and Android 0.1.0 build 3 predate required fixes and must not be released; ship a fresh 0.1.1 build. See [Release Status](RELEASE-STATUS.md), [1.0 Execution Roadmap](ROADMAP-1.0.md), and `release/manifest.json`. The production build and submit scripts call `scripts/release/assert-release-ready.mjs`. They refuse to run unless: diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 830b4c8..f51f9d3 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -21,6 +21,8 @@ OpenDating is designed so that your dating activity is private by default. - No one can enumerate all profiles - You control who sees your profile through visibility settings - Pausing hides you from new discovery +- **Display name and bio are automatically screened** for harmful content + before publication — see [Automated safety screening](#automated-safety-screening) ### Your Activity - **Likes are private** — the other person only knows if you match @@ -51,6 +53,25 @@ OpenDating is designed so that your dating activity is private by default. - Your private discovery preferences - Your message history with other people +## Automated safety screening + +Profile **display names and bios** are screened for harmful content before they +are published. This is the only automated processing of profile content. + +| | | +|---|---| +| **What is sent** | Display name and bio text only | +| **What is never sent** | Photos, messages, location, likes, blocks, reports, keys | +| **Where it runs** | Cloudflare Workers AI (`@cf/meta/llama-3.2-3b-instruct`), called by the OpenDating service | +| **Why** | Blocks abusive and exploitative profile text before other members see it | +| **When** | At publication time only — not continuously, and not retroactively | + +Message content is **never** screened. It is end-to-end encrypted and the +service cannot read it, so no automated system can act on it. + +This is disclosed in-app on the onboarding privacy screen and at +Settings → Privacy → Profile Safety Screening. + ## Limitations (Honest Disclosure) ### Screenshots diff --git a/src/app/(onboarding)/privacy.tsx b/src/app/(onboarding)/privacy.tsx index b1d9e26..8004c40 100644 --- a/src/app/(onboarding)/privacy.tsx +++ b/src/app/(onboarding)/privacy.tsx @@ -5,10 +5,12 @@ import React from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { useRouter } from 'expo-router'; import { OnboardingScreen } from '@/components/onboarding/onboarding-screen'; +import { useOnboardingDraft } from '@/features/onboarding/onboarding-draft'; import { CURRENT_POLICY_VERSION, - useOnboardingDraft, -} from '@/features/onboarding/onboarding-draft'; + POLICY_EFFECTIVE_LABEL, + isCurrentPolicy, +} from '@/lib/policy'; import { useTheme } from '@/state/theme-context'; import type { ThemeColors } from '@/theme/colors'; import { typography } from '@/theme/typography'; @@ -31,10 +33,15 @@ const PRIVACY_POINTS: { title: string; detail: string }[] = [ detail: 'When you block someone, they never know. There is no notification and no trace.', }, + { + title: 'Your profile text is safety-checked', + detail: + 'Your display name and bio are automatically screened for harmful content before they go live. That check runs on our service provider, Cloudflare. Your photos are not sent to it.', + }, { title: 'Your messages are encrypted', detail: - 'Conversations are end-to-end encrypted. Only you and your match can read them — not us, and not whoever carries the message.', + 'Conversations are end-to-end encrypted. Only you and your match can read them — not us, and not whoever carries the message. Messages are never screened.', }, ]; @@ -43,8 +50,7 @@ export default function PrivacyScreen() { const { colors } = useTheme(); const { draft, update } = useOnboardingDraft(); const styles = makeStyles(colors); - const hasAcceptedPolicies = - draft.policyAcceptance?.version === CURRENT_POLICY_VERSION; + const hasAcceptedPolicies = isCurrentPolicy(draft.policyAcceptance); const togglePolicyAcceptance = () => { update( @@ -150,7 +156,8 @@ export default function PrivacyScreen() { - Effective August 9, 2026. You must accept before creating a profile. + Effective {POLICY_EFFECTIVE_LABEL}. You must accept before creating a + profile. diff --git a/src/app/(onboarding)/review.tsx b/src/app/(onboarding)/review.tsx index 4dfdd9d..2a235aa 100644 --- a/src/app/(onboarding)/review.tsx +++ b/src/app/(onboarding)/review.tsx @@ -11,11 +11,11 @@ import { OnboardingScreen, } from '@/components/onboarding/onboarding-screen'; import { - CURRENT_POLICY_VERSION, GENDER_OPTIONS, INTENT_OPTIONS, useOnboardingDraft, } from '@/features/onboarding/onboarding-draft'; +import { isCurrentPolicy } from '@/lib/policy'; import { getOpenDatingClient } from '@/lib/opendating/open-dating-client'; import { PROFILE_CONTENT_VERSION, @@ -45,7 +45,7 @@ export default function ReviewScreen() { if (submitting) return; const policyAcceptance = draft.policyAcceptance; - if (policyAcceptance?.version !== CURRENT_POLICY_VERSION) { + if (!isCurrentPolicy(policyAcceptance)) { setError( 'Go back to the privacy step and accept the Terms of Service and Community Standards.' ); @@ -57,6 +57,13 @@ export default function ReviewScreen() { return; } + // The button is disabled without a pubkey, but the acceptance record is + // bound to an account and must never be written with a missing one. + if (!resolvedPubkey) { + setError('Still setting up your account — go back and create one first.'); + return; + } + setSubmitting(true); setError(null); try { @@ -71,10 +78,19 @@ export default function ReviewScreen() { // Mirror the bootstrap sequence: connect → capabilities → profile. await client.connect(); await client.fetchCapabilities(); - await storage.savePolicyAcceptance({ - ...policyAcceptance, - pubkey: resolvedPubkey, - }); + // Written before the profile exists so consent always precedes + // publication. Deliberately fatal, unlike the best-effort writes below: + // a profile must never go live without a durable acceptance record. + try { + await storage.savePolicyAcceptance({ + ...policyAcceptance, + pubkey: resolvedPubkey, + }); + } catch { + throw new Error( + 'Could not record your acceptance of the Terms on this device. Free up some storage and try again.' + ); + } await client.createProfile(); // Publish what the user actually filled in. Without this the profile @@ -164,8 +180,7 @@ export default function ReviewScreen() { return () => { active = false; }; }, [draft.pubkey]); - const hasAcceptedPolicies = - draft.policyAcceptance?.version === CURRENT_POLICY_VERSION; + const hasAcceptedPolicies = isCurrentPolicy(draft.policyAcceptance); const canSubmit = resolvedPubkey !== null && draft.displayName.length > 0 && diff --git a/src/app/settings/privacy.tsx b/src/app/settings/privacy.tsx index f46bf52..6150980 100644 --- a/src/app/settings/privacy.tsx +++ b/src/app/settings/privacy.tsx @@ -83,6 +83,10 @@ export default function PrivacyScreen() { title="Messages" body="Messages are end-to-end encrypted and readable only by you and the person you're chatting with. OpenDating can't read your conversations, and messages can't be recovered once deleted." /> +
( + null + ); + + useEffect(() => { + let active = true; + storage + .getPolicyAcceptance() + .then((record) => { + if (active) setAcceptance(record); + }) + .catch(() => { + // Unreadable record — fall through to the neutral summary below. + }); + return () => { + active = false; + }; + }, []); + + if (!acceptance) { + return 'Your acceptance is recorded on this device when you create your profile.'; + } + if (!isCurrentPolicy(acceptance)) { + return 'Your account accepted an earlier version of these terms. The version above applies to new profiles.'; + } + const accepted = formatAcceptedAt(acceptance.acceptedAt); + return accepted + ? `You accepted this version on ${accepted}.` + : 'You accepted this version on this device.'; +} + export default function TermsScreen() { const { colors, isDark } = useTheme(); + const acceptanceSummary = useAcceptanceSummary(); return ( <> @@ -54,12 +97,17 @@ export default function TermsScreen() { - EFFECTIVE AUGUST 9, 2026 + {`EFFECTIVE ${POLICY_EFFECTIVE_LABEL.toUpperCase()}`} - These Terms of Service include the Community Standards below. During onboarding, you must explicitly accept this August 9, 2026 version before a profile can be created. + {`These Terms of Service include the Community Standards below. During onboarding, you must explicitly accept this ${POLICY_EFFECTIVE_LABEL} version before a profile can be created.`} + + + {acceptanceSummary} {TERMS.map((term) => ( { + it('accepts only the exact current version', () => { + expect( + isCurrentPolicy({ + version: CURRENT_POLICY_VERSION, + acceptedAt: '2026-08-09T00:00:00.000Z', + }) + ).toBe(true); + }); + + // Onboarding drafts persist across builds. A draft written before the + // consent gate existed has no acceptance at all, and one written against an + // older policy must not satisfy the gate — either would let a profile go + // live without agreement to the shipped terms. + it('rejects a missing, empty, or stale acceptance', () => { + expect(isCurrentPolicy(null)).toBe(false); + expect(isCurrentPolicy(undefined)).toBe(false); + expect( + isCurrentPolicy({ version: '', acceptedAt: '2026-08-09T00:00:00.000Z' }) + ).toBe(false); + expect( + isCurrentPolicy({ + version: '2020-01-01', + acceptedAt: '2020-01-01T00:00:00.000Z', + }) + ).toBe(false); + }); +}); + +describe('formatAcceptedAt', () => { + it('renders a valid timestamp', () => { + expect(formatAcceptedAt('2026-08-09T12:00:00.000Z')).toEqual( + expect.stringContaining('2026') + ); + }); + + // The record round-trips through SecureStore as JSON, so a truncated or + // hand-edited value reaches this function. Returning null lets the caller + // fall back to prose instead of rendering "Invalid Date" to the member. + it('returns null for an unparseable timestamp', () => { + expect(formatAcceptedAt('')).toBeNull(); + expect(formatAcceptedAt('not-a-date')).toBeNull(); + }); +}); diff --git a/src/lib/policy.ts b/src/lib/policy.ts new file mode 100644 index 0000000..e3874c1 --- /dev/null +++ b/src/lib/policy.ts @@ -0,0 +1,44 @@ +// Terms of Service + Community Standards versioning. +// +// The version lives here rather than in the onboarding context so that any +// screen can check acceptance without pulling in onboarding state. Bump +// CURRENT_POLICY_VERSION and POLICY_EFFECTIVE_LABEL together whenever the +// policy text in src/app/settings/terms.tsx changes materially. + +export const CURRENT_POLICY_VERSION = '2026-08-09'; +export const POLICY_EFFECTIVE_LABEL = 'August 9, 2026'; + +/** What the member accepted, as captured in the onboarding draft. */ +export interface PolicyAcceptance { + version: string; + acceptedAt: string; +} + +/** + * The durable acceptance record. Unlike the draft copy, this survives the + * onboarding-draft cleanup and is bound to the account that accepted. + */ +export interface StoredPolicyAcceptance extends PolicyAcceptance { + pubkey: string; +} + +/** + * The single definition of "this member accepted the shipped terms". A type + * guard so callers that go on to persist the record cannot forget the check. + */ +export function isCurrentPolicy( + acceptance: PolicyAcceptance | null | undefined +): acceptance is PolicyAcceptance { + return acceptance?.version === CURRENT_POLICY_VERSION; +} + +/** Renders an acceptance timestamp for display, or null if it is unusable. */ +export function formatAcceptedAt(iso: string): string | null { + const parsed = new Date(iso); + if (Number.isNaN(parsed.getTime())) return null; + return parsed.toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + }); +} diff --git a/src/lib/storage/index.ts b/src/lib/storage/index.ts index 6d88cfe..7b56d45 100644 --- a/src/lib/storage/index.ts +++ b/src/lib/storage/index.ts @@ -5,6 +5,8 @@ import * as SecureStore from 'expo-secure-store'; import { Platform } from 'react-native'; +import type { StoredPolicyAcceptance } from '@/lib/policy'; + // In-memory fallback when SecureStore is unavailable (e.g., web during dev) const inMemoryStore = new Map(); @@ -130,14 +132,26 @@ export const storage = { // Terms of Service and Community Standards acceptance. This survives the // onboarding-draft cleanup so the app retains the exact accepted policy // version, timestamp, and account identifier. - async savePolicyAcceptance(acceptance: object): Promise { + async savePolicyAcceptance(acceptance: StoredPolicyAcceptance): Promise { await secureSet(STORAGE_KEYS.POLICY_ACCEPTANCE, JSON.stringify(acceptance)); }, - async getPolicyAcceptance(): Promise { + async getPolicyAcceptance(): Promise { const raw = await secureGet(STORAGE_KEYS.POLICY_ACCEPTANCE); if (!raw) return null; try { - return JSON.parse(raw) as T; + const parsed: unknown = JSON.parse(raw); + // A consent record is only meaningful if every field survived intact; + // a partial record must read as "no acceptance on file". + if ( + typeof parsed === 'object' && + parsed !== null && + typeof (parsed as StoredPolicyAcceptance).version === 'string' && + typeof (parsed as StoredPolicyAcceptance).acceptedAt === 'string' && + typeof (parsed as StoredPolicyAcceptance).pubkey === 'string' + ) { + return parsed as StoredPolicyAcceptance; + } + return null; } catch { return null; } From bf89b30a504b3c08a4dc47d21553625392d36b18 Mon Sep 17 00:00:00 2001 From: Jonathan Gan Date: Mon, 10 Aug 2026 05:00:44 -0400 Subject: [PATCH 03/39] fix: address all CodeRabbit review comments on dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #2, #8, #9 — merged: all comments were verified addressed. PR #10 — open — five remaining comments fixed: 1. privacy.tsx — replace hand-rolled Pressable checkbox with @expo/ui Switch 2. review.tsx — bind savePolicyAcceptance to identity.pubkey (authoritative) 3. terms.tsx — add loading/error states to useAcceptanceSummary 4. terms.tsx + use-auth.ts — clear POLICY_ACCEPTANCE on logout 5. policy.ts — add isValidPolicyTimestamp() guard against malformed dates 6. storage/index.ts — switch policy acceptance from SecureStore (silent in-memory fallback) to AsyncStorage (true durable persistence) Docs: README.md and RELEASE-STATUS.md now say proposed launch candidate, aligned with CLAUDE.md and ROADMAP-1.0.md. Build infra (carried from earlier in the session): - scripts/build-ios.sh — add -allowProvisioningUpdates to export step, pass DEVELOPMENT_TEAM from build.config.env - .gitignore — add build.config.env Co-Authored-By: Claude --- .gitignore | 1 + README.md | 2 +- docs/RELEASE-STATUS.md | 2 +- package-lock.json | 20 ++++++++ package.json | 3 +- scripts/build-ios.sh | 1 + src/app/(onboarding)/privacy.tsx | 84 ++++++-------------------------- src/app/(onboarding)/review.tsx | 4 +- src/app/settings/terms.tsx | 71 ++++++++++++++++++++------- src/features/auth/use-auth.ts | 5 ++ src/lib/policy.ts | 16 +++++- src/lib/storage/index.ts | 31 ++++++++---- 12 files changed, 138 insertions(+), 102 deletions(-) diff --git a/.gitignore b/.gitignore index b1db2fb..0832f4c 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ yarn-error.* # local env files .env .env*.local +build.config.env # typescript *.tsbuildinfo diff --git a/README.md b/README.md index 8200c37..87bc1e8 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@
-> **Release status:** approved for iOS and Android launch. See [Release Status](docs/RELEASE-STATUS.md) for verified capabilities, and the [1.0 Execution Roadmap](docs/ROADMAP-1.0.md) for the path through GA. +> **Release status:** 0.1.1 is a proposed launch candidate; approval is not effective until `release/manifest.json` records the reviewed source SHA, artifact IDs, and checksums. See [Release Status](docs/RELEASE-STATUS.md) for verified capabilities, and the [1.0 Execution Roadmap](docs/ROADMAP-1.0.md) for the path through GA. ## What is OpenDating? diff --git a/docs/RELEASE-STATUS.md b/docs/RELEASE-STATUS.md index 4fda777..8ce3e2f 100644 --- a/docs/RELEASE-STATUS.md +++ b/docs/RELEASE-STATUS.md @@ -1,6 +1,6 @@ # Release Status -**Status: approved for launch.** OpenDating Mobile 0.1.1 is cleared for iOS App Store and Google Play release. +**Status: proposed launch candidate.** OpenDating Mobile 0.1.1 is a proposed iOS App Store and Google Play release. Launch approval is not effective until Phase 0 release-containment gates pass and `release/manifest.json` records the reviewed source SHA, artifact IDs, build numbers, and checksums. ## Verified in this release diff --git a/package-lock.json b/package-lock.json index 6dade0b..b90cc94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@expo/ui": "~57.0.9", "@noble/curves": "1.9.7", "@nostr-dev-kit/ndk": "^2.15.2", + "@react-native-async-storage/async-storage": "^3.1.1", "@scure/base": "^2.3.0", "expo": "~57.0.11", "expo-clipboard": "~57.0.1", @@ -3352,6 +3353,19 @@ } } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-3.1.1.tgz", + "integrity": "sha512-z+PnLz1n6ECKhgoHZHkfc+dijXZEyZnNFSajbtE0NEbsJhmX8x9GlOeiMQIKX2E4DUqPSgfIh4FYBv1M49KgPQ==", + "license": "MIT", + "dependencies": { + "idb": "8.0.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@react-native-masked-view/masked-view": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz", @@ -9338,6 +9352,12 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", diff --git a/package.json b/package.json index 7a514c2..162336d 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@expo/ui": "~57.0.9", "@noble/curves": "1.9.7", "@nostr-dev-kit/ndk": "^2.15.2", + "@react-native-async-storage/async-storage": "^3.1.1", "@scure/base": "^2.3.0", "expo": "~57.0.11", "expo-clipboard": "~57.0.1", @@ -92,7 +93,7 @@ "test": "jest --runInBand", "test:watch": "jest --watch", "clean": "expo start --clear", - "deploy:web": "echo 'Deploy via GitHub Pages \u2014 push to main and the deploy-web workflow handles the rest.'", + "deploy:web": "echo 'Deploy via GitHub Pages — push to main and the deploy-web workflow handles the rest.'", "release:verify": "node scripts/release/assert-release-ready.mjs", "release:validate": "node scripts/release/assert-release-ready.mjs --validate-only", "security:audit": "node scripts/check-production-audit.mjs", diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh index 1aa4f3b..087fc58 100755 --- a/scripts/build-ios.sh +++ b/scripts/build-ios.sh @@ -112,6 +112,7 @@ xcodebuild -exportArchive \ -archivePath "$ARCHIVE_PATH" \ -exportPath builds \ -exportOptionsPlist "$EXPORT_OPTS" \ + -allowProvisioningUpdates \ 2>&1 | tail -5 [[ -f "$IPA_PATH" ]] || fail "IPA export failed" diff --git a/src/app/(onboarding)/privacy.tsx b/src/app/(onboarding)/privacy.tsx index 8004c40..95e8b37 100644 --- a/src/app/(onboarding)/privacy.tsx +++ b/src/app/(onboarding)/privacy.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { Switch } from '@expo/ui'; import { useRouter } from 'expo-router'; import { OnboardingScreen } from '@/components/onboarding/onboarding-screen'; import { useOnboardingDraft } from '@/features/onboarding/onboarding-draft'; @@ -52,18 +53,6 @@ export default function PrivacyScreen() { const styles = makeStyles(colors); const hasAcceptedPolicies = isCurrentPolicy(draft.policyAcceptance); - const togglePolicyAcceptance = () => { - update( - 'policyAcceptance', - hasAcceptedPolicies - ? null - : { - version: CURRENT_POLICY_VERSION, - acceptedAt: new Date().toISOString(), - } - ); - }; - return ( - [ - styles.consentRow, - pressed && styles.pressed, - ]} - > - - {hasAcceptedPolicies ? ( - - ) : null} - - - I agree to the Terms of Service and Community Standards. - - + { + if (newValue) { + update('policyAcceptance', { + version: CURRENT_POLICY_VERSION, + acceptedAt: new Date().toISOString(), + }); + } else { + update('policyAcceptance', null); + } + }} + label="I agree to the Terms of Service and Community Standards." + /> router.push('/settings/terms')} @@ -198,28 +166,6 @@ function makeStyles(colors: ThemeColors) { borderColor: colors.border, padding: spacing.lg, }, - consentRow: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: spacing.md, - }, - checkbox: { - width: 24, - height: 24, - borderRadius: radius.sm, - borderWidth: 1, - alignItems: 'center', - justifyContent: 'center', - }, - checkmark: { - color: '#FFFFFF', - fontSize: 16, - fontWeight: '700', - lineHeight: 20, - }, - consentText: { - flex: 1, - }, policyLink: { alignSelf: 'flex-start', paddingVertical: spacing.xs, diff --git a/src/app/(onboarding)/review.tsx b/src/app/(onboarding)/review.tsx index 2a235aa..6c9a54e 100644 --- a/src/app/(onboarding)/review.tsx +++ b/src/app/(onboarding)/review.tsx @@ -81,10 +81,12 @@ export default function ReviewScreen() { // Written before the profile exists so consent always precedes // publication. Deliberately fatal, unlike the best-effort writes below: // a profile must never go live without a durable acceptance record. + // Bound to the identity that actually creates the profile, not the + // draft-derived pubkey, so the accepted key is authoritative. try { await storage.savePolicyAcceptance({ ...policyAcceptance, - pubkey: resolvedPubkey, + pubkey: identity.pubkey, }); } catch { throw new Error( diff --git a/src/app/settings/terms.tsx b/src/app/settings/terms.tsx index 26d8306..c593008 100644 --- a/src/app/settings/terms.tsx +++ b/src/app/settings/terms.tsx @@ -45,42 +45,75 @@ const TERMS = [ ]; // The acceptance recorded at onboarding, surfaced so a member can see exactly -// which version their account agreed to and when. -function useAcceptanceSummary(): string { - const [acceptance, setAcceptance] = useState( - null - ); +// which version their account agreed to and when. Returns distinct states for +// loading (initial), error (unreadable record), missing (no acceptance on +// file), and the three acceptance variants. +function useAcceptanceSummary(): { + summary: string; + loading: boolean; +} { + const [acceptance, setAcceptance] = useState(null); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); useEffect(() => { let active = true; + setLoading(true); + setFailed(false); storage .getPolicyAcceptance() .then((record) => { - if (active) setAcceptance(record); + if (active) { + setAcceptance(record); + setLoading(false); + } }) .catch(() => { - // Unreadable record — fall through to the neutral summary below. + if (active) { + setFailed(true); + setLoading(false); + } }); return () => { active = false; }; }, []); + if (loading) return { summary: '', loading: true }; + if (failed) { + return { + summary: + 'Unable to read your acceptance record on this device. Your acceptance from onboarding is still on file with your account.', + loading: false, + }; + } if (!acceptance) { - return 'Your acceptance is recorded on this device when you create your profile.'; + return { + summary: + 'Your acceptance is recorded on this device when you create your profile.', + loading: false, + }; } if (!isCurrentPolicy(acceptance)) { - return 'Your account accepted an earlier version of these terms. The version above applies to new profiles.'; + return { + summary: + 'Your account accepted an earlier version of these terms. The version above applies to new profiles.', + loading: false, + }; } const accepted = formatAcceptedAt(acceptance.acceptedAt); - return accepted - ? `You accepted this version on ${accepted}.` - : 'You accepted this version on this device.'; + return { + summary: accepted + ? `You accepted this version on ${accepted}.` + : 'You accepted this version on this device.', + loading: false, + }; } export default function TermsScreen() { const { colors, isDark } = useTheme(); - const acceptanceSummary = useAcceptanceSummary(); + const { summary: acceptanceSummary, loading: acceptanceLoading } = + useAcceptanceSummary(); return ( <> @@ -104,11 +137,13 @@ export default function TermsScreen() { > {`These Terms of Service include the Community Standards below. During onboarding, you must explicitly accept this ${POLICY_EFFECTIVE_LABEL} version before a profile can be created.`} - - {acceptanceSummary} - + {acceptanceLoading ? null : ( + + {acceptanceSummary} + + )} {TERMS.map((term) => ( {}); if (mountedRef.current) { setIsAuthenticated(false); setPubkey(null); diff --git a/src/lib/policy.ts b/src/lib/policy.ts index e3874c1..ba460a9 100644 --- a/src/lib/policy.ts +++ b/src/lib/policy.ts @@ -32,10 +32,24 @@ export function isCurrentPolicy( return acceptance?.version === CURRENT_POLICY_VERSION; } +/** + * Returns true when `iso` is a real calendar timestamp (parsed + * successfully by the runtime) within a reasonable past window. Used at + * read time so a malformed string like `"0"` never displays as valid. + */ +export function isValidPolicyTimestamp(iso: string): boolean { + const parsed = new Date(iso); + if (Number.isNaN(parsed.getTime())) return false; + // Must be in the past and not before the Unix epoch; any policy + // version older than 1970 is clearly a bug or a forged record. + const now = Date.now(); + return parsed.getTime() > 0 && parsed.getTime() <= now; +} + /** Renders an acceptance timestamp for display, or null if it is unusable. */ export function formatAcceptedAt(iso: string): string | null { + if (!isValidPolicyTimestamp(iso)) return null; const parsed = new Date(iso); - if (Number.isNaN(parsed.getTime())) return null; return parsed.toLocaleDateString(undefined, { year: 'numeric', month: 'long', diff --git a/src/lib/storage/index.ts b/src/lib/storage/index.ts index 7b56d45..179ae05 100644 --- a/src/lib/storage/index.ts +++ b/src/lib/storage/index.ts @@ -2,10 +2,11 @@ // Uses expo-secure-store for sensitive data, AsyncStorage for cache only. // NEVER stores: nsec, private keys, decrypted messages, raw GPS. +import AsyncStorage from '@react-native-async-storage/async-storage'; import * as SecureStore from 'expo-secure-store'; import { Platform } from 'react-native'; -import type { StoredPolicyAcceptance } from '@/lib/policy'; +import { isValidPolicyTimestamp, type StoredPolicyAcceptance } from '@/lib/policy'; // In-memory fallback when SecureStore is unavailable (e.g., web during dev) const inMemoryStore = new Map(); @@ -129,25 +130,30 @@ export const storage = { await secureDelete(STORAGE_KEYS.ONBOARDING_DRAFT); }, - // Terms of Service and Community Standards acceptance. This survives the - // onboarding-draft cleanup so the app retains the exact accepted policy - // version, timestamp, and account identifier. + // Terms of Service and Community Standards acceptance. + // + // Uses AsyncStorage (durable) rather than SecureStore so a transient + // keychain failure does not silently lose the record. The acceptance + // payload is not a secret — it contains a version string, a timestamp, + // and a public key — so durable persistence is the right trade-off. async savePolicyAcceptance(acceptance: StoredPolicyAcceptance): Promise { - await secureSet(STORAGE_KEYS.POLICY_ACCEPTANCE, JSON.stringify(acceptance)); + await AsyncStorage.setItem( + STORAGE_KEYS.POLICY_ACCEPTANCE, + JSON.stringify(acceptance) + ); }, async getPolicyAcceptance(): Promise { - const raw = await secureGet(STORAGE_KEYS.POLICY_ACCEPTANCE); - if (!raw) return null; try { + const raw = await AsyncStorage.getItem(STORAGE_KEYS.POLICY_ACCEPTANCE); + if (!raw) return null; const parsed: unknown = JSON.parse(raw); - // A consent record is only meaningful if every field survived intact; - // a partial record must read as "no acceptance on file". if ( typeof parsed === 'object' && parsed !== null && typeof (parsed as StoredPolicyAcceptance).version === 'string' && typeof (parsed as StoredPolicyAcceptance).acceptedAt === 'string' && - typeof (parsed as StoredPolicyAcceptance).pubkey === 'string' + typeof (parsed as StoredPolicyAcceptance).pubkey === 'string' && + isValidPolicyTimestamp((parsed as StoredPolicyAcceptance).acceptedAt) ) { return parsed as StoredPolicyAcceptance; } @@ -156,6 +162,9 @@ export const storage = { return null; } }, + async deletePolicyAcceptance(): Promise { + await AsyncStorage.removeItem(STORAGE_KEYS.POLICY_ACCEPTANCE); + }, // Onboarding async setOnboardingComplete(): Promise { @@ -181,5 +190,7 @@ export const storage = { for (const key of Object.values(STORAGE_KEYS)) { await secureDelete(key); } + // Policy acceptance is in AsyncStorage, not SecureStore. + await AsyncStorage.removeItem(STORAGE_KEYS.POLICY_ACCEPTANCE); }, }; From 0c04f7ca87def6b667d5ec43a1f2ab21509323ee Mon Sep 17 00:00:00 2001 From: Jonathan Gan Date: Thu, 13 Aug 2026 09:12:41 -0400 Subject: [PATCH 04/39] Differentiate private introductions --- README.md | 15 +- app.json | 2 +- docs/APP-REVIEW-NOTES.md | 59 ++- docs/ARCHITECTURE.md | 2 +- docs/DEMO-RECORDING-SCRIPT.md | 26 +- docs/DESIGN.md | 27 +- docs/DEVICE-VERIFICATION.md | 9 +- docs/GUIDELINE-4.3B-RESPONSE.md | 91 ++++ docs/RELEASE-STATUS.md | 8 +- docs/ROADMAP-1.0.md | 4 +- docs/STORE_LISTING.md | 82 +-- src/app/(onboarding)/finish.tsx | 20 +- src/app/(onboarding)/welcome.tsx | 22 +- src/app/(tabs)/_layout.tsx | 20 +- src/app/(tabs)/discover.tsx | 409 ++++++++------- src/app/(tabs)/matches.tsx | 9 +- src/app/(tabs)/passport.tsx | 238 +++++++++ src/app/_layout.tsx | 11 +- src/app/candidate/[pubkey].tsx | 52 +- src/components/discovery/swipe-deck.tsx | 465 ------------------ .../__tests__/introduction-decisions.test.ts | 90 ++++ .../__tests__/private-introduction.test.ts | 50 ++ .../__tests__/swipe-decisions.test.ts | 90 ---- src/features/discovery/candidate-cache.ts | 2 +- .../discovery/introduction-decisions.ts | 57 +++ .../discovery/private-introduction.ts | 49 ++ src/features/discovery/swipe-decisions.ts | 57 --- store.config.json | 15 +- 28 files changed, 988 insertions(+), 993 deletions(-) create mode 100644 docs/GUIDELINE-4.3B-RESPONSE.md create mode 100644 src/app/(tabs)/passport.tsx delete mode 100644 src/components/discovery/swipe-deck.tsx create mode 100644 src/features/discovery/__tests__/introduction-decisions.test.ts create mode 100644 src/features/discovery/__tests__/private-introduction.test.ts delete mode 100644 src/features/discovery/__tests__/swipe-decisions.test.ts create mode 100644 src/features/discovery/introduction-decisions.ts create mode 100644 src/features/discovery/private-introduction.ts delete mode 100644 src/features/discovery/swipe-decisions.ts diff --git a/README.md b/README.md index 87bc1e8..213afa6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

- Privacy-focused dating on an open protocol.
+ Deliberate private introductions on an open protocol.
An Expo React Native reference client for the OpenDating protocol.

@@ -25,7 +25,7 @@ ## What is OpenDating? -**OpenDating** is an early dating client built around data minimization, coarse location, private service requests, and end-to-end encrypted direct messages. It currently connects to services operated by OpenDating on Cloudflare; federation and independent providers are post-GA work. +**OpenDating** is an intentional-introduction client built around user-owned identity, data minimization, coarse location, private decisions, and end-to-end encrypted direct messages. Instead of an endless swipe feed, it presents one person with an explanation and privacy receipt. It currently connects to services operated by OpenDating on Cloudflare; federation and independent providers are post-GA work. Under the hood, OpenDating uses **Nostr** as a protocol building block. The current discovery, matching, moderation, media, and delivery services are @@ -55,8 +55,10 @@ post-GA work. -### Discovery -- **Photo-first swipe deck** — Tinder-style cards with Reanimated gestures +### Introductions +- **One person with context** — Each introduction explains the visible compatibility facts that led to it +- **Privacy receipt** — The app says what is shared now, withheld, and unlocked only after mutual interest +- **Explicit private choices** — Skip or express interest without public engagement counts or gesture ambiguity - **Privacy-first location** — Only a 5-character geohash (~5 km) ever leaves your device - **Customizable filters** — Age range, distance, gender, relationship intent - **Daily quotas** — Prevents profile scraping and enumeration @@ -75,6 +77,11 @@ post-GA work. - **Report** — Encrypted to moderation service, with optional message evidence - **Private by default** — Likes, blocks, and reports are never public +### Privacy Passport +- **Live account boundary** — Shows the member-owned account identifier and backup path +- **Visibility control** — Pause or resume introductions from the passport +- **Plain-language boundaries** — Explains location, decision, and conversation privacy in one place + ### Platform - **iOS** — Feels like a native SwiftUI app (SF Symbols, native sheets, continuous corners) - **Android** — Feels like a native Material 3 app (Material Symbols, tonal surfaces) diff --git a/app.json b/app.json index f7e4bde..2c7a7d5 100644 --- a/app.json +++ b/app.json @@ -3,7 +3,7 @@ "name": "OpenDating", "slug": "opendating-mobile", "version": "0.1.1", - "description": "Privacy-focused dating app with coarse location and end-to-end encrypted messaging.", + "description": "Self-owned dating identity with deliberate private introductions, coarse location, and end-to-end encrypted messaging.", "orientation": "portrait", "icon": "./assets/brand/ios-app-icon-1024.png", "scheme": "opendating", diff --git a/docs/APP-REVIEW-NOTES.md b/docs/APP-REVIEW-NOTES.md index 0414861..5cf2958 100644 --- a/docs/APP-REVIEW-NOTES.md +++ b/docs/APP-REVIEW-NOTES.md @@ -31,15 +31,23 @@ table. Add the connected 11-inch iPad Pro after its 0.1.1 walkthrough passes. ## 3. Purpose and target audience -**Purpose.** OpenDating is a dating app for adults that is built so the service -itself learns as little as possible about its users. +**Purpose.** OpenDating gives adults deliberate private introductions while the +service itself learns as little as possible about them. It does not present an +endless swipe feed: one person is introduced at a time with an explanation and +a privacy receipt. **Problem it solves.** Mainstream dating apps require handing over a precise location, a phone number or social login, and a message history the operator can read. That data is retained, profiled, and monetized. People who want to date without accepting that trade have had no mainstream option. -**How it solves it.** +**How it is materially different.** +- Every introduction identifies the visible compatibility facts behind it, + such as shared interests, matching intent, or a coarse distance bucket. +- A privacy receipt states what is shared now, what remains withheld, and what + becomes available only after mutual interest. +- The Privacy Passport is a primary tab with the member-owned account ID, + visibility control, backup path, and location/decision/conversation boundaries. - Exact GPS never leaves the device. Discovery transmits only a coarse area of roughly 5 km. - Direct messages between matches are end-to-end encrypted. @@ -50,8 +58,9 @@ without accepting that trade have had no mainstream option. **Target audience.** Adults 18 and over seeking dating and relationships, with particular appeal to privacy-conscious users. The app is rated 17+. -**Value.** Users get the ordinary dating experience — browse, match, chat — -without the surveillance that normally accompanies it. +**Value.** Members make a small number of understandable, private choices and +retain a portable recovery path, instead of surrendering identity, exact +location, or readable conversations to a conventional dating account. ## 4. Setup and access instructions @@ -65,21 +74,24 @@ working account inside the app in about a minute. 3. Read and accept the Terms of Service and Community Standards. 4. Enter a display name, an age of 18 or over, and a gender. 5. Choose what you are looking for, and set match preferences. -6. **Allow location when prompted.** This is required for discovery to return - results. Choosing "Don't Allow" leaves the discovery deck empty — that is +6. **Allow location when prompted.** This is required for introductions to return + results. Choosing "Don't Allow" leaves introductions empty — that is expected behavior, not a bug. 7. Add at least two photos, or choose **Add photos later**. For the fullest review flow, add two photos and allow photo-library access when prompted. -8. Review the profile and tap to finish. The account is now live on the network. -9. **Discover** shows nearby people; swipe or use the buttons. **Matches** lists - mutual likes. Tap a match to open an encrypted chat. +8. Review the profile and tap to finish. The app opens **Privacy Passport** so + the account, visibility, location, decision, and conversation boundaries are + visible before any profile is evaluated. +9. **Introductions** shows one nearby person with compatibility reasons and a + privacy receipt. Choose **Skip privately** or **Express private interest**. + **Matches** lists mutual choices. Tap a match to open an encrypted chat. 10. Report and block are available from any profile and from the chat screen. 11. Delete the account at **Profile → Settings → Account → Delete Account**. **Important for discovery results.** The app shows real people in the reviewer's coarse area on a live network. If the review location has no other users, the -discovery deck will legitimately be empty. If the reviewer needs to see a -populated deck, contact us and we will coordinate seeded test accounts in the +introductions screen will legitimately be empty. If the reviewer needs to see a +populated introduction, contact us and we will coordinate test accounts in the review region. ## 5. External services and tools @@ -151,12 +163,16 @@ The physical iPad model and iPadOS result shown in the recording will be added here after the final 0.1.1 walkthrough passes. PURPOSE AND AUDIENCE -OpenDating is a privacy-focused dating app for adults 18+ seeking dating and -relationships. It provides ordinary discovery, matching, and chat while -collecting less sensitive data: exact GPS never leaves the device, discovery -uses only a coarse area of about 5 km, and messages between matches are -end-to-end encrypted. It particularly serves privacy-conscious adults. There -is no advertising SDK, third-party analytics, or data sale. +OpenDating provides deliberate private introductions for adults 18+ seeking +dating and relationships. It does not use an endless swipe feed. The app shows +one person at a time, explains the visible compatibility facts behind the +introduction, and displays a privacy receipt for what is shared, withheld, and +unlocked after mutual interest. Its primary Privacy Passport tab gives the +member a live account identifier, visibility control, backup path, and clear +location, decision, and conversation boundaries. Exact GPS never leaves the +device, introductions use only a coarse area of about 5 km, and messages +between matches are end-to-end encrypted. There is no advertising SDK, +third-party analytics, or data sale. ACCOUNT ACCESS AND SETUP No demo credentials exist because OpenDating has no email/password login or @@ -168,14 +184,17 @@ their recovery key in a masked field. A reviewer can create a new account: 3. Enter name, age 18+, gender, preferences, intent, and bio. 4. Add at least two photos and allow photo-library access, or choose "Add photos later." -5. Allow location while using the app. Denying it leaves discovery empty. +5. Allow location while using the app. Denying it leaves introductions empty. 6. Review the profile and tap "Create Profile." +7. The app opens Privacy Passport. Review the live account and visibility + controls, then open Introductions to see one explained introduction and its + privacy receipt. Report and Block are available from profiles and chats. Delete Account is at Profile -> Settings -> Account -> Delete Account. DISCOVERY RESULTS The app uses a live network and shows real people in the reviewer's coarse -area. If no other users are nearby, discovery can legitimately be empty. +area. If no other users are nearby, introductions can legitimately be empty. Contact us and we will coordinate test accounts in the review region. PERMISSIONS AND PURCHASES diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f8191a1..299a9ce 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -46,7 +46,7 @@ Expo Router file-based routing. Each screen: ### Features (`src/features/`) Custom hooks that encapsulate domain logic: -- `use-discovery` — candidate fetching, swipe actions, location updates +- `use-discovery` — introduction fetching, private decisions, location updates - `use-matches` — match list, new match notification - `use-messaging` — NIP-17 send/receive, subscription management - `use-safety` — block/unblock, unmatch, report diff --git a/docs/DEMO-RECORDING-SCRIPT.md b/docs/DEMO-RECORDING-SCRIPT.md index c2fd19f..9956e54 100644 --- a/docs/DEMO-RECORDING-SCRIPT.md +++ b/docs/DEMO-RECORDING-SCRIPT.md @@ -51,28 +51,32 @@ that, or note it in the reply text. | Photos | **Permission prompt fires here.** Tap Allow and pick at least two photos | | Location | **Permission prompt fires here.** Tap Allow While Using App. Pause on the coarse-area readout — it shows the ~5 km region, which demonstrates that exact GPS is not used | | Review | Scroll the assembled profile | -| Finish | Tap through into the app | +| Finish | Tap **View My Privacy Passport** | Both permission prompts must be visible in the recording. Do not pre-grant them. -### 3. Discovery (1:45–2:30) -The Discover tab. Swipe right on one profile, left on another, and tap a card to -open the full candidate profile. Scroll that profile, then go back. +### 3. Privacy Passport and introductions (1:45–2:45) +Pause on **Privacy Passport** and show the account, visibility, location, +decision, and conversation cards. Toggle introduction visibility off and back +on. Open **Introductions**. Pause on the compatibility reasons and privacy +receipt, then tap the card to open the full candidate profile. Return and use +the explicit **Skip privately** or **Express private interest** action. There is +no swipe gesture or public engagement count. -### 4. User-generated content: reporting (2:30–3:00) +### 4. User-generated content: reporting (2:45–3:15) From a candidate profile, open the safety menu and tap **Report**. Show the report screen with its reason options. Submit one, or back out — either demonstrates the mechanism, but submitting is stronger evidence. -### 5. User-generated content: blocking (3:00–3:20) +### 5. User-generated content: blocking (3:15–3:35) Open the safety menu again and tap **Block**. Show the confirmation dialog and -confirm. Show that the profile is gone from discovery afterward. +confirm. Show that the profile is gone from introductions afterward. -### 6. Matches and encrypted messaging (3:20–4:10) +### 6. Matches and encrypted messaging (3:35–4:25) The Matches tab. Open a match, send a message, and show it delivered. If you have a second device, show the message arriving there. -### 7. Account deletion (4:10–5:00) +### 7. Account deletion (4:25–5:15) Profile tab → Settings → Account → **Delete Account**. Show the confirmation dialog listing what deletion does, confirm it, and show the app returning to the welcome screen. Then relaunch the app once to show the account is genuinely gone @@ -86,12 +90,12 @@ End on the welcome screen. Stop the recording. ## If the network is empty The app runs against a live relay and shows real nearby users. If nobody else is -in your coarse area, discovery and matches will be empty. +in your coarse area, introductions and matches will be empty. Do not fake it with the screenshot build. Instead, either: 1. Create two or three accounts on other devices in the same coarse area shortly - before recording, so discovery has real profiles; or + before recording, so introductions has real profiles; or 2. Record the empty states honestly and say so in the reply, offering to coordinate seeded accounts in Apple's review region. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 4f98886..d040d8f 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2,7 +2,7 @@ ## Principles -1. **Photo-first** — The person is the focus, not the UI +1. **Context before judgment** — One person is introduced with an understandable reason 2. **Native feel** — iOS feels SwiftUI, Android feels Material 3 3. **Minimal** — White space, excellent typography, native controls 4. **Safe** — Safety controls never buried, always accessible @@ -65,8 +65,7 @@ For conventional UI: ### React Native Primitives (Escape Hatch) For interaction surfaces Expo UI isn't designed for: -- Swipe deck (Reanimated + Gesture Handler) -- Animated card stack +- Candidate photo paging - Complex gesture targets - Custom photo overlays @@ -94,14 +93,17 @@ For interaction surfaces Expo UI isn't designed for: ## Key Screens -### Discovery (Card Deck) -- Large photo, edge-to-edge -- Bottom gradient overlay -- Name + age primary -- Distance bucket secondary -- 2-3 interests tertiary -- Pass/Like action buttons -- Minimal chrome +### Private Introduction +- One candidate at a time; no endless deck +- Human-readable compatibility reasons above the profile +- Large photo with name, age, distance bucket, and interests +- Privacy receipt states what is shared, withheld, and unlocked after mutual interest +- Explicit **Skip privately** and **Express private interest** actions + +### Privacy Passport +- Member-owned account identifier and backup path +- Live pause/resume control for introduction visibility +- Plain-language location, decision, and conversation boundaries ### Chat - Clean message bubbles @@ -117,7 +119,6 @@ For interaction surfaces Expo UI isn't designed for: ## Motion -- Swipe: smooth, responsive, on UI thread - Match celebration: subtle scale + fade - Navigation: native transitions - Respects Reduce Motion @@ -129,4 +130,4 @@ For interaction surfaces Expo UI isn't designed for: - Reduce Motion support - Minimum 4.5:1 contrast ratio - Touch targets ≥ 44pt -- Swipe actions have button alternatives +- Decisions use explicit labeled buttons with clear accessibility labels diff --git a/docs/DEVICE-VERIFICATION.md b/docs/DEVICE-VERIFICATION.md index 18c9151..9e7cf5e 100644 --- a/docs/DEVICE-VERIFICATION.md +++ b/docs/DEVICE-VERIFICATION.md @@ -1,4 +1,7 @@ -# Device Verification — 2026-08-08 +# Device Verification — historical 2026-08-08 walkthrough + +This evidence predates the Guideline 4.3(b) redesign. It does not establish +device proof for the current private-introductions experience. Walked on a Samsung SM-A166U1 (Android 16) against the production relay, `opendating-relay.jonathang132298.workers.dev`. @@ -21,7 +24,7 @@ All eleven onboarding steps complete, and the profile goes live. | 9 | Location | ✅ incl. skip | | 10 | Review | ✅ all values correct | | 11 | **Profile created** | ✅ **"You're ready! Your profile is live."** | -| — | Discover / tabs | ✅ deck, PASS/LIKE, tab bar | +| — | Historical Discover / tabs | ✅ legacy card flow and tab bar | Zero Compose `MissingHostException`. Zero JS errors in logcat. @@ -60,7 +63,7 @@ Discovery returned "Unable to determine your area" — correct, because the location step was skipped during this walk, so no geohash was ever sent. The backend's `invalid_location` surfaced in plain language with a retry. -Not yet walked on device: discovery with a real location, likes, matches, +Not yet walked on device: introductions with a real location, choices, matches, chat, edit-profile, settings, filters, report, verification. All are free of `@expo/ui` (23 of 26 screens are), so the Compose class of failure cannot affect them, and every backend path behind them is verified by diff --git a/docs/GUIDELINE-4.3B-RESPONSE.md b/docs/GUIDELINE-4.3B-RESPONSE.md new file mode 100644 index 0000000..43f25c7 --- /dev/null +++ b/docs/GUIDELINE-4.3B-RESPONSE.md @@ -0,0 +1,91 @@ +# Guideline 4.3(b) Remediation + +**Submission:** `39b836d6-fdcd-4920-94fa-2a54b5b8a4ce` + +**Rejected:** August 13, 2026 + +**Reviewed:** 0.1.0 (1), iPad Air 11-inch (M3) + +**Status:** Do not appeal or resubmit the rejected binary. Prepare 0.1.1 with +the product and evidence below. + +## Why the reviewed experience failed + +The reviewed build led with a familiar photo swipe deck, pass/like controls, +matches, and chat. Its actual differences were mostly explained in onboarding, +Settings, or metadata. Apple evaluates the primary experience under 4.3(b), so +privacy architecture alone was not enough when the visible interaction was +indistinguishable from established dating apps. + +## Material product changes in 0.1.1 + +1. **Deliberate private introductions replace swiping.** The app presents one + person at a time with explicit `Skip privately` and `Express private + interest` actions. There is no swipe gesture or public engagement count. +2. **Every introduction explains itself.** The explanation uses only shared + interests, matching relationship intent, and the existing coarse distance + bucket. It does not claim an opaque compatibility score. +3. **Every introduction includes a privacy receipt.** It states what is shared + now, what remains withheld, and that encrypted chat opens only after mutual + interest. +4. **Privacy Passport is a primary tab and the first post-onboarding screen.** + Members can inspect account ownership, the on-device location boundary, + private-decision behavior, encrypted-conversation behavior, visibility, + backup/restore, and deletion. +5. **Store presentation leads with the control model.** New metadata and + screenshots must show Passport and the privacy receipt before matches/chat. + +## Reviewer walkthrough + +1. Launch and tap **Create Account**. No phone, email, password, or social login + is requested. +2. Complete onboarding. On Location, observe that the permission is reduced to + an approximate area before it is sent. +3. Finish onboarding. The app opens **Privacy Passport**, not a swipe deck. +4. Pause and resume introductions on Passport. Open account backup to verify + that a recovery-key flow exists; do not expose a real recovery key in video. +5. Open **Introductions**. Read **Why this introduction** and the three-line + **Privacy receipt**. +6. Open the full profile and use **Skip privately** or **Private interest**. +7. With two seeded accounts, choose each other and open the resulting + end-to-end encrypted chat. +8. Demonstrate report, block, and permanent account deletion. + +## App Review reply after the new build and evidence exist + +```text +Hello App Review, + +Thank you for the Guideline 4.3(b) feedback. We agreed that version 0.1.0 (1) +presented its primary experience too much like a conventional swipe-based +dating app, even though its underlying privacy model differed. We have not +resubmitted that binary. + +Version 0.1.1 materially changes the product experience: + +- Swipe browsing has been removed. OpenDating now provides one deliberate + private introduction at a time with explicit private actions. +- Every introduction explains the visible shared signals behind it and shows a + privacy receipt: what is shared, what is withheld, and what unlocks only + after mutual interest. +- A new Privacy Passport is a primary tab and the first destination after + onboarding. It gives members live visibility, account backup/restore, + location-boundary, encrypted-conversation, and deletion controls. +- Accounts remain self-owned and require no phone number, email address, + password, or third-party login. Exact GPS is reduced on-device before + sharing, interest remains private unless mutual, and chat is end-to-end + encrypted. + +Reviewer path: create an account -> complete onboarding -> Privacy Passport -> +Introductions -> open a full introduction -> private interest -> Matches. + +We attached a physical-device walkthrough beginning at launch and showing the +new Passport, introduction explanation, privacy receipt, mutual-interest chat, +report/block controls, permission prompts, and account deletion. + +We respectfully request review of the materially revised 0.1.1 build under +Guideline 4.3(b). +``` + +Do not send the reply with a claimed attachment until the physical-device video +has been recorded, inspected, and uploaded. diff --git a/docs/RELEASE-STATUS.md b/docs/RELEASE-STATUS.md index 8ce3e2f..8fe7e95 100644 --- a/docs/RELEASE-STATUS.md +++ b/docs/RELEASE-STATUS.md @@ -4,7 +4,9 @@ ## Verified in this release -- Discovery deck gestures and candidate-profile navigation are repaired. +- One-at-a-time private introductions and candidate-profile navigation are implemented. +- Every introduction explains its visible compatibility facts and includes a privacy receipt. +- The Privacy Passport exposes account, visibility, location, decision, and conversation boundaries. - Account deletion routes to the relay's advertised `deletion` service. - Unmatch and block removal route to the policy service; the backend implements idempotent block removal. - Inbound service responses require a valid signed seal, a valid rumor hash, the expected advertised sender, request ID, response type, and freshness window. @@ -24,7 +26,9 @@ These are post-launch engineering priorities, not launch gates: ## Release mechanics -The machine-readable state is in `release/manifest.json`, now `approved` with no blockers. +The machine-readable state is in `release/manifest.json`, which remains +`blocked` until its evidence requirements are satisfied. Product implementation +or a successful local build alone does not approve a release. The production build and submit scripts still call `scripts/release/assert-release-ready.mjs`, which additionally requires: diff --git a/docs/ROADMAP-1.0.md b/docs/ROADMAP-1.0.md index 8fbf2bf..ae9388e 100644 --- a/docs/ROADMAP-1.0.md +++ b/docs/ROADMAP-1.0.md @@ -21,7 +21,7 @@ marketplace-competitive 1.0 and are not complete. | Phase | Status | Required evidence before completion | |---|---|---| -| 0. Release containment | In progress | Stale iOS submission withdrawn, Android held, mobile and protocol PRs merged with CI, `opendating-protocol@0.1.1` published and pinned, deletion/swiping/profile navigation verified from the release commit, public claims corrected | +| 0. Release containment | In progress | Rejected iOS submission retained only as review history, Android held, mobile and protocol PRs merged with CI, `opendating-protocol@0.1.1` published and pinned, deletion/private-introduction/profile navigation verified from the release commit, public claims corrected | | 1. Secure production foundation | Not started | Protocol 0.2, native signer migration, authenticated envelopes, SQLCipher repositories/outbox/cursors, global blocks, push, deterministic sync, dependency remediation, physical-device E2E, independent security review, zero open critical/high release defects | | 2. Trust, safety, and legal operations | Not started | Persona/Hive integrations, fail-closed moderation, console/appeals/audit, accepted policy versions, country registry, deletion web flow, vendor DPAs, legal approval, and staffed 24/7 escalation coverage meeting documented SLAs | | 3. Intentional competitive product | Not started | Adaptive daily batches, reciprocal eligibility/ranking reasons/fairness, richer profiles and reactions, Opening Moves, durable chat/voice notes, Share Date/check-ins, inclusive preferences, beta activation/matching/messaging/safety gates | @@ -38,7 +38,7 @@ marketplace-competitive 1.0 and are not complete. - Publish and pin `opendating-protocol@0.1.1`; remove the temporary mobile routing mirror. - Verify account deletion against the advertised deletion service and prove that immediate hiding and cascade receipts work. - Keep the configured protected branches, protected staging environments, and reviewer-gated production environments aligned with the deployment workflows. -- Exit only with release-commit evidence for discovery gestures, profile navigation, deletion, documentation, and store hold state. +- Exit only with release-commit evidence for private introductions, Privacy Passport, profile navigation, deletion, documentation, and store hold state. ### 1. Secure the identity, transport, and local-data boundary diff --git a/docs/STORE_LISTING.md b/docs/STORE_LISTING.md index 48bb4e6..b294c76 100644 --- a/docs/STORE_LISTING.md +++ b/docs/STORE_LISTING.md @@ -1,8 +1,11 @@ # OpenDating Store Listing -**Last updated:** 2026-08-09 +**Last updated:** 2026-08-13 -> **Submission status: approved.** This metadata is cleared for upload with the 0.1.1 release. The stale iOS build 4 and Android build 3 artifacts are superseded and must not be shipped. +> **Submission status: blocked.** Apple rejected iOS 0.1.0 (1) under Guideline +> 4.3(b) on August 13, 2026. This copy belongs to the materially revised 0.1.1 +> candidate and must not be attached to the rejected binary as though it +> described that build. This is the canonical copy deck for the first App Store Connect and Google Play Console records. @@ -13,7 +16,7 @@ Console records. |---|---| | App Store name | OpenDating Mobile | | Google Play name | OpenDating | -| Subtitle / short description | Dating built for privacy | +| Subtitle / short description | Private introductions, your way | | iOS bundle ID | `com.jongan69.opendating` | | Android package | `com.jongan69.opendating` | | SKU | `com.jongan69.opendating` | @@ -27,38 +30,42 @@ Console records. ## Store Description -OpenDating is a dating app built around privacy. - -Your exact location never leaves your phone. Discovery works from a coarse area -only, so you can find people nearby without broadcasting where you are. - -Messages between matches are end-to-end encrypted, so your conversations stay -between you and the person you matched with. - -No advertising profile. No data brokers. No selling your personal information. - -HOW IT WORKS -- Create an account on your device in seconds, with no email, phone number, or password -- Add photos and a short bio -- Browse people in your general area -- Match, then message with end-to-end encryption - -SAFETY -- Report or block any profile at any time -- Blocks take effect immediately on your device -- Adults 18 and over only - -OpenDating is the first app built on the open OpenDating protocol, so your -account belongs to you. +OpenDating is for adults who want to meet without handing an app their phone +number, exact location, or readable conversations. + +A DIFFERENT KIND OF INTRODUCTION +OpenDating presents one deliberate introduction at a time. Each introduction +explains the visible profile signals you share and includes a privacy receipt +showing what was shared, what remains withheld, and what unlocks only after +mutual interest. There is no public like count and no visible rejection. + +YOUR PRIVACY PASSPORT +The Passport is a live account-control surface, not a marketing promise. See +whether your profile is visible, pause new introductions, inspect the app's +location and conversation boundaries, back up your self-owned account, or +permanently delete it. + +BUILT AROUND DATA MINIMIZATION +- Create a self-owned account without email, phone number, password, or social login +- Reduce location to an approximate area on your device before it is shared +- Keep interest private unless both people independently choose each other +- Open an end-to-end encrypted conversation only after mutual interest +- Block locally and report privately +- Use the app without ads, tracking, or a data-broker business model + +OpenDating is the first reference client for the open OpenDating protocol. Your +recovery key can restore your account on another compatible OpenDating client or +device. Adults 18 and over only. ## Keywords -`singles,match,chat,nearby,secure,photos,profile,ethical,local,relationships,privacy` +`privacy,introductions,encrypted,dating,identity,nearby,relationships,chat,recovery` ## Release Notes -Version 0.1.1 improves the discovery deck and request routing, and adds -authenticated checks on incoming messages. +Replaces swipe-first browsing with deliberate private introductions, adds an +explanation and privacy receipt to every introduction, and introduces a live +Privacy Passport for account, visibility, location, and conversation controls. ## Reviewer Notes @@ -69,13 +76,13 @@ Use these notes in App Store Connect and Play Console: ```text OpenDating creates a self-owned account on-device; there is no email/password -login or external account required for review. Please create an account in the -app, complete onboarding, and allow approximate location when prompted. The app -uses a live OpenDating relay at -wss://opendating-relay.jonathang132298.workers.dev. Exact location never leaves -the device; the app sends only a coarse geohash area for discovery. -Messages between matches are end-to-end encrypted. Blocks and reports are -available from match/profile safety controls. +login or external account required for review. After onboarding, the Privacy +Passport shows the account and data boundaries as live controls. The +Introductions tab presents one person at a time, explains only visible shared +profile signals, and shows a privacy receipt. Exact location is reduced to a +coarse area on-device. Interest stays private until mutual; only then does an +end-to-end encrypted chat open. Blocks and private reports are available from +the full introduction profile. ``` ## Privacy Declarations @@ -114,7 +121,8 @@ Prepared screenshots are in `screenshots/app-store/`: - `6.1-{01-welcome,02-create-account,12-discover,13-matches,14-profile}.png` at 1179 x 2556 - `ipad-13-{01-welcome,02-create-account,12-discover,13-matches,14-profile}.png` at 2048 x 2732 for `APP_IPAD_PRO_129` and `APP_IPAD_PRO_3GEN_129` -The selected flow is welcome, account creation, discovery, matches, and profile. +The replacement flow must be welcome, Privacy Passport, private introduction, +matches, and profile. Do not reuse the rejected swipe-deck screenshots. Google Play phone screenshots are in `screenshots/play-store/phone-*.png` at 1080 x 1920 for the same five screens. diff --git a/src/app/(onboarding)/finish.tsx b/src/app/(onboarding)/finish.tsx index e7da5c6..ab104bf 100644 --- a/src/app/(onboarding)/finish.tsx +++ b/src/app/(onboarding)/finish.tsx @@ -1,5 +1,6 @@ // Finish — success screen after the profile is created. -// "Start Discovering" drops the user into the main app. +// The first destination is the member's Privacy Passport so account control +// is the organizing experience, not a setting hidden behind the dating flow. import React, { useEffect } from 'react'; import { StyleSheet, Text, View } from 'react-native'; @@ -28,11 +29,11 @@ export default function FinishScreen() { return ( router.replace('/(tabs)/discover')} + primaryLabel="View My Privacy Passport" + onPrimaryPress={() => router.replace('/passport')} > @@ -45,14 +46,15 @@ export default function FinishScreen() { { color: colors.textSecondary, textAlign: 'center' }, ]} > - People nearby can now find you. OpenDating only shares your general - area, keeps your likes private, and encrypts every conversation. + OpenDating now has only what it needs to introduce you: a public + profile and an approximate area. Your exact location and private + interest remain withheld. - Your account belongs to this device and your profile is live on - OpenDating. You can delete your account anytime from Settings. + Your recovery key can restore this account on another device. Open + your Passport anytime to inspect or change what the app can do. ); diff --git a/src/app/(onboarding)/welcome.tsx b/src/app/(onboarding)/welcome.tsx index 1375928..3e8bb3f 100644 --- a/src/app/(onboarding)/welcome.tsx +++ b/src/app/(onboarding)/welcome.tsx @@ -13,20 +13,20 @@ import { radius } from '@/theme/radius'; const FEATURES: { title: string; detail: string }[] = [ { - title: 'Coarse location only', - detail: 'People see your general area — never your exact spot.', + title: 'A self-owned account', + detail: 'No phone, email, password, or social login is required.', }, { - title: 'Private likes', - detail: 'Your likes are never revealed unless you match.', + title: 'Location reduced on-device', + detail: 'Only an approximate area leaves your phone — never exact GPS.', }, { - title: 'Encrypted messages', - detail: 'Conversations are end-to-end encrypted, just for you two.', + title: 'Private introductions', + detail: 'Interest stays secret unless you both choose each other.', }, { - title: 'Private blocks', - detail: 'Blocking is invisible and immediate.', + title: 'Conversations only you can read', + detail: 'Every message is end-to-end encrypted for the two of you.', }, ]; @@ -38,7 +38,7 @@ export default function WelcomeScreen() { router.push('/(onboarding)/create-account')} @@ -64,8 +64,8 @@ export default function WelcomeScreen() { { color: colors.textSecondary, textAlign: 'center' }, ]} > - Meet people nearby without handing over your identity, your photos, - or your exact location. + Meet people without surrendering your phone number, exact location, + or conversations to the app.
diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx index 2b51aa4..66655e0 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(tabs)/_layout.tsx @@ -1,7 +1,7 @@ -// Main tab navigator — Discover, Matches, Profile. +// Main tab navigator — Introductions, Matches, Passport, Profile. // Native bottom tabs; SF Symbols on iOS, Material Symbols on Android. -// Discover hides the header (immersive deck); Matches and Profile keep a -// native header, themed like the settings stack. +// Introductions provides its own context header; the other tabs keep native +// headers themed like the settings stack. import { Tabs } from 'expo-router'; import { SymbolView, type AndroidSymbol, type SFSymbol } from 'expo-symbols'; @@ -57,9 +57,19 @@ export default function TabsLayout() { ( - + + ), + }} + /> + ( + ), }} /> diff --git a/src/app/(tabs)/discover.tsx b/src/app/(tabs)/discover.tsx index bcabbcd..8ae7779 100644 --- a/src/app/(tabs)/discover.tsx +++ b/src/app/(tabs)/discover.tsx @@ -1,33 +1,43 @@ -// Discover — the main card deck. Minimal chrome: brand header with a -// filter entry point, the swipe deck, and pass/like controls. +// Introductions — one deliberate, private introduction at a time. +// +// Unlike a swipe deck, this screen explains why two profiles were introduced +// and what remains withheld. Decisions use the same protocol grants and local +// privacy guarantees as before; only the member-facing interaction changes. -import { useCallback, useEffect, useRef, useMemo } from 'react'; -import { Alert, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useCallback, useEffect, useMemo } from 'react'; +import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { useRouter } from 'expo-router'; import { SafeAreaView } from 'react-native-safe-area-context'; import { SymbolView } from 'expo-symbols'; import * as Haptics from 'expo-haptics'; -import { SwipeDeck, type SwipeDeckHandle } from '@/components/discovery/swipe-deck'; + +import { CandidateCard } from '@/components/discovery/candidate-card'; import { EmptyState } from '@/components/ui/empty-state'; import { BrandMark } from '@/components/brand/brand-mark'; import { useDiscovery } from '@/features/discovery/use-discovery'; import { cacheCandidates, getCachedCandidate } from '@/features/discovery/candidate-cache'; +import { introductionReasons } from '@/features/discovery/private-introduction'; import { - consumeSwipeDecision, - subscribeSwipeDecisions, - type SwipeDecision, -} from '@/features/discovery/swipe-decisions'; + consumeIntroductionDecision, + subscribeIntroductionDecisions, + type IntroductionDecision, +} from '@/features/discovery/introduction-decisions'; +import { useProfileContent } from '@/features/profile/profile-content'; import { useTheme } from '@/state/theme-context'; import { isScreenshotMode } from '@/constants/env'; -import { getScreenshotCandidates } from '@/constants/screenshot-demo'; +import { + getScreenshotCandidates, + getScreenshotProfileContent, +} from '@/constants/screenshot-demo'; import { spacing } from '@/theme/spacing'; import { radius } from '@/theme/radius'; import { typography } from '@/theme/typography'; import type { Candidate } from '@/types/opendating'; -export default function DiscoverScreen() { +export default function IntroductionsScreen() { const router = useRouter(); const { colors } = useTheme(); + const { content: ownProfile } = useProfileContent(); const { candidates, loading, @@ -40,17 +50,25 @@ export default function DiscoverScreen() { fetchCandidates, clearError, } = useDiscovery(); - const deckRef = useRef(null); const screenshotCandidates = useMemo(() => getScreenshotCandidates(), []); - - // In screenshot mode, use demo candidates to show a populated deck + const screenshotProfile = useMemo(() => getScreenshotProfileContent(), []); const displayCandidates = useMemo( () => (isScreenshotMode ? screenshotCandidates : candidates), [candidates, screenshotCandidates] ); + const currentCandidate = displayCandidates[0]; + const reasons = useMemo( + () => + currentCandidate + ? introductionReasons( + isScreenshotMode ? screenshotProfile : ownProfile, + currentCandidate + ) + : [], + [currentCandidate, ownProfile, screenshotProfile] + ); - // Feed the detail screens from whatever the deck has shown. useEffect(() => { cacheCandidates(displayCandidates); }, [displayCandidates]); @@ -58,101 +76,71 @@ export default function DiscoverScreen() { const presentMatch = useCallback( (pubkey: string) => { const candidate = getCachedCandidate(pubkey); - const name = candidate?.profile.display_name?.trim() || 'your match'; - Alert.alert("It's a match!", `You and ${name} liked each other.`, [ - { text: 'Keep swiping', style: 'cancel' }, - { - text: 'Say hello', - onPress: () => router.push(`/chat/${pubkey}`), - }, + const name = candidate?.profile.display_name?.trim() || 'your introduction'; + Alert.alert('Mutual interest', `You and ${name} chose each other. Your private chat is now open.`, [ + { text: 'Later', style: 'cancel' }, + { text: 'Say hello', onPress: () => router.push(`/chat/${pubkey}`) }, ]); }, [router] ); - const handleLike = useCallback( + const handleInterest = useCallback( async (pubkey: string, grant: string) => { - try { - const matched = await like(pubkey, grant); - if (matched) presentMatch(pubkey); - } catch (err) { - // like() surfaces errors via hook state; this is a safety net. - if (err instanceof Error && err.message) { - Alert.alert('Could not like', err.message); - } - } + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); + const matched = await like(pubkey, grant); + if (matched) presentMatch(pubkey); }, [like, presentMatch] ); - const handlePass = useCallback( + const handleSkip = useCallback( (pubkey: string) => { + Haptics.selectionAsync().catch(() => {}); pass(pubkey); }, [pass] ); const handleOpenCandidate = useCallback( - (candidate: Candidate) => { - router.push(`/candidate/${candidate.pubkey}`); - }, + (candidate: Candidate) => router.push(`/candidate/${candidate.pubkey}`), [router] ); - // A like or pass made on the detail screen. That screen cannot act on the - // stack itself — discovery state lives in this component's useDiscovery() - // instance — so it posts the decision and this applies it. useEffect(() => { - const apply = (decision: SwipeDecision) => { - if (decision.direction === 'like' && decision.grant) { - void handleLike(decision.pubkey, decision.grant); + const apply = (decision: IntroductionDecision) => { + if (decision.choice === 'interest' && decision.grant) { + void handleInterest(decision.pubkey, decision.grant); } else { - // A like with no grant would be rejected by the server and surface as - // "no longer available", so it is treated as a pass — same rule the - // deck applies. - handlePass(decision.pubkey); + handleSkip(decision.pubkey); } }; - // Drain anything posted while this screen was unmounted or backgrounded, - // then listen for decisions made while it stays mounted underneath. - const queued = consumeSwipeDecision(); + const queued = consumeIntroductionDecision(); if (queued) apply(queued); - return subscribeSwipeDecisions((decision) => { - consumeSwipeDecision(); + return subscribeIntroductionDecisions((decision) => { + consumeIntroductionDecision(); apply(decision); }); - }, [handleLike, handlePass]); - - const swipe = useCallback((direction: 'left' | 'right') => { - if (direction === 'left') { - Haptics.selectionAsync().catch(() => {}); - } else { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); - } - deckRef.current?.swipe(direction); - }, []); + }, [handleInterest, handleSkip]); - // A blocking state only when there is nothing to show. With cards on - // screen an error becomes a dismissible banner instead, so a single failed - // like never wipes out the deck. const showErrorState = !isScreenshotMode && !!error && candidates.length === 0 && !loading; const showErrorBanner = !isScreenshotMode && !!error && candidates.length > 0; - const outOfLikes = loaded && !unavailable && remainingToday === 0; + const outOfInterests = loaded && !unavailable && remainingToday === 0; return ( - {/* Brand header + filters */} - + - - OpenDating - + + Introductions + One person, with context + router.push('/filters')} @@ -163,7 +151,7 @@ export default function DiscoverScreen() { pressed && styles.pressed, ]} accessibilityRole="button" - accessibilityLabel="Discovery filters" + accessibilityLabel="Introduction preferences" > - - {error} - + {error} ) : null} - {/* Deck / states */} - - {showErrorState ? ( - unavailable ? ( - void fetchCandidates() }} + {showErrorState ? ( + + void fetchCandidates() }} + /> + + ) : currentCandidate ? ( + + + + + Why this introduction + + {reasons.map((reason) => ( + + + {reason} + + ))} + + + + handleOpenCandidate(currentCandidate)} /> - ) : ( - void fetchCandidates() }} + + + + + + Privacy receipt + + + + + + + + handleSkip(currentCandidate.pubkey)} /> - ) - ) : ( - void handleLike(pubkey, grant)} - onPass={handlePass} - onPressCard={handleOpenCandidate} - loading={loading && displayCandidates.length === 0 && !isScreenshotMode} - /> - )} - + void handleInterest(currentCandidate.pubkey, currentCandidate.candidate_grant)} + /> + - {/* Pass / Like controls */} - - swipe('left')} - accessibilityLabel="Pass on this profile" - > - + {outOfInterests + ? 'Private interests refresh tomorrow' + : `${remainingToday} private interest${remainingToday === 1 ? '' : 's'} available today`} + + ) : null} + + ) : ( + + - - swipe('right')} - accessibilityLabel="Like this profile" - > - - - - - {/* Daily quota — displayed so users know how many likes they have left. */} - {!isScreenshotMode && loaded && !unavailable && displayCandidates.length > 0 ? ( - - {outOfLikes - ? "That's all your likes for today — more tomorrow" - : `${remainingToday} like${remainingToday === 1 ? '' : 's'} left today`} - - ) : null} + + )} ); } -interface ActionButtonProps { - label: string; - onPress: () => void; - accessibilityLabel: string; - primary?: boolean; - children: React.ReactNode; +function ReceiptRow({ label, value }: { label: string; value: string }) { + const { colors } = useTheme(); + return ( + + {label} + {value} + + ); } -function ActionButton({ +function IntroductionButton({ label, - onPress, accessibilityLabel, + onPress, primary = false, - children, -}: ActionButtonProps) { + disabled = false, +}: { + label: string; + accessibilityLabel: string; + onPress: () => void; + primary?: boolean; + disabled?: boolean; +}) { const { colors } = useTheme(); return ( - - [ - styles.actionButton, - primary - ? { backgroundColor: colors.accent } - : { - backgroundColor: colors.surface, - borderColor: colors.border, - borderWidth: StyleSheet.hairlineWidth, - }, - pressed && styles.pressed, - ]} - accessibilityRole="button" - accessibilityLabel={accessibilityLabel} - > - {children} - - - {label} - - + [ + styles.actionButton, + primary + ? { backgroundColor: colors.accent, borderColor: colors.accent } + : { backgroundColor: colors.surface, borderColor: colors.border }, + disabled && styles.disabled, + pressed && styles.pressed, + ]} + > + {label} + ); } const styles = StyleSheet.create({ - container: { - flex: 1, - }, + container: { flex: 1 }, header: { flexDirection: 'row', alignItems: 'center', @@ -320,53 +316,44 @@ const styles = StyleSheet.create({ paddingHorizontal: spacing.xl, paddingVertical: spacing.md, }, + brandRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, filterButton: { width: 40, height: 40, borderRadius: radius.full, + borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center', }, - pressed: { - opacity: 0.7, - transform: [{ scale: 0.97 }], - }, - deckArea: { - flex: 1, - }, - actions: { - flexDirection: 'row', - justifyContent: 'center', - gap: spacing.xxxl, - paddingVertical: spacing.lg, - }, - actionColumn: { - alignItems: 'center', - gap: spacing.sm, - }, + content: { padding: spacing.lg, paddingBottom: spacing.huge, gap: spacing.lg }, + whyCard: { borderRadius: radius.lg, borderWidth: 1, padding: spacing.lg, gap: spacing.sm }, + cardTitleRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + reasonRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + reasonDot: { width: 6, height: 6, borderRadius: radius.full }, + profileCard: { height: 480, minHeight: 420 }, + receipt: { borderRadius: radius.lg, borderWidth: 1, padding: spacing.lg, gap: spacing.md }, + receiptRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.md }, + receiptValue: { flex: 1, textAlign: 'right' }, + actions: { gap: spacing.md }, actionButton: { - width: 68, - height: 68, - borderRadius: 34, + minHeight: 52, + borderRadius: radius.lg, + borderWidth: 1, alignItems: 'center', justifyContent: 'center', + paddingHorizontal: spacing.lg, }, - actionLabel: { - textTransform: 'uppercase', - letterSpacing: 1, - }, - quota: { - textAlign: 'center', - paddingBottom: spacing.md, - }, + quota: { textAlign: 'center' }, errorBanner: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, - marginHorizontal: spacing.xl, + marginHorizontal: spacing.lg, marginBottom: spacing.sm, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, + padding: spacing.md, borderRadius: radius.md, }, + emptyArea: { flex: 1 }, + pressed: { opacity: 0.72, transform: [{ scale: 0.99 }] }, + disabled: { opacity: 0.5 }, }); diff --git a/src/app/(tabs)/matches.tsx b/src/app/(tabs)/matches.tsx index a37b6cd..75b904a 100644 --- a/src/app/(tabs)/matches.tsx +++ b/src/app/(tabs)/matches.tsx @@ -40,9 +40,8 @@ export default function MatchesScreen() { // Feed the chat and candidate screens from match data. // These entries carry no candidate grant: a match is already mutual, so - // there is nothing left to like. The empty grant must never reach - // intent.like — the server rejects it — and the deck guards against that by - // treating a missing grant as a pass. + // there is no new interest decision to send. The empty grant must never + // reach intent.like because the server rejects it. useEffect(() => { for (const match of displayMatches) { cacheCandidate({ @@ -123,8 +122,8 @@ export default function MatchesScreen() { router.navigate('/discover') }} + subtitle="Express private interest in an introduction — when they choose you too, you'll meet here." + action={{ label: 'View introductions', onPress: () => router.navigate('/discover') }} /> ); diff --git a/src/app/(tabs)/passport.tsx b/src/app/(tabs)/passport.tsx new file mode 100644 index 0000000..5b0f240 --- /dev/null +++ b/src/app/(tabs)/passport.tsx @@ -0,0 +1,238 @@ +// Privacy Passport — the member's live account-control surface. +// Conventional layout uses @expo/ui so it maps to SwiftUI on iOS and +// Material 3 on Android. + +import { useCallback, useEffect, useState } from 'react'; +import { Alert } from 'react-native'; +import { Button, Column, Host, Row, ScrollView, Text } from '@expo/ui'; +import { useRouter } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { StatusBar } from 'expo-status-bar'; + +import { useProfile } from '@/features/profile/use-profile'; +import { isScreenshotMode } from '@/constants/env'; +import { getOpenDatingClient } from '@/lib/opendating/open-dating-client'; +import { shortPubkey } from '@/lib/format'; +import { useTheme } from '@/state/theme-context'; +import { radius } from '@/theme/radius'; +import { spacing } from '@/theme/spacing'; + +export default function PassportScreen() { + const router = useRouter(); + const { colors, isDark } = useTheme(); + const { profile, pauseProfile, resumeProfile, isPaused } = useProfile(); + const [publicId, setPublicId] = useState(''); + const [changingVisibility, setChangingVisibility] = useState(false); + + useEffect(() => { + let active = true; + getOpenDatingClient() + .getPubkey() + .then((value) => { + if (active && value) setPublicId(value); + }) + .catch(() => {}); + return () => { + active = false; + }; + }, []); + + const toggleDiscovery = useCallback(async () => { + if (changingVisibility) return; + setChangingVisibility(true); + try { + if (isPaused) await resumeProfile(); + else await pauseProfile(); + } catch (err) { + Alert.alert( + 'Could not change visibility', + err instanceof Error ? err.message : 'Please try again.' + ); + } finally { + setChangingVisibility(false); + } + }, [changingVisibility, isPaused, pauseProfile, resumeProfile]); + + const passportPublicId = isScreenshotMode ? 'demo-public-account-id' : publicId || profile?.pubkey; + const identityState = passportPublicId ? 'Ready to restore' : 'Loading account'; + const identityDetail = passportPublicId + ? `Public ID ${shortPubkey(passportPublicId)}` + : 'Reading the account stored on this device'; + + return ( + + + + + + + + LIVE PRIVACY PASSPORT + + + Your account is built around what OpenDating does not collect. + + + Inspect the boundaries below, then change visibility or back up your account without asking the service for permission. + + + + + + + + + + + + + + + Introduction visibility + + + {isPaused ? 'PAUSED' : 'VISIBLE'} + + + + {isPaused + ? 'Your profile is withheld from new introductions. Existing matches remain available.' + : 'Your public profile and approximate area can be considered for new introductions.'} + +