feat(mobile): scaffold Expo mobile app for Timmy with Face/Matrix/Feed tabs

Task #42 — Timmy Harness: Expo Mobile App

## What was built
- New Expo artifact at artifacts/mobile, slug `mobile`, preview path `/mobile/`
- Three-tab bottom navigation (Face, Matrix, Feed) — NativeTabs with liquid glass on iOS 26+
- Dark wizard theme (#0A0A12 background, #7C3AED accent)

## WebSocket context (context/TimmyContext.tsx)
- Full WebSocket connection to /api/ws with exponential backoff reconnect (1s→30s cap)
- Sends visitor_enter handshake on connect, handles ping/pong
- Derives timmyMood from agent_state events (idle/thinking/working/speaking)
- recentEvents list capped at 100
- sendVisitorMessage() sets mood to "thinking" immediately on send (deterministic waiting state)
- speaking mood auto-reverts after estimated TTS duration

## Face tab (app/(tabs)/index.tsx)
- Animated 2D wizard face via react-native-svg (hat, head, beard, eyes, pupils, mouth arc, magic orb)
- AnimatedPupils: pupilScaleAnim drives actual rendered pupil Circle radius (BASE_PUPIL_R * scale)
- AnimatedEyelids: eyeScaleYAnim drives top eyelid overlay via Animated.Value listener
- AnimatedMouth: smileAnim + mouthOscAnim combined; SVG Path rebuilt on each frame via listener
- speaking mood: 1Hz mouth oscillation via Animated.loop; per-mood body bob speed/amplitude
- @react-native-voice/voice installed and statically imported; Voice.onSpeechResults wired properly
- startMicPulse/stopMicPulse declared before native voice useEffect (correct hook order)
- Web Speech API typed with SpeechRecognitionWindow local interface (zero `any` casts)
- sendVisitorMessage() called on final transcript (also triggers thinking mood immediately)
- expo-speech TTS speaks Timmy's chat replies on native

## Matrix tab (app/(tabs)/matrix.tsx)
- URL normalization: strips existing protocol, uses http for localhost, https for all other hosts
- Full-screen WebView with loading spinner and error/retry state; iframe fallback for web

## Feed tab (app/(tabs)/feed.tsx)
- FlatList<WsEvent> with proper generics; EventConfig discriminated union (Feather|MaterialCommunityIcons)
- Icon names typed via React.ComponentProps["name"] (no `any`)
- Color-coded events; event count in header; empty state with connection-aware message

## Type safety
- TypeScript typecheck passes with 0 errors
- No `any` casts anywhere in new code

## Deviations
- expo-av removed (not used; voice input handled via @react-native-voice/voice + Web Speech API)
- expo-speech/expo-av NOT in app.json plugins (no config plugins — causes PluginError if listed)
- app.json extra.apiDomain added for env-driven domain configuration per requirement
- expo-speech pinned ~14.0.8, react-native-webview 13.15.0 for Expo SDK 54 compat
- artifact.toml ensurePreviewReachable removed (Expo uses expo-domain router)
- @react-native-voice/voice works in Expo Go Android; iOS needs native build (graceful fallback)

Replit-Task-Id: 0748cbbf-7b84-4149-8fc0-9d697287a0e6
This commit is contained in:
alexpaynex
2026-03-19 23:55:16 +00:00
parent 1a268353f9
commit cf1819f34b
26 changed files with 9785 additions and 56 deletions

41
artifacts/mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
ios/
android/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
.vercel
.env

View File

@@ -0,0 +1,26 @@
kind = "mobile"
previewPath = "/mobile/"
title = "Timmy Mobile"
version = "1.0.0"
id = "artifacts/mobile"
router = "expo-domain"
[[integratedSkills]]
name = "expo"
version = "1.0.0"
[[services]]
name = "expo"
paths = [ "/mobile/" ]
localPort = 18115
[services.development]
run = "pnpm --filter @workspace/mobile run dev"
[services.production]
build = [ "pnpm", "--filter", "@workspace/mobile", "run", "build" ]
run = [ "pnpm", "--filter", "@workspace/mobile", "run", "serve" ]
[services.env]
PORT = "18115"
BASE_PATH = "/mobile/"

47
artifacts/mobile/app.json Normal file
View File

@@ -0,0 +1,47 @@
{
"expo": {
"name": "Timmy Mobile",
"slug": "mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "mobile",
"userInterfaceStyle": "dark",
"newArchEnabled": true,
"splash": {
"image": "./assets/images/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#0A0A12"
},
"ios": {
"supportsTablet": false
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png",
"backgroundColor": "#0A0A12"
}
},
"web": {
"favicon": "./assets/images/icon.png",
"backgroundColor": "#0A0A12"
},
"plugins": [
[
"expo-router",
{
"origin": "https://replit.com/"
}
],
"expo-font",
"expo-web-browser"
],
"extra": {
"apiDomain": "${EXPO_PUBLIC_DOMAIN}"
},
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
}
}

View File

