Update expo app to make it somewhat functional

This commit is contained in:
2026-03-26 12:36:18 -05:00
parent 56fe2a2af3
commit ff8c39dc75
18 changed files with 15732 additions and 7478 deletions

View File

@@ -1247,29 +1247,45 @@ See the 4-step checklist in [Section 5](#5-environment-variables--complete-refer
---
## 14. Expo App — Known Issues, Do Not Touch
## 14. Expo App — Scaffold Only, Not Production-Ready
The `apps/expo/` directory exists as a placeholder for future mobile development.
It is currently non-functional — it still contains the original T3 Turbo template
code and has not been migrated to use Convex or `@convex-dev/auth`.
The broken T3 Turbo template code has been cleaned up — all `@acme/*` references,
tRPC, and `better-auth` have been removed and replaced with the correct Convex patterns.
The app will now load, but it is still a bare scaffold with no real features.
**Do not work on the Expo app unless explicitly asked.** If you are asked to work on it,
be aware that the following are all broken and will need to be addressed:
### Current state
| File | Issue |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `src/utils/api.tsx` | Imports `AppRouter` from `@acme/api` — package does not exist. Uses tRPC, which is not used anywhere in this repo. |
| `src/utils/auth.ts` | Uses `better-auth` / `@better-auth/expo` instead of `@convex-dev/auth` |
| `src/app/index.tsx` | Old T3 Turbo template code using tRPC mutations and better-auth session patterns |
| `src/app/post/[id].tsx` | Same — tRPC and better-auth patterns throughout |
| `postcss.config.js` | References `@acme/tailwind-config` — package does not exist |
| `src/styles.css` | Imports from `@acme/tailwind-config` — package does not exist |
| `eslint.config.mts` | Imports from `@acme/eslint-config` — package does not exist |
| `eas.json` | Specifies `pnpm 9.15.4` as the package manager — should be bun |
The app is wired up with the correct providers and patterns:
When the time comes to properly implement the Expo app, the right approach is to start
fresh with Convex + `@convex-dev/auth` patterns (mirroring how the Next.js app works),
not to try to incrementally fix the broken T3 Turbo code.
- **`src/app/_layout.tsx`** — `ConvexAuthProvider` wraps the root, initialized with
a `ConvexReactClient` pointed at the self-hosted backend
- **`src/utils/convex.ts`** — Convex client setup, derives the backend URL from the
Expo dev server host in development; reads from `app.config.ts` `extra.convexUrl`
in production
- **`src/app/index.tsx`** — A minimal home screen that reads auth state via
`useConvexAuth()` and queries the current user via `api.auth.getUser`
- **`src/app/post/[id].tsx`** — A placeholder detail screen (route structure kept intact)
- **`src/utils/session-store.ts`** — `expo-secure-store` helpers for token storage
- **`src/utils/base-url.ts`** — Dev server localhost detection
### What still needs to be built
- Sign-in / sign-up screens (mirror the Next.js `(auth)/sign-in` flow, adapted for RN)
- Authenticated navigation / route protection (check `isAuthenticated` in layout)
- Any actual app screens and Convex queries for your specific project
### Production URL configuration
For production builds, set `convexUrl` in `app.config.ts` `extra`:
```typescript
extra: {
convexUrl: 'https://api.convex.example.com',
},
```
The client in `src/utils/convex.ts` will prefer this over the dev-server-derived URL.
---
@@ -1290,16 +1306,7 @@ native fix for this or if a custom update script is needed.
---
**2. Password validation mismatch** ✅ Fixed
`validatePassword()` in `packages/backend/convex/custom/auth/providers/password.ts`
now fully matches `PASSWORD_REGEX` in `packages/backend/types/auth.ts`. Both enforce:
minimum 8 characters, maximum 100, no whitespace, at least one digit, one lowercase
letter, one uppercase letter, and at least one special character (`[\p{P}\p{S}]`).
---
**3. Root `.dockerignore` includes `.env` in build context** Known, works intentionally
**2. Root `.dockerignore` includes `.env` in build context** Known, works intentionally
The root `.dockerignore` has `.env` commented out, meaning the `.env` file is sent to
the Docker build context. This is how build-time env vars (Sentry token, `NEXT_PUBLIC_*`)
@@ -1307,12 +1314,9 @@ reach the Next.js build step. It's a known imperfect approach but fully function
A proper solution would use Docker build args (`ARG`) or multi-stage secrets, but this
requires careful restructuring to avoid breaking Sentry source map uploads.
The redundant `docker/.dockerignore` has been deleted — it was never read by Docker
since the build context is always the repo root.
---
**4. In-memory IP banning resets on restart** Future enhancement
**3. In-memory IP banning resets on restart** Future enhancement
`src/lib/proxy/ban-sus-ips.ts` stores bans in a JavaScript `Map`. Bans reset whenever
the Next.js server restarts (container restart, redeploy, etc.). A more robust solution
@@ -1321,7 +1325,7 @@ job) or to Redis.
---
**5. `apps/next/src/lib/metadata.ts` has hardcoded branding** ⚠️ Template concern
**4. `apps/next/src/lib/metadata.ts` has hardcoded branding** ⚠️ Template concern
When using this as a template for a new project, `metadata.ts` must be updated:
the title template (`'%s | Convex Monorepo'`) and any other project-specific strings.
@@ -1329,7 +1333,7 @@ Same for `next.config.js` `images.remotePatterns` (currently `*.gbrown.org`).
---
**6. No CI/CD** Future enhancement
**5. No CI/CD** Future enhancement
There is no `.github/workflows/` directory. All deployment is done manually via SSH.
A future enhancement would add GitHub Actions (or Gitea Actions, since this repo is
@@ -1337,7 +1341,7 @@ on Gitea) for automated lint and typecheck on pull requests.
---
**7. Scripts organization** Future exploration
**6. Scripts organization** Future exploration
Currently:
@@ -1351,14 +1355,7 @@ moving everything to `docker/scripts/`.
---
**8. Expo `eas.json` specifies pnpm** Low priority (Expo is WIP anyway)
`apps/expo/eas.json` specifies `pnpm 9.15.4` as the package manager. Should specify
bun. This is low priority since the Expo app is not in active development.
---
**9. React Email templates not yet implemented** Future enhancement
**7. React Email templates not yet implemented** Future enhancement
`@react-email/components` and `react-email` are in `packages/backend/package.json` as
planned dependencies. The current `usesend.ts` uses inline HTML strings for email

