diff --git a/front/dist/resources/html/gameReport.html b/front/dist/resources/html/gameReport.html index 9a761c328816227103052649a867786db391a63d..59ca35920de83385533e6e2820bf37f620aee825 100644 --- a/front/dist/resources/html/gameReport.html +++ b/front/dist/resources/html/gameReport.html @@ -12,10 +12,6 @@ border-radius: 6px; margin: 2px auto 0; width: 298px; - height: 220px; - } - #gameReport .cautiousText { - font-size: 50%; } #gameReport h1 { background-image: linear-gradient(top, #f1f3f3, #d4dae0); @@ -30,6 +26,9 @@ text-align: center; text-shadow: 0 -1px 0 rgba(0,0,0,0.2), 0 1px 0 #fff; } + #gameReport h3 { + margin: 0; + } #gameReport textarea { font-size: 70%; background: linear-gradient(top, #d6d7d7, #dee0e0); @@ -51,15 +50,17 @@ } #gameReport button { margin-top: 10px; - background-color: black; + font-size: 60%; + background-color: #dc3545; color: white; border-radius: 7px; - padding-bottom: 4px; - width: 60px; + padding: 3px 10px 3px 10px; } #gameReport button#gameReportFormCancel { background-color: #c7c7c700; color: #292929; + display: block; + float: right; } #gameReport section a{ text-align: center; @@ -74,8 +75,11 @@ #gameReport section.text-center{ text-align: center; } - #gameReport section p{ + #gameReport p{ font-size: 8px; + margin: 3px 0 0 0; + } + #gameReport form p{ margin: 0px 70px; } #gameReport section p.err{ @@ -87,18 +91,32 @@ } </style> -<form id="gameReport" hidden> - <section class="text-center"> - <h5 id="nameReported"></h5> - <input type="hidden" id="idUserReported"/> +<main id="gameReport" hidden> + <section> + <button id="gameReportFormCancel">X</button> + <h1>Moderate <span id="nameReported"></span></h1> + <p id="askActionP">What action do you want to take?</p> </section> <section> - <h6>Message</h6> - <textarea type="text" name="report" id="gameReportInput"></textarea> - <p class="err" id="gameReportErr"></p> + <h3>Block: </h3> + <p>Block any communication from and to this user. This can be reverted.</p> + <section class="action"> + <button id="toggleBlockButton">Block this user</button> + </section> </section> - <section class="action"> - <button type="submit" id="gameReportFormSubmit">Submit</button> - <button type="submit" id="gameReportFormCancel">Close</button> + <section id="reportSection"> + <h3>Report: </h3> + <p>Send a report message to the administrators of this room. They may later ban this user.</p> + <form> + <section> + <h6>Your message: </h6> + <textarea type="text" name="report" id="gameReportInput"></textarea> + <p class="err" id="gameReportErr"></p> + </section> + <section class="action"> + <button type="submit" id="gameReportFormSubmit">Report this user</button> + </section> + </form> </section> -</form> +</main> + diff --git a/front/dist/resources/html/gameShare.html b/front/dist/resources/html/gameShare.html index 4e487328d00081c2f41e0ce450ed5de8ac754e54..21c65014cf434bc580a0a009072aaf99d604f6f9 100644 --- a/front/dist/resources/html/gameShare.html +++ b/front/dist/resources/html/gameShare.html @@ -14,9 +14,6 @@ width: 298px; height: 150px; } - #gameShare .cautiousText { - font-size: 50%; - } #gameShare h1 { background-image: linear-gradient(top, #f1f3f3, #d4dae0); border-bottom: 1px solid #a6abaf; diff --git a/front/dist/resources/logos/blockSign.svg b/front/dist/resources/logos/blockSign.svg new file mode 100644 index 0000000000000000000000000000000000000000..c64ba2949a211e551fc991681dc8df528379a09d --- /dev/null +++ b/front/dist/resources/logos/blockSign.svg @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> +<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" id="svg2985" version="1.1" inkscape:version="0.48.4 r9939" width="485.33627" height="485.33627" sodipodi:docname="600px-France_road_sign_B1j.svg[1].png"> + <metadata id="metadata2991"> + <rdf:RDF> + <cc:Work rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/> + <dc:title/> + </cc:Work> + </rdf:RDF> + </metadata> + <defs id="defs2989"/> + <sodipodi:namedview pagecolor="#ffffff" bordercolor="#666666" borderopacity="1" objecttolerance="10" gridtolerance="10" guidetolerance="10" inkscape:pageopacity="0" inkscape:pageshadow="2" inkscape:window-width="1272" inkscape:window-height="745" id="namedview2987" showgrid="false" inkscape:snap-global="true" inkscape:snap-grids="true" inkscape:snap-bbox="true" inkscape:bbox-paths="true" inkscape:bbox-nodes="true" inkscape:snap-bbox-edge-midpoints="true" inkscape:snap-bbox-midpoints="true" inkscape:object-paths="true" inkscape:snap-intersection-paths="true" inkscape:object-nodes="true" inkscape:snap-smooth-nodes="true" inkscape:snap-midpoints="true" inkscape:snap-object-midpoints="true" inkscape:snap-center="false" fit-margin-top="0" fit-margin-left="0" fit-margin-right="0" fit-margin-bottom="0" inkscape:zoom="0.59970176" inkscape:cx="390.56499" inkscape:cy="244.34365" inkscape:window-x="86" inkscape:window-y="-8" inkscape:window-maximized="1" inkscape:current-layer="layer1"> + <inkscape:grid type="xygrid" id="grid2995" empspacing="5" visible="true" enabled="true" snapvisiblegridlinesonly="true" originx="-57.33186px" originy="-57.33186px"/> + </sodipodi:namedview> + <g inkscape:groupmode="layer" id="layer1" inkscape:label="1" style="display:inline" transform="translate(-57.33186,-57.33186)"> + <path sodipodi:type="arc" style="color:#000000;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:2.5;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" id="path2997" sodipodi:cx="300" sodipodi:cy="300" sodipodi:rx="240" sodipodi:ry="240" d="M 540,300 C 540,432.54834 432.54834,540 300,540 167.45166,540 60,432.54834 60,300 60,167.45166 167.45166,60 300,60 432.54834,60 540,167.45166 540,300 z" transform="matrix(1.0058783,0,0,1.0058783,-1.76349,-1.76349)"/> + <path sodipodi:type="arc" style="color:#000000;fill:#ff0000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.5;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" id="path4005" sodipodi:cx="304.75" sodipodi:cy="214.75" sodipodi:rx="44.75" sodipodi:ry="44.75" d="m 349.5,214.75 c 0,24.71474 -20.03526,44.75 -44.75,44.75 -24.71474,0 -44.75,-20.03526 -44.75,-44.75 0,-24.71474 20.03526,-44.75 44.75,-44.75 24.71474,0 44.75,20.03526 44.75,44.75 z" transform="matrix(5.1364411,0,0,5.1364411,-1265.3304,-803.05073)"/> + <rect style="color:#000000;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.5;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" id="rect4001" width="345" height="80.599998" x="127.5" y="259.70001"/> + </g> +</svg> \ No newline at end of file diff --git a/front/dist/resources/logos/blockingIcon.png b/front/dist/resources/logos/blockingIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..ef5f66cc49e59d3883292a056d1f167652a0e7d8 Binary files /dev/null and b/front/dist/resources/logos/blockingIcon.png differ diff --git a/front/dist/resources/logos/cancel.png b/front/dist/resources/logos/cancel.png new file mode 100644 index 0000000000000000000000000000000000000000..5bf9b6d2914a24bf0b36fb52cefd3380cb3434bb Binary files /dev/null and b/front/dist/resources/logos/cancel.png differ diff --git a/front/dist/resources/style/style.css b/front/dist/resources/style/style.css index 111700d1e80292d7280bf407e4592e08864a8dc1..4bf05455f1e120ef95ca72771c6d8a4b6152a091 100644 --- a/front/dist/resources/style/style.css +++ b/front/dist/resources/style/style.css @@ -65,6 +65,12 @@ body .message-info.warning{ padding: 10px; z-index: 2; } +.video-container img.block-logo { + left: 30%; + bottom: 15%; + width: 150px; + height: 150px; +} .video-container button.report{ display: block; @@ -91,7 +97,7 @@ body .message-info.warning{ } .video-container button.report:hover { - width: 94px; + width: 150px; } .video-container button.report img{ @@ -111,6 +117,9 @@ body .message-info.warning{ font-size: 16px; cursor: url('/resources/logos/cursor_pointer.png'), pointer; } +.video-container img.active { + display: block !important; +} .video-container video{ height: 100%; @@ -188,10 +197,7 @@ video#myCamVideo{ transition: all .2s; right: 224px; } -/*.btn-call{ - transition: all .1s; - left: 0px; -}*/ + .btn-cam-action div img{ height: 22px; width: 30px; diff --git a/front/package.json b/front/package.json index 41932ca9c0f6ac786581d96ec5f6983e1f60fc1d..0e50ba805d1f000d3932a4a9e1aa73dfdce8a414 100644 --- a/front/package.json +++ b/front/package.json @@ -29,6 +29,7 @@ "phaser": "3.24.1", "queue-typescript": "^1.0.1", "quill": "^1.3.7", + "rxjs": "^6.6.3", "simple-peer": "^9.6.2", "socket.io-client": "^2.3.0", "webpack-require-http": "^0.4.3" diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index bd330ad97c7aac6ba19f31ffaec0f2fafb7dfe24..8eb7462fcbfc30434985347c3cfe1a6e294ffc18 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -464,7 +464,8 @@ export class RoomConnection implements RoomConnection { }); } - public getUserId(): number|null { + public getUserId(): number { + if (this.userId === null) throw 'UserId cannot be null!' return this.userId; } diff --git a/front/src/Phaser/Entity/RemotePlayer.ts b/front/src/Phaser/Entity/RemotePlayer.ts index 822e4e3664629dc0c54e7226705fb6e48f7ec9c7..81ea00e1e358bf346ec3cf30a498032aafd95ac9 100644 --- a/front/src/Phaser/Entity/RemotePlayer.ts +++ b/front/src/Phaser/Entity/RemotePlayer.ts @@ -1,7 +1,6 @@ import {GameScene} from "../Game/GameScene"; import {PointInterface} from "../../Connexion/ConnexionModels"; import {Character} from "../Entity/Character"; -import {Sprite} from "./Sprite"; /** * Class representing the sprite of a remote player (a player that plays on another computer) @@ -23,6 +22,11 @@ export class RemotePlayer extends Character { //set data this.userId = userId; + + //todo: implement on click action + /*this.playerName.setInteractive(); + this.playerName.on('pointerup', () => { + });*/ } updatePosition(position: PointInterface): void { diff --git a/front/src/Phaser/Menu/MenuScene.ts b/front/src/Phaser/Menu/MenuScene.ts index acc1ec3d2d81dbbdcd2e2126f1a7a47a768ac215..ab25c338fb0c314593103f0b4255f3e6ec1df32f 100644 --- a/front/src/Phaser/Menu/MenuScene.ts +++ b/front/src/Phaser/Menu/MenuScene.ts @@ -2,17 +2,16 @@ import {LoginScene, LoginSceneName} from "../Login/LoginScene"; import {SelectCharacterScene, SelectCharacterSceneName} from "../Login/SelectCharacterScene"; import {gameManager} from "../Game/GameManager"; import {localUserStore} from "../../Connexion/LocalUserStore"; -import {mediaManager, ReportCallback, ShowReportCallBack} from "../../WebRtc/MediaManager"; -import {coWebsiteManager} from "../../WebRtc/CoWebsiteManager"; -import {GameConnexionTypes} from "../../Url/UrlManager"; +import {mediaManager} from "../../WebRtc/MediaManager"; +import {gameReportKey, gameReportRessource, ReportMenu} from "./ReportMenu"; import {connectionManager} from "../../Connexion/ConnectionManager"; +import {GameConnexionTypes} from "../../Url/UrlManager"; export const MenuSceneName = 'MenuScene'; const gameMenuKey = 'gameMenu'; const gameMenuIconKey = 'gameMenuIcon'; const gameSettingsMenuKey = 'gameSettingsMenu'; const gameShare = 'gameShare'; -const gameReport = 'gameReport'; const closedSideMenuX = -200; const openedSideMenuX = 0; @@ -24,11 +23,10 @@ export class MenuScene extends Phaser.Scene { private menuElement!: Phaser.GameObjects.DOMElement; private gameQualityMenuElement!: Phaser.GameObjects.DOMElement; private gameShareElement!: Phaser.GameObjects.DOMElement; - private gameReportElement!: Phaser.GameObjects.DOMElement; + private gameReportElement!: ReportMenu; private sideMenuOpened = false; private settingsMenuOpened = false; private gameShareOpened = false; - private gameReportOpened = false; private gameQualityValue: number; private videoQualityValue: number; private menuButton!: Phaser.GameObjects.DOMElement; @@ -45,21 +43,21 @@ export class MenuScene extends Phaser.Scene { this.load.html(gameMenuIconKey, 'resources/html/gameMenuIcon.html'); this.load.html(gameSettingsMenuKey, 'resources/html/gameQualityMenu.html'); this.load.html(gameShare, 'resources/html/gameShare.html'); - this.load.html(gameReport, 'resources/html/gameReport.html'); + this.load.html(gameReportKey, gameReportRessource); } create() { this.menuElement = this.add.dom(closedSideMenuX, 30).createFromCache(gameMenuKey); this.menuElement.setOrigin(0); - this.revealMenusAfterInit(this.menuElement, 'gameMenu'); + MenuScene.revealMenusAfterInit(this.menuElement, 'gameMenu'); const middleX = (window.innerWidth / 3) - 298; this.gameQualityMenuElement = this.add.dom(middleX, -400).createFromCache(gameSettingsMenuKey); - this.revealMenusAfterInit(this.gameQualityMenuElement, 'gameQuality'); + MenuScene.revealMenusAfterInit(this.gameQualityMenuElement, 'gameQuality'); this.gameShareElement = this.add.dom(middleX, -400).createFromCache(gameShare); - this.revealMenusAfterInit(this.gameShareElement, gameShare); + MenuScene.revealMenusAfterInit(this.gameShareElement, gameShare); this.gameShareElement.addListener('click'); this.gameShareElement.on('click', (event:MouseEvent) => { event.preventDefault(); @@ -70,18 +68,11 @@ export class MenuScene extends Phaser.Scene { } }); - this.gameReportElement = this.add.dom(middleX, -400).createFromCache(gameReport); - this.revealMenusAfterInit(this.gameReportElement, gameReport); - this.gameReportElement.addListener('click'); - this.gameReportElement.on('click', (event:MouseEvent) => { - event.preventDefault(); - if((event?.target as HTMLInputElement).id === 'gameReportFormSubmit') { - this.submitReport(); - }else if((event?.target as HTMLInputElement).id === 'gameReportFormCancel') { - this.closeGameReport(); - } + this.gameReportElement = new ReportMenu(this, connectionManager.getConnexionType === GameConnexionTypes.anonymous); + mediaManager.setShowReportModalCallBacks((userId, userName) => { + this.closeAll(); + this.gameReportElement.open(parseInt(userId), userName); }); - mediaManager.setShowReportModalCallBacks(this.openGameReport.bind(this)); this.input.keyboard.on('keyup-TAB', () => { this.sideMenuOpened ? this.closeSideMenu() : this.openSideMenu(); @@ -96,7 +87,8 @@ export class MenuScene extends Phaser.Scene { this.menuElement.on('click', this.onMenuClick.bind(this)); } - private revealMenusAfterInit(menuElement: Phaser.GameObjects.DOMElement, rootDomId: string) { + //todo put this method in a parent menuElement class + static revealMenusAfterInit(menuElement: Phaser.GameObjects.DOMElement, rootDomId: string) { //Dom elements will appear inside the viewer screen when creating before being moved out of it, which create a flicker effect. //To prevent this, we put a 'hidden' attribute on the root element, we remove it only after the init is done. setTimeout(() => { @@ -245,71 +237,6 @@ export class MenuScene extends Phaser.Scene { }); } - private openGameReport(userId: string, userName: string|undefined){ - if (this.gameReportOpened) { - this.closeGameReport(); - return; - } - - //close all - this.closeAll(); - - const gameTitleReport = this.gameReportElement.getChildByID('nameReported') as HTMLElement; - gameTitleReport.innerText = userName ? `Report user: ${userName}` : 'Report user'; - const gameIdUserReported = this.gameReportElement.getChildByID('idUserReported') as HTMLInputElement; - gameIdUserReported.value = userId; - - this.gameReportOpened = true; - let middleY = (window.innerHeight / 3) - (257); - if(middleY < 0){ - middleY = 0; - } - let middleX = (window.innerWidth / 3) - 298; - if(middleX < 0){ - middleX = 0; - } - - gameManager.getCurrentGameScene(this).userInputManager.clearAllKeys(); - - this.tweens.add({ - targets: this.gameReportElement, - y: middleY, - x: middleX, - duration: 1000, - ease: 'Power3' - }); - return; - } - - private closeGameReport(): void{ - this.gameReportOpened = false; - gameManager.getCurrentGameScene(this).userInputManager.initKeyBoardEvent(); - this.tweens.add({ - targets: this.gameReportElement, - y: -400, - duration: 1000, - ease: 'Power3' - }); - } - - private submitReport(): void{ - const gamePError = this.gameReportElement.getChildByID('gameReportErr') as HTMLParagraphElement; - gamePError.innerText = ''; - gamePError.style.display = 'none'; - const gameTextArea = this.gameReportElement.getChildByID('gameReportInput') as HTMLInputElement; - const gameIdUserReported = this.gameReportElement.getChildByID('idUserReported') as HTMLInputElement; - if(!gameTextArea || !gameTextArea.value || !gameIdUserReported || !gameIdUserReported.value){ - gamePError.innerText = 'Report message cannot to be empty.'; - gamePError.style.display = 'block'; - return; - } - gameManager.getCurrentGameScene(this).connection.emitReportPlayerMessage( - parseInt(gameIdUserReported.value), - gameTextArea.value - ); - this.closeGameReport(); - } - private onMenuClick(event:MouseEvent) { if((event?.target as HTMLInputElement).classList.contains('not-button')){ return; @@ -372,6 +299,6 @@ export class MenuScene extends Phaser.Scene { private closeAll(){ this.closeGameQualityMenu(); this.closeGameShare(); - this.closeGameReport(); + this.gameReportElement.close(); } } diff --git a/front/src/Phaser/Menu/ReportMenu.ts b/front/src/Phaser/Menu/ReportMenu.ts new file mode 100644 index 0000000000000000000000000000000000000000..bee86c35ed5c05a96d21bee96b2a4a6386bd3686 --- /dev/null +++ b/front/src/Phaser/Menu/ReportMenu.ts @@ -0,0 +1,119 @@ +import {MenuScene} from "./MenuScene"; +import {gameManager} from "../Game/GameManager"; +import {blackListManager} from "../../WebRtc/BlackListManager"; + +export const gameReportKey = 'gameReport'; +export const gameReportRessource = 'resources/html/gameReport.html'; + +export class ReportMenu extends Phaser.GameObjects.DOMElement { + private opened: boolean = false; + + private userId!: number; + private userName!: string|undefined; + private anonymous: boolean; + + constructor(scene: Phaser.Scene, anonymous: boolean) { + super(scene, -2000, -2000); + this.anonymous = anonymous; + this.createFromCache(gameReportKey); + + if (this.anonymous) { + const divToHide = this.getChildByID('reportSection') as HTMLElement; + divToHide.hidden = true; + const textToHide = this.getChildByID('askActionP') as HTMLElement; + textToHide.hidden = true; + } + + scene.add.existing(this); + MenuScene.revealMenusAfterInit(this, gameReportKey); + + this.addListener('click'); + this.on('click', (event:MouseEvent) => { + event.preventDefault(); + if ((event?.target as HTMLInputElement).id === 'gameReportFormSubmit') { + this.submitReport(); + } else if((event?.target as HTMLInputElement).id === 'gameReportFormCancel') { + this.close(); + } else if((event?.target as HTMLInputElement).id === 'toggleBlockButton') { + this.toggleBlock(); + } + }); + } + + public open(userId: number, userName: string|undefined): void { + if (this.opened) { + this.close(); + return; + } + + this.userId = userId; + this.userName = userName; + + const mainEl = this.getChildByID('gameReport') as HTMLElement; + this.x = this.getCenteredX(mainEl); + this.y = this.getHiddenY(mainEl); + + const gameTitleReport = this.getChildByID('nameReported') as HTMLElement; + gameTitleReport.innerText = userName || ''; + + const blockButton = this.getChildByID('toggleBlockButton') as HTMLElement; + blockButton.innerText = blackListManager.isBlackListed(this.userId) ? 'Unblock this user' : 'Block this user'; + + this.opened = true; + + gameManager.getCurrentGameScene(this.scene).userInputManager.clearAllKeys(); + + this.scene.tweens.add({ + targets: this, + y: this.getCenteredY(mainEl), + duration: 1000, + ease: 'Power3' + }); + } + + public close(): void { + this.opened = false; + gameManager.getCurrentGameScene(this.scene).userInputManager.initKeyBoardEvent(); + const mainEl = this.getChildByID('gameReport') as HTMLElement; + this.scene.tweens.add({ + targets: this, + y: this.getHiddenY(mainEl), + duration: 1000, + ease: 'Power3' + }); + } + + //todo: into a parent class? + private getCenteredX(mainEl: HTMLElement): number { + return window.innerWidth / 4 - mainEl.clientWidth / 2; + } + private getHiddenY(mainEl: HTMLElement): number { + return - mainEl.clientHeight - 50; + } + private getCenteredY(mainEl: HTMLElement): number { + return window.innerHeight / 4 - mainEl.clientHeight / 2; + } + + private toggleBlock(): void { + !blackListManager.isBlackListed(this.userId) ? blackListManager.blackList(this.userId) : blackListManager.cancelBlackList(this.userId); + this.close(); + } + + private submitReport(): void{ + const gamePError = this.getChildByID('gameReportErr') as HTMLParagraphElement; + gamePError.innerText = ''; + gamePError.style.display = 'none'; + const gameTextArea = this.getChildByID('gameReportInput') as HTMLInputElement; + const gameIdUserReported = this.getChildByID('idUserReported') as HTMLInputElement; + if(!gameTextArea || !gameTextArea.value || !gameIdUserReported || !gameIdUserReported.value){ + gamePError.innerText = 'Report message cannot to be empty.'; + gamePError.style.display = 'block'; + return; + } + gameManager.getCurrentGameScene(this.scene).connection.emitReportPlayerMessage( + parseInt(gameIdUserReported.value), + gameTextArea.value + ); + this.close(); + } +} \ No newline at end of file diff --git a/front/src/WebRtc/BlackListManager.ts b/front/src/WebRtc/BlackListManager.ts new file mode 100644 index 0000000000000000000000000000000000000000..65efef3aa1880c863ba3db2d0c61954cd13baf34 --- /dev/null +++ b/front/src/WebRtc/BlackListManager.ts @@ -0,0 +1,24 @@ +import {Subject} from 'rxjs'; + +class BlackListManager { + private list: number[] = []; + public onBlockStream: Subject<number> = new Subject(); + public onUnBlockStream: Subject<number> = new Subject(); + + isBlackListed(userId: number): boolean { + return this.list.find((data) => data === userId) !== undefined; + } + + blackList(userId: number): void { + if (this.isBlackListed(userId)) return; + this.list.push(userId); + this.onBlockStream.next(userId); + } + + cancelBlackList(userId: number): void { + this.list.splice(this.list.findIndex(data => data === userId), 1); + this.onUnBlockStream.next(userId); + } +} + +export const blackListManager = new BlackListManager(); \ No newline at end of file diff --git a/front/src/WebRtc/MediaManager.ts b/front/src/WebRtc/MediaManager.ts index 7874949192d9c2a87353352c828558eedf711dee..12a6f7060cf160f50f7b8991a8093157287cd77a 100644 --- a/front/src/WebRtc/MediaManager.ts +++ b/front/src/WebRtc/MediaManager.ts @@ -3,8 +3,7 @@ import {HtmlUtils} from "./HtmlUtils"; import {discussionManager, SendMessageCallback} from "./DiscussionManager"; import {UserInputManager} from "../Phaser/UserInput/UserInputManager"; import {VIDEO_QUALITY_SELECT} from "../Administration/ConsoleGlobalMessageManager"; -import {connectionManager} from "../Connexion/ConnectionManager"; -import {GameConnexionTypes} from "../Url/UrlManager"; +import {UserSimplePeerInterface} from "./SimplePeer"; declare const navigator:any; // eslint-disable-line @typescript-eslint/no-explicit-any const localValueVideo = localStorage.getItem(VIDEO_QUALITY_SELECT); @@ -28,7 +27,6 @@ export type ReportCallback = (message: string) => void; export type ShowReportCallBack = (userId: string, userName: string|undefined) => void; // TODO: Split MediaManager in 2 classes: MediaManagerUI (in charge of HTML) and MediaManager (singleton in charge of the camera only) -// TODO: verify that microphone event listeners are not triggered plenty of time NOW (since MediaManager is created many times!!!!) export class MediaManager { localStream: MediaStream|null = null; localScreenCapture: MediaStream|null = null; @@ -473,8 +471,9 @@ export class MediaManager { return this.getCamera(); } - addActiveVideo(userId: string, userName: string = "", anonymous: boolean = true){ + addActiveVideo(user: UserSimplePeerInterface, userName: string = ""){ this.webrtcInAudio.play(); + const userId = ''+user.userId userName = userName.toUpperCase(); const color = this.getColorByString(userName); @@ -484,22 +483,18 @@ export class MediaManager { <div class="connecting-spinner"></div> <div class="rtc-error" style="display: none"></div> <i id="name-${userId}" style="background-color: ${color};">${userName}</i> - <img id="microphone-${userId}" src="resources/logos/microphone-close.svg"> - ` + - ((anonymous === false)?` - <button id="report-${userId}" class="report"> - <img src="resources/logos/report.svg"> - <span>Report</span> - </button> - `:'' - ) - + - `<video id="${userId}" autoplay></video> + <img id="microphone-${userId}" title="mute" src="resources/logos/microphone-close.svg"> + <button id="report-${userId}" class="report"> + <img title="report this user" src="resources/logos/report.svg"> + <span>Report/Block</span> + </button> + <video id="${userId}" autoplay></video> + <img src="resources/logos/blockSign.svg" id="blocking-${userId}" class="block-logo"> </div> `; layoutManager.add(DivImportance.Normal, userId, html); - + this.remoteVideo.set(userId, HtmlUtils.getElementByIdOrFail<HTMLVideoElement>(userId)); //permit to create participant in discussion part @@ -510,18 +505,17 @@ export class MediaManager { }; this.addNewParticipant(userId, userName, undefined, showReportUser); - if(!anonymous){ - const reportBanUserAction: HTMLImageElement = HtmlUtils.getElementByIdOrFail<HTMLImageElement>(`report-${userId}`); - reportBanUserAction.addEventListener('click', (e) => { - e.preventDefault(); - showReportUser(); - }); - } + const reportBanUserActionEl: HTMLImageElement = HtmlUtils.getElementByIdOrFail<HTMLImageElement>(`report-${userId}`); + reportBanUserActionEl.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + showReportUser(); + }); } addScreenSharingActiveVideo(userId: string, divImportance: DivImportance = DivImportance.Important){ - userId = `screen-sharing-${userId}`; + userId = this.getScreenSharingId(userId); const html = ` <div id="div-${userId}" class="video-container"> <video id="${userId}" autoplay></video> @@ -532,7 +526,11 @@ export class MediaManager { this.remoteVideo.set(userId, HtmlUtils.getElementByIdOrFail<HTMLVideoElement>(userId)); } - + + private getScreenSharingId(userId: string): string { + return `screen-sharing-${userId}`; + } + disabledMicrophoneByUserId(userId: number){ const element = document.getElementById(`microphone-${userId}`); if(!element){ @@ -571,6 +569,10 @@ export class MediaManager { } } + toggleBlockLogo(userId: number, show: boolean): void { + const blockLogoElement = HtmlUtils.getElementByIdOrFail<HTMLImageElement>('blocking-'+userId); + show ? blockLogoElement.classList.add('active') : blockLogoElement.classList.remove('active'); + } addStreamRemoteVideo(userId: string, stream : MediaStream): void { const remoteVideo = this.remoteVideo.get(userId); if (remoteVideo === undefined) { @@ -580,12 +582,12 @@ export class MediaManager { } addStreamRemoteScreenSharing(userId: string, stream : MediaStream){ // In the case of screen sharing (going both ways), we may need to create the HTML element if it does not exist yet - const remoteVideo = this.remoteVideo.get(`screen-sharing-${userId}`); + const remoteVideo = this.remoteVideo.get(this.getScreenSharingId(userId)); if (remoteVideo === undefined) { this.addScreenSharingActiveVideo(userId); } - this.addStreamRemoteVideo(`screen-sharing-${userId}`, stream); + this.addStreamRemoteVideo(this.getScreenSharingId(userId), stream); } removeActiveVideo(userId: string){ @@ -596,7 +598,7 @@ export class MediaManager { this.removeParticipant(userId); } removeActiveScreenSharingVideo(userId: string) { - this.removeActiveVideo(`screen-sharing-${userId}`) + this.removeActiveVideo(this.getScreenSharingId(userId)) } playWebrtcOutSound(): void { @@ -632,7 +634,7 @@ export class MediaManager { errorDiv.style.display = 'block'; } isErrorScreenSharing(userId: string): void { - this.isError(`screen-sharing-${userId}`); + this.isError(this.getScreenSharingId(userId)); } diff --git a/front/src/WebRtc/SimplePeer.ts b/front/src/WebRtc/SimplePeer.ts index bc2590d753128a35bf074a823d5ada5a187eca71..98f83b0c657677cb8ac73d727c33a3f34185aa73 100644 --- a/front/src/WebRtc/SimplePeer.ts +++ b/front/src/WebRtc/SimplePeer.ts @@ -9,10 +9,11 @@ import { UpdatedLocalStreamCallback } from "./MediaManager"; import {ScreenSharingPeer} from "./ScreenSharingPeer"; -import {MESSAGE_TYPE_CONSTRAINT, MESSAGE_TYPE_MESSAGE, VideoPeer} from "./VideoPeer"; +import {MESSAGE_TYPE_BLOCKED, MESSAGE_TYPE_CONSTRAINT, MESSAGE_TYPE_MESSAGE, VideoPeer} from "./VideoPeer"; import {RoomConnection} from "../Connexion/RoomConnection"; import {connectionManager} from "../Connexion/ConnectionManager"; import {GameConnexionTypes} from "../Url/UrlManager"; +import {blackListManager} from "./BlackListManager"; export interface UserSimplePeerInterface{ userId: number; @@ -38,6 +39,7 @@ export class SimplePeer { private readonly sendLocalScreenSharingStreamCallback: StartScreenSharingCallback; private readonly stopLocalScreenSharingStreamCallback: StopScreenSharingCallback; private readonly peerConnectionListeners: Array<PeerConnectionListener> = new Array<PeerConnectionListener>(); + private readonly userId: number; constructor(private Connection: RoomConnection, private enableReporting: boolean, private myName: string) { // We need to go through this weird bound function pointer in order to be able to "free" this reference later. @@ -48,6 +50,7 @@ export class SimplePeer { mediaManager.onUpdateLocalStream(this.sendLocalVideoStreamCallback); mediaManager.onStartScreenSharing(this.sendLocalScreenSharingStreamCallback); mediaManager.onStopScreenSharing(this.stopLocalScreenSharingStreamCallback); + this.userId = Connection.getUserId(); this.initialise(); } @@ -91,8 +94,7 @@ export class SimplePeer { }); } - private receiveWebrtcStart(user: UserSimplePeerInterface) { - //this.WebRtcRoomId = data.roomId; + private receiveWebrtcStart(user: UserSimplePeerInterface): void { this.Users.push(user); // Note: the clients array contain the list of all clients (even the ones we are already connected to in case a user joints a group) // So we can receive a request we already had before. (which will abort at the first line of createPeerConnection) @@ -136,13 +138,13 @@ export class SimplePeer { mediaManager.removeActiveVideo("" + user.userId); - mediaManager.addActiveVideo("" + user.userId, name, connectionManager.getConnexionType === GameConnexionTypes.anonymous); + mediaManager.addActiveVideo(user, name); - const peer = new VideoPeer(user.userId, user.initiator ? user.initiator : false, this.Connection); + const peer = new VideoPeer(user, user.initiator ? user.initiator : false, this.Connection); //permit to send message mediaManager.addSendMessageCallback(user.userId,(message: string) => { - peer.write(new Buffer(JSON.stringify({type: MESSAGE_TYPE_MESSAGE, name: this.myName.toUpperCase(), message: message}))); + peer.write(new Buffer(JSON.stringify({type: MESSAGE_TYPE_MESSAGE, name: this.myName.toUpperCase(), userId: this.userId, message: message}))); }); peer.toClose = false; @@ -298,6 +300,7 @@ export class SimplePeer { } private receiveWebrtcScreenSharingSignal(data: WebRtcSignalReceivedMessageInterface) { + if (blackListManager.isBlackListed(data.userId)) return; console.log("receiveWebrtcScreenSharingSignal", data); try { //if offer type, create peer connection @@ -390,6 +393,7 @@ export class SimplePeer { } private sendLocalScreenSharingStreamToUser(userId: number): void { + if (blackListManager.isBlackListed(userId)) return; // If a connection already exists with user (because it is already sharing a screen with us... let's use this connection) if (this.PeerScreenSharingConnectionArray.has(userId)) { this.pushScreenSharingToRemoteUser(userId); diff --git a/front/src/WebRtc/VideoPeer.ts b/front/src/WebRtc/VideoPeer.ts index cc5ac62ef433ecc16fc5bde52deea99ab21eb03a..b2df80c2081911707791392d9933742bc6affa7d 100644 --- a/front/src/WebRtc/VideoPeer.ts +++ b/front/src/WebRtc/VideoPeer.ts @@ -2,19 +2,30 @@ import * as SimplePeerNamespace from "simple-peer"; import {mediaManager} from "./MediaManager"; import {STUN_SERVER, TURN_PASSWORD, TURN_SERVER, TURN_USER} from "../Enum/EnvironmentVariable"; import {RoomConnection} from "../Connexion/RoomConnection"; +import {blackListManager} from "./BlackListManager"; +import {Subscription} from "rxjs"; +import {UserSimplePeerInterface} from "./SimplePeer"; const Peer: SimplePeerNamespace.SimplePeer = require('simple-peer'); export const MESSAGE_TYPE_CONSTRAINT = 'constraint'; export const MESSAGE_TYPE_MESSAGE = 'message'; +export const MESSAGE_TYPE_BLOCKED = 'blocked'; +export const MESSAGE_TYPE_UNBLOCKED = 'unblocked'; /** * A peer connection used to transmit video / audio signals between 2 peers. */ export class VideoPeer extends Peer { public toClose: boolean = false; public _connected: boolean = false; - - constructor(public userId: number, initiator: boolean, private connection: RoomConnection) { + private remoteStream!: MediaStream; + private blocked: boolean = false; + private userId: number; + private userName: string; + private onBlockSubscribe: Subscription; + private onUnBlockSubscribe: Subscription; + + constructor(public user: UserSimplePeerInterface, initiator: boolean, private connection: RoomConnection) { super({ initiator: initiator ? initiator : false, reconnectTimer: 10000, @@ -31,35 +42,15 @@ export class VideoPeer extends Peer { ] } }); - - console.log('PEER SETUP ', { - initiator: initiator ? initiator : false, - reconnectTimer: 10000, - config: { - iceServers: [ - { - urls: STUN_SERVER.split(',') - }, - { - urls: TURN_SERVER.split(','), - username: TURN_USER, - credential: TURN_PASSWORD - }, - ] - } - }); + this.userId = user.userId; + this.userName = user.name || ''; //start listen signal for the peer connection this.on('signal', (data: unknown) => { this.sendWebrtcSignal(data); }); - this.on('stream', (stream: MediaStream) => { - this.stream(stream); - }); - - /*peer.on('track', (track: MediaStreamTrack, stream: MediaStream) => { - });*/ + this.on('stream', (stream: MediaStream) => this.stream(stream)); this.on('close', () => { this._connected = false; @@ -70,7 +61,7 @@ export class VideoPeer extends Peer { // eslint-disable-next-line @typescript-eslint/no-explicit-any this.on('error', (err: any) => { console.error(`error => ${this.userId} => ${err.code}`, err); - mediaManager.isError("" + userId); + mediaManager.isError("" + this.userId); }); this.on('connect', () => { @@ -81,8 +72,6 @@ export class VideoPeer extends Peer { this.on('data', (chunk: Buffer) => { const message = JSON.parse(chunk.toString('utf8')); - console.log("data", message); - if(message.type === MESSAGE_TYPE_CONSTRAINT) { if (message.audio) { mediaManager.enabledMicrophoneByUserId(this.userId); @@ -95,8 +84,19 @@ export class VideoPeer extends Peer { } else { mediaManager.disabledVideoByUserId(this.userId); } - } else if(message.type === 'message') { - mediaManager.addNewMessage(message.name, message.message); + } else if(message.type === MESSAGE_TYPE_MESSAGE) { + if (!blackListManager.isBlackListed(message.userId)) { + mediaManager.addNewMessage(message.name, message.message); + } + } else if(message.type === MESSAGE_TYPE_BLOCKED) { + //FIXME when A blacklists B, the output stream from A is muted in B's js client. This is insecure since B can manipulate the code to unmute A stream. + // Find a way to block A's output stream in A's js client + //However, the output stream stream B is correctly blocked in A client + this.blocked = true; + this.toggleRemoteStream(false); + } else if(message.type === MESSAGE_TYPE_UNBLOCKED) { + this.blocked = false; + this.toggleRemoteStream(true); } }); @@ -105,6 +105,31 @@ export class VideoPeer extends Peer { }); this.pushVideoToRemoteUser(); + this.onBlockSubscribe = blackListManager.onBlockStream.subscribe((userId) => { + if (userId === this.userId) { + this.toggleRemoteStream(false); + this.sendBlockMessage(true); + } + }); + this.onUnBlockSubscribe = blackListManager.onUnBlockStream.subscribe((userId) => { + if (userId === this.userId) { + this.toggleRemoteStream(true); + this.sendBlockMessage(false); + } + }); + + if (blackListManager.isBlackListed(this.userId)) { + this.sendBlockMessage(true) + } + } + + private sendBlockMessage(blocking: boolean) { + this.write(new Buffer(JSON.stringify({type: blocking ? MESSAGE_TYPE_BLOCKED : MESSAGE_TYPE_UNBLOCKED, name: this.userName.toUpperCase(), userId: this.userId, message: ''}))); + } + + private toggleRemoteStream(enable: boolean) { + this.remoteStream.getTracks().forEach(track => track.enabled = enable); + mediaManager.toggleBlockLogo(this.userId, !enable); } private sendWebrtcSignal(data: unknown) { @@ -120,13 +145,13 @@ export class VideoPeer extends Peer { */ private stream(stream: MediaStream) { try { + this.remoteStream = stream; + if (blackListManager.isBlackListed(this.userId) || this.blocked) { + this.toggleRemoteStream(false); + } mediaManager.addStreamRemoteVideo("" + this.userId, stream); }catch (err){ console.error(err); - //Force add streem video - /*setTimeout(() => { - this.stream(stream); - }, 500);*/ //todo: find a way to prevent infinite regression. } } @@ -139,6 +164,8 @@ export class VideoPeer extends Peer { if(!this.toClose){ return; } + this.onBlockSubscribe.unsubscribe(); + this.onUnBlockSubscribe.unsubscribe(); mediaManager.removeActiveVideo("" + this.userId); // FIXME: I don't understand why "Closing connection with" message is displayed TWICE before "Nb users in peerConnectionArray" // I do understand the method closeConnection is called twice, but I don't understand how they manage to run in parallel. diff --git a/front/yarn.lock b/front/yarn.lock index fd34cd6d82ea497834b8a6dfdbcba5bccbfd6db5..0b85ad88995e0ed2db3ebce4c3aa6dc9510b384d 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -4094,7 +4094,7 @@ run-queue@^1.0.0, run-queue@^1.0.3: dependencies: aproba "^1.1.1" -rxjs@^6.6.0: +rxjs@^6.6.0, rxjs@^6.6.3: version "6.6.3" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552" integrity sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ==