diff --git a/src/app/store.ts b/src/app/store.ts index 5eecac9..5b6ddb9 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -1,4 +1,9 @@ -import { configureStore, ThunkAction, Action } from "@reduxjs/toolkit"; +import { + combineReducers, + configureStore, + ThunkAction, + Action, +} from "@reduxjs/toolkit"; import counterReducer from "../features/counter/counterSlice"; // TODO: Rework these to use a slice, otherwise redux complains about @@ -13,14 +18,22 @@ import roomReducer from "../modules/room/slice"; import makeSocketMiddleware from "../modules/websocket/middleware"; import socketReducer from "../modules/websocket/slice"; +const rootReducer = combineReducers({ + counter: counterReducer, + login: loginReducer, + rooms: roomReducer, + socket: socketReducer, + //users, +}); + +// Define this before defining `store`, to break a circular type reference: +// +// store -> middleware -> RootState +// +export type RootState = ReturnType; + export const store = configureStore({ - reducer: { - counter: counterReducer, - login: loginReducer, - rooms: roomReducer, - socket: socketReducer, - //users, - }, + reducer: rootReducer, middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat( makeSocketMiddleware(loginSocketMessageHandlers) @@ -28,7 +41,6 @@ export const store = configureStore({ }); export type AppDispatch = typeof store.dispatch; -export type RootState = ReturnType; export type AppThunk = ThunkAction< ReturnType, RootState, diff --git a/src/components/ConnectForm.tsx b/src/components/ConnectForm.tsx index 1b8d34a..2fa5d89 100644 --- a/src/components/ConnectForm.tsx +++ b/src/components/ConnectForm.tsx @@ -17,7 +17,7 @@ interface Props { const ConnectForm: FC = ({ socket }) => { const dispatch = useDispatch(); - const onSubmit = ({ url }) => { + const onSubmit = ({ url }: { url: string }) => { dispatch(socketOpen(url)); }; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index ac7df07..0cb2fca 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,15 +1,15 @@ import { FC } from "react"; -import { Link } from "react-router-dom"; +import { NavLink } from "react-router-dom"; const Header: FC = () => (

Solstice web UI