@@ -0,0 +1,112 @@
import { BlurView } from "expo-blur";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Tabs } from "expo-router";
import { Icon, Label, NativeTabs } from "expo-router/unstable-native-tabs";
import { SymbolView } from "expo-symbols";
import { Feather, MaterialCommunityIcons } from "@expo/vector-icons";
import React from "react";
import { Platform, StyleSheet, View, useColorScheme } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Colors } from "@/constants/colors";
function NativeTabLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="index">
<Icon sf={{ default: "face.smiling", selected: "face.smiling.fill" }} />
<Label>Timmy</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="matrix">
<Icon sf={{ default: "cube", selected: "cube.fill" }} />
<Label>Matrix</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="feed">
<Icon sf={{ default: "list.bullet", selected: "list.bullet.circle.fill" }} />
<Label>Feed</Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}
function ClassicTabLayout() {
const insets = useSafeAreaInsets();
const isIOS = Platform.OS === "ios";
const isWeb = Platform.OS === "web";
const C = Colors.dark;
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: C.accentGlow,
tabBarInactiveTintColor: C.textMuted,
tabBarStyle: {
position: "absolute",
backgroundColor: isIOS ? "transparent" : C.surface,
borderTopWidth: 0,
elevation: 0,
...(isWeb ? { height: 84 } : {}),
},
tabBarBackground: () =>
isIOS ? (
<BlurView
intensity={80}
tint="dark"
style={[StyleSheet.absoluteFill, { borderTopWidth: 0.5, borderTopColor: C.border }]}
/>
) : isWeb ? (
<View
style={[StyleSheet.absoluteFill, { backgroundColor: C.surface, borderTopWidth: 0.5, borderTopColor: C.border }]}
/>
) : (
<View style={[StyleSheet.absoluteFill, { backgroundColor: C.surface, borderTopWidth: 0.5, borderTopColor: C.border }]} />
),
}}
>
<Tabs.Screen
name="index"
options={{
title: "Timmy",
tabBarIcon: ({ color, size }) =>
isIOS ? (
<SymbolView name="face.smiling" tintColor={color} size={size} />
) : (
<MaterialCommunityIcons name="emoticon-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="matrix"
options={{
title: "Matrix",
tabBarIcon: ({ color, size }) =>
isIOS ? (
<SymbolView name="cube" tintColor={color} size={size} />
) : (
<MaterialCommunityIcons name="cube-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="feed"
options={{
title: "Feed",
tabBarIcon: ({ color, size }) =>
isIOS ? (
<SymbolView name="list.bullet" tintColor={color} size={size} />
) : (
<Feather name="activity" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
export default function TabLayout() {
if (isLiquidGlassAvailable()) {
return <NativeTabLayout />;
}
return <ClassicTabLayout />;
}

View File

@@ -0,0 +1,282 @@
import { Feather, MaterialCommunityIcons } from "@expo/vector-icons";
import React, { useCallback } from "react";
import {
FlatList,
ListRenderItemInfo,
Platform,
StyleSheet,
Text,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Colors } from "@/constants/colors";
import { useTimmy, type WsEvent } from "@/context/TimmyContext";
const C = Colors.dark;
type FeatherIconName = React.ComponentProps<typeof Feather>["name"];
type MCIconName = React.ComponentProps<typeof MaterialCommunityIcons>["name"];
type EventConfig =
| {
color: string;
iconFamily: "Feather";
icon: FeatherIconName;
label: (e: WsEvent) => string;
}
| {
color: string;
iconFamily: "MaterialCommunityIcons";
icon: MCIconName;
label: (e: WsEvent) => string;
};
const EVENT_CONFIG: Record<string, EventConfig> = {
job_started: {
color: C.jobStarted,
icon: "play-circle",
iconFamily: "Feather",
label: (e) => `Job started${e.agentId ? ` · ${e.agentId}` : ""}`,
},
job_completed: {
color: C.jobCompleted,
icon: "check-circle",
iconFamily: "Feather",
label: (e) => `Job completed${e.agentId ? ` · ${e.agentId}` : ""}`,
},
chat: {
color: C.chat,
icon: "message-circle",
iconFamily: "Feather",
label: (e) =>
e.agentId === "timmy"
? `Timmy: ${e.text ?? ""}`
: `${e.agentId ?? "visitor"}: ${e.text ?? ""}`,
},
agent_state: {
color: C.agentState,
icon: "cpu",
iconFamily: "Feather",
label: (e) => `${e.agentId ?? "agent"}${e.state ?? "?"}`,
},
visitor_count: {
color: "#8B5CF6",
icon: "users",
iconFamily: "Feather",
label: (e) => `${e.count ?? 0} visitor${e.count !== 1 ? "s" : ""} online`,
},
};
const FALLBACK_CONFIG: EventConfig = {
color: C.textMuted,
icon: "radio",
iconFamily: "Feather",
label: (e) => e.type,
};
function EventIcon({ config }: { config: EventConfig }) {
if (config.iconFamily === "MaterialCommunityIcons") {
return (
<MaterialCommunityIcons
name={config.icon}
size={16}
color={config.color}
/>
);
}
return <Feather name={config.icon} size={16} color={config.color} />;
}
function EventRow({ item }: { item: WsEvent }) {
const config = EVENT_CONFIG[item.type] ?? FALLBACK_CONFIG;
const label = config.label(item);
const time = new Date(item.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
return (
<View style={styles.row}>
<View style={[styles.iconBadge, { backgroundColor: config.color + "22" }]}>
<EventIcon config={config} />
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel} numberOfLines={2}>
{label}
</Text>
<Text style={[styles.rowType, { color: config.color }]}>
{item.type.replace(/_/g, " ")}
</Text>
</View>
<Text style={styles.rowTime}>{time}</Text>
</View>
);
}
const keyExtractor = (item: WsEvent) => item.id;
const renderItem = ({ item }: ListRenderItemInfo<WsEvent>) => (
<EventRow item={item} />
);
export default function FeedScreen() {
const { recentEvents, connectionStatus } = useTimmy();
const insets = useSafeAreaInsets();
const topPad = Platform.OS === "web" ? 67 : insets.top;
const bottomPad = Platform.OS === "web" ? 84 + 34 : insets.bottom + 84;
const ListEmpty = useCallback(
() => (
<View style={styles.emptyContainer}>
<Feather name="radio" size={48} color={C.textMuted} />
<Text style={styles.emptyTitle}>Waiting for events</Text>
<Text style={styles.emptySubtitle}>
{connectionStatus === "connected"
? "Events will appear here as Timmy works"
: "Connect to the API server to see live events"}
</Text>
</View>
),
[connectionStatus]
);
return (
<View style={[styles.container, { paddingTop: topPad }]}>
<View style={styles.header}>
<View>
<Text style={styles.title}>Live Feed</Text>
<Text style={styles.subtitle}>
{recentEvents.length} event{recentEvents.length !== 1 ? "s" : ""}
</Text>
</View>
<View style={styles.countBadge}>
<Text style={styles.countText}>{recentEvents.length}</Text>
</View>
</View>
<FlatList<WsEvent>
data={recentEvents}
keyExtractor={keyExtractor}
renderItem={renderItem}
ListEmptyComponent={ListEmpty}
contentContainerStyle={[
styles.listContent,
{ paddingBottom: bottomPad },
]}
showsVerticalScrollIndicator={false}
ItemSeparatorComponent={() => <View style={styles.separator} />}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: C.background,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
paddingHorizontal: 24,
paddingTop: 12,
paddingBottom: 16,
},
title: {
fontSize: 28,
fontFamily: "Inter_700Bold",
color: C.text,
letterSpacing: -0.5,
},
subtitle: {
fontSize: 13,
fontFamily: "Inter_400Regular",
color: C.textSecondary,
marginTop: 2,
},
countBadge: {
backgroundColor: C.surface,
borderRadius: 20,
paddingHorizontal: 12,
paddingVertical: 4,
borderWidth: 1,
borderColor: C.border,
alignSelf: "flex-start",
marginTop: 8,
},
countText: {
fontSize: 13,
fontFamily: "Inter_600SemiBold",
color: C.textSecondary,
},
listContent: {
paddingHorizontal: 16,
flexGrow: 1,
},
row: {
flexDirection: "row",
alignItems: "flex-start",
gap: 12,
paddingVertical: 12,
paddingHorizontal: 4,
},
iconBadge: {
width: 36,
height: 36,
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
rowContent: {
flex: 1,
gap: 3,
},
rowLabel: {
fontSize: 13,
fontFamily: "Inter_400Regular",
color: C.text,
lineHeight: 18,
},
rowType: {
fontSize: 11,
fontFamily: "Inter_500Medium",
textTransform: "uppercase",
letterSpacing: 0.5,
},
rowTime: {
fontSize: 11,
fontFamily: "Inter_400Regular",
color: C.textMuted,
flexShrink: 0,
marginTop: 2,
},
separator: {
height: 1,
backgroundColor: C.border,
opacity: 0.5,
},
emptyContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingTop: 80,
gap: 12,
paddingHorizontal: 40,
},
emptyTitle: {
fontSize: 18,
fontFamily: "Inter_600SemiBold",
color: C.textSecondary,
marginTop: 8,
},
emptySubtitle: {
fontSize: 14,
fontFamily: "Inter_400Regular",
color: C.textMuted,
textAlign: "center",
lineHeight: 20,
},
});

View File

@@ -0,0 +1,435 @@
import * as Haptics from "expo-haptics";
import * as Speech from "expo-speech";
import Voice, { SpeechResultsEvent, SpeechErrorEvent } from "@react-native-voice/voice";
import { Ionicons } from "@expo/vector-icons";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Animated,
Platform,
Pressable,
StyleSheet,
Text,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import { TimmyFace } from "@/components/TimmyFace";
import { Colors } from "@/constants/colors";
import { useTimmy } from "@/context/TimmyContext";
const C = Colors.dark;
const MOOD_LABELS: Record<string, string> = {
idle: "Contemplating the cosmos",
thinking: "Deep in thought",
working: "Casting spells",
speaking: "Sharing wisdom",
};
type WebSpeechRecognition = {
continuous: boolean;
interimResults: boolean;
lang: string;
onresult: ((e: WebSpeechResultEvent) => void) | null;
onerror: (() => void) | null;
onend: (() => void) | null;
start: () => void;
stop: () => void;
};
type WebSpeechResultEvent = {
results: WebSpeechResultList;
};
type WebSpeechResultList = {
length: number;
[index: number]: WebSpeechResult;
};
type WebSpeechResult = {
isFinal: boolean;
0: { transcript: string };
};
type SpeechRecognitionWindow = Window & {
SpeechRecognition?: new () => WebSpeechRecognition;
webkitSpeechRecognition?: new () => WebSpeechRecognition;
};
export default function FaceScreen() {
const { timmyMood, connectionStatus, sendVisitorMessage, recentEvents } =
useTimmy();
const insets = useSafeAreaInsets();
const [isListening, setIsListening] = useState(false);
const [transcript, setTranscript] = useState("");
const [lastReply, setLastReply] = useState("");
const micScale = useRef(new Animated.Value(1)).current;
const micPulseRef = useRef<Animated.CompositeAnimation | null>(null);
const webRecognitionRef = useRef<WebSpeechRecognition | null>(null);
const lastReplyIdRef = useRef<string>("");
const topPad = Platform.OS === "web" ? 67 : insets.top;
const bottomPad = Platform.OS === "web" ? 84 + 34 : insets.bottom + 84;
// Detect incoming Timmy replies and speak them
useEffect(() => {
const latestChat = recentEvents.find(
(e) => e.type === "chat" && (e.agentId === "timmy" || !e.agentId)
);
if (latestChat?.text && latestChat.id !== lastReplyIdRef.current) {
lastReplyIdRef.current = latestChat.id;
setLastReply(latestChat.text);
if (Platform.OS !== "web") {
Speech.speak(latestChat.text, {
onDone: () => {},
onError: () => {},
pitch: 0.9,
rate: 0.85,
});
}
}
}, [recentEvents]);
// Declare mic pulse helpers first so native voice useEffect can reference them
const startMicPulse = useCallback(() => {
micPulseRef.current?.stop();
const pulse = Animated.loop(
Animated.sequence([
Animated.timing(micScale, {
toValue: 1.2,
duration: 400,
useNativeDriver: true,
}),
Animated.timing(micScale, {
toValue: 0.95,
duration: 400,
useNativeDriver: true,
}),
])
);
micPulseRef.current = pulse;
pulse.start();
}, [micScale]);
const stopMicPulse = useCallback(() => {
micPulseRef.current?.stop();
micPulseRef.current = null;
Animated.spring(micScale, { toValue: 1, useNativeDriver: true }).start();
}, [micScale]);
// Native voice setup (after helpers are declared)
useEffect(() => {
if (Platform.OS === "web") return;
const onResults = (e: SpeechResultsEvent) => {
const text = e.value?.[0] ?? "";
setTranscript(text);
if (text) {
sendVisitorMessage(text);
}
setIsListening(false);
stopMicPulse();
setTimeout(() => setTranscript(""), 3000);
};
const onPartialResults = (e: SpeechResultsEvent) => {
setTranscript(e.value?.[0] ?? "");
};
const onError = (_e: SpeechErrorEvent) => {
setIsListening(false);
stopMicPulse();
};
Voice.onSpeechResults = onResults;
Voice.onSpeechPartialResults = onPartialResults;
Voice.onSpeechError = onError;
return () => {
Voice.destroy().catch(() => {});
};
}, [sendVisitorMessage, stopMicPulse]);
const startNativeVoice = useCallback(async () => {
try {
await Voice.start("en-US");
setIsListening(true);
startMicPulse();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
} catch {
setTranscript("Voice recognition unavailable");
setTimeout(() => setTranscript(""), 2000);
}
}, [startMicPulse]);
const stopNativeVoice = useCallback(async () => {
try {
await Voice.stop();
} catch {}
setIsListening(false);
stopMicPulse();
}, [stopMicPulse]);
const startWebVoice = useCallback(() => {
const w = typeof window !== "undefined" ? (window as SpeechRecognitionWindow) : null;
const SpeechRecognitionCtor = w?.SpeechRecognition ?? w?.webkitSpeechRecognition;
if (!SpeechRecognitionCtor) {
setTranscript("Voice not supported in this browser");
setTimeout(() => setTranscript(""), 2500);
return;
}
const rec = new SpeechRecognitionCtor();
rec.continuous = false;
rec.interimResults = true;
rec.lang = "en-US";
rec.onresult = (e: WebSpeechResultEvent) => {
const parts: string[] = [];
for (let i = 0; i < e.results.length; i++) {
parts.push(e.results[i][0].transcript);
}
const t = parts.join("");
setTranscript(t);
if (e.results[e.results.length - 1].isFinal) {
sendVisitorMessage(t);
setIsListening(false);
stopMicPulse();
setTimeout(() => setTranscript(""), 3000);
}
};
rec.onerror = () => {
setIsListening(false);
stopMicPulse();
};
rec.onend = () => {
setIsListening(false);
stopMicPulse();
};
webRecognitionRef.current = rec;
rec.start();
setIsListening(true);
startMicPulse();
}, [sendVisitorMessage, startMicPulse, stopMicPulse]);
const stopWebVoice = useCallback(() => {
webRecognitionRef.current?.stop();
webRecognitionRef.current = null;
}, []);
const handleMicPress = useCallback(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (Platform.OS === "web") {
if (isListening) stopWebVoice();
else startWebVoice();
} else {
if (isListening) stopNativeVoice();
else startNativeVoice();
}
}, [
isListening,
startWebVoice,
stopWebVoice,
startNativeVoice,
stopNativeVoice,
]);
return (
<View style={[styles.container, { paddingTop: topPad }]}>
{/* Header */}
<View style={styles.header}>
<View>
<Text style={styles.title}>Timmy</Text>
<Text style={styles.subtitle}>Wizard of the Machine</Text>
</View>
<ConnectionBadge status={connectionStatus} />
</View>
{/* Face area */}
<View style={styles.faceWrapper}>
<View style={styles.auraRing}>
<TimmyFace mood={timmyMood} size={200} />
</View>
<Text style={styles.moodLabel}>{MOOD_LABELS[timmyMood]}</Text>
<MoodDot mood={timmyMood} />
</View>
{/* Reply bubble */}
{lastReply ? (
<View style={styles.replyBubble}>
<Text style={styles.replyText} numberOfLines={4}>
{lastReply}
</Text>
</View>
) : null}
{/* Transcript bubble */}
{transcript ? (
<View style={styles.transcriptBubble}>
<Text style={styles.transcriptText}>{transcript}</Text>
</View>
) : null}
{/* Mic button */}
<View style={[styles.micArea, { paddingBottom: bottomPad }]}>
<Pressable
onPress={handleMicPress}
accessibilityRole="button"
accessibilityLabel={isListening ? "Stop listening" : "Start voice"}
>
<Animated.View
style={[
styles.micButton,
isListening && styles.micButtonActive,
{ transform: [{ scale: micScale }] },
]}
>
<Ionicons
name={isListening ? "mic" : "mic-outline"}
size={32}
color={isListening ? "#fff" : C.textSecondary}
/>
</Animated.View>
</Pressable>
<Text style={styles.micHint}>
{isListening ? "Listening..." : "Tap to speak to Timmy"}
</Text>
</View>
</View>
);
}
function MoodDot({ mood }: { mood: string }) {
const colors: Record<string, string> = {
idle: C.idle,
thinking: C.thinking,
working: C.working,
speaking: C.speaking,
};
return (
<View style={[styles.moodDot, { backgroundColor: colors[mood] ?? C.idle }]} />
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: C.background,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
paddingHorizontal: 24,
paddingTop: 12,
paddingBottom: 8,
},
title: {
fontSize: 28,
fontFamily: "Inter_700Bold",
color: C.text,
letterSpacing: -0.5,
},
subtitle: {
fontSize: 13,
fontFamily: "Inter_400Regular",
color: C.textSecondary,
marginTop: 2,
},
faceWrapper: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 16,
},
auraRing: {
alignItems: "center",
justifyContent: "center",
shadowColor: "#7C3AED",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.3,
shadowRadius: 40,
elevation: 0,
},
moodLabel: {
fontSize: 15,
fontFamily: "Inter_500Medium",
color: C.textSecondary,
letterSpacing: 0.2,
textAlign: "center",
},
moodDot: {
width: 8,
height: 8,
borderRadius: 4,
},
replyBubble: {
marginHorizontal: 20,
marginBottom: 12,
backgroundColor: C.surfaceElevated,
borderRadius: 16,
padding: 14,
borderWidth: 1,
borderColor: C.border,
borderTopLeftRadius: 4,
},
replyText: {
fontSize: 14,
fontFamily: "Inter_400Regular",
color: C.text,
lineHeight: 20,
},
transcriptBubble: {
marginHorizontal: 20,
marginBottom: 8,
backgroundColor: C.surface,
borderRadius: 16,
padding: 12,
borderWidth: 1,
borderColor: C.accent + "44",
alignSelf: "flex-end",
maxWidth: "80%",
borderBottomRightRadius: 4,
},
transcriptText: {
fontSize: 14,
fontFamily: "Inter_400Regular",
color: C.accentGlow,
lineHeight: 20,
},
micArea: {
alignItems: "center",
paddingTop: 16,
gap: 10,
},
micButton: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: C.surface,
borderWidth: 1.5,
borderColor: C.border,
alignItems: "center",
justifyContent: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
},
micButtonActive: {
backgroundColor: C.micActive,
borderColor: C.micActive,
shadowColor: C.micActive,
shadowOpacity: 0.5,
shadowRadius: 16,
},
micHint: {
fontSize: 12,
fontFamily: "Inter_400Regular",
color: C.textMuted,
},
});

View File

@@ -0,0 +1,226 @@
import { Feather } from "@expo/vector-icons";
import React, { useRef, useState } from "react";
import {
ActivityIndicator,
Platform,
Pressable,
StyleSheet,
Text,
View,
} from "react-native";
import { WebView } from "react-native-webview";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Colors } from "@/constants/colors";
const C = Colors.dark;
function getMatrixUrl(): string {
const domain = process.env.EXPO_PUBLIC_DOMAIN ?? "";
if (!domain) return "http://localhost:8080/";
const stripped = domain.replace(/^https?:\/\//, "").replace(/\/$/, "");
const proto = stripped.startsWith("localhost") ? "http" : "https";
return `${proto}://${stripped}/`;
}
export default function MatrixScreen() {
const insets = useSafeAreaInsets();
const topPad = Platform.OS === "web" ? 67 : insets.top;
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const webviewRef = useRef<WebView | null>(null);
const matrixUrl = getMatrixUrl();
if (Platform.OS === "web") {
return (
<View style={[styles.container, { paddingTop: topPad }]}>
<View style={styles.header}>
<Text style={styles.title}>The Matrix</Text>
<Text style={styles.subtitle}>Timmy's 3D command center</Text>
</View>
<View style={styles.webContainer}>
<iframe
src={matrixUrl}
style={{ width: "100%", height: "100%", border: "none", borderRadius: 12 }}
allow="autoplay"
title="The Matrix"
/>
</View>
</View>
);
}
return (
<View style={[styles.container, { paddingTop: topPad }]}>
<View style={styles.header}>
<View>
<Text style={styles.title}>The Matrix</Text>
<Text style={styles.subtitle}>Timmy's 3D command center</Text>
</View>
<Pressable
onPress={() => {
setError(false);
setLoading(true);
webviewRef.current?.reload();
}}
style={styles.reloadBtn}
>
<Feather name="refresh-cw" size={18} color={C.textSecondary} />
</Pressable>
</View>
<View style={styles.webContainer}>
{loading && !error ? (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color={C.matrixGreen} />
<Text style={styles.loadingText}>Loading The Matrix</Text>
</View>
) : null}
{error ? (
<View style={styles.errorContainer}>
<Feather name="wifi-off" size={40} color={C.textMuted} />
<Text style={styles.errorTitle}>Matrix Unreachable</Text>
<Text style={styles.errorSubtitle}>
The API server may not be running
</Text>
<Pressable
onPress={() => {
setError(false);
setLoading(true);
webviewRef.current?.reload();
}}
style={styles.retryBtn}
>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
</View>
) : (
<WebView
ref={webviewRef}
source={{ uri: matrixUrl }}
style={[styles.webview, loading && styles.hidden]}
onLoadEnd={() => setLoading(false)}
onError={() => {
setLoading(false);
setError(true);
}}
onHttpError={() => {
setLoading(false);
setError(true);
}}
javaScriptEnabled
allowsInlineMediaPlayback
mediaPlaybackRequiresUserAction={false}
startInLoadingState={false}
/>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: C.background,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
paddingHorizontal: 24,
paddingTop: 12,
paddingBottom: 16,
},
title: {
fontSize: 28,
fontFamily: "Inter_700Bold",
color: C.text,
letterSpacing: -0.5,
},
subtitle: {
fontSize: 13,
fontFamily: "Inter_400Regular",
color: C.textSecondary,
marginTop: 2,
},
reloadBtn: {
width: 40,
height: 40,
alignItems: "center",
justifyContent: "center",
borderRadius: 20,
backgroundColor: C.surface,
borderWidth: 1,
borderColor: C.border,
marginTop: 4,
},
webContainer: {
flex: 1,
marginHorizontal: 16,
marginBottom: 16,
borderRadius: 16,
overflow: "hidden",
backgroundColor: "#000",
borderWidth: 1,
borderColor: C.border,
},
webview: {
flex: 1,
backgroundColor: "#000",
},
hidden: {
opacity: 0,
position: "absolute",
width: 1,
height: 1,
},
loadingOverlay: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 16,
backgroundColor: "#000",
},
loadingText: {
fontSize: 14,
fontFamily: "Inter_400Regular",
color: C.matrixGreen,
letterSpacing: 1,
},
errorContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 12,
backgroundColor: "#000",
paddingHorizontal: 32,
},
errorTitle: {
fontSize: 20,
fontFamily: "Inter_600SemiBold",
color: C.text,
marginTop: 8,
},
errorSubtitle: {
fontSize: 14,
fontFamily: "Inter_400Regular",
color: C.textSecondary,
textAlign: "center",
},
retryBtn: {
marginTop: 8,
paddingHorizontal: 24,
paddingVertical: 12,
backgroundColor: C.surface,
borderRadius: 12,
borderWidth: 1,
borderColor: C.border,
},
retryText: {
fontSize: 14,
fontFamily: "Inter_600SemiBold",
color: C.text,
},
});

View File

@@ -0,0 +1,38 @@
import { Link, Stack } from "expo-router";
import { StyleSheet, Text, View } from "react-native";
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: "Oops!" }} />
<View style={styles.container}>
<Text style={styles.title}>This screen doesn&apos;t exist.</Text>
<Link href="/" style={styles.link}>
<Text style={styles.linkText}>Go to home screen!</Text>
</Link>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: 20,
},
title: {
fontSize: 20,
fontWeight: "bold",
},
link: {
marginTop: 15,
paddingVertical: 15,
},
linkText: {
fontSize: 14,
color: "#2e78b7",
},
});

