diff --git a/client/src/app/core/services/notify.service.ts b/client/src/app/core/services/notify.service.ts index d3d715aa1..40575d8a2 100644 --- a/client/src/app/core/services/notify.service.ts +++ b/client/src/app/core/services/notify.service.ts @@ -1,10 +1,62 @@ import { Injectable } from '@angular/core'; +import { Subject, Observable } from 'rxjs'; import { OpenSlidesComponent } from 'app/openslides.component'; import { WebsocketService } from './websocket.service'; +import { OperatorService } from './operator.service'; -interface NotifyFormat { - id: number; // Dummy +/** + * Encapslates the name and content of every message regardless of being a request or response. + */ +interface NotifyBase { + /** + * The name of the notify message. + */ + name: string; + + /** + * The content to send. + */ + content: T; +} + +/** + * This interface has all fields for a notify request to the server. Next to name and content + * one can give an array of user ids (or the value `true` for all users) and an array of + * channel names. + */ +export interface NotifyRequest extends NotifyBase { + /** + * User ids (or `true` for all users) to send this message to. + */ + users?: number[] | boolean; + + /** + * An array of channels to send this message to. + */ + replyChannels?: string[]; +} + +/** + * This is the notify-format one recieves from the server. + */ +export interface NotifyResponse extends NotifyBase { + /** + * This is the channel name of the one, who sends this message. Can be use to directly + * answer this message. + */ + senderChannelName: string; + + /** + * The user id of the user who sends this message. It is 0 for Anonymous. + */ + senderUserId: number; + + /** + * This is validated here and is true, if the senderUserId matches the current operator's id. + * It's also true, if one recieves a request from an anonymous and the operator itself is the anonymous. + */ + sendByThisUser: boolean; } /** @@ -14,29 +66,102 @@ interface NotifyFormat { providedIn: 'root' }) export class NotifyService extends OpenSlidesComponent { + /** + * A general subject for all messages. + */ + private notifySubject = new Subject>(); + + /** + * Subjects for specific messages. + */ + private messageSubjects: { + [name: string]: Subject>; + } = {}; + /** * Constructor to create the NotifyService. Registers itself to the WebsocketService. * @param websocketService */ - public constructor(private websocketService: WebsocketService) { + public constructor(private websocketService: WebsocketService, private operator: OperatorService) { super(); - websocketService.getOberservable('notify').subscribe(notify => { - this.receive(notify); + + websocketService.getOberservable>('notify').subscribe(notify => { + notify.sendByThisUser = notify.senderUserId === (this.operator.user ? this.operator.user.id : 0); + this.notifySubject.next(notify); + if (this.messageSubjects[notify.name]) { + this.messageSubjects[notify.name].next(notify); + } }); } - // TODO: Implement this - private receive(notify: NotifyFormat): void { - console.log('recv', notify); - // TODO: Use a Subject, so one can subscribe and get notifies. + /** + * Sents a notify message to all users (so all clients that are online). + * @param name The name of the notify message + * @param content The payload to send + */ + public sendToAllUsers(name: string, content: T): void { + this.send(name, content); } - // TODO: Make this api better: e.g. send(data, users?, projectors?, channel?, ...) /** - * Sents a notify object to the server - * @param notify the notify objects + * Sends a notify message to all open clients with the given users logged in. + * @param name The name of th enotify message + * @param content The payload to send. + * @param users Multiple user ids. */ - public send(notify: NotifyFormat): void { + public sendToUsers(name: string, content: T, ...users: number[]): void { + this.send(name, content, users); + } + + /** + * Sends a notify message to all given channels. + * @param name The name of th enotify message + * @param content The payload to send. + * @param channels Multiple channels to send this message to. + */ + public sendToChannels(name: string, content: T, ...channles: string[]): void { + this.send(name, content, null, channles); + } + + /** + * General send function for notify messages. + * @param name The name of the notify message + * @param content The payload to send. + * @param users Either an array of IDs or `true` meaning of sending this message to all online users clients. + * @param channels An array of channels to send this message to. + */ + public send(name: string, content: T, users?: number[] | boolean, channels?: string[]): void { + const notify: NotifyRequest = { + name: name, + content: content + }; + if (typeof users === 'boolean' && users !== true) { + throw new Error('You just can give true as a boolean to send this message to all users.'); + } + if (users !== null) { + notify.users = users; + } + if (channels !== null) { + notify.replyChannels = channels; + } this.websocketService.send('notify', notify); } + + /** + * Returns a general observalbe of all notify messages. + */ + public getObservable(): Observable> { + return this.notifySubject.asObservable(); + } + + /** + * Returns an observable for a specific type of messages. + * @param name The name of all messages to observe. + */ + public getMessageObservable(name: string): Observable> { + if (!this.messageSubjects[name]) { + this.messageSubjects[name] = new Subject>(); + } + return this.messageSubjects[name].asObservable() as Observable>; + } } diff --git a/client/src/app/shared/components/copyright-sign/copyright-sign.component.ts b/client/src/app/shared/components/copyright-sign/copyright-sign.component.ts new file mode 100644 index 000000000..d844c3cf0 --- /dev/null +++ b/client/src/app/shared/components/copyright-sign/copyright-sign.component.ts @@ -0,0 +1,610 @@ +/** + * Remember: Do not tell, Do not ask. + */ + +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { MatDialogRef, MatDialog } from '@angular/material'; +import { OperatorService } from '../../../core/services/operator.service'; +import { NotifyService, NotifyResponse } from '../../../core/services/notify.service'; +import { Subscription } from 'rxjs'; + +/** + * All player types. + */ +enum Player { + noPlayer, + thisPlayer, + partner +} + +/** + * All states the game board can have. + */ +enum BoardStatus { + Draw = 'draw', + thisPlayer = 'thisPlayer', + partner = 'partner', + NotDecided = 'not decided' +} + +/** + * All states for the statemachine + */ +type State = 'start' | 'search' | 'waitForResponse' | 'myTurn' | 'foreignTurn'; + +/** + * All events that can be handled by the statemachine. + */ +type StateEvent = + | 'searchClicked' + | 'recievedSearchRequest' + | 'recievedSearchResponse' + | 'recievedACK' + | 'waitTimeout' + | 'fieldClicked' + | 'recievedTurn' + | 'recievedRagequit'; + +/** + * An action in one state. + */ +interface SMAction { + handle: (data?: any) => State | null; +} + +/** + * The statemachine. Mapps events in states to actions. + */ +type StateMachine = { [state in State]?: { [event in StateEvent]?: SMAction } }; + +@Component({ + selector: 'os-c4dialog', + template: ` +

{{ caption | translate }}

+ + +
+
+ {{ getPlayerName() }} +
+
+
+ {{ partnerName }} +
+
+ + + + +
+
+
+ +
+ +
+
+ + `, + styles: [ + ` + span { + font-size: 20px; + padding-left: 10px; + } + .center { + text-align: center; + } + .space { + margin-bottom: 5px; + } + #c4 { + background-color: yellow; + padding: 10px; + margin: 15px auto; + } + #c4.disabled { + background-color: grey; + } + #c4 td { + width: 50px; + height: 50px; + } + #c4 .notSelected { + cursor: pointer; + background-color: white; + } + #c4.disabled .notSelected { + cursor: auto; + } + .thisPlayer { + background-color: blue; + } + #c4.disabled .thisPlayer { + background-color: #8888ff; + } + .partner { + background-color: red; + } + #c4.disabled .partner { + background-color: #ff8888; + } + .coin { + border-radius: 50%; + width: 100%; + height: 100%; + } + .coin.info-coin { + display: inline-block; + width: 20px; + height: 20px; + } + ` + ] +}) +export class C4DialogComponent implements OnInit, OnDestroy { + /** + * The dialogs caption + */ + public caption: string; + + /** + * Saves, if the board is disabled. + */ + public disableBoard: boolean; + + /** + * The board. First columns, then rows. Size is 7x6. + */ + public board: Player[][]; + + /** + * The channel of the partner. + */ + private replyChannel: string; + + /** + * The partners name. + */ + public partnerName: string; + + /** + * A timeout to go from waiting to search state. + */ + private waitTimout: number | null; + + /** + * A list of all subscriptions, so they can b unsubscribed on desroy. + */ + private subscriptions: Subscription[] = []; + + /** + * The current state of the state machine. + */ + public state: State; + + /** + * This is the state machine for this game :) + */ + public SM: StateMachine = { + start: { + searchClicked: { + handle: () => { + this.disableBoard = false; + this.resetBoard(); + return 'search'; + } + } + }, + search: { + recievedSearchRequest: { + handle: (notify: NotifyResponse<{ name: string }>) => { + this.replyChannel = notify.senderChannelName; + this.partnerName = notify.content.name; + return 'waitForResponse'; + } + }, + recievedSearchResponse: { + handle: (notify: NotifyResponse<{ name: string }>) => { + this.replyChannel = notify.senderChannelName; + this.partnerName = notify.content.name; + // who starts? + const startPlayer = Math.random() < 0.5 ? Player.thisPlayer : Player.partner; + const startPartner: boolean = startPlayer === Player.partner; + // send ACK + this.notifyService.sendToChannels('c4_ACK', startPartner, this.replyChannel); + return startPlayer === Player.thisPlayer ? 'myTurn' : 'foreignTurn'; + } + } + }, + waitForResponse: { + recievedACK: { + handle: (notify: NotifyResponse<{}>) => { + if (notify.senderChannelName !== this.replyChannel) { + return null; + } + return notify.content ? 'myTurn' : 'foreignTurn'; + } + }, + waitTimeout: { + handle: () => 'search' + }, + recievedRagequit: { + handle: (notify: NotifyResponse<{}>) => { + return notify.senderChannelName === this.replyChannel ? 'search' : null; + } + } + }, + myTurn: { + fieldClicked: { + handle: (data: { col: number; row: number }) => { + if (this.colFree(data.col)) { + this.setCoin(data.col, Player.thisPlayer); + this.notifyService.sendToChannels('c4_turn', { col: data.col }, this.replyChannel); + const nextState = this.getStateFromBoardStatus(); + return nextState === null ? 'foreignTurn' : nextState; + } else { + return null; + } + } + }, + recievedRagequit: { + handle: () => { + this.caption = "Your partner couldn't stand it anymore... You are the winner!"; + return 'start'; + } + } + }, + foreignTurn: { + recievedTurn: { + handle: (notify: NotifyResponse<{ col: number }>) => { + if (notify.senderChannelName !== this.replyChannel) { + return null; + } + const col: number = notify.content.col; + if (!this.colFree(col)) { + return null; + } + this.setCoin(col, Player.partner); + const nextState = this.getStateFromBoardStatus(); + return nextState === null ? 'myTurn' : nextState; + } + }, + recievedRagequit: { + handle: () => { + this.caption = "Your partner couldn't stand it anymore... You are the winner!"; + return 'start'; + } + } + } + }; + + public constructor( + public dialogRef: MatDialogRef, + private notifyService: NotifyService, + private op: OperatorService + ) { + this.resetBoard(); + } + + public ngOnInit(): void { + // Setup initial values. + this.state = 'start'; + this.caption = 'Connect 4'; + this.disableBoard = true; + + // Setup all subscription for needed notify messages + this.subscriptions = [ + this.notifyService.getMessageObservable('c4_ACK').subscribe(notify => { + if (!notify.sendByThisUser) { + this.handleEvent('recievedACK', notify); + } + }), + this.notifyService.getMessageObservable('c4_ragequit').subscribe(notify => { + if (!notify.sendByThisUser) { + this.handleEvent('recievedRagequit', notify); + } + }), + this.notifyService.getMessageObservable('c4_search_request').subscribe(notify => { + if (!notify.sendByThisUser) { + this.handleEvent('recievedSearchRequest', notify); + } + }), + this.notifyService.getMessageObservable('c4_search_response').subscribe(notify => { + if (!notify.sendByThisUser) { + this.handleEvent('recievedSearchResponse', notify); + } + }), + this.notifyService.getMessageObservable('c4_turn').subscribe(notify => { + if (!notify.sendByThisUser) { + this.handleEvent('recievedTurn', notify); + } + }) + ]; + } + + public ngOnDestroy(): void { + // send ragequit and unsubscribe all subscriptions. + if (this.replyChannel) { + this.notifyService.sendToChannels('c4_ragequit', null, this.replyChannel); + } + this.subscriptions.forEach(subscription => subscription.unsubscribe()); + } + + /** + * Resets the board. + */ + private resetBoard(): void { + this.board = []; + for (let i = 0; i < 7; i++) { + const row = []; + for (let j = 0; j < 6; j++) { + row.push(Player.noPlayer); + } + this.board.push(row); + } + } + + /** + * Returns the class needed in the board. + * @param row The row + * @param col The column + */ + public getCoinClass(row: number, col: number): string { + switch (this.board[col][row]) { + case Player.noPlayer: + return 'coin notSelected'; + case Player.thisPlayer: + return 'coin thisPlayer'; + case Player.partner: + return 'coin partner'; + } + } + + /** + * Returns the operators name. + */ + public getPlayerName(): string { + return this.op.user.short_name; + } + + /** + * Returns null, if the game is not finished. + */ + private getStateFromBoardStatus(): State { + switch (this.boardStatus()) { + case BoardStatus.Draw: + this.caption = 'Game draw!'; + return 'start'; + case BoardStatus.thisPlayer: + this.caption = 'You won!'; + return 'start'; + case BoardStatus.partner: + this.caption = 'Your partner has won!'; + return 'start'; + case BoardStatus.NotDecided: + return null; + } + } + + /** + * Main state machine handler. The current state handler will be called with + * the given event. If the handler returns a state (and not null), this will be + * the next state. The state enter method will be called. + * @param e The event for the statemachine. + * @param data Additional data for the handler. + */ + public handleEvent(e: StateEvent, data?: any): void { + let action: SMAction = null; + if (this.SM[this.state] && this.SM[this.state][e]) { + action = this.SM[this.state][e]; + const nextState = action.handle(data); + if (nextState !== null) { + this.state = nextState; + if (this['enter_' + nextState]) { + this['enter_' + nextState](); + } + } + } + } + + /** + * Handler for clicks on the field. + * @param row the row clicked + * @param col the col clicked + */ + public clickField(row: number, col: number): void { + if (!this.disableBoard) { + this.handleEvent('fieldClicked', { row: row, col: col }); + } + } + + // Enter state methods + /** + * Resets all attributes of the state machine. + */ + public enter_start(): void { + this.disableBoard = true; + this.replyChannel = null; + this.partnerName = null; + } + + /** + * Sends a search request for other players. + */ + public enter_search(): void { + this.caption = 'Searching for players...'; + this.notifyService.sendToAllUsers('c4_search_request', { name: this.getPlayerName() }); + } + + /** + * Sends a search response for a previous request. + * Also sets up a timeout to go back into the search state. + */ + public enter_waitForResponse(): void { + this.caption = 'Wait for response...'; + this.notifyService.send('c4_search_response', { name: this.getPlayerName() }); + if (this.waitTimout) { + clearTimeout(this.waitTimout); + } + this.waitTimout = setTimeout(() => { + this.handleEvent('waitTimeout'); + }, 5000); + } + + /** + * Sets the caption. + */ + public enter_myTurn(): void { + this.caption = "It's your turn!"; + } + + /** + * Sets the caption. + */ + public enter_foreignTurn(): void { + this.caption = "It's your partners turn"; + } + + // Board function + /** + * Places a coin on the board + * @param col The col to place a coin + * @param player The player who placed the coin + */ + private setCoin(col: number, player: Player): void { + for (let row = 0; row < 6; row++) { + if (this.board[col][row] === Player.noPlayer) { + this.board[col][row] = player; + break; + } + } + } + + /** + * Returns true, if the given col is free to place a coin there + * @param col the col + */ + private colFree(col: number): boolean { + return this.board[col][5] === Player.noPlayer; + } + + /** + * Returns the current state of the board + */ + private boardStatus(): BoardStatus { + // check if a player has won + // vertical + let won: Player; + for (let row = 0; row < 6; row++) { + for (let col = 0; col < 4; col++) { + won = this.board[col][row]; + for (let i = 1; i < 4 && won !== Player.noPlayer; i++) { + if (this.board[col + i][row] !== won) { + won = Player.noPlayer; + } + } + if (won !== Player.noPlayer) { + return won === Player.thisPlayer ? BoardStatus.thisPlayer : BoardStatus.partner; + } + } + } + // horizontal + for (let col = 0; col < 7; col++) { + for (let row = 0; row < 3; row++) { + won = this.board[col][row]; + for (let i = 1; i < 4 && won !== Player.noPlayer; i++) { + if (this.board[col][row + i] !== won) { + won = Player.noPlayer; + } + } + if (won !== Player.noPlayer) { + return won === Player.thisPlayer ? BoardStatus.thisPlayer : BoardStatus.partner; + } + } + } + // diag 1 + for (let col = 0; col < 4; col++) { + for (let row = 0; row < 3; row++) { + won = this.board[col][row]; + for (let i = 1; i < 4 && won !== Player.noPlayer; i++) { + if (this.board[col + i][row + i] !== won) { + won = Player.noPlayer; + } + } + if (won !== Player.noPlayer) { + return won === Player.thisPlayer ? BoardStatus.thisPlayer : BoardStatus.partner; + } + } + } + // diag 1 + for (let col = 3; col < 7; col++) { + for (let row = 0; row < 3; row++) { + won = this.board[col][row]; + for (let i = 1; i < 4 && won !== Player.noPlayer; i++) { + if (this.board[col - i][row + i] !== won) { + won = Player.noPlayer; + } + } + if (won !== Player.noPlayer) { + return won === Player.thisPlayer ? BoardStatus.thisPlayer : BoardStatus.partner; + } + } + } + // game draw? + let draw = true; + for (let col = 0; col < 7; col++) { + if (this.board[col][5] === Player.noPlayer) { + draw = false; + break; + } + } + return draw ? BoardStatus.Draw : BoardStatus.NotDecided; + } +} + +@Component({ + selector: 'os-copyright-sign', + template: ` + © + `, + styles: [``] +}) +export class CopyrightSignComponent { + private clickTimeout: number | null; + private clickCounter = 0; + + public constructor(private dialog: MatDialog, private op: OperatorService) {} + + public launchC4(event: Event): void { + event.stopPropagation(); + event.preventDefault(); + + // no anonymous invited.. + if (!this.op.user) { + return; + } + + this.clickCounter++; + if (this.clickTimeout) { + clearTimeout(this.clickTimeout); + } + + if (this.clickCounter === 5) { + this.clickCounter = 0; + this.dialog.open(C4DialogComponent, { width: '550px' }); + } else { + this.clickTimeout = setTimeout(() => { + this.clickCounter = 0; + }, 200); + } + } +} diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index beeabf7ca..e678969a7 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -72,6 +72,7 @@ import { OsSortFilterBarComponent } from './components/os-sort-filter-bar/os-sor import { OsSortBottomSheetComponent } from './components/os-sort-filter-bar/os-sort-bottom-sheet/os-sort-bottom-sheet.component'; import { FilterMenuComponent } from './components/os-sort-filter-bar/filter-menu/filter-menu.component'; import { LogoComponent } from './components/logo/logo.component'; +import { C4DialogComponent, CopyrightSignComponent } from './components/copyright-sign/copyright-sign.component'; /** * Share Module for all "dumb" components and pipes. @@ -176,7 +177,9 @@ import { LogoComponent } from './components/logo/logo.component'; SortingTreeComponent, TreeModule, OsSortFilterBarComponent, - LogoComponent + LogoComponent, + CopyrightSignComponent, + C4DialogComponent ], declarations: [ PermsDirective, @@ -194,7 +197,9 @@ import { LogoComponent } from './components/logo/logo.component'; OsSortFilterBarComponent, OsSortBottomSheetComponent, FilterMenuComponent, - LogoComponent + LogoComponent, + CopyrightSignComponent, + C4DialogComponent ], providers: [ { provide: DateAdapter, useClass: OpenSlidesDateAdapter }, @@ -204,6 +209,6 @@ import { LogoComponent } from './components/logo/logo.component'; OsSortFilterBarComponent, OsSortBottomSheetComponent ], - entryComponents: [OsSortBottomSheetComponent] + entryComponents: [OsSortBottomSheetComponent, C4DialogComponent] }) export class SharedModule {} diff --git a/client/src/app/site/common/components/start/start.component.css b/client/src/app/site/common/components/start/start.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/src/app/site/common/components/start/start.component.ts b/client/src/app/site/common/components/start/start.component.ts index 5db5b6e65..72ebbe28b 100644 --- a/client/src/app/site/common/components/start/start.component.ts +++ b/client/src/app/site/common/components/start/start.component.ts @@ -10,8 +10,7 @@ import { DataStoreService } from '../../../../core/services/data-store.service'; @Component({ selector: 'os-start', - templateUrl: './start.component.html', - styleUrls: ['./start.component.css'] + templateUrl: './start.component.html' }) export class StartComponent extends BaseComponent implements OnInit { public welcomeTitle: string; diff --git a/client/src/app/site/site.component.html b/client/src/app/site/site.component.html index 46f051577..261d8526c 100644 --- a/client/src/app/site/site.component.html +++ b/client/src/app/site/site.component.html @@ -120,7 +120,7 @@ target="_blank" (click)="toggleSideNav()" > - © Copyright by OpenSlides +  Copyright by OpenSlides