View File

@@ -6,7 +6,7 @@
"build": {
"base": {
"node": "22.12.0",
"pnpm": "9.15.4",
"bun": "1.3.10",
"ios": {
"resourceClass": "m-medium"
}

View File

@@ -1,7 +1,8 @@
import { baseConfig } from '@acme/eslint-config/base';
import { reactConfig } from '@acme/eslint-config/react';
import { defineConfig } from 'eslint/config';
import { baseConfig } from '@gib/eslint-config/base';
import { reactConfig } from '@gib/eslint-config/react';
export default defineConfig(
{
ignores: ['.expo/**', 'expo-plugins/**'],

View File

@@ -49,8 +49,7 @@
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.2",
"react-native-worklets": "~0.5.2",
"superjson": "2.2.3"
"react-native-worklets": "~0.5.2"
},
"devDependencies": {
"@gib/eslint-config": "workspace:*",

View File

@@ -1 +1 @@
module.exports = require('@acme/tailwind-config/postcss-config');
module.exports = require('@gib/tailwind-config/postcss-config');

View File

@@ -1,33 +1,30 @@
import { useColorScheme } from 'react-native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { QueryClientProvider } from '@tanstack/react-query';
import { ConvexAuthProvider } from '@convex-dev/auth/react';
import { queryClient } from '~/utils/api';
import { convex } from '~/utils/convex';
import '../styles.css';
// This is the main layout of the app
// It wraps your pages with the providers they need
export default function RootLayout() {
const RootLayout = () => {
const colorScheme = useColorScheme();
return (
<QueryClientProvider client={queryClient}>
{/*
The Stack component displays the current page.
It also allows you to configure your screens
*/}
<ConvexAuthProvider client={convex}>
<Stack
screenOptions={{
headerStyle: {
backgroundColor: '#c03484',
backgroundColor: colorScheme === 'dark' ? '#1c1917' : '#faf9f7',
},
headerTintColor: colorScheme === 'dark' ? '#fafaf9' : '#1c1917',
contentStyle: {
backgroundColor: colorScheme == 'dark' ? '#09090B' : '#FFFFFF',
backgroundColor: colorScheme === 'dark' ? '#1c1917' : '#faf9f7',
},
}}
/>
<StatusBar />
</QueryClientProvider>
<StatusBar style='auto' />
</ConvexAuthProvider>
);
}
};
export default RootLayout;

View File

@@ -1,172 +1,54 @@
import { useState } from 'react';
import { Pressable, Text, TextInput, View } from 'react-native';
import { Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Link, Stack } from 'expo-router';
import { LegendList } from '@legendapp/list';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Stack } from 'expo-router';
import { useAuthActions } from '@convex-dev/auth/react';
import { useConvexAuth, useQuery } from 'convex/react';
import type { RouterOutputs } from '~/utils/api';
import { trpc } from '~/utils/api';
import { authClient } from '~/utils/auth';
import { api } from '@gib/backend/convex/_generated/api.js';
const Index = () => {
const { isAuthenticated, isLoading } = useConvexAuth();
const { signOut } = useAuthActions();
const user = useQuery(api.auth.getUser, isAuthenticated ? {} : 'skip');
function PostCard(props: {
post: RouterOutputs['post']['all'][number];
onDelete: () => void;
}) {
return (
<View className='bg-muted flex flex-row rounded-lg p-4'>
<View className='grow'>
<Link
asChild
href={{
pathname: '/post/[id]',
params: { id: props.post.id },
}}
>
<Pressable className=''>
<Text className='text-primary text-xl font-semibold'>
{props.post.title}
<SafeAreaView className='bg-background flex-1'>
<Stack.Screen options={{ title: 'Home' }} />
<View className='flex-1 items-center justify-center gap-4 p-6'>
<Text className='text-foreground text-4xl font-bold'>
Convex Monorepo
</Text>
<Text className='text-muted-foreground text-center text-base'>
Your self-hosted Expo + Convex starter
</Text>
{isLoading ? (
<Text className='text-muted-foreground'>Loading...</Text>
) : isAuthenticated ? (
<View className='w-full gap-3'>
<Text className='text-foreground text-center text-lg'>
Welcome{user?.name ? `, ${user.name}` : ''}!
</Text>
<Text className='text-foreground mt-2'>{props.post.content}</Text>
</Pressable>
</Link>
</View>
<Pressable onPress={props.onDelete}>
<Text className='text-primary font-bold uppercase'>Delete</Text>
</Pressable>
</View>
);
}
function CreatePost() {
const queryClient = useQueryClient();
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const { mutate, error } = useMutation(
trpc.post.create.mutationOptions({
async onSuccess() {
setTitle('');
setContent('');
await queryClient.invalidateQueries(trpc.post.all.queryFilter());
},
}),
);
return (
<View className='mt-4 flex gap-2'>
<TextInput
className='border-input bg-background text-foreground items-center rounded-md border px-3 text-lg leading-tight'
value={title}
onChangeText={setTitle}
placeholder='Title'
/>
{error?.data?.zodError?.fieldErrors.title && (
<Text className='text-destructive mb-2'>
{error.data.zodError.fieldErrors.title}
</Text>
)}
<TextInput
className='border-input bg-background text-foreground items-center rounded-md border px-3 text-lg leading-tight'
value={content}
onChangeText={setContent}
placeholder='Content'
/>
{error?.data?.zodError?.fieldErrors.content && (
<Text className='text-destructive mb-2'>
{error.data.zodError.fieldErrors.content}
</Text>
)}
<Pressable
className='bg-primary flex items-center rounded-sm p-2'
onPress={() => {
mutate({
title,
content,
});
}}
>
<Text className='text-foreground'>Create</Text>
</Pressable>
{error?.data?.code === 'UNAUTHORIZED' && (
<Text className='text-destructive mt-2'>
You need to be logged in to create a post
</Text>
)}
</View>
);
}
function MobileAuth() {
const { data: session } = authClient.useSession();
return (
<>
<Text className='text-foreground pb-2 text-center text-xl font-semibold'>
{session?.user.name ? `Hello, ${session.user.name}` : 'Not logged in'}
</Text>
<Pressable
onPress={() =>
session
? authClient.signOut()
: authClient.signIn.social({
provider: 'discord',
callbackURL: '/',
})
}
className='bg-primary flex items-center rounded-sm p-2'
>
<Text>{session ? 'Sign Out' : 'Sign In With Discord'}</Text>
</Pressable>
</>
);
}
export default function Index() {
const queryClient = useQueryClient();
const postQuery = useQuery(trpc.post.all.queryOptions());
const deletePostMutation = useMutation(
trpc.post.delete.mutationOptions({
onSettled: () =>
queryClient.invalidateQueries(trpc.post.all.queryFilter()),
}),
);
return (
<SafeAreaView className='bg-background'>
{/* Changes page title visible on the header */}
<Stack.Screen options={{ title: 'Home Page' }} />
<View className='bg-background h-full w-full p-4'>
<Text className='text-foreground pb-2 text-center text-5xl font-bold'>
Create <Text className='text-primary'>T3</Text> Turbo
</Text>
<MobileAuth />
<View className='py-2'>
<Text className='text-primary font-semibold italic'>
Press on a post
</Text>
</View>
<LegendList
data={postQuery.data ?? []}
estimatedItemSize={20}
keyExtractor={(item) => item.id}
ItemSeparatorComponent={() => <View className='h-2' />}
renderItem={(p) => (
<PostCard
post={p.item}
onDelete={() => deletePostMutation.mutate(p.item.id)}
/>
)}
/>
<CreatePost />
<Pressable
className='bg-primary items-center rounded-md p-3'
onPress={() => void signOut()}
>
<Text className='text-primary-foreground font-semibold'>
Sign Out
</Text>
</Pressable>
</View>
) : (
<View className='w-full gap-3'>
<Text className='text-muted-foreground text-center'>
Sign in to get started
</Text>
{/* Add sign-in UI here — see apps/next/src/app/(auth)/sign-in for patterns */}
</View>
)}
</View>
</SafeAreaView>
);
}
};
export default Index;

View File

@@ -1,24 +1,21 @@
import { SafeAreaView, Text, View } from 'react-native';
import { Stack, useGlobalSearchParams } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
import { Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Stack, useLocalSearchParams } from 'expo-router';
import { trpc } from '~/utils/api';
export default function Post() {
const { id } = useGlobalSearchParams<{ id: string }>();
const { data } = useQuery(trpc.post.byId.queryOptions({ id }));
if (!data) return null;
const Post = () => {
const { id } = useLocalSearchParams<{ id: string }>();
return (
<SafeAreaView className='bg-background'>
<Stack.Screen options={{ title: data.title }} />
<View className='h-full w-full p-4'>
<Text className='text-primary py-2 text-3xl font-bold'>
{data.title}
<SafeAreaView className='bg-background flex-1'>
<Stack.Screen options={{ title: 'Post' }} />
<View className='flex-1 p-4'>
<Text className='text-foreground text-2xl font-bold'>Post {id}</Text>
<Text className='text-muted-foreground mt-2'>
Implement your post detail screen here using Convex queries.
</Text>
<Text className='text-foreground py-4'>{data.content}</Text>
</View>
</SafeAreaView>
);
}
};
export default Post;

View File

@@ -1,3 +1,3 @@
@import 'tailwindcss';
@import 'nativewind/theme';
@import '@acme/tailwind-config/theme';
@import '@gib/tailwind-config/theme';

View File

@@ -1,49 +0,0 @@
import type { AppRouter } from '@acme/api';
import { QueryClient } from '@tanstack/react-query';
import { createTRPCClient, httpBatchLink, loggerLink } from '@trpc/client';
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
import superjson from 'superjson';
import { authClient } from './auth';
import { getBaseUrl } from './base-url';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// ...
},
},
});
/**
* A set of typesafe hooks for consuming your API.
*/
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: createTRPCClient({
links: [
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === 'development' ||
(opts.direction === 'down' && opts.result instanceof Error),
colorMode: 'ansi',
}),
httpBatchLink({
transformer: superjson,
url: `${getBaseUrl()}/api/trpc`,
headers() {
const headers = new Map<string, string>();
headers.set('x-trpc-source', 'expo-react');
const cookies = authClient.getCookie();
if (cookies) {
headers.set('Cookie', cookies);
}
return headers;
},
}),
],
}),
queryClient,
});
export type { RouterInputs, RouterOutputs } from '@acme/api';