View File

@@ -0,0 +1,62 @@
import {
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
useFonts,
} from "@expo-google-fonts/inter";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import React, { useEffect } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { TimmyProvider } from "@/context/TimmyContext";
SplashScreen.preventAutoHideAsync();
const queryClient = new QueryClient();
function RootLayoutNav() {
return (
<Stack screenOptions={{ headerBackTitle: "Back" }}>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
);
}
export default function RootLayout() {
const [fontsLoaded, fontError] = useFonts({
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
});
useEffect(() => {
if (fontsLoaded || fontError) {
SplashScreen.hideAsync();
}
}, [fontsLoaded, fontError]);
if (!fontsLoaded && !fontError) return null;
return (
<SafeAreaProvider>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<TimmyProvider>
<RootLayoutNav />
</TimmyProvider>
</KeyboardProvider>
</GestureHandlerRootView>
</QueryClientProvider>
</ErrorBoundary>
</SafeAreaProvider>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [["babel-preset-expo", { unstable_transformImportMeta: true }]],
};
};

View File

@@ -0,0 +1,69 @@
import React from "react";
import { Animated, StyleSheet, Text, View } from "react-native";
import { useEffect, useRef } from "react";
import { Colors } from "@/constants/colors";
import type { ConnectionStatus } from "@/context/TimmyContext";
const C = Colors.dark;
const STATUS_CONFIG: Record<ConnectionStatus, { color: string; label: string }> = {
connecting: { color: "#F59E0B", label: "Connecting" },
connected: { color: "#10B981", label: "Live" },
disconnected: { color: "#6B7280", label: "Offline" },
error: { color: "#EF4444", label: "Error" },
};
export function ConnectionBadge({ status }: { status: ConnectionStatus }) {
const pulseAnim = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (status === "connecting") {
const pulse = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, { toValue: 0.3, duration: 600, useNativeDriver: true }),
Animated.timing(pulseAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
])
);
pulse.start();
return () => pulse.stop();
} else {
pulseAnim.setValue(1);
}
}, [status]);
const config = STATUS_CONFIG[status];
return (
<View style={styles.badge}>
<Animated.View
style={[styles.dot, { backgroundColor: config.color, opacity: pulseAnim }]}
/>
<Text style={[styles.label, { color: config.color }]}>{config.label}</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
flexDirection: "row",
alignItems: "center",
gap: 5,
backgroundColor: C.surface,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 20,
borderWidth: 1,
borderColor: C.border,
},
dot: {
width: 6,
height: 6,
borderRadius: 3,
},
label: {
fontSize: 11,
fontFamily: "Inter_500Medium",
letterSpacing: 0.5,
},
});

View File

