diff --git a/apps/freya-backend/package.json b/apps/freya-backend/package.json index 5d8d1d3..8453ee6 100644 --- a/apps/freya-backend/package.json +++ b/apps/freya-backend/package.json @@ -15,6 +15,8 @@ "create-admin": "bun run src/scripts/create-admin.ts" }, "dependencies": { + "@better-auth/core": "^1.6.20", + "@better-auth/expo": "^1.6.20", "@earendil-works/pi-coding-agent": "^0.79.1", "@freya/agent-protocol": "workspace:*", "@freya/core": "workspace:*", @@ -29,7 +31,7 @@ "@nym.sh/jrpc": "^0.1.0", "@openrouter/sdk": "^0.9.11", "arktype": "^2.1.29", - "better-auth": "^1", + "better-auth": "^1.6.20", "drizzle-orm": "^0.45.1", "hono": "^4", "lodash.merge": "^4.6.2", diff --git a/apps/freya-backend/src/auth/index.ts b/apps/freya-backend/src/auth/index.ts index a48a5ec..b1bc9c0 100644 --- a/apps/freya-backend/src/auth/index.ts +++ b/apps/freya-backend/src/auth/index.ts @@ -1,3 +1,4 @@ +import { expo } from "@better-auth/expo" import { betterAuth } from "better-auth" import { drizzleAdapter } from "better-auth/adapters/drizzle" import { admin } from "better-auth/plugins" @@ -32,7 +33,7 @@ export function createAuth(db: Database) { }, }, }, - plugins: [admin()], + plugins: [admin(), expo()], }) } diff --git a/apps/freya-client/app.json b/apps/freya-client/app.json index 2441c6a..d8b0d99 100644 --- a/apps/freya-client/app.json +++ b/apps/freya-client/app.json @@ -255,7 +255,8 @@ } ], "expo-web-browser", - "expo-image" + "expo-image", + "expo-secure-store" ], "experiments": { "typedRoutes": true, diff --git a/apps/freya-client/package.json b/apps/freya-client/package.json index 335f370..9fd4919 100644 --- a/apps/freya-client/package.json +++ b/apps/freya-client/package.json @@ -1,61 +1,72 @@ { - "name": "freya-client", - "version": "1.0.0", - "private": true, - "main": "expo-router/entry", - "scripts": { - "start": "./scripts/run-dev-server.sh", - "reset-project": "node ./scripts/reset-project.js", - "android": "expo start --android", - "ios": "expo start --ios", - "web": "expo start --web", - "lint": "expo lint", - "build:ios": "bunx eas-cli build --profile development --platform ios --non-interactive", - "build:ios-simulator": "bunx eas-cli build --profile development-simulator --platform ios --non-interactive", - "debugger": "bun run scripts/open-debugger.ts" - }, - "dependencies": { - "@expo-google-fonts/inter": "^0.4.2", - "@expo-google-fonts/source-serif-4": "^0.4.1", - "@expo/vector-icons": "^15.0.3", - "@freya/core": "workspace:*", - "@json-render/react-native": "^0.13.0", - "@shopify/flash-list": "2.0.2", - "@tanstack/react-query": "^5.90.21", - "arktype": "^2.2.1", - "expo": "^56.0.0", - "expo-blur": "~56.0.3", - "expo-constants": "~56.0.18", - "expo-dev-client": "~56.0.20", - "expo-font": "~56.0.7", - "expo-glass-effect": "~0.1.10", - "expo-haptics": "~56.0.3", - "expo-image": "~56.0.11", - "expo-linking": "~56.0.14", - "expo-location": "~56.0.18", - "expo-router": "~56.2.11", - "expo-splash-screen": "~56.0.10", - "expo-status-bar": "~56.0.4", - "expo-symbols": "~56.0.6", - "expo-system-ui": "~56.0.5", - "expo-web-browser": "~56.0.5", - "react": "19.2.3", - "react-dom": "19.2.3", - "react-native": "0.85.3", - "react-native-gesture-handler": "~2.31.1", - "react-native-reanimated": "4.3.1", - "react-native-safe-area-context": "~5.7.0", - "react-native-screens": "4.25.2", - "react-native-svg": "15.15.4", - "react-native-web": "~0.21.0", - "react-native-worklets": "0.8.3", - "twrnc": "^4.16.0", - "zod": "^4.3.6" - }, - "devDependencies": { - "@types/react": "~19.2.10", - "eslint": "^9.25.0", - "eslint-config-expo": "~56.0.4", - "typescript": "~6.0.3" - } + "name": "freya-client", + "version": "1.0.0", + "private": true, + "main": "expo-router/entry", + "scripts": { + "start": "./scripts/run-dev-server.sh", + "reset-project": "node ./scripts/reset-project.js", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "lint": "expo lint", + "build:ios": "bunx eas-cli build --profile development --platform ios --non-interactive", + "build:ios-simulator": "bunx eas-cli build --profile development-simulator --platform ios --non-interactive", + "debugger": "bun run scripts/open-debugger.ts" + }, + "dependencies": { + "@better-auth/core": "^1.6.20", + "@better-auth/expo": "^1.6.20", + "@expo-google-fonts/inter": "^0.4.2", + "@expo-google-fonts/source-serif-4": "^0.4.1", + "@expo/vector-icons": "^15.0.3", + "@freya/core": "workspace:*", + "@json-render/react-native": "^0.13.0", + "@react-native-masked-view/masked-view": "0.3.2", + "@shopify/flash-list": "2.0.2", + "@tanstack/react-query": "^5.90.21", + "arktype": "^2.2.1", + "better-auth": "^1.6.20", + "class-variance-authority": "^0.7.1", + "expo": "^56.0.0", + "expo-blur": "~56.0.3", + "expo-constants": "~56.0.18", + "expo-dev-client": "~56.0.20", + "expo-font": "~56.0.7", + "expo-glass-effect": "~0.1.10", + "expo-haptics": "~56.0.3", + "expo-image": "~56.0.11", + "expo-linear-gradient": "~56.0.4", + "expo-linking": "~56.0.14", + "expo-location": "~56.0.18", + "expo-network": "~56.0.5", + "expo-router": "~56.2.11", + "expo-secure-store": "~56.0.4", + "expo-splash-screen": "~56.0.10", + "expo-status-bar": "~56.0.4", + "expo-symbols": "~56.0.6", + "expo-system-ui": "~56.0.5", + "expo-web-browser": "~56.0.5", + "jotai": "^2.20.1", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-native": "0.85.3", + "react-native-easing-gradient": "^1.1.1", + "react-native-gesture-handler": "~2.31.1", + "react-native-keyboard-controller": "1.21.6", + "react-native-reanimated": "4.3.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "4.25.2", + "react-native-svg": "15.15.4", + "react-native-web": "~0.21.0", + "react-native-worklets": "0.8.3", + "twrnc": "^4.16.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/react": "~19.2.10", + "eslint": "^9.25.0", + "eslint-config-expo": "~56.0.4", + "typescript": "~6.0.3" + } } diff --git a/apps/freya-client/scripts/dev-proxy.ts b/apps/freya-client/scripts/dev-proxy.ts index 734f6ac..bf4c3fb 100644 --- a/apps/freya-client/scripts/dev-proxy.ts +++ b/apps/freya-client/scripts/dev-proxy.ts @@ -23,6 +23,8 @@ function forwardHeaders(headers: Headers): Headers { interface WsData { upstream: WebSocket + upstreamUrl: string + path: string isDevice: boolean } @@ -42,8 +44,10 @@ Bun.serve({ // WebSocket upgrade — bridge to Metro's ws endpoint if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { - const wsUrl = `${METRO_WS_BASE}${url.pathname}${url.search}` - const upstream = new WebSocket(wsUrl) + const path = `${url.pathname}${url.search}` + const wsUrl = `${METRO_WS_BASE}${path}` + console.log(`[proxy] ws connecting ${path}`) + const upstream = connectUpstreamWebSocket(wsUrl, getWebSocketHeaders(req, url)) // Wait for upstream to connect before upgrading the client try { @@ -56,7 +60,7 @@ Bun.serve({ } const isDevice = url.pathname.startsWith("/inspector/device") - const ok = server.upgrade(req, { data: { upstream, isDevice } }) + const ok = server.upgrade(req, { data: { upstream, upstreamUrl: wsUrl, path, isDevice } }) if (!ok) { upstream.close() return new Response("WebSocket upgrade failed", { status: 500 }) @@ -83,19 +87,28 @@ Bun.serve({ websocket: { message(ws: ServerWebSocket, msg) { - ws.data.upstream.send(msg) + sendUpstream(ws.data.upstream, msg) }, open(ws: ServerWebSocket) { const { upstream } = ws.data + console.log(`[proxy] ws open ${ws.data.path}`) upstream.addEventListener("message", (ev) => { if (typeof ev.data === "string") { ws.send(ev.data) } else if (ev.data instanceof ArrayBuffer) { ws.sendBinary(new Uint8Array(ev.data)) + } else if (ev.data instanceof Blob) { + ev.data.arrayBuffer().then((buffer) => ws.sendBinary(new Uint8Array(buffer))) } }) - upstream.addEventListener("close", () => ws.close()) - upstream.addEventListener("error", () => ws.close()) + upstream.addEventListener("close", (ev) => { + console.log(`[proxy] upstream close ${ws.data.path} code=${ev.code} reason=${ev.reason}`) + ws.close(ev.code, ev.reason) + }) + upstream.addEventListener("error", () => { + console.error(`[proxy] upstream error ${ws.data.upstreamUrl}`) + ws.close() + }) // Print debugger URL shortly after a device connects, // giving Metro time to register the target. @@ -104,6 +117,7 @@ Bun.serve({ } }, close(ws: ServerWebSocket) { + console.log(`[proxy] client close ${ws.data.path}`) ws.data.upstream.close() }, }, @@ -120,7 +134,7 @@ async function printDebuggerUrl() { if (!Array.isArray(parsedTargets)) return const targets = parsedTargets.filter(isDebugTarget) - const target = targets.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend) + const target = targets.find(prefersFuseboxFrontend) ?? targets[0] if (!target) return const wsPath = getProxyWebSocketPath(target.webSocketDebuggerUrl) @@ -153,6 +167,29 @@ async function fetchUpstream( } } +function sendUpstream(upstream: WebSocket, msg: string | Buffer) { + if (typeof msg === "string") { + upstream.send(msg) + return + } + + upstream.send(new Uint8Array(msg)) +} + +function getWebSocketHeaders(req: Request, url: URL) { + return { + Origin: req.headers.get("origin") ?? url.origin, + } +} + +function connectUpstreamWebSocket(url: string, headers: Record) { + const BunWebSocket = WebSocket as unknown as { + new (url: string, options: { headers: Record }): WebSocket + } + + return new BunWebSocket(url, { headers }) +} + function isDebugTarget(value: unknown): value is DebugTarget { if (!isRecord(value) || typeof value.webSocketDebuggerUrl !== "string") return false @@ -168,6 +205,10 @@ function isDebugTarget(value: unknown): value is DebugTarget { return prefersFuseboxFrontend === undefined || typeof prefersFuseboxFrontend === "boolean" } +function prefersFuseboxFrontend(target: DebugTarget) { + return target.reactNative?.capabilities?.prefersFuseboxFrontend === true +} + function getProxyWebSocketPath(webSocketDebuggerUrl: string) { const url = new URL(webSocketDebuggerUrl) return `${tsIp}:${PROXY_PORT}${url.pathname}${url.search}` diff --git a/apps/freya-client/scripts/open-debugger.ts b/apps/freya-client/scripts/open-debugger.ts index f6fceb0..2a275fb 100644 --- a/apps/freya-client/scripts/open-debugger.ts +++ b/apps/freya-client/scripts/open-debugger.ts @@ -29,7 +29,7 @@ if (!Array.isArray(parsedTargets)) { } const targets = parsedTargets.filter(isDebugTarget) -const target = targets.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend) +const target = targets.find(prefersFuseboxFrontend) ?? targets[0] if (!target) { console.error("No debug target found. Is the app connected?") @@ -68,6 +68,10 @@ function isDebugTarget(value: unknown): value is DebugTarget { return prefersFuseboxFrontend === undefined || typeof prefersFuseboxFrontend === "boolean" } +function prefersFuseboxFrontend(target: DebugTarget) { + return target.reactNative?.capabilities?.prefersFuseboxFrontend === true +} + function getProxyWebSocketPath(webSocketDebuggerUrl: string) { const url = new URL(webSocketDebuggerUrl) return `${tsIp}:${PROXY_PORT}${url.pathname}${url.search}` diff --git a/apps/freya-client/src/api/auth-middleware.ts b/apps/freya-client/src/api/auth-middleware.ts deleted file mode 100644 index 43cf325..0000000 --- a/apps/freya-client/src/api/auth-middleware.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ApiRequestMiddleware } from "./client" - -export const authMiddleware: ApiRequestMiddleware = (_url, init) => { - // TODO: placeholder auth middleware - return init -} diff --git a/apps/freya-client/src/api/client.ts b/apps/freya-client/src/api/client.ts index f16d31b..7ea83b7 100644 --- a/apps/freya-client/src/api/client.ts +++ b/apps/freya-client/src/api/client.ts @@ -27,9 +27,12 @@ export class ApiClient { (prevInit, middleware) => middleware(url, prevInit), init, ) - return fetch(this.baseUrl ? new URL(url.toString(), this.baseUrl) : url, finalInit).then( - (res) => Promise.all([Promise.resolve(res), res.json()]), - ) + return fetch(this.baseUrl ? this.baseUrl + url : url, finalInit) + .then((res) => Promise.all([Promise.resolve(res), res.json()])) + .catch((err) => { + console.log(`request error: ${url}`, err) + throw err + }) } } diff --git a/apps/freya-client/src/app/(app)/index.tsx b/apps/freya-client/src/app/(app)/index.tsx new file mode 100644 index 0000000..0fda911 --- /dev/null +++ b/apps/freya-client/src/app/(app)/index.tsx @@ -0,0 +1,134 @@ +import { Link } from "expo-router" +import { Pressable, ScrollView, View } from "react-native" +import tw from "twrnc" + +import { FeedCard } from "@/components/ui/feed-card" +import { MonospaceText } from "@/components/ui/monospace-text" +import { SansSerifText } from "@/components/ui/sans-serif-text" +import { SerifText } from "@/components/ui/serif-text" +import { ChatOverlay } from "@/conversations/chat-overlay" + +export default function HomeScreen() { + return ( + + + + + + Morning brief + + + 08:42 + + + + A calm start with two useful windows. + + + Your morning is light until the project sync. Rain holds off until late afternoon, and + your last note suggests starting with the proposal outline. + + + + + 90 min focus + + + + + 2 reminders + + + + + Low email volume + + + + + + + + Next up + + Today + + + + + + 09:30 + + + Project sync prep + + Three notes from yesterday are ready to skim. + + + + + + + 11:00 + + + Quiet work block + + Best slot for the proposal before the day gets busy. + + + + + + + + + Personal radar + + 4 signals + + + + + + + Package is likely to arrive before 2 PM. + + + + + + Energy prices dip again after midnight. + + + + + + A recipe you saved matches what is in the fridge. + + + + + + + + + View component library + + + + + + + + ) +} diff --git a/apps/freya-client/src/app/_layout.tsx b/apps/freya-client/src/app/_layout.tsx index f55fc72..29a5863 100644 --- a/apps/freya-client/src/app/_layout.tsx +++ b/apps/freya-client/src/app/_layout.tsx @@ -3,11 +3,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { Stack } from "expo-router" import { StatusBar } from "expo-status-bar" import React from "react" -import { useColorScheme } from "react-native" +import { useColorScheme, View } from "react-native" +import { KeyboardProvider } from "react-native-keyboard-controller" import tw, { useDeviceContext } from "twrnc" -import { authMiddleware } from "@/api/auth-middleware" import { ApiClient, ApiClientContext } from "@/api/client" +import { auth, authMiddleware } from "@/auth/auth" const queryClient = new QueryClient() const apiClient = new ApiClient({ @@ -22,6 +23,12 @@ export default function RootLayout() { const headerBg = colorScheme === "dark" ? "#1c1917" : "#f5f5f4" const headerTint = colorScheme === "dark" ? "#e7e5e4" : "#1c1917" + const { data: session, isPending: isLoadingSession } = auth.useSession() + + if (isLoadingSession) { + return null + } + return ( - - + + + + + + + + + + @@ -58,8 +73,10 @@ export default function RootLayout() { function ContextProvider({ children }: React.PropsWithChildren) { return ( - - {children} - + + + {children} + + ) } diff --git a/apps/freya-client/src/app/index.tsx b/apps/freya-client/src/app/index.tsx deleted file mode 100644 index ed84b50..0000000 --- a/apps/freya-client/src/app/index.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { BlurView } from "expo-blur" -import { GlassView } from "expo-glass-effect" -import { Link } from "expo-router" -import { Pressable, View, Text, TextInput } from "react-native" -import { SafeAreaView } from "react-native-safe-area-context" -import tw from "twrnc" - -import { Button } from "@/components/ui/button" -import { FeedCard } from "@/components/ui/feed-card" -import { MonospaceText } from "@/components/ui/monospace-text" -import { SansSerifText } from "@/components/ui/sans-serif-text" -import { SerifText } from "@/components/ui/serif-text" - -export default function HomeScreen() { - return ( - - - Hello world asdsadsa - Hello world - asdjsakljdl - + + + + + + + ) +} + +interface LoginFormContainerRef { + show: ({ fromHeight }: { fromHeight: number }) => void +} + +function LoginFormContainer({ + ref, + children, +}: React.PropsWithChildren<{ ref?: React.Ref }>) { + console.log("LoginFormContainer") + const safeAreaInsets = useSafeAreaInsets() + + const opacity = useSharedValue(0) + const contentOpacity = useSharedValue(0) + const insetX = useSharedValue(0) + const bottom = useSharedValue(0) + const height = useSharedValue(0) + const finalHeight = useRef(0) + + const show = useCallback( + ({ fromHeight }: { fromHeight: number }) => { + insetX.value = 24 + bottom.value = safeAreaInsets.bottom + height.value = fromHeight + opacity.value = 1 + + insetX.value = withSpring(0) + bottom.value = withSpring(0) + height.value = withSpring(finalHeight.current) + contentOpacity.value = withDelay(100, withSpring(1)) + }, + [opacity, insetX, bottom, safeAreaInsets.bottom, height, contentOpacity], + ) + + useImperativeHandle(ref, () => ({ show })) + + useKeyboardHandler({ + onMove: ({ progress, height, duration }) => { + "worklet" + bottom.value = height + }, + }) + + const animatedStyle = useAnimatedStyle(() => ({ + height: opacity.value !== 0 ? height.value : undefined, + opacity: opacity.value, + left: insetX.value, + right: insetX.value, + bottom: bottom.value, + })) + + return ( + { + finalHeight.current = layout.height + }} + style={[ + tw`absolute overflow-hidden border border-stone-200 dark:border-stone-700 rounded-2xl`, + animatedStyle, + ]} + > + + + + {children} + + + + + ) +} + +function LoginForm() { + console.log("LoginForm") + const emailRef = useRef("") + const passwordRef = useRef("") + const router = useRouter() + + const { mutate: signIn, isPending: isSigningIn } = useMutation( + mutationOptions({ + ...signInMutation, + onSuccess: (data) => { + if (data) { + router.replace("/") + } else { + // if no data is returned, nothing was done, so do nothing + } + }, + onError: (error) => { + console.log(error) + if (error instanceof InvalidCredentialsError) { + Alert.alert("Failed to sign in", "Incorrect email or password") + } else if (error instanceof BetterAuthError) { + Alert.alert( + "Failed to sign in", + "This is a fault on Freya's end. Please try again later.", + ) + } else { + Alert.alert( + "Unable to connect to Freya", + "Please check your internet connection and try again.", + ) + } + }, + }), + ) + + const handleSignInButtonPress = () => { + signIn({ + email: emailRef.current, + password: passwordRef.current, + }) + } + + return ( + + + + + Email + + + + Password + + + + { + emailRef.current = text + }} + /> + + { + passwordRef.current = text + }} + /> + + + + + ) +} diff --git a/apps/freya-client/src/auth/auth.ts b/apps/freya-client/src/auth/auth.ts new file mode 100644 index 0000000..0f333a0 --- /dev/null +++ b/apps/freya-client/src/auth/auth.ts @@ -0,0 +1,57 @@ +import { expoClient } from "@better-auth/expo/client" +import { mutationOptions } from "@tanstack/react-query" +import { createAuthClient } from "better-auth/react" +import * as SecureStore from "expo-secure-store" + +import type { ApiRequestMiddleware } from "../api/client" + +import { BetterAuthError, InvalidCredentialsError } from "./error" + +export const auth = createAuthClient({ + baseURL: process.env.EXPO_PUBLIC_SERVER_URL, + plugins: [ + expoClient({ + scheme: "freya", + storagePrefix: "chat.freya", + storage: SecureStore, + }), + ], +}) + +export const authMiddleware: ApiRequestMiddleware = (_url, init) => { + const cookie = auth.getCookie() + const headers = new Headers(init.headers) + if (cookie) { + headers.set("Cookie", cookie) + } + return { + ...init, + credentials: "omit", + headers, + } +} + +export const signInMutation = mutationOptions({ + mutationFn: async ({ email, password }: { email: string; password: string }) => { + if (email && password) { + const result = await auth.signIn.email({ + email, + password, + }) + if (result.error?.code) { + switch (result.error.code) { + case "INVALID_EMAIL": + throw new InvalidCredentialsError("Invalid email") + case "INVALID_PASSWORD": + throw new InvalidCredentialsError("Invalid password") + case "INVALID_EMAIL_OR_PASSWORD": + throw new InvalidCredentialsError("Invalid email or password") + default: + throw new BetterAuthError(result.error) + } + } + return result + } + return null + }, +}) diff --git a/apps/freya-client/src/auth/error.ts b/apps/freya-client/src/auth/error.ts new file mode 100644 index 0000000..2a3791b --- /dev/null +++ b/apps/freya-client/src/auth/error.ts @@ -0,0 +1,27 @@ +import type { auth } from "./auth" + +export class InvalidCredentialsError extends Error { + constructor(cause: unknown) { + super(`Invalid credentials: ${cause}`) + } +} + +export class BetterAuthError extends Error { + // the type is copied from the shape of result.error from authClient.signIn.email + constructor(error: { + code?: string | undefined + message?: string | undefined + status: number + statusText: string + }) { + super(`${error.message ?? "BetterAuthError"}: ${error.status} ${error.statusText}`) + } +} + +type BetterAuthErrorTypes = Partial> + +export const AuthErrorCode = { + INVALID_EMAIL: "INVALID_EMAIL", + INVALID_PASSWORD: "INVALID_PASSWORD", + INVALID_EMAIL_OR_PASSWORD: "INVALID_EMAIL_OR_PASSWORD", +} satisfies BetterAuthErrorTypes diff --git a/apps/freya-client/src/components/ui/button.showcase.tsx b/apps/freya-client/src/components/ui/button.showcase.tsx index 6768385..4d14395 100644 --- a/apps/freya-client/src/components/ui/button.showcase.tsx +++ b/apps/freya-client/src/components/ui/button.showcase.tsx @@ -8,25 +8,28 @@ function ButtonShowcase() { return (
-
-
-
-
) diff --git a/apps/freya-client/src/components/ui/button.tsx b/apps/freya-client/src/components/ui/button.tsx index 85b1f5e..cd85142 100644 --- a/apps/freya-client/src/components/ui/button.tsx +++ b/apps/freya-client/src/components/ui/button.tsx @@ -1,51 +1,214 @@ import Feather from "@expo/vector-icons/Feather" -import { type PressableProps, Pressable, type StyleProp, View, type ViewStyle } from "react-native" +import { createContext, useContext } from "react" +import { + type PressableProps, + Pressable, + type TextStyle, + useColorScheme, + ActivityIndicator, +} from "react-native" import tw from "twrnc" +import { rva, type RvaProps } from "@/lib/rva" + import { SansSerifText } from "./sans-serif-text" type FeatherIconName = React.ComponentProps["name"] +const button = rva( + tw.style("rounded-2xl px-4 py-3 w-fit flex-row items-center justify-center gap-1.5 h-10", { + borderCurve: "continuous", + }), + { + variants: { + intent: { + primary: tw`bg-teal-600`, + secondary: tw`bg-stone-100 dark:bg-stone-800`, + }, + pressed: { + true: tw`translate-y-px`, + false: null, + }, + enabled: { + true: null, + false: tw`opacity-50`, + }, + dark: { + true: null, + false: null, + }, + }, + defaultVariants: { + intent: "primary", + pressed: false, + enabled: true, + dark: false, + }, + compoundVariants: [ + // primary variants + { + intent: "primary", + enabled: true, + pressed: false, + style: { + boxShadow: + "inset 0 1px 0 0 #2dd4bf66, inset 0 -1px 0 0 #0f766eb3, 0 2px 4px 0 #0000001a, 0 0 0 1px #0f766e", + }, + }, + { + intent: "primary", + enabled: true, + pressed: true, + style: tw.style("bg-teal-700", { + boxShadow: + "inset 0 1px 2px 0 #042f2e80, inset 0 0 0 1px #0f766e, inset 0 -1px 0 0 #2dd4bf26", + }), + }, + + // secondary variants + { + intent: "secondary", + dark: false, + enabled: true, + pressed: false, + style: { + boxShadow: "inset 0 1px 0 0 #fdfdfd, 0 2px 4px 0 #0000001a, 0 0 0 1px #00000029", + }, + }, + { + intent: "secondary", + dark: false, + enabled: true, + pressed: true, + style: tw.style("bg-stone-200", { + boxShadow: + "inset 0 1px 2px 0 #0000001f, inset 0 0 0 1px #00000012, inset 0 -1px 0 0 #ffffff80", + }), + }, + + { + intent: "secondary", + dark: true, + enabled: true, + pressed: false, + style: tw.style("bg-stone-800", { + boxShadow: + "inset 0 1px 0 0 #4b4951, inset 0 -1px 0 0 #313036, 0 2px 4px 0 #0000001a, 0 0 0 1px #0d0d0d", + }), + }, + { + intent: "secondary", + dark: true, + enabled: true, + pressed: true, + style: tw.style("bg-stone-900", { + boxShadow: + "inset 0 1px 2px 0 #00000080, inset 0 0 0 1px #00000066, inset 0 -1px 0 0 #ffffff0a", + }), + }, + ], + }, +) + +const label = rva( + {}, + { + variants: { + intent: { + primary: tw`text-stone-100 dark:text-stone-200 font-medium`, + secondary: tw`text-stone-800 dark:text-stone-200 font-medium`, + }, + }, + }, +) + +type ButtonVariants = Omit, "dark" | "pressed"> +type ButtonProps = PressableProps & ButtonVariants + +interface ButtonContext extends ButtonVariants {} + +const Context = createContext({}) + +export function Button({ style, intent = "primary", enabled = true, ...props }: ButtonProps) { + const theme = useColorScheme() + + return ( + + [ + button({ + intent, + enabled, + pressed: state.pressed, + dark: theme === "dark", + }), + typeof style === "function" ? style(state) : style, + ]} + {...props} + /> + + ) +} + type ButtonIconProps = { name: FeatherIconName } function ButtonIcon({ name }: ButtonIconProps) { - return + const context = useContext(Context) + + let color: string + switch (context.intent) { + case "primary": + color = tw.color("text-stone-100 dark:text-stone-200") ?? "" + break + case "secondary": + color = tw.color("text-stone-800 dark:text-stone-200") ?? "" + break + default: + color = "" + break + } + + return } -type ButtonProps = Omit & { - label?: string - leadingIcon?: React.ReactNode - style?: StyleProp - trailingIcon?: React.ReactNode -} - -export function Button({ style, label, leadingIcon, trailingIcon, ...props }: ButtonProps) { - const hasIcons = leadingIcon != null || trailingIcon != null - - const textElement = label ? ( - - {label} - - ) : null +type ButtonLabelProps = React.ComponentProps +function ButtonLabel({ style, ...props }: ButtonLabelProps) { + const context = useContext(Context) return ( - - {hasIcons ? ( - - {leadingIcon} - {textElement} - {trailingIcon} - - ) : ( - textElement - )} - + /> ) } +function ButtonLoadingIndicator() { + const context = useContext(Context) + + let color: string + switch (context.intent) { + case "primary": + color = tw.color("text-stone-100 dark:text-stone-200") ?? "" + break + case "secondary": + color = tw.color("text-stone-800 dark:text-stone-200") ?? "" + break + default: + color = "" + break + } + + return +} + Button.Icon = ButtonIcon +Button.Label = ButtonLabel +Button.Loading = ButtonLoadingIndicator diff --git a/apps/freya-client/src/components/ui/feed-card.showcase.tsx b/apps/freya-client/src/components/ui/feed-card.showcase.tsx index dd7f400..47ef1e8 100644 --- a/apps/freya-client/src/components/ui/feed-card.showcase.tsx +++ b/apps/freya-client/src/components/ui/feed-card.showcase.tsx @@ -19,7 +19,9 @@ function FeedCardShowcase() { Title Body text inside a feed card. -
diff --git a/apps/freya-client/src/components/ui/text-input.tsx b/apps/freya-client/src/components/ui/text-input.tsx new file mode 100644 index 0000000..97554b8 --- /dev/null +++ b/apps/freya-client/src/components/ui/text-input.tsx @@ -0,0 +1,6 @@ +import { TextInput as NativeTextInput, type TextInputProps } from "react-native" +import tw from "twrnc" + +export function TextInput({ style, ...props }: TextInputProps) { + return +} diff --git a/apps/freya-client/src/conversations/chat-overlay.tsx b/apps/freya-client/src/conversations/chat-overlay.tsx new file mode 100644 index 0000000..f4aadbe --- /dev/null +++ b/apps/freya-client/src/conversations/chat-overlay.tsx @@ -0,0 +1,284 @@ +/* eslint-disable react-hooks/immutability */ +import MaskedView from "@react-native-masked-view/masked-view" +import { BlurView } from "expo-blur" +import { LinearGradient } from "expo-linear-gradient" +import { atom, useAtomValue, useSetAtom, useStore } from "jotai" +import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react" +import { useColorScheme, View, StyleSheet, Platform, Dimensions } from "react-native" +import { easeGradient } from "react-native-easing-gradient" +import { useKeyboardHandler } from "react-native-keyboard-controller" +import Animated, { FadeIn, FadeOut, useSharedValue, withSpring } from "react-native-reanimated" +import { useSafeAreaInsets } from "react-native-safe-area-context" +import tw from "twrnc" + +import { Button } from "@/components/ui/button" +import { TextInput } from "@/components/ui/text-input" + +import { ConversationList } from "./conversation-list" + +interface BottomProgressiveBlurRef { + setBlurHeight: (height: number) => void +} + +interface ConversationListContainerRef { + showFullChat: () => void +} + +const ChatViewMode = { + Hidden: "hidden", + Peek: "peek", + FullChat: "full-chat", +} as const +type ChatViewMode = (typeof ChatViewMode)[keyof typeof ChatViewMode] + +const chatInputHeightAtom = atom(0) +const isChatInputFocusedAtom = atom(false) +const chatViewModeAtom = atom(ChatViewMode.Hidden) + +export function ChatOverlay() { + const theme = useColorScheme() + const setChatInputHeight = useSetAtom(chatInputHeightAtom) + const setIsChatInputFocused = useSetAtom(isChatInputFocusedAtom) + const setChatViewMode = useSetAtom(chatViewModeAtom) + const store = useStore() + + const conversationListContainerRef = useRef(null) + + const onTextInputFocus = () => { + setChatViewMode(ChatViewMode.Peek) + } + + const onConversationListScroll = () => { + if (store.get(chatViewModeAtom) !== ChatViewMode.FullChat) { + setChatViewMode(ChatViewMode.FullChat) + conversationListContainerRef?.current?.showFullChat() + } + } + + return ( + + + + + + + + + { + setChatInputHeight(layout.height) + }} + intensity={35} + tint={theme === "dark" ? "systemThickMaterialDark" : "systemThickMaterialLight"} + style={tw`flex flex-row w-full py-1 pl-4 pr-1 border border-stone-300 dark:border-stone-700 rounded-full overflow-hidden`} + > + { + setIsChatInputFocused(false) + }} + style={tw`flex-1`} + placeholder="Message Freya..." + /> + + + + + ) +} + +function ChatOverlayContainer({ children }: React.PropsWithChildren) { + const bottom = useSharedValue(0) + + useKeyboardHandler({ + onMove: (event) => { + "worklet" + bottom.value = event.height + }, + }) + + return ( + + {children} + + ) +} + +function ConversationListContainer({ + ref, + children, +}: React.PropsWithChildren<{ ref?: React.Ref }>) { + const chatViewMode = useAtomValue(chatViewModeAtom) + const height = useSharedValue(Dimensions.get("window").height * 0.4) + + const { colors, locations } = useMemo( + () => + easeGradient({ + colorStops: { + 0: { color: "transparent" }, + 0.1: { color: "transparent" }, + 0.3: { color: tw.color("bg-stone-100 dark:bg-stone-950")! }, + 0.9: { color: tw.color("bg-stone-100 dark:bg-stone-950")! }, + 1: { color: tw.color("bg-stone-100 dark:bg-stone-950")! }, + }, + }), + [], + ) + + const showFullChat = useCallback(() => { + height.value = withSpring(Dimensions.get("window").height + 80) + }, [height]) + + useImperativeHandle( + ref, + () => ({ + showFullChat, + }), + [showFullChat], + ) + + return ( + + + + + } + style={tw`size-full`} + > + + {children} + + + + ) +} + +function OverlayBackdrop() { + const chatViewMode = useAtomValue(chatViewModeAtom) + const bottomProgressiveBlurRef = useRef(null) + + useEffect(() => { + if (chatViewMode === ChatViewMode.Peek) { + bottomProgressiveBlurRef?.current?.setBlurHeight(Dimensions.get("window").height * 0.75) + } + }, [chatViewMode]) + + if (chatViewMode === ChatViewMode.FullChat) { + return + } + return +} + +function BottomProgressiveBlur({ ref }: { ref?: React.Ref }) { + const progressiveBlurHeight = useSharedValue(192) + const colorScheme = useColorScheme() + + const { colors, locations } = useMemo( + () => + easeGradient({ + colorStops: { + 0: { color: "transparent" }, + 0.7: { color: tw.color("bg-stone-100 dark:bg-stone-950")! }, + 1: { color: tw.color("bg-stone-100 dark:bg-stone-950")! }, + }, + }), + [], + ) + + const setBlurHeight = useCallback( + (height: number) => { + progressiveBlurHeight.value = withSpring(height) + }, + [progressiveBlurHeight], + ) + + useImperativeHandle(ref, () => ({ setBlurHeight }), [setBlurHeight]) + + return ( + + + } + style={[StyleSheet.absoluteFill, tw`z-[1]`]} + > + + + + ) +} + +function BlurBackground() { + const colorScheme = useColorScheme() + return ( + + + + ) +} + +function ChatInputContainer({ children }: React.PropsWithChildren) { + const keyboardHeight = useSharedValue(0) + const insets = useSafeAreaInsets() + + useKeyboardHandler({ + onMove: (event) => { + "worklet" + keyboardHeight.value = Math.max(event.height - insets.bottom + 8, 0) + }, + }) + + return {children} +} + +function ConversationListHeader() { + const safeAreaInsets = useSafeAreaInsets() + return +} + +function ConversationListFooter() { + const chatInputHeight = useAtomValue(chatInputHeightAtom) + const safeAreaInsets = useSafeAreaInsets() + return +} diff --git a/apps/freya-client/src/conversations/conversation-list.tsx b/apps/freya-client/src/conversations/conversation-list.tsx new file mode 100644 index 0000000..1efa463 --- /dev/null +++ b/apps/freya-client/src/conversations/conversation-list.tsx @@ -0,0 +1,183 @@ +import { AssistantMessagePayload, UserMessagePayload } from "@freya/core" +import { FlashList } from "@shopify/flash-list" +import { useQuery } from "@tanstack/react-query" +import { View, ViewStyle } from "react-native" +import tw from "twrnc" + +import { SansSerifText } from "@/components/ui/sans-serif-text" + +import { ConversationEntry } from "./conversations" +import { useListConversationEntriesQuery, useDefaultConversationQuery } from "./queries" + +type ConversationListProps = Omit< + React.ComponentProps, + "data" | "keyExtractor" | "renderItem" | "maintainVisibleContentPosition" +> + +type PositionInGroup = "single" | "first" | "in-between" | "last" + +const messageBubbleRadius = 18 +const groupedMessageBubbleRadius = 6 + +export function ConversationList({ + ListFooterComponent, + ListHeaderComponent, + onScrollBeginDrag, +}: ConversationListProps) { + const { data: conversation } = useQuery(useDefaultConversationQuery()) + const { data: entries } = useQuery(useListConversationEntriesQuery(conversation?.id)) + const conversationEntries = entries ?? [] + + return ( + item.id} + renderItem={({ item, index }) => { + const previousEntryIsSameKind = conversationEntries[index - 1]?.kind === item.kind + const nextEntryIsSameKind = conversationEntries[index + 1]?.kind === item.kind + + let position: PositionInGroup + if (!previousEntryIsSameKind && !nextEntryIsSameKind) { + position = "single" + } else if (!previousEntryIsSameKind) { + position = "first" + } else if (!nextEntryIsSameKind) { + position = "last" + } else { + position = "in-between" + } + + return + }} + onScrollBeginDrag={onScrollBeginDrag} + ListHeaderComponent={ListHeaderComponent} + ListFooterComponent={ListFooterComponent} + /> + ) +} + +function MessageBubble({ + entry, + position, +}: { + entry: typeof ConversationEntry.infer + position: PositionInGroup +}) { + if (entry.kind === "user_message") { + const payload = UserMessagePayload.assert(entry.payload) + return + } + if (entry.kind === "assistant_message") { + const payload = AssistantMessagePayload.assert(entry.payload) + return + } + return null +} + +function UserMessageBubble({ + payload, + position, +}: { + payload: UserMessagePayload + position: PositionInGroup +}) { + const content = payload.parts.reduce((final, part) => { + if (part.type === "text") { + return final + part.text + } + return final + }, "") + + let corners: ViewStyle + switch (position) { + case "single": + case "first": + corners = { + borderRadius: messageBubbleRadius, + borderBottomRightRadius: groupedMessageBubbleRadius, + } + break + case "in-between": + corners = { + borderRadius: messageBubbleRadius, + borderTopRightRadius: groupedMessageBubbleRadius, + borderBottomRightRadius: groupedMessageBubbleRadius, + } + break + case "last": + corners = { + borderRadius: messageBubbleRadius, + borderTopRightRadius: groupedMessageBubbleRadius, + } + break + } + + return ( + + + {content} + + + ) +} + +function AssistantMessageBubble({ + payload, + position, +}: { + payload: AssistantMessagePayload + position: PositionInGroup +}) { + const content = payload.parts.reduce((final, part) => { + if (part.type === "text") { + return final + part.text + } + return final + }, "") + + let corners: ViewStyle + switch (position) { + case "single": + case "first": + corners = { + borderRadius: messageBubbleRadius, + borderBottomLeftRadius: groupedMessageBubbleRadius, + } + break + case "in-between": + corners = { + borderRadius: messageBubbleRadius, + borderTopLeftRadius: groupedMessageBubbleRadius, + borderBottomLeftRadius: groupedMessageBubbleRadius, + } + break + case "last": + corners = { + borderRadius: messageBubbleRadius, + borderTopLeftRadius: groupedMessageBubbleRadius, + } + break + } + + return ( + + + {content} + + + ) +} diff --git a/apps/freya-client/src/conversations/conversation-view.tsx b/apps/freya-client/src/conversations/conversation-view.tsx deleted file mode 100644 index cbbfbfd..0000000 --- a/apps/freya-client/src/conversations/conversation-view.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { FlashList } from "@shopify/flash-list"; -import { useQuery } from "@tanstack/react-query"; - -import { - useListConversationEntriesQuery, - useDefaultConversationQuery, - useListConversationsQuery, -} from "./queries"; - -export function ConversationView() { - const { data: conversation } = useQuery(useDefaultConversationQuery()); - const { data: entries } = useQuery( - useListConversationEntriesQuery(conversation?.id), - ); - - return ( - item.id} - renderItem={({ item }) =>
{item.kind}
} - /> - ); -} diff --git a/apps/freya-client/src/conversations/conversations.ts b/apps/freya-client/src/conversations/conversations.ts index 21c8929..2c17f2a 100644 --- a/apps/freya-client/src/conversations/conversations.ts +++ b/apps/freya-client/src/conversations/conversations.ts @@ -5,6 +5,12 @@ import { } from "@freya/core" import { type } from "arktype" +export const Conversation = type({ + id: "string.uuid", + createdAt: "string.date.iso", + updatedAt: "string.date.iso", +}) + export const ConversationEntry = type({ id: "string.uuid", sequence: "number", diff --git a/apps/freya-client/src/conversations/queries.ts b/apps/freya-client/src/conversations/queries.ts index f8d3195..e140a1e 100644 --- a/apps/freya-client/src/conversations/queries.ts +++ b/apps/freya-client/src/conversations/queries.ts @@ -3,9 +3,13 @@ import { type } from "arktype" import { useApiClient } from "@/api/client" -import { ConversationEntry } from "./conversations" +import { Conversation, ConversationEntry } from "./conversations" -const ConversationQueryResponse = type({ +const ListConversationsResponse = type({ + conversations: Conversation.array(), +}) + +const ConversationEntriesResponse = type({ entries: ConversationEntry.array(), }) @@ -16,14 +20,16 @@ export function useListConversationsQuery() { queryFn: async () => api .request("/conversations", { method: "GET" }) - .then(([, json]) => ConversationQueryResponse.assert(json)), + .then(([, json]) => ListConversationsResponse.assert(json)), }) } export function useDefaultConversationQuery() { return queryOptions({ ...useListConversationsQuery(), - select: (data) => (data.entries.length === 0 ? null : data.entries[0]), + select: (data) => { + return data.conversations.length === 0 ? null : data.conversations[0] + }, }) } @@ -35,7 +41,7 @@ export function useListConversationEntriesQuery(id?: string) { ? async () => api .request(`/conversations/${id}/entries`, { method: "GET" }) - .then(([, json]) => ConversationQueryResponse.assert(json).entries) + .then(([, json]) => ConversationEntriesResponse.assert(json).entries) : skipToken, }) } diff --git a/apps/freya-client/src/json-render/catalog.ts b/apps/freya-client/src/json-render/catalog.ts index cd8aace..f041d0e 100644 --- a/apps/freya-client/src/json-render/catalog.ts +++ b/apps/freya-client/src/json-render/catalog.ts @@ -15,15 +15,29 @@ export const catalog = defineCatalog(schema, { }, Button: { props: z.object({ - label: z.string(), - leadingIcon: z.string().nullable(), - trailingIcon: z.string().nullable(), + intent: z.enum(["primary", "secondary"]).nullable(), }), events: ["press"], - slots: [], + slots: ["default"], description: - "Pressable button with a label and optional Feather icons. Icon values are Feather icon names (e.g. 'plus', 'arrow-right'). Bind on.press to trigger an action.", - example: { label: "Add item", leadingIcon: "plus", trailingIcon: null }, + "Pressable button. Add ButtonLabel and optional ButtonIcon children in the default slot. Bind on.press to trigger an action.", + example: { intent: "primary" }, + }, + ButtonIcon: { + props: z.object({ + name: z.string(), + }), + slots: [], + description: "Feather icon for use inside a Button.", + example: { name: "plus" }, + }, + ButtonLabel: { + props: z.object({ + text: z.string(), + }), + slots: [], + description: "Text label for use inside a Button.", + example: { text: "Add item" }, }, FeedCard: { props: z.object({ diff --git a/apps/freya-client/src/json-render/registry.tsx b/apps/freya-client/src/json-render/registry.tsx index d34f6de..02e96f4 100644 --- a/apps/freya-client/src/json-render/registry.tsx +++ b/apps/freya-client/src/json-render/registry.tsx @@ -17,20 +17,13 @@ export const { registry } = defineRegistry(catalog, { View: ({ props, children }) => ( {children} ), - Button: ({ props, emit }) => ( - ), + ButtonIcon: ({ props }) => , + ButtonLabel: ({ props }) => {props.text}, FeedCard: ({ props, children }) => ( {children} ), diff --git a/apps/freya-client/src/lib/rva.ts b/apps/freya-client/src/lib/rva.ts new file mode 100644 index 0000000..3532d73 --- /dev/null +++ b/apps/freya-client/src/lib/rva.ts @@ -0,0 +1,215 @@ +import type { StyleProp, ViewStyle } from "react-native" + +type RvaNoInfer = [TValue][TValue extends unknown ? 0 : never] +type VariantValue = string | number | boolean | null | undefined +type VariantOptionKey = Extract +type BooleanVariantInput = + Extract, "true" | "false"> extends never ? never : boolean +type StringVariantInput = Exclude, "true" | "false"> +type VariantInput = + | StringVariantInput + | BooleanVariantInput + | null + | undefined +type CompoundCondition> = { + name: keyof TVariants + value: string +} +type NormalizedCompoundVariant> = { + conditions: CompoundCondition[] + style: StyleProp +} + +export type RvaVariants = { + readonly [name: string]: { + readonly [value: string]: StyleProp + } +} + +export type RvaVariantProps> = { + readonly [TName in keyof TVariants]?: VariantInput +} +type MutableRvaVariantProps> = { + -readonly [TName in keyof TVariants]?: VariantInput +} + +export type RvaCompoundVariant< + TStyle, + TVariants extends RvaVariants, +> = RvaVariantProps & { + readonly style: StyleProp +} + +export type RvaConfig> = { + readonly variants?: TVariants + readonly defaultVariants?: RvaVariantProps + readonly compoundVariants?: readonly RvaCompoundVariant[] +} + +export type RvaResolver> = ( + props?: RvaVariantProps, +) => StyleProp + +export type RvaProps = TResolver extends (props?: infer TProps) => unknown + ? NonNullable + : never + +export function rva(): < + const TVariants extends RvaVariants = RvaVariants, +>( + base: StyleProp>, + config?: RvaConfig, TVariants>, +) => RvaResolver +export function rva< + TStyle = ViewStyle, + const TVariants extends RvaVariants = RvaVariants, +>( + base: StyleProp>, + config?: RvaConfig, TVariants>, +): RvaResolver +export function rva< + TStyle = ViewStyle, + const TVariants extends RvaVariants = RvaVariants, +>( + base?: StyleProp, + config?: RvaConfig, +): + | RvaResolver + | ( = RvaVariants>( + base: StyleProp, + config?: RvaConfig, + ) => RvaResolver) { + if (base === undefined && config === undefined) { + return function createTypedRva< + const TTypedVariants extends RvaVariants = RvaVariants, + >(typedBase: StyleProp, typedConfig: RvaConfig = {}) { + return createRva(typedBase, typedConfig) + } + } + + return createRva(base, config ?? {}) +} + +function createRva = RvaVariants>( + base: StyleProp, + config: RvaConfig, +): RvaResolver { + const compoundVariants = normalizeCompoundVariants(config.compoundVariants) + + return function resolveRva(props: RvaVariantProps = {}) { + const merged = mergeVariantProps(config.defaultVariants, props) + const styles: StyleProp[] = [base] + const variants = config.variants + + if (variants !== undefined) { + for (const name in variants) { + const value = normalizeVariantValue(merged[name]) + + if (value === undefined) { + continue + } + + const style = variants[name]?.[value] + + if (style !== undefined) { + styles.push(style) + } + } + } + + for (const compound of compoundVariants) { + if (compoundMatches(compound, merged)) { + styles.push(compound.style) + } + } + + return styles + } +} + +function mergeVariantProps>( + defaultVariants: RvaVariantProps | undefined, + props: RvaVariantProps, +): RvaVariantProps { + const merged: MutableRvaVariantProps = {} + + if (defaultVariants !== undefined) { + for (const name of Object.keys(defaultVariants) as (keyof TVariants)[]) { + const value = defaultVariants[name] + + if (value !== undefined) { + merged[name] = value + } + } + } + + for (const name of Object.keys(props) as (keyof TVariants)[]) { + const value = props[name] + + if (value !== undefined) { + merged[name] = value + } + } + + return merged +} + +function normalizeCompoundVariants>( + compoundVariants: readonly RvaCompoundVariant[] | undefined, +) { + const normalized: NormalizedCompoundVariant[] = [] + + if (compoundVariants === undefined) { + return normalized + } + + for (const compound of compoundVariants) { + const conditions: CompoundCondition[] = [] + + for (const name in compound) { + if (name === "style") { + continue + } + + const variantName = name as keyof TVariants + const value = normalizeVariantValue(compound[variantName]) + + if (value !== undefined) { + conditions.push({ name: variantName, value }) + } + } + + normalized.push({ conditions, style: compound.style }) + } + + return normalized +} + +function compoundMatches>( + compound: NormalizedCompoundVariant, + props: RvaVariantProps, +) { + for (const condition of compound.conditions) { + if (normalizeVariantValue(props[condition.name]) !== condition.value) { + return false + } + } + + return true +} + +function normalizeVariantValue(value: VariantValue) { + if (value === null || value === undefined) { + return undefined + } + + if (value === true) { + return "true" + } + + if (value === false) { + return "false" + } + + return String(value) +} diff --git a/bun.lock b/bun.lock index ff08d6f..e772f8c 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,8 @@ "name": "@freya/backend", "version": "0.0.0", "dependencies": { + "@better-auth/core": "^1.6.20", + "@better-auth/expo": "^1.6.20", "@earendil-works/pi-coding-agent": "^0.79.1", "@freya/agent-protocol": "workspace:*", "@freya/core": "workspace:*", @@ -72,7 +74,7 @@ "@nym.sh/jrpc": "^0.1.0", "@openrouter/sdk": "^0.9.11", "arktype": "^2.1.29", - "better-auth": "^1", + "better-auth": "^1.6.20", "drizzle-orm": "^0.45.1", "hono": "^4", "lodash.merge": "^4.6.2", @@ -87,14 +89,19 @@ "name": "freya-client", "version": "1.0.0", "dependencies": { + "@better-auth/core": "^1.6.20", + "@better-auth/expo": "^1.6.20", "@expo-google-fonts/inter": "^0.4.2", "@expo-google-fonts/source-serif-4": "^0.4.1", "@expo/vector-icons": "^15.0.3", "@freya/core": "workspace:*", "@json-render/react-native": "^0.13.0", + "@react-native-masked-view/masked-view": "0.3.2", "@shopify/flash-list": "2.0.2", "@tanstack/react-query": "^5.90.21", "arktype": "^2.2.1", + "better-auth": "^1.6.20", + "class-variance-authority": "^0.7.1", "expo": "^56.0.0", "expo-blur": "~56.0.3", "expo-constants": "~56.0.18", @@ -103,18 +110,24 @@ "expo-glass-effect": "~0.1.10", "expo-haptics": "~56.0.3", "expo-image": "~56.0.11", + "expo-linear-gradient": "~56.0.4", "expo-linking": "~56.0.14", "expo-location": "~56.0.18", + "expo-network": "~56.0.5", "expo-router": "~56.2.11", + "expo-secure-store": "~56.0.4", "expo-splash-screen": "~56.0.10", "expo-status-bar": "~56.0.4", "expo-symbols": "~56.0.6", "expo-system-ui": "~56.0.5", "expo-web-browser": "~56.0.5", + "jotai": "^2.20.1", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.85.3", + "react-native-easing-gradient": "^1.1.1", "react-native-gesture-handler": "~2.31.1", + "react-native-keyboard-controller": "1.21.6", "react-native-reanimated": "4.3.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", @@ -282,6 +295,9 @@ }, }, }, + "patchedDependencies": { + "@ark/schema@0.56.0": "patches/@ark%2Fschema@0.56.0.patch", + }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], @@ -487,23 +503,25 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@better-auth/core": ["@better-auth/core@1.5.4", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-k5AdwPRQETZn0vdB60EB9CDxxfllpJXKqVxTjyXIUSRz7delNGlU0cR/iRP3VfVJwvYR1NbekphBDNo+KGoEzQ=="], + "@better-auth/core": ["@better-auth/core@1.6.20", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.6", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-y73I1xNXuNYiHBFduWGRcJ2ro2rNuVDEYkgVMJtIaRXtbosdXHs9gfyQrHecgeHMHKx1SYSBT/CExak0vVMTng=="], - "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.5.4", "", { "peerDependencies": { "@better-auth/core": "1.5.4", "@better-auth/utils": "^0.3.0", "drizzle-orm": ">=0.41.0" } }, "sha512-4M4nMAWrDd3TmpV6dONkJjybBVKRZghe5Oj0NNyDEoXubxastQdO7Sb5B54I1rTx5yoMgsqaB+kbJnu/9UgjQg=="], + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.20", "", { "peerDependencies": { "@better-auth/core": "^1.6.20", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-hJHfCdAiZrC7EmZAt3NAiGgcNo9Y5Qz3PLL+a9rODXaAJGCMvzUJniqef9wHuJBwU0SWW+2f4wXe8xQmaC/IKQ=="], - "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.5.4", "", { "peerDependencies": { "@better-auth/core": "1.5.4", "@better-auth/utils": "^0.3.0", "kysely": "^0.27.0 || ^0.28.0" } }, "sha512-DPww7rIfz6Ed7dZlJSW9xMQ42VKaJLB5Cs+pPqd+UHKRyighKjf3VgvMIcAdFPc4olQ0qRHo3+ZJhFlBCxRhxA=="], + "@better-auth/expo": ["@better-auth/expo@1.6.20", "", { "dependencies": { "@better-fetch/fetch": "1.3.1", "better-call": "1.3.6", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.20", "better-auth": "^1.6.20", "expo-constants": ">=17.0.0", "expo-linking": ">=7.0.0", "expo-network": ">=8.0.7", "expo-web-browser": ">=14.0.0" }, "optionalPeers": ["expo-constants", "expo-linking", "expo-network", "expo-web-browser"] }, "sha512-ngpD3ov51mcADMGqxVYu74mZLrjef72hFEcgpRNtJWc7uznrDn43LgiFRyrZWG8NUp3HaIK21D64/CVFvlVaKQ=="], - "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.5.4", "", { "peerDependencies": { "@better-auth/core": "1.5.4", "@better-auth/utils": "^0.3.0" } }, "sha512-iiWYut9rbQqiAsgRBtj6+nxanwjapxRgpIJbiS2o81h7b9iclE0AiDA0Foes590gdFQvskNauZcCpuF8ytxthg=="], + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.20", "", { "peerDependencies": { "@better-auth/core": "^1.6.20", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-Uvpmgbx5y8JqXroVanNzDdKzOl3HojoTz+/X6MR6zOUr25IzlYz660mjnu0rxKiIF55kD3CroqFsDzjNUw7ERw=="], - "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.5.4", "", { "peerDependencies": { "@better-auth/core": "1.5.4", "@better-auth/utils": "^0.3.0", "mongodb": "^6.0.0 || ^7.0.0" } }, "sha512-ArzJN5Obk6i6+vLK1HpPzLIcsjxZYXPPUvxVU8eyU5HyoUT2MlswWfPQ8UJAKPn0iq/T4PVp/wZcQMhWk1tuNA=="], + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.20", "", { "peerDependencies": { "@better-auth/core": "^1.6.20", "@better-auth/utils": "0.4.2" } }, "sha512-J5Ni0LlFijbzXlwu2rFHaD8zEFocmajyzWkRnHsq8LhV/Dk4iWQwwnqzLrPoDQEj8roECAUF03hrIeMzqWRqJQ=="], - "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.5.4", "", { "peerDependencies": { "@better-auth/core": "1.5.4", "@better-auth/utils": "^0.3.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-ZQTbcBopw/ezjjbNFsfR3CRp0QciC4tJCarAnB5G9fZtUYbDjfY0vZOxIRmU4kI3x755CXQpGqTrkwmXaMRa3w=="], + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.20", "", { "peerDependencies": { "@better-auth/core": "^1.6.20", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-ClDBJf6h4g85WJswxwQwxLaiyRU67Gmz/uaIf19tY1gqlLJDykSGjmqRNSBMG5rWABNzcNqbO4KG31rYUldbIw=="], - "@better-auth/telemetry": ["@better-auth/telemetry@1.5.4", "", { "dependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.5.4" } }, "sha512-mGXTY7Ecxo7uvlMr6TFCBUvlH0NUMOeE9LKgPhG4HyhBN6VfCEg/DD9PG0Z2IatmMWQbckkt7ox5A0eBpG9m5w=="], + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.20", "", { "peerDependencies": { "@better-auth/core": "^1.6.20", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-WhYdhSGuVSfu1peCSf2snmmVzfWjRaEvbSrsNCusiwGE9l94HlES4mjSPM48fed24hL7yg4j1dYK/yjEt87FpQ=="], - "@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="], + "@better-auth/telemetry": ["@better-auth/telemetry@1.6.20", "", { "peerDependencies": { "@better-auth/core": "^1.6.20", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1" } }, "sha512-3BhbY3naQDERvdJvJ7fGszVY6rpsVfc6c9uyBVZlC1coVEF/rkM0rIcjtMVI1GUH7vWy1wjR6qF5vQnMun3XNQ=="], - "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], + "@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], + + "@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="], "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@10.5.0", "", { "dependencies": { "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw=="], @@ -855,6 +873,8 @@ "@openrouter/sdk": ["@openrouter/sdk@0.9.11", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-BgFu6NcIJO4a9aVjr04y3kZ8pyM71j15I+bzfVAGEvxnj+KQNIkBYQGgwrG3D+aT1QpDKLki8btcQmpaxUas6A=="], + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], + "@oxfmt/darwin-arm64": ["@oxfmt/darwin-arm64@0.24.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aYXuGf/yq8nsyEcHindGhiz9I+GEqLkVq8CfPbd+6VE259CpPEH+CaGHEO1j6vIOmNr8KHRq+IAjeRO2uJpb8A=="], "@oxfmt/darwin-x64": ["@oxfmt/darwin-x64@0.24.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vs3b8Bs53hbiNvcNeBilzE/+IhDTWKjSBB3v/ztr664nZk65j0xr+5IHMBNz3CFppmX7o/aBta2PxY+t+4KoPg=="], @@ -1715,9 +1735,9 @@ "basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="], - "better-auth": ["better-auth@1.5.4", "", { "dependencies": { "@better-auth/core": "1.5.4", "@better-auth/drizzle-adapter": "1.5.4", "@better-auth/kysely-adapter": "1.5.4", "@better-auth/memory-adapter": "1.5.4", "@better-auth/mongo-adapter": "1.5.4", "@better-auth/prisma-adapter": "1.5.4", "@better-auth/telemetry": "1.5.4", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.2", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.11", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-ReykcEKx6Kp9560jG1wtlDBnftA7L7xb3ZZdDWm5yGXKKe2pUf+oBjH0fqekrkRII0m4XBVQbQ0mOrFv+3FdYg=="], + "better-auth": ["better-auth@1.6.20", "", { "dependencies": { "@better-auth/core": "1.6.20", "@better-auth/drizzle-adapter": "1.6.20", "@better-auth/kysely-adapter": "1.6.20", "@better-auth/memory-adapter": "1.6.20", "@better-auth/mongo-adapter": "1.6.20", "@better-auth/prisma-adapter": "1.6.20", "@better-auth/telemetry": "1.6.20", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.6", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-fSpGHGRKiGRiYVd3QTQtuVZ8oxpiSe/7ip0Rpvt/Sy8zQbEbVKUPMOhE0gLXg+FjqTUsIo7582hxUYxtEcqUpA=="], - "better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="], + "better-call": ["better-call@1.3.6", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-no1jI+h6Bkxs1NVBo4rONbVIzsPjZ8IUu7IHaJBiFwVX1XEQGN8KpHots5fSWmXe9nNyLuLIcgx6WEUcE6EDaA=="], "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], @@ -2089,6 +2109,8 @@ "expo-keep-awake": ["expo-keep-awake@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA=="], + "expo-linear-gradient": ["expo-linear-gradient@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-KUp1dNSRtuMyiExhf6FJf5YUtmw2cRaPytl10HQi7isj5Yac38udmD55T2tglNYTZlvgT5+oflpyFoH15hmOcw=="], + "expo-linking": ["expo-linking@56.0.14", "", { "dependencies": { "expo-constants": "~56.0.18", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-IvVQHWC+Cj4fK5qD3iEVYqpU2a4rLW0IpAAlGJ4MH+H1fyZiHh3eN6qg2WmoclOEPfYATSuEa+dQT6wfgVpXlQ=="], "expo-location": ["expo-location@56.0.18", "", { "dependencies": { "@expo/image-utils": "^0.10.1" }, "peerDependencies": { "expo": "*" } }, "sha512-6xP0UwGy8a7EEHAMeigYAp3HNo3yWHAg05tVPUfwrOWepWPpFXmjsfUBUxQdkpfpjddJ9r+f4PplxZqKI0LtjA=="], @@ -2101,8 +2123,12 @@ "expo-modules-jsi": ["expo-modules-jsi@56.0.10", "", { "peerDependencies": { "react-native": "*" } }, "sha512-fHZcFpYO/o62GYa6fJyAQJZcAShzhoN0iMMDzbr7vD3ewET6e1vAlTonbEakN9F0VHEgBFJ4NREy87uwVcpCuA=="], + "expo-network": ["expo-network@56.0.5", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-zmuyO95jayDY9jyUfOAlNp9XXJrJaAOkBXXLy0TS/nh2kppj7CHirRPkQ/tf0rsxhIL3AEd9nsRTiPtNsGT9Lw=="], + "expo-router": ["expo-router@56.2.11", "", { "dependencies": { "@expo/log-box": "^56.0.13", "@expo/metro-runtime": "^56.0.15", "@expo/schema-utils": "^56.0.0", "@expo/ui": "^56.0.18", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "client-only": "^0.0.1", "color": "^4.2.3", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^56.0.4", "expo-server": "^56.0.5", "expo-symbols": "^56.0.6", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-is": "^19.1.0", "react-native-drawer-layout": "^4.2.2", "react-native-screens": "^4.25.2", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "standard-navigation": "^0.0.5", "vaul": "^1.1.2" }, "peerDependencies": { "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^56.0.18", "expo-linking": "^56.0.14", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-08DBTrKv3QanOc9u1JNxSEChW9c/qNFbQ0dO28OLvufWWfdSRkSdHmh365D2FgoZg1qaOzZPCDuL3tM6nGSfkQ=="], + "expo-secure-store": ["expo-secure-store@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-hjEi/gmpdFFJ9lYbdp3k3p/WchV7Gi0Qt8jt/m/0WJadqQrskafHAlDxbZkII1cN3Yd7zp9Lvkeq3UfGhSwirQ=="], + "expo-server": ["expo-server@56.0.5", "", {}, "sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ=="], "expo-splash-screen": ["expo-splash-screen@56.0.10", "", { "dependencies": { "@expo/config-plugins": "~56.0.8", "@expo/image-utils": "^0.10.1", "xml2js": "0.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-vDIlo8hzt9HlCZQ0kSY66v83D1WEXOJbVMeyPDfXDu9tbDdPMNUyDpi4WGJXikAjxnAKfbt5Mv5NnEbxINy+VA=="], @@ -2473,6 +2499,8 @@ "jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="], + "jotai": ["jotai@2.20.1", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-dnuKfU/GLi8B28RRMjQ3AfoN7kfzP8o41+AX2FmITZqEMY8PHnjABq+VkEooomLwYaGjda+pgy0yFSjaHX/ZPg=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -2509,7 +2537,7 @@ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - "kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="], + "kysely": ["kysely@0.29.2", "", {}, "sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg=="], "lan-network": ["lan-network@0.2.1", "", { "bin": { "lan-network": "dist/lan-network-cli.js" } }, "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A=="], @@ -3025,10 +3053,14 @@ "react-native-drawer-layout": ["react-native-drawer-layout@4.2.5", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-gesture-handler": ">= 2.0.0", "react-native-reanimated": ">= 2.0.0" } }, "sha512-Yl82uLkXjXuq7222hWGIDsq5A6R/bsCeCEgdIxQUxAEHf00oRdDnRByLx3Fsij3qwtmYNPGrHV1NH8G8hbCbLQ=="], + "react-native-easing-gradient": ["react-native-easing-gradient@1.1.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-KrGU97kPTMrLNzVXxFirFMgl65uo70gVNoKw2OngDzcDfoswb648I3ggeCnoY+JgMWkPyx4LlG7n3ZNgSPFnLw=="], + "react-native-gesture-handler": ["react-native-gesture-handler@2.31.2", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "@types/react-test-renderer": "^19.1.0", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-rw5q74i2AfS7YGYdbxQDhOU7xqgY6WRM1132/CCm3erqjblhECZDZFHIm0tteHoC9ih24wogVBVVzcTBQtZ+5A=="], "react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.3.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA=="], + "react-native-keyboard-controller": ["react-native-keyboard-controller@1.21.6", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-reanimated": ">=3.0.0" } }, "sha512-nAXCmar/W8Gn4iQV7O5fAVuTh57JszCsqTS+cfR95WFOLR/AfbwfPz/+sWyz/q2SOIe2VpyQzq6hzYiwErhqqw=="], + "react-native-reanimated": ["react-native-reanimated@4.3.1", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.81 - 0.85", "react-native-worklets": "0.8.x" } }, "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q=="], "react-native-safe-area-context": ["react-native-safe-area-context@5.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ=="], diff --git a/package.json b/package.json index 3e70a01..47de7e8 100644 --- a/package.json +++ b/package.json @@ -29,5 +29,8 @@ }, "peerDependencies": { "typescript": "^6" + }, + "patchedDependencies": { + "@ark/schema@0.56.0": "patches/@ark%2Fschema@0.56.0.patch" } } diff --git a/patches/@ark%2Fschema@0.56.0.patch b/patches/@ark%2Fschema@0.56.0.patch new file mode 100644 index 0000000..49752a4 --- /dev/null +++ b/patches/@ark%2Fschema@0.56.0.patch @@ -0,0 +1,23 @@ +diff --git a/out/shared/disjoint.js b/out/shared/disjoint.js +index 9fb94ad4f257b95225536eb5fdf20eaf6193b7a6..377af13b28807b1943f14240c1e889c3b16efe94 100644 +--- a/out/shared/disjoint.js ++++ b/out/shared/disjoint.js +@@ -48,11 +48,17 @@ export class Disjoint extends Array { + return result; + } + withPrefixKey(key, kind) { +- return this.map(entry => ({ ++ const result = this.map(entry => ({ + ...entry, + path: [key, ...entry.path], + optional: entry.optional || kind === "optional" + })); ++ // Workaround for Static Hermes, which doesn't preserve the Disjoint Array subclass here. ++ // Mirrors the existing invert() guard added for https://github.com/arktypeio/arktype/issues/1027 ++ // and covers the same failure mode reported in https://github.com/arktypeio/arktype/issues/1415. ++ if (!(result instanceof Disjoint)) ++ return new Disjoint(...result); ++ return result; + } + toNeverIfDisjoint() { + return $ark.intrinsic.never; diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 0000000..4bccc0a --- /dev/null +++ b/patches/README.md @@ -0,0 +1,18 @@ +# Dependency Patches + +## `@ark/schema@0.56.0` + +`@ark/schema` is patched for React Native/Hermes compatibility. + +ArkType's internal `Disjoint` type extends `Array`. In Hermes, `Array.prototype.map()` +does not always preserve the subclass instance. If `Disjoint.withPrefixKey()` returns +a plain array, later ArkType reduction code can call schema methods such as `isRoot()` +on that array and crash during app startup. + +The patch mirrors ArkType's existing guard in `Disjoint.invert()` by wrapping the +mapped result back into `new Disjoint(...)` when Hermes returns a plain array. + +Upstream context: + +- https://github.com/arktypeio/arktype/issues/1415 +- https://github.com/arktypeio/arktype/issues/1027