View File

@@ -1,16 +0,0 @@
import * as SecureStore from 'expo-secure-store';
import { expoClient } from '@better-auth/expo/client';
import { createAuthClient } from 'better-auth/react';
import { getBaseUrl } from './base-url';
export const authClient = createAuthClient({
baseURL: getBaseUrl(),
plugins: [
expoClient({
scheme: 'expo',
storagePrefix: 'expo',
storage: SecureStore,
}),
],
});

View File

@@ -0,0 +1,26 @@
import Constants from 'expo-constants';
import { ConvexReactClient } from 'convex/react';
const getConvexUrl = (): string => {
// Allow override via Expo extra config (set in app.config.ts for production)
const fromConfig = Constants.expoConfig?.extra?.convexUrl as
| string
| undefined;
if (fromConfig) return fromConfig;
// Fall back to deriving from the dev server host (same pattern as getBaseUrl)
const debuggerHost = Constants.expoConfig?.hostUri;
const localhost = debuggerHost?.split(':')[0];
if (!localhost) {
throw new Error(
'Could not determine Convex URL. Set extra.convexUrl in app.config.ts for production.',
);
}
// Point at the self-hosted Convex backend on the local network
// Update this port if your Convex backend runs on a different port
return `http://${localhost}:3210`;
};
export const convex = new ConvexReactClient(getConvexUrl());

File diff suppressed because it is too large Load Diff

View File

@@ -33,7 +33,7 @@ const Profile = async () => {
{/* Profile Card */}
<Card className='border-border/40'>
<ProfileHeader preloadedUser={preloadedUser} />
<ProfileHeader />
<AvatarUpload preloadedUser={preloadedUser} />
<Separator className='my-6' />
<UserInfoForm

View File

@@ -3,6 +3,7 @@
"compilerOptions": {
"lib": ["ES2022", "dom", "dom.iterable"],
"jsx": "preserve",
"types": ["node"],
"paths": {
"@/*": ["./src/*"]
},

View File

@@ -52,7 +52,6 @@
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.2",
"react-native-worklets": "~0.5.2",
"superjson": "2.2.3",
},
"devDependencies": {
"@gib/eslint-config": "workspace:*",

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ type EventType =
| 'focusin'
| 'focusout';
export const useOnClickOutside = <T,>(
export const useOnClickOutside = <T extends Element>(
ref: React.RefObject<T | null> | React.RefObject<T | null>[],
handler: (event: MouseEvent | TouchEvent | FocusEvent) => void,
eventType: EventType = 'mousedown',