diff --git a/back/src/Controller/IoSocketController.ts b/back/src/Controller/IoSocketController.ts index 543b2f54..b2c9ea7e 100644 --- a/back/src/Controller/IoSocketController.ts +++ b/back/src/Controller/IoSocketController.ts @@ -162,11 +162,13 @@ export class IoSocketController { let memberTags: string[] = []; let memberTextures: CharacterTexture[] = []; + const room = await socketManager.getOrCreateRoom(roomId); - if (room.isFull()) { - res.writeStatus("503").end('Too many users'); - return; - } + //TODO http return status + /*if (room.isFull) { + throw new Error('Room is full'); + }*/ + try { const userData = await adminApi.fetchMemberDataByUuid(userUuid); //console.log('USERDATA', userData) @@ -234,30 +236,46 @@ export class IoSocketController { }, /* Handlers */ open: (ws) => { - // Let's join the room - const client = this.initClient(ws); //todo: into the upgrade instead? - socketManager.handleJoinRoom(client); - resetPing(client); + (async () => { + // Let's join the room + const client = this.initClient(ws); //todo: into the upgrade instead? - //get data information and shwo messages - adminApi.fetchMemberDataByUuid(client.userUuid).then((res: FetchMemberDataByUuidResponse) => { - if (!res.messages) { - return; + const room = socketManager.getRoomById(client.roomId); + if (room && room.isFull) { + socketManager.emitCloseMessage(client, 302); + }else { + socketManager.handleJoinRoom(client); + resetPing(client); } - res.messages.forEach((c: unknown) => { - const messageToSend = c as { type: string, message: string }; - socketManager.emitSendUserMessage({ - userUuid: client.userUuid, - type: messageToSend.type, - message: messageToSend.message - }) - }); - }).catch((err) => { - console.error('fetchMemberDataByUuid => err', err); - }); + + //get data information and shwo messages + try { + const res: FetchMemberDataByUuidResponse = await adminApi.fetchMemberDataByUuid(client.userUuid); + if (!res.messages) { + return; + } + res.messages.forEach((c: unknown) => { + const messageToSend = c as { type: string, message: string }; + socketManager.emitSendUserMessage({ + userUuid: client.userUuid, + type: messageToSend.type, + message: messageToSend.message + }) + }); + } catch (err) { + console.error('fetchMemberDataByUuid => err', err); + } + })(); }, message: (ws, arrayBuffer, isBinary): void => { + const client = ws as ExSocketInterface; + + const room = socketManager.getRoomById(client.roomId); + if (room && room.isFull) { + return; + } + const message = ClientToServerMessage.deserializeBinary(new Uint8Array(arrayBuffer)); if (message.hasViewportmessage()) { @@ -281,7 +299,6 @@ export class IoSocketController { } else if (message.hasQueryjitsijwtmessage()){ socketManager.handleQueryJitsiJwtMessage(client, message.getQueryjitsijwtmessage() as QueryJitsiJwtMessage); } - /* Ok is false if backpressure was built up, wait for drain */ //let ok = ws.send(message, isBinary); }, diff --git a/back/src/Controller/MapController.ts b/back/src/Controller/MapController.ts index e667f005..d3c42a32 100644 --- a/back/src/Controller/MapController.ts +++ b/back/src/Controller/MapController.ts @@ -85,7 +85,7 @@ export class MapController extends BaseController{ res.writeStatus('404').end('No room found'); return; } - res.writeStatus("200").end(world.isFull() ? '1':'0'); + res.writeStatus("200").end(world.isFull ? '1':'0'); }); } } diff --git a/back/src/Model/GameRoom.ts b/back/src/Model/GameRoom.ts index 718c5832..b8196c3f 100644 --- a/back/src/Model/GameRoom.ts +++ b/back/src/Model/GameRoom.ts @@ -10,7 +10,7 @@ import {ViewportInterface} from "_Model/Websocket/ViewportMessage"; import {Movable} from "_Model/Movable"; import {extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous} from "./RoomIdentifier"; import {arrayIntersect} from "../Services/ArrayHelper"; -import {MAX_USERS_PER_ROOM} from "_Enum/EnvironmentVariable"; +import {MAX_USERS_PER_ROOM} from "../Enum/EnvironmentVariable"; export type ConnectCallback = (user: User, group: Group) => void; export type DisconnectCallback = (user: User, group: Group) => void; @@ -180,7 +180,7 @@ export class GameRoom { } } - isFull() : boolean { + get isFull() : boolean { return this.getUsers().size > MAX_USERS_PER_ROOM; } diff --git a/back/src/Model/Group.ts b/back/src/Model/Group.ts index 9afa9764..d3b042a6 100644 --- a/back/src/Model/Group.ts +++ b/back/src/Model/Group.ts @@ -117,4 +117,8 @@ export class Group implements Movable { this.leave(user); } } + + get getSize(){ + return this.users.size; + } } diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index 50bd149e..1ec9ab20 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -24,7 +24,8 @@ import { QueryJitsiJwtMessage, SendJitsiJwtMessage, CharacterLayerMessage, - SendUserMessage + SendUserMessage, + CloseMessage } from "../Messages/generated/messages_pb"; import {PointInterface} from "../Model/Websocket/PointInterface"; import {User} from "../Model/User"; @@ -130,6 +131,7 @@ export class SocketManager { userJoinedMessage.setPosition(ProtobufUtils.toPositionMessage(player.position)); roomJoinedMessage.addUser(userJoinedMessage); + roomJoinedMessage.setTagList(client.tags); } else if (thing instanceof Group) { const groupUpdateMessage = new GroupUpdateMessage(); groupUpdateMessage.setGroupid(thing.getId()); @@ -406,6 +408,10 @@ export class SocketManager { return Promise.resolve(world) } + getRoomById(roomId: string) { + return this.Worlds.get(roomId); + } + private joinRoom(client : ExSocketInterface, position: PointInterface): GameRoom { const roomId = client.roomId; @@ -493,6 +499,7 @@ export class SocketManager { const groupUpdateMessage = new GroupUpdateMessage(); groupUpdateMessage.setGroupid(group.getId()); groupUpdateMessage.setPosition(pointMessage); + groupUpdateMessage.setGroupsize(group.getSize); const subMessage = new SubMessage(); subMessage.setGroupupdatemessage(groupUpdateMessage); @@ -692,6 +699,19 @@ export class SocketManager { return socket; } + public emitCloseMessage(socket: ExSocketInterface, status: number): ExSocketInterface { + const closeMessage = new CloseMessage(); + closeMessage.setStatus(status); + + const serverToClientMessage = new ServerToClientMessage(); + serverToClientMessage.setClosemessage(closeMessage); + + if (!socket.disconnecting) { + socket.send(serverToClientMessage.serializeBinary().buffer, true); + } + return socket; + } + /** * Merges the characterLayers received from the front (as an array of string) with the custom textures from the back. */ diff --git a/front/src/Connexion/ConnectionManager.ts b/front/src/Connexion/ConnectionManager.ts index f10f9788..7f3c1c41 100644 --- a/front/src/Connexion/ConnectionManager.ts +++ b/front/src/Connexion/ConnectionManager.ts @@ -81,10 +81,12 @@ class ConnectionManager { await Axios.get(`${API_URL}/verify`, {params: {token}}); } - private async anonymousLogin(): Promise { + public async anonymousLogin(isBenchmark: boolean = false): Promise { const data = await Axios.post(`${API_URL}/anonymLogin`).then(res => res.data); this.localUser = new LocalUser(data.userUuid, data.authToken, []); - localUserStore.saveUser(this.localUser); + if (!isBenchmark) { // In benchmark, we don't have a local storage. + localUserStore.saveUser(this.localUser); + } } public initBenchmark(): void { @@ -95,8 +97,7 @@ class ConnectionManager { return new Promise((resolve, reject) => { const connection = new RoomConnection(this.localUser.jwtToken, roomId, name, characterLayers, position, viewport); connection.onConnectError((error: object) => { - console.log(error); - if (false) { //todo: how to check error type? + if (error) { //todo: how to check error type? reject(connexionErrorTypes.tooManyUsers); } else { reject(connexionErrorTypes.serverError); diff --git a/front/src/Connexion/ConnexionModels.ts b/front/src/Connexion/ConnexionModels.ts index d1d80aff..19fec57e 100644 --- a/front/src/Connexion/ConnexionModels.ts +++ b/front/src/Connexion/ConnexionModels.ts @@ -30,6 +30,8 @@ export enum EventMessage{ TELEPORT = "teleport", USER_MESSAGE = "user-message", START_JITSI_ROOM = "start-jitsi-room", + + CLOSE_MESSAGE = "close-message", } export interface PointInterface { @@ -73,7 +75,8 @@ export interface PositionInterface { export interface GroupCreatedUpdatedMessageInterface { position: PositionInterface, - groupId: number + groupId: number, + groupSize: number } export interface WebRtcStartMessageInterface { diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index 7c57558a..5f2eff05 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -26,7 +26,8 @@ import { QueryJitsiJwtMessage, SendJitsiJwtMessage, CharacterLayerMessage, - SendUserMessage + SendUserMessage, + CloseMessage } from "../Messages/generated/messages_pb" import {UserSimplePeerInterface} from "../WebRtc/SimplePeer"; @@ -157,10 +158,11 @@ export class RoomConnection implements RoomConnection { this.dispatch(EventMessage.START_JITSI_ROOM, message.getSendjitsijwtmessage()); } else if (message.hasSendusermessage()) { this.dispatch(EventMessage.USER_MESSAGE, message.getSendusermessage()); + } else if (message.hasClosemessage()) { + this.dispatch(EventMessage.CLOSE_MESSAGE, message.getClosemessage()); } else { throw new Error('Unknown message received'); } - } } @@ -335,7 +337,8 @@ export class RoomConnection implements RoomConnection { return { groupId: message.getGroupid(), - position: position.toObject() + position: position.toObject(), + groupSize: message.getGroupsize() } } @@ -540,6 +543,12 @@ export class RoomConnection implements RoomConnection { }); } + public onCloseMessage(callback: (status: number) => void): void { + return this.onMessage(EventMessage.CLOSE_MESSAGE, (message: CloseMessage) => { + callback(message.getStatus()); + }); + } + public hasTag(tag: string): boolean { return this.tags.includes(tag); } diff --git a/front/src/Enum/EnvironmentVariable.ts b/front/src/Enum/EnvironmentVariable.ts index 16918e06..2e963e5e 100644 --- a/front/src/Enum/EnvironmentVariable.ts +++ b/front/src/Enum/EnvironmentVariable.ts @@ -1,5 +1,5 @@ const DEBUG_MODE: boolean = process.env.DEBUG_MODE == "true"; -const API_URL = (typeof(window) !== 'undefined' ? window.location.protocol : 'http:') + '//' + (process.env.API_URL || "api.workadventure.localhost"); +const API_URL = (process.env.API_PROTOCOL || (typeof(window) !== 'undefined' ? window.location.protocol : 'http:')) + '//' + (process.env.API_URL || "api.workadventure.localhost"); const TURN_SERVER: string = process.env.TURN_SERVER || "turn:numb.viagenie.ca"; const TURN_USER: string = process.env.TURN_USER || 'g.parant@thecodingmachine.com'; const TURN_PASSWORD: string = process.env.TURN_PASSWORD || 'itcugcOHxle9Acqi$'; diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index bf9a3b9f..7c9d5fd5 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -56,6 +56,7 @@ import {ConsoleGlobalMessageManager} from "../../Administration/ConsoleGlobalMes import {ResizableScene} from "../Login/ResizableScene"; import {Room} from "../../Connexion/Room"; import {MessageUI} from "../../Logger/MessageUI"; +import {WaitScene} from "../Reconnecting/WaitScene"; export enum Textures { @@ -110,6 +111,7 @@ export class GameScene extends ResizableScene implements CenterListener { startX!: number; startY!: number; circleTexture!: CanvasTexture; + circleRedTexture!: CanvasTexture; pendingEvents: Queue = new Queue(); private initPosition: PositionInterface|null = null; private playersPositionInterpolator = new PlayersPositionInterpolator(); @@ -411,11 +413,18 @@ export class GameScene extends ResizableScene implements CenterListener { this.initCamera(); // Let's generate the circle for the group delimiter - const circleElement = Object.values(this.textures.list).find((object: Texture) => object.key === 'circleSprite'); + let circleElement = Object.values(this.textures.list).find((object: Texture) => object.key === 'circleSprite-white'); if (circleElement) { - this.textures.remove('circleSprite'); + this.textures.remove('circleSprite-white'); } - this.circleTexture = this.textures.createCanvas('circleSprite', 96, 96); + + circleElement = Object.values(this.textures.list).find((object: Texture) => object.key === 'circleSprite-red'); + if (circleElement) { + this.textures.remove('circleSprite-red'); + } + + //create white circle canvas use to create sprite + this.circleTexture = this.textures.createCanvas('circleSprite-white', 96, 96); const context = this.circleTexture.context; context.beginPath(); context.arc(48, 48, 48, 0, 2 * Math.PI, false); @@ -424,6 +433,16 @@ export class GameScene extends ResizableScene implements CenterListener { context.stroke(); this.circleTexture.refresh(); + //create red circle canvas use to create sprite + this.circleRedTexture = this.textures.createCanvas('circleSprite-red', 96, 96); + const contextRed = this.circleRedTexture.context; + contextRed.beginPath(); + contextRed.arc(48, 48, 48, 0, 2 * Math.PI, false); + // context.lineWidth = 5; + contextRed.strokeStyle = '#ff0000'; + contextRed.stroke(); + this.circleRedTexture.refresh(); + // Let's pause the scene if the connection is not established yet if (this.connection === undefined) { // Let's wait 0.5 seconds before printing the "connecting" screen to avoid blinking @@ -600,6 +619,36 @@ export class GameScene extends ResizableScene implements CenterListener { this.startJitsi(room, jwt); }); + connection.onCloseMessage((status: number) => { + console.log(`close message status : ${status}`); + + //TODO show wait room + this.connection.closeConnection(); + this.simplePeer.unregister(); + connection.closeConnection(); + + const waitGameSceneKey = 'somekey' + Math.round(Math.random() * 10000); + //show wait scene + setTimeout(() => { + const game: Phaser.Scene = new WaitScene(waitGameSceneKey, status); + this.scene.add(waitGameSceneKey, game, true, { + initPosition: { + x: this.CurrentPlayer.x, + y: this.CurrentPlayer.y + } + }); + this.scene.stop(this.scene.key); + this.scene.start(waitGameSceneKey); + }, 500); + + //trying to reload map + setTimeout(() => { + this.scene.stop(waitGameSceneKey); + this.scene.remove(waitGameSceneKey); + this.scene.start(this.scene.key); + }, 30000); + }); + // When connection is performed, let's connect SimplePeer this.simplePeer = new SimplePeer(this.connection, !this.room.isPublic); this.GlobalMessageManager = new GlobalMessageManager(this.connection); @@ -1135,18 +1184,28 @@ export class GameScene extends ResizableScene implements CenterListener { private doShareGroupPosition(groupPositionMessage: GroupCreatedUpdatedMessageInterface) { const groupId = groupPositionMessage.groupId; + const groupSize = groupPositionMessage.groupSize; const group = this.groups.get(groupId); if (group !== undefined) { group.setPosition(Math.round(groupPositionMessage.position.x), Math.round(groupPositionMessage.position.y)); } else { // TODO: circle radius should not be hard stored + const positionX = 48; + const positionY = 48; + + console.log('doShareGroupPosition', groupSize); + let texture = 'circleSprite-red'; + if(groupSize < 4){ + texture = 'circleSprite-white'; + } const sprite = new Sprite( this, Math.round(groupPositionMessage.position.x), Math.round(groupPositionMessage.position.y), - 'circleSprite'); - sprite.setDisplayOrigin(48, 48); + texture + ); + sprite.setDisplayOrigin(positionX, positionY); this.add.existing(sprite); this.groups.set(groupId, sprite); } @@ -1278,6 +1337,10 @@ export class GameScene extends ResizableScene implements CenterListener { private loadSpritesheet(name: string, url: string): Promise { return new Promise(((resolve, reject) => { + if (this.textures.exists(name)) { + resolve(); + return; + } this.load.spritesheet( name, url, diff --git a/front/src/Phaser/Reconnecting/WaitScene.ts b/front/src/Phaser/Reconnecting/WaitScene.ts new file mode 100644 index 00000000..0d4df895 --- /dev/null +++ b/front/src/Phaser/Reconnecting/WaitScene.ts @@ -0,0 +1,70 @@ +import {TextField} from "../Components/TextField"; +import Image = Phaser.GameObjects.Image; + +enum ReconnectingTextures { + icon = "icon", + mainFont = "main_font" +} + +export class WaitScene extends Phaser.Scene { + private reconnectingField!: TextField; + private logo!: Image; + private text: string = ''; + + constructor(key: string, private readonly status: number) { + super({ + key: key + }); + this.initialiseText(); + } + + initialiseText() { + this.text = `${this.status}` + '\n' + '\n'; + switch (this.status) { + case 302: + this.text += 'Aie ! Work Adventure est victime de son succes, ' + + '\n' + + '\n' + + 'le nombre maximum de joueurs a ete atteint !' + + '\n' + + '\n' + + `Reconnexion dans 30 secondes ...`; + break; + } + } + + preload() { + this.load.image(ReconnectingTextures.icon, "resources/logos/tcm_full.png"); + // Note: arcade.png from the Phaser 3 examples at: https://github.com/photonstorm/phaser3-examples/tree/master/public/assets/fonts/bitmap + this.load.bitmapFont(ReconnectingTextures.mainFont, 'resources/fonts/arcade.png', 'resources/fonts/arcade.xml'); + this.load.spritesheet( + 'cat', + 'resources/characters/pipoya/Cat 01-1.png', + {frameWidth: 32, frameHeight: 32} + ); + } + + create() { + this.logo = new Image(this, this.game.renderer.width - 30, this.game.renderer.height - 20, ReconnectingTextures.icon); + this.add.existing(this.logo); + + this.reconnectingField = new TextField( + this, + this.game.renderer.width / 2, + this.game.renderer.height / 2, + this.text); + + const cat = this.physics.add.sprite( + this.game.renderer.width / 2, + this.game.renderer.height / 2 - 70, + 'cat'); + + this.anims.create({ + key: 'right', + frames: this.anims.generateFrameNumbers('cat', {start: 6, end: 8}), + frameRate: 10, + repeat: -1 + }); + cat.play('right'); + } +} diff --git a/front/src/WebRtc/ScreenSharingPeer.ts b/front/src/WebRtc/ScreenSharingPeer.ts index 3cbc4154..3efee1c3 100644 --- a/front/src/WebRtc/ScreenSharingPeer.ts +++ b/front/src/WebRtc/ScreenSharingPeer.ts @@ -13,6 +13,8 @@ export class ScreenSharingPeer extends Peer { * Whether this connection is currently receiving a video stream from a remote user. */ private isReceivingStream:boolean = false; + public toClose: boolean = false; + public _connected: boolean = false; constructor(private userId: number, initiator: boolean, private connection: RoomConnection) { super({ @@ -42,6 +44,8 @@ export class ScreenSharingPeer extends Peer { }); this.on('close', () => { + this._connected = false; + this.toClose = true; this.destroy(); }); @@ -62,11 +66,16 @@ export class ScreenSharingPeer extends Peer { }); this.on('connect', () => { + this._connected = true; // FIXME: we need to put the loader on the screen sharing connection mediaManager.isConnected("" + this.userId); console.info(`connect => ${this.userId}`); }); + this.once('finish', () => { + this._onFinish(); + }); + this.pushScreenSharingToRemoteUser(); } @@ -100,6 +109,10 @@ export class ScreenSharingPeer extends Peer { public destroy(error?: Error): void { try { + this._connected = false + if(!this.toClose){ + return; + } mediaManager.removeActiveScreenSharingVideo("" + 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. @@ -111,6 +124,18 @@ export class ScreenSharingPeer extends Peer { } } + _onFinish () { + if (this.destroyed) return + const destroySoon = () => { + this.destroy(); + } + if (this._connected) { + destroySoon(); + } else { + this.once('connect', destroySoon); + } + } + private pushScreenSharingToRemoteUser() { const localScreenCapture: MediaStream | null = mediaManager.localScreenCapture; if(!localScreenCapture){ diff --git a/front/src/WebRtc/SimplePeer.ts b/front/src/WebRtc/SimplePeer.ts index 4039e10b..eb2ee42b 100644 --- a/front/src/WebRtc/SimplePeer.ts +++ b/front/src/WebRtc/SimplePeer.ts @@ -108,44 +108,30 @@ export class SimplePeer { this.createPeerConnection(user); } - /** - * server has two people connected, start the meet - */ - private startWebRtc() { - console.warn('startWebRtc startWebRtc'); - this.Users.forEach((user: UserSimplePeerInterface) => { - //if it's not an initiator, peer connection will be created when gamer will receive offer signal - if(!user.initiator){ - return; - } - this.createPeerConnection(user); - }); - } - /** * create peer connection to bind users */ - private createPeerConnection(user : UserSimplePeerInterface) : VideoPeer | null{ + private createPeerConnection(user : UserSimplePeerInterface) : VideoPeer | null { const peerConnection = this.PeerConnectionArray.get(user.userId) - if(peerConnection){ - if(peerConnection.destroyed){ + if (peerConnection) { + if (peerConnection.destroyed) { peerConnection.toClose = true; peerConnection.destroy(); const peerConnexionDeleted = this.PeerConnectionArray.delete(user.userId); - if(!peerConnexionDeleted){ + if (!peerConnexionDeleted) { throw 'Error to delete peer connection'; } this.createPeerConnection(user); - }else { + } else { peerConnection.toClose = false; } return null; } let name = user.name; - if(!name){ + if (!name) { const userSearch = this.Users.find((userSearch: UserSimplePeerInterface) => userSearch.userId === user.userId); - if(userSearch) { + if (userSearch) { name = userSearch.name; } } @@ -153,8 +139,8 @@ export class SimplePeer { mediaManager.removeActiveVideo("" + user.userId); const reportCallback = this.enableReporting ? (comment: string) => { - this.reportUser(user.userId, comment); - }: undefined; + this.reportUser(user.userId, comment); + } : undefined; mediaManager.addActiveVideo("" + user.userId, reportCallback, name); @@ -179,9 +165,19 @@ export class SimplePeer { * create peer connection to bind users */ private createPeerScreenSharingConnection(user : UserSimplePeerInterface) : ScreenSharingPeer | null{ - if( - this.PeerScreenSharingConnectionArray.has(user.userId) - ){ + const peerConnection = this.PeerScreenSharingConnectionArray.get(user.userId); + if(peerConnection){ + if(peerConnection.destroyed){ + peerConnection.toClose = true; + peerConnection.destroy(); + const peerConnexionDeleted = this.PeerScreenSharingConnectionArray.delete(user.userId); + if(!peerConnexionDeleted){ + throw 'Error to delete peer connection'; + } + this.createPeerConnection(user); + }else { + peerConnection.toClose = false; + } return null; } diff --git a/front/src/WebRtc/VideoPeer.ts b/front/src/WebRtc/VideoPeer.ts index aa8a5d17..fb34f29e 100644 --- a/front/src/WebRtc/VideoPeer.ts +++ b/front/src/WebRtc/VideoPeer.ts @@ -39,13 +39,13 @@ export class VideoPeer extends Peer { urls: 'stun:stun.l.google.com:19302' }, { - urls: TURN_SERVER, + urls: TURN_SERVER.split(','), username: TURN_USER, credential: TURN_PASSWORD }, ] } - }) + }); //start listen signal for the peer connection this.on('signal', (data: unknown) => { diff --git a/messages/messages.proto b/messages/messages.proto index 63fb6059..1a2bc071 100644 --- a/messages/messages.proto +++ b/messages/messages.proto @@ -125,6 +125,7 @@ message BatchMessage { message GroupUpdateMessage { int32 groupId = 1; PointMessage position = 2; + int32 groupSize = 3; } message GroupDeleteMessage { @@ -188,6 +189,10 @@ message SendUserMessage{ string message = 2; } +message CloseMessage{ + int32 status = 1; +} + message ServerToClientMessage { oneof message { BatchMessage batchMessage = 1; @@ -202,5 +207,6 @@ message ServerToClientMessage { TeleportMessageMessage teleportMessageMessage = 10; SendJitsiJwtMessage sendJitsiJwtMessage = 11; SendUserMessage sendUserMessage = 12; + CloseMessage closeMessage = 13; } }