├── .env ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── app.json ├── assets └── images │ ├── adaptive-icon.png │ ├── favicon.png │ ├── icon.png │ └── splash-icon.png ├── bun.lockb ├── eas.json ├── eslint.config.js ├── global.d.ts ├── metro.config.js ├── metro.transformer.js ├── package.json ├── patches └── react-server-dom-webpack+19.0.0.patch ├── src ├── app │ ├── (index,info) │ │ ├── _debug.tsx │ │ ├── _layout.tsx │ │ ├── account.tsx │ │ ├── icon.tsx │ │ ├── index.tsx │ │ ├── info.tsx │ │ ├── privacy.tsx │ │ └── two.tsx │ ├── +html.tsx │ └── _layout.tsx ├── components │ ├── data │ │ └── async-font.tsx │ ├── example │ │ ├── glurry-modal.tsx │ │ └── privacy-dom.tsx │ ├── layout │ │ ├── modal.module.css │ │ ├── modalNavigator.tsx │ │ └── modalNavigator.web.tsx │ ├── runtime │ │ ├── apple-css-variables.ts │ │ ├── local-storage.ts │ │ └── local-storage.web.ts │ ├── torus-dom.tsx │ └── ui │ │ ├── BodyScrollView.tsx │ │ ├── ContentUnavailable.tsx │ │ ├── FadeIn.tsx │ │ ├── Form.tsx │ │ ├── Header.tsx │ │ ├── IconSymbol.ios.tsx │ │ ├── IconSymbol.tsx │ │ ├── IconSymbolFallback.tsx │ │ ├── Segments.tsx │ │ ├── Skeleton.tsx │ │ ├── Skeleton.web.tsx │ │ ├── Stack.tsx │ │ ├── TabBarBackground.ios.tsx │ │ ├── TabBarBackground.tsx │ │ ├── Tabs.tsx │ │ ├── ThemeProvider.tsx │ │ ├── TouchableBounce.tsx │ │ └── TouchableBounce.web.tsx ├── hooks │ ├── useHeaderSearch.ts │ ├── useMergedRef.ts │ └── useTabToTop.ts └── svg │ ├── expo.svg │ └── github.svg └── tsconfig.json /.env: -------------------------------------------------------------------------------- 1 | EXPO_UNSTABLE_DEPLOY_SERVER=1 2 | EXPO_METRO_UNSTABLE_ERRORS=0 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | expo-env.d.ts 11 | 12 | # Native 13 | *.orig.* 14 | *.jks 15 | *.p8 16 | *.p12 17 | *.key 18 | *.mobileprovision 19 | 20 | # Metro 21 | .metro-health-check* 22 | 23 | # debug 24 | npm-debug.* 25 | yarn-debug.* 26 | yarn-error.* 27 | 28 | # macOS 29 | .DS_Store 30 | *.pem 31 | 32 | # local env files 33 | .env*.local 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | 38 | app-example 39 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit", 4 | "source.fixAll": "explicit", 5 | "source.organizeImports": "explicit", 6 | "source.sortMembers": "explicit" 7 | }, 8 | "typescript.preferences.autoImportSpecifierExcludeRegexes": [ 9 | "^react-native-reanimated/lib/*", 10 | "^expo-router/build/*" 11 | ], 12 | "typescript.tsdk": "node_modules/typescript/lib" 13 | } 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Torus 2 | 3 | 4 | **_“You know a design is good when you want to touch it.”_** 5 | 6 | ~ Jonathan Ive 7 | 8 | 9 | 10 | 11 | 12 | 13 | ## Preview 14 | 15 | https://github.com/user-attachments/assets/5927b81f-a3ba-424f-92c0-6965389abffd 16 | 17 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "torus", 4 | "slug": "torus", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "torus", 9 | "userInterfaceStyle": "automatic", 10 | "newArchEnabled": true, 11 | "ios": { 12 | "supportsTablet": false, 13 | "infoPlist": { 14 | "UIViewControllerBasedStatusBarAppearance": true, 15 | "ITSAppUsesNonExemptEncryption": false 16 | }, 17 | "bundleIdentifier": "com.bacon.torus" 18 | }, 19 | "android": { 20 | "adaptiveIcon": { 21 | "foregroundImage": "./assets/images/adaptive-icon.png", 22 | "backgroundColor": "#ffffff" 23 | }, 24 | "edgeToEdgeEnabled": true 25 | }, 26 | "web": { 27 | "output": "server", 28 | "favicon": "./assets/images/favicon.png" 29 | }, 30 | "plugins": [ 31 | [ 32 | "expo-router", 33 | {} 34 | ], 35 | [ 36 | "expo-splash-screen", 37 | { 38 | "resizeMode": "contain", 39 | "backgroundColor": "#ffffff" 40 | } 41 | ], 42 | "expo-sqlite", 43 | "expo-font", 44 | "expo-web-browser" 45 | ], 46 | "experiments": { 47 | "typedRoutes": true, 48 | "reactCompiler": true 49 | }, 50 | "extra": { 51 | "eas": { 52 | "projectId": "925eb44e-8658-42be-a754-f4e9d799de91" 53 | }, 54 | "router": {} 55 | }, 56 | "runtimeVersion": { 57 | "policy": "appVersion" 58 | }, 59 | "updates": { 60 | "url": "https://u.expo.dev/925eb44e-8658-42be-a754-f4e9d799de91" 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanBacon/expo-beautiful-torus/f0937916351a8a2f5a81a1c93a5929f1eb6466d7/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanBacon/expo-beautiful-torus/f0937916351a8a2f5a81a1c93a5929f1eb6466d7/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanBacon/expo-beautiful-torus/f0937916351a8a2f5a81a1c93a5929f1eb6466d7/assets/images/icon.png -------------------------------------------------------------------------------- /assets/images/splash-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanBacon/expo-beautiful-torus/f0937916351a8a2f5a81a1c93a5929f1eb6466d7/assets/images/splash-icon.png -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanBacon/expo-beautiful-torus/f0937916351a8a2f5a81a1c93a5929f1eb6466d7/bun.lockb -------------------------------------------------------------------------------- /eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 16.6.1", 4 | "appVersionSource": "remote" 5 | }, 6 | "build": { 7 | "development": { 8 | "developmentClient": true, 9 | "distribution": "internal" 10 | }, 11 | "preview": { 12 | "distribution": "internal" 13 | }, 14 | "production": { 15 | "autoIncrement": true 16 | } 17 | }, 18 | "submit": { 19 | "production": {} 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // https://docs.expo.dev/guides/using-eslint/ 2 | const { defineConfig } = require("eslint/config"); 3 | const expoConfig = require("eslint-config-expo/flat"); 4 | 5 | module.exports = defineConfig([ 6 | expoConfig, 7 | { 8 | ignores: ["dist/*"], 9 | plugins: ["react-compiler"], 10 | rules: { 11 | "react-compiler/react-compiler": "error", 12 | }, 13 | }, 14 | ]); 15 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | import React from "react"; 3 | import { SvgProps } from "react-native-svg"; 4 | const content: React.FC; 5 | export default content; 6 | } 7 | -------------------------------------------------------------------------------- /metro.config.js: -------------------------------------------------------------------------------- 1 | // Learn more https://docs.expo.io/guides/customizing-metro 2 | const { getDefaultConfig } = require("expo/metro-config"); 3 | 4 | /** @type {import('expo/metro-config').MetroConfig} */ 5 | const config = getDefaultConfig(__dirname); 6 | 7 | config.resolver.sourceExts.push("svg"); 8 | config.resolver.assetExts = config.resolver.assetExts.filter( 9 | (ext) => ext !== "svg" 10 | ); 11 | 12 | config.transformer.babelTransformerPath = require.resolve( 13 | "./metro.transformer.js" 14 | ); 15 | 16 | config.transformer.getTransformOptions = async () => ({ 17 | transform: { 18 | experimentalImportSupport: true, 19 | }, 20 | }); 21 | 22 | module.exports = config; 23 | -------------------------------------------------------------------------------- /metro.transformer.js: -------------------------------------------------------------------------------- 1 | const upstreamTransformer = require("@expo/metro-config/babel-transformer"); 2 | 3 | async function convertSvgModule(projectRoot, src, options) { 4 | const { resolveConfig, transform } = require("@svgr/core"); 5 | const isNotNative = !options.platform || options.platform === "web"; 6 | 7 | const defaultSVGRConfig = { 8 | native: !isNotNative, 9 | plugins: ["@svgr/plugin-svgo", "@svgr/plugin-jsx"], 10 | svgoConfig: { 11 | // TODO: Maybe there's a better config for web? 12 | plugins: [ 13 | { 14 | name: "preset-default", 15 | params: { 16 | overrides: { 17 | inlineStyles: { 18 | onlyMatchedOnce: false, 19 | }, 20 | removeViewBox: false, 21 | removeUnknownsAndDefaults: false, 22 | convertColors: false, 23 | }, 24 | }, 25 | }, 26 | ], 27 | }, 28 | }; 29 | 30 | const svgUserConfig = await resolveConfig(projectRoot); 31 | const svgrConfig = svgUserConfig 32 | ? { ...defaultSVGRConfig, ...svgUserConfig } 33 | : defaultSVGRConfig; 34 | 35 | const output = await transform( 36 | src, 37 | // @ts-expect-error 38 | svgrConfig 39 | ); 40 | 41 | if (isNotNative) { 42 | // If the SVG is not native, we need to add a wrapper to make it work 43 | return output; 44 | } 45 | 46 | // RNSVG doesn't support RSC yet. 47 | return '"use client";\n' + output; 48 | } 49 | 50 | module.exports.transform = async ({ src, filename, options }) => { 51 | if (filename.endsWith(".svg")) { 52 | src = await convertSvgModule(options.projectRoot, src, { 53 | platform: options.platform, 54 | }); 55 | } 56 | // Pass the source through the upstream Expo transformer. 57 | return upstreamTransformer.transform({ src, filename, options }); 58 | }; 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "torus-dom", 3 | "main": "expo-router/entry", 4 | "version": "1.0.0", 5 | "scripts": { 6 | "start": "expo start", 7 | "android": "expo run:android", 8 | "ios": "expo run:ios", 9 | "web": "expo start --web", 10 | "lint": "expo lint", 11 | "deploy:ios": "npx testflight", 12 | "deploy:web": "expo export -p web && npx eas-cli@latest deploy" 13 | }, 14 | "dependencies": { 15 | "@bacons/apple-colors": "^0.0.8", 16 | "@expo-google-fonts/inter": "^0.3.0", 17 | "@expo-google-fonts/roboto-mono": "^0.3.0", 18 | "@expo-google-fonts/source-code-pro": "^0.3.0", 19 | "@expo/vector-icons": "^14.1.0", 20 | "@react-native-masked-view/masked-view": "0.3.2", 21 | "@react-native-segmented-control/segmented-control": "2.5.7", 22 | "@svgr/core": "^8.1.0", 23 | "@svgr/plugin-jsx": "^8.1.0", 24 | "@svgr/plugin-svgo": "^8.1.0", 25 | "babel-plugin-react-compiler": "^19.0.0-beta-af1b7da-20250417", 26 | "eslint-plugin-react-compiler": "^19.1.0-rc.1", 27 | "expo": "^53", 28 | "expo-application": "~6.1.4", 29 | "expo-blur": "~14.1.4", 30 | "expo-clipboard": "~7.1.4", 31 | "expo-constants": "~17.1.5", 32 | "expo-font": "~13.3.1", 33 | "expo-haptics": "~14.1.4", 34 | "expo-image": "~2.1.6", 35 | "expo-linking": "~7.1.4", 36 | "expo-router": "~5.0.5", 37 | "expo-splash-screen": "~0.30.8", 38 | "expo-sqlite": "~15.2.9", 39 | "expo-status-bar": "~2.2.3", 40 | "expo-symbols": "~0.4.4", 41 | "expo-system-ui": "~5.0.7", 42 | "expo-updates": "~0.28.12", 43 | "expo-web-browser": "~14.1.6", 44 | "react": "19.0.0", 45 | "react-dom": "19.0.0", 46 | "react-native": "0.79.2", 47 | "react-native-gesture-handler": "~2.24.0", 48 | "react-native-reanimated": "~3.17.3", 49 | "react-native-safe-area-context": "5.4.0", 50 | "react-native-screens": "~4.10.0", 51 | "react-native-svg": "15.11.2", 52 | "react-native-web": "~0.20.0", 53 | "react-native-webview": "13.13.5", 54 | "three": "^0.176.0", 55 | "vaul": "^1.1.2" 56 | }, 57 | "devDependencies": { 58 | "@babel/core": "^7.25.2", 59 | "@types/react": "~19.0.10", 60 | "eslint": "^9.0.0", 61 | "eslint-config-expo": "~9.2.0", 62 | "expo-atlas": "^0.4.0", 63 | "typescript": "^5.3.3" 64 | }, 65 | "private": true 66 | } 67 | -------------------------------------------------------------------------------- /patches/react-server-dom-webpack+19.0.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js b/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js 2 | index 38e04fb..24cc8ef 100644 3 | --- a/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js 4 | +++ b/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js 5 | @@ -800,9 +800,9 @@ 6 | return bound 7 | ? "fulfilled" === bound.status 8 | ? callServer(id, bound.value.concat(args)) 9 | - : Promise.resolve(bound).then(function (boundArgs) { 10 | - return callServer(id, boundArgs.concat(args)); 11 | - }) 12 | + // HACK: This is required to make native server actions return a non-undefined value. 13 | + // Seems like a bug in the Hermes engine since the same babel transforms work in Chrome/web. 14 | + : (async () => callServer(id, (await bound).concat(args)))() 15 | : callServer(id, args); 16 | } 17 | var id = metaData.id, 18 | diff --git a/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js b/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js 19 | index 7a5db2b..74dbad0 100644 20 | --- a/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js 21 | +++ b/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js 22 | @@ -512,9 +512,9 @@ function createBoundServerReference(metaData, callServer) { 23 | return bound 24 | ? "fulfilled" === bound.status 25 | ? callServer(id, bound.value.concat(args)) 26 | - : Promise.resolve(bound).then(function (boundArgs) { 27 | - return callServer(id, boundArgs.concat(args)); 28 | - }) 29 | + // HACK: This is required to make native server actions return a non-undefined value. 30 | + // Seems like a bug in the Hermes engine since the same babel transforms work in Chrome/web. 31 | + : (async () => callServer(id, (await bound).concat(args)))() 32 | : callServer(id, args); 33 | } 34 | var id = metaData.id, 35 | -------------------------------------------------------------------------------- /src/app/(index,info)/_debug.tsx: -------------------------------------------------------------------------------- 1 | import "@/components/runtime/local-storage"; 2 | 3 | import * as Form from "@/components/ui/Form"; 4 | import Constants, { ExecutionEnvironment } from "expo-constants"; 5 | 6 | import * as Clipboard from "expo-clipboard"; 7 | import { IconSymbol } from "@/components/ui/IconSymbol"; 8 | import * as AC from "@bacons/apple-colors"; 9 | import * as Updates from "expo-updates"; 10 | import { ActivityIndicator, Linking, View } from "react-native"; 11 | 12 | import * as Application from "expo-application"; 13 | import { router } from "expo-router"; 14 | import { useEffect, useState } from "react"; 15 | 16 | const ENV_SUPPORTS_OTA = 17 | process.env.EXPO_OS !== "web" && 18 | typeof window !== "undefined" && 19 | Constants.executionEnvironment !== ExecutionEnvironment.StoreClient; 20 | 21 | export default function DebugRoute() { 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | /_sitemap 29 | {process.env.EXPO_OS !== "web" && ( 30 | Linking.openSettings()} 32 | hint={} 33 | > 34 | Open System Settings 35 | 36 | )} 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | } 44 | 45 | // Async function to get App Store link by bundle ID, with caching 46 | async function getAppStoreLink(bundleId: string) { 47 | // Check cache first 48 | const cachedLink = localStorage.getItem(`appStoreLink_${bundleId}`); 49 | if (cachedLink) { 50 | console.log(`Returning cached App Store link for ${bundleId}`); 51 | return cachedLink; 52 | } 53 | 54 | // Make API call to iTunes Search API 55 | const response = await fetch( 56 | `https://itunes.apple.com/lookup?bundleId=${encodeURIComponent(bundleId)}` 57 | ); 58 | 59 | // Check if response is OK 60 | if (!response.ok) { 61 | throw new Error( 62 | `Failed to query App Store URL. Status: ${response.status}` 63 | ); 64 | } 65 | 66 | const data = await response.json(); 67 | 68 | // Validate API response 69 | if (data.resultCount === 0 || !data.results[0]?.trackId) { 70 | throw new Error(`No app found for bundle ID on App Store: ${bundleId}`); 71 | } 72 | 73 | // Extract App ID and construct App Store link 74 | const appId = data.results[0].trackId; 75 | const appStoreLink = `https://apps.apple.com/app/id${appId}`; 76 | 77 | // Cache the successful result 78 | localStorage.setItem(`appStoreLink_${bundleId}`, appStoreLink); 79 | console.log(`Cached App Store link for ${bundleId}`); 80 | 81 | return appStoreLink; 82 | } 83 | 84 | async function getStoreUrlAsync() { 85 | if (process.env.EXPO_OS === "ios") { 86 | return await getAppStoreLinkAsync(); 87 | } else if (process.env.EXPO_OS === "android") { 88 | return `https://play.google.com/store/apps/details?id=${Application.applicationId}`; 89 | } 90 | return null; 91 | } 92 | 93 | async function getAppStoreLinkAsync() { 94 | if (process.env.EXPO_OS !== "ios") { 95 | return null; 96 | } 97 | try { 98 | const link = await getAppStoreLink(Application.applicationId!); 99 | return link; 100 | } catch (error: any) { 101 | console.error("Error fetching App Store link:", error); 102 | alert(error.message); 103 | return null; 104 | } 105 | } 106 | 107 | function AppStoreSection() { 108 | const [canOpenStore, setCanOpenStore] = useState(true); 109 | if (process.env.EXPO_OS === "web") { 110 | return null; 111 | } 112 | 113 | return ( 114 | 117 | { 120 | const appStoreLink = await getStoreUrlAsync(); 121 | setCanOpenStore(!!appStoreLink); 122 | console.log("App Store link:", appStoreLink); 123 | if (appStoreLink) { 124 | // @ts-ignore: external URL 125 | router.push(appStoreLink); 126 | } 127 | }} 128 | style={{ color: AC.systemBlue }} 129 | > 130 | {canOpenStore ? `Check for app updates` : "App not available"} 131 | 132 | 133 | {process.env.EXPO_OS === "ios" ? `Bundle ID` : "App ID"} 134 | 135 | 136 | ); 137 | } 138 | 139 | function ExpoSection() { 140 | const sdkVersion = (() => { 141 | const current = Constants.expoConfig?.sdkVersion; 142 | if (current && current.includes(".")) { 143 | return current.split(".").shift(); 144 | } 145 | return current ?? "unknown"; 146 | })(); 147 | 148 | const [envName, setEnvName] = useState(null); 149 | useEffect(() => { 150 | getReleaseTypeAsync().then((name) => { 151 | setEnvName(name); 152 | }); 153 | }, []); 154 | 155 | const hermes = getHermesVersion(); 156 | 157 | return ( 158 | <> 159 | 160 | Environment 161 | {hermes && Hermes} 162 | 163 | Mode 164 | 165 | 166 | 167 | { 172 | Clipboard.setStringAsync(getDeploymentUrl()); 173 | alert("Copied to clipboard"); 174 | }} 175 | href={getDeploymentUrl()} 176 | > 177 | Expo Dashboard 178 | 179 | 180 | 181 | 182 | Host 183 | 184 | 185 | ); 186 | } 187 | 188 | function OTADynamicSection() { 189 | if (process.env.EXPO_OS === "web") { 190 | return null; 191 | } 192 | const updates = Updates.useUpdates(); 193 | 194 | const fetchingTitle = updates.isDownloading 195 | ? `Downloading...` 196 | : updates.isChecking 197 | ? `Checking for updates...` 198 | : updates.isUpdateAvailable 199 | ? "Reload app" 200 | : "Check again"; 201 | 202 | const checkError = updates.checkError; 203 | // const checkError = new Error( 204 | // "really long error name that hs sefsef sef sef sefsef sef eorhsoeuhfsef fselfkjhslehfse f" 205 | // ); // updates.checkError; 206 | 207 | const lastCheckTime = ( 208 | updates.lastCheckForUpdateTimeSinceRestart 209 | ? new Date(updates.lastCheckForUpdateTimeSinceRestart) 210 | : new Date() 211 | ).toLocaleString("en-US", { 212 | timeZoneName: "short", 213 | dateStyle: "short", 214 | timeStyle: "short", 215 | }); 216 | 217 | const isLoading = updates.isChecking || updates.isDownloading; 218 | return ( 219 | <> 220 | : lastCheckTime} 225 | > 226 | { 232 | if (__DEV__ && !ENV_SUPPORTS_OTA) { 233 | alert("OTA updates are not available in the Expo Go app."); 234 | return; 235 | } 236 | if (updates.availableUpdate) { 237 | Updates.reloadAsync(); 238 | } else { 239 | Updates.checkForUpdateAsync(); 240 | } 241 | }} 242 | hint={ 243 | isLoading ? ( 244 | 245 | ) : ( 246 | 247 | ) 248 | } 249 | > 250 | {fetchingTitle} 251 | 252 | {checkError && ( 253 | 254 | 255 | Error checking status 256 | 257 | {/* Spacer */} 258 | 259 | {/* Right */} 260 | 261 | {checkError.message} 262 | 263 | 264 | )} 265 | 266 | 267 | ); 268 | } 269 | 270 | function OTASection() { 271 | return ( 272 | <> 273 | 274 | Runtime version 275 | Channel 276 | 281 | Created 282 | 283 | Embedded 284 | 285 | Emergency Launch 286 | 287 | 288 | Launch Duration 289 | 290 | ID 291 | 292 | 293 | ); 294 | } 295 | 296 | function getHermesVersion() { 297 | // @ts-expect-error 298 | const HERMES_RUNTIME = global.HermesInternal?.getRuntimeProperties?.() ?? {}; 299 | const HERMES_VERSION = HERMES_RUNTIME["OSS Release Version"]; 300 | const isStaticHermes = HERMES_RUNTIME["Static Hermes"]; 301 | 302 | if (!HERMES_RUNTIME) { 303 | return null; 304 | } 305 | 306 | if (isStaticHermes) { 307 | return `${HERMES_VERSION} (shermes)`; 308 | } 309 | return HERMES_VERSION; 310 | } 311 | 312 | async function getReleaseTypeAsync() { 313 | if (process.env.EXPO_OS === "ios") { 314 | const releaseType = await Application.getIosApplicationReleaseTypeAsync(); 315 | 316 | const suffix = (() => { 317 | switch (releaseType) { 318 | case Application.ApplicationReleaseType.AD_HOC: 319 | return "Ad Hoc"; 320 | case Application.ApplicationReleaseType.ENTERPRISE: 321 | return "Enterprise"; 322 | case Application.ApplicationReleaseType.DEVELOPMENT: 323 | return "Development"; 324 | case Application.ApplicationReleaseType.APP_STORE: 325 | return "App Store"; 326 | case Application.ApplicationReleaseType.SIMULATOR: 327 | return "Simulator"; 328 | case Application.ApplicationReleaseType.UNKNOWN: 329 | default: 330 | return "unknown"; 331 | } 332 | })(); 333 | return `${Application.applicationName} (${suffix})`; 334 | } else if (process.env.EXPO_OS === "android") { 335 | return `${Application.applicationName}`; 336 | } 337 | 338 | return null; 339 | } 340 | 341 | // Get the linked server deployment URL for the current app. This makes it easy to open 342 | // the Expo dashboard and check errors/analytics for the current version of the app you're using. 343 | function getDeploymentUrl(): any { 344 | const deploymentId = (() => { 345 | // https://expo.dev/accounts/bacon/projects/expo-ai/hosting/deployments/o70t5q6t0r/requests 346 | const origin = Constants.expoConfig?.extra?.router?.origin; 347 | if (!origin) { 348 | return null; 349 | } 350 | try { 351 | const url = new URL(origin); 352 | // Should be like: https://exai--xxxxxx.expo.app 353 | // We need to extract the `xxxxxx` part if the URL matches `[\w\d]--([])`. 354 | return url.hostname.match(/(?:[^-]+)--([^.]+)\.expo\.app/)?.[1] ?? null; 355 | } catch { 356 | return null; 357 | } 358 | })(); 359 | 360 | const dashboardUrl = (() => { 361 | // TODO: There might be a better way to do this, using the project ID. 362 | const projectId = Constants.expoConfig?.extra?.eas?.projectId; 363 | if (projectId) { 364 | // https://expo.dev/projects/[uuid] 365 | return `https://expo.dev/projects/${projectId}`; 366 | } 367 | const owner = Constants.expoConfig?.owner ?? "[account]"; 368 | const slug = Constants.expoConfig?.slug ?? "[project]"; 369 | 370 | return `https://expo.dev/accounts/${owner}/projects/${slug}`; 371 | })(); 372 | 373 | let deploymentUrl = `${dashboardUrl}/hosting/deployments`; 374 | if (deploymentId) { 375 | deploymentUrl += `/${deploymentId}/requests`; 376 | } 377 | return deploymentUrl; 378 | } 379 | -------------------------------------------------------------------------------- /src/app/(index,info)/_layout.tsx: -------------------------------------------------------------------------------- 1 | import Stack from "@/components/ui/Stack"; 2 | import * as AC from "@bacons/apple-colors"; 3 | import { Text, View } from "react-native"; 4 | 5 | import * as Form from "@/components/ui/Form"; 6 | import { IconSymbol } from "@/components/ui/IconSymbol"; 7 | import { useMemo } from "react"; 8 | 9 | export const unstable_settings = { 10 | index: { 11 | initialRouteName: "index", 12 | }, 13 | info: { 14 | initialRouteName: "info", 15 | }, 16 | }; 17 | 18 | export default function Layout({ segment }: { segment: string }) { 19 | const screenName = segment.match(/\((.*)\)/)?.[1]!; 20 | 21 | const firstScreen = useMemo(() => { 22 | if (screenName === "index") { 23 | return ( 24 | ( 28 | 29 | 30 | 31 | ), 32 | }} 33 | /> 34 | ); 35 | } else { 36 | return ; 37 | } 38 | }, [screenName]); 39 | 40 | return ( 41 | 42 | {firstScreen} 43 | 44 | ( 54 | 55 | 60 | 61 | ), 62 | }} 63 | /> 64 | 65 | ( 70 | 71 | Done 72 | 73 | ), 74 | }} 75 | /> 76 | 77 | ( 82 | 83 | Done 84 | 85 | ), 86 | }} 87 | /> 88 | ( 93 | 94 | Done 95 | 96 | ), 97 | }} 98 | /> 99 | 100 | ); 101 | } 102 | 103 | function Avatar() { 104 | return ( 105 | 117 | 126 | EB 127 | 128 | 129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /src/app/(index,info)/account.tsx: -------------------------------------------------------------------------------- 1 | import * as Form from "@/components/ui/Form"; 2 | import { IconSymbol } from "@/components/ui/IconSymbol"; 3 | import * as AC from "@bacons/apple-colors"; 4 | import { Image, Platform, View } from "react-native"; 5 | import * as Application from "expo-application"; 6 | 7 | export default function Page() { 8 | return ( 9 | 10 | 11 | 12 | 20 | 21 | Evan's world 22 | Today 23 | 24 | 25 | 30 | Game Center 31 | 32 | 33 | 34 | 35 | Apps 36 | Subscriptions 37 | Purchase History 38 | Notifications 39 | 40 | 41 | 42 | {}}> 43 | Redeem Gift Card or Code 44 | 45 | {}}> 46 | Send Gift Card by Email 47 | 48 | {}}> 49 | Add Money to Account 50 | 51 | 52 | 53 | 54 | Personalized Recommendations 55 | 56 | 57 | 58 | Update All 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | ); 68 | } 69 | 70 | function AppUpdate({ name, icon }: { name: string; icon: string }) { 71 | return ( 72 | 73 | 74 | 82 | 83 | {name} 84 | Today 85 | 86 | 87 | 88 | 89 | 95 | 96 | - Minor bug-fixes 97 | 98 | ); 99 | } 100 | 101 | function SettingsInfoFooter() { 102 | const name = `${Application.applicationName} for ${Platform.select({ 103 | web: "Web", 104 | ios: `iOS v${Application.nativeApplicationVersion} (${Application.nativeBuildVersion})`, 105 | android: `Android v${Application.nativeApplicationVersion} (${Application.nativeBuildVersion})`, 106 | })}`; 107 | return ( 108 | 111 | 118 | {name} 119 | 120 | 121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /src/app/(index,info)/icon.tsx: -------------------------------------------------------------------------------- 1 | import Stack from "@/components/ui/Stack"; 2 | import TouchableBounce from "@/components/ui/TouchableBounce"; 3 | import { ScrollView, View } from "react-native"; 4 | import { Image } from "expo-image"; 5 | import * as AC from "@bacons/apple-colors"; 6 | import MaskedView from "@react-native-masked-view/masked-view"; 7 | 8 | const backgroundImage = 9 | process.env.EXPO_OS === "web" 10 | ? `backgroundImage` 11 | : `experimental_backgroundImage`; 12 | 13 | export default function Page() { 14 | const icons = [ 15 | "https://github.com/expo.png", 16 | "https://github.com/apple.png", 17 | "https://github.com/facebook.png", 18 | "https://github.com/evanbacon.png", 19 | "https://github.com/kitten.png", 20 | ]; 21 | return ( 22 | <> 23 | 24 | 25 | {icons.map((icon) => ( 26 | {}}> 27 | 35 | 44 | 45 | 46 | {process.env.EXPO_OS === "web" ? ( 47 | 62 | ) : ( 63 | 81 | } 82 | > 83 | 94 | 95 | )} 96 | 97 | ))} 98 | 99 | 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /src/app/(index,info)/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import * as Form from "@/components/ui/Form"; 4 | import { IconSymbol } from "@/components/ui/IconSymbol"; 5 | import { 6 | Segments, 7 | SegmentsContent, 8 | SegmentsList, 9 | SegmentsTrigger, 10 | } from "@/components/ui/Segments"; 11 | import Stack from "@/components/ui/Stack"; 12 | import * as AC from "@bacons/apple-colors"; 13 | import { Image } from "expo-image"; 14 | import * as SplashScreen from "expo-splash-screen"; 15 | import { ComponentProps } from "react"; 16 | import { 17 | OpaqueColorValue, 18 | StyleSheet, 19 | Text, 20 | TextProps, 21 | View, 22 | } from "react-native"; 23 | import Animated, { 24 | interpolate, 25 | useAnimatedRef, 26 | useAnimatedStyle, 27 | useScrollViewOffset, 28 | } from "react-native-reanimated"; 29 | 30 | import { Glur, GlurryList } from "@/components/example/glurry-modal"; 31 | import ShaderScene from "@/components/torus-dom"; 32 | import TouchableBounce from "@/components/ui/TouchableBounce"; 33 | import ExpoSvg from "@/svg/expo.svg"; 34 | import { BlurView } from "expo-blur"; 35 | import { Link } from "expo-router"; 36 | import { useSafeAreaInsets } from "react-native-safe-area-context"; 37 | 38 | function ProminentHeaderButton({ 39 | children, 40 | ...props 41 | }: { 42 | children?: React.ReactNode; 43 | }) { 44 | return ( 45 | {}} {...props}> 46 | 60 | {children} 61 | 62 | 63 | ); 64 | } 65 | 66 | export default function Page() { 67 | const ref = useAnimatedRef(); 68 | const scroll = useScrollViewOffset(ref); 69 | const style = useAnimatedStyle(() => { 70 | return { 71 | opacity: interpolate(scroll.value, [0, 30], [0, 1], "clamp"), 72 | transform: [ 73 | { translateY: interpolate(scroll.value, [0, 30], [5, 0], "clamp") }, 74 | ], 75 | }; 76 | }); 77 | const blurStyle = useAnimatedStyle(() => { 78 | return { 79 | opacity: interpolate(scroll.value, [0, 200], [0, 1], "clamp"), 80 | }; 81 | }); 82 | const shaderStyle = useAnimatedStyle(() => { 83 | return { 84 | transform: [ 85 | { 86 | scale: interpolate( 87 | scroll.value, 88 | [-200, 0, 800], 89 | [1, 1.2, 1.7], 90 | "clamp" 91 | ), 92 | }, 93 | ], 94 | }; 95 | }); 96 | const headerStyle = useAnimatedStyle(() => { 97 | return { 98 | opacity: interpolate(scroll.value, [100, 200], [0, 1], "clamp"), 99 | }; 100 | }); 101 | 102 | const [show, setShow] = React.useState(false); 103 | const { top } = useSafeAreaInsets(); 104 | return ( 105 | 106 | 119 | 120 | 121 | 122 | 140 | { 142 | window.location.reload(); 143 | }} 144 | > 145 | 146 | 147 | 148 | 149 | 154 | 155 | 156 | 157 | 158 | 161 | { 166 | SplashScreen.hideAsync(); 167 | }, 1); 168 | }, 169 | style: { 170 | flex: 1, 171 | }, 172 | contentInsetAdjustmentBehavior: "never", 173 | automaticallyAdjustContentInsets: false, 174 | 175 | containerStyle: { 176 | position: "absolute", 177 | top: 0, 178 | left: 0, 179 | right: 0, 180 | bottom: 0, 181 | zIndex: -1, 182 | }, 183 | }} 184 | speed={0.2} 185 | style={{ 186 | position: "absolute", 187 | top: 0, 188 | left: 0, 189 | right: 0, 190 | bottom: 0, 191 | }} 192 | /> 193 | 194 | 197 | {/* */} 206 | 211 | 212 | 213 | {show && } 214 | 227 | 239 | 246 | Bacon Components 247 | 248 | 249 | ); 250 | } 251 | return ( 252 | 265 | ); 266 | }, 267 | }} 268 | /> 269 | 277 | 288 | 289 | 298 | {`Enter\nCyberspace`} 299 | 300 | 301 | 302 | 303 | Future Software 304 | {/* Spacer */} 305 | 306 | {/* Right */} 307 | 308 | Apps from future generations, plucked out of cyber space. 309 | 310 | 311 | 312 | 313 | 314 | { 316 | setShow(true); 317 | }} 318 | > 319 | Choose Model 320 | 321 | Change App Icon 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 339 | } 340 | style={{ color: AC.label, fontWeight: "600" }} 341 | > 342 | Deploy on Expo 343 | 344 | 345 | 346 | 352 | {`~/ `} 353 | npx testflight 354 | 355 | 356 | 362 | {`~/ `} 363 | eas deploy 364 | 365 | 366 | 367 | 368 | 369 | 377 | 386 | 387 | 388 | Evan Bacon 389 | 390 | Artist and Developer 391 | 392 | 393 | 394 | 395 | 396 | 407 | 408 | 409 | 410 | 411 | Release Date 412 | Version 413 | 414 | 419 | Compatibility 420 | 421 | 422 | 423 | 424 | ); 425 | } 426 | 427 | // Text animation that animates in the text character by character but first showing a random hash character while typing out. 428 | function GlitchTextAnimation({ 429 | children, 430 | ...props 431 | }: { children: string } & TextProps) { 432 | // Return a set of entropy characters that are similar to the original character 433 | const getEntropyChars = (char: string) => { 434 | const englishToLatin: Record = { 435 | a: "α", 436 | b: "β", 437 | c: "γ", 438 | d: "δ", 439 | e: "ε", 440 | f: "φ", 441 | g: "γ", 442 | h: "η", 443 | i: "ι", 444 | j: "ϳ", 445 | k: "κ", 446 | l: "λ", 447 | m: "μ", 448 | n: "ν", 449 | o: "ο", 450 | p: "π", 451 | q: "θ", 452 | r: "ρ", 453 | s: "σ", 454 | t: "τ", 455 | u: "υ", 456 | v: "ϐ", 457 | w: "ω", 458 | x: "χ", 459 | C: "Γ", 460 | D: "Δ", 461 | F: "Φ", 462 | G: "Γ", 463 | J: "Ϗ", 464 | L: "Λ", 465 | P: "Π", 466 | Q: "Θ", 467 | R: "Ρ", 468 | S: "Σ", 469 | V: "ϐ", 470 | W: "Ω", 471 | "0": "𝟘", 472 | "1": "𝟙", 473 | "2": "𝟚", 474 | "3": "𝟛", 475 | }; 476 | for (const charset of [ 477 | "ijl|!¡ƒ†", 478 | "ceoauøπ∂", 479 | "CEOAUV", 480 | 481 | // letters of same width 482 | "abcdefghijklmnopqrstuvwxyzπ∂ƒ†", 483 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ∆#𝝠𝝠𝝠", 484 | // numbers 485 | "0123456789", 486 | ]) { 487 | if (charset.includes(char)) { 488 | const chars = charset.split("").filter((c) => c !== char); 489 | 490 | if (englishToLatin[char]) { 491 | return [englishToLatin[char], ...chars]; 492 | } 493 | return chars; 494 | } 495 | } 496 | 497 | return ["#", "%", "&", "*"]; 498 | }; 499 | 500 | const randomEntropyChar = (char: string) => { 501 | const chars = getEntropyChars(char); 502 | return chars[Math.floor(Math.random() * chars.length)]; 503 | }; 504 | 505 | // Initialize displayText while preserving whitespace. 506 | const initialText = Array.from(children, (char) => 507 | /\s/.test(char) ? char : randomEntropyChar(char) 508 | ); 509 | 510 | const [displayText, setDisplayText] = React.useState(initialText); 511 | 512 | React.useEffect(() => { 513 | const intervals: NodeJS.Timeout[] = []; 514 | const timeouts: NodeJS.Timeout[] = []; 515 | 516 | for (let i = 0; i < children.length; i++) { 517 | // Preserve whitespace characters. 518 | if (/\s/.test(children[i])) { 519 | continue; 520 | } 521 | 522 | // Update the character by cycling through similar entropy characters. 523 | const interval = setInterval(() => { 524 | setDisplayText((prev) => { 525 | if (prev[i] !== children[i]) { 526 | const newText = [...prev]; 527 | newText[i] = randomEntropyChar(children[i]); 528 | return newText; 529 | } 530 | return prev; 531 | }); 532 | }, 50); 533 | intervals.push(interval); 534 | 535 | // Reveal the actual character after a delay based on its position. 536 | const timeout = setTimeout(() => { 537 | clearInterval(interval); 538 | setDisplayText((prev) => { 539 | const newText = [...prev]; 540 | newText[i] = children[i]; 541 | return newText; 542 | }); 543 | }, i * 100); 544 | timeouts.push(timeout); 545 | } 546 | 547 | return () => { 548 | intervals.forEach(clearInterval); 549 | timeouts.forEach(clearTimeout); 550 | }; 551 | }, [children]); 552 | 553 | return {displayText.join("")}; 554 | } 555 | 556 | function FormExpandable({ 557 | children, 558 | hint, 559 | preview, 560 | }: { 561 | custom: true; 562 | children?: React.ReactNode; 563 | hint?: string; 564 | preview?: string; 565 | }) { 566 | const [open, setOpen] = React.useState(false); 567 | 568 | // TODO: If the entire preview can fit, then just skip the hint. 569 | 570 | return ( 571 | setOpen(!open)}> 572 | 573 | {children} 574 | {/* Spacer */} 575 | 576 | {open && ( 577 | 582 | )} 583 | {/* Right */} 584 | 585 | {open ? hint : preview} 586 | 587 | {!open && ( 588 | 593 | )} 594 | 595 | 596 | ); 597 | } 598 | 599 | function FormLabel({ 600 | children, 601 | systemImage, 602 | color, 603 | }: { 604 | /** Only used when `` is a direct child of `
`. */ 605 | onPress?: () => void; 606 | children: React.ReactNode; 607 | systemImage: ComponentProps["name"]; 608 | color?: OpaqueColorValue; 609 | }) { 610 | return ( 611 | 612 | 613 | {children} 614 | 615 | ); 616 | } 617 | 618 | function SegmentsTest() { 619 | return ( 620 | 621 | 622 | 623 | Account 624 | Password 625 | 626 | 627 | 628 | Account Section 629 | 630 | 631 | 632 | Password Section 633 | 634 | 635 | 636 | 637 | ); 638 | } 639 | 640 | function TripleItemTest() { 641 | return ( 642 | <> 643 | 644 | 645 | 654 | 655 | 670 | } 671 | subtitle="Evan Bacon" 672 | /> 673 | 674 | 683 | 684 | 685 | 686 | ); 687 | } 688 | 689 | function HorizontalItem({ 690 | title, 691 | badge, 692 | subtitle, 693 | }: { 694 | title: string; 695 | badge: React.ReactNode; 696 | subtitle: string; 697 | }) { 698 | return ( 699 | 700 | 708 | {title} 709 | 710 | {typeof badge === "string" ? ( 711 | 718 | {badge} 719 | 720 | ) : ( 721 | badge 722 | )} 723 | 724 | 730 | {subtitle} 731 | 732 | 733 | ); 734 | } 735 | -------------------------------------------------------------------------------- /src/app/(index,info)/info.tsx: -------------------------------------------------------------------------------- 1 | import * as Form from "@/components/ui/Form"; 2 | import Stack from "@/components/ui/Stack"; 3 | import * as AC from "@bacons/apple-colors"; 4 | import { Link } from "expo-router"; 5 | import { Text, View } from "react-native"; 6 | import Animated, { 7 | interpolate, 8 | useAnimatedRef, 9 | useAnimatedStyle, 10 | useScrollViewOffset, 11 | } from "react-native-reanimated"; 12 | 13 | export default function Page() { 14 | const ref = useAnimatedRef(); 15 | const scroll = useScrollViewOffset(ref); 16 | const style = useAnimatedStyle(() => ({ 17 | transform: [ 18 | { translateY: interpolate(scroll.value, [-120, -70], [50, 0], "clamp") }, 19 | ], 20 | })); 21 | 22 | return ( 23 | 24 | {process.env.EXPO_OS !== "web" && ( 25 | ( 28 | 35 | 36 | 43 | Bottom Sheet 44 | 45 | 46 | 47 | ), 48 | headerTitle() { 49 | return <>; 50 | }, 51 | }} 52 | /> 53 | )} 54 | 55 | 59 | Help improve Search by allowing Apple to store the searches you 60 | enter into Safari, Siri, and Spotlight in a way that is not linked 61 | to you.{"\n\n"}Searches include lookups of general knowledge, and 62 | requests to do things like play music and get directions.{"\n"} 63 | 64 | About Search & Privacy... 65 | 66 | 67 | } 68 | > 69 | Default 70 | Hint 71 | { 73 | console.log("Hey"); 74 | }} 75 | > 76 | Pressable 77 | 78 | 79 | 80 | Custom style 81 | 82 | Bold 83 | 84 | 85 | Wrapped 86 | 87 | 88 | {/* Table style: | A B |*/} 89 | 90 | Foo 91 | 92 | Bar 93 | 94 | 95 | 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /src/app/(index,info)/privacy.tsx: -------------------------------------------------------------------------------- 1 | import PrivacyPolicy from "@/components/example/privacy-dom"; 2 | import { Stack } from "expo-router"; 3 | import Head from "expo-router/head"; 4 | 5 | export default function Privacy() { 6 | return ( 7 | <> 8 | 9 | Privacy | Torus 10 | 11 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/(index,info)/two.tsx: -------------------------------------------------------------------------------- 1 | import { BodyScrollView } from "@/components/ui/BodyScrollView"; 2 | import { StyleSheet, Text, View } from "react-native"; 3 | 4 | import { FadeIn } from "@/components/ui/FadeIn"; 5 | import { IconSymbol } from "@/components/ui/IconSymbol"; 6 | import Skeleton from "@/components/ui/Skeleton"; 7 | import TouchableBounce from "@/components/ui/TouchableBounce"; 8 | import * as AC from "@bacons/apple-colors"; 9 | import { useState } from "react"; 10 | export default function Page() { 11 | return ( 12 | 13 | 14 | 15 | Hello World 16 | 17 | This is the first page of your app. 18 | 19 | 20 | 21 | 22 | 23 | TouchableBounce 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | } 36 | 37 | function FadeInTest() { 38 | const [show, setShow] = useState(false); 39 | return ( 40 | <> 41 | setShow(!show)}>Toggle 42 | {show && ( 43 | 44 | FadeIn 45 | 46 | )} 47 | 48 | ); 49 | } 50 | 51 | const styles = StyleSheet.create({ 52 | container: { 53 | flex: 1, 54 | alignItems: "center", 55 | padding: 24, 56 | }, 57 | main: { 58 | flex: 1, 59 | justifyContent: "center", 60 | maxWidth: 960, 61 | marginHorizontal: "auto", 62 | }, 63 | title: { 64 | fontSize: 64, 65 | fontWeight: "bold", 66 | color: AC.label, 67 | }, 68 | subtitle: { 69 | fontSize: 36, 70 | color: AC.secondaryLabel, 71 | }, 72 | }); 73 | -------------------------------------------------------------------------------- /src/app/+html.tsx: -------------------------------------------------------------------------------- 1 | import { ScrollViewStyleReset } from "expo-router/html"; 2 | import { type PropsWithChildren } from "react"; 3 | 4 | // This file is web-only and used to configure the root HTML for every 5 | // web page during static rendering. 6 | // The contents of this function only run in Node.js environments and 7 | // do not have access to the DOM or browser APIs. 8 | export default function Root({ children }: PropsWithChildren) { 9 | return ( 10 | 11 | 12 | 13 | 14 | 18 | 19 | {/* 20 | Disable body scrolling on web. This makes ScrollView components work closer to how they do on native. 21 | However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line. 22 | */} 23 | 24 | {/* 88 | 89 | ); 90 | }; 91 | 92 | export default Skeleton; 93 | -------------------------------------------------------------------------------- /src/components/ui/Stack.tsx: -------------------------------------------------------------------------------- 1 | // import { Stack as NativeStack } from "expo-router"; 2 | import { NativeStackNavigationOptions } from "@react-navigation/native-stack"; 3 | import React from "react"; 4 | 5 | // Better transitions on web, no changes on native. 6 | import NativeStack from "@/components/layout/modalNavigator"; 7 | 8 | // These are the default stack options for iOS, they disable on other platforms. 9 | const DEFAULT_STACK_HEADER: NativeStackNavigationOptions = 10 | process.env.EXPO_OS !== "ios" 11 | ? {} 12 | : { 13 | headerTransparent: true, 14 | headerBlurEffect: "systemChromeMaterial", 15 | headerShadowVisible: true, 16 | headerLargeTitleShadowVisible: false, 17 | headerLargeStyle: { 18 | backgroundColor: "transparent", 19 | }, 20 | headerLargeTitle: true, 21 | }; 22 | 23 | /** Create a bottom sheet on iOS with extra snap points (`sheetAllowedDetents`) */ 24 | export const BOTTOM_SHEET: NativeStackNavigationOptions = { 25 | // https://github.com/software-mansion/react-native-screens/blob/main/native-stack/README.md#sheetalloweddetents 26 | presentation: "formSheet", 27 | gestureDirection: "vertical", 28 | animation: "slide_from_bottom", 29 | sheetGrabberVisible: true, 30 | sheetInitialDetentIndex: 0, 31 | sheetAllowedDetents: [0.5, 1.0], 32 | }; 33 | 34 | export default function Stack({ 35 | screenOptions, 36 | children, 37 | ...props 38 | }: React.ComponentProps) { 39 | const processedChildren = React.Children.map(children, (child) => { 40 | if (React.isValidElement(child)) { 41 | const { sheet, modal, ...props } = child.props; 42 | if (sheet) { 43 | return React.cloneElement(child, { 44 | ...props, 45 | options: { 46 | ...BOTTOM_SHEET, 47 | ...props.options, 48 | }, 49 | }); 50 | } else if (modal) { 51 | return React.cloneElement(child, { 52 | ...props, 53 | options: { 54 | presentation: "modal", 55 | ...props.options, 56 | }, 57 | }); 58 | } 59 | } 60 | return child; 61 | }); 62 | 63 | return ( 64 | 72 | ); 73 | } 74 | 75 | Stack.Screen = NativeStack.Screen as React.FC< 76 | React.ComponentProps & { 77 | /** Make the sheet open as a bottom sheet with default options on iOS. */ 78 | sheet?: boolean; 79 | /** Make the screen open as a modal. */ 80 | modal?: boolean; 81 | } 82 | >; 83 | -------------------------------------------------------------------------------- /src/components/ui/TabBarBackground.ios.tsx: -------------------------------------------------------------------------------- 1 | import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"; 2 | import { BlurView } from "expo-blur"; 3 | import { StyleSheet } from "react-native"; 4 | 5 | export default function BlurTabBarBackground() { 6 | return ( 7 | 14 | ); 15 | } 16 | 17 | export function useBottomTabOverflow() { 18 | let tabHeight = 0; 19 | try { 20 | // eslint-disable-next-line react-hooks/rules-of-hooks 21 | tabHeight = useBottomTabBarHeight(); 22 | } catch {} 23 | 24 | return tabHeight; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/ui/TabBarBackground.tsx: -------------------------------------------------------------------------------- 1 | // This is a shim for web and Android where the tab bar is generally opaque. 2 | export default undefined; 3 | 4 | export function useBottomTabOverflow() { 5 | return 0; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/ui/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import { IconSymbol, IconSymbolName } from "@/components/ui/IconSymbol"; 2 | import { 3 | BottomTabBarButtonProps, 4 | BottomTabNavigationOptions, 5 | } from "@react-navigation/bottom-tabs"; 6 | import * as Haptics from "expo-haptics"; 7 | import React from "react"; 8 | // Better transitions on web, no changes on native. 9 | import { PlatformPressable } from "@react-navigation/elements"; 10 | import { Tabs as NativeTabs } from "expo-router"; 11 | import BlurTabBarBackground from "./TabBarBackground"; 12 | 13 | // These are the default tab options for iOS, they disable on other platforms. 14 | const DEFAULT_TABS: BottomTabNavigationOptions = 15 | process.env.EXPO_OS !== "ios" 16 | ? { 17 | headerShown: false, 18 | } 19 | : { 20 | headerShown: false, 21 | tabBarButton: HapticTab, 22 | tabBarBackground: BlurTabBarBackground, 23 | tabBarStyle: { 24 | // Use a transparent background on iOS to show the blur effect 25 | position: "absolute", 26 | }, 27 | }; 28 | 29 | export default function Tabs({ 30 | screenOptions, 31 | children, 32 | ...props 33 | }: React.ComponentProps) { 34 | const processedChildren = React.Children.map(children, (child) => { 35 | if (React.isValidElement(child)) { 36 | const { systemImage, title, ...props } = child.props; 37 | if (systemImage || title != null) { 38 | return React.cloneElement(child, { 39 | ...props, 40 | options: { 41 | tabBarIcon: !systemImage 42 | ? undefined 43 | : (props: any) => , 44 | title, 45 | ...props.options, 46 | }, 47 | }); 48 | } 49 | } 50 | return child; 51 | }); 52 | 53 | return ( 54 | 62 | ); 63 | } 64 | 65 | Tabs.Screen = NativeTabs.Screen as React.FC< 66 | React.ComponentProps & { 67 | /** Add a system image for the tab icon. */ 68 | systemImage?: IconSymbolName; 69 | /** Set the title of the icon. */ 70 | title?: string; 71 | } 72 | >; 73 | 74 | function HapticTab(props: BottomTabBarButtonProps) { 75 | return ( 76 | { 79 | if (process.env.EXPO_OS === "ios") { 80 | // Add a soft haptic feedback when pressing down on the tabs. 81 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); 82 | } 83 | props.onPressIn?.(ev); 84 | }} 85 | /> 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /src/components/ui/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | // import * as AppleColors from "@bacons/apple-colors"; 2 | import { 3 | DarkTheme, 4 | DefaultTheme, 5 | ThemeProvider as RNTheme, 6 | } from "@react-navigation/native"; 7 | import { useColorScheme } from "react-native"; 8 | 9 | // Use exact native P3 colors and equivalents on Android/web. 10 | // This lines up well with React Navigation. 11 | // const BaconDefaultTheme: Theme = { 12 | // dark: false, 13 | // colors: { 14 | // primary: AppleColors.systemBlue, 15 | // notification: AppleColors.systemRed, 16 | // ...DefaultTheme.colors, 17 | // // background: AppleColors.systemGroupedBackground, 18 | // // card: AppleColors.secondarySystemGroupedBackground, 19 | // // text: AppleColors.label, 20 | // // border: AppleColors.separator, 21 | // }, 22 | // fonts: DefaultTheme.fonts, 23 | // }; 24 | 25 | // const BaconDarkTheme: Theme = { 26 | // dark: true, 27 | // colors: { 28 | // // ...BaconDefaultTheme.colors, 29 | // ...DarkTheme.colors, 30 | // }, 31 | // fonts: DarkTheme.fonts, 32 | // }; 33 | 34 | export default function ThemeProvider(props: { children: React.ReactNode }) { 35 | // eslint-disable-next-line react-hooks/rules-of-hooks 36 | const colorScheme = process.env.EXPO_OS === "web" ? "dark" : useColorScheme(); 37 | return ( 38 | 44 | {props.children} 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/components/ui/TouchableBounce.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // @ts-expect-error: untyped helper 4 | import RNTouchableBounce from "react-native/Libraries/Components/Touchable/TouchableBounce"; 5 | 6 | import { 7 | TouchableOpacityProps as RNTouchableOpacityProps, 8 | View, 9 | } from "react-native"; 10 | 11 | import * as Haptics from "expo-haptics"; 12 | import * as React from "react"; 13 | 14 | export type TouchableScaleProps = Omit< 15 | RNTouchableOpacityProps, 16 | "activeOpacity" 17 | > & { 18 | /** Enables haptic feedback on press down. */ 19 | sensory?: 20 | | boolean 21 | | "success" 22 | | "error" 23 | | "warning" 24 | | "light" 25 | | "medium" 26 | | "heavy"; 27 | }; 28 | 29 | /** 30 | * Touchable which scales the children down when pressed. 31 | */ 32 | export default function TouchableBounce({ 33 | style, 34 | children, 35 | onPressIn, 36 | sensory, 37 | ...props 38 | }: TouchableScaleProps) { 39 | const onSensory = React.useCallback(() => { 40 | if (!sensory) return; 41 | if (sensory === true) { 42 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); 43 | } else if (sensory === "success") { 44 | Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); 45 | } else if (sensory === "error") { 46 | Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); 47 | } else if (sensory === "warning") { 48 | Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); 49 | } else if (sensory === "light") { 50 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); 51 | } else if (sensory === "medium") { 52 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); 53 | } else if (sensory === "heavy") { 54 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); 55 | } 56 | }, [sensory]); 57 | 58 | return ( 59 | { 62 | onSensory(); 63 | onPressIn?.(ev); 64 | }} 65 | > 66 | {children ? children : } 67 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /src/components/ui/TouchableBounce.web.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { TouchableOpacity } from "react-native"; 4 | 5 | export default TouchableOpacity; 6 | -------------------------------------------------------------------------------- /src/hooks/useHeaderSearch.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { useEffect, useState } from "react"; 4 | import { useNavigation } from "expo-router"; 5 | import { SearchBarProps } from "react-native-screens"; 6 | 7 | export function useHeaderSearch(options: Omit = {}) { 8 | const [search, setSearch] = useState(""); 9 | const navigation = useNavigation(); 10 | 11 | useEffect(() => { 12 | const interceptedOptions: SearchBarProps = { 13 | ...options, 14 | onChangeText(event) { 15 | setSearch(event.nativeEvent.text); 16 | options.onChangeText?.(event); 17 | }, 18 | onSearchButtonPress(e) { 19 | setSearch(e.nativeEvent.text); 20 | options.onSearchButtonPress?.(e); 21 | }, 22 | onCancelButtonPress(e) { 23 | setSearch(""); 24 | options.onCancelButtonPress?.(e); 25 | }, 26 | }; 27 | 28 | navigation.setOptions({ 29 | headerShown: true, 30 | headerSearchBarOptions: interceptedOptions, 31 | }); 32 | }, [options, navigation]); 33 | 34 | return search; 35 | } 36 | -------------------------------------------------------------------------------- /src/hooks/useMergedRef.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | 3 | type CallbackRef = (ref: T) => any; 4 | type ObjectRef = { current: T }; 5 | 6 | type Ref = CallbackRef | ObjectRef; 7 | 8 | export default function useMergedRef( 9 | ...refs: (Ref | undefined)[] 10 | ): CallbackRef { 11 | return useCallback( 12 | (current: T) => { 13 | for (const ref of refs) { 14 | if (ref != null) { 15 | if (typeof ref === "function") { 16 | ref(current); 17 | } else { 18 | ref.current = current; 19 | } 20 | } 21 | } 22 | }, 23 | // eslint-disable-next-line react-hooks/exhaustive-deps 24 | [...refs] 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/hooks/useTabToTop.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EventArg, 3 | NavigationProp, 4 | useNavigation, 5 | useRoute, 6 | } from "@react-navigation/core"; 7 | import * as React from "react"; 8 | import type { ScrollView } from "react-native"; 9 | import type { WebView } from "react-native-webview"; 10 | 11 | type ScrollOptions = { x?: number; y?: number; animated?: boolean }; 12 | 13 | type ScrollableView = 14 | | { scrollToTop(): void } 15 | | { scrollTo(options: ScrollOptions): void } 16 | | { scrollToOffset(options: { offset?: number; animated?: boolean }): void } 17 | | { scrollResponderScrollTo(options: ScrollOptions): void }; 18 | 19 | type ScrollableWrapper = 20 | | { getScrollResponder(): React.ReactNode | ScrollView } 21 | | { getNode(): ScrollableView } 22 | | ScrollableView; 23 | 24 | function getScrollableNode( 25 | ref: React.RefObject | React.RefObject 26 | ) { 27 | if (ref?.current == null) { 28 | return null; 29 | } 30 | 31 | if ( 32 | "scrollToTop" in ref.current || 33 | "scrollTo" in ref.current || 34 | "scrollToOffset" in ref.current || 35 | "scrollResponderScrollTo" in ref.current 36 | ) { 37 | // This is already a scrollable node. 38 | return ref.current; 39 | } else if ("getScrollResponder" in ref.current) { 40 | // If the view is a wrapper like FlatList, SectionList etc. 41 | // We need to use `getScrollResponder` to get access to the scroll responder 42 | return ref.current.getScrollResponder(); 43 | } else if ("getNode" in ref.current) { 44 | // When a `ScrollView` is wraped in `Animated.createAnimatedComponent` 45 | // we need to use `getNode` to get the ref to the actual scrollview. 46 | // Note that `getNode` is deprecated in newer versions of react-native 47 | // this is why we check if we already have a scrollable node above. 48 | return ref.current.getNode(); 49 | } else { 50 | return ref.current; 51 | } 52 | } 53 | 54 | export function useScrollToTop( 55 | ref: 56 | | React.RefObject 57 | | React.RefObject 58 | | React.Ref, 59 | offset: number = 0 60 | ) { 61 | const navigation = useNavigation(); 62 | const route = useRoute(); 63 | 64 | React.useEffect(() => { 65 | let tabNavigations: NavigationProp[] = []; 66 | let currentNavigation = navigation; 67 | 68 | // If the screen is nested inside multiple tab navigators, we should scroll to top for any of them 69 | // So we need to find all the parent tab navigators and add the listeners there 70 | while (currentNavigation) { 71 | if (currentNavigation.getState()?.type === "tab") { 72 | tabNavigations.push(currentNavigation); 73 | } 74 | 75 | currentNavigation = currentNavigation.getParent(); 76 | } 77 | 78 | if (tabNavigations.length === 0) { 79 | return; 80 | } 81 | 82 | const unsubscribers = tabNavigations.map((tab) => { 83 | return tab.addListener( 84 | // We don't wanna import tab types here to avoid extra deps 85 | // in addition, there are multiple tab implementations 86 | // @ts-expect-error 87 | "tabPress", 88 | (e: EventArg<"tabPress", true>) => { 89 | // We should scroll to top only when the screen is focused 90 | const isFocused = navigation.isFocused(); 91 | 92 | // In a nested stack navigator, tab press resets the stack to first screen 93 | // So we should scroll to top only when we are on first screen 94 | const isFirst = 95 | tabNavigations.includes(navigation) || 96 | navigation.getState()?.routes[0].key === route.key; 97 | 98 | // Run the operation in the next frame so we're sure all listeners have been run 99 | // This is necessary to know if preventDefault() has been called 100 | requestAnimationFrame(() => { 101 | const scrollable = getScrollableNode(ref) as 102 | | ScrollableWrapper 103 | | WebView; 104 | 105 | if (isFocused && isFirst && scrollable && !e.defaultPrevented) { 106 | if ("scrollToTop" in scrollable) { 107 | scrollable.scrollToTop(); 108 | } else if ("scrollTo" in scrollable) { 109 | scrollable.scrollTo({ y: offset, animated: true }); 110 | } else if ("scrollToOffset" in scrollable) { 111 | scrollable.scrollToOffset({ offset: offset, animated: true }); 112 | } else if ("scrollResponderScrollTo" in scrollable) { 113 | scrollable.scrollResponderScrollTo({ 114 | y: offset, 115 | animated: true, 116 | }); 117 | } else if ("injectJavaScript" in scrollable) { 118 | scrollable.injectJavaScript( 119 | `;window.scrollTo({ top: ${offset}, behavior: 'smooth' }); true;` 120 | ); 121 | } 122 | } 123 | }); 124 | } 125 | ); 126 | }); 127 | 128 | return () => { 129 | unsubscribers.forEach((unsubscribe) => unsubscribe()); 130 | }; 131 | }, [navigation, ref, offset, route.key]); 132 | } 133 | 134 | export const useScrollRef = 135 | process.env.EXPO_OS === "web" 136 | ? () => undefined 137 | : () => { 138 | const ref = React.useRef(null); 139 | 140 | useScrollToTop(ref); 141 | 142 | return ref; 143 | }; 144 | -------------------------------------------------------------------------------- /src/svg/expo.svg: -------------------------------------------------------------------------------- 1 | Expo icon -------------------------------------------------------------------------------- /src/svg/github.svg: -------------------------------------------------------------------------------- 1 | 3 | GitHub 4 | 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "baseUrl": ".", 6 | "paths": { 7 | "@/*": ["src/*"] 8 | }, 9 | "typeRoots": ["./global.d.ts"] 10 | }, 11 | "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] 12 | } 13 | --------------------------------------------------------------------------------