├── apps ├── mobile │ ├── .gitignore │ ├── tsconfig.json │ ├── assets │ │ ├── icon.png │ │ ├── favicon.png │ │ ├── splash.png │ │ └── adaptive-icon.png │ ├── app │ │ ├── store.ts │ │ ├── db │ │ │ └── init.ts │ │ ├── todo.tsx │ │ ├── sync │ │ │ └── SyncedExpoDB.ts │ │ └── utils.ts │ ├── babel.config.js │ ├── index.js │ ├── app.json │ ├── metro.config.js │ ├── package.json │ └── App.tsx └── server │ ├── .gitignore │ ├── schemas │ └── test.sql │ ├── package.json │ └── index.js ├── .eslintrc ├── .yarnrc.yml ├── package.json ├── .gitignore └── README.md /apps/mobile/.gitignore: -------------------------------------------------------------------------------- 1 | ios/ 2 | android/ -------------------------------------------------------------------------------- /apps/server/.gitignore: -------------------------------------------------------------------------------- 1 | .partykit 2 | dbs/ 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["universe/native"] 3 | } 4 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-3.6.1.cjs 4 | -------------------------------------------------------------------------------- /apps/mobile/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": {}, 3 | "extends": "expo/tsconfig.base" 4 | } 5 | -------------------------------------------------------------------------------- /apps/mobile/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/expo/todo-sync-example/HEAD/apps/mobile/assets/icon.png -------------------------------------------------------------------------------- /apps/mobile/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/expo/todo-sync-example/HEAD/apps/mobile/assets/favicon.png -------------------------------------------------------------------------------- /apps/mobile/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/expo/todo-sync-example/HEAD/apps/mobile/assets/splash.png -------------------------------------------------------------------------------- /apps/mobile/assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/expo/todo-sync-example/HEAD/apps/mobile/assets/adaptive-icon.png -------------------------------------------------------------------------------- /apps/server/schemas/test.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "todo" ("id" PRIMARY KEY, "text", "completed" INTEGER DEFAULT 0); 2 | SELECT crsql_as_crr('todo'); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expo-sqlite-cr-sqlite-monorepo-demo", 3 | "version": "1.0.0", 4 | "private": true, 5 | "workspaces": [ 6 | "apps/*" 7 | ], 8 | "packageManager": "yarn@3.6.1" 9 | } 10 | -------------------------------------------------------------------------------- /apps/mobile/app/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from 'tinybase'; 2 | 3 | export const store = createStore().setTablesSchema({ 4 | todo: { 5 | id: { type: "number" }, 6 | text: { type: "string" }, 7 | completed: { type: "number", default: 0 }, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /apps/mobile/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ["babel-preset-expo"], 5 | plugins: [ 6 | ["@babel/plugin-transform-private-methods", { loose: true }], 7 | "react-native-reanimated/plugin", 8 | ], 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | npm-debug.* 5 | *.jks 6 | *.p8 7 | *.p12 8 | *.key 9 | *.mobileprovision 10 | *.orig.* 11 | web-build/ 12 | .yarn/ 13 | 14 | # macOS 15 | .DS_Store 16 | 17 | # Temporary files created by Metro to check the health of the file watcher 18 | .metro-health-check* 19 | -------------------------------------------------------------------------------- /apps/mobile/index.js: -------------------------------------------------------------------------------- 1 | import { registerRootComponent } from 'expo'; 2 | import App from './App'; 3 | 4 | // registerRootComponent calls AppRegistry.registerComponent('main', () => App); 5 | // It also ensures that whether you load the app in Expo Go or in a native build, 6 | // the environment is set up appropriately 7 | registerRootComponent(App); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Proof of concept: expo-sqlite integration with CR-SQLite 2 | 3 | For a detailed look at what this repository is, why it exists, how it works, and how to run it, see this blog post: https://expo.dev/changelog/2023/08-10-cr-sqlite. Refer to the [initial-poc branch](https://github.com/expo/todo-sync-example/tree/initial-poc)on) for the code that was used in the blog post. -------------------------------------------------------------------------------- /apps/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "start": "node ./index.js", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@vlcn.io/ws-server": "0.1.0-next.14", 15 | "express": "^4.18.2" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/mobile/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "todo-sync-mobile", 4 | "slug": "todo-sync-mobile", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "userInterfaceStyle": "light", 8 | "ios": { 9 | "supportsTablet": true, 10 | "bundleIdentifier": "com.example.todosync" 11 | }, 12 | "android": { 13 | "adaptiveIcon": { 14 | "foregroundImage": "./assets/adaptive-icon.png", 15 | "backgroundColor": "#ffffff" 16 | }, 17 | "package": "com.example.todosync" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/server/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import * as http from "http"; 4 | import { attachWebsocketServer } from "@vlcn.io/ws-server"; 5 | import express from "express"; 6 | import { existsSync, mkdirSync } from 'fs'; 7 | 8 | const dbFolder = './dbs'; 9 | const port = process.env.PORT || 8080; 10 | 11 | const app = express(); 12 | const server = http.createServer(app); 13 | 14 | if (!existsSync(dbFolder)) { 15 | mkdirSync(dbFolder); 16 | } 17 | 18 | attachWebsocketServer(server, { 19 | schemaFolder: "./schemas", 20 | dbFolder, 21 | pathPattern: /\/sync/, 22 | }); 23 | 24 | server.listen(port, () => console.log("info", `listening on port ${port}!`)); 25 | -------------------------------------------------------------------------------- /apps/mobile/metro.config.js: -------------------------------------------------------------------------------- 1 | const { getDefaultConfig } = require('expo/metro-config'); 2 | const path = require('path'); 3 | 4 | // Find the project and workspace directories 5 | const projectRoot = __dirname; 6 | // This can be replaced with `find-yarn-workspace-root` 7 | const workspaceRoot = path.resolve(projectRoot, '../..'); 8 | 9 | const config = getDefaultConfig(projectRoot); 10 | 11 | // 1. Watch all files within the monorepo 12 | config.watchFolders = [workspaceRoot]; 13 | // 2. Let Metro know where to resolve packages and in what order 14 | config.resolver.nodeModulesPaths = [ 15 | path.resolve(projectRoot, 'node_modules'), 16 | path.resolve(workspaceRoot, 'node_modules'), 17 | ]; 18 | // 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths` 19 | config.resolver.disableHierarchicalLookup = true; 20 | 21 | module.exports = config; 22 | -------------------------------------------------------------------------------- /apps/mobile/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mobile", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "start": "expo start", 6 | "android": "expo run:android", 7 | "ios": "expo run:ios" 8 | }, 9 | "dependencies": { 10 | "@babel/plugin-transform-private-methods": "^7.22.5", 11 | "@types/react": "~18.2.14", 12 | "@vlcn.io/ws-client": "0.1.0-next.5", 13 | "@vlcn.io/ws-common": "0.1.0-next.0", 14 | "base-64": "^1.0.0", 15 | "expo": "^49.0.4", 16 | "expo-crypto": "~12.4.1", 17 | "expo-splash-screen": "~0.20.4", 18 | "expo-sqlite": "~11.7.1", 19 | "expo-status-bar": "~1.6.0", 20 | "react": "18.2.0", 21 | "react-native": "0.72.3", 22 | "react-native-reanimated": "~3.3.0", 23 | "tinybase": "^4.1.0", 24 | "typescript": "^5.1.3" 25 | }, 26 | "devDependencies": { 27 | "@babel/core": "^7.20.0" 28 | }, 29 | "private": true 30 | } 31 | -------------------------------------------------------------------------------- /apps/mobile/app/db/init.ts: -------------------------------------------------------------------------------- 1 | import * as SQLite from "expo-sqlite"; 2 | import { createSingletonDbProvider } from "../sync/SyncedExpoDB"; 3 | import { cryb64 } from "@vlcn.io/ws-common"; 4 | import {decode, encode} from 'base-64' 5 | if (!global.btoa) { 6 | global.btoa = encode; 7 | } 8 | 9 | if (!global.atob) { 10 | global.atob = decode; 11 | } 12 | 13 | export const dbName = "test.db"; 14 | export const db = SQLite.openDatabase(dbName); 15 | 16 | // TODO: ideally we can share the schema in a package between client and server 17 | // this is curently duplicated into /server/schemas/test.sql 18 | const schema = [ 19 | `CREATE TABLE IF NOT EXISTS "todo" ("id" PRIMARY KEY, "text", "completed" INTEGER DEFAULT 0);`, 20 | `SELECT crsql_as_crr('todo');` 21 | ]; 22 | 23 | export function initDatabase() { 24 | db.exec( 25 | schema.map((sql) => ({ sql, args: [] })), 26 | false, 27 | () => {} 28 | ); 29 | } 30 | 31 | export const dbProvider = createSingletonDbProvider({ 32 | dbName, 33 | db, 34 | // TODO: users shouldn't manually deal with any of this. 35 | // The browser db wrappers of cr-sqlite support automigration 36 | // but we don't have that in the Expo bindings yet. 37 | schemaName: "test.sql", 38 | schemaVersion: cryb64(schema.join("\n")), 39 | }); 40 | -------------------------------------------------------------------------------- /apps/mobile/app/todo.tsx: -------------------------------------------------------------------------------- 1 | import { EvilIcons, AntDesign, MaterialIcons } from "@expo/vector-icons"; 2 | import { Text, Pressable, StyleSheet } from "react-native"; 3 | import Animated, { BounceIn, SlideInRight } from "react-native-reanimated"; 4 | import { 5 | RowProps, 6 | useCell, 7 | useDelRowCallback, 8 | useSetCellCallback, 9 | } from "tinybase/lib/ui-react"; 10 | 11 | const AnimatedCheckmark = Animated.createAnimatedComponent(AntDesign); 12 | const ICON_SIZE = 24; 13 | 14 | export type Todo = { 15 | id: number; 16 | text: string; 17 | completed: boolean; 18 | }; 19 | 20 | export function TodoRow({ tableId, rowId }: RowProps) { 21 | const todo = useCell(tableId, rowId, "text"); 22 | const completed = useCell(tableId, rowId, "completed"); 23 | 24 | const toggleTodo = useSetCellCallback( 25 | tableId, 26 | rowId, 27 | "completed", 28 | () => (completed === 0 ? 1 : 0), 29 | [completed] 30 | ); 31 | 32 | const deleteRow = useDelRowCallback(tableId, rowId); 33 | 34 | return ( 35 | 36 | [ 38 | styles.container, 39 | { 40 | borderColor: completed ? "#10cc1f" : "rgba(0,0,0,.5)", 41 | opacity: pressed ? 0.5 : 1, 42 | }, 43 | ]} 44 | onPress={toggleTodo} 45 | > 46 | {completed ? ( 47 | 53 | ) : ( 54 | 59 | )} 60 | {todo} 61 | 62 | 63 | 64 | 65 | ); 66 | } 67 | 68 | const styles = StyleSheet.create({ 69 | container: { 70 | width: "100%", 71 | borderRadius: 10, 72 | marginVertical: 5, 73 | padding: 10, 74 | flexDirection: "row", 75 | alignItems: "center", 76 | justifyContent: "space-between", 77 | borderWidth: 2, 78 | backgroundColor: "white", 79 | gap: 10, 80 | }, 81 | title: { 82 | textAlign: "left", 83 | fontSize: 20, 84 | fontWeight: "bold", 85 | flex: 1, 86 | }, 87 | status: { 88 | flexDirection: "row", 89 | alignItems: "center", 90 | gap: 10, 91 | }, 92 | }); 93 | -------------------------------------------------------------------------------- /apps/mobile/App.tsx: -------------------------------------------------------------------------------- 1 | import { initDatabase, db, dbProvider, dbName } from "./app/db/init"; 2 | import { StatusBar } from "expo-status-bar"; 3 | import { 4 | StyleSheet, 5 | Text, 6 | View, 7 | ScrollView, 8 | Image, 9 | TouchableOpacity, 10 | Platform, 11 | } from "react-native"; 12 | import { TodoRow } from "./app/todo"; 13 | import { 14 | Provider, 15 | SortedTableView, 16 | useCreatePersister, 17 | useDelTableCallback, 18 | useStore, 19 | } from "tinybase/lib/ui-react"; 20 | import { createExpoSqlitePersister } from "tinybase/lib/persisters/persister-expo-sqlite"; 21 | import { generateRandomTodo, nanoid } from "./app/utils"; 22 | import { useCallback, useEffect } from "react"; 23 | import { createSyncedDB, defaultConfig } from "@vlcn.io/ws-client"; 24 | import { store } from "./app/store"; 25 | 26 | const uri = 27 | "https://images.unsplash.com/photo-1631891318333-dc891d26f52a?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MjZ8fGxhbmRtYXJrcyUyMHdhbGxwYXBlcnxlbnwwfHwwfHx8MA%3D%3D&auto=format&fit=crop&w=500&q=60"; 28 | 29 | export default function App() { 30 | return ( 31 | 32 | 33 | 34 | ); 35 | } 36 | 37 | const host = Platform.OS === "ios" ? "localhost" : "10.0.2.2"; 38 | 39 | function TodoList() { 40 | const store = useStore(); 41 | 42 | // TODO: allow enabling/disabling sync 43 | // TODO: return cleanup 44 | useEffect(() => { 45 | initDatabase(); 46 | const syncedDbPromise = createSyncedDB( 47 | { 48 | dbProvider: dbProvider, 49 | transportProvider: defaultConfig.transportProvider, 50 | }, 51 | dbName, 52 | { 53 | room: "my-room", 54 | url: `ws://${host}:8080/sync`, 55 | } 56 | ).then((synced) => { 57 | synced.start(); 58 | return synced; 59 | }); 60 | 61 | return () => { 62 | syncedDbPromise.then((synced) => synced.stop()); 63 | }; 64 | }, []); 65 | 66 | useCreatePersister( 67 | store, 68 | (store) => 69 | createExpoSqlitePersister( 70 | store, 71 | db, 72 | { 73 | mode: "tabular", 74 | tables: { 75 | load: { todo: { tableId: "todo", rowIdColumnName: "id" } }, 76 | save: { todo: { tableName: "todo", rowIdColumnName: "id" } }, 77 | }, 78 | }, 79 | console.info 80 | ), 81 | [db], 82 | async (persister) => { 83 | await persister.startAutoLoad(); 84 | await persister.startAutoSave(); 85 | } 86 | ); 87 | 88 | const addTodo = useCallback( 89 | () => store.setCell("todo", nanoid(10), "text", generateRandomTodo()), 90 | [store] 91 | ); 92 | 93 | const deleteTodo = useDelTableCallback("todo", store); 94 | 95 | return ( 96 | 97 | 98 | 99 | 104 | 105 | 106 | 110 | Add Todo 111 | 112 | 113 | 117 | Delete All Todos 118 | 119 | 120 | 121 | 125 | 131 | 132 | 133 | 134 | ); 135 | } 136 | 137 | const styles = StyleSheet.create({ 138 | container: { 139 | flex: 1, 140 | alignItems: "center", 141 | gap: 10, 142 | }, 143 | btn: { backgroundColor: "white", padding: 10, borderRadius: 5 }, 144 | btnText: { fontSize: 18, textAlign: "center" }, 145 | }); 146 | -------------------------------------------------------------------------------- /apps/mobile/app/sync/SyncedExpoDB.ts: -------------------------------------------------------------------------------- 1 | import { DB } from "@vlcn.io/ws-client"; 2 | import { Change, bytesToHex, hexToBytes } from "@vlcn.io/ws-common"; 3 | import { SQLiteDatabase } from "expo-sqlite"; 4 | 5 | class SyncedExpoDB implements DB { 6 | #db: SQLiteDatabase; 7 | #siteId: Uint8Array; 8 | #schemaName: string; 9 | #schemaVersion: bigint; 10 | 11 | constructor( 12 | db: SQLiteDatabase, 13 | siteId: Uint8Array, 14 | schemaName: string, 15 | schemaVersion: bigint 16 | ) { 17 | this.#db = db; 18 | this.#siteId = siteId; 19 | this.#schemaName = schemaName; 20 | this.#schemaVersion = schemaVersion; 21 | } 22 | 23 | get siteid() { 24 | return this.#siteId; 25 | } 26 | 27 | onChange(cb: () => void): () => void { 28 | console.log("registering db listener..."); 29 | const subscription = this.#db.onDatabaseChange(cb); 30 | return () => { 31 | subscription.remove(); 32 | }; 33 | } 34 | 35 | getSchemaNameAndVersion(): PromiseLike<[string, bigint]> { 36 | return Promise.resolve([this.#schemaName, this.#schemaVersion]); 37 | } 38 | 39 | async pullChangeset( 40 | since: readonly [bigint, number], 41 | excludeSites: readonly Uint8Array[], 42 | localOnly: boolean 43 | ): Promise { 44 | console.log("pulling changes"); 45 | const resultSet = await this.#db.execAsync( 46 | [ 47 | { 48 | // Have to do a hex conversion since expo-sqlite doesn't support blobs 49 | // 0 as "cl" is a placeholder given cl does not exist in 0.14.0 50 | 51 | sql: `SELECT "table", hex("pk") as "pk", "cid", "val", "col_version", "db_version", NULL, "cl" FROM crsql_changes WHERE db_version > ? AND site_id IS NOT unhex(?)`, 52 | args: [Number(since[0]), bytesToHex(excludeSites[0])], 53 | }, 54 | ], 55 | true 56 | ); 57 | const ret = resultSet[0]; 58 | if ("error" in ret) { 59 | throw ret.error; 60 | } 61 | console.log(`Pulled ${ret.rows.length} changes since ${since[0]}`); 62 | return ret.rows.map((row) => { 63 | const { table, pk, cid, val, col_version, db_version, cl } = row; 64 | return [ 65 | table, 66 | // and then back to a bytes again :/ 67 | hexToBytes(pk), 68 | cid, 69 | val, 70 | BigInt(col_version), 71 | BigInt(db_version), 72 | null, 73 | BigInt(cl), 74 | ]; 75 | }); 76 | } 77 | 78 | async applyChangesetAndSetLastSeen( 79 | changes: readonly Change[], 80 | siteId: Uint8Array, 81 | end: readonly [bigint, number] 82 | ): Promise { 83 | console.log("applying changes"); 84 | await this.#db.transactionAsync(async (transaction) => { 85 | const sql = `INSERT INTO crsql_changes ("table", "pk", "cid", "val", "col_version", "db_version", "site_id", "cl") VALUES (?, unhex(?), ?, ?, ?, ?, unhex(?), ?)`; 86 | for (const change of changes) { 87 | const [table, pk, cid, val, col_version, db_version, _, cl] = change; 88 | // TODO: expo blob bindings may still not work.. in which case we need to finagle with the bindings and `pk` to get it to work 89 | // doing these inserts in parallel wouldn't make sense hence awaiting in a loop. 90 | // also col_version, db_version may need to be coerced to numbers from bigints... 91 | // oof... the lack of bigin 😬 92 | const bind = [ 93 | table, 94 | bytesToHex(pk), 95 | cid, 96 | typeof val === "bigint" ? Number(val) : val, 97 | Number(col_version), 98 | Number(db_version), 99 | bytesToHex(siteId), 100 | Number(cl), 101 | ]; 102 | console.log(bind); 103 | await transaction.executeSqlAsync(sql, bind); 104 | console.log(bind); 105 | } 106 | await transaction.executeSqlAsync( 107 | `INSERT INTO "crsql_tracked_peers" ("site_id", "event", "version", "seq", "tag") VALUES (unhex(?), ?, ?, ?, 0) ON CONFLICT DO UPDATE SET 108 | "version" = MAX("version", excluded."version"), 109 | "seq" = CASE "version" > excluded."version" WHEN 1 THEN "seq" ELSE excluded."seq" END`, 110 | // TODO: expo doesn't support bigints. 111 | // This is okish since we'll never actually hit 2^53 112 | // TODO: hexify siteId? and unhex it in the db? 113 | [bytesToHex(siteId), 0, Number(end[0]), end[1]] 114 | ); 115 | }); 116 | } 117 | 118 | async getLastSeens(): Promise<[Uint8Array, [bigint, number]][]> { 119 | console.log("getting last seens"); 120 | // TODO: more hexing and unhexing due to lack of blob support 121 | // in the expo bindings 122 | const resultSet = await this.#db.execAsync( 123 | [ 124 | { 125 | sql: `SELECT hex("site_id") as "site_id", "version", "seq" FROM crsql_tracked_peers`, 126 | args: [], 127 | }, 128 | ], 129 | true 130 | ); 131 | const ret = resultSet[0]; 132 | if ("error" in ret) { 133 | throw ret.error; 134 | } 135 | return ret.rows.map((row) => { 136 | const { site_id, version, seq } = row; 137 | return [hexToBytes(site_id), [BigInt(version), seq]]; 138 | }); 139 | } 140 | 141 | close(closeWrappedDB: false) { 142 | if (closeWrappedDB) { 143 | this.#db.closeSync(); 144 | } 145 | } 146 | } 147 | 148 | export type SingletonDescriptor = { 149 | dbName: string; 150 | db: SQLiteDatabase; 151 | schemaName: string; 152 | schemaVersion: bigint; 153 | }; 154 | // This is a basic provider just for demonstration purposes. 155 | // It expect callers to use an already open an existing database. 156 | export function createSingletonDbProvider({ 157 | dbName: requiredDbName, 158 | db, 159 | schemaName, 160 | schemaVersion, 161 | }: SingletonDescriptor): (dbName: string) => Promise { 162 | return async (dbName: string) => { 163 | if (dbName !== requiredDbName) { 164 | throw new Error(`The singleton provider only supports ${requiredDbName}`); 165 | } 166 | const resultSet = await db.execAsync( 167 | [ 168 | { 169 | sql: `SELECT hex(crsql_site_id()) as site_id`, 170 | args: [], 171 | }, 172 | ], 173 | true 174 | ); 175 | const ret = resultSet[0]; 176 | if ("error" in ret) { 177 | throw ret.error; 178 | } 179 | const siteId = hexToBytes(ret.rows[0]["site_id"]); 180 | return new SyncedExpoDB(db, siteId, schemaName, schemaVersion); 181 | }; 182 | } 183 | -------------------------------------------------------------------------------- /apps/mobile/app/utils.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from "expo-crypto"; 2 | 3 | const todoList = [ 4 | "Buy groceries", 5 | "Pay bills", 6 | "Clean the house", 7 | "Exercise", 8 | "Finish the report", 9 | "Call mom", 10 | "Read a book", 11 | "Walk the dog", 12 | "Go for a run", 13 | "Water the plants", 14 | "Cook dinner", 15 | "Do the laundry", 16 | "Study for exam", 17 | "Write a blog post", 18 | "Practice coding", 19 | "Schedule appointment", 20 | "Volunteer at a shelter", 21 | "Organize desk", 22 | "Call a friend", 23 | "Plan a trip", 24 | "Watch a movie", 25 | "Take a nap", 26 | "Learn a new skill", 27 | "Attend a workshop", 28 | "Buy a birthday gift", 29 | "Send a thank-you note", 30 | "Clean out the fridge", 31 | "Start a journal", 32 | "Meditate for 10 minutes", 33 | "Fix a broken item", 34 | "Try a new recipe", 35 | "Go stargazing", 36 | "Sort out old clothes", 37 | "Create a budget", 38 | "Donate to charity", 39 | "Read the news", 40 | "Take a nature walk", 41 | "Learn to play an instrument", 42 | "Visit a museum", 43 | "Write a poem", 44 | "Do a random act of kindness", 45 | "Explore a new neighborhood", 46 | "Call a family member", 47 | "Plan a picnic", 48 | "Build a DIY project", 49 | "Join a book club", 50 | "Learn a magic trick", 51 | "Write a letter to yourself", 52 | "Research a new hobby", 53 | "Paint a picture", 54 | "Start a compost bin", 55 | "Try a new restaurant", 56 | "Do a puzzle", 57 | "Listen to a podcast", 58 | "Plant flowers in the garden", 59 | "Play a board game", 60 | "Take a cooking class", 61 | "Practice mindfulness", 62 | "Create a vision board", 63 | "Learn a new language", 64 | "Go to a farmer's market", 65 | "Declutter a room", 66 | "Visit a local landmark", 67 | "Do a home workout", 68 | "Attend a live performance", 69 | "Explore a nearby park", 70 | "Try a new hairstyle", 71 | "Play with a pet", 72 | "Write a short story", 73 | "Try a new type of cuisine", 74 | "Do a science experiment", 75 | "Watch a documentary", 76 | "Take a photography walk", 77 | "Do a crossword puzzle", 78 | "Learn to dance", 79 | "Write a to-do list for tomorrow", 80 | "Make a handmade gift", 81 | "Watch the sunrise", 82 | "Write down your dreams", 83 | "Have a technology-free day", 84 | "Take a bike ride", 85 | "Create a playlist of favorite songs", 86 | "Learn about a historical event", 87 | "Play a video game", 88 | "Try a new type of exercise", 89 | "Do a DIY home improvement", 90 | "Visit a local library", 91 | "Write a gratitude journal", 92 | "Join a community event", 93 | "Make a list of places to visit", 94 | "Try a new type of tea", 95 | "Do a brain-teaser puzzle", 96 | "Watch the sunset", 97 | "Create a scrapbook", 98 | "Go on a road trip", 99 | "Learn to juggle", 100 | "Explore a nearby forest", 101 | "Try a new board game", 102 | "Take a dance class", 103 | "Visit an art gallery", 104 | "Write a list of things you love about yourself", 105 | "Attend a meditation session", 106 | "Have a picnic in the park", 107 | "Try a new type of art", 108 | "Do a virtual escape room", 109 | "Learn to do origami", 110 | "Watch a stand-up comedy show", 111 | "Have a spa day at home", 112 | "Learn to solve a Rubik's cube", 113 | "Do a DIY fashion project", 114 | "Visit a botanical garden", 115 | "Write a letter to your future self", 116 | "Make a list of favorite quotes", 117 | "Try a new type of dessert", 118 | "Do a word search puzzle", 119 | "Explore a local market", 120 | "Try a new type of meditation", 121 | "Watch a classic movie", 122 | "Take a pottery class", 123 | "Learn to do magic tricks", 124 | "Visit a wildlife sanctuary", 125 | "Write a list of personal goals", 126 | "Have a movie marathon", 127 | "Try a new outdoor activity", 128 | "Do a DIY art project", 129 | "Explore a nearby lake", 130 | "Take a singing lesson", 131 | "Visit a historical site", 132 | "Write a short gratitude letter to someone", 133 | "Try a new type of cuisine", 134 | "Do a nature scavenger hunt", 135 | "Watch a wildlife documentary", 136 | "Have a game night with friends", 137 | "Learn to play chess", 138 | "Visit a science museum", 139 | "Write a letter to a future grandchild", 140 | "Make a list of places you want to travel to", 141 | "Try a new form of exercise", 142 | "Do a DIY woodworking project", 143 | "Explore a nearby beach", 144 | "Take a cooking class", 145 | "Learn to do calligraphy", 146 | "Visit a planetarium", 147 | "Write a letter to your past self", 148 | "Try a new type of craft", 149 | "Do a virtual museum tour", 150 | "Watch a documentary on space exploration", 151 | "Have a karaoke night", 152 | "Learn to do card tricks", 153 | "Visit a local zoo", 154 | "Write a list of things you're grateful for", 155 | "Try a new type of music", 156 | "Do a virtual art class", 157 | "Explore a nearby mountain", 158 | "Take a photography class", 159 | "Visit a local farm", 160 | "Write a letter to a mentor or role model", 161 | "Try a new form of dance", 162 | "Do a DIY gardening project", 163 | "Watch a historical documentary", 164 | "Have a board game marathon", 165 | "Learn to play a musical instrument", 166 | "Visit a natural hot spring", 167 | "Write a letter to your favorite author", 168 | "Try a new type of exercise", 169 | "Do a virtual cooking class", 170 | "Explore a nearby city", 171 | "Take a painting class", 172 | "Learn to do pottery", 173 | "Visit a local art fair", 174 | "Write a list of inspiring people", 175 | "Try a new form of meditation", 176 | "Do a DIY home decor project", 177 | "Watch a wildlife livestream", 178 | "Have a themed costume party", 179 | "Learn to do balloon animals", 180 | "Visit a local theater performance", 181 | "Write a letter to your future self", 182 | "Try a new type of cuisine", 183 | "Do a virtual yoga class", 184 | "Explore a nearby nature reserve", 185 | "Take a woodworking class", 186 | "Visit a historical reenactment", 187 | "Write a letter to your future child", 188 | "Try a new type of craft", 189 | "Do a virtual fitness class", 190 | "Watch a documentary on marine life", 191 | "Have a movie night under the stars", 192 | "Learn to do flower arrangements", 193 | "Visit a botanical garden", 194 | "Write a list of your favorite books", 195 | "Try a new form of art", 196 | "Do a virtual escape room", 197 | "Explore a nearby hiking trail", 198 | "Take a dancing class", 199 | "Visit an art gallery", 200 | "Write a list of personal affirmations", 201 | ]; 202 | 203 | export function generateRandomTodo() { 204 | return todoList[randInt(todoList.length)]; 205 | } 206 | 207 | function randInt(lessThan: number) { 208 | return Math.floor(Math.random() * lessThan); 209 | } 210 | 211 | export function hexToBytes(hex: string) { 212 | const ret = new Uint8Array(hex.length / 2); 213 | for (let c = 0; c < hex.length; c += 2) { 214 | ret[c / 2] = parseInt(hex.substring(c, c + 2), 16); 215 | } 216 | return ret; 217 | } 218 | 219 | export function bytesToHex(bytes: Uint8Array) { 220 | let hex: string[] = []; 221 | for (let i = 0; i < bytes.length; i++) { 222 | let current = bytes[i] < 0 ? bytes[i] + 256 : bytes[i]; 223 | hex.push((current >>> 4).toString(16)); 224 | hex.push((current & 0xf).toString(16)); 225 | } 226 | return hex.join(""); 227 | } 228 | 229 | export function nanoid(t = 21) { 230 | return crypto 231 | .getRandomValues(new Uint8Array(t)) 232 | .reduce( 233 | (t, e) => 234 | (t += 235 | (e &= 63) < 36 236 | ? e.toString(36) 237 | : e < 62 238 | ? (e - 26).toString(36).toUpperCase() 239 | : e > 62 240 | ? "-" 241 | : "_"), 242 | "" 243 | ); 244 | } 245 | --------------------------------------------------------------------------------