Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • main
1 result

Target

Select target project
  • hain5252/boost-board-game
  • efox6/boost-board-game-2020
  • soft-core/soft-260/boost-board-game
  • jadengoter/boost-board-game
  • michael.westberg/boost-board-game
  • mthomas41/boost-board-game
6 results
Select Git revision
  • master
1 result
Show changes
Showing
with 1754 additions and 0 deletions
import { combineReducers, getDefaultMiddleware, configureStore } from '@reduxjs/toolkit';
import { persistReducer, persistStore } from 'redux-persist';
import storage from 'redux-persist/lib/storage';
import tutorialProgressSlice from '../features/tutorial/tutorialProgressSlice.js';
import defaultSettingsSlice from '../features/lobby/defaultSettingsSlice.js';
import treeSlotsSlice from '../features/lobby/treeSlotsSlice.js';
import gameTreesSlice from '../features/play/gameTreesSlice.js';
import piecesSlice from '../features/preferences/piecesSlice.js';
const persistedReducer = persistReducer(
{
key: 'root',
storage,
},
combineReducers({
[tutorialProgressSlice.name]: tutorialProgressSlice.reducer,
[defaultSettingsSlice.name]: defaultSettingsSlice.reducer,
[treeSlotsSlice.name]: treeSlotsSlice.reducer,
[gameTreesSlice.name]: gameTreesSlice.reducer,
[piecesSlice.name]: piecesSlice.reducer,
}),
);
export const store = configureStore({
reducer: persistedReducer,
middleware: getDefaultMiddleware({
immutableCheck: false,
// If you want to enable serializableCheck, then set its value to the object
// {
// ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
// }
// instead of true, where those constants are imported from redux-persist
// (see https://github.com/rt2zz/redux-persist/issues/988).
serializableCheck: false,
}),
});
export const persistor = persistStore(store);
import { createContext, useContext, useState, useEffect } from 'react';
import {
HUMAN_PLAYER,
REMOTE_PLAYER,
getNetworkGamePlayers,
} from './features/play/playerTypes.js';
// From <https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState>:
const OPEN = 1;
const RECONNECTION_ATTEMPT_COUNT = 12;
const RECONNECTION_ATTEMPT_DELAY = 5000; // in millseconds
const DISCONNECT = Symbol('DISCONNECT');
export const SERVER_URL =
process.env.REACT_APP_SERVER_URL !== undefined ? process.env.REACT_APP_SERVER_URL : 'ws://localhost:4000';
function log(...rest) {
if (process.env.JEST_WORKER_ID === undefined) {
console.log(...rest);
}
}
export class NetworkError extends Error {
constructor() {
super('Network connection lost');
}
}
export class ServerError extends Error {
constructor(serverMessage) {
super(JSON.stringify(serverMessage));
this.serverMessage = serverMessage;
}
}
class GameDescription {
constructor(message) {
this.networkCode = message.code;
this.timestamp = message.timestamp;
this.gameIdentifier = message.gameIdentifier;
this.timeControls = message.timeControls;
this.timeUsed = message.timeUsed;
this.winnerOnTime = message.winnerOnTime === 'violet' ? 0 : message.winnerOnTime === 'cyan' ? 1 : undefined;
this.winnerByResignation = message.winnerByResignation === 'violet' ? 0 :
message.winnerByResignation === 'cyan' ? 1 : undefined;
this.dragonPoints = message.dragons;
this.players = getNetworkGamePlayers(
message.color === 'violet' ? 0 : message.color === 'cyan' ? 1 : undefined,
message.violet,
message.cyan,
);
this.moves = message.record;
}
}
class SeekReadyEvent extends Event {
constructor(message) {
super('seekready');
this.gameDescription = new GameDescription(message);
}
}
class InvitationMadeEvent extends Event {
constructor(message) {
super('invitationmade');
this.gameDescription = new GameDescription(message);
}
}
class InvitationRescindedEvent extends Event {
constructor(message) {
super('invitationrescinded');
this.networkCode = message.code;
}
}
class InvitationAcceptedEvent extends Event {
constructor(message) {
super('invitationaccepted');
this.gameDescription = new GameDescription(message);
}
}
class SynchronizationEvent extends Event {
constructor(message) {
super('synchronization');
this.gameDescription = new GameDescription(message);
}
}
class CleanupEvent extends Event {
constructor(games) {
super('cleanup');
this.networkCodes = new Set(games.map((game) => game.code));
}
}
class Client extends EventTarget {
constructor(autodisconnect = true) {
super();
this.autodisconnect = autodisconnect;
this.websocket = undefined;
this.openListener = () => this._onOpen();
this.messageListener = (message) => this._onReceiveMessage(message);
this.closeListener = () => this._onClose();
this.expectingClose = false;
this.attemptingToReconnect = false;
this.reconnectionAttemptsRemaining = 0;
this.pendingOutboxItem = undefined;
this.outbox = [];
this.session = undefined;
this.identity = undefined;
this.seeking = false;
}
_reconnect() {
console.assert(this.websocket === undefined, 'Tried to reconnect when not aware of any connection loss.');
this.websocket = new WebSocket(SERVER_URL);
this.websocket.addEventListener('open', this.openListener);
this.websocket.addEventListener('message', this.messageListener);
this.websocket.addEventListener('close', this.closeListener);
this.expectingClose = false;
}
_sendToServer(outboxItem) {
this.pendingOutboxItem = outboxItem;
this.websocket.send(JSON.stringify(outboxItem.message));
}
_onOpen() {
log('Connected to server.');
this.attemptingToReconnect = false;
this.reconnectionAttemptsRemaining = RECONNECTION_ATTEMPT_COUNT;
if (this.pendingOutboxItem !== undefined) {
this._sendToServer(this.pendingOutboxItem);
} else {
this._dispatchMessages();
}
}
_onClose() {
console.assert(this.websocket !== undefined, 'Got a connection close event when not aware of any connection.');
this.websocket.removeEventListener('open', this.openListener);
this.websocket.removeEventListener('message', this.messageListener);
this.websocket.removeEventListener('close', this.closeListener);
this.websocket = undefined;
this.attemptingToReconnect = false;
this._resetState();
if (this.pendingOutboxItem !== undefined || this.outbox.length > 0) {
if (this.expectingClose) {
log('Attempting to reconnect to server immediately…');
this._reconnect();
} else if (this.reconnectionAttemptsRemaining > 0) {
log('Will attempt to reconnect in a little bit…');
this.attemptingToReconnect = true;
--this.reconnectionAttemptsRemaining;
const index = RECONNECTION_ATTEMPT_COUNT - this.reconnectionAttemptsRemaining;
setTimeout(() => {
log(`Attempting to reconnect to server (attempt ${index}/${RECONNECTION_ATTEMPT_COUNT})…`);
this._reconnect();
}, RECONNECTION_ATTEMPT_DELAY);
} else {
log('Reconnection failed.');
const toFulfill = [];
if (this.pendingOutboxItem !== undefined) {
toFulfill.push(this.pendingOutboxItem);
}
this.pendingOutboxItem = undefined;
for (const outboxItem of this.outbox) {
toFulfill.push(outboxItem);
}
this.outbox = [];
// Only start resolving/rejecting once the client is in a consistent state.
for (const outboxItem of toFulfill) {
if (outboxItem.message === DISCONNECT) {
outboxItem.resolve();
} else {
outboxItem.reject(new NetworkError());
}
}
}
} else {
log('Disconnected from server.');
}
}
_dispatchMessagesWithoutYetResolvingDisconnects() {
const toResolve = [];
let wantDisconnect = false;
if (this.pendingOutboxItem === undefined) {
while (this.outbox.length > 0 && this.outbox[0].message === DISCONNECT) {
wantDisconnect = true;
toResolve.push(this.outbox.shift());
}
if (this.outbox.length === 0) {
if (wantDisconnect && this.websocket !== undefined) {
this.expectingClose = true;
this.websocket.close();
}
return toResolve;
}
if (this.websocket !== undefined && this.websocket.readyState === OPEN) {
this._sendToServer(this.outbox.shift());
return toResolve;
}
}
if (this.websocket === undefined) {
log('Connecting to server…');
this._reconnect();
}
return toResolve;
}
_dispatchMessages() {
if (this.attemptingToReconnect) {
return;
}
// Only start resolving once the client is in a consistent state.
for (const outboxItem of this._dispatchMessagesWithoutYetResolvingDisconnects()) {
outboxItem.resolve();
}
}
_modifyAuthentication(session, name, email) {
this.session = session;
this.identity = name !== undefined && email !== undefined ? {
name,
email,
} : undefined;
this.dispatchEvent(new Event('authenticationchanged'));
}
_modifySeeking(seeking) {
this.seeking = seeking;
this.dispatchEvent(new Event('seekingchanged'));
}
_resetState() {
this._modifyAuthentication(undefined, undefined, undefined);
this._modifySeeking(false);
}
async _subscribe(session, vapidPublicKey) {
try {
const registration = await navigator.serviceWorker.getRegistration();
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidPublicKey,
});
}
this.websocket.send(JSON.stringify({
action: 'subscribe',
session,
subscription: subscription.toJSON(),
}));
log('Subscribed to push notifications.');
} catch (exception) {
log('Unable to subscribe to push notifications because subscription is unsupported or was blocked.');
}
}
_onReceiveMessage(encodedMessage) {
let message = undefined;
try {
message = JSON.parse(encodedMessage.data);
} catch (exception) {
console.error(`Received invalid message: ${encodedMessage}.`);
return;
}
if (message.success) {
switch (message.action) {
case 'register':
case 'logIn':
this._subscribe(message.session, message.vapidPublicKey);
// intentionally fall through
case 'logInAsGuest':
case 'claimToken':
case 'changeEmail':
this._modifyAuthentication(message.session, message.name, message.email);
break;
case 'logOut':
this._resetState();
if (this.autodisconnect) {
this.disconnect();
}
break;
case 'seekReady':
this._modifySeeking(false);
this.dispatchEvent(new SeekReadyEvent(message));
break;
case 'invite':
this.dispatchEvent(new InvitationMadeEvent(message));
break;
case 'uninvite':
this.dispatchEvent(new InvitationRescindedEvent(message));
break;
case 'accept':
this.dispatchEvent(new InvitationAcceptedEvent(message));
break;
case 'observeLiveGames':
for (const game of message.games) {
this.dispatchEvent(new SynchronizationEvent(game));
}
this.dispatchEvent(new CleanupEvent(message.games));
break;
case 'observeGame':
case 'makeMove':
case 'move':
case 'resign':
this.dispatchEvent(new SynchronizationEvent(message));
break;
default:
}
} else if (message.recourse === 'logIn') {
this._resetState();
}
if (this.pendingOutboxItem !== undefined && message.action === this.pendingOutboxItem.message.action) {
if (message.success) {
this.pendingOutboxItem.resolve(message);
} else {
this.pendingOutboxItem.reject(new ServerError(message));
}
this.pendingOutboxItem = undefined;
this._dispatchMessages();
}
}
_send(message) {
return new Promise((resolve, reject) => {
this.outbox.push({
message,
resolve,
reject,
});
this._dispatchMessages();
});
}
logInAsGuest() {
return this._send({
action: 'logInAsGuest',
});
}
register(name, email, password) {
return this._send({
action: 'register',
name,
email,
password,
});
}
logIn(identity, password) {
return this._send({
action: 'logIn',
identity,
password,
});
}
claimToken(token) {
return this._send({
action: 'claimToken',
session: this.session,
token,
});
}
logOut() {
return this._send({
action: 'logOut',
session: this.session,
});
}
changeEmail(email, password) {
return this._send({
action: 'changeEmail',
session: this.session,
email,
password,
});
}
changePassword(oldPassword, newPassword) {
return this._send({
action: 'changePassword',
session: this.session,
oldPassword,
newPassword,
});
}
requestPasswordResetEmail(identity) {
return this._send({
action: 'requestPasswordResetEmail',
identity,
});
}
resetPassword(identity, passwordResetCode, password) {
return this._send({
action: 'resetPassword',
identity,
passwordResetCode,
password,
});
}
seek(fastestTimeControls, slowestTimeControls, opponentIndex = undefined) {
this._modifySeeking(true);
return this._send({
action: 'seek',
session: this.session,
fastestTimeControls,
slowestTimeControls,
colorSought: opponentIndex === 0 ? 'violet' : opponentIndex === 1 ? 'cyan' : null,
});
}
unseek() {
if (!this.seeking) {
return (async() => {})();
}
this._modifySeeking(false);
return this._send({
action: 'unseek',
session: this.session,
});
}
invite(dragons, players, timeControls) {
console.assert(
players.length === 2,
`Tried to create a two-player network game invite with ${players.length} player(s).`,
);
console.assert(
players.some((player) => player.type === HUMAN_PLAYER),
'Tried to create a network game invite with no local human player.',
);
console.assert(
players.some((player) => player.type === REMOTE_PLAYER),
'Tried to create a network game invite with no space for a network player.',
);
return this._send({
action: 'invite',
session: this.session,
dragons,
timeControls,
colorSought: players[0].type === REMOTE_PLAYER ? 'violet' : 'cyan',
});
}
uninvite(networkCode) {
return this._send({
action: 'uninvite',
session: this.session,
code: networkCode,
});
}
accept(networkCode) {
return this._send({
action: 'accept',
session: this.session,
code: networkCode,
});
}
observeLiveGames() {
return this._send({
action: 'observeLiveGames',
session: this.session,
});
}
unobserveLiveGames() {
return this._send({
action: 'unobserveLiveGames',
});
}
observeGame(networkCode) {
return this._send({
action: 'observeGame',
session: this.session,
code: networkCode,
});
}
unobserveGame(networkCode) {
return this._send({
action: 'unobserveGame',
code: networkCode,
});
}
makeMove(networkCode, move) {
return this._send({
action: 'makeMove',
session: this.session,
code: networkCode,
move,
});
}
resign(networkCode) {
return this._send({
action: 'resign',
session: this.session,
code: networkCode,
});
}
disconnect() {
// Non-testing code can probably ignore this return value; from the calling
// code's perspective, the disconnect takes effect immediately, and there is
// not much value in waiting for the underlying resource cleanup.
return this._send(DISCONNECT);
}
}
const CLIENT = new Client();
export const {
logInAsGuest,
register,
logIn,
claimToken,
logOut,
changeEmail,
changePassword,
requestPasswordResetEmail,
resetPassword,
seek,
unseek,
invite,
uninvite,
accept,
observeLiveGames,
unobserveLiveGames,
observeGame,
unobserveGame,
makeMove,
resign,
disconnect,
} = Object.assign({}, ...Object.getOwnPropertyNames(Object.getPrototypeOf(CLIENT)).map((method) => ({
[method]: CLIENT[method].bind(CLIENT),
})));
const CLIENT_IDENTITY_CONTEXT = createContext();
const SEEKING_CONTEXT = createContext();
export function NetworkClientProvider(props) {
const [clientIdentity, setClientIdentity] = useState(undefined);
const [seeking, setSeeking] = useState(false);
useEffect(() => {
const listener = () => setClientIdentity(CLIENT.identity);
CLIENT.addEventListener('authenticationchanged', listener);
return () => CLIENT.removeEventListener('authenticationchanged', listener);
});
useEffect(() => {
const listener = () => setSeeking(CLIENT.seeking);
CLIENT.addEventListener('seekingchanged', listener);
return () => CLIENT.removeEventListener('seekingchanged', listener);
});
return (
<CLIENT_IDENTITY_CONTEXT.Provider value={clientIdentity}>
<SEEKING_CONTEXT.Provider value={seeking}>
{props.children}
</SEEKING_CONTEXT.Provider>
</CLIENT_IDENTITY_CONTEXT.Provider>
);
}
export function useClientIdentity() {
return useContext(CLIENT_IDENTITY_CONTEXT);
}
export function useSeekingFlag() {
return useContext(SEEKING_CONTEXT);
}
function useOnEvent(eventName, listener) {
useEffect(() => {
CLIENT.addEventListener(eventName, listener);
return () => CLIENT.removeEventListener(eventName, listener);
});
}
export function useOnSeekReady(listener) {
useOnEvent('seekready', listener);
}
export function useOnInvitationMade(listener) {
useOnEvent('invitationmade', listener);
}
export function useOnInvitationRescinded(listener) {
useOnEvent('invitationrescinded', listener);
}
export function useOnInvitationAccepted(listener) {
useOnEvent('invitationaccepted', listener);
}
export function useOnSynchronization(listener) {
useOnEvent('synchronization', listener);
}
export function useOnCleanup(listener) {
useOnEvent('cleanup', listener);
}
export const internals = {
Client,
CLIENT,
SeekReadyEvent,
InvitationMadeEvent,
InvitationRescindedEvent,
InvitationAcceptedEvent,
SynchronizationEvent,
CleanupEvent,
};
This diff is collapsed.
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`the GameMenu component creates a new game preview if there is none 1`] = `
<div>
<div
id="portrait"
>
<div
id="app"
>
<div
class="menu bottom-aligned"
>
<div
class="scrollable"
>
<div
class="list"
>
<button
disabled=""
>
[No saved games available.]
</button>
<button>
<div>
[Board treeName=
defPreview
forceRunningClock=
undefined
disabled=
true
analysisOnly=
false
]
</div>
New Game
</button>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`the GameMenu component deletes saved games and closes the modals 1`] = `
<div>
<div
id="portrait"
>
<div
id="app"
>
<div
class="menu bottom-aligned"
>
<div
class="scrollable"
>
<div
class="list"
>
<button>
<div>
[Board treeName=
def/ghi
forceRunningClock=
false
disabled=
true
analysisOnly=
false
]
</div>
Saved Game
<br />
Human (with Opening Assistance) vs. Human (with Opening Assistance)
</button>
<button>
<div>
[Board treeName=
defPreview
forceRunningClock=
undefined
disabled=
true
analysisOnly=
false
]
</div>
New Game
</button>
</div>
</div>
</div>
</div>
<div
class="ReactModalPortal"
/>
<div
class="ReactModalPortal"
/>
</div>
</div>
`;
exports[`the GameMenu component does not create a new game preview if there already is one 1`] = `
<div>
<div
id="portrait"
>
<div
id="app"
>
<div
class="menu bottom-aligned"
>
<div
class="scrollable"
>
<div
class="list"
>
<button
disabled=""
>
[No saved games available.]
</button>
<button>
<div>
[Board treeName=
defPreview
forceRunningClock=
undefined
disabled=
true
analysisOnly=
false
]
</div>
New Game
</button>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`the GameMenu component forces running clocks if showing network games 1`] = `
<div>
<div
id="portrait"
>
<div
id="app"
>
<div
class="menu bottom-aligned"
>
<div
class="scrollable"
>
<div
class="list"
>
<button>
<div>
[Board treeName=
def/ghi
forceRunningClock=
true
disabled=
true
analysisOnly=
false
]
</div>
Saved Game
<br />
Human (with Opening Assistance) vs. Human (with Opening Assistance)
</button>
<button>
<div>
[Board treeName=
defPreview
forceRunningClock=
undefined
disabled=
true
analysisOnly=
false
]
</div>
New Game
</button>
</div>
</div>
</div>
<div
class="button-bar"
>
<div>
<button
class=""
>
<img
alt="Account"
src="account.svg"
/>
</button>
</div>
</div>
</div>
<div
class="ReactModalPortal"
/>
<div
class="ReactModalPortal"
/>
</div>
</div>
`;
exports[`the GameMenu component may have account and accept buttons together 1`] = `
<div>
<div
id="portrait"
>
<div
id="app"
>
<div
class="menu bottom-aligned"
>
<div
class="scrollable"
>
<div
class="list"
>
<button
disabled=""
>
[No saved games available.]
</button>
<button>
<div>
[Board treeName=
defPreview
forceRunningClock=
undefined
disabled=
true
analysisOnly=
false
]
</div>
New Game
</button>
</div>
</div>
</div>
<div
class="button-bar"
>
<div>
<button
class=""
>
<img
alt="Account"
src="account.svg"
/>
</button>
</div>
<div>
<button
class=""
>
<img
alt="Accept an Invitation"
src="invite.svg"
/>
</button>
</div>
</div>
</div>
</div>
</div>
`;
exports[`the GameMenu component opens a rename modal 1`] = `
<div>
<div
id="portrait"
>
<div
aria-hidden="true"
id="app"
>
<div
class="menu bottom-aligned"
>
<div
class="scrollable"
>
<div
class="list"
>
<button>
<div>
[Board treeName=
def/ghi
forceRunningClock=
false
disabled=
true
analysisOnly=
false
]
</div>
Saved Game
<br />
Human (with Opening Assistance) vs. Human (with Opening Assistance)
</button>
<button>
<div>
[Board treeName=
defPreview
forceRunningClock=
undefined
disabled=
true
analysisOnly=
false
]
</div>
New Game
</button>
</div>
</div>
</div>
</div>
<div
class="ReactModalPortal"
/>
<div
class="ReactModalPortal"
>
<div
class="ReactModal__Overlay overlay"
>
<div
aria-label="Saved Game"
aria-modal="true"
class="ReactModal__Content content"
role="dialog"
tabindex="-1"
>
<div
class="menu"
>
<div
class="scrollable"
>
<div
class="list"
>
<button
aria-level="2"
class="decorative"
disabled=""
role="heading"
>
Rename "Saved Game" to:
</button>
<input
type="text"
value="Saved Game"
/>
<button>
Rename
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`the GameMenu component shows a loading screen if network games have not yet been loaded 1`] = `
<div>
<div
id="portrait"
>
<div
id="app"
>
<div
class="form"
>
<p
class="message"
>
<strong>
<img
alt=""
src="thinking.svg"
/>
Loading network games…
</strong>
</p>
</div>
<div
class="button-bar"
>
<div>
<button
class=""
>
<img
alt="Account"
src="account.svg"
/>
</button>
</div>
</div>
</div>
</div>
</div>
`;
exports[`the GameMenu component shows context menus on saved games with rename and delete options 1`] = `
<div>
<div
id="portrait"
>
<div
aria-hidden="true"
id="app"
>
<div
class="menu bottom-aligned"
>
<div
class="scrollable"
>
<div
class="list"
>
<button>
<div>
[Board treeName=
def/ghi
forceRunningClock=
false
disabled=
true
analysisOnly=
false
]
</div>
Saved Game
<br />
Human (with Opening Assistance) vs. Human (with Opening Assistance)
</button>
<button>
<div>
[Board treeName=
defPreview
forceRunningClock=
undefined
disabled=
true
analysisOnly=
false
]
</div>
New Game
</button>
</div>
</div>
</div>
</div>
<div
class="ReactModalPortal"
>
<div
class="ReactModal__Overlay overlay"
>
<div
aria-label="Saved Game"
aria-modal="true"
class="ReactModal__Content content"
role="dialog"
tabindex="-1"
>
<div
class="menu"
>
<div
class="scrollable"
>
<div
class="list"
>
<button
aria-level="2"
class="decorative"
disabled=""
role="heading"
>
"Saved Game"
</button>
<button>
Rename
</button>
<button>
Delete
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ReactModalPortal"
/>
</div>
</div>
`;
exports[`the GameMenu component sorts saved games by modification time 1`] = `
<div>
<div
id="portrait"
>
<div
id="app"
>
<div
class="menu bottom-aligned"
>
<div
class="scrollable"
>
<div
class="list"
>
<button>
<div>
[Board treeName=
def/mno
forceRunningClock=
false
disabled=
true
analysisOnly=
false
]
</div>
Earlier Game
<br />
Human (with Opening Assistance) vs. Human (with Opening Assistance)
</button>
<button>
<div>
[Board treeName=
def/jkl
forceRunningClock=
false
disabled=
true
analysisOnly=
false
]
</div>
Later Game
<br />
Human (with Opening Assistance) vs. Human (with Opening Assistance)
</button>
<button>
<div>
[Board treeName=
defPreview
forceRunningClock=
undefined
disabled=
true
analysisOnly=
false
]
</div>
New Game
</button>
</div>
</div>
</div>
</div>
<div
class="ReactModalPortal"
/>
<div
class="ReactModalPortal"
/>
<div
class="ReactModalPortal"
/>
<div
class="ReactModalPortal"
/>
</div>
</div>
`;
exports[`the GameMenu component trims whitespace, renames saved games, and closes the modals 1`] = `
<div>
<div
id="portrait"
>
<div
id="app"
>
<div
class="menu bottom-aligned"
>
<div
class="scrollable"
>
<div
class="list"
>
<button>
<div>
[Board treeName=
def/ghi
forceRunningClock=
false
disabled=
true
analysisOnly=
false
]
</div>
Saved Game
<br />
Human (with Opening Assistance) vs. Human (with Opening Assistance)
</button>
<button>
<div>
[Board treeName=
defPreview
forceRunningClock=
undefined
disabled=
true
analysisOnly=
false
]
</div>
New Game
</button>
</div>
</div>
</div>
</div>
<div
class="ReactModalPortal"
/>
<div
class="ReactModalPortal"
/>
</div>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`the Invitation component disables the UI and tries to accept an invitation when the user taps the accept button 1`] = `
<div>
<div
id="portrait"
>
<div
id="app"
>
<div
class="form"
>
<p>
<strong>
Accepting invitation…
</strong>
</p>
<label
class="field"
>
<span>
Invite Code:
</span>
<input
class=""
disabled=""
type="text"
value=" code "
/>
</label>
<div
class="button-bar"
>
<div>
<button
class=""
disabled=""
>
Accept
</button>
</div>
</div>
</div>
<div
class="button-bar"
>
<div>
<button
class=""
>
<img
alt="Back"
src="back.svg"
/>
</button>
</div>
</div>
</div>
</div>
</div>
`;
exports[`the Invitation component gives an explanatory error message when the invitation cannot be accepted 1`] = `
<div>
<div
id="portrait"
>
<div
id="app"
>
<div
class="form"
>
<p>
<strong>
Invitation acceptance failed. Please check the invite code and try again.
</strong>
</p>
<label
class="field"
>
<span>
Invite Code:
</span>
<input
class=""
type="text"
value=" code "
/>
</label>
<div
class="button-bar"
>
<div>
<button
class=""
>
Accept
</button>
</div>
</div>
</div>
<div
class="button-bar"
>
<div>
<button
class=""
>
<img
alt="Back"
src="back.svg"
/>
</button>
</div>
</div>
</div>
</div>
</div>
`;
exports[`the Invitation component initially shows a textbox with accept and back buttons 1`] = `
<div>
<div
id="portrait"
>
<div
id="app"
>
<div
class="form"
>
<p>
<strong />
</p>
<label
class="field"
>
<span>
Invite Code:
</span>
<input
class=""
type="text"
value=""
/>
</label>
<div
class="button-bar"
>
<div>
<button
class=""
>
Accept
</button>
</div>
</div>
</div>
<div
class="button-bar"
>
<div>
<button
class=""
>
<img
alt="Back"
src="back.svg"
/>
</button>
</div>
</div>
</div>
</div>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`the NotificationHandler component loads the tree and shows a loading screen while the notification token has not yet been claimed 1`] = `
<div>
<div
id="portrait"
>
<div
id="app"
>
<div
class="form"
>
<p
class="message"
>
<strong>
<img
alt=""
src="thinking.svg"
/>
Loading network game…
</strong>
</p>
</div>
<div
class="button-bar"
>
<div>
<button
class=""
>
<img
alt="Cancel"
src="back.svg"
/>
</button>
</div>
</div>
</div>
</div>
</div>
`;
This diff is collapsed.
.logged-out {
color: rgba(95 0 0 / 100%);
}
.logged-in {
color: rgba(95 149 95 / 100%);
}
This diff is collapsed.
import { createSlice } from '@reduxjs/toolkit';
const defaultSettingsSlice = createSlice({
name: 'defaultSettings',
initialState: {
timeControlFlags: {},
fastestTimeControls: {},
slowestTimeControls: {},
players: {},
},
reducers: {
setDefaultTimeControlFlag: (defaultSettings, action) => {
const {
slot,
timeControlFlag,
} = action.payload;
defaultSettings.timeControlFlags[slot] = timeControlFlag;
},
setDefaultFastestTimeControls: (defaultSettings, action) => {
const {
slot,
timeControls,
} = action.payload;
defaultSettings.fastestTimeControls[slot] = timeControls;
},
setDefaultSlowestTimeControls: (defaultSettings, action) => {
const {
slot,
timeControls,
} = action.payload;
defaultSettings.slowestTimeControls[slot] = timeControls;
},
setDefaultPlayers: (defaultSettings, action) => {
const {
slot,
players,
} = action.payload;
defaultSettings.players[slot] = players;
},
},
});
export default defaultSettingsSlice;
export const {
setDefaultTimeControlFlag,
setDefaultFastestTimeControls,
setDefaultSlowestTimeControls,
setDefaultPlayers,
} = defaultSettingsSlice.actions;
export function selectDefaultTimeControlFlagsBySlot(state) {
return state.defaultSettings.timeControlFlags;
}
export function selectDefaultFastestTimeControlsBySlot(state) {
return state.defaultSettings.fastestTimeControls;
}
export function selectDefaultSlowestTimeControlsBySlot(state) {
return state.defaultSettings.slowestTimeControls;
}
export function selectDefaultPlayersBySlot(state) {
return state.defaultSettings.players;
}
This diff is collapsed.
.message {
vertical-align: middle;
}
.message img {
height: 0.75em;
}
This diff is collapsed.
import { useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useNavigate, Navigate } from 'react-router-dom';
import PropTypes from 'prop-types';
import { useClientIdentity, accept, useOnInvitationAccepted } from '../../client.js';
import {
loadTree,
} from './treeSlotsSlice.js';
import { Field, Form } from '../../widgets/form.js';
import { ButtonBar, Button } from '../../widgets/buttonBar.js';
import backIcon from '../../icons/back.svg';
export function Invitation(props) {
const clientIdentity = useClientIdentity();
const messageElement = useRef();
const [disabled, setDisabled] = useState(false);
const [message, setMessageDirectly] = useState();
const [networkCode, setNetworkCode] = useState('');
const navigate = useNavigate();
const dispatch = useDispatch();
useOnInvitationAccepted((event) => {
if (event.gameDescription.networkCode === networkCode.trim()) {
const treeName = `${props.slot}/${event.gameDescription.networkCode}`;
dispatch(loadTree({
slot: props.slot,
treeName,
}));
navigate(props.to);
}
});
const setMessage = (content) => {
setMessageDirectly(content);
if (messageElement.current && messageElement.current.scrollIntoView !== undefined) {
messageElement.current.scrollIntoView();
}
};
const onAccept = () => {
setDisabled(true);
setMessage('Accepting invitation…');
accept(networkCode.trim()).catch(() => {
setDisabled(false);
setMessage('Invitation acceptance failed. Please check the invite code and try again.');
});
};
if (clientIdentity === undefined) {
return (
<Navigate to={props.backTo} />
);
}
return (
<>
<Form>
<p ref={messageElement}>
<strong>{message !== undefined ? message : ''}</strong>
</p>
<Field
label={'Invite Code:'}
type={'text'}
value={networkCode}
autofocus={true}
disabled={disabled}
onChange={(event) => setNetworkCode(event.target.value)}
onEnterKey={onAccept} />
<ButtonBar>
<Button text={'Accept'} disabled={disabled} onClick={onAccept} />
</ButtonBar>
</Form>
<ButtonBar>
<Button image={backIcon} altText={'Back'} onClick={() => navigate(props.backTo)} />
</ButtonBar>
</>
);
}
Invitation.propTypes = {
slot: PropTypes.string.isRequired,
backTo: PropTypes.string.isRequired,
to: PropTypes.string.isRequired,
};
This diff is collapsed.
This diff is collapsed.
.message {
vertical-align: middle;
}
.message img {
height: 0.75em;
}
This diff is collapsed.