Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ PS_DATA_SOURCE_URI=postgresql://postgres:postgres@db:5432/postgres
# Or from Supabase Studio: http://localhost:54323 -> Settings -> API -> JWT Secret
PS_SUPABASE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long

# Site URL for auth redirects (Expo dev server default)
EXPO_PUBLIC_SITE_URL=http://LOCAL_IP:8081
# Site URL for auth redirects
EXPO_PUBLIC_SITE_URL=http://LOCAL_IP:3000

# Inbucket inbox URL for local email testing (Supabase Inbucket)
EXPO_PUBLIC_RESEND_LOCAL_INBOX_URL=http://LOCAL_IP:4000/inbox
Expand All @@ -30,6 +30,11 @@ EXPO_PUBLIC_RESEND_LOCAL_INBOX_URL=http://LOCAL_IP:4000/inbox
# EXPO_PUBLIC_POSTHOG_HOST=
# EXPO_PUBLIC_POSTHOG_DISABLED=true

# Edge geolocation for EU/EEA/UK PostHog gating (Cloudflare Worker; no IP stored).
# Set both in EAS for preview/production (must match cloud-services/geo-region/.env.txt).
# EXPO_PUBLIC_GEO_REGION_URL=
# EXPO_PUBLIC_GEO_REGION_API_KEY=

# PostHog CLI — source map uploads (EAS Build / after `eas update`)
# Personal API key with error tracking write + organization read scopes.
# Set these in EAS environment secrets for preview/production (not in the app bundle).
Expand Down
4 changes: 0 additions & 4 deletions .maestro/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,6 @@ function getUserByEmail(email) {
{ headers: getDefaultHeaders() }
);

console.log('Get user response status:', getUserResponse.status);
console.log('Get user response body:', getUserResponse.body);

const responseData = JSON.parse(getUserResponse.body);
const users = responseData.users || responseData;

Expand All @@ -58,7 +55,6 @@ function getUserByEmail(email) {
);
}

console.log('Found matching user with ID:', matchingUser.id);
return matchingUser;
}

