From b3e79ce1929f3dcbe73a3e39d412c8baf1a820d4 Mon Sep 17 00:00:00 2001 From: Sebastian Pietras Date: Wed, 11 May 2022 06:55:23 +0200 Subject: [PATCH] Improved state management --- App.tsx | 60 +++++----- package-lock.json | 205 +++++++++++++++++++++++++++++--- package.json | 2 + src/Room.ts | 54 +++++++++ src/components/emojis/Emoji.tsx | 20 ++-- src/hooks/useAppDispatch.ts | 4 + src/hooks/useAppSelector.ts | 6 + src/hooks/useAsyncStorage.ts | 20 ++-- src/hooks/useCancellable.ts | 24 ++++ src/hooks/useSvg.ts | 22 ++-- src/navigation/types.ts | 10 +- src/screens/Main.tsx | 47 +++++--- src/screens/Play.tsx | 199 ++++++++++++++++++++----------- src/screens/Settings.tsx | 19 ++- src/state/slices/code.ts | 26 ++++ src/state/slices/users.ts | 39 ++++++ src/state/store.ts | 13 ++ 17 files changed, 587 insertions(+), 183 deletions(-) create mode 100644 src/Room.ts create mode 100644 src/hooks/useAppDispatch.ts create mode 100644 src/hooks/useAppSelector.ts create mode 100644 src/hooks/useCancellable.ts create mode 100644 src/state/slices/code.ts create mode 100644 src/state/slices/users.ts create mode 100644 src/state/store.ts diff --git a/App.tsx b/App.tsx index 7aa82be..04c1ffa 100644 --- a/App.tsx +++ b/App.tsx @@ -15,6 +15,8 @@ import { gestureHandlerRootHOC } from "react-native-gesture-handler"; import * as SplashScreen from "expo-splash-screen"; import React from "react"; import { SettingsProvider } from "./src/contexts/settings"; +import { Provider } from "react-redux"; +import { store } from "./src/state/store"; const fonts = { Nunito_700Bold, @@ -62,33 +64,35 @@ export default function App() { if (!fontsLoaded) return null; return ( - - - - - - - - - + + + + + + + + + + + ); } diff --git a/package-lock.json b/package-lock.json index 07ffabc..2222e9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@react-native-picker/picker": "2.4.0", "@react-navigation/native": "^6.0.10", "@react-navigation/native-stack": "^6.6.2", + "@reduxjs/toolkit": "^1.8.1", "@shopify/react-native-skia": "^0.1.123", "expo": "^45.0.0", "expo-crypto": "~10.2.0", @@ -35,6 +36,7 @@ "react-native-screens": "~3.11.1", "react-native-sound": "^0.11.2", "react-native-web": "0.17.7", + "react-redux": "^8.0.1", "twrnc": "^3.3.0" }, "devDependencies": { @@ -4405,6 +4407,29 @@ "nanoid": "^3.1.23" } }, + "node_modules/@reduxjs/toolkit": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.8.1.tgz", + "integrity": "sha512-Q6mzbTpO9nOYRnkwpDlFOAbQnd3g7zj7CtHAZWz5SzE5lcV97Tf8f3SzOO8BoPOMYBFgfZaqTUZqgGu+a0+Fng==", + "dependencies": { + "immer": "^9.0.7", + "redux": "^4.1.2", + "redux-thunk": "^2.4.1", + "reselect": "^4.1.5" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18", + "react-redux": "^7.2.1 || ^8.0.0-beta" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@segment/loosely-validate-event": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@segment/loosely-validate-event/-/loosely-validate-event-2.0.0.tgz", @@ -4459,6 +4484,15 @@ "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.41.tgz", "integrity": "sha512-ewXv/ceBaJprikMcxCmWU1FKyMAQ2X7a9Gtmzw8fcg2kIePI1crERDM818W+XYrxqdBBOdlf2rm137bU+BltCA==" }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/invariant": { "version": "2.2.35", "resolved": "https://registry.npmjs.org/@types/invariant/-/invariant-2.2.35.tgz", @@ -4500,14 +4534,12 @@ "node_modules/@types/prop-types": { "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "dev": true + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" }, "node_modules/@types/react": { "version": "17.0.45", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.45.tgz", "integrity": "sha512-YfhQ22Lah2e3CHPsb93tRwIGNiSwkuz1/blk4e6QrWS0jQzCSNbGLtOEYhPg02W0yGTTmpajp7dCTbBAMN3qsg==", - "dev": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -4526,8 +4558,12 @@ "node_modules/@types/scheduler": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "dev": true + "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" }, "node_modules/@types/yargs": { "version": "15.0.14", @@ -6000,8 +6036,7 @@ "node_modules/csstype": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", - "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", - "dev": true + "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" }, "node_modules/dag-map": { "version": "1.0.2", @@ -7611,6 +7646,15 @@ "node": ">=4.0" } }, + "node_modules/immer": { + "version": "9.0.12", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.12.tgz", + "integrity": "sha512-lk7UNmSbAukB5B6dh9fnh5D0bJTOFKxVg2cyJWTYrWRfhLrLMBquONcUs3aFq507hNoIZEDDh8lb8UtOizSMhA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", @@ -11039,6 +11083,49 @@ "react": "^17.0.2" } }, + "node_modules/react-redux": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.0.1.tgz", + "integrity": "sha512-LMZMsPY4DYdZfLJgd7i79n5Kps5N9XVLCJJeWAaPYTV+Eah2zTuBjTxKtNEbjiyitbq80/eIkm55CYSLqAub3w==", + "dependencies": { + "@babel/runtime": "^7.12.1", + "@types/hoist-non-react-statics": "^3.3.1", + "@types/use-sync-external-store": "^0.0.3", + "hoist-non-react-statics": "^3.3.2", + "react-is": "^18.0.0", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "@types/react": "^16.8 || ^17.0 || ^18.0", + "@types/react-dom": "^16.8 || ^17.0 || ^18.0", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0", + "react-native": ">=0.59", + "redux": "^4" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-redux/node_modules/react-is": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.1.0.tgz", + "integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==" + }, "node_modules/react-refresh": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.4.3.tgz", @@ -11116,6 +11203,22 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" }, + "node_modules/redux": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.0.tgz", + "integrity": "sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, + "node_modules/redux-thunk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz", + "integrity": "sha512-OOYGNY5Jy2TWvTL1KgAlVy6dcx3siPJ1wTq741EPyUKfn6W6nChdICjZwCd0p8AZBs5kWpZlbkXW2nE/zjUa+Q==", + "peerDependencies": { + "redux": "^4" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -12905,6 +13008,14 @@ "react": "^16.8.0 || ^17.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.1.0.tgz", + "integrity": "sha512-SEnieB2FPKEVne66NpXPd1Np4R1lTNKfjuy3XdIoPQKYBAFdzbzSZlSn1KJZUiihQLQC5Znot4SBz1EOTBwQAQ==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -16456,6 +16567,17 @@ "nanoid": "^3.1.23" } }, + "@reduxjs/toolkit": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.8.1.tgz", + "integrity": "sha512-Q6mzbTpO9nOYRnkwpDlFOAbQnd3g7zj7CtHAZWz5SzE5lcV97Tf8f3SzOO8BoPOMYBFgfZaqTUZqgGu+a0+Fng==", + "requires": { + "immer": "^9.0.7", + "redux": "^4.1.2", + "redux-thunk": "^2.4.1", + "reselect": "^4.1.5" + } + }, "@segment/loosely-validate-event": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@segment/loosely-validate-event/-/loosely-validate-event-2.0.0.tgz", @@ -16504,6 +16626,15 @@ "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.41.tgz", "integrity": "sha512-ewXv/ceBaJprikMcxCmWU1FKyMAQ2X7a9Gtmzw8fcg2kIePI1crERDM818W+XYrxqdBBOdlf2rm137bU+BltCA==" }, + "@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "@types/invariant": { "version": "2.2.35", "resolved": "https://registry.npmjs.org/@types/invariant/-/invariant-2.2.35.tgz", @@ -16545,14 +16676,12 @@ "@types/prop-types": { "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "dev": true + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" }, "@types/react": { "version": "17.0.45", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.45.tgz", "integrity": "sha512-YfhQ22Lah2e3CHPsb93tRwIGNiSwkuz1/blk4e6QrWS0jQzCSNbGLtOEYhPg02W0yGTTmpajp7dCTbBAMN3qsg==", - "dev": true, "requires": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -16571,8 +16700,12 @@ "@types/scheduler": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "dev": true + "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" + }, + "@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" }, "@types/yargs": { "version": "15.0.14", @@ -17720,8 +17853,7 @@ "csstype": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", - "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", - "dev": true + "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" }, "dag-map": { "version": "1.0.2", @@ -18952,6 +19084,11 @@ "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.6.3.tgz", "integrity": "sha512-47xSUiQioGaB96nqtp5/q55m0aBQSQdyIloMOc/x+QVTDZLNmXE892IIDrJ0hM1A5vcNUDD5tDffkSP5lCaIIA==" }, + "immer": { + "version": "9.0.12", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.12.tgz", + "integrity": "sha512-lk7UNmSbAukB5B6dh9fnh5D0bJTOFKxVg2cyJWTYrWRfhLrLMBquONcUs3aFq507hNoIZEDDh8lb8UtOizSMhA==" + }, "import-fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", @@ -21519,6 +21656,26 @@ "scheduler": "^0.20.2" } }, + "react-redux": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.0.1.tgz", + "integrity": "sha512-LMZMsPY4DYdZfLJgd7i79n5Kps5N9XVLCJJeWAaPYTV+Eah2zTuBjTxKtNEbjiyitbq80/eIkm55CYSLqAub3w==", + "requires": { + "@babel/runtime": "^7.12.1", + "@types/hoist-non-react-statics": "^3.3.1", + "@types/use-sync-external-store": "^0.0.3", + "hoist-non-react-statics": "^3.3.2", + "react-is": "^18.0.0", + "use-sync-external-store": "^1.0.0" + }, + "dependencies": { + "react-is": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.1.0.tgz", + "integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==" + } + } + }, "react-refresh": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.4.3.tgz", @@ -21583,6 +21740,20 @@ } } }, + "redux": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.0.tgz", + "integrity": "sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA==", + "requires": { + "@babel/runtime": "^7.9.2" + } + }, + "redux-thunk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz", + "integrity": "sha512-OOYGNY5Jy2TWvTL1KgAlVy6dcx3siPJ1wTq741EPyUKfn6W6nChdICjZwCd0p8AZBs5kWpZlbkXW2nE/zjUa+Q==", + "requires": {} + }, "regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -22966,6 +23137,12 @@ "object-assign": "^4.1.1" } }, + "use-sync-external-store": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.1.0.tgz", + "integrity": "sha512-SEnieB2FPKEVne66NpXPd1Np4R1lTNKfjuy3XdIoPQKYBAFdzbzSZlSn1KJZUiihQLQC5Znot4SBz1EOTBwQAQ==", + "requires": {} + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 93f3f62..ad221f4 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@react-native-picker/picker": "2.4.0", "@react-navigation/native": "^6.0.10", "@react-navigation/native-stack": "^6.6.2", + "@reduxjs/toolkit": "^1.8.1", "@shopify/react-native-skia": "^0.1.123", "expo": "^45.0.0", "expo-crypto": "~10.2.0", @@ -39,6 +40,7 @@ "react-native-screens": "~3.11.1", "react-native-sound": "^0.11.2", "react-native-web": "0.17.7", + "react-redux": "^8.0.1", "twrnc": "^3.3.0" }, "devDependencies": { diff --git a/src/Room.ts b/src/Room.ts new file mode 100644 index 0000000..79a58b5 --- /dev/null +++ b/src/Room.ts @@ -0,0 +1,54 @@ +export type Action = { + key: Key; + type: "press" | "release"; + velocity: number; +}; + +export type RoomEvent = { + user: UserId; + action: Action; +}; + +export default class Room { + public readonly currentUser: UserId; + private readonly code: Code; + private readonly onConnected: (id: UserId, emoji: EmojiId) => any; + private readonly onUserConnect: (id: UserId, emoji: EmojiId) => any; + private readonly onUserDisconnect: (id: UserId) => any; + private readonly onEvent: (event: RoomEvent) => any; + + constructor( + code: Code, + onConnected: (id: UserId, emoji: EmojiId) => any = () => {}, + onUserConnect: (id: UserId, emoji: EmojiId) => any = () => {}, + onUserDisconnect: (id: UserId) => any = () => {}, + onEvent: (event: RoomEvent) => any = () => {} + ) { + this.code = code; + this.onConnected = onConnected; + this.onUserConnect = onUserConnect; + this.onUserDisconnect = onUserDisconnect; + this.onEvent = onEvent; + + const connection = this._connect(code); + this.currentUser = connection.userId; + + setTimeout(() => onConnected(this.currentUser, connection.emoji)); + } + + _connect(code: Code) { + return { + userId: "me", + emoji: "1f349", + }; + } + + disconnect() {} + + dispatch(action: Action) { + this.onEvent({ + user: this.currentUser, + action: action, + }); + } +} diff --git a/src/components/emojis/Emoji.tsx b/src/components/emojis/Emoji.tsx index 422d6bb..27fb18a 100644 --- a/src/components/emojis/Emoji.tsx +++ b/src/components/emojis/Emoji.tsx @@ -2,6 +2,7 @@ import React from "react"; import Svg, { SvgProps } from "../Svg"; import { downloadNetworkAsset } from "../../utils"; import useSvg from "../../hooks/useSvg"; +import useCancellable from "../../hooks/useCancellable"; export type EmojiProps = Omit & { id: EmojiId; @@ -22,21 +23,14 @@ function PrefetchSvg({ export default function Emoji({ id, size, ...otherProps }: EmojiProps) { const [fileUri, setFileUri] = React.useState(); - React.useEffect(() => { - let cancelled = false; - - const downloadEmoji = async (id: string) => { + useCancellable( + async (cancelInfo) => { const url = idToUrl(id); const uri = await downloadNetworkAsset(url); - if (!cancelled) setFileUri(uri); - }; - - downloadEmoji(id).catch((error) => console.log(error)); - - return () => { - cancelled = true; - }; - }, [id]); + if (!cancelInfo.cancelled) setFileUri(uri); + }, + [id] + ); return !fileUri ? ( diff --git a/src/hooks/useAppDispatch.ts b/src/hooks/useAppDispatch.ts new file mode 100644 index 0000000..486aaec --- /dev/null +++ b/src/hooks/useAppDispatch.ts @@ -0,0 +1,4 @@ +import { useDispatch } from "react-redux"; +import { AppDispatch } from "../state/store"; + +export default () => useDispatch(); diff --git a/src/hooks/useAppSelector.ts b/src/hooks/useAppSelector.ts new file mode 100644 index 0000000..0dba3da --- /dev/null +++ b/src/hooks/useAppSelector.ts @@ -0,0 +1,6 @@ +import { TypedUseSelectorHook, useSelector } from "react-redux"; +import { RootState } from "../state/store"; + +const useAppSelector: TypedUseSelectorHook = useSelector; + +export default useAppSelector; diff --git a/src/hooks/useAsyncStorage.ts b/src/hooks/useAsyncStorage.ts index 28df533..a790cf9 100644 --- a/src/hooks/useAsyncStorage.ts +++ b/src/hooks/useAsyncStorage.ts @@ -1,23 +1,17 @@ import React from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; +import useCancellable from "./useCancellable"; export default function useAsyncStorage(key: string, defaultValue?: string) { const [value, setValue] = React.useState(defaultValue); - React.useEffect(() => { - let cancelled = false; - - const getValue = async (key: string) => { + useCancellable( + async (cancelInfo) => { const value = await AsyncStorage.getItem(key); - if (value !== null && !cancelled) setValue(value); - }; - - getValue(key).catch((error) => console.log(error)); - - return () => { - cancelled = true; - }; - }, [key]); + if (value !== null && !cancelInfo.cancelled) setValue(value); + }, + [key] + ); const handleSetValue = React.useCallback( async (newValue: string) => { diff --git a/src/hooks/useCancellable.ts b/src/hooks/useCancellable.ts new file mode 100644 index 0000000..1084239 --- /dev/null +++ b/src/hooks/useCancellable.ts @@ -0,0 +1,24 @@ +import React from "react"; + +type CancelInfo = { + cancelled: boolean; +}; + +type Callback = (cancelInfo: CancelInfo) => any; + +export default function useCancellable( + callback: Callback, + deps?: React.DependencyList +) { + React.useEffect(() => { + let cancelInfo: CancelInfo = { + cancelled: false, + }; + + setTimeout(() => callback(cancelInfo)); + + return () => { + cancelInfo.cancelled = true; + }; + }, deps); +} diff --git a/src/hooks/useSvg.ts b/src/hooks/useSvg.ts index d6138bc..58af2d8 100644 --- a/src/hooks/useSvg.ts +++ b/src/hooks/useSvg.ts @@ -1,26 +1,20 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Skia } from "@shopify/react-native-skia/src/skia/Skia"; import { SkSVG } from "@shopify/react-native-skia"; import * as FileSystem from "expo-file-system"; +import useCancellable from "./useCancellable"; export default function useSvg(uri: string) { const [svg, setSvg] = useState(); - useEffect(() => { - let cancelled = false; - - const loadSvg = async (uri: string) => { + useCancellable( + async (cancelInfo) => { const data = await FileSystem.readAsStringAsync(uri); const newSvg = Skia.SVG.MakeFromString(data); - if (!cancelled && newSvg) setSvg(newSvg); - }; - - loadSvg(uri).catch((error) => console.log(error)); - - return () => { - cancelled = true; - }; - }, [uri]); + if (!cancelInfo.cancelled && newSvg) setSvg(newSvg); + }, + [uri] + ); return svg; } diff --git a/src/navigation/types.ts b/src/navigation/types.ts index ad5e97f..e2b5e61 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -11,14 +11,8 @@ export type MainParams = { codeLength?: number; }; -export type PlayParams = { - code: Code; -}; - -export type SettingsParams = { - code: Code; - users: Array<[UserId, EmojiId]>; -}; +export type PlayParams = {}; +export type SettingsParams = {}; export type MainProps = NativeStackScreenProps; export type PlayProps = NativeStackScreenProps; diff --git a/src/screens/Main.tsx b/src/screens/Main.tsx index 307f81b..0d236f0 100644 --- a/src/screens/Main.tsx +++ b/src/screens/Main.tsx @@ -3,36 +3,45 @@ import ReactNative from "react-native"; import Heading from "../components/text/Heading"; import RegularText from "../components/text/RegularText"; import EmojiCode from "../components/emojis/EmojiCode"; -import { useEffect, useState } from "react"; +import React from "react"; import EmojiGrid from "../components/emojis/EmojiGrid"; import { MainProps } from "../navigation/types"; +import useAppDispatch from "../hooks/useAppDispatch"; +import { resetCode, setCode } from "../state/slices/code"; +import useCancellable from "../hooks/useCancellable"; +import { resetUsers } from "../state/slices/users"; export default function Main({ route, navigation }: MainProps) { const { availableEmojiCodes, codeLength = 3 } = route.params; - const [code, setCode] = useState([]); + const [localCode, setLocalCode] = React.useState([]); + + const dispatch = useAppDispatch(); const onEmojiSelected: (emojiId: EmojiId) => void = (emojiId: EmojiId) => { - if (code.length < codeLength) setCode([...code, emojiId]); + if (localCode.length < codeLength) setLocalCode([...localCode, emojiId]); }; - useEffect(() => { - if (code.length < codeLength) return; - - let cancelled = false; + React.useEffect( + () => + navigation.addListener("focus", () => { + setLocalCode([]); + dispatch(resetCode()); + dispatch(resetUsers()); + }), + [navigation] + ); - const transition = async (code: Code) => { - if (!cancelled) { - setCode([]); - navigation.navigate("play", { code: code }); + useCancellable( + (cancelInfo) => { + if (localCode.length < codeLength) return; + if (!cancelInfo.cancelled) { + dispatch(setCode(localCode)); + navigation.navigate("play", {}); } - }; - transition(code).catch((error) => console.log(error)); - - return () => { - cancelled = true; - }; - }, [code]); + }, + [localCode] + ); return ( @@ -45,7 +54,7 @@ export default function Main({ route, navigation }: MainProps) { - + ; @@ -25,6 +30,8 @@ const allNotes = allOctaveNotes.flatMap((note) => octaveNums.map((octave) => note + octave) ); +const fallbackEmoji = "1f32d"; + function SettingsButton({ text, emojiId, @@ -59,27 +66,24 @@ function SettingsButton({ ); } -export default function Play({ route, navigation }: PlayProps) { - const { code } = route.params; - - const [scrolling, setScrolling] = useState(false); - const [keyboardState, setKeyboardState] = useState(new Map()); - const [player, setPlayer] = useState(); +export default function Play({ navigation }: PlayProps) { + const [scrolling, setScrolling] = React.useState(false); + const [player, setPlayer] = React.useState(); + const [room, setRoom] = React.useState(); + const [keyboardState, setKeyboardState] = React.useState( + new Map() + ); const settings = useSettings(); - const getUserEmoji = (user: UserId) => "1f349"; - - const getCurrentUser = () => "me"; + const dispatch = useAppDispatch(); + const code = useAppSelector((state) => state.code.code); + const users = useAppSelector((state) => state.users.users); - const getAllUsers = () => [getCurrentUser()]; + const getUserEmoji = (userId: UserId) => + users.find((user) => user.id === userId)?.emoji; - const handleKeyPressedIn = ( - key: Key, - user: UserId, - player?: Player, - velocity?: number - ) => { + const handleKeyPressedIn = (key: Key, user: UserId, velocity?: number) => { setKeyboardState((prevState) => { if (prevState.has(key)) return prevState; if (player && velocity) player.start(key, velocity).then(); @@ -87,12 +91,7 @@ export default function Play({ route, navigation }: PlayProps) { }); }; - const handleKeyPressedOut = ( - key: Key, - user: UserId, - player?: Player, - velocity?: number - ) => { + const handleKeyPressedOut = (key: Key, user: UserId, velocity?: number) => { setKeyboardState((prevState) => { if (!prevState.has(key) || prevState.get(key) !== user) return prevState; if (player && velocity) player.stop(key, velocity).then(); @@ -103,67 +102,131 @@ export default function Play({ route, navigation }: PlayProps) { }; const handleUserKeyPressedIn = React.useCallback( - (note: string, octave: number) => - handleKeyPressedIn( - note + octave, - getCurrentUser(), - player, - settings.noteOnVelocity.value === undefined - ? undefined - : Number(settings.noteOnVelocity.value) - ), - [player, settings.noteOnVelocity.value] + (note: string, octave: number) => { + if (!room || !player || settings.noteOnVelocity.value === undefined) + return; + room.dispatch({ + key: note + octave, + type: "press", + velocity: Number(settings.noteOffVelocity.value), + }); + }, + [room, settings.noteOnVelocity.value] ); const handleUserKeyPressedOut = React.useCallback( - (note: string, octave: number) => - handleKeyPressedOut( - note + octave, - getCurrentUser(), - player, - settings.noteOffVelocity.value === undefined - ? undefined - : Number(settings.noteOffVelocity.value) - ), - [player, settings.noteOffVelocity.value] + (note: string, octave: number) => { + if (!room || !player || settings.noteOffVelocity.value === undefined) + return; + room.dispatch({ + key: note + octave, + type: "release", + velocity: Number(settings.noteOffVelocity.value), + }); + }, + [room, settings.noteOffVelocity.value] ); const handleSettingsButtonPress = React.useCallback(() => { - navigation.navigate("settings", { - code: code, - users: getAllUsers().map((user) => [user, getUserEmoji(user)]), - }); - }, []); + navigation.navigate("settings", {}); + }, [navigation]); - const debouncer = React.useRef(debounce((value) => setScrolling(value), 500)); + const debouncer = React.useRef( + debounce((value) => setScrolling(value), 500) + ).current; const handleScrollStart = React.useCallback(() => { - debouncer.current.cancel(); + debouncer.cancel(); setScrolling(true); }, [debouncer]); - const handleScrollEnd = React.useCallback(() => debouncer.current(false), []); + const handleScrollEnd = React.useCallback(() => debouncer(false), []); + + const handleRoomConnected = React.useCallback( + (id: UserId, emoji: EmojiId) => { + dispatch( + addUser({ + id: id, + emoji: emoji, + }) + ); + }, + [dispatch] + ); - React.useEffect(() => { - if (!settings.instrument.value) return; + const handleUserConnected = React.useCallback( + (id: UserId, emoji: EmojiId) => { + dispatch( + addUser({ + id: id, + emoji: emoji, + }) + ); + }, + [dispatch] + ); + + const handleUserDisconnected = React.useCallback( + (id: UserId) => dispatch(removeUser(id)), + [dispatch] + ); - let cancelled = false; + const handleRoomEvent = React.useCallback( + (event: RoomEvent) => { + if (!player) return; + switch (event.action.type) { + case "press": + return handleKeyPressedIn( + event.action.key, + event.user, + event.action.velocity + ); + case "release": + return handleKeyPressedOut( + event.action.key, + event.user, + event.action.velocity + ); + } + }, + [player] + ); - const getPlayer = async (instrument: string, notes: string[]) => { + useCancellable( + (cancelInfo) => { + if (!code) return; + const room = new Room( + code, + handleRoomConnected, + handleUserConnected, + handleUserDisconnected, + handleRoomEvent + ); + cancelInfo.cancelled ? room.disconnect() : setRoom(room); + }, + [ + code, + handleRoomConnected, + handleUserConnected, + handleUserDisconnected, + handleRoomEvent, + ] + ); + + useCancellable( + async (cancelInfo) => { + if (!settings.instrument.value) return; player && (await player.destroy()); - const newPlayer = await loadPlayer(instrument, notes); - cancelled ? await newPlayer.destroy() : setPlayer(newPlayer); - }; + const newPlayer = await loadPlayer(settings.instrument.value, allNotes); + cancelInfo.cancelled ? await newPlayer.destroy() : setPlayer(newPlayer); + }, + [settings.instrument.value, allNotes] + ); - getPlayer(settings.instrument.value, allNotes).catch((error) => - console.log(error) - ); + if (!player || !room) return null; - return () => { - cancelled = true; - }; - }, [settings.instrument.value, allNotes]); + const currentUserEmoji = getUserEmoji(room.currentUser); - if (!player) return null; + if (!currentUserEmoji) return null; return ( @@ -178,7 +241,7 @@ export default function Play({ route, navigation }: PlayProps) { ({ - emojiId: getUserEmoji(user), + emojiId: getUserEmoji(user) || fallbackEmoji, }))} onKeyPressedIn={handleUserKeyPressedIn} onKeyPressedOut={handleUserKeyPressedOut} @@ -203,7 +266,7 @@ export default function Play({ route, navigation }: PlayProps) { > state.code.code); + const users = useAppSelector((state) => state.users.users); + const handleCloseButtonPress = useCallback( () => navigation.goBack(), [navigation] @@ -120,6 +122,8 @@ export default function Settings({ route, navigation }: SettingsProps) { [settings.midiInput.setValue] ); + if (!code) return null; + return ( - {users.map(([user, emoji]) => ( + {users.map((user) => ( - + ))} diff --git a/src/state/slices/code.ts b/src/state/slices/code.ts new file mode 100644 index 0000000..163710b --- /dev/null +++ b/src/state/slices/code.ts @@ -0,0 +1,26 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +export interface CodeState { + code?: Code; +} + +const initialState: CodeState = { + code: undefined, +}; + +export const codeSlice = createSlice({ + name: "code", + initialState, + reducers: { + setCode: (state, action: PayloadAction) => { + state.code = action.payload; + }, + resetCode: (state) => { + state.code = []; + }, + }, +}); + +export const { setCode, resetCode } = codeSlice.actions; + +export default codeSlice.reducer; diff --git a/src/state/slices/users.ts b/src/state/slices/users.ts new file mode 100644 index 0000000..a3e8063 --- /dev/null +++ b/src/state/slices/users.ts @@ -0,0 +1,39 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +type UserState = { + id: UserId; + emoji: EmojiId; +}; + +export interface UsersState { + users: UserState[]; +} + +const initialState: UsersState = { + users: [], +}; + +export const usersSlice = createSlice({ + name: "user", + initialState, + reducers: { + setUsers: (state, action: PayloadAction) => { + state.users = action.payload; + }, + addUser: (state, action: PayloadAction) => { + const newUser = action.payload; + const otherUsers = state.users.filter((user) => user.id !== newUser.id); + state.users = [...otherUsers, newUser]; + }, + removeUser: (state, action: PayloadAction) => { + state.users = state.users.filter((user) => user.id !== action.payload); + }, + resetUsers: (state) => { + state.users = []; + }, + }, +}); + +export const { setUsers, addUser, removeUser, resetUsers } = usersSlice.actions; + +export default usersSlice.reducer; diff --git a/src/state/store.ts b/src/state/store.ts new file mode 100644 index 0000000..9707f85 --- /dev/null +++ b/src/state/store.ts @@ -0,0 +1,13 @@ +import { configureStore } from "@reduxjs/toolkit"; +import codeReducer from "./slices/code"; +import usersReducer from "./slices/users"; + +export const store = configureStore({ + reducer: { + code: codeReducer, + users: usersReducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch;