Webatrice P.O.C. (#3854)

* port webclient POC into react shell

* Abstract websocket messaging behind redux store

* refactor architecture

* add rooms store

* introduce application service layer and login form

* display room messages

* implement roomSay

* improve Room view styling

* display room games

* improve gameList update logic

* hide protected games

* improve game update logic

* move mapping to earlier lifecycle hook

* add autoscroll to bottom

* tabs to spaces, refresh guard

* implement server joins/leaves

* show users in room

* add material-ui to build

* refactor, add room joins/leaves to store and render

* begin using Material UI components

* fix spectatorsCount

* remove unused package

* improve Server and Room styling

* fix scroll context

* route on room join

* refactor room path

* add auth guard

* refactor authGuard export

* add missing files

* clear store on disconnect, add logout button to Account view

* fix disconnect handling

* Safari fixes

* organize current todos

* improve login page and server status tracking

* improve login page

* introduce sorting arch, refine reducers, begin viewLogHistory

* audit fix for handlebars

* implement moderator log view

* comply with code style rules

* remove original POC from codebase

* add missing semi

* minor improvements, begin registration functionality

* retry as ws when wss fails

additionally, dont mutate the default options when connecting

* retain user/pass in WebClient.options for login

* take protocol off of options, make it a connect param that defaults to wss

* cleanup server page styling

* match wss logic with desktop client

* add virtual scroll component, add context menu to UserDisplay

* revert VirtualTable on messages

* improve styling for Room view

* add routing to Player view

* increase tooltip delay

* begin implementing Account view

* disable app level contextMenu

* implement buddy/ignore list management

* fix gitignore

Co-authored-by: Jay Letto <jeremy.letto@merrillcorp.com>
Co-authored-by: skwerlman <skwerlman@users.noreply.github.com>
Co-authored-by: Jeremy Letto <jeremy.letto@datasite.com>
This commit is contained in:
Jeremy Letto 2020-12-31 16:08:15 -06:00 committed by GitHub
parent d5b36e8b8a
commit 0457e65751
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
152 changed files with 19573 additions and 1071 deletions

23
webclient/.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

70
webclient/README.md Normal file
View file

@ -0,0 +1,70 @@
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.<br />
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br />
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.<br />
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.<br />
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br />
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
## To-Do List
1) RefreshGuard modal
- there is no browser support for displaying custom output to window.onbeforeunload
- we should also display a custom modal explaining why they shouldnt refresh or navigate from the site
- ideally, the custom popup can be synced with the alert, so when the alert is closed, the modal closes too
2) Disable AutoScrollToBottom when the user has scrolled up
- when the user scrolls back to bottom, it should renable
- renable after a period of inactivity (3 minutes?)
3) Figure out how to type components w/ RouteComponentProps
- Component<RouteComponentProps<???, ???, ???>>
4) clear input onSubmit
5) figure out how to reflect server status changes in the ui
6) Account page
7) Register/Forgot Passoword forms
8) Message User
9) Main Nav scheme

3
webclient/copy_shared_files.sh Executable file
View file

@ -0,0 +1,3 @@
#!/bin/bash
cp -a ../common/pb/. ./public/pb/
cp -a ../cockatrice/resources/countries/. ./src/images/countries

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -1,433 +0,0 @@
<!doctype html>
<html>
<head>
<title>Cockatrice web client</title>
<link rel="shortcut icon" href="imgs/cockatrice.png" />
<link rel="stylesheet" href="js/jquery-ui-1.12.1.min.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1><img src="imgs/cockatrice.png" align="absmiddle"/> Cockatrice web client</h1>
</header>
<div id="loading">
Loading cockatrice web client...
</div>
<div id="error-dialog" title="Error" style="display:none"></div>
<div id="info-dialog" title="Information" style="display:none"></div>
<div id="tabs" style="display:none">
<ul>
<li><a href="#tab-login">Login</a></li>
<li><a href="#tab-server">Server</a></li>
<li><a href="#tab-account">Account</a></li>
</ul>
<div id="tab-login">
<h3>Login to server</h3>
<label for="host">Host</label>
<input type="text" id="host" value="" />
<br/>
<label for="port">Port</label>
<input type="text" id="port" value ="4748" />
<br/>
<label for="user">Username</label>
<input type="text" id="user" />
<br/>
<label for="pass">Password</label>
<input type="password" id="pass" />
<br/>
<button id="loginnow">Connect</button>
<button id="quit" style="display:none">Disconnect</button>
<span id="status"></span>
</div>
<div id="tab-server">
<!--
<h3>Rooms</h3>
<span id="roomslist"></span>
-->
<h3>Server messages</h3>
<div id="servermessages"></div>
</div>
<div id="tab-account">
<div class="buddies-container">
<h3>Buddies</h3>
<ul id="buddies" size="10"></ul>
</div>
<div class="ignores-container">
<h3>Ignores</h3>
<ul id="ignores" size="10"></ul>
</div>
<div class="userinfo-container">
<h3>User info</h3>
<span id="userinfo"></span>
</div>
<div class="missingfeatures-container">
<h3>Missing features</h3>
<span id="features"></span>
</div>
<div class="clearfix"></div>
</div>
</div>
<script src="js/jquery-3.2.1.min.js"></script>
<script src="js/jquery-ui-1.12.1.min.js"></script>
<script src="js/protobuf-6.7.0.min.js"></script>
<script src="webclient.js"></script>
<script>
$("#tabs").tabs();
$("#tabs").tabs("disable").tabs("enable", "tab-login");
$("#loading").hide();
$("#tabs").show();
$( "#host" ).autocomplete({
source: [
// add custom servers here
"server.cockatrice.us",
"chickatrice.net",
"127.0.0.1"
]
});
$( document ).ready(function() {
function ts2time(ts) {
var d = new Date(Number(ts));
return ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2) + ':' + ('0' + d.getSeconds()).slice(-2);
}
function getTime() {
var d = new Date();
return ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2) + ':' + ('0' + d.getSeconds()).slice(-2);
}
function htmlEscape(msg) {
return $("<div>").text(msg).html();
}
function htmlSanitize(msg) {
var div = $("<div>").html(msg);
// remove all tags except some
var tagsWhitelist = "br,a,img,center,b,font";
$(div).find("*").not(tagsWhitelist).each(function() {
$(this).replaceWith(this.innerHTML);
});
// remove all attributes except some
var attributesWhitelist = ["href","color"];
$(div).find("*").each(function() {
var attributes = this.attributes;
var i = attributes.length;
while( i-- ) {
var attr = attributes[i];
if( $.inArray(attr.name,attributesWhitelist) == -1 )
this.removeAttributeNode(attr);
}
});
// permit only some protocols in href
var hrefWhitelist = ["http://","https://","ftp://","//"];
$(div).find("[href]").each(function() {
var attributeValue = $(this).attr('href');
for(var protocol in hrefWhitelist)
{
if(attributeValue.indexOf(hrefWhitelist[protocol]) == 0)
{
$(this).attr('target', '_blank');
return;
}
}
$(this).removeAttr('href');
});
return $(div).html();
}
function timeAgoToInterval(secs)
{
var days = Math.floor(secs / 86400);
var years = Math.floor(days / 365);
days -= years * 365;
var txt = '';
switch(years)
{
case 0:
break
case 1:
txt += '1 year ';
break;
default:
txt += years + ' years ';
break;
}
switch(days)
{
case 0:
txt += '0 days ';
case 1:
txt += '1 day ';
break;
default:
txt += days + ' days ';
break;
}
return txt;
}
function decodeUserLevel(id) {
var levels = WebClient.pb.ServerInfo_User.UserLevelFlag;
if (id & levels.IsAdmin)
return "Administrator";
else if (id & levels.IsModerator)
return "Moderator";
else if (id & levels.IsRegistered)
return "Registered user";
else
return "Unregistered user";
}
function sortUserlist(list) {
var li = $(list + ' li');
li.sort(function(a, b) {
return $(a).text().toLowerCase().localeCompare($(b).text().toLowerCase());
});
$(list).empty().html(li);
$(list).selectable();
}
$("#loginnow").click(connect);
$("#port, #user, #pass").keydown(function(e) {
if (e.keyCode == 13) { connect(); }
});
function connect() {
var host = $("#host").val();
var port = $("#port").val();
if(!host.length || !port.length)
{
alert('Please enter a valid host and port.');
return;
}
var options = {
"debug": false,
"autojoinrooms" : true,
"host": host,
"port": port,
"user": $("#user").val(),
"pass": $("#pass").val(),
"statusCallback" : function(id, desc) {
$("#status").text(desc);
if(id == StatusEnum.LOGGEDIN)
{
$("#tabs").tabs("enable").tabs("option", "active", 1);
$("#loginnow").hide();
$("#quit").show();
} else {
$("#tabs").tabs("disable").tabs("enable", "tab-login").tabs("option", "active", 0);
$("#quit").hide();
$("#loginnow").show().prop("disabled", false);
// close rooms
$(".room-header, .room-container").remove();
}
},
"connectionClosedCallback" : function(id, desc) {
$("#status").text('Connection closed: ' + desc);
$("#tabs").tabs("disable").tabs("enable", "tab-login").tabs("option", "active", 0);
$("#quit").hide();
$("#loginnow").show().prop("disabled", false);
// close rooms
$(".room-header, .room-container").remove();
},
"serverMessageCallback" : function(message) {
$("#servermessages").append(htmlSanitize(message) + '<br/>');
},
"serverIdentificationCallback" : function(data) {
console.log('Connected to: ' + data.serverName + ' version ' + data.serverVersion + ' protocol ' + data.protocolVersion);
},
"serverShutdownCallback" : function(data) {
$("#info-dialog").text('The server is going to be restarted in ' + data.minutes + ' minute(s).\nAll running games will be lost.\nReason for shutdown: ' + data.reason);
$("#info-dialog").dialog();
},
"notifyUserCallback" : function(data) {
switch(data.type)
{
case WebClient.pb.Event_NotifyUser.NotificationType.PROMOTED:
$("#info-dialog").text('You have been promoted to moderator. Please log out and back in for changes to take effect.');
$("#info-dialog").dialog();
break;
case WebClient.pb.Event_NotifyUser.NotificationType.WARNING:
$("#info-dialog").text('You have received a warning due to ' + data.warningReason + '.\nPlease refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator.');
$("#info-dialog").dialog();
break;
}
},
"userInfoCallback" : function(data) {
$("#userinfo").empty();
$.each(data.userInfo, function(key, value) {
// filter out inherited properties
if (!data.userInfo.hasOwnProperty(key))
return true;
// filter out empty values
if(value === null)
return true;
switch(key)
{
case 'avatarBmp':
$('#userinfo').prepend("<img id='avatar' src='data:image/JPEG;base64," + btoa(String.fromCharCode.apply(null, value)) + "' /><br/>");
break;
case 'accountageSecs':
$('#userinfo').append('Registered since: ' + timeAgoToInterval(value) + '<br/>');
break;
case 'userLevel':
$('#userinfo').append('User level: ' + decodeUserLevel(value) + '<br/>');
break;
case 'name':
$('#userinfo').append('Name: ' + value + '<br/>');
break;
case 'realName':
$('#userinfo').append('Real name: ' + value + '<br/>');
break;
case 'country':
$('#userinfo').append('Country: ' + value + '<br/>');
break;
case 'privlevel':
$('#userinfo').append('Privilege: ' + value + '<br/>');
break;
case 'id':
case 'serverId':
// don't output these fields
break;
default:
$('#userinfo').append(key + ': ' + value + '<br/>');
break;
}
});
$('#buddies').empty();
$.each(data.buddyList, function(key, value) {
$('#buddies').append('<li>' + value.name + '</li>');
});
$("#buddies").selectable();
$('#ignores').empty();
$.each(data.ignoreList, function(key, value) {
$('#ignores').append('<li>' + value.name + '</li>');
});
$("#ignores").selectable();
$("#features").empty();
$.each(data.missingFeatures, function(key, value) {
$('#features').append(value + '<br/>');
});
},
"listRoomsCallback" : function(rooms) {
// hide
//$("#roomslist").text(JSON.stringify(rooms, null, 4));
},
"errorCallback" : function(id, desc) {
$("#error-dialog").text(desc);
$("#error-dialog").dialog();
},
"joinRoomCallback" : function(room) {
$("div#tabs > ul").append(
"<li class='room-header'><a href='#tab-room-" + room["roomId"] + "'>" + room["name"] + "</a></li>"
);
$("div#tabs").append(
"<div class='room-container' id='tab-room-" + room["roomId"] + "'>" +
"<div class='userlist-container'>" +
"<h3>Userlist</h3>" +
"<ul class=\"userlist\" size=\"10\"></ul>" +
"</div><div class='chat-container'>" +
"<h3>Chat</h3>" +
"<div class=\"output\"></div>" +
"<br/><input type=\"text\" class=\"input\" />" +
"<button class=\"say\">say</button>" +
"</div></div><div class='clearfix'></div>"
);
$("div#tabs").tabs("refresh");
$("#tab-room-" + room["roomId"] + " .userlist").empty();
$.each(room["userList"], function(key, value) {
$("#tab-room-" + room["roomId"] + " .userlist").append('<li>' + value.name + '</li>');
});
sortUserlist("#tab-room-" + room["roomId"] + " .userlist");
$("#tab-room-" + room["roomId"] + " .say").click(function() {
var msg = $("#tab-room-" + room["roomId"] + " .input").val();
$("#tab-room-" + room["roomId"] + " .input").val("");
WebClient.roomSay(room["roomId"], msg);
});
$("#tab-room-" + room["roomId"] + " .input").keydown(function(e) {
if (e.keyCode == 13) {
var msg = $("#tab-room-" + room["roomId"] + " .input").val();
$("#tab-room-" + room["roomId"] + " .input").val("");
WebClient.roomSay(room["roomId"], msg);
}
});
},
"roomMessageCallback" : function(roomId, message) {
var text;
var out = $("#tab-room-" + roomId + " .output")[0];
var isScrolledToBottom = out.scrollHeight - out.clientHeight <= out.scrollTop + 1;
switch(message["messageType"])
{
case WebClient.pb.Event_RoomSay.RoomMessageType.Welcome:
text = "<span class='serverwelcome'>" + htmlEscape(message["message"]) + "</span>";
break;
case WebClient.pb.Event_RoomSay.RoomMessageType.ChatHistory:
text = "<span class='chathistory'>[" + ts2time(message["timeOf"]) + "] " + htmlEscape(message["message"]) + "</span>";
break;
default:
text = "[" + getTime() + "] " + htmlEscape(message["name"]) + ": " + htmlEscape(message["message"]);
break;
}
$("#tab-room-" + roomId + " .output").append(text + '<br/>');
if(isScrolledToBottom)
out.scrollTop = out.scrollHeight - out.clientHeight;
},
"roomJoinCallback" : function(roomId, message) {
$("#tab-room-" + roomId + " .userlist").append('<li>' + message['userInfo'].name + '</li>');
sortUserlist("#tab-room-" + roomId + " .userlist");
},
"roomLeaveCallback" : function(roomId, message) {
var name = message['name'];
$("#tab-room-" + roomId + " .userlist li").filter(function() {
return $.text([this]) === name;
}).remove();
sortUserlist("#tab-room-" + roomId + " .userlist");
},
"roomListGamesCallback" : function(roomId, message) {
// TBD
}
};
$(this).prop("disabled", true);
WebClient.connect(options);
};
$("#quit").click(function() {
WebClient.disconnect();
});
});
</script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

