| @ -1,7 +0,0 @@ | |||
| import { LOGIN_GET_STATUS } from "../constants/ActionTypes"; | |||
| export default { | |||
| getStatus: () => ({ | |||
| type: LOGIN_GET_STATUS, | |||
| }), | |||
| }; | |||
| @ -1,48 +0,0 @@ | |||
| import { | |||
| ROOM_GET_LIST, | |||
| ROOM_JOIN, | |||
| ROOM_LEAVE, | |||
| ROOM_MESSAGE, | |||
| ROOM_SELECT, | |||
| ROOM_SHOW_USERS, | |||
| ROOM_HIDE_USERS, | |||
| } from "../constants/ActionTypes"; | |||
| export default { | |||
| getList: () => ({ | |||
| type: ROOM_GET_LIST, | |||
| }), | |||
| join: (room_name) => ({ | |||
| type: ROOM_JOIN, | |||
| payload: room_name, | |||
| }), | |||
| leave: (room_name) => ({ | |||
| type: ROOM_LEAVE, | |||
| payload: room_name, | |||
| }), | |||
| select: (room_name) => ({ | |||
| type: ROOM_SELECT, | |||
| payload: room_name, | |||
| }), | |||
| sendMessage: (room_name, message) => ({ | |||
| type: ROOM_MESSAGE, | |||
| payload: { | |||
| room_name, | |||
| message, | |||
| }, | |||
| }), | |||
| showUsers: (room_name) => ({ | |||
| type: ROOM_SHOW_USERS, | |||
| payload: room_name, | |||
| }), | |||
| hideUsers: (room_name) => ({ | |||
| type: ROOM_HIDE_USERS, | |||
| payload: room_name, | |||
| }), | |||
| }; | |||
| @ -1,29 +0,0 @@ | |||
| import { | |||
| SOCKET_RECEIVE_MESSAGE, | |||
| SOCKET_SEND_MESSAGE, | |||
| SOCKET_SET_CLOSED, | |||
| SOCKET_SET_CLOSING, | |||
| SOCKET_SET_ERROR, | |||
| SOCKET_SET_OPEN, | |||
| SOCKET_SET_OPENING, | |||
| } from "../constants/ActionTypes"; | |||
| export default { | |||
| open: (url, handlers) => ({ | |||
| type: SOCKET_SET_OPENING, | |||
| payload: { | |||
| url, | |||
| onclose: handlers.onclose, | |||
| onerror: handlers.onerror, | |||
| onopen: handlers.onopen, | |||
| onmessage: handlers.onmessage, | |||
| }, | |||
| }), | |||
| close: () => ({ type: SOCKET_SET_CLOSING }), | |||
| send: (message) => ({ | |||
| type: SOCKET_SEND_MESSAGE, | |||
| payload: message, | |||
| }), | |||
| }; | |||
| @ -1,36 +0,0 @@ | |||
| import { | |||
| SOCKET_SET_CLOSED, | |||
| SOCKET_SET_ERROR, | |||
| SOCKET_SET_OPEN, | |||
| SOCKET_RECEIVE_MESSAGE, | |||
| } from "../constants/ActionTypes"; | |||
| export default { | |||
| onclose: (event) => ({ | |||
| type: SOCKET_SET_CLOSED, | |||
| payload: event.code, | |||
| }), | |||
| onerror: (event) => ({ type: SOCKET_SET_ERROR }), | |||
| onopen: (event) => ({ type: SOCKET_SET_OPEN }), | |||
| onmessage: (event) => { | |||
| console.log(`Received message: ${event.data}`); | |||
| const action = { type: SOCKET_RECEIVE_MESSAGE }; | |||
| try { | |||
| const { | |||
| variant, | |||
| fields: [data], | |||
| } = JSON.parse(event.data); | |||
| if (typeof variant === "undefined") { | |||
| throw new Error('Missing "variant" field in control response'); | |||
| } | |||
| action.payload = { variant, data }; | |||
| } catch (err) { | |||
| action.error = true; | |||
| action.payload = err; | |||
| } | |||
| return action; | |||
| }, | |||
| }; | |||
| @ -1,9 +0,0 @@ | |||
| import { USER_GET_LIST } from "../constants/ActionTypes"; | |||
| const UserActions = { | |||
| getList: () => ({ | |||
| type: USER_GET_LIST, | |||
| }), | |||
| }; | |||
| export default UserActions; | |||
| @ -1,23 +0,0 @@ | |||
| // Socket actions | |||
| export const SOCKET_SET_OPEN = "SOCKET_SET_OPEN"; | |||
| export const SOCKET_SET_OPENING = "SOCKET_SET_OPENING"; | |||
| export const SOCKET_SET_CLOSED = "SOCKET_SET_CLOSED"; | |||
| export const SOCKET_SET_CLOSING = "SOCKET_SET_CLOSING"; | |||
| export const SOCKET_SET_ERROR = "SOCKET_SET_ERROR"; | |||
| export const SOCKET_RECEIVE_MESSAGE = "SOCKET_RECEIVE_MESSAGE"; | |||
| export const SOCKET_SEND_MESSAGE = "SOCKET_SEND_MESSAGE"; | |||
| // Login actions | |||
| export const LOGIN_GET_STATUS = "LOGIN_GET_STATUS"; | |||
| // Room actions | |||
| export const ROOM_GET_LIST = "ROOM_GET_LIST"; | |||
| export const ROOM_SELECT = "ROOM_SELECT"; | |||
| export const ROOM_JOIN = "ROOM_JOIN"; | |||
| export const ROOM_LEAVE = "ROOM_LEAVE"; | |||
| export const ROOM_MESSAGE = "ROOM_MESSAGE"; | |||
| export const ROOM_SHOW_USERS = "ROOM_SHOW_USERS"; | |||
| export const ROOM_HIDE_USERS = "ROOM_HIDE_USERS"; | |||
| // User actions | |||
| export const USER_GET_LIST = "USER_GET_LIST"; | |||
| @ -1,5 +0,0 @@ | |||
| export const LOGIN_STATUS_UNKNOWN = Symbol("LOGIN_STATUS_UNKNOWN"); | |||
| export const LOGIN_STATUS_GETTING = Symbol("LOGIN_STATUS_GETTING"); | |||
| export const LOGIN_STATUS_PENDING = Symbol("LOGIN_STATUS_PENDING"); | |||
| export const LOGIN_STATUS_SUCCESS = Symbol("LOGIN_STATUS_SUCCESS"); | |||
| export const LOGIN_STATUS_FAILURE = Symbol("LOGIN_STATUS_FAILURE"); | |||
| @ -1,4 +0,0 @@ | |||
| export const STATE_OPENING = 0; | |||
| export const STATE_OPEN = 1; | |||
| export const STATE_CLOSING = 2; | |||
| export const STATE_CLOSED = 3; | |||
| @ -1,17 +0,0 @@ | |||
| import { combineReducers } from "redux"; | |||
| import { reducer as form } from "redux-form"; | |||
| import login from "./login"; | |||
| import rooms from "./rooms"; | |||
| import socket from "./socket"; | |||
| import users from "./users"; | |||
| const rootReducer = combineReducers({ | |||
| login, | |||
| rooms, | |||
| socket, | |||
| users, | |||
| form, | |||
| }); | |||
| export default rootReducer; | |||
| @ -1,75 +0,0 @@ | |||
| import Immutable from "immutable"; | |||
| import { | |||
| LOGIN_GET_STATUS, | |||
| SOCKET_RECEIVE_MESSAGE, | |||
| } from "../constants/ActionTypes"; | |||
| import { | |||
| LOGIN_STATUS_UNKNOWN, | |||
| LOGIN_STATUS_GETTING, | |||
| LOGIN_STATUS_PENDING, | |||
| LOGIN_STATUS_SUCCESS, | |||
| LOGIN_STATUS_FAILURE, | |||
| } from "../constants/login"; | |||
| const LoginRecord = Immutable.Record({ | |||
| status: LOGIN_STATUS_UNKNOWN, | |||
| username: undefined, | |||
| motd: undefined, | |||
| reason: undefined, | |||
| }); | |||
| const initialState = new LoginRecord(); | |||
| const reduceReceiveMessage = (state, message) => { | |||
| const { variant, data } = message; | |||
| if (variant !== "LoginStatusResponse") { | |||
| return state; | |||
| } | |||
| switch (data.variant) { | |||
| case "Pending": { | |||
| // sub-block required otherwise const username declarations clash | |||
| const [username] = data.fields; | |||
| return state | |||
| .set("status", LOGIN_STATUS_PENDING) | |||
| .set("username", username); | |||
| } | |||
| case "Success": { | |||
| // sub-block required otherwise const username declarations clash | |||
| const [username, motd] = data.fields; | |||
| return state | |||
| .set("status", LOGIN_STATUS_SUCCESS) | |||
| .set("username", username) | |||
| .set("motd", motd); | |||
| } | |||
| case "Failure": { | |||
| // sub-block required otherwise const username declarations clash | |||
| const [username, reason] = data.fields; | |||
| return state | |||
| .set("status", LOGIN_STATUS_FAILURE) | |||
| .set("username", username) | |||
| .set("reason", reason); | |||
| } | |||
| default: | |||
| return state; | |||
| } | |||
| }; | |||
| export default (state = initialState, action) => { | |||
| const { type, payload } = action; | |||
| switch (type) { | |||
| case LOGIN_GET_STATUS: | |||
| return state.set("status", LOGIN_STATUS_GETTING); | |||
| case SOCKET_RECEIVE_MESSAGE: | |||
| return reduceReceiveMessage(state, payload); | |||
| default: | |||
| return state; | |||
| } | |||
| }; | |||
| @ -1,123 +0,0 @@ | |||
| import Immutable from "immutable"; | |||
| import OrderedMap from "../utils/OrderedMap"; | |||
| import { | |||
| ROOM_JOIN, | |||
| ROOM_LEAVE, | |||
| ROOM_MESSAGE, | |||
| ROOM_SHOW_USERS, | |||
| ROOM_HIDE_USERS, | |||
| SOCKET_RECEIVE_MESSAGE, | |||
| } from "../constants/ActionTypes"; | |||
| const RoomRecord = Immutable.Record({ | |||
| membership: "", | |||
| visibility: "", | |||
| operated: false, | |||
| userCount: 0, | |||
| owner: "", | |||
| operators: Immutable.Map(), | |||
| members: Immutable.Map(), | |||
| messages: Immutable.List(), | |||
| tickers: Immutable.List(), | |||
| }); | |||
| const initialState = OrderedMap(); | |||
| const reduceReceiveMessageRoom = (roomData, { variant, data }) => { | |||
| switch (variant) { | |||
| case "RoomJoinResponse": | |||
| return roomData.set("membership", "Member"); | |||
| case "RoomLeaveResponse": | |||
| return roomData.set("membership", "NonMember"); | |||
| case "RoomMessageResponse": { | |||
| const { user_name, message } = data; | |||
| const messages = roomData.messages.push({ user_name, message }); | |||
| return roomData.set("messages", messages); | |||
| } | |||
| } | |||
| }; | |||
| const reduceReceiveMessage = (state, message) => { | |||
| const { variant, data } = message; | |||
| switch (variant) { | |||
| case "RoomJoinResponse": | |||
| case "RoomLeaveResponse": | |||
| case "RoomMessageResponse": { | |||
| const { room_name } = data; | |||
| return state.updateByName(data.room_name, (roomData) => { | |||
| if (roomData) { | |||
| return reduceReceiveMessageRoom(roomData, message); | |||
| } else { | |||
| console.log(`Error: unknown room ${data.room_name}`); | |||
| return roomData; | |||
| } | |||
| }); | |||
| } | |||
| case "RoomListResponse": | |||
| return state.updateAll(data.rooms, (newData, oldData) => { | |||
| if (!oldData) { | |||
| oldData = RoomRecord(); | |||
| } | |||
| return oldData | |||
| .set("membership", newData.membership) | |||
| .set("visibility", newData.visibility) | |||
| .set("operated", newData.operated) | |||
| .set("userCount", newData.user_count) | |||
| .set("owner", newData.owner) | |||
| .set("operators", newData.operators) | |||
| .set("members", newData.members) | |||
| .set("tickers", newData.tickers); | |||
| }); | |||
| default: | |||
| return state; | |||
| } | |||
| }; | |||
| const reduceRoom = (roomData, { type, payload }) => { | |||
| switch (type) { | |||
| case ROOM_JOIN: | |||
| return roomData.set("membership", "Joining"); | |||
| case ROOM_LEAVE: | |||
| return roomData.set("membership", "Leaving"); | |||
| case ROOM_SHOW_USERS: | |||
| return roomData.set("showUsers", true); | |||
| case ROOM_HIDE_USERS: | |||
| return roomData.set("showUsers", false); | |||
| } | |||
| }; | |||
| export default (state = initialState, action) => { | |||
| const { type, payload } = action; | |||
| switch (type) { | |||
| case SOCKET_RECEIVE_MESSAGE: | |||
| return reduceReceiveMessage(state, payload); | |||
| case ROOM_JOIN: | |||
| case ROOM_LEAVE: | |||
| case ROOM_SHOW_USERS: | |||
| case ROOM_HIDE_USERS: { | |||
| return state.updateByName(payload, (roomData) => { | |||
| if (roomData) { | |||
| return reduceRoom(roomData, action); | |||
| } else { | |||
| console.log(`Error: unknown room ${payload}`); | |||
| return roomData; | |||
| } | |||
| }); | |||
| } | |||
| case ROOM_MESSAGE: | |||
| default: | |||
| return state; | |||
| } | |||
| }; | |||
| @ -1,94 +0,0 @@ | |||
| import Immutable from "immutable"; | |||
| import * as types from "../constants/ActionTypes"; | |||
| import { | |||
| STATE_OPENING, | |||
| STATE_OPEN, | |||
| STATE_CLOSING, | |||
| STATE_CLOSED, | |||
| } from "../constants/socket"; | |||
| import ControlRequest from "../utils/ControlRequest"; | |||
| export const SocketRecord = Immutable.Record({ | |||
| state: STATE_CLOSED, | |||
| socket: undefined, | |||
| url: undefined, | |||
| }); | |||
| const initialState = new SocketRecord(); | |||
| export default (state = initialState, { type, payload }) => { | |||
| const sendRequest = (controlRequest) => { | |||
| try { | |||
| state.socket.send(JSON.stringify(controlRequest)); | |||
| } catch (err) { | |||
| console.log(`Socket error: failed to send ${controlRequest}`); | |||
| } | |||
| }; | |||
| switch (type) { | |||
| case types.SOCKET_SET_OPENING: { | |||
| if (state.state !== STATE_CLOSED) { | |||
| console.log("Cannot open socket, already open"); | |||
| return state; | |||
| } | |||
| const { url, onopen, onclose, onerror, onmessage } = payload; | |||
| const socket = new WebSocket(url); | |||
| socket.onopen = onopen; | |||
| socket.onclose = onclose; | |||
| socket.onerror = onerror; | |||
| socket.onmessage = onmessage; | |||
| return state | |||
| .set("state", STATE_OPENING) | |||
| .set("socket", socket) | |||
| .set("url", url); | |||
| } | |||
| case types.SOCKET_SET_OPEN: | |||
| return state.set("state", STATE_OPEN); | |||
| case types.SOCKET_SET_CLOSING: | |||
| // Ooh bad stateful reducing... | |||
| state.socket.close(); | |||
| return state.set("state", STATE_CLOSING); | |||
| case types.SOCKET_SET_CLOSED: | |||
| return state.set("state", STATE_CLOSED); | |||
| case types.SOCKET_SET_ERROR: | |||
| console.log("Socket error"); | |||
| return state.set("state", state.socket.readyState); | |||
| case types.LOGIN_GET_STATUS: | |||
| sendRequest(ControlRequest.loginStatus()); | |||
| return state; | |||
| case types.ROOM_GET_LIST: | |||
| sendRequest(ControlRequest.roomList()); | |||
| return state; | |||
| case types.ROOM_JOIN: | |||
| sendRequest(ControlRequest.roomJoin(payload)); | |||
| return state; | |||
| case types.ROOM_LEAVE: | |||
| sendRequest(ControlRequest.roomLeave(payload)); | |||
| return state; | |||
| case types.ROOM_MESSAGE: { | |||
| const { room_name, message } = payload; | |||
| sendRequest(ControlRequest.roomMessage(room_name, message)); | |||
| return state; | |||
| } | |||
| case types.USER_GET_LIST: | |||
| sendRequest(ControlRequest.userList()); | |||
| return state; | |||
| default: | |||
| return state; | |||
| } | |||
| }; | |||
| @ -1,84 +0,0 @@ | |||
| import Immutable from "immutable"; | |||
| import md5 from "md5"; | |||
| // Updates should be requested every 5 minutes at most. | |||
| const UPDATE_INTERVAL_MS = 5 * 60 * 1000; | |||
| const MapRecord = Immutable.Record({ | |||
| byName: Immutable.OrderedMap(), | |||
| byHash: Immutable.Map(), | |||
| lastUpdated: 0, | |||
| }); | |||
| // TODO: use a regular map and a reversible name -> hash encoding (e.g. base64). | |||
| // This would entirely remove the need for this complicated logic. | |||
| class OrderedMap extends MapRecord { | |||
| constructor(arg) { | |||
| super(arg); | |||
| } | |||
| getByName(name) { | |||
| return this.getIn(["byName", name]); | |||
| } | |||
| setByName(name, data) { | |||
| let thisModified = this; | |||
| if (!this.getByName(name)) { | |||
| // That key was not there yet, add hash -> name mapping. | |||
| thisModified = this.setIn(["byHash", md5(name)], name); | |||
| } | |||
| // Add data to map. | |||
| return thisModified.setIn(["byName", name], data); | |||
| } | |||
| updateByName(name, updater) { | |||
| return this.updateIn(["byName", name], updater); | |||
| } | |||
| getNameByHash(hash) { | |||
| return this.getIn(["byHash", hash]); | |||
| } | |||
| updateAll(nameAndDataList, merger) { | |||
| const { byName } = this; | |||
| let { byHash } = this; | |||
| // First sort the room list by room name | |||
| nameAndDataList.sort(([name1], [name2]) => { | |||
| if (name1 < name2) { | |||
| return -1; | |||
| } else if (name1 > name2) { | |||
| return 1; | |||
| } | |||
| return 0; | |||
| }); | |||
| // Then build the new map. | |||
| let newByName = Immutable.OrderedMap(); | |||
| for (const [name, newData] of nameAndDataList) { | |||
| // Get the old room data. | |||
| let data = byName.get(name); | |||
| if (!data) { | |||
| // Add the hash -> name mapping. | |||
| byHash = byHash.set(md5(name), name); | |||
| } | |||
| // Merge the old data and the new data using the provided function. | |||
| const mergedData = merger(newData, data); | |||
| // Insert that in the new room map. | |||
| newByName = newByName.set(name, mergedData); | |||
| } | |||
| return new OrderedMap({ | |||
| byName: newByName, | |||
| byHash, | |||
| lastUpdated: Date.now(), | |||
| }); | |||
| } | |||
| shouldUpdate() { | |||
| return Date.now() - this.lastUpdated > UPDATE_INTERVAL_MS; | |||
| } | |||
| } | |||
| export default () => new OrderedMap(); | |||
| @ -1,16 +0,0 @@ | |||
| const checkRequiredThenValidate = | |||
| (validator) => (props, propName, componentName, location) => { | |||
| if (props[propName] != null) { | |||
| return validator(props, propName, componentName, location); | |||
| } | |||
| return new Error( | |||
| `Required prop \`${propName}\` was not specified in ` + | |||
| `\`${componentName}\`.` | |||
| ); | |||
| }; | |||
| export default (validator) => { | |||
| validator.isRequired = checkRequiredThenValidate(validator); | |||
| return validator; | |||
| }; | |||
| @ -1,19 +0,0 @@ | |||
| import propTypeRequiredWrapper from "./propTypeRequiredWrapper"; | |||
| // Simple validator for Symbols (instanceof does not work on symbols). | |||
| function propTypeSymbol(props, propName, componentName) { | |||
| const prop = props[propName]; | |||
| if (prop === null) { | |||
| return; | |||
| } | |||
| const type = typeof prop; | |||
| if (type === "symbol") { | |||
| return; | |||
| } | |||
| return new Error( | |||
| `Invalid prop \`${propName}\` of type \`${type}\` ` + | |||
| `supplied to \`${componentName}\`, expected \`symbol\`` | |||
| ); | |||
| } | |||
| export default propTypeRequiredWrapper(propTypeSymbol); | |||