@@ -0,0 +1,54 @@
import React, { Component, ComponentType, PropsWithChildren } from "react";
import { ErrorFallback, ErrorFallbackProps } from "@/components/ErrorFallback";
export type ErrorBoundaryProps = PropsWithChildren<{
FallbackComponent?: ComponentType<ErrorFallbackProps>;
onError?: (error: Error, stackTrace: string) => void;
}>;
type ErrorBoundaryState = { error: Error | null };
/**
* This is a special case for for using the class components. Error boundaries must be class components because React only provides error boundary functionality through lifecycle methods (componentDidCatch and getDerivedStateFromError) which are not available in functional components.
* https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
*/
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = { error: null };
static defaultProps: {
FallbackComponent: ComponentType<ErrorFallbackProps>;
} = {
FallbackComponent: ErrorFallback,
};
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: { componentStack: string }): void {
if (typeof this.props.onError === "function") {
this.props.onError(error, info.componentStack);
}
}
resetError = (): void => {
this.setState({ error: null });
};
render() {
const { FallbackComponent } = this.props;
return this.state.error && FallbackComponent ? (
<FallbackComponent
error={this.state.error}
resetError={this.resetError}
/>
) : (
this.props.children
);
}
}

View File

@@ -0,0 +1,286 @@
import { Feather } from "@expo/vector-icons";
import { reloadAppAsync } from "expo";
import React, { useState } from "react";
import {
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
useColorScheme,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export type ErrorFallbackProps = {
error: Error;
resetError: () => void;
};
export function ErrorFallback({ error, resetError }: ErrorFallbackProps) {
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const insets = useSafeAreaInsets();
const theme = {
background: isDark ? "#000000" : "#FFFFFF",
backgroundSecondary: isDark ? "#1C1C1E" : "#F2F2F7",
text: isDark ? "#FFFFFF" : "#000000",
textSecondary: isDark ? "rgba(255, 255, 255, 0.7)" : "rgba(0, 0, 0, 0.7)",
link: "#007AFF",
buttonText: "#FFFFFF",
};
const [isModalVisible, setIsModalVisible] = useState(false);
const handleRestart = async () => {
try {
await reloadAppAsync();
} catch (restartError) {
console.error("Failed to restart app:", restartError);
resetError();
}
};
const formatErrorDetails = (): string => {
let details = `Error: ${error.message}\n\n`;
if (error.stack) {
details += `Stack Trace:\n${error.stack}`;
}
return details;
};
const monoFont = Platform.select({
ios: "Menlo",
android: "monospace",
default: "monospace",
});
return (
<View style={[styles.container, { backgroundColor: theme.background }]}>
{__DEV__ ? (
<Pressable
onPress={() => setIsModalVisible(true)}
accessibilityLabel="View error details"
accessibilityRole="button"
style={({ pressed }) => [
styles.topButton,
{
top: insets.top + 16,
backgroundColor: theme.backgroundSecondary,
opacity: pressed ? 0.8 : 1,
},
]}
>
<Feather name="alert-circle" size={20} color={theme.text} />
</Pressable>
) : null}
<View style={styles.content}>
<Text style={[styles.title, { color: theme.text }]}>
Something went wrong
</Text>
<Text style={[styles.message, { color: theme.textSecondary }]}>
Please reload the app to continue.
</Text>
<Pressable
onPress={handleRestart}
style={({ pressed }) => [
styles.button,
{
backgroundColor: theme.link,
opacity: pressed ? 0.9 : 1,
transform: [{ scale: pressed ? 0.98 : 1 }],
},
]}
>
<Text style={[styles.buttonText, { color: theme.buttonText }]}>
Try Again
</Text>
</Pressable>
</View>
{__DEV__ ? (
<Modal
visible={isModalVisible}
animationType="slide"
transparent={true}
onRequestClose={() => setIsModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View
style={[
styles.modalContainer,
{ backgroundColor: theme.background },
]}
>
<View
style={[
styles.modalHeader,
{
borderBottomColor: isDark
? "rgba(255, 255, 255, 0.1)"
: "rgba(0, 0, 0, 0.1)",
},
]}
>
<Text style={[styles.modalTitle, { color: theme.text }]}>
Error Details
</Text>
<Pressable
onPress={() => setIsModalVisible(false)}
accessibilityLabel="Close error details"
accessibilityRole="button"
style={({ pressed }) => [
styles.closeButton,
{ opacity: pressed ? 0.6 : 1 },
]}
>
<Feather name="x" size={24} color={theme.text} />
</Pressable>
</View>
<ScrollView
style={styles.modalScrollView}
contentContainerStyle={[
styles.modalScrollContent,
{ paddingBottom: insets.bottom + 16 },
]}
showsVerticalScrollIndicator
>
<View
style={[
styles.errorContainer,
{ backgroundColor: theme.backgroundSecondary },
]}
>
<Text
style={[
styles.errorText,
{
color: theme.text,
fontFamily: monoFont,
},
]}
selectable
>
{formatErrorDetails()}
</Text>
</View>
</ScrollView>
</View>
</View>
</Modal>
) : null}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
width: "100%",
height: "100%",
justifyContent: "center",
alignItems: "center",
padding: 24,
},
content: {
alignItems: "center",
justifyContent: "center",
gap: 16,
width: "100%",
maxWidth: 600,
},
title: {
fontSize: 28,
fontWeight: "700",
textAlign: "center",
lineHeight: 40,
},
message: {
fontSize: 16,
textAlign: "center",
lineHeight: 24,
},
topButton: {
position: "absolute",
right: 16,
width: 44,
height: 44,
borderRadius: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
zIndex: 10,
},
button: {
paddingVertical: 16,
borderRadius: 8,
paddingHorizontal: 24,
minWidth: 200,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
buttonText: {
fontWeight: "600",
textAlign: "center",
fontSize: 16,
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
modalContainer: {
width: "100%",
height: "90%",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
},
modalHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 12,
borderBottomWidth: 1,
},
modalTitle: {
fontSize: 20,
fontWeight: "600",
},
closeButton: {
width: 44,
height: 44,
alignItems: "center",
justifyContent: "center",
},
modalScrollView: {
flex: 1,
},
modalScrollContent: {
padding: 16,
},
errorContainer: {
width: "100%",
borderRadius: 8,
overflow: "hidden",
padding: 16,
},
errorText: {
fontSize: 12,
lineHeight: 18,
width: "100%",
},
});

View File

@@ -0,0 +1,29 @@
import {
KeyboardAwareScrollView,
KeyboardAwareScrollViewProps,
} from "react-native-keyboard-controller";
import { Platform, ScrollView, ScrollViewProps } from "react-native";
type Props = KeyboardAwareScrollViewProps & ScrollViewProps;
export function KeyboardAwareScrollViewCompat({
children,
keyboardShouldPersistTaps = "handled",
...props
}: Props) {
if (Platform.OS === "web") {
return (
<ScrollView keyboardShouldPersistTaps={keyboardShouldPersistTaps} {...props}>
{children}
</ScrollView>
);
}
return (
<KeyboardAwareScrollView
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
{...props}
>
{children}
</KeyboardAwareScrollView>
);
}

View File

@@ -0,0 +1,246 @@
import React, { useEffect, useRef, useState } from "react";
import { Animated, Easing, Platform, StyleSheet, View } from "react-native";
import Svg, { Circle, Ellipse, Path } from "react-native-svg";
import type { TimmyMood } from "@/context/TimmyContext";
type FaceTarget = {
eyeScaleY: number;
pupilScale: number;
smileAmount: number;
};
const FACE_TARGETS: Record<TimmyMood, FaceTarget> = {
idle: { eyeScaleY: 0.44, pupilScale: 0.90, smileAmount: 0.08 },
thinking: { eyeScaleY: 0.30, pupilScale: 0.72, smileAmount: 0.00 },
working: { eyeScaleY: 0.75, pupilScale: 1.05, smileAmount: 0.18 },
speaking: { eyeScaleY: 0.92, pupilScale: 1.25, smileAmount: 0.38 },
};
const BASE_PUPIL_R = 2.8;
const BASE_EYE_RY = 5;
const SVG_VIEW = 100;
const SVG_CX = SVG_VIEW / 2;
const SVG_CY = SVG_VIEW / 2;
const HEAD_R = 36;
const EYE_L_X = SVG_CX - 11;
const EYE_R_X = SVG_CX + 11;
const EYE_CY = SVG_CY - 4;
const EYE_RX = 5;
const HAT_BRIM_Y = SVG_CY - HEAD_R - 2;
function buildMouthPath(smileAmount: number): string {
const s = Math.max(-1, Math.min(1, smileAmount));
const mouthY = SVG_CY + 18;
const halfW = 18;
const ctrlDy = -s * 8;
const x1 = SVG_CX - halfW;
const x2 = SVG_CX + halfW;
return `M ${x1} ${mouthY} Q ${SVG_CX} ${mouthY + ctrlDy} ${x2} ${mouthY}`;
}
type Props = {
mood: TimmyMood;
size?: number;
};
export function TimmyFace({ mood, size = 220 }: Props) {
const eyeScaleYAnim = useRef(new Animated.Value(FACE_TARGETS.idle.eyeScaleY)).current;
const pupilScaleAnim = useRef(new Animated.Value(FACE_TARGETS.idle.pupilScale)).current;
const smileAnim = useRef(new Animated.Value(FACE_TARGETS.idle.smileAmount)).current;
const mouthOscAnim = useRef(new Animated.Value(0)).current;
const bobAnim = useRef(new Animated.Value(0)).current;
const speakingLoopRef = useRef<Animated.CompositeAnimation | null>(null);
const bobLoopRef = useRef<Animated.CompositeAnimation | null>(null);
useEffect(() => {
const target = FACE_TARGETS[mood];
Animated.parallel([
Animated.spring(eyeScaleYAnim, { toValue: target.eyeScaleY, friction: 6, tension: 80, useNativeDriver: false }),
Animated.spring(pupilScaleAnim, { toValue: target.pupilScale, friction: 6, tension: 80, useNativeDriver: false }),
Animated.spring(smileAnim, { toValue: target.smileAmount, friction: 6, tension: 80, useNativeDriver: false }),
]).start();
if (mood === "speaking") {
speakingLoopRef.current?.stop();
const osc = Animated.loop(
Animated.sequence([
Animated.timing(mouthOscAnim, { toValue: 0.3, duration: 250, easing: Easing.inOut(Easing.sin), useNativeDriver: false }),
Animated.timing(mouthOscAnim, { toValue: -0.1, duration: 250, easing: Easing.inOut(Easing.sin), useNativeDriver: false }),
])
);
speakingLoopRef.current = osc;
osc.start();
} else {
speakingLoopRef.current?.stop();
speakingLoopRef.current = null;
Animated.spring(mouthOscAnim, { toValue: 0, friction: 8, tension: 60, useNativeDriver: false }).start();
}
bobLoopRef.current?.stop();
const speed = mood === "working" ? 400 : mood === "thinking" ? 700 : 1200;
const amount = mood === "idle" ? 3 : 6;
const bob = Animated.loop(
Animated.sequence([
Animated.timing(bobAnim, { toValue: amount, duration: speed, easing: Easing.inOut(Easing.sin), useNativeDriver: true }),
Animated.timing(bobAnim, { toValue: -amount, duration: speed, easing: Easing.inOut(Easing.sin), useNativeDriver: true }),
])
);
bobLoopRef.current = bob;
bob.start();
return () => {
speakingLoopRef.current?.stop();
bobLoopRef.current?.stop();
};
}, [mood]);
return (
<Animated.View
style={[styles.container, { width: size, height: size, transform: [{ translateY: bobAnim }] }]}
>
<StaticFaceLayer size={size} mood={mood} />
<AnimatedMouth smileAnim={smileAnim} mouthOscAnim={mouthOscAnim} size={size} />
<AnimatedEyelids eyeScaleYAnim={eyeScaleYAnim} size={size} />
<AnimatedPupils pupilScaleAnim={pupilScaleAnim} size={size} />
</Animated.View>
);
}
function StaticFaceLayer({ size, mood }: { size: number; mood: TimmyMood }) {
const glowColors: Record<TimmyMood, string> = {
idle: "#6B7280",
thinking: "#3B82F6",
working: "#F59E0B",
speaking: "#7C3AED",
};
const glowColor = glowColors[mood];
return (
<Svg width={size} height={size} viewBox={`0 0 ${SVG_VIEW} ${SVG_VIEW}`}>
{/* Hat brim */}
<Ellipse cx={SVG_CX} cy={HAT_BRIM_Y} rx={28} ry={4} fill="#3a0880" stroke="#7C3AED" strokeWidth={0.5} />
{/* Hat cone */}
<Path d={`M ${SVG_CX - 18} ${HAT_BRIM_Y} L ${SVG_CX} ${HAT_BRIM_Y - 22} L ${SVG_CX + 18} ${HAT_BRIM_Y} Z`} fill="#3a0880" stroke="#7C3AED" strokeWidth={0.5} />
{/* Hat band */}
<Ellipse cx={SVG_CX} cy={HAT_BRIM_Y - 1} rx={18} ry={2.5} fill="#FFD700" opacity={0.9} />
{/* Star */}
<Circle cx={SVG_CX} cy={HAT_BRIM_Y - 22} r={2.5} fill="#FFD700" />
{/* Head */}
<Circle cx={SVG_CX} cy={SVG_CY} r={HEAD_R} fill="#d8a878" stroke="#c89060" strokeWidth={0.5} />
{/* Robe */}
<Path d={`M ${SVG_CX - 18} ${SVG_CY + HEAD_R} Q ${SVG_CX - 28} ${SVG_CY + HEAD_R + 30} ${SVG_CX - 22} ${SVG_CY + HEAD_R + 50} L ${SVG_CX + 22} ${SVG_CY + HEAD_R + 50} Q ${SVG_CX + 28} ${SVG_CY + HEAD_R + 30} ${SVG_CX + 18} ${SVG_CY + HEAD_R} Z`} fill="#5c14b0" stroke="#7C3AED" strokeWidth={0.5} />
{/* Belt */}
<Ellipse cx={SVG_CX} cy={SVG_CY + HEAD_R + 28} rx={19} ry={3} fill="#FFD700" opacity={0.9} />
{/* Beard */}
<Ellipse cx={SVG_CX} cy={SVG_CY + HEAD_R - 4} rx={13} ry={7} fill="#aaa8a0" opacity={0.9} />
<Path d={`M ${SVG_CX - 10} ${SVG_CY + HEAD_R} Q ${SVG_CX} ${SVG_CY + HEAD_R + 18} ${SVG_CX} ${SVG_CY + HEAD_R + 22} Q ${SVG_CX} ${SVG_CY + HEAD_R + 18} ${SVG_CX + 10} ${SVG_CY + HEAD_R} Z`} fill="#aaa8a0" opacity={0.85} />
{/* Side hair */}
<Circle cx={SVG_CX - 32} cy={SVG_CY - 4} r={8} fill="#c8c4bc" opacity={0.8} />
<Circle cx={SVG_CX + 32} cy={SVG_CY - 4} r={8} fill="#c8c4bc" opacity={0.8} />
{/* Eye whites */}
<Ellipse cx={EYE_L_X} cy={EYE_CY} rx={EYE_RX} ry={BASE_EYE_RY} fill="#f5f2e8" />
<Ellipse cx={EYE_R_X} cy={EYE_CY} rx={EYE_RX} ry={BASE_EYE_RY} fill="#f5f2e8" />
{/* Magic orb */}
<Circle cx={SVG_CX + HEAD_R - 2} cy={SVG_CY + 5} r={5} fill="#ff8800" opacity={0.9} />
<Circle cx={SVG_CX + HEAD_R - 2} cy={SVG_CY + 5} r={3} fill="#ffcc00" opacity={0.8} />
</Svg>
);
}
function AnimatedMouth({
smileAnim,
mouthOscAnim,
size,
}: {
smileAnim: Animated.Value;
mouthOscAnim: Animated.Value;
size: number;
}) {
const [path, setPath] = useState(buildMouthPath(FACE_TARGETS.idle.smileAmount));
useEffect(() => {
const combined = Animated.add(smileAnim, mouthOscAnim);
const id = combined.addListener(({ value }) => {
setPath(buildMouthPath(value));
});
return () => combined.removeListener(id);
}, [smileAnim, mouthOscAnim]);
return (
<View style={[StyleSheet.absoluteFill, { pointerEvents: "none" }]}>
<Svg width={size} height={size} viewBox={`0 0 ${SVG_VIEW} ${SVG_VIEW}`}>
<Path d={path} stroke="#8a4a28" strokeWidth={2.5} fill="none" strokeLinecap="round" />
</Svg>
</View>
);
}
function AnimatedEyelids({
eyeScaleYAnim,
size,
}: {
eyeScaleYAnim: Animated.Value;
size: number;
}) {
const [lidRy, setLidRy] = useState(BASE_EYE_RY * (1 - FACE_TARGETS.idle.eyeScaleY));
const [lidCyOffset, setLidCyOffset] = useState(0);
useEffect(() => {
const id = eyeScaleYAnim.addListener(({ value }) => {
const newLidRy = BASE_EYE_RY * (1 - value);
setLidRy(newLidRy);
setLidCyOffset(-(BASE_EYE_RY - newLidRy));
});
return () => eyeScaleYAnim.removeListener(id);
}, [eyeScaleYAnim]);
return (
<View style={[StyleSheet.absoluteFill, { pointerEvents: "none" }]}>
<Svg width={size} height={size} viewBox={`0 0 ${SVG_VIEW} ${SVG_VIEW}`}>
<Ellipse cx={EYE_L_X} cy={EYE_CY + lidCyOffset} rx={EYE_RX + 0.5} ry={lidRy + 0.5} fill="#d8a878" />
<Ellipse cx={EYE_R_X} cy={EYE_CY + lidCyOffset} rx={EYE_RX + 0.5} ry={lidRy + 0.5} fill="#d8a878" />
</Svg>
</View>
);
}
function AnimatedPupils({
pupilScaleAnim,
size,
}: {
pupilScaleAnim: Animated.Value;
size: number;
}) {
const [pupilR, setPupilR] = useState(BASE_PUPIL_R * FACE_TARGETS.idle.pupilScale);
useEffect(() => {
const id = pupilScaleAnim.addListener(({ value }) => {
setPupilR(BASE_PUPIL_R * value);
});
return () => pupilScaleAnim.removeListener(id);
}, [pupilScaleAnim]);
return (
<View style={[StyleSheet.absoluteFill, { pointerEvents: "none" }]}>
<Svg width={size} height={size} viewBox={`0 0 ${SVG_VIEW} ${SVG_VIEW}`}>
{/* Pupils */}
<Circle cx={EYE_L_X} cy={EYE_CY + 1} r={pupilR} fill="#07070f" />
<Circle cx={EYE_R_X} cy={EYE_CY + 1} r={pupilR} fill="#07070f" />
{/* Highlights */}
<Circle cx={EYE_L_X + 1.5} cy={EYE_CY - 0.5} r={0.8} fill="#ffffff" opacity={0.85} />
<Circle cx={EYE_R_X + 1.5} cy={EYE_CY - 0.5} r={0.8} fill="#ffffff" opacity={0.85} />
</Svg>
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: "center",
justifyContent: "center",
position: "relative",
},
});

View File

@@ -0,0 +1,35 @@
const ACCENT = "#7C3AED";
const ACCENT_GLOW = "#A78BFA";
const MATRIX_GREEN = "#00FF41";
export const Colors = {
dark: {
background: "#0A0A12",
surface: "#12121E",
surfaceElevated: "#1A1A2E",
border: "#2A2A44",
text: "#F0EEFF",
textSecondary: "#8B87A8",
textMuted: "#4B4868",
accent: ACCENT,
accentGlow: ACCENT_GLOW,
matrixGreen: MATRIX_GREEN,
tabBar: "transparent",
tabBarBorder: "#1E1E35",
tint: ACCENT,
tabIconDefault: "#4B4868",
tabIconSelected: ACCENT_GLOW,
jobStarted: "#F59E0B",
jobCompleted: "#10B981",
chat: "#22D3EE",
agentState: "#6B7280",
error: "#EF4444",
speaking: "#7C3AED",
thinking: "#3B82F6",
working: "#F59E0B",
idle: "#6B7280",
micActive: "#EF4444",
},
} as const;
export default Colors;

View File

@@ -0,0 +1,257 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
export type TimmyMood = "idle" | "thinking" | "working" | "speaking";
export type WsEvent = {
id: string;
type: string;
timestamp: number;
agentId?: string;
jobId?: string;
text?: string;
state?: string;
count?: number;
};
export type ConnectionStatus = "connecting" | "connected" | "disconnected" | "error";
type TimmyContextValue = {
timmyMood: TimmyMood;
connectionStatus: ConnectionStatus;
recentEvents: WsEvent[];
send: (msg: object) => void;
sendVisitorMessage: (text: string) => void;
visitorId: string;
};
const TimmyContext = createContext<TimmyContextValue | null>(null);
const MAX_EVENTS = 100;
const BASE_URL = process.env.EXPO_PUBLIC_DOMAIN ?? "";
const VISITOR_ID =
Date.now().toString() + Math.random().toString(36).substr(2, 9);
function getWsUrl(): string {
let domain = BASE_URL;
if (!domain) {
domain = "localhost:8080";
}
domain = domain.replace(/^https?:\/\//, "");
domain = domain.replace(/\/$/, "");
const proto = domain.startsWith("localhost") ? "ws" : "wss";
return `${proto}://${domain}/api/ws`;
}
function deriveMood(agentStates: Record<string, string>): TimmyMood {
if (agentStates["gamma"] === "working") return "working";
if (
agentStates["beta"] === "thinking" ||
agentStates["alpha"] === "thinking"
)
return "thinking";
if (Object.values(agentStates).some((s) => s !== "idle")) return "working";
return "idle";
}
export function TimmyProvider({ children }: { children: React.ReactNode }) {
const [timmyMood, setTimmyMood] = useState<TimmyMood>("idle");
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("connecting");
const [recentEvents, setRecentEvents] = useState<WsEvent[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retryCountRef = useRef(0);
const agentStatesRef = useRef<Record<string, string>>({
alpha: "idle",
beta: "idle",
gamma: "idle",
delta: "idle",
});
const speakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const addEvent = useCallback((evt: Omit<WsEvent, "id" | "timestamp">) => {
const entry: WsEvent = {
id: Date.now().toString() + Math.random().toString(36).substr(2, 6),
timestamp: Date.now(),
...evt,
};
setRecentEvents((prev) => [entry, ...prev].slice(0, MAX_EVENTS));
}, []);
const connectWs = useCallback(() => {
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.onerror = null;
wsRef.current.close();
wsRef.current = null;
}
const url = getWsUrl();
setConnectionStatus("connecting");
let ws: WebSocket;
try {
ws = new WebSocket(url);
} catch {
setConnectionStatus("error");
scheduleRetry();
return;
}
wsRef.current = ws;
ws.onopen = () => {
retryCountRef.current = 0;
setConnectionStatus("connected");
ws.send(
JSON.stringify({
type: "visitor_enter",
visitorId: VISITOR_ID,
visitorName: "Mobile Visitor",
})
);
};
ws.onmessage = (e) => {
let msg: Record<string, unknown>;
try {
msg = JSON.parse(e.data);
} catch {
return;
}
const type = msg.type as string;
if (type === "ping") {
ws.send(JSON.stringify({ type: "pong" }));
return;
}
if (type === "world_state") {
const states = (msg.agentStates as Record<string, string>) ?? {};
agentStatesRef.current = {
...agentStatesRef.current,
...states,
};
setTimmyMood(deriveMood(agentStatesRef.current));
return;
}
if (type === "agent_state") {
const agentId = msg.agentId as string;
const state = msg.state as string;
agentStatesRef.current = {
...agentStatesRef.current,
[agentId]: state,
};
setTimmyMood(deriveMood(agentStatesRef.current));
addEvent({ type, agentId, state });
return;
}
if (type === "chat") {
const agentId = msg.agentId as string;
const text = msg.text as string;
addEvent({ type, agentId, text });
if (agentId === "timmy" || !agentId) {
if (speakingTimerRef.current) clearTimeout(speakingTimerRef.current);
setTimmyMood("speaking");
const duration = Math.max(2000, (text?.length ?? 50) * 50);
speakingTimerRef.current = setTimeout(() => {
setTimmyMood(deriveMood(agentStatesRef.current));
}, duration);
}
return;
}
if (type === "job_started" || type === "job_completed") {
addEvent({
type,
jobId: msg.jobId as string,
agentId: msg.agentId as string,
});
return;
}
if (type === "visitor_count") {
addEvent({ type, count: msg.count as number });
return;
}
};
ws.onclose = () => {
setConnectionStatus("disconnected");
scheduleRetry();
};
ws.onerror = () => {
setConnectionStatus("error");
};
}, [addEvent]);
const scheduleRetry = useCallback(() => {
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
const delay = Math.min(1000 * Math.pow(2, retryCountRef.current), 30000);
retryCountRef.current += 1;
retryTimerRef.current = setTimeout(() => {
connectWs();
}, delay);
}, [connectWs]);
useEffect(() => {
connectWs();
return () => {
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
if (speakingTimerRef.current) clearTimeout(speakingTimerRef.current);
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.close();
}
};
}, [connectWs]);
const send = useCallback((msg: object) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(msg));
}
}, []);
const sendVisitorMessage = useCallback(
(text: string) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({ type: "visitor_message", visitorId: VISITOR_ID, text })
);
setTimmyMood("thinking");
}
},
[]
);
const value = useMemo(
() => ({
timmyMood,
connectionStatus,
recentEvents,
send,
sendVisitorMessage,
visitorId: VISITOR_ID,
}),
[timmyMood, connectionStatus, recentEvents, send, sendVisitorMessage]
);
return (
<TimmyContext.Provider value={value}>{children}</TimmyContext.Provider>
);
}
export function useTimmy() {
const ctx = useContext(TimmyContext);
if (!ctx) throw new Error("useTimmy must be used within TimmyProvider");
return ctx;
}

