diff --git a/webclient/package-lock.json b/webclient/package-lock.json
index 7487e9b8..54df47d3 100644
--- a/webclient/package-lock.json
+++ b/webclient/package-lock.json
@@ -4613,6 +4613,11 @@
}
}
},
+ "dexie": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/dexie/-/dexie-3.0.3.tgz",
+ "integrity": "sha512-BSFhGpngnCl1DOr+8YNwBDobRMH0ziJs2vts69VilwetHYOtEDcLqo7d/XiIphM0tJZ2rPPyAGd31lgH2Ln3nw=="
+ },
"diff-sequences": {
"version": "24.9.0",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-24.9.0.tgz",
@@ -12843,10 +12848,9 @@
"integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c="
},
"typescript": {
- "version": "3.6.4",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.6.4.tgz",
- "integrity": "sha512-unoCll1+l+YK4i4F8f22TaNVPRHcD9PA3yCuZ8g5e0qGqlVlJ/8FSateOLLSagn+Yg5+ZwuPkL8LFUc0Jcvksg==",
- "dev": true
+ "version": "4.3.5",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz",
+ "integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA=="
},
"uglify-js": {
"version": "3.4.10",
diff --git a/webclient/package.json b/webclient/package.json
index 1ac81cff..e87445b1 100644
--- a/webclient/package.json
+++ b/webclient/package.json
@@ -5,6 +5,7 @@
"dependencies": {
"@material-ui/core": "^4.11.4",
"@material-ui/icons": "^4.11.2",
+ "dexie": "^3.0.3",
"jquery": "^3.4.1",
"lodash": "^4.17.15",
"prop-types": "^15.7.2",
@@ -18,7 +19,8 @@
"react-window": "^1.8.5",
"redux": "^4.0.4",
"redux-form": "^8.2.6",
- "redux-thunk": "^2.3.0"
+ "redux-thunk": "^2.3.0",
+ "typescript": "^4.3.5"
},
"scripts": {
"postinstall": "echo 'Copying shared files...' && ./copy_shared_files.sh",
@@ -57,7 +59,6 @@
"@types/react-virtualized-auto-sizer": "^1.0.0",
"@types/react-window": "^1.8.2",
"@types/redux": "^3.6.0",
- "@types/redux-form": "^8.2.0",
- "typescript": "3.6.4"
+ "@types/redux-form": "^8.2.0"
}
}
diff --git a/webclient/src/components/Card/Card.css b/webclient/src/components/Card/Card.css
new file mode 100644
index 00000000..97661863
--- /dev/null
+++ b/webclient/src/components/Card/Card.css
@@ -0,0 +1,4 @@
+.card {
+ width: 100%;
+ height: 100%;
+}
diff --git a/webclient/src/components/Card/Card.tsx b/webclient/src/components/Card/Card.tsx
new file mode 100644
index 00000000..f89622c3
--- /dev/null
+++ b/webclient/src/components/Card/Card.tsx
@@ -0,0 +1,20 @@
+// eslint-disable-next-line
+import React, { useMemo, useState } from 'react';
+
+import { CardDTO } from 'services';
+
+import './Card.css';
+
+interface CardProps {
+ card: CardDTO;
+}
+
+const Card = ({ card }: CardProps) => {
+ const src = `https://api.scryfall.com/cards/${card?.identifiers?.scryfallId}?format=image`;
+
+ return card && (
+
+ );
+}
+
+export default Card;
diff --git a/webclient/src/components/CardDetails/CardDetails.css b/webclient/src/components/CardDetails/CardDetails.css
new file mode 100644
index 00000000..7009049d
--- /dev/null
+++ b/webclient/src/components/CardDetails/CardDetails.css
@@ -0,0 +1,45 @@
+.cardDetails {
+ padding: 10px;
+ width: calc(400px * .716);
+ font-size: 10px;
+}
+
+.cardDetails-card {
+ height: 400px;
+ margin: 0 auto;
+}
+
+.cardDetails-attribute {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+}
+
+.cardDetails-attributes {
+ margin: 10px 0;
+}
+
+.cardDetails-attribute__label {
+ text-transform: uppercase;
+ font-size: 10px;
+ margin-right: 10px;
+}
+
+.cardDetails-attribute__value {
+ text-align: right;
+}
+
+.cardDetails-text {
+ font-size: 12px;
+ padding: 5px;
+ background: rgba(0, 0, 0, .15);
+ white-space: pre-line;
+}
+
+.cardDetails-text__flavor {
+ font-style: italic;
+}
+
+.cardDetails-text__current:not(:empty) + .cardDetails-text__flavor {
+ margin-top: 10px;
+}
diff --git a/webclient/src/components/CardDetails/CardDetails.tsx b/webclient/src/components/CardDetails/CardDetails.tsx
new file mode 100644
index 00000000..84196fda
--- /dev/null
+++ b/webclient/src/components/CardDetails/CardDetails.tsx
@@ -0,0 +1,130 @@
+// eslint-disable-next-line
+import React, { useMemo, useState } from 'react';
+
+import { CardDTO } from 'services';
+
+import Card from '../Card/Card';
+
+import './CardDetails.css';
+
+interface CardProps {
+ card: CardDTO;
+}
+
+// @TODO: add missing fields (loyalty, hand, etc)
+
+const CardDetails = ({ card }: CardProps) => {
+ return (
+
+
+
+
+
+ {
+ card && (
+
+
+
+ Name:
+ {card.name}
+
+
+ {
+ (!card.power && !card.toughness) ? null : (
+
+ P/T:
+ {card.power || 0}/{card.toughness || 0}
+
+ )
+ }
+
+ {
+ !card.manaCost ? null : (
+
+ Cost:
+ {card.manaCost.replace(/\{|\}/g, '')}
+
+ )
+ }
+
+ {
+ !card.convertedManaCost ? null : (
+
+ CMC:
+ {card.convertedManaCost}
+
+ )
+ }
+
+ {
+ !card.colorIdentity?.length ? null : (
+
+ Identity:
+ {card.colorIdentity.join('')}
+
+ )
+ }
+
+ {
+ !card.colors?.length ? null : (
+
+ Color(s):
+ {card.colors.join('')}
+
+ )
+ }
+
+ {
+ !card.types?.length ? null : (
+
+ Main Type:
+ {card.types.join(', ')}
+
+ )
+ }
+
+ {
+ !card.type ? null : (
+
+ Type:
+ {card.type}
+
+ )
+ }
+
+ {
+ !card.side ? null : (
+
+ Side:
+ {card.side}
+
+ )
+ }
+
+ {
+ !card.layout ? null : (
+
+ Layout:
+ {card.layout}
+
+ )
+ }
+
+
+
+
+ {card.text?.trim()}
+
+
+
+ {card.flavorText?.trim()}
+
+
+
+ )
+ }
+
+ );
+}
+
+export default CardDetails;
diff --git a/webclient/src/components/CardImportDialog/CardImportDialog.css b/webclient/src/components/CardImportDialog/CardImportDialog.css
new file mode 100644
index 00000000..debc4819
--- /dev/null
+++ b/webclient/src/components/CardImportDialog/CardImportDialog.css
@@ -0,0 +1,5 @@
+.dialog-title {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
\ No newline at end of file
diff --git a/webclient/src/components/CardImportDialog/CardImportDialog.tsx b/webclient/src/components/CardImportDialog/CardImportDialog.tsx
new file mode 100644
index 00000000..52b60d24
--- /dev/null
+++ b/webclient/src/components/CardImportDialog/CardImportDialog.tsx
@@ -0,0 +1,36 @@
+import React from "react";
+import Dialog from '@material-ui/core/Dialog';
+import DialogContent from '@material-ui/core/DialogContent';
+import DialogTitle from '@material-ui/core/DialogTitle';
+import IconButton from '@material-ui/core/IconButton';
+import CloseIcon from '@material-ui/icons/Close';
+import Typography from '@material-ui/core/Typography';
+
+import { CardImportForm } from 'forms';
+
+import './CardImportDialog.css';
+
+const CardImportDialog = ({ classes, handleClose, isOpen }: any) => {
+ const handleOnClose = () => {
+ handleClose();
+ }
+
+ return (
+
+ );
+};
+
+export default CardImportDialog;
\ No newline at end of file
diff --git a/webclient/src/components/Header/Header.tsx b/webclient/src/components/Header/Header.tsx
index 4302184b..84e2ce81 100644
--- a/webclient/src/components/Header/Header.tsx
+++ b/webclient/src/components/Header/Header.tsx
@@ -19,6 +19,8 @@ import { Room, RouteEnum, User } from "types";
import "./Header.css";
import logo from "./logo.png";
+import CardImportDialog from '../CardImportDialog/CardImportDialog';
+
class Header extends Component {
state: HeaderState;
options: string[] = [
@@ -29,12 +31,17 @@ class Header extends Component {
constructor(props) {
super(props);
- this.state = { anchorEl: null };
+ this.state = {
+ anchorEl: null,
+ showCardImportDialog: false,
+ };
this.handleMenuOpen = this.handleMenuOpen.bind(this);
this.handleMenuItemClick = this.handleMenuItemClick.bind(this);
this.handleMenuClose = this.handleMenuClose.bind(this);
this.leaveRoom = this.leaveRoom.bind(this);
+ this.openImportCardWizard = this.openImportCardWizard.bind(this);
+ this.closeImportCardWizard = this.closeImportCardWizard.bind(this);
}
componentDidUpdate(prevProps) {
@@ -65,9 +72,18 @@ class Header extends Component {
RoomsService.leaveRoom(roomId);
};
+ openImportCardWizard() {
+ this.setState({ showCardImportDialog: true });
+ this.handleMenuClose();
+ }
+
+ closeImportCardWizard() {
+ this.setState({ showCardImportDialog: false });
+ }
+
render() {
const { joinedRooms, state, user } = this.props;
- const { anchorEl } = this.state;
+ const { anchorEl, showCardImportDialog } = this.state;
let options = [ ...this.options ];
@@ -156,6 +172,10 @@ class Header extends Component {
{option}
))}
+
+
@@ -163,6 +183,11 @@ class Header extends Component {
) }
+
+
)
}
@@ -177,7 +202,8 @@ interface HeaderProps {
}
interface HeaderState {
- anchorEl: Element
+ anchorEl: Element;
+ showCardImportDialog: boolean;
}
const mapStateToProps = state => ({
diff --git a/webclient/src/components/Message/CardCallout.css b/webclient/src/components/Message/CardCallout.css
new file mode 100644
index 00000000..0011b238
--- /dev/null
+++ b/webclient/src/components/Message/CardCallout.css
@@ -0,0 +1,4 @@
+.callout {
+ font-weight: bold;
+ color: green;
+}
diff --git a/webclient/src/components/Message/CardCallout.tsx b/webclient/src/components/Message/CardCallout.tsx
new file mode 100644
index 00000000..61d023f0
--- /dev/null
+++ b/webclient/src/components/Message/CardCallout.tsx
@@ -0,0 +1,87 @@
+// eslint-disable-next-line
+import React, { useMemo, useState } from 'react';
+import { makeStyles } from '@material-ui/core/styles';
+import Popover from '@material-ui/core/Popover';
+
+import { CardDTO, TokenDTO } from 'services';
+
+import CardDetails from '../CardDetails/CardDetails';
+import TokenDetails from '../TokenDetails/TokenDetails';
+
+import './CardCallout.css';
+
+const useStyles = makeStyles(theme => ({
+ popover: {
+ pointerEvents: 'none',
+ },
+ popoverContent: {
+ pointerEvents: 'none',
+ },
+}));
+
+const CardCallout = ({ name }) => {
+ const classes = useStyles();
+ const [card, setCard] = useState(null);
+ const [token, setToken] = useState(null);
+ const [anchorEl, setAnchorEl] = useState(null);
+
+ useMemo(async () => {
+ const card = await CardDTO.get(name);
+ if (card) {
+ return setCard(card)
+ }
+
+ const token = await TokenDTO.get(name);
+ if (token) {
+ return setToken(token);
+ }
+ }, [name]);
+
+ const handlePopoverOpen = (event) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ const handlePopoverClose = () => {
+ setAnchorEl(null);
+ };
+
+ const open = Boolean(anchorEl);
+
+ return (
+
+ {card?.name || token?.name?.value || name}
+
+ {
+ (card || token) && (
+
+
+ { card && ( ) }
+ { token && ( ) }
+
+
+ )
+ }
+
+ );
+};
+
+export default CardCallout;
diff --git a/webclient/src/components/Message/Message.css b/webclient/src/components/Message/Message.css
new file mode 100644
index 00000000..cbb0df2a
--- /dev/null
+++ b/webclient/src/components/Message/Message.css
@@ -0,0 +1,3 @@
+.link {
+ color: blue;
+}
diff --git a/webclient/src/components/Message/Message.tsx b/webclient/src/components/Message/Message.tsx
new file mode 100644
index 00000000..e041f717
--- /dev/null
+++ b/webclient/src/components/Message/Message.tsx
@@ -0,0 +1,105 @@
+// eslint-disable-next-line
+import React, { useEffect, useMemo, useState } from 'react';
+
+import { NavLink, generatePath } from "react-router-dom";
+
+import {
+ RouteEnum,
+ URL_REGEX,
+ MESSAGE_SENDER_REGEX,
+ MENTION_REGEX,
+ CARD_CALLOUT_REGEX,
+ CALLOUT_BOUNDARY_REGEX,
+} from 'types';
+
+import CardCallout from './CardCallout';
+import './Message.css';
+
+const Message = ({ message: { message, messageType, timeOf, timeReceived } }) => (
+
+);
+
+const ParsedMessage = ({ message }) => {
+ const [messageChunks, setMessageChunks] = useState(null);
+ const [name, setName] = useState(null);
+
+ useMemo(() => {
+ const name = message.match(MESSAGE_SENDER_REGEX);
+
+ if (name) {
+ setName(name[1]);
+ }
+
+ setMessageChunks(parseMessage(message));
+ }, [message]);
+
+ return (
+
+ { name && (
: ) }
+ { messageChunks }
+
+ );
+};
+
+const PlayerLink = ({ name, label = name }) => (
+
+ {label}
+
+);
+
+function parseMessage(message) {
+ return message.replace(MESSAGE_SENDER_REGEX, '')
+ .split(CARD_CALLOUT_REGEX)
+ .filter(chunk => !!chunk)
+ .map(parseChunks);
+}
+
+function parseChunks(chunk, index) {
+ if (chunk.match(CARD_CALLOUT_REGEX)) {
+ const name = chunk.replace(CALLOUT_BOUNDARY_REGEX, '').trim();
+ return ( );
+ }
+
+ if (chunk.match(URL_REGEX)) {
+ return parseUrlChunk(chunk);
+ }
+
+ if (chunk.match(MENTION_REGEX)) {
+ return parseMentionChunk(chunk);
+ }
+
+ return chunk;
+}
+
+function parseUrlChunk(chunk) {
+ return chunk.split(URL_REGEX)
+ .filter(urlChunk => !!urlChunk)
+ .map((urlChunk, index) => {
+ if (urlChunk.match(URL_REGEX)) {
+ return ( {urlChunk} );
+ }
+
+ return urlChunk;
+ });
+}
+
+function parseMentionChunk(chunk) {
+ return chunk.split(MENTION_REGEX)
+ .filter(mentionChunk => !!mentionChunk)
+ .map((mentionChunk, index) => {
+ const mention = mentionChunk.match(MENTION_REGEX);
+
+ if (mention) {
+ const name = mention[0].substr(1);
+ return ( );
+ }
+
+ return mentionChunk;
+ });
+}
+
+export default Message;
diff --git a/webclient/src/components/Token/Token.css b/webclient/src/components/Token/Token.css
new file mode 100644
index 00000000..08f18b87
--- /dev/null
+++ b/webclient/src/components/Token/Token.css
@@ -0,0 +1,4 @@
+.token {
+ width: 100%;
+ height: 100%;
+}
diff --git a/webclient/src/components/Token/Token.tsx b/webclient/src/components/Token/Token.tsx
new file mode 100644
index 00000000..29b39ecc
--- /dev/null
+++ b/webclient/src/components/Token/Token.tsx
@@ -0,0 +1,19 @@
+// eslint-disable-next-line
+import React, { useMemo, useState } from 'react';
+
+import { TokenDTO } from 'services';
+
+import './Token.css';
+
+interface TokenProps {
+ token: TokenDTO;
+}
+
+const Token = ({ token }: TokenProps) => {
+ const set = Array.isArray(token?.set) ? token?.set[0] : token?.set;
+ return token && (
+
+ );
+}
+
+export default Token;
diff --git a/webclient/src/components/TokenDetails/TokenDetails.css b/webclient/src/components/TokenDetails/TokenDetails.css
new file mode 100644
index 00000000..3c326783
--- /dev/null
+++ b/webclient/src/components/TokenDetails/TokenDetails.css
@@ -0,0 +1,46 @@
+.tokenDetails {
+ padding: 10px;
+ width: calc(400px * .716);
+ font-size: 10px;
+}
+
+.tokenDetails-token {
+ height: 400px;
+ margin: 0 auto;
+}
+
+.tokenDetails-attribute {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+}
+
+.tokenDetails-attributes {
+ margin-top: 10px;
+}
+
+.tokenDetails-attribute__label {
+ text-transform: uppercase;
+ font-size: 10px;
+ margin-right: 10px;
+}
+
+.tokenDetails-attribute__value {
+ text-align: right;
+}
+
+.tokenDetails-text {
+ font-size: 12px;
+ margin-top: 10px;
+ padding: 5px;
+ background: rgba(0, 0, 0, .15);
+ white-space: pre-line;
+}
+
+.tokenDetails-text__flavor {
+ font-style: italic;
+}
+
+.tokenDetails-text__current:not(:empty) + .tokenDetails-text__flavor {
+ margin-top: 10px;
+}
diff --git a/webclient/src/components/TokenDetails/TokenDetails.tsx b/webclient/src/components/TokenDetails/TokenDetails.tsx
new file mode 100644
index 00000000..f3d8aed9
--- /dev/null
+++ b/webclient/src/components/TokenDetails/TokenDetails.tsx
@@ -0,0 +1,86 @@
+// eslint-disable-next-line
+import React, { useMemo, useState } from 'react';
+
+import { TokenDTO } from 'services';
+
+import Token from '../Token/Token';
+
+import './TokenDetails.css';
+
+interface TokenProps {
+ token: TokenDTO;
+}
+
+const TokenDetails = ({ token }: TokenProps) => {
+ const props = token?.prop?.value;
+
+ return (
+
+
+
+
+
+ {
+ token && (
+
+
+
+ Name:
+ {token.name?.value}
+
+
+ {
+ (!props.pt?.value) ? null : (
+
+ P/T:
+ {props.pt.value}
+
+ )
+ }
+
+ {
+ !props.colors?.value ? null : (
+
+ Color(s):
+ {props.colors.value}
+
+ )
+ }
+
+ {
+ !props.maintype?.value ? null : (
+
+ Main Type:
+ {props.maintype.value}
+
+ )
+ }
+
+ {
+ !props.type?.value ? null : (
+
+ Type:
+ {props.type.value}
+
+ )
+ }
+
+
+ {
+ !token.text?.value ? null : (
+
+
+ {token.text.value}
+
+
+ )
+ }
+
+ )
+ }
+
+
+ );
+}
+
+export default TokenDetails;
diff --git a/webclient/src/components/VirtualList/VirtualList.tsx b/webclient/src/components/VirtualList/VirtualList.tsx
index 767da6a6..e40ef712 100644
--- a/webclient/src/components/VirtualList/VirtualList.tsx
+++ b/webclient/src/components/VirtualList/VirtualList.tsx
@@ -32,4 +32,4 @@ const Row = ({ data, index, style }) => (
);
-export default VirtualList;
\ No newline at end of file
+export default VirtualList;
diff --git a/webclient/src/components/index.ts b/webclient/src/components/index.ts
index 755e5bb2..4fcf62bb 100644
--- a/webclient/src/components/index.ts
+++ b/webclient/src/components/index.ts
@@ -1,7 +1,10 @@
// Common components
+export { default as Card } from './Card/Card';
+export { default as CardDetails } from './CardDetails/CardDetails';
export { default as Header } from './Header/Header';
export { default as InputField } from './InputField/InputField';
export { default as InputAction } from './InputAction/InputAction';
+export { default as Message } from './Message/Message';
export { default as VirtualList } from './VirtualList/VirtualList';
export { default as UserDisplay} from './UserDisplay/UserDisplay';
export { default as ThreePaneLayout } from './ThreePaneLayout/ThreePaneLayout';
@@ -13,3 +16,5 @@ export { default as ScrollToBottomOnChanges } from './ScrollToBottomOnChanges/Sc
export { default as AuthGuard } from './Guard/AuthGuard';
export { default as ModGuard} from './Guard/ModGuard';
+// Dialogs
+export { default as CardImportDialog} from './CardImportDialog/CardImportDialog';
diff --git a/webclient/src/containers/Room/Messages.css b/webclient/src/containers/Room/Messages.css
index f14cec6e..731002cb 100644
--- a/webclient/src/containers/Room/Messages.css
+++ b/webclient/src/containers/Room/Messages.css
@@ -6,12 +6,12 @@
line-height: 1.3;
}
-.message {
+.message-wrapper {
padding: 5px 0;
margin: 2px 0;
border-bottom: 1px dashed rgba(0, 0, 0, 0.25);
}
-.message:last-of-type {
+.message-wrapper:last-of-type {
border: 0;
}
diff --git a/webclient/src/containers/Room/Messages.tsx b/webclient/src/containers/Room/Messages.tsx
index 47b6d2ea..546a0f91 100644
--- a/webclient/src/containers/Room/Messages.tsx
+++ b/webclient/src/containers/Room/Messages.tsx
@@ -1,31 +1,20 @@
// eslint-disable-next-line
import React from "react";
+import { Message } from 'components';
+
import "./Messages.css";
const Messages = ({ messages }) => (
{
- messages && messages.map(({ message, messageType, timeOf, timeReceived }) => (
-
-
{ParsedMessage(message)}
+ messages && messages.map((message, index) => (
+
+
) )
}
);
-const ParsedMessage = (message) => {
- const name = message.match("^[^:]+:");
-
- if (name && name.length) {
- message = message.slice(name[0].length, message.length);
- }
-
- return
- {name}
- {message}
-
-};
-
-export default Messages;
\ No newline at end of file
+export default Messages;
diff --git a/webclient/src/containers/Room/OpenGames.css b/webclient/src/containers/Room/OpenGames.css
new file mode 100644
index 00000000..623ab47f
--- /dev/null
+++ b/webclient/src/containers/Room/OpenGames.css
@@ -0,0 +1,30 @@
+.games {
+}
+
+.games-header,
+.game {
+ display: flex;
+ padding: 10px;
+ border-bottom: 1px solid black;
+}
+
+.games-header__cell {
+ max-width: 200px;
+}
+
+.games-header__label,
+.game__detail {
+ width: 10%;
+ flex-grow: 0;
+}
+
+.games-header__label.description,
+.game__detail.description {
+ width: 20%;
+ flex-grow: 1;
+}
+
+.games-header__label.creator,
+.game__detail.creator {
+ width: 20%;
+}
diff --git a/webclient/src/containers/Room/OpenGames.tsx b/webclient/src/containers/Room/OpenGames.tsx
new file mode 100644
index 00000000..cb72c753
--- /dev/null
+++ b/webclient/src/containers/Room/OpenGames.tsx
@@ -0,0 +1,143 @@
+// eslint-disable-next-line
+import React, { Component } from "react";
+import { connect } from "react-redux";
+import * as _ from "lodash";
+
+import Table from "@material-ui/core/Table";
+import TableBody from "@material-ui/core/TableBody";
+import TableCell from "@material-ui/core/TableCell";
+import TableHead from "@material-ui/core/TableHead";
+import TableRow from "@material-ui/core/TableRow";
+import TableSortLabel from "@material-ui/core/TableSortLabel";
+import Tooltip from "@material-ui/core/Tooltip";
+
+// import { RoomsService } from "AppShell/common/services";
+
+import { SortUtil, RoomsDispatch, RoomsSelectors } from "store";
+import { UserDisplay } from "components";
+
+import "./OpenGames.css";
+
+// @TODO run interval to update timeSinceCreated
+class OpenGames extends Component
{
+ private headerCells = [
+ {
+ label: "Age",
+ field: "startTime"
+ },
+ {
+ label: "Description",
+ field: "description"
+ },
+ {
+ label: "Creator",
+ field: "creatorInfo.name"
+ },
+ {
+ label: "Type",
+ field: "gameType"
+ },
+ {
+ label: "Restrictions",
+ // field: "?"
+ },
+ {
+ label: "Players",
+ // field: ["maxPlayers", "playerCount"]
+ },
+ {
+ label: "Spectators",
+ field: "spectatorsCount"
+ },
+ ];
+
+ handleSort(sortByField) {
+ const { room: { roomId }, sortBy } = this.props;
+ const { field, order } = SortUtil.toggleSortBy(sortByField, sortBy);
+ RoomsDispatch.sortGames(roomId, field, order);
+ }
+
+ private isUnavailableGame({ started, maxPlayers, playerCount }) {
+ return !started && playerCount < maxPlayers;
+ }
+
+ private isPasswordProtectedGame({ withPassword }) {
+ return !withPassword;
+ }
+
+ private isBuddiesOnlyGame({ onlyBuddies }) {
+ return !onlyBuddies;
+ }
+
+ render() {
+ const { room, sortBy } = this.props;
+
+ const games = room.gameList.filter(game => (
+ this.isUnavailableGame(game) &&
+ this.isPasswordProtectedGame(game) &&
+ this.isBuddiesOnlyGame(game)
+ ));
+
+ return (
+
+
+
+
+ { _.map(this.headerCells, ({ label, field }) => {
+ const active = field === sortBy.field;
+ const order = sortBy.order.toLowerCase();
+ const sortDirection = active ? order : false;
+
+ return (
+
+ {!field ? label : (
+ this.handleSort(field)}
+ >
+ {label}
+
+ )}
+
+ );
+ })}
+
+
+
+ { _.map(games, ({ description, gameId, gameType, creatorInfo, maxPlayers, playerCount, spectatorsCount, startTime }) => (
+
+ {startTime}
+
+
+
+ {description}
+
+
+
+
+
+
+ {gameType}
+ ?
+ {`${playerCount}/${maxPlayers}`}
+ {spectatorsCount}
+
+ ))}
+
+
+
+ );
+ }
+}
+
+interface OpenGamesProps {
+ room: any;
+ sortBy: any;
+}
+
+const mapStateToProps = state => ({
+ sortBy: RoomsSelectors.getSortGamesBy(state)
+});
+
+export default connect(mapStateToProps)(OpenGames);
diff --git a/webclient/src/containers/Room/Room.tsx b/webclient/src/containers/Room/Room.tsx
index f37b7dad..0e026627 100644
--- a/webclient/src/containers/Room/Room.tsx
+++ b/webclient/src/containers/Room/Room.tsx
@@ -11,7 +11,7 @@ import { ScrollToBottomOnChanges, ThreePaneLayout, UserDisplay, VirtualList, Aut
import { RoomsStateMessages, RoomsStateRooms, JoinedRooms, RoomsSelectors } from "store";
import { RouteEnum } from "types";
-import Games from "./Games";
+import OpenGames from "./OpenGames";
import Messages from "./Messages";
import SayMessage from "./SayMessage";
@@ -60,7 +60,7 @@ class Room extends Component {
top={(
-
+
)}
diff --git a/webclient/src/forms/CardImportForm/CardImportForm.css b/webclient/src/forms/CardImportForm/CardImportForm.css
new file mode 100644
index 00000000..56ac3e16
--- /dev/null
+++ b/webclient/src/forms/CardImportForm/CardImportForm.css
@@ -0,0 +1,35 @@
+.cardImportForm {
+ width: 550px;
+}
+
+.cardImportForm-content.done {
+ font-size: 32px;
+ height: 150px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.cardImportForm-actions {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 20px;
+}
+
+.cardImportForm-error {
+ color: red;
+}
+
+.card-import-list {
+ height: 300px;
+ line-height: 1;
+ border: 1px solid lightgrey;
+ padding: 10px;
+}
+
+.loading {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
diff --git a/webclient/src/forms/CardImportForm/CardImportForm.tsx b/webclient/src/forms/CardImportForm/CardImportForm.tsx
new file mode 100644
index 00000000..08a59f3e
--- /dev/null
+++ b/webclient/src/forms/CardImportForm/CardImportForm.tsx
@@ -0,0 +1,225 @@
+// eslint-disable-next-line
+import React, { useEffect, useState } from 'react';
+import { connect } from 'react-redux';
+import { Form, Field, reduxForm} from 'redux-form'
+
+import Button from '@material-ui/core/Button';
+import Stepper from '@material-ui/core/Stepper';
+import Step from '@material-ui/core/Step';
+import StepLabel from '@material-ui/core/StepLabel';
+import CircularProgress from '@material-ui/core/CircularProgress';
+
+import { InputField, VirtualList } from 'components';
+import { cardImporterService, CardDTO, SetDTO, TokenDTO } from 'services';
+import { FormKey } from 'types';
+
+import './CardImportForm.css';
+
+const CardImportForm = (props) => {
+ const { handleSubmit, onSubmit:onClose } = props;
+
+ const [loading, setLoading] = useState(false);
+ const [activeStep, setActiveStep] = useState(0);
+ const [importedCards, setImportedCards] = useState([]);
+ const [importedSets, setImportedSets] = useState([]);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (loading) { setError(null); }
+ }, [loading])
+
+ const steps = ['Imports sets', 'Save sets', 'Import tokens', 'Finished'];
+
+ const handleNext = () => {
+ setActiveStep((prevActiveStep) => prevActiveStep + 1);
+ };
+
+ const handleBack = () => {
+ setActiveStep((prevActiveStep) => prevActiveStep - 1);
+ };
+
+ const handleCardDownload = ({ cardDownloadUrl }) => {
+ setLoading(true);
+
+ cardImporterService.importCards(cardDownloadUrl)
+ .then(({ cards, sets }) => {
+ setImportedCards(cards);
+ setImportedSets(sets);
+
+ handleNext();
+ })
+ .catch(({ message }) => setError(message))
+ .finally(() => setLoading(false));
+ }
+
+ const handleCardSave = async () => {
+ setLoading(true);
+
+ try {
+ await CardDTO.bulkAdd(importedCards);
+ await SetDTO.bulkAdd(importedSets);
+
+ handleNext();
+ } catch(e) {
+ console.error(e);
+ setError('Failed to save cards');
+ }
+
+ setLoading(false);
+ }
+
+ const handleTokenDownload = ({ tokenDownloadUrl }) => {
+ setLoading(true);
+
+ cardImporterService.importTokens(tokenDownloadUrl)
+ .then(async tokens => {
+ await TokenDTO.bulkAdd(tokens);
+ handleNext();
+ })
+ .catch(({ message }) => setError(message))
+ .finally(() => setLoading(false));
+ }
+
+ const getStepContent = (stepIndex) => {
+ switch (stepIndex) {
+ case 0: return (
+
+ );
+
+ case 1: return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ case 2: return (
+
+ );
+
+ case 3: return (
+
+
Finished!
+
+
+
+
+
+
+ );
+ }
+ };
+
+ return (
+
+
+ {steps.map((label) => (
+
+ {label}
+
+ ))}
+
+
+
+ { getStepContent(activeStep) }
+
+
+ { loading && (
+
+
+
+ ) }
+
+ );
+}
+
+const BackButton = ({ click, disabled }) => (
+
+);
+
+const ErrorMessage = ({ error }) => {
+ return error && (
+ {error}
+ );
+}
+
+const CardsImported = ({ cards, sets }) => {
+ const items = [
+ (
+
+ Import finished: {cards.length} cards.
+
+ ),
+
+ ( ),
+
+ ...sets.map(set => (
+ {set.name}: {set.cards.length} cards imported
+ ) )
+ ];
+
+ return (
+
+ index }
+ items={items}
+ size={15}
+ />
+
+ );
+};
+
+const propsMap = {
+ form: FormKey.CARD_IMPORT,
+ onClose: Function
+};
+
+const mapStateToProps = () => ({
+ initialValues: {
+ cardDownloadUrl: 'https://www.mtgjson.com/api/v5/AllPrintings.json',
+ tokenDownloadUrl: 'https://raw.githubusercontent.com/Cockatrice/Magic-Token/master/tokens.xml'
+ },
+});
+
+export default connect(mapStateToProps)(reduxForm(propsMap)(CardImportForm));
diff --git a/webclient/src/forms/index.ts b/webclient/src/forms/index.ts
index d01ceca0..72fa688e 100644
--- a/webclient/src/forms/index.ts
+++ b/webclient/src/forms/index.ts
@@ -1,3 +1,4 @@
+export { default as CardImportForm } from './CardImportForm/CardImportForm';
export { default as ConnectForm } from './ConnectForm/ConnectForm';
export { default as RegisterForm } from './RegisterForm/RegisterForm';
export { default as SearchForm } from './SearchForm/SearchForm';
diff --git a/webclient/src/services/CardImporterService.ts b/webclient/src/services/CardImporterService.ts
new file mode 100644
index 00000000..e456c8ba
--- /dev/null
+++ b/webclient/src/services/CardImporterService.ts
@@ -0,0 +1,104 @@
+// Fetch and parse card sets
+
+class CardImporterService {
+ importCards(url): Promise {
+ const error = 'Card import must be in valid MTG JSON format';
+
+ return fetch(url)
+ .then(response => {
+ if (response.headers.get('Content-Type') !== 'application/json') {
+ throw new Error(error);
+ }
+
+ return response.json();
+ })
+ .then((json) => {
+ try {
+ const sortedSets = Object.keys(json.data)
+ .map(key => json.data[key])
+ .sort((a, b) => new Date(a.releaseDate).getTime() - new Date(b.releaseDate).getTime());
+
+ const sets = sortedSets.map(({ cards, tokens, ...set}) => ({
+ ...set,
+ cards: cards.map(({ name }) => name),
+ tokens: tokens.map(({ name }) => name),
+ }));
+
+ const unsortedCards = sortedSets.reduce((acc, set) => {
+ set.cards.forEach(card => acc[card.name] = card);
+ return acc;
+ }, {});
+
+ const cards = Object.keys(unsortedCards)
+ .sort((a, b) => a.localeCompare(b))
+ .map(key => unsortedCards[key]);
+
+ return { cards, sets };
+ } catch (e) {
+ throw new Error(error);
+ }
+ });
+ }
+
+ importTokens(url): Promise {
+ const error = 'Token import must be in valid MTG XML format';
+
+ return fetch(url)
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('Failed to fetch');
+ }
+
+ return response.text()
+ })
+ .then((xmlString) => {
+ try {
+ const parser = new DOMParser();
+ const dom = parser.parseFromString(xmlString, "application/xml");
+
+ const tokens = Array.from(dom.querySelectorAll('card')).map(
+ (tokenElement) => this.parseXmlAttributes(tokenElement)
+ );
+
+ return tokens;
+ } catch (e) {
+ throw new Error(error);
+ }
+ })
+ }
+
+ private parseXmlAttributes(dom: Element) {
+ return Array.from(dom.children).reduce((attributes, child) => {
+ const value = child.children.length ? this.parseXmlAttributes(child) : child.innerHTML;
+
+ let parsedAttributes = { value };
+
+ if (child.attributes.length) {
+ const childAttributes = Array.from(child.attributes).reduce((acc, { name, value }) => {
+ acc[name] = value;
+ return acc;
+ }, {});
+
+ parsedAttributes = {
+ ...parsedAttributes,
+ ...childAttributes,
+ };
+ }
+
+ // @TODO: clean this up and normalize what i'm returning
+ if (attributes[child.tagName]) {
+ if (Array.isArray(attributes[child.tagName])) {
+ attributes[child.tagName].push(parsedAttributes)
+ } else {
+ attributes[child.tagName] = [ parsedAttributes ];
+ }
+ } else {
+ attributes[child.tagName] = parsedAttributes;
+ }
+
+ return attributes;
+ }, {});
+ }
+}
+
+export const cardImporterService = new CardImporterService();
diff --git a/webclient/src/services/DexieDTOs/CardDTO.ts b/webclient/src/services/DexieDTOs/CardDTO.ts
new file mode 100644
index 00000000..a060aedb
--- /dev/null
+++ b/webclient/src/services/DexieDTOs/CardDTO.ts
@@ -0,0 +1,19 @@
+import { Card } from 'types';
+
+import { dexieService } from '../DexieService';
+
+export class CardDTO extends Card {
+ save() {
+ return dexieService.cards.put(this);
+ }
+
+ static get(name) {
+ return dexieService.cards.where('name').equalsIgnoreCase(name).first();
+ }
+
+ static bulkAdd(cards: CardDTO[]): Promise {
+ return dexieService.cards.bulkPut(cards);
+ }
+};
+
+dexieService.cards.mapToClass(CardDTO);
diff --git a/webclient/src/services/DexieDTOs/SetDTO.ts b/webclient/src/services/DexieDTOs/SetDTO.ts
new file mode 100644
index 00000000..616e8d56
--- /dev/null
+++ b/webclient/src/services/DexieDTOs/SetDTO.ts
@@ -0,0 +1,19 @@
+import { Set } from 'types';
+
+import { dexieService } from '../DexieService';
+
+export class SetDTO extends Set {
+ save() {
+ return dexieService.sets.put(this);
+ }
+
+ static get(name) {
+ return dexieService.sets.where('name').equalsIgnoreCase(name).first();
+ }
+
+ static bulkAdd(sets: SetDTO[]): Promise {
+ return dexieService.sets.bulkPut(sets);
+ }
+};
+
+dexieService.cards.mapToClass(SetDTO);
diff --git a/webclient/src/services/DexieDTOs/TokenDTO.ts b/webclient/src/services/DexieDTOs/TokenDTO.ts
new file mode 100644
index 00000000..35d53770
--- /dev/null
+++ b/webclient/src/services/DexieDTOs/TokenDTO.ts
@@ -0,0 +1,19 @@
+import { Token } from 'types';
+
+import { dexieService } from '../DexieService';
+
+export class TokenDTO extends Token {
+ save() {
+ return dexieService.tokens.put(this);
+ }
+
+ static get(name) {
+ return dexieService.tokens.where('name.value').equalsIgnoreCase(name).first();
+ }
+
+ static bulkAdd(tokens: TokenDTO[]): Promise {
+ return dexieService.tokens.bulkPut(tokens);
+ }
+};
+
+dexieService.tokens.mapToClass(TokenDTO);
diff --git a/webclient/src/services/DexieDTOs/index.ts b/webclient/src/services/DexieDTOs/index.ts
new file mode 100644
index 00000000..2ec93016
--- /dev/null
+++ b/webclient/src/services/DexieDTOs/index.ts
@@ -0,0 +1,3 @@
+export * from './CardDTO';
+export * from './SetDTO';
+export * from './TokenDTO';
diff --git a/webclient/src/services/DexieService.ts b/webclient/src/services/DexieService.ts
new file mode 100644
index 00000000..2429481b
--- /dev/null
+++ b/webclient/src/services/DexieService.ts
@@ -0,0 +1,35 @@
+import Dexie from 'dexie';
+
+enum Stores {
+ CARDS = 'cards',
+ SETS = 'sets',
+ TOKENS = 'tokens',
+}
+
+const StoreKeyIndexes = {
+ [Stores.CARDS]: "name",
+ [Stores.SETS]: "code",
+ [Stores.TOKENS]: "name.value",
+};
+
+class DexieService {
+ private db: Dexie = new Dexie('Webatrice');
+
+ constructor() {
+ this.db.version(1).stores(StoreKeyIndexes);
+ }
+
+ get cards() {
+ return this.db.table(Stores.CARDS);
+ }
+
+ get sets() {
+ return this.db.table(Stores.SETS);
+ }
+
+ get tokens() {
+ return this.db.table(Stores.TOKENS);
+ }
+}
+
+export const dexieService = new DexieService();
diff --git a/webclient/src/services/index.ts b/webclient/src/services/index.ts
new file mode 100644
index 00000000..e5e2a150
--- /dev/null
+++ b/webclient/src/services/index.ts
@@ -0,0 +1,2 @@
+export * from "./CardImporterService";
+export * from './DexieDTOs';
diff --git a/webclient/src/types/cards.ts b/webclient/src/types/cards.ts
new file mode 100644
index 00000000..4e103d97
--- /dev/null
+++ b/webclient/src/types/cards.ts
@@ -0,0 +1,93 @@
+export class Card {
+ artist: string;
+ availability: string[];
+ borderColor: string;
+ colorIdentity: string[];
+ colors: string[];
+ convertedManaCost: number;
+ edhrecRank: number;
+ flavorText: string;
+ identifiers: {
+ cardKingdomId: string;
+ mcmId: string;
+ mcmMetaId: string;
+ mtgjsonV4Id: string;
+ multiverseId: string;
+ scryfallId: string;
+ scryfallIllustrationId: string;
+ scryfallOracleId: string;
+ tcgplayerProductId: string;
+ };
+ isOnlineOnly: boolean;
+ layout: string;
+ legalities: {
+ brawl: string;
+ commander: string;
+ duel: string;
+ future: string;
+ gladiator: string;
+ historic: string;
+ legacy: string;
+ modern: string;
+ pauper: string;
+ penny: string;
+ pioneer: string;
+ premodern: string;
+ standard: string;
+ vintage: string;
+ };
+ manaCost: string;
+ name: string;
+ originalText: string;
+ originalType: string;
+ power: string;
+ printings: string[];
+ rarity: string;
+ rulings: {
+ date: string;
+ text: string;
+ }[];
+ side: string;
+ setCode: string;
+ subtypes: string[];
+ supertypes: string[];
+ text: string;
+ toughness: string;
+ type: string;
+ types: string[];
+ uuid: string;
+ variations: string[];
+}
+
+export class Set {
+ baseSetSize: number;
+ block: string;
+ cards: string[];
+ code: string;
+ isOnlineOnly: boolean;
+ name: string;
+ releaseDate: string;
+ totalSetSize: number;
+ type: string;
+}
+
+export class Token {
+ name: { value: string };
+ prop: {
+ value: {
+ cmc: { value: string; };
+ colors: { value: string; };
+ maintype: { value: string; };
+ pt: { value: string; };
+ type: { value: string; };
+ };
+ };
+ related: { value: string; }[];
+ 'reverse-related': { value: string; }[];
+ set: {
+ value: string;
+ picURL: string;
+ }[];
+ tablerow: { value: string; };
+ text: { value: string; };
+}
diff --git a/webclient/src/types/constants.spec.ts b/webclient/src/types/constants.spec.ts
new file mode 100644
index 00000000..f95a16ea
--- /dev/null
+++ b/webclient/src/types/constants.spec.ts
@@ -0,0 +1,84 @@
+import {
+ URL_REGEX,
+ MESSAGE_SENDER_REGEX,
+ MENTION_REGEX,
+ CARD_CALLOUT_REGEX,
+ CALLOUT_BOUNDARY_REGEX,
+} from './constants';
+
+describe('RegEx', () => {
+ describe('URL_REGEX', () => {
+ it('should match and capture whole url in main capture group', () => {
+ const test = [
+ 'http://example.com',
+ 'https://example.com',
+ 'https://www.example.com',
+ ];
+
+ test.forEach(str => {
+ const match = str.match(URL_REGEX);
+
+ expect(match).toBeDefined();
+ expect(match[0]).toBe(str);
+ });
+ });
+
+ it('should not match bad urls', () => {
+ const test = [
+ 'htt://example.com',
+ 'https:/example.com',
+ 'https//www.example.com',
+ 'www.example.com',
+ 'example.com',
+ ];
+
+ test.forEach(str =>
+ expect(str.match(URL_REGEX)).toBe(null)
+ );
+ });
+ });
+
+ describe('MESSAGE_SENDER_REGEX', () => {
+ it('should match and capture sender name in second capture group', () => {
+ const sender = 'sender';
+ const match = `${sender}: message`.match(MESSAGE_SENDER_REGEX);
+
+ expect(match).toBeDefined();
+ expect(match[1]).toBe(sender);
+ });
+
+ it('should not match if spaces before :', () => {
+ const test = [
+ ' sender: message',
+ 'sender : message',
+ ' sender : message',
+ ];
+
+ test.forEach(str =>
+ expect(str.match(URL_REGEX)).toBe(null)
+ );
+ });
+ });
+
+ describe('MENTION_REGEX', () => {
+ it('should match and capture user mentions in second capture group', () => {
+ expect('@mention'.match(MENTION_REGEX)[0]).toBe('@mention');
+ expect('@mention '.match(MENTION_REGEX)[0]).toBe('@mention');
+ expect(' @mention'.match(MENTION_REGEX)[0]).toBe(' @mention');
+ expect(' @mention '.match(MENTION_REGEX)[0]).toBe(' @mention');
+ expect('leading @mention'.match(MENTION_REGEX)[0]).toBe(' @mention');
+ expect('leading @mention trailing'.match(MENTION_REGEX)[0]).toBe(' @mention');
+ expect('@mention trailing'.match(MENTION_REGEX)[0]).toBe('@mention');
+ });
+
+ it('should not match preceded by character', () => {
+ const test = [
+ 'leading@mention',
+ ];
+
+ test.forEach(str =>
+ expect(str.match(MENTION_REGEX)).toBe(null)
+ );
+ });
+ });
+});
diff --git a/webclient/src/types/constants.ts b/webclient/src/types/constants.ts
new file mode 100644
index 00000000..7341a22b
--- /dev/null
+++ b/webclient/src/types/constants.ts
@@ -0,0 +1,6 @@
+// eslint-disable-next-line
+export const URL_REGEX = /(https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b(?:[-a-zA-Z0-9@:%_\+.~#?&//=]*))/g;
+export const MESSAGE_SENDER_REGEX = /(^[^:\s]+):/;
+export const MENTION_REGEX = /(^|\s)(@\w+)/g;
+export const CARD_CALLOUT_REGEX = /(\[\[[^\]]+\]\])/g;
+export const CALLOUT_BOUNDARY_REGEX = /(\[\[|\]\])/g;
diff --git a/webclient/src/types/forms.ts b/webclient/src/types/forms.ts
index 3898b3e9..753885ea 100644
--- a/webclient/src/types/forms.ts
+++ b/webclient/src/types/forms.ts
@@ -1,6 +1,7 @@
export enum FormKey {
ADD_TO_BUDDIES = "ADD_TO_BUDDIES",
ADD_TO_IGNORE = "ADD_TO_IGNORE",
+ CARD_IMPORT = "CARD_IMPORT",
CONNECT = "CONNECT",
REGISTER = "REGISTER",
SEARCH_LOGS = "SEARCH_LOGS",
diff --git a/webclient/src/types/index.ts b/webclient/src/types/index.ts
index c4aee590..9403d6f8 100644
--- a/webclient/src/types/index.ts
+++ b/webclient/src/types/index.ts
@@ -1,3 +1,5 @@
+export * from "./cards";
+export * from "./constants";
export * from "./game";
export * from "./room";
export * from "./server";
diff --git a/webclient/src/types/server.tsx b/webclient/src/types/server.tsx
index ba8ebb30..91819737 100644
--- a/webclient/src/types/server.tsx
+++ b/webclient/src/types/server.tsx
@@ -22,7 +22,7 @@ export enum KnownHost {
}
export const KnownHosts = {
- [KnownHost.ROOSTER]: { port: 443, host: 'server.cockatrice.us', },
+ [KnownHost.ROOSTER]: { port: 4748, host: 'server.cockatrice.us', },
[KnownHost.TETRARCH]: { port: 443, host: 'mtg.tetrarch.co/servatrice'},
}
diff --git a/webclient/src/websocket/persistence/RoomService.tsx b/webclient/src/websocket/persistence/RoomService.tsx
index 1f2a33eb..b464b59b 100644
--- a/webclient/src/websocket/persistence/RoomService.tsx
+++ b/webclient/src/websocket/persistence/RoomService.tsx
@@ -31,8 +31,12 @@ export default class RoomService {
const game = gameList[0];
if (!game.gameType) {
- const { gametypeMap } = RoomsSelectors.getRoom(store.getState(), roomId);
- NormalizeService.normalizeGameObject(game, gametypeMap);
+ const room = RoomsSelectors.getRoom(store.getState(), roomId);
+
+ if (room) {
+ const { gametypeMap } = room;
+ NormalizeService.normalizeGameObject(game, gametypeMap);
+ }
}
RoomsDispatch.updateGames(roomId, gameList);