14059
webclient/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

58
webclient/package.json Normal file
View file

@ -0,0 +1,58 @@
{
"name": "webclient",
"version": "1.0.0",
"private": true,
"dependencies": {
"@material-ui/core": "^4.6.1",
"@types/jest": "24.0.20",
"@types/jquery": "^3.3.31",
"@types/lodash": "^4.14.145",
"@types/material-ui": "^0.21.7",
"@types/node": "12.11.7",
"@types/prop-types": "^15.7.3",
"@types/protobufjs": "^6.0.0",
"@types/react": "16.9.11",
"@types/react-dom": "16.9.3",
"@types/react-redux": "^7.1.5",
"@types/react-router-dom": "^5.1.0",
"@types/redux": "^3.6.0",
"@types/redux-form": "^8.2.0",
"jquery": "^3.4.1",
"lodash": "^4.17.15",
"prop-types": "^15.7.2",
"protobufjs": "^6.8.8",
"react": "^16.11.0",
"react-dom": "^16.11.0",
"react-redux": "^7.1.1",
"react-router-dom": "^5.1.2",
"react-scripts": "3.2.0",
"react-virtualized-auto-sizer": "^1.0.2",
"react-window": "^1.8.5",
"redux": "^4.0.4",
"redux-form": "^8.2.6",
"redux-thunk": "^2.3.0",
"typescript": "3.6.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"postinstall": "./copy_shared_files.sh"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View file

@ -1 +0,0 @@
../common/pb

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="stylesheet" type="text/css" href="%PUBLIC_URL%/reset.css">
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Webatrice: A Cockatrice Web Client"
/>
<link rel="apple-touch-icon" href="logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Webatrice</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

4
webclient/public/pb/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
# Ignore all files
*
# Except gitignore
!.gitignore

View file

@ -0,0 +1,48 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}

View file

@ -0,0 +1,2 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *

View file

@ -0,0 +1,49 @@
.account {
display: flex;
justify-content: space-between;
height: 100%;
padding: 5px;
}
.account-column {
display: flex;
flex-direction: column;
width: 33%;
}
.account-list {
display: flex;
flex-direction: column;
height: 100%;
padding: 20px;
}
.account-details {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.account-details__actions {
display: flex;
align-items: stretch;
justify-content: space-around;
width: 100%;
}
.account-details p {
margin-bottom: 10px;
}
.account-details button {
margin-top: 10px;
font-size: 10px;
}
.account-details img {
width: 100%;
margin-bottom: 20px;
}

View file

@ -0,0 +1,122 @@
// eslint-disable-next-line
import React, { Component } from "react";
import { connect } from "react-redux";
import Button from "@material-ui/core/Button";
import ListItem from "@material-ui/core/ListItem";
import Paper from "@material-ui/core/Paper";
import UserDisplay from "AppShell/common/components/UserDisplay/UserDisplay";
import VirtualList from "AppShell/common/components/VirtualList/VirtualList";
import { AuthenticationService, SessionService } from "AppShell/common/services";
import AuthGuard from "AppShell/common/guards/AuthGuard";
import { Selectors } from "store/server";
import { User } from 'types';
import AddToBuddies from './AddToBuddies/AddToBuddies';
import AddToIgnore from './AddToIgnore/AddToIgnore';
import "./Account.css";
class Account extends Component<AccountProps> {
handleAddToBuddies({ userName }) {
SessionService.addToBuddyList(userName);
}
handleAddToIgnore({ userName }) {
SessionService.addToIgnoreList(userName);
}
render() {
console.log(this.props);
const { buddyList, ignoreList, serverName, serverVersion, user } = this.props;
const { country, realName, name, userLevel, accountageSecs, avatarBmp } = user;
var url = URL.createObjectURL(new Blob([avatarBmp], {'type': 'image/png'}));
return (
<div className="account">
<AuthGuard />
<div className="account-column">
<Paper className="account-list">
<div className="">
Buddies Online: ?/{buddyList.length}
</div>
<VirtualList
itemKey={(index, data) => buddyList[index].name }
items={ buddyList.map(user => (
<ListItem button dense>
<UserDisplay user={user} />
</ListItem>
) ) }
/>
<div className="" style={{borderTop: "1px solid"}}>
<AddToBuddies onSubmit={this.handleAddToBuddies} />
</div>
</Paper>
</div>
<div className="account-column">
<Paper className="account-list overflow-scroll">
<div className="">
Ignored Users Online: ?/{ignoreList.length}
</div>
<VirtualList
itemKey={(index, data) => ignoreList[index].name }
items={ ignoreList.map(user => (
<ListItem button dense>
<UserDisplay user={user} />
</ListItem>
) ) }
/>
<div className="" style={{borderTop: "1px solid"}}>
<AddToIgnore onSubmit={this.handleAddToIgnore} />
</div>
</Paper>
</div>
<div className="account-column overflow-scroll">
<Paper className="account-details" style={{margin: "0 0 5px 0"}}>
<img src={url} alt={name} />
<p><strong>{name}</strong></p>
<p>Location: ({country.toUpperCase()})</p>
<p>User Level: {userLevel}</p>
<p>Account Age: {accountageSecs}</p>
<p>Real Name: {realName}</p>
<div className="account-details__actions">
<Button size="small" color="primary" variant="contained">Edit</Button>
<Button size="small" color="primary" variant="contained">Change<br />Password</Button>
<Button size="small" color="primary" variant="contained">Change<br />Avatar</Button>
</div>
</Paper>
<Paper className="account-details">
<p>Server Name: {serverName}</p>
<p>Server Version: {serverVersion}</p>
<Button color="primary" variant="contained" onClick={() => AuthenticationService.disconnect()}>Disconnect</Button>
</Paper>
</div>
</div>
)
}
}
interface AccountProps {
buddyList: User[];
ignoreList: User[];
serverName: string;
serverVersion: string;
user: User;
}
const mapStateToProps = state => ({
buddyList: Selectors.getBuddyList(state),
ignoreList: Selectors.getIgnoreList(state),
serverName: Selectors.getName(state),
serverVersion: Selectors.getVersion(state),
user: Selectors.getUser(state),
});
export default connect(mapStateToProps)(Account);

View file

@ -0,0 +1,18 @@
// eslint-disable-next-line
import React from "react";
import { connect } from "react-redux";
import { Form, reduxForm } from "redux-form"
import InputAction from 'AppShell/common/components/InputAction/InputAction';
const AddToBuddies = ({ handleSubmit }) => (
<Form onSubmit={handleSubmit}>
<InputAction action="Add" label="Add to Buddies" name="userName" />
</Form>
);
const propsMap = {
form: "addToBuddies"
};
export default connect()(reduxForm(propsMap)(AddToBuddies));

View file

@ -0,0 +1,18 @@
// eslint-disable-next-line
import React from "react";
import { connect } from "react-redux";
import { Form, reduxForm } from "redux-form"
import InputAction from 'AppShell/common/components/InputAction/InputAction';
const AddToIgnore = ({ handleSubmit }) => (
<Form onSubmit={handleSubmit}>
<InputAction action="Add" label="Add to Ignore" name="userName" />
</Form>
);
const propsMap = {
form: "addToIgnore"
};
export default connect()(reduxForm(propsMap)(AddToIgnore));

View file

@ -0,0 +1,10 @@
.AppShell,
.AppShell-routes {
height: 100%;
}
.AppShell {
display: flex;
flex-direction: column;
min-width: 768px;
}

View file

@ -0,0 +1,39 @@
// eslint-disable-next-line
import React, { Component } from "react";
import { Provider } from "react-redux";
import { MemoryRouter as Router } from "react-router-dom";
import CssBaseline from "@material-ui/core/CssBaseline";
import { store } from "store";
import "./AppShell.css";
import Routes from "./AppShellRoutes";
import Header from "./Header/Header";
class AppShell extends Component {
componentDidMount() {
// @TODO (1)
window.onbeforeunload = () => true;
}
handleContextMenu(event) {
event.preventDefault();
}
render() {
return (
<Provider store={store}>
<CssBaseline />
<div className="AppShell" onContextMenu={this.handleContextMenu}>
<Router>
<Header />
<Routes />
</Router>
</div>
</Provider>
);
}
}
export default AppShell;

View file

@ -0,0 +1,29 @@
import React from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import { RouteEnum } from "./common/types";
import Account from "./Account/Account";
import Decks from "./Decks/Decks";
import Game from "./Game/Game";
import Logs from "./Logs/Logs";
import Player from "./Player/Player";
import Room from "./Room/Room";
import Server from "./Server/Server";
const Routes = () => (
<div className="AppShell-routes overflow-scroll">
<Switch>
<Route path={RouteEnum.ACCOUNT} render={() => <Account />} />
<Route path={RouteEnum.DECKS} render={() => <Decks />} />
<Route path={RouteEnum.GAME} render={() => <Game />} />
<Route path={RouteEnum.LOGS} render={() => <Logs />} />
<Route path={RouteEnum.PLAYER} render={() => <Player />} />
{<Route path={RouteEnum.ROOM} render={() => <Room />} />}
<Route path={RouteEnum.SERVER} render={() => <Server />} />
<Redirect from="/" to={RouteEnum.SERVER} />
</Switch>
</div>
);
export default Routes;

View file

View file

@ -0,0 +1,19 @@
// eslint-disable-next-line
import React, { Component } from "react";
import AuthGuard from "AppShell/common/guards/AuthGuard";
import "./Decks.css";
class Decks extends Component {
render() {
return (
<div>
<AuthGuard />
<span>"Decks"</span>
</div>
)
}
}
export default Decks;

View file

View file

@ -0,0 +1,19 @@
// eslint-disable-next-line
import React, { Component } from "react";
import AuthGuard from "AppShell/common/guards/AuthGuard";
import "./Game.css";
class Game extends Component {
render() {
return (
<div>
<AuthGuard />
<span>"Game"</span>
</div>
)
}
}
export default Game;

View file

@ -0,0 +1,77 @@
.Header {
}
.Header__logo {
display: flex;
}
.Header__logo img {
height: 40px;
}
.Header-content {
display: flex;
align-items: center;
width: 100%;
padding: 5px;
color: white;
}
.Header-serverDetails {
font-size: 12px;
}
.Header-nav {
width: 100%;
display: flex;
align-items: center;
margin-right: 10px;
}
.Header-nav__items {
width: 100%;
display: flex;
justify-content: flex-end;
}
.Header-nav__item {
list-style: none;
margin: 0 10px;
}
.Header-account {
display: flex;
align-items: center;
}
.Header-account__name {
margin-right: 10px;
font-weight: bold;
}
.Header-account__indicator {
display: inline-block;
height: 16px;
width: 16px;
background: red;
border: 2px solid;
border-radius: 50%;
}
.temp-subnav__rooms {
display: flex;
align-items: center;
font-size: 10px;
padding: 5px;
}
.temp-chip {
margin-left: 5px;
text-decoration: none;
}
.temp-chip > div {
cursor: inherit;
}

View file

@ -0,0 +1,119 @@
import React, { Component } from "react";
import { connect } from "react-redux";
import { NavLink, withRouter, generatePath } from "react-router-dom";
import AppBar from "@material-ui/core/AppBar";
import Chip from "@material-ui/core/Chip";
import Toolbar from "@material-ui/core/Toolbar";
import * as _ from "lodash";
import { Selectors as RoomsSelectors } from "store/rooms";
import { Selectors as ServerSelectors } from "store/server";
import { Room, User } from "types";
import { AuthenticationService } from "AppShell/common/services";
import { RouteEnum } from "AppShell/common/types";
import "./Header.css";
import logo from "./logo.png";
class Header extends Component<HeaderProps> {
componentDidUpdate(prevProps) {
const currentRooms = this.props.joinedRooms;
const previousRooms = prevProps.joinedRooms;
if (currentRooms > previousRooms) {
const { roomId } = _.difference(currentRooms, previousRooms)[0];
this.props.history.push(generatePath(RouteEnum.ROOM, { roomId }));
}
}
render() {
const { joinedRooms, server, state, user } = this.props;
return (
<div>
{/*<header className="Header">*/}
<AppBar position="static">
<Toolbar variant="dense">
<NavLink to={RouteEnum.SERVER} className="Header__logo">
<img src={logo} alt="logo" />
</NavLink>
{ AuthenticationService.isConnected(state) && (
<div className="Header-content">
<nav className="Header-nav">
<ul className="Header-nav__items">
{
AuthenticationService.isModerator(user) && (
<li className="Header-nav__item">
<NavLink to={RouteEnum.LOGS}>
<button>Logs</button>
</NavLink>
</li>
)
}
<li className="Header-nav__item">
<NavLink to={RouteEnum.SERVER} className="plain-link">
Server ({server})
</NavLink>
</li>
<NavLink to={RouteEnum.ACCOUNT} className="plain-link">
<div className="Header-account">
<span className="Header-account__name">
{user.name}
</span>
<span className="Header-account__indicator"></span>
</div>
</NavLink>
</ul>
</nav>
</div>
) }
</Toolbar>
</AppBar>
<div className="temp-subnav">
{
!!joinedRooms.length && (
<Rooms rooms={joinedRooms} />
)
}
<div className="temp-subnav__games">
</div>
<div className="temp-subnav__chats">
</div>
</div>
</div>
)
}
}
const Rooms = props => (
<div className="temp-subnav__rooms">
<span>Rooms: </span>
{
_.reduce(props.rooms, (rooms, { name, roomId}) => {
rooms.push(
<NavLink to={generatePath(RouteEnum.ROOM, { roomId })} className="temp-chip" key={roomId}>
<Chip label={name} color="primary" />
</NavLink>
);
return rooms;
}, [])
}
</div>
)
interface HeaderProps {
state: number;
server: string;
user: User;
joinedRooms: Room[];
history: any;
}
const mapStateToProps = state => ({
state: ServerSelectors.getState(state),
server: ServerSelectors.getName(state),
user: ServerSelectors.getUser(state),
joinedRooms: RoomsSelectors.getJoinedRooms(state)
});
export default withRouter(connect(mapStateToProps)(Header));

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,3 @@
.log-results {
margin-bottom: 20px;
}

View file

@ -0,0 +1,122 @@
import React from "react";
import * as _ from "lodash";
import AppBar from "@material-ui/core/AppBar";
import Box from "@material-ui/core/Box";
import Paper from "@material-ui/core/Paper";
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 Tab from "@material-ui/core/Tab";
import Tabs from "@material-ui/core/Tabs";
import Typography from "@material-ui/core/Typography";
import "./LogResults.css";
const LogResults = (props) => {
const { logs } = props;
const hasRoomLogs = logs.room && logs.room.length;
const hasGameLogs = logs.game && logs.game.length;
const hasChatLogs = logs.chat && logs.chat.length;
const [value, setValue] = React.useState(0);
const handleChange = (event, newValue) => {
setValue(newValue);
};
const headerCells = [
{
label: "Time"
},
{
label: "Sender Name"
},
{
label: "Sender IP"
},
{
label: "Message"
},
{
label: "Target ID"
},
{
label: "Target Name"
}
];
return (
<div>
<AppBar position="static">
<Tabs value={value} onChange={handleChange} aria-label="simple tabs example">
<Tab label={"Rooms" + (hasRoomLogs ? ` [${logs.room.length}]` : "")} {...a11yProps(0)} />
<Tab label={"Games" + (hasGameLogs ? ` [${logs.game.length}]` : "")} {...a11yProps(1)} />
<Tab label={"Chats" + (hasChatLogs ? ` [${logs.chat.length}]` : "")} {...a11yProps(2)} />
</Tabs>
</AppBar>
<TabPanel value={value} index={0}>
<Results logs={logs.room} headerCells={headerCells} />
</TabPanel>
<TabPanel value={value} index={1}>
<Results logs={logs.game} headerCells={headerCells} />
</TabPanel>
<TabPanel value={value} index={2}>
<Results logs={logs.chat} headerCells={headerCells} />
</TabPanel>
</div>
)
};
const a11yProps = index => {
return {
id: `simple-tab-${index}`,
"aria-controls": `simple-tabpanel-${index}`,
};
};
const TabPanel = ({ children, value, index, ...other }) => {
return (
<Typography
component="div"
role="tabpanel"
hidden={value !== index}
id={`simple-tabpanel-${index}`}
aria-labelledby={`simple-tab-${index}`}
{...other}
>
<Box>{children}</Box>
</Typography>
);
};
const Results = ({headerCells, logs}) => (
<Paper className="log-results">
<Table size="small">
<TableHead>
<TableRow>
{ _.map(headerCells, ({ label }) => (
<TableCell key={label}>{label}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{ _.map(logs, ({ time, senderName, senderIp, message, targetId, targetName }, index) => (
<TableRow key={index}>
<TableCell>{time}</TableCell>
<TableCell>{senderName}</TableCell>
<TableCell>{senderIp}</TableCell>
<TableCell>{message}</TableCell>
<TableCell>{targetId}</TableCell>
<TableCell>{targetName}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
);
export default LogResults;

View file

@ -0,0 +1,14 @@
.moderator-logs {
height: 100%;
display: flex;
padding: 20px;
}
.moderator-logs__form {
width: 40%;
margin-right: 20px;
}
.moderator-logs__results {
width: 100%;
}

View file

@ -0,0 +1,102 @@
// eslint-disable-next-line
import React, { Component } from "react";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import * as _ from "lodash";
import { Dispatch, Selectors, ServerStateLogs } from "store/server"
import { ModeratorService } from "AppShell/common/services";
import AuthGuard from "AppShell/common/guards/AuthGuard";
import ModGuard from "AppShell/common/guards/ModGuard";
import LogResults from "./LogResults/LogResults";
import SearchForm from "./SearchForm/SearchForm";
import "./Logs.css";
class Logs extends Component<LogsTypes> {
MAXIMUM_RESULTS = 1000;
constructor(props) {
super(props);
this.onSubmit = this.onSubmit.bind(this);
}
componentWillUnmount() {
Dispatch.clearLogs();
}
onSubmit(fields) {
const trimmedFields: any = this.trimFields(fields);
const { userName, ipAddress, gameName, gameId, message, logLocation } = trimmedFields;
const required = _.filter({
userName, ipAddress, gameName, gameId, message
}, field => field);
if (logLocation) {
trimmedFields.logLocation = this.flattenLogLocations(logLocation);
}
trimmedFields.maximumResults = this.MAXIMUM_RESULTS;
if (_.size(required)) {
ModeratorService.viewLogHistory(trimmedFields);
} else {
// @TODO use yet-to-be-implemented banner/alert
}
}
private trimFields(fields) {
return _.reduce(fields, (obj, field, key) => {
if (typeof field === "string") {
const trimmed = _.trim(field);
if (!!trimmed) {
obj[key] = trimmed;
}
} else {
obj[key] = field;
}
return obj;
}, {});
}
private flattenLogLocations(logLocations) {
return _.reduce(logLocations, (arr, loc, key) => {
arr.push(key);
return arr;
}, [])
}
render() {
return (
<div className="moderator-logs overflow-scroll">
<AuthGuard />
<ModGuard />
<div className="moderator-logs__form">
<SearchForm onSubmit={this.onSubmit} />
</div>
<div className="moderator-logs__results">
<LogResults logs={this.props.logs} />
</div>
</div>
)
}
}
interface LogsTypes {
logs: ServerStateLogs
}
const mapStateToProps = state => ({
logs: Selectors.getLogs(state)
});
export default withRouter(connect(mapStateToProps)(Logs));

View file

@ -0,0 +1,35 @@
.log-search {
margin-bottom: 20px;
}
hr.MuiDivider-root {
margin: 20px 0;
}
.log-search__form {
width: 100%;
padding: 20px;
}
.log-search__form-item {
display: flex;
}
.log-search__form-item.log-location {
display: flex;
justify-content: space-around;
}
.log-search__form-item.log-location .checkbox-field {
display: flex;
flex-direction: column;
}
.log-search__form-item.log-location .checkbox-field__box {
order: 1;
}
.log-search__form-submit.MuiButton-root {
display: block;
margin: 0 auto;
}

View file

@ -0,0 +1,68 @@
// eslint-disable-next-line
import React, { Component } from "react";
import { connect } from "react-redux";
import { Form, Field, InjectedFormProps, reduxForm } from "redux-form"
import Button from "@material-ui/core/Button";
import Divider from "@material-ui/core/Divider";
import Paper from "@material-ui/core/Paper";
import InputField from "AppShell/common/components/InputField/InputField";
import CheckboxField from "AppShell/common/components/CheckboxField/CheckboxField";
import "./SearchForm.css";
class SearchForm extends Component<InjectedFormProps> {
render() {
return (
<Paper className="log-search">
<Form className="log-search__form" onSubmit={this.props.handleSubmit}>
<div className="log-search__form-item">
<Field label="Username" name="userName" component={InputField} />
</div>
<div className="log-search__form-item">
<Field label="IP Address" name="ipAddress" component={InputField} />
</div>
<div className="log-search__form-item">
<Field label="Game Name" name="gameName" component={InputField} />
</div>
<div className="log-search__form-item">
<Field label="GameID" name="gameId" component={InputField} />
</div>
<div className="log-search__form-item">
<Field label="Message" name="message" component={InputField} />
</div>
<Divider />
<div className="log-search__form-item log-location">
<Field label="Rooms" name="logLocation.room" component={CheckboxField} />
<Field label="Games" name="logLocation.game" component={CheckboxField} />
<Field label="Chats" name="logLocation.chat" component={CheckboxField} />
</div>
<Divider />
<div className="log-search__form-item">
<span>Date Range: Coming Soon</span>
</div>
<Divider />
<div className="log-search__form-item">
<span>Maximum Results: 1000</span>
</div>
<Divider />
<Button className="log-search__form-submit" color="primary" variant="contained" type="submit">
Search Logs
</Button>
</Form>
</Paper>
)
}
}
const propsMap = {
form: "logs"
};
const mapStateToProps = () => ({
});
export default connect(mapStateToProps)(reduxForm(propsMap)(SearchForm));

View file

View file

@ -0,0 +1,19 @@
// eslint-disable-next-line
import React, { Component } from "react";
import AuthGuard from "AppShell/common/guards/AuthGuard";
import "./Player.css";
class Player extends Component {
render() {
return (
<div>
<AuthGuard />
<span>"Player"</span>
</div>
)
}
}
export default Player;

View file

@ -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%;
}

View file

@ -0,0 +1,144 @@
// 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 } from "store/common";
import { Dispatch, Selectors } from "store/rooms";
import UserDisplay from "AppShell/common/components/UserDisplay/UserDisplay";
import "./Games.css";
// @TODO run interval to update timeSinceCreated
class Games extends Component<GamesProps> {
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);
Dispatch.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 (
<div className="games">
<Table size="small">
<TableHead>
<TableRow>
{ _.map(this.headerCells, ({ label, field }) => {
const active = field === sortBy.field;
const order = sortBy.order.toLowerCase();
const sortDirection = active ? order : false;
return (
<TableCell sortDirection={sortDirection} key={label}>
{!field ? label : (
<TableSortLabel
active={active}
direction={order}
onClick={() => this.handleSort(field)}
>
{label}
</TableSortLabel>
)}
</TableCell>
);
})}
</TableRow>
</TableHead>
<TableBody>
{ _.map(games, ({ description, gameId, gameType, creatorInfo, maxPlayers, playerCount, spectatorsCount, startTime }) => (
<TableRow key={gameId}>
<TableCell className="games-header__cell single-line-ellipsis">{startTime}</TableCell>
<TableCell className="games-header__cell">
<Tooltip title={description} placement="bottom-start" enterDelay={500}>
<div className="single-line-ellipsis">
{description}
</div>
</Tooltip>
</TableCell>
<TableCell className="games-header__cell">
<UserDisplay user={ creatorInfo } />
</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{gameType}</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">?</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{`${playerCount}/${maxPlayers}`}</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{spectatorsCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
}
interface GamesProps {
room: any;
sortBy: any;
}
const mapStateToProps = state => ({
sortBy: Selectors.getSortGamesBy(state)
});
export default connect(mapStateToProps)(Games);

View file

@ -0,0 +1,17 @@
.messages {
height: 100%;
width: 100%;
padding: 10px;
font-size: 12px;
line-height: 1.3;
}
.message {
padding: 5px 0;
margin: 2px 0;
border-bottom: 1px dashed rgba(0, 0, 0, 0.25);
}
.message:last-of-type {
border: 0;
}

View file

@ -0,0 +1,31 @@
// eslint-disable-next-line
import React from "react";
import "./Messages.css";
const Messages = ({ messages }) => (
<div className="messages">
{
messages && messages.map(({ message, messageType, timeOf, timeReceived }) => (
<div className="message" key={timeReceived}>
<div className="message__detail">{ParsedMessage(message)}</div>
</div>
) )
}
</div>
);
const ParsedMessage = (message) => {
const name = message.match("^[^:]+:");
if (name && name.length) {
message = message.slice(name[0].length, message.length);
}
return <div>
<strong>{name}</strong>
{message}
</div>
};
export default Messages;

View file

@ -0,0 +1,39 @@
.room-view,
.room-view__games,
.room-view__messages,
.room-view__messages-content,
.room-view__side {
height: 100%;
}
.room-view__messages,
.room-view__side {
display: flex;
flex-direction: column;
}
.room-view__messages-sayMessage {
width: 100%;
margin: 10px auto 2px;
}
.room-view__side-label {
position: sticky;
top: 0;
padding: 10px;
background: white;
z-index: 1;
}
.room-view__side-list,
.room-view__side-list .room-view__side-list__item {
height: 100%;
}
.room-view__side-list .room-view__side-list__item {
padding: 0;
}
.room-view__side-list .room-view__side-list__item .user-display__details {
padding: 0 10px;
}

View file

@ -0,0 +1,103 @@
// eslint-disable-next-line
import React, { Component } from "react";
import { connect } from "react-redux";
import { withRouter /*, RouteComponentProps */ } from "react-router-dom";
import ListItem from "@material-ui/core/ListItem";
import Paper from "@material-ui/core/Paper";
import { RoomsStateMessages, RoomsStateRooms, Selectors } from "store/rooms";
import AuthGuard from "AppShell/common/guards/AuthGuard";
import { RoomsService } from "AppShell/common/services";
import ScrollToBottomOnChanges from "AppShell/common/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges";
import ThreePaneLayout from "AppShell/common/components/ThreePaneLayout/ThreePaneLayout";
import UserDisplay from "AppShell/common/components/UserDisplay/UserDisplay";
import VirtualList from "AppShell/common/components/VirtualList/VirtualList";
import Games from "./Games/Games";
import Messages from "./Messages/Messages";
import SayMessage from "./SayMessage/SayMessage";
import "./Room.css";
// @TODO (3)
class Room extends Component<any> {
constructor(props) {
super(props);
this.handleRoomSay = this.handleRoomSay.bind(this);
}
handleRoomSay({ message }) {
if (message) {
const { roomId } = this.props.match.params;
RoomsService.roomSay(roomId, message);
}
}
render() {
const { match, rooms} = this.props;
const { roomId } = match.params;
const room = rooms[roomId];
const messages = this.props.messages[roomId];
const users = room.userList;
return (
<div className="room-view">
<AuthGuard />
<ThreePaneLayout
fixedHeight
top={(
<Paper className="room-view__games overflow-scroll">
<Games room={room} />
</Paper>
)}
bottom={(
<div className="room-view__messages">
<Paper className="room-view__messages-content overflow-scroll">
<ScrollToBottomOnChanges changes={messages} content={(
<Messages messages={messages} />
)} />
</Paper>
<Paper className="room-view__messages-sayMessage">
<SayMessage onSubmit={this.handleRoomSay} />
</Paper>
</div>
)}
side={(
<Paper className="room-view__side overflow-scroll">
<div className="room-view__side-label">
Users in this room: {users.length}
</div>
<VirtualList
className="room-view__side-list"
itemKey={(index, data) => users[index].name }
items={ users.map(user => (
<ListItem button className="room-view__side-list__item">
<UserDisplay user={user} />
</ListItem>
) ) }
/>
</Paper>
)}
/>
</div>
);
}
}
interface RoomProps {
messages: RoomsStateMessages;
rooms: RoomsStateRooms;
}
const mapStateToProps = state => ({
messages: Selectors.getMessages(state),
rooms: Selectors.getRooms(state)
});
export default withRouter(connect(mapStateToProps)(Room));

View file

@ -0,0 +1,18 @@
// eslint-disable-next-line
import React from "react";
import { connect } from "react-redux";
import { Form, reduxForm } from "redux-form"
import InputAction from 'AppShell/common/components/InputAction/InputAction';
const SayMessage = ({ handleSubmit }) => (
<Form onSubmit={handleSubmit}>
<InputAction action="Say" label="Chat" name="message" />
</Form>
);
const propsMap = {
form: "sayMessage"
};
export default connect()(reduxForm(propsMap)(SayMessage));

View file

@ -0,0 +1,14 @@
.connectForm {
width: 100%;
padding: 20px;
}
.connectForm-item {
display: flex;
flex-direction: column;
}
.connectForm-submit.MuiButton-root {
display: block;
margin: 20px auto 0;
}

View file

@ -0,0 +1,49 @@
// eslint-disable-next-line
import React, { Component } from "react";
import { connect } from "react-redux";
import { Form, Field, InjectedFormProps, reduxForm } from "redux-form"
import Button from "@material-ui/core/Button";
import InputField from "AppShell/common/components/InputField/InputField";
import "./ConnectForm.css";
class ConnectForm extends Component<InjectedFormProps> {
render() {
return (
<Form className="connectForm" onSubmit={this.props.handleSubmit}>
<div className="connectForm-item">
<Field label="Host" name="host" component={InputField} />
</div>
<div className="connectForm-item">
<Field label="Port" name="port" component={InputField} />
</div>
<div className="connectForm-item">
<Field label="User" name="user" component={InputField} autoComplete="username" />
</div>
<div className="connectForm-item">
<Field label="Pass" name="pass" type="password" component={InputField} autoComplete="current-password" />
</div>
<Button className="connectForm-submit" color="primary" variant="contained" type="submit">
Connect
</Button>
</Form>
);
}
}
const propsMap = {
form: "connect"
};
const mapStateToProps = () => ({
initialValues: {
// host: "mtg.tetrarch.co/servatrice",
// port: "443"
host: "server.cockatrice.us",
port: "4748"
}
});
export default connect(mapStateToProps)(reduxForm(propsMap)(ConnectForm));

View file

@ -0,0 +1,14 @@
.registerForm {
width: 100%;
padding: 20px;
}
.registerForm-item {
display: flex;
flex-direction: column;
}
.registerForm-submit.MuiButton-root {
display: block;
margin: 20px auto 0;
}

View file

@ -0,0 +1,58 @@
// eslint-disable-next-line
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { Form, Field, InjectedFormProps, reduxForm } from 'redux-form'
import Button from '@material-ui/core/Button';
import InputField from 'AppShell/common/components/InputField/InputField';
import './RegisterForm.css';
class RegisterForm extends Component<InjectedFormProps> {
render() {
return (
<Form className="registerForm" onSubmit={this.props.handleSubmit} autoComplete="off">
<div className="registerForm-item">
<Field label="Host" name="host" component={InputField} />
</div>
<div className="registerForm-item">
<Field label="Port" name="port" component={InputField} />
</div>
<div className="registerForm-item">
<Field label="Player Name" name="userName" component={InputField} />
</div>
<div className="registerForm-item">
<Field label="Password" name="password" type="password" component={InputField} />
</div>
<div className="registerForm-item">
<Field label="Password (again)" name="passwordConfirm" type="password" component={InputField} />
</div>
<div className="registerForm-item">
<Field label="Email" name="email" type="email" component={InputField} />
</div>
<div className="registerForm-item">
<Field label="Email (again)" name="emailConfirm" type="email" component={InputField} />
</div>
<div className="registerForm-item">
<Field label="Real Name" name="realName" component={InputField} />
</div>
<Button className="registerForm-submit" color="primary" variant="contained" type="submit">
Register
</Button>
</Form>
);
}
}
const propsMap = {
form: 'register'
};
const mapStateToProps = () => ({
initialValues: {
}
});
export default connect(mapStateToProps)(reduxForm(propsMap)(RegisterForm));

View file

@ -0,0 +1,26 @@
.rooms {
}
.rooms-header,
.room {
display: flex;
padding: 10px;
border-bottom: 1px solid black;
}
.rooms-header__label,
.room__detail {
width: 10%;
flex-grow: 0;
}
.rooms-header__label.name,
.room__detail.name {
width: 20%;
}
.rooms-header__label.description,
.room__detail.description {
width: 30%;
flex-grow: 1;
}

View file

@ -0,0 +1,61 @@
// eslint-disable-next-line
import React from "react";
import { generatePath } from "react-router-dom";
import * as _ from "lodash";
import Button from "@material-ui/core/Button";
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 { RoomsService } from "AppShell/common/services";
import { RouteEnum } from "AppShell/common/types";
import "./Rooms.css";
const Rooms = ({ rooms, joinedRooms, history }) => {
function onClick(roomId) {
if (_.find(joinedRooms, room => room.roomId === roomId)) {
history.push(generatePath(RouteEnum.ROOM, { roomId }));
} else {
RoomsService.joinRoom(roomId);
}
}
return (
<div className="rooms">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Description</TableCell>
<TableCell>Permissions</TableCell>
<TableCell>Players</TableCell>
<TableCell>Games</TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody>
{ _.map(rooms, ({ description, gameCount, name, permissionlevel, playerCount, roomId }) => (
<TableRow key={roomId}>
<TableCell>{name}</TableCell>
<TableCell>{description}</TableCell>
<TableCell>{permissionlevel}</TableCell>
<TableCell>{playerCount}</TableCell>
<TableCell>{gameCount}</TableCell>
<TableCell>
<Button size="small" color="primary" variant="contained" onClick={() => onClick(roomId)}>
Join
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
export default Rooms;

View file

@ -0,0 +1,61 @@
.server,
.server-rooms,
.server-rooms__side {
height: 100%;
}
.server {
display: flex;
flex-direction: column;
align-items: center;
}
.server .form-wrapper {
display: flex;
flex-direction: column;
}
.server-connect {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.server-connect__form,
.server-connect__description {
width: 100%;
max-width: 300px;
}
.server-connect__form {
margin: 50px 0;
}
.server-connect__description {
text-align: center;
margin: 20px 0;
padding: 20px;
}
.serverRoomWrapper {
height: 100%;
}
.serverMessage {
height: 100%;
padding: 20px;
margin-bottom: 2px;
}
.server-rooms {
width: 100%;
}
.server-rooms__side-label {
position: sticky;
top: 0;
padding: 10px;
background: white;
z-index: 1;
}

View file

@ -0,0 +1,162 @@
// eslint-disable-next-line
import React, { Component } from "react";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import Button from "@material-ui/core/Button";
import ListItem from "@material-ui/core/ListItem";
import Paper from "@material-ui/core/Paper";
import { Selectors as RoomsSelectors } from "store/rooms";
import { Selectors as ServerSelectors } from "store/server";
import { Room, StatusEnum, User } from "types";
import ThreePaneLayout from "AppShell/common/components/ThreePaneLayout/ThreePaneLayout";
import UserDisplay from "AppShell/common/components/UserDisplay/UserDisplay";
import VirtualList from "AppShell/common/components/VirtualList/VirtualList";
import { AuthenticationService } from "AppShell/common/services";
import ConnectForm from "./ConnectForm/ConnectForm";
import RegisterForm from "./RegisterForm/RegisterForm";
import Rooms from "./Rooms/Rooms";
import "./Server.css";
class Server extends Component<ServerProps, ServerState> {
constructor(props) {
super(props);
this.showDescription = this.showDescription.bind(this);
this.showRegisterForm = this.showRegisterForm.bind(this);
this.hideRegisterForm = this.hideRegisterForm.bind(this);
this.onRegister = this.onRegister.bind(this);
this.state = {
register: false
};
}
showDescription(state, description) {
const isDisconnected = state === StatusEnum.DISCONNECTED;
const hasDescription = description && !!description.length;
return isDisconnected && hasDescription;
}
showRegisterForm() {
this.setState({register: true});
}
hideRegisterForm() {
this.setState({register: false});
}
onRegister(fields) {
console.log("register", fields);
}
render() {
const { message, rooms, joinedRooms, history, state, description, users } = this.props;
const { register } = this.state;
const isConnected = AuthenticationService.isConnected(state);
return (
<div className="server">
{
isConnected
? ( <ServerRooms rooms={rooms} joinedRooms={joinedRooms} history={history} message={message} users={users} /> )
: (
<div className="server-connect">
<Paper className="server-connect__form">
{
register
? ( <Register connect={this.hideRegisterForm} onRegister={this.onRegister} /> )
: ( <Connect register={this.showRegisterForm} /> )
}
</Paper>
</div>
)
}
{
!isConnected && this.showDescription(state, description) && (
<Paper className="server-connect__description">
{description}
</Paper>
)
}
</div>
);
}
}
const ServerRooms = ({ rooms, joinedRooms, history, message, users}) => (
<div className="server-rooms">
<ThreePaneLayout
top={(
<Paper className="serverRoomWrapper overflow-scroll">
<Rooms rooms={rooms} joinedRooms={joinedRooms} history={history} />
</Paper>
)}
bottom={(
<Paper className="serverMessage overflow-scroll" dangerouslySetInnerHTML={{ __html: message }} />
)}
side={(
<Paper className="server-rooms__side overflow-scroll">
<div className="server-rooms__side-label">
Users connected to server: {users.length}
</div>
<VirtualList
itemKey={(index, data) => users[index].name }
items={ users.map(user => (
<ListItem button dense>
<UserDisplay user={user} />
</ListItem>
) ) }
/>
</Paper>
)}
/>
</div>
);
const Connect = ({register}) => (
<div className="form-wrapper">
<ConnectForm onSubmit={AuthenticationService.connect} />
{/*{<Button variant="outlined" onClick={register}>Register</Button>}*/}
</div>
);
const Register = ({ onRegister, connect }) => (
<div className="form-wrapper">
<RegisterForm onSubmit={event => onRegister(event)} />
<Button variant="outlined" onClick={connect}>Connect</Button>
</div>
);
interface ServerProps {
message: string;
state: number;
description: string;
rooms: Room[];
joinedRooms: Room[];
users: User[];
history: any;
}
interface ServerState {
register: boolean;
}
const mapStateToProps = state => ({
message: ServerSelectors.getMessage(state),
state: ServerSelectors.getState(state),
description: ServerSelectors.getDescription(state),
rooms: RoomsSelectors.getRooms(state),
joinedRooms: RoomsSelectors.getJoinedRooms(state),
users: ServerSelectors.getUsers(state)
});
export default withRouter(connect(mapStateToProps)(Server));

View file

@ -0,0 +1,25 @@
import React from "react";
import Checkbox from "@material-ui/core/Checkbox";
import FormControlLabel from "@material-ui/core/FormControlLabel";
const CheckboxField = ({ input, label }) => {
const { value, onChange } = input;
// @TODO this isnt unchecking properly
return (
<FormControlLabel
className="checkbox-field"
label={label}
control={
<Checkbox
className="checkbox-field__box"
checked={!!value}
onChange={onChange}
color="primary"
/>
}
/>
);
};
export default CheckboxField;

View file

@ -0,0 +1,19 @@
.input-action {
display: flex;
width: 100%;
align-items: center;
}
.input-action,
.input-action__item,
.input-action__submit {
padding: 5px;
}
.input-action__item {
width: 100%;
height: 100%;
}
.input-action__item > div {
margin: 0;
}

View file

@ -0,0 +1,23 @@
// eslint-disable-next-line
import React from "react";
import { Field } from "redux-form"
import Button from "@material-ui/core/Button";
import InputField from '../InputField/InputField';
import "./InputAction.css";
const InputAction = ({ action, label, name }) => (
<div className="input-action">
<div className="input-action__item">
<Field label={label} name={name} component={InputField} />
</div>
<div className="input-action__submit">
<Button color="primary" variant="contained" type="submit">
{action}
</Button>
</div>
</div>
);
export default InputAction;

View file

@ -0,0 +1,17 @@
import React from "react";
import TextField from "@material-ui/core/TextField";
const InputField = ({ input, label, name, autoComplete, type }) => (
<TextField
variant="outlined"
margin="dense"
fullWidth={true}
label={label}
name={name}
type={type}
autoComplete={autoComplete}
{ ...input }
/>
);
export default InputField;

View file

@ -0,0 +1,25 @@
import React, { useEffect, useRef } from "react";
const ScrollToBottomOnChanges = ({ content, changes }) => {
const messagesEndRef = useRef(null);
// @TODO (2)
const scrollToBottom = () => {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
}
useEffect(scrollToBottom, [changes]);
const styling = {
height: '100%'
};
return (
<div style={styling}>
{content}
<div ref={messagesEndRef} />
</div>
)
}
export default ScrollToBottomOnChanges;

View file

@ -0,0 +1,4 @@
.select-field label {
background: white;
padding: 0 5px;
}

View file

@ -0,0 +1,30 @@
import React from "react";
import FormControl from "@material-ui/core/FormControl";
import InputLabel from "@material-ui/core/InputLabel";
import MenuItem from "@material-ui/core/MenuItem";
import Select from "@material-ui/core/Select";
import './SelectField.css';
const SelectField = ({ input, label, options, value }) => {
const id = label + "-select-field";
const labelId = id + "-label";
return (
<FormControl variant="outlined" margin="dense" className="select-field">
<InputLabel id={labelId}>{label}</InputLabel>
<Select
labelId={labelId}
id={id}
value={value}
{ ...input }
>{
options.map((option, index) => (
<MenuItem value={index} key={index}> { option } </MenuItem>
))
}</Select>
</FormControl>
);
};
export default SelectField;

View file

@ -0,0 +1,33 @@
.three-pane-layout,
.three-pane-layout .grid {
width: 100%;
height: 100%;
margin: 0;
}
.three-pane-layout .grid-main,
.three-pane-layout .grid-side {
height: 100%;
}
.three-pane-layout .grid-main {
display: flex;
flex-direction: column;
}
.three-pane-layout .grid-main__top {
max-height: 50%;
width: 100%;
padding-bottom: 20px;
flex-shrink: 0;
}
.three-pane-layout .grid-main__top.fixedHeight {
height: 50%;
}
.three-pane-layout .grid-main__bottom {
height: 100%;
width: 100%;
flex-shrink: 1;
}

View file

@ -0,0 +1,47 @@
// eslint-disable-next-line
import React, { Component, CElement } from "react";
import { connect } from "react-redux";
import Grid from "@material-ui/core/Grid";
import Hidden from "@material-ui/core/Hidden";
import "./ThreePaneLayout.css";
class ThreePaneLayout extends Component<ThreePaneLayoutProps> {
render() {
return (
<div className="three-pane-layout">
<Grid container spacing={2} className="grid">
<Grid item xs={12} md={9} lg={10} className="grid-main">
<Grid item className={
"grid-main__top"
+ (this.props.fixedHeight ? " fixedHeight" : "")
}>
{this.props.top}
</Grid>
<Grid item className="grid-main__bottom">
{this.props.bottom}
</Grid>
</Grid>
<Hidden smDown>
<Grid item md={3} lg={2} className="grid-side">
{this.props.side}
</Grid>
</Hidden>
</Grid>
</div>
);
}
}
interface ThreePaneLayoutProps {
top: CElement<any, any>,
bottom: CElement<any, any>,
side?: CElement<any, any>,
fixedHeight?: boolean,
}
const mapStateToProps = state => ({
});
export default connect(mapStateToProps)(ThreePaneLayout);

View file

@ -0,0 +1,11 @@
.user-display,
.user-display__link {
height: 100%;
width: 100%;
}
.user-display__details {
height: 100%;
display: flex;
align-items: center;
}

View file

@ -0,0 +1,153 @@
// eslint-disable-next-line
import React, { Component } from "react";
import { connect } from "react-redux";
import { NavLink, generatePath } from "react-router-dom";
import Menu from "@material-ui/core/Menu";
import MenuItem from "@material-ui/core/MenuItem";
import { SessionService } from "AppShell/common/services";
import { RouteEnum } from "AppShell/common/types";
import { Selectors } from "store/server";
import { User } from "types";
import "./UserDisplay.css";
class UserDisplay extends Component<UserDisplayProps, UserDisplayState> {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
this.handleClose = this.handleClose.bind(this);
this.navigateToUserProfile = this.navigateToUserProfile.bind(this);
this.addToBuddyList = this.addToBuddyList.bind(this);
this.removeFromBuddyList = this.removeFromBuddyList.bind(this);
this.addToIgnoreList = this.addToIgnoreList.bind(this);
this.removeFromIgnoreList = this.removeFromIgnoreList.bind(this);
this.isABuddy = this.isABuddy.bind(this);
this.isIgnored = this.isIgnored.bind(this);
this.state = {
position: null
};
}
handleClick(event) {
event.preventDefault();
this.setState({
position: {
x: event.clientX + 2,
y: event.clientY + 4,
}
});
}
handleClose() {
this.setState({
position: null
});
}
navigateToUserProfile() {
this.handleClose();
}
addToBuddyList() {
SessionService.addToBuddyList(this.props.user.name);
this.handleClose();
}
removeFromBuddyList() {
SessionService.removeFromBuddyList(this.props.user.name);
this.handleClose();
}
addToIgnoreList() {
SessionService.addToIgnoreList(this.props.user.name);
this.handleClose();
}
removeFromIgnoreList() {
SessionService.removeFromIgnoreList(this.props.user.name);
this.handleClose();
}
isABuddy() {
return this.props.buddyList.filter(user => user.name === this.props.user.name).length;
}
isIgnored() {
return this.props.ignoreList.filter(user => user.name === this.props.user.name).length;
}
render() {
const { user } = this.props;
const { position } = this.state;
const { name } = user;
const isABuddy = this.isABuddy();
const isIgnored = this.isIgnored();
console.log('user', name, !!isABuddy, !!isIgnored);
return (
<div className="user-display">
<NavLink to={generatePath(RouteEnum.PLAYER, { name })} className="plain-link">
<div className="user-display__details" onContextMenu={this.handleClick}>
<div className="user-display__country"></div>
<div className="user-display__name single-line-ellipsis">{name}</div>
</div>
</NavLink>
<div className="user-display__menu">
<Menu
open={Boolean(position)}
onClose={this.handleClose}
anchorReference='anchorPosition'
anchorPosition={
position !== null
? { top: position.y, left: position.x }
: undefined
}
>
<NavLink to={generatePath(RouteEnum.PLAYER, { name })} className="user-display__link plain-link">
<MenuItem dense>Chat</MenuItem>
</NavLink>
{
!isABuddy
? ( <MenuItem dense onClick={this.addToBuddyList}>Add to Buddy List</MenuItem> )
: ( <MenuItem dense onClick={this.removeFromBuddyList}>Remove From Buddy List</MenuItem> )
}
{
!isIgnored
? ( <MenuItem dense onClick={this.addToIgnoreList}>Add to Ignore List</MenuItem> )
: ( <MenuItem dense onClick={this.removeFromIgnoreList}>Remove From Ignore List</MenuItem> )
}
</Menu>
</div>
</div>
);
}
}
interface UserDisplayProps {
user: User;
buddyList: User[];
ignoreList: User[];
}
interface UserDisplayState {
position: any;
}
const mapStateToProps = (state) => ({
buddyList: Selectors.getBuddyList(state),
ignoreList: Selectors.getIgnoreList(state)
});
export default connect(mapStateToProps)(UserDisplay);

View file

@ -0,0 +1,3 @@
.virtual-list {
height: 100%;
}

View file

@ -0,0 +1,35 @@
// eslint-disable-next-line
import React from "react";
import { FixedSizeList as List } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import './VirtualList.css';
const VirtualList = ({ items, itemKey, className = {}, size = 30 }) => (
<div className="virtual-list">
<AutoSizer>
{({ height, width }) => (
<List
className={`virtual-list__list ${className}`}
height={height}
width={width}
itemData={items}
itemCount={items.length}
itemSize={size}
itemKey={itemKey}
>
{Row}
</List>
)}
</AutoSizer>
</div>
);
const Row = ({ data, index, style }) => (
<div style={style}>
{data[index]}
</div>
);
export default VirtualList;

View file

@ -0,0 +1,26 @@
import React, { Component } from "react";
import { connect } from "react-redux";
import { Redirect } from "react-router-dom";
import { Selectors } from "store/server";
import { AuthenticationService } from "AppShell/common/services";
import { RouteEnum } from "AppShell/common/types";
class AuthGuard extends Component<AuthGuardProps> {
render() {
return !AuthenticationService.isConnected(this.props.state)
? <Redirect from="*" to={RouteEnum.SERVER} />
: "";
}
};
interface AuthGuardProps {
state: number;
}
const mapStateToProps = state => ({
state: Selectors.getState(state),
});
export default connect(mapStateToProps)(AuthGuard);

View file

@ -0,0 +1,27 @@
import React, { Component } from "react";
import { connect } from "react-redux";
import { Redirect } from "react-router-dom";
import { Selectors } from "store/server";
import { User } from "types";
import { AuthenticationService } from "AppShell/common/services";
import { RouteEnum } from "AppShell/common/types";
class ModGuard extends Component<ModGuardProps> {
render() {
return !AuthenticationService.isModerator(this.props.user)
? <Redirect from="*" to={RouteEnum.SERVER} />
: "";
}
};
interface ModGuardProps {
user: User;
}
const mapStateToProps = state => ({
user: Selectors.getUser(state),
});
export default connect(mapStateToProps)(ModGuard);

View file

@ -0,0 +1,23 @@
import { StatusEnum } from "types";
import webClient from "WebClient/WebClient";
export class AuthenticationService {
static connect(options) {
webClient.services.session.connectServer(options);
}
static disconnect() {
webClient.services.session.disconnectServer();
}
static isConnected(state) {
return state === StatusEnum.LOGGEDIN;
}
static isModerator(user) {
return user.userLevel >= webClient.pb.ServerInfo_User.UserLevelFlag.IsModerator;
}
static isAdmin() {
}
}

View file

@ -0,0 +1,7 @@
import webClient from "WebClient/WebClient";
export class ModeratorService {
static viewLogHistory(filters) {
webClient.commands.session.viewLogHistory(filters);
}
}

View file

@ -0,0 +1,11 @@
import webClient from "WebClient/WebClient";
export class RoomsService {
static joinRoom(roomId) {
webClient.commands.session.joinRoom(roomId);
}
static roomSay(roomId, message) {
webClient.commands.room.roomSay(roomId, message);
}
}

View file

@ -0,0 +1,7 @@
import { RouteEnum } from "../types";
export class RouterService {
resolveUrl(path, params) {
}
}

View file

@ -0,0 +1,19 @@
import webClient from "WebClient/WebClient";
export class SessionService {
static addToBuddyList(userName) {
webClient.commands.session.addToBuddyList(userName);
}
static removeFromBuddyList(userName) {
webClient.commands.session.removeFromBuddyList(userName);
}
static addToIgnoreList(userName) {
webClient.commands.session.addToIgnoreList(userName);
}
static removeFromIgnoreList(userName) {
webClient.commands.session.removeFromIgnoreList(userName);
}
}

View file

@ -0,0 +1,4 @@
export * from "./AuthenticationService";
export * from "./ModeratorService";
export * from "./RoomsService";
export * from "./SessionService";

View file

@ -0,0 +1,2 @@
export * from "./routes";
export * from "./user";

View file

@ -0,0 +1,10 @@
export enum RouteEnum {
PLAYER = "/player/:name",
SERVER = "/server",
ROOM = "/room/:roomId",
LOGS = "/logs",
GAME = "/game",
DECKS = "/decks",
DECK = "/deck",
ACCOUNT = "/account",
}

View file

@ -0,0 +1,3 @@
export interface User {
name: string;
}

View file

@ -0,0 +1,157 @@
const ProtoFiles = [
"admin_commands.proto",
"card_attributes.proto",
"color.proto",
"command_attach_card.proto",
"command_change_zone_properties.proto",
"command_concede.proto",
"command_create_arrow.proto",
"command_create_counter.proto",
"command_create_token.proto",
"command_deck_del.proto",
"command_deck_del_dir.proto",
"command_deck_download.proto",
"command_deck_list.proto",
"command_deck_new_dir.proto",
"command_deck_select.proto",
"command_deck_upload.proto",
"command_del_counter.proto",
"command_delete_arrow.proto",
"command_draw_cards.proto",
"command_dump_zone.proto",
"command_flip_card.proto",
"command_game_say.proto",
"command_inc_card_counter.proto",
"command_inc_counter.proto",
"command_kick_from_game.proto",
"command_leave_game.proto",
"command_move_card.proto",
"command_mulligan.proto",
"command_next_turn.proto",
"command_ready_start.proto",
"command_replay_delete_match.proto",
"command_replay_download.proto",
"command_replay_list.proto",
"command_replay_modify_match.proto",
"command_reveal_cards.proto",
"command_roll_die.proto",
"command_set_active_phase.proto",
"command_set_card_attr.proto",
"command_set_card_counter.proto",
"command_set_counter.proto",
"command_set_sideboard_lock.proto",
"command_set_sideboard_plan.proto",
"command_shuffle.proto",
"command_stop_dump_zone.proto",
"command_undo_draw.proto",
"commands.proto",
"context_concede.proto",
"context_connection_state_changed.proto",
"context_deck_select.proto",
"context_move_card.proto",
"context_mulligan.proto",
"context_ping_changed.proto",
"context_ready_start.proto",
"context_set_sideboard_lock.proto",
"context_undo_draw.proto",
"event_add_to_list.proto",
"event_attach_card.proto",
"event_change_zone_properties.proto",
"event_connection_closed.proto",
"event_create_arrow.proto",
"event_create_counter.proto",
"event_create_token.proto",
"event_del_counter.proto",
"event_delete_arrow.proto",
"event_destroy_card.proto",
"event_draw_cards.proto",
"event_dump_zone.proto",
"event_flip_card.proto",
"event_game_closed.proto",
"event_game_host_changed.proto",
"event_game_joined.proto",
"event_game_say.proto",
"event_game_state_changed.proto",
"event_join.proto",
"event_join_room.proto",
"event_kicked.proto",
"event_leave.proto",
"event_leave_room.proto",
"event_list_games.proto",
"event_list_rooms.proto",
"event_move_card.proto",
"event_notify_user.proto",
"event_player_properties_changed.proto",
"event_remove_from_list.proto",
"event_replay_added.proto",
"event_reveal_cards.proto",
"event_roll_die.proto",
"event_room_say.proto",
"event_server_complete_list.proto",
"event_server_identification.proto",
"event_server_message.proto",
"event_server_shutdown.proto",
"event_set_active_phase.proto",
"event_set_active_player.proto",
"event_set_card_attr.proto",
"event_set_card_counter.proto",
"event_set_counter.proto",
"event_shuffle.proto",
"event_stop_dump_zone.proto",
"event_user_joined.proto",
"event_user_left.proto",
"event_user_message.proto",
"game_commands.proto",
"game_event.proto",
"game_event_container.proto",
"game_event_context.proto",
"game_replay.proto",
"isl_message.proto",
"moderator_commands.proto",
"move_card_to_zone.proto",
"response.proto",
"response_activate.proto",
"response_adjust_mod.proto",
"response_ban_history.proto",
"response_deck_download.proto",
"response_deck_list.proto",
"response_deck_upload.proto",
"response_dump_zone.proto",
"response_forgotpasswordrequest.proto",
"response_get_games_of_user.proto",
"response_get_user_info.proto",
"response_join_room.proto",
"response_list_users.proto",
"response_login.proto",
"response_register.proto",
"response_replay_download.proto",
"response_replay_list.proto",
"response_viewlog_history.proto",
"response_warn_history.proto",
"response_warn_list.proto",
"room_commands.proto",
"room_event.proto",
"server_message.proto",
"serverinfo_arrow.proto",
"serverinfo_ban.proto",
"serverinfo_card.proto",
"serverinfo_cardcounter.proto",
"serverinfo_chat_message.proto",
"serverinfo_counter.proto",
"serverinfo_deckstorage.proto",
"serverinfo_game.proto",
"serverinfo_gametype.proto",
"serverinfo_player.proto",
"serverinfo_playerping.proto",
"serverinfo_playerproperties.proto",
"serverinfo_replay.proto",
"serverinfo_replay_match.proto",
"serverinfo_room.proto",
"serverinfo_user.proto",
"serverinfo_warning.proto",
"serverinfo_zone.proto",
"session_commands.proto",
"session_event.proto",
];
export default ProtoFiles;

View file

@ -0,0 +1,329 @@
import protobuf from "protobufjs";
import { StatusEnum } from "types";
import * as roomEvents from "./events/RoomEvents";
import * as sessionEvents from "./events/SessionEvents";
import { RoomService, SessionService } from "./services";
import { RoomCommands, SessionCommands } from "./commands";
import ProtoFiles from "./ProtoFiles";
const roomEventKeys = Object.keys(roomEvents);
const sessionEventKeys = Object.keys(sessionEvents);
interface ApplicationCommands {
room: RoomCommands;
session: SessionCommands;
}
interface ApplicationServices {
room: RoomService;
session: SessionService;
}
export class WebClient {
private socket: WebSocket;
private status: StatusEnum = StatusEnum.DISCONNECTED;
private keepalivecb;
private lastPingPending = false;
private cmdId = 0;
private pendingCommands = {};
public commands: ApplicationCommands;
public services: ApplicationServices;
public protocolVersion = 14;
public pb;
public clientConfig = {
"clientver" : "webclient-1.0 (2019-10-31)",
"clientfeatures" : [
"client_id",
"client_ver",
"feature_set",
"room_chat_history",
"client_warnings",
/* unimplemented features */
"forgot_password",
"idle_client",
"mod_log_lookup",
"user_ban_history",
// satisfy server reqs for POC
"websocket",
"2.6.1_min_version",
"2.7.0_min_version",
]
};
public options: any = {
host: "",
port: "",
user: "",
pass: "",
debug: false,
autojoinrooms: true,
keepalive: 5000
};
constructor() {
const files = ProtoFiles.map(file => `${WebClient.PB_FILE_DIR}/${file}`);
this.pb = new protobuf.Root();
this.pb.load(files, { keepCase: false }, (err, root) => {
if (err) {
throw err;
}
});
// This sucks. I can"t seem to get out of this
// circular dependency trap, so this is my current best.
this.commands = {
room: new RoomCommands(this),
session: new SessionCommands(this),
};
this.services = {
room: new RoomService(this),
session: new SessionService(this),
};
console.log(this);
}
private clearStores() {
this.services.room.clearStore();
this.services.session.clearStore();
}
public updateStatus(status, description) {
console.log(`Status: [${status}]: ${description}`);
this.status = status;
this.services.session.updateStatus(status, description);
if (status === StatusEnum.DISCONNECTED) {
this.clearStores();
this.endPingLoop();
this.resetConnectionvars();
}
}
public resetConnectionvars() {
this.cmdId = 0;
this.pendingCommands = {};
this.lastPingPending = false;
}
public sendCommand(cmd, callback) {
this.cmdId++;
cmd["cmdId"] = this.cmdId;
this.pendingCommands[this.cmdId] = callback;
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(this.pb.CommandContainer.encode(cmd).finish());
this.debug(() => console.log("Sent: " + cmd.toString()));
} else {
this.debug(() => console.log("Send: Not connected"));
}
}
public sendRoomCommand(roomId, roomCmd, callback?) {
const cmd = this.pb.CommandContainer.create({
"roomId" : roomId,
"roomCommand" : [ roomCmd ]
});
this.sendCommand(cmd, raw => {
this.debug(() => console.log(raw));
if (callback) {
callback(raw);
}
});
}
public sendSessionCommand(sesCmd, callback?) {
const cmd = this.pb.CommandContainer.create({
"sessionCommand" : [ sesCmd ]
});
this.sendCommand(cmd, (raw) => {
this.debug(() => console.log(raw));
if (callback) {
callback(raw);
}
});
}
public sendModeratorCommand(modCmd, callback?) {
const cmd = this.pb.CommandContainer.create({
"moderatorCommand" : [ modCmd ]
});
this.sendCommand(cmd, (raw) => {
this.debug(() => console.log(raw));
if (callback) {
callback(raw);
}
});
}
public startPingLoop() {
this.keepalivecb = setInterval(() => {
// check if the previous ping got no reply
if (this.lastPingPending) {
this.disconnect();
this.updateStatus(StatusEnum.DISCONNECTED, "Connection timeout");
}
// stop the ping loop if we"re disconnected
if (this.status !== StatusEnum.LOGGEDIN) {
this.endPingLoop();
return;
}
// send a ping
this.lastPingPending = true;
const ping = this.pb.Command_Ping.create();
const command = this.pb.SessionCommand.create({
".Command_Ping.ext" : ping
});
this.sendSessionCommand(command, () => this.lastPingPending = false);
}, this.options.keepalive);
}
private endPingLoop() {
clearInterval(this.keepalivecb);
this.keepalivecb = null;
}
public connect(options) {
this.options = { ...this.options, ...options };
const { host, port } = this.options;
const protocol = port === '443' ? 'wss' : 'ws';
this.socket = new WebSocket(protocol + "://" + host + ":" + port);
this.socket.binaryType = "arraybuffer"; // We are talking binary
this.socket.onopen = () => {
this.updateStatus(StatusEnum.CONNECTED, "Connected");
};
this.socket.onclose = () => {
// dont overwrite failure messages
if (this.status !== StatusEnum.DISCONNECTED) {
this.updateStatus(StatusEnum.DISCONNECTED, "Connection Closed");
}
};
this.socket.onerror = () => {
this.updateStatus(StatusEnum.DISCONNECTED, "Connection Failed");
};
this.socket.onmessage = (event) => {
const msg = this.decodeServerMessage(event);
if (msg) {
switch (msg.messageType) {
case this.pb.ServerMessage.MessageType.RESPONSE:
this.processServerResponse(msg.response);
break;
case this.pb.ServerMessage.MessageType.ROOM_EVENT:
this.processRoomEvent(msg.roomEvent, msg);
break;
case this.pb.ServerMessage.MessageType.SESSION_EVENT:
this.processSessionEvent(msg.sessionEvent, msg);
break;
case this.pb.ServerMessage.MessageType.GAME_EVENT_CONTAINER:
// @TODO
break;
}
}
}
}
public disconnect() {
if (this.socket) {
this.socket.close();
}
}
public debug(debug) {
if (this.options.debug) {
debug();
}
}
private decodeServerMessage(event) {
const uint8msg = new Uint8Array(event.data);
let msg;
try {
msg = this.pb.ServerMessage.decode(uint8msg);
this.debug(() => console.log(msg));
return msg;
} catch (err) {
console.error("Processing failed:", err);
this.debug(() => {
let str = "";
for (let i = 0; i < uint8msg.length; i++) {
str += String.fromCharCode(uint8msg[i]);
}
console.log(str);
});
return;
}
}
private processServerResponse(response) {
const cmdId = response.cmdId;
if (!this.pendingCommands.hasOwnProperty(cmdId)) {
return;
}
this.pendingCommands[cmdId](response);
delete this.pendingCommands[cmdId];
}
private processRoomEvent(response, raw) {
this.processEvent(response, roomEvents, roomEventKeys, raw);
}
private processSessionEvent(response, raw) {
this.processEvent(response, sessionEvents, sessionEventKeys, raw);
}
private processEvent(response, events, keys, raw) {
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const event = events[key];
const payload = response[event.id];
if (payload) {
events[key].action(payload, this, raw);
return;
}
}
}
static PB_FILE_DIR = `${process.env.PUBLIC_URL}/pb`;
}
const webClient = new WebClient();
export default webClient;

View file

@ -0,0 +1,27 @@
import * as _ from 'lodash';
import { WebClient } from "../WebClient";
export class RoomCommands {
private webClient: WebClient;
constructor(webClient) {
this.webClient = webClient;
}
roomSay(roomId, message) {
const trimmed = _.trim(message);
if (!trimmed) return;
var CmdRoomSay = this.webClient.pb.Command_RoomSay.create({
"message" : trimmed
});
var rc = this.webClient.pb.RoomCommand.create({
".Command_RoomSay.ext" : CmdRoomSay
});
this.webClient.sendRoomCommand(roomId, rc);
}
}

View file

@ -0,0 +1,234 @@
import { StatusEnum } from "types";
import { WebClient } from "../WebClient";
import { guid } from "../util";
export class SessionCommands {
private webClient: WebClient;
constructor(webClient) {
this.webClient = webClient;
}
login() {
const loginConfig = {
...this.webClient.clientConfig,
"userName" : this.webClient.options.user,
"password" : this.webClient.options.pass,
"clientid" : guid()
};
const CmdLogin = this.webClient.pb.Command_Login.create(loginConfig);
const command = this.webClient.pb.SessionCommand.create({
".Command_Login.ext" : CmdLogin
});
this.webClient.sendSessionCommand(command, raw => {
const resp = raw[".Response_Login.ext"];
this.webClient.debug(() => console.log(".Response_Login.ext", resp));
switch(raw.responseCode) {
case this.webClient.pb.Response.ResponseCode.RespOk:
const { buddyList, ignoreList, userInfo } = resp;
this.webClient.services.session.updateBuddyList(buddyList);
this.webClient.services.session.updateIgnoreList(ignoreList);
this.webClient.services.session.updateUser(userInfo);
this.webClient.commands.session.listUsers();
this.webClient.commands.session.listRooms();
this.webClient.updateStatus(StatusEnum.LOGGEDIN, "Logged in.");
this.webClient.startPingLoop();
break;
case this.webClient.pb.Response.ResponseCode.RespClientUpdateRequired:
this.webClient.updateStatus(StatusEnum.DISCONNECTED, "Login failed: missing features");
break;
case this.webClient.pb.Response.ResponseCode.RespWrongPassword:
case this.webClient.pb.Response.ResponseCode.RespUsernameInvalid:
this.webClient.updateStatus(StatusEnum.DISCONNECTED, "Login failed: incorrect username or password");
break;
case this.webClient.pb.Response.ResponseCode.RespWouldOverwriteOldSession:
this.webClient.updateStatus(StatusEnum.DISCONNECTED, "Login failed: duplicated user session");
break;
case this.webClient.pb.Response.ResponseCode.RespUserIsBanned:
this.webClient.updateStatus(StatusEnum.DISCONNECTED, "Login failed: banned user");
break;
case this.webClient.pb.Response.ResponseCode.RespRegistrationRequired:
this.webClient.updateStatus(StatusEnum.DISCONNECTED, "Login failed: registration required");
break;
case this.webClient.pb.Response.ResponseCode.RespClientIdRequired:
this.webClient.updateStatus(StatusEnum.DISCONNECTED, "Login failed: missing client ID");
break;
case this.webClient.pb.Response.ResponseCode.RespContextError:
this.webClient.updateStatus(StatusEnum.DISCONNECTED, "Login failed: server error");
break;
case this.webClient.pb.Response.ResponseCode.RespAccountNotActivated:
this.webClient.updateStatus(StatusEnum.DISCONNECTED, "Login failed: account not activated");
break;
default:
this.webClient.updateStatus(StatusEnum.DISCONNECTED, "Login failed: unknown error " + raw.responseCode);
}
});
}
listUsers() {
const CmdListUsers = this.webClient.pb.Command_ListUsers.create();
const sc = this.webClient.pb.SessionCommand.create({
".Command_ListUsers.ext" : CmdListUsers
});
this.webClient.sendSessionCommand(sc, raw => {
const { responseCode } = raw;
const response = raw[".Response_ListUsers.ext"];
if (response) {
switch (responseCode) {
case this.webClient.pb.Response.ResponseCode.RespOk:
this.webClient.services.session.updateUsers(response.userList);
break;
default:
console.log(`Failed to fetch Server Rooms [${responseCode}] : `, raw);
}
}
});
}
listRooms() {
const CmdListRooms = this.webClient.pb.Command_ListRooms.create();
const sc = this.webClient.pb.SessionCommand.create({
".Command_ListRooms.ext" : CmdListRooms
});
this.webClient.sendSessionCommand(sc);
}
joinRoom(roomId: string) {
const CmdJoinRoom = this.webClient.pb.Command_JoinRoom.create({
"roomId" : roomId
});
const sc = this.webClient.pb.SessionCommand.create({
".Command_JoinRoom.ext" : CmdJoinRoom
});
this.webClient.sendSessionCommand(sc, (raw) => {
const { responseCode } = raw;
let error;
switch(responseCode) {
case this.webClient.pb.Response.ResponseCode.RespOk:
const { roomInfo } = raw[".Response_JoinRoom.ext"];
this.webClient.services.room.joinRoom(roomInfo);
this.webClient.debug(() => console.log("Join Room: ", roomInfo.name));
return;
case this.webClient.pb.Response.ResponseCode.RespNameNotFound:
error = "Failed to join the room: it doesn\"t exist on the server.";
break;
case this.webClient.pb.Response.ResponseCode.RespContextError:
error = "The server thinks you are in the room but Cockatrice is unable to display it. Try restarting Cockatrice.";
break;
case this.webClient.pb.Response.ResponseCode.RespUserLevelTooLow:
error = "You do not have the required permission to join this room.";
break;
default:
error = "Failed to join the room due to an unknown error.";
break;
}
if (error) {
console.error(responseCode, error);
}
});
}
addToBuddyList(userName) {
this.addToList('buddy', userName);
}
removeFromBuddyList(userName) {
this.removeFromList('buddy', userName);
}
addToIgnoreList(userName) {
this.addToList('ignore', userName);
}
removeFromIgnoreList(userName) {
this.removeFromList('ignore', userName);
}
addToList(list: string, userName: string) {
const CmdAddToList = this.webClient.pb.Command_AddToList.create({ list, userName });
const sc = this.webClient.pb.SessionCommand.create({
".Command_AddToList.ext" : CmdAddToList
});
this.webClient.sendSessionCommand(sc, ({ responseCode }) => {
// @TODO: filter responseCode, pop snackbar for error
this.webClient.debug(() => console.log('Added to List Response: ', responseCode));
});
}
removeFromList(list: string, userName: string) {
const CmdRemoveFromList = this.webClient.pb.Command_RemoveFromList.create({ list, userName });
const sc = this.webClient.pb.SessionCommand.create({
".Command_RemoveFromList.ext" : CmdRemoveFromList
});
this.webClient.sendSessionCommand(sc, ({ responseCode }) => {
// @TODO: filter responseCode, pop snackbar for error
this.webClient.debug(() => console.log('Removed from List Response: ', responseCode));
});
}
viewLogHistory(filters) {
const CmdViewLogHistory = this.webClient.pb.Command_ViewLogHistory.create(filters);
const sc = this.webClient.pb.ModeratorCommand.create({
".Command_ViewLogHistory.ext" : CmdViewLogHistory
});
this.webClient.sendModeratorCommand(sc, (raw) => {
const { responseCode } = raw;
let error;
switch(responseCode) {
case this.webClient.pb.Response.ResponseCode.RespOk:
const { logMessage } = raw[".Response_ViewLogHistory.ext"];
console.log("Response_ViewLogHistory: ", logMessage)
this.webClient.services.session.viewLogs(logMessage)
this.webClient.debug(() => console.log("View Log History: ", logMessage));
return;
default:
error = "Failed to retrieve log history.";
break;
}
if (error) {
console.error(responseCode, error);
}
});
}
}

View file

@ -0,0 +1,2 @@
export * from "./RoomCommands";
export * from "./SessionCommands";

View file

@ -0,0 +1,7 @@
export const JoinRoom = {
id: ".Event_JoinRoom.ext",
action: ({ userInfo }, webClient, { roomEvent }) => {
const { roomId } = roomEvent;
webClient.services.room.userJoined(roomId, userInfo);
}
};

View file

@ -0,0 +1,7 @@
export const LeaveRoom = {
id: ".Event_LeaveRoom.ext",
action: ({ name }, webClient, { roomEvent }) => {
const { roomId } = roomEvent;
webClient.services.room.userLeft(roomId, name);
}
};

View file

@ -0,0 +1,7 @@
export const ListGames = {
id: ".Event_ListGames.ext",
action: ({ gameList }, webClient, { roomEvent }) => {
const { roomId } = roomEvent;
webClient.services.room.updateGames(roomId, gameList);
}
};

View file

@ -0,0 +1,7 @@
export const RoomSay = {
id: ".Event_RoomSay.ext",
action: (message, webClient, { roomEvent }) => {
const { roomId } = roomEvent;
webClient.services.room.addMessage(roomId, message);
}
};

View file

@ -0,0 +1,4 @@
export * from "./JoinRoom";
export * from "./LeaveRoom";
export * from "./ListGames";
export * from "./RoomSay";

View file

@ -0,0 +1,18 @@
export const AddToList = {
id: ".Event_AddToList.ext",
action: ({ listName, userInfo}, webClient) => {
switch (listName) {
case 'buddy': {
webClient.services.session.addToBuddyList(userInfo);
break;
}
case 'ignore': {
webClient.services.session.addToIgnoreList(userInfo);
break;
}
default: {
webClient.debug(() => console.log('Attempted to add to unknown list: ', listName));
}
}
}
};

View file

@ -0,0 +1,39 @@
import { StatusEnum } from "types";
export const ConnectionClosed = {
id: ".Event_ConnectionClosed.ext",
action: ({ reason }, webClient) => {
let message = "";
// @TODO (5)
switch(reason) {
case webClient.pb.Event_ConnectionClosed.CloseReason.USER_LIMIT_REACHED:
message = "The server has reached its maximum user capacity";
break;
case webClient.pb.Event_ConnectionClosed.CloseReason.TOO_MANY_CONNECTIONS:
message = "There are too many concurrent connections from your address";
break;
case webClient.pb.Event_ConnectionClosed.CloseReason.BANNED:
message = "You are banned";
break;
case webClient.pb.Event_ConnectionClosed.CloseReason.DEMOTED:
message = "You were demoted";
break;
case webClient.pb.Event_ConnectionClosed.CloseReason.SERVER_SHUTDOWN:
message = "Scheduled server shutdown";
break;
case webClient.pb.Event_ConnectionClosed.CloseReason.USERNAMEINVALID:
message = "Invalid username";
break;
case webClient.pb.Event_ConnectionClosed.CloseReason.LOGGEDINELSEWERE:
message = "You have been logged out due to logging in at another location";
break;
case webClient.pb.Event_ConnectionClosed.CloseReason.OTHER:
default:
message = "Unknown reason";
break;
}
webClient.updateStatus(StatusEnum.DISCONNECTED, message);
}
};

Some files were not shown because too many files have changed in this diff Show more