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.'}
+
+
+
+
+
+
+
+ The Passport reflects the behavior of this installed OpenDating client. It is not a verification of another member's identity.
+
+
+
+
+
+ );
+}
+
+function PassportCard({
+ title,
+ state,
+ detail,
+ color,
+ background,
+ textColor,
+ secondaryColor,
+}: {
+ title: string;
+ state: string;
+ detail: string;
+ color: string;
+ background: string;
+ textColor: string;
+ secondaryColor: string;
+}) {
+ return (
+
+
+ {title}
+ {state.toLocaleUpperCase()}
+
+ {detail}
+
+ );
+}
+
+const textStyles = {
+ eyebrow: { fontSize: 12, fontWeight: '700', lineHeight: 16, letterSpacing: 1.2 },
+ hero: { fontSize: 26, fontWeight: '700', lineHeight: 32, letterSpacing: 0 },
+ title: { fontSize: 16, fontWeight: '600', lineHeight: 22, letterSpacing: 0.1 },
+ state: { fontSize: 11, fontWeight: '700', lineHeight: 16, letterSpacing: 0.7 },
+ body: { fontSize: 14, fontWeight: '400', lineHeight: 21, letterSpacing: 0.2 },
+ caption: { fontSize: 12, fontWeight: '400', lineHeight: 17, letterSpacing: 0.3 },
+} as const;
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index cb4e77a..a9b5971 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -14,15 +14,8 @@ export default function RootLayout() {
const isDark = colorScheme === 'dark';
return (
- // GestureHandlerRootView is required for react-native-gesture-handler to
- // recognize anything at all. It was missing, and the failure is nearly
- // invisible: in development GestureDetector throws, but in a release build
- // it renders fine and the gesture is simply never recognized — so the
- // swipe deck looked correct and did not respond to a drag. Every card
- // decision had to go through the Pass/Like buttons.
- //
- // It must be the outermost view and must fill the screen; wrapping only
- // the deck would leave a gesture near a screen edge unrecognized.
+ // GestureHandlerRootView must remain the outermost full-screen view for
+ // navigation and any gesture-driven controls used elsewhere in the app.
{/* Provides the insets that useSafeAreaInsets reads. Also absent, which
left the chat composer measuring a zero bottom inset and sitting
diff --git a/src/app/candidate/[pubkey].tsx b/src/app/candidate/[pubkey].tsx
index 5100f03..c55d459 100644
--- a/src/app/candidate/[pubkey].tsx
+++ b/src/app/candidate/[pubkey].tsx
@@ -1,4 +1,4 @@
-// Candidate detail — the full profile behind a card in the deck.
+// Candidate detail — the full profile behind a private introduction.
//
// Reads entirely from the candidate cache. It deliberately does NOT call
// useDiscovery(): that hook's mount effect fetches a page of candidates and
@@ -6,8 +6,8 @@
// read every single time a profile was opened, into a second copy of the
// discovery stack whose state nothing else could see.
//
-// Like and pass are posted to the swipe-decision channel and applied by the
-// Discover screen, which owns the real stack.
+// Interest and skip decisions are posted to the existing decision channel and
+// applied by the Introductions screen, which owns the live candidate grant.
import { useCallback, useMemo, useRef, useState } from 'react';
import {
@@ -25,7 +25,7 @@ import { SymbolView } from 'expo-symbols';
import * as Haptics from 'expo-haptics';
import { SafetyMenu, type SafetyMenuHandle } from '@/components/safety/safety-menu';
import { useCachedCandidate } from '@/features/discovery/candidate-cache';
-import { postSwipeDecision } from '@/features/discovery/swipe-decisions';
+import { postIntroductionDecision } from '@/features/discovery/introduction-decisions';
import { distanceLabel, genderLabel, intentLabel } from '@/lib/profile-labels';
import { useTheme } from '@/state/theme-context';
import type { ThemeColors } from '@/theme/colors';
@@ -79,9 +79,9 @@ export default function CandidateDetail() {
} else {
Haptics.selectionAsync().catch(() => {});
}
- postSwipeDecision({
+ postIntroductionDecision({
pubkey: candidate.pubkey,
- direction,
+ choice: direction === 'like' ? 'interest' : 'skip',
grant: candidate.candidate_grant,
});
router.back();
@@ -101,13 +101,13 @@ export default function CandidateDetail() {
onPress={() => router.back()}
accessibilityRole="button"
style={({ pressed }) => [
- styles.backToDeck,
+ styles.backToIntroductions,
{ borderColor: colors.border },
pressed && styles.pressed,
]}
>
- Back to browsing
+ Back to introductions
@@ -278,13 +278,13 @@ export default function CandidateDetail() {
- {/* Deciding from here rather than backing out and hunting for the card
- again is the whole point of opening a profile. */}
+ {/* Explicit text preserves the deliberate-introduction model on the full
+ profile and makes the privacy consequence of each choice clear. */}
decide('pass')}
accessibilityRole="button"
- accessibilityLabel={`Pass on ${displayName}`}
+ accessibilityLabel={`Privately skip ${displayName}`}
hitSlop={spacing.sm}
style={({ pressed }) => [
styles.actionButton,
@@ -292,17 +292,12 @@ export default function CandidateDetail() {
pressed && styles.pressed,
]}
>
-
+ Skip privately decide('like')}
accessibilityRole="button"
- accessibilityLabel={`Like ${displayName}`}
+ accessibilityLabel={`Express private interest in ${displayName}`}
hitSlop={spacing.sm}
style={({ pressed }) => [
styles.actionButton,
@@ -311,12 +306,7 @@ export default function CandidateDetail() {
pressed && styles.pressed,
]}
>
-
+ Private interest
@@ -443,7 +433,7 @@ function makeStyles(colors: ThemeColors, screenWidth: number) {
gap: spacing.lg,
padding: spacing.xl,
},
- backToDeck: {
+ backToIntroductions: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
borderRadius: radius.full,
@@ -532,18 +522,18 @@ function makeStyles(colors: ThemeColors, screenWidth: number) {
},
actions: {
flexDirection: 'row',
- justifyContent: 'center',
- gap: spacing.xxxl,
- paddingVertical: spacing.md,
+ gap: spacing.md,
+ padding: spacing.md,
borderTopWidth: StyleSheet.hairlineWidth,
},
actionButton: {
- width: 60,
- height: 60,
- borderRadius: 30,
+ flex: 1,
+ minHeight: 52,
+ borderRadius: radius.lg,
borderWidth: StyleSheet.hairlineWidth,
alignItems: 'center',
justifyContent: 'center',
+ paddingHorizontal: spacing.md,
},
actionButtonPrimary: {
borderWidth: 0,
diff --git a/src/components/discovery/swipe-deck.tsx b/src/components/discovery/swipe-deck.tsx
deleted file mode 100644
index 646a972..0000000
--- a/src/components/discovery/swipe-deck.tsx
+++ /dev/null
@@ -1,465 +0,0 @@
-// Tinder-style swipe deck — pan gesture with spring-back, off-screen commit,
-// stacked back cards, and UI-thread-only animation via Reanimated.
-/* eslint-disable react-hooks/immutability */ // Reanimated shared value .value = is the library API, not React state
-import {
- forwardRef,
- useCallback,
- useEffect,
- useImperativeHandle,
- useMemo,
- useRef,
-} from 'react';
-import { StyleSheet, Text, View, useWindowDimensions } from 'react-native';
-import { Gesture, GestureDetector } from 'react-native-gesture-handler';
-import Animated, {
- Easing,
- Extrapolation,
- ReduceMotion,
- clamp,
- interpolate,
- runOnJS,
- useAnimatedStyle,
- useReducedMotion,
- useSharedValue,
- withRepeat,
- withSpring,
- withTiming,
-} from 'react-native-reanimated';
-import * as Haptics from 'expo-haptics';
-import { CandidateCard } from '@/components/discovery/candidate-card';
-import { EmptyState } from '@/components/ui/empty-state';
-import { useTheme } from '@/state/theme-context';
-import { radius } from '@/theme/radius';
-import { spacing } from '@/theme/spacing';
-import { typography } from '@/theme/typography';
-import type { Candidate } from '@/types/opendating';
-
-interface SwipeDeckProps {
- candidates: Candidate[];
- onLike: (pubkey: string, grant: string) => void;
- onPass: (pubkey: string) => void;
- /** Fetching the next batch — show skeletons instead of the deck */
- loading?: boolean;
- /** Forced empty state (e.g. no more candidates today) */
- empty?: boolean;
- /** Open the full profile for the top card. */
- onPressCard?: (candidate: Candidate) => void;
-}
-
-export interface SwipeDeckHandle {
- swipe: (direction: 'left' | 'right') => void;
-}
-
-const MAX_ROTATION_DEG = 8;
-const FLING_VELOCITY = 1200;
-const BACK_CARD_1_SCALE = 0.94;
-const BACK_CARD_1_Y = 12;
-const BACK_CARD_2_SCALE = 0.88;
-const BACK_CARD_2_Y = 24;
-
-const SPRING_CONFIG = {
- damping: 18,
- stiffness: 220,
- mass: 0.85,
- overshootClamping: false,
- reduceMotion: ReduceMotion.System,
-} as const;
-
-const COMMIT_CONFIG = {
- duration: 240,
- easing: Easing.out(Easing.quad),
- reduceMotion: ReduceMotion.System,
-} as const;
-
-export const SwipeDeck = forwardRef(function SwipeDeck(
- {
- candidates,
- onLike,
- onPass,
- loading = false,
- empty = false,
- onPressCard,
- }: SwipeDeckProps,
- ref
-) {
- const { colors } = useTheme();
- const { width: screenWidth } = useWindowDimensions();
- const reduceMotion = useReducedMotion();
-
- // Deck-level pose shared values (all animation on the UI thread)
- const tx = useSharedValue(0);
- const ty = useSharedValue(0);
- const rot = useSharedValue(0);
- const scale = useSharedValue(1);
- const progress = useSharedValue(0);
- const busy = useSharedValue(0);
-
- const swipeThreshold = screenWidth * 0.28;
-
- // The parent list is the single source of truth for what is left to show.
- // The deck deliberately keeps no cursor of its own: when it tracked an
- // index *and* the parent removed the swiped candidate, both advanced and
- // every second profile was skipped without ever being seen.
- const visible = useMemo(() => candidates.slice(0, 3), [candidates]);
- const topCandidate = visible[0];
-
- // Deepest card first, so the interactive top card is the last child painted.
- const ordered = useMemo(
- () =>
- visible
- .map((candidate, offset) => ({ candidate, offset }))
- .reverse(),
- [visible]
- );
-
- const dispatchSwipe = useCallback(
- (dir: 'left' | 'right', pubkey: string | undefined, grant: string | undefined) => {
- if (!pubkey) return;
- if (dir === 'right') {
- // A like is only valid with the grant discovery issued for this
- // viewer/candidate pair; the server rejects anything else. Sending an
- // empty string would fail server-side and read to the user as "no
- // longer available", so treat a missing grant as a pass instead.
- if (!grant) {
- onPass(pubkey);
- return;
- }
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(
- () => {}
- );
- onLike(pubkey, grant);
- } else {
- Haptics.selectionAsync().catch(() => {});
- onPass(pubkey);
- }
- },
- [onLike, onPass]
- );
-
- const springBack = () => {
- 'worklet';
- if (busy.value) return;
- tx.value = withSpring(0, SPRING_CONFIG);
- ty.value = withSpring(0, SPRING_CONFIG);
- rot.value = withSpring(0, SPRING_CONFIG);
- scale.value = withSpring(1, SPRING_CONFIG);
- progress.value = withTiming(
- 0,
- { duration: reduceMotion ? 0 : 160, easing: Easing.out(Easing.quad) }
- );
- };
-
- // A plain function, not useCallback: this is a Reanimated worklet reading
- // shared values, and wrapping it in a memo hook makes the React Compiler
- // bail out of compiling this component entirely.
- const commit = (dir: 'left' | 'right') => {
- 'worklet';
- if (busy.value) return;
- busy.value = 1;
-
- const isRight = dir === 'right';
- const pubkey = topCandidate?.pubkey;
- const grant = topCandidate?.candidate_grant;
-
- const targetX = (isRight ? 1 : -1) * screenWidth * 1.35;
- tx.value = withTiming(targetX, COMMIT_CONFIG, (finished) => {
- 'worklet';
- if (!finished) return;
- // Reset the pose before handing off, so the card the parent promotes
- // renders at rest. The back-card pose has already interpolated to the
- // full-size pose, which makes this snap invisible.
- tx.value = 0;
- ty.value = 0;
- rot.value = 0;
- scale.value = 1;
- progress.value = 0;
- busy.value = 0;
- // Dispatched on completion rather than on commit: the parent drops the
- // candidate synchronously, and doing that mid-flight would yank the
- // card out from under its own exit animation.
- runOnJS(dispatchSwipe)(dir, pubkey, grant);
- });
- rot.value = withTiming(
- isRight ? MAX_ROTATION_DEG * 1.5 : -MAX_ROTATION_DEG * 1.5,
- { duration: COMMIT_CONFIG.duration, reduceMotion: ReduceMotion.System }
- );
- };
-
- // `commit` closes over the current top candidate, so it is a new function
- // every render. Reading it through a ref keeps the imperative handle itself
- // stable while still calling the latest version — listing `commit` as a
- // dependency instead would rebuild the handle on every render.
- const commitRef = useRef(commit);
- // Updated in an effect rather than during render: writing a ref while
- // rendering is not safe under concurrent rendering, and the lint rule is
- // right to reject it.
- useEffect(() => {
- commitRef.current = commit;
- });
-
- // Imperative swipe for the pass/like action buttons — same commit path as
- // a gesture fling, so state (busy guard, advancement) stays consistent.
- useImperativeHandle(
- ref,
- () => ({
- swipe: (direction: 'left' | 'right') => {
- commitRef.current(direction);
- },
- }),
- []
- );
-
- const pan = Gesture.Pan()
- .minDistance(8)
- .onUpdate((e) => {
- if (busy.value) return;
- tx.value = e.translationX;
- ty.value = clamp(e.translationY, -64, 64);
- const p = clamp(Math.abs(tx.value) / swipeThreshold, 0, 1);
- progress.value = p;
- scale.value = 1 - 0.05 * p;
- rot.value = reduceMotion
- ? 0
- : clamp(tx.value / swipeThreshold, -1, 1) * MAX_ROTATION_DEG;
- })
- .onEnd((e) => {
- if (busy.value) return;
- const crossed =
- Math.abs(tx.value) > swipeThreshold || Math.abs(e.velocityX) > FLING_VELOCITY;
- if (!crossed) {
- springBack();
- return;
- }
- const dir =
- tx.value === 0
- ? e.velocityX > 0
- ? 'right'
- : 'left'
- : tx.value > 0
- ? 'right'
- : 'left';
- commit(dir);
- });
-
- const topCardStyle = useAnimatedStyle(() => ({
- transform: [
- { translateX: tx.value },
- { translateY: ty.value },
- { rotate: `${rot.value}deg` },
- { scale: scale.value },
- ],
- }));
-
- const backCardStyle = useAnimatedStyle(() => {
- const p = progress.value;
- return {
- transform: [
- { translateY: BACK_CARD_1_Y - BACK_CARD_1_Y * p },
- { scale: BACK_CARD_1_SCALE + (1 - BACK_CARD_1_SCALE) * p },
- ],
- };
- });
-
- const backCard2Style = useAnimatedStyle(() => {
- const p = progress.value;
- return {
- transform: [
- { translateY: BACK_CARD_2_Y - (BACK_CARD_2_Y - BACK_CARD_1_Y) * p },
- { scale: BACK_CARD_2_SCALE + (BACK_CARD_1_SCALE - BACK_CARD_2_SCALE) * p },
- ],
- };
- });
-
- const likeStampStyle = useAnimatedStyle(() => ({
- opacity: interpolate(
- tx.value,
- [0, swipeThreshold],
- [0, 1],
- Extrapolation.CLAMP
- ),
- transform: [{ rotate: '-14deg' }],
- }));
-
- const passStampStyle = useAnimatedStyle(() => ({
- opacity: interpolate(
- tx.value,
- [-swipeThreshold, 0],
- [1, 0],
- Extrapolation.CLAMP
- ),
- transform: [{ rotate: '14deg' }],
- }));
-
- if (loading) {
- return ;
- }
-
- const isEmpty = empty || visible.length === 0;
- if (isEmpty) {
- return (
-
- );
- }
-
- return (
-
- {/* Rendered back-to-front. These cards are absolutely positioned siblings,
- so both painting and hit-testing follow document order and the LAST
- child wins. Mapping the array as-is put the deepest back card on top:
- it covered candidate 1 with candidate 3's photo, and — because it also
- won hit-testing — swallowed the drag before it could reach the top
- card's GestureDetector. That is why the deck did not swipe.
-
- zIndex reinforces the order; elevation is deliberately not used, since
- on Android it would cast a shadow from these transparent full-bleed
- wrappers rather than from the card. */}
- {ordered.map(({ candidate, offset }) => {
- const layer = { zIndex: visible.length - offset };
-
- if (offset > 0) {
- return (
-
-
-
- );
- }
-
- return (
-
-
- onPressCard(candidate) : undefined}
- />
-
-
- LIKE
-
-
-
-
- PASS
-
-
-
-
- );
- })}
-
- );
-});
-
-// ---- Loading skeleton ----
-
-function SkeletonDeck({ reduceMotion }: { reduceMotion: boolean }) {
- const { colors } = useTheme();
- const pulse = useSharedValue(0.45);
-
- useEffect(() => {
- if (reduceMotion) {
- pulse.value = 0.7;
- return;
- }
- pulse.value = withRepeat(
- withTiming(1, {
- duration: 850,
- easing: Easing.inOut(Easing.quad),
- reduceMotion: ReduceMotion.System,
- }),
- -1,
- true
- );
- }, [reduceMotion, pulse]);
-
- const skeletonStyle = useAnimatedStyle(() => ({
- opacity: pulse.value,
- }));
-
- return (
-
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- },
- card: {
- ...StyleSheet.absoluteFill,
- },
- stamp: {
- position: 'absolute',
- top: spacing.xxl,
- zIndex: 5,
- },
- // Each stamp is anchored on the side the card is moving AWAY from, so it
- // stays on screen while the card travels. Anchored the other way round — the
- // intuitive-looking way — LIKE rode the card off the right edge during a
- // right swipe and PASS off the left during a left swipe, so the stamp
- // vanished at exactly the moment it was meant to confirm the decision.
- stampLike: {
- left: spacing.xl,
- },
- stampPass: {
- right: spacing.xl,
- },
- stampText: {
- ...typography.displayMedium,
- fontWeight: '800',
- letterSpacing: 4,
- textTransform: 'uppercase',
- borderWidth: 4,
- borderRadius: radius.sm,
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.xs,
- backgroundColor: 'rgba(0, 0, 0, 0.25)',
- },
- skeletonCard: {
- borderRadius: radius.xxl,
- },
- skeletonCardBack: {
- transform: [{ translateY: BACK_CARD_1_Y }, { scale: BACK_CARD_1_SCALE }],
- },
-});
diff --git a/src/features/discovery/__tests__/introduction-decisions.test.ts b/src/features/discovery/__tests__/introduction-decisions.test.ts
new file mode 100644
index 0000000..84a8f0e
--- /dev/null
+++ b/src/features/discovery/__tests__/introduction-decisions.test.ts
@@ -0,0 +1,90 @@
+import {
+ consumeIntroductionDecision,
+ postIntroductionDecision,
+ resetIntroductionDecisions,
+ subscribeIntroductionDecisions,
+} from '@/features/discovery/introduction-decisions';
+
+beforeEach(() => {
+ resetIntroductionDecisions();
+});
+
+describe('introduction decisions', () => {
+ // The detail screen posts and immediately navigates back, so Introductions is
+ // often not subscribed at the moment the decision is made.
+ it('holds a decision posted while nobody is listening', () => {
+ postIntroductionDecision({ pubkey: 'abc', choice: 'interest', grant: 'g1' });
+
+ expect(consumeIntroductionDecision()).toEqual({
+ pubkey: 'abc',
+ choice: 'interest',
+ grant: 'g1',
+ });
+ });
+
+ // Interest applied twice would spend two of the member's daily choices and, on
+ // the second call, be rejected with a grant the server already consumed.
+ it('yields a decision only once', () => {
+ postIntroductionDecision({ pubkey: 'abc', choice: 'interest', grant: 'g1' });
+
+ expect(consumeIntroductionDecision()).not.toBeNull();
+ expect(consumeIntroductionDecision()).toBeNull();
+ });
+
+ it('returns null when nothing is queued', () => {
+ expect(consumeIntroductionDecision()).toBeNull();
+ });
+
+ it('notifies live subscribers', () => {
+ const seen: string[] = [];
+ const unsubscribe = subscribeIntroductionDecisions((d) => seen.push(d.pubkey));
+
+ postIntroductionDecision({ pubkey: 'first', choice: 'skip' });
+ postIntroductionDecision({ pubkey: 'second', choice: 'skip' });
+
+ expect(seen).toEqual(['first', 'second']);
+ unsubscribe();
+ });
+
+ // Introductions consumes from inside its subscriber, so the decision must be
+ // available before listeners run.
+ it('queues the decision before it notifies subscribers', () => {
+ let queuedDuringNotify: string | null = null;
+ const unsubscribe = subscribeIntroductionDecisions(() => {
+ queuedDuringNotify = consumeIntroductionDecision()?.pubkey ?? null;
+ });
+
+ postIntroductionDecision({ pubkey: 'abc', choice: 'skip' });
+
+ expect(queuedDuringNotify).toBe('abc');
+ expect(consumeIntroductionDecision()).toBeNull();
+ unsubscribe();
+ });
+
+ it('stops notifying after unsubscribe', () => {
+ const seen: string[] = [];
+ subscribeIntroductionDecisions((d) => seen.push(d.pubkey))();
+
+ postIntroductionDecision({ pubkey: 'ignored', choice: 'skip' });
+
+ expect(seen).toEqual([]);
+ });
+
+ it('keeps only the most recent decision', () => {
+ postIntroductionDecision({ pubkey: 'stale', choice: 'skip' });
+ postIntroductionDecision({ pubkey: 'fresh', choice: 'interest', grant: 'g' });
+
+ expect(consumeIntroductionDecision()?.pubkey).toBe('fresh');
+ expect(consumeIntroductionDecision()).toBeNull();
+ });
+
+ // Introductions issues a grant per viewer/candidate pair; interest without
+ // one is rejected server-side, so the consumer downgrades it to a skip.
+ it('preserves interest posted without a grant', () => {
+ postIntroductionDecision({ pubkey: 'abc', choice: 'interest' });
+
+ const decision = consumeIntroductionDecision();
+ expect(decision?.choice).toBe('interest');
+ expect(decision?.grant).toBeUndefined();
+ });
+});
diff --git a/src/features/discovery/__tests__/private-introduction.test.ts b/src/features/discovery/__tests__/private-introduction.test.ts
new file mode 100644
index 0000000..c56968a
--- /dev/null
+++ b/src/features/discovery/__tests__/private-introduction.test.ts
@@ -0,0 +1,50 @@
+import { introductionReasons } from '@/features/discovery/private-introduction';
+import type { Candidate, ProfileContent } from '@/types/opendating';
+
+const ownProfile: ProfileContent = {
+ display_name: 'Alex',
+ interests: ['Coffee', 'Hiking'],
+ relationship_intent: 'long_term',
+ v: '0.1',
+};
+
+function candidate(overrides: Partial = {}): Candidate {
+ return {
+ pubkey: 'candidate',
+ candidate_grant: 'grant',
+ distance_bucket: 'within 5 mi',
+ profile: {
+ display_name: 'Jordan',
+ interests: ['coffee', 'art'],
+ relationship_intent: 'long_term',
+ },
+ ...overrides,
+ };
+}
+
+describe('introductionReasons', () => {
+ it('uses only visible shared profile facts and caps the explanation', () => {
+ expect(introductionReasons(ownProfile, candidate())).toEqual([
+ 'You both chose Coffee',
+ 'You both want long-term relationship',
+ ]);
+ });
+
+ it('uses the service distance bucket without exposing precise location', () => {
+ expect(
+ introductionReasons(
+ { ...ownProfile, interests: [], relationship_intent: undefined },
+ candidate({ profile: { display_name: 'Jordan' } })
+ )
+ ).toEqual(['Nearby, based on approximate area only']);
+ });
+
+ it('falls back to a truthful privacy explanation', () => {
+ expect(
+ introductionReasons(
+ null,
+ candidate({ distance_bucket: 'unknown', profile: { display_name: 'Jordan' } })
+ )
+ ).toEqual(['Introduced without revealing either exact location']);
+ });
+});
diff --git a/src/features/discovery/__tests__/swipe-decisions.test.ts b/src/features/discovery/__tests__/swipe-decisions.test.ts
deleted file mode 100644
index 884864c..0000000
--- a/src/features/discovery/__tests__/swipe-decisions.test.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import {
- consumeSwipeDecision,
- postSwipeDecision,
- resetSwipeDecisions,
- subscribeSwipeDecisions,
-} from '@/features/discovery/swipe-decisions';
-
-beforeEach(() => {
- resetSwipeDecisions();
-});
-
-describe('swipe decisions', () => {
- // The detail screen posts and immediately navigates back, so Discover is
- // often not subscribed at the moment the decision is made.
- it('holds a decision posted while nobody is listening', () => {
- postSwipeDecision({ pubkey: 'abc', direction: 'like', grant: 'g1' });
-
- expect(consumeSwipeDecision()).toEqual({
- pubkey: 'abc',
- direction: 'like',
- grant: 'g1',
- });
- });
-
- // A like applied twice would spend two of the member's daily likes and, on
- // the second call, be rejected with a grant the server already consumed.
- it('yields a decision only once', () => {
- postSwipeDecision({ pubkey: 'abc', direction: 'like', grant: 'g1' });
-
- expect(consumeSwipeDecision()).not.toBeNull();
- expect(consumeSwipeDecision()).toBeNull();
- });
-
- it('returns null when nothing is queued', () => {
- expect(consumeSwipeDecision()).toBeNull();
- });
-
- it('notifies live subscribers', () => {
- const seen: string[] = [];
- const unsubscribe = subscribeSwipeDecisions((d) => seen.push(d.pubkey));
-
- postSwipeDecision({ pubkey: 'first', direction: 'pass' });
- postSwipeDecision({ pubkey: 'second', direction: 'pass' });
-
- expect(seen).toEqual(['first', 'second']);
- unsubscribe();
- });
-
- // Discover consumes from inside its subscriber, so the decision must be
- // available before listeners run.
- it('queues the decision before it notifies subscribers', () => {
- let queuedDuringNotify: string | null = null;
- const unsubscribe = subscribeSwipeDecisions(() => {
- queuedDuringNotify = consumeSwipeDecision()?.pubkey ?? null;
- });
-
- postSwipeDecision({ pubkey: 'abc', direction: 'pass' });
-
- expect(queuedDuringNotify).toBe('abc');
- expect(consumeSwipeDecision()).toBeNull();
- unsubscribe();
- });
-
- it('stops notifying after unsubscribe', () => {
- const seen: string[] = [];
- subscribeSwipeDecisions((d) => seen.push(d.pubkey))();
-
- postSwipeDecision({ pubkey: 'ignored', direction: 'pass' });
-
- expect(seen).toEqual([]);
- });
-
- it('keeps only the most recent decision', () => {
- postSwipeDecision({ pubkey: 'stale', direction: 'pass' });
- postSwipeDecision({ pubkey: 'fresh', direction: 'like', grant: 'g' });
-
- expect(consumeSwipeDecision()?.pubkey).toBe('fresh');
- expect(consumeSwipeDecision()).toBeNull();
- });
-
- // Discovery issues a grant per viewer/candidate pair; a like without one is
- // rejected server-side, so the consumer downgrades it to a pass.
- it('preserves a like posted without a grant', () => {
- postSwipeDecision({ pubkey: 'abc', direction: 'like' });
-
- const decision = consumeSwipeDecision();
- expect(decision?.direction).toBe('like');
- expect(decision?.grant).toBeUndefined();
- });
-});
diff --git a/src/features/discovery/candidate-cache.ts b/src/features/discovery/candidate-cache.ts
index 73848e1..78f875d 100644
--- a/src/features/discovery/candidate-cache.ts
+++ b/src/features/discovery/candidate-cache.ts
@@ -1,6 +1,6 @@
// Module-level candidate cache.
// Detail screens (candidate, chat) look up profile data that was already
-// shown in the discovery deck or the match list, without a re-fetch. The
+// shown in introductions or the match list, without a re-fetch. The
// cache is populated by the screens that own that data (discover, matches).
import { useSyncExternalStore } from 'react';
diff --git a/src/features/discovery/introduction-decisions.ts b/src/features/discovery/introduction-decisions.ts
new file mode 100644
index 0000000..61611cb
--- /dev/null
+++ b/src/features/discovery/introduction-decisions.ts
@@ -0,0 +1,57 @@
+// A one-slot channel for an interest/skip choice made on candidate detail.
+//
+// The candidate detail screen needs to express interest or skip, but discovery
+// state lives in a `useDiscovery()` instance owned by Introductions. Calling that
+// hook a second time from the detail screen does not share state — it mounts a
+// whole separate stack and, through its mount effect, fires another
+// `getCandidates()` and another GPS read every time a profile is opened.
+//
+// So the detail screen posts its choice here and navigates back; Introductions
+// consumes it and applies it to the candidate grant it already owns. Same shape as
+// candidate-cache: a module singleton with subscribers, no provider to thread
+// through the tree.
+
+export type IntroductionChoice = 'interest' | 'skip';
+
+export interface IntroductionDecision {
+ pubkey: string;
+ choice: IntroductionChoice;
+ /** Grant from the candidate that was on screen; interest without it is invalid. */
+ grant?: string;
+}
+
+let pending: IntroductionDecision | null = null;
+const listeners = new Set<(decision: IntroductionDecision) => void>();
+
+/** Record a decision made on the full-profile screen. */
+export function postIntroductionDecision(decision: IntroductionDecision): void {
+ // Held even when nobody is listening: the detail screen posts and then
+ // navigates, so Introductions may not have re-subscribed yet when this runs.
+ pending = decision;
+ for (const fn of listeners) fn(decision);
+}
+
+/**
+ * Take the pending decision, if any. Reading clears it, so a decision is
+ * applied exactly once and cannot be replayed by a later remount.
+ */
+export function consumeIntroductionDecision(): IntroductionDecision | null {
+ const decision = pending;
+ pending = null;
+ return decision;
+}
+
+export function subscribeIntroductionDecisions(
+ listener: (decision: IntroductionDecision) => void
+): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+/** Test seam — drops any queued decision and every subscriber. */
+export function resetIntroductionDecisions(): void {
+ pending = null;
+ listeners.clear();
+}
diff --git a/src/features/discovery/private-introduction.ts b/src/features/discovery/private-introduction.ts
new file mode 100644
index 0000000..1c737c0
--- /dev/null
+++ b/src/features/discovery/private-introduction.ts
@@ -0,0 +1,49 @@
+import { distanceLabel, intentLabel } from '@/lib/profile-labels';
+import type { Candidate, ProfileContent } from '@/types/opendating';
+
+const MAX_REASONS = 2;
+
+function normalized(value: string): string {
+ return value.trim().toLocaleLowerCase();
+}
+/**
+ * Explain an introduction using only facts already present on the two
+ * profiles and the service-provided distance bucket. This deliberately does
+ * not claim that an opaque recommendation algorithm chose the person.
+ */
+export function introductionReasons(
+ ownProfile: ProfileContent | null | undefined,
+ candidate: Candidate
+): string[] {
+ const reasons: string[] = [];
+ const ownInterests = new Map(
+ (ownProfile?.interests ?? [])
+ .filter(Boolean)
+ .map((interest) => [normalized(interest), interest.trim()])
+ );
+ const sharedInterests = (candidate.profile.interests ?? [])
+ .filter(Boolean)
+ .map((interest) => ownInterests.get(normalized(interest)))
+ .filter((interest): interest is string => Boolean(interest));
+
+ if (sharedInterests.length > 0) {
+ reasons.push(`You both chose ${sharedInterests[0]}`);
+ }
+
+ const ownIntent = ownProfile?.relationship_intent;
+ const candidateIntent = candidate.profile.relationship_intent;
+ if (ownIntent && ownIntent === candidateIntent) {
+ reasons.push(`You both want ${intentLabel(ownIntent).toLocaleLowerCase()}`);
+ }
+
+ const distance = distanceLabel(candidate.distance_bucket);
+ if (distance && reasons.length < MAX_REASONS) {
+ reasons.push(`${distance}, based on approximate area only`);
+ }
+
+ if (reasons.length === 0) {
+ reasons.push('Introduced without revealing either exact location');
+ }
+
+ return reasons.slice(0, MAX_REASONS);
+}
diff --git a/src/features/discovery/swipe-decisions.ts b/src/features/discovery/swipe-decisions.ts
deleted file mode 100644
index 39d53bd..0000000
--- a/src/features/discovery/swipe-decisions.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-// A one-slot channel for a like/pass made outside the deck.
-//
-// The candidate detail screen needs to like or pass, but discovery state lives
-// in a `useDiscovery()` instance owned by the Discover screen. Calling that
-// hook a second time from the detail screen does not share state — it mounts a
-// whole separate stack and, through its mount effect, fires another
-// `getCandidates()` and another GPS read every time a profile is opened.
-//
-// So the detail screen posts its decision here and navigates back; Discover
-// consumes it and applies it to the stack it already owns. Same shape as
-// candidate-cache: a module singleton with subscribers, no provider to thread
-// through the tree.
-
-export type SwipeDirection = 'like' | 'pass';
-
-export interface SwipeDecision {
- pubkey: string;
- direction: SwipeDirection;
- /** Grant from the candidate that was on screen; a like without it is invalid. */
- grant?: string;
-}
-
-let pending: SwipeDecision | null = null;
-const listeners = new Set<(decision: SwipeDecision) => void>();
-
-/** Record a decision made away from the deck. */
-export function postSwipeDecision(decision: SwipeDecision): void {
- // Held even when nobody is listening: the detail screen posts and then
- // navigates, so Discover may not have re-subscribed yet when this runs.
- pending = decision;
- for (const fn of listeners) fn(decision);
-}
-
-/**
- * Take the pending decision, if any. Reading clears it, so a decision is
- * applied exactly once and cannot be replayed by a later remount.
- */
-export function consumeSwipeDecision(): SwipeDecision | null {
- const decision = pending;
- pending = null;
- return decision;
-}
-
-export function subscribeSwipeDecisions(
- listener: (decision: SwipeDecision) => void
-): () => void {
- listeners.add(listener);
- return () => {
- listeners.delete(listener);
- };
-}
-
-/** Test seam — drops any queued decision and every subscriber. */
-export function resetSwipeDecisions(): void {
- pending = null;
- listeners.clear();
-}
diff --git a/store.config.json b/store.config.json
index 08641d4..07ec231 100644
--- a/store.config.json
+++ b/store.config.json
@@ -7,11 +7,11 @@
"info": {
"en-US": {
"title": "OpenDating Mobile",
- "subtitle": "Dating built for privacy",
- "description": "OpenDating is a dating app built around privacy.\n\nYour exact location never leaves your phone. Discovery works from a coarse area only, so you can find people nearby without broadcasting where you are.\n\nMessages between matches are end-to-end encrypted, so your conversations stay between you and the person you matched with.\n\nNo advertising profile. No data brokers. No selling your personal information.\n\nHOW IT WORKS\n- Create an account on your device in seconds, with no email, phone number, or password\n- Add photos and a short bio\n- Browse people in your general area\n- Match, then message with end-to-end encryption\n\nSAFETY\n- Report or block any profile at any time\n- Blocks take effect immediately on your device\n- Adults 18 and over only\n\nOpenDating is the first app built on the open OpenDating protocol, so your account belongs to you.",
- "keywords": ["singles,match,chat,nearby,secure,photos,profile,ethical,local,relationships,privacy"],
- "releaseNotes": "Version 0.1.1 improves the discovery deck and request routing, and adds authenticated checks on incoming messages.",
- "promoText": "Meet people nearby with a dating app built around coarse location, encrypted messages, and no advertising profile.",
+ "subtitle": "Private introductions, your way",
+ "description": "OpenDating is for adults who want to meet without handing an app their phone number, exact location, or readable conversations.\n\nA DIFFERENT KIND OF INTRODUCTION\nOpenDating 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.\n\nYOUR PRIVACY PASSPORT\nThe 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.\n\nBUILT AROUND DATA MINIMIZATION\n- Create a self-owned account without email, phone number, password, or social login\n- Reduce location to an approximate area on your device before it is shared\n- Keep interest private unless both people independently choose each other\n- Open an end-to-end encrypted conversation only after mutual interest\n- Block locally and report privately\n- Use the app without ads, tracking, or a data-broker business model\n\nOpenDating 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": ["privacy,introductions,encrypted,dating,identity,nearby,relationships,chat,recovery"],
+ "releaseNotes": "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.",
+ "promoText": "One private introduction at a time, with a receipt showing what is shared, withheld, and unlocked only after mutual interest.",
"privacyPolicyUrl": "https://jongan69.github.io/opendating-mobile/privacy/",
"supportUrl": "https://jongan69.github.io/opendating-mobile/safety/",
"marketingUrl": "https://jongan69.github.io/opendating-mobile/",
@@ -63,6 +63,11 @@
"koreaAgeRatingOverride": "NONE",
"lootBox": false,
"unrestrictedWebAccess": false,
+ "messagingAndChat": true,
+ "userGeneratedContent": true,
+ "socialMedia": true,
+ "socialMediaAgeRestricted": false,
+ "ageAssurance": false,
"violenceCartoonOrFantasy": "NONE",
"violenceRealistic": "NONE",
"violenceRealisticProlongedGraphicOrSadistic": "NONE"
From a459abd3b38bae219520075ce7b7d47d36b599d4 Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Thu, 20 Aug 2026 15:25:06 -0400
Subject: [PATCH 05/39] feat: gate future Plus monetization
---
.env.example | 6 +
bun.lock | 139 +++++++------
docs/MONETIZATION.md | 44 ++++
package.json | 25 +--
src/app/_layout.tsx | 19 +-
src/app/settings/_layout.tsx | 1 +
src/app/settings/index.tsx | 19 +-
src/app/settings/plus.tsx | 127 ++++++++++++
src/lib/__tests__/revenuecat-config.test.ts | 75 +++++++
src/lib/revenuecat-config.ts | 47 +++++
src/state/revenuecat-context.tsx | 210 ++++++++++++++++++++
11 files changed, 619 insertions(+), 93 deletions(-)
create mode 100644 docs/MONETIZATION.md
create mode 100644 src/app/settings/plus.tsx
create mode 100644 src/lib/__tests__/revenuecat-config.test.ts
create mode 100644 src/lib/revenuecat-config.ts
create mode 100644 src/state/revenuecat-context.tsx
diff --git a/.env.example b/.env.example
index 843564b..640e080 100644
--- a/.env.example
+++ b/.env.example
@@ -1,3 +1,9 @@
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
+# Both gates must be true before a paywall can appear. Keep feature readiness
+# false until paid customization/convenience features and safety gates ship.
+EXPO_PUBLIC_REVENUECAT_ENABLED=false
+EXPO_PUBLIC_OPENDATING_PLUS_FEATURES_READY=false
+EXPO_PUBLIC_REVENUECAT_IOS_API_KEY=
+EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY=
diff --git a/bun.lock b/bun.lock
index 591fca2..4b46a52 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,28 +5,28 @@
"": {
"name": "opendating-mobile",
"dependencies": {
- "@expo/ui": "~57.0.9",
+ "@expo/ui": "~57.0.12",
"@noble/curves": "1.9.7",
- "@nostr-dev-kit/ndk": "^2.15.2",
- "@react-native-async-storage/async-storage": "^3.1.1",
+ "@nostr-dev-kit/ndk": "^2.18.1",
+ "@react-native-async-storage/async-storage": "2.2.0",
"@scure/base": "^2.3.0",
- "expo": "~57.0.11",
+ "expo": "~57.0.15",
"expo-clipboard": "~57.0.1",
- "expo-constants": "~57.0.9",
+ "expo-constants": "~57.0.13",
"expo-crypto": "~57.0.1",
- "expo-dev-client": "~57.0.10",
+ "expo-dev-client": "~57.0.14",
"expo-device": "~57.0.1",
"expo-font": "~57.0.1",
"expo-glass-effect": "~57.0.1",
"expo-haptics": "^57.0.1",
- "expo-image": "~57.0.2",
- "expo-image-picker": "^57.0.8",
+ "expo-image": "~57.0.3",
+ "expo-image-picker": "~57.0.12",
"expo-linear-gradient": "~57.0.1",
- "expo-linking": "~57.0.5",
- "expo-location": "^57.0.8",
- "expo-router": "~57.0.11",
+ "expo-linking": "~57.0.7",
+ "expo-location": "~57.0.12",
+ "expo-router": "~57.0.15",
"expo-secure-store": "^57.0.1",
- "expo-splash-screen": "~57.0.5",
+ "expo-splash-screen": "~57.0.7",
"expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1",
"expo-symbols": "~57.0.2",
@@ -39,22 +39,23 @@
"react-native": "0.86.2",
"react-native-gesture-handler": "~2.32.0",
"react-native-get-random-values": "^1.11.0",
+ "react-native-purchases": "^10.6.0",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
- "react-native-screens": "~4.26.0",
+ "react-native-screens": "~4.26.2",
"react-native-svg": "15.15.4",
- "react-native-web": "~0.21.0",
+ "react-native-web": "~0.21.2",
"react-native-worklets": "0.10.1",
},
"devDependencies": {
"@react-native/jest-preset": "^0.86.2",
"@testing-library/react-native": "13.3.3",
"@types/jest": "^29.5.14",
- "@types/react": "~19.2.2",
- "eslint": "^9.0.0",
+ "@types/react": "~19.2.18",
+ "eslint": "^9.39.5",
"eslint-config-expo": "~57.0.1",
"jest": "^29.7.0",
- "jest-expo": "~57.0.3",
+ "jest-expo": "~57.0.4",
"react-test-renderer": "19.2.3",
"ts-jest": "^29.4.12",
"typescript": "~6.0.3",
@@ -65,8 +66,6 @@
"uuid": ">=14.0.1",
},
"packages": {
- "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
-
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
@@ -271,11 +270,11 @@
"@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.42", "", {}, "sha512-KZmHZRcthJ3KFZZlpzHjopA9guZgWR9fb3uVZlTR0BNlvG2pw1bnYBCpkze2PB0vRllwGhAM7lWXsfmcWCbXYg=="],
- "@expo/cli": ["@expo/cli@57.0.15", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~57.0.7", "@expo/config-plugins": "~57.0.8", "@expo/devcert": "^1.2.1", "@expo/env": "~2.4.2", "@expo/image-utils": "^0.11.4", "@expo/inline-modules": "^0.1.5", "@expo/json-file": "^11.0.1", "@expo/log-box": "^57.0.2", "@expo/metro": "~56.0.0", "@expo/metro-config": "~57.0.8", "@expo/metro-file-map": "^57.0.1", "@expo/osascript": "^2.7.1", "@expo/package-manager": "^1.13.1", "@expo/plist": "^0.8.1", "@expo/prebuild-config": "^57.0.12", "@expo/require-utils": "^57.0.4", "@expo/router-server": "^57.0.6", "@expo/schema-utils": "^57.0.2", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.86.2", "accepts": "^1.3.8", "agent-cli-detector": "^0.1.2", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^57.0.3", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.2", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "sandbox-cli-detector": "^0.2.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-YIE5xgZlkwx6OyjjVq/zAfqtYvczsWLEkOukDJwaZDaMQl8x5/CjoqC/Oe670DFQc5xjqguH6cmZUWHHL7yH3g=="],
+ "@expo/cli": ["@expo/cli@57.0.17", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~57.0.8", "@expo/config-plugins": "~57.0.8", "@expo/devcert": "^1.2.1", "@expo/env": "~2.4.2", "@expo/image-utils": "^0.11.4", "@expo/inline-modules": "^0.1.6", "@expo/json-file": "^11.0.1", "@expo/log-box": "^57.0.3", "@expo/metro": "~56.0.0", "@expo/metro-config": "~57.0.9", "@expo/metro-file-map": "^57.0.1", "@expo/osascript": "^2.7.1", "@expo/package-manager": "^1.13.1", "@expo/plist": "^0.8.1", "@expo/prebuild-config": "^57.0.13", "@expo/require-utils": "^57.0.4", "@expo/router-server": "^57.0.7", "@expo/schema-utils": "^57.0.2", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.86.2", "accepts": "^1.3.8", "agent-cli-detector": "0.1.6", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^57.0.3", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.2", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "sandbox-cli-detector": "^0.2.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-PQc7if117dNh2qe+CHdlmSWpgwhFiP9CJ7XbwNF0w4KSKxFpJ2qUjDPoAcCMW1VZg1x30mEJ907bT8G1iDIZBQ=="],
"@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.6", "", { "dependencies": { "node-forge": "^1.3.3" } }, "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w=="],
- "@expo/config": ["@expo/config@57.0.7", "", { "dependencies": { "@expo/config-plugins": "~57.0.7", "@expo/config-types": "^57.0.2", "@expo/json-file": "^11.0.1", "@expo/require-utils": "^57.0.4", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4" } }, "sha512-4A+V8x5OmQqNm76l84S+RrB6kVoeFrvcm/Xn/6d+ELPF/HeucDheAFchdYxYNy+NEvWzuNlI/oCvrftIeK+dbQ=="],
+ "@expo/config": ["@expo/config@57.0.8", "", { "dependencies": { "@expo/config-plugins": "~57.0.8", "@expo/config-types": "^57.0.2", "@expo/json-file": "^11.0.1", "@expo/require-utils": "^57.0.4", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4" } }, "sha512-7VpAu2ZMXNZI0+Kn+nD3vQBIyST89LATNkZpnp/cXh8/aj7zUXO8d/T+hOxsvhOFwR8eQO+jKXEw8tQbidiMxA=="],
"@expo/config-plugins": ["@expo/config-plugins@57.0.8", "", { "dependencies": { "@expo/config-types": "^57.0.2", "@expo/json-file": "~11.0.1", "@expo/plist": "^0.8.1", "@expo/require-utils": "^57.0.4", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "semver": "^7.5.4", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-x6lx4s/19i39/+1dMPwb9tFCc4WR843dG5Yi+C64lhKL3YHX+6oKrT2Kv72PCEalnUY+usQDkB3NrLSrYOWtDg=="],
@@ -291,21 +290,21 @@
"@expo/expo-modules-macros-plugin": ["@expo/expo-modules-macros-plugin@0.6.1", "", {}, "sha512-cpsLZE4rqkc1Y3eZTkxB98jrqY1YXgetmtxFt8q89jBRmk3quRuk1BZo+VcnCSObZardjg99r1k5xijEMONFGA=="],
- "@expo/fingerprint": ["@expo/fingerprint@0.20.7", "", { "dependencies": { "@expo/env": "^2.4.2", "@expo/spawn-async": "^1.8.0", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "minimatch": "^10.2.2", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-tYyZD4XZSn1C30pr9IvjN/BjAqpf6r9e1NL09lPvheO1DMLByOVdmMHFLSwW8Pu6veVgzue7GDWmq9P8mc8GMg=="],
+ "@expo/fingerprint": ["@expo/fingerprint@0.20.9", "", { "dependencies": { "@expo/env": "^2.4.2", "@expo/spawn-async": "^1.8.0", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "minimatch": "^10.2.2", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-h+YvPyNmeAUCCqaXvftiXklA2zGyRcIPv1B8fFS0YSIgNhwcxFGh1EyLOMsvJ2aWsBViNtIJ1HIFLzNu43/r4w=="],
"@expo/image-utils": ["@expo/image-utils@0.11.4", "", { "dependencies": { "@expo/require-utils": "^57.0.4", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "semver": "^7.6.0" } }, "sha512-pn/4770DIEOcYZr484uazuwg20FX/qaDkeMRF6J+oxejynDmEmO8wLsCudaNShFE0BhyKGQTYrs2rsRhqrqESw=="],
- "@expo/inline-modules": ["@expo/inline-modules@0.1.5", "", { "dependencies": { "@expo/config-plugins": "~57.0.7" } }, "sha512-LC+kWeIwnvsGIvDaFBd8uzleWzWZiZTCG7CmtfxBLjW/Gify596X67ZqTi76iHzm5KToAfwwX0nppAKPDA9vQw=="],
+ "@expo/inline-modules": ["@expo/inline-modules@0.1.6", "", { "dependencies": { "@expo/config-plugins": "~57.0.8" } }, "sha512-5f6EiOIKsFj9zlrCBet4ZIQRPEa9dBUdQgTzpjYwdZ8Z/M8W5lqMVL9dEs4HYKvEmDnqv0dQuDkFLyRSwu/DAQ=="],
"@expo/json-file": ["@expo/json-file@11.0.1", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "json5": "^2.2.3" } }, "sha512-zxHWj4MKKMAL29ZQSY/Fssx4Thluk40JmuGNaeS078wy/NhlFhnVi+rHHunulE3xJAJ0CM73m8VK2+GkF9eRwQ=="],
- "@expo/local-build-cache-provider": ["@expo/local-build-cache-provider@57.0.6", "", { "dependencies": { "@expo/config": "~57.0.7", "chalk": "^4.1.2" } }, "sha512-6aFMROb1SzIvrefpwhgS5QGNELU9T2lpK0Hcl6oiZ4/mbKgZRk0WEPauo/dHH6IgDmyK25InCMHF5nj7XY4LWg=="],
+ "@expo/local-build-cache-provider": ["@expo/local-build-cache-provider@57.0.7", "", { "dependencies": { "@expo/config": "~57.0.8", "chalk": "^4.1.2" } }, "sha512-Hq5xWhXJWuyH3CWh3uqDWoHtxM0XYqLs9h2OzRWImBJahCPfePOo4kmw2uTEB6qHT5T47eqO4jtDG3MRGGZCpw=="],
- "@expo/log-box": ["@expo/log-box@57.0.2", "", { "dependencies": { "@expo/dom-webview": "^57.0.1", "anser": "^1.4.9", "stacktrace-parser": "^0.1.10" }, "peerDependencies": { "@expo/dom-webview": "^57.0.1", "expo": "*", "react": "*", "react-native": "*" } }, "sha512-ZsFyfIR7YCbQAdVLzuTUmMHofZC7ZS9ywYCJNPlLc78x59cI8GwXFEIVbRjjC0uJERpNtXx/tsNNnkhexXlzMw=="],
+ "@expo/log-box": ["@expo/log-box@57.0.3", "", { "dependencies": { "@expo/dom-webview": "^57.0.1", "anser": "^1.4.9", "stacktrace-parser": "^0.1.10" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-qv/cMliNax6es07Un/4IGJIcs4PUuhTKKTrJZX/0X2YRcOT5cqm/QicibZwTIbiZWi1i8P4YBRQTfFT2B17giw=="],
"@expo/metro": ["@expo/metro@56.0.0", "", { "dependencies": { "metro": "0.84.4", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-config": "0.84.4", "metro-core": "0.84.4", "metro-file-map": "0.84.4", "metro-minify-terser": "0.84.4", "metro-resolver": "0.84.4", "metro-runtime": "0.84.4", "metro-source-map": "0.84.4", "metro-symbolicate": "0.84.4", "metro-transform-plugins": "0.84.4", "metro-transform-worker": "0.84.4" } }, "sha512-5gIgQHtEpjjvsjKfVtIv23a98LLRV0/y07PDShEwYSytAMlE3FSF8RHXqtHc1sUJL6dn7hnuIBpIbrLXXuVi0A=="],
- "@expo/metro-config": ["@expo/metro-config@57.0.8", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@expo/config": "~57.0.7", "@expo/env": "~2.4.2", "@expo/json-file": "~11.0.1", "@expo/metro": "~56.0.0", "@expo/require-utils": "^57.0.4", "@expo/spawn-async": "^1.8.0", "@jridgewell/gen-mapping": "^0.3.13", "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.5.5", "browserslist": "^4.25.0", "chalk": "^4.1.0", "debug": "^4.3.2", "getenv": "^2.0.0", "glob": "^13.0.0", "hermes-parser": "^0.36.0", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", "picomatch": "^4.0.4", "postcss": "^8.5.14", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*" }, "optionalPeers": ["expo"] }, "sha512-cZOVjbljqRBMCXcloc5k23gsFOhWdiKXhDkp5jU/wY6IXR6G7cYtNOwxKfxI1QLhs0KcXY0gDmZ2UIueoHzY7g=="],
+ "@expo/metro-config": ["@expo/metro-config@57.0.9", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@expo/config": "~57.0.8", "@expo/env": "~2.4.2", "@expo/json-file": "~11.0.1", "@expo/metro": "~56.0.0", "@expo/require-utils": "^57.0.4", "@expo/spawn-async": "^1.8.0", "@jridgewell/gen-mapping": "^0.3.13", "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.5.5", "browserslist": "^4.25.0", "chalk": "^4.1.0", "debug": "^4.3.2", "getenv": "^2.0.0", "glob": "^13.0.0", "hermes-parser": "^0.36.0", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", "picomatch": "^4.0.4", "postcss": "^8.5.14", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*" }, "optionalPeers": ["expo"] }, "sha512-+lalXZdoMaKTG1uKXsi2KWO0f7H3KiWrhZzAla4EmNSkcUMRx6K1rhZuBYoaJaKIp9BHZ333KbqRPron7slKQA=="],
"@expo/metro-file-map": ["@expo/metro-file-map@57.0.1", "", { "dependencies": { "debug": "^4.3.4", "fb-watchman": "^2.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "walker": "^1.0.8" } }, "sha512-8JXfVstZN7QnP4NianZZnlTVboOWR0sG8trUDNajOjnbGlPln29vponXM84tY+3tAHapz5/TxE53L0ixUwqPtA=="],
@@ -317,11 +316,11 @@
"@expo/plist": ["@expo/plist@0.8.1", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-3gTReGIUm0oRaMClsAJYxBnVPCl6fVpsl8HS+DTVxDhW4GyVyxg9E/Znm3BvcHtUJ51RJJI14pC1wvrNilCRHw=="],
- "@expo/prebuild-config": ["@expo/prebuild-config@57.0.12", "", { "dependencies": { "@expo/config": "~57.0.7", "@expo/config-plugins": "~57.0.8", "@expo/config-types": "^57.0.2", "@expo/image-utils": "^0.11.4", "@expo/json-file": "^11.0.1", "@react-native/normalize-colors": "0.86.2", "debug": "^4.3.1", "expo-modules-autolinking": "~57.0.10", "resolve-from": "^5.0.0", "semver": "^7.6.0" } }, "sha512-3vwrQ2DSGijJUa8K/j3xUfOdhYgygKdWAjJ7o3r143BGnKD1SwPIMEN7dSDhkmDWR4FAXTQwgaD8dHh5VZd/jA=="],
+ "@expo/prebuild-config": ["@expo/prebuild-config@57.0.13", "", { "dependencies": { "@expo/config": "~57.0.8", "@expo/config-plugins": "~57.0.8", "@expo/config-types": "^57.0.2", "@expo/image-utils": "^0.11.4", "@expo/json-file": "^11.0.1", "@react-native/normalize-colors": "0.86.2", "debug": "^4.3.1", "expo-modules-autolinking": "~57.0.10", "resolve-from": "^5.0.0", "semver": "^7.6.0" } }, "sha512-VhSySuXqOwK4fIc+9rgms7zN+c1Khj2xpuIgpVLPNyWRtpS8wB+eyV5CSohjSUBa3h+NsFdPk/VG6p0ZwlMDrg=="],
"@expo/require-utils": ["@expo/require-utils@57.0.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8" }, "peerDependencies": { "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" } }, "sha512-e7xbg/9BTQcsZE/oErafZXtI7kh5IgfasLJ97J5sFSzX2cA74pDvdlhW1KHVSaDkQyQv6h1LSLhsY7dEeOk7hw=="],
- "@expo/router-server": ["@expo/router-server@57.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "@expo/metro-runtime": "^57.0.10", "expo": "*", "expo-constants": "^57.0.11", "expo-font": "^57.0.1", "expo-router": "*", "expo-server": "^57.0.3", "react": "*", "react-dom": "*", "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" }, "optionalPeers": ["@expo/metro-runtime", "expo-router", "react-dom", "react-server-dom-webpack"] }, "sha512-/0H7OIilU4lmdR3V9UZuKou71cwaJ11sneW8/T6Rtr46LA71lgBWzdRldvVxaWv8dT+cu90f4jl+M3DYXN9MqQ=="],
+ "@expo/router-server": ["@expo/router-server@57.0.7", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "@expo/metro-runtime": "^57.0.12", "expo": "*", "expo-constants": "^57.0.13", "expo-font": "^57.0.1", "expo-router": "*", "expo-server": "^57.0.3", "react": "*", "react-dom": "*", "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" }, "optionalPeers": ["@expo/metro-runtime", "expo-router", "react-dom", "react-server-dom-webpack"] }, "sha512-QFHwt6V7UovmYA7+8BoMeKj3fh6WtkM9zksKjNAlZdcEI/hUhcW4uged6So5yc4tq+eA60kA7gIVZPKgBIZdrQ=="],
"@expo/schema-utils": ["@expo/schema-utils@57.0.2", "", {}, "sha512-fMu/jyN0l1Wzv7XkeWR4IYCx1M8ryui3FdBNGrWwbRgJ7EhxXxK8E2jxP2W3pbgUwUY0V3hG8+GyfCZwny+Lxw=="],
@@ -331,7 +330,7 @@
"@expo/sudo-prompt": ["@expo/sudo-prompt@9.3.2", "", {}, "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw=="],
- "@expo/ui": ["@expo/ui@57.0.11", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0", "vaul": "^1.1.2" }, "peerDependencies": { "@babel/core": "*", "expo": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-worklets": "*" }, "optionalPeers": ["@babel/core", "react-dom", "react-native-worklets"] }, "sha512-m1BSq15sg4sCmoFwOsMxBpETgyFhGlCbVPUyjdJ6Y00Ve0Fb4sueaFLRyeYRbZ8DC1z3Hgp5LNCYzBBBdMhxMg=="],
+ "@expo/ui": ["@expo/ui@57.0.12", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0", "vaul": "^1.1.2" }, "peerDependencies": { "@babel/core": "*", "expo": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-worklets": "*" }, "optionalPeers": ["@babel/core", "react-dom", "react-native-worklets"] }, "sha512-ZkrECV6xZe0+Drq6C0hrBKvz1534/VyARNp9ziKihC6Db69W29+jBDZm8k9cyRyS1Hh23q6LP8J+2wDyUM8eAA=="],
"@expo/ws-tunnel": ["@expo/ws-tunnel@2.0.0", "", { "peerDependencies": { "ws": "^8.0.0" } }, "sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw=="],
@@ -457,7 +456,7 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="],
- "@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@3.1.1", "", { "dependencies": { "idb": "8.0.3" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-z+PnLz1n6ECKhgoHZHkfc+dijXZEyZnNFSajbtE0NEbsJhmX8x9GlOeiMQIKX2E4DUqPSgfIh4FYBv1M49KgPQ=="],
+ "@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@2.2.0", "", { "dependencies": { "merge-options": "^3.0.4" }, "peerDependencies": { "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw=="],
"@react-native-masked-view/masked-view": ["@react-native-masked-view/masked-view@0.3.2", "", { "peerDependencies": { "react": ">=16", "react-native": ">=0.57" } }, "sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ=="],
@@ -491,6 +490,12 @@
"@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.86.2", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.2.0", "react": "*", "react-native": "0.86.2" } }, "sha512-uO0J72gh3EvE+1/GHRk18QRyBDTRHRB0AraAfojsRjbT7VMuJwKrZYaKGshavoaEud6aw00ZB9/8mTMIKjjcAw=="],
+ "@revenuecat/purchases-js": ["@revenuecat/purchases-js@1.52.3", "", {}, "sha512-ZU+mqdW7jsGTqYhtDmvDjcLzplvfh+jTspSKC0eMijxmFik0r/Wwq/CkD3o0QEqVRbVF0HkwEh2zZp6qdGR1Eg=="],
+
+ "@revenuecat/purchases-js-hybrid-mappings": ["@revenuecat/purchases-js-hybrid-mappings@18.31.0", "", { "dependencies": { "@revenuecat/purchases-js": "1.52.3" } }, "sha512-iVnTulEs9tXHAJwogYhUN639mU1vV/2eLv1vKvEV9lxDw6rbL5mj4vDHbK9Yrw4IeI0C8Z8Xy14UDZ74W+y2VQ=="],
+
+ "@revenuecat/purchases-typescript-internal": ["@revenuecat/purchases-typescript-internal@18.31.0", "", {}, "sha512-5AE00P0AX34ou14ayEXFEdlWbNPFSYwAQl+1MWvTMKOd9wup1/T3erM53kp2t+ms4oajJv14qwNjzlCnH4JdnA=="],
+
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
"@scure/base": ["@scure/base@2.3.0", "", {}, "sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA=="],
@@ -519,20 +524,12 @@
"@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="],
- "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
-
- "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
-
"@testing-library/react-native": ["@testing-library/react-native@13.3.3", "", { "dependencies": { "jest-matcher-utils": "^30.0.5", "picocolors": "^1.1.1", "pretty-format": "^30.0.5", "redent": "^3.0.0" }, "peerDependencies": { "jest": ">=29.0.0", "react": ">=18.2.0", "react-native": ">=0.71", "react-test-renderer": ">=18.2.0" } }, "sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg=="],
- "@testing-library/user-event": ["@testing-library/user-event@14.6.3", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g=="],
-
"@tootallnate/once": ["@tootallnate/once@2.0.1", "", {}, "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
- "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
-
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@@ -665,7 +662,7 @@
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
- "agent-cli-detector": ["agent-cli-detector@0.1.5", "", { "bin": "dist/cli.js" }, "sha512-6xvLw0EGPuxoYGZeqyMV4AK+WoZ31jsqb7a5pln1BTf6oDFsrgvfCJT7E7y9oAqifJZhJ3fZ0J36H7o6JzrB0Q=="],
+ "agent-cli-detector": ["agent-cli-detector@0.1.6", "", { "bin": { "agent-cli-detector": "dist/cli.js" } }, "sha512-vKrPeEVN3upDF3GjWxsWBbwQgMtNJ8VB1cduPvK3svmmz4ENpS7yaPxbogsbe3w+xp9xp2Cu+0Ar41rjAR4+lA=="],
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
@@ -685,8 +682,6 @@
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
- "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
-
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
"array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="],
@@ -853,8 +848,6 @@
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
- "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
-
"cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="],
"cssstyle": ["cssstyle@2.3.0", "", { "dependencies": { "cssom": "~0.3.6" } }, "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A=="],
@@ -909,8 +902,6 @@
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
- "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
-
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
@@ -1011,27 +1002,27 @@
"expect": ["expect@29.7.0", "", { "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", "jest-matcher-utils": "^29.7.0", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="],
- "expo": ["expo@57.0.13", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "^57.0.15", "@expo/config": "~57.0.7", "@expo/config-plugins": "~57.0.8", "@expo/devtools": "~57.0.1", "@expo/dom-webview": "~57.0.1", "@expo/fingerprint": "^0.20.7", "@expo/local-build-cache-provider": "^57.0.6", "@expo/log-box": "^57.0.2", "@expo/metro": "~56.0.0", "@expo/metro-config": "~57.0.8", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~57.0.7", "expo-asset": "~57.0.11", "expo-constants": "~57.0.11", "expo-file-system": "~57.0.3", "expo-font": "~57.0.1", "expo-keep-awake": "~57.0.1", "expo-modules-autolinking": "~57.0.10", "expo-modules-core": "~57.0.11", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" }, "peerDependencies": { "@expo/metro-runtime": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-web": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/metro-runtime", "react-dom", "react-native-web", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-oJnPSAcQYDIXTJowL9fm6SvAU5CU0P+u1WtWM9a/7uUmeCrkcVKoaGstwF+UY3GoM11S7FO4KKmRl1aU12CTKA=="],
+ "expo": ["expo@57.0.15", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "^57.0.17", "@expo/config": "~57.0.8", "@expo/config-plugins": "~57.0.8", "@expo/devtools": "~57.0.1", "@expo/dom-webview": "~57.0.1", "@expo/fingerprint": "^0.20.9", "@expo/local-build-cache-provider": "^57.0.7", "@expo/log-box": "^57.0.3", "@expo/metro": "~56.0.0", "@expo/metro-config": "~57.0.9", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~57.0.7", "expo-asset": "~57.0.13", "expo-constants": "~57.0.13", "expo-file-system": "~57.0.5", "expo-font": "~57.0.1", "expo-keep-awake": "~57.0.1", "expo-modules-autolinking": "~57.0.10", "expo-modules-core": "~57.0.12", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" }, "peerDependencies": { "@expo/metro-runtime": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-web": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/metro-runtime", "react-dom", "react-native-web", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-9UooISw8S8Gxo9wpsPcGkyRlf2UzNr+F26LSU2+M8iaiZ1WAjPR0I75NKFX8ETbrSgthZ2lQHgy5IU2KE/mpBA=="],
- "expo-asset": ["expo-asset@57.0.11", "", { "dependencies": { "@expo/image-utils": "^0.11.4", "expo-constants": "~57.0.11" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-tDaExt9fOKeSc51mmZNqfejyBt9argzo14XP9OmKEdAk2E/mWKJnUNj6rfUzHC5MhG120xuZC/d1ncqq0aC7Pg=="],
+ "expo-asset": ["expo-asset@57.0.13", "", { "dependencies": { "@expo/image-utils": "^0.11.4", "expo-constants": "~57.0.13" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-RPjMcmPXRMb6UbQdTIkmSDEnQL5LKGTu7ZXatq3U67V6ldlSdQeQ9pQV7zlmk0DrbnaMEh3HYiEh2YxWLNyCzA=="],
"expo-clipboard": ["expo-clipboard@57.0.1", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-HWICri4+1ao7S6QEfcorxVumXDiDnx1guGGewjZgGJWLGxFYs0RgH8ujBs+lkTzBkMmlwADaWSlaesR+nDJt5Q=="],
- "expo-constants": ["expo-constants@57.0.11", "", { "dependencies": { "@expo/env": "~2.4.2" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-gpSZ13Nfx0BOc9ALjG1SMhks5csb8Z6ncEVdhDgv0NRrmJcHPRvyUlWaKjRjzwW730XrQ6PIXFRcnNmAxOLj5A=="],
+ "expo-constants": ["expo-constants@57.0.13", "", { "dependencies": { "@expo/env": "~2.4.2" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-eB5AHp7kKxsVIBjTetUgj5WQSw8joI2ekzgTlcUv+Hc0x2t0jZU6IiL4DpyOT2yZQ71n0WJZCkmOKLgxlajIzg=="],
"expo-crypto": ["expo-crypto@57.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-xwegXQw3ATgeL1ZuqbSNrGzOeG+zNeh6Z6DSJk825Qpa3TEQQ1kG3ioE1p3g/SNF373BAVz2iBKUTSytlIbBRA=="],
- "expo-dev-client": ["expo-dev-client@57.0.12", "", { "dependencies": { "expo-dev-launcher": "~57.0.12", "expo-dev-menu": "~57.0.12", "expo-dev-menu-interface": "~57.0.0", "expo-manifests": "~57.0.1", "expo-updates-interface": "~57.0.1" }, "peerDependencies": { "expo": "*" } }, "sha512-KAVV7YyIEQNLB5452nm1BHa7ePthD+HytkEfZha6DILjWowhr0eQ9roUnUKMBMsXnfb4J5cErSzsV8zMTKAUcQ=="],
+ "expo-dev-client": ["expo-dev-client@57.0.14", "", { "dependencies": { "expo-dev-launcher": "~57.0.14", "expo-dev-menu": "~57.0.14", "expo-dev-menu-interface": "~57.0.0", "expo-manifests": "~57.0.1", "expo-updates-interface": "~57.0.1" }, "peerDependencies": { "expo": "*" } }, "sha512-0WEZJZtUZC0PgkZXm3BHWVYiOBM8hbkMP50Nk24X+K6k5+EWBmwmsSO/K4sojKyO1Pxhb/GHtSYaV8t1YI1x/w=="],
- "expo-dev-launcher": ["expo-dev-launcher@57.0.12", "", { "dependencies": { "@expo/schema-utils": "^57.0.2", "expo-dev-menu": "~57.0.12", "expo-manifests": "~57.0.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-Au00VBTezkzdlwbwxb6pcWGDmF/QlA0kTAYZZY1JFi8A351FLBEv9kcVdgNU+xasr8qloaD3wWwS429eqA6lWA=="],
+ "expo-dev-launcher": ["expo-dev-launcher@57.0.14", "", { "dependencies": { "@expo/schema-utils": "^57.0.2", "expo-dev-menu": "~57.0.14", "expo-manifests": "~57.0.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-zb1Y6cBIhniBjTpihBHaWx0O81w3aWp5ddAw+ThpfCGrMVlYCdaBb2zlo1dZ1swJna7mn09eIID97U3pPAh83g=="],
- "expo-dev-menu": ["expo-dev-menu@57.0.12", "", { "dependencies": { "expo-dev-menu-interface": "~57.0.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-ub513/edOV/QIPTxLU86+TynUNCIlOAMAUrhHy7hJAHRLCJWn2qVpOxnfcGZimSGk7lthpRAf67oyAK0VE2egA=="],
+ "expo-dev-menu": ["expo-dev-menu@57.0.14", "", { "dependencies": { "expo-dev-menu-interface": "~57.0.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-lTD5ErQrrH57v1HOM4RY2gA9hmvqXoZ/AArM7LQLCts3fKqaahOGg45ETRUgdvULv3K4wbKwhXwCRoANWMxGTw=="],
"expo-dev-menu-interface": ["expo-dev-menu-interface@57.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-F47VdzOHYc19FhI/jBgctpO8a5UskTIxG6a1E5t3W5gF8VImuvBQffdXXfLHhsuCl7dS3v3U0R45cleeVXO1Zg=="],
"expo-device": ["expo-device@57.0.1", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-jyEMDUticH+dhcL3GHa2aiifOvGXJsmb3oVT2R2q4i8bN7Bddy61+NkpMmuS2VAZrvoLQwf0TJJ/1vi1ukvutA=="],
- "expo-file-system": ["expo-file-system@57.0.4", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-h8qDOxSW1SQfnZyvH0+HeOfXCr2X4v3x50+d19JuyQcjDR+JA3tng20TlQxIHazzLPPbwtSKA1Dj3+XIOpnoKg=="],
+ "expo-file-system": ["expo-file-system@57.0.5", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-XjGrCClF0935y5wLB9qNQQUVptNEy+0OloBstXlCd+IeNXLo3uNOod6cX4N0hofIhNIrN1Al8fwfay7R0Z1a2A=="],
"expo-font": ["expo-font@57.0.1", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-QyS9L1Kh9sKJg4gfU6rdbpxpmH+DyzBX8z6jVvXMUDoqLr1GqmkO/Wu379KCXjL///kWbhpNlbi7AgBuj4VdIQ=="],
@@ -1043,7 +1034,7 @@
"expo-image-loader": ["expo-image-loader@57.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug=="],
- "expo-image-picker": ["expo-image-picker@57.0.10", "", { "dependencies": { "expo-image-loader": "~57.0.1" }, "peerDependencies": { "expo": "*" } }, "sha512-Zr00cd4RuUJlke8yN8/lKdl55UauoacF45j86zJa6oxJoe+wjlt9kkf0aGxQeoPW1JILT5Ikr3Os4+a9ROJYEw=="],
+ "expo-image-picker": ["expo-image-picker@57.0.12", "", { "dependencies": { "expo-image-loader": "~57.0.1" }, "peerDependencies": { "expo": "*" } }, "sha512-BjZLioItcDNrXwp46XZDZGDEUJ16/bK0QzxPjdNijiPYpLfQLeuFYoX9yH31sosnli0UL++x4ndYQoTMsICdlw=="],
"expo-json-utils": ["expo-json-utils@57.0.1", "", {}, "sha512-cgTe1NqzQdYs/WN+3nIY5IZg8s0pb0xaTUbhYvxQDn137GbwRfHoGM2se3m3Vsl4Qu+B9G4RPEK5WJDEU2Do7g=="],
@@ -1051,25 +1042,25 @@
"expo-linear-gradient": ["expo-linear-gradient@57.0.1", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-CpS8eMqoIWcHVGKV66zbDvzotCw9qYp3f8CuI9N+h1LaO0tMLUzBpkhAKePUsXlpN3yolYlHFSPkfVZ/uSh+iA=="],
- "expo-linking": ["expo-linking@57.0.6", "", { "dependencies": { "expo-constants": "~57.0.11", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-NdKjEGk+ywwGRNvgQCq9KettM1eVHfR1+uLov+0ohb6rmmMEgnstfLcc3f0DKMQ0PnROsY5G5+ZkNeiairYRXA=="],
+ "expo-linking": ["expo-linking@57.0.7", "", { "dependencies": { "expo-constants": "~57.0.13", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Ujn4kY0bd1MoJFLYuvIUWamlZH61pkahrUpug3hBtOeS89LDbPyKNjjmWef6+CPTGQfed2PlfuMZuPO1BCrg5w=="],
- "expo-location": ["expo-location@57.0.10", "", { "dependencies": { "@expo/image-utils": "^0.11.4" }, "peerDependencies": { "expo": "*" } }, "sha512-E/NdVxmd2+lODFgMc8CC6L0Zl+w20L1VEwPBGdJXblGew6Sv70FA3NlVP73Hi/QWmjECvbYTjkKR7OnrVnmaLA=="],
+ "expo-location": ["expo-location@57.0.12", "", { "dependencies": { "@expo/image-utils": "^0.11.4" }, "peerDependencies": { "expo": "*" } }, "sha512-Fnh1dzIvZgc2mX7fXuXf6NxbEttZQLEnSXCbFHCLNtxyImUf/sURE93C9Jed+IfS/ozmtl84s/1nfz/cI87e9A=="],
"expo-manifests": ["expo-manifests@57.0.1", "", { "dependencies": { "expo-json-utils": "~57.0.1" }, "peerDependencies": { "expo": "*" } }, "sha512-qB/mDG2dYdl+EvUeQuqP8KFYCFgFCQjJYdWIHo8SFBgDzMYmdF286DFY2M1M9Okr99wkb5M4tgA3aCcwv3aEQA=="],
"expo-modules-autolinking": ["expo-modules-autolinking@57.0.10", "", { "dependencies": { "@expo/require-utils": "^57.0.4", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-jNqLswMx8QHN8VXRgud+xT+9q+J9U63CEGlx+k4V/IE9Qyt9HrKbWp+pIDma0fOquta5HBfSfsShcI+NkEpTiA=="],
- "expo-modules-core": ["expo-modules-core@57.0.11", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "0.6.1", "expo-modules-jsi": "~57.0.4", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0" }, "optionalPeers": ["react-native-worklets"] }, "sha512-gzRAI+vs0g9eObyQfGq1kp017flMeqZMzprqquCWxKg/AYdJXKmGXibJqMVFvRbUpluYBsW8U9P0inntKbzJOg=="],
+ "expo-modules-core": ["expo-modules-core@57.0.12", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "0.6.1", "expo-modules-jsi": "~57.0.5", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0" }, "optionalPeers": ["react-native-worklets"] }, "sha512-hKqCdu8+78oNKWCgM4xSeblfmD/audgNVCn6uUCJuNBS/hc4DyC0Ia1X96SSZF9w9bGTLjNS8j2b7DshiC3WJA=="],
- "expo-modules-jsi": ["expo-modules-jsi@57.0.4", "", { "peerDependencies": { "react-native": "*" } }, "sha512-vt7FyqUqqFXiRVnBqYD7y+GSPTgeua5Ocoy0+SYt+RSHkZEA2Fyop7If3g1TYDzQObYybPRo7TG2Rle1XLaWFw=="],
+ "expo-modules-jsi": ["expo-modules-jsi@57.0.5", "", { "peerDependencies": { "react-native": "*" } }, "sha512-NQF7MUF0j3rQHV8KJqETYA1SrJr3USvQ/AWEhODo+unMRFBDP9BKpn8+aNGGmx6fa2XLnqC2qulVw4DT6Gp13Q=="],
- "expo-router": ["expo-router@57.0.13", "", { "dependencies": { "@expo/log-box": "^57.0.2", "@expo/metro-runtime": "^57.0.10", "@expo/schema-utils": "^57.0.2", "@expo/ui": "^57.0.11", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "client-only": "^0.0.1", "color": "^4.2.3", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^57.0.1", "expo-server": "^57.0.3", "expo-symbols": "^57.0.2", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-is": "^19.1.0", "react-native-drawer-layout": "^4.2.2", "react-native-screens": "^4.26.0", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "standard-navigation": "^0.0.5", "vaul": "^1.1.2" }, "peerDependencies": { "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^57.0.11", "expo-linking": "^57.0.6", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-f0dgD1F2q2upYLum7Qo/kNjCJ2P5nqBTAUqWggCPSFLck+mNT72LRUH6R5OMgprjuIL3mc4pAscqTktfZHxa5w=="],
+ "expo-router": ["expo-router@57.0.15", "", { "dependencies": { "@expo/log-box": "^57.0.3", "@expo/metro-runtime": "^57.0.12", "@expo/schema-utils": "^57.0.2", "@expo/ui": "^57.0.12", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", "client-only": "^0.0.1", "color": "^4.2.3", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^57.0.1", "expo-server": "^57.0.3", "expo-symbols": "^57.0.2", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-is": "^19.1.0", "react-native-drawer-layout": "^4.2.2", "react-native-screens": "^4.26.0", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "standard-navigation": "^0.0.5", "vaul": "^1.1.2" }, "peerDependencies": { "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^57.0.13", "expo-linking": "^57.0.7", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-vwr2HL2U7hqv3QTkuz0e8hQc5hkgpXztisy30VwhdXJVDPdP0/m8wSiYUm6GKyZAbcXfD4fn44ZYcJsSESY6cA=="],
"expo-secure-store": ["expo-secure-store@57.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-tLa1VmSadOq19mA/dwkl99RbHyjLE0T1qqBYMY3/OsguZTI+rlrDy/DDJjupqlVtmr95hD7o1pYqx5aL+B4YMA=="],
"expo-server": ["expo-server@57.0.3", "", {}, "sha512-aK+LdKzauHSGmsOStZtyxdzv0zWssCkxTw3m4QuOhfDSJsZaMRTd9O41d8ixU/QfELTbaJ0oRNcF7JFV/7O9YQ=="],
- "expo-splash-screen": ["expo-splash-screen@57.0.6", "", { "dependencies": { "@expo/config-plugins": "~57.0.7", "@expo/image-utils": "^0.11.4", "xml2js": "0.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-FY0E7hMyXVAsNh18yGzIjdooPXdjafalEb6mk2EcqNPjXspHuu+pVEhUscEunw4wF8F/w6MOpT7UfOwHVWez9g=="],
+ "expo-splash-screen": ["expo-splash-screen@57.0.7", "", { "dependencies": { "@expo/config-plugins": "~57.0.8", "@expo/image-utils": "^0.11.4", "xml2js": "0.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-QxqfjOCTnTGnqK2BxfEtxUJOcnsJbUXO873Q342r3VkgHAG877eozsNzgYWQXcCFj/asMwx2uy9OXxBShxc5rQ=="],
"expo-sqlite": ["expo-sqlite@57.0.1", "", { "dependencies": { "await-lock": "^2.2.2" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-I6KoUvfGIiROTKxr5D3H+jRIGA/iEvWEtqHK4XMukAA7tVTinz/YdS8zOz6/DdG6vgrNmvF7gyOcbhLinlfxzQ=="],
@@ -1221,8 +1212,6 @@
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
- "idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="],
-
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -1291,6 +1280,8 @@
"is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
+ "is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="],
+
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
@@ -1473,8 +1464,6 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
- "lz-string": ["lz-string@1.5.0", "", { "bin": "bin/bin.js" }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
-
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
"make-error": ["make-error@1.3.6", "", {}, "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="],
@@ -1491,6 +1480,8 @@
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
+ "merge-options": ["merge-options@3.0.4", "", { "dependencies": { "is-plain-obj": "^2.1.0" } }, "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ=="],
+
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"metro": ["metro@0.84.4", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "accepts": "^2.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.35.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-config": "0.84.4", "metro-core": "0.84.4", "metro-file-map": "0.84.4", "metro-resolver": "0.84.4", "metro-runtime": "0.84.4", "metro-source-map": "0.84.4", "metro-symbolicate": "0.84.4", "metro-transform-plugins": "0.84.4", "metro-transform-worker": "0.84.4", "mime-types": "^3.0.1", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": "src/cli.js" }, "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA=="],
@@ -1733,6 +1724,8 @@
"react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.3.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA=="],
+ "react-native-purchases": ["react-native-purchases@10.7.2", "", { "dependencies": { "@revenuecat/purchases-js-hybrid-mappings": "18.31.0", "@revenuecat/purchases-typescript-internal": "18.31.0" }, "peerDependencies": { "react": ">= 16.6.3", "react-native": ">= 0.73.0", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-pI2Vh1oUtjjggYCd8P6u4Rl8Hck/lNhx6exxeoZYCGEeu7VQgGxzRDYTJ5f+96g057G6FcZ0bfMyD4VBxxQsuA=="],
+
"react-native-reanimated": ["react-native-reanimated@4.5.1", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.83 - 0.86", "react-native-worklets": "0.10.x" } }, "sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA=="],
"react-native-safe-area-context": ["react-native-safe-area-context@5.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ=="],
@@ -2153,8 +2146,6 @@
"@expo/cli/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
- "@expo/config/@expo/config-plugins": ["@expo/config-plugins@57.0.7", "", { "dependencies": { "@expo/config-types": "^57.0.2", "@expo/json-file": "~11.0.1", "@expo/plist": "^0.8.1", "@expo/require-utils": "^57.0.4", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "semver": "^7.5.4", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-jvXMiNuH8W7fmU9yCk4/jVwDX2G/5rWUg5PZ22mriccEeQVS9HJjQiUHivMaK6MxEG5L9f0RPScxe/nQfnpQvg=="],
-
"@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"@expo/fingerprint/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
@@ -2163,6 +2154,8 @@
"@expo/metro-config/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
+ "@expo/metro-runtime/@expo/log-box": ["@expo/log-box@57.0.2", "", { "dependencies": { "@expo/dom-webview": "^57.0.1", "anser": "^1.4.9", "stacktrace-parser": "^0.1.10" }, "peerDependencies": { "@expo/dom-webview": "^57.0.1", "expo": "*", "react": "*", "react-native": "*" } }, "sha512-ZsFyfIR7YCbQAdVLzuTUmMHofZC7ZS9ywYCJNPlLc78x59cI8GwXFEIVbRjjC0uJERpNtXx/tsNNnkhexXlzMw=="],
+
"@expo/metro-runtime/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
"@expo/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
@@ -2201,10 +2194,6 @@
"@scure/bip39/@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="],
- "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
-
- "@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
-
"@types/jest/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="],
@@ -2249,7 +2238,7 @@
"expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
- "expo-splash-screen/@expo/config-plugins": ["@expo/config-plugins@57.0.7", "", { "dependencies": { "@expo/config-types": "^57.0.2", "@expo/json-file": "~11.0.1", "@expo/plist": "^0.8.1", "@expo/require-utils": "^57.0.4", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "semver": "^7.5.4", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-jvXMiNuH8W7fmU9yCk4/jVwDX2G/5rWUg5PZ22mriccEeQVS9HJjQiUHivMaK6MxEG5L9f0RPScxe/nQfnpQvg=="],
+ "expo-router/@expo/metro-runtime": ["@expo/metro-runtime@57.0.12", "", { "dependencies": { "@expo/log-box": "^57.0.3", "anser": "^1.4.9", "pretty-format": "^29.7.0", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom"] }, "sha512-WpgjfRFh88B5tKJrbsypvyxx0MKuXMo+ru9Gbjq4Qwj7OboJmrQ+g555YCgiQD3uk3J1ThzgEU06yZOV43suGA=="],
"fbjs/promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="],
@@ -2427,8 +2416,6 @@
"@react-native/metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.36.0", "", {}, "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w=="],
- "@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
-
"@types/jest/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
"@types/jest/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
@@ -2445,6 +2432,8 @@
"expect/jest-matcher-utils/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
+ "expo-router/@expo/metro-runtime/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
+
"expo/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
"expo/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
@@ -2547,6 +2536,10 @@
"expect/jest-matcher-utils/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
+ "expo-router/@expo/metro-runtime/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
+
+ "expo-router/@expo/metro-runtime/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
+
"expo/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="],
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
@@ -2581,6 +2574,8 @@
"expect/jest-matcher-utils/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="],
+ "expo-router/@expo/metro-runtime/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="],
+
"log-symbols/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
diff --git a/docs/MONETIZATION.md b/docs/MONETIZATION.md
new file mode 100644
index 0000000..91c4bd8
--- /dev/null
+++ b/docs/MONETIZATION.md
@@ -0,0 +1,44 @@
+# OpenDating monetization contract
+
+**Status:** development-only and disabled. Do not merge this integration into
+the next App Review candidate merely because the SDK compiles. The app still
+has no release-ready paid benefit, store products, webhook reconciliation, or
+native purchase evidence.
+
+OpenDating Plus may sell customization and convenience. It must never sell
+ranking, boosts, hidden admirers, safety, verification, recovery, deletion,
+matching, reactions, discovery, filters, or messaging.
+
+## RevenueCat catalog
+
+- Entitlement: `plus`
+- Offering: `default`
+- Products: `opendating_plus_monthly`, `opendating_plus_annual`
+- Packages: `$rc_monthly`, `$rc_annual`
+- Initial pricing hypothesis: $2.99 monthly or $19.99 annual in the US, with
+ store-localized pricing everywhere else
+
+Prices are never hardcoded in the app. The paywall renders the localized store
+price delivered by RevenueCat.
+
+## Privacy and release gates
+
+The SDK derives a stable SHA-256 billing identifier from a domain-separated
+value. A raw Nostr public key is never sent to RevenueCat or logged.
+
+The paywall is fail closed and requires both
+`EXPO_PUBLIC_REVENUECAT_ENABLED=true` and
+`EXPO_PUBLIC_OPENDATING_PLUS_FEATURES_READY=true`. Feature readiness stays
+false until at least one durable customization/convenience benefit exists and
+the product's verification, account recovery, moderation, abuse-reporting,
+deletion, and privacy acceptance gates have runtime evidence.
+
+Before enabling, create and validate both store products, map the entitlement
+and packages in RevenueCat, add signed webhook reconciliation to the backend,
+then exercise purchase, renewal, cancellation, revocation, restore, identity
+import, identity deletion, offline access, and app-reinstall paths in native
+development builds.
+
+The client fails closed unless RevenueCat reports `VERIFIED` or
+`VERIFIED_ON_DEVICE` for the entitlement. App Privacy and the public
+subprocessor list must be updated before either feature gate becomes true.
diff --git a/package.json b/package.json
index 1be1ca1..f05f82f 100644
--- a/package.json
+++ b/package.json
@@ -29,28 +29,28 @@
"main": "index.ts",
"version": "0.1.1",
"dependencies": {
- "@expo/ui": "~57.0.11",
+ "@expo/ui": "~57.0.12",
"@noble/curves": "1.9.7",
"@nostr-dev-kit/ndk": "^2.18.1",
- "@react-native-async-storage/async-storage": "^3.1.1",
+ "@react-native-async-storage/async-storage": "2.2.0",
"@scure/base": "^2.3.0",
- "expo": "~57.0.13",
+ "expo": "~57.0.15",
"expo-clipboard": "~57.0.1",
- "expo-constants": "~57.0.11",
+ "expo-constants": "~57.0.13",
"expo-crypto": "~57.0.1",
- "expo-dev-client": "~57.0.12",
+ "expo-dev-client": "~57.0.14",
"expo-device": "~57.0.1",
"expo-font": "~57.0.1",
"expo-glass-effect": "~57.0.1",
"expo-haptics": "^57.0.1",
"expo-image": "~57.0.3",
- "expo-image-picker": "^57.0.10",
+ "expo-image-picker": "~57.0.12",
"expo-linear-gradient": "~57.0.1",
- "expo-linking": "~57.0.6",
- "expo-location": "^57.0.10",
- "expo-router": "~57.0.13",
+ "expo-linking": "~57.0.7",
+ "expo-location": "~57.0.12",
+ "expo-router": "~57.0.15",
"expo-secure-store": "^57.0.1",
- "expo-splash-screen": "~57.0.6",
+ "expo-splash-screen": "~57.0.7",
"expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1",
"expo-symbols": "~57.0.2",
@@ -63,6 +63,7 @@
"react-native": "0.86.2",
"react-native-gesture-handler": "~2.32.0",
"react-native-get-random-values": "^1.11.0",
+ "react-native-purchases": "^10.6.0",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.2",
@@ -105,7 +106,7 @@
"build:ios:screenshot": "eas build --profile screenshot --platform ios",
"submit:ios": "bun run release:verify && eas submit --profile production --platform ios",
"store:ios:lookup-asc-app-id": "node scripts/release/lib/lookup-asc-app-id.mjs",
- "store:metadata:lint": "bun x eas-cli@21.7.0 metadata:lint --profile production",
+ "store:metadata:lint": "ASC_API_KEY_PATH=${ASC_API_KEY_PATH:-/dev/null} ASC_API_KEY_ISSUER_ID=${ASC_API_KEY_ISSUER_ID:-00000000-0000-0000-0000-000000000000} bun x eas-cli@21.7.0 metadata:lint --profile production",
"store:metadata:push": "bun run release:verify && bun x eas-cli@21.7.0 metadata:push --profile production",
"build:all": "bun run release:verify -- --source-only && eas build --profile production --platform all",
"screenshots:capture": "./scripts/capture-screenshots.sh",
@@ -114,7 +115,7 @@
"blog:generate": "node scripts/release/lib/generate-articles.mjs",
"blog:build": "node scripts/release/lib/build-blog.mjs",
"check:duplicates": "node scripts/check-duplicate-runtimes.mjs",
- "test:release-tools": "bun test scripts/release/lib/security-utils.test.mjs",
+ "test:release-tools": "bun test ./scripts/release/lib/security-utils.test.mjs",
"postinstall": "node scripts/check-duplicate-runtimes.mjs"
},
"private": true,
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index a9b5971..93284a9 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -6,6 +6,7 @@ import { StyleSheet, useColorScheme } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { ThemeProvider } from '@/state/theme-context';
+import { RevenueCatProvider } from '@/state/revenuecat-context';
import { ErrorBoundary } from '@/components/error-boundary';
import { StatusBar } from 'expo-status-bar';
@@ -23,14 +24,16 @@ export default function RootLayout() {
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/settings/_layout.tsx b/src/app/settings/_layout.tsx
index 2c52b5a..c89d822 100644
--- a/src/app/settings/_layout.tsx
+++ b/src/app/settings/_layout.tsx
@@ -27,6 +27,7 @@ export default function SettingsLayout() {
+
);
}
diff --git a/src/app/settings/index.tsx b/src/app/settings/index.tsx
index 19fbc36..8b91b01 100644
--- a/src/app/settings/index.tsx
+++ b/src/app/settings/index.tsx
@@ -2,13 +2,14 @@
import { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Alert, Pressable, StyleSheet, Switch, Text, View } from 'react-native';
-import { useRouter } from 'expo-router';
+import { useRouter, type Href } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar';
import Constants from 'expo-constants';
import { getOpenDatingClient } from '@/lib/opendating/open-dating-client';
import { useTheme } from '@/state/theme-context';
+import { useRevenueCat } from '@/state/revenuecat-context';
import { typography } from '@/theme/typography';
import { spacing } from '@/theme/spacing';
import { radius } from '@/theme/radius';
@@ -78,6 +79,7 @@ function SectionHeader({ children }: { children: string }) {
export default function SettingsScreen() {
const router = useRouter();
const { colors, isDark } = useTheme();
+ const revenueCat = useRevenueCat();
const [profileLoaded, setProfileLoaded] = useState(false);
const [paused, setPaused] = useState(false);
@@ -193,6 +195,21 @@ export default function SettingsScreen() {
/>
+ {revenueCat.enabled ? (
+ <>
+ Plus
+
+ router.push('/settings/plus' as Href)}
+ />
+
+ >
+ ) : null}
+
{/* Discovery */}
Discovery
diff --git a/src/app/settings/plus.tsx b/src/app/settings/plus.tsx
new file mode 100644
index 0000000..9e9f088
--- /dev/null
+++ b/src/app/settings/plus.tsx
@@ -0,0 +1,127 @@
+import { useState } from 'react';
+import { ScrollView, StyleSheet, Text, View } from 'react-native';
+import type { PurchasesPackage } from 'react-native-purchases';
+import { Redirect } from 'expo-router';
+
+import { AppButton } from '@/components/ui/app-button';
+import { useRevenueCat } from '@/state/revenuecat-context';
+import { useTheme } from '@/state/theme-context';
+import { radius } from '@/theme/radius';
+import { spacing } from '@/theme/spacing';
+import { typography } from '@/theme/typography';
+
+export default function PlusScreen() {
+ const { colors } = useTheme();
+ const revenueCat = useRevenueCat();
+ const [purchasing, setPurchasing] = useState(null);
+ const [restoring, setRestoring] = useState(false);
+ const [message, setMessage] = useState(null);
+
+ if (!revenueCat.enabled) {
+ return ;
+ }
+
+ const buy = async (pkg: PurchasesPackage) => {
+ setMessage(null);
+ setPurchasing(pkg.identifier);
+ const active = await revenueCat.purchase(pkg);
+ setMessage(active ? 'OpenDating Plus is active.' : null);
+ setPurchasing(null);
+ };
+
+ const restore = async () => {
+ setMessage(null);
+ setRestoring(true);
+ const active = await revenueCat.restore();
+ setMessage(active ? 'Your Plus purchase was restored.' : 'No active Plus purchase was found.');
+ setRestoring(false);
+ };
+
+ return (
+
+
+ OPENDATING PLUS
+
+ Pay for convenience, never visibility.
+
+
+ Core discovery, reactions, matching, messaging, verification, recovery, filters,
+ deletion, and every safety tool remain free. Plus adds customization and reusable
+ convenience controls only.
+
+
+
+ {revenueCat.isPlus ? (
+ Plus is active.
+ ) : null}
+ {revenueCat.packages.map((pkg) => (
+
+
+ {pkg.product.title}
+ {pkg.product.priceString}
+
+ void buy(pkg)}
+ >
+ Choose
+
+
+ ))}
+
+ {revenueCat.ready && revenueCat.packages.length === 0 && !revenueCat.isPlus ? (
+
+ Plus is not available in this build.
+
+ ) : null}
+ {revenueCat.error ? (
+
+ {revenueCat.error}
+
+ ) : null}
+ {message ? (
+
+ {message}
+
+ ) : null}
+ void restore()}
+ >
+ Restore purchases
+
+
+ Subscriptions renew through your store account until canceled there. Store-localized price
+ and terms appear before confirmation.
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1 },
+ content: { gap: spacing.md, padding: spacing.lg, paddingBottom: spacing.xxl },
+ hero: { borderRadius: radius.xl, gap: spacing.sm, padding: spacing.xl },
+ status: { ...typography.titleMedium },
+ plan: {
+ alignItems: 'center',
+ borderRadius: radius.lg,
+ borderWidth: StyleSheet.hairlineWidth,
+ flexDirection: 'row',
+ gap: spacing.md,
+ padding: spacing.md,
+ },
+ planCopy: { flex: 1, gap: spacing.xs },
+ note: { ...typography.bodyMedium },
+ footnote: { textAlign: 'center' },
+});
diff --git a/src/lib/__tests__/revenuecat-config.test.ts b/src/lib/__tests__/revenuecat-config.test.ts
new file mode 100644
index 0000000..41a51ce
--- /dev/null
+++ b/src/lib/__tests__/revenuecat-config.test.ts
@@ -0,0 +1,75 @@
+import {
+ deriveRevenueCatAppUserId,
+ getRevenueCatApiKey,
+ isRevenueCatEnabled,
+ isRevenueCatKeyValid,
+ isTrustedRevenueCatVerification,
+ OPENDATING_PRODUCT_IDS,
+} from '@/lib/revenuecat-config';
+import * as Crypto from 'expo-crypto';
+
+jest.mock('expo-crypto', () => ({
+ CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
+ digestStringAsync: jest.fn(),
+}));
+
+describe('RevenueCat release gates', () => {
+ const originalIosKey = process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY;
+ const originalAndroidKey = process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY;
+ const originalRevenueCatEnabled = process.env.EXPO_PUBLIC_REVENUECAT_ENABLED;
+ const originalFeaturesReady = process.env.EXPO_PUBLIC_OPENDATING_PLUS_FEATURES_READY;
+
+ afterEach(() => {
+ process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY = originalIosKey;
+ process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY = originalAndroidKey;
+ process.env.EXPO_PUBLIC_REVENUECAT_ENABLED = originalRevenueCatEnabled;
+ process.env.EXPO_PUBLIC_OPENDATING_PLUS_FEATURES_READY = originalFeaturesReady;
+ jest.clearAllMocks();
+ });
+
+ it('requires both release gates', () => {
+ process.env.EXPO_PUBLIC_REVENUECAT_ENABLED = 'true';
+ process.env.EXPO_PUBLIC_OPENDATING_PLUS_FEATURES_READY = 'false';
+ expect(isRevenueCatEnabled()).toBe(false);
+
+ process.env.EXPO_PUBLIC_OPENDATING_PLUS_FEATURES_READY = 'true';
+ expect(isRevenueCatEnabled()).toBe(true);
+ });
+
+ it('accepts only platform-matching public SDK keys', () => {
+ process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY = ' appl_public ';
+ process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY = ' goog_public ';
+
+ expect(getRevenueCatApiKey('ios')).toBe('appl_public');
+ expect(getRevenueCatApiKey('android')).toBe('goog_public');
+ expect(isRevenueCatKeyValid('appl_public', 'ios')).toBe(true);
+ expect(isRevenueCatKeyValid('goog_public', 'android')).toBe(true);
+ expect(isRevenueCatKeyValid('goog_public', 'ios')).toBe(false);
+ expect(isRevenueCatKeyValid('appl_public', 'android')).toBe(false);
+ expect(isRevenueCatKeyValid('appl_public', 'web')).toBe(false);
+ });
+
+ it('allows only the documented OpenDating products', () => {
+ expect([...OPENDATING_PRODUCT_IDS]).toEqual([
+ 'opendating_plus_monthly',
+ 'opendating_plus_annual',
+ ]);
+ });
+
+ it('grants Plus only for verified entitlements', () => {
+ expect(isTrustedRevenueCatVerification('VERIFIED')).toBe(true);
+ expect(isTrustedRevenueCatVerification('VERIFIED_ON_DEVICE')).toBe(true);
+ expect(isTrustedRevenueCatVerification('NOT_REQUESTED')).toBe(false);
+ expect(isTrustedRevenueCatVerification('FAILED')).toBe(false);
+ });
+
+ it('derives an opaque billing identifier', async () => {
+ jest.mocked(Crypto.digestStringAsync).mockResolvedValue('digest');
+
+ await expect(deriveRevenueCatAppUserId('raw-pubkey')).resolves.toBe('opendating:digest');
+ expect(Crypto.digestStringAsync).toHaveBeenCalledWith(
+ Crypto.CryptoDigestAlgorithm.SHA256,
+ 'opendating:billing:v1:raw-pubkey',
+ );
+ });
+});
diff --git a/src/lib/revenuecat-config.ts b/src/lib/revenuecat-config.ts
new file mode 100644
index 0000000..5eb95a6
--- /dev/null
+++ b/src/lib/revenuecat-config.ts
@@ -0,0 +1,47 @@
+import * as Crypto from 'expo-crypto';
+
+export const OPENDATING_ENTITLEMENT_ID = 'plus';
+export const OPENDATING_OFFERING_ID = 'default';
+export const OPENDATING_PRODUCT_IDS = new Set([
+ 'opendating_plus_monthly',
+ 'opendating_plus_annual',
+]);
+
+export function isRevenueCatEnabled(): boolean {
+ return (
+ process.env.EXPO_PUBLIC_REVENUECAT_ENABLED === 'true' &&
+ process.env.EXPO_PUBLIC_OPENDATING_PLUS_FEATURES_READY === 'true'
+ );
+}
+
+export function getRevenueCatApiKey(platform: string): string {
+ if (platform === 'ios') {
+ return process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY?.trim() ?? '';
+ }
+ if (platform === 'android') {
+ return process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY?.trim() ?? '';
+ }
+ return '';
+}
+
+export function isRevenueCatKeyValid(apiKey: string, platform: string): boolean {
+ if (platform === 'ios') return apiKey.startsWith('appl_');
+ if (platform === 'android') return apiKey.startsWith('goog_');
+ return false;
+}
+
+export function isTrustedRevenueCatVerification(verification: string): boolean {
+ return verification === 'VERIFIED' || verification === 'VERIFIED_ON_DEVICE';
+}
+
+/**
+ * RevenueCat receives a stable, domain-separated digest—not a user's Nostr
+ * public key. This keeps the billing identifier opaque across systems.
+ */
+export async function deriveRevenueCatAppUserId(pubkey: string): Promise {
+ const digest = await Crypto.digestStringAsync(
+ Crypto.CryptoDigestAlgorithm.SHA256,
+ `opendating:billing:v1:${pubkey}`,
+ );
+ return `opendating:${digest}`;
+}
diff --git a/src/state/revenuecat-context.tsx b/src/state/revenuecat-context.tsx
new file mode 100644
index 0000000..4f5021c
--- /dev/null
+++ b/src/state/revenuecat-context.tsx
@@ -0,0 +1,210 @@
+import Purchases, {
+ type CustomerInfo,
+ type PurchasesPackage,
+} from 'react-native-purchases';
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type PropsWithChildren,
+} from 'react';
+import { AppState, Platform } from 'react-native';
+import { usePathname } from 'expo-router';
+
+import { getOpenDatingClient } from '@/lib/opendating/open-dating-client';
+import {
+ deriveRevenueCatAppUserId,
+ getRevenueCatApiKey,
+ isRevenueCatEnabled,
+ isRevenueCatKeyValid,
+ isTrustedRevenueCatVerification,
+ OPENDATING_ENTITLEMENT_ID,
+ OPENDATING_OFFERING_ID,
+ OPENDATING_PRODUCT_IDS,
+} from '@/lib/revenuecat-config';
+
+interface RevenueCatValue {
+ enabled: boolean;
+ ready: boolean;
+ isPlus: boolean;
+ packages: PurchasesPackage[];
+ error: string | null;
+ purchase: (pkg: PurchasesPackage) => Promise;
+ restore: () => Promise;
+}
+
+const RevenueCatContext = createContext({
+ enabled: false,
+ ready: true,
+ isPlus: false,
+ packages: [],
+ error: null,
+ purchase: async () => false,
+ restore: async () => false,
+});
+
+let configuredApiKey: string | null = null;
+
+function hasPlus(customerInfo: CustomerInfo): boolean {
+ const entitlement = customerInfo.entitlements.active[OPENDATING_ENTITLEMENT_ID];
+ return Boolean(entitlement && isTrustedRevenueCatVerification(entitlement.verification));
+}
+
+function messageFor(error: unknown): string {
+ if (
+ typeof error === 'object' &&
+ error &&
+ 'code' in error &&
+ error.code === Purchases.PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR
+ ) {
+ return '';
+ }
+ return 'Purchases are unavailable right now. Please try again later.';
+}
+
+export function RevenueCatProvider({ children }: PropsWithChildren) {
+ const pathname = usePathname();
+ const apiKey = getRevenueCatApiKey(Platform.OS);
+ const enabled =
+ isRevenueCatEnabled() &&
+ Platform.OS !== 'web' &&
+ isRevenueCatKeyValid(apiKey, Platform.OS);
+ const [ready, setReady] = useState(!enabled);
+ const [isPlus, setIsPlus] = useState(false);
+ const [packages, setPackages] = useState([]);
+ const [error, setError] = useState(null);
+
+ const refresh = useCallback(async () => {
+ const [customerInfo, offerings] = await Promise.all([
+ Purchases.getCustomerInfo(),
+ Purchases.getOfferings(),
+ ]);
+ setIsPlus(hasPlus(customerInfo));
+ const offering = offerings.all[OPENDATING_OFFERING_ID] ?? offerings.current;
+ setPackages(
+ (offering?.availablePackages ?? []).filter((pkg) =>
+ OPENDATING_PRODUCT_IDS.has(pkg.product.identifier),
+ ),
+ );
+ }, []);
+
+ const syncOpaqueIdentity = useCallback(async () => {
+ const pubkey = await getOpenDatingClient().getPubkey();
+ const currentUserId = await Purchases.getAppUserID();
+ if (!pubkey) {
+ if (!currentUserId.startsWith('$RCAnonymousID')) {
+ const customerInfo = await Purchases.logOut();
+ setIsPlus(hasPlus(customerInfo));
+ }
+ return;
+ }
+
+ const billingId = await deriveRevenueCatAppUserId(pubkey);
+ if (currentUserId !== billingId) {
+ const result = await Purchases.logIn(billingId);
+ setIsPlus(hasPlus(result.customerInfo));
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!enabled) {
+ return;
+ }
+
+ let active = true;
+ const listener = (customerInfo: CustomerInfo) => {
+ if (active) setIsPlus(hasPlus(customerInfo));
+ };
+ const initialize = async () => {
+ try {
+ setError(null);
+ if (!configuredApiKey) {
+ Purchases.configure({
+ apiKey,
+ entitlementVerificationMode:
+ Purchases.ENTITLEMENT_VERIFICATION_MODE.INFORMATIONAL,
+ });
+ configuredApiKey = apiKey;
+ }
+ Purchases.addCustomerInfoUpdateListener(listener);
+ await syncOpaqueIdentity();
+ await refresh();
+ } catch (caught) {
+ if (active) setError(messageFor(caught) || null);
+ } finally {
+ if (active) setReady(true);
+ }
+ };
+
+ void initialize();
+ return () => {
+ active = false;
+ Purchases.removeCustomerInfoUpdateListener(listener);
+ };
+ }, [apiKey, enabled, refresh, syncOpaqueIdentity]);
+
+ useEffect(() => {
+ if (!enabled || !configuredApiKey) return;
+ queueMicrotask(() => {
+ void syncOpaqueIdentity().then(refresh).catch((caught) => setError(messageFor(caught)));
+ });
+ }, [enabled, pathname, refresh, syncOpaqueIdentity]);
+
+ useEffect(() => {
+ if (!enabled) return;
+ const subscription = AppState.addEventListener('change', (state) => {
+ if (state === 'active') {
+ void syncOpaqueIdentity().then(refresh).catch((caught) => setError(messageFor(caught)));
+ }
+ });
+ return () => subscription.remove();
+ }, [enabled, refresh, syncOpaqueIdentity]);
+
+ const purchase = useCallback(async (pkg: PurchasesPackage) => {
+ if (!enabled) return false;
+ try {
+ setError(null);
+ const result = await Purchases.purchasePackage(pkg);
+ const active = hasPlus(result.customerInfo);
+ setIsPlus(active);
+ return active;
+ } catch (caught) {
+ const message = messageFor(caught);
+ if (message) setError(message);
+ return false;
+ }
+ }, [enabled]);
+
+ const restore = useCallback(async () => {
+ if (!enabled) return false;
+ try {
+ setError(null);
+ const customerInfo = await Purchases.restorePurchases();
+ const active = hasPlus(customerInfo);
+ setIsPlus(active);
+ return active;
+ } catch (caught) {
+ setError(messageFor(caught));
+ return false;
+ }
+ }, [enabled]);
+
+ const value = useMemo(() => ({
+ enabled,
+ ready,
+ isPlus,
+ packages,
+ error,
+ purchase,
+ restore,
+ }), [enabled, error, isPlus, packages, purchase, ready, restore]);
+
+ return {children};
+}
+
+export function useRevenueCat(): RevenueCatValue {
+ return useContext(RevenueCatContext);
+}
From 2cedb184d2e527065ef158d03a8041b970cd7758 Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Thu, 20 Aug 2026 15:25:17 -0400
Subject: [PATCH 06/39] docs: correct release handoff state
---
.gitignore | 3 +++
docs/HANDOFF.md | 43 ++++++++++++++++++++++++++++--------------
docs/RELEASE-STATUS.md | 6 ++++++
release/manifest.json | 5 +++--
4 files changed, 41 insertions(+), 16 deletions(-)
diff --git a/.gitignore b/.gitignore
index 0832f4c..3c52517 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,3 +63,6 @@ coverage/
*.tmp
screenshots/*.png
screenshots/app-store/
+
+# Local brand source exports; app-ready assets live under assets/brand.
+/BrandAssetPack/animations/
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index d221e0e..36f3fa4 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -1,10 +1,19 @@
# OpenDating Mobile Handoff
-Last reviewed: August 9, 2026.
+Last reviewed: August 20, 2026.
## Handoff status
-The repository is ready for another engineer to clone, test, and continue. Release 0.1.1 is approved for iOS and Android launch in `release/manifest.json`. The broader technical, operational, legal, and marketplace gates in `ROADMAP-1.0.md` remain the path toward 1.0.
+The repository is ready for another engineer to clone, test, and continue.
+Release 0.1.1 is **not approved for launch**: `release/manifest.json` is blocked,
+Apple rejected the previous submission under Guideline 4.3(b), and the next
+candidate still needs external and physical-device evidence.
+
+`main` remains the production branch. `dev` contains the reconciled remote
+baseline plus disabled, development-only monetization work. Do not merge that
+work into the next App Review candidate until `docs/MONETIZATION.md` is
+satisfied; the safest next candidate is the private-introductions redesign
+without paid features.
The source of truth for current readiness is:
@@ -26,12 +35,12 @@ Historical documents with `COMPLETE` in their filenames describe the v0.1 featur
```bash
cp .env.example .env
-npm ci
-npm run typecheck
-npm run lint
-npm test
-npm run security:audit
-npm run release:validate
+bun install --frozen-lockfile
+bun run typecheck
+bun run lint
+bun run test
+bun run security:audit
+bun run release:validate
```
Expo SDK 57 APIs must be checked against the versioned Expo documentation. Protocol calls must be checked against the installed package declarations in `node_modules/opendating-protocol/dist/index.d.ts`.
@@ -52,15 +61,21 @@ Do not put credential values in issues, documentation, chat, or Git.
## Known dependency state
-The mobile lockfile currently contains two high `image-size` advisories without patched releases and one medium `uuid` advisory through Expo/Metro/Xcode build tooling. `security/audit-exceptions.json` binds exceptions to exact advisories, affected ranges, owners, and a September 30, 2026 expiry. They must be removed or explicitly renewed with evidence before expiry.
+`security/audit-exceptions.json` is the only allowed dependency exception
+source. The audit fails when Bun reports a new high/critical advisory, when the
+documented advisory set changes, or when the exception expires. Do not expand
+the exception merely to make CI green; first update the Expo-compatible
+dependency set and inspect the remaining paths.
## Immediate continuation order
-1. Authenticate npm, publish and verify `opendating-protocol@0.1.1`, then pin the exact artifact here.
-2. Record App Store withdrawal and Android hold evidence.
-3. Begin Phase 1 with the native signer boundary and SQLCipher repositories; these precede public acquisition.
-4. Implement deterministic sync/outbox/global blocks, then push and verification.
-5. Do not start public beta until the trust-and-safety, legal, vendor, deletion, security, and staffing gates are evidenced.
+1. Keep `dev` clean and run the Bun quality gates above before new work.
+2. Authenticate npm, publish and verify `opendating-protocol@0.1.1`, then pin the exact artifact here and remove the temporary request-routing mirror.
+3. Resolve the dependency audit without broad or permanent exceptions.
+4. Choose a release commit without unfinished RevenueCat behavior, then perform the complete iOS and Android physical-device walkthroughs.
+5. Capture fresh Passport/private-introduction screenshots and the App Review video; create a fresh 0.1.1 build with a new build number.
+6. Confirm App Privacy, Regulations and Permits, release notes, pricing/availability, and the 0.1.1 Store version before resubmitting with the prepared 4.3(b) response.
+7. Do not start public beta until the trust-and-safety, legal, vendor, deletion, security, and staffing gates are evidenced.
## Release handoff rule
diff --git a/docs/RELEASE-STATUS.md b/docs/RELEASE-STATUS.md
index 8fe7e95..885cd25 100644
--- a/docs/RELEASE-STATUS.md
+++ b/docs/RELEASE-STATUS.md
@@ -2,6 +2,11 @@
**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.
+Apple rejected the previous submission under Guideline 4.3(b). The valid
+0.1.1 build already in App Store Connect predates the private-introductions
+redesign and must not be resubmitted. The next attempt needs a fresh build and
+the evidence listed in `docs/GUIDELINE-4.3B-RESPONSE.md`.
+
## Verified in this release
- One-at-a-time private introductions and candidate-profile navigation are implemented.
@@ -23,6 +28,7 @@ These are post-launch engineering priorities, not launch gates:
3. Enforce global blocks and active-match authorization before any inbound DM reaches UI state.
4. Add push delivery, liveness/age verification, profile moderation, appeals, and staffed trust-and-safety operations.
5. Resolve or renew the time-limited SDK dependency exceptions in `security/audit-exceptions.json`.
+6. Finish or remove the development-only RevenueCat integration before selecting a release commit.
## Release mechanics
diff --git a/release/manifest.json b/release/manifest.json
index b42c511..1c979db 100644
--- a/release/manifest.json
+++ b/release/manifest.json
@@ -23,11 +23,12 @@
"approvedAt": null,
"approvedBy": null,
"blockers": [
- "iOS 0.1.0 build 4 is not yet withdrawn or rejected in App Store Connect with evidence attached",
+ "App Store submission 39b836d6-fdcd-4920-94fa-2a54b5b8a4ce remains rejected under Guideline 4.3(b); the next submission requires a fresh post-redesign 0.1.1 build, screenshots, physical-device recording, and reviewed response",
"Android 0.1.0 build 3 is not yet confirmed held and unavailable to production users",
"opendating-protocol 0.1.1 is not published or verified on the registry (jongan69/OpenDating#7)",
"The protocol dependency is exact-pinned to 0.1.0; it is not yet moved to the verified 0.1.1 artifact and the temporary request-routing mirror is still in place",
- "Deletion, discovery gestures, and candidate-profile navigation are not verified on physical iOS and Android devices from this release candidate",
+ "Deletion, private introductions, and candidate-profile navigation are not verified on physical iOS and Android devices from this release candidate",
+ "Development-only RevenueCat work must remain excluded from the App Review candidate or be completed with store catalog, privacy disclosure, webhook, and native purchase evidence",
"Protected CI run, signed tag, source SHA, EAS artifact IDs and checksums, store state, migrations, and rollback evidence are not recorded",
"Cloudflare deployment provenance and environment isolation are unproven (jongan69/OpenDating#8)"
]
From 9c73f78322c26a729be3db65f31cda4861755860 Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 09:47:07 -0400
Subject: [PATCH 07/39] Publish Expo web preview
---
package.json | 5 +-
src/app/+html.tsx | 45 +++
src/app/index.web.tsx | 645 ++++++++++++++++++++++++++++++++++++++++++
3 files changed, 694 insertions(+), 1 deletion(-)
create mode 100644 src/app/+html.tsx
create mode 100644 src/app/index.web.tsx
diff --git a/package.json b/package.json
index f05f82f..08e045c 100644
--- a/package.json
+++ b/package.json
@@ -89,12 +89,15 @@
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
+ "export:web": "expo export --platform web",
+ "serve:web": "expo serve",
"typecheck": "tsc --noEmit",
"lint": "expo lint",
"test": "jest --runInBand",
"test:watch": "jest --watch",
"clean": "expo start --clear",
- "deploy:web": "echo 'Deploy via GitHub Pages — push to main and the deploy-web workflow handles the rest.'",
+ "deploy:web": "bun run export:web && eas deploy",
+ "deploy:web:prod": "bun run export:web && eas deploy --prod",
"release:verify": "node scripts/release/assert-release-ready.mjs",
"release:validate": "node scripts/release/assert-release-ready.mjs --validate-only",
"security:audit": "bun scripts/check-production-audit.mjs",
diff --git a/src/app/+html.tsx b/src/app/+html.tsx
new file mode 100644
index 0000000..5d120bb
--- /dev/null
+++ b/src/app/+html.tsx
@@ -0,0 +1,45 @@
+import type { ReactNode } from 'react';
+import {
+ ScrollViewStyleReset,
+ useServerDocumentContext,
+} from 'expo-router/html';
+
+export default function RootHtml({ children }: { children: ReactNode }) {
+ const { bodyAttributes, bodyNodes, headNodes, htmlAttributes } =
+ useServerDocumentContext();
+
+ return (
+
+
+
+
+
+ OpenDating — Private by design
+
+
+
+
+
+
+
+ {headNodes}
+
+
+ {children}
+ {bodyNodes}
+
+
+ );
+}
diff --git a/src/app/index.web.tsx b/src/app/index.web.tsx
new file mode 100644
index 0000000..2c0d587
--- /dev/null
+++ b/src/app/index.web.tsx
@@ -0,0 +1,645 @@
+import { Link } from 'expo-router';
+import {
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ useWindowDimensions,
+ View,
+} from 'react-native';
+
+import { BrandMark } from '@/components/brand/brand-mark';
+import { useTheme } from '@/state/theme-context';
+
+const PRINCIPLES = [
+ {
+ number: '01',
+ title: 'Nearby, never exact',
+ body: 'Your location becomes a broad area on your device. Exact coordinates are never published.',
+ },
+ {
+ number: '02',
+ title: 'Private by default',
+ body: 'Likes stay private, direct messages are end-to-end encrypted, and blocks take effect immediately.',
+ },
+ {
+ number: '03',
+ title: 'Connection over pressure',
+ body: 'Safety tools stay free. Paid ranking and manipulative boosts are not part of the product.',
+ },
+] as const;
+
+export default function WebHome() {
+ const { colors, isDark } = useTheme();
+ const { width } = useWindowDimensions();
+ const compact = width < 760;
+
+ return (
+
+
+
+
+ OpenDating
+
+
+ {!compact ? (
+ <>
+
+
+ >
+ ) : null}
+
+
+
+
+
+
+
+
+ WEB PREVIEW
+
+
+
+ Dating that keeps your private life private.
+
+
+ OpenDating is a calmer kind of dating app—built around coarse location,
+ encrypted conversations, and an identity that belongs to you.
+
+
+
+
+
+
+
+
+ Native release in progress · Open source under MIT
+
+
+
+
+
+
+
+
+
+
+ PRIVACY RECEIPT
+ Shared by you
+
+
+ Protected
+
+
+
+
+
+
+
+
+
+
+
+
+ “Enough information to help people meet. Nothing extra to profile them.”
+
+
+
+
+
+
+
+ A BETTER DEFAULT
+
+ Designed for trust before growth.
+
+
+
+
+ {PRINCIPLES.map((principle) => (
+
+
+ {principle.number}
+
+
+ {principle.title}
+
+
+ {principle.body}
+
+
+ ))}
+
+
+
+
+
+
+ OpenDating
+
+
+ Privacy, portability, and genuine human connection.
+
+
+
+ );
+}
+
+function ReceiptRow({
+ label,
+ value,
+ colors,
+ last = false,
+}: {
+ label: string;
+ value: string;
+ colors: ReturnType['colors'];
+ last?: boolean;
+}) {
+ return (
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+function ExternalTextLink({ href, label }: { href: string; label: string }) {
+ const { colors } = useTheme();
+ return (
+
+
+ {label}
+
+
+ );
+}
+
+function ExternalButton({
+ href,
+ label,
+ primary = false,
+ compact = false,
+}: {
+ href: string;
+ label: string;
+ primary?: boolean;
+ compact?: boolean;
+}) {
+ const { colors } = useTheme();
+ return (
+
+
+
+ {label}
+
+ {!compact ? (
+ ↗
+ ) : null}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ page: {
+ flexGrow: 1,
+ },
+ nav: {
+ maxWidth: 1180,
+ alignSelf: 'stretch',
+ marginHorizontal: 'auto',
+ paddingHorizontal: 32,
+ paddingVertical: 24,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ navCompact: {
+ paddingHorizontal: 20,
+ paddingVertical: 18,
+ },
+ brand: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ },
+ brandName: {
+ fontSize: 19,
+ lineHeight: 24,
+ fontWeight: '700',
+ letterSpacing: -0.45,
+ },
+ navLinks: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ },
+ textLinkHitArea: {
+ minHeight: 44,
+ paddingHorizontal: 14,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ textLink: {
+ fontSize: 14,
+ lineHeight: 20,
+ fontWeight: '600',
+ },
+ hero: {
+ maxWidth: 1180,
+ alignSelf: 'stretch',
+ marginHorizontal: 'auto',
+ paddingHorizontal: 32,
+ paddingTop: 72,
+ paddingBottom: 108,
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ alignItems: 'center',
+ gap: 72,
+ },
+ heroCompact: {
+ paddingHorizontal: 20,
+ paddingTop: 48,
+ paddingBottom: 72,
+ flexDirection: 'column',
+ flexWrap: 'nowrap',
+ alignItems: 'stretch',
+ gap: 48,
+ },
+ heroCopy: {
+ flexGrow: 1,
+ flexShrink: 1,
+ flexBasis: 520,
+ },
+ heroCopyCompact: {
+ alignSelf: 'stretch',
+ minWidth: 0,
+ maxWidth: '100%',
+ flexGrow: 0,
+ flexShrink: 0,
+ flexBasis: 'auto',
+ },
+ eyebrow: {
+ alignSelf: 'flex-start',
+ minHeight: 30,
+ paddingHorizontal: 12,
+ borderRadius: 999,
+ borderWidth: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 7,
+ marginBottom: 26,
+ },
+ liveDot: {
+ width: 6,
+ height: 6,
+ borderRadius: 3,
+ },
+ eyebrowText: {
+ fontSize: 11,
+ lineHeight: 16,
+ fontWeight: '800',
+ letterSpacing: 1.35,
+ },
+ heroTitle: {
+ maxWidth: 680,
+ fontSize: 66,
+ lineHeight: 70,
+ fontWeight: '700',
+ letterSpacing: -3.5,
+ },
+ heroTitleCompact: {
+ fontSize: 43,
+ lineHeight: 47,
+ letterSpacing: -2,
+ },
+ heroBody: {
+ maxWidth: 610,
+ marginTop: 24,
+ fontSize: 20,
+ lineHeight: 31,
+ letterSpacing: -0.2,
+ },
+ heroBodyCompact: {
+ fontSize: 17,
+ lineHeight: 27,
+ },
+ actions: {
+ marginTop: 34,
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 12,
+ },
+ button: {
+ minHeight: 50,
+ paddingHorizontal: 19,
+ borderWidth: 1,
+ borderRadius: 999,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 10,
+ },
+ buttonCompact: {
+ minHeight: 42,
+ paddingHorizontal: 15,
+ },
+ buttonText: {
+ fontSize: 14,
+ lineHeight: 20,
+ fontWeight: '700',
+ },
+ arrow: {
+ fontSize: 16,
+ lineHeight: 20,
+ fontWeight: '700',
+ },
+ releaseNote: {
+ marginTop: 20,
+ fontSize: 12,
+ lineHeight: 18,
+ letterSpacing: 0.2,
+ },
+ preview: {
+ minWidth: 310,
+ maxWidth: 430,
+ flexGrow: 1,
+ flexShrink: 1,
+ flexBasis: 390,
+ padding: 26,
+ borderRadius: 30,
+ borderWidth: 1,
+ shadowOffset: { width: 0, height: 24 },
+ shadowOpacity: 0.16,
+ shadowRadius: 50,
+ overflow: 'hidden',
+ },
+ previewCompact: {
+ minWidth: 0,
+ alignSelf: 'stretch',
+ maxWidth: '100%',
+ flexGrow: 0,
+ flexShrink: 0,
+ flexBasis: 'auto',
+ padding: 20,
+ borderRadius: 24,
+ },
+ glow: {
+ position: 'absolute',
+ width: 240,
+ height: 240,
+ borderRadius: 120,
+ right: -100,
+ top: -120,
+ opacity: 0.8,
+ },
+ previewTop: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ miniMark: {
+ width: 46,
+ height: 46,
+ borderRadius: 14,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ previewHeading: {
+ flex: 1,
+ marginLeft: 12,
+ },
+ previewLabel: {
+ fontSize: 9,
+ lineHeight: 13,
+ fontWeight: '800',
+ letterSpacing: 1.15,
+ },
+ previewTitle: {
+ marginTop: 2,
+ fontSize: 18,
+ lineHeight: 23,
+ fontWeight: '700',
+ letterSpacing: -0.4,
+ },
+ statusPill: {
+ paddingHorizontal: 10,
+ paddingVertical: 6,
+ borderRadius: 999,
+ },
+ statusText: {
+ fontSize: 10,
+ lineHeight: 14,
+ fontWeight: '700',
+ },
+ receipt: {
+ marginTop: 24,
+ borderWidth: 1,
+ borderRadius: 18,
+ overflow: 'hidden',
+ },
+ receiptRow: {
+ minHeight: 58,
+ paddingHorizontal: 16,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: 12,
+ },
+ receiptLabel: {
+ fontSize: 13,
+ lineHeight: 18,
+ fontWeight: '500',
+ },
+ receiptValueWrap: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 7,
+ },
+ check: {
+ width: 7,
+ height: 7,
+ borderRadius: 4,
+ },
+ receiptValue: {
+ fontSize: 13,
+ lineHeight: 18,
+ fontWeight: '700',
+ },
+ promise: {
+ marginTop: 16,
+ paddingHorizontal: 18,
+ paddingVertical: 16,
+ borderRadius: 18,
+ },
+ promiseQuote: {
+ fontSize: 14,
+ lineHeight: 21,
+ fontWeight: '600',
+ letterSpacing: -0.1,
+ },
+ principlesSection: {
+ maxWidth: 1180,
+ alignSelf: 'stretch',
+ marginHorizontal: 'auto',
+ paddingHorizontal: 32,
+ paddingVertical: 88,
+ borderTopWidth: 1,
+ },
+ principlesSectionCompact: {
+ paddingHorizontal: 20,
+ paddingVertical: 64,
+ },
+ sectionHeading: {
+ maxWidth: 640,
+ marginBottom: 42,
+ },
+ sectionKicker: {
+ fontSize: 11,
+ lineHeight: 16,
+ fontWeight: '800',
+ letterSpacing: 1.4,
+ marginBottom: 12,
+ },
+ sectionTitle: {
+ fontSize: 36,
+ lineHeight: 43,
+ fontWeight: '700',
+ letterSpacing: -1.6,
+ },
+ sectionTitleCompact: {
+ fontSize: 30,
+ lineHeight: 37,
+ letterSpacing: -1.1,
+ },
+ principles: {
+ flexDirection: 'row',
+ gap: 18,
+ },
+ principlesCompact: {
+ flexDirection: 'column',
+ },
+ principle: {
+ flex: 1,
+ minHeight: 210,
+ padding: 24,
+ borderWidth: 1,
+ borderRadius: 20,
+ },
+ principleNumber: {
+ fontSize: 12,
+ lineHeight: 18,
+ fontWeight: '800',
+ letterSpacing: 0.8,
+ },
+ principleTitle: {
+ marginTop: 28,
+ fontSize: 19,
+ lineHeight: 25,
+ fontWeight: '700',
+ letterSpacing: -0.4,
+ },
+ principleBody: {
+ marginTop: 10,
+ fontSize: 14,
+ lineHeight: 22,
+ },
+ footer: {
+ maxWidth: 1180,
+ alignSelf: 'stretch',
+ marginHorizontal: 'auto',
+ paddingHorizontal: 32,
+ paddingVertical: 30,
+ borderTopWidth: 1,
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: 16,
+ },
+ footerCompact: {
+ paddingHorizontal: 20,
+ },
+ footerBrand: {
+ fontSize: 15,
+ lineHeight: 20,
+ fontWeight: '700',
+ },
+ footerCopy: {
+ fontSize: 12,
+ lineHeight: 18,
+ },
+});
From 679a4c32e0e140797516d7bcd28ce11b0880498b Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 11:07:29 -0400
Subject: [PATCH 08/39] Enable encrypted web accounts
---
jest.config.js | 2 +-
src/app/(onboarding)/create-account.tsx | 68 +-
src/app/(onboarding)/finish.tsx | 3 +-
src/app/(onboarding)/import-account.tsx | 99 +--
src/app/(onboarding)/photos.tsx | 12 +-
src/app/(tabs)/discover.tsx | 8 +-
src/app/+html.tsx | 13 +-
src/app/_layout.tsx | 34 +-
src/app/candidate/[pubkey].tsx | 5 +-
src/app/chat/[pubkey].tsx | 27 +-
src/app/index.tsx | 3 +
src/app/index.web.tsx | 645 ------------------
src/app/settings/_layout.tsx | 1 +
src/app/settings/account.tsx | 9 +-
src/app/settings/advanced.tsx | 151 +++-
src/app/settings/index.tsx | 17 +-
src/app/settings/report-problem.tsx | 124 ++++
src/app/unlock.tsx | 121 ++++
src/components/error-boundary.tsx | 61 +-
src/features/auth/use-bootstrap.ts | 8 +-
src/lib/__tests__/feedback.test.ts | 27 +
src/lib/feedback.ts | 46 ++
.../opendating/__tests__/recovery-key.test.ts | 15 +
src/lib/opendating/open-dating-client.ts | 85 ++-
src/lib/opendating/recovery-key.ts | 34 +
.../__tests__/identity-vault.web.test.ts | 65 ++
src/lib/storage/identity-vault.ts | 58 ++
src/lib/storage/identity-vault.types.ts | 49 ++
src/lib/storage/identity-vault.web.ts | 223 ++++++
src/lib/storage/index.ts | 23 +-
src/lib/storage/index.web.ts | 93 +++
src/types/opendating.ts | 1 +
32 files changed, 1360 insertions(+), 770 deletions(-)
delete mode 100644 src/app/index.web.tsx
create mode 100644 src/app/settings/report-problem.tsx
create mode 100644 src/app/unlock.tsx
create mode 100644 src/lib/__tests__/feedback.test.ts
create mode 100644 src/lib/feedback.ts
create mode 100644 src/lib/opendating/__tests__/recovery-key.test.ts
create mode 100644 src/lib/opendating/recovery-key.ts
create mode 100644 src/lib/storage/__tests__/identity-vault.web.test.ts
create mode 100644 src/lib/storage/identity-vault.ts
create mode 100644 src/lib/storage/identity-vault.types.ts
create mode 100644 src/lib/storage/identity-vault.web.ts
create mode 100644 src/lib/storage/index.web.ts
diff --git a/jest.config.js b/jest.config.js
index a2edc2a..e901c78 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -13,7 +13,7 @@ module.exports = {
transform: {
'^.+\\.[tj]sx?$': ['ts-jest', { tsconfig: '/tsconfig.jest.json' }],
},
- transformIgnorePatterns: ['/node_modules/(?!(opendating-protocol|@noble)/)'],
+ transformIgnorePatterns: ['/node_modules/(?!(opendating-protocol|@noble|@scure)/)'],
testPathIgnorePatterns: ['/node_modules/', '/.expo/'],
testMatch: ['**/__tests__/**/*.test.ts'],
};
diff --git a/src/app/(onboarding)/create-account.tsx b/src/app/(onboarding)/create-account.tsx
index 1181e84..c43b175 100644
--- a/src/app/(onboarding)/create-account.tsx
+++ b/src/app/(onboarding)/create-account.tsx
@@ -2,7 +2,7 @@
// The key is stored in the phone's secure storage and never leaves the device.
import React, { useState } from 'react';
-import { StyleSheet, Text, View } from 'react-native';
+import { Platform, StyleSheet, Text, TextInput, View } from 'react-native';
import { useRouter } from 'expo-router';
import {
ErrorBanner,
@@ -24,12 +24,22 @@ export default function CreateAccountScreen() {
const [creating, setCreating] = useState(false);
const [error, setError] = useState(null);
+ const [passphrase, setPassphrase] = useState('');
+ const [confirmation, setConfirmation] = useState('');
const handleCreate = async () => {
if (creating) return;
setCreating(true);
setError(null);
try {
+ if (Platform.OS === 'web') {
+ if (passphrase.length < 12) {
+ throw new Error('Use at least 12 characters for your browser-lock passphrase.');
+ }
+ if (passphrase !== confirmation) {
+ throw new Error('The browser-lock passphrases do not match.');
+ }
+ }
if (isScreenshotMode) {
// Skip real key generation — use the demo pubkey from the draft
update('pubkey', 'demo-pubkey-0000000000000000000000000000000000000000000000000000000000000000');
@@ -37,7 +47,9 @@ export default function CreateAccountScreen() {
return;
}
const client = getOpenDatingClient();
- const { pubkey } = await client.createIdentity();
+ const { pubkey } = await client.createIdentity({
+ vaultPassphrase: Platform.OS === 'web' ? passphrase : undefined,
+ });
update('pubkey', pubkey);
router.push('/(onboarding)/privacy');
} catch (err) {
@@ -64,9 +76,40 @@ export default function CreateAccountScreen() {
>
{error ? : null}
+ {Platform.OS === 'web' ? (
+
+ Protect this browser copy
+
+ Create a browser-lock passphrase with at least 12 characters. It encrypts your recovery key in this browser and is not an OpenDating account password.
+
+
+
+
+ ) : null}
+
- Your recovery key stays on this device
+ Your recovery key stays {Platform.OS === 'web' ? 'encrypted in this browser' : 'on this device'}
- We generate a recovery key right on your phone. It's stored in your
- device's secure storage — not on our servers — and it never leaves
- your phone.
+ {Platform.OS === 'web'
+ ? 'We generate a recovery key in this browser and encrypt it with your browser-lock passphrase before saving it. It is never sent to our servers.'
+ : "We generate a recovery key right on your phone. It's stored in your device's secure storage — not on our servers — and it never leaves your phone."}
@@ -128,6 +171,19 @@ function makeStyles(colors: ThemeColors) {
padding: spacing.lg,
marginBottom: spacing.lg,
},
+ passphraseCard: {
+ gap: spacing.md,
+ marginBottom: spacing.lg,
+ },
+ input: {
+ minHeight: 52,
+ borderWidth: 1,
+ borderColor: colors.border,
+ borderRadius: radius.md,
+ backgroundColor: colors.surface,
+ paddingHorizontal: spacing.lg,
+ fontSize: 17,
+ },
steps: {
gap: spacing.md,
marginTop: spacing.md,
diff --git a/src/app/(onboarding)/finish.tsx b/src/app/(onboarding)/finish.tsx
index ab104bf..4e494cb 100644
--- a/src/app/(onboarding)/finish.tsx
+++ b/src/app/(onboarding)/finish.tsx
@@ -3,7 +3,7 @@
// is the organizing experience, not a setting hidden behind the dating flow.
import React, { useEffect } from 'react';
-import { StyleSheet, Text, View } from 'react-native';
+import { Platform, StyleSheet, Text, View } from 'react-native';
import * as Haptics from 'expo-haptics';
import { useRouter } from 'expo-router';
import { OnboardingScreen } from '@/components/onboarding/onboarding-screen';
@@ -19,6 +19,7 @@ export default function FinishScreen() {
const styles = makeStyles(colors);
useEffect(() => {
+ if (Platform.OS === 'web') return;
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(
() => {
// Haptics are optional — ignore failures on unsupported devices.
diff --git a/src/app/(onboarding)/import-account.tsx b/src/app/(onboarding)/import-account.tsx
index 8f6df50..0873f16 100644
--- a/src/app/(onboarding)/import-account.tsx
+++ b/src/app/(onboarding)/import-account.tsx
@@ -4,8 +4,7 @@
// It never leaves the device.
import React, { useState } from 'react';
-import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
-import { bech32, hex } from '@scure/base';
+import { Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { useRouter } from 'expo-router';
import {
ErrorBanner,
@@ -16,49 +15,11 @@ import { useTheme } from '@/state/theme-context';
import type { ThemeColors } from '@/theme/colors';
import { useOnboardingDraft } from '@/features/onboarding/onboarding-draft';
import { getOpenDatingClient } from '@/lib/opendating/open-dating-client';
+import { normalizeRecoveryKey } from '@/lib/opendating/recovery-key';
import { typography } from '@/theme/typography';
import { spacing } from '@/theme/spacing';
import { radius } from '@/theme/radius';
-/**
- * Normalize a user-supplied private key to lowercase hex.
- * Accepts: 64 hex chars, or an nsec… bech32 key (checksum-validated).
- */
-function normalizePrivateKey(input: string): string {
- const trimmed = input.trim();
-
- if (/^[0-9a-fA-F]{64}$/.test(trimmed)) {
- return trimmed.toLowerCase();
- }
-
- if (trimmed.toLowerCase().startsWith('nsec1')) {
- let decoded;
- try {
- decoded = bech32.decode(trimmed.toLowerCase() as `${string}1${string}`);
- } catch {
- // Plain apostrophe: this is a JS string, not JSX, so an HTML entity
- // would reach the member verbatim.
- throw new Error(
- "That key doesn't look valid — double-check it was copied completely."
- );
- }
- if (decoded.prefix !== 'nsec') {
- throw new Error(
- 'That looks like a public account ID. Import needs your recovery key, the secret one.'
- );
- }
- const bytes = bech32.fromWords(decoded.words);
- if (bytes.length !== 32) {
- throw new Error('That key is the wrong length — check it was copied in full.');
- }
- return hex.encode(bytes);
- }
-
- throw new Error(
- 'That does not look like a recovery key. Paste the whole thing, including the prefix.'
- );
-}
-
export default function ImportAccountScreen() {
const router = useRouter();
const { colors } = useTheme();
@@ -68,6 +29,8 @@ export default function ImportAccountScreen() {
const [showKey, setShowKey] = useState(false);
const [importing, setImporting] = useState(false);
const [error, setError] = useState(null);
+ const [passphrase, setPassphrase] = useState('');
+ const [confirmation, setConfirmation] = useState('');
const styles = makeStyles(colors);
@@ -76,9 +39,19 @@ export default function ImportAccountScreen() {
setImporting(true);
setError(null);
try {
- const privkeyHex = normalizePrivateKey(keyText);
+ const privkeyHex = normalizeRecoveryKey(keyText);
+ if (Platform.OS === 'web') {
+ if (passphrase.length < 12) {
+ throw new Error('Use at least 12 characters for your browser-lock passphrase.');
+ }
+ if (passphrase !== confirmation) {
+ throw new Error('The browser-lock passphrases do not match.');
+ }
+ }
const client = getOpenDatingClient();
- const { pubkey } = await client.importIdentity(privkeyHex);
+ const { pubkey } = await client.importIdentity(privkeyHex, {
+ vaultPassphrase: Platform.OS === 'web' ? passphrase : undefined,
+ });
update('pubkey', pubkey);
router.push('/(onboarding)/privacy');
} catch (err) {
@@ -128,6 +101,37 @@ export default function ImportAccountScreen() {
+ {Platform.OS === 'web' ? (
+
+ Protect this browser copy
+
+ This passphrase encrypts the browser copy of your recovery key. It is not an OpenDating account password.
+
+
+
+
+ ) : null}
+
{/* Security warning */}
@@ -139,9 +143,9 @@ export default function ImportAccountScreen() {
{ color: colors.textSecondary, marginTop: spacing.sm },
]}
>
- Anyone with this key controls the account. It's stored only in your
- device's secure storage and never sent anywhere — but don't share it,
- screenshot it, or paste it where others can see.
+ {Platform.OS === 'web'
+ ? 'Anyone with this key controls the account. This browser encrypts its copy with your browser-lock passphrase and never sends the key to our servers. Do not share or screenshot it.'
+ : "Anyone with this key controls the account. It's stored only in your device's secure storage and never sent anywhere — but don't share it, screenshot it, or paste it where others can see."}
@@ -172,6 +176,9 @@ function makeStyles(colors: ThemeColors) {
marginTop: spacing.sm,
paddingHorizontal: spacing.xs,
},
+ browserCopy: {
+ marginBottom: spacing.sm,
+ },
warningBox: {
borderRadius: radius.md,
backgroundColor: colors.warningLight,
diff --git a/src/app/(onboarding)/photos.tsx b/src/app/(onboarding)/photos.tsx
index 0e9ca9d..97bc3b7 100644
--- a/src/app/(onboarding)/photos.tsx
+++ b/src/app/(onboarding)/photos.tsx
@@ -1,8 +1,8 @@
// Photos — pick 2–6 photos from the library, shown in a 3-column grid.
// Previews use expo-image; picker uses expo-image-picker.
-import React, { useState } from 'react';
-import { Pressable, StyleSheet, Text, View } from 'react-native';
+import React, { useEffect, useState } from 'react';
+import { Platform, Pressable, StyleSheet, Text, View } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router';
@@ -32,6 +32,14 @@ export default function PhotosScreen() {
const styles = makeStyles(colors);
+ useEffect(() => {
+ if (Platform.OS !== 'web' || !draft.photos.some((uri) => uri.startsWith('blob:'))) return;
+ const restorable = draft.photos.filter((uri) => !uri.startsWith('blob:'));
+ setPhotos(restorable);
+ update('photos', restorable);
+ setError('Photos selected before this reload need to be chosen again.');
+ }, [draft.photos, update]);
+
const pickPhotos = async () => {
if (picking) return;
const remaining = MAX_PHOTOS - photos.length;
diff --git a/src/app/(tabs)/discover.tsx b/src/app/(tabs)/discover.tsx
index 2cdf773..ec30047 100644
--- a/src/app/(tabs)/discover.tsx
+++ b/src/app/(tabs)/discover.tsx
@@ -5,7 +5,7 @@
// privacy guarantees as before; only the member-facing interaction changes.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
+import { Alert, Platform, 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';
@@ -93,7 +93,9 @@ export default function IntroductionsScreen() {
interestPendingRef.current = true;
setInterestPending(true);
try {
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
+ if (Platform.OS !== 'web') {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
+ }
const matched = await like(pubkey, grant);
if (matched) presentMatch(pubkey);
} finally {
@@ -106,7 +108,7 @@ export default function IntroductionsScreen() {
const handleSkip = useCallback(
(pubkey: string) => {
- Haptics.selectionAsync().catch(() => {});
+ if (Platform.OS !== 'web') Haptics.selectionAsync().catch(() => {});
pass(pubkey);
},
[pass]
diff --git a/src/app/+html.tsx b/src/app/+html.tsx
index 5d120bb..8c0ba96 100644
--- a/src/app/+html.tsx
+++ b/src/app/+html.tsx
@@ -13,6 +13,11 @@ export default function RootHtml({ children }: { children: ReactNode }) {
+
+
-
+
+
{headNodes}
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index 93284a9..03b2595 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -2,7 +2,7 @@
// Polyfill crypto.getRandomValues before anything else (required by @noble/curves for key generation)
import 'react-native-get-random-values';
import { Stack } from 'expo-router';
-import { StyleSheet, useColorScheme } from 'react-native';
+import { Platform, StyleSheet, useColorScheme } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { ThemeProvider } from '@/state/theme-context';
@@ -17,17 +17,33 @@ export default function RootLayout() {
return (
// GestureHandlerRootView must remain the outermost full-screen view for
// navigation and any gesture-driven controls used elsewhere in the app.
-
+
{/* Provides the insets that useSafeAreaInsets reads. Also absent, which
left the chat composer measuring a zero bottom inset and sitting
under the home indicator. */}
-
+
+
@@ -45,4 +61,16 @@ const styles = StyleSheet.create({
root: {
flex: 1,
},
+ app: {
+ flex: 1,
+ },
+ webApp: {
+ width: '100%',
+ maxWidth: 760,
+ alignSelf: 'center',
+ shadowColor: '#000000',
+ shadowOpacity: 0.08,
+ shadowRadius: 30,
+ shadowOffset: { width: 0, height: 4 },
+ },
});
diff --git a/src/app/candidate/[pubkey].tsx b/src/app/candidate/[pubkey].tsx
index c55d459..3079e3f 100644
--- a/src/app/candidate/[pubkey].tsx
+++ b/src/app/candidate/[pubkey].tsx
@@ -12,6 +12,7 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import {
Pressable,
+ Platform,
ScrollView,
StyleSheet,
Text,
@@ -74,7 +75,9 @@ export default function CandidateDetail() {
// discovery screen so a double tap cannot reuse the same grant.
if (decidedRef.current) return;
decidedRef.current = true;
- if (direction === 'like') {
+ if (Platform.OS === 'web') {
+ // Browser feedback stays visual; Expo haptics is native-only here.
+ } else if (direction === 'like') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
} else {
Haptics.selectionAsync().catch(() => {});
diff --git a/src/app/chat/[pubkey].tsx b/src/app/chat/[pubkey].tsx
index c28c99c..a8fd749 100644
--- a/src/app/chat/[pubkey].tsx
+++ b/src/app/chat/[pubkey].tsx
@@ -1,6 +1,6 @@
// Chat screen — NIP-17 encrypted messaging with safety controls.
-import { useCallback, useEffect } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
import {
ActionSheetIOS,
Alert,
@@ -22,6 +22,7 @@ import { useSafety } from '@/features/safety/use-safety';
import { useCachedCandidate } from '@/features/discovery/candidate-cache';
import { MessageList } from '@/components/chat/message-list';
import { ChatComposer } from '@/components/chat/chat-composer';
+import { SafetyMenu, type SafetyMenuHandle } from '@/components/safety/safety-menu';
import { shortPubkey } from '@/lib/format';
import { typography } from '@/theme/typography';
import { spacing } from '@/theme/spacing';
@@ -34,6 +35,7 @@ export default function ChatScreen() {
const insets = useSafeAreaInsets();
const { messages, sendMessage, error } = useMessaging(pubkey);
const { blockUser, unmatchUser } = useSafety();
+ const safetyRef = useRef(null);
const candidate = useCachedCandidate(pubkey);
const displayName =
@@ -60,6 +62,12 @@ export default function ChatScreen() {
}, [router, pubkey, displayName]);
const confirmUnmatch = useCallback(() => {
+ if (Platform.OS === 'web') {
+ if (globalThis.confirm("Unmatch? You'll both disappear from each other's matches.")) {
+ void unmatchUser(pubkey).then(goBack);
+ }
+ return;
+ }
Alert.alert('Unmatch?', "You'll both disappear from each other's matches.", [
{ text: 'Cancel', style: 'cancel' },
{
@@ -73,6 +81,12 @@ export default function ChatScreen() {
}, [pubkey, unmatchUser, goBack]);
const confirmBlock = useCallback(() => {
+ if (Platform.OS === 'web') {
+ if (globalThis.confirm("Block? They won't be able to message you or see you in discovery.")) {
+ void blockUser(pubkey).then(goBack);
+ }
+ return;
+ }
Alert.alert('Block?', "They won't be able to message you or see you in discovery.", [
{ text: 'Cancel', style: 'cancel' },
{
@@ -86,6 +100,10 @@ export default function ChatScreen() {
}, [pubkey, blockUser, goBack]);
const openSafetyMenu = useCallback(() => {
+ if (Platform.OS === 'web') {
+ safetyRef.current?.present();
+ return;
+ }
if (Platform.OS === 'ios') {
ActionSheetIOS.showActionSheetWithOptions(
{
@@ -203,6 +221,13 @@ export default function ChatScreen() {
+ void unmatchUser(pubkey).then(goBack)}
+ onBlock={() => void blockUser(pubkey).then(goBack)}
+ />
);
}
diff --git a/src/app/index.tsx b/src/app/index.tsx
index 3ed0a34..60a0157 100644
--- a/src/app/index.tsx
+++ b/src/app/index.tsx
@@ -34,6 +34,9 @@ export default function BootstrapScreen() {
case 'no_identity':
router.replace('/(onboarding)/welcome');
break;
+ case 'identity_locked':
+ router.replace('/unlock');
+ break;
case 'no_profile':
router.replace('/(onboarding)/basics');
break;
diff --git a/src/app/index.web.tsx b/src/app/index.web.tsx
deleted file mode 100644
index 2c0d587..0000000
--- a/src/app/index.web.tsx
+++ /dev/null
@@ -1,645 +0,0 @@
-import { Link } from 'expo-router';
-import {
- Pressable,
- ScrollView,
- StyleSheet,
- Text,
- useWindowDimensions,
- View,
-} from 'react-native';
-
-import { BrandMark } from '@/components/brand/brand-mark';
-import { useTheme } from '@/state/theme-context';
-
-const PRINCIPLES = [
- {
- number: '01',
- title: 'Nearby, never exact',
- body: 'Your location becomes a broad area on your device. Exact coordinates are never published.',
- },
- {
- number: '02',
- title: 'Private by default',
- body: 'Likes stay private, direct messages are end-to-end encrypted, and blocks take effect immediately.',
- },
- {
- number: '03',
- title: 'Connection over pressure',
- body: 'Safety tools stay free. Paid ranking and manipulative boosts are not part of the product.',
- },
-] as const;
-
-export default function WebHome() {
- const { colors, isDark } = useTheme();
- const { width } = useWindowDimensions();
- const compact = width < 760;
-
- return (
-
-
-
-
- OpenDating
-
-
- {!compact ? (
- <>
-
-
- >
- ) : null}
-
-
-
-
-
-
-
-
- WEB PREVIEW
-
-
-
- Dating that keeps your private life private.
-
-
- OpenDating is a calmer kind of dating app—built around coarse location,
- encrypted conversations, and an identity that belongs to you.
-
-
-
-
-
-
-
-
- Native release in progress · Open source under MIT
-
-
-
-
-
-
-
-
-
-
- PRIVACY RECEIPT
- Shared by you
-
-
- Protected
-
-
-
-
-
-
-
-
-
-
-
-
- “Enough information to help people meet. Nothing extra to profile them.”
-
-
-
-
-
-
-
- A BETTER DEFAULT
-
- Designed for trust before growth.
-
-
-
-
- {PRINCIPLES.map((principle) => (
-
-
- {principle.number}
-
-
- {principle.title}
-
-
- {principle.body}
-
-
- ))}
-
-
-
-
-
-
- OpenDating
-
-
- Privacy, portability, and genuine human connection.
-
-
-
- );
-}
-
-function ReceiptRow({
- label,
- value,
- colors,
- last = false,
-}: {
- label: string;
- value: string;
- colors: ReturnType['colors'];
- last?: boolean;
-}) {
- return (
-
- {label}
-
-
- {value}
-
-
- );
-}
-
-function ExternalTextLink({ href, label }: { href: string; label: string }) {
- const { colors } = useTheme();
- return (
-
-
- {label}
-
-
- );
-}
-
-function ExternalButton({
- href,
- label,
- primary = false,
- compact = false,
-}: {
- href: string;
- label: string;
- primary?: boolean;
- compact?: boolean;
-}) {
- const { colors } = useTheme();
- return (
-
-
-
- {label}
-
- {!compact ? (
- ↗
- ) : null}
-
-
- );
-}
-
-const styles = StyleSheet.create({
- page: {
- flexGrow: 1,
- },
- nav: {
- maxWidth: 1180,
- alignSelf: 'stretch',
- marginHorizontal: 'auto',
- paddingHorizontal: 32,
- paddingVertical: 24,
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- },
- navCompact: {
- paddingHorizontal: 20,
- paddingVertical: 18,
- },
- brand: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 10,
- },
- brandName: {
- fontSize: 19,
- lineHeight: 24,
- fontWeight: '700',
- letterSpacing: -0.45,
- },
- navLinks: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 10,
- },
- textLinkHitArea: {
- minHeight: 44,
- paddingHorizontal: 14,
- alignItems: 'center',
- justifyContent: 'center',
- },
- textLink: {
- fontSize: 14,
- lineHeight: 20,
- fontWeight: '600',
- },
- hero: {
- maxWidth: 1180,
- alignSelf: 'stretch',
- marginHorizontal: 'auto',
- paddingHorizontal: 32,
- paddingTop: 72,
- paddingBottom: 108,
- flexDirection: 'row',
- flexWrap: 'wrap',
- alignItems: 'center',
- gap: 72,
- },
- heroCompact: {
- paddingHorizontal: 20,
- paddingTop: 48,
- paddingBottom: 72,
- flexDirection: 'column',
- flexWrap: 'nowrap',
- alignItems: 'stretch',
- gap: 48,
- },
- heroCopy: {
- flexGrow: 1,
- flexShrink: 1,
- flexBasis: 520,
- },
- heroCopyCompact: {
- alignSelf: 'stretch',
- minWidth: 0,
- maxWidth: '100%',
- flexGrow: 0,
- flexShrink: 0,
- flexBasis: 'auto',
- },
- eyebrow: {
- alignSelf: 'flex-start',
- minHeight: 30,
- paddingHorizontal: 12,
- borderRadius: 999,
- borderWidth: 1,
- flexDirection: 'row',
- alignItems: 'center',
- gap: 7,
- marginBottom: 26,
- },
- liveDot: {
- width: 6,
- height: 6,
- borderRadius: 3,
- },
- eyebrowText: {
- fontSize: 11,
- lineHeight: 16,
- fontWeight: '800',
- letterSpacing: 1.35,
- },
- heroTitle: {
- maxWidth: 680,
- fontSize: 66,
- lineHeight: 70,
- fontWeight: '700',
- letterSpacing: -3.5,
- },
- heroTitleCompact: {
- fontSize: 43,
- lineHeight: 47,
- letterSpacing: -2,
- },
- heroBody: {
- maxWidth: 610,
- marginTop: 24,
- fontSize: 20,
- lineHeight: 31,
- letterSpacing: -0.2,
- },
- heroBodyCompact: {
- fontSize: 17,
- lineHeight: 27,
- },
- actions: {
- marginTop: 34,
- flexDirection: 'row',
- flexWrap: 'wrap',
- gap: 12,
- },
- button: {
- minHeight: 50,
- paddingHorizontal: 19,
- borderWidth: 1,
- borderRadius: 999,
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- gap: 10,
- },
- buttonCompact: {
- minHeight: 42,
- paddingHorizontal: 15,
- },
- buttonText: {
- fontSize: 14,
- lineHeight: 20,
- fontWeight: '700',
- },
- arrow: {
- fontSize: 16,
- lineHeight: 20,
- fontWeight: '700',
- },
- releaseNote: {
- marginTop: 20,
- fontSize: 12,
- lineHeight: 18,
- letterSpacing: 0.2,
- },
- preview: {
- minWidth: 310,
- maxWidth: 430,
- flexGrow: 1,
- flexShrink: 1,
- flexBasis: 390,
- padding: 26,
- borderRadius: 30,
- borderWidth: 1,
- shadowOffset: { width: 0, height: 24 },
- shadowOpacity: 0.16,
- shadowRadius: 50,
- overflow: 'hidden',
- },
- previewCompact: {
- minWidth: 0,
- alignSelf: 'stretch',
- maxWidth: '100%',
- flexGrow: 0,
- flexShrink: 0,
- flexBasis: 'auto',
- padding: 20,
- borderRadius: 24,
- },
- glow: {
- position: 'absolute',
- width: 240,
- height: 240,
- borderRadius: 120,
- right: -100,
- top: -120,
- opacity: 0.8,
- },
- previewTop: {
- flexDirection: 'row',
- alignItems: 'center',
- },
- miniMark: {
- width: 46,
- height: 46,
- borderRadius: 14,
- alignItems: 'center',
- justifyContent: 'center',
- },
- previewHeading: {
- flex: 1,
- marginLeft: 12,
- },
- previewLabel: {
- fontSize: 9,
- lineHeight: 13,
- fontWeight: '800',
- letterSpacing: 1.15,
- },
- previewTitle: {
- marginTop: 2,
- fontSize: 18,
- lineHeight: 23,
- fontWeight: '700',
- letterSpacing: -0.4,
- },
- statusPill: {
- paddingHorizontal: 10,
- paddingVertical: 6,
- borderRadius: 999,
- },
- statusText: {
- fontSize: 10,
- lineHeight: 14,
- fontWeight: '700',
- },
- receipt: {
- marginTop: 24,
- borderWidth: 1,
- borderRadius: 18,
- overflow: 'hidden',
- },
- receiptRow: {
- minHeight: 58,
- paddingHorizontal: 16,
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- gap: 12,
- },
- receiptLabel: {
- fontSize: 13,
- lineHeight: 18,
- fontWeight: '500',
- },
- receiptValueWrap: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 7,
- },
- check: {
- width: 7,
- height: 7,
- borderRadius: 4,
- },
- receiptValue: {
- fontSize: 13,
- lineHeight: 18,
- fontWeight: '700',
- },
- promise: {
- marginTop: 16,
- paddingHorizontal: 18,
- paddingVertical: 16,
- borderRadius: 18,
- },
- promiseQuote: {
- fontSize: 14,
- lineHeight: 21,
- fontWeight: '600',
- letterSpacing: -0.1,
- },
- principlesSection: {
- maxWidth: 1180,
- alignSelf: 'stretch',
- marginHorizontal: 'auto',
- paddingHorizontal: 32,
- paddingVertical: 88,
- borderTopWidth: 1,
- },
- principlesSectionCompact: {
- paddingHorizontal: 20,
- paddingVertical: 64,
- },
- sectionHeading: {
- maxWidth: 640,
- marginBottom: 42,
- },
- sectionKicker: {
- fontSize: 11,
- lineHeight: 16,
- fontWeight: '800',
- letterSpacing: 1.4,
- marginBottom: 12,
- },
- sectionTitle: {
- fontSize: 36,
- lineHeight: 43,
- fontWeight: '700',
- letterSpacing: -1.6,
- },
- sectionTitleCompact: {
- fontSize: 30,
- lineHeight: 37,
- letterSpacing: -1.1,
- },
- principles: {
- flexDirection: 'row',
- gap: 18,
- },
- principlesCompact: {
- flexDirection: 'column',
- },
- principle: {
- flex: 1,
- minHeight: 210,
- padding: 24,
- borderWidth: 1,
- borderRadius: 20,
- },
- principleNumber: {
- fontSize: 12,
- lineHeight: 18,
- fontWeight: '800',
- letterSpacing: 0.8,
- },
- principleTitle: {
- marginTop: 28,
- fontSize: 19,
- lineHeight: 25,
- fontWeight: '700',
- letterSpacing: -0.4,
- },
- principleBody: {
- marginTop: 10,
- fontSize: 14,
- lineHeight: 22,
- },
- footer: {
- maxWidth: 1180,
- alignSelf: 'stretch',
- marginHorizontal: 'auto',
- paddingHorizontal: 32,
- paddingVertical: 30,
- borderTopWidth: 1,
- flexDirection: 'row',
- flexWrap: 'wrap',
- alignItems: 'center',
- justifyContent: 'space-between',
- gap: 16,
- },
- footerCompact: {
- paddingHorizontal: 20,
- },
- footerBrand: {
- fontSize: 15,
- lineHeight: 20,
- fontWeight: '700',
- },
- footerCopy: {
- fontSize: 12,
- lineHeight: 18,
- },
-});
diff --git a/src/app/settings/_layout.tsx b/src/app/settings/_layout.tsx
index c89d822..7979d9b 100644
--- a/src/app/settings/_layout.tsx
+++ b/src/app/settings/_layout.tsx
@@ -27,6 +27,7 @@ export default function SettingsLayout() {
+
);
diff --git a/src/app/settings/account.tsx b/src/app/settings/account.tsx
index a318c3b..722c8d2 100644
--- a/src/app/settings/account.tsx
+++ b/src/app/settings/account.tsx
@@ -1,7 +1,7 @@
// Account — delete account with confirmation, local cleanup, and redirect.
import { useCallback, useState } from 'react';
-import { Alert, ScrollView, StyleSheet, Text, View } from 'react-native';
+import { Alert, Platform, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar';
@@ -43,6 +43,7 @@ export default function AccountScreen() {
// Clear all local state and reset the client singleton. Awaited so the
// relay subscription is torn down before we route away — otherwise it
// could still deliver into the freshly-cleared session.
+ await client.deleteIdentity();
await storage.clearAll();
await resetOpenDatingClient();
@@ -57,6 +58,12 @@ export default function AccountScreen() {
}, [deleting, router]);
const confirmDelete = useCallback(() => {
+ if (Platform.OS === 'web') {
+ if (globalThis.confirm(`Delete Account?\n\n${DELETE_CONSEQUENCES}`)) {
+ void deleteAccount();
+ }
+ return;
+ }
Alert.alert('Delete Account?', DELETE_CONSEQUENCES, [
{ text: 'Cancel', style: 'cancel' },
{
diff --git a/src/app/settings/advanced.tsx b/src/app/settings/advanced.tsx
index 6598637..c76dd57 100644
--- a/src/app/settings/advanced.tsx
+++ b/src/app/settings/advanced.tsx
@@ -2,12 +2,14 @@
// Deliberately out of the normal flow.
import { useCallback, useEffect, useState } from 'react';
-import { Alert, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
+import { Alert, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
+import { useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar';
import * as Clipboard from 'expo-clipboard';
import { getOpenDatingClient } from '@/lib/opendating/open-dating-client';
+import { encodeRecoveryKey } from '@/lib/opendating/recovery-key';
import { connectionStateLabel, shortPubkey } from '@/lib/format';
import type { ConnectionState, OpenDatingCapabilities } from '@/types/opendating';
import { useTheme } from '@/state/theme-context';
@@ -75,12 +77,17 @@ function Section({ title, children }: SectionProps) {
}
export default function AdvancedScreen() {
+ const router = useRouter();
const { colors, isDark } = useTheme();
const [pubkey, setPubkey] = useState(null);
const [capabilities, setCapabilities] = useState(null);
const [connectionState, setConnectionState] = useState('starting');
const [copiedKey, setCopiedKey] = useState(null);
+ const [exportOpen, setExportOpen] = useState(false);
+ const [exportPassphrase, setExportPassphrase] = useState('');
+ const [exportKey, setExportKey] = useState(null);
+ const [exportError, setExportError] = useState(null);
useEffect(() => {
const client = getOpenDatingClient();
@@ -123,7 +130,7 @@ export default function AdvancedScreen() {
Alert.alert('No identity found', 'There is no account on this device to export.');
return;
}
- await copyValue('recovery-key', identity.privkey);
+ await copyValue('recovery-key', encodeRecoveryKey(identity.privkey));
Alert.alert(
'Recovery key copied',
'Your recovery key is now on your clipboard. Store it somewhere safe, then clear your clipboard.'
@@ -137,6 +144,13 @@ export default function AdvancedScreen() {
}, [copyValue]);
const exportPrivateKey = useCallback(async () => {
+ if (Platform.OS === 'web') {
+ setExportPassphrase('');
+ setExportKey(null);
+ setExportError(null);
+ setExportOpen(true);
+ return;
+ }
Alert.alert(
'Export Recovery Key',
'Your recovery key unlocks full access to your account. Anyone who has it can impersonate you and take over your account. Only export it to a place you fully trust.',
@@ -147,6 +161,25 @@ export default function AdvancedScreen() {
);
}, [doExportPrivateKey]);
+ const revealWebRecoveryKey = useCallback(async () => {
+ setExportError(null);
+ try {
+ const client = getOpenDatingClient();
+ await client.unlockIdentity(exportPassphrase);
+ const identity = await client.loadIdentity();
+ if (!identity) throw new Error('No account is stored in this browser.');
+ setExportKey(encodeRecoveryKey(identity.privkey));
+ setExportPassphrase('');
+ } catch (error) {
+ setExportError(error instanceof Error ? error.message : 'Could not unlock this browser.');
+ }
+ }, [exportPassphrase]);
+
+ const lockBrowser = useCallback(async () => {
+ await getOpenDatingClient().lockIdentity();
+ router.replace('/unlock');
+ }, [router]);
+
const networkUrl = getOpenDatingClient().getRelayUrl();
const infoUrl = getOpenDatingClient().getInfoUrl();
const protocolVersion = getOpenDatingClient().getProtocolVersion();
@@ -275,6 +308,22 @@ export default function AdvancedScreen() {
+ {Platform.OS === 'web' ? (
+ <>
+ void lockBrowser()}
+ style={({ pressed }) => [
+ styles.row,
+ { backgroundColor: pressed ? colors.surfaceSheet : colors.surface },
+ ]}
+ accessibilityRole="button"
+ >
+ Lock this browser
+ Now
+
+
+ >
+ ) : null}
The recovery key is the only way to restore your account on another device.
@@ -283,6 +332,66 @@ export default function AdvancedScreen() {
+ setExportOpen(false)}>
+
+
+ Export recovery key
+
+ Anyone with this key controls your account. Re-enter your browser-lock passphrase, preview the key, then copy it only to a place you trust.
+
+ {exportKey ? (
+ <>
+
+ {exportKey}
+
+ void copyValue('recovery-key', exportKey)}
+ style={[styles.modalButton, { backgroundColor: colors.destructive }]}
+ accessibilityRole="button"
+ >
+
+ {copiedKey === 'recovery-key' ? 'Copied' : 'Copy recovery key'}
+
+
+ >
+ ) : (
+ <>
+ void revealWebRecoveryKey()}
+ style={[styles.modalInput, { color: colors.text, borderColor: colors.border }]}
+ />
+ {exportError ? {exportError} : null}
+ void revealWebRecoveryKey()}
+ style={[styles.modalButton, { backgroundColor: colors.destructive }]}
+ accessibilityRole="button"
+ >
+ Preview recovery key
+
+ >
+ )}
+ {
+ setExportOpen(false);
+ setExportKey(null);
+ setExportPassphrase('');
+ }}
+ style={styles.modalCancel}
+ accessibilityRole="button"
+ >
+ Close
+
+
+
+
);
}
@@ -330,4 +439,42 @@ const styles = StyleSheet.create({
mono: {
fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace' }),
},
+ modalBackdrop: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: spacing.xl,
+ backgroundColor: 'rgba(0,0,0,0.55)',
+ },
+ modalCard: {
+ width: '100%',
+ maxWidth: 520,
+ borderRadius: radius.xl,
+ borderWidth: StyleSheet.hairlineWidth,
+ padding: spacing.xl,
+ gap: spacing.md,
+ },
+ modalInput: {
+ minHeight: 52,
+ borderWidth: 1,
+ borderRadius: radius.md,
+ paddingHorizontal: spacing.lg,
+ fontSize: 17,
+ },
+ keyPreview: {
+ padding: spacing.md,
+ borderRadius: radius.md,
+ fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace' }),
+ },
+ modalButton: {
+ minHeight: 48,
+ borderRadius: radius.md,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingHorizontal: spacing.lg,
+ },
+ modalCancel: {
+ alignSelf: 'center',
+ padding: spacing.sm,
+ },
});
diff --git a/src/app/settings/index.tsx b/src/app/settings/index.tsx
index 8b91b01..3b6b302 100644
--- a/src/app/settings/index.tsx
+++ b/src/app/settings/index.tsx
@@ -1,7 +1,7 @@
// Settings — grouped menu with account, discovery, about, and danger sections.
import { useCallback, useEffect, useState } from 'react';
-import { ActivityIndicator, Alert, Pressable, StyleSheet, Switch, Text, View } from 'react-native';
+import { ActivityIndicator, Alert, Platform, Pressable, StyleSheet, Switch, Text, View } from 'react-native';
import { useRouter, type Href } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar';
@@ -147,6 +147,13 @@ export default function SettingsScreen() {
);
const changeVisibility = useCallback(() => {
+ if (Platform.OS === 'web') {
+ const next = visibility === 'hidden' ? 'visible' : 'hidden';
+ if (globalThis.confirm(`Make your profile ${next} in discovery?`)) {
+ void updateVisibility(next);
+ }
+ return;
+ }
Alert.alert(
'Visibility',
'Choose who can see your profile in discovery.',
@@ -162,7 +169,7 @@ export default function SettingsScreen() {
},
]
);
- }, [updateVisibility]);
+ }, [updateVisibility, visibility]);
const appVersion = Constants.expoConfig?.version ?? '1.0.0';
@@ -253,6 +260,12 @@ export default function SettingsScreen() {
supportingText="Effective August 9, 2026"
onPress={() => router.push('/settings/terms')}
/>
+
+ router.push('/settings/report-problem')}
+ />
{/* Danger */}
diff --git a/src/app/settings/report-problem.tsx b/src/app/settings/report-problem.tsx
new file mode 100644
index 0000000..9d2bb35
--- /dev/null
+++ b/src/app/settings/report-problem.tsx
@@ -0,0 +1,124 @@
+import { useMemo, useState } from 'react';
+import { Linking, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { StatusBar } from 'expo-status-bar';
+import Constants from 'expo-constants';
+import * as Clipboard from 'expo-clipboard';
+
+import { buildProblemReport, type ProblemReportInput, type SafeBuildContext } from '@/lib/feedback';
+import { useTheme } from '@/state/theme-context';
+import { radius } from '@/theme/radius';
+import { spacing } from '@/theme/spacing';
+import { typography } from '@/theme/typography';
+
+const EMAIL = 'jonny2298@live.com';
+const FREQUENCIES = ['Once', 'Sometimes', 'Every time'];
+
+export default function ReportProblemScreen() {
+ const { colors, isDark } = useTheme();
+ const [input, setInput] = useState({ trying: '', happened: '', expected: '', frequency: 'Once' });
+ const [preview, setPreview] = useState(null);
+ const [copied, setCopied] = useState(false);
+ const context = useMemo(() => ({
+ appVersion: Constants.expoConfig?.version ?? 'unknown',
+ build: String(Platform.OS === 'ios' ? Constants.expoConfig?.ios?.buildNumber ?? 'unknown' : Constants.expoConfig?.android?.versionCode ?? 'web'),
+ platform: Platform.OS,
+ osVersion: String(Platform.Version),
+ }), []);
+ const complete = input.trying.trim() && input.happened.trim() && input.expected.trim();
+
+ const update = (key: keyof ProblemReportInput, value: string) => {
+ setInput((current) => ({ ...current, [key]: value }));
+ setPreview(null);
+ };
+
+ const email = async () => {
+ const report = preview ?? buildProblemReport(input, context);
+ await Linking.openURL(`mailto:${EMAIL}?subject=${encodeURIComponent('OpenDating problem report')}&body=${encodeURIComponent(report)}`);
+ };
+
+ return (
+
+
+
+
+ Tell us what went wrong. OpenDating does not automatically attach screenshots, logs, routes, location, messages, account IDs, or recovery keys.
+
+ {([
+ ['trying', 'What were you trying to do?'],
+ ['happened', 'What happened?'],
+ ['expected', 'What did you expect?'],
+ ] as const).map(([key, label]) => (
+
+ {label}
+ update(key, value)}
+ multiline
+ textAlignVertical="top"
+ style={[styles.input, { color: colors.text, borderColor: colors.border, backgroundColor: colors.surface }]}
+ accessibilityLabel={label}
+ />
+
+ ))}
+
+ How often does it happen?
+
+ {FREQUENCIES.map((frequency) => (
+ update('frequency', frequency)}
+ accessibilityRole="radio"
+ accessibilityState={{ checked: input.frequency === frequency }}
+ style={[
+ styles.option,
+ { borderColor: input.frequency === frequency ? colors.accent : colors.border, backgroundColor: colors.surface },
+ ]}
+ >
+ {frequency}
+
+ ))}
+
+
+ {!preview ? (
+ setPreview(buildProblemReport(input, context))}
+ style={[styles.primary, { backgroundColor: colors.accent, opacity: complete ? 1 : 0.5 }]}
+ accessibilityRole="button"
+ >
+ Preview report
+
+ ) : (
+
+ Review before sharing
+ {preview}
+ void email()} style={[styles.primary, { backgroundColor: colors.accent }]} accessibilityRole="button">
+ Open email
+
+ void Clipboard.setStringAsync(preview).then(() => setCopied(true))}
+ style={[styles.secondary, { borderColor: colors.border }]}
+ accessibilityRole="button"
+ >
+ {copied ? 'Copied' : 'Copy instead'}
+
+
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1 },
+ content: { padding: spacing.lg, paddingBottom: spacing.xxxl, gap: spacing.lg },
+ field: { gap: spacing.sm },
+ input: { minHeight: 92, borderWidth: 1, borderRadius: radius.md, padding: spacing.md, fontSize: 16 },
+ options: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm },
+ option: { minHeight: 44, justifyContent: 'center', borderWidth: 1, borderRadius: radius.full, paddingHorizontal: spacing.lg },
+ primary: { minHeight: 50, borderRadius: radius.lg, alignItems: 'center', justifyContent: 'center', paddingHorizontal: spacing.lg },
+ secondary: { minHeight: 50, borderRadius: radius.lg, borderWidth: 1, alignItems: 'center', justifyContent: 'center' },
+ previewGroup: { gap: spacing.md },
+ preview: { padding: spacing.md, borderRadius: radius.md, lineHeight: 18 },
+});
diff --git a/src/app/unlock.tsx b/src/app/unlock.tsx
new file mode 100644
index 0000000..f66d423
--- /dev/null
+++ b/src/app/unlock.tsx
@@ -0,0 +1,121 @@
+import { useCallback, useState } from 'react';
+import { Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
+import { useRouter } from 'expo-router';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { StatusBar } from 'expo-status-bar';
+
+import { BrandMark } from '@/components/brand/brand-mark';
+import { getOpenDatingClient, resetOpenDatingClient } from '@/lib/opendating/open-dating-client';
+import { storage } from '@/lib/storage';
+import { useTheme } from '@/state/theme-context';
+import { radius } from '@/theme/radius';
+import { spacing } from '@/theme/spacing';
+import { typography } from '@/theme/typography';
+
+export default function UnlockScreen() {
+ const router = useRouter();
+ const { colors, isDark } = useTheme();
+ const [passphrase, setPassphrase] = useState('');
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ const unlock = useCallback(async () => {
+ if (!passphrase || busy) return;
+ setBusy(true);
+ setError(null);
+ try {
+ await getOpenDatingClient().unlockIdentity(passphrase);
+ setPassphrase('');
+ router.replace('/');
+ } catch (caught) {
+ setError(caught instanceof Error ? caught.message : 'Could not unlock this browser.');
+ } finally {
+ setBusy(false);
+ }
+ }, [busy, passphrase, router]);
+
+ const handleDifferentKey = useCallback(async () => {
+ const confirmed =
+ Platform.OS !== 'web' ||
+ globalThis.confirm(
+ 'Remove the account copy stored in this browser? This does not delete server data. Make sure you have your recovery key.'
+ );
+ if (!confirmed) return;
+ await getOpenDatingClient().deleteIdentity();
+ await storage.clearAll();
+ await resetOpenDatingClient();
+ router.replace('/(onboarding)/welcome');
+ }, [router]);
+
+ return (
+
+
+
+
+
+ Welcome back
+
+ Enter the browser-lock passphrase you created here. It encrypts this browser copy; it is not your OpenDating account password.
+
+
+
+ void unlock()}
+ style={[styles.input, { color: colors.text, borderColor: colors.border, backgroundColor: colors.surface }]}
+ accessibilityLabel="Browser-lock passphrase"
+ />
+ {error ? {error} : null}
+ void unlock()}
+ disabled={!passphrase || busy}
+ accessibilityRole="button"
+ style={({ pressed }) => [
+ styles.button,
+ { backgroundColor: colors.accent, opacity: !passphrase || busy ? 0.5 : pressed ? 0.85 : 1 },
+ ]}
+ >
+ {busy ? 'Unlocking…' : 'Unlock'}
+
+
+ void handleDifferentKey()} accessibilityRole="button">
+ Use a different recovery key
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1 },
+ content: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingHorizontal: spacing.xxl,
+ gap: spacing.xxl,
+ },
+ copy: { alignItems: 'center', gap: spacing.sm, maxWidth: 520 },
+ centered: { textAlign: 'center', lineHeight: 22 },
+ form: { width: '100%', maxWidth: 440, gap: spacing.md },
+ input: {
+ minHeight: 52,
+ borderWidth: 1,
+ borderRadius: radius.lg,
+ paddingHorizontal: spacing.lg,
+ fontSize: 17,
+ },
+ button: {
+ minHeight: 52,
+ borderRadius: radius.lg,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+});
diff --git a/src/components/error-boundary.tsx b/src/components/error-boundary.tsx
index 2e89510..acccc1d 100644
--- a/src/components/error-boundary.tsx
+++ b/src/components/error-boundary.tsx
@@ -4,9 +4,13 @@
// Wraps the root layout so any uncaught render error anywhere in the tree
// is contained here.
-import React from 'react';
-import { Pressable, StyleSheet, Text, View, useColorScheme } from 'react-native';
+import React, { useMemo, useState } from 'react';
+import { Platform, Pressable, StyleSheet, Text, View, useColorScheme } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
+import Constants from 'expo-constants';
+import * as Clipboard from 'expo-clipboard';
+import { randomUUID } from 'expo-crypto';
+import { buildCrashDiagnostic, type SafeBuildContext } from '@/lib/feedback';
import { typography } from '@/theme/typography';
import { spacing } from '@/theme/spacing';
import { radius } from '@/theme/radius';
@@ -62,6 +66,14 @@ function ErrorScreen({
const colorScheme = useColorScheme();
const isDark = colorScheme === 'dark';
const c = isDark ? colors.dark : colors.light;
+ const [diagnostic, setDiagnostic] = useState(null);
+ const [copied, setCopied] = useState(false);
+ const context = useMemo(() => ({
+ appVersion: Constants.expoConfig?.version ?? 'unknown',
+ build: String(Platform.OS === 'ios' ? Constants.expoConfig?.ios?.buildNumber ?? 'unknown' : Constants.expoConfig?.android?.versionCode ?? 'web'),
+ platform: Platform.OS,
+ osVersion: String(Platform.Version),
+ }), []);
return (
@@ -90,6 +102,29 @@ function ErrorScreen({
>
Try Again
+ {!diagnostic ? (
+ setDiagnostic(buildCrashDiagnostic(context, randomUUID()))}
+ style={styles.linkButton}
+ >
+ Preview diagnostic report
+
+ ) : (
+
+
+ This contains only version, build, platform, timestamp, and a generic error ID. It does not include the error, logs, routes, location, messages, files, or account data.
+
+ {diagnostic}
+ void Clipboard.setStringAsync(diagnostic).then(() => setCopied(true))}
+ style={[styles.diagnosticButton, { borderColor: c.border }]}
+ >
+ {copied ? 'Copied' : 'Copy diagnostic report'}
+
+
+ )}
);
@@ -137,4 +172,26 @@ const styles = StyleSheet.create({
...typography.button,
color: '#FFFFFF',
},
+ linkButton: {
+ padding: spacing.sm,
+ },
+ diagnosticGroup: {
+ width: '100%',
+ maxWidth: 520,
+ gap: spacing.sm,
+ },
+ diagnosticCopy: {
+ textAlign: 'center',
+ },
+ diagnostic: {
+ padding: spacing.md,
+ borderRadius: radius.md,
+ },
+ diagnosticButton: {
+ minHeight: 44,
+ borderWidth: 1,
+ borderRadius: radius.md,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
});
diff --git a/src/features/auth/use-bootstrap.ts b/src/features/auth/use-bootstrap.ts
index 140db2e..02a3d76 100644
--- a/src/features/auth/use-bootstrap.ts
+++ b/src/features/auth/use-bootstrap.ts
@@ -41,12 +41,16 @@ export function useBootstrap(): BootstrapResult {
// Step 1: Check identity
setAppState('loading');
- const hasId = await client.hasIdentity();
+ const identityState = await client.getIdentityState();
- if (!hasId) {
+ if (identityState === 'missing') {
setAppState('no_identity');
return;
}
+ if (identityState === 'locked') {
+ setAppState('identity_locked');
+ return;
+ }
// Step 2: Load identity
const identity = await client.loadIdentity();
diff --git a/src/lib/__tests__/feedback.test.ts b/src/lib/__tests__/feedback.test.ts
new file mode 100644
index 0000000..c1ff35f
--- /dev/null
+++ b/src/lib/__tests__/feedback.test.ts
@@ -0,0 +1,27 @@
+import { buildCrashDiagnostic, buildProblemReport, type SafeBuildContext } from '../feedback';
+
+const context: SafeBuildContext = {
+ appVersion: '0.1.1',
+ build: '42',
+ platform: 'web',
+ osVersion: 'web',
+};
+
+describe('privacy-safe reports', () => {
+ it('serializes only entered answers and safe build context', () => {
+ const report = buildProblemReport(
+ { trying: 'open matches', happened: 'blank view', expected: 'matches', frequency: 'Once' },
+ { ...context, route: '/secret', location: 'raw', key: 'private' } as SafeBuildContext
+ );
+ expect(report).toContain('open matches');
+ expect(report).not.toContain('/secret');
+ expect(report).not.toContain('private');
+ expect(report).not.toContain('raw');
+ });
+
+ it('keeps crash diagnostics generic', () => {
+ const report = buildCrashDiagnostic(context, 'generic-id', '2026-08-29T00:00:00.000Z');
+ expect(report).toContain('generic-id');
+ expect(report).not.toMatch(/message|stack|route|location|recovery key/i);
+ });
+});
diff --git a/src/lib/feedback.ts b/src/lib/feedback.ts
new file mode 100644
index 0000000..6d681de
--- /dev/null
+++ b/src/lib/feedback.ts
@@ -0,0 +1,46 @@
+export interface SafeBuildContext {
+ appVersion: string;
+ build: string;
+ platform: string;
+ osVersion: string;
+}
+
+export interface ProblemReportInput {
+ trying: string;
+ happened: string;
+ expected: string;
+ frequency: string;
+}
+
+export function buildProblemReport(input: ProblemReportInput, context: SafeBuildContext): string {
+ return [
+ 'OpenDating problem report',
+ '',
+ `What I was trying to do: ${input.trying.trim()}`,
+ `What happened: ${input.happened.trim()}`,
+ `What I expected: ${input.expected.trim()}`,
+ `How often: ${input.frequency}`,
+ '',
+ 'Safe app context',
+ `Version: ${context.appVersion}`,
+ `Build: ${context.build}`,
+ `Platform: ${context.platform}`,
+ `OS: ${context.osVersion}`,
+ ].join('\n');
+}
+
+export function buildCrashDiagnostic(
+ context: SafeBuildContext,
+ genericErrorId: string,
+ timestamp = new Date().toISOString()
+): string {
+ return [
+ 'OpenDating crash diagnostic',
+ `Error ID: ${genericErrorId}`,
+ `Timestamp: ${timestamp}`,
+ `Version: ${context.appVersion}`,
+ `Build: ${context.build}`,
+ `Platform: ${context.platform}`,
+ `OS: ${context.osVersion}`,
+ ].join('\n');
+}
diff --git a/src/lib/opendating/__tests__/recovery-key.test.ts b/src/lib/opendating/__tests__/recovery-key.test.ts
new file mode 100644
index 0000000..ea7f375
--- /dev/null
+++ b/src/lib/opendating/__tests__/recovery-key.test.ts
@@ -0,0 +1,15 @@
+import { derivePublicKey, generateKeypair } from 'opendating-protocol';
+
+import { encodeRecoveryKey, normalizeRecoveryKey } from '../recovery-key';
+
+describe('recovery keys', () => {
+ it('round-trips standard and hex keys without changing the identity', () => {
+ const pair = generateKeypair();
+ const encoded = encodeRecoveryKey(pair.privateKey);
+
+ expect(encoded).toMatch(/^nsec1/);
+ expect(normalizeRecoveryKey(encoded)).toBe(pair.privateKey);
+ expect(derivePublicKey(normalizeRecoveryKey(encoded))).toBe(pair.publicKey);
+ expect(() => normalizeRecoveryKey('nsec1broken')).toThrow(/valid/);
+ });
+});
diff --git a/src/lib/opendating/open-dating-client.ts b/src/lib/opendating/open-dating-client.ts
index 6d3a712..edf031a 100644
--- a/src/lib/opendating/open-dating-client.ts
+++ b/src/lib/opendating/open-dating-client.ts
@@ -25,7 +25,6 @@ import {
validateEnvelope,
type OpenDatingEnvelope,
} from 'opendating-protocol';
-import * as SecureStore from 'expo-secure-store';
import { randomUUID } from 'expo-crypto';
import {
type OpenDatingCapabilities,
@@ -49,6 +48,8 @@ import { mapServiceError, ServiceUnavailableError } from './errors';
import { parseCapabilities, serviceLabel } from './capabilities';
import { unwrapGiftWrap } from './gift-wrap';
import { uploadPendingPhotos } from './media';
+import { identityVault, type IdentityState } from '@/lib/storage/identity-vault';
+import { storage } from '@/lib/storage';
import {
getRequestRoute,
type ClientRequestType,
@@ -65,10 +66,6 @@ const INFO_URL =
const PROTOCOL_VERSION =
process.env.EXPO_PUBLIC_OPENDATING_PROTOCOL_VERSION ?? '0.1';
-const SECURE_STORE_PRIVKEY_KEY = 'opendating_privkey';
-const SECURE_STORE_PUBKEY_KEY = 'opendating_pubkey';
-const SERVICES_CACHE_KEY = 'opendating_services_cache';
-
const CONNECT_TIMEOUT_MS = 15_000;
/** How long to wait for NIP-42 before proceeding without it. */
const AUTH_TIMEOUT_MS = 10_000;
@@ -104,6 +101,10 @@ interface OpenDatingClientConfig {
infoUrl?: string;
}
+interface IdentityPersistenceOptions {
+ vaultPassphrase?: string;
+}
+
// ---- Client Implementation ----
class OpenDatingClientImpl {
@@ -144,47 +145,75 @@ class OpenDatingClientImpl {
// ---- Identity ----
async hasIdentity(): Promise {
- const pubkey = await SecureStore.getItemAsync(SECURE_STORE_PUBKEY_KEY);
- const privkey = await SecureStore.getItemAsync(SECURE_STORE_PRIVKEY_KEY);
- return !!(pubkey && privkey);
+ return (await identityVault.getState()) === 'ready';
+ }
+
+ async getIdentityState(): Promise {
+ return identityVault.getState();
}
async getPubkey(): Promise {
if (this.userPubkey) return this.userPubkey;
- return SecureStore.getItemAsync(SECURE_STORE_PUBKEY_KEY);
+ try {
+ return (await identityVault.load())?.pubkey ?? null;
+ } catch {
+ return null;
+ }
}
- async createIdentity(): Promise<{ pubkey: string }> {
+ async createIdentity(
+ options: IdentityPersistenceOptions = {}
+ ): Promise<{ pubkey: string }> {
const kp = generateKeypair();
- await SecureStore.setItemAsync(SECURE_STORE_PRIVKEY_KEY, kp.privateKey);
- await SecureStore.setItemAsync(SECURE_STORE_PUBKEY_KEY, kp.publicKey);
+ await identityVault.save(
+ { privkey: kp.privateKey, pubkey: kp.publicKey },
+ options.vaultPassphrase
+ );
this.userPubkey = kp.publicKey;
this.userPrivkey = kp.privateKey;
return { pubkey: kp.publicKey };
}
- async importIdentity(privkeyHex: string): Promise<{ pubkey: string }> {
+ async importIdentity(
+ privkeyHex: string,
+ options: IdentityPersistenceOptions = {}
+ ): Promise<{ pubkey: string }> {
const { derivePublicKey } = await import('opendating-protocol');
const pubkey = derivePublicKey(privkeyHex);
- await SecureStore.setItemAsync(SECURE_STORE_PRIVKEY_KEY, privkeyHex);
- await SecureStore.setItemAsync(SECURE_STORE_PUBKEY_KEY, pubkey);
+ await identityVault.save(
+ { privkey: privkeyHex, pubkey },
+ options.vaultPassphrase
+ );
this.userPubkey = pubkey;
this.userPrivkey = privkeyHex;
return { pubkey };
}
+ async unlockIdentity(vaultPassphrase: string): Promise<{ pubkey: string }> {
+ const identity = await identityVault.unlock(vaultPassphrase);
+ this.userPubkey = identity.pubkey;
+ this.userPrivkey = identity.privkey;
+ return { pubkey: identity.pubkey };
+ }
+
async loadIdentity(): Promise<{ pubkey: string; privkey: string } | null> {
- const pubkey = await SecureStore.getItemAsync(SECURE_STORE_PUBKEY_KEY);
- const privkey = await SecureStore.getItemAsync(SECURE_STORE_PRIVKEY_KEY);
- if (!pubkey || !privkey) return null;
- this.userPubkey = pubkey;
- this.userPrivkey = privkey;
- return { pubkey, privkey };
+ const identity = await identityVault.load();
+ if (!identity) return null;
+ this.userPubkey = identity.pubkey;
+ this.userPrivkey = identity.privkey;
+ return identity;
}
async deleteIdentity(): Promise {
- await SecureStore.deleteItemAsync(SECURE_STORE_PRIVKEY_KEY);
- await SecureStore.deleteItemAsync(SECURE_STORE_PUBKEY_KEY);
+ await identityVault.clear();
+ this.userPubkey = '';
+ this.userPrivkey = '';
+ this.bufferedMessages = [];
+ }
+
+ async lockIdentity(): Promise {
+ await this.disconnect();
+ await identityVault.lock();
this.userPubkey = '';
this.userPrivkey = '';
this.bufferedMessages = [];
@@ -368,10 +397,7 @@ class OpenDatingClientImpl {
// Cache services
try {
- await SecureStore.setItemAsync(
- SERVICES_CACHE_KEY,
- JSON.stringify(this.capabilities)
- );
+ await storage.saveServicesCache(this.capabilities);
} catch {
// Non-critical
}
@@ -380,9 +406,8 @@ class OpenDatingClientImpl {
} catch (err) {
// Try cached services
try {
- const cached = await SecureStore.getItemAsync(SERVICES_CACHE_KEY);
- if (cached) {
- const parsed = JSON.parse(cached) as OpenDatingCapabilities;
+ const parsed = await storage.getServicesCache();
+ if (parsed) {
this.capabilities = parsed;
this.services = parsed.roles;
return parsed;
diff --git a/src/lib/opendating/recovery-key.ts b/src/lib/opendating/recovery-key.ts
new file mode 100644
index 0000000..1a74a69
--- /dev/null
+++ b/src/lib/opendating/recovery-key.ts
@@ -0,0 +1,34 @@
+import { bech32, hex } from '@scure/base';
+
+const HEX_PRIVATE_KEY = /^[0-9a-f]{64}$/;
+
+/** Normalize a standard recovery key or 64-character hex key to lowercase hex. */
+export function normalizeRecoveryKey(input: string): string {
+ const trimmed = input.trim();
+ const lowercase = trimmed.toLowerCase();
+
+ if (HEX_PRIVATE_KEY.test(lowercase)) return lowercase;
+
+ if (lowercase.startsWith('nsec1')) {
+ try {
+ const decoded = bech32.decode(lowercase as `${string}1${string}`);
+ const bytes = bech32.fromWords(decoded.words);
+ if (decoded.prefix !== 'nsec' || bytes.length !== 32) throw new Error('invalid');
+ return hex.encode(bytes);
+ } catch {
+ throw new Error(
+ "That key doesn't look valid — double-check it was copied completely."
+ );
+ }
+ }
+
+ throw new Error(
+ 'That does not look like a recovery key. Paste the whole thing, including the prefix.'
+ );
+}
+
+/** Encode a private key in the interoperable recovery-key format. */
+export function encodeRecoveryKey(privateKeyHex: string): string {
+ const normalized = normalizeRecoveryKey(privateKeyHex);
+ return bech32.encode('nsec', bech32.toWords(hex.decode(normalized)), 5_000);
+}
diff --git a/src/lib/storage/__tests__/identity-vault.web.test.ts b/src/lib/storage/__tests__/identity-vault.web.test.ts
new file mode 100644
index 0000000..d4383f7
--- /dev/null
+++ b/src/lib/storage/__tests__/identity-vault.web.test.ts
@@ -0,0 +1,65 @@
+import { generateKeypair } from 'opendating-protocol';
+
+import { identityVault } from '../identity-vault.web';
+
+class MemoryStorage implements Storage {
+ private readonly values = new Map();
+ get length() { return this.values.size; }
+ clear() { this.values.clear(); }
+ getItem(key: string) { return this.values.get(key) ?? null; }
+ key(index: number) { return [...this.values.keys()][index] ?? null; }
+ removeItem(key: string) { this.values.delete(key); }
+ setItem(key: string, value: string) { this.values.set(key, value); }
+}
+
+describe('browser identity vault', () => {
+ const storage = new MemoryStorage();
+
+ beforeAll(() => {
+ Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage });
+ });
+
+ beforeEach(async () => {
+ storage.clear();
+ await identityVault.lock();
+ });
+
+ it('encrypts, locks, rejects wrong/tampered records, unlocks, and clears', async () => {
+ const pair = generateKeypair();
+ await identityVault.save(
+ { privkey: pair.privateKey, pubkey: pair.publicKey },
+ 'a long browser passphrase'
+ );
+
+ const persisted = storage.getItem('opendating_identity_vault_v1')!;
+ expect(persisted).not.toContain(pair.privateKey);
+ expect(persisted).not.toContain(pair.publicKey);
+ expect(await identityVault.getState()).toBe('ready');
+
+ await identityVault.lock();
+ expect(await identityVault.getState()).toBe('locked');
+ await expect(identityVault.unlock('wrong passphrase')).rejects.toThrow(/did not unlock/);
+ await expect(identityVault.unlock('a long browser passphrase')).resolves.toEqual({
+ privkey: pair.privateKey,
+ pubkey: pair.publicKey,
+ });
+
+ await identityVault.lock();
+ const record = JSON.parse(persisted) as { ciphertext: string };
+ record.ciphertext = `${record.ciphertext.slice(0, -2)}AA`;
+ storage.setItem('opendating_identity_vault_v1', JSON.stringify(record));
+ await expect(identityVault.unlock('a long browser passphrase')).rejects.toThrow(/did not unlock/);
+
+ await identityVault.clear();
+ expect(await identityVault.getState()).toBe('missing');
+ });
+
+ it('surfaces unavailable browser storage', async () => {
+ Object.defineProperty(globalThis, 'localStorage', {
+ configurable: true,
+ get() { throw new Error('blocked'); },
+ });
+ await expect(identityVault.getState()).rejects.toThrow(/storage is unavailable/);
+ Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage });
+ });
+});
diff --git a/src/lib/storage/identity-vault.ts b/src/lib/storage/identity-vault.ts
new file mode 100644
index 0000000..f88ce3f
--- /dev/null
+++ b/src/lib/storage/identity-vault.ts
@@ -0,0 +1,58 @@
+import * as SecureStore from 'expo-secure-store';
+
+import type { IdentityVault } from './identity-vault.types';
+
+const PRIVKEY_KEY = 'opendating_privkey';
+const PUBKEY_KEY = 'opendating_pubkey';
+
+export const identityVault: IdentityVault = {
+ async getState() {
+ const [pubkey, privkey] = await Promise.all([
+ SecureStore.getItemAsync(PUBKEY_KEY),
+ SecureStore.getItemAsync(PRIVKEY_KEY),
+ ]);
+ return pubkey && privkey ? 'ready' : 'missing';
+ },
+
+ async save(identity) {
+ await SecureStore.setItemAsync(PRIVKEY_KEY, identity.privkey);
+ try {
+ await SecureStore.setItemAsync(PUBKEY_KEY, identity.pubkey);
+ } catch (error) {
+ await SecureStore.deleteItemAsync(PRIVKEY_KEY).catch(() => {});
+ throw error;
+ }
+ },
+
+ async unlock() {
+ const identity = await this.load();
+ if (!identity) throw new Error('No account is stored on this device.');
+ return identity;
+ },
+
+ async load() {
+ const [pubkey, privkey] = await Promise.all([
+ SecureStore.getItemAsync(PUBKEY_KEY),
+ SecureStore.getItemAsync(PRIVKEY_KEY),
+ ]);
+ return pubkey && privkey ? { pubkey, privkey } : null;
+ },
+
+ async clear() {
+ await Promise.all([
+ SecureStore.deleteItemAsync(PRIVKEY_KEY),
+ SecureStore.deleteItemAsync(PUBKEY_KEY),
+ ]);
+ },
+
+ async lock() {
+ // Native keys remain protected by the OS keychain. Browser-only locking
+ // clears an in-memory decrypted key in identity-vault.web.ts.
+ },
+};
+
+export type {
+ EncryptedIdentityRecordV1,
+ IdentityState,
+ StoredIdentity,
+} from './identity-vault.types';
diff --git a/src/lib/storage/identity-vault.types.ts b/src/lib/storage/identity-vault.types.ts
new file mode 100644
index 0000000..ee1a5af
--- /dev/null
+++ b/src/lib/storage/identity-vault.types.ts
@@ -0,0 +1,49 @@
+export type IdentityState = 'missing' | 'locked' | 'ready';
+
+export interface StoredIdentity {
+ pubkey: string;
+ privkey: string;
+}
+
+export interface EncryptedIdentityRecordV1 {
+ version: 1;
+ kdf: 'PBKDF2';
+ hash: 'SHA-256';
+ iterations: number;
+ salt: string;
+ cipher: 'AES-GCM';
+ iv: string;
+ ciphertext: string;
+}
+
+export interface IdentityVault {
+ getState(): Promise;
+ save(identity: StoredIdentity, passphrase?: string): Promise;
+ unlock(passphrase: string): Promise;
+ load(): Promise;
+ clear(): Promise;
+ lock(): Promise;
+}
+
+export class IdentityVaultLockedError extends Error {
+ constructor() {
+ super('This browser is locked. Enter your browser lock passphrase.');
+ this.name = 'IdentityVaultLockedError';
+ }
+}
+
+export class IdentityVaultUnavailableError extends Error {
+ constructor() {
+ super(
+ 'Secure browser storage is unavailable. Leave private browsing or enable site storage and try again.'
+ );
+ this.name = 'IdentityVaultUnavailableError';
+ }
+}
+
+export class IdentityVaultUnlockError extends Error {
+ constructor() {
+ super('That passphrase did not unlock this browser. Check it and try again.');
+ this.name = 'IdentityVaultUnlockError';
+ }
+}
diff --git a/src/lib/storage/identity-vault.web.ts b/src/lib/storage/identity-vault.web.ts
new file mode 100644
index 0000000..b92c9cd
--- /dev/null
+++ b/src/lib/storage/identity-vault.web.ts
@@ -0,0 +1,223 @@
+import { derivePublicKey } from 'opendating-protocol';
+
+import {
+ IdentityVaultLockedError,
+ IdentityVaultUnavailableError,
+ IdentityVaultUnlockError,
+ type EncryptedIdentityRecordV1,
+ type IdentityVault,
+ type StoredIdentity,
+} from './identity-vault.types';
+
+const VAULT_KEY = 'opendating_identity_vault_v1';
+const VAULT_AAD = new TextEncoder().encode('OpenDating identity vault v1');
+const KDF_ITERATIONS = 600_000;
+const HEX_KEY = /^[0-9a-f]{64}$/;
+
+let unlockedIdentity: StoredIdentity | null = null;
+
+function getBrowserStorage(): Storage {
+ try {
+ const storage = globalThis.localStorage;
+ if (!storage) throw new Error('missing');
+ return storage;
+ } catch {
+ throw new IdentityVaultUnavailableError();
+ }
+}
+
+function readVaultRecord(): string | null {
+ try {
+ return getBrowserStorage().getItem(VAULT_KEY);
+ } catch (error) {
+ if (error instanceof IdentityVaultUnavailableError) throw error;
+ throw new IdentityVaultUnavailableError();
+ }
+}
+
+function toBase64(bytes: Uint8Array): string {
+ let binary = '';
+ for (let index = 0; index < bytes.length; index += 0x8000) {
+ binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
+ }
+ return globalThis.btoa(binary);
+}
+
+function fromBase64(value: string): Uint8Array {
+ const binary = globalThis.atob(value);
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
+}
+
+function parseRecord(raw: string): EncryptedIdentityRecordV1 {
+ let value: unknown;
+ try {
+ value = JSON.parse(raw);
+ } catch {
+ throw new IdentityVaultUnlockError();
+ }
+ if (
+ typeof value !== 'object' ||
+ value === null ||
+ (value as EncryptedIdentityRecordV1).version !== 1 ||
+ (value as EncryptedIdentityRecordV1).kdf !== 'PBKDF2' ||
+ (value as EncryptedIdentityRecordV1).hash !== 'SHA-256' ||
+ (value as EncryptedIdentityRecordV1).iterations !== KDF_ITERATIONS ||
+ (value as EncryptedIdentityRecordV1).cipher !== 'AES-GCM' ||
+ typeof (value as EncryptedIdentityRecordV1).salt !== 'string' ||
+ typeof (value as EncryptedIdentityRecordV1).iv !== 'string' ||
+ typeof (value as EncryptedIdentityRecordV1).ciphertext !== 'string'
+ ) {
+ throw new IdentityVaultUnlockError();
+ }
+ return value as EncryptedIdentityRecordV1;
+}
+
+function assertIdentity(value: unknown): asserts value is StoredIdentity {
+ if (
+ typeof value !== 'object' ||
+ value === null ||
+ typeof (value as StoredIdentity).pubkey !== 'string' ||
+ typeof (value as StoredIdentity).privkey !== 'string' ||
+ !HEX_KEY.test((value as StoredIdentity).pubkey) ||
+ !HEX_KEY.test((value as StoredIdentity).privkey) ||
+ derivePublicKey((value as StoredIdentity).privkey) !== (value as StoredIdentity).pubkey
+ ) {
+ throw new IdentityVaultUnlockError();
+ }
+}
+
+async function deriveKey(
+ passphrase: string,
+ salt: Uint8Array,
+ usages: KeyUsage[]
+): Promise {
+ const material = await globalThis.crypto.subtle.importKey(
+ 'raw',
+ new TextEncoder().encode(passphrase),
+ 'PBKDF2',
+ false,
+ ['deriveKey']
+ );
+ return globalThis.crypto.subtle.deriveKey(
+ {
+ name: 'PBKDF2',
+ hash: 'SHA-256',
+ iterations: KDF_ITERATIONS,
+ salt: salt as BufferSource,
+ },
+ material,
+ { name: 'AES-GCM', length: 256 },
+ false,
+ usages
+ );
+}
+
+export async function encryptIdentity(
+ identity: StoredIdentity,
+ passphrase: string
+): Promise {
+ if (passphrase.length < 12) {
+ throw new Error('Use at least 12 characters for your browser lock passphrase.');
+ }
+ assertIdentity(identity);
+ if (!globalThis.crypto?.subtle) throw new IdentityVaultUnavailableError();
+
+ const salt = globalThis.crypto.getRandomValues(new Uint8Array(16));
+ const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
+ const key = await deriveKey(passphrase, salt, ['encrypt']);
+ const plaintext = new TextEncoder().encode(JSON.stringify(identity));
+ const ciphertext = await globalThis.crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv: iv as BufferSource, additionalData: VAULT_AAD },
+ key,
+ plaintext
+ );
+
+ return {
+ version: 1,
+ kdf: 'PBKDF2',
+ hash: 'SHA-256',
+ iterations: KDF_ITERATIONS,
+ salt: toBase64(salt),
+ cipher: 'AES-GCM',
+ iv: toBase64(iv),
+ ciphertext: toBase64(new Uint8Array(ciphertext)),
+ };
+}
+
+export async function decryptIdentity(
+ record: EncryptedIdentityRecordV1,
+ passphrase: string
+): Promise {
+ if (!globalThis.crypto?.subtle) throw new IdentityVaultUnavailableError();
+ try {
+ const salt = fromBase64(record.salt);
+ const iv = fromBase64(record.iv);
+ if (salt.length !== 16 || iv.length !== 12) throw new Error('invalid record');
+ const key = await deriveKey(passphrase, salt, ['decrypt']);
+ const plaintext = await globalThis.crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv: iv as BufferSource, additionalData: VAULT_AAD },
+ key,
+ fromBase64(record.ciphertext) as BufferSource
+ );
+ const identity: unknown = JSON.parse(new TextDecoder().decode(plaintext));
+ assertIdentity(identity);
+ return identity;
+ } catch (error) {
+ if (error instanceof IdentityVaultUnavailableError) throw error;
+ throw new IdentityVaultUnlockError();
+ }
+}
+
+export const identityVault: IdentityVault = {
+ async getState() {
+ if (unlockedIdentity) return 'ready';
+ return readVaultRecord() ? 'locked' : 'missing';
+ },
+
+ async save(identity, passphrase) {
+ if (!passphrase) {
+ throw new Error('Create a browser lock passphrase before saving this account.');
+ }
+ const record = await encryptIdentity(identity, passphrase);
+ try {
+ getBrowserStorage().setItem(VAULT_KEY, JSON.stringify(record));
+ } catch {
+ throw new IdentityVaultUnavailableError();
+ }
+ unlockedIdentity = identity;
+ },
+
+ async unlock(passphrase) {
+ const raw = readVaultRecord();
+ if (!raw) throw new Error('No account is stored in this browser.');
+ const identity = await decryptIdentity(parseRecord(raw), passphrase);
+ unlockedIdentity = identity;
+ return identity;
+ },
+
+ async load() {
+ if (unlockedIdentity) return unlockedIdentity;
+ if (readVaultRecord()) throw new IdentityVaultLockedError();
+ return null;
+ },
+
+ async clear() {
+ try {
+ getBrowserStorage().removeItem(VAULT_KEY);
+ } catch {
+ throw new IdentityVaultUnavailableError();
+ } finally {
+ unlockedIdentity = null;
+ }
+ },
+
+ async lock() {
+ unlockedIdentity = null;
+ },
+};
+
+export type {
+ EncryptedIdentityRecordV1,
+ IdentityState,
+ StoredIdentity,
+} from './identity-vault.types';
diff --git a/src/lib/storage/index.ts b/src/lib/storage/index.ts
index 179ae05..b2f7a90 100644
--- a/src/lib/storage/index.ts
+++ b/src/lib/storage/index.ts
@@ -1,6 +1,7 @@
// Local storage abstraction.
// Uses expo-secure-store for sensitive data, AsyncStorage for cache only.
-// NEVER stores: nsec, private keys, decrypted messages, raw GPS.
+// Identity is owned by identity-vault.ts. This module never stores decrypted
+// messages or raw GPS.
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as SecureStore from 'expo-secure-store';
@@ -49,8 +50,6 @@ async function secureDelete(key: string): Promise {
// ---- Public API ----
const STORAGE_KEYS = {
- IDENTITY_PRIVKEY: 'opendating_privkey',
- IDENTITY_PUBKEY: 'opendating_pubkey',
SERVICES_CACHE: 'opendating_services',
LOCATION_PREFIX: 'opendating_location_prefix',
ONBOARDING_COMPLETE: 'opendating_onboarding_done',
@@ -61,24 +60,6 @@ const STORAGE_KEYS = {
} as const;
export const storage = {
- // Identity (secure)
- async savePrivateKey(key: string): Promise {
- await secureSet(STORAGE_KEYS.IDENTITY_PRIVKEY, key);
- },
- async getPrivateKey(): Promise {
- return secureGet(STORAGE_KEYS.IDENTITY_PRIVKEY);
- },
- async savePublicKey(key: string): Promise {
- await secureSet(STORAGE_KEYS.IDENTITY_PUBKEY, key);
- },
- async getPublicKey(): Promise {
- return secureGet(STORAGE_KEYS.IDENTITY_PUBKEY);
- },
- async deleteIdentity(): Promise {
- await secureDelete(STORAGE_KEYS.IDENTITY_PRIVKEY);
- await secureDelete(STORAGE_KEYS.IDENTITY_PUBKEY);
- },
-
// Services cache
async saveServicesCache(data: object): Promise {
await secureSet(STORAGE_KEYS.SERVICES_CACHE, JSON.stringify(data));
diff --git a/src/lib/storage/index.web.ts b/src/lib/storage/index.web.ts
new file mode 100644
index 0000000..8b2e0f4
--- /dev/null
+++ b/src/lib/storage/index.web.ts
@@ -0,0 +1,93 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+
+import { isValidPolicyTimestamp, type StoredPolicyAcceptance } from '@/lib/policy';
+
+const STORAGE_KEYS = {
+ SERVICES_CACHE: 'opendating_services',
+ LOCATION_PREFIX: 'opendating_location_prefix',
+ ONBOARDING_COMPLETE: 'opendating_onboarding_done',
+ THEME_PREFERENCE: 'opendating_theme',
+ PROFILE_CONTENT: 'opendating_profile_content',
+ ONBOARDING_DRAFT: 'opendating_onboarding_draft',
+ POLICY_ACCEPTANCE: 'opendating_policy_acceptance',
+} as const;
+
+async function readJson(key: string): Promise {
+ const raw = await AsyncStorage.getItem(key);
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw) as T;
+ } catch {
+ return null;
+ }
+}
+
+export const storage = {
+ async saveServicesCache(data: object): Promise {
+ await AsyncStorage.setItem(STORAGE_KEYS.SERVICES_CACHE, JSON.stringify(data));
+ },
+ async getServicesCache(): Promise {
+ return readJson(STORAGE_KEYS.SERVICES_CACHE);
+ },
+
+ async saveProfileContent(content: object): Promise {
+ await AsyncStorage.setItem(STORAGE_KEYS.PROFILE_CONTENT, JSON.stringify(content));
+ },
+ async getProfileContent(): Promise {
+ return readJson(STORAGE_KEYS.PROFILE_CONTENT);
+ },
+
+ async saveOnboardingDraft(draft: object): Promise {
+ await AsyncStorage.setItem(STORAGE_KEYS.ONBOARDING_DRAFT, JSON.stringify(draft));
+ },
+ async getOnboardingDraft(): Promise {
+ return readJson(STORAGE_KEYS.ONBOARDING_DRAFT);
+ },
+ async clearOnboardingDraft(): Promise {
+ await AsyncStorage.removeItem(STORAGE_KEYS.ONBOARDING_DRAFT);
+ },
+
+ async savePolicyAcceptance(acceptance: StoredPolicyAcceptance): Promise {
+ await AsyncStorage.setItem(
+ STORAGE_KEYS.POLICY_ACCEPTANCE,
+ JSON.stringify(acceptance)
+ );
+ },
+ async getPolicyAcceptance(): Promise {
+ const parsed = await readJson(
+ STORAGE_KEYS.POLICY_ACCEPTANCE
+ );
+ if (
+ !parsed ||
+ typeof parsed.version !== 'string' ||
+ typeof parsed.acceptedAt !== 'string' ||
+ typeof parsed.pubkey !== 'string' ||
+ !isValidPolicyTimestamp(parsed.acceptedAt)
+ ) {
+ return null;
+ }
+ return parsed;
+ },
+ async deletePolicyAcceptance(): Promise {
+ await AsyncStorage.removeItem(STORAGE_KEYS.POLICY_ACCEPTANCE);
+ },
+
+ async setOnboardingComplete(): Promise {
+ await AsyncStorage.setItem(STORAGE_KEYS.ONBOARDING_COMPLETE, 'true');
+ },
+ async isOnboardingComplete(): Promise {
+ return (await AsyncStorage.getItem(STORAGE_KEYS.ONBOARDING_COMPLETE)) === 'true';
+ },
+
+ async saveThemePreference(theme: 'light' | 'dark' | 'system'): Promise {
+ await AsyncStorage.setItem(STORAGE_KEYS.THEME_PREFERENCE, theme);
+ },
+ async getThemePreference(): Promise<'light' | 'dark' | 'system' | null> {
+ const value = await AsyncStorage.getItem(STORAGE_KEYS.THEME_PREFERENCE);
+ return value === 'light' || value === 'dark' || value === 'system' ? value : null;
+ },
+
+ async clearAll(): Promise {
+ await AsyncStorage.multiRemove(Object.values(STORAGE_KEYS));
+ },
+};
diff --git a/src/types/opendating.ts b/src/types/opendating.ts
index b757e74..ea12d1b 100644
--- a/src/types/opendating.ts
+++ b/src/types/opendating.ts
@@ -248,6 +248,7 @@ export type ConnectionState =
export type AppBootstrapState =
| 'loading'
| 'no_identity'
+ | 'identity_locked'
| 'connecting'
| 'authenticating'
| 'fetching_capabilities'
From 278dcf45271e2b4bbe58abc99d3448f84c306260 Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 11:34:41 -0400
Subject: [PATCH 09/39] Keep Nostr web events CSP-safe
---
bun.lock | 3 +++
package.json | 3 +++
patches/tseep@1.3.1.patch | 11 +++++++++++
src/lib/__tests__/tseep-csp.test.ts | 26 ++++++++++++++++++++++++++
4 files changed, 43 insertions(+)
create mode 100644 patches/tseep@1.3.1.patch
create mode 100644 src/lib/__tests__/tseep-csp.test.ts
diff --git a/bun.lock b/bun.lock
index 4b46a52..d63922e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -62,6 +62,9 @@
},
},
},
+ "patchedDependencies": {
+ "tseep@1.3.1": "patches/tseep@1.3.1.patch",
+ },
"overrides": {
"uuid": ">=14.0.1",
},
diff --git a/package.json b/package.json
index 08e045c..269bd2f 100644
--- a/package.json
+++ b/package.json
@@ -125,5 +125,8 @@
"packageManager": "bun@1.3.14",
"overrides": {
"uuid": ">=14.0.1"
+ },
+ "patchedDependencies": {
+ "tseep@1.3.1": "patches/tseep@1.3.1.patch"
}
}
diff --git a/patches/tseep@1.3.1.patch b/patches/tseep@1.3.1.patch
new file mode 100644
index 0000000..903cfd2
--- /dev/null
+++ b/patches/tseep@1.3.1.patch
@@ -0,0 +1,11 @@
+diff --git a/lib/index.js b/lib/index.js
+index ee8a7a19fb5fbf409464e467aa7c3a4c0869534c..caae05c2433dea825722fcc5b5d0815486178f45 100644
+--- a/lib/index.js
++++ b/lib/index.js
+@@ -15,5 +15,5 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
+ };
+ Object.defineProperty(exports, "__esModule", { value: true });
+ __exportStar(require("./types"), exports);
+-__exportStar(require("./ee"), exports);
++__exportStar(require("./ee-safe"), exports);
+ //# sourceMappingURL=index.js.map
diff --git a/src/lib/__tests__/tseep-csp.test.ts b/src/lib/__tests__/tseep-csp.test.ts
new file mode 100644
index 0000000..6c300d9
--- /dev/null
+++ b/src/lib/__tests__/tseep-csp.test.ts
@@ -0,0 +1,26 @@
+import { EventEmitter } from 'tseep';
+
+describe('CSP-safe Nostr events', () => {
+ it('supports multiple listeners when eval is blocked', () => {
+ const originalEval = globalThis.eval;
+ globalThis.eval = (() => {
+ throw new EvalError('blocked by Content Security Policy');
+ }) as typeof eval;
+
+ try {
+ const emitter = new EventEmitter<{ connect: (url: string) => void }>();
+ const calls: string[] = [];
+
+ emitter.on('connect', (url) => calls.push(`first:${url}`));
+ emitter.on('connect', (url) => calls.push(`second:${url}`));
+ emitter.emit('connect', 'wss://relay.example');
+
+ expect(calls).toEqual([
+ 'first:wss://relay.example',
+ 'second:wss://relay.example',
+ ]);
+ } finally {
+ globalThis.eval = originalEval;
+ }
+ });
+});
From e6e2bfbf509925628d12dbb2e3154baa743060b2 Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 11:43:54 -0400
Subject: [PATCH 10/39] Secure static web routes
---
package.json | 2 +-
scripts/secure-web-export.mjs | 32 ++++++++++++++++++++++++++++++++
src/app/(tabs)/discover.tsx | 8 ++++++--
src/app/(tabs)/matches.tsx | 2 +-
src/app/_layout.tsx | 4 ++--
src/app/candidate.tsx | 1 +
src/app/chat.tsx | 1 +
src/app/chat/[pubkey].tsx | 2 +-
8 files changed, 45 insertions(+), 7 deletions(-)
create mode 100644 scripts/secure-web-export.mjs
create mode 100644 src/app/candidate.tsx
create mode 100644 src/app/chat.tsx
diff --git a/package.json b/package.json
index 269bd2f..1d776a9 100644
--- a/package.json
+++ b/package.json
@@ -89,7 +89,7 @@
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
- "export:web": "expo export --platform web",
+ "export:web": "expo export --platform web && node scripts/secure-web-export.mjs",
"serve:web": "expo serve",
"typecheck": "tsc --noEmit",
"lint": "expo lint",
diff --git a/scripts/secure-web-export.mjs b/scripts/secure-web-export.mjs
new file mode 100644
index 0000000..26bee05
--- /dev/null
+++ b/scripts/secure-web-export.mjs
@@ -0,0 +1,32 @@
+#!/usr/bin/env node
+
+import { readdir, readFile, writeFile } from 'node:fs/promises';
+import { join, resolve } from 'node:path';
+
+const dist = resolve('dist');
+const index = await readFile(join(dist, 'index.html'), 'utf8');
+const csp = index.match(/]+http-equiv="Content-Security-Policy"[^>]*>/i)?.[0];
+
+if (!csp) throw new Error('The exported app shell is missing its Content Security Policy.');
+
+async function htmlFiles(directory) {
+ const entries = await readdir(directory, { withFileTypes: true });
+ const nested = await Promise.all(
+ entries.map((entry) => {
+ const path = join(directory, entry.name);
+ return entry.isDirectory() ? htmlFiles(path) : entry.name.endsWith('.html') ? [path] : [];
+ })
+ );
+ return nested.flat();
+}
+
+let secured = 0;
+for (const path of await htmlFiles(dist)) {
+ const html = await readFile(path, 'utf8');
+ if (/http-equiv="Content-Security-Policy"/i.test(html)) continue;
+ if (!/]*)?>/i.test(html)) throw new Error(`Cannot secure HTML without : ${path}`);
+ await writeFile(path, html.replace(/]*)?>/i, (head) => `${head}${csp}`));
+ secured++;
+}
+
+console.log(`Secured ${secured} static HTML files.`);
diff --git a/src/app/(tabs)/discover.tsx b/src/app/(tabs)/discover.tsx
index ec30047..0e3ce43 100644
--- a/src/app/(tabs)/discover.tsx
+++ b/src/app/(tabs)/discover.tsx
@@ -81,7 +81,10 @@ export default function IntroductionsScreen() {
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}`) },
+ {
+ text: 'Say hello',
+ onPress: () => router.push({ pathname: '/chat', params: { pubkey } }),
+ },
]);
},
[router]
@@ -115,7 +118,8 @@ export default function IntroductionsScreen() {
);
const handleOpenCandidate = useCallback(
- (candidate: Candidate) => router.push(`/candidate/${candidate.pubkey}`),
+ (candidate: Candidate) =>
+ router.push({ pathname: '/candidate', params: { pubkey: candidate.pubkey } }),
[router]
);
diff --git a/src/app/(tabs)/matches.tsx b/src/app/(tabs)/matches.tsx
index 75b904a..e7e1522 100644
--- a/src/app/(tabs)/matches.tsx
+++ b/src/app/(tabs)/matches.tsx
@@ -57,7 +57,7 @@ export default function MatchesScreen() {
(match: Match) => {
markMessaged(match.match_id);
markConversationRead(match.pubkey);
- router.push(`/chat/${match.pubkey}`);
+ router.push({ pathname: '/chat', params: { pubkey: match.pubkey } });
},
[markMessaged, router]
);
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index 03b2595..5c008f9 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -45,8 +45,8 @@ export default function RootLayout() {
-
-
+
+
diff --git a/src/app/candidate.tsx b/src/app/candidate.tsx
new file mode 100644
index 0000000..cd9d3fc
--- /dev/null
+++ b/src/app/candidate.tsx
@@ -0,0 +1 @@
+export { default } from './candidate/[pubkey]';
diff --git a/src/app/chat.tsx b/src/app/chat.tsx
new file mode 100644
index 0000000..900764d
--- /dev/null
+++ b/src/app/chat.tsx
@@ -0,0 +1 @@
+export { default } from './chat/[pubkey]';
diff --git a/src/app/chat/[pubkey].tsx b/src/app/chat/[pubkey].tsx
index a8fd749..3bee636 100644
--- a/src/app/chat/[pubkey].tsx
+++ b/src/app/chat/[pubkey].tsx
@@ -54,7 +54,7 @@ export default function ChatScreen() {
}, [router]);
const openProfile = useCallback(() => {
- router.push(`/candidate/${pubkey}`);
+ router.push({ pathname: '/candidate', params: { pubkey } });
}, [router, pubkey]);
const openReport = useCallback(() => {
From 515dde3fd902a0505112db163b953afb2f9884b5 Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 11:48:28 -0400
Subject: [PATCH 11/39] Avoid web route hydration errors
---
src/app/candidate.tsx | 7 ++++++-
src/app/chat.tsx | 7 ++++++-
src/lib/use-client-ready.ts | 7 +++++++
3 files changed, 19 insertions(+), 2 deletions(-)
create mode 100644 src/lib/use-client-ready.ts
diff --git a/src/app/candidate.tsx b/src/app/candidate.tsx
index cd9d3fc..c2911f5 100644
--- a/src/app/candidate.tsx
+++ b/src/app/candidate.tsx
@@ -1 +1,6 @@
-export { default } from './candidate/[pubkey]';
+import CandidateDetail from './candidate/[pubkey]';
+import { useClientReady } from '@/lib/use-client-ready';
+
+export default function StaticCandidateRoute() {
+ return useClientReady() ? : null;
+}
diff --git a/src/app/chat.tsx b/src/app/chat.tsx
index 900764d..6abee4a 100644
--- a/src/app/chat.tsx
+++ b/src/app/chat.tsx
@@ -1 +1,6 @@
-export { default } from './chat/[pubkey]';
+import ChatScreen from './chat/[pubkey]';
+import { useClientReady } from '@/lib/use-client-ready';
+
+export default function StaticChatRoute() {
+ return useClientReady() ? : null;
+}
diff --git a/src/lib/use-client-ready.ts b/src/lib/use-client-ready.ts
new file mode 100644
index 0000000..4bc0f79
--- /dev/null
+++ b/src/lib/use-client-ready.ts
@@ -0,0 +1,7 @@
+import { useSyncExternalStore } from 'react';
+
+const subscribe = () => () => {};
+
+export function useClientReady() {
+ return useSyncExternalStore(subscribe, () => true, () => false);
+}
From d51c496c923b7f6bbf5635f88c07ec5c81677854 Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 12:08:53 -0400
Subject: [PATCH 12/39] Polish the web shell and introductions
---
src/app/+html.tsx | 6 ++++--
src/app/_layout.tsx | 9 +++------
src/constants/screenshot-demo.ts | 16 +++++++++-------
3 files changed, 16 insertions(+), 15 deletions(-)
diff --git a/src/app/+html.tsx b/src/app/+html.tsx
index 8c0ba96..bb7b05b 100644
--- a/src/app/+html.tsx
+++ b/src/app/+html.tsx
@@ -36,11 +36,13 @@ export default function RootHtml({ children }: { children: ReactNode }) {
property="og:image"
content="https://opendating.org/images/lockup-coral.png"
/>
-
+
+
+
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index 5c008f9..c35f728 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -8,6 +8,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
import { ThemeProvider } from '@/state/theme-context';
import { RevenueCatProvider } from '@/state/revenuecat-context';
import { ErrorBoundary } from '@/components/error-boundary';
+import { colors } from '@/theme/colors';
import { StatusBar } from 'expo-status-bar';
export default function RootLayout() {
@@ -21,7 +22,7 @@ export default function RootLayout() {
style={[
styles.root,
Platform.OS === 'web' && {
- backgroundColor: isDark ? '#111111' : '#EDE9E4',
+ backgroundColor: isDark ? colors.dark.background : colors.light.background,
},
]}
>
@@ -33,7 +34,7 @@ export default function RootLayout() {
styles.app,
Platform.OS === 'web' && styles.webApp,
Platform.OS === 'web' && {
- backgroundColor: isDark ? '#181818' : '#FAF9F7',
+ backgroundColor: isDark ? colors.dark.background : colors.light.background,
},
]}
>
@@ -68,9 +69,5 @@ const styles = StyleSheet.create({
width: '100%',
maxWidth: 760,
alignSelf: 'center',
- shadowColor: '#000000',
- shadowOpacity: 0.08,
- shadowRadius: 30,
- shadowOffset: { width: 0, height: 4 },
},
});
diff --git a/src/constants/screenshot-demo.ts b/src/constants/screenshot-demo.ts
index 8eeea18..051c615 100644
--- a/src/constants/screenshot-demo.ts
+++ b/src/constants/screenshot-demo.ts
@@ -1,18 +1,20 @@
-import { Image as RNImage, type ImageSourcePropType } from 'react-native';
+import { Asset } from 'expo-asset';
import type { Candidate, CandidatePhoto, Match, ProfileContent } from '@/types/opendating';
+type AssetModule = Parameters[0];
+
const PHOTO_SOURCES = {
- alex: require('../../assets/demo/alex.png') as ImageSourcePropType,
- emma: require('../../assets/demo/emma.png') as ImageSourcePropType,
- marcus: require('../../assets/demo/marcus.png') as ImageSourcePropType,
- jordan: require('../../assets/demo/jordan.png') as ImageSourcePropType,
+ alex: require('../../assets/demo/alex.png') as AssetModule,
+ emma: require('../../assets/demo/emma.png') as AssetModule,
+ marcus: require('../../assets/demo/marcus.png') as AssetModule,
+ jordan: require('../../assets/demo/jordan.png') as AssetModule,
};
-function photo(id: string, source: ImageSourcePropType, order = 0): CandidatePhoto {
+function photo(id: string, source: AssetModule, order = 0): CandidatePhoto {
return {
id,
order,
- url: RNImage.resolveAssetSource(source).uri,
+ url: Asset.fromModule(source).uri,
};
}
From e995182d5ff3a16995868bc287c260c470f15212 Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 12:10:33 -0400
Subject: [PATCH 13/39] Verify production web exports in CI
---
.github/workflows/ci.yml | 21 ++++++++++++++++++++-
1 file changed, 20 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7fa38b3..d49b1db 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,6 +40,25 @@ jobs:
- run: bun install --frozen-lockfile
- run: bun run lint
+ web-export:
+ name: Web export
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ persist-credentials: false
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+ with:
+ bun-version-file: package.json
+ - run: bun install --frozen-lockfile
+ - run: bun run export:web
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: web-export
+ path: dist
+ if-no-files-found: error
+ retention-days: 7
+
test:
name: Tests
runs-on: ubuntu-latest
@@ -112,7 +131,7 @@ jobs:
# Full check passes only when all release checks pass.
ci:
name: CI
- needs: [typecheck, lint, test, security, dependency-review, sbom]
+ needs: [typecheck, lint, web-export, test, security, dependency-review, sbom]
runs-on: ubuntu-latest
steps:
- run: echo "All checks passed"
From f5bc1f9b7a3b8ce775c4010b1f5ce7224aba0f88 Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 12:25:30 -0400
Subject: [PATCH 14/39] Make web account deletion reliable
---
src/app/settings/account.tsx | 86 ++++++++++++++++++++++++++++++++++--
1 file changed, 82 insertions(+), 4 deletions(-)
diff --git a/src/app/settings/account.tsx b/src/app/settings/account.tsx
index 722c8d2..2196da1 100644
--- a/src/app/settings/account.tsx
+++ b/src/app/settings/account.tsx
@@ -1,7 +1,7 @@
// Account — delete account with confirmation, local cleanup, and redirect.
import { useCallback, useState } from 'react';
-import { Alert, Platform, ScrollView, StyleSheet, Text, View } from 'react-native';
+import { Alert, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar';
@@ -23,6 +23,7 @@ export default function AccountScreen() {
const router = useRouter();
const { colors, isDark } = useTheme();
const [deleting, setDeleting] = useState(false);
+ const [confirming, setConfirming] = useState(false);
const deleteAccount = useCallback(async () => {
if (deleting) return;
@@ -59,9 +60,7 @@ export default function AccountScreen() {
const confirmDelete = useCallback(() => {
if (Platform.OS === 'web') {
- if (globalThis.confirm(`Delete Account?\n\n${DELETE_CONSEQUENCES}`)) {
- void deleteAccount();
- }
+ setConfirming(true);
return;
}
Alert.alert('Delete Account?', DELETE_CONSEQUENCES, [
@@ -74,6 +73,11 @@ export default function AccountScreen() {
]);
}, [deleteAccount]);
+ const confirmWebDelete = useCallback(() => {
+ setConfirming(false);
+ void deleteAccount();
+ }, [deleteAccount]);
+
return (
+
+ {Platform.OS === 'web' ? (
+ setConfirming(false)}
+ >
+ setConfirming(false)}
+ accessibilityLabel="Cancel account deletion"
+ />
+
+
+ Delete Account?
+
+ {DELETE_CONSEQUENCES}
+
+
+ setConfirming(false)}
+ style={[styles.confirmButton, { borderColor: colors.border }]}
+ >
+ Cancel
+
+
+ Delete permanently
+
+
+
+
+
+ ) : null}
);
}
@@ -161,4 +208,35 @@ const styles = StyleSheet.create({
textAlign: 'center',
paddingHorizontal: spacing.xl,
},
+ confirmBackdrop: {
+ ...StyleSheet.absoluteFill,
+ },
+ confirmOverlay: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'rgba(0, 0, 0, 0.45)',
+ paddingHorizontal: spacing.xxl,
+ },
+ confirmCard: {
+ width: '100%',
+ maxWidth: 380,
+ borderRadius: radius.xl,
+ borderWidth: StyleSheet.hairlineWidth,
+ padding: spacing.xl,
+ gap: spacing.lg,
+ },
+ confirmActions: {
+ flexDirection: 'row',
+ gap: spacing.md,
+ },
+ confirmButton: {
+ flex: 1,
+ minHeight: 48,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: radius.lg,
+ borderWidth: 1,
+ paddingHorizontal: spacing.md,
+ },
});
From 5be2a79169fe7b4782d95f187093c90b753c2edd Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 12:35:07 -0400
Subject: [PATCH 15/39] Handle profileless match records
---
src/app/(tabs)/matches.tsx | 5 +++--
src/types/opendating.ts | 2 +-
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/app/(tabs)/matches.tsx b/src/app/(tabs)/matches.tsx
index e7e1522..e761b1c 100644
--- a/src/app/(tabs)/matches.tsx
+++ b/src/app/(tabs)/matches.tsx
@@ -44,6 +44,7 @@ export default function MatchesScreen() {
// reach intent.like because the server rejects it.
useEffect(() => {
for (const match of displayMatches) {
+ if (!match.profile) continue;
cacheCandidate({
pubkey: match.pubkey,
profile: match.profile,
@@ -329,7 +330,7 @@ interface AvatarProps {
function Avatar({ match, size, ring = false, ringColor }: AvatarProps) {
const { colors } = useTheme();
- const photoUrl = match.profile.photos?.find((p) => p.url.length > 0)?.url;
+ const photoUrl = match.profile?.photos?.find((p) => p.url.length > 0)?.url;
return (
Date: Sat, 29 Aug 2026 12:42:56 -0400
Subject: [PATCH 16/39] Guard protected web deep links
---
src/app/_layout.tsx | 76 +++++++++++++++++++++++++++++++++++++++------
1 file changed, 67 insertions(+), 9 deletions(-)
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index c35f728..26c04ca 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -1,7 +1,8 @@
// Root layout — theme provider + navigation shell
// Polyfill crypto.getRandomValues before anything else (required by @noble/curves for key generation)
import 'react-native-get-random-values';
-import { Stack } from 'expo-router';
+import { useEffect, useState } from 'react';
+import { Stack, usePathname, useRouter } from 'expo-router';
import { Platform, StyleSheet, useColorScheme } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
@@ -10,6 +11,26 @@ import { RevenueCatProvider } from '@/state/revenuecat-context';
import { ErrorBoundary } from '@/components/error-boundary';
import { colors } from '@/theme/colors';
import { StatusBar } from 'expo-status-bar';
+import { getOpenDatingClient } from '@/lib/opendating/open-dating-client';
+
+const PUBLIC_WEB_ROUTES = new Set([
+ '/',
+ '/unlock',
+ '/welcome',
+ '/create-account',
+ '/import-account',
+ '/privacy',
+ '/basics',
+ '/preferences',
+ '/intent',
+ '/about',
+ '/photos',
+ '/location',
+ '/review',
+ '/finish',
+ '/settings/terms',
+ '/settings/privacy',
+]);
export default function RootLayout() {
const colorScheme = useColorScheme();
@@ -42,14 +63,7 @@ export default function RootLayout() {
-
-
-
-
-
-
-
-
+
@@ -58,6 +72,50 @@ export default function RootLayout() {
);
}
+function AppNavigator() {
+ const pathname = usePathname();
+ const router = useRouter();
+ const [webRouteReady, setWebRouteReady] = useState(false);
+ const isPublicWebRoute = Platform.OS !== 'web' || PUBLIC_WEB_ROUTES.has(pathname);
+
+ useEffect(() => {
+ if (isPublicWebRoute) return;
+
+ let active = true;
+ const client = getOpenDatingClient();
+ void client
+ .getIdentityState()
+ .then((identityState) => {
+ if (!active) return;
+ if (identityState !== 'ready' || !client.getCapabilities()) {
+ router.replace('/');
+ return;
+ }
+ setWebRouteReady(true);
+ })
+ .catch(() => {
+ if (active) router.replace('/');
+ });
+
+ return () => {
+ active = false;
+ };
+ }, [isPublicWebRoute, router]);
+
+ if (!isPublicWebRoute && !webRouteReady) return null;
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
const styles = StyleSheet.create({
root: {
flex: 1,
From 90ea66a22784398452e751a95b4b2779886a998b Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 13:17:34 -0400
Subject: [PATCH 17/39] Complete core web dating flows
---
src/app/(onboarding)/location.tsx | 20 ++-
src/app/(tabs)/discover.tsx | 10 +-
src/app/(tabs)/matches.tsx | 8 +-
src/app/(tabs)/passport.tsx | 5 +
src/app/candidate/[pubkey].tsx | 13 +-
src/app/chat/[pubkey].tsx | 37 +++--
src/app/filters.tsx | 31 ++--
src/app/settings/index.tsx | 44 +++++-
src/app/unlock.tsx | 24 ++-
src/components/confirmation-dialog.tsx | 102 ++++++++++++
src/components/safety/block-confirmation.tsx | 147 ------------------
src/components/safety/safety-menu.tsx | 121 ++++++++------
.../__tests__/discovery-preferences.test.ts | 36 +++++
.../discovery/discovery-preferences.ts | 15 +-
.../__tests__/message-history.test.ts | 21 +++
src/features/messaging/message-history.ts | 10 ++
src/features/safety/use-safety.ts | 4 +
src/lib/opendating/open-dating-client.ts | 25 ++-
src/lib/storage/index.ts | 16 ++
src/lib/storage/index.web.ts | 11 ++
src/state/theme-context.tsx | 33 +++-
src/types/opendating.ts | 1 -
22 files changed, 470 insertions(+), 264 deletions(-)
create mode 100644 src/components/confirmation-dialog.tsx
delete mode 100644 src/components/safety/block-confirmation.tsx
create mode 100644 src/features/discovery/__tests__/discovery-preferences.test.ts
create mode 100644 src/features/messaging/__tests__/message-history.test.ts
create mode 100644 src/features/messaging/message-history.ts
diff --git a/src/app/(onboarding)/location.tsx b/src/app/(onboarding)/location.tsx
index 3fd33f2..5d8e3d4 100644
--- a/src/app/(onboarding)/location.tsx
+++ b/src/app/(onboarding)/location.tsx
@@ -4,7 +4,7 @@
import React, { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
-import { useRouter } from 'expo-router';
+import { useLocalSearchParams, useRouter } from 'expo-router';
import {
ErrorBanner,
OnboardingScreen,
@@ -12,6 +12,7 @@ import {
import { useOnboardingDraft } from '@/features/onboarding/onboarding-draft';
import { isScreenshotMode } from '@/constants/env';
import { getCoarseLocation } from '@/lib/location';
+import { getOpenDatingClient } from '@/lib/opendating/open-dating-client';
import { useTheme } from '@/state/theme-context';
import type { ThemeColors } from '@/theme/colors';
import { typography } from '@/theme/typography';
@@ -25,6 +26,8 @@ interface CoarseArea {
export default function LocationScreen() {
const router = useRouter();
+ const { mode } = useLocalSearchParams<{ mode?: string }>();
+ const isUpdate = mode === 'update';
const { colors } = useTheme();
const { draft, update } = useOnboardingDraft();
@@ -47,6 +50,12 @@ export default function LocationScreen() {
setArea(coarse);
update('geohashPrefix', coarse.geohashPrefix);
update('countryCode', coarse.countryCode ?? null);
+ if (isUpdate) {
+ await getOpenDatingClient().updateLocation(
+ coarse.geohashPrefix,
+ coarse.countryCode
+ );
+ }
} catch (err) {
setError(
err instanceof Error
@@ -59,7 +68,8 @@ export default function LocationScreen() {
};
const handleContinue = () => {
- router.push('/(onboarding)/review');
+ if (isUpdate) router.back();
+ else router.push('/(onboarding)/review');
};
return (
@@ -78,7 +88,7 @@ export default function LocationScreen() {
style={styles.skip}
>
- {area ? 'Continue' : 'Not now — skip for later'}
+ {isUpdate ? 'Done' : area ? 'Continue' : 'Not now — skip for later'}
}
@@ -122,8 +132,8 @@ export default function LocationScreen() {
) : null}
- You can skip this and enable location later from Settings — without it,
- you'll only see people worldwide.
+ You can skip this and set an area later from your Privacy Passport. An
+ approximate area is required before OpenDating can offer introductions.
);
diff --git a/src/app/(tabs)/discover.tsx b/src/app/(tabs)/discover.tsx
index 0e3ce43..5a28b02 100644
--- a/src/app/(tabs)/discover.tsx
+++ b/src/app/(tabs)/discover.tsx
@@ -143,6 +143,7 @@ export default function IntroductionsScreen() {
!isScreenshotMode && !!error && candidates.length === 0 && !loading;
const showErrorBanner =
!isScreenshotMode && !!error && candidates.length > 0;
+ const needsArea = error?.toLowerCase().includes('area') ?? false;
const outOfInterests = loaded && !unavailable && remainingToday === 0;
return (
@@ -196,7 +197,14 @@ export default function IntroductionsScreen() {
icon={unavailable ? '🌱' : '📡'}
title={unavailable ? 'Introductions are coming online' : "Couldn't load introductions"}
subtitle={error}
- action={{ label: 'Check again', onPress: () => void fetchCandidates() }}
+ action={
+ needsArea
+ ? {
+ label: 'Set my approximate area',
+ onPress: () => router.push('/(onboarding)/location?mode=update'),
+ }
+ : { label: 'Check again', onPress: () => void fetchCandidates() }
+ }
/>
) : currentCandidate ? (
diff --git a/src/app/(tabs)/matches.tsx b/src/app/(tabs)/matches.tsx
index e761b1c..2066c6f 100644
--- a/src/app/(tabs)/matches.tsx
+++ b/src/app/(tabs)/matches.tsx
@@ -9,7 +9,7 @@ import {
Text,
View,
} from 'react-native';
-import { useRouter } from 'expo-router';
+import { useFocusEffect, useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Image } from 'expo-image';
import { SymbolView } from 'expo-symbols';
@@ -34,6 +34,12 @@ export default function MatchesScreen() {
const { matches, newMatches, loading, error, refresh, markMessaged } = useMatches();
const conversationLog = useConversationLog();
+ useFocusEffect(
+ useCallback(() => {
+ void refresh();
+ }, [refresh])
+ );
+
const screenshotMatches = useMemo(() => getScreenshotMatches(), []);
const displayMatches = isScreenshotMode ? screenshotMatches : matches;
const displayNewMatches = isScreenshotMode ? screenshotMatches : newMatches;
diff --git a/src/app/(tabs)/passport.tsx b/src/app/(tabs)/passport.tsx
index 80a231e..364ac95 100644
--- a/src/app/(tabs)/passport.tsx
+++ b/src/app/(tabs)/passport.tsx
@@ -171,6 +171,11 @@ export default function PassportScreen() {
+ router.push('/(onboarding)/location?mode=update')}
+ />
(null);
+ const { blockUser, error: safetyError } = useSafety();
const decidedRef = useRef(false);
const [photoIndex, setPhotoIndex] = useState(0);
@@ -284,6 +286,11 @@ export default function CandidateDetail() {
{/* Explicit text preserves the deliberate-introduction model on the full
profile and makes the privacy consequence of each choice clear. */}
+ {safetyError ? (
+
+ {safetyError}
+
+ ) : null}
decide('pass')}
accessibilityRole="button"
@@ -317,7 +324,9 @@ export default function CandidateDetail() {
ref={safetyRef}
targetPubkey={candidate.pubkey}
targetName={displayName}
- onBlock={() => router.back()}
+ onBlock={() => {
+ void blockUser(candidate.pubkey).then(() => router.back()).catch(() => {});
+ }}
/>
);
@@ -525,10 +534,12 @@ function makeStyles(colors: ThemeColors, screenWidth: number) {
},
actions: {
flexDirection: 'row',
+ flexWrap: 'wrap',
gap: spacing.md,
padding: spacing.md,
borderTopWidth: StyleSheet.hairlineWidth,
},
+ safetyError: { width: '100%', textAlign: 'center' },
actionButton: {
flex: 1,
minHeight: 52,
diff --git a/src/app/chat/[pubkey].tsx b/src/app/chat/[pubkey].tsx
index 3bee636..a0aaec3 100644
--- a/src/app/chat/[pubkey].tsx
+++ b/src/app/chat/[pubkey].tsx
@@ -34,13 +34,18 @@ export default function ChatScreen() {
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const { messages, sendMessage, error } = useMessaging(pubkey);
- const { blockUser, unmatchUser } = useSafety();
+ const { blockUser, unmatchUser, error: safetyError } = useSafety();
const safetyRef = useRef(null);
const candidate = useCachedCandidate(pubkey);
const displayName =
candidate?.profile.display_name?.trim() || (pubkey ? shortPubkey(pubkey) : 'Chat');
const photoUrl = candidate?.profile.photos?.find((p) => p.url.length > 0)?.url;
+ const evidenceEventIds = messages
+ .filter((message) => !message.pending)
+ .slice(-5)
+ .map((message) => message.id)
+ .join(',');
// Opening the conversation clears its badge; new arrivals while it is open
// are already visible, so they must not re-raise it either.
@@ -58,13 +63,20 @@ export default function ChatScreen() {
}, [router, pubkey]);
const openReport = useCallback(() => {
- router.push({ pathname: '/report', params: { pubkey, name: displayName } });
- }, [router, pubkey, displayName]);
+ router.push({
+ pathname: '/report',
+ params: {
+ pubkey,
+ name: displayName,
+ ...(evidenceEventIds ? { evidence_event_ids: evidenceEventIds } : {}),
+ },
+ });
+ }, [router, pubkey, displayName, evidenceEventIds]);
const confirmUnmatch = useCallback(() => {
if (Platform.OS === 'web') {
if (globalThis.confirm("Unmatch? You'll both disappear from each other's matches.")) {
- void unmatchUser(pubkey).then(goBack);
+ void unmatchUser(pubkey).then(goBack).catch(() => {});
}
return;
}
@@ -74,7 +86,7 @@ export default function ChatScreen() {
text: 'Unmatch',
style: 'destructive',
onPress: () => {
- void unmatchUser(pubkey).then(goBack);
+ void unmatchUser(pubkey).then(goBack).catch(() => {});
},
},
]);
@@ -83,7 +95,7 @@ export default function ChatScreen() {
const confirmBlock = useCallback(() => {
if (Platform.OS === 'web') {
if (globalThis.confirm("Block? They won't be able to message you or see you in discovery.")) {
- void blockUser(pubkey).then(goBack);
+ void blockUser(pubkey).then(goBack).catch(() => {});
}
return;
}
@@ -93,7 +105,7 @@ export default function ChatScreen() {
text: 'Block',
style: 'destructive',
onPress: () => {
- void blockUser(pubkey).then(goBack);
+ void blockUser(pubkey).then(goBack).catch(() => {});
},
},
]);
@@ -211,9 +223,11 @@ export default function ChatScreen() {
all at once. */}
- {error ? (
+ {error || safetyError ? (
- {error}
+
+ {error || safetyError}
+
) : null}
@@ -225,8 +239,9 @@ export default function ChatScreen() {
ref={safetyRef}
targetPubkey={pubkey}
targetName={displayName}
- onUnmatch={() => void unmatchUser(pubkey).then(goBack)}
- onBlock={() => void blockUser(pubkey).then(goBack)}
+ evidenceEventIds={evidenceEventIds}
+ onUnmatch={() => void unmatchUser(pubkey).then(goBack).catch(() => {})}
+ onBlock={() => void blockUser(pubkey).then(goBack).catch(() => {})}
/>
);
diff --git a/src/app/filters.tsx b/src/app/filters.tsx
index 55165be..9e75023 100644
--- a/src/app/filters.tsx
+++ b/src/app/filters.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -9,6 +9,7 @@ import { radius } from '@/theme/radius';
import {
applyDiscoveryPreferences,
getDiscoveryPreferences,
+ hydrateDiscoveryPreferences,
kmToMiles,
resetDiscoveryPreferences,
} from '@/features/discovery/discovery-preferences';
@@ -29,9 +30,9 @@ export default function FiltersScreen() {
const saved = getDiscoveryPreferences();
return saved.genders ?? [];
});
- const [intents, setIntents] = useState(() => {
+ const [intent, setIntent] = useState(() => {
const saved = getDiscoveryPreferences();
- return saved.relationship_intents ?? [];
+ return saved.intent;
});
const [minAge, setMinAge] = useState(() => {
const saved = getDiscoveryPreferences();
@@ -50,9 +51,15 @@ export default function FiltersScreen() {
setGenders((prev) => (prev.includes(g) ? prev.filter((x) => x !== g) : [...prev, g]));
};
- const toggleIntent = (i: string) => {
- setIntents((prev) => (prev.includes(i) ? prev.filter((x) => x !== i) : [...prev, i]));
- };
+ useEffect(() => {
+ void hydrateDiscoveryPreferences().then((saved) => {
+ setGenders(saved.genders ?? []);
+ setIntent(saved.intent);
+ setMinAge(saved.min_age ?? 18);
+ setMaxAge(saved.max_age != null ? Math.min(saved.max_age, 80) : 55);
+ setDistance(saved.max_distance_km != null ? kmToMiles(saved.max_distance_km) : 25);
+ });
+ }, []);
// Apply persists the filters (local store + server, best-effort), then
// closes the sheet.
@@ -62,14 +69,14 @@ export default function FiltersScreen() {
max_age: maxAge,
max_distance_km: Math.round(distance * KM_PER_MILE),
genders: genders.length > 0 ? genders : undefined,
- relationship_intents: intents.length > 0 ? intents : undefined,
+ intent,
});
router.back();
};
const handleReset = async () => {
setGenders([]);
- setIntents([]);
+ setIntent(undefined);
setMinAge(18);
setMaxAge(55);
setDistance(25);
@@ -191,16 +198,16 @@ export default function FiltersScreen() {
{INTENT_OPTIONS.map((option) => (
toggleIntent(option.value)}
+ onPress={() => setIntent((value) => (value === option.value ? undefined : option.value))}
style={[
styles.chip,
{
- backgroundColor: intents.includes(option.value) ? colors.accent : colors.surface,
- borderColor: intents.includes(option.value) ? colors.accent : colors.border,
+ backgroundColor: intent === option.value ? colors.accent : colors.surface,
+ borderColor: intent === option.value ? colors.accent : colors.border,
},
]}
>
-
+
{option.label}
diff --git a/src/app/settings/index.tsx b/src/app/settings/index.tsx
index 3b6b302..ed5318f 100644
--- a/src/app/settings/index.tsx
+++ b/src/app/settings/index.tsx
@@ -1,7 +1,7 @@
// Settings — grouped menu with account, discovery, about, and danger sections.
import { useCallback, useEffect, useState } from 'react';
-import { ActivityIndicator, Alert, Platform, Pressable, StyleSheet, Switch, Text, View } from 'react-native';
+import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
import { useRouter, type Href } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar';
@@ -14,6 +14,7 @@ import { typography } from '@/theme/typography';
import { spacing } from '@/theme/spacing';
import { radius } from '@/theme/radius';
import { ChevronRight } from '@/components/chevron';
+import { ConfirmationDialog } from '@/components/confirmation-dialog';
interface MenuRowProps {
label: string;
@@ -78,13 +79,14 @@ function SectionHeader({ children }: { children: string }) {
export default function SettingsScreen() {
const router = useRouter();
- const { colors, isDark } = useTheme();
+ const { colors, isDark, themePreference, setThemePreference } = useTheme();
const revenueCat = useRevenueCat();
const [profileLoaded, setProfileLoaded] = useState(false);
const [paused, setPaused] = useState(false);
const [visibility, setVisibility] = useState('visible');
const [pausing, setPausing] = useState(false);
+ const [pendingVisibility, setPendingVisibility] = useState(null);
const loadProfile = useCallback(async () => {
try {
@@ -149,9 +151,7 @@ export default function SettingsScreen() {
const changeVisibility = useCallback(() => {
if (Platform.OS === 'web') {
const next = visibility === 'hidden' ? 'visible' : 'hidden';
- if (globalThis.confirm(`Make your profile ${next} in discovery?`)) {
- void updateVisibility(next);
- }
+ setPendingVisibility(next);
return;
}
Alert.alert(
@@ -172,6 +172,15 @@ export default function SettingsScreen() {
}, [updateVisibility, visibility]);
const appVersion = Constants.expoConfig?.version ?? '1.0.0';
+ const cycleTheme = useCallback(() => {
+ setThemePreference(
+ themePreference === 'system'
+ ? 'light'
+ : themePreference === 'light'
+ ? 'dark'
+ : 'system'
+ );
+ }, [setThemePreference, themePreference]);
return (
-
+
{/* Account */}
Account
@@ -250,6 +259,12 @@ export default function SettingsScreen() {
{/* About */}
About
+
+ router.push('/settings/privacy')}
@@ -283,7 +298,20 @@ export default function SettingsScreen() {
OpenDating v{appVersion}
-
+
+ setPendingVisibility(null)}
+ onConfirm={() => {
+ const next = pendingVisibility;
+ setPendingVisibility(null);
+ if (next) void updateVisibility(next);
+ }}
+ />
);
}
@@ -293,7 +321,7 @@ const styles = StyleSheet.create({
flex: 1,
},
content: {
- flex: 1,
+ flexGrow: 1,
padding: spacing.lg,
paddingTop: spacing.md,
},
diff --git a/src/app/unlock.tsx b/src/app/unlock.tsx
index f66d423..6f04c23 100644
--- a/src/app/unlock.tsx
+++ b/src/app/unlock.tsx
@@ -1,10 +1,11 @@
import { useCallback, useState } from 'react';
-import { Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
+import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar';
import { BrandMark } from '@/components/brand/brand-mark';
+import { ConfirmationDialog } from '@/components/confirmation-dialog';
import { getOpenDatingClient, resetOpenDatingClient } from '@/lib/opendating/open-dating-client';
import { storage } from '@/lib/storage';
import { useTheme } from '@/state/theme-context';
@@ -18,6 +19,7 @@ export default function UnlockScreen() {
const [passphrase, setPassphrase] = useState('');
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
+ const [confirmDifferentKey, setConfirmDifferentKey] = useState(false);
const unlock = useCallback(async () => {
if (!passphrase || busy) return;
@@ -35,12 +37,6 @@ export default function UnlockScreen() {
}, [busy, passphrase, router]);
const handleDifferentKey = useCallback(async () => {
- const confirmed =
- Platform.OS !== 'web' ||
- globalThis.confirm(
- 'Remove the account copy stored in this browser? This does not delete server data. Make sure you have your recovery key.'
- );
- if (!confirmed) return;
await getOpenDatingClient().deleteIdentity();
await storage.clearAll();
await resetOpenDatingClient();
@@ -85,10 +81,22 @@ export default function UnlockScreen() {
{busy ? 'Unlocking…' : 'Unlock'}
- void handleDifferentKey()} accessibilityRole="button">
+ setConfirmDifferentKey(true)} accessibilityRole="button">
Use a different recovery key
+ setConfirmDifferentKey(false)}
+ onConfirm={() => {
+ setConfirmDifferentKey(false);
+ void handleDifferentKey();
+ }}
+ />
);
}
diff --git a/src/components/confirmation-dialog.tsx b/src/components/confirmation-dialog.tsx
new file mode 100644
index 0000000..5ecdd3c
--- /dev/null
+++ b/src/components/confirmation-dialog.tsx
@@ -0,0 +1,102 @@
+import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
+import { useTheme } from '@/state/theme-context';
+import { radius } from '@/theme/radius';
+import { spacing } from '@/theme/spacing';
+import { typography } from '@/theme/typography';
+
+interface ConfirmationDialogProps {
+ visible: boolean;
+ title: string;
+ message: string;
+ confirmLabel: string;
+ destructive?: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+export function ConfirmationDialog({
+ visible,
+ title,
+ message,
+ confirmLabel,
+ destructive = false,
+ onConfirm,
+ onCancel,
+}: ConfirmationDialogProps) {
+ const { colors } = useTheme();
+
+ return (
+
+
+
+
+ {title}
+ {message}
+
+ [
+ styles.button,
+ { backgroundColor: colors.surfaceElevated, borderColor: colors.border },
+ pressed && styles.pressed,
+ ]}
+ >
+ Cancel
+
+ [
+ styles.button,
+ {
+ backgroundColor: destructive ? colors.destructive : colors.accent,
+ borderColor: destructive ? colors.destructive : colors.accent,
+ },
+ pressed && styles.pressed,
+ ]}
+ >
+
+ {confirmLabel}
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ backdrop: { ...StyleSheet.absoluteFill },
+ overlay: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'rgba(0, 0, 0, 0.45)',
+ paddingHorizontal: spacing.xxl,
+ },
+ card: {
+ width: '100%',
+ maxWidth: 380,
+ borderRadius: radius.xl,
+ borderWidth: StyleSheet.hairlineWidth,
+ padding: spacing.xl,
+ gap: spacing.md,
+ },
+ actions: { flexDirection: 'row', gap: spacing.md, marginTop: spacing.sm },
+ button: {
+ flex: 1,
+ minHeight: 48,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: radius.lg,
+ borderWidth: 1,
+ paddingHorizontal: spacing.md,
+ },
+ pressed: { opacity: 0.82 },
+});
diff --git a/src/components/safety/block-confirmation.tsx b/src/components/safety/block-confirmation.tsx
deleted file mode 100644
index d85e778..0000000
--- a/src/components/safety/block-confirmation.tsx
+++ /dev/null
@@ -1,147 +0,0 @@
-// Block confirmation dialog — explains consequences, destructive confirm
-import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
-import { useTheme } from '@/state/theme-context';
-import { radius } from '@/theme/radius';
-import { spacing } from '@/theme/spacing';
-import { typography } from '@/theme/typography';
-
-interface BlockConfirmationProps {
- targetName: string;
- onConfirm: () => void;
- onCancel: () => void;
- visible: boolean;
-}
-
-export function BlockConfirmation({
- targetName,
- onConfirm,
- onCancel,
- visible,
-}: BlockConfirmationProps) {
- const { colors } = useTheme();
-
- return (
-
- {/* Backdrop */}
-
-
-
-
- Block {targetName}?
-
-
- You won't see each other in discovery anymore, and{" "}
- {targetName} won't be able to message you or match with you.
-
-
- They won't be notified.
-
-
-
- [
- styles.button,
- styles.buttonSecondary,
- { backgroundColor: colors.surfaceElevated, borderColor: colors.border },
- pressed && styles.buttonPressed,
- ]}
- >
-
- Cancel
-
-
- [
- styles.button,
- { backgroundColor: colors.destructive },
- pressed && styles.buttonPressed,
- ]}
- >
-
- Block
-
-
-
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- backdrop: {
- ...StyleSheet.absoluteFill,
- },
- backdropOverlay: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: 'rgba(0, 0, 0, 0.45)',
- paddingHorizontal: spacing.xxl,
- },
- card: {
- width: '100%',
- maxWidth: 340,
- borderRadius: radius.xl,
- borderWidth: StyleSheet.hairlineWidth,
- padding: spacing.xl,
- gap: spacing.md,
- },
- title: {
- ...typography.headlineMedium,
- },
- body: {
- ...typography.bodyMedium,
- },
- notice: {
- ...typography.bodyMedium,
- fontWeight: '600',
- },
- actions: {
- flexDirection: 'row',
- gap: spacing.md,
- marginTop: spacing.sm,
- },
- button: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- height: 48,
- borderRadius: radius.lg,
- borderWidth: StyleSheet.hairlineWidth,
- },
- buttonSecondary: {
- borderWidth: 1,
- },
- buttonPressed: {
- opacity: 0.85,
- },
- buttonLabel: {
- ...typography.labelLarge,
- },
-});
diff --git a/src/components/safety/safety-menu.tsx b/src/components/safety/safety-menu.tsx
index 3029d80..a5895f4 100644
--- a/src/components/safety/safety-menu.tsx
+++ b/src/components/safety/safety-menu.tsx
@@ -12,6 +12,7 @@ import {
View,
} from 'react-native';
import { useRouter } from 'expo-router';
+import { ConfirmationDialog } from '@/components/confirmation-dialog';
import { useTheme } from '@/state/theme-context';
import { radius } from '@/theme/radius';
import { spacing } from '@/theme/spacing';
@@ -26,6 +27,7 @@ interface SafetyMenuProps {
targetName: string;
onUnmatch?: () => void;
onBlock?: () => void;
+ evidenceEventIds?: string;
/** Extra hook invoked after navigating to /report */
onReport?: () => void;
}
@@ -34,28 +36,30 @@ type SafetyAction = 'unmatch' | 'block' | 'report';
export const SafetyMenu = forwardRef(
function SafetyMenu(
- { targetPubkey, targetName, onUnmatch, onBlock, onReport },
+ { targetPubkey, targetName, onUnmatch, onBlock, evidenceEventIds, onReport },
ref
) {
const router = useRouter();
const { colors } = useTheme();
const [sheetVisible, setSheetVisible] = useState(false);
+ const [confirmation, setConfirmation] = useState | null>(null);
const handleReport = useCallback(() => {
// Navigate to the report flow with the target's pubkey
- router.push({ pathname: '/report', params: { pubkey: targetPubkey } });
+ router.push({
+ pathname: '/report',
+ params: {
+ pubkey: targetPubkey,
+ name: targetName,
+ ...(evidenceEventIds ? { evidence_event_ids: evidenceEventIds } : {}),
+ },
+ });
onReport?.();
- }, [router, targetPubkey, onReport]);
+ }, [router, targetName, targetPubkey, evidenceEventIds, onReport]);
const confirmUnmatch = useCallback(() => {
if (Platform.OS === 'web') {
- if (
- window.confirm(
- `Unmatch ${targetName}? You won't be able to message each other anymore.`
- )
- ) {
- onUnmatch?.();
- }
+ setConfirmation('unmatch');
return;
}
Alert.alert(
@@ -74,13 +78,7 @@ export const SafetyMenu = forwardRef(
const confirmBlock = useCallback(() => {
if (Platform.OS === 'web') {
- if (
- window.confirm(
- `Block ${targetName}? You won't see each other in discovery or be able to message or match. They won't be notified.`
- )
- ) {
- onBlock?.();
- }
+ setConfirmation('block');
return;
}
Alert.alert(
@@ -113,29 +111,35 @@ export const SafetyMenu = forwardRef(
const present = useCallback(() => {
if (Platform.OS === 'ios') {
+ const actions = [
+ ...(onUnmatch ? [{ label: 'Unmatch', run: confirmUnmatch }] : []),
+ ...(onBlock ? [{ label: 'Block', run: confirmBlock }] : []),
+ { label: 'Report', run: handleReport },
+ ];
ActionSheetIOS.showActionSheetWithOptions(
{
title: targetName,
message: 'Choose an action',
- options: ['Unmatch', 'Block', 'Report', 'Cancel'],
- cancelButtonIndex: 3,
- destructiveButtonIndex: 0,
+ options: [...actions.map((action) => action.label), 'Cancel'],
+ cancelButtonIndex: actions.length,
+ destructiveButtonIndex: actions
+ .map((action, index) => (action.label === 'Report' ? -1 : index))
+ .filter((index) => index >= 0),
},
(index) => {
- if (index === 0) confirmUnmatch();
- else if (index === 1) confirmBlock();
- else if (index === 2) handleReport();
+ actions[index]?.run();
}
);
} else {
// Android / Web — themed bottom sheet
setSheetVisible(true);
}
- }, [targetName, confirmUnmatch, confirmBlock, handleReport]);
+ }, [targetName, onUnmatch, onBlock, confirmUnmatch, confirmBlock, handleReport]);
useImperativeHandle(ref, () => ({ present }), [present]);
return (
+ <>
(
{targetName}
- choose('unmatch')}
- style={({ pressed }) => [
- styles.option,
- pressed && styles.optionPressed,
- ]}
- >
-
- Unmatch
-
-
-
- choose('block')}
- style={({ pressed }) => [
- styles.option,
- pressed && styles.optionPressed,
- ]}
- >
-
- Block
-
-
-
+ {onUnmatch ? (
+ <>
+ choose('unmatch')}
+ style={({ pressed }) => [styles.option, pressed && styles.optionPressed]}
+ >
+ Unmatch
+
+
+ >
+ ) : null}
+ {onBlock ? (
+ <>
+ choose('block')}
+ style={({ pressed }) => [styles.option, pressed && styles.optionPressed]}
+ >
+ Block
+
+
+ >
+ ) : null}
choose('report')}
@@ -217,6 +219,25 @@ export const SafetyMenu = forwardRef(
+ setConfirmation(null)}
+ onConfirm={() => {
+ const action = confirmation;
+ setConfirmation(null);
+ if (action === 'block') onBlock?.();
+ else if (action === 'unmatch') onUnmatch?.();
+ }}
+ />
+ >
);
}
);
diff --git a/src/features/discovery/__tests__/discovery-preferences.test.ts b/src/features/discovery/__tests__/discovery-preferences.test.ts
new file mode 100644
index 0000000..46ebc56
--- /dev/null
+++ b/src/features/discovery/__tests__/discovery-preferences.test.ts
@@ -0,0 +1,36 @@
+import {
+ applyDiscoveryPreferences,
+ getDiscoveryPreferences,
+} from '@/features/discovery/discovery-preferences';
+import { storage } from '@/lib/storage';
+
+const mockUpdateDiscoveryPreferences = jest.fn();
+
+jest.mock('@/lib/storage', () => ({
+ storage: {
+ getDiscoveryPreferences: jest.fn(),
+ saveDiscoveryPreferences: jest.fn(),
+ },
+}));
+jest.mock('@/lib/opendating/open-dating-client', () => ({
+ getOpenDatingClient: () => ({
+ updateDiscoveryPreferences: mockUpdateDiscoveryPreferences,
+ }),
+}));
+
+it('keeps one relationship intent and persists the applied filters', async () => {
+ mockUpdateDiscoveryPreferences.mockResolvedValue(undefined);
+
+ const preferences = {
+ min_age: 25,
+ max_age: 45,
+ max_distance_km: 40,
+ genders: ['woman'],
+ intent: 'long_term',
+ };
+ await applyDiscoveryPreferences(preferences);
+
+ expect(getDiscoveryPreferences()).toEqual(preferences);
+ expect(storage.saveDiscoveryPreferences).toHaveBeenCalledWith(preferences);
+ expect(mockUpdateDiscoveryPreferences).toHaveBeenCalledWith(preferences);
+});
diff --git a/src/features/discovery/discovery-preferences.ts b/src/features/discovery/discovery-preferences.ts
index c4e4fc4..8833395 100644
--- a/src/features/discovery/discovery-preferences.ts
+++ b/src/features/discovery/discovery-preferences.ts
@@ -4,6 +4,7 @@
import type { DiscoveryPreferences } from '@/types/opendating';
import { getOpenDatingClient } from '@/lib/opendating/open-dating-client';
+import { storage } from '@/lib/storage';
const KM_PER_MILE = 1.609344;
@@ -12,7 +13,7 @@ export const DEFAULT_DISCOVERY_PREFERENCES: DiscoveryPreferences = {
max_age: 99,
max_distance_km: 80, // ~50 mi
genders: undefined,
- relationship_intents: undefined,
+ intent: undefined,
};
let current: DiscoveryPreferences = { ...DEFAULT_DISCOVERY_PREFERENCES };
@@ -22,6 +23,12 @@ export function getDiscoveryPreferences(): DiscoveryPreferences {
return current;
}
+export async function hydrateDiscoveryPreferences(): Promise {
+ const saved = await storage.getDiscoveryPreferences();
+ if (saved) current = { ...DEFAULT_DISCOVERY_PREFERENCES, ...saved };
+ return current;
+}
+
export function kmToMiles(km: number): number {
return Math.round(km / KM_PER_MILE);
}
@@ -41,6 +48,12 @@ export async function applyDiscoveryPreferences(
current = { ...next };
for (const fn of listeners) fn();
+ try {
+ await storage.saveDiscoveryPreferences(current);
+ } catch {
+ // Server sync can still succeed when local browser storage is unavailable.
+ }
+
try {
await getOpenDatingClient().updateDiscoveryPreferences(current);
} catch {
diff --git a/src/features/messaging/__tests__/message-history.test.ts b/src/features/messaging/__tests__/message-history.test.ts
new file mode 100644
index 0000000..58a68ac
--- /dev/null
+++ b/src/features/messaging/__tests__/message-history.test.ts
@@ -0,0 +1,21 @@
+import { rememberMessage } from '@/features/messaging/message-history';
+import type { ODMessage } from '@/types/opendating';
+
+function message(id: string): ODMessage {
+ return {
+ id,
+ sender_pubkey: 'a'.repeat(64),
+ recipient_pubkey: 'b'.repeat(64),
+ conversation_pubkey: 'a'.repeat(64),
+ text: id,
+ created_at: 1,
+ outgoing: false,
+ };
+}
+
+it('keeps the newest bounded in-memory message history', () => {
+ const history = [message('first')];
+ rememberMessage(history, message('second'), 2);
+ rememberMessage(history, message('third'), 2);
+ expect(history.map(({ id }) => id)).toEqual(['second', 'third']);
+});
diff --git a/src/features/messaging/message-history.ts b/src/features/messaging/message-history.ts
new file mode 100644
index 0000000..ec318ad
--- /dev/null
+++ b/src/features/messaging/message-history.ts
@@ -0,0 +1,10 @@
+import type { ODMessage } from '@/types/opendating';
+
+export function rememberMessage(
+ history: ODMessage[],
+ message: ODMessage,
+ limit = 500
+): void {
+ history.push(message);
+ if (history.length > limit) history.splice(0, history.length - limit);
+}
diff --git a/src/features/safety/use-safety.ts b/src/features/safety/use-safety.ts
index d2a201f..19b39fb 100644
--- a/src/features/safety/use-safety.ts
+++ b/src/features/safety/use-safety.ts
@@ -73,6 +73,7 @@ export function useSafety(): UseSafetyResult {
// Reconcile: revert the optimistic block.
setBlocks((prev) => prev.filter((b) => b.target_pubkey !== pubkey));
setError(toUserMessage(err));
+ throw err;
}
},
[isBlocked]
@@ -101,6 +102,7 @@ export function useSafety(): UseSafetyResult {
: [removed, ...prev]
);
setError(toUserMessage(err));
+ throw err;
}
},
[blocks]
@@ -112,6 +114,7 @@ export function useSafety(): UseSafetyResult {
await getOpenDatingClient().unmatch(pubkey);
} catch (err) {
setError(toUserMessage(err));
+ throw err;
}
}, []);
@@ -121,6 +124,7 @@ export function useSafety(): UseSafetyResult {
await getOpenDatingClient().report(report);
} catch (err) {
setError(toUserMessage(err));
+ throw err;
}
}, []);
diff --git a/src/lib/opendating/open-dating-client.ts b/src/lib/opendating/open-dating-client.ts
index edf031a..ebacd72 100644
--- a/src/lib/opendating/open-dating-client.ts
+++ b/src/lib/opendating/open-dating-client.ts
@@ -48,6 +48,7 @@ import { mapServiceError, ServiceUnavailableError } from './errors';
import { parseCapabilities, serviceLabel } from './capabilities';
import { unwrapGiftWrap } from './gift-wrap';
import { uploadPendingPhotos } from './media';
+import { rememberMessage } from '@/features/messaging/message-history';
import { identityVault, type IdentityState } from '@/lib/storage/identity-vault';
import { storage } from '@/lib/storage';
import {
@@ -121,8 +122,8 @@ class OpenDatingClientImpl {
private messageListeners = new Set<(msg: ODMessage) => void>();
/** Rumor ids already delivered — relays re-send, and we must not double-fire. */
private seenRumorIds = new Set();
- /** DMs that arrive during bootstrap, before the conversation hook mounts. */
- private bufferedMessages: ODMessage[] = [];
+ /** Bounded, memory-only replay for every conversation subscriber. */
+ private messageHistory: ODMessage[] = [];
/** From the relay's NIP-11 `limitation.auth_required`. */
private relayRequiresAuth = true;
@@ -208,7 +209,7 @@ class OpenDatingClientImpl {
await identityVault.clear();
this.userPubkey = '';
this.userPrivkey = '';
- this.bufferedMessages = [];
+ this.messageHistory = [];
}
async lockIdentity(): Promise {
@@ -216,7 +217,7 @@ class OpenDatingClientImpl {
await identityVault.lock();
this.userPubkey = '';
this.userPrivkey = '';
- this.bufferedMessages = [];
+ this.messageHistory = [];
}
// ---- Connection ----
@@ -348,7 +349,7 @@ class OpenDatingClientImpl {
}
this.pendingRequests.clear();
this.seenRumorIds.clear();
- this.bufferedMessages = [];
+ this.messageHistory = [];
// NDK manages relay connections via its pool; no explicit disconnect() method.
// Just drop the reference and let GC handle cleanup.
@@ -661,13 +662,9 @@ class OpenDatingClientImpl {
outgoing,
};
- if (this.messageListeners.size === 0) {
- this.bufferedMessages.push(msg);
- if (this.bufferedMessages.length > 100) this.bufferedMessages.shift();
- } else {
- for (const listener of this.messageListeners) {
- listener(msg);
- }
+ rememberMessage(this.messageHistory, msg);
+ for (const listener of this.messageListeners) {
+ listener(msg);
}
return true;
}
@@ -889,9 +886,7 @@ class OpenDatingClientImpl {
subscribeToMessages(callback: (msg: ODMessage) => void): () => void {
this.messageListeners.add(callback);
- const buffered = this.bufferedMessages;
- this.bufferedMessages = [];
- for (const message of buffered) callback(message);
+ for (const message of this.messageHistory) callback(message);
// The inbox subscription is opened on connect and shared by every
// listener, so attaching here never opens a second relay subscription.
this.startInboxSubscription();
diff --git a/src/lib/storage/index.ts b/src/lib/storage/index.ts
index b2f7a90..ea355bc 100644
--- a/src/lib/storage/index.ts
+++ b/src/lib/storage/index.ts
@@ -57,6 +57,7 @@ const STORAGE_KEYS = {
PROFILE_CONTENT: 'opendating_profile_content',
ONBOARDING_DRAFT: 'opendating_onboarding_draft',
POLICY_ACCEPTANCE: 'opendating_policy_acceptance',
+ DISCOVERY_PREFERENCES: 'opendating_discovery_preferences',
} as const;
export const storage = {
@@ -166,6 +167,21 @@ export const storage = {
return null;
},
+ async saveDiscoveryPreferences(preferences: object): Promise {
+ await AsyncStorage.setItem(
+ STORAGE_KEYS.DISCOVERY_PREFERENCES,
+ JSON.stringify(preferences)
+ );
+ },
+ async getDiscoveryPreferences(): Promise {
+ try {
+ const raw = await AsyncStorage.getItem(STORAGE_KEYS.DISCOVERY_PREFERENCES);
+ return raw ? (JSON.parse(raw) as T) : null;
+ } catch {
+ return null;
+ }
+ },
+
// Clear all app data
async clearAll(): Promise {
for (const key of Object.values(STORAGE_KEYS)) {
diff --git a/src/lib/storage/index.web.ts b/src/lib/storage/index.web.ts
index 8b2e0f4..4eadadd 100644
--- a/src/lib/storage/index.web.ts
+++ b/src/lib/storage/index.web.ts
@@ -10,6 +10,7 @@ const STORAGE_KEYS = {
PROFILE_CONTENT: 'opendating_profile_content',
ONBOARDING_DRAFT: 'opendating_onboarding_draft',
POLICY_ACCEPTANCE: 'opendating_policy_acceptance',
+ DISCOVERY_PREFERENCES: 'opendating_discovery_preferences',
} as const;
async function readJson(key: string): Promise {
@@ -87,6 +88,16 @@ export const storage = {
return value === 'light' || value === 'dark' || value === 'system' ? value : null;
},
+ async saveDiscoveryPreferences(preferences: object): Promise {
+ await AsyncStorage.setItem(
+ STORAGE_KEYS.DISCOVERY_PREFERENCES,
+ JSON.stringify(preferences)
+ );
+ },
+ async getDiscoveryPreferences(): Promise {
+ return readJson(STORAGE_KEYS.DISCOVERY_PREFERENCES);
+ },
+
async clearAll(): Promise {
await AsyncStorage.multiRemove(Object.values(STORAGE_KEYS));
},
diff --git a/src/state/theme-context.tsx b/src/state/theme-context.tsx
index e30053f..8586bd5 100644
--- a/src/state/theme-context.tsx
+++ b/src/state/theme-context.tsx
@@ -1,27 +1,54 @@
// Theme context — provides colors, spacing, etc. based on color scheme
-import React, { createContext, useContext, useMemo } from 'react';
+import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useColorScheme } from 'react-native';
+import { storage } from '@/lib/storage';
import { colors, type ThemeColors } from '@/theme/colors';
export type { ThemeColors };
+export type ThemePreference = 'light' | 'dark' | 'system';
interface ThemeContextValue {
colors: ThemeColors;
isDark: boolean;
+ themePreference: ThemePreference;
+ setThemePreference: (preference: ThemePreference) => void;
}
const ThemeContext = createContext({
colors: colors.light,
isDark: false,
+ themePreference: 'system',
+ setThemePreference: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const systemScheme = useColorScheme();
- const isDark = systemScheme === 'dark';
+ const [themePreference, setPreference] = useState('system');
+ const isDark =
+ themePreference === 'dark' ||
+ (themePreference === 'system' && systemScheme === 'dark');
const themeColors: ThemeColors = isDark ? colors.dark : colors.light;
- const value = useMemo(() => ({ colors: themeColors, isDark }), [isDark, themeColors]);
+ useEffect(() => {
+ let active = true;
+ void storage.getThemePreference().then((saved) => {
+ if (active && saved) setPreference(saved);
+ });
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ const setThemePreference = useCallback((preference: ThemePreference) => {
+ setPreference(preference);
+ void storage.saveThemePreference(preference);
+ }, []);
+
+ const value = useMemo(
+ () => ({ colors: themeColors, isDark, themePreference, setThemePreference }),
+ [isDark, setThemePreference, themeColors, themePreference]
+ );
return (
{children}
diff --git a/src/types/opendating.ts b/src/types/opendating.ts
index ece8449..f16d600 100644
--- a/src/types/opendating.ts
+++ b/src/types/opendating.ts
@@ -44,7 +44,6 @@ export interface DiscoveryPreferences {
max_age?: number;
intent?: string;
genders?: string[];
- relationship_intents?: string[];
}
export interface CandidateQuery {
From 510c6ac94c39122e89db4a20bee9c4f23b27fce4 Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 13:35:07 -0400
Subject: [PATCH 18/39] Resume persisted onboarding drafts
---
src/app/index.tsx | 13 +++++++++++-
.../__tests__/onboarding-resume.test.ts | 17 +++++++++++++++
src/features/onboarding/onboarding-draft.tsx | 11 ++++++++--
src/features/onboarding/onboarding-resume.ts | 21 +++++++++++++++++++
4 files changed, 59 insertions(+), 3 deletions(-)
create mode 100644 src/features/onboarding/__tests__/onboarding-resume.test.ts
create mode 100644 src/features/onboarding/onboarding-resume.ts
diff --git a/src/app/index.tsx b/src/app/index.tsx
index 60a0157..27774a0 100644
--- a/src/app/index.tsx
+++ b/src/app/index.tsx
@@ -12,6 +12,9 @@ import { typography } from '@/theme/typography';
import { spacing } from '@/theme/spacing';
import { radius } from '@/theme/radius';
import { StatusBar } from 'expo-status-bar';
+import { storage } from '@/lib/storage';
+import type { OnboardingDraft } from '@/features/onboarding/onboarding-draft';
+import { getOnboardingResumePath } from '@/features/onboarding/onboarding-resume';
const STATUS_COPY: Record = {
loading: 'Loading…',
@@ -30,6 +33,7 @@ export default function BootstrapScreen() {
const effectiveState = isScreenshotMode ? 'no_identity' : state;
useEffect(() => {
+ let active = true;
switch (effectiveState) {
case 'no_identity':
router.replace('/(onboarding)/welcome');
@@ -38,13 +42,20 @@ export default function BootstrapScreen() {
router.replace('/unlock');
break;
case 'no_profile':
- router.replace('/(onboarding)/basics');
+ void storage
+ .getOnboardingDraft>()
+ .then((saved) => {
+ if (active) router.replace(getOnboardingResumePath(saved));
+ });
break;
case 'ready':
router.replace('/(tabs)/discover');
break;
// loading, connecting, services_unavailable, error — handled below
}
+ return () => {
+ active = false;
+ };
}, [effectiveState, router]);
return (
diff --git a/src/features/onboarding/__tests__/onboarding-resume.test.ts b/src/features/onboarding/__tests__/onboarding-resume.test.ts
new file mode 100644
index 0000000..2a5691c
--- /dev/null
+++ b/src/features/onboarding/__tests__/onboarding-resume.test.ts
@@ -0,0 +1,17 @@
+import { getOnboardingResumePath } from '@/features/onboarding/onboarding-resume';
+import { CURRENT_POLICY_VERSION } from '@/lib/policy';
+
+it('resumes a complete unpublished draft at review', () => {
+ expect(
+ getOnboardingResumePath({
+ displayName: 'QA Alice',
+ age: 29,
+ gender: 'woman',
+ intent: 'long_term',
+ policyAcceptance: {
+ version: CURRENT_POLICY_VERSION,
+ acceptedAt: '2026-08-29T12:00:00.000Z',
+ },
+ })
+ ).toBe('/(onboarding)/review');
+});
diff --git a/src/features/onboarding/onboarding-draft.tsx b/src/features/onboarding/onboarding-draft.tsx
index 8c3d138..528eaca 100644
--- a/src/features/onboarding/onboarding-draft.tsx
+++ b/src/features/onboarding/onboarding-draft.tsx
@@ -19,7 +19,10 @@ import React, {
} from 'react';
import { isScreenshotMode } from '@/constants/env';
import { storage } from '@/lib/storage';
-import { CURRENT_POLICY_VERSION, type PolicyAcceptance } from '@/lib/policy';
+import {
+ CURRENT_POLICY_VERSION,
+ type PolicyAcceptance,
+} from '@/lib/policy';
// ---- Option lists ----
// Re-exported from the canonical source so the onboarding pickers, the profile
@@ -139,6 +142,7 @@ export function OnboardingDraftProvider({
children: React.ReactNode;
}) {
const [draft, setDraft] = useState(DEFAULT_DRAFT);
+ const [hydrated, setHydrated] = useState(isScreenshotMode);
// Rehydrate anything from a previous run. Onboarding is eleven steps; losing
// it to a reload, a backgrounded app, or an OS memory kill left the member
@@ -158,6 +162,9 @@ export function OnboardingDraftProvider({
})
.catch(() => {
// A corrupt draft must not block onboarding — start fresh instead.
+ })
+ .finally(() => {
+ if (active) setHydrated(true);
});
return () => {
active = false;
@@ -186,7 +193,7 @@ export function OnboardingDraftProvider({
return (
- {children}
+ {hydrated ? children : null}
);
}
diff --git a/src/features/onboarding/onboarding-resume.ts b/src/features/onboarding/onboarding-resume.ts
new file mode 100644
index 0000000..f8c3134
--- /dev/null
+++ b/src/features/onboarding/onboarding-resume.ts
@@ -0,0 +1,21 @@
+import { isCurrentPolicy, type PolicyAcceptance } from '@/lib/policy';
+
+interface ResumeDraft {
+ policyAcceptance?: PolicyAcceptance | null;
+ displayName?: string;
+ age?: number | null;
+ gender?: string | null;
+ intent?: string | null;
+}
+
+export function getOnboardingResumePath(
+ saved: ResumeDraft | null
+): '/(onboarding)/privacy' | '/(onboarding)/basics' | '/(onboarding)/preferences' | '/(onboarding)/review' {
+ if (!isCurrentPolicy(saved?.policyAcceptance ?? null)) {
+ return '/(onboarding)/privacy';
+ }
+ if (!saved?.displayName || saved.age == null || !saved.gender) {
+ return '/(onboarding)/basics';
+ }
+ return saved.intent ? '/(onboarding)/review' : '/(onboarding)/preferences';
+}
From f7b0f0d31098a8b29bdcc0c4e59553803b159500 Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 13:41:27 -0400
Subject: [PATCH 19/39] Match web shell to app theme
---
src/app/_layout.tsx | 65 ++++++++++++++++++++++-----------------------
1 file changed, 32 insertions(+), 33 deletions(-)
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index 26c04ca..a6e8906 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -3,13 +3,12 @@
import 'react-native-get-random-values';
import { useEffect, useState } from 'react';
import { Stack, usePathname, useRouter } from 'expo-router';
-import { Platform, StyleSheet, useColorScheme } from 'react-native';
+import { Platform, StyleSheet } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
-import { ThemeProvider } from '@/state/theme-context';
+import { ThemeProvider, useTheme } from '@/state/theme-context';
import { RevenueCatProvider } from '@/state/revenuecat-context';
import { ErrorBoundary } from '@/components/error-boundary';
-import { colors } from '@/theme/colors';
import { StatusBar } from 'expo-status-bar';
import { getOpenDatingClient } from '@/lib/opendating/open-dating-client';
@@ -33,42 +32,42 @@ const PUBLIC_WEB_ROUTES = new Set([
]);
export default function RootLayout() {
- const colorScheme = useColorScheme();
- const isDark = colorScheme === 'dark';
-
return (
// GestureHandlerRootView must remain the outermost full-screen view for
// navigation and any gesture-driven controls used elsewhere in the app.
-
+
+
+
+
+ );
+}
+
+function ThemedRoot() {
+ const { colors, isDark } = useTheme();
+
+ useEffect(() => {
+ if (Platform.OS !== 'web') return;
+
+ document.documentElement.style.backgroundColor = colors.background;
+ document.body.style.backgroundColor = colors.background;
+ }, [colors.background]);
+
+ return (
+
- {/* Provides the insets that useSafeAreaInsets reads. Also absent, which
- left the chat composer measuring a zero bottom inset and sitting
- under the home indicator. */}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
);
}
From c9659b3279ee7597ee628f128215815de6b4142f Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 13:47:39 -0400
Subject: [PATCH 20/39] Lock persisted onboarding routes
---
src/app/_layout.tsx | 25 +++----------------
.../auth/__tests__/web-route-access.test.ts | 9 +++++++
src/features/auth/web-route-access.ts | 13 ++++++++++
3 files changed, 26 insertions(+), 21 deletions(-)
create mode 100644 src/features/auth/__tests__/web-route-access.test.ts
create mode 100644 src/features/auth/web-route-access.ts
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index a6e8906..477e348 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -11,25 +11,7 @@ import { RevenueCatProvider } from '@/state/revenuecat-context';
import { ErrorBoundary } from '@/components/error-boundary';
import { StatusBar } from 'expo-status-bar';
import { getOpenDatingClient } from '@/lib/opendating/open-dating-client';
-
-const PUBLIC_WEB_ROUTES = new Set([
- '/',
- '/unlock',
- '/welcome',
- '/create-account',
- '/import-account',
- '/privacy',
- '/basics',
- '/preferences',
- '/intent',
- '/about',
- '/photos',
- '/location',
- '/review',
- '/finish',
- '/settings/terms',
- '/settings/privacy',
-]);
+import { requiresWebIdentity } from '@/features/auth/web-route-access';
export default function RootLayout() {
return (
@@ -75,7 +57,8 @@ function AppNavigator() {
const pathname = usePathname();
const router = useRouter();
const [webRouteReady, setWebRouteReady] = useState(false);
- const isPublicWebRoute = Platform.OS !== 'web' || PUBLIC_WEB_ROUTES.has(pathname);
+ const isPublicWebRoute =
+ Platform.OS !== 'web' || !requiresWebIdentity(pathname);
useEffect(() => {
if (isPublicWebRoute) return;
@@ -87,7 +70,7 @@ function AppNavigator() {
.then((identityState) => {
if (!active) return;
if (identityState !== 'ready' || !client.getCapabilities()) {
- router.replace('/');
+ router.replace(identityState === 'locked' ? '/unlock' : '/');
return;
}
setWebRouteReady(true);
diff --git a/src/features/auth/__tests__/web-route-access.test.ts b/src/features/auth/__tests__/web-route-access.test.ts
new file mode 100644
index 0000000..009b2da
--- /dev/null
+++ b/src/features/auth/__tests__/web-route-access.test.ts
@@ -0,0 +1,9 @@
+import { requiresWebIdentity } from '@/features/auth/web-route-access';
+
+it('locks onboarding drafts while leaving account entry routes public', () => {
+ expect(requiresWebIdentity('/review')).toBe(true);
+ expect(requiresWebIdentity('/basics')).toBe(true);
+ expect(requiresWebIdentity('/welcome')).toBe(false);
+ expect(requiresWebIdentity('/create-account')).toBe(false);
+ expect(requiresWebIdentity('/settings/privacy')).toBe(false);
+});
diff --git a/src/features/auth/web-route-access.ts b/src/features/auth/web-route-access.ts
new file mode 100644
index 0000000..2e3671e
--- /dev/null
+++ b/src/features/auth/web-route-access.ts
@@ -0,0 +1,13 @@
+const IDENTITY_FREE_WEB_ROUTES = new Set([
+ '/',
+ '/unlock',
+ '/welcome',
+ '/create-account',
+ '/import-account',
+ '/settings/terms',
+ '/settings/privacy',
+]);
+
+export function requiresWebIdentity(pathname: string): boolean {
+ return !IDENTITY_FREE_WEB_ROUTES.has(pathname);
+}
From b845890800031f282330ea7ec609d5e3060f813c Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 13:56:37 -0400
Subject: [PATCH 21/39] Keep unlocked onboarding local
---
src/app/_layout.tsx | 13 ++++++++++---
.../auth/__tests__/web-route-access.test.ts | 7 ++++++-
src/features/auth/web-route-access.ts | 16 ++++++++++++++++
3 files changed, 32 insertions(+), 4 deletions(-)
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index 477e348..5ac6fc0 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -11,7 +11,10 @@ import { RevenueCatProvider } from '@/state/revenuecat-context';
import { ErrorBoundary } from '@/components/error-boundary';
import { StatusBar } from 'expo-status-bar';
import { getOpenDatingClient } from '@/lib/opendating/open-dating-client';
-import { requiresWebIdentity } from '@/features/auth/web-route-access';
+import {
+ isOnboardingWebRoute,
+ requiresWebIdentity,
+} from '@/features/auth/web-route-access';
export default function RootLayout() {
return (
@@ -69,10 +72,14 @@ function AppNavigator() {
.getIdentityState()
.then((identityState) => {
if (!active) return;
- if (identityState !== 'ready' || !client.getCapabilities()) {
+ if (identityState !== 'ready') {
router.replace(identityState === 'locked' ? '/unlock' : '/');
return;
}
+ if (!isOnboardingWebRoute(pathname) && !client.getCapabilities()) {
+ router.replace('/');
+ return;
+ }
setWebRouteReady(true);
})
.catch(() => {
@@ -82,7 +89,7 @@ function AppNavigator() {
return () => {
active = false;
};
- }, [isPublicWebRoute, router]);
+ }, [isPublicWebRoute, pathname, router]);
if (!isPublicWebRoute && !webRouteReady) return null;
diff --git a/src/features/auth/__tests__/web-route-access.test.ts b/src/features/auth/__tests__/web-route-access.test.ts
index 009b2da..03f16dc 100644
--- a/src/features/auth/__tests__/web-route-access.test.ts
+++ b/src/features/auth/__tests__/web-route-access.test.ts
@@ -1,4 +1,7 @@
-import { requiresWebIdentity } from '@/features/auth/web-route-access';
+import {
+ isOnboardingWebRoute,
+ requiresWebIdentity,
+} from '@/features/auth/web-route-access';
it('locks onboarding drafts while leaving account entry routes public', () => {
expect(requiresWebIdentity('/review')).toBe(true);
@@ -6,4 +9,6 @@ it('locks onboarding drafts while leaving account entry routes public', () => {
expect(requiresWebIdentity('/welcome')).toBe(false);
expect(requiresWebIdentity('/create-account')).toBe(false);
expect(requiresWebIdentity('/settings/privacy')).toBe(false);
+ expect(isOnboardingWebRoute('/privacy')).toBe(true);
+ expect(isOnboardingWebRoute('/discover')).toBe(false);
});
diff --git a/src/features/auth/web-route-access.ts b/src/features/auth/web-route-access.ts
index 2e3671e..a2b2e76 100644
--- a/src/features/auth/web-route-access.ts
+++ b/src/features/auth/web-route-access.ts
@@ -8,6 +8,22 @@ const IDENTITY_FREE_WEB_ROUTES = new Set([
'/settings/privacy',
]);
+const ONBOARDING_WEB_ROUTES = new Set([
+ '/privacy',
+ '/basics',
+ '/preferences',
+ '/intent',
+ '/about',
+ '/photos',
+ '/location',
+ '/review',
+ '/finish',
+]);
+
export function requiresWebIdentity(pathname: string): boolean {
return !IDENTITY_FREE_WEB_ROUTES.has(pathname);
}
+
+export function isOnboardingWebRoute(pathname: string): boolean {
+ return ONBOARDING_WEB_ROUTES.has(pathname);
+}
From cb3f90ddb802b7d864c82cdc37767553994226a3 Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 14:00:11 -0400
Subject: [PATCH 22/39] Decouple web route authorization
---
src/app/_layout.tsx | 9 +--------
.../auth/__tests__/web-route-access.test.ts | 7 +------
src/features/auth/web-route-access.ts | 16 ----------------
3 files changed, 2 insertions(+), 30 deletions(-)
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index 5ac6fc0..7c3c8eb 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -11,10 +11,7 @@ import { RevenueCatProvider } from '@/state/revenuecat-context';
import { ErrorBoundary } from '@/components/error-boundary';
import { StatusBar } from 'expo-status-bar';
import { getOpenDatingClient } from '@/lib/opendating/open-dating-client';
-import {
- isOnboardingWebRoute,
- requiresWebIdentity,
-} from '@/features/auth/web-route-access';
+import { requiresWebIdentity } from '@/features/auth/web-route-access';
export default function RootLayout() {
return (
@@ -76,10 +73,6 @@ function AppNavigator() {
router.replace(identityState === 'locked' ? '/unlock' : '/');
return;
}
- if (!isOnboardingWebRoute(pathname) && !client.getCapabilities()) {
- router.replace('/');
- return;
- }
setWebRouteReady(true);
})
.catch(() => {
diff --git a/src/features/auth/__tests__/web-route-access.test.ts b/src/features/auth/__tests__/web-route-access.test.ts
index 03f16dc..009b2da 100644
--- a/src/features/auth/__tests__/web-route-access.test.ts
+++ b/src/features/auth/__tests__/web-route-access.test.ts
@@ -1,7 +1,4 @@
-import {
- isOnboardingWebRoute,
- requiresWebIdentity,
-} from '@/features/auth/web-route-access';
+import { requiresWebIdentity } from '@/features/auth/web-route-access';
it('locks onboarding drafts while leaving account entry routes public', () => {
expect(requiresWebIdentity('/review')).toBe(true);
@@ -9,6 +6,4 @@ it('locks onboarding drafts while leaving account entry routes public', () => {
expect(requiresWebIdentity('/welcome')).toBe(false);
expect(requiresWebIdentity('/create-account')).toBe(false);
expect(requiresWebIdentity('/settings/privacy')).toBe(false);
- expect(isOnboardingWebRoute('/privacy')).toBe(true);
- expect(isOnboardingWebRoute('/discover')).toBe(false);
});
diff --git a/src/features/auth/web-route-access.ts b/src/features/auth/web-route-access.ts
index a2b2e76..2e3671e 100644
--- a/src/features/auth/web-route-access.ts
+++ b/src/features/auth/web-route-access.ts
@@ -8,22 +8,6 @@ const IDENTITY_FREE_WEB_ROUTES = new Set([
'/settings/privacy',
]);
-const ONBOARDING_WEB_ROUTES = new Set([
- '/privacy',
- '/basics',
- '/preferences',
- '/intent',
- '/about',
- '/photos',
- '/location',
- '/review',
- '/finish',
-]);
-
export function requiresWebIdentity(pathname: string): boolean {
return !IDENTITY_FREE_WEB_ROUTES.has(pathname);
}
-
-export function isOnboardingWebRoute(pathname: string): boolean {
- return ONBOARDING_WEB_ROUTES.has(pathname);
-}
From 5aaa55c24dcaec800145a08d476e1815a55f1b45 Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 14:06:06 -0400
Subject: [PATCH 23/39] Lock onboarding only on reload
---
src/app/(onboarding)/_layout.tsx | 35 ++++++++++++++++++-
.../auth/__tests__/web-route-access.test.ts | 15 +++++---
src/features/auth/web-route-access.ts | 25 +++++++++++++
3 files changed, 70 insertions(+), 5 deletions(-)
diff --git a/src/app/(onboarding)/_layout.tsx b/src/app/(onboarding)/_layout.tsx
index 2a41e45..fa2d266 100644
--- a/src/app/(onboarding)/_layout.tsx
+++ b/src/app/(onboarding)/_layout.tsx
@@ -2,12 +2,45 @@
// Wraps every onboarding screen in the in-memory draft provider so screens
// can share the profile data collected along the way.
-import { Stack } from 'expo-router';
+import { useEffect, useState } from 'react';
+import { Stack, usePathname, useRouter } from 'expo-router';
+import { Platform } from 'react-native';
import { OnboardingDraftProvider } from '@/features/onboarding/onboarding-draft';
import { useTheme } from '@/state/theme-context';
+import { getOpenDatingClient } from '@/lib/opendating/open-dating-client';
+import { getOnboardingRouteRedirect } from '@/features/auth/web-route-access';
export default function OnboardingLayout() {
const { colors } = useTheme();
+ const pathname = usePathname();
+ const [initialPathname] = useState(pathname);
+ const router = useRouter();
+ const [routeReady, setRouteReady] = useState(Platform.OS !== 'web');
+
+ useEffect(() => {
+ if (Platform.OS !== 'web') return;
+
+ let active = true;
+ void getOpenDatingClient()
+ .getIdentityState()
+ .then((identityState) => {
+ if (!active) return;
+ const redirect = getOnboardingRouteRedirect(initialPathname, identityState);
+ if (redirect) {
+ router.replace(redirect);
+ return;
+ }
+ setRouteReady(true);
+ })
+ .catch(() => {
+ if (active) router.replace('/');
+ });
+ return () => {
+ active = false;
+ };
+ }, [initialPathname, router]);
+
+ if (!routeReady) return null;
return (
diff --git a/src/features/auth/__tests__/web-route-access.test.ts b/src/features/auth/__tests__/web-route-access.test.ts
index 009b2da..40c737b 100644
--- a/src/features/auth/__tests__/web-route-access.test.ts
+++ b/src/features/auth/__tests__/web-route-access.test.ts
@@ -1,9 +1,16 @@
-import { requiresWebIdentity } from '@/features/auth/web-route-access';
+import {
+ getOnboardingRouteRedirect,
+ requiresWebIdentity,
+} from '@/features/auth/web-route-access';
-it('locks onboarding drafts while leaving account entry routes public', () => {
- expect(requiresWebIdentity('/review')).toBe(true);
- expect(requiresWebIdentity('/basics')).toBe(true);
+it('locks reloaded onboarding drafts while leaving the flow locally navigable', () => {
+ expect(requiresWebIdentity('/review')).toBe(false);
+ expect(requiresWebIdentity('/basics')).toBe(false);
expect(requiresWebIdentity('/welcome')).toBe(false);
expect(requiresWebIdentity('/create-account')).toBe(false);
expect(requiresWebIdentity('/settings/privacy')).toBe(false);
+ expect(getOnboardingRouteRedirect('/review', 'locked')).toBe('/unlock');
+ expect(getOnboardingRouteRedirect('/review', 'missing')).toBe('/welcome');
+ expect(getOnboardingRouteRedirect('/review', 'ready')).toBeNull();
+ expect(getOnboardingRouteRedirect('/create-account', 'missing')).toBeNull();
});
diff --git a/src/features/auth/web-route-access.ts b/src/features/auth/web-route-access.ts
index 2e3671e..b657f08 100644
--- a/src/features/auth/web-route-access.ts
+++ b/src/features/auth/web-route-access.ts
@@ -4,10 +4,35 @@ const IDENTITY_FREE_WEB_ROUTES = new Set([
'/welcome',
'/create-account',
'/import-account',
+ '/privacy',
+ '/basics',
+ '/preferences',
+ '/intent',
+ '/about',
+ '/photos',
+ '/location',
+ '/review',
+ '/finish',
'/settings/terms',
'/settings/privacy',
]);
+const ONBOARDING_ENTRY_ROUTES = new Set([
+ '/welcome',
+ '/create-account',
+ '/import-account',
+]);
+
export function requiresWebIdentity(pathname: string): boolean {
return !IDENTITY_FREE_WEB_ROUTES.has(pathname);
}
+
+export function getOnboardingRouteRedirect(
+ pathname: string,
+ identityState: 'missing' | 'locked' | 'ready'
+): '/welcome' | '/unlock' | null {
+ if (ONBOARDING_ENTRY_ROUTES.has(pathname) || identityState === 'ready') {
+ return null;
+ }
+ return identityState === 'locked' ? '/unlock' : '/welcome';
+}
From e4811eb968358b9a85a15f062ecaec35b19852a6 Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 14:09:41 -0400
Subject: [PATCH 24/39] Resume onboarding after browser unlock
---
src/app/unlock.tsx | 8 +++++++-
.../onboarding/__tests__/onboarding-resume.test.ts | 10 +++++++++-
src/features/onboarding/onboarding-resume.ts | 7 +++++++
3 files changed, 23 insertions(+), 2 deletions(-)
diff --git a/src/app/unlock.tsx b/src/app/unlock.tsx
index 6f04c23..d908d24 100644
--- a/src/app/unlock.tsx
+++ b/src/app/unlock.tsx
@@ -8,6 +8,8 @@ import { BrandMark } from '@/components/brand/brand-mark';
import { ConfirmationDialog } from '@/components/confirmation-dialog';
import { getOpenDatingClient, resetOpenDatingClient } from '@/lib/opendating/open-dating-client';
import { storage } from '@/lib/storage';
+import type { OnboardingDraft } from '@/features/onboarding/onboarding-draft';
+import { getPostUnlockPath } from '@/features/onboarding/onboarding-resume';
import { useTheme } from '@/state/theme-context';
import { radius } from '@/theme/radius';
import { spacing } from '@/theme/spacing';
@@ -28,7 +30,11 @@ export default function UnlockScreen() {
try {
await getOpenDatingClient().unlockIdentity(passphrase);
setPassphrase('');
- router.replace('/');
+ const [onboardingComplete, savedDraft] = await Promise.all([
+ storage.isOnboardingComplete(),
+ storage.getOnboardingDraft>(),
+ ]);
+ router.replace(getPostUnlockPath(onboardingComplete, savedDraft));
} catch (caught) {
setError(caught instanceof Error ? caught.message : 'Could not unlock this browser.');
} finally {
diff --git a/src/features/onboarding/__tests__/onboarding-resume.test.ts b/src/features/onboarding/__tests__/onboarding-resume.test.ts
index 2a5691c..e568b7c 100644
--- a/src/features/onboarding/__tests__/onboarding-resume.test.ts
+++ b/src/features/onboarding/__tests__/onboarding-resume.test.ts
@@ -1,4 +1,7 @@
-import { getOnboardingResumePath } from '@/features/onboarding/onboarding-resume';
+import {
+ getOnboardingResumePath,
+ getPostUnlockPath,
+} from '@/features/onboarding/onboarding-resume';
import { CURRENT_POLICY_VERSION } from '@/lib/policy';
it('resumes a complete unpublished draft at review', () => {
@@ -15,3 +18,8 @@ it('resumes a complete unpublished draft at review', () => {
})
).toBe('/(onboarding)/review');
});
+
+it('resumes unfinished onboarding after browser unlock', () => {
+ expect(getPostUnlockPath(false, null)).toBe('/(onboarding)/privacy');
+ expect(getPostUnlockPath(true, null)).toBe('/');
+});
diff --git a/src/features/onboarding/onboarding-resume.ts b/src/features/onboarding/onboarding-resume.ts
index f8c3134..8cea38e 100644
--- a/src/features/onboarding/onboarding-resume.ts
+++ b/src/features/onboarding/onboarding-resume.ts
@@ -19,3 +19,10 @@ export function getOnboardingResumePath(
}
return saved.intent ? '/(onboarding)/review' : '/(onboarding)/preferences';
}
+
+export function getPostUnlockPath(
+ onboardingComplete: boolean,
+ saved: ResumeDraft | null
+): '/' | ReturnType {
+ return onboardingComplete ? '/' : getOnboardingResumePath(saved);
+}
From ec3883d28672537302b354e6c5c2f9b7afb19ea9 Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 14:16:38 -0400
Subject: [PATCH 25/39] Open Passport after onboarding
---
src/app/(onboarding)/finish.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/app/(onboarding)/finish.tsx b/src/app/(onboarding)/finish.tsx
index 4e494cb..cc96fd0 100644
--- a/src/app/(onboarding)/finish.tsx
+++ b/src/app/(onboarding)/finish.tsx
@@ -34,7 +34,7 @@ export default function FinishScreen() {
subtitle="Your profile is live, and its privacy controls belong to you."
showBack={false}
primaryLabel="View My Privacy Passport"
- onPrimaryPress={() => router.replace('/passport')}
+ onPrimaryPress={() => router.replace('/(tabs)/passport')}
>
From 33228bff55b8d2552e5e1187fcfdd0d48a4ab7c9 Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 14:48:07 -0400
Subject: [PATCH 26/39] Add lifetime Plus themes
---
docs/APP-REVIEW-NOTES.md | 39 ++++++-----
docs/MONETIZATION.md | 37 +++++------
docs/STORE_LISTING.md | 4 +-
public/privacy/index.html | 8 +--
public/subprocessors/index.html | 2 +-
public/terms/index.html | 2 +-
src/app/_layout.tsx | 18 ++---
src/app/settings/index.tsx | 3 +-
src/app/settings/plus.tsx | 70 ++++++++++++++++++--
src/app/settings/privacy.tsx | 4 ++
src/app/settings/terms.tsx | 4 ++
src/features/onboarding/onboarding-draft.tsx | 2 +-
src/lib/__tests__/policy.test.ts | 10 +--
src/lib/__tests__/revenuecat-config.test.ts | 3 +-
src/lib/policy.ts | 6 +-
src/lib/revenuecat-config.ts | 3 +-
src/lib/storage/index.ts | 10 +++
src/lib/storage/index.web.ts | 10 +++
src/state/theme-context.tsx | 47 +++++++++++--
src/theme/__tests__/colors.test.ts | 11 +++
src/theme/colors.ts | 30 +++++++++
21 files changed, 247 insertions(+), 76 deletions(-)
create mode 100644 src/theme/__tests__/colors.test.ts
diff --git a/docs/APP-REVIEW-NOTES.md b/docs/APP-REVIEW-NOTES.md
index fb67574..af5166b 100644
--- a/docs/APP-REVIEW-NOTES.md
+++ b/docs/APP-REVIEW-NOTES.md
@@ -4,7 +4,7 @@ Copy the **Reviewer Notes** block at the bottom into the Notes field of the App
Review Information section in App Store Connect. The rest of this document is
the working source for that block.
-**Last updated:** 2026-08-09 · **App version:** 0.1.1
+**Last updated:** 2026-08-29 · **App version:** 0.1.1
---
@@ -17,8 +17,9 @@ beginning with app launch, showing the typical user flow. Must include account
registration, login, and deletion; user-generated content with reporting and
blocking; and every permission prompt.
-There is no paid content, purchase, or subscription flow in this app, so that
-section does not apply. Say so explicitly in the reply rather than omitting it.
+Show the optional one-time OpenDating Plus purchase, selecting a custom accent,
+and Restore Purchases. The recording must use an Apple sandbox account and must
+not show credentials or payment details.
## 2. Devices and operating systems tested
@@ -100,12 +101,12 @@ review region.
|---|---|---|
| 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. Photos are not sent, and it does not process encrypted messages. Disclosed in-app on the onboarding privacy screen and at Settings → Privacy. |
+| RevenueCat | Optional one-time OpenDating Plus purchase and entitlement status. | Receives an opaque, domain-separated billing identifier and Apple purchase/entitlement data. It does not receive profile text, photos, location, recovery keys, likes, matches, reports, or 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 third-party
-authentication provider, no data broker, and no App Tracking Transparency
-prompt (the app does not track).
+**Not used:** no subscriptions, advertising SDK, third-party analytics or crash
+reporting, third-party authentication provider, data broker, or App Tracking
+Transparency prompt (the app does not track).
The app's privacy manifest declares `NSPrivacyTracking: false` with no tracking
domains.
@@ -113,8 +114,8 @@ domains.
## 6. Regional differences
**The app functions identically in all regions.** There is no region-locked
-content, no country-specific feature gating, no regional pricing (the app is
-free everywhere), and no geographic availability restriction in the app itself.
+content or country-specific feature gating. The app is free; Apple localizes the
+optional one-time Plus price by storefront.
A country code derived from the user's coarse location is used only to scope
discovery to a sensible area. It does not enable or disable features.
@@ -135,7 +136,7 @@ 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 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
+ accepts the August 29, 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
@@ -154,8 +155,8 @@ 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.
+encrypted messaging, the one-time Plus purchase and restore flow, and account
+deletion.
DEVICES TESTED
- Samsung SM-A166U1, Android 16: physical-device onboarding and discovery.
@@ -204,7 +205,10 @@ PERMISSIONS AND PURCHASES
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.
+- OpenDating Plus is an optional $4.99 one-time purchase that unlocks Sage,
+ Ocean, and Plum app accents. It does not affect discovery, ranking, matching,
+ messaging, recovery, deletion, or safety. Restore Purchases is available on
+ the same screen. There is no subscription.
EXTERNAL SERVICES
- Developer-operated OpenDating relay on Cloudflare Workers: profile storage,
@@ -214,14 +218,17 @@ EXTERNAL SERVICES
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.
+- RevenueCat: validates the optional lifetime Plus entitlement using an opaque
+ billing identifier. It does not receive profile, location, key, safety, or
+ message content.
- 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.
+No advertising network, analytics provider, data broker, or third-party
+authentication service is used.
REGIONS
The app behaves consistently across all regions. There is no region-locked
-content, feature gating, regional pricing, or in-app availability restriction.
+content or feature gating. Apple localizes the optional one-time Plus price.
REGULATED SERVICES AND THIRD-PARTY MATERIAL
OpenDating is a dating/social-networking app, not a financial, medical, legal,
diff --git a/docs/MONETIZATION.md b/docs/MONETIZATION.md
index 91c4bd8..a285928 100644
--- a/docs/MONETIZATION.md
+++ b/docs/MONETIZATION.md
@@ -1,9 +1,8 @@
# OpenDating monetization contract
-**Status:** development-only and disabled. Do not merge this integration into
-the next App Review candidate merely because the SDK compiles. The app still
-has no release-ready paid benefit, store products, webhook reconciliation, or
-native purchase evidence.
+**Status:** implementation complete but disabled until the store and RevenueCat
+catalogs are connected and the purchase/restore flow passes on a physical iOS
+device.
OpenDating Plus may sell customization and convenience. It must never sell
ranking, boosts, hidden admirers, safety, verification, recovery, deletion,
@@ -13,10 +12,12 @@ matching, reactions, discovery, filters, or messaging.
- Entitlement: `plus`
- Offering: `default`
-- Products: `opendating_plus_monthly`, `opendating_plus_annual`
-- Packages: `$rc_monthly`, `$rc_annual`
-- Initial pricing hypothesis: $2.99 monthly or $19.99 annual in the US, with
- store-localized pricing everywhere else
+- Product: `opendating_plus_lifetime` (non-consumable)
+- App Store Connect product ID: `6806640623`
+- App Store Connect version ID: `c62d972a-949a-4148-822a-6ae49f98dbe9`
+- Package: `$rc_lifetime`
+- Price: $4.99 in the US, with store-localized pricing elsewhere
+- Benefit: Sage, Ocean, and Plum app accent themes
Prices are never hardcoded in the app. The paywall renders the localized store
price delivered by RevenueCat.
@@ -26,18 +27,16 @@ price delivered by RevenueCat.
The SDK derives a stable SHA-256 billing identifier from a domain-separated
value. A raw Nostr public key is never sent to RevenueCat or logged.
-The paywall is fail closed and requires both
+The purchase screen is fail closed and requires both
`EXPO_PUBLIC_REVENUECAT_ENABLED=true` and
-`EXPO_PUBLIC_OPENDATING_PLUS_FEATURES_READY=true`. Feature readiness stays
-false until at least one durable customization/convenience benefit exists and
-the product's verification, account recovery, moderation, abuse-reporting,
-deletion, and privacy acceptance gates have runtime evidence.
-
-Before enabling, create and validate both store products, map the entitlement
-and packages in RevenueCat, add signed webhook reconciliation to the backend,
-then exercise purchase, renewal, cancellation, revocation, restore, identity
-import, identity deletion, offline access, and app-reinstall paths in native
-development builds.
+`EXPO_PUBLIC_OPENDATING_PLUS_FEATURES_READY=true`. The paid entitlement changes
+only local accent colors. It never changes discovery, ranking, matching,
+messaging, recovery, deletion, or safety behavior.
+
+Before enabling, validate the store product, map the entitlement and lifetime
+package in RevenueCat, then exercise purchase, refund/revocation, restore,
+identity import, identity deletion, offline access, and app-reinstall paths in
+a native build.
The client fails closed unless RevenueCat reports `VERIFIED` or
`VERIFIED_ON_DEVICE` for the entitlement. App Privacy and the public
diff --git a/docs/STORE_LISTING.md b/docs/STORE_LISTING.md
index 1779312..2375ab4 100644
--- a/docs/STORE_LISTING.md
+++ b/docs/STORE_LISTING.md
@@ -1,6 +1,6 @@
# OpenDating Store Listing
-**Last updated:** 2026-08-13
+**Last updated:** 2026-08-29
> **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
@@ -52,6 +52,7 @@ BUILT AROUND DATA MINIMIZATION
- 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
+- Optionally unlock Sage, Ocean, and Plum app accents with one lifetime purchase
OpenDating is the first reference client for the open OpenDating protocol. Your
recovery key can restore your account on another compatible OpenDating client or
@@ -101,6 +102,7 @@ Safety. Confirm the final console answers before submitting for review.
| Messages and user-generated content | Messaging and safety | Yes | No |
| Public account ID | Account functionality | Yes | No |
| Reports and block actions | Safety and moderation | Yes | No |
+| Purchase history | Optional Plus entitlement | Yes, through an opaque billing ID | No |
The app does not use third-party advertising SDKs and does not sell personal
data. iOS export compliance is configured as standard encryption with no France
diff --git a/public/privacy/index.html b/public/privacy/index.html
index 07d3a7b..a010430 100644
--- a/public/privacy/index.html
+++ b/public/privacy/index.html
@@ -30,7 +30,7 @@
Privacy Policy
-
Last updated: August 9, 2026
+
Last updated: August 29, 2026
Our Approach to Privacy
OpenDating is designed to minimize data collection and separate service responsibilities. Privacy depends on both architecture and operating policy; no system can honestly promise that surveillance or compromise is impossible.
@@ -54,7 +54,7 @@
Information we do NOT collect
Contact lists or address books
Browsing history
Device advertising identifiers
-
Payment information in the current beta
+
Full payment-card or bank-account details; Apple processes payment
Any data from other apps on your device
@@ -66,7 +66,7 @@
How We Use Information
Encrypted likes are routed to the matcher service to create matches
Encrypted messages are routed between matched users
-
The current beta does not use advertising SDKs or product analytics. Infrastructure security and operational logs may still be processed. Any future allowlisted aggregate analytics or moderation vendors will require an updated notice and production approval.
+
The app does not use advertising SDKs or product analytics. RevenueCat processes an opaque billing identifier and Apple purchase-entitlement status only when the optional one-time Plus feature is enabled. Infrastructure security and operational logs may still be processed. Any future allowlisted aggregate analytics or moderation vendors will require an updated notice and production approval.
Data Storage and Security
@@ -78,7 +78,7 @@
Data Storage and Security
Data Sharing
-
We do not sell or rent profile or relationship data and do not have advertising partners. Cloudflare processes service data as the current infrastructure provider; Apple, Google, and Expo may process build or distribution data. See the current subprocessors list.
+
We do not sell or rent profile or relationship data and do not have advertising partners. Cloudflare processes service data as the current infrastructure provider. RevenueCat may process an opaque billing identifier and purchase-entitlement status; it does not receive profile text, photos, location, recovery keys, likes, matches, reports, or messages. Apple, Google, and Expo may process purchase, build, or distribution data. See the current subprocessors list.
OpenDating currently uses the following infrastructure providers in development and pre-release operation.
Cloudflare
Relay compute, database, object storage, queues, caching, security, and hosting. Processing locations depend on Cloudflare service configuration.
Expo / Amazon Web Services
Development and store-build infrastructure. Push notification processing is not enabled in pre-release builds or before an approved release.
Apple and Google
App distribution, platform security, and store operations when builds are distributed through their stores.
Not active yet
Persona, Hive, RevenueCat, Sentry, and outsourced moderation are planned production vendors but must not receive production user data until contracts, configuration, consent, retention, and country approvals are complete.
Material production changes will be posted here before activation.
+Subprocessors — OpenDating
Subprocessors
Updated August 29, 2026
OpenDating uses the following infrastructure providers.
Cloudflare
Relay compute, database, object storage, queues, caching, security, and hosting. Processing locations depend on Cloudflare service configuration.
RevenueCat
Optional one-time purchase entitlement validation. RevenueCat receives an opaque billing identifier and Apple purchase-entitlement status, not profile text, photos, location, recovery keys, likes, matches, reports, or messages.
Expo / Amazon Web Services
Development and store-build infrastructure. Push notification processing is not enabled.
Apple and Google
App distribution, platform security, store purchases, and store operations.
Not active yet
Persona, Hive, Sentry, and outsourced moderation are planned vendors but must not receive production user data until contracts, configuration, consent, retention, and country approvals are complete.
Material production changes will be posted here before activation.
Pre-release notice. OpenDating is not approved for general availability. This draft describes intended rules for invited testing; it requires counsel review and a versioned acceptance flow before becoming the production agreement.
Eligibility
You must be at least 18 and legally able to enter an agreement where you live. Do not create an account for another person or return after a ban without written permission.
Your account
You are responsible for protecting your recovery key and activity signed by your account. The beta recovery flow is self-custodied; OpenDating cannot restore a lost key.
Acceptable use
Be truthful, respectful, and lawful. Harassment, threats, scams, impersonation, sexual exploitation, content involving minors, non-consensual intimate content, hate, spam, scraping, and bypassing safety controls are prohibited.
Your content
You keep ownership of profile content you submit and grant OpenDating a limited license to host, process, and display that profile content only as needed to operate, secure, and improve the service. Direct-message content is excluded from that license; messages may be processed only for encrypted transmission, delivery, and security operations. Upload only content you have the right to use.
Safety
OpenDating does not guarantee another member’s identity, intentions, conduct, compatibility, or availability. Use good judgment, meet in public, tell someone your plans, and contact local emergency services when necessary.
Enforcement and deletion
OpenDating may limit or remove accounts and content to enforce these terms. You may request deletion in the app. Some safety records may be retained when legally required or necessary to prevent repeat abuse.
Beta availability
The service may change, be unavailable, or lose data. Jurisdiction-specific consumer terms, governing-law provisions, and dispute language will be completed through legal review before launch.
You must be at least 18 and legally able to enter an agreement where you live. Do not create an account for another person or return after a ban without written permission.
Your account
You are responsible for protecting your recovery key and activity signed by your account. Recovery is self-custodied; OpenDating cannot restore a lost key.
Acceptable use
Be truthful, respectful, and lawful. Harassment, threats, scams, impersonation, sexual exploitation, content involving minors, non-consensual intimate content, hate, spam, scraping, and bypassing safety controls are prohibited.
Your content
You keep ownership of profile content you submit and grant OpenDating a limited license to host, process, and display that profile content only as needed to operate, secure, and improve the service. Direct-message content is excluded from that license; messages may be processed only for encrypted transmission, delivery, and security operations. Upload only content you have the right to use.
Optional purchases
OpenDating Plus is an optional one-time purchase for cosmetic app accents. Discovery, matching, messaging, recovery, deletion, and safety features remain free. Apple processes payment and refunds, and Restore Purchases can recover the entitlement on supported devices.
Safety
OpenDating does not guarantee another member’s identity, intentions, conduct, compatibility, or availability. Use good judgment, meet in public, tell someone your plans, and contact local emergency services when necessary.
Enforcement and deletion
OpenDating may limit or remove accounts and content to enforce these terms. You may request deletion in the app. Some safety records may be retained when legally required or necessary to prevent repeat abuse.
Service changes
Features may change or be temporarily unavailable. We will give notice of material changes where required.
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index 7c3c8eb..aa5e166 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -18,9 +18,13 @@ export default function RootLayout() {
// GestureHandlerRootView must remain the outermost full-screen view for
// navigation and any gesture-driven controls used elsewhere in the app.
-
-
-
+
+
+
+
+
+
+
);
}
@@ -43,12 +47,8 @@ function ThemedRoot() {
Platform.OS === 'web' && { backgroundColor: colors.background },
]}
>
-
-
-
-
-
-
+
+
);
}
diff --git a/src/app/settings/index.tsx b/src/app/settings/index.tsx
index ed5318f..4b33d9c 100644
--- a/src/app/settings/index.tsx
+++ b/src/app/settings/index.tsx
@@ -15,6 +15,7 @@ import { spacing } from '@/theme/spacing';
import { radius } from '@/theme/radius';
import { ChevronRight } from '@/components/chevron';
import { ConfirmationDialog } from '@/components/confirmation-dialog';
+import { POLICY_EFFECTIVE_LABEL } from '@/lib/policy';
interface MenuRowProps {
label: string;
@@ -272,7 +273,7 @@ export default function SettingsScreen() {
router.push('/settings/terms')}
/>
diff --git a/src/app/settings/plus.tsx b/src/app/settings/plus.tsx
index 9e9f088..6bbb4ef 100644
--- a/src/app/settings/plus.tsx
+++ b/src/app/settings/plus.tsx
@@ -1,17 +1,23 @@
import { useState } from 'react';
-import { ScrollView, StyleSheet, Text, View } from 'react-native';
+import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import type { PurchasesPackage } from 'react-native-purchases';
import { Redirect } from 'expo-router';
import { AppButton } from '@/components/ui/app-button';
import { useRevenueCat } from '@/state/revenuecat-context';
import { useTheme } from '@/state/theme-context';
+import { getThemeColors } from '@/theme/colors';
import { radius } from '@/theme/radius';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export default function PlusScreen() {
- const { colors } = useTheme();
+ const {
+ colors,
+ isDark,
+ accentPreference,
+ setAccentPreference,
+ } = useTheme();
const revenueCat = useRevenueCat();
const [purchasing, setPurchasing] = useState(null);
const [restoring, setRestoring] = useState(false);
@@ -50,13 +56,49 @@ export default function PlusScreen() {
Core discovery, reactions, matching, messaging, verification, recovery, filters,
deletion, and every safety tool remain free. Plus adds customization and reusable
- convenience controls only.
+ convenience controls only. The lifetime purchase unlocks custom app accents.
{revenueCat.isPlus ? (
Plus is active.
) : null}
+
+ Custom app accents
+
+ Choose Coral, Sage, Ocean, or Plum. Your selection stays on this device and never
+ changes who sees your profile.
+
+
+ {(['coral', 'sage', 'ocean', 'plum'] as const).map((accent) => {
+ const swatch = getThemeColors(isDark ? 'dark' : 'light', accent).accent;
+ const selected = accentPreference === accent;
+ return (
+ setAccentPreference(accent)}
+ style={[
+ styles.accentButton,
+ { borderColor: selected ? colors.text : colors.border },
+ !revenueCat.isPlus && accent !== 'coral' && styles.disabled,
+ ]}
+ >
+
+
+ {accent[0].toUpperCase() + accent.slice(1)}
+
+
+ );
+ })}
+
+
{revenueCat.packages.map((pkg) => (
Restore purchases
- Subscriptions renew through your store account until canceled there. Store-localized price
- and terms appear before confirmation.
+ One-time purchase. No subscription. The store-localized price and terms appear before
+ confirmation.
);
@@ -113,6 +155,24 @@ const styles = StyleSheet.create({
content: { gap: spacing.md, padding: spacing.lg, paddingBottom: spacing.xxl },
hero: { borderRadius: radius.xl, gap: spacing.sm, padding: spacing.xl },
status: { ...typography.titleMedium },
+ feature: {
+ borderRadius: radius.lg,
+ borderWidth: StyleSheet.hairlineWidth,
+ gap: spacing.sm,
+ padding: spacing.md,
+ },
+ accents: { flexDirection: 'row', gap: spacing.sm },
+ accentButton: {
+ alignItems: 'center',
+ borderRadius: radius.md,
+ borderWidth: 2,
+ flex: 1,
+ gap: spacing.xs,
+ minHeight: 64,
+ padding: spacing.sm,
+ },
+ swatch: { borderRadius: 12, height: 24, width: 24 },
+ disabled: { opacity: 0.42 },
plan: {
alignItems: 'center',
borderRadius: radius.lg,
diff --git a/src/app/settings/privacy.tsx b/src/app/settings/privacy.tsx
index 6150980..191c5e9 100644
--- a/src/app/settings/privacy.tsx
+++ b/src/app/settings/privacy.tsx
@@ -87,6 +87,10 @@ export default function PrivacyScreen() {
title="Profile Safety Screening"
body="Before your display name and bio go live, they are automatically screened for harmful content. That screening runs on our service provider, Cloudflare, and is the only place your profile text is processed by an automated system. Your photos are not sent to it, and your messages are never screened — they stay end-to-end encrypted."
/>
+
{
expect(
isCurrentPolicy({
version: CURRENT_POLICY_VERSION,
- acceptedAt: '2026-08-09T00:00:00.000Z',
+ acceptedAt: '2026-08-29T00:00:00.000Z',
})
).toBe(true);
});
@@ -23,7 +23,7 @@ describe('isCurrentPolicy', () => {
expect(isCurrentPolicy(null)).toBe(false);
expect(isCurrentPolicy(undefined)).toBe(false);
expect(
- isCurrentPolicy({ version: '', acceptedAt: '2026-08-09T00:00:00.000Z' })
+ isCurrentPolicy({ version: '', acceptedAt: '2026-08-29T00:00:00.000Z' })
).toBe(false);
expect(
isCurrentPolicy({
@@ -60,7 +60,7 @@ describe('isCurrentPolicy', () => {
).toBe(false);
});
- // "2026-08-09" parses to UTC midnight, which is exactly the effective-date
+ // "2026-08-29" parses to UTC midnight, which is exactly the effective-date
// floor, so a bare date would otherwise clear the gate while recording
// consent at a precision no writer in the app produces.
it('rejects a date-only or non-canonical acceptance timestamp', () => {
@@ -114,8 +114,8 @@ describe('isValidPolicyTimestamp', () => {
});
it('applies the caller-supplied floor', () => {
- const floor = Date.parse('2026-08-09T00:00:00Z');
- expect(isValidPolicyTimestamp('2026-08-09T00:00:00.000Z', floor)).toBe(
+ const floor = Date.parse('2026-08-29T00:00:00Z');
+ expect(isValidPolicyTimestamp('2026-08-29T00:00:00.000Z', floor)).toBe(
true
);
expect(isValidPolicyTimestamp('2026-08-08T23:59:59.999Z', floor)).toBe(
diff --git a/src/lib/__tests__/revenuecat-config.test.ts b/src/lib/__tests__/revenuecat-config.test.ts
index 41a51ce..80685f1 100644
--- a/src/lib/__tests__/revenuecat-config.test.ts
+++ b/src/lib/__tests__/revenuecat-config.test.ts
@@ -51,8 +51,7 @@ describe('RevenueCat release gates', () => {
it('allows only the documented OpenDating products', () => {
expect([...OPENDATING_PRODUCT_IDS]).toEqual([
- 'opendating_plus_monthly',
- 'opendating_plus_annual',
+ 'opendating_plus_lifetime',
]);
});
diff --git a/src/lib/policy.ts b/src/lib/policy.ts
index 4a43c7c..8fe9542 100644
--- a/src/lib/policy.ts
+++ b/src/lib/policy.ts
@@ -5,8 +5,8 @@
// 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';
+export const CURRENT_POLICY_VERSION = '2026-08-29';
+export const POLICY_EFFECTIVE_LABEL = 'August 29, 2026';
/** What the member accepted, as captured in the onboarding draft. */
export interface PolicyAcceptance {
@@ -49,7 +49,7 @@ export function isCurrentPolicy(
* True only for the exact form `new Date().toISOString()` produces, which is
* what every consent writer in the app records.
*
- * Consent must name a complete instant. A date-only string like "2026-08-09"
+ * Consent must name a complete instant. A date-only string like "2026-08-29"
* parses to UTC midnight — precisely the effective-date floor — so without
* this check a bare date would satisfy the current-policy gate while claiming
* a precision no writer in this app produces.
diff --git a/src/lib/revenuecat-config.ts b/src/lib/revenuecat-config.ts
index 5eb95a6..4852578 100644
--- a/src/lib/revenuecat-config.ts
+++ b/src/lib/revenuecat-config.ts
@@ -3,8 +3,7 @@ import * as Crypto from 'expo-crypto';
export const OPENDATING_ENTITLEMENT_ID = 'plus';
export const OPENDATING_OFFERING_ID = 'default';
export const OPENDATING_PRODUCT_IDS = new Set([
- 'opendating_plus_monthly',
- 'opendating_plus_annual',
+ 'opendating_plus_lifetime',
]);
export function isRevenueCatEnabled(): boolean {
diff --git a/src/lib/storage/index.ts b/src/lib/storage/index.ts
index ea355bc..6e520c7 100644
--- a/src/lib/storage/index.ts
+++ b/src/lib/storage/index.ts
@@ -54,6 +54,7 @@ const STORAGE_KEYS = {
LOCATION_PREFIX: 'opendating_location_prefix',
ONBOARDING_COMPLETE: 'opendating_onboarding_done',
THEME_PREFERENCE: 'opendating_theme',
+ ACCENT_PREFERENCE: 'opendating_accent',
PROFILE_CONTENT: 'opendating_profile_content',
ONBOARDING_DRAFT: 'opendating_onboarding_draft',
POLICY_ACCEPTANCE: 'opendating_policy_acceptance',
@@ -166,6 +167,15 @@ export const storage = {
if (val === 'light' || val === 'dark' || val === 'system') return val;
return null;
},
+ async saveAccentPreference(accent: 'coral' | 'sage' | 'ocean' | 'plum'): Promise {
+ await secureSet(STORAGE_KEYS.ACCENT_PREFERENCE, accent);
+ },
+ async getAccentPreference(): Promise<'coral' | 'sage' | 'ocean' | 'plum' | null> {
+ const value = await secureGet(STORAGE_KEYS.ACCENT_PREFERENCE);
+ return value === 'coral' || value === 'sage' || value === 'ocean' || value === 'plum'
+ ? value
+ : null;
+ },
async saveDiscoveryPreferences(preferences: object): Promise {
await AsyncStorage.setItem(
diff --git a/src/lib/storage/index.web.ts b/src/lib/storage/index.web.ts
index 4eadadd..e3b603b 100644
--- a/src/lib/storage/index.web.ts
+++ b/src/lib/storage/index.web.ts
@@ -7,6 +7,7 @@ const STORAGE_KEYS = {
LOCATION_PREFIX: 'opendating_location_prefix',
ONBOARDING_COMPLETE: 'opendating_onboarding_done',
THEME_PREFERENCE: 'opendating_theme',
+ ACCENT_PREFERENCE: 'opendating_accent',
PROFILE_CONTENT: 'opendating_profile_content',
ONBOARDING_DRAFT: 'opendating_onboarding_draft',
POLICY_ACCEPTANCE: 'opendating_policy_acceptance',
@@ -87,6 +88,15 @@ export const storage = {
const value = await AsyncStorage.getItem(STORAGE_KEYS.THEME_PREFERENCE);
return value === 'light' || value === 'dark' || value === 'system' ? value : null;
},
+ async saveAccentPreference(accent: 'coral' | 'sage' | 'ocean' | 'plum'): Promise {
+ await AsyncStorage.setItem(STORAGE_KEYS.ACCENT_PREFERENCE, accent);
+ },
+ async getAccentPreference(): Promise<'coral' | 'sage' | 'ocean' | 'plum' | null> {
+ const value = await AsyncStorage.getItem(STORAGE_KEYS.ACCENT_PREFERENCE);
+ return value === 'coral' || value === 'sage' || value === 'ocean' || value === 'plum'
+ ? value
+ : null;
+ },
async saveDiscoveryPreferences(preferences: object): Promise {
await AsyncStorage.setItem(
diff --git a/src/state/theme-context.tsx b/src/state/theme-context.tsx
index 8586bd5..790bd21 100644
--- a/src/state/theme-context.tsx
+++ b/src/state/theme-context.tsx
@@ -3,7 +3,13 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useColorScheme } from 'react-native';
import { storage } from '@/lib/storage';
-import { colors, type ThemeColors } from '@/theme/colors';
+import { useRevenueCat } from '@/state/revenuecat-context';
+import {
+ colors,
+ getThemeColors,
+ type AccentPreference,
+ type ThemeColors,
+} from '@/theme/colors';
export type { ThemeColors };
export type ThemePreference = 'light' | 'dark' | 'system';
@@ -12,28 +18,44 @@ interface ThemeContextValue {
colors: ThemeColors;
isDark: boolean;
themePreference: ThemePreference;
+ accentPreference: AccentPreference;
setThemePreference: (preference: ThemePreference) => void;
+ setAccentPreference: (preference: AccentPreference) => void;
}
const ThemeContext = createContext({
colors: colors.light,
isDark: false,
themePreference: 'system',
+ accentPreference: 'coral',
setThemePreference: () => {},
+ setAccentPreference: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const systemScheme = useColorScheme();
+ const { isPlus } = useRevenueCat();
const [themePreference, setPreference] = useState('system');
+ const [savedAccentPreference, setSavedAccentPreference] =
+ useState('coral');
const isDark =
themePreference === 'dark' ||
(themePreference === 'system' && systemScheme === 'dark');
- const themeColors: ThemeColors = isDark ? colors.dark : colors.light;
+ const accentPreference = isPlus ? savedAccentPreference : 'coral';
+ const themeColors = useMemo(
+ () => getThemeColors(isDark ? 'dark' : 'light', accentPreference),
+ [accentPreference, isDark],
+ );
useEffect(() => {
let active = true;
- void storage.getThemePreference().then((saved) => {
- if (active && saved) setPreference(saved);
+ void Promise.all([
+ storage.getThemePreference(),
+ storage.getAccentPreference(),
+ ]).then(([savedTheme, savedAccent]) => {
+ if (!active) return;
+ if (savedTheme) setPreference(savedTheme);
+ if (savedAccent) setSavedAccentPreference(savedAccent);
});
return () => {
active = false;
@@ -45,9 +67,22 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
void storage.saveThemePreference(preference);
}, []);
+ const setAccentPreference = useCallback((preference: AccentPreference) => {
+ if (preference !== 'coral' && !isPlus) return;
+ setSavedAccentPreference(preference);
+ void storage.saveAccentPreference(preference);
+ }, [isPlus]);
+
const value = useMemo(
- () => ({ colors: themeColors, isDark, themePreference, setThemePreference }),
- [isDark, setThemePreference, themeColors, themePreference]
+ () => ({
+ colors: themeColors,
+ isDark,
+ themePreference,
+ accentPreference,
+ setThemePreference,
+ setAccentPreference,
+ }),
+ [accentPreference, isDark, setAccentPreference, setThemePreference, themeColors, themePreference]
);
return (
diff --git a/src/theme/__tests__/colors.test.ts b/src/theme/__tests__/colors.test.ts
new file mode 100644
index 0000000..075cb0d
--- /dev/null
+++ b/src/theme/__tests__/colors.test.ts
@@ -0,0 +1,11 @@
+import { colors, getThemeColors } from '@/theme/colors';
+
+describe('Plus accent themes', () => {
+ it('keeps the free coral theme and applies paid accents without changing semantic colors', () => {
+ expect(getThemeColors('light', 'coral')).toBe(colors.light);
+
+ const ocean = getThemeColors('light', 'ocean');
+ expect(ocean.accent).toBe('#3C78A8');
+ expect(ocean.success).toBe(colors.light.success);
+ });
+});
diff --git a/src/theme/colors.ts b/src/theme/colors.ts
index 1e44771..d9dbebe 100644
--- a/src/theme/colors.ts
+++ b/src/theme/colors.ts
@@ -31,6 +31,8 @@ export interface ThemeColors {
skeleton: string;
}
+export type AccentPreference = 'coral' | 'sage' | 'ocean' | 'plum';
+
const lightColors: ThemeColors = {
// Backgrounds
background: '#FAF9F7',
@@ -127,3 +129,31 @@ export const colors = {
};
export type ColorScheme = 'light' | 'dark';
+
+const accentOverrides: Record<
+ Exclude,
+ Record>
+> = {
+ sage: {
+ light: { accent: '#4F7A68', accentLight: '#EDF5F1', accentMuted: '#CFE2D8' },
+ dark: { accent: '#83B49F', accentLight: '#192821', accentMuted: '#2F4B3F' },
+ },
+ ocean: {
+ light: { accent: '#3C78A8', accentLight: '#EBF4FA', accentMuted: '#C9DEEC' },
+ dark: { accent: '#72A9D2', accentLight: '#182633', accentMuted: '#29475E' },
+ },
+ plum: {
+ light: { accent: '#8B5A83', accentLight: '#F6EEF4', accentMuted: '#E3CDDE' },
+ dark: { accent: '#C190B8', accentLight: '#2B1D28', accentMuted: '#503947' },
+ },
+};
+
+export function getThemeColors(
+ scheme: ColorScheme,
+ accentPreference: AccentPreference,
+): ThemeColors {
+ const base = colors[scheme];
+ return accentPreference === 'coral'
+ ? base
+ : { ...base, ...accentOverrides[accentPreference][scheme] };
+}
From c505b83a11b986bb29879c178327460f187ec370 Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 14:54:38 -0400
Subject: [PATCH 27/39] Draft Shipaton submission
---
devpost-submission.md | 142 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 142 insertions(+)
create mode 100644 devpost-submission.md
diff --git a/devpost-submission.md b/devpost-submission.md
new file mode 100644
index 0000000..940700a
--- /dev/null
+++ b/devpost-submission.md
@@ -0,0 +1,142 @@
+# OpenDating — RevenueCat Shipaton 2026
+
+Status: draft only. Nothing in this file has been submitted to Devpost.
+
+## Project details
+
+- Project: OpenDating
+- Tagline: Deliberate private introductions on an open protocol.
+- Devpost draft: https://devpost.com/software/opendating
+- Live web preview: https://opendating-mobile.expo.app
+- Source: https://github.com/jongan69/opendating-mobile
+- Platform: iOS (iPhone and iPad)
+- Built with: Expo, React Native, TypeScript, RevenueCat, Nostr, Cloudflare Workers, Cloudflare Workers AI
+
+## Description
+
+### Inspiration
+
+Dating apps often ask people to trade privacy for participation: a phone number,
+precise location, readable conversations, and an engagement-optimized swipe
+feed. OpenDating explores a smaller, calmer model where members own their
+identity and understand each disclosure before making a choice.
+
+### What it does
+
+OpenDating presents one deliberate introduction at a time. Each introduction
+explains the visible compatibility facts behind it and includes a privacy
+receipt showing what is shared, withheld, and unlocked only after mutual
+interest. Interest is private, and a conversation opens only after both people
+choose each other.
+
+Members create a self-owned account without email, phone number, or social
+login. Exact GPS is reduced on-device to an approximate 5 km area before
+discovery. Matched conversations use end-to-end encryption. The app also
+includes blocking, private reporting, visibility controls, recovery-key backup,
+and permanent account deletion.
+
+OpenDating Plus is an optional $4.99 lifetime purchase powered by RevenueCat.
+It unlocks Sage, Ocean, and Plum accent themes. It never changes discovery,
+ranking, matching, messaging, recovery, deletion, or safety.
+
+### How we built it
+
+The client uses Expo Router, React Native, and strict TypeScript. Nostr provides
+the identity and cryptographic building blocks, while the OpenDating protocol
+defines profiles, discovery, private choices, matches, moderation, and encrypted
+messages. A Cloudflare Worker operates the relay and media services. RevenueCat
+validates the lifetime Plus entitlement using an opaque billing identifier;
+profile, location, recovery-key, safety, and message content is not sent to
+RevenueCat.
+
+The browser build uses an encrypted local identity vault so the real app can be
+tested without a native install. The private recovery key is encrypted with
+AES-256-GCM using a PBKDF2-derived browser-lock key and is never persisted in
+plaintext.
+
+### Challenges
+
+The hardest boundary was making a decentralized identity understandable and
+recoverable without turning the recovery key into a normal password. We also
+had to keep coarse location, encrypted messaging, moderation, browser storage,
+and StoreKit entitlement state explicit across degraded-network and locked
+states.
+
+Apple declined the first iOS candidate under Guideline 4.3(b). We replaced the
+swipe-first experience with deliberate private introductions, privacy receipts,
+and a Privacy Passport before preparing a fresh release.
+
+### Accomplishments
+
+- One explained introduction instead of an endless swipe feed
+- A live Privacy Passport for identity, visibility, and disclosure boundaries
+- Coarse-location discovery without transmitting raw GPS
+- Match-only end-to-end encrypted conversations
+- Operational report, block, unmatch, feedback, and deletion paths
+- A browser app with an encrypted recovery-key vault
+- A non-consumable Plus product that sells customization, not dating outcomes
+
+### What we learned
+
+Privacy works better as an interaction than as a policy page. Showing a privacy
+receipt at the moment of an introduction makes the boundary understandable.
+Likewise, a portable identity needs visible backup, lock, and deletion controls
+to feel trustworthy rather than merely decentralized.
+
+### How we used AI
+
+Cloudflare Workers AI screens only profile display names and bios for obvious
+safety-policy violations before publication. It does not process photos,
+location, recovery keys, likes, matches, reports, or encrypted messages. Codex
+was used to inspect the repository, implement and review changes, run tests,
+and verify deployment and release artifacts.
+
+### What's next
+
+Complete the RevenueCat catalog, run the lifetime purchase and restore flow on
+a physical iOS device, publish the revised App Store build, and validate the
+live Store listing. Federation and independent service providers remain
+post-hackathon work.
+
+## Shipaton submission fields
+
+- Includes App Icon (27378): pending upload of
+ `BrandAssetPack/icons/ios-app-icon-1024.png`
+- Includes screenshot (27379): pending fresh 1179 × 2556 physical-device capture
+- First Version Date Confirmation (27380): answer only after the revised build is published
+- Is Staff or Sponsor (27381): false
+- App type (27382): iOS (iPhone and/or iPad)
+- Published App Store URL (27383): pending
+- RevenueCat project ID (28118): pending RevenueCat account connection
+- Promo code (28135): pending App Store promo-code creation
+- HAMM Award (27388): OpenDating uses one transparent lifetime purchase for
+ cosmetic themes. A non-consumable purchase fits a privacy-first utility better
+ than recurring access fees or advertising, and core dating and safety features
+ remain free.
+- Peace Prize (27389): OpenDating minimizes sensitive data collection, keeps
+ choices private until mutual, encrypts matched conversations, and gives members
+ direct visibility, recovery, reporting, blocking, and deletion controls.
+- Design Award (27391): The app replaces swipe mechanics with one deliberate
+ introduction, visible compatibility reasons, a privacy receipt, and a Privacy
+ Passport. The interface uses calm platform-aware surfaces and accessible
+ light, dark, and accent themes.
+- Additional notes (27392): The public web preview demonstrates the application
+ flow, but the standard Shipaton entry will use the published iOS Store URL and
+ a physical-device demo video.
+
+## Required completion evidence
+
+- [x] Description drafted
+- [x] 1024 × 1024 icon exists
+- [x] Production web preview deployed
+- [x] Cloudflare Worker responds in production
+- [x] App Store non-consumable created
+- [ ] RevenueCat project, entitlement, offering, and product connected
+- [ ] Fresh iOS production build uploaded
+- [ ] Purchase, restore, and entitlement verified on a physical iOS device
+- [ ] Fresh 1179 × 2556 screenshot uploaded without a device frame
+- [ ] Public YouTube or Vimeo demo, two minutes or less
+- [ ] Revised app fully published on the App Store
+- [ ] App Store promo code created for judges
+- [ ] Devpost project update confirmed by Jonathan
+- [ ] Final Devpost submission explicitly confirmed by Jonathan
From e86f2fb27391e5b396a1b058620bb5626d8aaef4 Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 15:11:31 -0400
Subject: [PATCH 28/39] Record RevenueCat project
---
devpost-submission.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/devpost-submission.md b/devpost-submission.md
index 940700a..9ab45c5 100644
--- a/devpost-submission.md
+++ b/devpost-submission.md
@@ -107,7 +107,7 @@ post-hackathon work.
- Is Staff or Sponsor (27381): false
- App type (27382): iOS (iPhone and/or iPad)
- Published App Store URL (27383): pending
-- RevenueCat project ID (28118): pending RevenueCat account connection
+- RevenueCat project ID (28118): `proj3363ecdc`
- Promo code (28135): pending App Store promo-code creation
- HAMM Award (27388): OpenDating uses one transparent lifetime purchase for
cosmetic themes. A non-consumable purchase fits a privacy-first utility better
@@ -131,7 +131,7 @@ post-hackathon work.
- [x] Production web preview deployed
- [x] Cloudflare Worker responds in production
- [x] App Store non-consumable created
-- [ ] RevenueCat project, entitlement, offering, and product connected
+- [x] RevenueCat project, entitlement, offering, and product connected
- [ ] Fresh iOS production build uploaded
- [ ] Purchase, restore, and entitlement verified on a physical iOS device
- [ ] Fresh 1179 × 2556 screenshot uploaded without a device frame
From aa3a61c0777a1c0aaf68b64eac793100f5e8a03c Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 15:28:13 -0400
Subject: [PATCH 29/39] Record iOS candidate evidence
---
devpost-submission.md | 3 ++-
docs/HANDOFF.md | 23 ++++++++++++-----------
docs/MONETIZATION.md | 10 +++++++---
docs/RELEASE-STATUS.md | 14 +++++++++-----
docs/STORE_LISTING.md | 5 +++--
release/manifest.json | 14 +++++++-------
6 files changed, 40 insertions(+), 29 deletions(-)
diff --git a/devpost-submission.md b/devpost-submission.md
index 9ab45c5..a556f53 100644
--- a/devpost-submission.md
+++ b/devpost-submission.md
@@ -128,11 +128,12 @@ post-hackathon work.
- [x] Description drafted
- [x] 1024 × 1024 icon exists
+- [x] Devpost project, write-up, links, and app icon published
- [x] Production web preview deployed
- [x] Cloudflare Worker responds in production
- [x] App Store non-consumable created
- [x] RevenueCat project, entitlement, offering, and product connected
-- [ ] Fresh iOS production build uploaded
+- [x] Fresh iOS 0.1.1 build 5 uploaded and valid in App Store Connect
- [ ] Purchase, restore, and entitlement verified on a physical iOS device
- [ ] Fresh 1179 × 2556 screenshot uploaded without a device frame
- [ ] Public YouTube or Vimeo demo, two minutes or less
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index 36f3fa4..204c001 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -1,6 +1,6 @@
# OpenDating Mobile Handoff
-Last reviewed: August 20, 2026.
+Last reviewed: August 29, 2026.
## Handoff status
@@ -9,11 +9,11 @@ Release 0.1.1 is **not approved for launch**: `release/manifest.json` is blocked
Apple rejected the previous submission under Guideline 4.3(b), and the next
candidate still needs external and physical-device evidence.
-`main` remains the production branch. `dev` contains the reconciled remote
-baseline plus disabled, development-only monetization work. Do not merge that
-work into the next App Review candidate until `docs/MONETIZATION.md` is
-satisfied; the safest next candidate is the private-introductions redesign
-without paid features.
+`main` remains the production branch. `dev` contains the production web app and
+the one-time Plus implementation. EAS production build 0.1.1 (5) enables the
+RevenueCat public SDK configuration and is attached to the editable App Store
+version, but it is not approved for release until `docs/MONETIZATION.md` and the
+physical-device gates are satisfied.
The source of truth for current readiness is:
@@ -54,10 +54,11 @@ Do not put credential values in issues, documentation, chat, or Git.
| GitHub | Admin or maintainer on both repositories | `main` is protected; staging and reviewer-gated production environments exist |
| npm | Publish rights for `opendating-protocol` | Current session is unauthenticated; registry remains `0.1.0` |
| Expo/EAS | Project owner or developer | Production builds remain release-manifest gated |
-| Apple | App Store Connect access | Ship a fresh 0.1.1 build; 0.1.0 build 4 is superseded |
+| Apple | App Store Connect access | 0.1.1 build 5 is valid, attached, and in the internal `OpenDating QA` TestFlight group; physical proof and replacement media remain |
| Google | Play Console and service account | Ship a fresh 0.1.1 bundle; 0.1.0 version code 3 is superseded |
| Cloudflare | Read access for integration diagnosis | Resource ownership and deployments belong to the backend repository |
-| Future vendors | Persona, Hive, RevenueCat, Sentry, moderation provider | Not approved or active for production data |
+| RevenueCat | Project `proj3363ecdc` | Catalog and public SDK key are active for build 5; Apple credentials and native transaction proof remain |
+| Future vendors | Persona, Hive, Sentry, moderation provider | Not approved or active for production data |
## Known dependency state
@@ -72,9 +73,9 @@ dependency set and inspect the remaining paths.
1. Keep `dev` clean and run the Bun quality gates above before new work.
2. Authenticate npm, publish and verify `opendating-protocol@0.1.1`, then pin the exact artifact here and remove the temporary request-routing mirror.
3. Resolve the dependency audit without broad or permanent exceptions.
-4. Choose a release commit without unfinished RevenueCat behavior, then perform the complete iOS and Android physical-device walkthroughs.
-5. Capture fresh Passport/private-introduction screenshots and the App Review video; create a fresh 0.1.1 build with a new build number.
-6. Confirm App Privacy, Regulations and Permits, release notes, pricing/availability, and the 0.1.1 Store version before resubmitting with the prepared 4.3(b) response.
+4. Install TestFlight build 5 on a physical iOS device and verify purchase, restore, revocation, identity changes, reinstall, offline behavior, and the complete critical walkthrough.
+5. Capture fresh Passport/private-introduction screenshots, the Plus review screenshot, and the App Review video from that verified build.
+6. Confirm App Privacy, Regulations and Permits, release notes, pricing/availability, and the 0.1.1 Store version before any resubmission with the prepared 4.3(b) response.
7. Do not start public beta until the trust-and-safety, legal, vendor, deletion, security, and staffing gates are evidenced.
## Release handoff rule
diff --git a/docs/MONETIZATION.md b/docs/MONETIZATION.md
index a285928..7c9608d 100644
--- a/docs/MONETIZATION.md
+++ b/docs/MONETIZATION.md
@@ -1,8 +1,8 @@
# OpenDating monetization contract
-**Status:** implementation complete but disabled until the store and RevenueCat
-catalogs are connected and the purchase/restore flow passes on a physical iOS
-device.
+**Status:** implementation and catalogs are connected in production candidate
+0.1.1 (5). The feature remains a blocked release candidate until the
+purchase/restore flow passes on a physical iOS device.
OpenDating Plus may sell customization and convenience. It must never sell
ranking, boosts, hidden admirers, safety, verification, recovery, deletion,
@@ -10,6 +10,8 @@ matching, reactions, discovery, filters, or messaging.
## RevenueCat catalog
+- Project: `proj3363ecdc`
+- iOS app: `app8aaadf2571`
- Entitlement: `plus`
- Offering: `default`
- Product: `opendating_plus_lifetime` (non-consumable)
@@ -18,6 +20,8 @@ matching, reactions, discovery, filters, or messaging.
- Package: `$rc_lifetime`
- Price: $4.99 in the US, with store-localized pricing elsewhere
- Benefit: Sage, Ocean, and Plum app accent themes
+- EAS build: `cf5d4b22-f7aa-430b-980f-f4b191718612`
+- App Store Connect build: `12dd3ff2-f9bd-491a-984f-b14aafd02ef8` (0.1.1 build 5, valid)
Prices are never hardcoded in the app. The paywall renders the localized store
price delivered by RevenueCat.
diff --git a/docs/RELEASE-STATUS.md b/docs/RELEASE-STATUS.md
index 885cd25..6057627 100644
--- a/docs/RELEASE-STATUS.md
+++ b/docs/RELEASE-STATUS.md
@@ -2,10 +2,12 @@
**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.
-Apple rejected the previous submission under Guideline 4.3(b). The valid
-0.1.1 build already in App Store Connect predates the private-introductions
-redesign and must not be resubmitted. The next attempt needs a fresh build and
-the evidence listed in `docs/GUIDELINE-4.3B-RESPONSE.md`.
+Apple rejected the previous submission under Guideline 4.3(b). Fresh production
+build 0.1.1 (5), built from `c505b83a11b986bb29879c178327460f187ec370`,
+is valid in App Store Connect and attached to the editable 0.1.1 version. It is
+not submitted for review. Replacement screenshots, physical-device recording,
+purchase/restore proof, and the evidence in `docs/GUIDELINE-4.3B-RESPONSE.md`
+remain required.
## Verified in this release
@@ -18,6 +20,8 @@ the evidence listed in `docs/GUIDELINE-4.3B-RESPONSE.md`.
- Early direct messages are buffered during bootstrap and in-memory replay tracking is bounded.
- Typecheck, lint, unit tests, protocol conformance tests, dependency review, production audit exceptions, and release-manifest validation are CI gates.
- Both repositories protect `main`; GitHub staging environments are protected-branch-only and production environments require `jongan69` approval.
+- The production web app and Cloudflare Worker respond successfully over HTTPS, and the web export includes the restrictive CSP.
+- RevenueCat project `proj3363ecdc` contains the App Store product, `plus` entitlement, current `default` offering, and `$rc_lifetime` package used by build 5.
## Tracked for follow-up releases
@@ -28,7 +32,7 @@ These are post-launch engineering priorities, not launch gates:
3. Enforce global blocks and active-match authorization before any inbound DM reaches UI state.
4. Add push delivery, liveness/age verification, profile moderation, appeals, and staffed trust-and-safety operations.
5. Resolve or renew the time-limited SDK dependency exceptions in `security/audit-exceptions.json`.
-6. Finish or remove the development-only RevenueCat integration before selecting a release commit.
+6. Validate RevenueCat purchase, restore, revocation, identity changes, reinstall, and offline behavior on a physical iOS device.
## Release mechanics
diff --git a/docs/STORE_LISTING.md b/docs/STORE_LISTING.md
index 2375ab4..4dc1f35 100644
--- a/docs/STORE_LISTING.md
+++ b/docs/STORE_LISTING.md
@@ -135,10 +135,11 @@ the release evidence before adding those files back to `store.config.json`.
| Platform | Build | Artifact |
|---|---|---|
-| iOS | `79e9b81e-6f66-4dcc-b46f-4ae53da4ae67` | Superseded App Store IPA, v0.1.0 build 4 — do not ship |
+| iOS | `cf5d4b22-f7aa-430b-980f-f4b191718612` | EAS production IPA, v0.1.1 build 5; App Store Connect build `12dd3ff2-f9bd-491a-984f-b14aafd02ef8`, valid and attached but not submitted |
| Android | `f5b79c72-91ee-4dd3-96e7-0343911e2ca2` | Superseded Play Store AAB, v0.1.0 version code 3 — do not ship |
-Build fresh 0.1.1 artifacts before submitting.
+Build a fresh Android 0.1.1 artifact before any Android submission. Do not
+replace iOS build 5 unless physical testing finds a release-blocking defect.
## Submission Commands
diff --git a/release/manifest.json b/release/manifest.json
index 1c979db..085b1b5 100644
--- a/release/manifest.json
+++ b/release/manifest.json
@@ -2,15 +2,15 @@
"schemaVersion": 2,
"status": "blocked",
"appVersion": "0.1.1",
- "gitSha": null,
+ "gitSha": "c505b83a11b986bb29879c178327460f187ec370",
"protocolVersion": "0.1",
"databaseSchema": "legacy-unversioned",
"artifacts": {
"ios": {
"appVersion": "0.1.1",
- "buildNumber": null,
- "buildId": null,
- "sha256": null
+ "buildNumber": "5",
+ "buildId": "cf5d4b22-f7aa-430b-980f-f4b191718612",
+ "sha256": "9e8c5848ddf82cd9c0215c1d7e5222cc0ae23e416c9e2d3d24e70c22ca7489e6"
},
"android": {
"appVersion": "0.1.1",
@@ -23,13 +23,13 @@
"approvedAt": null,
"approvedBy": null,
"blockers": [
- "App Store submission 39b836d6-fdcd-4920-94fa-2a54b5b8a4ce remains rejected under Guideline 4.3(b); the next submission requires a fresh post-redesign 0.1.1 build, screenshots, physical-device recording, and reviewed response",
+ "App Store version 0.1.1 has fresh build 5 attached, but still requires replacement screenshots, a physical-device recording, purchase and restore evidence, and the reviewed Guideline 4.3(b) response",
"Android 0.1.0 build 3 is not yet confirmed held and unavailable to production users",
"opendating-protocol 0.1.1 is not published or verified on the registry (jongan69/OpenDating#7)",
"The protocol dependency is exact-pinned to 0.1.0; it is not yet moved to the verified 0.1.1 artifact and the temporary request-routing mirror is still in place",
"Deletion, private introductions, and candidate-profile navigation are not verified on physical iOS and Android devices from this release candidate",
- "Development-only RevenueCat work must remain excluded from the App Review candidate or be completed with store catalog, privacy disclosure, webhook, and native purchase evidence",
- "Protected CI run, signed tag, source SHA, EAS artifact IDs and checksums, store state, migrations, and rollback evidence are not recorded",
+ "RevenueCat project, product, entitlement, offering, package, privacy disclosure, and production public SDK key are connected; Apple credentials and native purchase, restore, revocation, and offline evidence remain unverified",
+ "Protected CI run, signed tag, manifest approval commit, migrations, final store state, and rollback evidence are not recorded",
"Cloudflare deployment provenance and environment isolation are unproven (jongan69/OpenDating#8)"
]
}
From 8f85188846ff0b88c6bf2e407257ee226cb2d3df Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 15:30:17 -0400
Subject: [PATCH 30/39] Clarify Shipaton eligibility
---
devpost-submission.md | 7 +++++++
docs/RELEASE-STATUS.md | 5 +++++
2 files changed, 12 insertions(+)
diff --git a/devpost-submission.md b/devpost-submission.md
index a556f53..b3a2132 100644
--- a/devpost-submission.md
+++ b/devpost-submission.md
@@ -2,6 +2,12 @@
Status: draft only. Nothing in this file has been submitted to Devpost.
+The live web build is the same Expo application and is the public demo. It does
+not replace the native Store URL for a standard Shipaton entry: Shipaton states
+that web apps and TestFlight builds are not eligible. The only no-Store path is
+the Next Gen Award for an eligible student using a verifiable academic email,
+public repository, and demo video.
+
## Project details
- Project: OpenDating
@@ -134,6 +140,7 @@ post-hackathon work.
- [x] App Store non-consumable created
- [x] RevenueCat project, entitlement, offering, and product connected
- [x] Fresh iOS 0.1.1 build 5 uploaded and valid in App Store Connect
+- [x] Build 5 attached to App Store version 0.1.1 and internal TestFlight QA
- [ ] Purchase, restore, and entitlement verified on a physical iOS device
- [ ] Fresh 1179 × 2556 screenshot uploaded without a device frame
- [ ] Public YouTube or Vimeo demo, two minutes or less
diff --git a/docs/RELEASE-STATUS.md b/docs/RELEASE-STATUS.md
index 6057627..9f62f63 100644
--- a/docs/RELEASE-STATUS.md
+++ b/docs/RELEASE-STATUS.md
@@ -9,6 +9,11 @@ not submitted for review. Replacement screenshots, physical-device recording,
purchase/restore proof, and the evidence in `docs/GUIDELINE-4.3B-RESPONSE.md`
remain required.
+The stale rejected review submission is canceled. `asc review doctor` reports
+no public-API blocking checks; the IAP review image and the initial-version
+release-note warning remain, along with website-only Regulations and Permits
+declarations.
+
## Verified in this release
- One-at-a-time private introductions and candidate-profile navigation are implemented.
From 55dfa4bdb323572597e9717b52be4f5c619b2bca Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 15:38:22 -0400
Subject: [PATCH 31/39] Record App Review submission
---
docs/HANDOFF.md | 9 +++++----
docs/RELEASE-STATUS.md | 8 +++++---
docs/STORE_LISTING.md | 8 ++++----
release/manifest.json | 2 +-
4 files changed, 15 insertions(+), 12 deletions(-)
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index 204c001..bd85004 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -12,8 +12,9 @@ candidate still needs external and physical-device evidence.
`main` remains the production branch. `dev` contains the production web app and
the one-time Plus implementation. EAS production build 0.1.1 (5) enables the
RevenueCat public SDK configuration and is attached to the editable App Store
-version, but it is not approved for release until `docs/MONETIZATION.md` and the
-physical-device gates are satisfied.
+version. App Review submission `278e4366-f6a6-4f02-a7a7-2fcce7980f42` is
+waiting for review, but the repository does not approve release until
+`docs/MONETIZATION.md` and the physical-device gates are satisfied.
The source of truth for current readiness is:
@@ -54,7 +55,7 @@ Do not put credential values in issues, documentation, chat, or Git.
| GitHub | Admin or maintainer on both repositories | `main` is protected; staging and reviewer-gated production environments exist |
| npm | Publish rights for `opendating-protocol` | Current session is unauthenticated; registry remains `0.1.0` |
| Expo/EAS | Project owner or developer | Production builds remain release-manifest gated |
-| Apple | App Store Connect access | 0.1.1 build 5 is valid, attached, and in the internal `OpenDating QA` TestFlight group; physical proof and replacement media remain |
+| Apple | App Store Connect access | 0.1.1 build 5 is waiting for App Review and is in the internal `OpenDating QA` TestFlight group; physical proof and replacement media remain |
| Google | Play Console and service account | Ship a fresh 0.1.1 bundle; 0.1.0 version code 3 is superseded |
| Cloudflare | Read access for integration diagnosis | Resource ownership and deployments belong to the backend repository |
| RevenueCat | Project `proj3363ecdc` | Catalog and public SDK key are active for build 5; Apple credentials and native transaction proof remain |
@@ -75,7 +76,7 @@ dependency set and inspect the remaining paths.
3. Resolve the dependency audit without broad or permanent exceptions.
4. Install TestFlight build 5 on a physical iOS device and verify purchase, restore, revocation, identity changes, reinstall, offline behavior, and the complete critical walkthrough.
5. Capture fresh Passport/private-introduction screenshots, the Plus review screenshot, and the App Review video from that verified build.
-6. Confirm App Privacy, Regulations and Permits, release notes, pricing/availability, and the 0.1.1 Store version before any resubmission with the prepared 4.3(b) response.
+6. Monitor App Review submission `278e4366-f6a6-4f02-a7a7-2fcce7980f42`; respond truthfully with the prepared 4.3(b) evidence if Apple requests clarification.
7. Do not start public beta until the trust-and-safety, legal, vendor, deletion, security, and staffing gates are evidenced.
## Release handoff rule
diff --git a/docs/RELEASE-STATUS.md b/docs/RELEASE-STATUS.md
index 9f62f63..fe0f759 100644
--- a/docs/RELEASE-STATUS.md
+++ b/docs/RELEASE-STATUS.md
@@ -5,9 +5,11 @@
Apple rejected the previous submission under Guideline 4.3(b). Fresh production
build 0.1.1 (5), built from `c505b83a11b986bb29879c178327460f187ec370`,
is valid in App Store Connect and attached to the editable 0.1.1 version. It is
-not submitted for review. Replacement screenshots, physical-device recording,
-purchase/restore proof, and the evidence in `docs/GUIDELINE-4.3B-RESPONSE.md`
-remain required.
+waiting for App Review in submission
+`278e4366-f6a6-4f02-a7a7-2fcce7980f42`, submitted on August 29, 2026.
+Replacement screenshots, physical-device recording, purchase/restore proof,
+and the evidence in `docs/GUIDELINE-4.3B-RESPONSE.md` remain required before
+the repository can record launch approval.
The stale rejected review submission is canceled. `asc review doctor` reports
no public-API blocking checks; the IAP review image and the initial-version
diff --git a/docs/STORE_LISTING.md b/docs/STORE_LISTING.md
index 4dc1f35..e37aa7f 100644
--- a/docs/STORE_LISTING.md
+++ b/docs/STORE_LISTING.md
@@ -2,10 +2,10 @@
**Last updated:** 2026-08-29
-> **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.
+> **Submission status: waiting for App Review.** Apple rejected iOS 0.1.0 (1)
+> under Guideline 4.3(b) on August 13, 2026. The materially revised 0.1.1 (5)
+> candidate was submitted on August 29, 2026. Repository launch approval stays
+> blocked until its independent release evidence is complete.
This is the canonical copy deck for the first App Store Connect and Google Play
Console records.
diff --git a/release/manifest.json b/release/manifest.json
index 085b1b5..8f4abef 100644
--- a/release/manifest.json
+++ b/release/manifest.json
@@ -23,7 +23,7 @@
"approvedAt": null,
"approvedBy": null,
"blockers": [
- "App Store version 0.1.1 has fresh build 5 attached, but still requires replacement screenshots, a physical-device recording, purchase and restore evidence, and the reviewed Guideline 4.3(b) response",
+ "App Store version 0.1.1 build 5 is waiting for review in submission 278e4366-f6a6-4f02-a7a7-2fcce7980f42, but still requires physical-device purchase and restore evidence before repository launch approval",
"Android 0.1.0 build 3 is not yet confirmed held and unavailable to production users",
"opendating-protocol 0.1.1 is not published or verified on the registry (jongan69/OpenDating#7)",
"The protocol dependency is exact-pinned to 0.1.0; it is not yet moved to the verified 0.1.1 artifact and the temporary request-routing mirror is still in place",
From edd512f9d49c447f56928ebf8f3634a97a090ff0 Mon Sep 17 00:00:00 2001
From: Jonathan Gan
Date: Sat, 29 Aug 2026 16:49:32 -0400
Subject: [PATCH 32/39] Prepare final Devpost assets
---
devpost-submission.md | 67 +++++++++++++++---
.../devpost/opendating-welcome-1179x2556.png | Bin 0 -> 57720 bytes
2 files changed, 59 insertions(+), 8 deletions(-)
create mode 100644 screenshots/devpost/opendating-welcome-1179x2556.png
diff --git a/devpost-submission.md b/devpost-submission.md
index b3a2132..ccf1a73 100644
--- a/devpost-submission.md
+++ b/devpost-submission.md
@@ -1,6 +1,7 @@
# OpenDating — RevenueCat Shipaton 2026
-Status: draft only. Nothing in this file has been submitted to Devpost.
+Status: draft only. The public project page is published, but OpenDating has not
+been entered into Shipaton judging.
The live web build is the same Expo application and is the public demo. It does
not replace the native Store URL for a standard Shipaton entry: Shipaton states
@@ -99,16 +100,64 @@ and verify deployment and release artifacts.
### What's next
-Complete the RevenueCat catalog, run the lifetime purchase and restore flow on
-a physical iOS device, publish the revised App Store build, and validate the
-live Store listing. Federation and independent service providers remain
+Wait for App Review of iOS 0.1.1 (5), complete the App Store metadata for the
+lifetime purchase, run purchase and restore on a physical iOS device, and add
+the resulting Store URL, promo code, screenshot, and public demo video to the
+Shipaton entry. Federation and independent service providers remain
post-hackathon work.
+## Testing instructions
+
+1. Open https://opendating-mobile.expo.app in a modern HTTPS browser.
+2. Create a browser account with a 12-character-or-longer browser-lock
+ passphrase, or import a disposable Nostr `nsec`/hex recovery key.
+3. Complete onboarding, then verify Privacy Passport, introductions, profile,
+ visibility, blocking/reporting, matches, browser lock, and recovery-key
+ export/import.
+4. Reload the page and verify the encrypted identity locks before reconnecting.
+5. On iOS, use App Store build 0.1.1 (5) and verify the lifetime Plus purchase,
+ restore, and entitlement removal with a disposable StoreKit account.
+
+Do not use a personal recovery key or include private keys, exact location, or
+message content in screenshots or bug reports.
+
+## Public demo and repository
+
+- Live app: https://opendating-mobile.expo.app
+- Source: https://github.com/jongan69/opendating-mobile
+- Demo video: pending a public YouTube or Vimeo upload, two minutes or less
+
+## Demo video outline
+
+- 0:00–0:15 — problem and OpenDating's private-introduction model
+- 0:15–0:45 — create/import account and coarse-location boundary
+- 0:45–1:15 — Privacy Passport, introduction reasons, and privacy receipt
+- 1:15–1:35 — mutual match, encrypted messaging, report/block controls
+- 1:35–1:50 — $4.99 lifetime Plus themes and RevenueCat restore
+- 1:50–2:00 — live web app, open protocol, and closing result
+
+## Screenshot shot list
+
+- Prepared: `screenshots/devpost/opendating-welcome-1179x2556.png` — live app,
+ exact required dimensions, no device frame, no user data
+- Pending physical capture: Privacy Passport and introduction privacy receipt
+- Pending IAP review capture: Plus lifetime-purchase screen
+
+## Known limitations
+
+- The public web app is a functional preview, but standard Shipaton eligibility
+ requires the fully published native Store listing.
+- OpenDating Plus remains unavailable until Apple accepts its missing review
+ metadata and the purchase is proven on a physical device.
+- The relay and protocol are an early public implementation without federation
+ or a production availability SLA.
+
## Shipaton submission fields
-- Includes App Icon (27378): pending upload of
- `BrandAssetPack/icons/ios-app-icon-1024.png`
-- Includes screenshot (27379): pending fresh 1179 × 2556 physical-device capture
+- Includes App Icon (27378): `BrandAssetPack/icons/ios-app-icon-1024.png` is
+ already the project thumbnail; confirm the checkbox during final review
+- Includes screenshot (27379): prepared at
+ `screenshots/devpost/opendating-welcome-1179x2556.png`; pending Devpost upload
- First Version Date Confirmation (27380): answer only after the revised build is published
- Is Staff or Sponsor (27381): false
- App type (27382): iOS (iPhone and/or iPad)
@@ -142,8 +191,10 @@ post-hackathon work.
- [x] Fresh iOS 0.1.1 build 5 uploaded and valid in App Store Connect
- [x] Build 5 attached to App Store version 0.1.1 and internal TestFlight QA
- [ ] Purchase, restore, and entitlement verified on a physical iOS device
-- [ ] Fresh 1179 × 2556 screenshot uploaded without a device frame
+- [x] Fresh 1179 × 2556 screenshot captured without a device frame or user data
+- [ ] Fresh screenshot uploaded to Devpost
- [ ] Public YouTube or Vimeo demo, two minutes or less
+- [x] Revised iOS 0.1.1 build 5 submitted and waiting for App Review
- [ ] Revised app fully published on the App Store
- [ ] App Store promo code created for judges
- [ ] Devpost project update confirmed by Jonathan
diff --git a/screenshots/devpost/opendating-welcome-1179x2556.png b/screenshots/devpost/opendating-welcome-1179x2556.png
new file mode 100644
index 0000000000000000000000000000000000000000..36cbfa70a990f5f82e29ded0853330cb635a16fa
GIT binary patch
literal 57720
zcmeFZc~n|g+c#|5CTWuO`W3N
zsWA!!y^9GbD3&-NA~Az#qM%Vk6cnQd6%}!whnK$J_q^*}-?zT?taq*ZdDgey^_=qu
z=d69swfEV3U;DcD@A_RF3?01F`s}jT_g-3u4ryr}(wtfcGOcg5j(l|Z$l;HU965aC
z=+Pq|AN%asu}?lZrhD?#r=J<P53~<`sD0?5
zUF)o-kso|`=&$zv+wswn4-bEQ^n<@jp`U3!;=>O<{OIuULx(>+_R&X2wGRDtr+wt}
zzv%$J_CKm?U~*0G5)>A9>;CVL`})WAO~3i^4c`|J162OoWSSW{%9t*P&W4?q6kgF}b^?+1dO
z$6NOo0bhRM|Yu(Y3a>o2Y#n6-8JR${aWC))Xw*8)n;b
zyEkrh#Z4PDo71GNT^yH&gxCbazaq`=n~
z)N+$KTJK;3W5>dc1bpo=SCgpmW6%E;9scjjI8l1?YGXm|M8xavw!4@-_FBHS5XO<>
z$gPnX>>_LFTZ8hy4JK~0zOoUQ4@q)lsc!pc>USeZRBr_c8(h2V-K`kwl2qm-9iquqJ}-e>ZZ|sqXqIS;=})Tb9B^x
ziV<8i%*OS<7S9ssa-DAU~H?
zrBR0nyhTs7o=cW?9NrPfOB*vslf+Fs9-7(J_vvA_uXyps_TL9UmZj@2xp*78O*{h>zc?kg4tT5`9*RZWjYt@-VCwQZEgpYgBRa>!K{eYT1%;Taxi4evn@v@X@{
zO)9WnZR=S92U-HgfmX`&-%1R7i~kaL;y_DVadU0&8_TzUUW#KmnV97_`t&-$r>FgV
z)%tZ6r)C(tS@bWI+Q0rZ8KCktPv6Y)ygIq(o9<4jTiUO>{X=PQ_9|@OePH_4wx&Jj
zXLCNd>UO>_X!Ae|`fo3P^8a}0moHbw_w=%g)|?ji^V&MMDO}6XnJn`b%
zLEez!0Diw-dH+Av_5W`dM|{!@;^VD@JK1pA1}e{5rm#9?b-9DozR0Pt4!mlBHC8`)c7|qSPKqlQo*f-o@`QSi
z33b_R%jInZeE-xRSew{+>tL3zm0w~QDGta!&`KZtG@>NqGsR70X=Hf+no(-+sWFAj
zLJWpH!;*jv0tirqn!pjtXXFmco!@Ue(5J5MK5S;nYk9#6YhHHd)1dBd(d`%wlOFMY?MwYCkb##9T1x}99tJI1j{#v`+EyjM&*kH0{&f@0p
zeDW+^oM%czn5N`eQ0WwUqdWKzu90^*G
z<>XA271q0yC#qfl7P-oe1se4=k@!-%eZf`zXhKu`Mc!S#AOx>2o^RA6h5&VR0Bz}s
zN1fuvD))kJH|*&EVQqJCgLNoIlc{0LED=#gi6Rlpq*<+ns@)LYe~S*cqzw02j9X{&
zB3d7%a7y(&vJ$Rx*@@mZJK}|&m9xAk#;Ci(sMGLa#q&q**~pG_fzA0R-z$W;Swbc1
zhIns`C#p>tYrEauJ~&f!#Vr?ck!mVl*+wf0e4kGq8cH_)-Ll9x#pOhlw`isx8_vux
zt)?JU-Mj}Q^p=)oflsH63_IC&=jzL|)_*K!>!d;^Qtjp(lzj|(jY|f_MBtgz7Vdv;
zKrMs-z==6!g`_*hMjt-D$6Ob1hvqX&c-B9~KC&))nQrhz@?=r8GGlZcEW-Hvt>*nZia2F2X(18
zxR616>Fk&Ng?(!8XQ=Jbny}DkPfAJH&j}R1lL4~1QKxCicW=zz{o|w6+up04u~6YS
z!{yAKh$F@Sl(X*_VNJ?%nJuQM7-9?^M=_V%kEKjK-0)w$KSRq&N(=XH54Wryk;G67
z2L+y>VQzp|U#rF;Ye#FVp++6D_6?`SqfIw$pYV=*3^dCuEj{W!=wM^Stv(PNesd$@
zB1Hj)8Hol{stKCOEd+Zf_|E|QZHk}UZ~jJ(JnyzTYhUXhD~_fxLueJ57*w^!bHrmO
zmj7w2dXZVYX=>ExcIs$c=KY!|StBedf@VyNp;VK^;WQLQ1xl2={0_{pTa=tw?C~v~
z;bz+^@yiHA&7&%+*@%kmewjg>%^AbQNH6rZqcY}uIinnteTNoGa>g^Jex75;u&L|!^>F$YQY2?AgDy|Os|!Q^@x$R_=(r%vITPng
zp!bmj4=0L>y|3ACAyA6EY64-Z5L145-+&Z4$f0eI_`N9;zsHd)c6=iuNLbNydfKfp
zqlGWtF4^7l3E8^B7Pg^A)j`zuc7M-A^2fN&ep~aIek8qI{L_2
zEx_(uOxfqf%q=Y)z8O+`Q}`$?B&d<+d(X%|e(ohTy551R=yFSJ#~RNk&g~al&4!969#(scgLUaunF~LU6rrX%Sq1rmG%>
zfy^=rqp3uE0oCL}#xzxKhCL1|m|N?(Zg3;`Ri!gu8cnW~F{`M~K5D=8t&I;FthTQn
z>qx3OUJ9IHc?EvCOQ+KZ1Y!9wA0J(kqKq1?+OAa_rsE;@#k&Wpxa(gbRpu
zE9{Va26%{z@LBOy<(mcDK3=l+wS^