Expand Down
6 changes: 0 additions & 6 deletions .maestro/flows/_launch-and-onboard.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,6 @@ appId: ${MAESTRO_APP_ID}
---
- launchApp:
clearState: true
- assertVisible: Terms & Privacy
- scrollUntilVisible:
element: I agree
- tapOn:
text: I agree
waitToSettleTimeoutMs: 0 # stop waiting for onboarding animation
- assertVisible:
text: Every language. Every culture.
waitToSettleTimeoutMs: 0 # stop waiting for onboarding animation
Expand Down
12 changes: 12 additions & 0 deletions .maestro/flows/_opt-into-analytics.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
appId: ${MAESTRO_APP_ID}
---
- runFlow:
when:
visible: Share how you use LangQuest
commands:
- tapOn:
id: analytics-opt-in-switch
- assertVisible:
id: analytics-opt-in-switch
checked: true
- tapOn: Continue
20 changes: 17 additions & 3 deletions .maestro/flows/register.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,25 @@ onFlowStart:
- inputText: password
- tapOn: Confirm Password
- inputText: password
- hideKeyboard
- scrollUntilVisible:
element:
id: register-legal-consent
- tapOn:
id: register-legal-consent
- scrollUntilVisible:
element: Register
- tapOn: Register
- tapOn: Register
- assertVisible: Projects
- runFlow: _opt-into-analytics.yaml
- tapOn:
id: app-drawer-menu-button
- tapOn: Profile
- assertVisible: Profile
- assertVisible:
text: Profile
above:
text: ${output.email}
- assertVisible:
id: analytics-opt-in-switch
checked: true
rightOf: Enable Analytics
- evalScript: ${output.api.deleteUser(output.email)}
8 changes: 8 additions & 0 deletions .maestro/flows/sign-in.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ appId: ${MAESTRO_APP_ID}
- tapOn:
text: Sign In
below: I forgot my password
- runFlow:
when:
visible: We've updated our legal documents
commands:
- scrollUntilVisible:
element: I agree and continue
- tapOn: I agree and continue
- runFlow: _opt-into-analytics.yaml
- tapOn:
id: app-drawer-menu-button
- tapOn: Profile
Expand Down
4 changes: 2 additions & 2 deletions app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,8 @@ export default ({ config }: ConfigContext): ExpoConfig =>
'expo-dev-client',
'expo-sharing',
'expo-sqlite',
['testflight-dev-deploy', { enabled: appVariant === 'development' }],
'posthog-react-native/expo'
'posthog-react-native/expo',
['testflight-dev-deploy', { enabled: appVariant === 'development' }]
],
experiments: {
typedRoutes: true,
Expand Down
26 changes: 14 additions & 12 deletions app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import React, { Suspense, useCallback, useEffect, useState } from 'react';
import { BackHandler, View } from 'react-native';

import { AccountDeletedOverlay } from '@/components/AccountDeletedOverlay';
import { ScheduledDeletionOverlay } from '@/components/ScheduledDeletionOverlay';
import AppDrawer from '@/components/AppDrawer';
import AppHeader from '@/components/AppHeader';
import { useAuth } from '@/contexts/AuthContext';
Expand All @@ -36,7 +37,6 @@ function AppContent() {
const { isAuthenticated, currentUser } = useAuth();
const { profile } = useProfileByUserId(currentUser?.id || '');
const setTriggerOnboarding = useLocalStore((s) => s.setTriggerOnboarding);
const dateTermsAccepted = useLocalStore((s) => s.dateTermsAccepted);
const triggerOnboarding = useLocalStore((s) => s.triggerOnboarding);
const onboardingIsOpen = useLocalStore((s) => s.onboardingIsOpen);
const setOnboardingIsOpen = useLocalStore((s) => s.setOnboardingIsOpen);
Expand All @@ -45,8 +45,10 @@ function AppContent() {
const pathname = usePathname();
const isProjectsView = pathname === '/' || pathname === '';

// Account deleted overlay (soft-delete: active === false)
const accountDeleted = !!profile && profile.active === false;
// Account deleted overlays
const hasScheduledDeletion = !!profile?.deletion_requested_at;
const isLegacySoftDelete =
!!profile && profile.active === false && !profile.deletion_requested_at;

const handleOnboardingPress = useCallback(() => {
if (isProjectsView) {
Expand All @@ -55,15 +57,10 @@ function AppContent() {
}, [isProjectsView, setTriggerOnboarding]);

useEffect(() => {
if (dateTermsAccepted && !onboardingCompleted && !onboardingIsOpen) {
if (!onboardingCompleted && !onboardingIsOpen) {
setOnboardingIsOpen(true);
}
}, [
dateTermsAccepted,
onboardingCompleted,
onboardingIsOpen,
setOnboardingIsOpen
]);
}, [onboardingCompleted, onboardingIsOpen, setOnboardingIsOpen]);

useEffect(() => {
if (triggerOnboarding && !onboardingIsOpen) {
Expand Down Expand Up @@ -102,8 +99,13 @@ function AppContent() {
return () => backHandler.remove();
}, [drawerIsVisible]);

// Account deleted: block everything with overlay
if (accountDeleted) {
if (hasScheduledDeletion && profile.deletion_scheduled_for) {
return (
<ScheduledDeletionOverlay purgeDate={profile.deletion_scheduled_for} />
);
}

if (isLegacySoftDelete) {
return <AccountDeletedOverlay />;
}

Expand Down
13 changes: 0 additions & 13 deletions app/(auth)/terms.tsx

This file was deleted.

55 changes: 36 additions & 19 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { UpdateBanner } from '@/components/UpdateBanner';
import { AudioProvider } from '@/contexts/AudioContext';
import { AuthProvider } from '@/contexts/AuthContext';
import PostHogProvider from '@/contexts/PostHogProvider';
import { AnalyticsConsentGate } from '@/components/AnalyticsConsentGate';
import { LegalUpdateGate } from '@/components/LegalUpdateGate';
import { system } from '@/db/powersync/system';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useExpoDb } from '@/hooks/useExpoDb';
Expand All @@ -34,11 +36,9 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
import { useAuth } from '@/contexts/AuthContext';
import { cssTokens } from '@/generated-tokens';
import { useDrizzleStudio } from '@/hooks/useDrizzleStudio';
import { initializePostHogWithStore } from '@/services/posthog';
import { useHasHydrated, useLocalStore } from '@/store/localStore';
import { useHasHydrated } from '@/store/localStore';
import { initializeNetwork } from '@/store/networkStore';
import { toNavTheme } from '@/utils/styleUtils';
import { TermsGateView } from '@/views/TermsGateView';
import { DarkTheme, DefaultTheme } from '@react-navigation/native';
import { StatusBar } from 'expo-status-bar';

Expand Down Expand Up @@ -80,6 +80,31 @@ export const FORM_SHEET_OPTIONS = {
sheetCornerRadius: Platform.OS === 'android' ? 24 : undefined
} as const;

/**
* Legal/analytics gates only run when the app is ready for normal use.
* Migration and upgrade screens must block these gates entirely.
*/
function GatedAppContent() {
const { isLoading, isAuthenticated, migrationNeeded, appUpgradeNeeded } =
useAuth();

const needsMigration = isAuthenticated && !!migrationNeeded;
const needsUpgrade = isAuthenticated && !!appUpgradeNeeded;
const appReady = !needsMigration && !needsUpgrade && !isLoading;

const navigator = <RootNavigator />;

if (!appReady) {
return navigator;
}

return (
<LegalUpdateGate>
<AnalyticsConsentGate>{navigator}</AnalyticsConsentGate>
</LegalUpdateGate>
);
}

/**
* Root navigator with Stack.Protected guards.
* Must be rendered inside providers so hooks (useAuth, useLocalStore) work.
Expand All @@ -103,12 +128,7 @@ function RootNavigator() {
useDrizzleStudio();

useEffect(() => {
const cleanup = initializeNetwork();
const cleanupPostHog = initializePostHogWithStore();
return () => {
cleanup();
cleanupPostHog?.();
};
return initializeNetwork();
}, []);

useEffect(() => {
Expand Down Expand Up @@ -200,7 +220,12 @@ export default function RootLayout() {
}, []);

const hasHydrated = useHasHydrated();
const termsAccepted = useLocalStore((s) => !!s.dateTermsAccepted);

useEffect(() => {
if (isColorSchemeLoaded && fontsLoaded && hasHydrated) {
void SplashScreen.hideAsync();
}
}, [isColorSchemeLoaded, fontsLoaded, hasHydrated]);

if (!isColorSchemeLoaded || !fontsLoaded || !hasHydrated) {
return null;
Expand All @@ -209,14 +234,6 @@ export default function RootLayout() {
const scheme: 'light' | 'dark' = colorScheme === 'dark' ? 'dark' : 'light';
const systemBarsStyle = scheme === 'dark' ? 'light' : 'dark';

if (!termsAccepted) {
return (
<ThemeProvider value={NAV_THEME[scheme]}>
<TermsGateView systemBarsStyle={systemBarsStyle} />
</ThemeProvider>
);
}

return (
<PowerSyncContext.Provider value={system.powersync}>
<PostHogProvider>
Expand All @@ -232,7 +249,7 @@ export default function RootLayout() {
<UpdateBanner />
<BottomSheetModalProvider>
<ThemeProvider value={NAV_THEME[scheme]}>
<RootNavigator />
<GatedAppContent />
<PortalHost />
</ThemeProvider>
</BottomSheetModalProvider>
Expand Down
2 changes: 2 additions & 0 deletions cloud-services/posthog-relay/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
.wrangler/
27 changes: 27 additions & 0 deletions cloud-services/posthog-relay/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# PostHog relay (Cloudflare Worker)

Reverse-proxies LangQuest mobile analytics to PostHog Cloud US at `/ingest/*`. Blocks requests from known EU, EEA, and UK edge countries only; unknown geo is allowed through.

The mobile app sets `EXPO_PUBLIC_POSTHOG_HOST` to this Worker's `*.workers.dev` URL (no `langquest.org`).

## Endpoints

| Method | Path | Behavior |
|--------|------|----------|
| `GET` | `/` | `{ "ok": true }` |
| `*` | `/ingest/*` | Proxy to PostHog US, or `403` when `cf.country` is EU/EEA/GB |

## Setup

```bash
cd cloud-services/posthog-relay
npm install
```

## Deploy

```bash
npm run deploy
```

Set `EXPO_PUBLIC_POSTHOG_HOST` in EAS to the deployed Worker origin (no trailing slash), e.g. `https://langquest-posthog-relay.<subdomain>.workers.dev`. The app appends `/ingest`.
Loading
Loading