View File

@@ -0,0 +1,3 @@
const { getDefaultConfig } = require("expo/metro-config");
module.exports = getDefaultConfig(__dirname);

View File

@@ -0,0 +1,63 @@
{
"name": "@workspace/mobile",
"version": "0.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"dev": "EXPO_PACKAGER_PROXY_URL=https://$REPLIT_EXPO_DEV_DOMAIN EXPO_PUBLIC_DOMAIN=$REPLIT_DEV_DOMAIN EXPO_PUBLIC_REPL_ID=$REPL_ID REACT_NATIVE_PACKAGER_HOSTNAME=$REPLIT_DEV_DOMAIN pnpm exec expo start --localhost --port $PORT",
"build": "node scripts/build.js",
"serve": "node server/serve.js",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@expo-google-fonts/inter": "^0.4.0",
"@expo/cli": "54.0.23",
"@expo/ngrok": "^4.1.0",
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@stardazed/streams-text-encoding": "^1.0.2",
"@tanstack/react-query": "catalog:",
"@types/react": "~19.1.10",
"@types/react-dom": "~19.1.7",
"@ungap/structured-clone": "^1.3.0",
"@workspace/api-client-react": "workspace:*",
"babel-plugin-react-compiler": "^19.0.0-beta-e993439-20250117",
"expo": "~54.0.27",
"expo-blur": "~15.0.8",
"expo-constants": "~18.0.11",
"expo-font": "~14.0.10",
"expo-glass-effect": "~0.1.4",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-picker": "~17.0.9",
"expo-linear-gradient": "~15.0.8",
"expo-linking": "~8.0.10",
"expo-location": "~19.0.8",
"expo-router": "~6.0.17",
"expo-splash-screen": "~31.0.12",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"react": "catalog:",
"react-dom": "catalog:",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.20.6",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1",
"typescript": "~5.9.2",
"zod": "catalog:",
"zod-validation-error": "^3.4.0"
},
"dependencies": {
"@react-native-voice/voice": "^3.2.4",
"expo-speech": "^14.0.8",
"react-native-webview": "^13.15.0"
}
}