- + Rooms - - + + Users - +
); diff --git a/src/components/RoomList.tsx b/src/components/RoomList.tsx index ef92822..9245f03 100644 --- a/src/components/RoomList.tsx +++ b/src/components/RoomList.tsx @@ -3,9 +3,11 @@ import { useDispatch } from "react-redux"; import RoomComponent from "./Room"; import SearchableList from "./SearchableList"; -import { Room, RoomSliceState } from "../modules/room/slice"; +import { RoomSliceState } from "../modules/room/slice"; import { socketSendMessage } from "../modules/websocket/slice"; +const SearchableRoomList = SearchableList(RoomComponent); + const RoomList: FC = ({ rooms }) => { const dispatch = useDispatch(); const refresh = () => { @@ -17,13 +19,7 @@ const RoomList: FC = ({ rooms }) => { ); }; - return ( - - id="room-list" - map={rooms} - refresh={refresh} - /> - ); + return ; }; export default RoomList; diff --git a/src/components/SearchableList.tsx b/src/components/SearchableList.tsx index 7e9a1ab..974e9e1 100644 --- a/src/components/SearchableList.tsx +++ b/src/components/SearchableList.tsx @@ -1,43 +1,46 @@ -import { ComponentType, FC } from "react"; +import { ComponentType, FC, ReactEventHandler } from "react"; -interface Props { +interface ItemProps { + name: string; + data: Item; +} + +interface ListProps { id: string; map: { [key: string]: Item }; refresh: () => void; } -function SearchableList< - Item, - ItemComponent extends ComponentType> ->({ id, map, refresh }: Props) { - const children = []; +function SearchableList( + ItemComponent: ComponentType> +): FC> { + return ({ id, map, refresh }: ListProps) => { + const children = []; - for (const name in map) { - children.push( -
  • - -
  • - ); - } + for (const name in map) { + children.push( +
  • + +
  • + ); + } - const onClick = (event) => { - event.preventDefault(); - refresh(); - }; + const onClick: ReactEventHandler = (event) => { + event.preventDefault(); + refresh(); + }; - return ( -
    -
    -
    - + return ( +
    +
    +
    + +
    +
      {children}
    -
      {children}
    -
    - ); + ); + }; } -// eslint-disable-next-line -let _assertType: FC> = SearchableList; - export default SearchableList; diff --git a/src/components/SolsticeApp.tsx b/src/components/SolsticeApp.tsx index 91b4951..91cd373 100644 --- a/src/components/SolsticeApp.tsx +++ b/src/components/SolsticeApp.tsx @@ -44,7 +44,8 @@ const ConnectedApp: FC = ({ children }) => { ); } - return children; + // TODO: eliminate the need for this extra
    wrapper. + return
    {children}
    ; }; const SolsticeApp = () => ( diff --git a/src/modules/login/message.ts b/src/modules/login/message.ts index 1848bc1..736a07c 100644 --- a/src/modules/login/message.ts +++ b/src/modules/login/message.ts @@ -1,5 +1,5 @@ -import { loginSetState } from "../login/slice"; -import { SocketMessage } from "../websocket/message"; +import { loginSetState, LoginStatus } from "../login/slice"; +import { SocketMessage, SocketMessageHandler } from "../websocket/message"; export function loginStatusRequest(): SocketMessage { return { @@ -21,7 +21,7 @@ const loginStatusResponseHandler: SocketMessageHandler = { }); } case "Success": { - const [username, motd] = data.fields; + const [username, motd] = fields; return loginSetState({ status: LoginStatus.Success, username, @@ -30,7 +30,7 @@ const loginStatusResponseHandler: SocketMessageHandler = { } case "Failure": { - const [username, reason] = data.fields; + const [username, reason] = fields; return loginSetState({ status: LoginStatus.Failure, username, diff --git a/src/modules/login/slice.ts b/src/modules/login/slice.ts index 9a1c6af..9107da3 100644 --- a/src/modules/login/slice.ts +++ b/src/modules/login/slice.ts @@ -19,9 +19,6 @@ export interface LoginSliceState { const initialState: LoginSliceState = { status: LoginStatus.Unknown, - url: undefined, - motd: undefined, - reason: undefined, }; export const loginSlice = createSlice({ @@ -29,7 +26,7 @@ export const loginSlice = createSlice({ initialState, reducers: { loginSetState: ( - state: RootState, + state: LoginSliceState, action: PayloadAction ) => { state = action.payload; diff --git a/src/modules/room/slice.ts b/src/modules/room/slice.ts index a7db6e0..dc2fb8f 100644 --- a/src/modules/room/slice.ts +++ b/src/modules/room/slice.ts @@ -9,6 +9,11 @@ export enum RoomMembership { Left, } +export interface RoomMessage { + user_name: string; + message: string; +} + export interface Room { name: string; membership: RoomMembership; @@ -18,18 +23,24 @@ export interface Room { owner: string; operators: Set; members: Set; - messages: string[]; + messages: RoomMessage[]; tickers: string[]; // showUsers: boolean; } -export interface RoomSliceState { +export interface RoomMap { [name: string]: Room; } -const initialState: RoomSliceState = {}; +export interface RoomSliceState { + rooms: RoomMap; +} -export interface RoomMessage { +const initialState: RoomSliceState = { + rooms: {}, +}; + +export interface RoomMessagePayload { room_name: string; user_name: string; message: string; @@ -44,7 +55,7 @@ export const roomSlice = createSlice({ action: PayloadAction<[string, RoomMembership]> ) => { const [name, membership] = action.payload; - const room = state[name]; + const room = state.rooms[name]; if (room === undefined) { console.log(`Cannot set membership of room ${name}`); return; @@ -54,10 +65,10 @@ export const roomSlice = createSlice({ }, roomMessage: ( state: RoomSliceState, - action: PayloadAction + action: PayloadAction ) => { const { room_name, user_name, message } = action.payload; - const room = state[room_name]; + const room = state.rooms[room_name]; if (room === undefined) { console.log( `Unknown room ${room_name} received message from ` + @@ -71,7 +82,7 @@ export const roomSlice = createSlice({ roomSetAll: (state: RoomSliceState, action: PayloadAction) => { state.rooms = {}; for (const room of action.payload) { - state[room.name] = room; + state.rooms[room.name] = room; } }, }, @@ -79,9 +90,8 @@ export const roomSlice = createSlice({ export const { roomSetMembership, roomMessage, roomSetAll } = roomSlice.actions; -export const selectAllRooms = (state: RootState) => state.rooms.rooms; - -export const selectRoom = (name: string) => (state: RootState) => - state.rooms.rooms.get(name); +export function selectAllRooms(state: RootState): RoomMap { + return state.rooms.rooms; +} export default roomSlice.reducer; diff --git a/src/modules/websocket/actions.ts b/src/modules/websocket/actions.ts deleted file mode 100644 index eb33ce7..0000000 --- a/src/modules/websocket/actions.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const wsConnect = (host) => ({ type: "WS_CONNECT", host }); -export const wsConnecting = (host) => ({ type: "WS_CONNECTING", host }); -export const wsConnected = (host) => ({ type: "WS_CONNECTED", host }); -export const wsDisconnect = () => ({ type: "WS_DISCONNECT" }); -export const wsDisconnected = () => ({ type: "WS_DISCONNECTED" }); diff --git a/src/modules/websocket/message.ts b/src/modules/websocket/message.ts index 3162b29..9495aa8 100644 --- a/src/modules/websocket/message.ts +++ b/src/modules/websocket/message.ts @@ -5,7 +5,7 @@ import { PayloadAction } from "@reduxjs/toolkit"; // The type of a message exchanged over a websocket. export interface SocketMessage { variant: string; - fields?: List; + fields: any[]; } // Serializes the given message for sending over the wire. @@ -25,7 +25,9 @@ export function parseMessage(serialized: string): SocketMessage { } // Handles the `data` field of a `SocketMessage` and returns an optional action. -export type SocketMessageDataHandler = (any) => PayloadAction | undefined; +export type SocketMessageDataHandler = ( + data: any +) => PayloadAction | undefined; // A message handler creates an action out of a socket message. // diff --git a/src/modules/websocket/middleware.ts b/src/modules/websocket/middleware.ts index 6a762dd..cec4bc4 100644 --- a/src/modules/websocket/middleware.ts +++ b/src/modules/websocket/middleware.ts @@ -17,24 +17,24 @@ import { socketClosed, socketSendMessage, } from "./slice"; -import { AppDispatch, RootState } from "../app/store"; +import { AppDispatch, RootState } from "../../app/store"; // The WebSocket singleton. -// TODO: use undefined instead, it is more idiomatic. -let socket: WebSocket | null = null; +let socket: WebSocket | undefined; -const onOpen = (dispatch: AppDispatch) => (event) => { - console.log("Websocket open", event.target.url); - dispatch(socketOpened(event.target.url)); +const makeOnOpen = (dispatch: AppDispatch) => (event: Event) => { + const target = event.target as WebSocket; + console.log("WebSocket open", target.url); + dispatch(socketOpened(target.url)); }; -const onClose = (dispatch: AppDispatch) => () => { +const makeOnClose = (dispatch: AppDispatch) => () => { dispatch(socketClosed()); }; -function prepareHandlers( - handlers: SocketMessageHandler[] -): Map { +type HandlerMap = Map; + +function prepareHandlers(handlers: SocketMessageHandler[]): HandlerMap { const map = new Map(); for (const handler of handlers) { @@ -52,16 +52,15 @@ function prepareHandlers( return map; } -const onMessage = - (dispatch: AppDispatch, handlers: Map) => - (event: MessageEvent) => { - console.log("Websocket received message", event.data); +const makeOnMessage = + (dispatch: AppDispatch, handlers: HandlerMap) => (event: MessageEvent) => { + console.log("WebSocket received message", event.data); let message; try { message = parseMessage(event.data); } catch (err) { - console.log("Websocket received invalid message:", err); + console.log("WebSocket received invalid message:", err); return; } @@ -70,7 +69,7 @@ const onMessage = return; } - for (handler of list) { + for (const handler of list) { const action = handler(message); if (action !== undefined) { dispatch(action); @@ -78,38 +77,65 @@ const onMessage = } }; +type SocketFactory = (url: string) => WebSocket; + +function makeSocketFactory( + dispatch: AppDispatch, + handlers: HandlerMap +): SocketFactory { + const onOpen = makeOnOpen(dispatch); + const onClose = makeOnClose(dispatch); + const onMessage = makeOnMessage(dispatch, handlers); + + return (url) => { + socket = new WebSocket(url); + + // Bind websocket handlers. + socket.onopen = onOpen; + socket.onclose = onClose; + socket.onmessage = onMessage; + + return socket; + }; +} + // See: https://redux.js.org/tutorials/fundamentals/part-4-store#writing-custom-middleware function makeMiddleware( handlers: SocketMessageHandler[] ): Middleware<{}, RootState> { const handlerMap = prepareHandlers(handlers); - return (storeApi) => (next) => (action) => { - if (socketOpen.match(action)) { - if (socket !== null) { - socket.close(); + return (storeApi) => { + const socketFactory = makeSocketFactory(storeApi.dispatch, handlerMap); + + return (next) => (action) => { + if (socketOpen.match(action)) { + if (socket === undefined) { + const url = action.payload; + console.log(`Opening WebSocket to ${url}`); + socket = socketFactory(url); + } else { + console.log("Ignoring socketOpen action, socket is already open."); + } + } else if (socketClose.match(action)) { + if (socket !== undefined) { + socket.close(); + socket = undefined; + console.log("WebSocket closed."); + } else { + console.log("Ignoring socketClose action, socket is already closed."); + } + } else if (socketSendMessage.match(action)) { + if (socket !== undefined) { + console.log("WebSocket sending message", action.payload); + socket.send(JSON.stringify(action.payload)); + } else { + console.log("Ignoring socketSendMessage action, socket is closed."); + } } - // Connect to the remote host. - socket = new WebSocket(action.payload); - - // Bind websocket handlers. - socket.onmessage = onMessage(storeApi.dispatch, handlerMap); - socket.onclose = onClose(storeApi.dispatch); - socket.onopen = onOpen(storeApi.dispatch); - } else if (socketClose.match(action)) { - if (socket !== null) { - socket.close(); - } - - socket = null; - console.log("Websocket closed."); - } else if (socketSendMessage.match(action)) { - console.log("Websocket sending message", action.payload); - socket.send(JSON.stringify(action.payload)); - } - - return next(action); + return next(action); + }; }; } diff --git a/src/modules/websocket/slice.ts b/src/modules/websocket/slice.ts index 0ff6a34..df7208f 100644 --- a/src/modules/websocket/slice.ts +++ b/src/modules/websocket/slice.ts @@ -27,7 +27,7 @@ export const socketSlice = createSlice({ state.state = SocketState.Opening; state.url = action.payload; }, - socketOpened: (state, action: PayoadAction) => { + socketOpened: (state, action: PayloadAction) => { state.state = SocketState.Open; state.url = action.payload; },