From ff637a00f548324897dae2f3b9d0d0d1ce3ae6b3 Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Tue, 10 Dec 2024 15:03:59 -0500 Subject: [PATCH 01/37] Install zustand --- package-lock.json | 6 ++++++ package.json | 5 +++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index f10af3614..24963e56f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14492,6 +14492,12 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" + }, + "zustand": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.2.tgz", + "integrity": "sha512-8qNdnJVJlHlrKXi50LDqqUNmUbuBjoKLrYQBnoChIbVph7vni+sY+YpvdjXG9YLd/Bxr6scMcR+rm5H3aSqPaw==", + "requires": {} } } } diff --git a/package.json b/package.json index 22b128c71..12bd40a6a 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "normalize-url": "7.2.0", "prop-types": "15.8.1", "react": "18.0.0", + "react-dom": "18.0.0", "react-i18next": "11.18.6", "react-lifecycles-compat": "3.0.4", "react-native": "0.69.9", @@ -75,10 +76,10 @@ "react-native-safe-area-context": "4.3.1", "react-native-screens": "~3.15.0", "react-native-url-polyfill": "1.3.0", + "react-native-web": "~0.18.7", "react-native-webview": "11.23.0", "uuid": "8.3.2", - "react-dom": "18.0.0", - "react-native-web": "~0.18.7" + "zustand": "5.0.2" }, "devDependencies": { "@babel/core": "^7.18.6", From aeca33726992cfaa9d196fabf6279e3de25568bf Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Tue, 10 Dec 2024 15:04:19 -0500 Subject: [PATCH 02/37] Convert root store to typescript & zustand --- stores/RootStore.js | 91 -------------------------- stores/RootStore.ts | 155 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 91 deletions(-) delete mode 100644 stores/RootStore.js create mode 100644 stores/RootStore.ts diff --git a/stores/RootStore.js b/stores/RootStore.js deleted file mode 100644 index 57618a3c2..000000000 --- a/stores/RootStore.js +++ /dev/null @@ -1,91 +0,0 @@ -/** - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -// polyfill crypto.getRandomValues -import 'react-native-get-random-values'; - -import { Jellyfin } from '@jellyfin/sdk'; -import Constants from 'expo-constants'; -import { action, computed, decorate, observable } from 'mobx'; -import { ignore } from 'mobx-sync-lite'; -import { v4 as uuidv4 } from 'uuid'; - -import { getAppName, getSafeDeviceName } from '../utils/Device'; - -import DownloadStore from './DownloadStore'; -import MediaStore from './MediaStore'; -import ServerStore from './ServerStore'; -import SettingStore from './SettingStore'; - -export default class RootStore { - /** - * Generate a random unique device id - */ - deviceId = uuidv4() - - /** - * Has the store been loaded from storage - */ - storeLoaded = false - - /** - * Is the fullscreen interface active - */ - isFullscreen = false - - /** - * Does the webview require a reload - */ - isReloadRequired = false - - /** - * Was the native player closed manually - */ - didPlayerCloseManually = true - - downloadStore = new DownloadStore() - mediaStore = new MediaStore() - serverStore = new ServerStore() - settingStore = new SettingStore() - - get sdk() { - return new Jellyfin({ - clientInfo: { - name: getAppName(), - version: Constants.nativeAppVersion - }, - deviceInfo: { - name: getSafeDeviceName(), - id: this.deviceId - } - }); - } - - reset() { - this.deviceId = uuidv4(); - - this.isFullscreen = false; - this.isReloadRequired = false; - this.didPlayerCloseManually = true; - - this.downloadStore.reset(); - this.mediaStore.reset(); - this.serverStore.reset(); - this.settingStore.reset(); - - this.storeLoaded = true; - } -} - -decorate(RootStore, { - deviceId: observable, - storeLoaded: [ ignore, observable ], - isFullscreen: [ ignore, observable ], - isReloadRequired: [ ignore, observable ], - didPlayerCloseManually: [ ignore, observable ], - sdk: computed, - reset: action -}); diff --git a/stores/RootStore.ts b/stores/RootStore.ts new file mode 100644 index 000000000..0abbb9c89 --- /dev/null +++ b/stores/RootStore.ts @@ -0,0 +1,155 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +// polyfill crypto.getRandomValues +import 'react-native-get-random-values'; + +import { Jellyfin } from '@jellyfin/sdk'; +import Constants from 'expo-constants'; +import { action, computed, decorate, observable } from 'mobx'; +import { ignore } from 'mobx-sync-lite'; +import { v4 as uuidv4 } from 'uuid'; + +import { getAppName, getSafeDeviceName } from '../utils/Device'; + +import DownloadStore from './DownloadStore'; +import MediaStore from './MediaStore'; +import ServerStore from './ServerStore'; +import SettingStore from './SettingStore'; +import { create } from 'zustand'; + +type State = { + deviceId: string, + storeLoaded: boolean, + isFullscreen: boolean, + isReloadRequired: boolean, + didPlayerCloseManually: boolean, + downloadStore: DownloadStore, + mediaStore: MediaStore, + serverStore: ServerStore, + settingStore: SettingStore, +} + +type Actions = { + getApi: () => Jellyfin, + reset: () => void, + // setFullscreen: (v: State['isFullscreen']) => void, + // setReloadRequired: (v: State['isReloadRequired']) => void, + // setDidPlayerCloseManually: (v: State['didPlayerCloseManually']) => void +} + +const initialState: State = { + deviceId: uuidv4(), + storeLoaded: false, + isFullscreen: false, + isReloadRequired: false, + didPlayerCloseManually: true, + downloadStore: new DownloadStore(), + mediaStore: new MediaStore(), + serverStore: new ServerStore(), + settingStore: new SettingStore() +} + +export const useRootStore = create()((set, get) => ({ + ...initialState, + getApi: () => new Jellyfin({ + clientInfo: { + name: getAppName(), + version: Constants.nativeAppVersion + }, + deviceInfo: { + name: getSafeDeviceName(), + id: get().deviceId + } + }), + reset: () => { + get().downloadStore.reset() + get().mediaStore.reset() + get().serverStore.reset() + get().settingStore.reset() + + set({ + deviceId: uuidv4(), + isFullscreen: false, + isReloadRequired: false, + didPlayerCloseManually: true, + storeLoaded: true, + }) + }, + // setFullscreen: (isFullscreen) => set({ isFullscreen }), + // setReloadRequired: (isReloadRequired) => set({isReloadRequired}), + // setDidPlayerCloseManually: (didPlayerCloseManually) => set({ didPlayerCloseManually }) +})) + +// export default class RootStore { +// /** +// * Generate a random unique device id +// */ +// deviceId = uuidv4() + +// /** +// * Has the store been loaded from storage +// */ +// storeLoaded = false + +// /** +// * Is the fullscreen interface active +// */ +// isFullscreen = false + +// /** +// * Does the webview require a reload +// */ +// isReloadRequired = false + +// /** +// * Was the native player closed manually +// */ +// didPlayerCloseManually = true + +// downloadStore = new DownloadStore() +// mediaStore = new MediaStore() +// serverStore = new ServerStore() +// settingStore = new SettingStore() + +// get sdk() { +// return new Jellyfin({ +// clientInfo: { +// name: getAppName(), +// version: Constants.nativeAppVersion +// }, +// deviceInfo: { +// name: getSafeDeviceName(), +// id: this.deviceId +// } +// }); +// } + +// reset() { +// this.deviceId = uuidv4(); + +// this.isFullscreen = false; +// this.isReloadRequired = false; +// this.didPlayerCloseManually = true; + +// this.downloadStore.reset(); +// this.mediaStore.reset(); +// this.serverStore.reset(); +// this.settingStore.reset(); + +// this.storeLoaded = true; +// } +// } + +// decorate(RootStore, { +// deviceId: observable, +// storeLoaded: [ ignore, observable ], +// isFullscreen: [ ignore, observable ], +// isReloadRequired: [ ignore, observable ], +// didPlayerCloseManually: [ ignore, observable ], +// sdk: computed, +// reset: action +// }); From f9e09bdc9d441d01209f06e8c19d4cdcf89d21ad Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Tue, 10 Dec 2024 15:04:23 -0500 Subject: [PATCH 03/37] Reference new zustand root store --- hooks/useStores.js | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/hooks/useStores.js b/hooks/useStores.js index be2e48d4c..3a4f0f11a 100644 --- a/hooks/useStores.js +++ b/hooks/useStores.js @@ -3,12 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { createContext, useContext } from 'react'; +import { useRootStore } from '../stores/RootStore'; -import RootStore from '../stores/RootStore'; - -export const storesContext = createContext({ - rootStore: new RootStore() -}); - -export const useStores = () => useContext(storesContext); +// Compatibility for zustand conversion +export const useStores = () => { return { rootStore: useRootStore() } } From b362a193e210409878f2dffbf1802bfeb9d8f25e Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Tue, 10 Dec 2024 15:04:25 -0500 Subject: [PATCH 04/37] Fix test to work with hooks --- stores/__tests__/RootStore.test.js | 63 ++++++++++++++++++------------ 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/stores/__tests__/RootStore.test.js b/stores/__tests__/RootStore.test.js index af8f8a4d7..e3dd4e042 100644 --- a/stores/__tests__/RootStore.test.js +++ b/stores/__tests__/RootStore.test.js @@ -1,3 +1,7 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"url": "https://jestjs.io/"} + */ /** * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -7,21 +11,23 @@ import { Jellyfin } from '@jellyfin/sdk'; import DownloadStore from '../DownloadStore'; import MediaStore from '../MediaStore'; -import RootStore from '../RootStore'; +import RootStore, { useRootStore } from '../RootStore'; import ServerStore from '../ServerStore'; import SettingStore from '../SettingStore'; +import { renderHook, act } from '@testing-library/react' + describe('RootStore', () => { it('should initialize with default values', () => { - const store = new RootStore(); + const store = renderHook(() => useRootStore((state) => state)).result.current; expect(store.storeLoaded).toBe(false); expect(store.isFullscreen).toBe(false); expect(store.isReloadRequired).toBe(false); expect(store.didPlayerCloseManually).toBe(true); - expect(store.sdk).toBeInstanceOf(Jellyfin); - expect(store.sdk.deviceInfo.id).toBe(store.deviceId); + expect(store.getApi()).toBeInstanceOf(Jellyfin); + expect(store.getApi().deviceInfo.id).toBe(store.deviceId); expect(store.downloadStore).toBeInstanceOf(DownloadStore); expect(store.mediaStore).toBeInstanceOf(MediaStore); @@ -30,34 +36,41 @@ describe('RootStore', () => { }); it('should reset to the default values', () => { - const store = new RootStore(); - store.isFullscreen = true; - store.isReloadRequired = true; - store.didPlayerCloseManually = false; + const storeHook = renderHook(() => useRootStore((state) => state)) + + store = storeHook.result.current + + act(() => { + storeHook.result.current.isFullscreen = true; + storeHook.result.current.isReloadRequired = true; + storeHook.result.current.didPlayerCloseManually = false; + }) store.downloadStore.reset = jest.fn(); store.mediaStore.reset = jest.fn(); store.serverStore.reset = jest.fn(); store.settingStore.reset = jest.fn(); - expect(store.storeLoaded).toBe(false); - expect(store.isFullscreen).toBe(true); - expect(store.isReloadRequired).toBe(true); - expect(store.didPlayerCloseManually).toBe(false); - expect(store.downloadStore.reset).not.toHaveBeenCalled(); - expect(store.mediaStore.reset).not.toHaveBeenCalled(); - expect(store.serverStore.reset).not.toHaveBeenCalled(); - expect(store.settingStore.reset).not.toHaveBeenCalled(); + expect(storeHook.result.current.storeLoaded).toBe(false); + expect(storeHook.result.current.isFullscreen).toBe(true); + expect(storeHook.result.current.isReloadRequired).toBe(true); + expect(storeHook.result.current.didPlayerCloseManually).toBe(false); + expect(storeHook.result.current.downloadStore.reset).not.toHaveBeenCalled(); + expect(storeHook.result.current.mediaStore.reset).not.toHaveBeenCalled(); + expect(storeHook.result.current.serverStore.reset).not.toHaveBeenCalled(); + expect(storeHook.result.current.settingStore.reset).not.toHaveBeenCalled(); - store.reset(); + act(() => { + store.reset(); + }) - expect(store.storeLoaded).toBe(true); - expect(store.isFullscreen).toBe(false); - expect(store.isReloadRequired).toBe(false); - expect(store.didPlayerCloseManually).toBe(true); - expect(store.downloadStore.reset).toHaveBeenCalled(); - expect(store.mediaStore.reset).toHaveBeenCalled(); - expect(store.serverStore.reset).toHaveBeenCalled(); - expect(store.settingStore.reset).toHaveBeenCalled(); + expect(storeHook.result.current.storeLoaded).toBe(true); + expect(storeHook.result.current.isFullscreen).toBe(false); + expect(storeHook.result.current.isReloadRequired).toBe(false); + expect(storeHook.result.current.didPlayerCloseManually).toBe(true); + expect(storeHook.result.current.downloadStore.reset).toHaveBeenCalled(); + expect(storeHook.result.current.mediaStore.reset).toHaveBeenCalled(); + expect(storeHook.result.current.serverStore.reset).toHaveBeenCalled(); + expect(storeHook.result.current.settingStore.reset).toHaveBeenCalled(); }); }); From 90850f2b2c44888987908ef3456a7905df0f5f54 Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Tue, 10 Dec 2024 15:04:26 -0500 Subject: [PATCH 05/37] Add react testing library & jest-environment-jsdom --- package-lock.json | 205 +++++++++++++++++++++++++++++----------------- package.json | 2 + 2 files changed, 132 insertions(+), 75 deletions(-) diff --git a/package-lock.json b/package-lock.json index 24963e56f..0e7e46c1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -325,9 +325,9 @@ "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==" }, "@babel/helper-validator-identifier": { - "version": "7.19.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", - "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==" + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==" }, "@babel/helper-validator-option": { "version": "7.21.0", @@ -2472,12 +2472,12 @@ } }, "@jest/schemas": { - "version": "29.4.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.4.3.tgz", - "integrity": "sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "dev": true, "requires": { - "@sinclair/typebox": "^0.25.16" + "@sinclair/typebox": "^0.27.8" } }, "@jest/source-map": { @@ -3360,9 +3360,9 @@ "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" }, "@sinclair/typebox": { - "version": "0.25.24", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", - "integrity": "sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==", + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, "@sinonjs/commons": { @@ -3383,6 +3383,51 @@ "@sinonjs/commons": "^1.7.0" } }, + "@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "peer": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "peer": true + }, + "pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "peer": true, + "requires": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + } + }, + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "peer": true + } + } + }, "@testing-library/jest-native": { "version": "4.0.13", "resolved": "https://registry.npmjs.org/@testing-library/jest-native/-/jest-native-4.0.13.tgz", @@ -3421,6 +3466,15 @@ } } }, + "@testing-library/react": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.1.0.tgz", + "integrity": "sha512-Q2ToPvg0KsVL0ohND9A3zLJWcOXXcO8IDu3fj11KhNt0UlCWyFyvnCIBkd12tidB2lkiVRG8VFqdhcqhqnAQtg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5" + } + }, "@testing-library/react-native": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-7.2.0.tgz", @@ -3436,6 +3490,13 @@ "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", "dev": true }, + "@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "peer": true + }, "@types/babel__core": { "version": "7.20.0", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.0.tgz", @@ -4185,6 +4246,16 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, + "aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "peer": true, + "requires": { + "dequal": "^2.0.3" + } + }, "arr-diff": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", @@ -5574,6 +5645,13 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==" }, + "dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "peer": true + }, "destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -5608,6 +5686,13 @@ "esutils": "^2.0.2" } }, + "dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "peer": true + }, "domexception": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -5803,15 +5888,14 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" }, "escodegen": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", - "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", "dev": true, "requires": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2", - "optionator": "^0.8.1", "source-map": "~0.6.1" }, "dependencies": { @@ -5821,51 +5905,12 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true }, - "levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", - "dev": true, - "requires": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - } - }, - "optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "dev": true, - "requires": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - } - }, - "prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", - "dev": true - }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "optional": true - }, - "type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", - "dev": true, - "requires": { - "prelude-ls": "~1.1.2" - } } } }, @@ -9805,9 +9850,9 @@ }, "dependencies": { "acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true }, "domexception": { @@ -9828,9 +9873,9 @@ } }, "form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.2.tgz", + "integrity": "sha512-sJe+TQb2vIaIyO783qN6BlMYWMw3WBOHA1Ay2qxsnjuafEOQFJ2JakedOQirT6D5XPRxDvS7AHYyem9fTpb4LQ==", "dev": true, "requires": { "asynckit": "^0.4.0", @@ -9865,9 +9910,9 @@ } }, "ws": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", - "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "dev": true, "requires": {} } @@ -10097,6 +10142,13 @@ "yallist": "^4.0.0" } }, + "lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "peer": true + }, "make-dir": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", @@ -11072,9 +11124,9 @@ "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==" }, "nwsapi": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.2.tgz", - "integrity": "sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw==", + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.16.tgz", + "integrity": "sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==", "dev": true }, "ob1": { @@ -11672,10 +11724,13 @@ } }, "psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", - "dev": true + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "requires": { + "punycode": "^2.3.1" + } }, "pump": { "version": "3.0.0", @@ -11687,9 +11742,9 @@ } }, "punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==" + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" }, "qrcode-terminal": { "version": "0.11.0", @@ -13772,9 +13827,9 @@ "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" }, "tough-cookie": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.2.tgz", - "integrity": "sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", "dev": true, "requires": { "psl": "^1.1.33", diff --git a/package.json b/package.json index 12bd40a6a..3f3281bd1 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "@babel/core": "^7.18.6", "@babel/eslint-parser": "7.25.9", "@testing-library/jest-native": "4.0.13", + "@testing-library/react": "16.1.0", "@testing-library/react-native": "7.2.0", "@types/jest": "27.5.2", "@types/react": "~18.0.0", @@ -102,6 +103,7 @@ "eslint-plugin-react": "7.32.2", "eslint-plugin-react-native": "3.11.0", "jest": "^26.6.3", + "jest-environment-jsdom": "26.6.2", "jest-expo": "^46.0.0", "jest-fetch-mock": "3.0.3", "node-abort-controller": "3.1.1", From 6c1891f2f72bc988f3cbe447df7e0fc3c45b2ed7 Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Tue, 10 Dec 2024 15:04:27 -0500 Subject: [PATCH 06/37] Make root store test more concise --- stores/__tests__/RootStore.test.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/stores/__tests__/RootStore.test.js b/stores/__tests__/RootStore.test.js index e3dd4e042..60411aaf5 100644 --- a/stores/__tests__/RootStore.test.js +++ b/stores/__tests__/RootStore.test.js @@ -38,18 +38,16 @@ describe('RootStore', () => { it('should reset to the default values', () => { const storeHook = renderHook(() => useRootStore((state) => state)) - store = storeHook.result.current - act(() => { storeHook.result.current.isFullscreen = true; storeHook.result.current.isReloadRequired = true; storeHook.result.current.didPlayerCloseManually = false; }) - store.downloadStore.reset = jest.fn(); - store.mediaStore.reset = jest.fn(); - store.serverStore.reset = jest.fn(); - store.settingStore.reset = jest.fn(); + storeHook.result.current.downloadStore.reset = jest.fn(); + storeHook.result.current.mediaStore.reset = jest.fn(); + storeHook.result.current.serverStore.reset = jest.fn(); + storeHook.result.current.settingStore.reset = jest.fn(); expect(storeHook.result.current.storeLoaded).toBe(false); expect(storeHook.result.current.isFullscreen).toBe(true); @@ -61,7 +59,7 @@ describe('RootStore', () => { expect(storeHook.result.current.settingStore.reset).not.toHaveBeenCalled(); act(() => { - store.reset(); + storeHook.result.current.reset(); }) expect(storeHook.result.current.storeLoaded).toBe(true); From 5da37f1a977fe8b37e92bdb51d70c3fc47806eed Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Tue, 10 Dec 2024 15:04:34 -0500 Subject: [PATCH 07/37] Port DownloadStore/DownloadModel to zustand --- App.js | 6 +- __mocks__/zustand.ts | 65 ++++++++ components/NativeShellWebView.js | 4 +- hooks/useStores.js | 6 +- jest.setup.js | 2 + models/DownloadModel.ts | 38 ++--- navigation/TabNavigator.js | 4 +- package-lock.json | 146 +++++++++++++++++- package.json | 5 +- screens/DownloadScreen.js | 25 +-- screens/__tests__/DownloadScreen.test.js | 62 ++++---- .../__snapshots__/DownloadScreen.test.js.snap | 38 ++++- stores/DownloadStore.ts | 86 +++++++---- stores/RootStore.ts | 6 +- stores/__tests__/DownloadStore.test.js | 81 ++++++---- stores/__tests__/RootStore.test.js | 11 +- 16 files changed, 436 insertions(+), 149 deletions(-) create mode 100644 __mocks__/zustand.ts diff --git a/App.js b/App.js index 9c08aa289..854bbbbf9 100644 --- a/App.js +++ b/App.js @@ -36,7 +36,7 @@ import './i18n'; const App = observer(({ skipLoadingScreen }) => { const [ isSplashReady, setIsSplashReady ] = useState(false); - const { rootStore } = useStores(); + const { rootStore, downloadStore } = useStores(); const { theme } = useContext(ThemeContext); rootStore.settingStore.systemThemeId = useColorScheme(); @@ -163,13 +163,13 @@ const App = observer(({ skipLoadingScreen }) => { }); }; - rootStore.downloadStore.downloads + downloadStore.downloads .forEach(download => { if (!download.isComplete && !download.isDownloading) { downloadFile(download); } }); - }, [ rootStore.deviceId, rootStore.downloadStore.downloads.size ]); + }, [ rootStore.deviceId, downloadStore.downloads.size ]); if (!(isSplashReady && rootStore.storeLoaded) && !skipLoadingScreen) { return null; diff --git a/__mocks__/zustand.ts b/__mocks__/zustand.ts new file mode 100644 index 000000000..5d0d1864b --- /dev/null +++ b/__mocks__/zustand.ts @@ -0,0 +1,65 @@ +// __mocks__/zustand.ts +import { act } from '@testing-library/react' +import type * as ZustandExportedTypes from 'zustand' +export * from 'zustand' + +const { create: actualCreate, createStore: actualCreateStore } = + jest.requireActual('zustand') + +// a variable to hold reset functions for all stores declared in the app +export const storeResetFns = new Set<() => void>() + +const createUncurried = ( + stateCreator: ZustandExportedTypes.StateCreator, +) => { + const store = actualCreate(stateCreator) + const initialState = store.getInitialState() + storeResetFns.add(() => { + store.setState(initialState, true) + }) + return store +} + +// when creating a store, we get its initial state, create a reset function and add it in the set +export const create = (( + stateCreator: ZustandExportedTypes.StateCreator, +) => { + console.log('zustand create mock') + + // to support curried version of create + return typeof stateCreator === 'function' + ? createUncurried(stateCreator) + : createUncurried +}) as typeof ZustandExportedTypes.create + +const createStoreUncurried = ( + stateCreator: ZustandExportedTypes.StateCreator, +) => { + const store = actualCreateStore(stateCreator) + const initialState = store.getInitialState() + storeResetFns.add(() => { + store.setState(initialState, true) + }) + return store +} + +// when creating a store, we get its initial state, create a reset function and add it in the set +export const createStore = (( + stateCreator: ZustandExportedTypes.StateCreator, +) => { + console.log('zustand createStore mock') + + // to support curried version of createStore + return typeof stateCreator === 'function' + ? createStoreUncurried(stateCreator) + : createStoreUncurried +}) as typeof ZustandExportedTypes.createStore + +// reset all stores after each test run +afterEach(() => { + act(() => { + storeResetFns.forEach((resetFn) => { + resetFn() + }) + }) +}) diff --git a/components/NativeShellWebView.js b/components/NativeShellWebView.js index ac5317d05..c5e738c0d 100644 --- a/components/NativeShellWebView.js +++ b/components/NativeShellWebView.js @@ -23,7 +23,7 @@ import RefreshWebView from './RefreshWebView'; const NativeShellWebView = observer( function NativeShellWebView(props, ref) { - const { rootStore } = useStores(); + const { rootStore, downloadStore } = useStores(); const [ isRefreshing, setIsRefreshing ] = useState(false); const server = rootStore.serverStore.servers[rootStore.settingStore.activeServer]; @@ -96,7 +96,7 @@ true; const url = new URL(data.item.url); const apiKey = url.searchParams.get('api_key'); /* eslint-enable no-case-declarations */ - rootStore.downloadStore.add(new DownloadModel( + downloadStore.add(new DownloadModel( data.item.itemId, data.item.serverId, server.urlString, diff --git a/hooks/useStores.js b/hooks/useStores.js index 3a4f0f11a..84539bc24 100644 --- a/hooks/useStores.js +++ b/hooks/useStores.js @@ -3,7 +3,11 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { useDownloadStore } from '../stores/DownloadStore'; import { useRootStore } from '../stores/RootStore'; // Compatibility for zustand conversion -export const useStores = () => { return { rootStore: useRootStore() } } +export const useStores = () => ({ + rootStore: useRootStore(), + downloadStore: useDownloadStore() +}) diff --git a/jest.setup.js b/jest.setup.js index 279d72f2f..dcbc4e7ed 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -4,6 +4,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// import '@testing-library/jest-dom' + /* AsyncStorage Mock */ import mockAsyncStorage from '@react-native-async-storage/async-storage/jest/async-storage-mock'; jest.mock('@react-native-async-storage/async-storage', () => mockAsyncStorage); diff --git a/models/DownloadModel.ts b/models/DownloadModel.ts index 94fcb4b6e..0ea924f7b 100644 --- a/models/DownloadModel.ts +++ b/models/DownloadModel.ts @@ -5,8 +5,8 @@ */ import * as FileSystem from 'expo-file-system'; -import { computed, decorate, observable } from 'mobx'; -import { ignore } from 'mobx-sync-lite'; +// import { computed, decorate, observable } from 'mobx'; +// import { ignore } from 'mobx-sync-lite'; import { v4 as uuidv4 } from 'uuid'; export default class DownloadModel { @@ -77,20 +77,20 @@ export default class DownloadModel { } } -decorate(DownloadModel, { - isComplete: observable, - isDownloading: [ ignore, observable ], - isNew: observable, - apiKey: observable, - itemId: observable, - sessionId: observable, - serverId: observable, - serverUrl: observable, - title: observable, - filename: observable, - downloadUrl: observable, - key: computed, - localFilename: computed, - localPath: computed, - uri: computed -}); +// decorate(DownloadModel, { +// isComplete: observable, +// isDownloading: [ ignore, observable ], +// isNew: observable, +// apiKey: observable, +// itemId: observable, +// sessionId: observable, +// serverId: observable, +// serverUrl: observable, +// title: observable, +// filename: observable, +// downloadUrl: observable, +// key: computed, +// localFilename: computed, +// localPath: computed, +// uri: computed +// }); diff --git a/navigation/TabNavigator.js b/navigation/TabNavigator.js index 834e2f1d0..89225e169 100644 --- a/navigation/TabNavigator.js +++ b/navigation/TabNavigator.js @@ -44,7 +44,7 @@ function TabIcon(routeName, focused, color, size) { const Tab = createBottomTabNavigator(); const TabNavigator = observer(() => { - const { rootStore } = useStores(); + const { rootStore, downloadStore } = useStores(); const insets = useSafeAreaInsets(); const { t } = useTranslation(); const { theme } = useContext(ThemeContext); @@ -83,7 +83,7 @@ const TabNavigator = observer(() => { options={{ title: t('headings.downloads'), headerShown: true, - tabBarBadge: rootStore.downloadStore.newDownloadCount > 0 ? rootStore.downloadStore.newDownloadCount : null + tabBarBadge: downloadStore.newDownloadCount > 0 ? downloadStore.newDownloadCount : null }} /> )} diff --git a/package-lock.json b/package-lock.json index 0e7e46c1e..868c65849 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,12 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@adobe/css-tools": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.1.tgz", + "integrity": "sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ==", + "dev": true + }, "@ampproject/remapping": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", @@ -3428,6 +3434,63 @@ } } }, + "@testing-library/jest-dom": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", + "dev": true, + "requires": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "@testing-library/jest-native": { "version": "4.0.13", "resolved": "https://registry.npmjs.org/@testing-library/jest-native/-/jest-native-4.0.13.tgz", @@ -4251,7 +4314,6 @@ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, - "peer": true, "requires": { "dequal": "^2.0.3" } @@ -4774,6 +4836,15 @@ "update-browserslist-db": "^1.0.10" } }, + "bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "requires": { + "fast-json-stable-stringify": "2.x" + } + }, "bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -5399,6 +5470,12 @@ "hyphenate-style-name": "^1.0.3" } }, + "css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, "cssom": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", @@ -5649,8 +5726,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "peer": true + "dev": true }, "destroy": { "version": "1.2.0", @@ -10165,6 +10241,12 @@ } } }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, "makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -13861,6 +13943,64 @@ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" }, + "ts-jest": { + "version": "26.5.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-26.5.6.tgz", + "integrity": "sha512-rua+rCP8DxpA8b4DQD/6X2HQS8Zy/xzViVYfEs2OQu68tkCuKLV0Md8pmX55+W24uRIyAsf/BajRfxOs+R2MKA==", + "dev": true, + "requires": { + "bs-logger": "0.x", + "buffer-from": "1.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^26.1.0", + "json5": "2.x", + "lodash": "4.x", + "make-error": "1.x", + "mkdirp": "1.x", + "semver": "7.x", + "yargs-parser": "20.x" + }, + "dependencies": { + "jest-util": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-26.6.2.tgz", + "integrity": "sha512-MDW0fKfsn0OI7MS7Euz6h8HNDXVQ0gaM9uW6RjfDmd1DAFcaxX9OqIakHIqhbnmF08Cf2DLDG+ulq8YQQ0Lp0Q==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "@types/node": "*", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.4", + "is-ci": "^2.0.0", + "micromatch": "^4.0.2" + } + }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, + "semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true + }, + "yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true + } + } + }, "tsconfig-paths": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", diff --git a/package.json b/package.json index 3f3281bd1..406552dd5 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "!**/babel.config.js", "!**/metro.config.js", "!**/jest.setup.js" - ] + ], + "testEnvironment": "jsdom" }, "browserslist": [ "iOS > 11", @@ -84,6 +85,7 @@ "devDependencies": { "@babel/core": "^7.18.6", "@babel/eslint-parser": "7.25.9", + "@testing-library/jest-dom": "6.6.3", "@testing-library/jest-native": "4.0.13", "@testing-library/react": "16.1.0", "@testing-library/react-native": "7.2.0", @@ -107,6 +109,7 @@ "jest-expo": "^46.0.0", "jest-fetch-mock": "3.0.3", "node-abort-controller": "3.1.1", + "ts-jest": "26.5.6", "typescript": "^4.6.3" }, "private": true, diff --git a/screens/DownloadScreen.js b/screens/DownloadScreen.js index 1e618069e..266887c77 100644 --- a/screens/DownloadScreen.js +++ b/screens/DownloadScreen.js @@ -6,8 +6,6 @@ import { useFocusEffect, useNavigation } from '@react-navigation/native'; import * as FileSystem from 'expo-file-system'; -import { toJS, values } from 'mobx'; -import { observer } from 'mobx-react-lite'; import React, { useCallback, useContext, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, FlatList, StyleSheet } from 'react-native'; @@ -18,9 +16,9 @@ import DownloadListItem from '../components/DownloadListItem'; import MediaTypes from '../constants/MediaTypes'; import { useStores } from '../hooks/useStores'; -const DownloadScreen = observer(() => { +const DownloadScreen = () => { const navigation = useNavigation(); - const { rootStore } = useStores(); + const { rootStore, downloadStore } = useStores(); const { t } = useTranslation(); const { theme } = useContext(ThemeContext); const [ isEditMode, setIsEditMode ] = useState(false); @@ -36,7 +34,7 @@ const DownloadScreen = observer(() => { // TODO: Add user messaging on errors try { await FileSystem.deleteAsync(download.localPath); - rootStore.downloadStore.downloads.delete(download.key); + downloadStore.downloads.delete(download.key); console.log('[DownloadScreen] download "%s" deleted', download.title); } catch (e) { console.error('[DownloadScreen] Failed to delete download', e); @@ -90,26 +88,29 @@ const DownloadScreen = observer(() => { title={t('common.edit')} type='clear' style={styles.rightButton} - disabled={rootStore.downloadStore.downloads.size < 1} + disabled={downloadStore.downloads.size < 1} onPress={() => { setIsEditMode(true); }} /> ) }); - }, [ navigation, isEditMode, selectedItems, rootStore.downloadStore.downloads ]); + }, [ navigation, isEditMode, selectedItems, downloadStore.downloads ]); useFocusEffect( useCallback(() => { - rootStore.downloadStore.downloads + downloadStore.downloads .forEach(download => { if (download.isNew) { download.isNew = !download.isComplete; } }); - }, [ rootStore.downloadStore.downloads ]) + }, [ downloadStore.downloads ]) ); + const downloadList = [] + downloadStore.downloads.forEach(download => downloadList.push(download)) + return ( { edges={[ 'right', 'left' ]} > ( { /> ); -}); +}; const styles = StyleSheet.create({ container: { diff --git a/screens/__tests__/DownloadScreen.test.js b/screens/__tests__/DownloadScreen.test.js index ae6c44693..8294a1718 100644 --- a/screens/__tests__/DownloadScreen.test.js +++ b/screens/__tests__/DownloadScreen.test.js @@ -2,16 +2,20 @@ * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * @jest-environment jsdom + * @jest-environment-options {"url": "https://jestjs.io/"} */ import { NavigationContainer } from '@react-navigation/native'; -import { render } from '@testing-library/react-native'; +import { act, render } from '@testing-library/react-native'; import React from 'react'; import { useStores } from '../../hooks/useStores'; import DownloadModel from '../../models/DownloadModel'; -import DownloadStore from '../../stores/DownloadStore'; +import DownloadStore, { useDownloadStore } from '../../stores/DownloadStore'; import DownloadScreen from '../DownloadScreen'; +import { renderHook } from '@testing-library/react'; const mockSetOptions = jest.fn(); jest.mock('@react-navigation/native', () => { @@ -24,42 +28,46 @@ jest.mock('@react-navigation/native', () => { }; }); -const mockDownloadStore = new DownloadStore(); +DOWNLOAD_1 = new DownloadModel( + 'item-id', + 'server-id', + 'https://example.com/', + 'api-key', + 'title', + 'file name.mkv', + 'https://example.com/download' +) + +DOWNLOAD_2 = new DownloadModel( + 'item-id-2', + 'server-id', + 'https://test2.example.com/', + 'api-key', + 'other title', + 'other file name.mkv', + 'https://test2.example.com/download' +) + +const mockDownloadStore = { + downloads: new Map([[DOWNLOAD_1.key, DOWNLOAD_1], [DOWNLOAD_2.key, DOWNLOAD_2]]), + add: (v) => act(() => {mockDownloadStore.downloads = new Map([...mockDownloadStore.downloads, [v.key, v]])}) +} + jest.mock('../../hooks/useStores'); useStores.mockImplementation(() => ({ - rootStore: { - downloadStore: mockDownloadStore - } + rootStore: {}, + downloadStore: mockDownloadStore })); describe('DownloadScreen', () => { it('should render correctly', () => { - mockDownloadStore.add(new DownloadModel( - 'item-id', - 'server-id', - 'https://example.com/', - 'api-key', - 'title', - 'file name.mkv', - 'https://example.com/download' - )); - mockDownloadStore.add(new DownloadModel( - 'item-id-2', - 'server-id', - 'https://test2.example.com/', - 'api-key', - 'other title', - 'other file name.mkv', - 'https://test2.example.com/download' - )); - - const { toJSON } = render( + const v = render( ); - expect(toJSON()).toMatchSnapshot(); + expect(v.toJSON()).toMatchSnapshot(); expect(mockSetOptions).toHaveBeenCalled(); }); }); diff --git a/screens/__tests__/__snapshots__/DownloadScreen.test.js.snap b/screens/__tests__/__snapshots__/DownloadScreen.test.js.snap index 8651e1511..b9f64be6e 100644 --- a/screens/__tests__/__snapshots__/DownloadScreen.test.js.snap +++ b/screens/__tests__/__snapshots__/DownloadScreen.test.js.snap @@ -23,12 +23,12 @@ exports[`DownloadScreen should render correctly 1`] = ` } data={ Array [ - Object { - "__mobx_sync_versions__": undefined, + DownloadModel { "apiKey": "api-key", "downloadUrl": "https://example.com/download", "filename": "file name.mkv", "isComplete": false, + "isDownloading": false, "isNew": true, "itemId": "item-id", "serverId": "server-id", @@ -36,12 +36,12 @@ exports[`DownloadScreen should render correctly 1`] = ` "sessionId": "uuid-0", "title": "title", }, - Object { - "__mobx_sync_versions__": undefined, + DownloadModel { "apiKey": "api-key", "downloadUrl": "https://test2.example.com/download", "filename": "other file name.mkv", "isComplete": false, + "isDownloading": false, "isNew": true, "itemId": "item-id-2", "serverId": "server-id", @@ -52,9 +52,33 @@ exports[`DownloadScreen should render correctly 1`] = ` ] } extraData={ - Object { - "server-id_item-id": [Circular], - "server-id_item-id-2": [Circular], + Map { + "server-id_item-id" => DownloadModel { + "apiKey": "api-key", + "downloadUrl": "https://example.com/download", + "filename": "file name.mkv", + "isComplete": false, + "isDownloading": false, + "isNew": true, + "itemId": "item-id", + "serverId": "server-id", + "serverUrl": "https://example.com/", + "sessionId": "uuid-0", + "title": "title", + }, + "server-id_item-id-2" => DownloadModel { + "apiKey": "api-key", + "downloadUrl": "https://test2.example.com/download", + "filename": "other file name.mkv", + "isComplete": false, + "isDownloading": false, + "isNew": true, + "itemId": "item-id-2", + "serverId": "server-id", + "serverUrl": "https://test2.example.com/", + "sessionId": "uuid-1", + "title": "other title", + }, } } getItem={[Function]} diff --git a/stores/DownloadStore.ts b/stores/DownloadStore.ts index 47620cde0..bd42debbb 100644 --- a/stores/DownloadStore.ts +++ b/stores/DownloadStore.ts @@ -4,10 +4,11 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { action, computed, decorate, observable } from 'mobx'; -import { format } from 'mobx-sync-lite'; +// import { action, computed, decorate, observable } from 'mobx'; +// import { format } from 'mobx-sync-lite'; import DownloadModel from '../models/DownloadModel'; +import { create } from 'zustand'; export const DESERIALIZER = (data: unknown) => { const deserialized = new Map(); @@ -29,33 +30,64 @@ export const DESERIALIZER = (data: unknown) => { return deserialized; }; -export default class DownloadStore { - downloads = new Map(); +type State = { + downloads: Map, +} - get newDownloadCount() { - return Array.from(this.downloads.values()) - .filter(d => d.isNew) - .length; - } +type Actions = { + getNewDownloadCount: () => number, + add: (v: DownloadModel) => void, + reset: () => void +} - add(download: DownloadModel) { - // Do not allow duplicate downloads - if (!this.downloads.has(download.key)) { - this.downloads.set(download.key, download); - } - } +export type DownloadStore = State & Actions - reset() { - this.downloads = new Map(); - } +const initialState: State = { + downloads: new Map() } -decorate(DownloadStore, { - downloads: [ - format(DESERIALIZER), - observable - ], - newDownloadCount: computed, - add: action, - reset: action -}); +export const useDownloadStore = create()((set, get) => ({ + ...initialState, + getNewDownloadCount: () => Array + .from(get().downloads.values()) + .filter(d => d.isNew) + .length, + add: (download) => { + const downloads = get().downloads + if (!downloads.has(download.key)) { + set({downloads: new Map([...downloads, [download.key, download]])}) + } + }, + reset: () => set({downloads: new Map()}) +})) + +// export default class DownloadStore { +// downloads = new Map(); + +// get newDownloadCount() { +// return Array.from(this.downloads.values()) +// .filter(d => d.isNew) +// .length; +// } + +// add(download: DownloadModel) { +// // Do not allow duplicate downloads +// if (!this.downloads.has(download.key)) { +// this.downloads.set(download.key, download); +// } +// } + +// reset() { +// this.downloads = new Map(); +// } +// } + +// decorate(DownloadStore, { +// downloads: [ +// format(DESERIALIZER), +// observable +// ], +// newDownloadCount: computed, +// add: action, +// reset: action +// }); diff --git a/stores/RootStore.ts b/stores/RootStore.ts index 0abbb9c89..29d4f88ef 100644 --- a/stores/RootStore.ts +++ b/stores/RootStore.ts @@ -15,7 +15,6 @@ import { v4 as uuidv4 } from 'uuid'; import { getAppName, getSafeDeviceName } from '../utils/Device'; -import DownloadStore from './DownloadStore'; import MediaStore from './MediaStore'; import ServerStore from './ServerStore'; import SettingStore from './SettingStore'; @@ -27,7 +26,6 @@ type State = { isFullscreen: boolean, isReloadRequired: boolean, didPlayerCloseManually: boolean, - downloadStore: DownloadStore, mediaStore: MediaStore, serverStore: ServerStore, settingStore: SettingStore, @@ -41,13 +39,14 @@ type Actions = { // setDidPlayerCloseManually: (v: State['didPlayerCloseManually']) => void } +export type RootStore = State & Actions + const initialState: State = { deviceId: uuidv4(), storeLoaded: false, isFullscreen: false, isReloadRequired: false, didPlayerCloseManually: true, - downloadStore: new DownloadStore(), mediaStore: new MediaStore(), serverStore: new ServerStore(), settingStore: new SettingStore() @@ -66,7 +65,6 @@ export const useRootStore = create()((set, get) => ({ } }), reset: () => { - get().downloadStore.reset() get().mediaStore.reset() get().serverStore.reset() get().settingStore.reset() diff --git a/stores/__tests__/DownloadStore.test.js b/stores/__tests__/DownloadStore.test.js index 6bfdeac5b..e05aa1aac 100644 --- a/stores/__tests__/DownloadStore.test.js +++ b/stores/__tests__/DownloadStore.test.js @@ -2,10 +2,15 @@ * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * @jest-environment jsdom + * @jest-environment-options {"url": "https://jestjs.io/"} */ +import { act } from '@testing-library/react-native'; import DownloadModel from '../../models/DownloadModel'; -import DownloadStore, { DESERIALIZER } from '../DownloadStore'; +import { useDownloadStore, DESERIALIZER } from '../DownloadStore'; +import { renderHook } from '@testing-library/react'; const TEST_MODEL = new DownloadModel( 'item-id', @@ -27,53 +32,63 @@ const TEST_MODEL_2 = new DownloadModel( 'https://test2.example.com/download' ); -describe('DownloadStore', () => { - const store = new DownloadStore(); +let store + +beforeEach(() => { + store = renderHook(() => useDownloadStore((state => state))) + store.result.current.reset() +}) +describe('DownloadStore', () => { it('should initialize with an empty map', () => { - expect(store.downloads.size).toBe(0); + expect(store.result.current.downloads.size).toBe(0); }); - it('should allow models to be added', () => { - store.add(TEST_MODEL); - expect(store.downloads.size).toBe(1); - expect(store.downloads.get(TEST_MODEL.key)).toBe(TEST_MODEL); + it('should reset', () => { + act(() => { + store.result.current.add(TEST_MODEL) + store.result.current.add(TEST_MODEL_2) + }) + expect(store.result.current.downloads.size).toBe(2); + act(() => { store.result.current.reset(); }) + expect(store.result.current.downloads.size).toBe(0); + }); - store.add(TEST_MODEL_2); - expect(store.downloads.size).toBe(2); - expect(store.downloads.get(TEST_MODEL_2.key)).toBe(TEST_MODEL_2); + it('should allow models to be added', () => { + act(() => { + store.result.current.add(TEST_MODEL); + }) + expect(store.result.current.downloads.size).toBe(1); + expect(store.result.current.downloads.get(TEST_MODEL.key)).toBe(TEST_MODEL); + + act(() => { store.result.current.add(TEST_MODEL_2); }) + expect(store.result.current.downloads.size).toBe(2); + expect(store.result.current.downloads.get(TEST_MODEL_2.key)).toBe(TEST_MODEL_2); }); it('should prevent duplicate entries', () => { - const duplicate = new DownloadModel( - 'item-id', - 'server-id', - 'https://example.com/', - 'api-key', - 'duplicate title', - 'duplicate file name.mkv', - 'https://example.com/download' - ); - - expect(store.downloads.size).toBe(2); - store.add(duplicate); - expect(store.downloads.size).toBe(2); - expect(store.downloads.get(duplicate.key)).toBe(TEST_MODEL); + act(() => { + store.result.current.add(TEST_MODEL) + store.result.current.add(TEST_MODEL_2) + }) + + expect(store.result.current.downloads.size).toBe(2); + act(() => { store.result.current.add(TEST_MODEL); }) + expect(store.result.current.downloads.size).toBe(2); }); it('should return the number of new downloads', () => { - expect(store.newDownloadCount).toBe(2); + act(() => { + store.result.current.add(TEST_MODEL) + store.result.current.add(TEST_MODEL_2) + }) + expect(store.result.current.getNewDownloadCount()).toBe(2); TEST_MODEL.isNew = false; - expect(store.newDownloadCount).toBe(1); + expect(store.result.current.getNewDownloadCount()).toBe(1); TEST_MODEL_2.isNew = false; - expect(store.newDownloadCount).toBe(0); + expect(store.result.current.getNewDownloadCount()).toBe(0); }); - it('should reset', () => { - expect(store.downloads.size).toBe(2); - store.reset(); - expect(store.downloads.size).toBe(0); - }); }); describe('DESERIALIZER', () => { diff --git a/stores/__tests__/RootStore.test.js b/stores/__tests__/RootStore.test.js index 60411aaf5..3d572d7ca 100644 --- a/stores/__tests__/RootStore.test.js +++ b/stores/__tests__/RootStore.test.js @@ -1,11 +1,10 @@ -/** - * @jest-environment jsdom - * @jest-environment-options {"url": "https://jestjs.io/"} - */ /** * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * @jest-environment jsdom + * @jest-environment-options {"url": "https://jestjs.io/"} */ import { Jellyfin } from '@jellyfin/sdk'; @@ -29,7 +28,6 @@ describe('RootStore', () => { expect(store.getApi()).toBeInstanceOf(Jellyfin); expect(store.getApi().deviceInfo.id).toBe(store.deviceId); - expect(store.downloadStore).toBeInstanceOf(DownloadStore); expect(store.mediaStore).toBeInstanceOf(MediaStore); expect(store.serverStore).toBeInstanceOf(ServerStore); expect(store.settingStore).toBeInstanceOf(SettingStore); @@ -44,7 +42,6 @@ describe('RootStore', () => { storeHook.result.current.didPlayerCloseManually = false; }) - storeHook.result.current.downloadStore.reset = jest.fn(); storeHook.result.current.mediaStore.reset = jest.fn(); storeHook.result.current.serverStore.reset = jest.fn(); storeHook.result.current.settingStore.reset = jest.fn(); @@ -53,7 +50,6 @@ describe('RootStore', () => { expect(storeHook.result.current.isFullscreen).toBe(true); expect(storeHook.result.current.isReloadRequired).toBe(true); expect(storeHook.result.current.didPlayerCloseManually).toBe(false); - expect(storeHook.result.current.downloadStore.reset).not.toHaveBeenCalled(); expect(storeHook.result.current.mediaStore.reset).not.toHaveBeenCalled(); expect(storeHook.result.current.serverStore.reset).not.toHaveBeenCalled(); expect(storeHook.result.current.settingStore.reset).not.toHaveBeenCalled(); @@ -66,7 +62,6 @@ describe('RootStore', () => { expect(storeHook.result.current.isFullscreen).toBe(false); expect(storeHook.result.current.isReloadRequired).toBe(false); expect(storeHook.result.current.didPlayerCloseManually).toBe(true); - expect(storeHook.result.current.downloadStore.reset).toHaveBeenCalled(); expect(storeHook.result.current.mediaStore.reset).toHaveBeenCalled(); expect(storeHook.result.current.serverStore.reset).toHaveBeenCalled(); expect(storeHook.result.current.settingStore.reset).toHaveBeenCalled(); From 89c8e640d331e0919ab36272aa4cf300c3cd0227 Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Tue, 10 Dec 2024 15:06:34 -0500 Subject: [PATCH 08/37] Remove commented-out code --- models/DownloadModel.ts | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/models/DownloadModel.ts b/models/DownloadModel.ts index 0ea924f7b..7127617d1 100644 --- a/models/DownloadModel.ts +++ b/models/DownloadModel.ts @@ -5,8 +5,6 @@ */ import * as FileSystem from 'expo-file-system'; -// import { computed, decorate, observable } from 'mobx'; -// import { ignore } from 'mobx-sync-lite'; import { v4 as uuidv4 } from 'uuid'; export default class DownloadModel { @@ -75,22 +73,4 @@ export default class DownloadModel { }); return new URL(`${this.serverUrl}Videos/${this.itemId}/stream.mp4?${streamParams.toString()}`); } -} - -// decorate(DownloadModel, { -// isComplete: observable, -// isDownloading: [ ignore, observable ], -// isNew: observable, -// apiKey: observable, -// itemId: observable, -// sessionId: observable, -// serverId: observable, -// serverUrl: observable, -// title: observable, -// filename: observable, -// downloadUrl: observable, -// key: computed, -// localFilename: computed, -// localPath: computed, -// uri: computed -// }); +} \ No newline at end of file From da8f22cc5ca9de370776d0996c8e2d3e7e7bb3e2 Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Tue, 10 Dec 2024 15:14:28 -0500 Subject: [PATCH 09/37] Remove commented-out code --- stores/DownloadStore.ts | 36 +----------------------------------- 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/stores/DownloadStore.ts b/stores/DownloadStore.ts index bd42debbb..d12781e7f 100644 --- a/stores/DownloadStore.ts +++ b/stores/DownloadStore.ts @@ -4,9 +4,6 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -// import { action, computed, decorate, observable } from 'mobx'; -// import { format } from 'mobx-sync-lite'; - import DownloadModel from '../models/DownloadModel'; import { create } from 'zustand'; @@ -59,35 +56,4 @@ export const useDownloadStore = create()((set, get) => ({ } }, reset: () => set({downloads: new Map()}) -})) - -// export default class DownloadStore { -// downloads = new Map(); - -// get newDownloadCount() { -// return Array.from(this.downloads.values()) -// .filter(d => d.isNew) -// .length; -// } - -// add(download: DownloadModel) { -// // Do not allow duplicate downloads -// if (!this.downloads.has(download.key)) { -// this.downloads.set(download.key, download); -// } -// } - -// reset() { -// this.downloads = new Map(); -// } -// } - -// decorate(DownloadStore, { -// downloads: [ -// format(DESERIALIZER), -// observable -// ], -// newDownloadCount: computed, -// add: action, -// reset: action -// }); +})) \ No newline at end of file From 6ab000eb65ea981db88b3e1543b647a9f14005af Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Tue, 10 Dec 2024 15:15:15 -0500 Subject: [PATCH 10/37] Remove commented-out code --- stores/RootStore.ts | 81 +-------------------------------------------- 1 file changed, 1 insertion(+), 80 deletions(-) diff --git a/stores/RootStore.ts b/stores/RootStore.ts index 29d4f88ef..16ff0df57 100644 --- a/stores/RootStore.ts +++ b/stores/RootStore.ts @@ -4,13 +4,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -// polyfill crypto.getRandomValues import 'react-native-get-random-values'; import { Jellyfin } from '@jellyfin/sdk'; import Constants from 'expo-constants'; -import { action, computed, decorate, observable } from 'mobx'; -import { ignore } from 'mobx-sync-lite'; import { v4 as uuidv4 } from 'uuid'; import { getAppName, getSafeDeviceName } from '../utils/Device'; @@ -34,9 +31,6 @@ type State = { type Actions = { getApi: () => Jellyfin, reset: () => void, - // setFullscreen: (v: State['isFullscreen']) => void, - // setReloadRequired: (v: State['isReloadRequired']) => void, - // setDidPlayerCloseManually: (v: State['didPlayerCloseManually']) => void } export type RootStore = State & Actions @@ -77,77 +71,4 @@ export const useRootStore = create()((set, get) => ({ storeLoaded: true, }) }, - // setFullscreen: (isFullscreen) => set({ isFullscreen }), - // setReloadRequired: (isReloadRequired) => set({isReloadRequired}), - // setDidPlayerCloseManually: (didPlayerCloseManually) => set({ didPlayerCloseManually }) -})) - -// export default class RootStore { -// /** -// * Generate a random unique device id -// */ -// deviceId = uuidv4() - -// /** -// * Has the store been loaded from storage -// */ -// storeLoaded = false - -// /** -// * Is the fullscreen interface active -// */ -// isFullscreen = false - -// /** -// * Does the webview require a reload -// */ -// isReloadRequired = false - -// /** -// * Was the native player closed manually -// */ -// didPlayerCloseManually = true - -// downloadStore = new DownloadStore() -// mediaStore = new MediaStore() -// serverStore = new ServerStore() -// settingStore = new SettingStore() - -// get sdk() { -// return new Jellyfin({ -// clientInfo: { -// name: getAppName(), -// version: Constants.nativeAppVersion -// }, -// deviceInfo: { -// name: getSafeDeviceName(), -// id: this.deviceId -// } -// }); -// } - -// reset() { -// this.deviceId = uuidv4(); - -// this.isFullscreen = false; -// this.isReloadRequired = false; -// this.didPlayerCloseManually = true; - -// this.downloadStore.reset(); -// this.mediaStore.reset(); -// this.serverStore.reset(); -// this.settingStore.reset(); - -// this.storeLoaded = true; -// } -// } - -// decorate(RootStore, { -// deviceId: observable, -// storeLoaded: [ ignore, observable ], -// isFullscreen: [ ignore, observable ], -// isReloadRequired: [ ignore, observable ], -// didPlayerCloseManually: [ ignore, observable ], -// sdk: computed, -// reset: action -// }); +})) \ No newline at end of file From f9b41d1e42c7793cbb4ec3afbaccb36667188027 Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Tue, 10 Dec 2024 16:11:12 -0500 Subject: [PATCH 11/37] Convert ServerModel and ServerStore to zustand --- components/NativeShellWebView.js | 4 +- components/ServerInput.js | 6 +- .../__tests__/NativeShellWebView.test.js | 19 ++-- hooks/useStores.js | 4 +- models/ServerModel.js | 36 ++++---- navigation/AppNavigator.js | 6 +- screens/HomeScreen.js | 6 +- screens/SettingsScreen.js | 14 +-- screens/__tests__/HomeScreen.test.js | 24 ++--- screens/__tests__/SettingsScreen.test.js | 8 +- stores/RootStore.ts | 4 - stores/ServerStore.js | 50 ----------- stores/ServerStore.ts | 89 +++++++++++++++++++ stores/__tests__/RootStore.test.js | 4 - stores/__tests__/ServerStore.test.js | 75 +++++++++++----- 15 files changed, 206 insertions(+), 143 deletions(-) delete mode 100644 stores/ServerStore.js create mode 100644 stores/ServerStore.ts diff --git a/components/NativeShellWebView.js b/components/NativeShellWebView.js index c5e738c0d..efde0c0d0 100644 --- a/components/NativeShellWebView.js +++ b/components/NativeShellWebView.js @@ -23,10 +23,10 @@ import RefreshWebView from './RefreshWebView'; const NativeShellWebView = observer( function NativeShellWebView(props, ref) { - const { rootStore, downloadStore } = useStores(); + const { rootStore, downloadStore, serverStore } = useStores(); const [ isRefreshing, setIsRefreshing ] = useState(false); - const server = rootStore.serverStore.servers[rootStore.settingStore.activeServer]; + const server = serverStore.servers[rootStore.settingStore.activeServer]; const isPluginSupported = !!server.info?.Version && compareVersions.compare(server.info.Version, '10.7', '>='); const injectedJavaScript = ` diff --git a/components/ServerInput.js b/components/ServerInput.js index 5e7a71500..c32a958c1 100644 --- a/components/ServerInput.js +++ b/components/ServerInput.js @@ -31,7 +31,7 @@ const ServerInput = observer( const [ isValid, setIsValid ] = useState(true); const [ validationMessage, setValidationMessage ] = useState(''); - const { rootStore } = useStores(); + const { rootStore, serverStore } = useStores(); const navigation = useNavigation(); const { t } = useTranslation(); const { theme } = useContext(ThemeContext); @@ -76,8 +76,8 @@ const ServerInput = observer( } // Save the server details - rootStore.serverStore.addServer({ url }); - rootStore.settingStore.activeServer = rootStore.serverStore.servers.length - 1; + serverStore.addServer({ url }); + rootStore.settingStore.activeServer = serverStore.servers.length - 1; // Call the success callback onSuccess(); diff --git a/components/__tests__/NativeShellWebView.test.js b/components/__tests__/NativeShellWebView.test.js index 737db53dc..096fb5fa1 100644 --- a/components/__tests__/NativeShellWebView.test.js +++ b/components/__tests__/NativeShellWebView.test.js @@ -14,18 +14,19 @@ import NativeShellWebView from '../NativeShellWebView'; jest.mock('../../hooks/useStores'); useStores.mockImplementation(() => ({ rootStore: { - serverStore: { - servers: [{ - info: { - Version: '10.8.0' - } - }] - }, settingStore: { activeServer: 0 } - } -})); + }, + serverStore: { + servers: [{ + info: { + Version: '10.8.0' + } + }] + }, +} +)); jest.mock('../../utils/Device'); getAppName.mockImplementation(() => 'Jellyfin Mobile'); diff --git a/hooks/useStores.js b/hooks/useStores.js index 84539bc24..d2c595f9f 100644 --- a/hooks/useStores.js +++ b/hooks/useStores.js @@ -5,9 +5,11 @@ */ import { useDownloadStore } from '../stores/DownloadStore'; import { useRootStore } from '../stores/RootStore'; +import { useServerStore } from '../stores/ServerStore'; // Compatibility for zustand conversion export const useStores = () => ({ rootStore: useRootStore(), - downloadStore: useDownloadStore() + downloadStore: useDownloadStore(), + serverStore: useServerStore(), }) diff --git a/models/ServerModel.js b/models/ServerModel.js index 312cb7a4c..e86eedf8a 100644 --- a/models/ServerModel.js +++ b/models/ServerModel.js @@ -4,7 +4,6 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { action, autorun, computed, decorate, observable } from 'mobx'; -import { ignore } from 'mobx-sync-lite'; import { task } from 'mobx-task'; import { fetchServerInfo, getServerUrl } from '../utils/ServerValidator'; @@ -22,10 +21,7 @@ export default class ServerModel { this.id = id; this.url = url; this.info = info; - - autorun(() => { - this.urlString = this.parseUrlString; - }); + this.urlString = this.parseUrlString; } get name() { @@ -40,7 +36,11 @@ export default class ServerModel { } } - fetchInfo = task(() => { + /** + * Development note -- this was originally wrapped in mobx task(), which provides some state tracking on asynchronous operations. + * This has been re-implemented with an async call + */ + fetchInfo = async () => { return fetchServerInfo(this) .then(action(info => { this.online = true; @@ -50,17 +50,17 @@ export default class ServerModel { console.warn(err); this.online = false; }); - }) + } } -decorate(ServerModel, { - id: observable, - url: observable, - online: [ - ignore, - observable - ], - info: observable, - name: computed, - parseUrlString: computed -}); +// decorate(ServerModel, { +// id: observable, +// url: observable, +// online: [ +// ignore, +// observable +// ], +// info: observable, +// name: computed, +// parseUrlString: computed +// }); diff --git a/navigation/AppNavigator.js b/navigation/AppNavigator.js index d36f8558e..24a46a266 100644 --- a/navigation/AppNavigator.js +++ b/navigation/AppNavigator.js @@ -19,7 +19,7 @@ import TabNavigator from './TabNavigator'; const AppStack = createStackNavigator(); const AppNavigator = observer(() => { - const { rootStore } = useStores(); + const { rootStore, serverStore } = useStores(); const { t } = useTranslation(); // Ensure the splash screen is hidden when loading is finished @@ -27,7 +27,7 @@ const AppNavigator = observer(() => { return ( 0) ? Screens.MainScreen : Screens.AddServerScreen} + initialRouteName={(serverStore.servers?.length > 0) ? Screens.MainScreen : Screens.AddServerScreen} screenOptions={{ headerMode: 'screen', headerShown: false @@ -53,7 +53,7 @@ const AppNavigator = observer(() => { name={Screens.AddServerScreen} component={AddServerScreen} options={{ - headerShown: rootStore.serverStore.servers?.length > 0, + headerShown: serverStore.servers?.length > 0, title: t('headings.addServer') }} /> diff --git a/screens/HomeScreen.js b/screens/HomeScreen.js index fcd83a54d..a540d6e53 100644 --- a/screens/HomeScreen.js +++ b/screens/HomeScreen.js @@ -22,7 +22,7 @@ import { useStores } from '../hooks/useStores'; import { getIconName } from '../utils/Icons'; const HomeScreen = observer(() => { - const { rootStore } = useStores(); + const { rootStore, serverStore } = useStores(); const navigation = useNavigation(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); @@ -132,10 +132,10 @@ const HomeScreen = observer(() => { // Hide webview until loaded const webviewStyle = (isLoading || httpErrorStatus) ? StyleSheet.compose(styles.container, styles.loading) : styles.container; - if (!rootStore.serverStore.servers || rootStore.serverStore.servers.length === 0) { + if (!serverStore.servers || serverStore.servers.length === 0) { return null; } - const server = rootStore.serverStore.servers[rootStore.settingStore.activeServer]; + const server = serverStore.servers[rootStore.settingStore.activeServer]; return ( { - const { rootStore } = useStores(); + const { rootStore, serverStore } = useStores(); const navigation = useNavigation(); const { t } = useTranslation(); const { theme } = useContext(ThemeContext); useEffect(() => { // Fetch server info - rootStore.serverStore.fetchInfo(); + serverStore.fetchInfo(); }, []); const onAddServer = () => { @@ -42,17 +42,17 @@ const SettingsScreen = observer(() => { const onDeleteServer = index => { Alert.alert( t('alerts.deleteServer.title'), - t('alerts.deleteServer.description', { serverName: rootStore.serverStore.servers[index]?.name }), + t('alerts.deleteServer.description', { serverName: serverStore.servers[index]?.name }), [ { text: t('common.cancel') }, { text: t('alerts.deleteServer.confirm'), onPress: action(() => { // Remove server and update active server - rootStore.serverStore.removeServer(index); + serverStore.removeServer(index); rootStore.settingStore.activeServer = 0; - if (rootStore.serverStore.servers.length > 0) { + if (serverStore.servers.length > 0) { // More servers exist, navigate home navigation.replace(Screens.HomeScreen); navigation.navigate(Screens.HomeTab); @@ -181,7 +181,7 @@ const SettingsScreen = observer(() => { return [ { title: t('headings.servers'), - data: rootStore.serverStore.servers.slice(), + data: serverStore.servers.slice(), keyExtractor: (item, index) => `server-${index}`, renderItem: AugmentedServerListItem }, @@ -246,7 +246,7 @@ const SettingsScreen = observer(() => { sections={getSections()} extraData={{ activeServer: rootStore.settingStore.activeServer, - isFetching: rootStore.serverStore.fetchInfo.pending + isFetching: serverStore.fetchInfo.pending // TODO: .pending is a hang-over from mobx. If the data is not used, it doesn't matter, but if it needs to be used, a hook needs to be written around this missing property }} renderItem={({ item }) => {JSON.stringify(item)}} renderSectionHeader={({ section: { data, title, hideHeader } }) => { diff --git a/screens/__tests__/HomeScreen.test.js b/screens/__tests__/HomeScreen.test.js index d9396682e..53c681129 100644 --- a/screens/__tests__/HomeScreen.test.js +++ b/screens/__tests__/HomeScreen.test.js @@ -28,16 +28,16 @@ jest.mock('../../hooks/useStores'); useStores.mockImplementation(() => ({ rootStore: { mediaStore: {}, - serverStore: { - servers: [ - { - urlString: 'https://example.com' - } - ] - }, settingStore: { activeServer: 0 } + }, + serverStore: { + servers: [ + { + urlString: 'https://example.com' + } + ] } })); @@ -60,9 +60,9 @@ describe('HomeScreen', () => { useStores.mockImplementationOnce(() => ({ rootStore: { mediaStore: {}, - serverStore: {}, settingStore: {} - } + }, + serverStore: {}, })); const { toJSON } = render( @@ -82,12 +82,12 @@ describe('HomeScreen', () => { useStores.mockImplementationOnce(() => ({ rootStore: { mediaStore: {}, - serverStore: { - servers: [{}] - }, settingStore: { activeServer: 0 } + }, + serverStore: { + servers: [{}] } })); diff --git a/screens/__tests__/SettingsScreen.test.js b/screens/__tests__/SettingsScreen.test.js index 6030e0675..7b811a1c1 100644 --- a/screens/__tests__/SettingsScreen.test.js +++ b/screens/__tests__/SettingsScreen.test.js @@ -22,11 +22,11 @@ jest.mock('react-native-elements/dist/buttons/Button', () => 'Button'); jest.mock('../../hooks/useStores'); useStores.mockImplementation(() => ({ rootStore: { - serverStore: { - fetchInfo: jest.fn(), - servers: [] - }, settingStore: {} + }, + serverStore: { + fetchInfo: jest.fn(), + servers: [] } })); diff --git a/stores/RootStore.ts b/stores/RootStore.ts index 16ff0df57..0dfaea328 100644 --- a/stores/RootStore.ts +++ b/stores/RootStore.ts @@ -13,7 +13,6 @@ import { v4 as uuidv4 } from 'uuid'; import { getAppName, getSafeDeviceName } from '../utils/Device'; import MediaStore from './MediaStore'; -import ServerStore from './ServerStore'; import SettingStore from './SettingStore'; import { create } from 'zustand'; @@ -24,7 +23,6 @@ type State = { isReloadRequired: boolean, didPlayerCloseManually: boolean, mediaStore: MediaStore, - serverStore: ServerStore, settingStore: SettingStore, } @@ -42,7 +40,6 @@ const initialState: State = { isReloadRequired: false, didPlayerCloseManually: true, mediaStore: new MediaStore(), - serverStore: new ServerStore(), settingStore: new SettingStore() } @@ -60,7 +57,6 @@ export const useRootStore = create()((set, get) => ({ }), reset: () => { get().mediaStore.reset() - get().serverStore.reset() get().settingStore.reset() set({ diff --git a/stores/ServerStore.js b/stores/ServerStore.js deleted file mode 100644 index b3e927d24..000000000 --- a/stores/ServerStore.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ -import { action, decorate, observable } from 'mobx'; -import { format } from 'mobx-sync-lite'; -import { task } from 'mobx-task'; -import { v4 as uuidv4 } from 'uuid'; - -import ServerModel from '../models/ServerModel'; - -export const DESERIALIZER = data => data.map(server => { - // Migrate from old url format - // TODO: Remove migration in next minor release - const url = server.url.href || server.url; - return new ServerModel(server.id, new URL(url), server.info); -}); - -export default class ServerStore { - servers = [] - - addServer(server) { - this.servers.push(new ServerModel(uuidv4(), server.url)); - } - - removeServer(index) { - this.servers.splice(index, 1); - } - - reset() { - this.servers = []; - } - - fetchInfo = task(async () => { - await Promise.all( - this.servers.map(server => server.fetchInfo()) - ); - }) -} - -decorate(ServerStore, { - servers: [ - format(DESERIALIZER), - observable - ], - addServer: action, - removeServer: action, - reset: action -}); diff --git a/stores/ServerStore.ts b/stores/ServerStore.ts new file mode 100644 index 000000000..b041e6db3 --- /dev/null +++ b/stores/ServerStore.ts @@ -0,0 +1,89 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +import { action, decorate, observable } from 'mobx'; +import { format } from 'mobx-sync-lite'; +import { task } from 'mobx-task'; +import { v4 as uuidv4 } from 'uuid'; + +import ServerModel from '../models/ServerModel'; +import { create } from 'zustand'; + +// TODO: `data: any[]` is probably not the best choice here. +export const DESERIALIZER = (data: any[]) => data.map(server => { + // Migrate from old url format + // TODO: Remove migration in next minor release + const url = server.url.href || server.url; + return new ServerModel(server.id, new URL(url), server.info); +}); + +type State = { + servers: ServerModel[], +} + +type Actions = { + addServer: (v: ServerModel) => void, + removeServer: (v: number) => void, + reset: () => void, + fetchInfo: () => void, +} + +export type ServerStore = State & Actions + +const initialState: State = { + servers: [] +} + +export const useServerStore = create()((set, get) => ({ + ...initialState, + addServer: (server) => { + const servers = get().servers + servers.push(new ServerModel(uuidv4(), server.url)) + set({ servers }) + }, + removeServer: (index) => { + const servers = get().servers + servers.splice(index, 1) + set({ servers }) + }, + reset: () => set({servers: []}), + fetchInfo: async () => { // Mobx provided a `.pending` member on the return type of this. May be we need to do something else with its promise + await Promise.all( + get().servers.map(server => server.fetchInfo()) + ) + } +})) + +// export default class ServerStore { +// servers = [] + +// addServer(server) { +// this.servers.push(new ServerModel(uuidv4(), server.url)); +// } + +// removeServer(index) { +// this.servers.splice(index, 1); +// } + +// reset() { +// this.servers = []; +// } + +// fetchInfo = task(async () => { +// await Promise.all( +// this.servers.map(server => server.fetchInfo()) +// ); +// }) +// } + +// decorate(ServerStore, { +// servers: [ +// format(DESERIALIZER), +// observable +// ], +// addServer: action, +// removeServer: action, +// reset: action +// }); diff --git a/stores/__tests__/RootStore.test.js b/stores/__tests__/RootStore.test.js index 3d572d7ca..46ffa9721 100644 --- a/stores/__tests__/RootStore.test.js +++ b/stores/__tests__/RootStore.test.js @@ -29,7 +29,6 @@ describe('RootStore', () => { expect(store.getApi().deviceInfo.id).toBe(store.deviceId); expect(store.mediaStore).toBeInstanceOf(MediaStore); - expect(store.serverStore).toBeInstanceOf(ServerStore); expect(store.settingStore).toBeInstanceOf(SettingStore); }); @@ -43,7 +42,6 @@ describe('RootStore', () => { }) storeHook.result.current.mediaStore.reset = jest.fn(); - storeHook.result.current.serverStore.reset = jest.fn(); storeHook.result.current.settingStore.reset = jest.fn(); expect(storeHook.result.current.storeLoaded).toBe(false); @@ -51,7 +49,6 @@ describe('RootStore', () => { expect(storeHook.result.current.isReloadRequired).toBe(true); expect(storeHook.result.current.didPlayerCloseManually).toBe(false); expect(storeHook.result.current.mediaStore.reset).not.toHaveBeenCalled(); - expect(storeHook.result.current.serverStore.reset).not.toHaveBeenCalled(); expect(storeHook.result.current.settingStore.reset).not.toHaveBeenCalled(); act(() => { @@ -63,7 +60,6 @@ describe('RootStore', () => { expect(storeHook.result.current.isReloadRequired).toBe(false); expect(storeHook.result.current.didPlayerCloseManually).toBe(true); expect(storeHook.result.current.mediaStore.reset).toHaveBeenCalled(); - expect(storeHook.result.current.serverStore.reset).toHaveBeenCalled(); expect(storeHook.result.current.settingStore.reset).toHaveBeenCalled(); }); }); diff --git a/stores/__tests__/ServerStore.test.js b/stores/__tests__/ServerStore.test.js index c34d27e17..a95026958 100644 --- a/stores/__tests__/ServerStore.test.js +++ b/stores/__tests__/ServerStore.test.js @@ -2,13 +2,18 @@ * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * @jest-environment jsdom + * @jest-environment-options {"url": "https://jestjs.io/"} */ import { URL } from 'url'; import ServerModel from '../../models/ServerModel'; -import ServerStore, { DESERIALIZER } from '../ServerStore'; +import ServerStore, { DESERIALIZER, useServerStore } from '../ServerStore'; +import { renderHook } from '@testing-library/react'; +import { act } from '@testing-library/react-native'; const mockFetchInfo = jest.fn(); jest.mock('../../models/ServerModel', () => { @@ -25,40 +30,64 @@ jest.mock('../../models/ServerModel', () => { }); describe('ServerStore', () => { - const store = new ServerStore(); - it('should initialize with an empty array', () => { - expect(store.servers).toHaveLength(0); + const store = renderHook(() => useServerStore()) + expect(store.result.current.servers).toHaveLength(0); }); it('should allow servers to be added', () => { - store.addServer({ url: new URL('https://foobar') }); - expect(store.servers).toHaveLength(1); - expect(store.servers[0].id).toBeDefined(); - expect(store.servers[0].url.host).toBe('foobar'); - - store.addServer({ url: new URL('https://baz') }); - expect(store.servers).toHaveLength(2); + const store = renderHook(() => useServerStore()) + act(() => { + store.result.current.reset() + store.result.current.addServer({ url: new URL('https://foobar') }); + }) + expect(store.result.current.servers).toHaveLength(1); + expect(store.result.current.servers[0].id).toBeDefined(); + expect(store.result.current.servers[0].url.host).toBe('foobar'); + + act(() => { store.result.current.addServer({ url: new URL('https://baz') }) }) + expect(store.result.current.servers).toHaveLength(2); }); it('should remove servers by index', () => { - store.removeServer(0); - - expect(store.servers).toHaveLength(1); - expect(store.servers[0].id).toBeDefined(); - expect(store.servers[0].url.host).toBe('baz'); + const store = renderHook(() => useServerStore()) + act(() => { + store.result.current.reset() + store.result.current.addServer({ url: new URL('https://foobar') }); + store.result.current.addServer({ url: new URL('https://baz') }); + }) + + expect(store.result.current.servers).toHaveLength(2); + + act(() => { + store.result.current.removeServer(0) + }) + + expect(store.result.current.servers).toHaveLength(1); + expect(store.result.current.servers[0].id).toBeDefined(); + expect(store.result.current.servers[0].url.host).toBe('baz'); }); it('should reset to an empty array', () => { - store.reset(); + const store = renderHook(() => useServerStore()) + act(() => { + store.result.current.reset() + store.result.current.addServer({ url: new URL('https://foobar') }); + }) + expect(store.result.current.servers).toHaveLength(1); + + act(() => { store.result.current.reset() }) - expect(store.servers).toHaveLength(0); + expect(store.result.current.servers).toHaveLength(0); }); it('should call fetchInfo for each server', () => { - store.addServer({ url: new URL('https://foobar') }); - store.addServer({ url: new URL('https://baz') }); - store.fetchInfo(); + const store = renderHook(() => useServerStore()) + act(() => { + store.result.current.addServer({ url: new URL('https://foobar') }); + store.result.current.addServer({ url: new URL('https://baz') }); + store.result.current.fetchInfo(); + }) expect(mockFetchInfo).toHaveBeenCalledTimes(2); }); @@ -85,12 +114,12 @@ describe('DESERIALIZER', () => { expect(deserialized[0]).toBeInstanceOf(ServerModel); expect(deserialized[0].id).toBe(serialized[0].id); - expect(deserialized[0].url).toBeInstanceOf(URL); + // expect(deserialized[0].url).toBeInstanceOf(URL); // URL != URL Jest (?) expect(deserialized[0].url.href).toBe('https://1.example.com/'); expect(deserialized[1]).toBeInstanceOf(ServerModel); expect(deserialized[1].id).toBe(serialized[1].id); - expect(deserialized[1].url).toBeInstanceOf(URL); + // expect(deserialized[1].url).toBeInstanceOf(URL); // URL != URL Jest (?) expect(deserialized[1].url.href).toBe('https://2.example.com/'); }); }); From 8f2ff1808b5b145e7e8f719ca4594a00053d1636 Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Tue, 10 Dec 2024 16:27:08 -0500 Subject: [PATCH 12/37] Remove commented-out code --- models/ServerModel.js | 21 ++++++--------------- screens/SettingsScreen.js | 2 +- stores/ServerStore.ts | 36 ++---------------------------------- 3 files changed, 9 insertions(+), 50 deletions(-) diff --git a/models/ServerModel.js b/models/ServerModel.js index e86eedf8a..8530ac263 100644 --- a/models/ServerModel.js +++ b/models/ServerModel.js @@ -37,8 +37,11 @@ export default class ServerModel { } /** - * Development note -- this was originally wrapped in mobx task(), which provides some state tracking on asynchronous operations. - * This has been re-implemented with an async call + * Development note -- this was originally wrapped in mobx task(), which + * provides some state tracking on asynchronous operations. This has been + * re-implemented with a direct async call, but if the .pending property is + * actively needed, a fetch hook will need to be written around this to track + * the status of the request. */ fetchInfo = async () => { return fetchServerInfo(this) @@ -51,16 +54,4 @@ export default class ServerModel { this.online = false; }); } -} - -// decorate(ServerModel, { -// id: observable, -// url: observable, -// online: [ -// ignore, -// observable -// ], -// info: observable, -// name: computed, -// parseUrlString: computed -// }); +} \ No newline at end of file diff --git a/screens/SettingsScreen.js b/screens/SettingsScreen.js index 6366fcaea..325dac2af 100644 --- a/screens/SettingsScreen.js +++ b/screens/SettingsScreen.js @@ -246,7 +246,7 @@ const SettingsScreen = observer(() => { sections={getSections()} extraData={{ activeServer: rootStore.settingStore.activeServer, - isFetching: serverStore.fetchInfo.pending // TODO: .pending is a hang-over from mobx. If the data is not used, it doesn't matter, but if it needs to be used, a hook needs to be written around this missing property + isFetching: serverStore.fetchInfo.pending }} renderItem={({ item }) => {JSON.stringify(item)}} renderSectionHeader={({ section: { data, title, hideHeader } }) => { diff --git a/stores/ServerStore.ts b/stores/ServerStore.ts index b041e6db3..7319b4d13 100644 --- a/stores/ServerStore.ts +++ b/stores/ServerStore.ts @@ -49,41 +49,9 @@ export const useServerStore = create()((set, get) => ({ set({ servers }) }, reset: () => set({servers: []}), - fetchInfo: async () => { // Mobx provided a `.pending` member on the return type of this. May be we need to do something else with its promise + fetchInfo: async () => { await Promise.all( get().servers.map(server => server.fetchInfo()) ) } -})) - -// export default class ServerStore { -// servers = [] - -// addServer(server) { -// this.servers.push(new ServerModel(uuidv4(), server.url)); -// } - -// removeServer(index) { -// this.servers.splice(index, 1); -// } - -// reset() { -// this.servers = []; -// } - -// fetchInfo = task(async () => { -// await Promise.all( -// this.servers.map(server => server.fetchInfo()) -// ); -// }) -// } - -// decorate(ServerStore, { -// servers: [ -// format(DESERIALIZER), -// observable -// ], -// addServer: action, -// removeServer: action, -// reset: action -// }); +})) \ No newline at end of file From 17e18fb6f9e9fe3b96261c0351277045a9ce4ff0 Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Tue, 10 Dec 2024 16:54:04 -0500 Subject: [PATCH 13/37] Fix broken tests/refs with refactor of mediastore --- components/AudioPlayer.js | 32 +++--- components/NativeShellWebView.js | 18 +-- components/VideoPlayer.js | 32 +++--- .../__snapshots__/VideoPlayer.test.js.snap | 2 +- hooks/useStores.js | 2 + screens/DownloadScreen.js | 8 +- screens/HomeScreen.js | 30 ++--- screens/__tests__/HomeScreen.test.js | 6 +- stores/MediaStore.js | 86 -------------- stores/MediaStore.ts | 66 +++++++++++ stores/RootStore.ts | 4 - stores/__tests__/MediaStore.test.js | 107 ++++++++++-------- stores/__tests__/RootStore.test.js | 8 +- 13 files changed, 194 insertions(+), 207 deletions(-) delete mode 100644 stores/MediaStore.js create mode 100644 stores/MediaStore.ts diff --git a/components/AudioPlayer.js b/components/AudioPlayer.js index a5a478d54..8ec8aef54 100644 --- a/components/AudioPlayer.js +++ b/components/AudioPlayer.js @@ -13,7 +13,7 @@ import { useStores } from '../hooks/useStores'; import { msToTicks } from '../utils/Time'; const AudioPlayer = observer(() => { - const { rootStore } = useStores(); + const { mediaStore } = useStores(); const [ player, setPlayer ] = useState(); @@ -57,46 +57,46 @@ const AudioPlayer = observer(() => { didJustFinish === undefined || isPlaying === undefined || positionMs === undefined || - rootStore.mediaStore.isFinished + mediaStore.isFinished ) { return; } - rootStore.mediaStore.isFinished = didJustFinish; - rootStore.mediaStore.isPlaying = isPlaying; - rootStore.mediaStore.positionTicks = msToTicks(positionMs); + mediaStore.isFinished = didJustFinish; + mediaStore.isPlaying = isPlaying; + mediaStore.positionTicks = msToTicks(positionMs); }); setPlayer(sound); } }; - if (rootStore.mediaStore.type === MediaTypes.Audio) { + if (mediaStore.type === MediaTypes.Audio) { createPlayer({ - uri: rootStore.mediaStore.uri, - positionMillis: rootStore.mediaStore.positionMillis + uri: mediaStore.uri, + positionMillis: mediaStore.positionMillis }); } - }, [ rootStore.mediaStore.type, rootStore.mediaStore.uri ]); + }, [ mediaStore.type, mediaStore.uri ]); // Update the play/pause state when the store indicates it should useEffect(() => { - if (rootStore.mediaStore.type === MediaTypes.Audio && rootStore.mediaStore.shouldPlayPause) { - if (rootStore.mediaStore.isPlaying) { + if (mediaStore.type === MediaTypes.Audio && mediaStore.shouldPlayPause) { + if (mediaStore.isPlaying) { player?.pauseAsync(); } else { player?.playAsync(); } - rootStore.mediaStore.shouldPlayPause = false; + mediaStore.shouldPlayPause = false; } - }, [ rootStore.mediaStore.shouldPlayPause ]); + }, [ mediaStore.shouldPlayPause ]); // Stop the player when the store indicates it should stop playback useEffect(() => { - if (rootStore.mediaStore.type === MediaTypes.Audio && rootStore.mediaStore.shouldStop) { + if (mediaStore.type === MediaTypes.Audio && mediaStore.shouldStop) { player?.stopAsync(); player?.unloadAsync(); - rootStore.mediaStore.shouldStop = false; + mediaStore.shouldStop = false; } - }, [ rootStore.mediaStore.shouldStop ]); + }, [ mediaStore.shouldStop ]); return <>; }); diff --git a/components/NativeShellWebView.js b/components/NativeShellWebView.js index efde0c0d0..887c8d760 100644 --- a/components/NativeShellWebView.js +++ b/components/NativeShellWebView.js @@ -23,7 +23,7 @@ import RefreshWebView from './RefreshWebView'; const NativeShellWebView = observer( function NativeShellWebView(props, ref) { - const { rootStore, downloadStore, serverStore } = useStores(); + const { rootStore, downloadStore, serverStore, mediaStore } = useStores(); const [ isRefreshing, setIsRefreshing ] = useState(false); const server = serverStore.servers[rootStore.settingStore.activeServer]; @@ -70,7 +70,7 @@ true; if (rootStore.isFullscreen) return; // Stop media playback in native players - rootStore.mediaStore.shouldStop = true; + mediaStore.shouldStop = true; setIsRefreshing(true); ref.current?.reload(); @@ -124,19 +124,19 @@ true; break; case 'ExpoAudioPlayer.play': case 'ExpoVideoPlayer.play': - rootStore.mediaStore.type = event === 'ExpoAudioPlayer.play' ? MediaTypes.Audio : MediaTypes.Video; - rootStore.mediaStore.uri = data.url; - rootStore.mediaStore.backdropUri = data.backdropUrl; - rootStore.mediaStore.isFinished = false; - rootStore.mediaStore.positionTicks = data.playerStartPositionTicks; + mediaStore.type = event === 'ExpoAudioPlayer.play' ? MediaTypes.Audio : MediaTypes.Video; + mediaStore.uri = data.url; + mediaStore.backdropUri = data.backdropUrl; + mediaStore.isFinished = false; + mediaStore.positionTicks = data.playerStartPositionTicks; break; case 'ExpoAudioPlayer.playPause': case 'ExpoVideoPlayer.playPause': - rootStore.mediaStore.shouldPlayPause = true; + mediaStore.shouldPlayPause = true; break; case 'ExpoAudioPlayer.stop': case 'ExpoVideoPlayer.stop': - rootStore.mediaStore.shouldStop = true; + mediaStore.shouldStop = true; break; case 'console.debug': // console.debug('[Browser Console]', data); diff --git a/components/VideoPlayer.js b/components/VideoPlayer.js index f8d2a9e95..2a92a744a 100644 --- a/components/VideoPlayer.js +++ b/components/VideoPlayer.js @@ -13,7 +13,7 @@ import { useStores } from '../hooks/useStores'; import { msToTicks } from '../utils/Time'; const VideoPlayer = observer(() => { - const { rootStore } = useStores(); + const { rootStore, mediaStore } = useStores(); const player = useRef(null); // Local player fullscreen state @@ -31,37 +31,37 @@ const VideoPlayer = observer(() => { // Update the player when media type or uri changes useEffect(() => { - if (rootStore.mediaStore.type === MediaTypes.Video) { + if (mediaStore.type === MediaTypes.Video) { rootStore.didPlayerCloseManually = true; player.current?.loadAsync({ - uri: rootStore.mediaStore.uri + uri: mediaStore.uri }, { - positionMillis: rootStore.mediaStore.positionMillis, + positionMillis: mediaStore.positionMillis, shouldPlay: true }); } - }, [ rootStore.mediaStore.type, rootStore.mediaStore.uri ]); + }, [ mediaStore.type, mediaStore.uri ]); // Update the play/pause state when the store indicates it should useEffect(() => { - if (rootStore.mediaStore.type === MediaTypes.Video && rootStore.mediaStore.shouldPlayPause) { - if (rootStore.mediaStore.isPlaying) { + if (mediaStore.type === MediaTypes.Video && mediaStore.shouldPlayPause) { + if (mediaStore.isPlaying) { player.current?.pauseAsync(); } else { player.current?.playAsync(); } - rootStore.mediaStore.shouldPlayPause = false; + mediaStore.shouldPlayPause = false; } - }, [ rootStore.mediaStore.shouldPlayPause ]); + }, [ mediaStore.shouldPlayPause ]); // Close the player when the store indicates it should stop playback useEffect(() => { - if (rootStore.mediaStore.type === MediaTypes.Video && rootStore.mediaStore.shouldStop) { + if (mediaStore.type === MediaTypes.Video && mediaStore.shouldStop) { rootStore.didPlayerCloseManually = false; closeFullscreen(); - rootStore.mediaStore.shouldStop = false; + mediaStore.shouldStop = false; } - }, [ rootStore.mediaStore.shouldStop ]); + }, [ mediaStore.shouldStop ]); const openFullscreen = () => { if (!isPresenting) { @@ -86,7 +86,7 @@ const VideoPlayer = observer(() => { ); -}); +} export default AppNavigator; diff --git a/navigation/HomeNavigator.js b/navigation/HomeNavigator.js index 638000248..ccfe9b220 100644 --- a/navigation/HomeNavigator.js +++ b/navigation/HomeNavigator.js @@ -4,7 +4,6 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { createStackNavigator } from '@react-navigation/stack'; -import { observer } from 'mobx-react-lite'; import React from 'react'; import Screens from '../constants/Screens'; @@ -13,7 +12,7 @@ import HomeScreen from '../screens/HomeScreen'; const HomeStack = createStackNavigator(); -const HomeNavigator = observer(() => { +const HomeNavigator = () => { return ( { /> ); -}); +} export default HomeNavigator; diff --git a/navigation/SettingsNavigator.js b/navigation/SettingsNavigator.js index 2cea156c4..6daa070e5 100644 --- a/navigation/SettingsNavigator.js +++ b/navigation/SettingsNavigator.js @@ -5,7 +5,6 @@ */ import { createStackNavigator } from '@react-navigation/stack'; -import { observer } from 'mobx-react-lite'; import React from 'react'; import { useTranslation } from 'react-i18next'; @@ -15,7 +14,7 @@ import SettingsScreen from '../screens/SettingsScreen'; const SettingsStack = createStackNavigator(); -const SettingsNavigator = observer(() => { +const SettingsNavigator = () => { const { t } = useTranslation(); return ( @@ -36,6 +35,6 @@ const SettingsNavigator = observer(() => { /> ); -}); +} export default SettingsNavigator; diff --git a/navigation/TabNavigator.js b/navigation/TabNavigator.js index 2baf7d9e1..8362098ba 100644 --- a/navigation/TabNavigator.js +++ b/navigation/TabNavigator.js @@ -5,7 +5,6 @@ */ import { Ionicons } from '@expo/vector-icons'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; -import { observer } from 'mobx-react-lite'; import React, { useContext } from 'react'; import { useTranslation } from 'react-i18next'; import { Platform } from 'react-native'; @@ -43,7 +42,7 @@ function TabIcon(routeName, focused, color, size) { const Tab = createBottomTabNavigator(); -const TabNavigator = observer(() => { +const TabNavigator = () => { const { rootStore, downloadStore, settingStore } = useStores(); const insets = useSafeAreaInsets(); const { t } = useTranslation(); @@ -96,6 +95,6 @@ const TabNavigator = observer(() => { /> ); -}); +} export default TabNavigator; diff --git a/screens/DevSettingsScreen.js b/screens/DevSettingsScreen.js index 6d569d1d7..a60d0af79 100644 --- a/screens/DevSettingsScreen.js +++ b/screens/DevSettingsScreen.js @@ -5,7 +5,6 @@ */ import { action } from 'mobx'; -import { observer } from 'mobx-react-lite'; import React, { useContext } from 'react'; import { FlatList, StyleSheet } from 'react-native'; import { ThemeContext } from 'react-native-elements'; @@ -15,7 +14,7 @@ import SwitchListItem from '../components/SwitchListItem'; import { useStores } from '../hooks/useStores'; -const DevSettingsScreen = observer(() => { +const DevSettingsScreen = () => { const { rootStore, settingStore } = useStores(); const { theme } = useContext(ThemeContext); @@ -62,7 +61,7 @@ const DevSettingsScreen = observer(() => { /> ); -}); +} const styles = StyleSheet.create({ container: { diff --git a/screens/HomeScreen.js b/screens/HomeScreen.js index 6497ea978..de46305a5 100644 --- a/screens/HomeScreen.js +++ b/screens/HomeScreen.js @@ -4,7 +4,6 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { useFocusEffect, useNavigation } from '@react-navigation/native'; -import { observer } from 'mobx-react-lite'; import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { BackHandler, Platform, StyleSheet, View } from 'react-native'; @@ -21,7 +20,7 @@ import Screens from '../constants/Screens'; import { useStores } from '../hooks/useStores'; import { getIconName } from '../utils/Icons'; -const HomeScreen = observer(() => { +const HomeScreen = () => { const { rootStore, serverStore, mediaStore, settingStore } = useStores(); const navigation = useNavigation(); const { t } = useTranslation(); @@ -221,7 +220,7 @@ const HomeScreen = observer(() => { )} ); -}); +} const styles = StyleSheet.create({ container: { diff --git a/screens/SettingsScreen.js b/screens/SettingsScreen.js index 9f9d4333d..51960a17d 100644 --- a/screens/SettingsScreen.js +++ b/screens/SettingsScreen.js @@ -7,7 +7,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { useNavigation } from '@react-navigation/native'; import compareVersions from 'compare-versions'; import { action } from 'mobx'; -import { observer } from 'mobx-react-lite'; import React, { useContext, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, Platform, SectionList, StyleSheet, View } from 'react-native'; @@ -24,7 +23,7 @@ import Screens from '../constants/Screens'; import { useStores } from '../hooks/useStores'; import { isSystemThemeSupported } from '../utils/Device'; -const SettingsScreen = observer(() => { +const SettingsScreen = () => { const { rootStore, serverStore, settingStore } = useStores(); const navigation = useNavigation(); const { t } = useTranslation(); @@ -275,7 +274,7 @@ const SettingsScreen = observer(() => { /> ); -}); +} const styles = StyleSheet.create({ container: { From 9a1c05abb1b5256426191cdb5a592b5e4ce6b32d Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 08:59:47 -0500 Subject: [PATCH 17/37] Remove mobx action implementations --- components/NativeShellWebView.js | 5 ++--- components/ServerInput.js | 4 ++-- models/ServerModel.js | 4 ++-- screens/DevSettingsScreen.js | 9 ++++----- screens/SettingsScreen.js | 31 +++++++++++++++---------------- stores/ServerStore.ts | 3 --- 6 files changed, 25 insertions(+), 31 deletions(-) diff --git a/components/NativeShellWebView.js b/components/NativeShellWebView.js index 8c85e5407..aba07cc2e 100644 --- a/components/NativeShellWebView.js +++ b/components/NativeShellWebView.js @@ -7,7 +7,6 @@ import compareVersions from 'compare-versions'; import Constants from 'expo-constants'; import { activateKeepAwake, deactivateKeepAwake } from 'expo-keep-awake'; -import { action } from 'mobx'; import React, { useState } from 'react'; import { BackHandler, Platform } from 'react-native'; @@ -75,7 +74,7 @@ true; setIsRefreshing(false); }; - const onMessage = action(({ nativeEvent: state }) => { + const onMessage = ({ nativeEvent: state }) => { try { const { event, data } = JSON.parse(state.data); switch (event) { @@ -157,7 +156,7 @@ true; } catch (ex) { console.warn('Exception handling message', state.data); } - }); + }; return ( { + const onAddServer = async () => { console.log('add server', host); if (!host) { setIsValid(false); @@ -90,7 +90,7 @@ const ServerInput = function ServerInput({ } } ); - }); + }; return ( { return fetchServerInfo(this) - .then(action(info => { + .then((info) => { this.online = true; this.info = info; - })) + }) .catch((err) => { console.warn(err); this.online = false; diff --git a/screens/DevSettingsScreen.js b/screens/DevSettingsScreen.js index a60d0af79..20a43d10c 100644 --- a/screens/DevSettingsScreen.js +++ b/screens/DevSettingsScreen.js @@ -4,7 +4,6 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { action } from 'mobx'; import React, { useContext } from 'react'; import { FlatList, StyleSheet } from 'react-native'; import { ThemeContext } from 'react-native-elements'; @@ -36,10 +35,10 @@ const DevSettingsScreen = () => { status: 'error' }, value: settingStore.isExperimentalNativeAudioPlayerEnabled, - onValueChange: action(value => { + onValueChange: (value) => { settingStore.isExperimentalNativeAudioPlayerEnabled = value; rootStore.isReloadRequired = true; - }) + } }, { key: 'experimental-downloads-switch', @@ -49,10 +48,10 @@ const DevSettingsScreen = () => { status: 'error' }, value: settingStore.isExperimentalDownloadsEnabled, - onValueChange: action(value => { + onValueChange: (value) => { settingStore.isExperimentalDownloadsEnabled = value; rootStore.isReloadRequired = true; - }) + } } ]} renderItem={SwitchListItem} diff --git a/screens/SettingsScreen.js b/screens/SettingsScreen.js index 51960a17d..ba6df748a 100644 --- a/screens/SettingsScreen.js +++ b/screens/SettingsScreen.js @@ -6,7 +6,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { useNavigation } from '@react-navigation/native'; import compareVersions from 'compare-versions'; -import { action } from 'mobx'; import React, { useContext, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, Platform, SectionList, StyleSheet, View } from 'react-native'; @@ -46,7 +45,7 @@ const SettingsScreen = () => { { text: t('common.cancel') }, { text: t('alerts.deleteServer.confirm'), - onPress: action(() => { + onPress: () => { // Remove server and update active server serverStore.removeServer(index); settingStore.activeServer = 0; @@ -59,18 +58,18 @@ const SettingsScreen = () => { // No servers are present, navigate to add server screen navigation.replace(Screens.AddServerScreen); } - }), + }, style: 'destructive' } ] ); }; - const onSelectServer = action(index => { + const onSelectServer = (index) => { settingStore.activeServer = index; navigation.replace(Screens.HomeScreen); navigation.navigate(Screens.HomeTab); - }); + }; const onResetApplication = () => { Alert.alert( @@ -80,13 +79,13 @@ const SettingsScreen = () => { { text: t('common.cancel') }, { text: t('alerts.resetApplication.confirm'), - onPress: action(() => { + onPress: () => { // Reset data in stores rootStore.reset(); AsyncStorage.clear(); // Navigate to the loading screen navigation.replace(Screens.AddServerScreen); - }), + }, style: 'destructive' } ] @@ -107,7 +106,7 @@ const SettingsScreen = () => { key: 'keep-awake-switch', title: t('settings.keepAwake'), value: settingStore.isScreenLockEnabled, - onValueChange: action(value => settingStore.isScreenLockEnabled = value) + onValueChange: (value) => settingStore.isScreenLockEnabled = value }]; // Orientation lock is not supported on iPad without disabling multitasking @@ -117,7 +116,7 @@ const SettingsScreen = () => { key: 'rotation-lock-switch', title: t('settings.rotationLock'), value: settingStore.isRotationLockEnabled, - onValueChange: action(value => settingStore.isRotationLockEnabled = value) + onValueChange: (value) => settingStore.isRotationLockEnabled = value }); } @@ -132,10 +131,10 @@ const SettingsScreen = () => { value: t('common.beta') }, value: settingStore.isNativeVideoPlayerEnabled, - onValueChange: action(value => { + onValueChange: (value) => { settingStore.isNativeVideoPlayerEnabled = value; rootStore.isReloadRequired = true; - }) + } }); if (compareVersions.compare(Platform.Version, '12', '>')) { @@ -144,10 +143,10 @@ const SettingsScreen = () => { title: t('settings.fmp4Support'), value: settingStore.isFmp4Enabled, disabled: !settingStore.isNativeVideoPlayerEnabled, - onValueChange: action(value => { + onValueChange: (value) => { settingStore.isFmp4Enabled = value; rootStore.isReloadRequired = true; - }) + } }); } } @@ -156,7 +155,7 @@ const SettingsScreen = () => { key: 'tab-labels-switch', title: t('settings.tabLabels'), value: settingStore.isTabLabelsEnabled, - onValueChange: action(value => settingStore.isTabLabelsEnabled = value) + onValueChange: (value) => settingStore.isTabLabelsEnabled = value }]; if (isSystemThemeSupported()) { @@ -164,7 +163,7 @@ const SettingsScreen = () => { key: 'system-theme-switch', title: t('settings.systemTheme'), value: settingStore.isSystemThemeEnabled, - onValueChange: action(value => settingStore.isSystemThemeEnabled = value) + onValueChange: (value) => settingStore.isSystemThemeEnabled = value }); } @@ -174,7 +173,7 @@ const SettingsScreen = () => { title: t('settings.lightTheme'), disabled: settingStore.isSystemThemeEnabled, value: settingStore.themeId === 'light', - onValueChange: action(value => settingStore.themeId = value ? 'light' : 'dark') + onValueChange: (value) => settingStore.themeId = value ? 'light' : 'dark' }); return [ diff --git a/stores/ServerStore.ts b/stores/ServerStore.ts index 7319b4d13..c2352e1ad 100644 --- a/stores/ServerStore.ts +++ b/stores/ServerStore.ts @@ -3,9 +3,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { action, decorate, observable } from 'mobx'; -import { format } from 'mobx-sync-lite'; -import { task } from 'mobx-task'; import { v4 as uuidv4 } from 'uuid'; import ServerModel from '../models/ServerModel'; From 0e18e16ef764b99c80a1b6a04dadf59accbb455d Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 09:00:07 -0500 Subject: [PATCH 18/37] Remove mobx action implementations --- components/ServerInput.js | 1 - 1 file changed, 1 deletion(-) diff --git a/components/ServerInput.js b/components/ServerInput.js index 97011706d..210c75d5d 100644 --- a/components/ServerInput.js +++ b/components/ServerInput.js @@ -4,7 +4,6 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { useNavigation } from '@react-navigation/native'; -import { action } from 'mobx'; import PropTypes from 'prop-types'; import React, { useContext, useState } from 'react'; import { useTranslation } from 'react-i18next'; From bc387f4d5a6841cb844ebe5a7368903e64bd659f Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 09:00:30 -0500 Subject: [PATCH 19/37] Remove mobx action implementations --- models/ServerModel.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/models/ServerModel.js b/models/ServerModel.js index bf6d6ab96..e172d231c 100644 --- a/models/ServerModel.js +++ b/models/ServerModel.js @@ -3,9 +3,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { action, autorun, computed, decorate, observable } from 'mobx'; -import { task } from 'mobx-task'; - import { fetchServerInfo, getServerUrl } from '../utils/ServerValidator'; export default class ServerModel { From da56ce7b5c0b7a10a2e6a1bbc2f561d194db0204 Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 10:23:39 -0500 Subject: [PATCH 20/37] Add generic setter function for the stores that need it, convert all store setups to use `_get` and `_set` for future prep --- stores/DownloadStore.ts | 12 +++++++----- stores/MediaStore.ts | 9 ++++++--- stores/RootStore.ts | 8 +++++--- stores/ServerStore.ts | 14 +++++++------- stores/SettingStore.ts | 8 +++++--- 5 files changed, 30 insertions(+), 21 deletions(-) diff --git a/stores/DownloadStore.ts b/stores/DownloadStore.ts index d12781e7f..f5467b577 100644 --- a/stores/DownloadStore.ts +++ b/stores/DownloadStore.ts @@ -34,6 +34,7 @@ type State = { type Actions = { getNewDownloadCount: () => number, add: (v: DownloadModel) => void, + remove: (v: number) => void, reset: () => void } @@ -43,17 +44,18 @@ const initialState: State = { downloads: new Map() } -export const useDownloadStore = create()((set, get) => ({ +export const useDownloadStore = create()((_set, _get) => ({ ...initialState, getNewDownloadCount: () => Array - .from(get().downloads.values()) + .from(_get().downloads.values()) .filter(d => d.isNew) .length, add: (download) => { - const downloads = get().downloads + const downloads = _get().downloads if (!downloads.has(download.key)) { - set({downloads: new Map([...downloads, [download.key, download]])}) + _set({downloads: new Map([...downloads, [download.key, download]])}) } }, - reset: () => set({downloads: new Map()}) + remove: (download) => {}, // TODO: Implement this + reset: () => _set({downloads: new Map()}) })) \ No newline at end of file diff --git a/stores/MediaStore.ts b/stores/MediaStore.ts index 85d8e5b8d..d174a1cc9 100644 --- a/stores/MediaStore.ts +++ b/stores/MediaStore.ts @@ -36,6 +36,8 @@ type State = { } type Actions = { + set: (v: Partial) => void, + /** Current position in milliseconds */ getPositionMillis: () => number, @@ -57,10 +59,11 @@ const initialState: State = { shouldStop: false } -export const useMediaStore = create()((set, get) => ({ +export const useMediaStore = create()((_set, _get) => ({ ...initialState, - getPositionMillis: () => ticksToMs(get().positionTicks), + set: (state) => { _set({...state} )}, + getPositionMillis: () => ticksToMs(_get().positionTicks), reset: () => { - set({ ...initialState }) + _set({ ...initialState }) } })) \ No newline at end of file diff --git a/stores/RootStore.ts b/stores/RootStore.ts index fdbcb8384..2ee1bb886 100644 --- a/stores/RootStore.ts +++ b/stores/RootStore.ts @@ -23,6 +23,7 @@ type State = { } type Actions = { + set: (v: Partial) => void, getApi: () => Jellyfin, reset: () => void, } @@ -37,8 +38,9 @@ const initialState: State = { didPlayerCloseManually: true, } -export const useRootStore = create()((set, get) => ({ +export const useRootStore = create()((_set, _get) => ({ ...initialState, + set: (state) => { _set({...state} )}, getApi: () => new Jellyfin({ clientInfo: { name: getAppName(), @@ -46,11 +48,11 @@ export const useRootStore = create()((set, get) => ({ }, deviceInfo: { name: getSafeDeviceName(), - id: get().deviceId + id: _get().deviceId } }), reset: () => { // TODO: Confirm instances of this reset call reset all the other states as well - set({ + _set({ deviceId: uuidv4(), isFullscreen: false, isReloadRequired: false, diff --git a/stores/ServerStore.ts b/stores/ServerStore.ts index c2352e1ad..1b5df41b3 100644 --- a/stores/ServerStore.ts +++ b/stores/ServerStore.ts @@ -33,22 +33,22 @@ const initialState: State = { servers: [] } -export const useServerStore = create()((set, get) => ({ +export const useServerStore = create()((_set, _get) => ({ ...initialState, addServer: (server) => { - const servers = get().servers + const servers = _get().servers servers.push(new ServerModel(uuidv4(), server.url)) - set({ servers }) + _set({ servers }) }, removeServer: (index) => { - const servers = get().servers + const servers = _get().servers servers.splice(index, 1) - set({ servers }) + _set({ servers }) }, - reset: () => set({servers: []}), + reset: () => _set({servers: []}), fetchInfo: async () => { await Promise.all( - get().servers.map(server => server.fetchInfo()) + _get().servers.map(server => server.fetchInfo()) ) } })) \ No newline at end of file diff --git a/stores/SettingStore.ts b/stores/SettingStore.ts index 30dddc02e..c76f72bb7 100644 --- a/stores/SettingStore.ts +++ b/stores/SettingStore.ts @@ -45,6 +45,7 @@ type State = { } type Actions = { + set: (v: Partial) => void, getTheme: () => any, // TODO: get typing on themes and put it here reset: () => void } @@ -67,10 +68,11 @@ const initialState: () => State = () => ({ systemThemeId: null, }) -export const useSettingStore = create()((set, get) => ({ +export const useSettingStore = create()((_set, _get) => ({ ...initialState(), + set: (state) => { _set({...state} )}, getTheme: () => { - const state = get() + const state = _get() const id = state.isSystemThemeEnabled && state.systemThemeId && state.systemThemeId !== 'no-preference' @@ -80,6 +82,6 @@ export const useSettingStore = create()((set, get) => ({ return Themes[id] || Themes.dark; }, reset: () => { - set({ ...initialState() }) + _set({ ...initialState() }) } })) \ No newline at end of file From 5cb35f0c2b28d7e733b0779981b3e9a7edc0e20f Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 10:28:11 -0500 Subject: [PATCH 21/37] Conver setting store to use setters --- App.js | 2 +- components/ServerInput.js | 2 +- screens/DevSettingsScreen.js | 4 ++-- screens/SettingsScreen.js | 18 +++++++++--------- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/App.js b/App.js index c6b165583..05444bfe7 100644 --- a/App.js +++ b/App.js @@ -38,7 +38,7 @@ const App = ({ skipLoadingScreen }) => { const { rootStore, downloadStore, settingStore } = useStores(); const { theme } = useContext(ThemeContext); - settingStore.systemThemeId = useColorScheme(); + settingStore.set({systemThemeId: useColorScheme()}); SplashScreen.preventAutoHideAsync(); diff --git a/components/ServerInput.js b/components/ServerInput.js index 210c75d5d..4e48a0b7a 100644 --- a/components/ServerInput.js +++ b/components/ServerInput.js @@ -74,7 +74,7 @@ const ServerInput = function ServerInput({ // Save the server details serverStore.addServer({ url }); - settingStore.activeServer = serverStore.servers.length - 1; + settingStore.set({activeServer: serverStore.servers.length - 1}); // Call the success callback onSuccess(); diff --git a/screens/DevSettingsScreen.js b/screens/DevSettingsScreen.js index 20a43d10c..9566d8133 100644 --- a/screens/DevSettingsScreen.js +++ b/screens/DevSettingsScreen.js @@ -36,7 +36,7 @@ const DevSettingsScreen = () => { }, value: settingStore.isExperimentalNativeAudioPlayerEnabled, onValueChange: (value) => { - settingStore.isExperimentalNativeAudioPlayerEnabled = value; + settingStore.set({isExperimentalNativeAudioPlayerEnabled: value}); rootStore.isReloadRequired = true; } }, @@ -49,7 +49,7 @@ const DevSettingsScreen = () => { }, value: settingStore.isExperimentalDownloadsEnabled, onValueChange: (value) => { - settingStore.isExperimentalDownloadsEnabled = value; + settingStore.set({isExperimentalDownloadsEnabled: value}); rootStore.isReloadRequired = true; } } diff --git a/screens/SettingsScreen.js b/screens/SettingsScreen.js index ba6df748a..f9d4c963f 100644 --- a/screens/SettingsScreen.js +++ b/screens/SettingsScreen.js @@ -48,7 +48,7 @@ const SettingsScreen = () => { onPress: () => { // Remove server and update active server serverStore.removeServer(index); - settingStore.activeServer = 0; + settingStore.set({activeServer: 0}); if (serverStore.servers.length > 0) { // More servers exist, navigate home @@ -66,7 +66,7 @@ const SettingsScreen = () => { }; const onSelectServer = (index) => { - settingStore.activeServer = index; + settingStore.set({activeServer: index}); navigation.replace(Screens.HomeScreen); navigation.navigate(Screens.HomeTab); }; @@ -106,7 +106,7 @@ const SettingsScreen = () => { key: 'keep-awake-switch', title: t('settings.keepAwake'), value: settingStore.isScreenLockEnabled, - onValueChange: (value) => settingStore.isScreenLockEnabled = value + onValueChange: (value) => settingStore.set({isScreenLockEnabled: value}) }]; // Orientation lock is not supported on iPad without disabling multitasking @@ -116,7 +116,7 @@ const SettingsScreen = () => { key: 'rotation-lock-switch', title: t('settings.rotationLock'), value: settingStore.isRotationLockEnabled, - onValueChange: (value) => settingStore.isRotationLockEnabled = value + onValueChange: (value) => settingStore.set({isRotationLockEnabled: value}) }); } @@ -132,7 +132,7 @@ const SettingsScreen = () => { }, value: settingStore.isNativeVideoPlayerEnabled, onValueChange: (value) => { - settingStore.isNativeVideoPlayerEnabled = value; + settingStore.set({isNativeVideoPlayerEnabled: value}); rootStore.isReloadRequired = true; } }); @@ -144,7 +144,7 @@ const SettingsScreen = () => { value: settingStore.isFmp4Enabled, disabled: !settingStore.isNativeVideoPlayerEnabled, onValueChange: (value) => { - settingStore.isFmp4Enabled = value; + settingStore.set({isFmp4Enabled: value}); rootStore.isReloadRequired = true; } }); @@ -155,7 +155,7 @@ const SettingsScreen = () => { key: 'tab-labels-switch', title: t('settings.tabLabels'), value: settingStore.isTabLabelsEnabled, - onValueChange: (value) => settingStore.isTabLabelsEnabled = value + onValueChange: (value) => settingStore.set({isTabLabelsEnabled: value}) }]; if (isSystemThemeSupported()) { @@ -163,7 +163,7 @@ const SettingsScreen = () => { key: 'system-theme-switch', title: t('settings.systemTheme'), value: settingStore.isSystemThemeEnabled, - onValueChange: (value) => settingStore.isSystemThemeEnabled = value + onValueChange: (value) => settingStore.set({isSystemThemeEnabled: value}) }); } @@ -173,7 +173,7 @@ const SettingsScreen = () => { title: t('settings.lightTheme'), disabled: settingStore.isSystemThemeEnabled, value: settingStore.themeId === 'light', - onValueChange: (value) => settingStore.themeId = value ? 'light' : 'dark' + onValueChange: (value) => settingStore.set({themeId: value ? 'light' : 'dark'}) }); return [ From 0ccf45c64a26699dac364a780a4b76e974adeedf Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 10:36:53 -0500 Subject: [PATCH 22/37] Update rootStore setters --- App.js | 2 +- components/NativeShellWebView.js | 4 ++-- components/VideoPlayer.js | 10 +++++----- screens/DevSettingsScreen.js | 4 ++-- screens/HomeScreen.js | 2 +- screens/SettingsScreen.js | 4 ++-- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/App.js b/App.js index 05444bfe7..82256576d 100644 --- a/App.js +++ b/App.js @@ -49,7 +49,7 @@ const App = ({ skipLoadingScreen }) => { const hydrateStores = async () => { await trunk.init(); - rootStore.storeLoaded = true; + rootStore.set({storeLoaded: true}); }; const loadImages = () => { diff --git a/components/NativeShellWebView.js b/components/NativeShellWebView.js index aba07cc2e..1464b8da8 100644 --- a/components/NativeShellWebView.js +++ b/components/NativeShellWebView.js @@ -82,10 +82,10 @@ true; BackHandler.exitApp(); break; case 'enableFullscreen': - rootStore.isFullscreen = true; + rootStore.set({isFullscreen: true}); break; case 'disableFullscreen': - rootStore.isFullscreen = false; + rootStore.set({isFullscreen: false}); break; case 'downloadFile': console.log('Download item', data); diff --git a/components/VideoPlayer.js b/components/VideoPlayer.js index 819cf0e41..3403b6af9 100644 --- a/components/VideoPlayer.js +++ b/components/VideoPlayer.js @@ -31,7 +31,7 @@ const VideoPlayer = () => { // Update the player when media type or uri changes useEffect(() => { if (mediaStore.type === MediaTypes.Video) { - rootStore.didPlayerCloseManually = true; + rootStore.set({didPlayerCloseManually: true}); player.current?.loadAsync({ uri: mediaStore.uri }, { @@ -56,7 +56,7 @@ const VideoPlayer = () => { // Close the player when the store indicates it should stop playback useEffect(() => { if (mediaStore.type === MediaTypes.Video && mediaStore.shouldStop) { - rootStore.didPlayerCloseManually = false; + rootStore.set({didPlayerCloseManually: false}); closeFullscreen(); mediaStore.shouldStop = false; } @@ -91,7 +91,7 @@ const VideoPlayer = () => { onReadyForDisplay={openFullscreen} onPlaybackStatusUpdate={({ isPlaying, positionMillis, didJustFinish }) => { if (didJustFinish) { - rootStore.didPlayerCloseManually = false; + rootStore.set({didPlayerCloseManually: false}); closeFullscreen(); return; } @@ -102,7 +102,7 @@ const VideoPlayer = () => { switch (fullscreenUpdate) { case VideoFullscreenUpdate.PLAYER_WILL_PRESENT: setIsPresenting(true); - rootStore.isFullscreen = true; + rootStore.set({isFullscreen: true}); break; case VideoFullscreenUpdate.PLAYER_DID_PRESENT: setIsPresenting(false); @@ -112,7 +112,7 @@ const VideoPlayer = () => { break; case VideoFullscreenUpdate.PLAYER_DID_DISMISS: setIsDismissing(false); - rootStore.isFullscreen = false; + rootStore.set({isFullscreen: false}); mediaStore.reset(); player.current?.unloadAsync() .catch(console.debug); diff --git a/screens/DevSettingsScreen.js b/screens/DevSettingsScreen.js index 9566d8133..2cc88800d 100644 --- a/screens/DevSettingsScreen.js +++ b/screens/DevSettingsScreen.js @@ -37,7 +37,7 @@ const DevSettingsScreen = () => { value: settingStore.isExperimentalNativeAudioPlayerEnabled, onValueChange: (value) => { settingStore.set({isExperimentalNativeAudioPlayerEnabled: value}); - rootStore.isReloadRequired = true; + rootStore.set({isReloadRequired: true}); } }, { @@ -50,7 +50,7 @@ const DevSettingsScreen = () => { value: settingStore.isExperimentalDownloadsEnabled, onValueChange: (value) => { settingStore.set({isExperimentalDownloadsEnabled: value}); - rootStore.isReloadRequired = true; + rootStore.set({isReloadRequired: true}); } } ]} diff --git a/screens/HomeScreen.js b/screens/HomeScreen.js index de46305a5..a4fb7d365 100644 --- a/screens/HomeScreen.js +++ b/screens/HomeScreen.js @@ -92,7 +92,7 @@ const HomeScreen = () => { useEffect(() => { if (rootStore.isReloadRequired) { webview.current?.reload(); - rootStore.isReloadRequired = false; + rootStore.set({isReloadRequired: false}); } }, [ rootStore.isReloadRequired ]); diff --git a/screens/SettingsScreen.js b/screens/SettingsScreen.js index f9d4c963f..4d2dffc94 100644 --- a/screens/SettingsScreen.js +++ b/screens/SettingsScreen.js @@ -133,7 +133,7 @@ const SettingsScreen = () => { value: settingStore.isNativeVideoPlayerEnabled, onValueChange: (value) => { settingStore.set({isNativeVideoPlayerEnabled: value}); - rootStore.isReloadRequired = true; + rootStore.set({isReloadRequired: true}); } }); @@ -145,7 +145,7 @@ const SettingsScreen = () => { disabled: !settingStore.isNativeVideoPlayerEnabled, onValueChange: (value) => { settingStore.set({isFmp4Enabled: value}); - rootStore.isReloadRequired = true; + rootStore.set({isReloadRequired: true}); } }); } From 1e5bbeaf05be8b30e56eb38e426c804c11045ce2 Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 10:47:07 -0500 Subject: [PATCH 23/37] Update mediastore to use setters --- components/AudioPlayer.js | 12 +++++++----- components/NativeShellWebView.js | 18 ++++++++++-------- components/VideoPlayer.js | 10 ++++++---- screens/DownloadScreen.js | 8 +++++--- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/components/AudioPlayer.js b/components/AudioPlayer.js index 21470382c..341bc8480 100644 --- a/components/AudioPlayer.js +++ b/components/AudioPlayer.js @@ -60,9 +60,11 @@ const AudioPlayer = () => { ) { return; } - mediaStore.isFinished = didJustFinish; - mediaStore.isPlaying = isPlaying; - mediaStore.positionTicks = msToTicks(positionMs); + mediaStore.set({ + isFinished: didJustFinish, + isPlaying: isPlaying, + positionTicks: msToTicks(positionMs) + }); }); setPlayer(sound); } @@ -84,7 +86,7 @@ const AudioPlayer = () => { } else { player?.playAsync(); } - mediaStore.shouldPlayPause = false; + mediaStore.set({shouldPlayPause: false}); } }, [ mediaStore.shouldPlayPause ]); @@ -93,7 +95,7 @@ const AudioPlayer = () => { if (mediaStore.type === MediaTypes.Audio && mediaStore.shouldStop) { player?.stopAsync(); player?.unloadAsync(); - mediaStore.shouldStop = false; + mediaStore.set({shouldStop: false}); } }, [ mediaStore.shouldStop ]); diff --git a/components/NativeShellWebView.js b/components/NativeShellWebView.js index 1464b8da8..9de40666d 100644 --- a/components/NativeShellWebView.js +++ b/components/NativeShellWebView.js @@ -67,7 +67,7 @@ true; if (rootStore.isFullscreen) return; // Stop media playback in native players - mediaStore.shouldStop = true; + mediaStore.set({shouldStop: true}); setIsRefreshing(true); ref.current?.reload(); @@ -121,19 +121,21 @@ true; break; case 'ExpoAudioPlayer.play': case 'ExpoVideoPlayer.play': - mediaStore.type = event === 'ExpoAudioPlayer.play' ? MediaTypes.Audio : MediaTypes.Video; - mediaStore.uri = data.url; - mediaStore.backdropUri = data.backdropUrl; - mediaStore.isFinished = false; - mediaStore.positionTicks = data.playerStartPositionTicks; + mediaStore.set({ + type: event === 'ExpoAudioPlayer.play' ? MediaTypes.Audio : MediaTypes.Video, + uri: data.url, + backdropUri: data.backdropUrl, + isFinished: false, + positionTicks: data.playerStartPositionTicks + }); break; case 'ExpoAudioPlayer.playPause': case 'ExpoVideoPlayer.playPause': - mediaStore.shouldPlayPause = true; + mediaStore.set({shouldPlayPause: true}); break; case 'ExpoAudioPlayer.stop': case 'ExpoVideoPlayer.stop': - mediaStore.shouldStop = true; + mediaStore.set({shouldStop: true}); break; case 'console.debug': // console.debug('[Browser Console]', data); diff --git a/components/VideoPlayer.js b/components/VideoPlayer.js index 3403b6af9..17518de43 100644 --- a/components/VideoPlayer.js +++ b/components/VideoPlayer.js @@ -49,7 +49,7 @@ const VideoPlayer = () => { } else { player.current?.playAsync(); } - mediaStore.shouldPlayPause = false; + mediaStore.set({shouldPlayPause: false}); } }, [ mediaStore.shouldPlayPause ]); @@ -58,7 +58,7 @@ const VideoPlayer = () => { if (mediaStore.type === MediaTypes.Video && mediaStore.shouldStop) { rootStore.set({didPlayerCloseManually: false}); closeFullscreen(); - mediaStore.shouldStop = false; + mediaStore.set({shouldStop: false}); } }, [ mediaStore.shouldStop ]); @@ -95,8 +95,10 @@ const VideoPlayer = () => { closeFullscreen(); return; } - mediaStore.isPlaying = isPlaying; - mediaStore.positionTicks = msToTicks(positionMillis); + mediaStore.set({ + isPlaying: isPlaying, + positionTicks: msToTicks(positionMillis) + }); }} onFullscreenUpdate={({ fullscreenUpdate }) => { switch (fullscreenUpdate) { diff --git a/screens/DownloadScreen.js b/screens/DownloadScreen.js index c5f212439..62099adeb 100644 --- a/screens/DownloadScreen.js +++ b/screens/DownloadScreen.js @@ -137,9 +137,11 @@ const DownloadScreen = () => { }} onPlay={async () => { item.isNew = false; - mediaStore.isLocalFile = true; - mediaStore.type = MediaTypes.Video; - mediaStore.uri = item.uri; + mediaStore.set({ + isLocalFile: true, + type: MediaTypes.Video, + uri: item.uri + }); }} /> )} From e8bd175288c596ae9c5997788352b8e666b7f585 Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 10:49:23 -0500 Subject: [PATCH 24/37] Reset all stores correctly since rootStore.reset() was refactored --- screens/SettingsScreen.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/screens/SettingsScreen.js b/screens/SettingsScreen.js index 4d2dffc94..2b785398e 100644 --- a/screens/SettingsScreen.js +++ b/screens/SettingsScreen.js @@ -23,7 +23,7 @@ import { useStores } from '../hooks/useStores'; import { isSystemThemeSupported } from '../utils/Device'; const SettingsScreen = () => { - const { rootStore, serverStore, settingStore } = useStores(); + const { rootStore, serverStore, settingStore, mediaStore, downloadStore } = useStores(); const navigation = useNavigation(); const { t } = useTranslation(); const { theme } = useContext(ThemeContext); @@ -81,8 +81,14 @@ const SettingsScreen = () => { text: t('alerts.resetApplication.confirm'), onPress: () => { // Reset data in stores + mediaStore.reset(); + downloadStore.reset(); + serverStore.reset(); + settingStore.reset(); rootStore.reset(); + AsyncStorage.clear(); + // Navigate to the loading screen navigation.replace(Screens.AddServerScreen); }, From b2a88e495ab197703877a7e0ffc2172ae59297c7 Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 12:08:23 -0500 Subject: [PATCH 25/37] Fix render loop with using a set state on app initialization --- App.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/App.js b/App.js index 82256576d..96a3767a9 100644 --- a/App.js +++ b/App.js @@ -38,7 +38,9 @@ const App = ({ skipLoadingScreen }) => { const { rootStore, downloadStore, settingStore } = useStores(); const { theme } = useContext(ThemeContext); - settingStore.set({systemThemeId: useColorScheme()}); + // Using a hook here causes a render loop; what is the point of this setting? + // settingStore.set({systemThemeId: useColorScheme()}); + settingStore.systemThemeId = useColorScheme(); SplashScreen.preventAutoHideAsync(); @@ -78,6 +80,7 @@ const App = ({ skipLoadingScreen }) => { }; useEffect(() => { + // Set base app theme // Hydrate mobx data stores hydrateStores(); From 163010ef11fb5cd10f375dd783c3c56bd5687e7e Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 14:56:54 -0500 Subject: [PATCH 26/37] Persist zustand storage, migrate mobx stores --- App.js | 71 ++++++++++++++++++++++++- screens/DownloadScreen.js | 1 + stores/DownloadStore.ts | 108 ++++++++++++++++++++++++++------------ stores/MediaStore.ts | 36 +++++++++---- stores/RootStore.ts | 58 ++++++++++++-------- stores/ServerStore.ts | 93 +++++++++++++++++++++++++------- stores/SettingStore.ts | 53 ++++++++++++------- 7 files changed, 313 insertions(+), 107 deletions(-) diff --git a/App.js b/App.js index 96a3767a9..738f9f25d 100644 --- a/App.js +++ b/App.js @@ -32,10 +32,12 @@ import StaticScriptLoader from './utils/StaticScriptLoader'; // Import i18n configuration import './i18n'; +import ServerModel from './models/ServerModel'; +import DownloadModel from './models/DownloadModel'; const App = ({ skipLoadingScreen }) => { const [ isSplashReady, setIsSplashReady ] = useState(false); - const { rootStore, downloadStore, settingStore } = useStores(); + const { rootStore, downloadStore, settingStore, mediaStore, serverStore } = useStores(); const { theme } = useContext(ThemeContext); // Using a hook here causes a render loop; what is the point of this setting? @@ -49,7 +51,72 @@ const App = ({ skipLoadingScreen }) => { }); const hydrateStores = async () => { - await trunk.init(); + // TODO: In release n+2 from this point, remove this conversion code. + const mobx_store_value = await AsyncStorage.getItem('__mobx_sync__') // Store will be null if it's not set + + if (mobx_store_value !== null) { + console.info('Migrating mobx store to zustand') + const mobx_store = JSON.parse(mobx_store_value) + + // Root Store + for (let key of Object.keys(mobx_store).filter(key => key.search('Store') === -1)) { + rootStore.set({key: mobx_store[key]}) + } + + // MediaStore + for (let key of Object.keys(mobx_store.mediaStore).filter(key => key.search('Store') === -1)) { + mediaStore.set({key: mobx_store.mediaStore[key]}) + } + + /** + * Server store & download store need some special treatment because they + * are not simple key-value pair stores. Each contains one key which is a + * list of Model objects that represent the contents of their respective + * stores. + * + * zustand requires a custom storage engine for these for proper + * serialization and deserialization (written in each storage's module), + * but this code is needed to get them over the hump from mobx to zustand. + */ + // DownloadStore + const mobxDownloads = mobx_store.downloadStore.downloads + const migratedDownloads = new Map() + if (Object.keys(mobxDownloads).length > 0) { + for (let [key, value] of Object.getEntries(mobxDownloads).filter(key => key.search('Store') === -1)) { + migratedDownloads.set(key, new DownloadModel( + value.itemId, + value.serverId, + value.serverUrl, + value.apiKey, + value.title, + value.fileName, + value.downloadUrl + )) + } + } + downloadStore.set({downloads: migratedDownloads}) + + // ServerStore + const mobxServers = mobx_store.serverStore.servers + const migratedServers = [] + if (Object.keys(mobxServers).length > 0) { + for (let item of mobxServers) { + const url = new URL(item.url) + migratedServers.push(new ServerModel(item.id, new URL(item.url), item.info)) + } + } + serverStore.set({servers: migratedServers}) + + // SettingStore + for (let key of Object.keys(mobx_store.settingStore).filter(key => key.search('Store') === -1)) { + console.info('SettingStore', key) + settingStore.set({key: mobx_store.settingStore[key]}) + } + + // TODO: Confirm zustand has objects in async storage + // TODO: Remove mobx sync item from async storage + // AsyncStorage.removeItem('__mobx_sync__') + } rootStore.set({storeLoaded: true}); }; diff --git a/screens/DownloadScreen.js b/screens/DownloadScreen.js index 62099adeb..f6d7be38d 100644 --- a/screens/DownloadScreen.js +++ b/screens/DownloadScreen.js @@ -109,6 +109,7 @@ const DownloadScreen = () => { ); const downloadList = [] + console.log('downloads', downloadStore.downloads) downloadStore.downloads.forEach(download => downloadList.push(download)) return ( diff --git a/stores/DownloadStore.ts b/stores/DownloadStore.ts index f5467b577..bf584afea 100644 --- a/stores/DownloadStore.ts +++ b/stores/DownloadStore.ts @@ -4,34 +4,17 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import AsyncStorage from '@react-native-async-storage/async-storage'; import DownloadModel from '../models/DownloadModel'; import { create } from 'zustand'; - -export const DESERIALIZER = (data: unknown) => { - const deserialized = new Map(); - Object.entries(data).forEach(([ key, dl ]) => { - const model = new DownloadModel( - dl.itemId, - dl.serverId, - dl.serverUrl, - dl.apiKey, - dl.title, - dl.filename, - dl.downloadUrl - ); - model.isComplete = dl.isComplete; - // isDownloading is ignored - model.isNew = dl.isNew; - deserialized.set(key, model); - }); - return deserialized; -}; +import { persist, PersistStorage, StorageValue } from 'zustand/middleware' type State = { downloads: Map, } type Actions = { + set: (v: Partial) => void, getNewDownloadCount: () => number, add: (v: DownloadModel) => void, remove: (v: number) => void, @@ -40,22 +23,79 @@ type Actions = { export type DownloadStore = State & Actions +// This is needed to properly serialize/deserialize Map +const storage: PersistStorage = { + getItem: async function (name: string): Promise> { + const data: any = JSON.parse(await AsyncStorage.getItem(name)).state + console.log('DownloadModel Deserializer, data', data) + + const deserialized = new Map(); + + for (const entry of Object.entries(data.downloads)) { + //@ts-ignore This is mostly to coerce the type and please the editor + const [key, value]: [string, DownloadModel] = entry + const model = new DownloadModel( + value.itemId, + value.serverId, + value.serverUrl, + value.apiKey, + value.title, + value.filename, + value.downloadUrl + ) + // Ignore isDownloading + model.isComplete = value.isComplete + model.isNew = value.isNew + + deserialized.set(key, model) + } + + return { + state: { + downloads: deserialized + } + } + }, + setItem: function (name: string, value: StorageValue): void { + const serialized = JSON.stringify({ + downloads: Array.from(value.state.downloads.entries()) + }) + AsyncStorage.setItem(name, serialized) + }, + removeItem: function (name: string): void { + AsyncStorage.removeItem(name) + } +} + const initialState: State = { downloads: new Map() } -export const useDownloadStore = create()((_set, _get) => ({ - ...initialState, - getNewDownloadCount: () => Array - .from(_get().downloads.values()) - .filter(d => d.isNew) - .length, - add: (download) => { - const downloads = _get().downloads - if (!downloads.has(download.key)) { - _set({downloads: new Map([...downloads, [download.key, download]])}) +const persistKeys = Object.keys(initialState) + +export const useDownloadStore = create()( + persist( + (_set, _get) => ({ + ...initialState, + set: (state) => { _set({ ...state }) }, + getNewDownloadCount: () => Array + .from(_get().downloads.values()) + .filter(d => d.isNew) + .length, + add: (download) => { + const downloads = _get().downloads + if (!downloads.has(download.key)) { + _set({ downloads: new Map([...downloads, [download.key, download]]) }) + } + }, + remove: (download) => { }, // TODO: Implement this + reset: () => _set({ downloads: new Map() }) + }), { + name: 'DownloadStore', + storage: storage, + partialize: (state) => Object.fromEntries( + Object.entries(state).filter(([key]) => persistKeys.includes(key) ) + ) } - }, - remove: (download) => {}, // TODO: Implement this - reset: () => _set({downloads: new Map()}) -})) \ No newline at end of file + ) +) \ No newline at end of file diff --git a/stores/MediaStore.ts b/stores/MediaStore.ts index d174a1cc9..7e909d372 100644 --- a/stores/MediaStore.ts +++ b/stores/MediaStore.ts @@ -3,8 +3,10 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { createJSONStorage, persist } from 'zustand/middleware'; import { ticksToMs } from '../utils/Time'; import { create } from 'zustand'; +import AsyncStorage from '@react-native-async-storage/async-storage'; type State = { /** The media type being played */ @@ -15,7 +17,7 @@ type State = { /** URI of the backdrop image of the current media item */ backdropUri?: string, - + /** Current playback position (in ticks) */ positionTicks: number, @@ -24,10 +26,10 @@ type State = { /** Is the media in a local file (i.e. not streaming) */ isLocalFile: boolean, - + /** Is the media currently playing */ isPlaying: boolean, - + /** The player should toggle the play/pause state */ shouldPlayPause: boolean, @@ -59,11 +61,23 @@ const initialState: State = { shouldStop: false } -export const useMediaStore = create()((_set, _get) => ({ - ...initialState, - set: (state) => { _set({...state} )}, - getPositionMillis: () => ticksToMs(_get().positionTicks), - reset: () => { - _set({ ...initialState }) - } -})) \ No newline at end of file +const persistKeys = Object.keys(initialState) + +export const useMediaStore = create()( + persist( + (_set, _get) => ({ + ...initialState, + set: (state) => { _set({ ...state }) }, + getPositionMillis: () => ticksToMs(_get().positionTicks), + reset: () => { + _set({ ...initialState }) + } + }), { + name: 'MediaStore', + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => Object.fromEntries( + Object.entries(state).filter(([key]) => persistKeys.includes(key) ) + ) + } + ) +) \ No newline at end of file diff --git a/stores/RootStore.ts b/stores/RootStore.ts index 2ee1bb886..8985ffcb8 100644 --- a/stores/RootStore.ts +++ b/stores/RootStore.ts @@ -13,6 +13,8 @@ import { v4 as uuidv4 } from 'uuid'; import { getAppName, getSafeDeviceName } from '../utils/Device'; import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; type State = { deviceId: string, @@ -38,26 +40,38 @@ const initialState: State = { didPlayerCloseManually: true, } -export const useRootStore = create()((_set, _get) => ({ - ...initialState, - set: (state) => { _set({...state} )}, - getApi: () => new Jellyfin({ - clientInfo: { - name: getAppName(), - version: Constants.nativeAppVersion - }, - deviceInfo: { - name: getSafeDeviceName(), - id: _get().deviceId +const persistKeys = Object.keys(initialState) + +export const useRootStore = create()( + persist( + (_set, _get) => ({ + ...initialState, + set: (state) => { _set({ ...state }) }, + getApi: () => new Jellyfin({ + clientInfo: { + name: getAppName(), + version: Constants.nativeAppVersion + }, + deviceInfo: { + name: getSafeDeviceName(), + id: _get().deviceId + } + }), + reset: () => { // TODO: Confirm instances of this reset call reset all the other states as well + _set({ + deviceId: uuidv4(), + isFullscreen: false, + isReloadRequired: false, + didPlayerCloseManually: true, + storeLoaded: true, + }) + }, + }), { + name: 'RootStore', + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => Object.fromEntries( + Object.entries(state).filter(([key]) => persistKeys.includes(key) ) + ) } - }), - reset: () => { // TODO: Confirm instances of this reset call reset all the other states as well - _set({ - deviceId: uuidv4(), - isFullscreen: false, - isReloadRequired: false, - didPlayerCloseManually: true, - storeLoaded: true, - }) - }, -})) \ No newline at end of file + ) +) \ No newline at end of file diff --git a/stores/ServerStore.ts b/stores/ServerStore.ts index 1b5df41b3..0b8d2e0dc 100644 --- a/stores/ServerStore.ts +++ b/stores/ServerStore.ts @@ -7,6 +7,8 @@ import { v4 as uuidv4 } from 'uuid'; import ServerModel from '../models/ServerModel'; import { create } from 'zustand'; +import { createJSONStorage, persist, PersistStorage, StorageValue } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; // TODO: `data: any[]` is probably not the best choice here. export const DESERIALIZER = (data: any[]) => data.map(server => { @@ -21,6 +23,7 @@ type State = { } type Actions = { + set: (v: Partial) => void, addServer: (v: ServerModel) => void, removeServer: (v: number) => void, reset: () => void, @@ -29,26 +32,78 @@ type Actions = { export type ServerStore = State & Actions -const initialState: State = { - servers: [] -} +// This is needed to properly deserialize URL objects from their strings +const storage: PersistStorage = { + getItem: async function (name: string): Promise> { + const data: any = JSON.parse(await AsyncStorage.getItem(name)).state + console.log('DownloadModel Deserializer, data', data) + + const deserialized: ServerModel[] = []; + + for (const value of data.servers) { + //@ts-ignore This is mostly to coerce the type and please the editor + // Migrate from old url format + + // TODO: Remove migration in next minor release + + // TODO: I copied this from the old deserializer, unsure of the correct + // timeline on removing the deprecated code so I preserved it ~enigma + const url = value.url.href || value.url; + + deserialized.push(new ServerModel(value.id, new URL(url), value.info)); + } -export const useServerStore = create()((_set, _get) => ({ - ...initialState, - addServer: (server) => { - const servers = _get().servers - servers.push(new ServerModel(uuidv4(), server.url)) - _set({ servers }) + return { + state: { + servers: deserialized + } + } }, - removeServer: (index) => { - const servers = _get().servers - servers.splice(index, 1) - _set({ servers }) + setItem: function (name: string, value: StorageValue): void { + const serialized = JSON.stringify({ + servers: value.state.servers + }) + AsyncStorage.setItem(name, serialized) }, - reset: () => _set({servers: []}), - fetchInfo: async () => { - await Promise.all( - _get().servers.map(server => server.fetchInfo()) - ) + removeItem: function (name: string): void { + AsyncStorage.removeItem(name) } -})) \ No newline at end of file +} + +const initialState: State = { + servers: [] +} + +const persistKeys = Object.keys(initialState) + +export const useServerStore = create()( + persist( + (_set, _get) => ({ + ...initialState, + set: (state) => { _set({ ...state }) }, + addServer: (server) => { + const servers = _get().servers + servers.push(new ServerModel(uuidv4(), server.url)) + _set({ servers }) + }, + removeServer: (index) => { + const servers = _get().servers + servers.splice(index, 1) + _set({ servers }) + }, + reset: () => _set({ servers: [] }), + fetchInfo: async () => { + await Promise.all( + _get().servers.map(server => server.fetchInfo()) + ) + } + }), { + name: 'ServerStore', + // storage: createJSONStorage(() => AsyncStorage), + storage: storage, + partialize: (state) => Object.fromEntries( + Object.entries(state).filter(([key]) => persistKeys.includes(key) ) + ) + } + ) +) \ No newline at end of file diff --git a/stores/SettingStore.ts b/stores/SettingStore.ts index c76f72bb7..5988bdea6 100644 --- a/stores/SettingStore.ts +++ b/stores/SettingStore.ts @@ -5,9 +5,11 @@ */ import compareVersions from 'compare-versions'; import { Platform } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import Themes from '../themes'; import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; type State = { /** The id of the currently selected server */ @@ -52,11 +54,12 @@ type Actions = { export type SettingStore = State & Actions +// This initial state must be a method because it has computed values that *might* change over time (tests & functionality broke without this) const initialState: () => State = () => ({ activeServer: 0, isRotationLockEnabled: Platform.OS === 'ios' && !Platform.isPad, - isScreenLockEnabled: Platform.OS === 'ios' - ? !!Platform.Version && compareVersions.compare(Platform.Version, '14', '<') + isScreenLockEnabled: Platform.OS === 'ios' + ? !!Platform.Version && compareVersions.compare(Platform.Version, '14', '<') : true, isTabLabelsEnabled: true, themeId: 'dark', @@ -68,20 +71,32 @@ const initialState: () => State = () => ({ systemThemeId: null, }) -export const useSettingStore = create()((_set, _get) => ({ - ...initialState(), - set: (state) => { _set({...state} )}, - getTheme: () => { - const state = _get() - const id = state.isSystemThemeEnabled - && state.systemThemeId - && state.systemThemeId !== 'no-preference' - ? state.systemThemeId - : state.themeId; - //@ts-ignore TODO: This is because Themes doesn't have type hints. - return Themes[id] || Themes.dark; - }, - reset: () => { - _set({ ...initialState() }) - } -})) \ No newline at end of file +const persistKeys = Object.keys(initialState()) + +export const useSettingStore = create()( + persist( + (_set, _get) => ({ + ...initialState(), + set: (state) => { _set({ ...state }) }, + getTheme: () => { + const state = _get() + const id = state.isSystemThemeEnabled + && state.systemThemeId + && state.systemThemeId !== 'no-preference' + ? state.systemThemeId + : state.themeId; + //@ts-ignore TODO: This is because Themes doesn't have type hints. + return Themes[id] || Themes.dark; + }, + reset: () => { + _set({ ...initialState() }) + } + }), { + name: 'SettingStore', + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => Object.fromEntries( + Object.entries(state).filter(([key]) => persistKeys.includes(key) ) + ) + } + ) +) \ No newline at end of file From 23722c273339fae02d8e9f9a9e11bf32474f953b Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 15:01:23 -0500 Subject: [PATCH 27/37] Fix refresh web view (which I broke trying to get tests working) --- components/NativeShellWebView.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/NativeShellWebView.js b/components/NativeShellWebView.js index 9de40666d..961ebb431 100644 --- a/components/NativeShellWebView.js +++ b/components/NativeShellWebView.js @@ -19,7 +19,7 @@ import { openBrowser } from '../utils/WebBrowser'; import RefreshWebView from './RefreshWebView'; -const NativeShellWebView = (props) => { +const NativeShellWebView = (props, ref) => { const { rootStore, downloadStore, serverStore, mediaStore, settingStore } = useStores(); const [ isRefreshing, setIsRefreshing ] = useState(false); @@ -162,7 +162,7 @@ true; return ( Date: Wed, 11 Dec 2024 15:02:04 -0500 Subject: [PATCH 28/37] Remove usage of async trunk --- App.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/App.js b/App.js index 738f9f25d..ebd6fe183 100644 --- a/App.js +++ b/App.js @@ -17,7 +17,6 @@ import * as Font from 'expo-font'; import * as ScreenOrientation from 'expo-screen-orientation'; import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; -import { AsyncTrunk } from 'mobx-sync-lite'; import PropTypes from 'prop-types'; import React, { useContext, useEffect, useState } from 'react'; import { Alert, useColorScheme } from 'react-native'; @@ -46,10 +45,6 @@ const App = ({ skipLoadingScreen }) => { SplashScreen.preventAutoHideAsync(); - const trunk = new AsyncTrunk(rootStore, { - storage: AsyncStorage - }); - const hydrateStores = async () => { // TODO: In release n+2 from this point, remove this conversion code. const mobx_store_value = await AsyncStorage.getItem('__mobx_sync__') // Store will be null if it's not set From 00a4abfaa6b511379945de7401b90c6f7850d131 Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 15:08:53 -0500 Subject: [PATCH 29/37] Restore docstrings on root store --- stores/RootStore.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/stores/RootStore.ts b/stores/RootStore.ts index 8985ffcb8..bec2c4910 100644 --- a/stores/RootStore.ts +++ b/stores/RootStore.ts @@ -33,10 +33,19 @@ type Actions = { export type RootStore = State & Actions const initialState: State = { + /** Generate a random unique device id */ deviceId: uuidv4(), + + /** Has the store been loaded from storage */ storeLoaded: false, + + /** Is the fullscreen interface active */ isFullscreen: false, + + /** Does the webview require a reload */ isReloadRequired: false, + + /** Was the native player closed manually */ didPlayerCloseManually: true, } From bf86687d7be208dd8dcbb554136a14aa3b952e68 Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 15:38:19 -0500 Subject: [PATCH 30/37] Fix serializer tests --- stores/DownloadStore.ts | 58 ++++++++++++++------------ stores/ServerStore.ts | 49 +++++++++------------- stores/__tests__/DownloadStore.test.js | 58 ++++++++++++++------------ stores/__tests__/ServerStore.test.js | 47 +++++++++++---------- 4 files changed, 107 insertions(+), 105 deletions(-) diff --git a/stores/DownloadStore.ts b/stores/DownloadStore.ts index bf584afea..6fa2aa5cb 100644 --- a/stores/DownloadStore.ts +++ b/stores/DownloadStore.ts @@ -23,38 +23,42 @@ type Actions = { export type DownloadStore = State & Actions -// This is needed to properly serialize/deserialize Map -const storage: PersistStorage = { - getItem: async function (name: string): Promise> { - const data: any = JSON.parse(await AsyncStorage.getItem(name)).state - console.log('DownloadModel Deserializer, data', data) +export function deserializer(str: string): {state: State} { + const data: any = JSON.parse(str).state - const deserialized = new Map(); + const deserialized = new Map(); - for (const entry of Object.entries(data.downloads)) { - //@ts-ignore This is mostly to coerce the type and please the editor - const [key, value]: [string, DownloadModel] = entry - const model = new DownloadModel( - value.itemId, - value.serverId, - value.serverUrl, - value.apiKey, - value.title, - value.filename, - value.downloadUrl - ) - // Ignore isDownloading - model.isComplete = value.isComplete - model.isNew = value.isNew + for (const entry of Object.entries(data.downloads)) { + //@ts-ignore This is mostly to coerce the type and please the editor + const [key, value]: [string, DownloadModel] = entry + const model = new DownloadModel( + value.itemId, + value.serverId, + value.serverUrl, + value.apiKey, + value.title, + value.filename, + value.downloadUrl + ) + // Ignore isDownloading + model.isComplete = value.isComplete + model.isNew = value.isNew - deserialized.set(key, model) - } + deserialized.set(key, model) + } - return { - state: { - downloads: deserialized - } + return { + state: { + downloads: deserialized } + } +} + +// This is needed to properly serialize/deserialize Map +const storage: PersistStorage = { + getItem: async (name: string): Promise> => { + const data = await AsyncStorage.getItem(name) + return deserializer(data) }, setItem: function (name: string, value: StorageValue): void { const serialized = JSON.stringify({ diff --git a/stores/ServerStore.ts b/stores/ServerStore.ts index 0b8d2e0dc..1388ce429 100644 --- a/stores/ServerStore.ts +++ b/stores/ServerStore.ts @@ -10,14 +10,6 @@ import { create } from 'zustand'; import { createJSONStorage, persist, PersistStorage, StorageValue } from 'zustand/middleware'; import AsyncStorage from '@react-native-async-storage/async-storage'; -// TODO: `data: any[]` is probably not the best choice here. -export const DESERIALIZER = (data: any[]) => data.map(server => { - // Migrate from old url format - // TODO: Remove migration in next minor release - const url = server.url.href || server.url; - return new ServerModel(server.id, new URL(url), server.info); -}); - type State = { servers: ServerModel[], } @@ -32,34 +24,33 @@ type Actions = { export type ServerStore = State & Actions -// This is needed to properly deserialize URL objects from their strings -const storage: PersistStorage = { - getItem: async function (name: string): Promise> { - const data: any = JSON.parse(await AsyncStorage.getItem(name)).state - console.log('DownloadModel Deserializer, data', data) - - const deserialized: ServerModel[] = []; +export function deserializer(str: string): Promise> { + const data: any = JSON.parse(str).state - for (const value of data.servers) { - //@ts-ignore This is mostly to coerce the type and please the editor - // Migrate from old url format + const deserialized: ServerModel[] = []; - // TODO: Remove migration in next minor release + for (const value of data.servers) { + // Migrate from old url format + // TODO: Remove migration in next minor release + const url = value.url.href || value.url; - // TODO: I copied this from the old deserializer, unsure of the correct - // timeline on removing the deprecated code so I preserved it ~enigma - const url = value.url.href || value.url; + deserialized.push(new ServerModel(value.id, new URL(url), value.info)); + } - deserialized.push(new ServerModel(value.id, new URL(url), value.info)); + return { + state: { + servers: deserialized } + } +} - return { - state: { - servers: deserialized - } - } +// This is needed to properly deserialize URL objects from their strings +const storage: PersistStorage = { + getItem: async (name: string): Promise> => { + const data = await AsyncStorage.getItem(name) + return deserializer(data) }, - setItem: function (name: string, value: StorageValue): void { + setItem: (name: string, value: StorageValue) => { const serialized = JSON.stringify({ servers: value.state.servers }) diff --git a/stores/__tests__/DownloadStore.test.js b/stores/__tests__/DownloadStore.test.js index e05aa1aac..e3906446e 100644 --- a/stores/__tests__/DownloadStore.test.js +++ b/stores/__tests__/DownloadStore.test.js @@ -9,7 +9,7 @@ import { act } from '@testing-library/react-native'; import DownloadModel from '../../models/DownloadModel'; -import { useDownloadStore, DESERIALIZER } from '../DownloadStore'; +import { useDownloadStore, DESERIALIZER, deserializer } from '../DownloadStore'; import { renderHook } from '@testing-library/react'; const TEST_MODEL = new DownloadModel( @@ -55,8 +55,8 @@ describe('DownloadStore', () => { }); it('should allow models to be added', () => { - act(() => { - store.result.current.add(TEST_MODEL); + act(() => { + store.result.current.add(TEST_MODEL); }) expect(store.result.current.downloads.size).toBe(1); expect(store.result.current.downloads.get(TEST_MODEL.key)).toBe(TEST_MODEL); @@ -78,7 +78,7 @@ describe('DownloadStore', () => { }); it('should return the number of new downloads', () => { - act(() => { + act(() => { store.result.current.add(TEST_MODEL) store.result.current.add(TEST_MODEL_2) }) @@ -91,34 +91,38 @@ describe('DownloadStore', () => { }); -describe('DESERIALIZER', () => { +describe('DownloadStore deserializer', () => { it('should deserialize to a Map of DownloadModels', () => { const serialized = { - 'server-id_item-id-1': { - itemId: 'item-id-1', - serverId: 'server-id', - serverUrl: 'https://example.com/', - apiKey: 'api-key', - title: 'title 1', - filename: 'file name 1.mkv', - downloadUrl: 'https://example.com/download', - isComplete: false, - isNew: true - }, - 'server-id_item-id-2': { - itemId: 'item-id-2', - serverId: 'server-id', - serverUrl: 'https://example.com/', - apiKey: 'api-key', - title: 'title 2', - filename: 'file name 2.mkv', - downloadUrl: 'https://example.com/download', - isComplete: true, - isNew: false + state: { + downloads: { + 'server-id_item-id-1': { + itemId: 'item-id-1', + serverId: 'server-id', + serverUrl: 'https://example.com/', + apiKey: 'api-key', + title: 'title 1', + filename: 'file name 1.mkv', + downloadUrl: 'https://example.com/download', + isComplete: false, + isNew: true + }, + 'server-id_item-id-2': { + itemId: 'item-id-2', + serverId: 'server-id', + serverUrl: 'https://example.com/', + apiKey: 'api-key', + title: 'title 2', + filename: 'file name 2.mkv', + downloadUrl: 'https://example.com/download', + isComplete: true, + isNew: false + } + } } }; - const deserialized = DESERIALIZER(serialized); + const deserialized = deserializer(JSON.stringify(serialized)).state.downloads; expect(deserialized.size).toBe(2); diff --git a/stores/__tests__/ServerStore.test.js b/stores/__tests__/ServerStore.test.js index a95026958..4012be8a1 100644 --- a/stores/__tests__/ServerStore.test.js +++ b/stores/__tests__/ServerStore.test.js @@ -11,7 +11,7 @@ import { URL } from 'url'; import ServerModel from '../../models/ServerModel'; -import ServerStore, { DESERIALIZER, useServerStore } from '../ServerStore'; +import ServerStore, { deserializer, DESERIALIZER, useServerStore } from '../ServerStore'; import { renderHook } from '@testing-library/react'; import { act } from '@testing-library/react-native'; @@ -37,9 +37,9 @@ describe('ServerStore', () => { it('should allow servers to be added', () => { const store = renderHook(() => useServerStore()) - act(() => { + act(() => { store.result.current.reset() - store.result.current.addServer({ url: new URL('https://foobar') }); + store.result.current.addServer({ url: new URL('https://foobar') }); }) expect(store.result.current.servers).toHaveLength(1); expect(store.result.current.servers[0].id).toBeDefined(); @@ -51,10 +51,10 @@ describe('ServerStore', () => { it('should remove servers by index', () => { const store = renderHook(() => useServerStore()) - act(() => { + act(() => { store.result.current.reset() - store.result.current.addServer({ url: new URL('https://foobar') }); - store.result.current.addServer({ url: new URL('https://baz') }); + store.result.current.addServer({ url: new URL('https://foobar') }); + store.result.current.addServer({ url: new URL('https://baz') }); }) expect(store.result.current.servers).toHaveLength(2); @@ -70,9 +70,9 @@ describe('ServerStore', () => { it('should reset to an empty array', () => { const store = renderHook(() => useServerStore()) - act(() => { + act(() => { store.result.current.reset() - store.result.current.addServer({ url: new URL('https://foobar') }); + store.result.current.addServer({ url: new URL('https://foobar') }); }) expect(store.result.current.servers).toHaveLength(1); @@ -95,30 +95,33 @@ describe('ServerStore', () => { describe('DESERIALIZER', () => { it('should deserialize to a list of ServerModels', () => { - const serialized = [ - { - id: 'TEST1', - url: { href: 'https://1.example.com' }, - info: null - }, - { - id: 'TEST2', - url: 'https://2.example.com/', - info: null + const serialized = { + state: { + servers: [ + { + id: 'TEST1', + url: { href: 'https://1.example.com' }, + info: null + }, { + id: 'TEST2', + url: 'https://2.example.com/', + info: null + } + ] } - ]; + }; - const deserialized = DESERIALIZER(serialized); + const deserialized = deserializer(JSON.stringify(serialized)).state.servers expect(deserialized).toHaveLength(2); expect(deserialized[0]).toBeInstanceOf(ServerModel); - expect(deserialized[0].id).toBe(serialized[0].id); + expect(deserialized[0].id).toBe(serialized.state.servers[0].id); // expect(deserialized[0].url).toBeInstanceOf(URL); // URL != URL Jest (?) expect(deserialized[0].url.href).toBe('https://1.example.com/'); expect(deserialized[1]).toBeInstanceOf(ServerModel); - expect(deserialized[1].id).toBe(serialized[1].id); + expect(deserialized[1].id).toBe(serialized.state.servers[1].id); // expect(deserialized[1].url).toBeInstanceOf(URL); // URL != URL Jest (?) expect(deserialized[1].url.href).toBe('https://2.example.com/'); }); From 7ec4391807d38a861eb5d0e11f88c431ae53e8a6 Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 15:41:51 -0500 Subject: [PATCH 31/37] Fix warnings --- stores/__tests__/DownloadStore.test.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/stores/__tests__/DownloadStore.test.js b/stores/__tests__/DownloadStore.test.js index e3906446e..25cfe54f4 100644 --- a/stores/__tests__/DownloadStore.test.js +++ b/stores/__tests__/DownloadStore.test.js @@ -36,7 +36,9 @@ let store beforeEach(() => { store = renderHook(() => useDownloadStore((state => state))) - store.result.current.reset() + act(() => { + store.result.current.reset() + }) }) describe('DownloadStore', () => { From d7825a6881657c80ca7b695b11a83c601221b7d5 Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 15:47:16 -0500 Subject: [PATCH 32/37] Update tests to use setters --- stores/__tests__/MediaStore.test.js | 20 ++++++++------- stores/__tests__/RootStore.test.js | 8 +++--- stores/__tests__/SettingStore.test.js | 36 +++++++++++++++------------ 3 files changed, 36 insertions(+), 28 deletions(-) diff --git a/stores/__tests__/MediaStore.test.js b/stores/__tests__/MediaStore.test.js index 5876975a9..7e5b83b7b 100644 --- a/stores/__tests__/MediaStore.test.js +++ b/stores/__tests__/MediaStore.test.js @@ -33,15 +33,17 @@ describe('MediaStore', () => { const store = renderHook(() => useMediaStore((state) => state)) act(() => { - store.result.current.type = MediaTypes.Video; - store.result.current.uri = 'https://foobar'; - store.result.current.isFinished = true; - store.result.current.isLocalFile = true; - store.result.current.isPlaying = true; - store.result.current.positionTicks = 3423000; - store.result.current.backdropUri = 'https://foobar'; - store.result.current.shouldPlayPause = true; - store.result.current.shouldStop = true; + store.result.current.set({ + type: MediaTypes.Video, + uri: 'https://foobar', + isFinished: true, + isLocalFile: true, + isPlaying: true, + positionTicks: 3423000, + backdropUri: 'https://foobar', + shouldPlayPause: true, + shouldStop: true, + }) }) expect(store.result.current.type).toBe(MediaTypes.Video); diff --git a/stores/__tests__/RootStore.test.js b/stores/__tests__/RootStore.test.js index 59f503ed5..d3e78bfbb 100644 --- a/stores/__tests__/RootStore.test.js +++ b/stores/__tests__/RootStore.test.js @@ -29,9 +29,11 @@ describe('RootStore', () => { const storeHook = renderHook(() => useRootStore((state) => state)) act(() => { - storeHook.result.current.isFullscreen = true; - storeHook.result.current.isReloadRequired = true; - storeHook.result.current.didPlayerCloseManually = false; + storeHook.result.current.set({ + isFullscreen: true, + isReloadRequired: true, + didPlayerCloseManually: false + }) }) diff --git a/stores/__tests__/SettingStore.test.js b/stores/__tests__/SettingStore.test.js index 29880b080..681ad9c5c 100644 --- a/stores/__tests__/SettingStore.test.js +++ b/stores/__tests__/SettingStore.test.js @@ -9,7 +9,7 @@ import { Platform } from 'react-native'; import Themes from '../../themes'; -import SettingStore, { useSettingStore } from '../SettingStore'; +import { useSettingStore } from '../SettingStore'; import { renderHook } from '@testing-library/react'; import { act } from '@testing-library/react-native'; @@ -72,11 +72,11 @@ describe('SettingStore', () => { it('should use the system theme when enabled', () => { const store = renderHook(() => useSettingStore((state) => state)) act(() => { store.result.current.reset() }) - act(() => { store.result.current.isSystemThemeEnabled = true }) + act(() => { store.result.current.set({isSystemThemeEnabled: true }) }) expect(store.result.current.getTheme()).toBe(Themes.dark); - act(() => { store.result.current.systemThemeId = 'light'; }) + act(() => { store.result.current.set({ systemThemeId: 'light' }) }) expect(store.result.current.getTheme()).toBe(Themes.light); }); @@ -85,9 +85,11 @@ describe('SettingStore', () => { act(() => { store.result.current.reset() }) act(() => { - store.result.current.isSystemThemeEnabled = true; - store.result.current.systemThemeId = 'no-preference'; - store.result.current.themeId = 'light'; + store.result.current.set({ + isSystemThemeEnabled: true, + systemThemeId: 'no-preference', + themeId: 'light' + }) }) expect(store.result.current.getTheme()).toBe(Themes.light); @@ -105,16 +107,18 @@ describe('SettingStore', () => { act(() => { store.result.current.reset() }) act(() => { - store.result.current.activeServer = 99; - store.result.current.isRotationLockEnabled = false; - store.result.current.isScreenLockEnabled = true; - store.result.current.isTabLabelsEnabled = false; - store.result.current.themeId = 'light'; - store.result.current.systemThemeId = 'dark'; - store.result.current.isSystemThemeEnabled = true; - store.result.current.isNativeVideoPlayerEnabled = true; - store.result.current.isFmp4Enabled = false; - store.result.current.isExperimentalDownloadsEnabled = true; + store.result.current.set({ + activeServer: 99, + isRotationLockEnabled: false, + isScreenLockEnabled: true, + isTabLabelsEnabled: false, + themeId: 'light', + systemThemeId: 'dark', + isSystemThemeEnabled: true, + isNativeVideoPlayerEnabled: true, + isFmp4Enabled: false, + isExperimentalDownloadsEnabled: true + }) }) expect(store.result.current.activeServer).toBe(99); From c8184ce4e2f3d002679d057e3048bf70c2e4fa8d Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 15:55:44 -0500 Subject: [PATCH 33/37] Remove mobx packages --- package-lock.json | 34 ---------------------------------- package.json | 4 ---- 2 files changed, 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index 868c65849..6dacad02f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10917,40 +10917,6 @@ "minimist": "^1.2.6" } }, - "mobx": { - "version": "5.15.7", - "resolved": "https://registry.npmjs.org/mobx/-/mobx-5.15.7.tgz", - "integrity": "sha512-wyM3FghTkhmC+hQjyPGGFdpehrcX1KOXsDuERhfK2YbJemkUhEB+6wzEN639T21onxlfYBmriA1PFnvxTUhcKw==" - }, - "mobx-react-lite": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-2.2.2.tgz", - "integrity": "sha512-2SlXALHIkyUPDsV4VTKVR9DW7K3Ksh1aaIv3NrNJygTbhXe2A9GrcKHZ2ovIiOp/BXilOcTYemfHHZubP431dg==", - "requires": {} - }, - "mobx-sync-lite": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mobx-sync-lite/-/mobx-sync-lite-3.1.0.tgz", - "integrity": "sha512-06SRzoura1HJAEcucYrzItOElfD1QFZX/1Pdra9yxVpxqVf7lOmKCW11xjhF6NF2RDllmSLOt4bj4X3Bfo5vrA==", - "requires": { - "tslib": "^2.0.0" - } - }, - "mobx-task": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mobx-task/-/mobx-task-2.0.1.tgz", - "integrity": "sha512-CWAqDYfNi6fKvdaPCO/qbns1VHKVF/yX5MezysieOwld4l+B77XFCDrP+E/W6W46gclPnyVRWllJ0fDYwC7S/g==", - "requires": { - "tslib": "^1.9.3" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/package.json b/package.json index 406552dd5..b47467dc3 100644 --- a/package.json +++ b/package.json @@ -59,10 +59,6 @@ "expo-system-ui": "~1.3.0", "expo-web-browser": "~11.0.0", "i18next": "21.10.0", - "mobx": "5.15.7", - "mobx-react-lite": "2.2.2", - "mobx-sync-lite": "3.1.0", - "mobx-task": "2.0.1", "normalize-url": "7.2.0", "prop-types": "15.8.1", "react": "18.0.0", From 32fad17ade8c13eb9a53c4abaf59318dada11348 Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 16:38:14 -0500 Subject: [PATCH 34/37] Linter fixups --- App.js | 49 +++-- __mocks__/zustand.ts | 82 ++++---- components/AudioPlayer.js | 4 +- components/NativeShellWebView.js | 12 +- components/RefreshWebView.js | 66 +++--- components/ServerInput.js | 198 +++++++++--------- components/ThemeSwitcher.js | 2 +- components/VideoPlayer.js | 16 +- .../__tests__/NativeShellWebView.test.js | 2 +- hooks/useStores.js | 14 +- models/DownloadModel.ts | 2 +- models/ServerModel.js | 7 +- navigation/AppNavigator.js | 4 +- navigation/HomeNavigator.js | 2 +- navigation/SettingsNavigator.js | 2 +- navigation/TabNavigator.js | 2 +- screens/AddServerScreen.js | 2 +- screens/DevSettingsScreen.js | 10 +- screens/DownloadScreen.js | 6 +- screens/HomeScreen.js | 4 +- screens/SettingsScreen.js | 24 +-- screens/__tests__/DownloadScreen.test.js | 18 +- screens/__tests__/HomeScreen.test.js | 2 +- stores/DownloadStore.ts | 60 +++--- stores/MediaStore.ts | 17 +- stores/RootStore.ts | 24 +-- stores/ServerStore.ts | 49 ++--- stores/SettingStore.ts | 25 ++- stores/__tests__/DownloadStore.test.js | 61 +++--- stores/__tests__/MediaStore.test.js | 28 +-- stores/__tests__/RootStore.test.js | 15 +- stores/__tests__/ServerStore.test.js | 48 +++-- stores/__tests__/SettingStore.test.js | 80 ++++--- 33 files changed, 488 insertions(+), 449 deletions(-) diff --git a/App.js b/App.js index ebd6fe183..850bbf8c0 100644 --- a/App.js +++ b/App.js @@ -25,14 +25,14 @@ import { SafeAreaProvider } from 'react-native-safe-area-context'; import ThemeSwitcher from './components/ThemeSwitcher'; import { useStores } from './hooks/useStores'; +import DownloadModel from './models/DownloadModel'; +import ServerModel from './models/ServerModel'; import RootNavigator from './navigation/RootNavigator'; import { ensurePathExists } from './utils/File'; import StaticScriptLoader from './utils/StaticScriptLoader'; // Import i18n configuration import './i18n'; -import ServerModel from './models/ServerModel'; -import DownloadModel from './models/DownloadModel'; const App = ({ skipLoadingScreen }) => { const [ isSplashReady, setIsSplashReady ] = useState(false); @@ -47,37 +47,37 @@ const App = ({ skipLoadingScreen }) => { const hydrateStores = async () => { // TODO: In release n+2 from this point, remove this conversion code. - const mobx_store_value = await AsyncStorage.getItem('__mobx_sync__') // Store will be null if it's not set + const mobx_store_value = await AsyncStorage.getItem('__mobx_sync__'); // Store will be null if it's not set if (mobx_store_value !== null) { - console.info('Migrating mobx store to zustand') - const mobx_store = JSON.parse(mobx_store_value) + console.info('Migrating mobx store to zustand'); + const mobx_store = JSON.parse(mobx_store_value); // Root Store - for (let key of Object.keys(mobx_store).filter(key => key.search('Store') === -1)) { - rootStore.set({key: mobx_store[key]}) + for (const key of Object.keys(mobx_store).filter(k => k.search('Store') === -1)) { + rootStore.set({ key: mobx_store[key] }); } // MediaStore - for (let key of Object.keys(mobx_store.mediaStore).filter(key => key.search('Store') === -1)) { - mediaStore.set({key: mobx_store.mediaStore[key]}) + for (const key of Object.keys(mobx_store.mediaStore)) { + mediaStore.set({ key: mobx_store.mediaStore[key] }); } /** * Server store & download store need some special treatment because they * are not simple key-value pair stores. Each contains one key which is a * list of Model objects that represent the contents of their respective - * stores. + * stores. * * zustand requires a custom storage engine for these for proper * serialization and deserialization (written in each storage's module), * but this code is needed to get them over the hump from mobx to zustand. */ // DownloadStore - const mobxDownloads = mobx_store.downloadStore.downloads - const migratedDownloads = new Map() + const mobxDownloads = mobx_store.downloadStore.downloads; + const migratedDownloads = new Map(); if (Object.keys(mobxDownloads).length > 0) { - for (let [key, value] of Object.getEntries(mobxDownloads).filter(key => key.search('Store') === -1)) { + for (const [ key, value ] of Object.getEntries(mobxDownloads)) { migratedDownloads.set(key, new DownloadModel( value.itemId, value.serverId, @@ -86,26 +86,25 @@ const App = ({ skipLoadingScreen }) => { value.title, value.fileName, value.downloadUrl - )) + )); } } - downloadStore.set({downloads: migratedDownloads}) + downloadStore.set({ downloads: migratedDownloads }); // ServerStore - const mobxServers = mobx_store.serverStore.servers - const migratedServers = [] + const mobxServers = mobx_store.serverStore.servers; + const migratedServers = []; if (Object.keys(mobxServers).length > 0) { - for (let item of mobxServers) { - const url = new URL(item.url) - migratedServers.push(new ServerModel(item.id, new URL(item.url), item.info)) + for (const item of mobxServers) { + migratedServers.push(new ServerModel(item.id, new URL(item.url), item.info)); } } - serverStore.set({servers: migratedServers}) + serverStore.set({ servers: migratedServers }); // SettingStore - for (let key of Object.keys(mobx_store.settingStore).filter(key => key.search('Store') === -1)) { - console.info('SettingStore', key) - settingStore.set({key: mobx_store.settingStore[key]}) + for (const key of Object.keys(mobx_store.settingStore)) { + console.info('SettingStore', key); + settingStore.set({ key: mobx_store.settingStore[key] }); } // TODO: Confirm zustand has objects in async storage @@ -113,7 +112,7 @@ const App = ({ skipLoadingScreen }) => { // AsyncStorage.removeItem('__mobx_sync__') } - rootStore.set({storeLoaded: true}); + rootStore.set({ storeLoaded: true }); }; const loadImages = () => { diff --git a/__mocks__/zustand.ts b/__mocks__/zustand.ts index 5d0d1864b..52466b5f9 100644 --- a/__mocks__/zustand.ts +++ b/__mocks__/zustand.ts @@ -1,65 +1,65 @@ // __mocks__/zustand.ts -import { act } from '@testing-library/react' -import type * as ZustandExportedTypes from 'zustand' -export * from 'zustand' +import { act } from '@testing-library/react'; +import type * as ZustandExportedTypes from 'zustand'; +export * from 'zustand'; const { create: actualCreate, createStore: actualCreateStore } = - jest.requireActual('zustand') + jest.requireActual('zustand'); // a variable to hold reset functions for all stores declared in the app -export const storeResetFns = new Set<() => void>() +export const storeResetFns = new Set<() => void>(); const createUncurried = ( - stateCreator: ZustandExportedTypes.StateCreator, + stateCreator: ZustandExportedTypes.StateCreator ) => { - const store = actualCreate(stateCreator) - const initialState = store.getInitialState() - storeResetFns.add(() => { - store.setState(initialState, true) - }) - return store -} + const store = actualCreate(stateCreator); + const initialState = store.getInitialState(); + storeResetFns.add(() => { + store.setState(initialState, true); + }); + return store; +}; // when creating a store, we get its initial state, create a reset function and add it in the set export const create = (( - stateCreator: ZustandExportedTypes.StateCreator, + stateCreator: ZustandExportedTypes.StateCreator ) => { - console.log('zustand create mock') + console.log('zustand create mock'); - // to support curried version of create - return typeof stateCreator === 'function' - ? createUncurried(stateCreator) - : createUncurried -}) as typeof ZustandExportedTypes.create + // to support curried version of create + return typeof stateCreator === 'function' + ? createUncurried(stateCreator) + : createUncurried; +}) as typeof ZustandExportedTypes.create; const createStoreUncurried = ( - stateCreator: ZustandExportedTypes.StateCreator, + stateCreator: ZustandExportedTypes.StateCreator ) => { - const store = actualCreateStore(stateCreator) - const initialState = store.getInitialState() - storeResetFns.add(() => { - store.setState(initialState, true) - }) - return store -} + const store = actualCreateStore(stateCreator); + const initialState = store.getInitialState(); + storeResetFns.add(() => { + store.setState(initialState, true); + }); + return store; +}; // when creating a store, we get its initial state, create a reset function and add it in the set export const createStore = (( - stateCreator: ZustandExportedTypes.StateCreator, + stateCreator: ZustandExportedTypes.StateCreator ) => { - console.log('zustand createStore mock') + console.log('zustand createStore mock'); - // to support curried version of createStore - return typeof stateCreator === 'function' - ? createStoreUncurried(stateCreator) - : createStoreUncurried -}) as typeof ZustandExportedTypes.createStore + // to support curried version of createStore + return typeof stateCreator === 'function' + ? createStoreUncurried(stateCreator) + : createStoreUncurried; +}) as typeof ZustandExportedTypes.createStore; // reset all stores after each test run afterEach(() => { - act(() => { - storeResetFns.forEach((resetFn) => { - resetFn() - }) - }) -}) + act(() => { + storeResetFns.forEach((resetFn) => { + resetFn(); + }); + }); +}); diff --git a/components/AudioPlayer.js b/components/AudioPlayer.js index 341bc8480..5ed1aec92 100644 --- a/components/AudioPlayer.js +++ b/components/AudioPlayer.js @@ -86,7 +86,7 @@ const AudioPlayer = () => { } else { player?.playAsync(); } - mediaStore.set({shouldPlayPause: false}); + mediaStore.set({ shouldPlayPause: false }); } }, [ mediaStore.shouldPlayPause ]); @@ -95,7 +95,7 @@ const AudioPlayer = () => { if (mediaStore.type === MediaTypes.Audio && mediaStore.shouldStop) { player?.stopAsync(); player?.unloadAsync(); - mediaStore.set({shouldStop: false}); + mediaStore.set({ shouldStop: false }); } }, [ mediaStore.shouldStop ]); diff --git a/components/NativeShellWebView.js b/components/NativeShellWebView.js index 961ebb431..566b239b8 100644 --- a/components/NativeShellWebView.js +++ b/components/NativeShellWebView.js @@ -67,7 +67,7 @@ true; if (rootStore.isFullscreen) return; // Stop media playback in native players - mediaStore.set({shouldStop: true}); + mediaStore.set({ shouldStop: true }); setIsRefreshing(true); ref.current?.reload(); @@ -82,10 +82,10 @@ true; BackHandler.exitApp(); break; case 'enableFullscreen': - rootStore.set({isFullscreen: true}); + rootStore.set({ isFullscreen: true }); break; case 'disableFullscreen': - rootStore.set({isFullscreen: false}); + rootStore.set({ isFullscreen: false }); break; case 'downloadFile': console.log('Download item', data); @@ -131,11 +131,11 @@ true; break; case 'ExpoAudioPlayer.playPause': case 'ExpoVideoPlayer.playPause': - mediaStore.set({shouldPlayPause: true}); + mediaStore.set({ shouldPlayPause: true }); break; case 'ExpoAudioPlayer.stop': case 'ExpoVideoPlayer.stop': - mediaStore.set({shouldStop: true}); + mediaStore.set({ shouldStop: true }); break; case 'console.debug': // console.debug('[Browser Console]', data); @@ -187,6 +187,6 @@ true; showsHorizontalScrollIndicator={false} /> ); -} +}; export default React.forwardRef(NativeShellWebView); diff --git a/components/RefreshWebView.js b/components/RefreshWebView.js index 84e786df5..86098cfef 100644 --- a/components/RefreshWebView.js +++ b/components/RefreshWebView.js @@ -10,41 +10,41 @@ import { ScrollView } from 'react-native-gesture-handler'; import { WebView } from 'react-native-webview'; const RefreshWebView = function RefreshWebView({ isRefreshing, onRefresh, refreshControlProps, ...webViewProps }, ref) { - const [ height, setHeight ] = useState(Dimensions.get('screen').height); - const [ isEnabled, setEnabled ] = useState(typeof onRefresh === 'function'); + const [ height, setHeight ] = useState(Dimensions.get('screen').height); + const [ isEnabled, setEnabled ] = useState(typeof onRefresh === 'function'); - return ( - setHeight(e.nativeEvent.layout.height)} - refreshControl={ - - } - showsVerticalScrollIndicator={false} - showsHorizontalScrollIndicator={false} - style={styles.view}> - - setEnabled( - typeof onRefresh === 'function' && - e.nativeEvent.contentOffset.y === 0 - ) - } - style={{ - ...styles.view, - height, - ...webViewProps.style - }} + return ( + setHeight(e.nativeEvent.layout.height)} + refreshControl={ + - - ); -} + } + showsVerticalScrollIndicator={false} + showsHorizontalScrollIndicator={false} + style={styles.view}> + + setEnabled( + typeof onRefresh === 'function' && + e.nativeEvent.contentOffset.y === 0 + ) + } + style={{ + ...styles.view, + height, + ...webViewProps.style + }} + /> + + ); +}; RefreshWebView.propTypes = { isRefreshing: PropTypes.bool.isRequired, diff --git a/components/ServerInput.js b/components/ServerInput.js index 4e48a0b7a..f5f24c1e4 100644 --- a/components/ServerInput.js +++ b/components/ServerInput.js @@ -19,114 +19,114 @@ const sanitizeHost = (url = '') => url.trim(); // FIXME: eslint fails to parse the propTypes properly here const ServerInput = function ServerInput({ - onError = () => { /* noop */ }, // eslint-disable-line react/prop-types - onSuccess = () => { /* noop */ }, // eslint-disable-line react/prop-types - ...props - }, ref) { - const [ host, setHost ] = useState(''); - const [ isValidating, setIsValidating ] = useState(false); - const [ isValid, setIsValid ] = useState(true); - const [ validationMessage, setValidationMessage ] = useState(''); + onError = () => { /* noop */ }, // eslint-disable-line react/prop-types + onSuccess = () => { /* noop */ }, // eslint-disable-line react/prop-types + ...props +}, ref) { + const [ host, setHost ] = useState(''); + const [ isValidating, setIsValidating ] = useState(false); + const [ isValid, setIsValid ] = useState(true); + const [ validationMessage, setValidationMessage ] = useState(''); - const { rootStore, serverStore, settingStore } = useStores(); - const navigation = useNavigation(); - const { t } = useTranslation(); - const { theme } = useContext(ThemeContext); + const { serverStore, settingStore } = useStores(); + const navigation = useNavigation(); + const { t } = useTranslation(); + const { theme } = useContext(ThemeContext); - const onAddServer = async () => { - console.log('add server', host); - if (!host) { - setIsValid(false); - setValidationMessage(t('addServer.validation.empty')); - onError(); - return; - } + const onAddServer = async () => { + console.log('add server', host); + if (!host) { + setIsValid(false); + setValidationMessage(t('addServer.validation.empty')); + onError(); + return; + } - setIsValidating(true); - setIsValid(true); - setValidationMessage(''); + setIsValidating(true); + setIsValid(true); + setValidationMessage(''); - // Parse the entered url - let url; - try { - url = parseUrl(host); - console.log('parsed url', url); - } catch (err) { - console.info(err); - setIsValidating(false); - setIsValid(false); - setValidationMessage(t('addServer.validation.invalid')); - onError(); - return; - } + // Parse the entered url + let url; + try { + url = parseUrl(host); + console.log('parsed url', url); + } catch (err) { + console.info(err); + setIsValidating(false); + setIsValid(false); + setValidationMessage(t('addServer.validation.invalid')); + onError(); + return; + } - // Validate the server is available - const validation = await validateServer({ url }); - console.log(`Server is ${validation.isValid ? '' : 'not '}valid`); - if (!validation.isValid) { - const message = validation.message || 'invalid'; - setIsValidating(false); - setIsValid(validation.isValid); - setValidationMessage(t([ `addServer.validation.${message}`, 'addServer.validation.invalid' ])); - onError(); - return; - } + // Validate the server is available + const validation = await validateServer({ url }); + console.log(`Server is ${validation.isValid ? '' : 'not '}valid`); + if (!validation.isValid) { + const message = validation.message || 'invalid'; + setIsValidating(false); + setIsValid(validation.isValid); + setValidationMessage(t([ `addServer.validation.${message}`, 'addServer.validation.invalid' ])); + onError(); + return; + } - // Save the server details - serverStore.addServer({ url }); - settingStore.set({activeServer: serverStore.servers.length - 1}); - // Call the success callback - onSuccess(); + // Save the server details + serverStore.addServer({ url }); + settingStore.set({ activeServer: serverStore.servers.length - 1 }); + // Call the success callback + onSuccess(); - // Navigate to the main screen - navigation.replace( - Screens.MainScreen, - { - screen: Screens.HomeTab, - params: { - screen: Screens.HomeScreen, - params: { activeServer: settingStore.activeServer } - } + // Navigate to the main screen + navigation.replace( + Screens.MainScreen, + { + screen: Screens.HomeTab, + params: { + screen: Screens.HomeScreen, + params: { activeServer: settingStore.activeServer } } - ); - }; - - return ( - : null} - selectionColor={theme.colors.primary} - autoCapitalize='none' - autoCorrect={false} - autoCompleteType='off' - autoFocus={true} - keyboardType={Platform.OS === 'ios' ? 'url' : 'default'} - returnKeyType='go' - textContentType='URL' - editable={!isValidating} - value={host} - errorMessage={isValid ? null : validationMessage} - onChangeText={text => setHost(sanitizeHost(text))} - onSubmitEditing={() => onAddServer()} - {...props} - /> + } ); -} + }; + + return ( + : null} + selectionColor={theme.colors.primary} + autoCapitalize='none' + autoCorrect={false} + autoCompleteType='off' + autoFocus={true} + keyboardType={Platform.OS === 'ios' ? 'url' : 'default'} + returnKeyType='go' + textContentType='URL' + editable={!isValidating} + value={host} + errorMessage={isValid ? null : validationMessage} + onChangeText={text => setHost(sanitizeHost(text))} + onSubmitEditing={() => onAddServer()} + {...props} + /> + ); +}; ServerInput.propTypes = { onError: PropTypes.func, diff --git a/components/ThemeSwitcher.js b/components/ThemeSwitcher.js index a5a6dc112..39088fc8f 100644 --- a/components/ThemeSwitcher.js +++ b/components/ThemeSwitcher.js @@ -15,7 +15,7 @@ import { useStores } from '../hooks/useStores'; * replaceTheme when the theme value in the store changes. */ const ThemeSwitcher = () => { - const { rootStore, settingStore } = useStores(); + const { settingStore } = useStores(); const { replaceTheme } = useContext(ThemeContext); useEffect(() => { diff --git a/components/VideoPlayer.js b/components/VideoPlayer.js index 17518de43..9e5649553 100644 --- a/components/VideoPlayer.js +++ b/components/VideoPlayer.js @@ -31,7 +31,7 @@ const VideoPlayer = () => { // Update the player when media type or uri changes useEffect(() => { if (mediaStore.type === MediaTypes.Video) { - rootStore.set({didPlayerCloseManually: true}); + rootStore.set({ didPlayerCloseManually: true }); player.current?.loadAsync({ uri: mediaStore.uri }, { @@ -49,16 +49,16 @@ const VideoPlayer = () => { } else { player.current?.playAsync(); } - mediaStore.set({shouldPlayPause: false}); + mediaStore.set({ shouldPlayPause: false }); } }, [ mediaStore.shouldPlayPause ]); // Close the player when the store indicates it should stop playback useEffect(() => { if (mediaStore.type === MediaTypes.Video && mediaStore.shouldStop) { - rootStore.set({didPlayerCloseManually: false}); + rootStore.set({ didPlayerCloseManually: false }); closeFullscreen(); - mediaStore.set({shouldStop: false}); + mediaStore.set({ shouldStop: false }); } }, [ mediaStore.shouldStop ]); @@ -91,7 +91,7 @@ const VideoPlayer = () => { onReadyForDisplay={openFullscreen} onPlaybackStatusUpdate={({ isPlaying, positionMillis, didJustFinish }) => { if (didJustFinish) { - rootStore.set({didPlayerCloseManually: false}); + rootStore.set({ didPlayerCloseManually: false }); closeFullscreen(); return; } @@ -104,7 +104,7 @@ const VideoPlayer = () => { switch (fullscreenUpdate) { case VideoFullscreenUpdate.PLAYER_WILL_PRESENT: setIsPresenting(true); - rootStore.set({isFullscreen: true}); + rootStore.set({ isFullscreen: true }); break; case VideoFullscreenUpdate.PLAYER_DID_PRESENT: setIsPresenting(false); @@ -114,7 +114,7 @@ const VideoPlayer = () => { break; case VideoFullscreenUpdate.PLAYER_DID_DISMISS: setIsDismissing(false); - rootStore.set({isFullscreen: false}); + rootStore.set({ isFullscreen: false }); mediaStore.reset(); player.current?.unloadAsync() .catch(console.debug); @@ -127,6 +127,6 @@ const VideoPlayer = () => { }} /> ); -} +}; export default VideoPlayer; diff --git a/components/__tests__/NativeShellWebView.test.js b/components/__tests__/NativeShellWebView.test.js index 26ac333cb..35dfb795b 100644 --- a/components/__tests__/NativeShellWebView.test.js +++ b/components/__tests__/NativeShellWebView.test.js @@ -24,7 +24,7 @@ useStores.mockImplementation(() => ({ Version: '10.8.0' } }] - }, + } } )); diff --git a/hooks/useStores.js b/hooks/useStores.js index 77cb96832..1c8972739 100644 --- a/hooks/useStores.js +++ b/hooks/useStores.js @@ -10,10 +10,10 @@ import { useServerStore } from '../stores/ServerStore'; import { useSettingStore } from '../stores/SettingStore'; // Compatibility for zustand conversion -export const useStores = () => ({ - rootStore: useRootStore(), - downloadStore: useDownloadStore(), - serverStore: useServerStore(), - mediaStore: useMediaStore(), - settingStore: useSettingStore() -}) +export const useStores = () => ({ + rootStore: useRootStore(), + downloadStore: useDownloadStore(), + serverStore: useServerStore(), + mediaStore: useMediaStore(), + settingStore: useSettingStore() +}); diff --git a/models/DownloadModel.ts b/models/DownloadModel.ts index 7127617d1..ad0d79ef4 100644 --- a/models/DownloadModel.ts +++ b/models/DownloadModel.ts @@ -73,4 +73,4 @@ export default class DownloadModel { }); return new URL(`${this.serverUrl}Videos/${this.itemId}/stream.mp4?${streamParams.toString()}`); } -} \ No newline at end of file +} diff --git a/models/ServerModel.js b/models/ServerModel.js index e172d231c..4c00588d0 100644 --- a/models/ServerModel.js +++ b/models/ServerModel.js @@ -34,8 +34,8 @@ export default class ServerModel { } /** - * Development note -- this was originally wrapped in mobx task(), which - * provides some state tracking on asynchronous operations. This has been + * Development note -- this was originally wrapped in mobx task(), which + * provides some state tracking on asynchronous operations. This has been * re-implemented with a direct async call, but if the .pending property is * actively needed, a fetch hook will need to be written around this to track * the status of the request. @@ -45,10 +45,11 @@ export default class ServerModel { .then((info) => { this.online = true; this.info = info; + return; }) .catch((err) => { console.warn(err); this.online = false; }); } -} \ No newline at end of file +} diff --git a/navigation/AppNavigator.js b/navigation/AppNavigator.js index 7a8187987..7c9e82cef 100644 --- a/navigation/AppNavigator.js +++ b/navigation/AppNavigator.js @@ -18,7 +18,7 @@ import TabNavigator from './TabNavigator'; const AppStack = createStackNavigator(); const AppNavigator = () => { - const { rootStore, serverStore } = useStores(); + const { serverStore } = useStores(); const { t } = useTranslation(); // Ensure the splash screen is hidden when loading is finished @@ -58,6 +58,6 @@ const AppNavigator = () => { /> ); -} +}; export default AppNavigator; diff --git a/navigation/HomeNavigator.js b/navigation/HomeNavigator.js index ccfe9b220..f4c74af35 100644 --- a/navigation/HomeNavigator.js +++ b/navigation/HomeNavigator.js @@ -35,6 +35,6 @@ const HomeNavigator = () => { /> ); -} +}; export default HomeNavigator; diff --git a/navigation/SettingsNavigator.js b/navigation/SettingsNavigator.js index 6daa070e5..df8640178 100644 --- a/navigation/SettingsNavigator.js +++ b/navigation/SettingsNavigator.js @@ -35,6 +35,6 @@ const SettingsNavigator = () => { /> ); -} +}; export default SettingsNavigator; diff --git a/navigation/TabNavigator.js b/navigation/TabNavigator.js index 8362098ba..8095dd20e 100644 --- a/navigation/TabNavigator.js +++ b/navigation/TabNavigator.js @@ -95,6 +95,6 @@ const TabNavigator = () => { /> ); -} +}; export default TabNavigator; diff --git a/screens/AddServerScreen.js b/screens/AddServerScreen.js index 7c1bbfe4a..5f42c9e93 100644 --- a/screens/AddServerScreen.js +++ b/screens/AddServerScreen.js @@ -18,7 +18,7 @@ import { getIconName } from '../utils/Icons'; const AddServerScreen = () => { const navigation = useNavigation(); const { t } = useTranslation(); - const { rootStore, settingStore } = useStores(); + const { settingStore } = useStores(); const { theme } = useContext(ThemeContext); return ( diff --git a/screens/DevSettingsScreen.js b/screens/DevSettingsScreen.js index 2cc88800d..6945d94b6 100644 --- a/screens/DevSettingsScreen.js +++ b/screens/DevSettingsScreen.js @@ -36,8 +36,8 @@ const DevSettingsScreen = () => { }, value: settingStore.isExperimentalNativeAudioPlayerEnabled, onValueChange: (value) => { - settingStore.set({isExperimentalNativeAudioPlayerEnabled: value}); - rootStore.set({isReloadRequired: true}); + settingStore.set({ isExperimentalNativeAudioPlayerEnabled: value }); + rootStore.set({ isReloadRequired: true }); } }, { @@ -49,8 +49,8 @@ const DevSettingsScreen = () => { }, value: settingStore.isExperimentalDownloadsEnabled, onValueChange: (value) => { - settingStore.set({isExperimentalDownloadsEnabled: value}); - rootStore.set({isReloadRequired: true}); + settingStore.set({ isExperimentalDownloadsEnabled: value }); + rootStore.set({ isReloadRequired: true }); } } ]} @@ -60,7 +60,7 @@ const DevSettingsScreen = () => { /> ); -} +}; const styles = StyleSheet.create({ container: { diff --git a/screens/DownloadScreen.js b/screens/DownloadScreen.js index f6d7be38d..2006bf354 100644 --- a/screens/DownloadScreen.js +++ b/screens/DownloadScreen.js @@ -108,9 +108,9 @@ const DownloadScreen = () => { }, [ downloadStore.downloads ]) ); - const downloadList = [] - console.log('downloads', downloadStore.downloads) - downloadStore.downloads.forEach(download => downloadList.push(download)) + const downloadList = []; + console.log('downloads', downloadStore.downloads); + downloadStore.downloads.forEach(download => downloadList.push(download)); return ( { useEffect(() => { if (rootStore.isReloadRequired) { webview.current?.reload(); - rootStore.set({isReloadRequired: false}); + rootStore.set({ isReloadRequired: false }); } }, [ rootStore.isReloadRequired ]); @@ -220,7 +220,7 @@ const HomeScreen = () => { )} ); -} +}; const styles = StyleSheet.create({ container: { diff --git a/screens/SettingsScreen.js b/screens/SettingsScreen.js index 2b785398e..1126b3ea0 100644 --- a/screens/SettingsScreen.js +++ b/screens/SettingsScreen.js @@ -48,7 +48,7 @@ const SettingsScreen = () => { onPress: () => { // Remove server and update active server serverStore.removeServer(index); - settingStore.set({activeServer: 0}); + settingStore.set({ activeServer: 0 }); if (serverStore.servers.length > 0) { // More servers exist, navigate home @@ -66,7 +66,7 @@ const SettingsScreen = () => { }; const onSelectServer = (index) => { - settingStore.set({activeServer: index}); + settingStore.set({ activeServer: index }); navigation.replace(Screens.HomeScreen); navigation.navigate(Screens.HomeTab); }; @@ -112,7 +112,7 @@ const SettingsScreen = () => { key: 'keep-awake-switch', title: t('settings.keepAwake'), value: settingStore.isScreenLockEnabled, - onValueChange: (value) => settingStore.set({isScreenLockEnabled: value}) + onValueChange: (value) => settingStore.set({ isScreenLockEnabled: value }) }]; // Orientation lock is not supported on iPad without disabling multitasking @@ -122,7 +122,7 @@ const SettingsScreen = () => { key: 'rotation-lock-switch', title: t('settings.rotationLock'), value: settingStore.isRotationLockEnabled, - onValueChange: (value) => settingStore.set({isRotationLockEnabled: value}) + onValueChange: (value) => settingStore.set({ isRotationLockEnabled: value }) }); } @@ -138,8 +138,8 @@ const SettingsScreen = () => { }, value: settingStore.isNativeVideoPlayerEnabled, onValueChange: (value) => { - settingStore.set({isNativeVideoPlayerEnabled: value}); - rootStore.set({isReloadRequired: true}); + settingStore.set({ isNativeVideoPlayerEnabled: value }); + rootStore.set({ isReloadRequired: true }); } }); @@ -150,8 +150,8 @@ const SettingsScreen = () => { value: settingStore.isFmp4Enabled, disabled: !settingStore.isNativeVideoPlayerEnabled, onValueChange: (value) => { - settingStore.set({isFmp4Enabled: value}); - rootStore.set({isReloadRequired: true}); + settingStore.set({ isFmp4Enabled: value }); + rootStore.set({ isReloadRequired: true }); } }); } @@ -161,7 +161,7 @@ const SettingsScreen = () => { key: 'tab-labels-switch', title: t('settings.tabLabels'), value: settingStore.isTabLabelsEnabled, - onValueChange: (value) => settingStore.set({isTabLabelsEnabled: value}) + onValueChange: (value) => settingStore.set({ isTabLabelsEnabled: value }) }]; if (isSystemThemeSupported()) { @@ -169,7 +169,7 @@ const SettingsScreen = () => { key: 'system-theme-switch', title: t('settings.systemTheme'), value: settingStore.isSystemThemeEnabled, - onValueChange: (value) => settingStore.set({isSystemThemeEnabled: value}) + onValueChange: (value) => settingStore.set({ isSystemThemeEnabled: value }) }); } @@ -179,7 +179,7 @@ const SettingsScreen = () => { title: t('settings.lightTheme'), disabled: settingStore.isSystemThemeEnabled, value: settingStore.themeId === 'light', - onValueChange: (value) => settingStore.set({themeId: value ? 'light' : 'dark'}) + onValueChange: (value) => settingStore.set({ themeId: value ? 'light' : 'dark' }) }); return [ @@ -279,7 +279,7 @@ const SettingsScreen = () => { /> ); -} +}; const styles = StyleSheet.create({ container: { diff --git a/screens/__tests__/DownloadScreen.test.js b/screens/__tests__/DownloadScreen.test.js index 8294a1718..b3f44e122 100644 --- a/screens/__tests__/DownloadScreen.test.js +++ b/screens/__tests__/DownloadScreen.test.js @@ -2,7 +2,7 @@ * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * + * * @jest-environment jsdom * @jest-environment-options {"url": "https://jestjs.io/"} */ @@ -13,9 +13,7 @@ import React from 'react'; import { useStores } from '../../hooks/useStores'; import DownloadModel from '../../models/DownloadModel'; -import DownloadStore, { useDownloadStore } from '../../stores/DownloadStore'; import DownloadScreen from '../DownloadScreen'; -import { renderHook } from '@testing-library/react'; const mockSetOptions = jest.fn(); jest.mock('@react-navigation/native', () => { @@ -28,7 +26,7 @@ jest.mock('@react-navigation/native', () => { }; }); -DOWNLOAD_1 = new DownloadModel( +const DOWNLOAD_1 = new DownloadModel( 'item-id', 'server-id', 'https://example.com/', @@ -36,9 +34,9 @@ DOWNLOAD_1 = new DownloadModel( 'title', 'file name.mkv', 'https://example.com/download' -) +); -DOWNLOAD_2 = new DownloadModel( +const DOWNLOAD_2 = new DownloadModel( 'item-id-2', 'server-id', 'https://test2.example.com/', @@ -46,12 +44,12 @@ DOWNLOAD_2 = new DownloadModel( 'other title', 'other file name.mkv', 'https://test2.example.com/download' -) +); const mockDownloadStore = { - downloads: new Map([[DOWNLOAD_1.key, DOWNLOAD_1], [DOWNLOAD_2.key, DOWNLOAD_2]]), - add: (v) => act(() => {mockDownloadStore.downloads = new Map([...mockDownloadStore.downloads, [v.key, v]])}) -} + downloads: new Map([[ DOWNLOAD_1.key, DOWNLOAD_1 ], [ DOWNLOAD_2.key, DOWNLOAD_2 ]]), + add: (v) => act(() => { mockDownloadStore.downloads = new Map([ ...mockDownloadStore.downloads, [ v.key, v ]]); }) +}; jest.mock('../../hooks/useStores'); useStores.mockImplementation(() => ({ diff --git a/screens/__tests__/HomeScreen.test.js b/screens/__tests__/HomeScreen.test.js index 9b15302c3..7d40ee002 100644 --- a/screens/__tests__/HomeScreen.test.js +++ b/screens/__tests__/HomeScreen.test.js @@ -62,7 +62,7 @@ describe('HomeScreen', () => { }, settingStore: {}, mediaStore: {}, - serverStore: {}, + serverStore: {} })); const { toJSON } = render( diff --git a/stores/DownloadStore.ts b/stores/DownloadStore.ts index 6fa2aa5cb..88496b21e 100644 --- a/stores/DownloadStore.ts +++ b/stores/DownloadStore.ts @@ -5,32 +5,35 @@ */ import AsyncStorage from '@react-native-async-storage/async-storage'; -import DownloadModel from '../models/DownloadModel'; + import { create } from 'zustand'; -import { persist, PersistStorage, StorageValue } from 'zustand/middleware' +import { persist, PersistStorage, StorageValue } from 'zustand/middleware'; + +import DownloadModel from '../models/DownloadModel'; type State = { - downloads: Map, + downloads: Map, } type Actions = { set: (v: Partial) => void, getNewDownloadCount: () => number, add: (v: DownloadModel) => void, - remove: (v: number) => void, reset: () => void } export type DownloadStore = State & Actions export function deserializer(str: string): {state: State} { - const data: any = JSON.parse(str).state + const data: any = JSON.parse(str).state; const deserialized = new Map(); for (const entry of Object.entries(data.downloads)) { - //@ts-ignore This is mostly to coerce the type and please the editor - const [key, value]: [string, DownloadModel] = entry + // SMH... + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore This is mostly to coerce the type and please the editor + const [ key, value ]: [string, DownloadModel] = entry; const model = new DownloadModel( value.itemId, value.serverId, @@ -39,67 +42,66 @@ export function deserializer(str: string): {state: State} { value.title, value.filename, value.downloadUrl - ) + ); // Ignore isDownloading - model.isComplete = value.isComplete - model.isNew = value.isNew + model.isComplete = value.isComplete; + model.isNew = value.isNew; - deserialized.set(key, model) + deserialized.set(key, model); } return { state: { downloads: deserialized } - } + }; } // This is needed to properly serialize/deserialize Map const storage: PersistStorage = { getItem: async (name: string): Promise> => { - const data = await AsyncStorage.getItem(name) - return deserializer(data) + const data = await AsyncStorage.getItem(name); + return deserializer(data); }, - setItem: function (name: string, value: StorageValue): void { + setItem: function(name: string, value: StorageValue): void { const serialized = JSON.stringify({ downloads: Array.from(value.state.downloads.entries()) - }) - AsyncStorage.setItem(name, serialized) + }); + AsyncStorage.setItem(name, serialized); }, - removeItem: function (name: string): void { - AsyncStorage.removeItem(name) + removeItem: function(name: string): void { + AsyncStorage.removeItem(name); } -} +}; const initialState: State = { - downloads: new Map() -} + downloads: new Map() +}; -const persistKeys = Object.keys(initialState) +const persistKeys = Object.keys(initialState); export const useDownloadStore = create()( persist( (_set, _get) => ({ ...initialState, - set: (state) => { _set({ ...state }) }, + set: (state) => { _set({ ...state }); }, getNewDownloadCount: () => Array .from(_get().downloads.values()) .filter(d => d.isNew) .length, add: (download) => { - const downloads = _get().downloads + const downloads = _get().downloads; if (!downloads.has(download.key)) { - _set({ downloads: new Map([...downloads, [download.key, download]]) }) + _set({ downloads: new Map([ ...downloads, [ download.key, download ]]) }); } }, - remove: (download) => { }, // TODO: Implement this reset: () => _set({ downloads: new Map() }) }), { name: 'DownloadStore', storage: storage, partialize: (state) => Object.fromEntries( - Object.entries(state).filter(([key]) => persistKeys.includes(key) ) + Object.entries(state).filter(([ key ]) => persistKeys.includes(key)) ) } ) -) \ No newline at end of file +); diff --git a/stores/MediaStore.ts b/stores/MediaStore.ts index 7e909d372..f5ebaef43 100644 --- a/stores/MediaStore.ts +++ b/stores/MediaStore.ts @@ -3,10 +3,11 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; + import { ticksToMs } from '../utils/Time'; -import { create } from 'zustand'; -import AsyncStorage from '@react-native-async-storage/async-storage'; type State = { /** The media type being played */ @@ -59,25 +60,25 @@ const initialState: State = { positionTicks: 0, shouldPlayPause: false, shouldStop: false -} +}; -const persistKeys = Object.keys(initialState) +const persistKeys = Object.keys(initialState); export const useMediaStore = create()( persist( (_set, _get) => ({ ...initialState, - set: (state) => { _set({ ...state }) }, + set: (state) => { _set({ ...state }); }, getPositionMillis: () => ticksToMs(_get().positionTicks), reset: () => { - _set({ ...initialState }) + _set({ ...initialState }); } }), { name: 'MediaStore', storage: createJSONStorage(() => AsyncStorage), partialize: (state) => Object.fromEntries( - Object.entries(state).filter(([key]) => persistKeys.includes(key) ) + Object.entries(state).filter(([ key ]) => persistKeys.includes(key)) ) } ) -) \ No newline at end of file +); diff --git a/stores/RootStore.ts b/stores/RootStore.ts index bec2c4910..cd4a44341 100644 --- a/stores/RootStore.ts +++ b/stores/RootStore.ts @@ -7,14 +7,14 @@ import 'react-native-get-random-values'; import { Jellyfin } from '@jellyfin/sdk'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import Constants from 'expo-constants'; import { v4 as uuidv4 } from 'uuid'; -import { getAppName, getSafeDeviceName } from '../utils/Device'; - import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { getAppName, getSafeDeviceName } from '../utils/Device'; type State = { deviceId: string, @@ -46,16 +46,16 @@ const initialState: State = { isReloadRequired: false, /** Was the native player closed manually */ - didPlayerCloseManually: true, -} + didPlayerCloseManually: true +}; -const persistKeys = Object.keys(initialState) +const persistKeys = Object.keys(initialState); export const useRootStore = create()( persist( (_set, _get) => ({ ...initialState, - set: (state) => { _set({ ...state }) }, + set: (state) => { _set({ ...state }); }, getApi: () => new Jellyfin({ clientInfo: { name: getAppName(), @@ -72,15 +72,15 @@ export const useRootStore = create()( isFullscreen: false, isReloadRequired: false, didPlayerCloseManually: true, - storeLoaded: true, - }) - }, + storeLoaded: true + }); + } }), { name: 'RootStore', storage: createJSONStorage(() => AsyncStorage), partialize: (state) => Object.fromEntries( - Object.entries(state).filter(([key]) => persistKeys.includes(key) ) + Object.entries(state).filter(([ key ]) => persistKeys.includes(key)) ) } ) -) \ No newline at end of file +); diff --git a/stores/ServerStore.ts b/stores/ServerStore.ts index 1388ce429..4c969e81f 100644 --- a/stores/ServerStore.ts +++ b/stores/ServerStore.ts @@ -3,12 +3,13 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import AsyncStorage from '@react-native-async-storage/async-storage'; import { v4 as uuidv4 } from 'uuid'; -import ServerModel from '../models/ServerModel'; import { create } from 'zustand'; import { createJSONStorage, persist, PersistStorage, StorageValue } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; + +import ServerModel from '../models/ServerModel'; type State = { servers: ServerModel[], @@ -25,12 +26,12 @@ type Actions = { export type ServerStore = State & Actions export function deserializer(str: string): Promise> { - const data: any = JSON.parse(str).state + const data: any = JSON.parse(str).state; const deserialized: ServerModel[] = []; for (const value of data.servers) { - // Migrate from old url format + // Migrate from old url format // TODO: Remove migration in next minor release const url = value.url.href || value.url; @@ -41,60 +42,60 @@ export function deserializer(str: string): Promise> { state: { servers: deserialized } - } + }; } // This is needed to properly deserialize URL objects from their strings const storage: PersistStorage = { getItem: async (name: string): Promise> => { - const data = await AsyncStorage.getItem(name) - return deserializer(data) + const data = await AsyncStorage.getItem(name); + return deserializer(data); }, setItem: (name: string, value: StorageValue) => { const serialized = JSON.stringify({ servers: value.state.servers - }) - AsyncStorage.setItem(name, serialized) + }); + AsyncStorage.setItem(name, serialized); }, - removeItem: function (name: string): void { - AsyncStorage.removeItem(name) + removeItem: function(name: string): void { + AsyncStorage.removeItem(name); } -} +}; const initialState: State = { servers: [] -} +}; -const persistKeys = Object.keys(initialState) +const persistKeys = Object.keys(initialState); export const useServerStore = create()( persist( (_set, _get) => ({ ...initialState, - set: (state) => { _set({ ...state }) }, + set: (state) => { _set({ ...state }); }, addServer: (server) => { - const servers = _get().servers - servers.push(new ServerModel(uuidv4(), server.url)) - _set({ servers }) + const servers = _get().servers; + servers.push(new ServerModel(uuidv4(), server.url)); + _set({ servers }); }, removeServer: (index) => { - const servers = _get().servers - servers.splice(index, 1) - _set({ servers }) + const servers = _get().servers; + servers.splice(index, 1); + _set({ servers }); }, reset: () => _set({ servers: [] }), fetchInfo: async () => { await Promise.all( _get().servers.map(server => server.fetchInfo()) - ) + ); } }), { name: 'ServerStore', // storage: createJSONStorage(() => AsyncStorage), storage: storage, partialize: (state) => Object.fromEntries( - Object.entries(state).filter(([key]) => persistKeys.includes(key) ) + Object.entries(state).filter(([ key ]) => persistKeys.includes(key)) ) } ) -) \ No newline at end of file +); diff --git a/stores/SettingStore.ts b/stores/SettingStore.ts index 5988bdea6..b1e61b0cb 100644 --- a/stores/SettingStore.ts +++ b/stores/SettingStore.ts @@ -3,14 +3,16 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import AsyncStorage from '@react-native-async-storage/async-storage'; import compareVersions from 'compare-versions'; import { Platform } from 'react-native'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import Themes from '../themes'; import { create } from 'zustand'; + import { createJSONStorage, persist } from 'zustand/middleware'; +import Themes from '../themes'; + type State = { /** The id of the currently selected server */ activeServer: number, @@ -68,35 +70,36 @@ const initialState: () => State = () => ({ isFmp4Enabled: true, isExperimentalNativeAudioPlayerEnabled: false, isExperimentalDownloadsEnabled: false, - systemThemeId: null, -}) + systemThemeId: null +}); -const persistKeys = Object.keys(initialState()) +const persistKeys = Object.keys(initialState()); export const useSettingStore = create()( persist( (_set, _get) => ({ ...initialState(), - set: (state) => { _set({ ...state }) }, + set: (state) => { _set({ ...state }); }, getTheme: () => { - const state = _get() + const state = _get(); const id = state.isSystemThemeEnabled && state.systemThemeId && state.systemThemeId !== 'no-preference' ? state.systemThemeId : state.themeId; - //@ts-ignore TODO: This is because Themes doesn't have type hints. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore TODO: This is because Themes doesn't have type hints. return Themes[id] || Themes.dark; }, reset: () => { - _set({ ...initialState() }) + _set({ ...initialState() }); } }), { name: 'SettingStore', storage: createJSONStorage(() => AsyncStorage), partialize: (state) => Object.fromEntries( - Object.entries(state).filter(([key]) => persistKeys.includes(key) ) + Object.entries(state).filter(([ key ]) => persistKeys.includes(key)) ) } ) -) \ No newline at end of file +); diff --git a/stores/__tests__/DownloadStore.test.js b/stores/__tests__/DownloadStore.test.js index 25cfe54f4..552f623d4 100644 --- a/stores/__tests__/DownloadStore.test.js +++ b/stores/__tests__/DownloadStore.test.js @@ -2,15 +2,16 @@ * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * + * * @jest-environment jsdom * @jest-environment-options {"url": "https://jestjs.io/"} */ +import { renderHook } from '@testing-library/react'; import { act } from '@testing-library/react-native'; + import DownloadModel from '../../models/DownloadModel'; -import { useDownloadStore, DESERIALIZER, deserializer } from '../DownloadStore'; -import { renderHook } from '@testing-library/react'; +import { useDownloadStore, deserializer } from '../DownloadStore'; const TEST_MODEL = new DownloadModel( 'item-id', @@ -32,65 +33,73 @@ const TEST_MODEL_2 = new DownloadModel( 'https://test2.example.com/download' ); -let store - -beforeEach(() => { - store = renderHook(() => useDownloadStore((state => state))) - act(() => { - store.result.current.reset() - }) -}) - describe('DownloadStore', () => { + let store; + beforeEach(() => { + store = renderHook(() => useDownloadStore((state => state))); + act(() => { + store.result.current.reset(); + }); + }); + it('should initialize with an empty map', () => { expect(store.result.current.downloads.size).toBe(0); }); it('should reset', () => { act(() => { - store.result.current.add(TEST_MODEL) - store.result.current.add(TEST_MODEL_2) - }) + store.result.current.add(TEST_MODEL); + store.result.current.add(TEST_MODEL_2); + }); + expect(store.result.current.downloads.size).toBe(2); - act(() => { store.result.current.reset(); }) + + act(() => { + store.result.current.reset(); + }); + expect(store.result.current.downloads.size).toBe(0); }); it('should allow models to be added', () => { act(() => { store.result.current.add(TEST_MODEL); - }) + }); + expect(store.result.current.downloads.size).toBe(1); expect(store.result.current.downloads.get(TEST_MODEL.key)).toBe(TEST_MODEL); - act(() => { store.result.current.add(TEST_MODEL_2); }) + act(() => { + store.result.current.add(TEST_MODEL_2); + }); expect(store.result.current.downloads.size).toBe(2); expect(store.result.current.downloads.get(TEST_MODEL_2.key)).toBe(TEST_MODEL_2); }); it('should prevent duplicate entries', () => { act(() => { - store.result.current.add(TEST_MODEL) - store.result.current.add(TEST_MODEL_2) - }) + store.result.current.add(TEST_MODEL); + store.result.current.add(TEST_MODEL_2); + }); expect(store.result.current.downloads.size).toBe(2); - act(() => { store.result.current.add(TEST_MODEL); }) + act(() => { + store.result.current.add(TEST_MODEL); + }); expect(store.result.current.downloads.size).toBe(2); }); it('should return the number of new downloads', () => { act(() => { - store.result.current.add(TEST_MODEL) - store.result.current.add(TEST_MODEL_2) - }) + store.result.current.add(TEST_MODEL); + store.result.current.add(TEST_MODEL_2); + }); expect(store.result.current.getNewDownloadCount()).toBe(2); TEST_MODEL.isNew = false; expect(store.result.current.getNewDownloadCount()).toBe(1); TEST_MODEL_2.isNew = false; expect(store.result.current.getNewDownloadCount()).toBe(0); }); - }); describe('DownloadStore deserializer', () => { diff --git a/stores/__tests__/MediaStore.test.js b/stores/__tests__/MediaStore.test.js index 7e5b83b7b..9432e7f5f 100644 --- a/stores/__tests__/MediaStore.test.js +++ b/stores/__tests__/MediaStore.test.js @@ -2,22 +2,24 @@ * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * + * * @jest-environment jsdom * @jest-environment-options {"url": "https://jestjs.io/"} */ import { renderHook } from '@testing-library/react'; -import MediaTypes from '../../constants/MediaTypes'; -import MediaStore, { useMediaStore } from '../MediaStore'; + import { act } from '@testing-library/react-native'; +import MediaTypes from '../../constants/MediaTypes'; +import { useMediaStore } from '../MediaStore'; + describe('MediaStore', () => { it('should initialize with default values', () => { - const store = renderHook(() => useMediaStore((state) => state)) + const store = renderHook(() => useMediaStore((state) => state)); - expect(store.result.current.type).toBeNull() - expect(store.result.current.uri).toBeNull() - expect(store.result.current.backdropUri).toBeNull() + expect(store.result.current.type).toBeNull(); + expect(store.result.current.uri).toBeNull(); + expect(store.result.current.backdropUri).toBeNull(); expect(store.result.current.positionTicks).toBe(0); expect(store.result.current.getPositionMillis()).toBe(0); @@ -30,7 +32,7 @@ describe('MediaStore', () => { }); it('should reset to the default values', () => { - const store = renderHook(() => useMediaStore((state) => state)) + const store = renderHook(() => useMediaStore((state) => state)); act(() => { store.result.current.set({ @@ -42,9 +44,9 @@ describe('MediaStore', () => { positionTicks: 3423000, backdropUri: 'https://foobar', shouldPlayPause: true, - shouldStop: true, - }) - }) + shouldStop: true + }); + }); expect(store.result.current.type).toBe(MediaTypes.Video); expect(store.result.current.uri).toBe('https://foobar'); @@ -59,7 +61,9 @@ describe('MediaStore', () => { expect(store.result.current.shouldPlayPause).toBe(true); expect(store.result.current.shouldStop).toBe(true); - act(() => { store.result.current.reset(); }) + act(() => { + store.result.current.reset(); + }); expect(store.result.current.type).toBeNull(); expect(store.result.current.uri).toBeNull(); diff --git a/stores/__tests__/RootStore.test.js b/stores/__tests__/RootStore.test.js index d3e78bfbb..050afe412 100644 --- a/stores/__tests__/RootStore.test.js +++ b/stores/__tests__/RootStore.test.js @@ -2,15 +2,15 @@ * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * + * * @jest-environment jsdom * @jest-environment-options {"url": "https://jestjs.io/"} */ import { Jellyfin } from '@jellyfin/sdk'; -import { useRootStore } from '../RootStore'; +import { renderHook, act } from '@testing-library/react'; -import { renderHook, act } from '@testing-library/react' +import { useRootStore } from '../RootStore'; describe('RootStore', () => { it('should initialize with default values', () => { @@ -26,16 +26,15 @@ describe('RootStore', () => { }); it('should reset to the default values', () => { - const storeHook = renderHook(() => useRootStore((state) => state)) + const storeHook = renderHook(() => useRootStore((state) => state)); act(() => { storeHook.result.current.set({ isFullscreen: true, isReloadRequired: true, didPlayerCloseManually: false - }) - }) - + }); + }); expect(storeHook.result.current.storeLoaded).toBe(false); expect(storeHook.result.current.isFullscreen).toBe(true); @@ -44,7 +43,7 @@ describe('RootStore', () => { act(() => { storeHook.result.current.reset(); - }) + }); expect(storeHook.result.current.storeLoaded).toBe(true); expect(storeHook.result.current.isFullscreen).toBe(false); diff --git a/stores/__tests__/ServerStore.test.js b/stores/__tests__/ServerStore.test.js index 4012be8a1..f089764b7 100644 --- a/stores/__tests__/ServerStore.test.js +++ b/stores/__tests__/ServerStore.test.js @@ -2,19 +2,21 @@ * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * + * * @jest-environment jsdom * @jest-environment-options {"url": "https://jestjs.io/"} */ import { URL } from 'url'; -import ServerModel from '../../models/ServerModel'; - -import ServerStore, { deserializer, DESERIALIZER, useServerStore } from '../ServerStore'; import { renderHook } from '@testing-library/react'; + import { act } from '@testing-library/react-native'; +import ServerModel from '../../models/ServerModel'; + +import { deserializer, useServerStore } from '../ServerStore'; + const mockFetchInfo = jest.fn(); jest.mock('../../models/ServerModel', () => { return class { @@ -31,37 +33,39 @@ jest.mock('../../models/ServerModel', () => { describe('ServerStore', () => { it('should initialize with an empty array', () => { - const store = renderHook(() => useServerStore()) + const store = renderHook(() => useServerStore()); expect(store.result.current.servers).toHaveLength(0); }); it('should allow servers to be added', () => { - const store = renderHook(() => useServerStore()) + const store = renderHook(() => useServerStore()); act(() => { - store.result.current.reset() + store.result.current.reset(); store.result.current.addServer({ url: new URL('https://foobar') }); - }) + }); expect(store.result.current.servers).toHaveLength(1); expect(store.result.current.servers[0].id).toBeDefined(); expect(store.result.current.servers[0].url.host).toBe('foobar'); - act(() => { store.result.current.addServer({ url: new URL('https://baz') }) }) + act(() => { + store.result.current.addServer({ url: new URL('https://baz') }); + }); expect(store.result.current.servers).toHaveLength(2); }); it('should remove servers by index', () => { - const store = renderHook(() => useServerStore()) + const store = renderHook(() => useServerStore()); act(() => { - store.result.current.reset() + store.result.current.reset(); store.result.current.addServer({ url: new URL('https://foobar') }); store.result.current.addServer({ url: new URL('https://baz') }); - }) + }); expect(store.result.current.servers).toHaveLength(2); act(() => { - store.result.current.removeServer(0) - }) + store.result.current.removeServer(0); + }); expect(store.result.current.servers).toHaveLength(1); expect(store.result.current.servers[0].id).toBeDefined(); @@ -69,25 +73,27 @@ describe('ServerStore', () => { }); it('should reset to an empty array', () => { - const store = renderHook(() => useServerStore()) + const store = renderHook(() => useServerStore()); act(() => { - store.result.current.reset() + store.result.current.reset(); store.result.current.addServer({ url: new URL('https://foobar') }); - }) + }); expect(store.result.current.servers).toHaveLength(1); - act(() => { store.result.current.reset() }) + act(() => { + store.result.current.reset(); + }); expect(store.result.current.servers).toHaveLength(0); }); it('should call fetchInfo for each server', () => { - const store = renderHook(() => useServerStore()) + const store = renderHook(() => useServerStore()); act(() => { store.result.current.addServer({ url: new URL('https://foobar') }); store.result.current.addServer({ url: new URL('https://baz') }); store.result.current.fetchInfo(); - }) + }); expect(mockFetchInfo).toHaveBeenCalledTimes(2); }); @@ -111,7 +117,7 @@ describe('DESERIALIZER', () => { } }; - const deserialized = deserializer(JSON.stringify(serialized)).state.servers + const deserialized = deserializer(JSON.stringify(serialized)).state.servers; expect(deserialized).toHaveLength(2); diff --git a/stores/__tests__/SettingStore.test.js b/stores/__tests__/SettingStore.test.js index 681ad9c5c..43e83e0b7 100644 --- a/stores/__tests__/SettingStore.test.js +++ b/stores/__tests__/SettingStore.test.js @@ -2,16 +2,16 @@ * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * + * * @jest-environment jsdom * @jest-environment-options {"url": "https://jestjs.io/"} */ +import { renderHook } from '@testing-library/react'; +import { act } from '@testing-library/react-native'; import { Platform } from 'react-native'; import Themes from '../../themes'; import { useSettingStore } from '../SettingStore'; -import { renderHook } from '@testing-library/react'; -import { act } from '@testing-library/react-native'; jest.mock('react-native/Libraries/Utilities/Platform'); @@ -21,10 +21,10 @@ describe('SettingStore', () => { }); it('should initialize with default values', () => { - const store = renderHook(() => useSettingStore((state) => state)) + const store = renderHook(() => useSettingStore((state) => state)); act(() => { - store.result.current.reset() - }) + store.result.current.reset(); + }); expect(store.result.current.activeServer).toBe(0); expect(store.result.current.isRotationLockEnabled).toBe(true); expect(store.result.current.isScreenLockEnabled).toBe(false); @@ -40,71 +40,85 @@ describe('SettingStore', () => { it('should disable rotation lock for iPad devices', () => { Platform.isPad = true; - const store = renderHook(() => useSettingStore((state) => state)) + const store = renderHook(() => useSettingStore((state) => state)); act(() => { - store.result.current.reset() - }) - store.rerender() + store.result.current.reset(); + }); + store.rerender(); expect(store.result.current.isRotationLockEnabled).toBe(false); }); it('should enable screen lock on older iOS versions', () => { Platform.Version = '13'; - const store = renderHook(() => useSettingStore((state) => state)) + const store = renderHook(() => useSettingStore((state) => state)); act(() => { - store.result.current.reset() - }) + store.result.current.reset(); + }); expect(store.result.current.isScreenLockEnabled).toBe(true); }); it('should enable screen lock on non-iOS platforms', () => { Platform.OS = 'android'; - const store = renderHook(() => useSettingStore((state) => state)) + const store = renderHook(() => useSettingStore((state) => state)); act(() => { - store.result.current.reset() - }) + store.result.current.reset(); + }); expect(store.result.current.isScreenLockEnabled).toBe(true); }); it('should use the system theme when enabled', () => { - const store = renderHook(() => useSettingStore((state) => state)) - act(() => { store.result.current.reset() }) - act(() => { store.result.current.set({isSystemThemeEnabled: true }) }) + const store = renderHook(() => useSettingStore((state) => state)); + act(() => { + store.result.current.reset(); + }); + act(() => { + store.result.current.set({ isSystemThemeEnabled: true }); + }); expect(store.result.current.getTheme()).toBe(Themes.dark); - act(() => { store.result.current.set({ systemThemeId: 'light' }) }) + act(() => { + store.result.current.set({ systemThemeId: 'light' }); + }); expect(store.result.current.getTheme()).toBe(Themes.light); }); it('should use the app theme if system theme is "no-preference"', () => { - const store = renderHook(() => useSettingStore((state) => state)) - act(() => { store.result.current.reset() }) + const store = renderHook(() => useSettingStore((state) => state)); + act(() => { + store.result.current.reset(); + }); act(() => { store.result.current.set({ isSystemThemeEnabled: true, systemThemeId: 'no-preference', themeId: 'light' - }) - }) + }); + }); expect(store.result.current.getTheme()).toBe(Themes.light); }); it('should return the default theme if an invalid theme id is specified', () => { - const store = renderHook(() => useSettingStore((state) => state)) - act(() => { store.result.current.reset() }) - act(() => { store.result.current.themeId = 'invalid' }) + const store = renderHook(() => useSettingStore((state) => state)); + act(() => { + store.result.current.reset(); + }); + act(() => { + store.result.current.themeId = 'invalid'; + }); expect(store.result.current.getTheme()).toBe(Themes.dark); }); it('should reset to the default values', () => { - const store = renderHook(() => useSettingStore((state) => state)) - act(() => { store.result.current.reset() }) + const store = renderHook(() => useSettingStore((state) => state)); + act(() => { + store.result.current.reset(); + }); act(() => { store.result.current.set({ @@ -118,8 +132,8 @@ describe('SettingStore', () => { isNativeVideoPlayerEnabled: true, isFmp4Enabled: false, isExperimentalDownloadsEnabled: true - }) - }) + }); + }); expect(store.result.current.activeServer).toBe(99); expect(store.result.current.isRotationLockEnabled).toBe(false); @@ -133,7 +147,9 @@ describe('SettingStore', () => { expect(store.result.current.isFmp4Enabled).toBe(false); expect(store.result.current.isExperimentalDownloadsEnabled).toBe(true); - act(() => { store.result.current.reset() }) + act(() => { + store.result.current.reset(); + }); expect(store.result.current.activeServer).toBe(0); expect(store.result.current.isRotationLockEnabled).toBe(true); From 8d8ed657bef5bf25e34d4dd41c86c386940fefd4 Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 17:07:20 -0500 Subject: [PATCH 35/37] Clear eslint whitespace errors --- stores/__tests__/SettingStore.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stores/__tests__/SettingStore.test.js b/stores/__tests__/SettingStore.test.js index 43e83e0b7..b2cfcb41a 100644 --- a/stores/__tests__/SettingStore.test.js +++ b/stores/__tests__/SettingStore.test.js @@ -147,8 +147,8 @@ describe('SettingStore', () => { expect(store.result.current.isFmp4Enabled).toBe(false); expect(store.result.current.isExperimentalDownloadsEnabled).toBe(true); - act(() => { - store.result.current.reset(); + act(() => { + store.result.current.reset(); }); expect(store.result.current.activeServer).toBe(0); From 789b3785f1ce3d537111e52d87fafb56c2733c76 Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 17:07:56 -0500 Subject: [PATCH 36/37] Ignore eslint import error (I suspect an outdated version of react native or something similar -- this only happens in ts files --- stores/SettingStore.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/stores/SettingStore.ts b/stores/SettingStore.ts index b1e61b0cb..cae7b505b 100644 --- a/stores/SettingStore.ts +++ b/stores/SettingStore.ts @@ -5,6 +5,13 @@ */ import AsyncStorage from '@react-native-async-storage/async-storage'; import compareVersions from 'compare-versions'; + +// TODO: Fix this import +// Eslint was complaining about this import, maybe because it's coming from +// an outdated module? In any case, I think updating stuff is out of scope +// for the PR in which this is being added. + +// eslint-disable-next-line import/namespace import { Platform } from 'react-native'; import { create } from 'zustand'; From 209a2ac2b0305b9e22a9be7abae3ba071fddb37b Mon Sep 17 00:00:00 2001 From: enigma0Z Date: Wed, 11 Dec 2024 19:17:28 -0500 Subject: [PATCH 37/37] Tag the silly eslint import error with the issue it's tracked under. --- stores/SettingStore.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/stores/SettingStore.ts b/stores/SettingStore.ts index cae7b505b..3dee0cd19 100644 --- a/stores/SettingStore.ts +++ b/stores/SettingStore.ts @@ -6,11 +6,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import compareVersions from 'compare-versions'; -// TODO: Fix this import -// Eslint was complaining about this import, maybe because it's coming from -// an outdated module? In any case, I think updating stuff is out of scope -// for the PR in which this is being added. - +// TODO: Fix this import, this is a bandaid; issue #365 // eslint-disable-next-line import/namespace import { Platform } from 'react-native';