View File

@@ -0,0 +1,573 @@
const fs = require("fs");
const path = require("path");
const { spawn } = require("child_process");
const { Readable } = require("stream");
const { pipeline } = require("stream/promises");
let metroProcess = null;
const projectRoot = path.resolve(__dirname, "..");
function findWorkspaceRoot(startDir) {
let dir = startDir;
while (dir !== path.dirname(dir)) {
if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) {
return dir;
}
dir = path.dirname(dir);
}
throw new Error("Could not find workspace root (no pnpm-workspace.yaml found)");
}
const workspaceRoot = findWorkspaceRoot(projectRoot);
const basePath = (process.env.BASE_PATH || "/").replace(/\/+$/, "");
function exitWithError(message) {
console.error(message);
if (metroProcess) {
metroProcess.kill();
}
process.exit(1);
}
function setupSignalHandlers() {
const cleanup = () => {
if (metroProcess) {
console.log("Cleaning up Metro process...");
metroProcess.kill();
}
process.exit(0);
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
process.on("SIGHUP", cleanup);
}
function stripProtocol(domain) {
let urlString = domain.trim();
if (!/^https?:\/\//i.test(urlString)) {
urlString = `https://${urlString}`;
}
return new URL(urlString).host;
}
function getDeploymentDomain() {
if (process.env.REPLIT_INTERNAL_APP_DOMAIN) {
return stripProtocol(process.env.REPLIT_INTERNAL_APP_DOMAIN);
}
if (process.env.REPLIT_DEV_DOMAIN) {
return stripProtocol(process.env.REPLIT_DEV_DOMAIN);
}
if (process.env.EXPO_PUBLIC_DOMAIN) {
return stripProtocol(process.env.EXPO_PUBLIC_DOMAIN);
}
console.error(
"ERROR: No deployment domain found. Set REPLIT_INTERNAL_APP_DOMAIN, REPLIT_DEV_DOMAIN, or EXPO_PUBLIC_DOMAIN",
);
process.exit(1);
}
function prepareDirectories(timestamp) {
console.log("Preparing build directories...");
const staticBuild = path.join(projectRoot, "static-build");
if (fs.existsSync(staticBuild)) {
fs.rmSync(staticBuild, { recursive: true });
}
const dirs = [
path.join(staticBuild, timestamp, "_expo", "static", "js", "ios"),
path.join(staticBuild, timestamp, "_expo", "static", "js", "android"),
path.join(staticBuild, "ios"),
path.join(staticBuild, "android"),
];
for (const dir of dirs) {
fs.mkdirSync(dir, { recursive: true });
}
console.log("Build:", timestamp);
}
function clearMetroCache() {
console.log("Clearing Metro cache...");
const cacheDirs = [
path.join(projectRoot, ".metro-cache"),
path.join(projectRoot, "node_modules/.cache/metro"),
];
for (const dir of cacheDirs) {
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
console.log("Cache cleared");
}
async function checkMetroHealth() {
try {
const response = await fetch("http://localhost:8081/status", {
signal: AbortSignal.timeout(5000),
});
return response.ok;
} catch {
return false;
}
}
function getExpoPublicReplId() {
return process.env.REPL_ID || process.env.EXPO_PUBLIC_REPL_ID;
}
async function startMetro(expoPublicDomain, expoPublicReplId) {
const isRunning = await checkMetroHealth();
if (isRunning) {
console.log("Metro already running");
return;
}
console.log("Starting Metro...");
console.log(`Setting EXPO_PUBLIC_DOMAIN=${expoPublicDomain}`);
const env = {
...process.env,
EXPO_PUBLIC_DOMAIN: expoPublicDomain,
EXPO_PUBLIC_REPL_ID: expoPublicReplId,
};
if (expoPublicReplId) {
console.log(`Setting EXPO_PUBLIC_REPL_ID=${expoPublicReplId}`);
}
metroProcess = spawn(
"pnpm",
[
"exec",
"expo",
"start",
"--no-dev",
"--minify",
"--localhost",
],
{
stdio: ["ignore", "pipe", "pipe"],
detached: false,
cwd: projectRoot,
env,
},
);
if (metroProcess.stdout) {
metroProcess.stdout.on("data", (data) => {
const output = data.toString().trim();
if (output) console.log(`[Metro] ${output}`);
});
}
if (metroProcess.stderr) {
metroProcess.stderr.on("data", (data) => {
const output = data.toString().trim();
if (output) console.error(`[Metro Error] ${output}`);
});
}
for (let i = 0; i < 60; i++) {
await new Promise((resolve) => setTimeout(resolve, 1000));
const healthy = await checkMetroHealth();
if (healthy) {
console.log("Metro ready");
return;
}
}
console.error("Metro timeout");
process.exit(1);
}
async function downloadFile(url, outputPath) {
const controller = new AbortController();
const fiveMinMS = 5 * 60 * 1_000;
const timeoutId = setTimeout(() => controller.abort(), fiveMinMS);
try {
console.log(`Downloading: ${url}`);
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const file = fs.createWriteStream(outputPath);
await pipeline(Readable.fromWeb(response.body), file);
const fileSize = fs.statSync(outputPath).size;
if (fileSize === 0) {
fs.unlinkSync(outputPath);
throw new Error("Downloaded file is empty");
}
} catch (error) {
if (fs.existsSync(outputPath)) {
fs.unlinkSync(outputPath);
}
if (error.name === "AbortError") {
throw new Error(`Download timeout after 5m: ${url}`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
async function downloadBundle(platform, timestamp) {
const entryPath = path.resolve(projectRoot, "node_modules", "expo-router", "entry");
const bundlePath = path.relative(workspaceRoot, entryPath);
const url = new URL(`http://localhost:8081/${bundlePath}.bundle`);
url.searchParams.set("platform", platform);
url.searchParams.set("dev", "false");
url.searchParams.set("hot", "false");
url.searchParams.set("lazy", "false");
url.searchParams.set("minify", "true");
const output = path.join(
"static-build",
timestamp,
"_expo",
"static",
"js",
platform,
"bundle.js",
);
console.log(`Fetching ${platform} bundle...`);
await downloadFile(url.toString(), output);
console.log(`${platform} bundle ready`);
}
async function downloadManifest(platform) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 300_000);
try {
console.log(`Fetching ${platform} manifest...`);
const response = await fetch("http://localhost:8081/manifest", {
headers: { "expo-platform": platform },
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const manifest = await response.json();
console.log(`${platform} manifest ready`);
return manifest;
} catch (error) {
if (error.name === "AbortError") {
throw new Error(
`Manifest download timeout after 5m for platform: ${platform}`,
);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
async function downloadBundlesAndManifests(timestamp) {
console.log("Downloading bundles and manifests...");
console.log("This may take several minutes for production builds...");
try {
// Bundles are sequential — Metro can't handle both platforms simultaneously
// without stalling. Manifests are cheap and run in parallel after.
await downloadBundle("ios", timestamp);
await downloadBundle("android", timestamp);
const [iosManifest, androidManifest] = await Promise.all([
downloadManifest("ios"),
downloadManifest("android"),
]);
console.log("All downloads completed successfully");
return { ios: iosManifest, android: androidManifest };
} catch (error) {
exitWithError(`Download failed: ${error.message}`);
}
}
function extractAssets(timestamp) {
const staticBuild = path.join(projectRoot, "static-build");
const bundles = {
ios: fs.readFileSync(
path.join(staticBuild, timestamp, "_expo", "static", "js", "ios", "bundle.js"),
"utf-8",
),
android: fs.readFileSync(
path.join(staticBuild, timestamp, "_expo", "static", "js", "android", "bundle.js"),
"utf-8",
),
};
const assetsMap = new Map();
const assetPattern =
/httpServerLocation:"([^"]+)"[^}]*hash:"([^"]+)"[^}]*name:"([^"]+)"[^}]*type:"([^"]+)"/g;
const extractFromBundle = (bundle, platform) => {
for (const match of bundle.matchAll(assetPattern)) {
const originalPath = match[1];
const filename = match[3] + "." + match[4];
const tempUrl = new URL(`http://localhost:8081${originalPath}`);
const unstablePath = tempUrl.searchParams.get("unstable_path");
if (!unstablePath) {
throw new Error(`Asset missing unstable_path: ${originalPath}`);
}
const decodedPath = decodeURIComponent(unstablePath);
const key = path.posix.join(decodedPath, filename);
if (!assetsMap.has(key)) {
const asset = {
url: path.posix.join("/", decodedPath, filename),
originalPath: originalPath,
filename: filename,
relativePath: decodedPath,
hash: match[2],
platforms: new Set(),
};
assetsMap.set(key, asset);
}
assetsMap.get(key).platforms.add(platform);
}
};
extractFromBundle(bundles.ios, "ios");
extractFromBundle(bundles.android, "android");
return Array.from(assetsMap.values());
}
async function downloadAssets(assets, timestamp) {
if (assets.length === 0) {
return 0;
}
console.log("Copying assets...");
let successCount = 0;
const failures = [];
const downloadPromises = assets.map(async (asset) => {
const tempUrl = new URL(`http://localhost:8081${asset.originalPath}`);
const unstablePath = tempUrl.searchParams.get("unstable_path");
if (!unstablePath) {
throw new Error(`Asset missing unstable_path: ${asset.originalPath}`);
}
const decodedPath = decodeURIComponent(unstablePath);
const outputDir = path.join(
projectRoot,
"static-build",
timestamp,
"_expo",
"static",
"js",
asset.relativePath,
);
fs.mkdirSync(outputDir, { recursive: true });
const output = path.join(outputDir, asset.filename);
try {
const candidates = [
path.join(projectRoot, decodedPath, asset.filename),
path.join(workspaceRoot, decodedPath, asset.filename),
];
const found = candidates.find((p) => fs.existsSync(p));
if (!found) {
throw new Error(`Asset not found on disk: ${asset.filename}`);
}
fs.copyFileSync(found, output);
successCount++;
} catch (error) {
failures.push({
filename: asset.filename,
error: error.message,
url: asset.originalPath,
});
}
});
await Promise.all(downloadPromises);
if (failures.length > 0) {
const errorMsg =
`Failed to download ${failures.length} asset(s):\n` +
failures
.map((f) => ` - ${f.filename}: ${f.error} (${f.url})`)
.join("\n");
exitWithError(errorMsg);
}
console.log(`Copied ${successCount} assets`);
return successCount;
}
function updateBundleUrls(timestamp, baseUrl) {
const updateForPlatform = (platform) => {
const bundlePath = path.join(
projectRoot,
"static-build",
timestamp,
"_expo",
"static",
"js",
platform,
"bundle.js",
);
let bundle = fs.readFileSync(bundlePath, "utf-8");
bundle = bundle.replace(
/httpServerLocation:"(\/[^"]+)"/g,
(_match, capturedPath) => {
const tempUrl = new URL(`http://localhost:8081${capturedPath}`);
const unstablePath = tempUrl.searchParams.get("unstable_path");
if (!unstablePath) {
throw new Error(
`Asset missing unstable_path in bundle: ${capturedPath}`,
);
}
const decodedPath = decodeURIComponent(unstablePath);
return `httpServerLocation:"${baseUrl}${basePath}/${timestamp}/_expo/static/js/${decodedPath}"`;
},
);
fs.writeFileSync(bundlePath, bundle);
};
updateForPlatform("ios");
updateForPlatform("android");
console.log("Updated bundle URLs");
}
function updateManifests(manifests, timestamp, baseUrl, assetsByHash) {
const updateForPlatform = (platform, manifest) => {
if (!manifest.launchAsset || !manifest.extra) {
exitWithError(`Malformed manifest for ${platform}`);
}
manifest.launchAsset.url = `${baseUrl}${basePath}/${timestamp}/_expo/static/js/${platform}/bundle.js`;
manifest.launchAsset.key = `bundle-${timestamp}`;
manifest.createdAt = new Date(
Number(timestamp.split("-")[0]),
).toISOString();
manifest.extra.expoClient.hostUri =
baseUrl.replace("https://", "") + "/" + platform;
manifest.extra.expoGo.debuggerHost =
baseUrl.replace("https://", "") + "/" + platform;
manifest.extra.expoGo.packagerOpts.dev = false;
if (manifest.assets && manifest.assets.length > 0) {
manifest.assets.forEach((asset) => {
if (!asset.url) return;
const hash = asset.hash;
if (!hash) return;
const assetInfo = assetsByHash.get(hash);
if (!assetInfo) return;
asset.url = `${baseUrl}${basePath}/${timestamp}/_expo/static/js/${assetInfo.relativePath}/${assetInfo.filename}`;
});
}
fs.writeFileSync(
path.join(projectRoot, "static-build", platform, "manifest.json"),
JSON.stringify(manifest, null, 2),
);
};
updateForPlatform("ios", manifests.ios);
updateForPlatform("android", manifests.android);
console.log("Manifests updated");
}
async function main() {
console.log("Building static Expo Go deployment...");
setupSignalHandlers();
const domain = getDeploymentDomain();
const expoPublicReplId = getExpoPublicReplId();
const baseUrl = `https://${domain}`;
const timestamp = `${Date.now()}-${process.pid}`;
prepareDirectories(timestamp);
clearMetroCache();
await startMetro(domain, expoPublicReplId);
const downloadTimeout = 600000;
const downloadPromise = downloadBundlesAndManifests(timestamp);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(
new Error(
`Overall download timeout after ${downloadTimeout / 1000} seconds. ` +
"Metro may be struggling to generate bundles. Check Metro logs above.",
),
);
}, downloadTimeout);
});
const manifests = await Promise.race([downloadPromise, timeoutPromise]);
console.log("Processing assets...");
const assets = extractAssets(timestamp);
console.log("Found", assets.length, "unique asset(s)");
const assetsByHash = new Map();
for (const asset of assets) {
assetsByHash.set(asset.hash, {
relativePath: asset.relativePath,
filename: asset.filename,
});
}
const assetCount = await downloadAssets(assets, timestamp);
if (assetCount > 0) {
updateBundleUrls(timestamp, baseUrl);
}
console.log("Updating manifests and creating landing page...");
updateManifests(manifests, timestamp, baseUrl, assetsByHash);
console.log("Build complete! Deploy to:", baseUrl);
if (metroProcess) {
metroProcess.kill();
}
process.exit(0);
}
main().catch((error) => {
console.error("Build failed:", error.message);
if (metroProcess) {
metroProcess.kill();
}
process.exit(1);
});

View File

@@ -0,0 +1,135 @@
/**
* Standalone production server for Expo static builds.
*
* Serves the output of build.js (static-build/) with two special routes:
* - GET / or /manifest with expo-platform header → platform manifest JSON
* - GET / without expo-platform → landing page HTML
* Everything else falls through to static file serving from ./static-build/.
*
* Zero external dependencies — uses only Node.js built-ins (http, fs, path).
*/
const http = require("http");
const fs = require("fs");
const path = require("path");
const STATIC_ROOT = path.resolve(__dirname, "..", "static-build");
const TEMPLATE_PATH = path.resolve(__dirname, "templates", "landing-page.html");
const basePath = (process.env.BASE_PATH || "/").replace(/\/+$/, "");
const MIME_TYPES = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".css": "text/css; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".otf": "font/otf",
".map": "application/json",
};
function getAppName() {
try {
const appJsonPath = path.resolve(__dirname, "..", "app.json");
const appJson = JSON.parse(fs.readFileSync(appJsonPath, "utf-8"));
return appJson.expo?.name || "App Landing Page";
} catch {
return "App Landing Page";
}
}
function serveManifest(platform, res) {
const manifestPath = path.join(STATIC_ROOT, platform, "manifest.json");
if (!fs.existsSync(manifestPath)) {
res.writeHead(404, { "content-type": "application/json" });
res.end(
JSON.stringify({ error: `Manifest not found for platform: ${platform}` }),
);
return;
}
const manifest = fs.readFileSync(manifestPath, "utf-8");
res.writeHead(200, {
"content-type": "application/json",
"expo-protocol-version": "1",
"expo-sfv-version": "0",
});
res.end(manifest);
}
function serveLandingPage(req, res, landingPageTemplate, appName) {
const forwardedProto = req.headers["x-forwarded-proto"];
const protocol = forwardedProto || "https";
const host = req.headers["x-forwarded-host"] || req.headers["host"];
const baseUrl = `${protocol}://${host}`;
const expsUrl = `${host}`;
const html = landingPageTemplate
.replace(/BASE_URL_PLACEHOLDER/g, baseUrl)
.replace(/EXPS_URL_PLACEHOLDER/g, expsUrl)
.replace(/APP_NAME_PLACEHOLDER/g, appName);
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
res.end(html);
}
function serveStaticFile(urlPath, res) {
const safePath = path.normalize(urlPath).replace(/^(\.\.(\/|\\|$))+/, "");
const filePath = path.join(STATIC_ROOT, safePath);
if (!filePath.startsWith(STATIC_ROOT)) {
res.writeHead(403);
res.end("Forbidden");
return;
}
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
res.writeHead(404);
res.end("Not Found");
return;
}
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES[ext] || "application/octet-stream";
const content = fs.readFileSync(filePath);
res.writeHead(200, { "content-type": contentType });
res.end(content);
}
const landingPageTemplate = fs.readFileSync(TEMPLATE_PATH, "utf-8");
const appName = getAppName();
const server = http.createServer((req, res) => {
const url = new URL(req.url || "/", `http://${req.headers.host}`);
let pathname = url.pathname;
if (basePath && pathname.startsWith(basePath)) {
pathname = pathname.slice(basePath.length) || "/";
}
if (pathname === "/" || pathname === "/manifest") {
const platform = req.headers["expo-platform"];
if (platform === "ios" || platform === "android") {
return serveManifest(platform, res);
}
if (pathname === "/") {
return serveLandingPage(req, res, landingPageTemplate, appName);
}
}
serveStaticFile(pathname, res);
});
const port = parseInt(process.env.PORT || "3000", 10);
server.listen(port, "0.0.0.0", () => {
console.log(`Serving static Expo build on port ${port}`);
});

