diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..af486d8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,14 @@ +{ + // Monorepo ESLint (flat config). Run the extension per-package so each file + // is linted with its nearest eslint.config.mjs AND from that package's cwd — + // the latter is what lets import/tsconfig resolvers see `@/` path aliases. + // Without this the extension runs at repo root, applies the root base config + // to apps/mobile/**, and errors "rule react-hooks/* not found". + "eslint.workingDirectories": [{ "mode": "auto" }], + // Opt into ESLint v10's file-based config lookup early (default in v10). + // Redundant once workingDirectories scopes cwd, but keeps a root-level run + // resolving each file's nearest config too, and eases the v10 bump. + "eslint.options": { + "flags": ["v10_config_lookup_from_file"] + } +} diff --git a/README.md b/README.md index 2aaa924..d844d0e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ the scale primitives are documented with the concrete signal that triggers each. - **Group-native E2EE** — OpenMLS / RFC 9420 TreeKEM, a Rust core (`engine.rs`, ~1.1k lines) exposed to React Native through UniFFI. 1:1 and group chats share - one crypto path. + one crypto path. - **Realtime transport** — a Cloudflare Worker fronting two Durable Object classes: `UserInbox` (one per user, holds device sockets) and `Chat` (one per chat, owns fanout + ordering). WebSocket Hibernation → idle sockets cost ~\$0. @@ -174,11 +174,10 @@ packages/ chat-calls 1:1 voice/video (placeholder — M7) db-edge Worker-runtime Neon HTTP client for the persist hot path (ADR-0010) database Prisma schema authority + migrations + seed - api tRPC client bindings for the app - identity identity/verification helpers + identity tier permission map (hasPermission) schemas shared zod schemas ui shared React UI - eslint-config / typescript-config / tailwind-config shared configs + eslint-config / typescript-config shared configs infra/stacks/ SST stack modules (see below) ``` diff --git a/apps/mobile/app/(auth)/forgot-password.tsx b/apps/mobile/app/(auth)/forgot-password.tsx new file mode 100644 index 0000000..ff5372b --- /dev/null +++ b/apps/mobile/app/(auth)/forgot-password.tsx @@ -0,0 +1,96 @@ +import { useState } from "react"; +import { Feather } from "@expo/vector-icons"; +import { Spinner, YStack, useTheme } from "tamagui"; +import { Button } from "@repo/ui/glacier/button"; +import { TextField } from "@repo/ui/glacier/text-field"; +import { BodyMd, BodySm, Title } from "@repo/ui/glacier/typography"; +import { authClient } from "@/lib/auth/client"; +import { AuthShell, AuthFooter } from "@/lib/auth/ui"; + +export default function ForgotPassword() { + const theme = useTheme(); + const [email, setEmail] = useState(""); + const [loading, setLoading] = useState(false); + const [sent, setSent] = useState(false); + + const iconColor = theme.onSurfaceVariant.val; + + async function handleRequestReset() { + if (!email) return; + setLoading(true); + try { + // Fire-and-forget. We show the confirmation regardless of the result so + // the screen never signals whether an account exists (no user + // enumeration). Works end-to-end once an email provider is wired + // server-side (login/DESIGN.md — no sendResetPassword handler yet). + await authClient.requestPasswordReset({ email }); + } catch { + // swallowed — same confirmation either way + } finally { + setLoading(false); + setSent(true); + } + } + + const footer = ( + + ); + + if (sent) { + return ( + + + + Check your inbox + + If an account exists for {email}, a link to reset your password is + on its way. + + + + ); + } + + return ( + + + + Enter the email tied to your account to receive a reset link. + + + } + placeholder="Email Address" + value={email} + onChangeText={setEmail} + keyboardType="email-address" + inputMode="email" + autoCapitalize="none" + autoComplete="email" + enterKeyHint="send" + onSubmitEditing={handleRequestReset} + /> + + + + + ); +} diff --git a/apps/mobile/app/(auth)/reset-password.tsx b/apps/mobile/app/(auth)/reset-password.tsx new file mode 100644 index 0000000..57a0c9a --- /dev/null +++ b/apps/mobile/app/(auth)/reset-password.tsx @@ -0,0 +1,129 @@ +import { useState } from "react"; +import { router, useLocalSearchParams } from "expo-router"; +import { Feather } from "@expo/vector-icons"; +import { Spinner, YStack, useTheme } from "tamagui"; +import { Button } from "@repo/ui/glacier/button"; +import { TextField } from "@repo/ui/glacier/text-field"; +import { BodyMd, BodySm, Title } from "@repo/ui/glacier/typography"; +import { authClient } from "@/lib/auth/client"; +import { AuthShell, AuthFooter } from "@/lib/auth/ui"; + +// Mirrors the server rule (services/api/src/lib/auth.ts minPasswordLength). +const MIN_PASSWORD = 8; + +export default function ResetPassword() { + const theme = useTheme(); + // Deep link: mortstack-chatapp://reset-password?token=… + const { token } = useLocalSearchParams<{ token?: string }>(); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + + const iconColor = theme.onSurfaceVariant.val; + const passwordTooShort = + password.length > 0 && password.length < MIN_PASSWORD; + + async function handleReset() { + if (!token) { + setError("This reset link is invalid or has expired."); + return; + } + if (password.length < MIN_PASSWORD) { + setError(`Password must be at least ${MIN_PASSWORD} characters`); + return; + } + setLoading(true); + setError(null); + try { + const result = await authClient.resetPassword({ + newPassword: password, + token, + }); + if (result.error) throw new Error(result.error.message); + setDone(true); + } catch (e) { + setError(e instanceof Error ? e.message : "Reset failed"); + } finally { + setLoading(false); + } + } + + const footer = ( + + ); + + if (done) { + return ( + + + + All set + + Your password has been reset. Sign in with your new password. + + + + + ); + } + + return ( + + + + Enter a new password for your account. + + + } + placeholder="New Password" + value={password} + onChangeText={setPassword} + secureTextEntry + autoComplete="new-password" + enterKeyHint="go" + onSubmitEditing={handleReset} + error={!!error || passwordTooShort} + /> + + {(error || passwordTooShort) && ( + + {error ?? `Password must be at least ${MIN_PASSWORD} characters`} + + )} + + + + + ); +} diff --git a/apps/mobile/app/(auth)/sign-in.tsx b/apps/mobile/app/(auth)/sign-in.tsx index 02d1cb1..dd9382a 100644 --- a/apps/mobile/app/(auth)/sign-in.tsx +++ b/apps/mobile/app/(auth)/sign-in.tsx @@ -1,16 +1,24 @@ import { useState } from "react"; -import { router } from "expo-router"; -import { YStack, Text, Button, Input, Spinner } from "tamagui"; +import { router, Link } from "expo-router"; +import { Feather } from "@expo/vector-icons"; +import { Spinner, XStack, YStack, useTheme } from "tamagui"; +import { Button } from "@repo/ui/glacier/button"; +import { TextField } from "@repo/ui/glacier/text-field"; +import { BodySm, Meta } from "@repo/ui/glacier/typography"; import { useAuthStore } from "@/store/auth"; import { authClient } from "@/lib/auth/client"; +import { AuthShell, AuthFooter } from "@/lib/auth/ui"; export default function SignIn() { + const theme = useTheme(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const setSession = useAuthStore((s) => s.setSession); + const iconColor = theme.onSurfaceVariant.val; + async function handleEmailSignIn() { if (!email || !password) return; setLoading(true); @@ -28,65 +36,69 @@ export default function SignIn() { } return ( - - - - Mortstack - - - Chat app - - - - - + } + > + + } + placeholder="Email Address" value={email} onChangeText={setEmail} - autoCapitalize="none" keyboardType="email-address" - size="$5" + inputMode="email" + autoCapitalize="none" + autoComplete="email" + enterKeyHint="next" + error={!!error} /> - } placeholder="Password" value={password} onChangeText={setPassword} secureTextEntry - size="$5" + autoComplete="password" + enterKeyHint="go" + onSubmitEditing={handleEmailSignIn} + error={!!error} /> - {error && ( - - {error} - - )} + + + Forgot Password? + + + + {error && {error}} - - - + ); } diff --git a/apps/mobile/app/(auth)/sign-up.tsx b/apps/mobile/app/(auth)/sign-up.tsx index 7740a95..1eda429 100644 --- a/apps/mobile/app/(auth)/sign-up.tsx +++ b/apps/mobile/app/(auth)/sign-up.tsx @@ -1,10 +1,19 @@ import { useState } from "react"; import { router } from "expo-router"; -import { YStack, Text, Button, Input, Spinner } from "tamagui"; +import { Feather } from "@expo/vector-icons"; +import { Spinner, YStack, useTheme } from "tamagui"; +import { Button } from "@repo/ui/glacier/button"; +import { TextField } from "@repo/ui/glacier/text-field"; +import { BodySm } from "@repo/ui/glacier/typography"; import { useAuthStore, type Session } from "@/store/auth"; import { authClient } from "@/lib/auth/client"; +import { AuthShell, AuthFooter } from "@/lib/auth/ui"; + +// Mirrors the server rule (services/api/src/lib/auth.ts minPasswordLength). +const MIN_PASSWORD = 8; export default function SignUp() { + const theme = useTheme(); const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -12,8 +21,16 @@ export default function SignUp() { const [error, setError] = useState(null); const setSession = useAuthStore((s) => s.setSession); + const iconColor = theme.onSurfaceVariant.val; + const passwordTooShort = + password.length > 0 && password.length < MIN_PASSWORD; + async function handleSignUp() { if (!email || !password || !name) return; + if (password.length < MIN_PASSWORD) { + setError(`Password must be at least ${MIN_PASSWORD} characters`); + return; + } setLoading(true); setError(null); try { @@ -29,72 +46,76 @@ export default function SignUp() { } return ( - - - - Create account - - - Join the Mortstack Chat network - - - - - + } + > + + } + placeholder="Full Name" value={name} onChangeText={setName} autoCapitalize="words" - size="$5" + autoComplete="name" + enterKeyHint="next" /> - } + placeholder="Email Address" value={email} onChangeText={setEmail} - autoCapitalize="none" keyboardType="email-address" - size="$5" + inputMode="email" + autoCapitalize="none" + autoComplete="email" + enterKeyHint="next" + error={!!error} /> - } placeholder="Password" value={password} onChangeText={setPassword} secureTextEntry - size="$5" + autoComplete="new-password" + enterKeyHint="go" + onSubmitEditing={handleSignUp} + error={!!error || passwordTooShort} /> - {error && ( - - {error} - + {(error || passwordTooShort) && ( + + {error ?? `Password must be at least ${MIN_PASSWORD} characters`} + )} - - - + ); } diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx index d9ce970..df8a384 100644 --- a/apps/mobile/app/(tabs)/_layout.tsx +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -1,77 +1,9 @@ -import { Tabs } from "expo-router"; -import { Text } from "tamagui"; +// Chat-only app: no tab bar. The chat-list screen owns a single "New Chat" +// bottom action bar instead (chat-list/DESIGN.md 2.1.0 — the 5-icon tab bar and +// Stories row were cut). This group is now just a headerless Stack whose index +// is the conversations list. +import { Stack } from "expo-router"; -function TabIcon({ label, focused }: { label: string; focused: boolean }) { - return ( - - {label} - - ); -} - -export default function TabsLayout() { - return ( - - ( - - ), - tabBarShowLabel: false, - }} - /> - ( - - ), - tabBarShowLabel: false, - }} - /> - ( - - ), - tabBarShowLabel: false, - }} - /> - , - tabBarShowLabel: false, - }} - /> - , - tabBarShowLabel: false, - }} - /> - - ); +export default function AppLayout() { + return ; } diff --git a/apps/mobile/app/(tabs)/chats.tsx b/apps/mobile/app/(tabs)/chats.tsx deleted file mode 100644 index 2658f2c..0000000 --- a/apps/mobile/app/(tabs)/chats.tsx +++ /dev/null @@ -1,244 +0,0 @@ -// Chat list screen — the M4 "Chats" tab. Renders chats from the store -// (populated by chat.list on auth + transport reconnect). Tap row → thread; -// tap + button → New Chat picker. - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Pressable, StyleSheet } from "react-native"; -import { FlashList } from "@shopify/flash-list"; -import { useRouter } from "expo-router"; -import { Button, Spinner, Text, View, YStack, XStack } from "tamagui"; - -import { useChats, useChatStore, type ChatRecord } from "@repo/chat"; - -import { getMyAccount } from "@/lib/account/me"; - -function initials(text: string | null): string { - if (!text) return "?"; - const trimmed = text.trim(); - if (!trimmed) return "?"; - return trimmed.slice(0, 2).toUpperCase(); -} - -// Deterministic background colour for the avatar circle. Hash the -// accountId into a small palette of raw hex values — the project's Tamagui -// config doesn't ship the standard $red/$blue/etc. shade tokens. -const AVATAR_HUES = [ - "#ef4444", // red - "#f97316", // orange - "#eab308", // yellow - "#22c55e", // green - "#3b82f6", // blue - "#6366f1", // indigo - "#ec4899", // pink - "#a855f7", // purple -]; - -const ERROR_COLOR = "#dc2626"; - -function avatarHue(seed: string): string { - let h = 0; - for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0; - return AVATAR_HUES[h % AVATAR_HUES.length] ?? "#3b82f6"; -} - -function chatTitle(chat: ChatRecord, myAccountId: string | null): string { - if (chat.kind === "group") { - if (chat.name && chat.name.trim()) return chat.name; - // Default group name: comma-joined handles of other members. - const handles = chat.members - .filter((m) => m.accountId !== myAccountId) - .map((m) => m.handle ?? m.displayName ?? "?"); - return handles.join(", ") || "Group"; - } - const peer = chat.members.find((m) => m.accountId !== myAccountId); - return peer?.handle ?? peer?.displayName ?? "Direct chat"; -} - -function chatAvatarSeed(chat: ChatRecord, myAccountId: string | null): string { - if (chat.kind === "group") return chat.id; - const peer = chat.members.find((m) => m.accountId !== myAccountId); - return peer?.accountId ?? chat.id; -} - -interface ChatRowProps { - chat: ChatRecord; - myAccountId: string | null; - onPress: (chatId: string) => void; -} - -function ChatRow({ chat, myAccountId, onPress }: ChatRowProps) { - const title = chatTitle(chat, myAccountId); - const hue = avatarHue(chatAvatarSeed(chat, myAccountId)); - const lastMessage = useChatStore((s) => { - const list = s.messages.get(chat.id); - return list?.[list.length - 1] ?? null; - }); - - return ( - onPress(chat.id)}> - - - - {initials(title)} - - - - - {title} - - - {lastMessage?.text ?? - (chat.kind === "group" ? "New group" : "Tap to message")} - - - - - ); -} - -export default function ChatsScreen() { - const router = useRouter(); - const { chats, isLoading, error } = useChats(); - const me = useMyAccountId(); - - const onOpenChat = useCallback( - (chatId: string) => { - // expo-router typed routes regenerate after first build with the new - // files; cast until then. - router.push(`/chat/${chatId}` as never); - }, - [router], - ); - - const onNewChat = useCallback(() => { - router.push("/chat/new" as never); - }, [router]); - - const renderItem = useCallback( - ({ item }: { item: ChatRecord }) => ( - - ), - [me, onOpenChat], - ); - - const empty = useMemo(() => { - if (isLoading) { - return ( - - - Loading chats… - - ); - } - if (error) { - return ( - - Couldn’t load chats - - {error} - - - ); - } - return ( - - - No chats yet - - Start one to message a peer. - - - ); - }, [isLoading, error, onNewChat]); - - return ( - - - - Chats - - - - - {chats.length === 0 ? ( - empty - ) : ( - c.id} - renderItem={renderItem} - contentContainerStyle={styles.listContent} - /> - )} - - - ); -} - -// Tiny hook that loads the caller's accountId once and caches it. Used to -// distinguish "me" vs peers when rendering chat titles. -function useMyAccountId(): string | null { - const [accountId, setAccountId] = useMemoState(null); - // useMemo + a one-shot async load — keeps the hook synchronous-feeling. - useOnce(async () => { - try { - const me = await getMyAccount(); - setAccountId(me.accountId); - } catch { - // unauthenticated — leave null - } - }); - return accountId; -} - -function useMemoState(initial: T) { - return useState(initial); -} - -function useOnce(fn: () => Promise) { - // Stable ref so the effect can call the latest closure without re-running. - const fnRef = useRef(fn); - fnRef.current = fn; - const ranRef = useRef(false); - useEffect(() => { - if (ranRef.current) return; - ranRef.current = true; - void fnRef.current(); - }, []); -} - -const styles = StyleSheet.create({ - listContent: { paddingBottom: 32 }, -}); diff --git a/apps/mobile/app/(tabs)/create.tsx b/apps/mobile/app/(tabs)/create.tsx deleted file mode 100644 index 6e8efe6..0000000 --- a/apps/mobile/app/(tabs)/create.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { YStack, Text } from "tamagui"; - -export default function Create() { - return ( - - - Create - - - ); -} diff --git a/apps/mobile/app/(tabs)/gigs.tsx b/apps/mobile/app/(tabs)/gigs.tsx deleted file mode 100644 index 327e38d..0000000 --- a/apps/mobile/app/(tabs)/gigs.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { YStack, Text } from "tamagui"; - -export default function Gigs() { - return ( - - - Gigs - - - ); -} diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index da1093f..d7ff4e7 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -1,18 +1,258 @@ -import { Link } from "expo-router"; -import { YStack, Text, Button } from "tamagui"; +// Chat List — the conversations index ("Mortstack"). Glacier / App-Light. +// Anatomy (chat-list/DESIGN.md): app-name header + search, "Chats" section row +// + overflow, high-density ListRows, and a sticky full-width New Chat action +// bar in place of a tab bar. +// +// NOTE: ChatRecord/Message carry no unread count, read receipts or presence yet +// (see @repo/chat types), so rows render in the "read" state and show no badge/ +// tick until the model grows those fields. Layout/type/token fidelity is full; +// the data-driven states light up automatically once backed. + +import { useCallback, useEffect, useRef, useState } from "react"; +import { StyleSheet } from "react-native"; +import { FlashList } from "@shopify/flash-list"; +import { useRouter } from "expo-router"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Feather } from "@expo/vector-icons"; +import { Spinner, XStack, YStack, useTheme } from "tamagui"; + +import { useChats, useChatStore, type ChatRecord } from "@repo/chat"; +import { HeadlineMd, BodyMd } from "@repo/ui/glacier/typography"; +import { ListRow } from "@repo/ui/glacier/list-row"; +import { Button } from "@repo/ui/glacier/button"; + +import { getMyAccount } from "@/lib/account/me"; + +function formatTime(ms: number): string { + const d = new Date(ms); + const hh = d.getHours().toString().padStart(2, "0"); + const mm = d.getMinutes().toString().padStart(2, "0"); + return `${hh}:${mm}`; +} + +function firstName( + m: { handle: string | null; displayName: string | null } | undefined, +): string { + const n = m?.handle ?? m?.displayName ?? "?"; + return n.split(/\s+/)[0] ?? n; +} + +function chatTitle(chat: ChatRecord, myId: string | null): string { + if (chat.kind === "group") { + if (chat.name?.trim()) return chat.name; + const others = chat.members + .filter((m) => m.accountId !== myId) + .map((m) => m.handle ?? m.displayName ?? "?"); + return others.join(", ") || "Group"; + } + const peer = chat.members.find((m) => m.accountId !== myId); + return peer?.handle ?? peer?.displayName ?? "Direct chat"; +} + +function chatSeed(chat: ChatRecord, myId: string | null): string { + if (chat.kind === "group") return chat.id; + return chat.members.find((m) => m.accountId !== myId)?.accountId ?? chat.id; +} + +interface ChatRowProps { + chat: ChatRecord; + myId: string | null; + onPress: (chatId: string) => void; +} + +function ChatRow({ chat, myId, onPress }: ChatRowProps) { + const title = chatTitle(chat, myId); + const last = useChatStore((s) => { + const list = s.messages.get(chat.id); + return list?.[list.length - 1] ?? null; + }); + + let preview: string; + if (!last) { + preview = chat.kind === "group" ? "New group" : "Tap to message"; + } else if (chat.kind === "group") { + const sender = chat.members.find( + (m) => m.authUserId === last.senderAuthUserId, + ); + preview = `${firstName(sender)}: ${last.text}`; + } else { + preview = last.text; + } + + return ( + onPress(chat.id)} + /> + ); +} + +export default function ChatListScreen() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const theme = useTheme(); + const { chats, isLoading, error } = useChats(); + const myId = useMyAccountId(); + + const ovc = theme.onSurfaceVariant?.val; + + const onOpenChat = useCallback( + (chatId: string) => router.push(`/chat/${chatId}` as never), + [router], + ); + const onNewChat = useCallback( + () => router.push("/chat/new" as never), + [router], + ); + + const renderItem = useCallback( + ({ item }: { item: ChatRecord }) => ( + + ), + [myId, onOpenChat], + ); -// Feed screen — populated via Replicache subscriptions in next iteration -export default function Feed() { return ( - - - Feed - - {__DEV__ ? ( - - - - ) : null} + + {/* Header — app name + search */} + + + Mortstack + + ); } + +// Loads the caller's accountId once (to split "me" from peers in titles). +function useMyAccountId(): string | null { + const [accountId, setAccountId] = useState(null); + const ran = useRef(false); + useEffect(() => { + if (ran.current) return; + ran.current = true; + getMyAccount() + .then((me) => setAccountId(me.accountId)) + .catch(() => { + /* unauthenticated — leave null */ + }); + }, []); + return accountId; +} + +const styles = StyleSheet.create({ + listContent: { paddingHorizontal: 4, paddingBottom: 12 }, +}); diff --git a/apps/mobile/app/(tabs)/profile.tsx b/apps/mobile/app/(tabs)/profile.tsx deleted file mode 100644 index 0a852a1..0000000 --- a/apps/mobile/app/(tabs)/profile.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { YStack, Text, Button } from "tamagui"; -import { router } from "expo-router"; -import { useAuthStore } from "@/store/auth"; -import { authClient } from "@/lib/auth/client"; - -export default function Profile() { - const { session, clearSession } = useAuthStore(); - - async function handleSignOut() { - await authClient.signOut(); - clearSession(); - router.replace("/(auth)/sign-in"); - } - - return ( - - - {session?.user?.name ?? "Profile"} - - - {session?.user?.email ?? ""} - - - - - ); -} diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index d391758..8a816ef 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -10,16 +10,27 @@ import { Stack } from "expo-router"; import { useFonts } from "expo-font"; import * as SplashScreen from "expo-splash-screen"; import { StatusBar } from "expo-status-bar"; +// Glacier type system (THEME §3): Sora (heading), Plus Jakarta Sans (body + +// metadata), JetBrains Mono (crypto/technical). Face names MUST match the +// `face` entries in the Tamagui config. import { - IBMPlexSans_400Regular, - IBMPlexSans_400Regular_Italic, - IBMPlexSans_500Medium, - IBMPlexSans_500Medium_Italic, - IBMPlexSans_600SemiBold, - IBMPlexSans_600SemiBold_Italic, - IBMPlexSans_700Bold, - IBMPlexSans_700Bold_Italic, -} from "@expo-google-fonts/ibm-plex-sans"; + Sora_400Regular, + Sora_500Medium, + Sora_600SemiBold, + Sora_700Bold, +} from "@expo-google-fonts/sora"; +import { + PlusJakartaSans_400Regular, + PlusJakartaSans_400Regular_Italic, + PlusJakartaSans_500Medium, + PlusJakartaSans_500Medium_Italic, + PlusJakartaSans_600SemiBold, + PlusJakartaSans_700Bold, +} from "@expo-google-fonts/plus-jakarta-sans"; +import { + JetBrainsMono_400Regular, + JetBrainsMono_500Medium, +} from "@expo-google-fonts/jetbrains-mono"; import { Providers } from "@/providers"; import { logChatEndpoints } from "@/lib/api/url"; import { getChatDb } from "@repo/chat-db"; @@ -68,14 +79,18 @@ getChatDb() export default function RootLayout() { const [fontsLoaded, fontError] = useFonts({ - IBMPlexSans_400Regular, - IBMPlexSans_400Regular_Italic, - IBMPlexSans_500Medium, - IBMPlexSans_500Medium_Italic, - IBMPlexSans_600SemiBold, - IBMPlexSans_600SemiBold_Italic, - IBMPlexSans_700Bold, - IBMPlexSans_700Bold_Italic, + Sora_400Regular, + Sora_500Medium, + Sora_600SemiBold, + Sora_700Bold, + PlusJakartaSans_400Regular, + PlusJakartaSans_400Regular_Italic, + PlusJakartaSans_500Medium, + PlusJakartaSans_500Medium_Italic, + PlusJakartaSans_600SemiBold, + PlusJakartaSans_700Bold, + JetBrainsMono_400Regular, + JetBrainsMono_500Medium, }); useEffect(() => { @@ -89,7 +104,8 @@ export default function RootLayout() { return ( - + {/* Light-only app → dark status-bar content on the hospital-white surface */} + ); } diff --git a/apps/mobile/app/chat-db-debug.tsx b/apps/mobile/app/chat-db-debug.tsx deleted file mode 100644 index 6014e1b..0000000 --- a/apps/mobile/app/chat-db-debug.tsx +++ /dev/null @@ -1,1038 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import * as Crypto from "expo-crypto"; -import { Button, Input, ScrollView, Text, XStack, YStack } from "tamagui"; -import { - getChatDb, - mls as mlsStore, - outbox, - type MlsGroupListItem, - type PendingOutboxRow, -} from "@repo/chat-db"; -import { - ChatCrypto, - ED25519_PUBLIC_KEY_BYTES, - SEED_BYTES, - X25519_PUBLIC_KEY_BYTES, -} from "@repo/chat-crypto"; -import { ChatMlsCore } from "@repo/chat-mls-core"; -import { getMlsClient } from "@/lib/chat/mls-auto-publish"; -import { clearChatGroupCache } from "@/lib/chat/group-resolver"; -import { - decryptInbound, - encryptOutbound, - type EncryptedIncomingMessage, - type FanoutTarget, - type OutboundEnvelope, -} from "@repo/chat"; - -import { - getOrCreateChatIdentity, - type ChatIdentity, -} from "@/lib/chat/identity"; -import { getPeerDevices, type PeerDevice } from "@/lib/chat/peer-keys"; -import { getMyAccount, type MyAccount } from "@/lib/account/me"; -import { useChatConnectionState, useChatTransport } from "@/lib/chat/transport"; - -const CHAT_ID_TEST = "debug-chat"; - -function encodeUtf8(s: string): Uint8Array { - return new TextEncoder().encode(s); -} - -function hex(bytes: Uint8Array | undefined, max = 16): string { - if (!bytes) return "?"; - let s = ""; - const n = Math.min(bytes.length, max); - for (let i = 0; i < n; i++) { - s += (bytes[i] ?? 0).toString(16).padStart(2, "0"); - } - return bytes.length > max - ? `${s}…(${bytes.length}B)` - : `${s} (${bytes.length}B)`; -} - -// Fingerprint convention for visual comparison across devices: first 8 bytes -// of the ed25519 pub. Matches what users would read off a contact-verify -// screen in the M4 chat UI. -function fp(pub: Uint8Array | undefined): string { - return hex(pub, 8); -} - -interface CipherSnapshot { - envelope: OutboundEnvelope; - text: string; - textBytes: number; -} - -interface WrongKeyOutcome { - attempted: boolean; - threw: boolean; - message: string; -} - -interface InboxEntry extends EncryptedIncomingMessage { - receivedAt: number; -} - -// Chunk 5 acceptance panel — surfaces live engine + directory state so the -// 2-device acceptance harness from README §M3.5 can be eyeballed: -// "current KeyPackage count, group epoch, ratchet tree hash". The KP pool -// number comes from the auto-publish loop's last server-reported total; the -// group list comes from chat-db; the snapshot timestamp from the same store. -interface MlsAcceptanceSnapshot { - engineAccountId: string | null; - kpPoolSize: number | null; - snapshotUpdatedAt: number | null; - groups: Array< - MlsGroupListItem & { currentEpoch: number; memberCount: number } - >; -} - -function MlsAcceptancePanel() { - const [snap, setSnap] = useState(null); - const [err, setErr] = useState(null); - - const refresh = useCallback(async () => { - setErr(null); - try { - let engineAccountId: string | null = null; - try { - engineAccountId = ChatMlsCore.engineAccountId(); - } catch { - // engine not initialised yet — leave null - } - - const client = getMlsClient(); - const kpPoolSize = client ? client.knownPoolSize : null; - - const dbHandle = await getChatDb(); - const groupRows = await mlsStore.listGroups(dbHandle.db); - const groups = groupRows.map((g) => { - // currentEpoch + memberCount come from the native engine — wrap in - // try/catch in case the snapshot lags the listGroups row write. - let currentEpoch = -1; - let memberCount = -1; - try { - currentEpoch = ChatMlsCore.currentEpoch(g.groupId); - memberCount = ChatMlsCore.memberCount(g.groupId); - } catch { - // skip — render -1 sentinels - } - return { ...g, currentEpoch, memberCount }; - }); - - let snapshotUpdatedAt: number | null = null; - if (engineAccountId) { - const row = await mlsStore.loadEngineSnapshot( - dbHandle.db, - engineAccountId, - ); - snapshotUpdatedAt = row?.updated_at ?? null; - } - - setSnap({ - engineAccountId, - kpPoolSize, - snapshotUpdatedAt, - groups, - }); - } catch (e) { - setErr(String(e)); - } - }, []); - - useEffect(() => { - void refresh(); - }, [refresh]); - - return ( - - - mls acceptance (Chunk 5) - - - engine account: {snap?.engineAccountId ?? "(not initialised)"} - - - kp pool (server-reported):{" "} - {snap?.kpPoolSize == null ? "?" : snap.kpPoolSize} - - - snapshot updatedAt:{" "} - {snap?.snapshotUpdatedAt == null - ? "—" - : new Date(snap.snapshotUpdatedAt).toLocaleTimeString()} - - - joined groups: {snap?.groups.length ?? 0} - - {snap?.groups.map((g) => ( - - gid {hex(g.groupId, 6)} · epoch {g.currentEpoch} (cursor{" "} - {g.lastAppliedEpoch}) · members {g.memberCount} - {g.chatId ? ` · chat ${g.chatId.slice(0, 8)}` : ""} - - ))} - {err && ( - - {err} - - )} - - - - - ); -} - -// Chunk 7 acceptance harness — interactive buttons covering the README §M3.5 -// acceptance scenarios. Each handler captures its result inline so the -// 2-device tester can read state changes back without leaving the screen. -// -// Scenarios covered here (per README §M3.5 Acceptance): -// - 2-device DM via MLS group → Create v=2 group + Add peer -// - 5-device group, 1 cipher to all → Add peer (×N) + send via transport -// - member add / remove mid-conv → Add peer mid-conversation -// - KeyPackage exhaustion → server-side cap surfaces TRPC error -// - multi-account swap on same install → Reset engine + log back in -// - offline catch-up → background app + 30s timer + relaunch -// -// Buttons here drive MlsClient lifecycle helpers; the "send" path is the -// normal encrypted transport (v=2 routes once the chat has an mls_group_id). -function MlsLifecyclePanel({ - chatId, - onChatIdChange, -}: { - chatId: string; - onChatIdChange: (next: string) => void; -}) { - const [peerInput, setPeerInput] = useState(""); - const [log, setLog] = useState([]); - const [busy, setBusy] = useState(false); - - const append = useCallback((line: string) => { - setLog((prev) => - [`${new Date().toLocaleTimeString()} · ${line}`, ...prev].slice(0, 30), - ); - }, []); - - const run = useCallback( - async (label: string, fn: () => Promise) => { - const client = getMlsClient(); - if (!client) { - append(`${label}: no engine yet — wait for auto-bootstrap`); - return; - } - setBusy(true); - try { - const out = await fn(); - append(`${label} · ${out}`); - } catch (err) { - append(`${label} FAIL · ${String(err).slice(0, 140)}`); - } finally { - setBusy(false); - } - }, - [append], - ); - - const onCreateGroup = useCallback( - () => - run("createGroup", async () => { - const client = getMlsClient()!; - const dbHandle = await getChatDb(); - // Derive a deterministic chatId from the new GroupId so the founder - // and joiner converge on the same chats row without out-of-band - // coordination. The joiner side mirrors this convention in - // MlsClient.pollPendingWelcomes (M4 chat-create RPC replaces both). - const { groupId } = await client.createGroup(); - // Plain hex of first 8 bytes — must match MlsClient.hexShort used - // by the joiner's pollPendingWelcomes so both sides converge. - let gidHex = ""; - for (let i = 0; i < Math.min(groupId.length, 8); i++) { - gidHex += (groupId[i] ?? 0).toString(16).padStart(2, "0"); - } - const derivedChatId = `mls-${gidHex}`; - await mlsStore.ensureChatForDebug(dbHandle.db, derivedChatId, "group"); - await mlsStore.setChatMlsGroupId(dbHandle.db, derivedChatId, groupId); - // Also backfill mls_group.chat_id so the tick's listGroups() returns - // this chatId and auto-subscribes the WS channel on future ticks. - await mlsStore.upsertGroup(dbHandle.db, { - groupId, - chatId: derivedChatId, - }); - clearChatGroupCache(derivedChatId); - onChatIdChange(derivedChatId); - return `gid ${hex(groupId, 6)} · chat ${derivedChatId} linked`; - }), - [onChatIdChange, run], - ); - - const onAddPeer = useCallback( - () => - run("addMembers", async () => { - const client = getMlsClient()!; - const dbHandle = await getChatDb(); - const peer = peerInput.trim(); - if (!peer) throw new Error("peer accountId empty"); - // Resolve which MLS group is linked to this chat — we use chats.mls_group_id - // rather than asking the user to paste the GroupId. - const result = await dbHandle.db.execute( - "SELECT mls_group_id FROM chats WHERE id = ?", - [chatId], - ); - const row = (result.rows?.[0] ?? null) as { - mls_group_id: unknown; - } | null; - if (!row?.mls_group_id) { - throw new Error("chat has no mls_group_id — run Create group first"); - } - const groupId = - row.mls_group_id instanceof Uint8Array - ? row.mls_group_id - : new Uint8Array(row.mls_group_id as ArrayBuffer); - const out = await client.addMembersByAccounts({ - groupId, - accountIds: [peer], - }); - return `epoch=${out.epoch} devices=${out.devicesAdded.length}`; - }), - [chatId, peerInput, run], - ); - - const onDrainKPs = useCallback( - () => - run("drainKPs", async () => { - const client = getMlsClient()!; - const out = await client.drainServerKeyPackages(); - return `wiped ${out.deleted} KP(s) — pool empty, auto-publish will refill on next tick (≤30s)`; - }), - [run], - ); - - const onRemovePeer = useCallback( - () => - run("removeMembers", async () => { - const client = getMlsClient()!; - const dbHandle = await getChatDb(); - const peer = peerInput.trim(); - if (!peer) throw new Error("peer accountId empty"); - const result = await dbHandle.db.execute( - "SELECT mls_group_id FROM chats WHERE id = ?", - [chatId], - ); - const row = (result.rows?.[0] ?? null) as { - mls_group_id: unknown; - } | null; - if (!row?.mls_group_id) { - throw new Error("chat has no mls_group_id — run Create group first"); - } - const groupId = - row.mls_group_id instanceof Uint8Array - ? row.mls_group_id - : new Uint8Array(row.mls_group_id as ArrayBuffer); - const out = await client.removeMembersByAccounts({ - groupId, - accountIds: [peer], - }); - return `epoch=${out.epoch}`; - }), - [chatId, peerInput, run], - ); - - const onForceWelcomes = useCallback( - () => - run("pollWelcomes", async () => { - const out = await getMlsClient()!.pollPendingWelcomes(); - // Pivot the harness chatId to a joined MLS group so the chatId - // useEffect sends `sub` for it (DO fanout requires attachment). - // Two-stage fallback: prefer the just-joined group; otherwise pick - // any group already known to chat-db. The fallback handles the case - // where the 30s auto-tick consumed the Welcome before the user - // tapped this button (server is consume-on-fetch, so the explicit - // poll comes back with joined=0 even though state is correct). - let pivot: string | null = null; - const first = out.joinedGroupIds[0]; - if (first) { - let gidHex = ""; - for (let i = 0; i < Math.min(first.length, 8); i++) { - gidHex += (first[i] ?? 0).toString(16).padStart(2, "0"); - } - pivot = `mls-${gidHex}`; - } else { - const dbHandle = await getChatDb(); - const groups = await mlsStore.listGroups(dbHandle.db); - const existing = groups.find( - (g) => typeof g.chatId === "string" && g.chatId.startsWith("mls-"), - ); - if (existing?.chatId) pivot = existing.chatId; - } - if (pivot) onChatIdChange(pivot); - return `joined=${out.joinedGroupIds.length}${pivot ? ` · pivot=${pivot}` : ""}`; - }), - [onChatIdChange, run], - ); - - const onForceCommits = useCallback( - () => - run("pollCommits", async () => { - const client = getMlsClient()!; - const dbHandle = await getChatDb(); - const groups = await mlsStore.listGroups(dbHandle.db); - let totalApplied = 0; - for (const g of groups) { - const o = await client.pollPendingCommits(g.groupId); - totalApplied += o.applied; - } - return `groups=${groups.length} applied=${totalApplied}`; - }), - [run], - ); - - const onReset = useCallback( - () => - run("reset", async () => { - await getMlsClient()!.reset(); - clearChatGroupCache(); - return "engine wiped, snapshot cleared"; - }), - [run], - ); - - return ( - - - mls lifecycle (Chunk 7) - - - target chat: {chatId} - - - - - - - - - - - - - {log.map((line, i) => ( - - {line} - - ))} - - - ); -} - -// Chunk 0/1 smoke harness — calls ChatMlsCore.ping() and renders the result. -// "ok" = the UniFFI XCFramework (iOS) / jniLibs (Android) loaded and the -// Swift/Kotlin → Rust FFI hop works end-to-end. Anything else = native module -// loaded but bridge fault; an exception = native module didn't load at all. -function MlsPingPanel() { - const [result, setResult] = useState(null); - const [err, setErr] = useState(null); - return ( - - - chat-mls-core ping - - {result != null ? ( - - ping() → {JSON.stringify(result)}{" "} - {result === "ok" ? "✓" : "(unexpected payload)"} - - ) : ( - - (tap to call) - - )} - {err && ( - - {err} - - )} - - - - - ); -} - -export default function ChatDbDebug() { - // ── Outbox panel (M2 — kept) ──────────────────────────────────────────── - const [rows, setRows] = useState([]); - const [keySource, setKeySource] = useState("?"); - const [lastError, setLastError] = useState(null); - - // ── M3 acceptance state ───────────────────────────────────────────────── - const [identity, setIdentity] = useState(null); - const [me, setMe] = useState(null); - const [meErr, setMeErr] = useState(null); - const [peerInput, setPeerInput] = useState(""); - const [peerDevices, setPeerDevices] = useState(null); - const [peerErr, setPeerErr] = useState(null); - const [text, setText] = useState("hello bob from alice"); - const [chatId, setChatId] = useState(CHAT_ID_TEST); - const [lastCipher, setLastCipher] = useState(null); - const [wrongKey, setWrongKey] = useState({ - attempted: false, - threw: false, - message: "", - }); - const [sendResults, setSendResults] = useState([]); - const [inbox, setInbox] = useState([]); - - const transport = useChatTransport(); - const connState = useChatConnectionState(); - const seenInbox = useRef(new Set()); - - // ── Loaders ──────────────────────────────────────────────────────────── - - const refresh = useCallback(async () => { - try { - const { db, keySource: src } = await getChatDb(); - setKeySource(src); - const due = await outbox.due(db, 100); - setRows(due); - setLastError(null); - } catch (err) { - setLastError(String(err)); - } - }, []); - - const loadIdentity = useCallback(() => { - getOrCreateChatIdentity() - .then(setIdentity) - .catch(() => setIdentity(null)); - }, []); - - const loadMe = useCallback(() => { - setMeErr(null); - getMyAccount() - .then(setMe) - .catch((err: unknown) => { - setMe(null); - setMeErr(String(err)); - }); - }, []); - - useEffect(() => { - refresh(); - loadIdentity(); - loadMe(); - }, [refresh, loadIdentity, loadMe]); - - // Cold-open pivot: if the engine already joined an MLS group on a prior - // tick (e.g. sim B reopens the app after the 30s poll consumed a Welcome), - // jump straight to that chatId so the user can send replies without - // manually pasting `mls-`. Only fires while the chatId is still the - // default placeholder — never overrides a deliberate user choice. - useEffect(() => { - if (chatId !== CHAT_ID_TEST) return; - let cancelled = false; - void (async () => { - try { - const { db } = await getChatDb(); - const groups = await mlsStore.listGroups(db); - const first = groups.find( - (g): g is typeof g & { chatId: string } => - typeof g.chatId === "string" && g.chatId.startsWith("mls-"), - ); - if (!cancelled && first) setChatId(first.chatId); - } catch { - // listGroups failure is non-fatal; user can still set chatId manually. - } - })(); - return () => { - cancelled = true; - }; - }, [chatId]); - - // Live inbox — subscribe to decrypted message stream. The encrypted - // transport already wires resolveSenderX25519Pubs to getPeerDevices so the - // sender's pub lookup is automatic. - useEffect(() => { - return transport.onMessage((m) => { - if (seenInbox.current.has(m.serverMsgId)) return; - seenInbox.current.add(m.serverMsgId); - setInbox((prev) => - [{ ...m, receivedAt: Date.now() }, ...prev].slice(0, 50), - ); - }); - }, [transport]); - - // Ensure subscription updates when chatId changes. - useEffect(() => { - if (!chatId) return; - transport.subscribe([chatId]); - }, [chatId, transport]); - - // ── Outbox handlers (M2 — kept) ───────────────────────────────────────── - const onEnqueue = useCallback(async () => { - try { - const { db } = await getChatDb(); - const id = Crypto.randomUUID(); - await outbox.enqueue(db, { - id, - chatId: CHAT_ID_TEST, - payload: encodeUtf8(`hello-${Date.now()}`), - idempotencyKey: id, - }); - await refresh(); - } catch (err) { - setLastError(String(err)); - } - }, [refresh]); - - const onMarkFirstSent = useCallback(async () => { - if (!rows[0]) return; - try { - const { db } = await getChatDb(); - await outbox.markSent(db, rows[0].id); - await refresh(); - } catch (err) { - setLastError(String(err)); - } - }, [rows, refresh]); - - // ── M3 handlers ───────────────────────────────────────────────────────── - - const onLookupPeer = useCallback(async () => { - setPeerErr(null); - setPeerDevices(null); - const id = peerInput.trim(); - if (!id) return; - try { - const map = await getPeerDevices([id]); - setPeerDevices(map.get(id) ?? []); - } catch (err) { - setPeerErr(String(err)); - } - }, [peerInput]); - - const peerTargets = useMemo(() => { - if (!peerDevices) return []; - const id = peerInput.trim(); - if (!id) return []; - return [{ accountId: id, devices: peerDevices }]; - }, [peerDevices, peerInput]); - - // Encrypt-only: shows the bytes that WOULD go over the wire, without - // actually sending. Lets the harness prove ciphertext + nonce are opaque - // and lets the wrong-key button operate on a known-good envelope. - const onEncryptPreview = useCallback(() => { - if (!identity) return; - if (peerTargets.length === 0 || peerTargets[0]?.devices.length === 0) { - setLastCipher(null); - setWrongKey({ attempted: false, threw: false, message: "" }); - return; - } - const envs = encryptOutbound({ - text, - seed: identity.seed, - targets: peerTargets, - }); - const first = envs[0]; - if (!first) return; - setLastCipher({ envelope: first, text, textBytes: text.length }); - setWrongKey({ attempted: false, threw: false, message: "" }); - }, [identity, peerTargets, text]); - - const onWrongKeyDecrypt = useCallback(() => { - if (!lastCipher) { - setWrongKey({ - attempted: true, - threw: false, - message: "no cipher captured — run Encrypt preview first", - }); - return; - } - const wrongSeed = ChatCrypto.generateIdentitySeed(); - try { - decryptInbound({ - ciphertext: lastCipher.envelope.ciphertext, - nonce: lastCipher.envelope.nonce, - senderAccountId: me?.accountId ?? "", - seed: wrongSeed, - candidateSenderX25519Pubs: identity ? [identity.x25519Pub] : [], - }); - setWrongKey({ - attempted: true, - threw: false, - message: - "FAIL — wrong-key decrypt unexpectedly succeeded. Crypto broken?", - }); - } catch (err) { - setWrongKey({ - attempted: true, - threw: true, - message: `OK — threw as expected: ${String(err).slice(0, 100)}`, - }); - } - }, [identity, lastCipher, me?.accountId]); - - const onSend = useCallback(async () => { - // v=2 sends route by chats.mls_group_id and don't need peer targets. - // The guard below applies to v=1 only — checked by peeking chat-db. - try { - const { db } = await getChatDb(); - const row = await db.execute( - "SELECT mls_group_id FROM chats WHERE id = ?", - [chatId], - ); - const hasMls = !!(row.rows?.[0] as { mls_group_id?: unknown } | undefined) - ?.mls_group_id; - if ( - !hasMls && - (peerTargets.length === 0 || peerTargets[0]?.devices.length === 0) - ) { - setSendResults((prev) => - [ - "err: v=1 send needs peer devices — lookup peer or create v=2 group first", - ...prev, - ].slice(0, 20), - ); - return; - } - const results = await transport.send({ - chatId, - text, - targets: peerTargets, - }); - setSendResults((prev) => - [ - `ok ${results.length} cipher(s):`, - ...results.map((r) => - r.frameVersion === 2 - ? ` → v=2 group · srv ${r.serverMsgId} · ${new Date(r.ts).toLocaleTimeString()}` - : ` → device ${r.recipientDeviceId.slice(0, 8)} · srv ${r.serverMsgId} · ${new Date(r.ts).toLocaleTimeString()}`, - ), - ...prev, - ].slice(0, 20), - ); - } catch (err) { - setSendResults((prev) => - [`err: ${String(err).slice(0, 120)}`, ...prev].slice(0, 20), - ); - } - }, [chatId, peerTargets, text, transport]); - - if (!__DEV__) { - return ( - - Not available in production. - - ); - } - - return ( - - - - chat-db debug · M3 acceptance - - - ws state: {connState} - - - {/* ── Chunk 0/1: chat-mls-core native bridge smoke ───────────── */} - - - {/* ── Chunk 5: MLS acceptance state ──────────────────────────── */} - - - {/* ── Chunk 7: MLS lifecycle harness ─────────────────────────── */} - - - {/* ── M3: My identity ─────────────────────────────────────────── */} - - - my identity - - {meErr ? ( - - account.me: {meErr} - - ) : ( - - accountId: {me?.accountId ?? "loading…"} - - )} - - deviceId: {identity?.deviceId ?? "?"} - - - ed25519 fp: {fp(identity?.ed25519Pub)} - - - x25519 fp: {fp(identity?.x25519Pub)} - - - - {/* ── M3: Peer lookup ─────────────────────────────────────────── */} - - - peer lookup - - - - - - {peerErr ? ( - - {peerErr} - - ) : null} - {peerDevices ? ( - peerDevices.length === 0 ? ( - - no devices for that accountId - - ) : ( - - {peerDevices.map((d) => ( - - {d.deviceId.slice(0, 8)} · ed {fp(d.ed25519Pub)} · x{" "} - {fp(d.x25519Pub)} - - ))} - - ) - ) : null} - - - {/* ── M3: Encrypt preview + wrong-key ─────────────────────────── */} - - - encrypt preview (no send) - - - - - - - {lastCipher ? ( - - - {`last text: "${lastCipher.text}" (${lastCipher.textBytes} chars)`} - - - cipher: {hex(lastCipher.envelope.ciphertext, 12)} - - - nonce: {hex(lastCipher.envelope.nonce, 8)} - - - → {lastCipher.envelope.recipientAccountId.slice(0, 8)} · device{" "} - {lastCipher.envelope.recipientDeviceId.slice(0, 8)} - - - ) : null} - {wrongKey.attempted ? ( - - {wrongKey.message} - - ) : null} - {identity ? ( - - sizes: SEED={SEED_BYTES} · ED_PUB={ED25519_PUBLIC_KEY_BYTES} · - X_PUB={X25519_PUBLIC_KEY_BYTES} - - ) : null} - - - {/* ── M3: Send via encrypted transport ────────────────────────── */} - - - send via encrypted transport - - - chatId (read-only — set by Create v=2 group): - - - {chatId} - - - message text: - - - - - - - - {sendResults.map((line, i) => ( - - {line} - - ))} - - - - {/* ── M3: Live decrypted inbox ────────────────────────────────── */} - - - live inbox · {inbox.length} - - {inbox.length === 0 ? ( - - waiting for inbound messages… - - ) : ( - - {inbox.map((m) => ( - - - {m.frame.text} - - - v={m.frameVersion} · from {m.senderId.slice(0, 8)} · chat{" "} - {m.chatId.slice(0, 8)} · srv {m.serverMsgId} ·{" "} - {new Date(m.ts).toLocaleTimeString()} - - - ))} - - )} - - - {/* M3.5 panels (signal prekey directory smoke + chat-version - sticky + v=2 send harness) removed per ADR-015. MLS-equivalent - panels (KeyPackage pool, current group epoch, ratchet tree hash) - land in Chunk 6 with the mobile MLS orchestrator. */} - - {/* ── M2: Outbox (existing) ───────────────────────────────────── */} - - - outbox (M2) - - - keySource: {keySource} · due rows: {rows.length} - - - - - - - {lastError ? ( - - err: {lastError} - - ) : null} - {rows.map((r) => ( - - - {r.id.slice(0, 8)} · idemp {r.idempotency_key.slice(0, 8)} · - attempts {r.attempts} - - - ))} - - - - ); -} diff --git a/apps/mobile/app/chat/[chatId]/_layout.tsx b/apps/mobile/app/chat/[chatId]/_layout.tsx new file mode 100644 index 0000000..15336b0 --- /dev/null +++ b/apps/mobile/app/chat/[chatId]/_layout.tsx @@ -0,0 +1,14 @@ +// Nested stack for a single chat: the thread (index) + info, plus the crypto +// inspector presented modally over the thread (crypto-inspector/DESIGN.md §4). +import { Stack } from "expo-router"; + +export default function ChatIdLayout() { + return ( + + + + ); +} diff --git a/apps/mobile/app/chat/[chatId]/index.tsx b/apps/mobile/app/chat/[chatId]/index.tsx index 48be2e2..6a0d1aa 100644 --- a/apps/mobile/app/chat/[chatId]/index.tsx +++ b/apps/mobile/app/chat/[chatId]/index.tsx @@ -1,13 +1,20 @@ -// Thread screen — inverted FlashList of messages, Tamagui composer, Telegram- -// ish bubbles. Outgoing right (brand), incoming left (surface). Per-bubble -// timestamp + sender display (groups only). Long-press on a failed own -// bubble opens BubbleActionSheet (retry / delete). +// Chat thread — Glacier / App-Light "Frozen Light reading room" (chat/DESIGN.md). +// Full-bleed message list with sticky day-dividers, incoming avatars, outgoing +// gradient bubbles, and a pinned Composer. Long-press any bubble → actions menu +// (Copy · Delete/Report · Inspect encryption → crypto inspector). import { useCallback, useMemo, useRef, useState } from "react"; -import { KeyboardAvoidingView, Platform, StyleSheet } from "react-native"; +import { + KeyboardAvoidingView, + Platform, + Pressable, + StyleSheet, +} from "react-native"; import { FlashList, type FlashListRef } from "@shopify/flash-list"; import { useLocalSearchParams, useRouter } from "expo-router"; -import { Button, Input, Spinner, Text, View, XStack, YStack } from "tamagui"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Feather } from "@expo/vector-icons"; +import { Spinner, XStack, YStack, useTheme } from "tamagui"; import { useChat, @@ -18,14 +25,57 @@ import { type Member, type Message, } from "@repo/chat"; +import { Avatar } from "@repo/ui/glacier/avatar"; +import { ChatBubble, DayDivider } from "@repo/ui/glacier/chat-bubble"; +import { Composer } from "@repo/ui/glacier/composer"; +import { HeadlineMd, BodyMd, Meta, Title } from "@repo/ui/glacier/typography"; import { useAuthStore } from "@/store/auth"; -import { BubbleActionSheet } from "@/lib/chat/components/BubbleActionSheet"; -import { MessageBubble } from "@/lib/chat/components/MessageBubble"; +import { MessageActionsSheet } from "@/lib/chat/components/MessageActionsSheet"; import { trpc } from "@/lib/trpc/client"; +function formatTime(ms: number): string { + const d = new Date(ms); + let h = d.getHours(); + const m = d.getMinutes().toString().padStart(2, "0"); + const ampm = h >= 12 ? "PM" : "AM"; + h = h % 12 || 12; + return `${h}:${m} ${ampm}`; +} + +function sameDay(a: number, b: number): boolean { + const da = new Date(a); + const db = new Date(b); + return ( + da.getFullYear() === db.getFullYear() && + da.getMonth() === db.getMonth() && + da.getDate() === db.getDate() + ); +} + +function dayLabel(ms: number): string { + const d = new Date(ms); + const now = new Date(); + if (sameDay(ms, now.getTime())) return `Today, ${formatTime(ms)}`; + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +type Row = + | { type: "divider"; id: string; label: string } + | { + type: "message"; + id: string; + message: Message; + isMine: boolean; + isLastInRun: boolean; + isFirstInRun: boolean; + sender: Member | null; + }; + export default function ChatThreadScreen() { const router = useRouter(); + const insets = useSafeAreaInsets(); + const theme = useTheme(); const params = useLocalSearchParams<{ chatId: string }>(); const chatId = params.chatId ?? ""; const { chat } = useChat(chatId); @@ -36,51 +86,67 @@ export default function ChatThreadScreen() { const myAuthUserId = useAuthStore((s) => s.session?.user.id ?? null); const [text, setText] = useState(""); const [sheetTarget, setSheetTarget] = useState(null); - const listRef = useRef>(null); + const listRef = useRef>(null); - // Members lookup table for sender display in group chats. + const isGroup = chat?.kind === "group"; const memberByAuthUserId = useMemo(() => { const map = new Map(); if (chat) for (const m of chat.members) map.set(m.authUserId, m); return map; }, [chat]); + // Flatten messages → rows with day-dividers + same-sender run metadata. + const rows = useMemo(() => { + const out: Row[] = []; + for (let i = 0; i < messages.length; i++) { + const cur = messages[i]!; + const prev = messages[i - 1]; + const next = messages[i + 1]; + if (!prev || !sameDay(prev.createdAt, cur.createdAt)) { + out.push({ + type: "divider", + id: `d-${cur.id}`, + label: dayLabel(cur.createdAt), + }); + } + const prevRun = + !!prev && + prev.senderAuthUserId === cur.senderAuthUserId && + sameDay(prev.createdAt, cur.createdAt); + const nextRun = + !!next && + next.senderAuthUserId === cur.senderAuthUserId && + sameDay(next.createdAt, cur.createdAt); + out.push({ + type: "message", + id: cur.id, + message: cur, + isMine: cur.senderAuthUserId === myAuthUserId, + isFirstInRun: !prevRun, + isLastInRun: !nextRun, + sender: memberByAuthUserId.get(cur.senderAuthUserId) ?? null, + }); + } + return out; + }, [messages, myAuthUserId, memberByAuthUserId]); + const onSend = useCallback(() => { if (!myAuthUserId) return; const trimmed = text.trim(); - if (trimmed.length === 0) return; + if (!trimmed) return; send({ chatId, text: trimmed, senderAuthUserId: myAuthUserId }); setText(""); - // Jump to the newest bubble once the optimistic row is laid out — your - // own sends always scroll to the bottom regardless of prior scroll pos. requestAnimationFrame(() => listRef.current?.scrollToEnd({ animated: true }), ); }, [text, send, chatId, myAuthUserId]); - const handleLongPress = useCallback( + const onInspect = useCallback( (m: Message) => { - const isMine = m.senderAuthUserId === myAuthUserId; - // Own bubbles only open the sheet when failed (retry/delete). Other - // users' bubbles always open it (report). Avoids a no-op animation - // for sent/sending own rows that have no actions yet. - if (isMine && m.status !== "failed") return; - setSheetTarget(m); + const msgId = m.serverSerial ?? m.clientMsgId; + router.push(`/chat/${chatId}/inspect/${msgId}` as never); }, - [myAuthUserId], - ); - - const renderItem = useCallback( - ({ item }: { item: Message }) => ( - - ), - [chat?.kind, handleLongPress, memberByAuthUserId, myAuthUserId], + [router, chatId], ); const title = useMemo(() => { @@ -90,82 +156,135 @@ export default function ChatThreadScreen() { const peer = chat.members.find((m) => m.authUserId !== myAuthUserId); return peer?.handle ?? peer?.displayName ?? "Direct chat"; } - const peers = chat.members - .filter((m) => m.authUserId !== myAuthUserId) - .map((m) => m.handle ?? m.displayName ?? "?"); - return peers.join(", ") || "Group"; + return ( + chat.members + .filter((m) => m.authUserId !== myAuthUserId) + .map((m) => m.handle ?? m.displayName ?? "?") + .join(", ") || "Group" + ); }, [chat, myAuthUserId]); + const ovc = theme.onSurfaceVariant?.val; + + const renderItem = useCallback( + ({ item }: { item: Row }) => { + if (item.type === "divider") return ; + const { message: m, isMine, isLastInRun, isFirstInRun, sender } = item; + const senderName = + isGroup && !isMine && isFirstInRun + ? (sender?.handle ?? sender?.displayName ?? "Unknown") + : null; + const receipt = + isMine && m.status === "sent" ? ( + + ) : null; + + const bubble = ( + setSheetTarget(m)} + /> + ); + + return ( + setSheetTarget(m)} delayLongPress={300}> + + {isMine ? ( + bubble + ) : ( + + {isLastInRun ? ( + + ) : ( + + )} + {/* flex={1} gives the bubble a definite-width column parent so + its maxWidth:"85%" resolves — without it the Text collapses + to min-content (~30px) inside the row. */} + + {bubble} + + + )} + + + ); + }, + [isGroup, ovc], + ); + return ( - - - - - {title} - - {chat && chat.kind === "group" && ( - - {chat.members.length} members - - )} - - - - - - {messages.length === 0 ? ( + + router.back()} + icon={} + /> + + {title} + {/* Presence is a static placeholder — no presence field on the + model yet. Group chats show member count instead. */} + + {isGroup ? `${chat?.members.length ?? 0} members` : "online"} + + + router.push(`/chat/${chatId}/info` as never)} + icon={} + /> + + + + {/* Messages */} + + {rows.length === 0 ? ( {chat ? ( <> - - No messages yet - - - Say hello - + Say hello + + This conversation is end-to-end encrypted. + ) : ( - + )} ) : ( m.id} + data={rows} + keyExtractor={(r) => r.id} renderItem={renderItem} - // v2 chat pattern: render from the bottom and auto-stick to the - // newest message when the user is near the bottom. Replaces the - // v1 `inverted` transform, which fought v2's default - // maintainVisibleContentPosition and left new bubbles off-screen - // until an unrelated re-layout (the "press return to see it" bug). + getItemType={(r) => r.type} maintainVisibleContentPosition={{ startRenderingFromBottom: true, autoscrollToBottomThreshold: 0.2, @@ -173,43 +292,29 @@ export default function ChatThreadScreen() { contentContainerStyle={styles.listContent} /> )} - - - - - - + + + {/* Composer */} + } + renderSendIcon={(active) => ( + + )} + /> - setSheetTarget(null)} onRetry={(m) => void retry({ chatId: m.chatId, clientMsgId: m.clientMsgId }) @@ -217,15 +322,11 @@ export default function ChatThreadScreen() { onDelete={(m) => void deleteMessage({ chatId: m.chatId, clientMsgId: m.clientMsgId }) } + onInspect={onInspect} onReport={(m, reason) => { - // Fire-and-forget — UI returns to the thread immediately. The - // backend already de-dupes identical (reporter, target, reason) - // so accidental double-fires are safe. void trpc.reports.create .mutate({ targetType: "MESSAGE", - // serverSerial is the authoritative server id; clientMsgId - // works for pending rows but those shouldn't be reportable. targetId: m.serverSerial ?? m.clientMsgId, reason, }) @@ -236,6 +337,33 @@ export default function ChatThreadScreen() { ); } +// Borderless 44×44 header icon button (search/back/call). Ghost Button carries a +// border; header chrome in the design is borderless, so this is a bare press. +function IconButton({ + icon, + label, + onPress, +}: { + icon: React.ReactNode; + label: string; + onPress: () => void; +}) { + return ( + + {icon} + + ); +} + const styles = StyleSheet.create({ flex: { flex: 1 }, listContent: { paddingVertical: 8 }, diff --git a/apps/mobile/app/chat/[chatId]/info.tsx b/apps/mobile/app/chat/[chatId]/info.tsx index a777c57..b66edbd 100644 --- a/apps/mobile/app/chat/[chatId]/info.tsx +++ b/apps/mobile/app/chat/[chatId]/info.tsx @@ -6,10 +6,14 @@ // default). Roles can land in M8 if needed. import { useCallback, useEffect, useState } from "react"; -import { Alert, Pressable, StyleSheet } from "react-native"; +import { Alert, StyleSheet } from "react-native"; import { FlashList } from "@shopify/flash-list"; import { useLocalSearchParams, useRouter } from "expo-router"; -import { Button, Input, Spinner, Text, View, XStack, YStack } from "tamagui"; +import { Feather } from "@expo/vector-icons"; +import { Button, Spinner, Text, View, XStack, YStack, useTheme } from "tamagui"; +import { TextField } from "@repo/ui/glacier/text-field"; +import { ListRow } from "@repo/ui/glacier/list-row"; +import { Title, HeadlineMd, BodySm, Label } from "@repo/ui/glacier/typography"; import { useChat, @@ -28,7 +32,6 @@ import { const SEARCH_DEBOUNCE_MS = 250; const MIN_QUERY_LEN = 2; -const ERROR_COLOR = "#dc2626"; interface SearchedUser { accountId: string; @@ -38,6 +41,8 @@ interface SearchedUser { export default function ChatInfoScreen() { const router = useRouter(); + const theme = useTheme(); + const iconColor = theme.onSurfaceVariant.val; const params = useLocalSearchParams<{ chatId: string }>(); const chatId = params.chatId ?? ""; const { chat } = useChat(chatId); @@ -195,7 +200,7 @@ export default function ChatInfoScreen() { await trpc.chat.leave.mutate({ chatId: chat.id }); removeChatFromStore(chat.id); clearChatGroupCache(chat.id); - router.replace("/chats" as never); + router.replace("/(tabs)" as never); } catch (err) { setError(err instanceof Error ? err.message : String(err)); setPending(null); @@ -230,15 +235,15 @@ export default function ChatInfoScreen() { py="$3" alignItems="center" justifyContent="space-between" - borderBottomWidth={1} - borderColor="$borderColor" + borderBottomWidth={0.5} + borderColor="$outlineVariant" > - - Chat info - + Chat info @@ -247,19 +252,15 @@ export default function ChatInfoScreen() { keyExtractor={(m) => m.accountId} ListHeaderComponent={ - + {chat.name ?? (chat.kind === "direct" ? "Direct chat" : "Group")} - - + + {chat.kind === "group" ? `Group · ${chat.members.length} members` : "Direct chat"} - - {error && ( - - {error} - - )} + + {error && {error}} } renderItem={({ item }) => ( @@ -277,10 +278,9 @@ export default function ChatInfoScreen() { {chat.kind === "group" && ( - - Add members - - Add members + } value={query} onChangeText={setQuery} placeholder="Search by handle…" @@ -288,39 +288,33 @@ export default function ChatInfoScreen() { autoCorrect={false} /> {searching ? ( - + ) : query.trim().length >= MIN_QUERY_LEN && results.length === 0 ? ( - + No matches (already in chat?) - + ) : null} {results.map((u) => ( - void onAddMember(u)} - > - - - {u.displayName} - - @{u.handle} - - - {pending === `add:${u.accountId}` ? ( - + name={u.displayName} + preview={`@${u.handle}`} + timestamp="" + avatar={{ name: u.handle, seed: u.accountId }} + receipt={ + pending === `add:${u.accountId}` ? ( + ) : ( - + Add - )} - - + + ) + } + onPress={() => void onAddMember(u)} + /> ))} )} @@ -331,7 +325,7 @@ export default function ChatInfoScreen() { {chat.kind === "direct" && otherMembers[0] && ( router.replace("/chats" as never)} + onBlocked={() => router.replace("/(tabs)" as never)} /> )} @@ -339,21 +333,22 @@ export default function ChatInfoScreen() { size="$3" disabled={pending === "leave"} onPress={onLeave} - style={{ backgroundColor: ERROR_COLOR }} + backgroundColor="$error" + pressStyle={{ backgroundColor: "$error", opacity: 0.85 }} > {pending === "leave" ? ( - + ) : ( - + Leave chat )} {otherMembers.length === 0 && chat.kind === "direct" && ( - + The other member left. - + )} } @@ -449,9 +444,11 @@ function DirectChatTrustActions({ onPress={onReport} > {busy === "report" ? ( - + ) : ( - Report user + + Report user + )} @@ -484,50 +483,30 @@ function MemberRow({ isPending: boolean; onRemove: () => void; }) { + const name = member.displayName ?? member.handle ?? "Unknown"; return ( - - - - {(member.handle ?? member.displayName ?? "?") - .slice(0, 2) - .toUpperCase()} - - - - - {member.displayName ?? member.handle ?? "Unknown"} - {isMe ? " (you)" : ""} - - {member.handle && ( - - @{member.handle} - - )} - - {canRemove && ( - - )} - + + ) + ) : null + } + /> ); } diff --git a/apps/mobile/app/chat/[chatId]/inspect/[serverMsgId].tsx b/apps/mobile/app/chat/[chatId]/inspect/[serverMsgId].tsx new file mode 100644 index 0000000..b9486f6 --- /dev/null +++ b/apps/mobile/app/chat/[chatId]/inspect/[serverMsgId].tsx @@ -0,0 +1,361 @@ +// Crypto Inspector — "The server never sees plaintext" (crypto-inspector/ +// DESIGN.md). App-Light modal. Three panes make E2EE physically visible: dim +// ciphertext on the wire (A), bright plaintext on this device (B), gibberish at +// rest on disk (C); a proof strip shows the wrong key can't open it. +// +// SCOPE (this pass = UI shell + choreography): +// • Pane B plaintext is REAL (from the chat store). +// • Pane A/C bytes are ILLUSTRATIVE (see lib/chat/inspector-demo.ts) — frame +// retention (DESIGN §11) is the deferred follow-up that swaps in the actual +// wire bytes. The byte-count footer is tagged "illustrative" so nothing +// claims to be the literal frame. +// • "Try the wrong key" simulates the AEAD failure result; the real +// decryptInbound throw (DESIGN §11) is the deferred follow-up. + +import { useEffect, useMemo, useState } from "react"; +import { ScrollView } from "react-native"; +import * as Clipboard from "expo-clipboard"; +import * as Haptics from "expo-haptics"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Feather } from "@expo/vector-icons"; +import Animated, { + FadeIn, + FadeInUp, + useAnimatedStyle, + useReducedMotion, + useSharedValue, + withSequence, + withTiming, +} from "react-native-reanimated"; +import { Spinner, XStack, YStack, useTheme } from "tamagui"; + +import { useChat, useMessages, type Message } from "@repo/chat"; +import { useAuthStore } from "@/store/auth"; +import { Button } from "@repo/ui/glacier/button"; +import { + InspectorPane, + PaneMonoContent, + PanePlaintext, +} from "@repo/ui/glacier/inspector-pane"; +import { BodyLg, BodySm, HeadlineMd, Meta } from "@repo/ui/glacier/typography"; + +import { + deterministicBytes, + groupHex, + hexdumpLines, + shortHex, +} from "@/lib/chat/inspector-demo"; + +const INSPECTOR_ENABLED = + __DEV__ || process.env.EXPO_PUBLIC_DEMO_CRYPTO_INSPECTOR === "1"; + +// Random-glyph settle for Pane A ("this is the raw machine view", DESIGN §7.2). +function useScramble(final: string, disabled: boolean): string { + const [out, setOut] = useState(disabled ? final : ""); + useEffect(() => { + if (disabled) { + setOut(final); + return; + } + const glyphs = "0123456789abcdef"; + const dur = 520; + let raf = 0; + let start: number | undefined; + const tick = (t: number) => { + if (start === undefined) start = t; + const p = Math.min(1, (t - start) / dur); + const reveal = Math.floor(p * final.length); + let s = final.slice(0, reveal); + for (let i = reveal; i < final.length; i++) { + const c = final[i]; + s += c === " " ? " " : glyphs[Math.floor(Math.random() * 16)]; + } + setOut(s); + if (p < 1) raf = requestAnimationFrame(tick); + else setOut(final); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [final, disabled]); + return out; +} + +function timeOf(ms: number): string { + const d = new Date(ms); + let h = d.getHours(); + const m = d.getMinutes().toString().padStart(2, "0"); + const ap = h >= 12 ? "PM" : "AM"; + h = h % 12 || 12; + return `${h}:${m} ${ap}`; +} + +export default function InspectScreen() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const theme = useTheme(); + const reduced = useReducedMotion(); + const params = useLocalSearchParams<{ + chatId: string; + serverMsgId: string; + }>(); + const chatId = params.chatId ?? ""; + const serverMsgId = params.serverMsgId ?? ""; + + const { chat } = useChat(chatId); + const { messages } = useMessages(chatId); + const myId = useAuthStore((s) => s.session?.user.id ?? null); + const [showHow, setShowHow] = useState(false); + const [wrongKey, setWrongKey] = useState(false); + + const message: Message | undefined = useMemo( + () => + messages.find( + (m) => + m.serverSerial === serverMsgId || + m.clientMsgId === serverMsgId || + m.id === serverMsgId, + ), + [messages, serverMsgId], + ); + + const senderLabel = useMemo(() => { + if (!message) return ""; + if (message.senderAuthUserId === myId) return "you"; + const m = chat?.members.find( + (x) => x.authUserId === message.senderAuthUserId, + ); + const n = m?.handle ?? m?.displayName ?? "peer"; + return n.split(/\s+/)[0] ?? n; + }, [message, myId, chat]); + + // Illustrative wire/at-rest bytes (see file header + inspector-demo.ts). + const cipher = useMemo(() => { + const len = 200 + ((message?.text.length ?? 20) % 60); + return deterministicBytes(serverMsgId + ":wire", len); + }, [serverMsgId, message?.text.length]); + const cipherHex = useMemo(() => groupHex(cipher), [cipher]); + const diskHex = useMemo( + () => hexdumpLines(deterministicBytes(serverMsgId + ":disk", 24)), + [serverMsgId], + ); + const scrambled = useScramble(cipherHex, reduced || !message); + + // Wrong-key proof: shake the result line (DESIGN §7.4). + const shake = useSharedValue(0); + const shakeStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: shake.value }], + })); + const onWrongKey = () => { + setWrongKey(true); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + if (!reduced) { + shake.value = withSequence( + withTiming(-4, { duration: 40 }), + withTiming(4, { duration: 40 }), + withTiming(-4, { duration: 40 }), + withTiming(0, { duration: 40 }), + ); + } + }; + + const ink = theme.onSurfaceVariant?.val; + const primary = theme.primary?.val; + + const copyHex = async () => { + await Clipboard.setStringAsync(cipherHex); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + }; + + if (!INSPECTOR_ENABLED) { + return ( + + Not available in this build. + + ); + } + + const frameLabel = chat?.kind === "group" ? "v2 (MLS group)" : "v1 (box)"; + + return ( + + {/* Header */} + + + router.back()} + > + + + Inspect encryption + setShowHow((v) => !v)} + > + + + + + {`Message · srv ${shortHex(serverMsgId, 2)} · ${frameLabel}`} + + + + {showHow ? ( + + + The box/MLS ciphertext leaves the device; the server stores and + routes bytes it can’t read. Only devices in the group hold the keys. + + + ) : null} + + {!message ? ( + + {messages.length === 0 ? ( + + ) : ( + Message not found. + )} + + ) : ( + + {/* Pane A — On the wire (illustrative bytes) */} + } + action={ + + } + > + + {scrambled} + + + {`nonce ${shortHex(serverMsgId + ":n")} · ${cipher.length} B · illustrative`} + + + + {/* Pane B — On this device (REAL plaintext) */} + + } + > + {message.text} + + {`from ${senderLabel} · ${timeOf(message.createdAt)}`} + + + + + {/* Pane C — At rest on disk (SQLCipher, illustrative dump) */} + } + > + + + {diskHex.map((line) => ( + + {line} + + ))} + + + + key: Secure Enclave · never leaves device + + + + {/* Proof strip — wrong key can't open it */} + + + {wrongKey ? ( + + + ✗ Can’t open it — wrong key, AEAD auth failed. As it must. + + + ) : null} + + + {/* Closing line */} + + The server only ever holds the dim column. The bright one exists + nowhere but this phone. + + + )} + + ); +} diff --git a/apps/mobile/app/chat/new.tsx b/apps/mobile/app/chat/new.tsx index 8b07dd4..e1dfdf8 100644 --- a/apps/mobile/app/chat/new.tsx +++ b/apps/mobile/app/chat/new.tsx @@ -5,15 +5,11 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Pressable, StyleSheet } from "react-native"; import { FlashList } from "@shopify/flash-list"; import { useRouter } from "expo-router"; -import { - Button, - Input, - Spinner, - Text, - View, - XStack, - YStack, -} from "tamagui"; +import { Feather } from "@expo/vector-icons"; +import { Button, Spinner, Text, View, XStack, YStack, useTheme } from "tamagui"; +import { TextField } from "@repo/ui/glacier/text-field"; +import { ListRow } from "@repo/ui/glacier/list-row"; +import { Title, Label, BodyMd, BodySm } from "@repo/ui/glacier/typography"; import { trpc } from "@/lib/trpc/client"; import { createNewChat } from "@/lib/chat/create-chat"; @@ -31,6 +27,8 @@ type Mode = "direct" | "group"; export default function NewChatScreen() { const router = useRouter(); + const theme = useTheme(); + const iconColor = theme.onSurfaceVariant.val; const [mode, setMode] = useState("direct"); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); @@ -128,22 +126,29 @@ export default function NewChatScreen() { py="$3" alignItems="center" justifyContent="space-between" - borderBottomWidth={1} - borderColor="$borderColor" + borderBottomWidth={0.5} + borderColor="$outlineVariant" > - - New Chat - + New Chat @@ -162,9 +167,7 @@ export default function NewChatScreen() { {mode === "group" && selected.length > 0 && ( - - Selected ({selected.length}) - + {selected.map((u) => ( removeSelected(u.accountId)} > - {u.handle} - - × - + {u.handle} + × ))} @@ -192,7 +193,7 @@ export default function NewChatScreen() { {mode === "group" && selected.length >= 2 && ( - - } value={query} onChangeText={setQuery} placeholder="Search by handle…" @@ -214,31 +216,24 @@ export default function NewChatScreen() { {error && ( - - {error} - + {error} )} {searching ? ( - + ) : query.trim().length < MIN_QUERY_LEN ? ( - + Type at least {MIN_QUERY_LEN} characters - + ) : results.length === 0 ? ( - No matches + No matches ) : ( - + {label} @@ -286,6 +285,9 @@ function ModeTab({ ); } +// Search result row — same Glacier ListRow the chat list uses, so the picker +// reads as one surface. Name is the display name, handle sits in the preview +// line, and group multi-select uses the trailing receipt slot for the tick. function UserRow({ user, isSelected, @@ -295,44 +297,20 @@ function UserRow({ isSelected: boolean; onPress: () => void; }) { + const theme = useTheme(); return ( - - - - - {user.handle.slice(0, 2).toUpperCase()} - - - - - {user.displayName} - - - @{user.handle} - - - {isSelected && ( - - ✓ - - )} - - + + ) : null + } + onPress={onPress} + /> ); } diff --git a/apps/mobile/index.js b/apps/mobile/index.js index 07088ad..c380e18 100644 --- a/apps/mobile/index.js +++ b/apps/mobile/index.js @@ -2,5 +2,6 @@ // Order matters — teleport (Sheet/Dialog/Popover) first, then gesture handler. import "@tamagui/native/setup-teleport"; import "@tamagui/native/setup-gesture-handler"; +import "@tamagui/native/setup-expo-linear-gradient"; import "expo-router/entry"; diff --git a/apps/mobile/lib/auth/ui.tsx b/apps/mobile/lib/auth/ui.tsx new file mode 100644 index 0000000..e2fd504 --- /dev/null +++ b/apps/mobile/lib/auth/ui.tsx @@ -0,0 +1,112 @@ +// Shared auth-flow UI (login/DESIGN.md). One shell for sign-in / sign-up / +// forgot-password: full-bleed $surface screen, centred column, brand halo + +// "Mortstack" wordmark, subtitle, then the screen's form. Lives under lib/ (not +// app/) so expo-router doesn't treat it as a route. +import type { ReactNode } from "react"; +import { ScrollView } from "react-native"; +import { Link } from "expo-router"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; +import { Text, XStack, YStack, useTheme } from "tamagui"; +import { HeadlineLg, BodySm } from "@repo/ui/glacier/typography"; + +// Brand mark — cyan ring disc + soft halo + teal chat glyph (login/DESIGN.md). +function LogoMark() { + const theme = useTheme(); + return ( + + + + ); +} + +export function AuthShell({ + subtitle, + children, + footer, +}: { + subtitle: string; + children: ReactNode; + footer: ReactNode; +}) { + return ( + + + + + + + Mortstack + + {subtitle} + + + + + {children} + + {footer} + + + + ); +} + +// Centred footer line: prompt in $onSurfaceVariant, action word as a $primary +// weight-600 Link (login/DESIGN.md). +export function AuthFooter({ + prompt, + action, + href, +}: { + prompt: string; + action: string; + href: string; +}) { + return ( + + {prompt} + + + {action} + + + + ); +} diff --git a/apps/mobile/lib/chat/components/BubbleActionSheet.tsx b/apps/mobile/lib/chat/components/BubbleActionSheet.tsx deleted file mode 100644 index f1d7e11..0000000 --- a/apps/mobile/lib/chat/components/BubbleActionSheet.tsx +++ /dev/null @@ -1,113 +0,0 @@ -// Bottom-sheet action menu for message bubbles. Two modes: -// - Own bubble in status="failed" → Retry + Delete (Plan 1 retry path). -// - Other user's bubble → Report message (App Store Guideline 1.2). -// Own bubbles in status "sending" / "sent" don't open a sheet (edit / -// delete-sent are post-MVP). - -import { Alert } from "react-native"; -import { Button, Sheet, Text, YStack } from "tamagui"; - -import type { Message } from "@repo/chat"; - -export interface BubbleActionSheetProps { - message: Message | null; - isMine: boolean; - onClose: () => void; - onRetry: (message: Message) => void; - onDelete: (message: Message) => void; - onReport: (message: Message, reason: "SPAM" | "HARASSMENT" | "OTHER") => void; -} - -export function BubbleActionSheet({ - message, - isMine, - onClose, - onRetry, - onDelete, - onReport, -}: BubbleActionSheetProps) { - const ownFailed = isMine && message?.status === "failed"; - const otherMode = !isMine && !!message; - const open = ownFailed || otherMode; - - const handleReport = () => { - if (!message) return; - Alert.alert( - "Report this message?", - "We'll review within 24 hours. Choose a reason:", - [ - { text: "Cancel", style: "cancel" }, - { text: "Spam", onPress: () => onReport(message, "SPAM") }, - { - text: "Harassment", - onPress: () => onReport(message, "HARASSMENT"), - }, - { text: "Other", onPress: () => onReport(message, "OTHER") }, - ], - ); - onClose(); - }; - - return ( - { - if (!next) onClose(); - }} - dismissOnSnapToBottom - snapPointsMode="fit" - > - - - - {ownFailed ? ( - <> - - Message failed to send - - - The server didn’t accept this message after several attempts. - - - - - - - - ) : ( - <> - - Message actions - - - - - - - )} - - - ); -} diff --git a/apps/mobile/lib/chat/components/MessageActionsSheet.tsx b/apps/mobile/lib/chat/components/MessageActionsSheet.tsx new file mode 100644 index 0000000..2bae9ee --- /dev/null +++ b/apps/mobile/lib/chat/components/MessageActionsSheet.tsx @@ -0,0 +1,173 @@ +// Long-press context menu for a message bubble (chat/DESIGN.md §Long-press). +// Everyday actions (Copy · Delete/Report · Retry-on-failed) sit above a hairline; +// the advanced "Inspect encryption" entry sits below it and opens the crypto +// inspector for THIS message. Inspect is demo-gated (__DEV__ / demo flag). +import { Alert } from "react-native"; +import * as Clipboard from "expo-clipboard"; +import * as Haptics from "expo-haptics"; +import { Feather, MaterialCommunityIcons } from "@expo/vector-icons"; +import { Separator, Sheet, XStack, useTheme } from "tamagui"; + +import type { Message } from "@repo/chat"; +import { Label } from "@repo/ui/glacier/typography"; + +const INSPECTOR_ENABLED = + __DEV__ || process.env.EXPO_PUBLIC_DEMO_CRYPTO_INSPECTOR === "1"; + +export interface MessageActionsSheetProps { + message: Message | null; + isMine: boolean; + onClose: () => void; + onDelete: (message: Message) => void; + onRetry: (message: Message) => void; + onReport: (message: Message, reason: "SPAM" | "HARASSMENT" | "OTHER") => void; + onInspect: (message: Message) => void; +} + +function MenuItem({ + icon, + label, + danger, + onPress, +}: { + icon: React.ReactNode; + label: string; + danger?: boolean; + onPress: () => void; +}) { + return ( + + {icon} + + + ); +} + +export function MessageActionsSheet({ + message, + isMine, + onClose, + onDelete, + onRetry, + onReport, + onInspect, +}: MessageActionsSheetProps) { + const theme = useTheme(); + const open = message !== null; + const isFailed = message?.status === "failed"; + const ink = theme.onSurface?.val; + const err = theme.error?.val; + const primary = theme.primary?.val; + + const handleCopy = async () => { + if (message) { + await Clipboard.setStringAsync(message.text); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + onClose(); + }; + + const handleReport = () => { + if (!message) return; + Alert.alert( + "Report this message?", + "We'll review within 24 hours. Choose a reason:", + [ + { text: "Cancel", style: "cancel" }, + { text: "Spam", onPress: () => onReport(message, "SPAM") }, + { text: "Harassment", onPress: () => onReport(message, "HARASSMENT") }, + { text: "Other", onPress: () => onReport(message, "OTHER") }, + ], + ); + onClose(); + }; + + return ( + { + if (!next) onClose(); + }} + dismissOnSnapToBottom + snapPointsMode="fit" + > + + + + + } + label="Copy" + onPress={handleCopy} + /> + + {isMine && isFailed ? ( + } + label="Retry" + onPress={() => { + if (message) onRetry(message); + onClose(); + }} + /> + ) : null} + + {isMine ? ( + } + label="Delete" + danger + onPress={() => { + if (message) onDelete(message); + onClose(); + }} + /> + ) : ( + } + label="Report" + onPress={handleReport} + /> + )} + + {INSPECTOR_ENABLED ? ( + <> + + + } + label="Inspect encryption" + onPress={() => { + if (message) onInspect(message); + onClose(); + }} + /> + + ) : null} + + + ); +} diff --git a/apps/mobile/lib/chat/components/MessageBubble.tsx b/apps/mobile/lib/chat/components/MessageBubble.tsx deleted file mode 100644 index e0ebd05..0000000 --- a/apps/mobile/lib/chat/components/MessageBubble.tsx +++ /dev/null @@ -1,108 +0,0 @@ -// Chat message bubble — handles the three lifecycle states the store -// exposes (sending / sent / failed) with distinct visual cues + a -// long-press hook that opens BubbleActionSheet for retry/delete/copy. -// -// Extracted from chat/[chatId]/index.tsx so action handling stays out of -// the screen file's render path. - -import { useCallback } from "react"; -import { Pressable } from "react-native"; -import { Text, XStack, YStack } from "tamagui"; - -import type { Member, Message } from "@repo/chat"; - -function formatTime(ms: number): string { - const d = new Date(ms); - return `${d.getHours().toString().padStart(2, "0")}:${d - .getMinutes() - .toString() - .padStart(2, "0")}`; -} - -function statusIcon(status: Message["status"]): string { - if (status === "sending") return "⌛"; - if (status === "failed") return "⚠︎"; - return "✓"; -} - -export interface MessageBubbleProps { - message: Message; - isMine: boolean; - isGroup: boolean; - sender: Member | null; - // Fires when the user long-presses an own bubble. Caller opens the - // action sheet — kept as a prop (rather than the bubble owning the - // sheet) so a single sheet instance can be shared across the list and - // animated independently of any single row. - onLongPress?: (message: Message) => void; -} - -export function MessageBubble({ - message, - isMine, - isGroup, - sender, - onLongPress, -}: MessageBubbleProps) { - // Long-press is enabled for both own and other bubbles. Own bubbles - // surface retry/delete (failed only); other bubbles surface Report. The - // sheet caller routes by `isMine`, the bubble just forwards the press. - const handleLongPress = useCallback(() => { - onLongPress?.(message); - }, [message, onLongPress]); - - const isPending = message.status === "sending"; - const isFailed = message.status === "failed"; - - // Visual deltas: - // pending → 60% opacity so the bubble reads as not-yet-confirmed - // failed → 2px red left border + footer "Failed to send" label - // sent → full opacity, no extra chrome - return ( - - - - {!isMine && isGroup && ( - - {sender?.handle ?? sender?.displayName ?? "Unknown"} - - )} - - {message.text} - - - - {formatTime(message.createdAt)} - - {isMine && ( - - {statusIcon(message.status)} - - )} - - {isMine && isFailed && ( - - Failed to send · long-press to retry - - )} - - - - ); -} diff --git a/apps/mobile/lib/chat/inspector-demo.ts b/apps/mobile/lib/chat/inspector-demo.ts new file mode 100644 index 0000000..e3eb84c --- /dev/null +++ b/apps/mobile/lib/chat/inspector-demo.ts @@ -0,0 +1,68 @@ +// Deterministic byte/hex helpers for the crypto inspector's UI shell. +// +// IMPORTANT: these produce ILLUSTRATIVE bytes derived from a message id — they +// are NOT the literal frame that crossed the wire. The real inspector (v1) +// retains the actual { ciphertext, nonce, recipientDeviceId } at send time +// (crypto-inspector/DESIGN.md §11) and renders those. This shell uses stable +// stand-ins so the layout + reveal choreography can be built now; swap the data +// source in `` once frame retention lands. Never present these as +// "the exact bytes the server received". + +function xmur3(str: string): () => number { + let h = 1779033703 ^ str.length; + for (let i = 0; i < str.length; i++) { + h = Math.imul(h ^ str.charCodeAt(i), 3432918353); + h = (h << 13) | (h >>> 19); + } + return () => { + h = Math.imul(h ^ (h >>> 16), 2246822507); + h = Math.imul(h ^ (h >>> 13), 3266489909); + h ^= h >>> 16; + return h >>> 0; + }; +} + +/** Stable pseudo-random bytes for a seed. Illustrative only (see file note). */ +export function deterministicBytes(seed: string, n: number): number[] { + const rand = xmur3(seed); + const out: number[] = []; + for (let i = 0; i < n; i++) out.push(rand() & 0xff); + return out; +} + +function hex2(b: number): string { + return b.toString(16).padStart(2, "0"); +} + +/** Group hex in 3-byte clusters, e.g. "9f2ac4 71bd0e 55..". */ +export function groupHex(bytes: number[]): string { + const clusters: string[] = []; + for (let i = 0; i < bytes.length; i += 3) { + clusters.push( + bytes + .slice(i, i + 3) + .map(hex2) + .join(""), + ); + } + return clusters.join(" "); +} + +/** Short hex tag, e.g. nonce "c1a4…" or device short-id "3b9f…". */ +export function shortHex(seed: string, nBytes = 2): string { + return deterministicBytes(seed, nBytes).map(hex2).join("") + "…"; +} + +/** Classic hexdump slice for Pane C, e.g. "00000000: 53 51 4c 43 …". */ +export function hexdumpLines(bytes: number[], cols = 8, lines = 3): string[] { + const out: string[] = []; + for (let row = 0; row < lines; row++) { + const offset = (row * cols).toString(16).padStart(8, "0"); + const slice = bytes + .slice(row * cols, row * cols + cols) + .map(hex2) + .join(" "); + out.push(`${offset}: ${slice}`); + } + return out; +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index be76c42..ea59ba0 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -21,6 +21,9 @@ }, "dependencies": { "@expo-google-fonts/ibm-plex-sans": "^0.3.0", + "@expo-google-fonts/jetbrains-mono": "^0.4.1", + "@expo-google-fonts/plus-jakarta-sans": "^0.4.2", + "@expo-google-fonts/sora": "^0.4.2", "@op-engineering/op-sqlite": "^14.1.0", "@repo/api-server": "workspace:*", "@repo/chat": "workspace:*", @@ -29,7 +32,7 @@ "@repo/chat-db": "workspace:*", "@repo/chat-mls-core": "workspace:*", "@repo/chat-transport": "workspace:*", - "@repo/schemas": "workspace:*", + "@repo/ui": "workspace:*", "@shopify/flash-list": "^2.3.1", "@tamagui/animations-react-native": "^2.0.0-rc.41", "@tamagui/config": "^2.0.0-rc.41", @@ -40,15 +43,19 @@ "@trpc/client": "^11.17.0", "better-auth": "^1.3.0", "expo": "~54.0.0", + "expo-blur": "~15.0.8", "expo-build-properties": "~0.14.0", + "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.0", "expo-crypto": "~15.0.0", "expo-dev-client": "~6.0.20", "expo-device": "~8.0.7", "expo-file-system": "~19.0.18", - "expo-notifications": "~0.32.12", "expo-font": "~14.0.0", + "expo-haptics": "~15.0.8", + "expo-linear-gradient": "~15.0.8", "expo-linking": "~8.0.0", + "expo-notifications": "~0.32.12", "expo-router": "~6.0.0", "expo-secure-store": "~15.0.0", "expo-splash-screen": "~31.0.0", diff --git a/apps/mobile/providers/index.tsx b/apps/mobile/providers/index.tsx index 4b74ff6..f2fc3da 100644 --- a/apps/mobile/providers/index.tsx +++ b/apps/mobile/providers/index.tsx @@ -1,5 +1,4 @@ import type { ReactNode } from "react"; -import { useColorScheme } from "react-native"; import { TamaguiProvider } from "tamagui"; import { QueryClientProvider } from "@tanstack/react-query"; import { GestureHandlerRootView } from "react-native-gesture-handler"; @@ -9,14 +8,12 @@ import { ChatTransportProvider } from "@/lib/chat/transport"; import { MobileChatStoreProvider } from "@/lib/chat/store-provider"; export function Providers({ children }: { children: ReactNode }) { - const colorScheme = useColorScheme(); - + // Glacier is light-only (THEME §1). The crypto inspector — the one surface + // that used to be dark — moved to App (Light) in its DESIGN.md 2.1.0, so we + // pin `light` rather than following the OS colour scheme. return ( - + {children} diff --git a/apps/mobile/tamagui.config.ts b/apps/mobile/tamagui.config.ts index ee618c1..8f50926 100644 --- a/apps/mobile/tamagui.config.ts +++ b/apps/mobile/tamagui.config.ts @@ -1,306 +1,6 @@ -import { createFont, createTamagui, createTokens } from "tamagui"; -import { createAnimations } from "@tamagui/animations-react-native"; - -// ── Animations ──────────────────────────────────────────────────────────────── -const animations = createAnimations({ - fast: { - damping: 20, - mass: 1.2, - stiffness: 250, - }, - medium: { - damping: 10, - mass: 0.9, - stiffness: 100, - }, - slow: { - damping: 20, - stiffness: 60, - }, -}); - -// ── IBM Plex Sans font ──────────────────────────────────────────────────────── -// Font data loaded via useFonts() in _layout.tsx — keys must match exactly. -const ibmPlexSans = createFont({ - family: "IBMPlexSans", - size: { - 1: 11, - 2: 12, - 3: 13, - 4: 14, - 5: 15, - 6: 16, - 7: 20, - 8: 23, - 9: 30, - 10: 46, - 11: 55, - 12: 62, - 13: 72, - 14: 92, - 15: 114, - 16: 134, - }, - lineHeight: { - 1: 17, - 2: 18, - 3: 19, - 4: 20, - 5: 22, - 6: 24, - 7: 28, - 8: 32, - 9: 40, - 10: 56, - }, - weight: { - 1: "400", - 2: "500", - 3: "600", - 4: "700", - }, - letterSpacing: { - 1: 0, - 2: -0.5, - 3: -1, - }, - face: { - 400: { - normal: "IBMPlexSans_400Regular", - italic: "IBMPlexSans_400Regular_Italic", - }, - 500: { - normal: "IBMPlexSans_500Medium", - italic: "IBMPlexSans_500Medium_Italic", - }, - 600: { - normal: "IBMPlexSans_600SemiBold", - italic: "IBMPlexSans_600SemiBold_Italic", - }, - 700: { - normal: "IBMPlexSans_700Bold", - italic: "IBMPlexSans_700Bold_Italic", - }, - }, -}); - -// ── Tokens ──────────────────────────────────────────────────────────────────── -const size = { - 0: 0, - 0.25: 2, - 0.5: 4, - 0.75: 6, - 1: 8, - 1.5: 12, - 2: 16, - 2.5: 20, - 3: 24, - 3.5: 28, - 4: 32, - 5: 40, - 6: 48, - 7: 56, - 8: 64, - 9: 72, - 10: 80, - true: 16, // default -}; - -export const tokens = createTokens({ - size, - space: { ...size, "-1": -8, "-1.5": -12, "-2": -16, "-2.5": -20 }, - radius: { - 0: 0, - 1: 4, - 2: 8, - 3: 12, - 4: 16, - 5: 24, - 6: 32, - true: 8, - full: 9999, - }, - zIndex: { - 0: 0, - 1: 100, - 2: 200, - 3: 300, - 4: 400, - 5: 500, - }, - color: { - // Brand — electric violet - brand50: "#f3f0ff", - brand100: "#e9e3ff", - brand200: "#d4caff", - brand300: "#b8a9ff", - brand400: "#9b7dff", - brand500: "#7c3aed", - brand600: "#6d28d9", - brand700: "#5b21b6", - brand800: "#4c1d95", - brand900: "#3b0764", - - // Neutrals - gray50: "#fafafa", - gray100: "#f4f4f5", - gray200: "#e4e4e7", - gray300: "#d1d1d6", - gray400: "#a1a1aa", - gray500: "#71717a", - gray600: "#52525b", - gray700: "#3f3f46", - gray800: "#27272a", - gray900: "#18181b", - gray950: "#09090b", - - // Semantic - success: "#22c55e", - warning: "#f59e0b", - error: "#ef4444", - info: "#3b82f6", - - white: "#ffffff", - black: "#000000", - }, -}); - -// ── Themes ──────────────────────────────────────────────────────────────────── -const lightTheme = { - background: tokens.color.white, - backgroundHover: tokens.color.gray50, - backgroundPress: tokens.color.gray100, - backgroundFocus: tokens.color.gray100, - backgroundStrong: tokens.color.gray100, - backgroundTransparent: "rgba(255,255,255,0)", - - color: tokens.color.gray950, - colorHover: tokens.color.gray800, - colorPress: tokens.color.gray700, - colorFocus: tokens.color.gray900, - colorTransparent: "rgba(0,0,0,0)", - - borderColor: tokens.color.gray200, - borderColorHover: tokens.color.gray300, - borderColorFocus: tokens.color.brand500, - borderColorPress: tokens.color.gray400, - - placeholderColor: tokens.color.gray400, - outlineColor: tokens.color.brand500, - - // Brand - brand: tokens.color.brand500, - brandHover: tokens.color.brand600, - brandPress: tokens.color.brand700, - brandText: tokens.color.white, - - // Surfaces - surface1: tokens.color.white, - surface2: tokens.color.gray50, - surface3: tokens.color.gray100, - - shadowColor: tokens.color.gray950, - shadowColorStrong: tokens.color.gray950, -}; - -const darkTheme = { - background: tokens.color.gray950, - backgroundHover: tokens.color.gray900, - backgroundPress: tokens.color.gray800, - backgroundFocus: tokens.color.gray800, - backgroundStrong: tokens.color.gray800, - backgroundTransparent: "rgba(0,0,0,0)", - - color: tokens.color.gray50, - colorHover: tokens.color.gray100, - colorPress: tokens.color.gray200, - colorFocus: tokens.color.white, - colorTransparent: "rgba(255,255,255,0)", - - borderColor: tokens.color.gray800, - borderColorHover: tokens.color.gray700, - borderColorFocus: tokens.color.brand400, - borderColorPress: tokens.color.gray600, - - placeholderColor: tokens.color.gray600, - outlineColor: tokens.color.brand400, - - brand: tokens.color.brand400, - brandHover: tokens.color.brand300, - brandPress: tokens.color.brand200, - brandText: tokens.color.gray950, - - surface1: tokens.color.gray950, - surface2: tokens.color.gray900, - surface3: tokens.color.gray800, - - shadowColor: tokens.color.black, - shadowColorStrong: tokens.color.black, -}; - -// ── Config ──────────────────────────────────────────────────────────────────── -const config = createTamagui({ - animations, - fonts: { - heading: ibmPlexSans, - body: ibmPlexSans, - mono: ibmPlexSans, - }, - tokens, - themes: { - light: lightTheme, - dark: darkTheme, - }, - media: { - xs: { maxWidth: 660 }, - sm: { maxWidth: 800 }, - md: { maxWidth: 1020 }, - lg: { maxWidth: 1280 }, - xl: { maxWidth: 1650 }, - xxl: { minWidth: 1651 }, - gtXs: { minWidth: 660 + 1 }, - gtSm: { minWidth: 800 + 1 }, - gtMd: { minWidth: 1020 + 1 }, - gtLg: { minWidth: 1280 + 1 }, - short: { maxHeight: 820 }, - tall: { minHeight: 820 }, - hoverable: { hover: "hover" }, - touch: { pointer: "coarse" }, - }, - shorthands: { - px: "paddingHorizontal", - py: "paddingVertical", - pt: "paddingTop", - pb: "paddingBottom", - pl: "paddingLeft", - pr: "paddingRight", - p: "padding", - mx: "marginHorizontal", - my: "marginVertical", - mt: "marginTop", - mb: "marginBottom", - ml: "marginLeft", - mr: "marginRight", - m: "margin", - f: "flex", - w: "width", - h: "height", - bg: "backgroundColor", - br: "borderRadius", - ai: "alignItems", - jc: "justifyContent", - } as const, - settings: { - allowedStyleValues: "somewhat-strict", - autocompleteSpecificTokens: "except-special", - }, -}); - -type AppConfig = typeof config; - -declare module "tamagui" { - // eslint-disable-next-line @typescript-eslint/no-empty-object-type - interface TamaguiCustomConfig extends AppConfig {} -} - -export default config; +// Glacier config lives in @repo/ui (it owns the `declare module "tamagui"` +// augmentation so the design-system components type-check $primary etc.). +// This re-export keeps the path stable for the metro plugin (metro.config.js +// → config: "./tamagui.config.ts") and providers/index.tsx. +export { default, tokens } from "@repo/ui/tamagui.config"; +export { default as config } from "@repo/ui/tamagui.config"; diff --git a/infra/stacks/api.ts b/infra/stacks/api.ts index f0eb992..abd9f47 100644 --- a/infra/stacks/api.ts +++ b/infra/stacks/api.ts @@ -55,6 +55,11 @@ export const apiFunction = new sst.aws.Function("Api", { // directly, so we map it explicitly here. Pooler URL only — direct URL // is reserved for migrations. DATABASE_URL: databaseUrl.value, + // Transactional email (Resend). Captured from the deploy environment. + // For production, promote these to sst.Secret in infra/stacks/secrets.ts + // and reference .value here instead of plaintext passthrough. + RESEND_API_KEY: process.env.RESEND_API_KEY ?? "", + EMAIL_FROM: process.env.EMAIL_FROM ?? "", }, ...prismaLambdaBundling, }); diff --git a/infra/stacks/chat-ws.ts b/infra/stacks/chat-ws.ts index ab89045..fbc3939 100644 --- a/infra/stacks/chat-ws.ts +++ b/infra/stacks/chat-ws.ts @@ -142,8 +142,8 @@ export const chatWsWorker = new sst.cloudflare.Worker("ChatWs", { // is still v1 — in that case set { oldTag: "v1", newTag: "v2" } here and // let the KV binding ride that deploy, rather than jumping to v3. args.migrations = { - oldTag: "v3", - newTag: "v4", + oldTag: "v7", + newTag: "v8", }; // Compatibility date with WebSocket auto-reply-to-close behaviour diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs new file mode 100644 index 0000000..d748f6f --- /dev/null +++ b/lint-staged.config.mjs @@ -0,0 +1,38 @@ +import path from "node:path"; + +// Monorepo lint-staged. ESLint v9 flat config resolves from the *current +// working directory*, and lint-staged runs at the repo root — so a plain +// `eslint --fix` lints apps/mobile/** with the ROOT config (no react-hooks +// plugin) and resolves imports from the root cwd (breaking `@/` aliases). +// +// Fix: group staged files by their workspace and run eslint from inside each +// one, so both flat-config lookup and the import/tsconfig resolvers use that +// package's cwd — matching how `turbo lint` already runs per package. + +const repoRoot = process.cwd(); + +// Workspaces per package.json: apps/*, packages/*, services/*, infra. +function workspaceDir(absFile) { + const [top, second] = path.relative(repoRoot, absFile).split(path.sep); + if (top === "apps" || top === "packages" || top === "services") { + return second ? `${top}/${second}` : top; + } + if (top === "infra") return "infra"; + return "."; // repo-root files (sst.config.ts, eslint.config.mjs, …) +} + +export default { + "*.{ts,tsx}": (files) => { + const groups = new Map(); + for (const abs of files) { + const ws = workspaceDir(abs); + (groups.get(ws) ?? groups.set(ws, []).get(ws)).push(abs); + } + return [...groups].map(([ws, abs]) => { + const cwd = path.join(repoRoot, ws); + const rels = abs.map((f) => `"${path.relative(cwd, f)}"`).join(" "); + return `sh -c 'cd ${ws} && eslint --fix ${rels}'`; + }); + }, + "*.{ts,tsx,js,jsx,json,css,md}": "prettier --write", +}; diff --git a/package.json b/package.json index 849c097..0ae9493 100644 --- a/package.json +++ b/package.json @@ -49,10 +49,6 @@ }, "version": "1.0.0", "packageManager": "pnpm@10.33.1+sha512.05ba3c1d5d1c18f68df06470d74055e62d41fc110a0c660db1b2dfb2785327f04cf0f68345d4609bc52089e7fa0343c31593b2f9594e2c5d5da426230acc9820", - "lint-staged": { - "*.{ts,tsx}": "eslint --fix", - "*.{ts,tsx,js,jsx,json,css,md}": "prettier --write" - }, "dependencies": { "expo": "~54.0.34", "react": "19.1.0", diff --git a/packages/api/eslint.config.mjs b/packages/api/eslint.config.mjs deleted file mode 100644 index 19170f8..0000000 --- a/packages/api/eslint.config.mjs +++ /dev/null @@ -1,4 +0,0 @@ -import { config } from "@repo/eslint-config/react-internal"; - -/** @type {import("eslint").Linter.Config} */ -export default config; diff --git a/packages/api/package.json b/packages/api/package.json deleted file mode 100644 index 4f7339a..0000000 --- a/packages/api/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@repo/api", - "version": "0.0.0", - "private": true, - "main": "./src/index.ts", - "types": "./src/index.ts", - "scripts": { - "lint": "eslint .", - "check-types": "tsc --noEmit", - "test": "echo \"no tests yet\" && exit 0" - }, - "dependencies": { - "@trpc/client": "^11.0.0", - "@trpc/react-query": "^11.0.0", - "@tanstack/react-query": "^5.62.11", - "@repo/api-server": "workspace:*" - }, - "devDependencies": { - "@repo/eslint-config": "workspace:*", - "@repo/typescript-config": "workspace:*", - "@types/react": "~19.1.10", - "eslint": "^9.39.1", - "react": "^19.1.0", - "typescript": "5.9.2" - } -} diff --git a/packages/api/src/client.ts b/packages/api/src/client.ts deleted file mode 100644 index 3f28865..0000000 --- a/packages/api/src/client.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { createTRPCClient, httpBatchLink } from "@trpc/client"; -import type { AppRouter } from "@repo/api-server"; - -function getBaseUrl() { - if (typeof window !== "undefined") { - // Browser: use relative path - return ""; - } - // Server: use full URL - return process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; -} - -export const trpc = createTRPCClient({ - links: [ - httpBatchLink({ - url: `${getBaseUrl()}/trpc`, - headers: () => { - // Get token from storage (implement based on your storage strategy) - const token = - typeof window !== "undefined" - ? localStorage.getItem("accessToken") - : null; - - return token - ? { - Authorization: `Bearer ${token}`, - } - : {}; - }, - }), - ], -}); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts deleted file mode 100644 index 6c57a22..0000000 --- a/packages/api/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { trpc } from "./client"; -export { api, getClientConfig } from "./react"; -export type { AppRouter } from "@repo/api-server"; diff --git a/packages/api/src/react.tsx b/packages/api/src/react.tsx deleted file mode 100644 index 16c7b71..0000000 --- a/packages/api/src/react.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { createTRPCReact, httpBatchLink } from "@trpc/react-query"; -import type { AppRouter } from "@repo/api-server"; - -export const api = createTRPCReact(); - -function getBaseUrl() { - if (typeof window !== "undefined") { - return ""; - } - return process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; -} - -export function getClientConfig() { - return { - links: [ - httpBatchLink({ - url: `${getBaseUrl()}/trpc`, - headers: () => { - const token = - typeof window !== "undefined" - ? localStorage.getItem("accessToken") - : null; - - return token - ? { - Authorization: `Bearer ${token}`, - } - : {}; - }, - }), - ], - }; -} diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json deleted file mode 100644 index 8bc0fff..0000000 --- a/packages/api/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "@repo/typescript-config/react-library.json", - "compilerOptions": { - "outDir": "dist", - "declaration": false, - "declarationMap": false - }, - "include": ["src"], - "exclude": ["node_modules", "dist"] -} diff --git a/packages/identity/src/index.ts b/packages/identity/src/index.ts index d876cec..eea524d 100644 --- a/packages/identity/src/index.ts +++ b/packages/identity/src/index.ts @@ -1,4 +1 @@ export * from "./types"; -export * from "./registry"; -export { PhoneProvider } from "./providers/phone"; -export { WorldIdProvider } from "./providers/world-id"; diff --git a/packages/identity/src/providers/phone.ts b/packages/identity/src/providers/phone.ts deleted file mode 100644 index ca60ea6..0000000 --- a/packages/identity/src/providers/phone.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { - IdentityProvider, - IdentityCheckInit, - IdentityCheckResult, -} from "../types"; - -/** - * Phone OTP provider (Web2) - * - * Current implementation: stub returning mock data. - * Production: replace with Twilio Verify or AWS SNS. - * - * Grants: BASIC tier (can post images) - */ -export class PhoneProvider implements IdentityProvider { - readonly name = "phone"; - readonly tier = "BASIC" as const; - - async initiate( - userId: string, - metadata?: { phoneNumber: string }, - ): Promise { - if (!metadata?.phoneNumber) { - throw new Error("phoneNumber required"); - } - - // TODO: Replace with Twilio Verify - // const verification = await twilioClient.verify.v2 - // .services(process.env.TWILIO_VERIFY_SID!) - // .verifications.create({ to: metadata.phoneNumber, channel: 'sms' }); - - console.warn("[PhoneProvider] STUB - replace with Twilio in production"); - const externalId = `phone_${userId}_${Date.now()}`; - - return { - externalId, - clientToken: externalId, // In prod: Twilio session SID - }; - } - - async verify( - externalId: string, - // proof: unknown, - ): Promise { - // const { code } = proof as { code: string }; - - // TODO: Replace with Twilio Verify check - // const check = await twilioClient.verify.v2 - // .services(process.env.TWILIO_VERIFY_SID!) - // .verificationChecks.create({ to: phoneNumber, code }); - - console.warn("[PhoneProvider] STUB - always approves in development"); - - return { - externalId, - status: "approved", - tier: "BASIC", - }; - } - - async getStatus(externalId: string): Promise { - return { - externalId, - status: "approved", - tier: "BASIC", - }; - } -} diff --git a/packages/identity/src/providers/world-id.ts b/packages/identity/src/providers/world-id.ts deleted file mode 100644 index 560bd0d..0000000 --- a/packages/identity/src/providers/world-id.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { - IdentityProvider, - IdentityCheckInit, - IdentityCheckResult, -} from "../types"; - -/** - * World ID provider (Web3 - Worldcoin) - * https://docs.worldcoin.org/ - * - * Proof of personhood via iris scan → ZK proof. - * No personal data is stored. Cryptographically unique per person. - * - * Current implementation: STUB - not wired up. - * Production: - * 1. Integrate World ID widget in mobile app - * 2. Widget returns a ZK proof - * 3. This provider verifies the proof against Worldcoin's API - * - * Grants: CREATOR tier (can upload audio/video) - */ -export class WorldIdProvider implements IdentityProvider { - readonly name = "world_id"; - readonly tier = "CREATOR" as const; - - async initiate(userId: string): Promise { - // TODO: Generate World ID verification request - // const appId = process.env.WORLD_ID_APP_ID; - // const action = 'verify-creator'; - // Return the app_id + action for the client to pass to World ID widget - - console.warn("[WorldIdProvider] STUB - not implemented"); - const externalId = `worldid_${userId}_${Date.now()}`; - - return { - externalId, - // In prod: redirect/deep-link to World ID verification - redirectUrl: `https://worldcoin.org/verify?action=verify-creator&signal=${userId}`, - }; - } - - async verify( - externalId: string, - // proof: unknown, - ): Promise { - // TODO: Verify ZK proof via Worldcoin Developer Portal API - // POST https://developer.worldcoin.org/api/v2/verify/{app_id} - // Body: { nullifier_hash, merkle_root, proof, verification_level, action, signal } - - console.warn("[WorldIdProvider] STUB - always approves in development"); - - return { - externalId, - status: "approved", - tier: "CREATOR", - }; - } - - async getStatus(externalId: string): Promise { - return { - externalId, - status: "pending", - tier: "CREATOR", - }; - } -} diff --git a/packages/identity/src/registry.ts b/packages/identity/src/registry.ts deleted file mode 100644 index 2c4330a..0000000 --- a/packages/identity/src/registry.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { PhoneProvider } from "./providers/phone"; -import { WorldIdProvider } from "./providers/world-id"; -import type { IdentityProvider } from "./types"; - -// Central registry of all identity providers -// To add a new provider: implement IdentityProvider, add it here -const providers: Record = { - phone: new PhoneProvider(), - world_id: new WorldIdProvider(), -}; - -export function getProvider(name: string): IdentityProvider { - const provider = providers[name]; - if (!provider) { - throw new Error( - `Unknown identity provider: "${name}". Available: ${Object.keys(providers).join(", ")}`, - ); - } - return provider; -} - -export function listProviders(): string[] { - return Object.keys(providers); -} diff --git a/packages/identity/src/types.ts b/packages/identity/src/types.ts index 29d2c21..d9c0253 100644 --- a/packages/identity/src/types.ts +++ b/packages/identity/src/types.ts @@ -1,48 +1,5 @@ import type { IdentityTier } from "@repo/database"; -// Core result shape all providers must return -export interface IdentityCheckResult { - externalId: string; // Provider's reference ID (store in IdentityCheck.externalId) - status: "approved" | "rejected" | "pending"; - tier: IdentityTier; - expiresAt?: Date; - providerPayload?: Record; // Raw provider response for audit -} - -// Initiation result - some providers return a URL to redirect the user to -export interface IdentityCheckInit { - externalId: string; - redirectUrl?: string; // Present for redirect-based flows (World ID, Gitcoin) - clientToken?: string; // Present for embedded flows (phone OTP) -} - -// The interface every provider must implement -// Adding a new provider = implement this interface, register it below -export interface IdentityProvider { - readonly name: string; - readonly tier: IdentityTier; // What tier does this provider grant? - - /** - * Start a verification session. - * For phone: sends OTP. For World ID: returns redirect URL. For stake: records intent. - */ - initiate( - userId: string, - metadata?: Record, - ): Promise; - - /** - * Complete or verify the check. - * For phone: verify OTP code. For World ID: verify ZK proof. For OAuth: verify callback. - */ - verify(externalId: string, proof: unknown): Promise; - - /** - * Check the status of an existing session (for async providers like stake). - */ - getStatus(externalId: string): Promise; -} - // Content permission map - what each tier can do export const TIER_PERMISSIONS: Record< IdentityTier, diff --git a/packages/tailwind-config/package.json b/packages/tailwind-config/package.json deleted file mode 100644 index 61fd66f..0000000 --- a/packages/tailwind-config/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@repo/tailwind-config", - "version": "0.0.0", - "private": true, - "exports": { - ".": "./tailwind.config.js" - } -} diff --git a/packages/tailwind-config/tailwind.config.js b/packages/tailwind-config/tailwind.config.js deleted file mode 100644 index 99d3f3c..0000000 --- a/packages/tailwind-config/tailwind.config.js +++ /dev/null @@ -1,17 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - darkMode: "class", - theme: { - extend: { - colors: { - background: "#0A0A0A", - "background-soft": "#111111", - "background-strong": "#000000", - foreground: "#F0F0F0", - "foreground-subtle": "#888888", - border: "#222222", - }, - }, - }, - plugins: [], -}; diff --git a/packages/ui/package.json b/packages/ui/package.json index 336bd71..144d8fc 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -3,6 +3,7 @@ "version": "0.0.0", "private": true, "exports": { + "./tamagui.config": "./src/tamagui.config.ts", "./*": "./src/*.tsx" }, "scripts": { @@ -15,12 +16,13 @@ "@repo/typescript-config": "workspace:*", "@types/node": "^22.15.3", "@types/react": "~19.1.10", - "@types/react-dom": "~19.1.10", "eslint": "^9.39.1", "typescript": "5.9.2" }, "dependencies": { + "@tamagui/animations-react-native": "2.0.0-rc.42", + "@tamagui/linear-gradient": "2.0.0-rc.42", "react": "^19.1.0", - "react-dom": "^19.1.0" + "tamagui": "2.0.0-rc.42" } } diff --git a/packages/ui/src/glacier/avatar.tsx b/packages/ui/src/glacier/avatar.tsx new file mode 100644 index 0000000..b89b8bc --- /dev/null +++ b/packages/ui/src/glacier/avatar.tsx @@ -0,0 +1,121 @@ +// Glacier Avatar (components/Avatar.md). Photo or deterministic initials, with +// an optional presence pip. Hue is picked deterministically from a small fixed +// accent palette; fill is that hue at 15%, initials at full opacity. +import { Image, Text, YStack, styled } from "tamagui"; + +type Size = "sm" | "md" | "lg"; +type Status = "online" | "away" | "offline"; + +const DIAMETER: Record = { sm: 32, md: 40, lg: 48 }; + +// Fixed accent palette (THEME §2): primary teal, secondary violet, slate. +const HUES = ["#00696e", "#5400c3", "#3a494a"] as const; + +function hueFor(seed: string): string { + let h = 0; + for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0; + return HUES[h % HUES.length] as string; +} + +function tint15(hex: string): string { + const n = parseInt(hex.slice(1), 16); + const r = (n >> 16) & 255; + const g = (n >> 8) & 255; + const b = n & 255; + return `rgba(${r},${g},${b},0.15)`; +} + +function initialsOf(name: string): string { + const t = name.trim(); + if (!t) return "?"; + const parts = t.split(/\s+/); + if (parts.length >= 2) return (parts[0]![0]! + parts[1]![0]!).toUpperCase(); + return t.slice(0, 2).toUpperCase(); +} + +const AvatarFrame = styled(YStack, { + name: "GlacierAvatar", + borderRadius: "$full", + overflow: "hidden", + alignItems: "center", + justifyContent: "center", + backgroundColor: "$surfaceContainerLow", + + variants: { + inactive: { true: { opacity: 0.8 } }, + } as const, +}); + +const StatusPip = styled(YStack, { + name: "GlacierStatusPip", + position: "absolute", + bottom: -1, + right: -1, + width: 12, + height: 12, + borderRadius: "$full", + borderWidth: 2, + borderColor: "$surface", + + variants: { + status: { + online: { backgroundColor: "$success" }, + away: { backgroundColor: "$tertiary" }, + offline: { display: "none" }, + }, + } as const, +}); + +export interface AvatarProps { + size?: Size; + /** Photo URL; falls back to initials when absent. */ + uri?: string | null; + /** Display name → initials + deterministic hue. */ + name?: string | null; + /** Stable seed for the hue (e.g. accountId). Defaults to `name`. */ + seed?: string; + status?: Status; + inactive?: boolean; +} + +export function Avatar({ + size = "lg", + uri, + name, + seed, + status = "offline", + inactive, +}: AvatarProps) { + const d = DIAMETER[size]; + const hue = hueFor(seed ?? name ?? "?"); + const showPip = status !== "offline"; + + return ( + + + {uri ? ( + + ) : ( + + {initialsOf(name ?? "?")} + + )} + + {showPip && } + + ); +} + +export { AvatarFrame, StatusPip }; diff --git a/packages/ui/src/glacier/badge.tsx b/packages/ui/src/glacier/badge.tsx new file mode 100644 index 0000000..246bb9e --- /dev/null +++ b/packages/ui/src/glacier/badge.tsx @@ -0,0 +1,40 @@ +// Glacier unread-count Badge (components/Badge.md). Numbered, not a bare dot. +// Circular for 1–2 digits, pill for "99+". Distinct from the avatar presence +// pip — this answers "how many unread", the pip answers "are they online". +import { Text, YStack, styled } from "tamagui"; + +const CountBadge = styled(YStack, { + name: "GlacierCountBadge", + minWidth: 19, + height: 19, + paddingHorizontal: 4, + borderRadius: "$full", + backgroundColor: "$primary", + alignItems: "center", + justifyContent: "center", +}); + +const CountBadgeText = styled(Text, { + name: "GlacierCountBadgeText", + fontFamily: "$body", + fontWeight: "600", + fontSize: 10, + lineHeight: 12, + color: "$onPrimary", + fontVariant: ["tabular-nums"], +}); + +export interface BadgeProps { + count: number; +} + +export function Badge({ count }: BadgeProps) { + if (count <= 0) return null; + return ( + + {count > 99 ? "99+" : count} + + ); +} + +export { CountBadge, CountBadgeText }; diff --git a/packages/ui/src/glacier/button.tsx b/packages/ui/src/glacier/button.tsx new file mode 100644 index 0000000..62c1192 --- /dev/null +++ b/packages/ui/src/glacier/button.tsx @@ -0,0 +1,103 @@ +// Glacier Button (components/Button.md). Three variants share one shape +// language; sizes sm/md/lg all meet the 44pt tap-target floor. Icon-agnostic: +// pass rendered icon nodes via `icon` / `iconAfter` (screens own the icon set). +import type { ReactNode } from "react"; +import { styled, Text, YStack, type GetProps } from "tamagui"; + +const ButtonFrame = styled(YStack, { + name: "GlacierButton", + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: "$xs", + borderRadius: "$true", // radius.DEFAULT = 8; pass br="$full" for the pill + paddingHorizontal: "$sm", + height: 44, + + variants: { + variant: { + primary: { + backgroundColor: "$primary", + pressStyle: { backgroundColor: "$primary", opacity: 0.85 }, + }, + ghost: { + backgroundColor: "transparent", + borderWidth: 0.5, + borderColor: "$outlineVariant", + pressStyle: { backgroundColor: "$surfaceContainerLow" }, + }, + ghostDanger: { + backgroundColor: "transparent", + borderWidth: 0.5, + borderColor: "$error", + opacity: 0.9, + pressStyle: { backgroundColor: "$errorContainer" }, + }, + }, + size: { + sm: { height: 36, paddingHorizontal: "$xs" }, + md: { height: 44, paddingHorizontal: "$sm" }, + lg: { height: 52, paddingHorizontal: "$md" }, + }, + disabled: { + true: { opacity: 0.4, pointerEvents: "none" }, + }, + } as const, + + defaultVariants: { variant: "primary", size: "md" }, +}); + +const ButtonLabel = styled(Text, { + name: "GlacierButtonLabel", + fontFamily: "$body", + fontSize: 13, + lineHeight: 16, + fontWeight: "500", + letterSpacing: 0.26, + + variants: { + variant: { + primary: { color: "$onPrimary" }, + ghost: { color: "$onSurfaceVariant" }, + ghostDanger: { color: "$error" }, + }, + } as const, +}); + +type Variant = "primary" | "ghost" | "ghostDanger"; +type Size = "sm" | "md" | "lg"; + +export type ButtonProps = Omit< + GetProps, + "variant" | "size" +> & { + variant?: Variant; + size?: Size; + /** Rendered icon node (screen owns the icon set + colour). */ + icon?: ReactNode; + iconAfter?: ReactNode; + children?: ReactNode; +}; + +export function Button({ + variant = "primary", + size = "md", + icon, + iconAfter, + children, + ...rest +}: ButtonProps) { + return ( + + {icon} + {typeof children === "string" ? ( + {children} + ) : ( + children + )} + {iconAfter} + + ); +} + +export { ButtonFrame, ButtonLabel }; diff --git a/packages/ui/src/glacier/chat-bubble.tsx b/packages/ui/src/glacier/chat-bubble.tsx new file mode 100644 index 0000000..59ee08e --- /dev/null +++ b/packages/ui/src/glacier/chat-bubble.tsx @@ -0,0 +1,163 @@ +// Glacier Chat Bubble (components/ChatBubble.md). Incoming = flat surface + thin +// border, tail bottom-left. Outgoing = ice→violet diagonal gradient, tail +// bottom-right, soft glow. Timestamp + read-receipt sit BELOW the bubble, never +// inside. Presentational only — the screen wraps it in a Pressable for +// long-press (→ the crypto inspector). +import type { ReactNode } from "react"; +import { Text, XStack, YStack, styled } from "tamagui"; +import { LinearGradient } from "@tamagui/linear-gradient"; + +import { BodyMd, BodySm, Meta } from "./typography"; + +export type BubbleStatus = "sending" | "sent" | "delivered" | "read" | "failed"; + +const IncomingShell = styled(YStack, { + name: "IncomingBubble", + alignSelf: "flex-start", + maxWidth: "85%", + backgroundColor: "$surface", + borderWidth: 0.5, + borderColor: "$outlineVariant", + borderRadius: "$lg", + borderBottomLeftRadius: 4, + paddingHorizontal: "$sm", + paddingVertical: 10, + + variants: { + failed: { + true: { borderWidth: 1, borderColor: "$error" }, + }, + } as const, +}); + +export interface ChatBubbleProps { + text: string; + outgoing: boolean; + /** Pre-formatted "10:42 AM" style label. */ + timestamp?: string; + /** Only the last bubble in a same-sender run shows its timestamp. */ + showTimestamp?: boolean; + /** Sender name — incoming group messages only. */ + sender?: string | null; + status?: BubbleStatus; + /** Read-receipt tick node (outgoing only; screen owns the icon). */ + receipt?: ReactNode; + onRetryPress?: () => void; +} + +export function ChatBubble({ + text, + outgoing, + timestamp, + showTimestamp = true, + sender, + status = "sent", + receipt, + onRetryPress, +}: ChatBubbleProps) { + const sending = status === "sending"; + const failed = status === "failed"; + + return ( + + {outgoing ? ( + // Gradient is an absolute fill behind the text, not the sizing element. + // expo LinearGradient shrink-wraps to min-content (longest word), which + // wrapped bubbles far too narrow — letting the padded YStack size to the + // text (like IncomingShell) fixes the width. The gradient self-clips via + // matching radii; shadow lives on the outer YStack so it isn't masked. + + + {text} + + ) : ( + + {sender ? ( + + {sender} + + ) : null} + {text} + + )} + + {/* Footer: timestamp + receipt, or the failed affordance */} + {failed ? ( + + Tap to retry + + ) : sending || !showTimestamp ? null : ( + + {timestamp ? {timestamp} : null} + {outgoing ? receipt : null} + + )} + + ); +} + +// Day-divider pill (chat/DESIGN.md) — centred chip above a run of messages. +export function DayDivider({ label }: { label: string }) { + return ( + + + + {label} + + + + ); +} + +export { IncomingShell }; diff --git a/packages/ui/src/glacier/composer.tsx b/packages/ui/src/glacier/composer.tsx new file mode 100644 index 0000000..333b911 --- /dev/null +++ b/packages/ui/src/glacier/composer.tsx @@ -0,0 +1,132 @@ +// Glacier Composer (components/Composer.md). Pill input (emoji trailing, inside +// the pill) + a circular send button. Send is $primary-tinted when active — +// NEVER success-green (that's an inspector-only token). Focus lifts the pill to +// a full 0.5px $primary box + soft glow. +import { useState, type ReactNode } from "react"; +import { Input, Spinner, XStack, YStack, styled } from "tamagui"; + +const ComposerBar = styled(XStack, { + name: "GlacierComposerBar", + alignItems: "flex-end", + gap: "$sm", + paddingHorizontal: "$md", + paddingVertical: "$xs", + backgroundColor: "$surface", + borderTopWidth: 0.5, + borderTopColor: "$outlineVariant", +}); + +const ComposerPill = styled(XStack, { + name: "GlacierComposerPill", + flex: 1, + alignItems: "center", + minHeight: 44, + paddingHorizontal: "$sm", + borderRadius: "$full", + backgroundColor: "$surfaceContainerLow", + borderWidth: 1, + borderColor: "$outlineVariant", + // Glow kept always-present at 0 opacity so focus only changes values on a + // fixed style signature — otherwise adding shadow* on focus makes Tamagui + // reconstruct the pill view, remounting the Input and dropping focus. + shadowColor: "$primary", + shadowRadius: 6, + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0, + + variants: { + focused: { + true: { + borderColor: "$primary", + borderWidth: 1.5, + shadowOpacity: 0.15, + }, + }, + } as const, +}); + +const SendButton = styled(YStack, { + name: "GlacierSendButton", + width: 44, + height: 44, + borderRadius: "$md", + alignItems: "center", + justifyContent: "center", + + variants: { + active: { + true: { backgroundColor: "rgba(0,105,110,0.12)" }, + false: { backgroundColor: "$surfaceContainerLow" }, + }, + } as const, + defaultVariants: { active: false }, +}); + +export interface ComposerProps { + value: string; + onChangeText: (t: string) => void; + onSend: () => void; + sending?: boolean; + disabled?: boolean; + placeholder?: string; + bottomInset?: number; + /** Emoji affordance, trailing inside the pill. */ + emojiIcon?: ReactNode; + /** Send glyph — receives `active` so the screen can colour it. */ + renderSendIcon?: (active: boolean) => ReactNode; +} + +export function Composer({ + value, + onChangeText, + onSend, + sending, + disabled, + placeholder = "Write a message…", + bottomInset = 0, + emojiIcon, + renderSendIcon, +}: ComposerProps) { + const [focused, setFocused] = useState(false); + const active = value.trim().length > 0 && !disabled; + + return ( + + + setFocused(true)} + onBlur={() => setFocused(false)} + onSubmitEditing={onSend} + blurOnSubmit={false} + /> + {emojiIcon ? {emojiIcon} : null} + + + + {sending ? ( + + ) : ( + renderSendIcon?.(active) + )} + + + ); +} + +export { ComposerBar, ComposerPill, SendButton }; diff --git a/packages/ui/src/glacier/inspector-pane.tsx b/packages/ui/src/glacier/inspector-pane.tsx new file mode 100644 index 0000000..8ca075b --- /dev/null +++ b/packages/ui/src/glacier/inspector-pane.tsx @@ -0,0 +1,123 @@ +// Glacier Inspector Pane (components/InspectorPane.md). One card, two +// structurally-identical but visually-opposite tones — the contrast IS the +// feature. recessed = "on the wire / at rest" (muted, flat, mono). emphasized +// = "on this device" (primary-tinted panel, bold plaintext, soft glow). +// App-Light: dim/bright is carried by tint + border + weight, not a dark canvas. +import type { ReactNode } from "react"; +import { Text, XStack, YStack, styled } from "tamagui"; + +const PaneFrame = styled(YStack, { + name: "GlacierInspectorPane", + borderRadius: "$lg", + padding: "$md", + gap: "$xs", + + variants: { + tone: { + recessed: { + backgroundColor: "$surfaceContainerLow", + borderWidth: 0.5, + borderColor: "$outlineVariant", + }, + emphasized: { + backgroundColor: "rgba(0,105,110,0.06)", + borderWidth: 0.5, + borderColor: "rgba(0,105,110,0.25)", + shadowColor: "$primary", + shadowOpacity: 0.1, + shadowRadius: 20, + }, + }, + } as const, + defaultVariants: { tone: "recessed" }, +}); + +const PaneLabel = styled(Text, { + name: "GlacierPaneLabel", + fontFamily: "$body", + fontSize: 12, + lineHeight: 16, + fontWeight: "500", + letterSpacing: 0.96, + textTransform: "uppercase", + fontVariant: ["tabular-nums"], + + variants: { + tone: { + recessed: { color: "$onSurfaceVariant" }, + emphasized: { color: "$primary" }, + }, + } as const, +}); + +// Ciphertext / hexdump — dim, recessed, machine. +export const PaneMonoContent = styled(Text, { + name: "GlacierPaneMono", + fontFamily: "$mono", + fontSize: 13, + lineHeight: 20, + color: "$onSurfaceVariant", + fontVariant: ["tabular-nums"], +}); + +// Plaintext — bright, forward, human. +export const PanePlaintext = styled(Text, { + name: "GlacierPanePlaintext", + fontFamily: "$body", + fontSize: 18, + lineHeight: 28, + fontWeight: "600", + color: "$onPrimaryContainer", +}); + +type Tone = "recessed" | "emphasized"; + +export interface InspectorPaneProps { + tone?: Tone; + /** Uppercase pane label, e.g. "ON THE WIRE". */ + label: string; + /** Sub-label, e.g. "what the server received". */ + sub?: string; + /** Leading icon node (screen owns the icon set). */ + icon?: ReactNode; + /** Trailing action, e.g. a Copy button. */ + action?: ReactNode; + children?: ReactNode; +} + +export function InspectorPane({ + tone = "recessed", + label, + sub, + icon, + action, + children, +}: InspectorPaneProps) { + return ( + + + {icon} + {label} + {action ? ( + + {action} + + ) : null} + + {sub ? ( + + {sub} + + ) : null} + {children} + + ); +} + +export { PaneFrame, PaneLabel }; diff --git a/packages/ui/src/glacier/list-row.tsx b/packages/ui/src/glacier/list-row.tsx new file mode 100644 index 0000000..8158846 --- /dev/null +++ b/packages/ui/src/glacier/list-row.tsx @@ -0,0 +1,115 @@ +// Glacier List Row (components/ListRow.md). [Avatar lg] [name + preview] [meta: +// time + badge/tick]. No separators by default — density comes from padding. +// Unread lifts weight + turns the timestamp $primary and shows a numbered Badge. +import type { ReactNode } from "react"; +import { Text, XStack, YStack, styled } from "tamagui"; + +import { Avatar, type AvatarProps } from "./avatar"; +import { Badge } from "./badge"; + +const RowFrame = styled(XStack, { + name: "GlacierListRow", + alignItems: "center", + gap: "$sm", + paddingHorizontal: "$sm", + paddingVertical: 12, + borderRadius: "$md", + pressStyle: { backgroundColor: "$surfaceContainerLow" }, +}); + +const RowTitle = styled(Text, { + name: "RowTitle", + fontFamily: "$body", + fontSize: 15, + lineHeight: 20, + color: "$onSurface", + numberOfLines: 1, + variants: { + unread: { true: { fontWeight: "600" }, false: { fontWeight: "500" } }, + } as const, +}); + +const RowPreview = styled(Text, { + name: "RowPreview", + fontFamily: "$body", + fontSize: 14, + lineHeight: 20, + numberOfLines: 1, + variants: { + unread: { + true: { color: "$onSurface", fontWeight: "500" }, + false: { color: "$onSurfaceVariant", fontWeight: "400" }, + }, + system: { + true: { fontStyle: "italic", color: "$outline", fontWeight: "500" }, + }, + } as const, +}); + +const RowTimestamp = styled(Text, { + name: "RowTimestamp", + fontFamily: "$body", + fontSize: 12, + lineHeight: 16, + letterSpacing: 0.96, + textTransform: "uppercase", + fontVariant: ["tabular-nums"], + variants: { + unread: { + true: { color: "$primary", fontWeight: "600" }, + false: { color: "$onSurfaceVariant", fontWeight: "500" }, + }, + } as const, +}); + +export interface ListRowProps { + name: string; + preview: string; + timestamp: string; + unread?: boolean; + unreadCount?: number; + /** System/status row (e.g. "You're now an admin") — italic preview, no meta. */ + system?: boolean; + /** Read-receipt tick node (read 1:1 only; screen owns the icon). */ + receipt?: ReactNode; + avatar?: Omit; + onPress?: () => void; + onLongPress?: () => void; +} + +export function ListRow({ + name, + preview, + timestamp, + unread, + unreadCount = 0, + system, + receipt, + avatar, + onPress, + onLongPress, +}: ListRowProps) { + return ( + + + + + {name} + + {preview} + + + + + {timestamp} + {system ? null : unread && unreadCount > 0 ? ( + + ) : ( + receipt + )} + + + ); +} + +export { RowFrame, RowTitle, RowPreview, RowTimestamp }; diff --git a/packages/ui/src/glacier/text-field.tsx b/packages/ui/src/glacier/text-field.tsx new file mode 100644 index 0000000..486c93e --- /dev/null +++ b/packages/ui/src/glacier/text-field.tsx @@ -0,0 +1,86 @@ +// Glacier TextField (components/TextField.md). Follows THEME §6 input rule: +// inactive = bottom hairline only; focus = full 0.5px accent box + soft primary +// glow. Icon-agnostic — pass a rendered leading icon node (screens own the icon +// set + colour), same contract as glacier/button.tsx. +import { useState, type ReactNode } from "react"; +import { styled, Input, XStack, type GetProps } from "tamagui"; + +const FieldFrame = styled(XStack, { + name: "GlacierTextFieldFrame", + alignItems: "center", + gap: "$xs", + height: 52, // exceeds the 44pt tap-target floor (THEME §8) + paddingHorizontal: "$sm", + backgroundColor: "$surface", + borderRadius: "$true", // radius.DEFAULT = 8 + // Rest state: bottom hairline only (THEME §6). Side/top borders are present + // but transparent so the focus transition only recolours — no layout shift. + borderWidth: 0.5, + borderColor: "transparent", + borderBottomColor: "$outlineVariant", + // Soft accent halo — glow reserved for interactive/active (THEME §5). Kept + // always-present at 0 opacity for the same reason as the border above: focus + // only changes values on a fixed style signature, so Tamagui never + // reconstructs the frame view (which would remount the Input and drop focus). + shadowColor: "$primary", + shadowRadius: 12, + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0, + + variants: { + focused: { + true: { + borderColor: "$primary", + shadowOpacity: 0.18, + }, + }, + error: { + true: { + borderColor: "$error", + borderBottomColor: "$error", + }, + }, + } as const, +}); + +export type TextFieldProps = GetProps & { + /** Rendered leading icon node (screen owns the icon set + colour). */ + icon?: ReactNode; + /** Error state — recolours the border to `$error`. */ + error?: boolean; +}; + +export function TextField({ + icon, + error, + onFocus, + onBlur, + ...rest +}: TextFieldProps) { + const [focused, setFocused] = useState(true); + return ( + + {icon} + { + setFocused(true); + onFocus?.(e); + }} + onBlur={(e) => { + setFocused(false); + onBlur?.(e); + }} + {...rest} + /> + + ); +} + +export { FieldFrame }; diff --git a/packages/ui/src/glacier/typography.tsx b/packages/ui/src/glacier/typography.tsx new file mode 100644 index 0000000..d4f9939 --- /dev/null +++ b/packages/ui/src/glacier/typography.tsx @@ -0,0 +1,117 @@ +// Glacier type scale (THEME §3.1) as styled Text presets. Three families: +// Sora (heading), Plus Jakarta Sans (body/meta), JetBrains Mono (machine). +// letterSpacing is em→px at each size; meta + mono carry tabular figures. +import { styled, Text } from "tamagui"; + +// ── Sora (display & headlines) ────────────────────────────────────────────── +export const DisplayLg = styled(Text, { + name: "DisplayLg", + fontFamily: "$heading", + fontSize: 48, + lineHeight: 56, + fontWeight: "700", + letterSpacing: -0.96, + color: "$onSurface", +}); + +export const HeadlineLg = styled(Text, { + name: "HeadlineLg", + fontFamily: "$heading", + fontSize: 32, + lineHeight: 40, + fontWeight: "600", + letterSpacing: -0.32, + color: "$onSurface", +}); + +export const HeadlineMd = styled(Text, { + name: "HeadlineMd", + fontFamily: "$heading", + fontSize: 24, + lineHeight: 32, + fontWeight: "600", + color: "$onSurface", +}); + +export const Title = styled(Text, { + name: "Title", + fontFamily: "$heading", + fontSize: 20, + lineHeight: 28, + fontWeight: "600", + color: "$onSurface", +}); + +// ── Plus Jakarta Sans (body, labels, metadata) ────────────────────────────── +export const BodyLg = styled(Text, { + name: "BodyLg", + fontFamily: "$body", + fontSize: 18, + lineHeight: 28, + fontWeight: "400", + color: "$onSurface", +}); + +export const BodyMd = styled(Text, { + name: "BodyMd", + fontFamily: "$body", + fontSize: 16, + lineHeight: 24, + fontWeight: "400", + letterSpacing: 0.16, + color: "$onSurface", +}); + +export const BodySm = styled(Text, { + name: "BodySm", + fontFamily: "$body", + fontSize: 14, + lineHeight: 20, + fontWeight: "400", + letterSpacing: 0.14, + color: "$onSurface", +}); + +export const Label = styled(Text, { + name: "Label", + fontFamily: "$body", + fontSize: 13, + lineHeight: 16, + fontWeight: "500", + letterSpacing: 0.26, + color: "$onSurface", +}); + +// "Today" · "Delivered" · timestamps — uppercase, tracked, tabular. +export const Meta = styled(Text, { + name: "Meta", + fontFamily: "$body", + fontSize: 12, + lineHeight: 16, + fontWeight: "500", + letterSpacing: 0.96, + textTransform: "uppercase", + color: "$onSurfaceVariant", + fontVariant: ["tabular-nums"], +}); + +// ── JetBrains Mono (cipher hex, fingerprints, byte counts) ────────────────── +export const MonoMd = styled(Text, { + name: "MonoMd", + fontFamily: "$mono", + fontSize: 13, + lineHeight: 20, + fontWeight: "500", + color: "$onSurfaceVariant", + fontVariant: ["tabular-nums"], +}); + +export const MonoSm = styled(Text, { + name: "MonoSm", + fontFamily: "$mono", + fontSize: 12, + lineHeight: 18, + fontWeight: "400", + color: "$onSurfaceVariant", + fontVariant: ["tabular-nums"], +}); diff --git a/packages/ui/src/tamagui.config.ts b/packages/ui/src/tamagui.config.ts new file mode 100644 index 0000000..980e898 --- /dev/null +++ b/packages/ui/src/tamagui.config.ts @@ -0,0 +1,376 @@ +// ── Glacier design system ────────────────────────────────────────────────── +// Single source of truth in code for docs/design/THEME.md ("Glacier", v2.x). +// One system, App (Light) mode only — the crypto inspector moved to light in +// its DESIGN.md 2.1.0, so there is no `inspector_dark` theme here. Product UI +// is light-only. +// +// This file owns the `declare module "tamagui"` augmentation, so it lives in +// @repo/ui where the design-system components can type-check `$primary` etc. +// apps/mobile/tamagui.config.ts re-exports it (kept at that path for the metro +// plugin + providers). +// +// Fonts are loaded via useFonts() in the app's _layout.tsx — the `face` family +// names below MUST match the @expo-google-fonts export names exactly. + +import { createFont, createTamagui, createTokens } from "tamagui"; +import { createAnimations } from "@tamagui/animations-react-native"; + +// ── Animations ───────────────────────────────────────────────────────────── +// 160–220ms ease-out feel (THEME §7). `fast` = state feedback; `medium` = the +// inspector reveal beats; `slow` = ambient glow loops. +const animations = createAnimations({ + fast: { damping: 22, mass: 1, stiffness: 250 }, + medium: { damping: 18, mass: 0.9, stiffness: 120 }, + slow: { damping: 20, stiffness: 60 }, +}); + +// ── Fonts (THEME §3) ──────────────────────────────────────────────────────── +// Three families, three jobs. Weight/size/colour carry hierarchy inside a +// family; the family switch signals a change of register (voice → text → machine). +// Components pass literal fontSize/lineHeight per the type scale, so the size +// maps below only need to give sane defaults for bare . + +const headingFont = createFont({ + family: "Sora", + size: { + 1: 12, + 2: 13, + 3: 14, + 4: 16, + 5: 18, + 6: 20, + 7: 24, + 8: 32, + 9: 40, + 10: 48, + true: 20, + }, + lineHeight: { + 1: 16, + 2: 18, + 3: 20, + 4: 24, + 5: 28, + 6: 28, + 7: 32, + 8: 40, + 9: 48, + 10: 56, + true: 28, + }, + weight: { 1: "400", 2: "500", 3: "600", 4: "700" }, + letterSpacing: { 1: 0, 2: -0.2, 3: -0.5, 4: -1 }, + face: { + 400: { normal: "Sora_400Regular" }, + 500: { normal: "Sora_500Medium" }, + 600: { normal: "Sora_600SemiBold" }, + 700: { normal: "Sora_700Bold" }, + }, +}); + +const bodyFont = createFont({ + family: "PlusJakartaSans", + size: { + 1: 11, + 2: 12, + 3: 13, + 4: 14, + 5: 16, + 6: 18, + 7: 20, + 8: 24, + 9: 30, + 10: 40, + true: 16, + }, + lineHeight: { + 1: 16, + 2: 16, + 3: 16, + 4: 20, + 5: 24, + 6: 28, + 7: 28, + 8: 32, + 9: 40, + 10: 48, + true: 24, + }, + weight: { 1: "400", 2: "500", 3: "600", 4: "700" }, + letterSpacing: { 1: 0, 2: 0.1, 3: 0.2, 4: 0.96 }, + face: { + 400: { + normal: "PlusJakartaSans_400Regular", + italic: "PlusJakartaSans_400Regular_Italic", + }, + 500: { + normal: "PlusJakartaSans_500Medium", + italic: "PlusJakartaSans_500Medium_Italic", + }, + 600: { normal: "PlusJakartaSans_600SemiBold" }, + 700: { normal: "PlusJakartaSans_700Bold" }, + }, +}); + +const monoFont = createFont({ + family: "JetBrainsMono", + size: { + 1: 11, + 2: 12, + 3: 13, + 4: 14, + 5: 15, + 6: 16, + 7: 18, + 8: 20, + 9: 24, + 10: 30, + true: 13, + }, + lineHeight: { + 1: 16, + 2: 18, + 3: 20, + 4: 20, + 5: 22, + 6: 24, + 7: 26, + 8: 28, + 9: 32, + 10: 40, + true: 20, + }, + weight: { 1: "400", 2: "500", 3: "600", 4: "700" }, + letterSpacing: { 1: 0, 2: 0, 3: 0, 4: 0 }, + face: { + 400: { normal: "JetBrainsMono_400Regular" }, + 500: { normal: "JetBrainsMono_500Medium" }, + }, +}); + +// ── Raw palette ───────────────────────────────────────────────────────────── +// THEME §2 hexes. Themes map these to semantic slots below; components never +// reference raw hex — they use the semantic theme tokens ($primary, $surface…). +const palette = { + // Shared accent ramp (§2.1) + ice100: "#e6f7ff", + ice300: "#7dd3fc", + ice500: "#00f5ff", + ice700: "#00696e", + violet500: "#5400c3", + lavender300: "#c8a0f0", + + // App (Light) surfaces + ink (§2.2) + background: "#f9f9fd", + surface: "#ffffff", + surfaceContainerLow: "#f3f3f7", + surfaceContainer: "#eeedf2", + surfaceContainerHigh: "#e8e8ec", + surfaceContainerHighest: "#e2e2e6", + onSurface: "#1a1c1f", + onSurfaceVariant: "#3a494a", + outline: "#6a7a7b", + outlineVariant: "#b9caca", + onPrimary: "#ffffff", + onPrimaryContainer: "#00363a", + onSecondary: "#ffffff", + tertiary: "#5b5f61", + error: "#ba1a1a", + onError: "#ffffff", + errorContainer: "#ffdad6", + success: "#0f7a5a", + + black: "#000000", + white: "#ffffff", +} as const; + +// ── Tokens ────────────────────────────────────────────────────────────────── +// size/space keep a numeric scale (used by width/height/size props + a few +// retained non-chat screens) AND add the named 8pt scale from THEME §4 that the +// design-system components consume ($xs $sm $md $lg $xl). radius likewise carries +// numeric + named keys; per-group resolution means radius.$md (12) and +// space.$md (24) coexist without collision. +const sizeScale = { + 0: 0, + 0.5: 4, + 1: 8, + 1.5: 12, + 2: 16, + 2.5: 20, + 3: 24, + 3.5: 28, + 4: 32, + 5: 40, + 6: 48, + 7: 56, + 8: 64, + 9: 72, + 10: 80, + true: 16, +}; + +export const tokens = createTokens({ + size: sizeScale, + space: { + ...sizeScale, + "-1": -8, + "-2": -16, + // Named 8pt scale (THEME §4) + base: 4, + xs: 8, + sm: 16, + md: 24, + lg: 40, + xl: 64, + gutter: 16, + }, + radius: { + 0: 0, + 1: 4, + 2: 8, + 3: 12, + 4: 16, + 5: 24, + 6: 32, + true: 8, + // Named radii (THEME §4): sm 4 · DEFAULT 8 · md 12 · lg 16 · xl 24 + sm: 4, + md: 12, + lg: 16, + xl: 24, + full: 9999, + }, + zIndex: { 0: 0, 1: 100, 2: 200, 3: 300, 4: 400, 5: 500 }, + color: palette, +}); + +// ── Theme (App Light) ──────────────────────────────────────────────────────── +// Glacier semantic slots + a small set of legacy aliases (background/color/ +// borderColor/brand/placeholderColor…) so the retained non-chat screens +// (settings, auth, chat/new, chat/info) keep rendering until they're restyled. +const light = { + // Glacier semantic (THEME §2.2) + background: palette.background, + surface: palette.surface, + surfaceContainerLow: palette.surfaceContainerLow, + surfaceContainer: palette.surfaceContainer, + surfaceContainerHigh: palette.surfaceContainerHigh, + surfaceContainerHighest: palette.surfaceContainerHighest, + onSurface: palette.onSurface, + onSurfaceVariant: palette.onSurfaceVariant, + outline: palette.outline, + outlineVariant: palette.outlineVariant, + primary: palette.ice700, + onPrimary: palette.onPrimary, + primaryContainer: palette.ice500, + onPrimaryContainer: palette.onPrimaryContainer, + secondary: palette.violet500, + onSecondary: palette.onSecondary, + tertiary: palette.tertiary, + error: palette.error, + onError: palette.onError, + errorContainer: palette.errorContainer, + success: palette.success, + + // Semantic crypto slots (light-mode equivalents — colour + label + weight + // carry the wire/device contrast, per crypto-inspector 2.1.0). cipher = dim + // recessed ink; plaintext = bright forward ink. + cipher: palette.onSurfaceVariant, + plaintext: palette.onSurface, + verified: palette.success, + tamper: palette.error, + + // Tamagui built-in interaction slots + backgroundHover: palette.surfaceContainerLow, + backgroundPress: palette.surfaceContainer, + backgroundFocus: palette.surfaceContainerLow, + backgroundStrong: palette.surfaceContainer, + backgroundTransparent: "rgba(249,249,253,0)", + color: palette.onSurface, + colorHover: palette.onSurfaceVariant, + colorPress: palette.onSurfaceVariant, + colorFocus: palette.onSurface, + colorTransparent: "rgba(0,0,0,0)", + borderColor: palette.outlineVariant, + borderColorHover: palette.outline, + borderColorFocus: palette.ice700, + borderColorPress: palette.outline, + placeholderColor: palette.onSurfaceVariant, + outlineColor: palette.ice700, + shadowColor: palette.black, + shadowColorStrong: palette.black, + + // Legacy aliases (retained screens) → mapped to Glacier equivalents + brand: palette.ice700, + brandHover: palette.onPrimaryContainer, + brandPress: palette.onPrimaryContainer, + brandText: palette.onPrimary, + surface1: palette.surface, + surface2: palette.surfaceContainerLow, + surface3: palette.surfaceContainer, +}; + +// Product is light-only. `dark` mirrors `light` so a stray dark colorScheme +// can't crash TamaguiProvider — the app forces `light` in providers regardless. +const dark = { ...light }; + +// ── Config ──────────────────────────────────────────────────────────────── +const config = createTamagui({ + animations, + fonts: { heading: headingFont, body: bodyFont, mono: monoFont }, + tokens, + themes: { light, dark }, + defaultFont: "body", + media: { + xs: { maxWidth: 660 }, + sm: { maxWidth: 800 }, + md: { maxWidth: 1020 }, + lg: { maxWidth: 1280 }, + xl: { maxWidth: 1650 }, + xxl: { minWidth: 1651 }, + gtXs: { minWidth: 660 + 1 }, + gtSm: { minWidth: 800 + 1 }, + gtMd: { minWidth: 1020 + 1 }, + gtLg: { minWidth: 1280 + 1 }, + short: { maxHeight: 820 }, + tall: { minHeight: 820 }, + hoverable: { hover: "hover" }, + touch: { pointer: "coarse" }, + }, + shorthands: { + px: "paddingHorizontal", + py: "paddingVertical", + pt: "paddingTop", + pb: "paddingBottom", + pl: "paddingLeft", + pr: "paddingRight", + p: "padding", + mx: "marginHorizontal", + my: "marginVertical", + mt: "marginTop", + mb: "marginBottom", + ml: "marginLeft", + mr: "marginRight", + m: "margin", + f: "flex", + w: "width", + h: "height", + bg: "backgroundColor", + br: "borderRadius", + ai: "alignItems", + jc: "justifyContent", + } as const, + settings: { + allowedStyleValues: "somewhat-strict", + autocompleteSpecificTokens: "except-special", + }, +}); + +type AppConfig = typeof config; + +declare module "tamagui" { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface TamaguiCustomConfig extends AppConfig {} +} + +export default config; diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index ca86687..9ed283b 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -1,7 +1,13 @@ { "extends": "@repo/typescript-config/react-library.json", "compilerOptions": { - "outDir": "dist" + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "noEmit": true, + "declaration": false, + "declarationMap": false }, "include": ["src"], "exclude": ["node_modules", "dist"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3aa3ec9..3593513 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,15 @@ importers: '@expo-google-fonts/ibm-plex-sans': specifier: ^0.3.0 version: 0.3.0 + '@expo-google-fonts/jetbrains-mono': + specifier: ^0.4.1 + version: 0.4.1 + '@expo-google-fonts/plus-jakarta-sans': + specifier: ^0.4.2 + version: 0.4.2 + '@expo-google-fonts/sora': + specifier: ^0.4.2 + version: 0.4.2 '@op-engineering/op-sqlite': specifier: ^14.1.0 version: 14.1.4(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -106,9 +115,9 @@ importers: '@repo/chat-transport': specifier: workspace:* version: link:../../packages/chat-transport - '@repo/schemas': + '@repo/ui': specifier: workspace:* - version: link:../../packages/schemas + version: link:../../packages/ui '@shopify/flash-list': specifier: ^2.3.1 version: 2.3.1(@babel/runtime@7.29.2)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -139,9 +148,15 @@ importers: expo: specifier: ~54.0.0 version: 54.0.34(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.14.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.2) + expo-blur: + specifier: ~15.0.8 + version: 15.0.8(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-build-properties: specifier: ~0.14.0 version: 0.14.8(expo@54.0.34) + expo-clipboard: + specifier: ~8.0.8 + version: 8.0.8(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-constants: specifier: ~18.0.0 version: 18.0.13(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)) @@ -160,6 +175,12 @@ importers: expo-font: specifier: ~14.0.0 version: 14.0.11(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-haptics: + specifier: ~15.0.8 + version: 15.0.8(expo@54.0.34) + expo-linear-gradient: + specifier: ~15.0.8 + version: 15.0.8(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-linking: specifier: ~8.0.0 version: 8.0.12(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -168,7 +189,7 @@ importers: version: 0.32.17(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.2) expo-router: specifier: ~6.0.0 - version: 6.0.23(d270f903a381d04171c7e3105bcc3cd4) + version: 6.0.23(cc35041b6ef808321d4e4724234aa178) expo-secure-store: specifier: ~15.0.0 version: 15.0.8(expo@54.0.34) @@ -237,40 +258,6 @@ importers: specifier: 5.9.2 version: 5.9.2 - packages/api: - dependencies: - '@repo/api-server': - specifier: workspace:* - version: link:../../services/api - '@tanstack/react-query': - specifier: ^5.62.11 - version: 5.100.9(react@19.1.0) - '@trpc/client': - specifier: ^11.0.0 - version: 11.17.0(@trpc/server@11.17.0(typescript@5.9.2))(typescript@5.9.2) - '@trpc/react-query': - specifier: ^11.0.0 - version: 11.17.0(@tanstack/react-query@5.100.9(react@19.1.0))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.2))(typescript@5.9.2))(@trpc/server@11.17.0(typescript@5.9.2))(react@19.1.0)(typescript@5.9.2) - devDependencies: - '@repo/eslint-config': - specifier: workspace:* - version: link:../eslint-config - '@repo/typescript-config': - specifier: workspace:* - version: link:../typescript-config - '@types/react': - specifier: ~19.1.10 - version: 19.1.17 - eslint: - specifier: ^9.39.1 - version: 9.39.4(jiti@2.7.0) - react: - specifier: ^19.1.0 - version: 19.1.0 - typescript: - specifier: 5.9.2 - version: 5.9.2 - packages/chat: dependencies: '@msgpack/msgpack': @@ -587,18 +574,22 @@ importers: specifier: 5.9.2 version: 5.9.2 - packages/tailwind-config: {} - packages/typescript-config: {} packages/ui: dependencies: + '@tamagui/animations-react-native': + specifier: 2.0.0-rc.42 + version: 2.0.0-rc.42(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tamagui/linear-gradient': + specifier: 2.0.0-rc.42 + version: 2.0.0-rc.42(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: specifier: ^19.1.0 version: 19.1.0 - react-dom: - specifier: ^19.1.0 - version: 19.1.0(react@19.1.0) + tamagui: + specifier: 2.0.0-rc.42 + version: 2.0.0-rc.42(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) devDependencies: '@repo/eslint-config': specifier: workspace:* @@ -612,9 +603,6 @@ importers: '@types/react': specifier: ~19.1.10 version: 19.1.17 - '@types/react-dom': - specifier: ~19.1.10 - version: 19.1.11(@types/react@19.1.17) eslint: specifier: ^9.39.1 version: 9.39.4(jiti@2.7.0) @@ -627,9 +615,6 @@ importers: '@noble/ed25519': specifier: ^2.3.0 version: 2.3.0 - '@repo/chat-crypto': - specifier: workspace:* - version: link:../../packages/chat-crypto '@repo/chat-mls-core': specifier: workspace:* version: link:../../packages/chat-mls-core @@ -648,6 +633,9 @@ importers: better-auth: specifier: ^1.3.4 version: 1.6.9(@cloudflare/workers-types@4.20260509.1)(@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.2))(typescript@5.9.2))(next@16.1.0(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(prisma@6.19.3(typescript@5.9.2))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@2.1.9(@types/node@22.19.18)(lightningcss@1.32.0)(terser@5.47.1)) + resend: + specifier: ^6.17.0 + version: 6.17.0 zod: specifier: ^3.24.1 version: 3.25.76 @@ -725,9 +713,6 @@ importers: aws4fetch: specifier: ^1.0.20 version: 1.0.20 - ulid: - specifier: ^2.3.0 - version: 2.4.0 devDependencies: '@cloudflare/workers-types': specifier: ^4.20251101.0 @@ -2251,6 +2236,15 @@ packages: '@expo-google-fonts/ibm-plex-sans@0.3.0': resolution: {integrity: sha512-Z02fSj41Tz76nHXZBqyrgpvNId6Q3bL4/awC1VQ524galhejLrnXHCixdZtHHX7mufvxVyAfDaPknvT9DZosiA==} + '@expo-google-fonts/jetbrains-mono@0.4.1': + resolution: {integrity: sha512-CslACrtMOcRwoWXCO7OMEI+9w3fukWSoBtvNz46OqPoogEuuoY0tkDY1O8sFumk8t0pC6Cx0Xr95O0TOQhpkug==} + + '@expo-google-fonts/plus-jakarta-sans@0.4.2': + resolution: {integrity: sha512-6LYVmVGwjQvH+uzzWlVc9+oMj4lkNQ41aymVDjO+x8aFk8kCye20wOyLomYMZaMezA++Uf1mZRCw3W3Fy/hxEA==} + + '@expo-google-fonts/sora@0.4.2': + resolution: {integrity: sha512-X86Kl1cfObcSUzTLIbtmrPCLRlIbCNh0uBOo8bjmXb8gWgVVhnI8CZJEK2btsdq85JPn5k4P6sbV9JLbAm7dqQ==} + '@expo/cli@54.0.24': resolution: {integrity: sha512-5xse1bEgnVUBhOrtttc6xTNJVvjyTRavpzuF0/0nuj+312vfSbk7EiRbG+xJ2pW/iZxnhLPJkFCrPYG0nmheAQ==} hasBin: true @@ -3613,6 +3607,9 @@ packages: '@speed-highlight/core@1.2.15': resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -4403,15 +4400,6 @@ packages: '@trpc/server': 11.17.0 typescript: '>=5.7.2' - '@trpc/react-query@11.17.0': - resolution: {integrity: sha512-AGcl5YAF8NnhBmyJ6PqJqKb1M5VTGSoNRNqJ3orct4o4epdcg0GWhW+qT9q6gPzs/2ImIwYCdfFpgNGdZ9yLHA==} - peerDependencies: - '@tanstack/react-query': ^5.80.3 - '@trpc/client': 11.17.0 - '@trpc/server': 11.17.0 - react: '>=18.2.0' - typescript: '>=5.7.2' - '@trpc/server@11.17.0': resolution: {integrity: sha512-jbAOUe0PpUTCYqziyu+8vYXZdDXPudZgnEhWCQ2NjKnVEjfE93RqHTt1oycZJv/HNf51YlRXfEEwSIAbb161rw==} hasBin: true @@ -5832,11 +5820,25 @@ packages: react: '*' react-native: '*' + expo-blur@15.0.8: + resolution: {integrity: sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-build-properties@0.14.8: resolution: {integrity: sha512-GTFNZc5HaCS9RmCi6HspCe2+isleuOWt2jh7UEKHTDQ9tdvzkIoWc7U6bQO9lH3Mefk4/BcCUZD/utl7b1wdqw==} peerDependencies: expo: '*' + expo-clipboard@8.0.8: + resolution: {integrity: sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-constants@18.0.13: resolution: {integrity: sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==} peerDependencies: @@ -5886,6 +5888,11 @@ packages: react: '*' react-native: '*' + expo-haptics@15.0.8: + resolution: {integrity: sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==} + peerDependencies: + expo: '*' + expo-json-utils@0.15.0: resolution: {integrity: sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==} @@ -5895,6 +5902,13 @@ packages: expo: '*' react: '*' + expo-linear-gradient@15.0.8: + resolution: {integrity: sha512-V2d8Wjn0VzhPHO+rrSBtcl+Fo+jUUccdlmQ6OoL9/XQB7Qk3d9lYrqKDJyccwDxmQT10JdST3Tmf2K52NLc3kw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-linking@8.0.12: resolution: {integrity: sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==} peerDependencies: @@ -6052,6 +6066,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} @@ -7711,6 +7728,9 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postal-mime@2.7.4: + resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==} + postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -8027,6 +8047,15 @@ packages: resolution: {integrity: sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==} engines: {node: '>= 4.0.0'} + resend@6.17.0: + resolution: {integrity: sha512-hYNLU58nKg1Sx9kPF1E6fo447/9OMNbk3pOzuQGZMtoRU4FhtNTrW7SHFLPT8F8quTcIAa6Cs5Q5hEuWHJNTpA==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -8358,6 +8387,9 @@ packages: resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} engines: {node: '>=6'} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -8671,10 +8703,6 @@ packages: resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} hasBin: true - ulid@2.4.0: - resolution: {integrity: sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg==} - hasBin: true - unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -10637,6 +10665,12 @@ snapshots: '@expo-google-fonts/ibm-plex-sans@0.3.0': {} + '@expo-google-fonts/jetbrains-mono@0.4.1': {} + + '@expo-google-fonts/plus-jakarta-sans@0.4.2': {} + + '@expo-google-fonts/sora@0.4.2': {} + '@expo/cli@54.0.24(expo-router@6.0.23)(expo@54.0.34)(graphql@16.14.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(typescript@5.9.2)': dependencies: '@0no-co/graphql.web': 1.2.0(graphql@16.14.0) @@ -10704,7 +10738,7 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.20.0 optionalDependencies: - expo-router: 6.0.23(d270f903a381d04171c7e3105bcc3cd4) + expo-router: 6.0.23(cc35041b6ef808321d4e4724234aa178) react-native: 0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) transitivePeerDependencies: - bufferutil @@ -12370,6 +12404,8 @@ snapshots: '@speed-highlight/core@1.2.15': {} + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.15': @@ -14037,7 +14073,7 @@ snapshots: '@tanstack/query-core': 5.100.9 react: 19.1.0 - '@testing-library/react-native@13.3.3(jest@29.7.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.2.0(react@19.1.0))(react@19.1.0)': + '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@22.19.18))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.2.0(react@19.1.0))(react@19.1.0)': dependencies: jest-matcher-utils: 30.4.1 picocolors: 1.1.1 @@ -14055,14 +14091,6 @@ snapshots: '@trpc/server': 11.17.0(typescript@5.9.2) typescript: 5.9.2 - '@trpc/react-query@11.17.0(@tanstack/react-query@5.100.9(react@19.1.0))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.2))(typescript@5.9.2))(@trpc/server@11.17.0(typescript@5.9.2))(react@19.1.0)(typescript@5.9.2)': - dependencies: - '@tanstack/react-query': 5.100.9(react@19.1.0) - '@trpc/client': 11.17.0(@trpc/server@11.17.0(typescript@5.9.2))(typescript@5.9.2) - '@trpc/server': 11.17.0(typescript@5.9.2) - react: 19.1.0 - typescript: 5.9.2 - '@trpc/server@11.17.0(typescript@5.9.2)': dependencies: typescript: 5.9.2 @@ -15750,12 +15778,24 @@ snapshots: - supports-color - typescript + expo-blur@15.0.8(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.34(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.14.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.2) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + expo-build-properties@0.14.8(expo@54.0.34): dependencies: ajv: 8.20.0 expo: 54.0.34(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.14.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.2) semver: 7.8.0 + expo-clipboard@8.0.8(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.34(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.14.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.2) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + expo-constants@18.0.13(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)): dependencies: '@expo/config': 12.0.13 @@ -15816,6 +15856,10 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + expo-haptics@15.0.8(expo@54.0.34): + dependencies: + expo: 54.0.34(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.14.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.2) + expo-json-utils@0.15.0: {} expo-keep-awake@15.0.8(expo@54.0.34)(react@19.1.0): @@ -15823,6 +15867,12 @@ snapshots: expo: 54.0.34(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.14.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.2) react: 19.1.0 + expo-linear-gradient@15.0.8(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.34(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.14.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.2) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + expo-linking@8.0.12(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: expo-constants: 18.0.13(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)) @@ -15871,7 +15921,7 @@ snapshots: - supports-color - typescript - expo-router@6.0.23(d270f903a381d04171c7e3105bcc3cd4): + expo-router@6.0.23(cc35041b6ef808321d4e4724234aa178): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.34)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@expo/schema-utils': 0.1.8 @@ -15904,7 +15954,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.1.0) vaul: 1.1.2(@types/react-dom@19.1.11(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) optionalDependencies: - '@testing-library/react-native': 13.3.3(jest@29.7.0)(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.2.0(react@19.1.0))(react@19.1.0) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@22.19.18))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.2.0(react@19.1.0))(react@19.1.0) react-dom: 19.1.0(react@19.1.0) react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-reanimated: 4.1.7(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -16058,6 +16108,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: {} + fast-uri@3.1.2: {} fast-xml-builder@1.2.0: @@ -18246,6 +18298,8 @@ snapshots: possible-typed-array-names@1.1.0: {} + postal-mime@2.7.4: {} + postcss-value-parser@4.2.0: {} postcss@8.4.31: @@ -18626,6 +18680,11 @@ snapshots: rc: 1.2.8 resolve: 1.7.1 + resend@6.17.0: + dependencies: + postal-mime: 2.7.4 + standardwebhooks: 1.0.0 + resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -19042,6 +19101,11 @@ snapshots: dependencies: type-fest: 0.7.1 + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@1.5.0: {} statuses@2.0.2: {} @@ -19444,8 +19508,6 @@ snapshots: ua-parser-js@1.0.41: {} - ulid@2.4.0: {} - unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 diff --git a/services/api/package.json b/services/api/package.json index 4fb150e..bce2d48 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -15,13 +15,13 @@ }, "dependencies": { "@noble/ed25519": "^2.3.0", - "@repo/chat-crypto": "workspace:*", "@repo/chat-mls-core": "workspace:*", "@repo/database": "workspace:*", "@repo/identity": "workspace:*", "@repo/schemas": "workspace:*", "@trpc/server": "^11.0.0", "better-auth": "^1.3.4", + "resend": "^6.17.0", "zod": "^3.24.1" }, "devDependencies": { diff --git a/services/api/src/lib/auth.ts b/services/api/src/lib/auth.ts index 2553747..10e104a 100644 --- a/services/api/src/lib/auth.ts +++ b/services/api/src/lib/auth.ts @@ -4,6 +4,8 @@ import { prismaAdapter } from "better-auth/adapters/prisma"; import { bearer } from "better-auth/plugins"; import { prisma } from "@repo/database"; import { purgeSessionCache } from "./chat-ws-push"; +import { sendEmail, resetPasswordLink } from "./email"; +import { verificationEmail, resetPasswordEmail } from "./email-templates"; // ── Better Auth server instance ─────────────────────────────────────────────── // Sessions are DB-backed (via Prisma) — fully revocable on logout or ban. // The `bearer` plugin enables Authorization: Bearer for API clients @@ -55,8 +57,32 @@ export const auth = betterAuth({ // Email + password auth (web2 path) emailAndPassword: { enabled: true, - requireEmailVerification: false, // set true when email provider is wired + // Nudge, not a wall: signup still returns a session and the client routes + // straight into the app. Flip to true (and add the "check your inbox" + // signup state + a verify landing screen) to gate login on verification. + requireEmailVerification: false, minPasswordLength: 8, + // Reset flow: forgot-password.tsx calls requestPasswordReset; we build our + // own deep link with the token so the email opens the app's reset screen + // directly (no browser bounce). The app calls authClient.resetPassword. + sendResetPassword: async ({ user, token }) => { + const { subject, html, text } = resetPasswordEmail( + resetPasswordLink(token), + ); + await sendEmail({ to: user.email, subject, html, text }); + }, + }, + + // Verification email on signup. Uses Better Auth's server-side `url` + // (/auth/verify-email?token=…): tapping it verifies and redirects, so no app + // screen is required for the non-gated flow. + emailVerification: { + sendOnSignUp: true, + autoSignInAfterVerification: true, + sendVerificationEmail: async ({ user, url }) => { + const { subject, html, text } = verificationEmail(url); + await sendEmail({ to: user.email, subject, html, text }); + }, }, // On new email signup, create linked domain Account diff --git a/services/api/src/lib/email-templates.ts b/services/api/src/lib/email-templates.ts new file mode 100644 index 0000000..8470a3d --- /dev/null +++ b/services/api/src/lib/email-templates.ts @@ -0,0 +1,47 @@ +// ── Email bodies ────────────────────────────────────────────────────────────── +// Minimal, dependency-free HTML + plaintext. Keep portable: no framework, no +// remote assets. Callers pass a ready-to-click link. + +function shell( + heading: string, + body: string, + cta: { href: string; label: string }, +): string { + return ` + + + + +
+

${heading}

+

${body}

+ ${cta.label} +

If you didn't request this, you can safely ignore this email.

+
+ +`; +} + +export function verificationEmail(url: string) { + return { + subject: "Verify your email", + html: shell( + "Confirm your email", + "Tap below to verify your email address and finish setting up your account.", + { href: url, label: "Verify email" }, + ), + text: `Confirm your email\n\nVerify your email address: ${url}\n\nIf you didn't request this, ignore this email.`, + }; +} + +export function resetPasswordEmail(url: string) { + return { + subject: "Reset your password", + html: shell( + "Reset your password", + "Tap below to choose a new password. This link expires shortly.", + { href: url, label: "Reset password" }, + ), + text: `Reset your password\n\nChoose a new password: ${url}\n\nIf you didn't request this, ignore this email.`, + }; +} diff --git a/services/api/src/lib/email.ts b/services/api/src/lib/email.ts new file mode 100644 index 0000000..a03cd09 --- /dev/null +++ b/services/api/src/lib/email.ts @@ -0,0 +1,59 @@ +import { Resend } from "resend"; + +// ── Transactional email ─────────────────────────────────────────────────────── +// Single seam over the email provider. Swap Resend for SES/Postmark/etc by +// rewriting only sendEmail() — callers (auth.ts) never see the provider. +// +// Env: +// RESEND_API_KEY — provider key +// EMAIL_FROM — verified sender, e.g. "Mortstack " +// APP_SCHEME — deep-link scheme for links back into the app +// (defaults to the Expo scheme in app.json) + +const APP_SCHEME = process.env.APP_SCHEME ?? "mortstack-chatapp"; + +// Deep link the mobile app handles (see app/(auth)/reset-password.tsx). The +// token rides as a query param; the app calls authClient.resetPassword with it. +export function resetPasswordLink(token: string): string { + return `${APP_SCHEME}://reset-password?token=${encodeURIComponent(token)}`; +} + +let client: Resend | null = null; +function resend(): Resend { + if (!client) { + const key = process.env.RESEND_API_KEY; + if (!key) throw new Error("RESEND_API_KEY is not set"); + client = new Resend(key); + } + return client; +} + +export interface SendEmailArgs { + to: string; + subject: string; + html: string; + text: string; +} + +export async function sendEmail({ + to, + subject, + html, + text, +}: SendEmailArgs): Promise { + const from = process.env.EMAIL_FROM; + if (!from) throw new Error("EMAIL_FROM is not set"); + + const { error } = await resend().emails.send({ + from, + to, + subject, + html, + text, + }); + if (error) { + // Surface for the caller's try/catch; auth flows swallow to avoid user + // enumeration but log server-side. + throw new Error(`Resend send failed: ${error.message}`); + } +} diff --git a/services/chat-ws/package.json b/services/chat-ws/package.json index 0257ecb..904d42e 100644 --- a/services/chat-ws/package.json +++ b/services/chat-ws/package.json @@ -16,8 +16,7 @@ "dependencies": { "@repo/chat-transport": "workspace:*", "@repo/db-edge": "workspace:*", - "aws4fetch": "^1.0.20", - "ulid": "^2.3.0" + "aws4fetch": "^1.0.20" }, "devDependencies": { "@cloudflare/workers-types": "^4.20251101.0",