diff --git a/.vscode/settings.json b/.vscode/settings.json index 5b6c03dc1..4013db8fe 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,11 +7,15 @@ "SHOCKWALLET", "Unmount", "autocorrect", + "healthz", + "immer", "luxon", "persistor", - "uuidv", "qrcode", + "reduxjs", "serv", + "shockping", + "uuidv", "videojs", "youtube" ] diff --git a/package-lock.json b/package-lock.json index 62c4cc2ea..b9c096713 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2182,6 +2182,12 @@ "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=" }, + "@types/luxon": { + "version": "1.26.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-1.26.2.tgz", + "integrity": "sha512-2pvzy4LuxBMBBLAbml6PDcJPiIeZQ0Hqj3PE31IxkNI250qeoRMDovTrHXeDkIL4auvtarSdpTkLHs+st43EYQ==", + "dev": true + }, "@types/minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.4.tgz", diff --git a/package.json b/package.json index 791d701c6..e12ab98db 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ }, "devDependencies": { "@types/classnames": "^2.2.11", + "@types/luxon": "^1.26.2", "@types/react-router-dom": "5.x.x", "@types/socket.io-client": "^1.4.35", "eslint-config-prettier": "^8.1.0", diff --git a/src/actions/AuthActions.js b/src/actions/AuthActions.ts similarity index 100% rename from src/actions/AuthActions.js rename to src/actions/AuthActions.ts diff --git a/src/actions/NodeActions.js b/src/actions/NodeActions.js index fd5bbbd13..e26b8ae7f 100644 --- a/src/actions/NodeActions.js +++ b/src/actions/NodeActions.js @@ -163,6 +163,7 @@ export const unlockWallet = ({ alias, password }) => async dispatch => { alias: data.user.alias, authToken: data.authorization, publicKey: data.user.publicKey, + // @ts-expect-error authTokenExpirationDate: decodedToken.exp } }); @@ -188,6 +189,7 @@ export const createAlias = ({ alias, password }) => async dispatch => { alias: data.user.alias, authToken: data.authorization, publicKey: data.user.publicKey, + // @ts-expect-error authTokenExpirationDate: decodedToken.exp } }); @@ -220,6 +222,7 @@ export const createWallet = ({ alias, password }) => async dispatch => { alias: data.user.alias, authToken: data.authorization, publicKey: data.user.publicKey, + // @ts-expect-error authTokenExpirationDate: decodedToken.exp } }); diff --git a/src/pages/Feed/index.js b/src/pages/Feed/index.js index 7bae95383..422e15b18 100644 --- a/src/pages/Feed/index.js +++ b/src/pages/Feed/index.js @@ -7,7 +7,8 @@ import React, { useMemo, useState } from "react"; -import { useDispatch, useSelector } from "react-redux"; +import { useDispatch } from "react-redux"; +import * as Common from "shock-common"; import { processDisplayName } from "../../utils/String"; import { attachMedia } from "../../utils/Torrents"; @@ -16,7 +17,6 @@ import BottomBar from "../../common/BottomBar"; import UserIcon from "./components/UserIcon"; import SendTipModal from "./components/SendTipModal"; import Loader from "../../common/Loader"; -import ShockAvatar from "../../common/ShockAvatar"; import { subscribeFollows } from "../../actions/FeedActions"; @@ -28,9 +28,9 @@ const SharedPost = React.lazy(() => import("../../common/Post/SharedPost")); const FeedPage = () => { const dispatch = useDispatch(); - const follows = useSelector(({ feed }) => feed.follows); - const posts = useSelector(({ feed }) => feed.posts); - const userProfiles = useSelector(({ userProfiles }) => userProfiles); + const follows = Store.useSelector(({ feed }) => feed.follows); + const posts = Store.useSelector(({ feed }) => feed.posts); + const userProfiles = Store.useSelector(({ userProfiles }) => userProfiles); const [tipModalData, setTipModalOpen] = useState(null); const [unlockModalData, setUnlockModalOpen] = useState(null); const { avatar } = Store.useSelector(Store.selectSelfUser); @@ -77,14 +77,8 @@ const FeedPage = () => { }, [dispatch]); useEffect(() => { - const subscription = startFollowsSubscription(); - - return async () => { - const resolvedSubscription = await subscription; - resolvedSubscription.off("*"); - resolvedSubscription.close(); - }; - }, [dispatch]); + startFollowsSubscription(); + }, [dispatch, startFollowsSubscription]); useLayoutEffect(() => { attachMedia( @@ -106,11 +100,15 @@ const FeedPage = () => {
{follows?.map(follow => { const publicKey = follow.user; - const profile = userProfiles[publicKey] ?? {}; + const profile = + userProfiles[publicKey] ?? Common.createEmptyUser(publicKey); return ( ); })} @@ -162,6 +160,8 @@ const FeedPage = () => { openUnlockModal={toggleUnlockModal} // TODO: User online status handling isOnlineNode + tipCounter={undefined} + tipValue={undefined} /> ); diff --git a/src/reducers/README.md b/src/reducers/README.md new file mode 100644 index 000000000..89e4fafb7 --- /dev/null +++ b/src/reducers/README.md @@ -0,0 +1,50 @@ +# Reducers + +## Guidelines + +- Define an state interface, named according to the reducer, e.g. `interface NodeState { foo: string }`. +- Declare an initial state, of the state type, e.g: `const INITIAL_STATE: NodeState = { foo: 'baz' }`. +- Import `AnyAction` from `redux`, this will be the type for the `action` the reducer accepts. +- Either import the actions enum from an action creators file, e.g. `import { NODE_ACTIONS } from '../actions/NodeActions'` or use a single action creator's `.match()` method if it was created via `createAction` from `@reduxjs/toolkit`: + +```ts +import { AnyAction } from "redux"; +import { NODE_ACTIONS, setFoo } from "../actions/NodeActions"; +// ... +const node = (state: NodeState, action: ShockAction) => { + try { + if (action.type === NODE_ACTIONS.setFoo) { + // foo will NOT be typed as string + const { foo } = action.payload; + return { ...state, foo }; + } + if (setFoo.match(action)) { + // foo will be typed as string + const { foo } = action.payload; + return { ...state, foo }; + } + } catch (e) { + logger.error(`Error inside Node reducer:`); + logger.error(e); + } +}; +``` + +- Prefer `immer` over homegrown immutable data, especially for complex updates, the resulting code will be cleaner/shorter/less buggier. Small example: + +```ts +const node = produce((draft: NodeState, action: ShockAction) => { + try { + if (action.type === "setFoo") { + draft.foo = action.data.foo; + } + } catch (e) { + logger.error(`Error inside Node reducer:`); + logger.error(e); + } +}, INITIAL_STATE); +``` + +- Wrap every reducer's body in a try-catch. Inside the catch block, if the reducer can reasonably handle the error, then it should handle it, otherwise, return the current state, always log the error and pre-pend a `logger.error('Error inside X reducer:')` before logging the actual error. + +- Only one default export per file, the reducer, if something else "needs" to be exported, then it doesn't belong in a reducer file. E.g. typings should go into `shock-common`. diff --git a/src/store/index.ts b/src/store/index.ts index de6c2b01c..d00e4415c 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,4 +1,4 @@ -import { createStore, applyMiddleware, compose } from "redux"; +import { createStore, applyMiddleware, compose, AnyAction } from "redux"; import thunk from "redux-thunk"; import rootReducer, { State } from "../reducers"; import { useSelector as origUseSelector } from "react-redux"; @@ -7,6 +7,9 @@ import storage from "redux-persist/lib/storage"; // defaults to localStorage for import Migrations from "./Migrations"; import createMigrate from "redux-persist/es/createMigrate"; import autoMergeLevel2 from "redux-persist/es/stateReconciler/autoMergeLevel2"; +import createSagaMiddleware from "redux-saga"; + +import rootSaga, { _setStore as setSagaStore } from "./sagas"; const persistConfig = { key: "root", @@ -19,18 +22,37 @@ const persistConfig = { }) }; -const persistedReducer = persistReducer(persistConfig, rootReducer); +const persistedReducer = persistReducer( + persistConfig, + rootReducer +); const initializeStore = () => { + const sagaMiddleware = createSagaMiddleware(); + const appliedMiddleware = applyMiddleware(thunk, sagaMiddleware); // @ts-expect-error const store = window.__REDUX_DEVTOOLS_EXTENSION__ ? createStore( persistedReducer, // @ts-expect-error - compose(applyMiddleware(thunk), window.__REDUX_DEVTOOLS_EXTENSION__()) + compose(appliedMiddleware, window.__REDUX_DEVTOOLS_EXTENSION__()) ) - : createStore(persistedReducer, applyMiddleware(thunk)); + : createStore(persistedReducer, appliedMiddleware); let persistor = persistStore(store); + setSagaStore(store); + sagaMiddleware.run(rootSaga); + // In the future if polls (which cause ticks in the store) are moved to sagas + // they will be dependant on the ping socket, we need a keep alive tick for + // when the are no actions being dispatched making the store tick and + // therefore the ping saga realizing the socket died, if it did so. Ideally, + // the ping/socket subscription should emit a timeout event of such but I'd + // rather do that when I learn Event Channels and implement the ping socket + // using that. + setInterval(() => { + store.dispatch({ + type: "shock::keepAlive" + }); + }, 20000); return { store, persistor }; }; diff --git a/src/store/sagas/README.md b/src/store/sagas/README.md new file mode 100644 index 000000000..c466f6192 --- /dev/null +++ b/src/store/sagas/README.md @@ -0,0 +1,33 @@ +# Sagas + +Sagas are preferred over thunks because: + +- No overloading of `dispatch()`. +- More expressive power. +- Called on every store tick so they can react to state changes. + +## Guidelines + +- Sagas can import from services, actions and selectors but not from reducers. +- Only one default export per file, a saga named according to the file name and scope of the code, e.g. `nodeSaga` in `node.ts`, if something else "needs" to be exported, then it doesn't belong in a saga file. E.g. in utils. That saga can then run other sagas as needed. Naming helps error traces. +- Use `getStore()` from `./_store` and not from elsewhere. +- In the future we should switch to channel events to avoid the `getStore()` conundrum. +- Ping saga handles online state. +- Build and connect sockets/polls when `Selectors.isReady(yield select())` returns true, disconnect them and tear them down when false, this simplifies token/user handling. +- Call `_setStore()` on store creation but before running the root saga. +- Wrap every single saga's body in a try-catch. Inside the catch block, if the saga can reasonably handle the error, then it should handle it, always log the error and pre-pend a `logger.error('Error inside [sagaName]* ()')` before logging the actual error. E.g.: + +```ts +function* handlePing() { + try { + // ... + } catch (e) { + logger.error("Error inside handlePing* ()"); + logger.error(e); + } +} +``` + +- Do not do conditional dispatch based on state inside the saga, let the reducer handle the action as it seems fit, reducers are pure and are more easily testable, only check for conditions inside of those. +- Use `Common.YieldReturn` for type safety. +- There's one exception to all of these rules and that is the `_store` file. diff --git a/src/store/sagas/_store.ts b/src/store/sagas/_store.ts new file mode 100644 index 000000000..d12af37f2 --- /dev/null +++ b/src/store/sagas/_store.ts @@ -0,0 +1,13 @@ +import { Store, AnyAction } from "redux"; + +import { State } from "../../reducers"; + +type ShockStore = Store; + +let currStore = {} as ShockStore; + +export const _setStore = (store: ShockStore) => { + currStore = store; +}; + +export const getStore = () => currStore; diff --git a/src/store/sagas/index.ts b/src/store/sagas/index.ts new file mode 100644 index 000000000..96659280c --- /dev/null +++ b/src/store/sagas/index.ts @@ -0,0 +1,13 @@ +import { all } from "redux-saga/effects"; + +import node from "./node"; +import ping from "./ping"; + +import { _setStore } from "./_store"; + +function* rootSaga() { + yield all([node, ping]); +} + +export { _setStore }; +export default rootSaga; diff --git a/src/store/sagas/node.ts b/src/store/sagas/node.ts new file mode 100644 index 000000000..0c7767e6c --- /dev/null +++ b/src/store/sagas/node.ts @@ -0,0 +1,31 @@ +import { all, takeEvery } from "redux-saga/effects"; + +import * as Utils from "../../utils"; + +// eslint-disable-next-line require-yield +function* handleTokenInvalidation() { + try { + // Utils.navigate("/"); + } catch (e) { + Utils.logger.error(`Error inside handleTokenInvalidation* ()`); + Utils.logger.error(e); + } +} + +function* auth() { + try { + yield; + } catch (e) { + Utils.logger.error("Error inside auth* ()"); + Utils.logger.error(e); + } +} + +function* nodeSaga() { + yield all([ + auth, + takeEvery("node/tokenDidInvalidate", handleTokenInvalidation) + ]); +} + +export default nodeSaga; diff --git a/src/store/sagas/ping.ts b/src/store/sagas/ping.ts new file mode 100644 index 000000000..523790ba4 --- /dev/null +++ b/src/store/sagas/ping.ts @@ -0,0 +1,130 @@ +import { takeEvery, select, put } from "redux-saga/effects"; +import SocketIO from "socket.io-client"; +import { Constants } from "shock-common"; + +import * as Utils from "../../utils"; + +import { getStore } from "./_store"; + +let socket: ReturnType | null = null; + +function* ping() { + try { + const { + node: { authToken: token, hostIP: host } + } = yield select(); + + if ((!token || !host) && socket) { + Utils.logger.log( + `Will kill ping socket because of token invalidation or host was unset` + ); + socket.off("*"); + socket.close(); + socket = null; + + // force next tick + yield put({ type: "shock::keepAlive" }); + } + + if (token && host && !socket) { + Utils.logger.log(`Will try to connect ping socket`); + socket = SocketIO(`http://${host}/shockping`, { + query: { + token + } + }); + + socket.on("shockping", () => { + getStore().dispatch({ + type: "node/ping", + payload: { + timestamp: Utils.normalizeTimestampToMs(Date.now()) + } + }); + }); + + socket.on(Constants.ErrorCode.NOT_AUTH, () => { + getStore().dispatch({ + type: "node/tokenDidInvalidate" + }); + }); + + socket.on("$error", (e: string) => { + Utils.logger.error(`Error received by ping socket: ${e}`); + }); + + socket.on("connect_error", (e: unknown) => { + Utils.logger.error(`ping socket connect_error`); + Utils.logger.error(e); + }); + + socket.on("connect_timeout", (timeout: unknown) => { + Utils.logger.log(`ping socket connect_timeout`); + Utils.logger.log(timeout); + }); + + socket.on("connect", () => { + Utils.logger.log("ping socket connect"); + }); + + socket.on("disconnect", (reason: string) => { + Utils.logger.log(`ping socket disconnected due to -> ${reason}`); + + // from docs + if (reason === "io server disconnect") { + // the disconnection was initiated by the server, you need to reconnect manually + socket && socket.connect(); + } + // else the socket will automatically try to reconnect + }); + + socket.on("error", (e: unknown) => { + Utils.logger.error(`Error inside ping socket`); + Utils.logger.error(e); + }); + + socket.on("reconnect", (attemptNumber: number) => { + Utils.logger.log(`ping socket reconnect attempt -> ${attemptNumber}`); + }); + + socket.on("reconnecting", (attemptNumber: number) => { + Utils.logger.log( + `ping socket reconnecting attempt -> ${attemptNumber}` + ); + }); + + socket.on("reconnect_error", (e: unknown) => { + Utils.logger.log(`ping socket reconnect_error`); + Utils.logger.error(e); + }); + + socket.on("reconnect_failed", () => { + Utils.logger.error(`ping socket reconnect_failed`); + }); + + socket.on("ping", () => { + Utils.logger.log(`ping socket pinging api (socket.io internal)`); + }); + + socket.on("pong", () => { + Utils.logger.log(`ping socket ponged by api (socket.io internal)`); + + getStore().dispatch({ + type: "ping", + payload: { + timestamp: Utils.normalizeTimestampToMs(Date.now()) + } + }); + }); + } + } catch (err) { + Utils.logger.error("Error inside ping* ()"); + Utils.logger.error(err.message); + } +} + +function* rootSaga() { + yield takeEvery("*", ping); +} + +export default rootSaga; diff --git a/src/utils/Error.ts b/src/utils/Error.ts index 8b84a87e1..7eaadfa3d 100644 --- a/src/utils/Error.ts +++ b/src/utils/Error.ts @@ -1,7 +1,7 @@ import { AxiosError } from "axios"; import FieldError, { FieldErrorType } from "./FieldError"; -type MixedError = Error | AxiosError | FieldErrorType; +export type MixedError = Error | AxiosError | FieldErrorType; export const parseError = (error: MixedError) => { if ("response" in error) { diff --git a/src/utils/index.ts b/src/utils/index.ts index ad5470518..198f005e3 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,6 +2,7 @@ import * as Common from "shock-common"; export * from "./Date"; export { default as Http } from "./Http"; +export * from "./Error"; export const logger = { log: (...args: any[]) => console.log(...args), @@ -119,3 +120,59 @@ export const processImageFile = async ( return resizedImage; }; + +export const wait = (ms: number): Promise => + new Promise(r => { + setTimeout(r, ms); + }); + +export const retryOperation = ( + operation: () => Promise, + delay: number, + retries: number +): Promise => + new Promise((resolve, reject) => { + return operation() + .then(resolve) + .catch(reason => { + if (retries > 0) { + return ( + wait(delay) + .then(retryOperation.bind(null, operation, delay, retries - 1)) + // @ts-expect-error + .then(resolve) + .catch(reject) + ); + } + return reject(reason); + }); + }); + +/** + * Converts seconds/microseconds timestamps to milliseconds, leaves milliseconds + * timestamps untouched. Works for timestamps no older than 2001. + * @timestamp A timestamp that can be seconds, milliseconds or microseconds. + * Should be no older than 2001. + */ +export function normalizeTimestampToMs(timestamp: number): number { + if (timestamp === 0) { + return timestamp; + } + + const t = timestamp.toString(); + + if (t.length === 10) { + // is seconds + return Number(t) * 1000; + } else if (t.length === 13) { + // is milliseconds + return Number(t); + } else if (t.length === 16) { + // is microseconds + return Number(t) / 1000; + } + + logger.error("normalizeTimestamp() -> could not interpret timestamp"); + + return Number(t); +}