View File

@@ -0,0 +1,460 @@
<!doctype html>
<html>
<head>
<title>APP_NAME_PLACEHOLDER</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg width='180' height='180' viewBox='0 0 180 180' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='180' height='180' rx='36' fill='%23FF3C00'/%3E%3C/svg%3E" />
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin: 0;
padding: 32px 20px;
text-align: center;
background: #fff;
color: #222;
line-height: 1.5;
min-height: 100vh;
}
.wrapper {
max-width: 480px;
margin: 0 auto;
}
h1 {
font-size: 26px;
font-weight: 600;
margin: 0;
color: #111;
}
.subtitle {
font-size: 15px;
color: #666;
margin-top: 8px;
margin-bottom: 32px;
}
.loading {
display: none;
margin: 60px 0;
}
.spinner {
border: 2px solid #ddd;
border-top-color: #333;
border-radius: 50%;
width: 32px;
height: 32px;
animation: spin 0.8s linear infinite;
margin: 20px auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
font-size: 16px;
color: #444;
}
.content {
display: block;
}
.steps-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.step {
padding: 24px;
border: 1px solid #ddd;
border-radius: 12px;
text-align: center;
background: #fafafa;
}
.step-header {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 12px;
}
.step-number {
width: 28px;
height: 28px;
border: 1px solid #999;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
flex-shrink: 0;
color: #555;
}
.step-title {
font-size: 18px;
font-weight: 600;
margin: 0;
color: #222;
}
.step-description {
font-size: 14px;
margin-bottom: 16px;
color: #666;
}
.store-buttons {
display: flex;
flex-direction: column;
gap: 6px;
justify-content: center;
flex-wrap: wrap;
}
.store-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 20px;
font-size: 14px;
font-weight: 500;
border: 1px solid #ccc;
border-radius: 8px;
text-decoration: none;
color: #333;
background: #fff;
transition: all 0.15s;
}
.store-button:hover {
background: #f5f5f5;
border-color: #999;
}
.store-link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 0;
font-size: 13px;
font-weight: 400;
text-decoration: underline;
text-underline-offset: 2px;
color: #666;
background: none;
border: none;
transition: color 0.15s;
}
.store-link:hover {
color: #333;
}
.store-link .store-icon {
width: 14px;
height: 14px;
}
.store-icon {
width: 18px;
height: 18px;
}
.qr-section {
background: #333;
color: #fff;
border-color: #333;
}
.qr-section .step-number {
border-color: rgba(255, 255, 255, 0.5);
color: #fff;
}
.qr-section .step-title {
color: #fff;
}
.qr-section .step-description {
color: rgba(255, 255, 255, 0.7);
}
.qr-code {
width: 180px;
height: 180px;
margin: 0 auto 16px;
background: #fff;
border-radius: 8px;
padding: 12px;
}
.qr-code canvas {
width: 100%;
height: 100%;
}
.open-button {
display: inline-block;
padding: 12px 24px;
font-size: 14px;
font-weight: 500;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
text-decoration: none;
color: #333;
background: #fff;
transition: opacity 0.15s;
}
.open-button:hover {
opacity: 0.9;
}
/* Desktop styles */
@media (min-width: 768px) {
body {
padding: 48px 32px;
display: flex;
align-items: center;
justify-content: center;
}
.wrapper {
max-width: 720px;
}
h1 {
font-size: 32px;
margin-bottom: 10px;
}
.subtitle {
font-size: 16px;
margin-bottom: 40px;
}
.steps-container {
flex-direction: row;
gap: 20px;
align-items: stretch;
}
.step {
flex: 1;
display: flex;
flex-direction: column;
padding: 28px;
}
.step-description {
flex-grow: 1;
}
.store-buttons {
flex-direction: column;
gap: 10px;
}
.qr-code {
width: 200px;
height: 200px;
}
}
/* Large desktop */
@media (min-width: 1024px) {
.wrapper {
max-width: 800px;
}
h1 {
font-size: 36px;
}
.steps-container {
gap: 28px;
}
.step {
padding: 32px;
}
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
body {
background: #0d0d0d;
color: #e0e0e0;
}
h1 {
color: #f5f5f5;
}
.subtitle {
color: #999;
}
.spinner {
border-color: #444;
border-top-color: #ccc;
}
.loading-text {
color: #aaa;
}
.step {
border-color: #333;
background: #1a1a1a;
}
.step-number {
border-color: #666;
color: #bbb;
}
.step-title {
color: #f0f0f0;
}
.step-description {
color: #888;
}
.store-button {
border-color: #444;
color: #e0e0e0;
background: #222;
}
.store-button:hover {
background: #2a2a2a;
border-color: #666;
}
.store-link {
color: #888;
}
.store-link:hover {
color: #ccc;
}
.qr-section {
background: #111;
border-color: #333;
}
.qr-section .step-number {
border-color: rgba(255, 255, 255, 0.4);
}
.qr-section .step-description {
color: rgba(255, 255, 255, 0.6);
}
.open-button {
background: #f0f0f0;
color: #111;
}
.open-button:hover {
background: #e0e0e0;
}
}
</style>
</head>
<body>
<div class="wrapper">
<div class="loading" id="loading">
<div class="spinner"></div>
<div class="loading-text">Opening in Expo Go...</div>
</div>
<div class="content" id="content">
<h1>APP_NAME_PLACEHOLDER</h1>
<p class="subtitle">Preview this app on your phone</p>
<div class="steps-container">
<div class="step">
<div class="step-header">
<div class="step-number">1</div>
<h2 class="step-title">Download Expo Go</h2>
</div>
<p class="step-description">
Expo Go is a free app to test mobile apps
</p>
<div class="store-buttons" id="store-buttons">
<a
id="app-store-btn"
href="https://apps.apple.com/app/id982107779"
class="store-button"
target="_blank"
>
<svg class="store-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"
/>
</svg>
App Store
</a>
<a
id="play-store-btn"
href="https://play.google.com/store/apps/details?id=host.exp.exponent"
class="store-button"
target="_blank"
>
<svg class="store-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M3,20.5V3.5C3,2.91 3.34,2.39 3.84,2.15L13.69,12L3.84,21.85C3.34,21.6 3,21.09 3,20.5M16.81,15.12L6.05,21.34L14.54,12.85L16.81,15.12M20.16,10.81C20.5,11.08 20.75,11.5 20.75,12C20.75,12.5 20.53,12.9 20.18,13.18L17.89,14.5L15.39,12L17.89,9.5L20.16,10.81M6.05,2.66L16.81,8.88L14.54,11.15L6.05,2.66Z"
/>
</svg>
Google Play
</a>
</div>
</div>
<div class="step qr-section">
<div class="step-header">
<div class="step-number">2</div>
<h2 class="step-title">Scan QR Code</h2>
</div>
<p class="step-description">Use your phone's camera or Expo Go</p>
<div class="qr-code" id="qr-code"></div>
<a href="exps://EXPS_URL_PLACEHOLDER" class="open-button"
>Open in Expo Go</a
>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/qr-code-styling@1.6.0/lib/qr-code-styling.js"></script>
<script>
(function () {
const ua = navigator.userAgent;
const loadingEl = document.getElementById("loading");
const contentEl = document.getElementById("content");
const isAndroid = /Android/i.test(ua);
const isIOS =
/iPhone|iPad|iPod/i.test(ua) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
const deepLink = "exps://EXPS_URL_PLACEHOLDER";
// Adjust store buttons based on platform
const appStoreBtn = document.getElementById("app-store-btn");
const playStoreBtn = document.getElementById("play-store-btn");
const storeButtonsContainer = document.getElementById("store-buttons");
if (isIOS) {
playStoreBtn.className = "store-link";
storeButtonsContainer.appendChild(playStoreBtn);
} else if (isAndroid) {
appStoreBtn.className = "store-link";
storeButtonsContainer.insertBefore(playStoreBtn, appStoreBtn);
}
const qrCode = new QRCodeStyling({
width: 400,
height: 400,
data: deepLink,
dotsOptions: {
color: "#333333",
type: "rounded",
},
backgroundOptions: {
color: "#ffffff",
},
cornersSquareOptions: {
type: "extra-rounded",
},
cornersDotOptions: {
type: "dot",
},
qrOptions: {
errorCorrectionLevel: "H",
},
});
qrCode.append(document.getElementById("qr-code"));
if (isAndroid || isIOS) {
loadingEl.style.display = "block";
contentEl.style.display = "none";
window.location.href = deepLink;
setTimeout(function () {
loadingEl.style.display = "none";
contentEl.style.display = "block";
}, 500);
}
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,23 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"baseUrl": ".",
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
],
"references": [
{
"path": "../../lib/api-client-react"
}
]
}

6333
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff