diff --git a/client/public/index.html b/client/public/index.html index 5195978ccc73c6ac5a1bcfe9728534d65dd5ba64..4d4313cf33978f9188b411cd663f485670e507d2 100644 --- a/client/public/index.html +++ b/client/public/index.html @@ -2,7 +2,7 @@ <html> <head> <meta charset="UTF-8" /> - <title>Todo web application example</title> + <title>Chat app</title> <link rel="stylesheet" href="bootstrap.min.css" /> </head> <body> diff --git a/client/src/chat-component.tsx b/client/src/chat-component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8c402fe5199bedae074ac878f033aa1af6449fcf --- /dev/null +++ b/client/src/chat-component.tsx @@ -0,0 +1,109 @@ +import * as React from 'react'; +import { Component } from 'react-simplified'; +import chatService, { Subscription } from './chat-service'; +import { Alert, Card, Column, Form, Row } from './widgets'; +import { KeyboardEvent } from 'react'; + +export class Chat extends Component { + subscription: Subscription | null = null; + connected = false; + users: string[] = []; + messages: string[] = []; + message = ''; + user = ''; + + render() { + return ( + <> + <Card title={this.connected ? 'Chat(Connected)' : 'Chat(Not connected)'}> + <Card title="Connected users"> + {this.users.map((user, index) => { + return <div key={index}>{user}</div>; + })} + </Card> + <Card title="Messages"> + {this.messages.map((message, index) => { + return <div key={index}>{message}</div>; + })} + </Card> + <Card title="New message"> + <Row> + <Column width={2}> + <Form.Input + type="text" + placeholder="Enter username" + value={this.user} + disabled={this.subscription} + onChange={(event) => { + this.user = event.currentTarget.value; + }} + onKeyUp={(event: KeyboardEvent<HTMLInputElement>) => { + if (event.key == 'Enter') { + if (!this.subscription) { + this.subscription = chatService.subscribe(); + this.subscription.onopen = () => { + this.connected = true; + chatService.send({ addUser: this.user }); + + // Remove user when web page is closed + window.addEventListener('beforeunload', () => + chatService.send({ removeUser: this.user }) + ); + }; + // Called on incoming message + this.subscription.onmessage = (message) => { + if ('text' in message) { + this.messages.push(message.text); + } + if ('users' in message) { + this.users = message.users; + } + }; + + // Called if connection is closed + this.subscription.onclose = (code, reason) => { + this.connected = false; + Alert.danger( + 'Connection closed with code ' + code + ' and reason: ' + reason + ); + }; + // Called on connection error + this.subscription.onerror = (error) => { + this.connected = false; + Alert.danger('Connection error: ' + error.message); + }; + } + } + }} + /> + </Column> + <Column> + <Form.Input + placeholder="Message" + type="text" + value={this.message} + onChange={(event) => { + this.message = event.currentTarget.value; + }} + onKeyUp={(event: KeyboardEvent<HTMLInputElement>) => { + if (event.key == 'Enter') { + if (this.connected) { + chatService.send({ text: this.user + ': ' + this.message }); + this.message = ''; + } else Alert.danger('Not connected to server'); + } + }} + /> + </Column> + </Row> + </Card> + </Card> + </> + ); + } + + // Unsubscribe from chatService when component is no longer in use + beforeUnmount() { + if (this.subscription) chatService.unsubscribe(this.subscription); + } +} diff --git a/client/src/whiteboard-service.tsx b/client/src/chat-service.tsx similarity index 79% rename from client/src/whiteboard-service.tsx rename to client/src/chat-service.tsx index 0b17723ea4d96aa7eb9514ba62eb17e9e21542da..f884fbd2a6e8a364c26ca935278745ea14291e31 100644 --- a/client/src/whiteboard-service.tsx +++ b/client/src/chat-service.tsx @@ -1,30 +1,31 @@ /** * In and out message type (to and from server). */ -export type Message = { line: { from: { x: number; y: number }; to: { x: number; y: number } } }; +export type ClientMessage = { text: string } | { addUser: string } | { removeUser: string }; +export type ServerMessage = { text: string } | { users: string[] }; /** - * Subscription class that enables multiple components to receive events from Whiteboard server. + * Subscription class that enables multiple components to receive events from Chat server. */ export class Subscription { onopen: () => void = () => {}; - onmessage: (message: Message) => void = () => {}; + onmessage: (message: ServerMessage) => void = () => {}; onclose: (code: number, reason: string) => void = () => {}; onerror: (error: Error) => void = () => {}; } /** - * Service class to communicate with Whiteboard server. + * Service class to communicate with Chat server. * * Variables and functions marked with @private should not be used outside of this class. */ -class WhiteboardService { +class ChatService { /** - * Connection to Whiteboard server. + * Connection to Chat server. * * @private */ - connection = new WebSocket('ws://localhost:3000/api/v1/whiteboard'); + connection = new WebSocket('ws://localhost:3000/api/v1/chat'); /** * Component subscriptions. * @@ -39,10 +40,9 @@ class WhiteboardService { }; this.connection.onmessage = (event) => { - // Call subscription onmessage functions on messages from Whiteboard server + // Call subscription onmessage functions on messages from chat server const data = event.data; - console.log(data); - if (typeof data == 'string') + if (typeof data === 'string') this.subscriptions.forEach((subscription) => subscription.onmessage(JSON.parse(data))); }; @@ -73,7 +73,7 @@ class WhiteboardService { } /** - * Returns a subscription that enables multiple components to receive events from Whiteboard server. + * Returns a subscription that enables multiple components to receive events from chat server. */ subscribe() { const subscription = new Subscription(); @@ -95,19 +95,19 @@ class WhiteboardService { } /** - * Given subscription will no longer receive events from Whiteboard server. + * Given subscription will no longer receive events from chat server. */ unsubscribe(subscription: Subscription) { - this.subscriptions.delete(subscription); + if (subscription) this.subscriptions.delete(subscription); } /** - * Send message to Whiteboard server. + * Send message to chat server. */ - send(message: Message) { + send(message: ClientMessage) { this.connection.send(JSON.stringify(message)); } } -const whiteboardService = new WhiteboardService(); -export default whiteboardService; +const chatService = new ChatService(); +export default chatService; diff --git a/client/src/index.tsx b/client/src/index.tsx index 0fb489d3f0d615268fc7ce28b9c84038342c47aa..f8e79fa545032d06666a3673bd8bd5d3d1037397 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -1,12 +1,14 @@ import ReactDOM from 'react-dom'; import * as React from 'react'; -import { Whiteboard } from './whiteboard-component'; +import { Chat } from './chat-component'; import { Alert } from './widgets'; -ReactDOM.render( - <> - <Alert /> - <Whiteboard /> - </>, - document.getElementById('root') -); +const root = document.getElementById('root'); +if (root) + ReactDOM.render( + <> + <Alert /> + <Chat /> + </>, + root + ); diff --git a/client/src/whiteboard-component.tsx b/client/src/whiteboard-component.tsx deleted file mode 100644 index f9001be25fad34739ca6f0d02078c9e0cd2e4178..0000000000000000000000000000000000000000 --- a/client/src/whiteboard-component.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import * as React from 'react'; -import { Component } from 'react-simplified'; -import whiteboardService, { Subscription } from './whiteboard-service'; -import { Alert } from './widgets'; - -export class Whiteboard extends Component { - canvas: HTMLCanvasElement | null = null; - lastPos: { x: number; y: number } | null = null; - subscription: Subscription | null = null; - connected = false; - - render() { - return ( - <> - <canvas - ref={(e) => (this.canvas = e) /* Store canvas element */} - onMouseMove={(event) => { - // Send lines to Whiteboard server - const pos = { x: event.clientX, y: event.clientY }; - if (this.lastPos && this.connected) { - whiteboardService.send({ line: { from: this.lastPos, to: pos } }); - } - this.lastPos = pos; - }} - width={400} - height={400} - style={{ border: '2px solid black' }} - /> - <div>{this.connected ? 'Connected' : 'Not connected'}</div> - </> - ); - } - - mounted() { - // Subscribe to whiteboardService to receive events from Whiteboard server in this component - this.subscription = whiteboardService.subscribe(); - - // Called when the subscription is ready - this.subscription.onopen = () => { - this.connected = true; - }; - - // Called on incoming message - this.subscription.onmessage = (message) => { - const context = this.canvas?.getContext('2d'); - context?.beginPath(); - context?.moveTo(message.line.from.x, message.line.from.y); - context?.lineTo(message.line.to.x, message.line.to.y); - context?.closePath(); - context?.stroke(); - }; - - // Called if connection is closed - this.subscription.onclose = (code, reason) => { - this.connected = false; - Alert.danger('Connection closed with code ' + code + ' and reason: ' + reason); - }; - - // Called on connection error - this.subscription.onerror = (error) => { - this.connected = false; - Alert.danger('Connection error: ' + error.message); - }; - } - - // Unsubscribe from whiteboardService when component is no longer in use - beforeUnmount() { - if (this.subscription) whiteboardService.unsubscribe(this.subscription); - } -} diff --git a/client/test/whiteboard-component.test.tsx b/client/test/chat-component.test.tsx similarity index 70% rename from client/test/whiteboard-component.test.tsx rename to client/test/chat-component.test.tsx index 613411018fc7064baa3e753174ec8f225899bb29..237b0859ed71fe90d7d56de6ff44296c46131c4b 100644 --- a/client/test/whiteboard-component.test.tsx +++ b/client/test/chat-component.test.tsx @@ -1,13 +1,13 @@ import * as React from 'react'; -import { Whiteboard } from '../src/whiteboard-component'; +import { Chat } from '../src/chat-component'; import { shallow } from 'enzyme'; -jest.mock('../src/whiteboard-service', () => { +jest.mock('../src/chat-service', () => { class Subscription { onopen = () => {}; } - class WhiteboardService { + class ChatService { constructor() {} subscribe() { @@ -19,12 +19,13 @@ jest.mock('../src/whiteboard-service', () => { return subscription; } } - return new WhiteboardService(); + return new ChatService(); }); -describe('Whiteboard component tests', () => { +describe('Chat component tests', () => { test('draws correctly when connected', (done) => { - const wrapper = shallow(<Whiteboard />); + // @ts-ignore: do not type check next line. + const wrapper = shallow(<Chat />); expect(wrapper.containsMatchingElement(<div>Not connected</div>)).toEqual(true); diff --git a/server/package.json b/server/package.json index 21de6d8c380a0eb92f2100d13df37c17affaa8a6..3827ca77420e6c18366c437c57da02943b6c05ff 100644 --- a/server/package.json +++ b/server/package.json @@ -18,6 +18,7 @@ "axios": "^0.21.1", "express": "^4.17.1", "mysql": "^2.18.1", + "reload": "^3.2.0", "ws": "^8.1.0" }, "devDependencies": { diff --git a/server/src/chat-server.ts b/server/src/chat-server.ts new file mode 100644 index 0000000000000000000000000000000000000000..6184151be48c10df016ba7d80da8dc9063d38e2b --- /dev/null +++ b/server/src/chat-server.ts @@ -0,0 +1,48 @@ +import type http from 'http'; +import type https from 'https'; +import WebSocket from 'ws'; + +/** + * In message type (from client). + */ +export type ClientMessage = { text: string } | { addUser: string } | { removeUser: string }; +/** + * Out message type (to client). + */ +export type ServerMessage = { text: string } | { users: string[] }; + +/** + * Chat server + */ +export default class ChatServer { + users: string[] = []; + + /** + * Constructs a WebSocket server that will respond to the given path on webServer. + */ + constructor(webServer: http.Server | https.Server, path: string) { + const server = new WebSocket.Server({ server: webServer, path: path + '/chat' }); + + server.on('connection', (connection, _request) => { + connection.on('message', (message) => { + const data: ClientMessage = JSON.parse(message.toString()); + if ('addUser' in data) { + this.users.push(data.addUser); + const message = JSON.stringify({ users: this.users } as ServerMessage); + server.clients.forEach((connection) => connection.send(message)); + } + if ('removeUser' in data) { + this.users = this.users.filter((e) => e != data.removeUser); + const message = JSON.stringify({ users: this.users } as ServerMessage); + server.clients.forEach((connection) => connection.send(message)); + } + if ('text' in data) { + // Send the message to all current client connections + server.clients.forEach((connection) => + connection.send(JSON.stringify({ text: data.text } as ServerMessage)) + ); + } + }); + }); + } +} diff --git a/server/src/server.ts b/server/src/server.ts index 2d5f8245689dbd61e2d78da8f814259e28bbd741..0069b3cfb075e55dd913953154ca317665c6d3e2 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -6,13 +6,13 @@ import app from './app'; import express from 'express'; import path from 'path'; import http from 'http'; -import WhiteboardServer from './whiteboard-server'; +import ChatServer from './chat-server'; // Serve client files app.use(express.static(path.join(__dirname, '/../../client/public'))); const webServer = http.createServer(app); -new WhiteboardServer(webServer, '/api/v1'); +const webSocketServer = new ChatServer(webServer, '/api/v1'); const port = 3000; webServer.listen(port, () => { diff --git a/server/src/whiteboard-server.ts b/server/src/whiteboard-server.ts deleted file mode 100644 index 0e48757d1b40454445f386714e1553041e002a03..0000000000000000000000000000000000000000 --- a/server/src/whiteboard-server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type http from 'http'; -import type https from 'https'; -import WebSocket from 'ws'; - -/** - * Whiteboard server - */ -export default class WhiteboardServer { - /** - * Constructs a WebSocket server that will respond to the given path on webServer. - */ - constructor(webServer: http.Server | https.Server, path: string) { - const server = new WebSocket.Server({ server: webServer, path: path + '/whiteboard' }); - - server.on('connection', (connection, _request) => { - connection.on('message', (message) => { - // Send the message to all current client connections - server.clients.forEach((connection) => connection.send(message.toString())); - }); - }); - } -} diff --git a/server/test/whiteboard-server.test.ts b/server/test/chat-server.test.ts similarity index 80% rename from server/test/whiteboard-server.test.ts rename to server/test/chat-server.test.ts index 609a6a3e49797df30fe1de69f46629b64ec3483d..4dce340905aa67feac85f0db9546fb40fe195f40 100644 --- a/server/test/whiteboard-server.test.ts +++ b/server/test/chat-server.test.ts @@ -1,11 +1,11 @@ import http from 'http'; import WebSocket from 'ws'; -import WhiteboardServer from '../src/whiteboard-server'; +import ChatServer from '../src/chat-server'; let webServer: any; beforeAll((done) => { webServer = http.createServer(); - new WhiteboardServer(webServer, '/api/v1'); + new ChatServer(webServer, '/api/v1'); // Use separate port for testing webServer.listen(3001, () => done()); }); @@ -15,9 +15,9 @@ afterAll((done) => { webServer.close(() => done()); }); -describe('WhiteboardServer tests', () => { +describe('ChatServer tests', () => { test('Connection opens successfully', (done) => { - const connection = new WebSocket('ws://localhost:3001/api/v1/whiteboard'); + const connection = new WebSocket('ws://localhost:3001/api/v1/chat'); connection.on('open', () => { connection.close(); @@ -29,8 +29,8 @@ describe('WhiteboardServer tests', () => { }); }); - test('WhiteboardServer replies correctly', (done) => { - const connection = new WebSocket('ws://localhost:3001/api/v1/whiteboard'); + test('ChatServer replies correctly', (done) => { + const connection = new WebSocket('ws://localhost:3001/api/v1/chat'); connection.on('open', () => connection.send('test'));