diff --git a/liwords-ui/src/App.tsx b/liwords-ui/src/App.tsx index 932f51f04..b0270bb34 100644 --- a/liwords-ui/src/App.tsx +++ b/liwords-ui/src/App.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { Route, Switch, useLocation, Redirect } from 'react-router-dom'; -import { useMountedState } from './utils/mounted'; import './App.scss'; import axios from 'axios'; import 'antd/dist/antd.css'; @@ -17,7 +16,7 @@ import { useFriendsStoreContext, } from './store/store'; -import { LiwordsSocket } from './socket/socket'; +import { useLiwordsSocketContext } from './socket/socket'; import { Team } from './about/team'; import { Register } from './lobby/register'; import { UserProfile } from './profile/profile'; @@ -66,8 +65,6 @@ if (bnjyTile) { } const App = React.memo(() => { - const { useState } = useMountedState(); - const { setExcludedPlayers, setExcludedPlayersFetched, @@ -75,6 +72,11 @@ const App = React.memo(() => { setPendingBlockRefresh, } = useExcludedPlayersStoreContext(); + const { + liwordsSocketValues: { sendMessage }, + resetLiwordsSocketStore, + } = useLiwordsSocketContext(); + const { loginState } = useLoginStateStoreContext(); const { loggedIn, userID } = loginState; @@ -90,26 +92,16 @@ const App = React.memo(() => { setPendingFriendsRefresh, } = useFriendsStoreContext(); - const { resetStore } = useResetStoreContext(); - - // See store.tsx for how this works. - const [socketId, setSocketId] = useState(0); - const resetSocket = useCallback(() => setSocketId((n) => (n + 1) | 0), []); - - const [liwordsSocketValues, setLiwordsSocketValues] = useState({ - sendMessage: (msg: Uint8Array) => {}, - justDisconnected: false, - }); - const { sendMessage } = liwordsSocketValues; + const { resetRestOfStore } = useResetStoreContext(); const location = useLocation(); const knownLocation = useRef(location.pathname); // Remember the location on first render. const isCurrentLocation = knownLocation.current === location.pathname; useEffect(() => { if (!isCurrentLocation) { - resetStore(); + resetRestOfStore(); } - }, [isCurrentLocation, resetStore]); + }, [isCurrentLocation, resetRestOfStore]); const getFullBlocks = useCallback(() => { void userID; // used only as effect dependency @@ -226,17 +218,12 @@ const App = React.memo(() => { return (
- diff --git a/liwords-ui/src/lobby/login.tsx b/liwords-ui/src/lobby/login.tsx index 9171ad11f..5f6670668 100644 --- a/liwords-ui/src/lobby/login.tsx +++ b/liwords-ui/src/lobby/login.tsx @@ -11,7 +11,7 @@ import { toAPIUrl } from '../api/api'; export const Login = React.memo(() => { const { useState } = useMountedState(); - const { resetStore } = useResetStoreContext(); + const { resetLoginStateStore } = useResetStoreContext(); const [err, setErr] = useState(''); const [loggedIn, setLoggedIn] = useState(false); @@ -42,9 +42,9 @@ export const Login = React.memo(() => { React.useEffect(() => { if (loggedIn) { - resetStore(); + resetLoginStateStore(); } - }, [loggedIn, resetStore]); + }, [loggedIn, resetLoginStateStore]); return (
diff --git a/liwords-ui/src/settings/settings.tsx b/liwords-ui/src/settings/settings.tsx index b0943d206..86b4bcd6e 100644 --- a/liwords-ui/src/settings/settings.tsx +++ b/liwords-ui/src/settings/settings.tsx @@ -80,7 +80,7 @@ export const Settings = React.memo((props: Props) => { const { loginState } = useLoginStateStoreContext(); const { userID, username: viewer, loggedIn } = loginState; const { useState } = useMountedState(); - const { resetStore } = useResetStoreContext(); + const { resetLoginStateStore } = useResetStoreContext(); const { section } = useParams(); const [category, setCategory] = useState( getInitialCategory(section, loggedIn) @@ -170,13 +170,13 @@ export const Settings = React.memo((props: Props) => { message: 'Success', description: 'You have been logged out.', }); - resetStore(); + resetLoginStateStore(); history.push('/'); }) .catch((e) => { console.log(e); }); - }, [history, resetStore]); + }, [history, resetLoginStateStore]); const updatedAvatar = useCallback( (avatarUrl: string) => { diff --git a/liwords-ui/src/socket/socket.ts b/liwords-ui/src/socket/socket.ts index 46ce9d32f..76c6ca1ff 100644 --- a/liwords-ui/src/socket/socket.ts +++ b/liwords-ui/src/socket/socket.ts @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from 'react'; import axios from 'axios'; import jwt from 'jsonwebtoken'; import useWebSocket from 'react-use-websocket'; @@ -7,7 +14,6 @@ import { message } from 'antd'; import { useMountedState } from '../utils/mounted'; import { useLoginStateStoreContext } from '../store/store'; import { - useOnSocketMsg, ReverseMessageType, enableShowSocket, parseMsgs, @@ -18,6 +24,42 @@ import { ActionType } from '../actions/actions'; import { reloadAction } from './reload'; import { birthdateWarning } from './birthdateWarning'; +// Store-specific code. + +const defaultFunction = () => {}; + +export type LiwordsSocketValues = { + sendMessage: (msg: Uint8Array) => void; + justDisconnected: boolean; +}; + +export type OnSocketMsgType = (reader: FileReader) => void; + +type LiwordsSocketStoreData = { + liwordsSocketValues: LiwordsSocketValues; + onSocketMsg: OnSocketMsgType; + resetLiwordsSocketStore: () => void; + setLiwordsSocketValues: React.Dispatch< + React.SetStateAction + >; + setOnSocketMsg: React.Dispatch>; +}; + +export const LiwordsSocketContext = createContext({ + liwordsSocketValues: { + sendMessage: defaultFunction, + justDisconnected: false, + }, + onSocketMsg: defaultFunction, + resetLiwordsSocketStore: defaultFunction, + setLiwordsSocketValues: defaultFunction, + setOnSocketMsg: defaultFunction, +}); + +export const useLiwordsSocketContext = () => useContext(LiwordsSocketContext); + +// Non-Store code follows. + const getSocketURI = (): string => { const loc = window.location; let protocol; @@ -51,23 +93,36 @@ type DecodedToken = { // Returning undefined from useEffect is fine, but some linters dislike it. const doNothing = () => {}; -export const LiwordsSocket = (props: { - resetSocket: () => void; - setValues: (_: { - sendMessage: (msg: Uint8Array) => void; - justDisconnected: boolean; - }) => void; -}): null => { +export const LiwordsSocket = (props: {}): null => { const isMountedRef = useRef(true); useEffect(() => () => void (isMountedRef.current = false), []); const { useState } = useMountedState(); - const { resetSocket, setValues } = props; - const onSocketMsg = useOnSocketMsg(); + const { + onSocketMsg, + resetLiwordsSocketStore, + setLiwordsSocketValues, + } = useLiwordsSocketContext(); const loginStateStore = useLoginStateStoreContext(); const location = useLocation(); - const { pathname } = location; + const pathname = useMemo(() => { + const originalPathname = location.pathname; + // XXX: The socket requires path to know which realms it has to connect to. + // See liwords-socket pkg/hub/hub.go RegisterRealm. + // That calls back into liwords pkg/bus/bus.go handleNatsRequest. + + // It seems only a few paths matter. + if ( + originalPathname.startsWith('/game/') || + originalPathname.startsWith('/tournament/') || + originalPathname.startsWith('/club/') + ) + return originalPathname; + + // For everything else, there's MasterCard. + return '/'; + }, [location.pathname]); // const [socketToken, setSocketToken] = useState(''); const [justDisconnected, setJustDisconnected] = useState(false); @@ -109,8 +164,6 @@ export const LiwordsSocket = (props: { userID: decoded.uid, loggedIn: decoded.a, connID: cid, - isChild: decoded.cs, - path: pathname, perms: decoded.perms?.split(','), }, }); @@ -213,12 +266,21 @@ export const LiwordsSocket = (props: { useEffect(() => { const t = setTimeout(() => { console.log('reconnecting socket'); - resetSocket(); + resetLiwordsSocketStore(); }, 15000); return () => { clearTimeout(t); }; - }, [patienceId, resetSocket]); + }, [patienceId, resetLiwordsSocketStore]); + + // Force reconnection when pathname materially changes. + const knownPathname = useRef(pathname); // Remember the pathname on first render. + const isCurrentPathname = knownPathname.current === pathname; + useEffect(() => { + if (!isCurrentPathname) { + resetLiwordsSocketStore(); + } + }, [isCurrentPathname, resetLiwordsSocketStore]); const { sendMessage: originalSendMessage } = useWebSocket( getFullSocketUrlAsync, @@ -271,8 +333,8 @@ export const LiwordsSocket = (props: { justDisconnected, ]); useEffect(() => { - setValues(ret); - }, [setValues, ret]); + setLiwordsSocketValues(ret); + }, [setLiwordsSocketValues, ret]); return null; }; diff --git a/liwords-ui/src/store/login_state.ts b/liwords-ui/src/store/login_state.ts index ab0a8b8d7..ab70d9c92 100644 --- a/liwords-ui/src/store/login_state.ts +++ b/liwords-ui/src/store/login_state.ts @@ -1,21 +1,15 @@ import { Action, ActionType } from '../actions/actions'; -export type LoginState = { +export type AuthInfo = { username: string; userID: string; loggedIn: boolean; connID: string; - connectedToSocket: boolean; - path: string; perms: Array; }; -export type AuthInfo = { - username: string; - userID: string; - loggedIn: boolean; - connID: string; - perms: Array; +export type LoginState = AuthInfo & { + connectedToSocket: boolean; }; export function LoginStateReducer( diff --git a/liwords-ui/src/store/socket_handlers.ts b/liwords-ui/src/store/socket_handlers.ts index ad84d5127..bc4cba209 100644 --- a/liwords-ui/src/store/socket_handlers.ts +++ b/liwords-ui/src/store/socket_handlers.ts @@ -163,6 +163,8 @@ export const ReverseMessageType = (() => { return ret; })(); +// This needs to have access to Rest Of Store. +// Therefore it cannot be used directly from LiwordsSocket. export const useOnSocketMsg = () => { const { challengeResultEvent } = useChallengeResultEventStoreContext(); const { addChat, deleteChat } = useChatStoreContext(); diff --git a/liwords-ui/src/store/store.tsx b/liwords-ui/src/store/store.tsx index 0519de30a..f3ddde52f 100644 --- a/liwords-ui/src/store/store.tsx +++ b/liwords-ui/src/store/store.tsx @@ -29,6 +29,14 @@ import { } from './reducers/tournament_reducer'; import { MetaEventState, MetaStates } from './meta_game_events'; import { StandardEnglishAlphabet } from '../constants/alphabets'; +import { + LiwordsSocket, + LiwordsSocketContext, + LiwordsSocketValues, + OnSocketMsgType, + useLiwordsSocketContext, +} from '../socket/socket'; +import { useOnSocketMsg } from './socket_handlers'; import { SeekRequest } from '../gen/api/proto/ipc/omgseeks_pb'; import { ServerChallengeResultEvent } from '../gen/api/proto/ipc/omgwords_pb'; @@ -243,7 +251,6 @@ const LoginStateContext = createContext({ loggedIn: false, connectedToSocket: false, connID: '', - path: '', perms: [], }, dispatchLoginState: defaultFunction, @@ -759,9 +766,87 @@ const ExaminableStore = ({ children }: { children: React.ReactNode }) => { return ; }; -// The Real Store. +// The Real LoginState Store. -const RealStore = ({ children, ...props }: Props) => { +const RealLoginStateStore = ({ children, ...props }: Props) => { + const { useState } = useMountedState(); + + const [loginState, setLoginState] = useState({ + username: '', + userID: '', + loggedIn: false, + connectedToSocket: false, + connID: '', + perms: new Array(), + }); + const dispatchLoginState = useCallback( + (action) => setLoginState((state) => LoginStateReducer(state, action)), + [] + ); + + const loginStateStore = useMemo( + () => ({ + loginState, + dispatchLoginState, + }), + [loginState, dispatchLoginState] + ); + + return ( + + ); +}; + +// The Real LiwordsSocket Store. + +const RealLiwordsSocketStore = ({ + resetLiwordsSocketStore, + children, + ...props +}: Props & { + resetLiwordsSocketStore: () => void; +}) => { + const { useState } = useMountedState(); + + const [onSocketMsg, setOnSocketMsg] = useState( + () => defaultFunction + ); + + const [liwordsSocketValues, setLiwordsSocketValues] = useState< + LiwordsSocketValues + >({ + sendMessage: defaultFunction, + justDisconnected: false, + }); + + const liwordsSocketStore = useMemo( + () => ({ + liwordsSocketValues, + onSocketMsg, + resetLiwordsSocketStore, + setLiwordsSocketValues, + setOnSocketMsg, + }), + [ + liwordsSocketValues, + onSocketMsg, + resetLiwordsSocketStore, + setLiwordsSocketValues, + setOnSocketMsg, + ] + ); + + return ( + + ); +}; + +// The Real Rest Of Store. + +const RealRestOfStore = ({ children, ...props }: Props) => { const { useState } = useMountedState(); const clockController = useRef(null); @@ -788,19 +873,6 @@ const RealStore = ({ children, ...props }: Props) => { (action) => setLobbyContext((state) => LobbyReducer(state, action)), [] ); - const [loginState, setLoginState] = useState({ - username: '', - userID: '', - loggedIn: false, - connectedToSocket: false, - connID: '', - path: '', - perms: new Array(), - }); - const dispatchLoginState = useCallback( - (action) => setLoginState((state) => LoginStateReducer(state, action)), - [] - ); const [tournamentContext, setTournamentContext] = useState( defaultTournamentState @@ -978,13 +1050,6 @@ const RealStore = ({ children, ...props }: Props) => { }), [lobbyContext, dispatchLobbyContext] ); - const loginStateStore = useMemo( - () => ({ - loginState, - dispatchLoginState, - }), - [loginState, dispatchLoginState] - ); const tournamentStateStore = useMemo( () => ({ tournamentContext, @@ -1156,7 +1221,6 @@ const RealStore = ({ children, ...props }: Props) => { ); ret = ; - ret = ; ret = ; ret = ( @@ -1206,30 +1270,87 @@ const RealStore = ({ children, ...props }: Props) => { return ; }; -const ResetStoreContext = createContext({ resetStore: defaultFunction }); +// This needs to be nested inside the Rest Of Store. + +const InstallOnSocketMsg = ({ children }: { children: React.ReactNode }) => { + const { onSocketMsg, setOnSocketMsg } = useLiwordsSocketContext(); + + const newOnSocketMsg = useOnSocketMsg(); + + const oldOnSocketMsgRef = useRef(onSocketMsg); + oldOnSocketMsgRef.current = onSocketMsg; + + React.useEffect(() => { + const old = oldOnSocketMsgRef.current; + setOnSocketMsg(() => newOnSocketMsg); + return () => { + setOnSocketMsg(() => old); + }; + }, [newOnSocketMsg, setOnSocketMsg]); + + return ; +}; + +const ResetStoreContext = createContext({ + resetLoginStateStore: defaultFunction, + resetRestOfStore: defaultFunction, +}); export const useResetStoreContext = () => useContext(ResetStoreContext); +// Now includes the Socket. + export const Store = ({ children }: { children: React.ReactNode }) => { const { useState } = useMountedState(); // In JS the | 0 loops within int32 and avoids reaching Number.MAX_SAFE_INTEGER. - const [storeId, setStoreId] = useState(0); - const resetStore = useCallback(() => setStoreId((n) => (n + 1) | 0), []); + const [loginStateStoreId, setLoginStateStoreId] = useState(0); + const resetLoginStateStore = useCallback( + () => setLoginStateStoreId((n) => (n + 1) | 0), + [] + ); + const [liwordsSocketStoreId, setLiwordsSocketStoreId] = useState(0); + const resetLiwordsSocketStore = useCallback( + () => setLiwordsSocketStoreId((n) => (n + 1) | 0), + [] + ); + const [restOfStoreId, setRestOfStoreId] = useState(0); + const resetRestOfStore = useCallback( + () => setRestOfStoreId((n) => (n + 1) | 0), + [] + ); // Reset on browser navigation. React.useEffect(() => { const handleBrowserNavigation = (evt: PopStateEvent) => { - resetStore(); + resetRestOfStore(); }; window.addEventListener('popstate', handleBrowserNavigation); return () => { window.removeEventListener('popstate', handleBrowserNavigation); }; - }, [resetStore]); + }, [resetRestOfStore]); + + const resetStore = useMemo( + () => ({ + resetLoginStateStore, + resetRestOfStore, + }), + [resetLoginStateStore, resetRestOfStore] + ); return ( - - + + + + + + {children} + + + ); }; diff --git a/liwords-ui/src/topbar/topbar.tsx b/liwords-ui/src/topbar/topbar.tsx index 6ee0a01fd..e3e824836 100644 --- a/liwords-ui/src/topbar/topbar.tsx +++ b/liwords-ui/src/topbar/topbar.tsx @@ -81,7 +81,7 @@ export const TopBar = React.memo((props: Props) => { const { currentLagMs } = useLagStoreContext(); const { loginState } = useLoginStateStoreContext(); - const { resetStore } = useResetStoreContext(); + const { resetLoginStateStore } = useResetStoreContext(); const { tournamentContext } = useTournamentStoreContext(); const { username, loggedIn, connectedToSocket } = loginState; const [loginModalVisible, setLoginModalVisible] = useState(false); @@ -97,7 +97,7 @@ export const TopBar = React.memo((props: Props) => { message: 'Success', description: 'You have been logged out.', }); - resetStore(); + resetLoginStateStore(); }) .catch((e) => { console.log(e); diff --git a/liwords-ui/src/tournament/room.tsx b/liwords-ui/src/tournament/room.tsx index eef06e476..1375b269b 100644 --- a/liwords-ui/src/tournament/room.tsx +++ b/liwords-ui/src/tournament/room.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useCallback, useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; import { useLoginStateStoreContext, @@ -35,7 +36,8 @@ export const TournamentRoom = (props: Props) => { const { competitorState: competitorContext } = tournamentContext; const { isRegistered } = competitorContext; const { sendSocketMsg } = props; - const { path } = loginState; + const location = useLocation(); + const path = location.pathname; const [badTournament, setBadTournament] = useState(false); const [selectedGameTab, setSelectedGameTab] = useState('GAMES');