diff --git a/CHANGELOG.md b/CHANGELOG.md index 33658d2d..50c09ca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ - Use `WA.onInit(): Promise` to wait for scripting API initialization - Use `WA.room.showLayer(): void` to show a layer - Use `WA.room.hideLayer(): void` to hide a layer - - Use `WA.room.setProperty() : void` to add or change existing property of a layer + - Use `WA.room.setProperty() : void` to add, delete or change existing property of a layer - Use `WA.player.onPlayerMove(): void` to track the movement of the current player - Use `WA.player.id: string|undefined` to get the ID of the current player - Use `WA.player.name: string` to get the name of the current player @@ -20,13 +20,23 @@ - Use `WA.room.mapURL: string` to get the URL of the map - Use `WA.room.mapURL: string` to get the URL of the map - Use `WA.room.getMap(): Promise` to get the JSON map file - - Use `WA.room.setTiles(): void` to change an array of tiles + - Use `WA.room.setTiles(): void` to add, delete or change an array of tiles - Use `WA.ui.registerMenuCommand(): void` to add a custom menu - Use `WA.state.loadVariable(key: string): unknown` to retrieve a variable - Use `WA.state.saveVariable(key: string, value: unknown): Promise` to set a variable (across the room, for all users) - Use `WA.state.onVariableChange(key: string): Observable` to track a variable - Use `WA.state.[any variable]: unknown` to access directly any variable (this is a shortcut to using `WA.state.loadVariable` and `WA.state.saveVariable`) - Users blocking now relies on UUID rather than ID. A blocked user that leaves a room and comes back will stay blocked. +- The text chat was redesigned to be prettier and to use more features : + - The chat is now persistent bewteen discussions and always accesible + - The chat now tracks incoming and outcoming users in your conversation + - The chat allows your to see the visit card of users + - You can close the chat window with the escape key +- Added a 'Enable notifications' button in the menu. +- The exchange format between Pusher and Admin servers has changed. If you have your own implementation of an admin server, these endpoints signatures have changed: + - `/api/map`: now accepts a complete room URL instead of organization/world/room slugs + - `/api/ban`: new endpoint to report users + - as a side effect, the "routing" is now completely stored on the admin side, so by implementing your own admin server, you can develop completely custom routing ## Version 1.4.3 - 1.4.4 - 1.4.5 diff --git a/back/src/Model/GameRoom.ts b/back/src/Model/GameRoom.ts index ffe3563f..f26fd459 100644 --- a/back/src/Model/GameRoom.ts +++ b/back/src/Model/GameRoom.ts @@ -5,27 +5,21 @@ import { PositionInterface } from "_Model/PositionInterface"; import { EmoteCallback, EntersCallback, LeavesCallback, MovesCallback } from "_Model/Zone"; import { PositionNotifier } from "./PositionNotifier"; import { Movable } from "_Model/Movable"; -import { extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous } from "./RoomIdentifier"; -import { arrayIntersect } from "../Services/ArrayHelper"; import { BatchToPusherMessage, BatchToPusherRoomMessage, EmoteEventMessage, - JoinRoomMessage, SubToPusherRoomMessage, VariableMessage + JoinRoomMessage, + SubToPusherRoomMessage, + VariableMessage, } from "../Messages/generated/messages_pb"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; -import {RoomSocket, ZoneSocket} from "src/RoomManager"; +import { RoomSocket, ZoneSocket } from "src/RoomManager"; import { Admin } from "../Model/Admin"; export type ConnectCallback = (user: User, group: Group) => void; export type DisconnectCallback = (user: User, group: Group) => void; -export enum GameRoomPolicyTypes { - ANONYMOUS_POLICY = 1, - MEMBERS_ONLY_POLICY, - USE_TAGS_POLICY, -} - export class GameRoom { private readonly minDistance: number; private readonly groupRadius: number; @@ -43,17 +37,14 @@ export class GameRoom { public readonly variables = new Map(); private readonly positionNotifier: PositionNotifier; - public readonly roomId: string; - public readonly roomSlug: string; - public readonly worldSlug: string = ""; - public readonly organizationSlug: string = ""; + public readonly roomUrl: string; private versionNumber: number = 1; private nextUserId: number = 1; private roomListeners: Set = new Set(); constructor( - roomId: string, + roomUrl: string, connectCallback: ConnectCallback, disconnectCallback: DisconnectCallback, minDistance: number, @@ -63,16 +54,7 @@ export class GameRoom { onLeaves: LeavesCallback, onEmote: EmoteCallback ) { - this.roomId = roomId; - - if (isRoomAnonymous(roomId)) { - this.roomSlug = extractRoomSlugPublicRoomId(this.roomId); - } else { - const { organizationSlug, worldSlug, roomSlug } = extractDataFromPrivateRoomId(this.roomId); - this.roomSlug = roomSlug; - this.organizationSlug = organizationSlug; - this.worldSlug = worldSlug; - } + this.roomUrl = roomUrl; this.users = new Map(); this.usersByUuid = new Map(); @@ -191,7 +173,7 @@ export class GameRoom { } else { const closestUser: User = closestItem; const group: Group = new Group( - this.roomId, + this.roomUrl, [user, closestUser], this.connectCallback, this.disconnectCallback, diff --git a/back/src/Model/RoomIdentifier.ts b/back/src/Model/RoomIdentifier.ts deleted file mode 100644 index d1de8800..00000000 --- a/back/src/Model/RoomIdentifier.ts +++ /dev/null @@ -1,30 +0,0 @@ -//helper functions to parse room IDs - -export const isRoomAnonymous = (roomID: string): boolean => { - if (roomID.startsWith("_/")) { - return true; - } else if (roomID.startsWith("@/")) { - return false; - } else { - throw new Error("Incorrect room ID: " + roomID); - } -}; - -export const extractRoomSlugPublicRoomId = (roomId: string): string => { - const idParts = roomId.split("/"); - if (idParts.length < 3) throw new Error("Incorrect roomId: " + roomId); - return idParts.slice(2).join("/"); -}; -export interface extractDataFromPrivateRoomIdResponse { - organizationSlug: string; - worldSlug: string; - roomSlug: string; -} -export const extractDataFromPrivateRoomId = (roomId: string): extractDataFromPrivateRoomIdResponse => { - const idParts = roomId.split("/"); - if (idParts.length < 4) throw new Error("Incorrect roomId: " + roomId); - const organizationSlug = idParts[1]; - const worldSlug = idParts[2]; - const roomSlug = idParts[3]; - return { organizationSlug, worldSlug, roomSlug }; -}; diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index 824c8bfb..82efc71b 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -30,7 +30,9 @@ import { BanUserMessage, RefreshRoomMessage, EmotePromptMessage, - VariableMessage, BatchToPusherRoomMessage, SubToPusherRoomMessage, + VariableMessage, + BatchToPusherRoomMessage, + SubToPusherRoomMessage, } from "../Messages/generated/messages_pb"; import { User, UserSocket } from "../Model/User"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; @@ -49,7 +51,7 @@ import Jwt from "jsonwebtoken"; import { JITSI_URL } from "../Enum/EnvironmentVariable"; import { clientEventsEmitter } from "./ClientEventsEmitter"; import { gaugeManager } from "./GaugeManager"; -import {RoomSocket, ZoneSocket} from "../RoomManager"; +import { RoomSocket, ZoneSocket } from "../RoomManager"; import { Zone } from "_Model/Zone"; import Debug from "debug"; import { Admin } from "_Model/Admin"; @@ -270,12 +272,12 @@ export class SocketManager { //user leave previous world room.leave(user); if (room.isEmpty()) { - this.rooms.delete(room.roomId); + this.rooms.delete(room.roomUrl); gaugeManager.decNbRoomGauge(); - debug('Room is empty. Deleting room "%s"', room.roomId); + debug('Room is empty. Deleting room "%s"', room.roomUrl); } } finally { - clientEventsEmitter.emitClientLeave(user.uuid, room.roomId); + clientEventsEmitter.emitClientLeave(user.uuid, room.roomUrl); console.log("A user left"); } } @@ -467,10 +469,7 @@ export class SocketManager { const serverToClientMessage1 = new ServerToClientMessage(); serverToClientMessage1.setWebrtcstartmessage(webrtcStartMessage1); - //if (!user.socket.disconnecting) { user.socket.write(serverToClientMessage1); - //console.log('Sending webrtcstart initiator to '+user.socket.userId) - //} const webrtcStartMessage2 = new WebRtcStartMessage(); webrtcStartMessage2.setUserid(user.id); @@ -484,10 +483,7 @@ export class SocketManager { const serverToClientMessage2 = new ServerToClientMessage(); serverToClientMessage2.setWebrtcstartmessage(webrtcStartMessage2); - //if (!otherUser.socket.disconnecting) { otherUser.socket.write(serverToClientMessage2); - //console.log('Sending webrtcstart to '+otherUser.socket.userId) - //} } } @@ -731,9 +727,9 @@ export class SocketManager { public leaveAdminRoom(room: GameRoom, admin: Admin) { room.adminLeave(admin); if (room.isEmpty()) { - this.rooms.delete(room.roomId); + this.rooms.delete(room.roomUrl); gaugeManager.decNbRoomGauge(); - debug('Room is empty. Deleting room "%s"', room.roomId); + debug('Room is empty. Deleting room "%s"', room.roomUrl); } } @@ -875,7 +871,6 @@ export class SocketManager { emoteEventMessage.setActoruserid(user.id); room.emitEmoteEvent(user, emoteEventMessage); } - } export const socketManager = new SocketManager(); diff --git a/back/tests/RoomIdentifierTest.ts b/back/tests/RoomIdentifierTest.ts deleted file mode 100644 index c3817ff7..00000000 --- a/back/tests/RoomIdentifierTest.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous} from "../src/Model/RoomIdentifier"; - -describe("RoomIdentifier", () => { - it("should flag public id as anonymous", () => { - expect(isRoomAnonymous('_/global/test')).toBe(true); - }); - it("should flag public id as not anonymous", () => { - expect(isRoomAnonymous('@/afup/afup2020/1floor')).toBe(false); - }); - it("should extract roomSlug from public ID", () => { - expect(extractRoomSlugPublicRoomId('_/global/npeguin/test.json')).toBe('npeguin/test.json'); - }); - it("should extract correct from private ID", () => { - const {organizationSlug, worldSlug, roomSlug} = extractDataFromPrivateRoomId('@/afup/afup2020/1floor'); - expect(organizationSlug).toBe('afup'); - expect(worldSlug).toBe('afup2020'); - expect(roomSlug).toBe('1floor'); - }); -}) \ No newline at end of file diff --git a/docs/maps/api-room.md b/docs/maps/api-room.md index 9f911b35..69d40df9 100644 --- a/docs/maps/api-room.md +++ b/docs/maps/api-room.md @@ -54,6 +54,7 @@ WA.room.showLayer(layerName : string): void WA.room.hideLayer(layerName : string) : void ``` These 2 methods can be used to show and hide a layer. +if `layerName` is the name of a group layer, show/hide all the layer in that group layer. Example : ```javascript @@ -70,6 +71,9 @@ WA.room.setProperty(layerName : string, propertyName : string, propertyValue : s Set the value of the `propertyName` property of the layer `layerName` at `propertyValue`. If the property doesn't exist, create the property `propertyName` and set the value of the property at `propertyValue`. +Note : +To unset a property from a layer, use `setProperty` with `propertyValue` set to `undefined`. + Example : ```javascript WA.room.setProperty('wikiLayer', 'openWebsite', 'https://www.wikipedia.org/'); @@ -148,6 +152,7 @@ If `tile` is a string, it's not the id of the tile but the value of the property **Important !** : If you use `tile` as a number, be sure to add the `firstgid` of the tileset of the tile that you want to the id of the tile in Tiled Editor. +Note: If you want to unset a tile, use `setTiles` with `tile` set to `null`. Example : ```javascript diff --git a/front/dist/index.tmpl.html b/front/dist/index.tmpl.html index aa63229f..30ea8353 100644 --- a/front/dist/index.tmpl.html +++ b/front/dist/index.tmpl.html @@ -37,8 +37,7 @@
-
-
+
diff --git a/front/dist/resources/html/gameMenu.html b/front/dist/resources/html/gameMenu.html index 26be2a1c..bb0a6e1e 100644 --- a/front/dist/resources/html/gameMenu.html +++ b/front/dist/resources/html/gameMenu.html @@ -57,6 +57,9 @@
+
+ +
diff --git a/front/dist/resources/service-worker.js b/front/dist/resources/service-worker.js new file mode 100644 index 00000000..e496f7fc --- /dev/null +++ b/front/dist/resources/service-worker.js @@ -0,0 +1,53 @@ +let CACHE_NAME = 'workavdenture-cache-v1'; +let urlsToCache = [ + '/' +]; + +self.addEventListener('install', function(event) { + // Perform install steps + event.waitUntil( + caches.open(CACHE_NAME) + .then(function(cache) { + console.log('Opened cache'); + return cache.addAll(urlsToCache); + }) + ); +}); + +self.addEventListener('fetch', function(event) { + event.respondWith( + caches.match(event.request) + .then(function(response) { + // Cache hit - return response + if (response) { + return response; + } + + return fetch(event.request).then( + function(response) { + // Check if we received a valid response + if(!response || response.status !== 200 || response.type !== 'basic') { + return response; + } + + // IMPORTANT: Clone the response. A response is a stream + // and because we want the browser to consume the response + // as well as the cache consuming the response, we need + // to clone it so we have two streams. + var responseToCache = response.clone(); + + caches.open(CACHE_NAME) + .then(function(cache) { + cache.put(event.request, responseToCache); + }); + + return response; + } + ); + }) + ); +}); + +self.addEventListener('activate', function(event) { + //TODO activate service worker +}); \ No newline at end of file diff --git a/front/dist/static/images/favicons/icon-512x512.png b/front/dist/static/images/favicons/icon-512x512.png new file mode 100644 index 00000000..86cb7477 Binary files /dev/null and b/front/dist/static/images/favicons/icon-512x512.png differ diff --git a/front/dist/static/images/favicons/manifest.json b/front/dist/static/images/favicons/manifest.json index 47ad9377..30d08769 100644 --- a/front/dist/static/images/favicons/manifest.json +++ b/front/dist/static/images/favicons/manifest.json @@ -119,7 +119,13 @@ "src": "/static/images/favicons/android-icon-192x192.png", "sizes": "192x192", "type": "image\/png", - "density": "4.0" + "density": "4.0", + "purpose": "any maskable" + }, + { + "src": "/static/images/favicons/icon-512x512.png", + "sizes": "512x512", + "type": "image\/png" } ], "start_url": "/", @@ -127,6 +133,7 @@ "display_override": ["window-control-overlay", "minimal-ui"], "display": "standalone", "scope": "/", + "lang": "en", "theme_color": "#000000", "shortcuts": [ { @@ -134,7 +141,7 @@ "short_name": "WA", "description": "WorkAdventure application", "url": "/", - "icons": [{ "src": "/static/images/favicons/android-icon-192x192.png", "sizes": "192x192" }] + "icons": [{ "src": "/static/images/favicons/android-icon-192x192.png", "sizes": "192x192", "type": "image/png" }] } ], "description": "WorkAdventure application", diff --git a/front/dist/static/images/send.png b/front/dist/static/images/send.png new file mode 100644 index 00000000..1f75634a Binary files /dev/null and b/front/dist/static/images/send.png differ diff --git a/front/package.json b/front/package.json index 9c592578..4e4d66c9 100644 --- a/front/package.json +++ b/front/package.json @@ -39,7 +39,7 @@ }, "dependencies": { "@fontsource/press-start-2p": "^4.3.0", - "@types/simple-peer": "^9.6.0", + "@types/simple-peer": "^9.11.1", "@types/socket.io-client": "^1.4.32", "axios": "^0.21.1", "cross-env": "^7.0.3", @@ -51,7 +51,7 @@ "queue-typescript": "^1.0.1", "quill": "1.3.6", "rxjs": "^6.6.3", - "simple-peer": "^9.6.2", + "simple-peer": "^9.11.0", "socket.io-client": "^2.3.0", "standardized-audio-context": "^25.2.4" }, diff --git a/front/src/Api/Events/SetTilesEvent.ts b/front/src/Api/Events/SetTilesEvent.ts index c7f8f16d..371f0884 100644 --- a/front/src/Api/Events/SetTilesEvent.ts +++ b/front/src/Api/Events/SetTilesEvent.ts @@ -5,7 +5,7 @@ export const isSetTilesEvent = tg.isArray( .withProperties({ x: tg.isNumber, y: tg.isNumber, - tile: tg.isUnion(tg.isNumber, tg.isString), + tile: tg.isUnion(tg.isUnion(tg.isNumber, tg.isString), tg.isNull), layer: tg.isString, }) .get() diff --git a/front/src/Api/iframe/player.ts b/front/src/Api/iframe/player.ts index 420e2d18..b2c8d58d 100644 --- a/front/src/Api/iframe/player.ts +++ b/front/src/Api/iframe/player.ts @@ -2,27 +2,34 @@ import { IframeApiContribution, sendToWorkadventure } from "./IframeApiContribut import type { HasPlayerMovedEvent, HasPlayerMovedEventCallback } from "../Events/HasPlayerMovedEvent"; import { Subject } from "rxjs"; import { apiCallback } from "./registeredCallbacks"; +import { getGameState } from "./room"; import { isHasPlayerMovedEvent } from "../Events/HasPlayerMovedEvent"; +interface User { + id: string | undefined; + nickName: string | null; + tags: string[]; +} + const moveStream = new Subject(); -let playerName: string|undefined; +let playerName: string | undefined; export const setPlayerName = (name: string) => { playerName = name; -} +}; -let tags: string[]|undefined; +let tags: string[] | undefined; export const setTags = (_tags: string[]) => { tags = _tags; -} +}; -let uuid: string|undefined; +let uuid: string | undefined; -export const setUuid = (_uuid: string|undefined) => { +export const setUuid = (_uuid: string | undefined) => { uuid = _uuid; -} +}; export class WorkadventurePlayerCommands extends IframeApiContribution { callbacks = [ @@ -43,25 +50,27 @@ export class WorkadventurePlayerCommands extends IframeApiContribution> = new Map { roomId = id; -} +}; -let mapURL: string|undefined; +let mapURL: string | undefined; export const setMapURL = (url: string) => { mapURL = url; -} +}; export class WorkadventureRoomCommands extends IframeApiContribution { callbacks = [ @@ -90,16 +90,18 @@ export class WorkadventureRoomCommands extends IframeApiContribution
{/if} - - {#if $gameOverlayVisibilityStore}
@@ -94,4 +88,7 @@
{/if} + {#if $chatVisibilityStore} + + {/if}
diff --git a/front/src/Components/Chat/Chat.svelte b/front/src/Components/Chat/Chat.svelte new file mode 100644 index 00000000..0f302126 --- /dev/null +++ b/front/src/Components/Chat/Chat.svelte @@ -0,0 +1,104 @@ + + + + + + + + \ No newline at end of file diff --git a/front/src/Components/Chat/ChatElement.svelte b/front/src/Components/Chat/ChatElement.svelte new file mode 100644 index 00000000..66ed724b --- /dev/null +++ b/front/src/Components/Chat/ChatElement.svelte @@ -0,0 +1,83 @@ + + +
+
+ {#if message.type === ChatMessageTypes.userIncoming} + >> {#each targets as target, index}{#if !isLastIteration(index)}, {/if}{/each} entered ({renderDate(message.date)}) + {:else if message.type === ChatMessageTypes.userOutcoming} + << {#each targets as target, index}{#if !isLastIteration(index)}, {/if}{/each} left ({renderDate(message.date)}) + {:else if message.type === ChatMessageTypes.me} +

Me: ({renderDate(message.date)})

+ {#each texts as text} +

{@html urlifyText(text)}

+ {/each} + {:else} +

: ({renderDate(message.date)})

+ {#each texts as text} +

{@html urlifyText(text)}

+ {/each} + {/if} +
+
+ + \ No newline at end of file diff --git a/front/src/Components/Chat/ChatMessageForm.svelte b/front/src/Components/Chat/ChatMessageForm.svelte new file mode 100644 index 00000000..01ea6ee3 --- /dev/null +++ b/front/src/Components/Chat/ChatMessageForm.svelte @@ -0,0 +1,56 @@ + + +
+ + +
+ + \ No newline at end of file diff --git a/front/src/Components/Chat/ChatPlayerName.svelte b/front/src/Components/Chat/ChatPlayerName.svelte new file mode 100644 index 00000000..9b0630c0 --- /dev/null +++ b/front/src/Components/Chat/ChatPlayerName.svelte @@ -0,0 +1,51 @@ + + + + + {player.name} + + {#if isSubMenuOpen} + + {/if} + + + + \ No newline at end of file diff --git a/front/src/Components/Chat/ChatSubMenu.svelte b/front/src/Components/Chat/ChatSubMenu.svelte new file mode 100644 index 00000000..6690699e --- /dev/null +++ b/front/src/Components/Chat/ChatSubMenu.svelte @@ -0,0 +1,33 @@ + + +
    +
  • +
  • +
+ + + \ No newline at end of file diff --git a/front/src/Components/Video/VideoMediaBox.svelte b/front/src/Components/Video/VideoMediaBox.svelte index 1a581914..d46f3ca7 100644 --- a/front/src/Components/Video/VideoMediaBox.svelte +++ b/front/src/Components/Video/VideoMediaBox.svelte @@ -37,9 +37,7 @@ Report this user Report/Block - {#if $streamStore } - {/if} {#if $constraintStore && $constraintStore.audio !== false} diff --git a/front/src/Components/Video/utils.ts b/front/src/Components/Video/utils.ts index ca1f3b41..06bfcfa7 100644 --- a/front/src/Components/Video/utils.ts +++ b/front/src/Components/Video/utils.ts @@ -1,3 +1,6 @@ +import type { UserSimplePeerInterface } from "../../WebRtc/SimplePeer"; +import { STUN_SERVER, TURN_PASSWORD, TURN_SERVER, TURN_USER } from "../../Enum/EnvironmentVariable"; + export function getColorByString(str: string): string | null { let hash = 0; if (str.length === 0) { @@ -15,7 +18,7 @@ export function getColorByString(str: string): string | null { return color; } -export function srcObject(node: HTMLVideoElement, stream: MediaStream) { +export function srcObject(node: HTMLVideoElement, stream: MediaStream | null) { node.srcObject = stream; return { update(newStream: MediaStream) { @@ -25,3 +28,19 @@ export function srcObject(node: HTMLVideoElement, stream: MediaStream) { }, }; } + +export function getIceServersConfig(user: UserSimplePeerInterface): RTCIceServer[] { + const config: RTCIceServer[] = [ + { + urls: STUN_SERVER.split(","), + }, + ]; + if (TURN_SERVER !== "") { + config.push({ + urls: TURN_SERVER.split(","), + username: user.webRtcUser || TURN_USER, + credential: user.webRtcPassword || TURN_PASSWORD, + }); + } + return config; +} diff --git a/front/src/Components/VisitCard/VisitCard.svelte b/front/src/Components/VisitCard/VisitCard.svelte index 78f10359..e9eca3b1 100644 --- a/front/src/Components/VisitCard/VisitCard.svelte +++ b/front/src/Components/VisitCard/VisitCard.svelte @@ -45,8 +45,9 @@ .visitCard { pointer-events: all; - margin-left: auto; - margin-right: auto; + position: absolute; + left: 50%; + transform: translate(-50%, 0); margin-top: 200px; max-width: 80vw; diff --git a/front/src/Connexion/ConnectionManager.ts b/front/src/Connexion/ConnectionManager.ts index 8112ba17..0c459629 100644 --- a/front/src/Connexion/ConnectionManager.ts +++ b/front/src/Connexion/ConnectionManager.ts @@ -1,92 +1,107 @@ import Axios from "axios"; -import {PUSHER_URL, START_ROOM_URL} from "../Enum/EnvironmentVariable"; -import {RoomConnection} from "./RoomConnection"; -import type {OnConnectInterface, PositionInterface, ViewportInterface} from "./ConnexionModels"; -import {GameConnexionTypes, urlManager} from "../Url/UrlManager"; -import {localUserStore} from "./LocalUserStore"; -import {CharacterTexture, LocalUser} from "./LocalUser"; -import {Room} from "./Room"; - +import { PUSHER_URL, START_ROOM_URL } from "../Enum/EnvironmentVariable"; +import { RoomConnection } from "./RoomConnection"; +import type { OnConnectInterface, PositionInterface, ViewportInterface } from "./ConnexionModels"; +import { GameConnexionTypes, urlManager } from "../Url/UrlManager"; +import { localUserStore } from "./LocalUserStore"; +import { CharacterTexture, LocalUser } from "./LocalUser"; +import { Room } from "./Room"; class ConnectionManager { - private localUser!:LocalUser; + private localUser!: LocalUser; - private connexionType?: GameConnexionTypes - private reconnectingTimeout: NodeJS.Timeout|null = null; - private _unloading:boolean = false; + private connexionType?: GameConnexionTypes; + private reconnectingTimeout: NodeJS.Timeout | null = null; + private _unloading: boolean = false; - get unloading () { + get unloading() { return this._unloading; } constructor() { - window.addEventListener('beforeunload', () => { + window.addEventListener("beforeunload", () => { this._unloading = true; - if (this.reconnectingTimeout) clearTimeout(this.reconnectingTimeout) - }) + if (this.reconnectingTimeout) clearTimeout(this.reconnectingTimeout); + }); } /** * Tries to login to the node server and return the starting map url to be loaded */ public async initGameConnexion(): Promise { - const connexionType = urlManager.getGameConnexionType(); this.connexionType = connexionType; - if(connexionType === GameConnexionTypes.register) { - const organizationMemberToken = urlManager.getOrganizationToken(); - const data = await Axios.post(`${PUSHER_URL}/register`, {organizationMemberToken}).then(res => res.data); + if (connexionType === GameConnexionTypes.register) { + const organizationMemberToken = urlManager.getOrganizationToken(); + const data = await Axios.post(`${PUSHER_URL}/register`, { organizationMemberToken }).then( + (res) => res.data + ); this.localUser = new LocalUser(data.userUuid, data.authToken, data.textures); localUserStore.saveUser(this.localUser); - const organizationSlug = data.organizationSlug; - const worldSlug = data.worldSlug; - const roomSlug = data.roomSlug; + const roomUrl = data.roomUrl; - const room = new Room('/@/'+organizationSlug+'/'+worldSlug+'/'+roomSlug + window.location.search + window.location.hash); + const room = await Room.createRoom( + new URL( + window.location.protocol + + "//" + + window.location.host + + roomUrl + + window.location.search + + window.location.hash + ) + ); urlManager.pushRoomIdToUrl(room); return Promise.resolve(room); - } else if (connexionType === GameConnexionTypes.organization || connexionType === GameConnexionTypes.anonymous || connexionType === GameConnexionTypes.empty) { - + } else if ( + connexionType === GameConnexionTypes.organization || + connexionType === GameConnexionTypes.anonymous || + connexionType === GameConnexionTypes.empty + ) { let localUser = localUserStore.getLocalUser(); if (localUser && localUser.jwtToken && localUser.uuid && localUser.textures) { this.localUser = localUser; try { await this.verifyToken(localUser.jwtToken); - } catch(e) { + } catch (e) { // If the token is invalid, let's generate an anonymous one. - console.error('JWT token invalid. Did it expire? Login anonymously instead.'); + console.error("JWT token invalid. Did it expire? Login anonymously instead."); await this.anonymousLogin(); } - }else{ + } else { await this.anonymousLogin(); } localUser = localUserStore.getLocalUser(); - if(!localUser){ + if (!localUser) { throw "Error to store local user data"; } - let roomId: string; + let roomPath: string; if (connexionType === GameConnexionTypes.empty) { - roomId = START_ROOM_URL; + roomPath = window.location.protocol + "//" + window.location.host + START_ROOM_URL; } else { - roomId = window.location.pathname + window.location.search + window.location.hash; + roomPath = + window.location.protocol + + "//" + + window.location.host + + window.location.pathname + + window.location.search + + window.location.hash; } //get detail map for anonymous login and set texture in local storage - const room = new Room(roomId); - const mapDetail = await room.getMapDetail(); - if(mapDetail.textures != undefined && mapDetail.textures.length > 0) { + const room = await Room.createRoom(new URL(roomPath)); + if (room.textures != undefined && room.textures.length > 0) { //check if texture was changed - if(localUser.textures.length === 0){ - localUser.textures = mapDetail.textures; - }else{ - mapDetail.textures.forEach((newTexture) => { + if (localUser.textures.length === 0) { + localUser.textures = room.textures; + } else { + room.textures.forEach((newTexture) => { const alreadyExistTexture = localUser?.textures.find((c) => newTexture.id === c.id); - if(localUser?.textures.findIndex((c) => newTexture.id === c.id) !== -1){ + if (localUser?.textures.findIndex((c) => newTexture.id === c.id) !== -1) { return; } - localUser?.textures.push(newTexture) + localUser?.textures.push(newTexture); }); } this.localUser = localUser; @@ -95,55 +110,79 @@ class ConnectionManager { return Promise.resolve(room); } - return Promise.reject(new Error('Invalid URL')); + return Promise.reject(new Error("Invalid URL")); } private async verifyToken(token: string): Promise { - await Axios.get(`${PUSHER_URL}/verify`, {params: {token}}); + await Axios.get(`${PUSHER_URL}/verify`, { params: { token } }); } public async anonymousLogin(isBenchmark: boolean = false): Promise { - const data = await Axios.post(`${PUSHER_URL}/anonymLogin`).then(res => res.data); + const data = await Axios.post(`${PUSHER_URL}/anonymLogin`).then((res) => res.data); this.localUser = new LocalUser(data.userUuid, data.authToken, []); - if (!isBenchmark) { // In benchmark, we don't have a local storage. + if (!isBenchmark) { + // In benchmark, we don't have a local storage. localUserStore.saveUser(this.localUser); } } public initBenchmark(): void { - this.localUser = new LocalUser('', 'test', []); + this.localUser = new LocalUser("", "test", []); } - public connectToRoomSocket(roomId: string, name: string, characterLayers: string[], position: PositionInterface, viewport: ViewportInterface, companion: string|null): Promise { + public connectToRoomSocket( + roomUrl: string, + name: string, + characterLayers: string[], + position: PositionInterface, + viewport: ViewportInterface, + companion: string | null + ): Promise { return new Promise((resolve, reject) => { - const connection = new RoomConnection(this.localUser.jwtToken, roomId, name, characterLayers, position, viewport, companion); + const connection = new RoomConnection( + this.localUser.jwtToken, + roomUrl, + name, + characterLayers, + position, + viewport, + companion + ); connection.onConnectError((error: object) => { - console.log('An error occurred while connecting to socket server. Retrying'); + console.log("An error occurred while connecting to socket server. Retrying"); reject(error); }); connection.onConnectingError((event: CloseEvent) => { - console.log('An error occurred while connecting to socket server. Retrying'); - reject(new Error('An error occurred while connecting to socket server. Retrying. Code: '+event.code+', Reason: '+event.reason)); + console.log("An error occurred while connecting to socket server. Retrying"); + reject( + new Error( + "An error occurred while connecting to socket server. Retrying. Code: " + + event.code + + ", Reason: " + + event.reason + ) + ); }); connection.onConnect((connect: OnConnectInterface) => { resolve(connect); }); - }).catch((err) => { // Let's retry in 4-6 seconds return new Promise((resolve, reject) => { this.reconnectingTimeout = setTimeout(() => { //todo: allow a way to break recursion? //todo: find a way to avoid recursive function. Otherwise, the call stack will grow indefinitely. - this.connectToRoomSocket(roomId, name, characterLayers, position, viewport, companion).then((connection) => resolve(connection)); - }, 4000 + Math.floor(Math.random() * 2000) ); + this.connectToRoomSocket(roomUrl, name, characterLayers, position, viewport, companion).then( + (connection) => resolve(connection) + ); + }, 4000 + Math.floor(Math.random() * 2000)); }); }); } - get getConnexionType(){ + get getConnexionType() { return this.connexionType; } } diff --git a/front/src/Connexion/Room.ts b/front/src/Connexion/Room.ts index 434f9060..57d52766 100644 --- a/front/src/Connexion/Room.ts +++ b/front/src/Connexion/Room.ts @@ -3,91 +3,103 @@ import { PUSHER_URL } from "../Enum/EnvironmentVariable"; import type { CharacterTexture } from "./LocalUser"; export class MapDetail { - constructor(public readonly mapUrl: string, public readonly textures: CharacterTexture[] | undefined) { - } + constructor(public readonly mapUrl: string, public readonly textures: CharacterTexture[] | undefined) {} +} + +export interface RoomRedirect { + redirectUrl: string; } export class Room { public readonly id: string; public readonly isPublic: boolean; - private mapUrl: string | undefined; - private textures: CharacterTexture[] | undefined; + private _mapUrl: string | undefined; + private _textures: CharacterTexture[] | undefined; private instance: string | undefined; - private _search: URLSearchParams; + private readonly _search: URLSearchParams; - constructor(id: string) { - const url = new URL(id, 'https://example.com'); + private constructor(private roomUrl: URL) { + this.id = roomUrl.pathname; - this.id = url.pathname; - - if (this.id.startsWith('/')) { + if (this.id.startsWith("/")) { this.id = this.id.substr(1); } - if (this.id.startsWith('_/')) { + if (this.id.startsWith("_/")) { this.isPublic = true; - } else if (this.id.startsWith('@/')) { + } else if (this.id.startsWith("@/")) { this.isPublic = false; } else { - throw new Error('Invalid room ID'); + throw new Error("Invalid room ID"); } - this._search = new URLSearchParams(url.search); + this._search = new URLSearchParams(roomUrl.search); } - public static getIdFromIdentifier(identifier: string, baseUrl: string, currentInstance: string): { roomId: string, hash: string | null } { - let roomId = ''; - let hash = null; - if (!identifier.startsWith('/_/') && !identifier.startsWith('/@/')) { //relative file link - //Relative identifier can be deep enough to rewrite the base domain, so we cannot use the variable 'baseUrl' as the actual base url for the URL objects. - //We instead use 'workadventure' as a dummy base value. - const baseUrlObject = new URL(baseUrl); - const absoluteExitSceneUrl = new URL(identifier, 'http://workadventure/_/' + currentInstance + '/' + baseUrlObject.hostname + baseUrlObject.pathname); - roomId = absoluteExitSceneUrl.pathname; //in case of a relative url, we need to create a public roomId - roomId = roomId.substring(1); //remove the leading slash - hash = absoluteExitSceneUrl.hash; - hash = hash.substring(1); //remove the leading diese - if (!hash.length) { - hash = null - } - } else { //absolute room Id - const parts = identifier.split('#'); - roomId = parts[0]; - roomId = roomId.substring(1); //remove the leading slash - if (parts.length > 1) { - hash = parts[1] + /** + * Creates a "Room" object representing the room. + * This method will follow room redirects if necessary, so the instance returned is a "real" room. + */ + public static async createRoom(roomUrl: URL): Promise { + let redirectCount = 0; + while (redirectCount < 32) { + const room = new Room(roomUrl); + const result = await room.getMapDetail(); + if (result instanceof MapDetail) { + return room; } + redirectCount++; + roomUrl = new URL(result.redirectUrl); } - return { roomId, hash } + throw new Error("Room resolving seems stuck in a redirect loop after 32 redirect attempts"); } - public async getMapDetail(): Promise { - return new Promise((resolve, reject) => { - if (this.mapUrl !== undefined && this.textures != undefined) { - resolve(new MapDetail(this.mapUrl, this.textures)); - return; - } + public static getRoomPathFromExitUrl(exitUrl: string, currentRoomUrl: string): URL { + const url = new URL(exitUrl, currentRoomUrl); + return url; + } - if (this.isPublic) { - const match = /_\/[^/]+\/(.+)/.exec(this.id); - if (!match) throw new Error('Could not extract url from "' + this.id + '"'); - this.mapUrl = window.location.protocol + '//' + match[1]; - resolve(new MapDetail(this.mapUrl, this.textures)); - return; - } else { - // We have a private ID, we need to query the map URL from the server. - const urlParts = this.parsePrivateUrl(this.id); + /** + * @deprecated USage of exitSceneUrl is deprecated and therefore, this method is deprecated too. + */ + public static getRoomPathFromExitSceneUrl( + exitSceneUrl: string, + currentRoomUrl: string, + currentMapUrl: string + ): URL { + const absoluteExitSceneUrl = new URL(exitSceneUrl, currentMapUrl); + const baseUrl = new URL(currentRoomUrl); - Axios.get(`${PUSHER_URL}/map`, { - params: urlParts - }).then(({ data }) => { - console.log('Map ', this.id, ' resolves to URL ', data.mapUrl); - resolve(data); - return; - }).catch((reason) => { - reject(reason); - }); - } + const currentRoom = new Room(baseUrl); + let instance: string = "global"; + if (currentRoom.isPublic) { + instance = currentRoom.instance as string; + } + + baseUrl.pathname = "/_/" + instance + "/" + absoluteExitSceneUrl.host + absoluteExitSceneUrl.pathname; + if (absoluteExitSceneUrl.hash) { + baseUrl.hash = absoluteExitSceneUrl.hash; + } + + return baseUrl; + } + + private async getMapDetail(): Promise { + const result = await Axios.get(`${PUSHER_URL}/map`, { + params: { + playUri: this.roomUrl.toString(), + }, }); + + const data = result.data; + if (data.redirectUrl) { + return { + redirectUrl: data.redirectUrl as string, + }; + } + console.log("Map ", this.id, " resolves to URL ", data.mapUrl); + this._mapUrl = data.mapUrl; + this._textures = data.textures; + return new MapDetail(data.mapUrl, data.textures); } /** @@ -108,21 +120,24 @@ export class Room { } else { const match = /@\/([^/]+)\/([^/]+)\/.+/.exec(this.id); if (!match) throw new Error('Could not extract instance from "' + this.id + '"'); - this.instance = match[1] + '/' + match[2]; + this.instance = match[1] + "/" + match[2]; return this.instance; } } - private parsePrivateUrl(url: string): { organizationSlug: string, worldSlug: string, roomSlug?: string } { + /** + * @deprecated + */ + private parsePrivateUrl(url: string): { organizationSlug: string; worldSlug: string; roomSlug?: string } { const regex = /@\/([^/]+)\/([^/]+)(?:\/([^/]*))?/gm; const match = regex.exec(url); if (!match) { - throw new Error('Invalid URL ' + url); + throw new Error("Invalid URL " + url); } - const results: { organizationSlug: string, worldSlug: string, roomSlug?: string } = { + const results: { organizationSlug: string; worldSlug: string; roomSlug?: string } = { organizationSlug: match[1], worldSlug: match[2], - } + }; if (match[3] !== undefined) { results.roomSlug = match[3]; } @@ -130,8 +145,8 @@ export class Room { } public isDisconnected(): boolean { - const alone = this._search.get('alone'); - if (alone && alone !== '0' && alone.toLowerCase() !== 'false') { + const alone = this._search.get("alone"); + if (alone && alone !== "0" && alone.toLowerCase() !== "false") { return true; } return false; @@ -140,4 +155,32 @@ export class Room { public get search(): URLSearchParams { return this._search; } + + /** + * 2 rooms are equal if they share the same path (but not necessarily the same hash) + * @param room + */ + public isEqual(room: Room): boolean { + return room.key === this.key; + } + + /** + * A key representing this room + */ + public get key(): string { + const newUrl = new URL(this.roomUrl.toString()); + newUrl.hash = ""; + return newUrl.toString(); + } + + get textures(): CharacterTexture[] | undefined { + return this._textures; + } + + get mapUrl(): string { + if (!this._mapUrl) { + throw new Error("Map URL not fetched yet"); + } + return this._mapUrl; + } } diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index 33122caa..04ef6619 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -76,11 +76,11 @@ export class RoomConnection implements RoomConnection { /** * * @param token A JWT token containing the UUID of the user - * @param roomId The ID of the room in the form "_/[instance]/[map_url]" or "@/[org]/[event]/[map]" + * @param roomUrl The URL of the room in the form "https://example.com/_/[instance]/[map_url]" or "https://example.com/@/[org]/[event]/[map]" */ public constructor( token: string | null, - roomId: string, + roomUrl: string, name: string, characterLayers: string[], position: PositionInterface, @@ -93,7 +93,7 @@ export class RoomConnection implements RoomConnection { url += "/"; } url += "room"; - url += "?roomId=" + (roomId ? encodeURIComponent(roomId) : ""); + url += "?roomId=" + encodeURIComponent(roomUrl); url += "&token=" + (token ? encodeURIComponent(token) : ""); url += "&name=" + encodeURIComponent(name); for (const layer of characterLayers) { diff --git a/front/src/Phaser/Components/OpenChatIcon.ts b/front/src/Phaser/Components/OpenChatIcon.ts index ab07a80c..8c648bc1 100644 --- a/front/src/Phaser/Components/OpenChatIcon.ts +++ b/front/src/Phaser/Components/OpenChatIcon.ts @@ -1,7 +1,7 @@ -import {discussionManager} from "../../WebRtc/DiscussionManager"; -import {DEPTH_INGAME_TEXT_INDEX} from "../Game/DepthIndexes"; +import { DEPTH_INGAME_TEXT_INDEX } from "../Game/DepthIndexes"; +import { chatVisibilityStore } from "../../Stores/ChatStore"; -export const openChatIconName = 'openChatIcon'; +export const openChatIconName = "openChatIcon"; export class OpenChatIcon extends Phaser.GameObjects.Image { constructor(scene: Phaser.Scene, x: number, y: number) { super(scene, x, y, openChatIconName, 3); @@ -9,9 +9,9 @@ export class OpenChatIcon extends Phaser.GameObjects.Image { this.setScrollFactor(0, 0); this.setOrigin(0, 1); this.setInteractive(); - this.setVisible(false); + //this.setVisible(false); this.setDepth(DEPTH_INGAME_TEXT_INDEX); - this.on("pointerup", () => discussionManager.showDiscussionPart()); + this.on("pointerup", () => chatVisibilityStore.set(true)); } -} \ No newline at end of file +} diff --git a/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts b/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts index d2a659ec..3c47c9d9 100644 --- a/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts +++ b/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts @@ -101,7 +101,6 @@ export const createLoadingPromise = ( frameConfig: FrameConfig ) => { return new Promise((res, rej) => { - console.log("count", loadPlugin.listenerCount("loaderror")); if (loadPlugin.textureManager.exists(playerResourceDescriptor.name)) { return res(playerResourceDescriptor); } diff --git a/front/src/Phaser/Game/GameManager.ts b/front/src/Phaser/Game/GameManager.ts index 3e39de9a..7f0b2061 100644 --- a/front/src/Phaser/Game/GameManager.ts +++ b/front/src/Phaser/Game/GameManager.ts @@ -28,7 +28,7 @@ export class GameManager { public async init(scenePlugin: Phaser.Scenes.ScenePlugin): Promise { this.startRoom = await connectionManager.initGameConnexion(); - await this.loadMap(this.startRoom, scenePlugin); + this.loadMap(this.startRoom, scenePlugin); if (!this.playerName) { return LoginSceneName; @@ -68,20 +68,19 @@ export class GameManager { return this.companion; } - public async loadMap(room: Room, scenePlugin: Phaser.Scenes.ScenePlugin): Promise { - const roomID = room.id; - const mapDetail = await room.getMapDetail(); + public loadMap(room: Room, scenePlugin: Phaser.Scenes.ScenePlugin) { + const roomID = room.key; const gameIndex = scenePlugin.getIndex(roomID); if (gameIndex === -1) { - const game: Phaser.Scene = new GameScene(room, mapDetail.mapUrl); + const game: Phaser.Scene = new GameScene(room, room.mapUrl); scenePlugin.add(roomID, game, false); } } public goToStartingMap(scenePlugin: Phaser.Scenes.ScenePlugin): void { - console.log("starting " + (this.currentGameSceneName || this.startRoom.id)); - scenePlugin.start(this.currentGameSceneName || this.startRoom.id); + console.log("starting " + (this.currentGameSceneName || this.startRoom.key)); + scenePlugin.start(this.currentGameSceneName || this.startRoom.key); scenePlugin.launch(MenuSceneName); if ( diff --git a/front/src/Phaser/Game/GameMap.ts b/front/src/Phaser/Game/GameMap.ts index e095dab1..98583cba 100644 --- a/front/src/Phaser/Game/GameMap.ts +++ b/front/src/Phaser/Game/GameMap.ts @@ -152,7 +152,10 @@ export class GameMap { } private getTileProperty(index: number): Array { - return this.tileSetPropertyMap[index]; + if (this.tileSetPropertyMap[index]) { + return this.tileSetPropertyMap[index]; + } + return []; } private trigger( @@ -189,6 +192,10 @@ export class GameMap { return this.phaserLayers.find((layer) => layer.layer.name === layerName); } + public findPhaserLayers(groupName: string): TilemapLayer[] { + return this.phaserLayers.filter((l) => l.layer.name.includes(groupName)); + } + public addTerrain(terrain: Phaser.Tilemaps.Tileset): void { for (const phaserLayer of this.phaserLayers) { phaserLayer.tileset.push(terrain); @@ -198,37 +205,45 @@ export class GameMap { private putTileInFlatLayer(index: number, x: number, y: number, layer: string): void { const fLayer = this.findLayer(layer); if (fLayer == undefined) { - console.error("The layer that you want to change doesn't exist."); + console.error("The layer '" + layer + "' that you want to change doesn't exist."); return; } if (fLayer.type !== "tilelayer") { - console.error("The layer that you want to change is not a tilelayer. Tile can only be put in tilelayer."); + console.error( + "The layer '" + + layer + + "' that you want to change is not a tilelayer. Tile can only be put in tilelayer." + ); return; } if (typeof fLayer.data === "string") { - console.error("Data of the layer that you want to change is only readable."); + console.error("Data of the layer '" + layer + "' that you want to change is only readable."); return; } - fLayer.data[x + y * fLayer.height] = index; + fLayer.data[x + y * fLayer.width] = index; } - public putTile(tile: string | number, x: number, y: number, layer: string): void { + public putTile(tile: string | number | null, x: number, y: number, layer: string): void { const phaserLayer = this.findPhaserLayer(layer); if (phaserLayer) { + if (tile === null) { + phaserLayer.putTileAt(-1, x, y); + return; + } const tileIndex = this.getIndexForTileType(tile); if (tileIndex !== undefined) { this.putTileInFlatLayer(tileIndex, x, y, layer); const phaserTile = phaserLayer.putTileAt(tileIndex, x, y); for (const property of this.getTileProperty(tileIndex)) { - if (property.name === "collides" && property.value === "true") { + if (property.name === "collides" && property.value) { phaserTile.setCollision(true); } } } else { - console.error("The tile that you want to place doesn't exist."); + console.error("The tile '" + tile + "' that you want to place doesn't exist."); } } else { - console.error("The layer that you want to change is not a tilelayer. Tile can only be put in tilelayer."); + console.error("The layer '" + layer + "' does not exist (or is not a tilelaye)."); } } diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index 3ed0254b..c6615ce3 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -87,6 +87,7 @@ import { videoFocusStore } from "../../Stores/VideoFocusStore"; import { biggestAvailableAreaStore } from "../../Stores/BiggestAvailableAreaStore"; import { SharedVariablesManager } from "./SharedVariablesManager"; import { playersStore } from "../../Stores/PlayersStore"; +import { chatVisibilityStore } from "../../Stores/ChatStore"; export interface GameSceneInitInterface { initPosition: PointInterface | null; @@ -164,9 +165,10 @@ export class GameScene extends DirtyScene { private createPromiseResolve!: (value?: void | PromiseLike) => void; private iframeSubscriptionList!: Array; private peerStoreUnsubscribe!: () => void; + private chatVisibilityUnsubscribe!: () => void; private biggestAvailableAreaStoreUnsubscribe!: () => void; MapUrlFile: string; - RoomId: string; + roomUrl: string; instance: string; currentTick!: number; @@ -200,14 +202,14 @@ export class GameScene extends DirtyScene { constructor(private room: Room, MapUrlFile: string, customKey?: string | undefined) { super({ - key: customKey ?? room.id, + key: customKey ?? room.key, }); this.Terrains = []; this.groups = new Map(); this.instance = room.getInstance(); this.MapUrlFile = MapUrlFile; - this.RoomId = room.id; + this.roomUrl = room.key; this.createPromise = new Promise((resolve, reject): void => { this.createPromiseResolve = resolve; @@ -459,11 +461,13 @@ export class GameScene extends DirtyScene { if (layer.type === "tilelayer") { const exitSceneUrl = this.getExitSceneUrl(layer); if (exitSceneUrl !== undefined) { - this.loadNextGame(exitSceneUrl); + this.loadNextGame( + Room.getRoomPathFromExitSceneUrl(exitSceneUrl, window.location.toString(), this.MapUrlFile) + ); } const exitUrl = this.getExitUrl(layer); if (exitUrl !== undefined) { - this.loadNextGame(exitUrl); + this.loadNextGameFromExitUrl(exitUrl); } } if (layer.type === "objectgroup") { @@ -476,7 +480,7 @@ export class GameScene extends DirtyScene { } this.gameMap.exitUrls.forEach((exitUrl) => { - this.loadNextGame(exitUrl); + this.loadNextGameFromExitUrl(exitUrl); }); this.startPositionCalculator = new StartPositionCalculator( @@ -567,6 +571,10 @@ export class GameScene extends DirtyScene { } oldPeerNumber = newPeerNumber; }); + + this.chatVisibilityUnsubscribe = chatVisibilityStore.subscribe((v) => { + this.openChatIcon.setVisible(!v); + }); } /** @@ -577,7 +585,7 @@ export class GameScene extends DirtyScene { connectionManager .connectToRoomSocket( - this.RoomId, + this.roomUrl, this.playerName, this.characterLayers, { @@ -688,12 +696,12 @@ export class GameScene extends DirtyScene { const self = this; this.simplePeer.registerPeerConnectionListener({ onConnect(peer) { - self.openChatIcon.setVisible(true); + //self.openChatIcon.setVisible(true); audioManager.decreaseVolume(); }, onDisconnect(userId: number) { if (self.simplePeer.getNbConnections() === 0) { - self.openChatIcon.setVisible(false); + //self.openChatIcon.setVisible(false); audioManager.restoreVolume(); } }, @@ -707,7 +715,11 @@ export class GameScene extends DirtyScene { }); // Set up variables manager - this.sharedVariablesManager = new SharedVariablesManager(this.connection, this.gameMap, onConnect.room.variables); + this.sharedVariablesManager = new SharedVariablesManager( + this.connection, + this.gameMap, + onConnect.room.variables + ); //this.initUsersPosition(roomJoinedMessage.users); this.connectionAnswerPromiseResolve(onConnect.room); @@ -768,10 +780,13 @@ export class GameScene extends DirtyScene { private triggerOnMapLayerPropertyChange() { this.gameMap.onPropertyChange("exitSceneUrl", (newValue, oldValue) => { - if (newValue) this.onMapExit(newValue as string); + if (newValue) + this.onMapExit( + Room.getRoomPathFromExitSceneUrl(newValue as string, window.location.toString(), this.MapUrlFile) + ); }); this.gameMap.onPropertyChange("exitUrl", (newValue, oldValue) => { - if (newValue) this.onMapExit(newValue as string); + if (newValue) this.onMapExit(Room.getRoomPathFromExitUrl(newValue as string, window.location.toString())); }); this.gameMap.onPropertyChange("openWebsite", (newValue, oldValue, allProps) => { if (newValue === undefined) { @@ -996,9 +1011,9 @@ ${escapedMessage} ); this.iframeSubscriptionList.push( iframeListener.loadPageStream.subscribe((url: string) => { - this.loadNextGame(url).then(() => { + this.loadNextGameFromExitUrl(url).then(() => { this.events.once(EVENT_TYPE.POST_UPDATE, () => { - this.onMapExit(url); + this.onMapExit(Room.getRoomPathFromExitUrl(url, window.location.toString())); }); }); }) @@ -1056,7 +1071,7 @@ ${escapedMessage} startLayerName: this.startPositionCalculator.startLayerName, uuid: localUserStore.getLocalUser()?.uuid, nickname: this.playerName, - roomId: this.RoomId, + roomId: this.roomUrl, tags: this.connection ? this.connection.getAllTags() : [], variables: this.sharedVariablesManager.variables, }; @@ -1080,53 +1095,86 @@ ${escapedMessage} console.warn('Could not find layer "' + layerName + '" when calling setProperty'); return; } + if (propertyName === "exitUrl" && typeof propertyValue === "string") { + this.loadNextGameFromExitUrl(propertyValue); + } if (layer.properties === undefined) { layer.properties = []; } const property = layer.properties.find((property) => property.name === propertyName); if (property === undefined) { + if (propertyValue === undefined) { + return; + } layer.properties.push({ name: propertyName, type: typeof propertyValue, value: propertyValue }); return; } + if (propertyValue === undefined) { + const index = layer.properties.indexOf(property); + layer.properties.splice(index, 1); + } property.value = propertyValue; } private setLayerVisibility(layerName: string, visible: boolean): void { const phaserLayer = this.gameMap.findPhaserLayer(layerName); - if (phaserLayer === undefined) { - console.warn('Could not find layer "' + layerName + '" when calling WA.hideLayer / WA.showLayer'); - return; + if (phaserLayer != undefined) { + phaserLayer.setVisible(visible); + phaserLayer.setCollisionByProperty({ collides: true }, visible); + } else { + const phaserLayers = this.gameMap.findPhaserLayers(layerName + "/"); + if (phaserLayers === []) { + console.warn( + 'Could not find layer with name that contains "' + + layerName + + '" when calling WA.hideLayer / WA.showLayer' + ); + return; + } + for (let i = 0; i < phaserLayers.length; i++) { + phaserLayers[i].setVisible(visible); + phaserLayers[i].setCollisionByProperty({ collides: true }, visible); + } } - phaserLayer.setVisible(visible); - this.dirty = true; + this.markDirty(); } private getMapDirUrl(): string { return this.MapUrlFile.substr(0, this.MapUrlFile.lastIndexOf("/")); } - private onMapExit(exitKey: string) { + private async onMapExit(roomUrl: URL) { if (this.mapTransitioning) return; this.mapTransitioning = true; - const { roomId, hash } = Room.getIdFromIdentifier(exitKey, this.MapUrlFile, this.instance); - if (!roomId) throw new Error("Could not find the room from its exit key: " + exitKey); - if (hash) { - urlManager.pushStartLayerNameToUrl(hash); + + let targetRoom: Room; + try { + targetRoom = await Room.createRoom(roomUrl); + } catch (e: unknown) { + console.error('Error while fetching new room "' + roomUrl.toString() + '"', e); + this.mapTransitioning = false; + return; } + + if (roomUrl.hash) { + urlManager.pushStartLayerNameToUrl(roomUrl.hash); + } + const menuScene: MenuScene = this.scene.get(MenuSceneName) as MenuScene; menuScene.reset(); - if (roomId !== this.scene.key) { - if (this.scene.get(roomId) === null) { - console.error("next room not loaded", exitKey); + + if (!targetRoom.isEqual(this.room)) { + if (this.scene.get(targetRoom.key) === null) { + console.error("next room not loaded", targetRoom.key); return; } this.cleanupClosingScene(); this.scene.stop(); + this.scene.start(targetRoom.key); this.scene.remove(this.scene.key); - this.scene.start(roomId); } else { //if the exit points to the current map, we simply teleport the user back to the startLayer - this.startPositionCalculator.initPositionFromLayerName(hash, hash); + this.startPositionCalculator.initPositionFromLayerName(roomUrl.hash, roomUrl.hash); this.CurrentPlayer.x = this.startPositionCalculator.startPosition.x; this.CurrentPlayer.y = this.startPositionCalculator.startPosition.y; setTimeout(() => (this.mapTransitioning = false), 500); @@ -1153,6 +1201,7 @@ ${escapedMessage} this.pinchManager?.destroy(); this.emoteManager.destroy(); this.peerStoreUnsubscribe(); + this.chatVisibilityUnsubscribe(); this.biggestAvailableAreaStoreUnsubscribe(); iframeListener.unregisterAnswerer("getState"); this.sharedVariablesManager?.close(); @@ -1218,11 +1267,18 @@ ${escapedMessage} .map((property) => property.value); } + private loadNextGameFromExitUrl(exitUrl: string): Promise { + return this.loadNextGame(Room.getRoomPathFromExitUrl(exitUrl, window.location.toString())); + } + //todo: push that into the gameManager - private loadNextGame(exitSceneIdentifier: string): Promise { - const { roomId, hash } = Room.getIdFromIdentifier(exitSceneIdentifier, this.MapUrlFile, this.instance); - const room = new Room(roomId); - return gameManager.loadMap(room, this.scene).catch(() => {}); + private async loadNextGame(exitRoomPath: URL): Promise { + try { + const room = await Room.createRoom(exitRoomPath); + return gameManager.loadMap(room, this.scene); + } catch (e: unknown) { + console.warn('Error while pre-loading exit room "' + exitRoomPath.toString() + '"', e); + } } //todo: in a dedicated class/function? diff --git a/front/src/Phaser/Game/PlayerInterface.ts b/front/src/Phaser/Game/PlayerInterface.ts index 5a81c89a..6ab439df 100644 --- a/front/src/Phaser/Game/PlayerInterface.ts +++ b/front/src/Phaser/Game/PlayerInterface.ts @@ -7,4 +7,5 @@ export interface PlayerInterface { visitCardUrl: string | null; companion: string | null; userUuid: string; + color?: string; } diff --git a/front/src/Phaser/Menu/MenuScene.ts b/front/src/Phaser/Menu/MenuScene.ts index da59cecb..4cf18cce 100644 --- a/front/src/Phaser/Menu/MenuScene.ts +++ b/front/src/Phaser/Menu/MenuScene.ts @@ -19,6 +19,7 @@ import { sendMenuClickedEvent } from "../../Api/iframe/Ui/MenuItem"; import { consoleGlobalMessageManagerVisibleStore } from "../../Stores/ConsoleGlobalMessageManagerStore"; import { get } from "svelte/store"; import { playersStore } from "../../Stores/PlayersStore"; +import { mediaManager } from "../../WebRtc/MediaManager"; export const MenuSceneName = "MenuScene"; const gameMenuKey = "gameMenu"; @@ -98,6 +99,10 @@ export class MenuScene extends Phaser.Scene { this.menuElement.setOrigin(0); MenuScene.revealMenusAfterInit(this.menuElement, "gameMenu"); + if (mediaManager.hasNotification()) { + HtmlUtils.getElementByIdOrFail("enableNotification").hidden = true; + } + const middleX = window.innerWidth / 3 - 298; this.gameQualityMenuElement = this.add.dom(middleX, -400).createFromCache(gameSettingsMenuKey); MenuScene.revealMenusAfterInit(this.gameQualityMenuElement, "gameQuality"); @@ -357,6 +362,9 @@ export class MenuScene extends Phaser.Scene { case "toggleFullscreen": this.toggleFullscreen(); break; + case "enableNotification": + this.enableNotification(); + break; case "adminConsoleButton": if (get(consoleGlobalMessageManagerVisibleStore)) { consoleGlobalMessageManagerVisibleStore.set(false); @@ -419,4 +427,12 @@ export class MenuScene extends Phaser.Scene { public isDirty(): boolean { return false; } + + private enableNotification() { + mediaManager.requestNotification().then(() => { + if (mediaManager.hasNotification()) { + HtmlUtils.getElementByIdOrFail("enableNotification").hidden = true; + } + }); + } } diff --git a/front/src/Stores/ChatStore.ts b/front/src/Stores/ChatStore.ts new file mode 100644 index 00000000..df488115 --- /dev/null +++ b/front/src/Stores/ChatStore.ts @@ -0,0 +1,118 @@ +import { writable } from "svelte/store"; +import { playersStore } from "./PlayersStore"; +import type { PlayerInterface } from "../Phaser/Game/PlayerInterface"; + +export const chatVisibilityStore = writable(false); +export const chatInputFocusStore = writable(false); + +export const newChatMessageStore = writable(null); + +export enum ChatMessageTypes { + text = 1, + me, + userIncoming, + userOutcoming, +} + +export interface ChatMessage { + type: ChatMessageTypes; + date: Date; + author?: PlayerInterface; + targets?: PlayerInterface[]; + text?: string[]; +} + +function getAuthor(authorId: number): PlayerInterface { + const author = playersStore.getPlayerById(authorId); + if (!author) { + throw "Could not find data for author " + authorId; + } + return author; +} + +function createChatMessagesStore() { + const { subscribe, update } = writable([]); + + return { + subscribe, + addIncomingUser(authorId: number) { + update((list) => { + const lastMessage = list[list.length - 1]; + if (lastMessage && lastMessage.type === ChatMessageTypes.userIncoming && lastMessage.targets) { + lastMessage.targets.push(getAuthor(authorId)); + } else { + list.push({ + type: ChatMessageTypes.userIncoming, + targets: [getAuthor(authorId)], + date: new Date(), + }); + } + return list; + }); + }, + addOutcomingUser(authorId: number) { + update((list) => { + const lastMessage = list[list.length - 1]; + if (lastMessage && lastMessage.type === ChatMessageTypes.userOutcoming && lastMessage.targets) { + lastMessage.targets.push(getAuthor(authorId)); + } else { + list.push({ + type: ChatMessageTypes.userOutcoming, + targets: [getAuthor(authorId)], + date: new Date(), + }); + } + return list; + }); + }, + addPersonnalMessage(text: string) { + newChatMessageStore.set(text); + update((list) => { + const lastMessage = list[list.length - 1]; + if (lastMessage && lastMessage.type === ChatMessageTypes.me && lastMessage.text) { + lastMessage.text.push(text); + } else { + list.push({ + type: ChatMessageTypes.me, + text: [text], + date: new Date(), + }); + } + return list; + }); + }, + addExternalMessage(authorId: number, text: string) { + update((list) => { + const lastMessage = list[list.length - 1]; + if (lastMessage && lastMessage.type === ChatMessageTypes.text && lastMessage.text) { + lastMessage.text.push(text); + } else { + list.push({ + type: ChatMessageTypes.text, + text: [text], + author: getAuthor(authorId), + date: new Date(), + }); + } + return list; + }); + }, + }; +} +export const chatMessagesStore = createChatMessagesStore(); + +function createChatSubMenuVisibilityStore() { + const { subscribe, update } = writable(""); + + return { + subscribe, + openSubMenu(playerName: string, index: number) { + const id = playerName + index; + update((oldValue) => { + return oldValue === id ? "" : id; + }); + }, + }; +} + +export const chatSubMenuVisbilityStore = createChatSubMenuVisibilityStore(); diff --git a/front/src/Stores/PlayersStore.ts b/front/src/Stores/PlayersStore.ts index 6c21de7a..2ea988bb 100644 --- a/front/src/Stores/PlayersStore.ts +++ b/front/src/Stores/PlayersStore.ts @@ -1,6 +1,7 @@ import { writable } from "svelte/store"; import type { PlayerInterface } from "../Phaser/Game/PlayerInterface"; import type { RoomConnection } from "../Connexion/RoomConnection"; +import { getRandomColor } from "../WebRtc/ColorGenerator"; /** * A store that contains the list of players currently known. @@ -24,6 +25,7 @@ function createPlayersStore() { visitCardUrl: message.visitCardUrl, companion: message.companion, userUuid: message.userUuid, + color: getRandomColor(), }); return users; }); diff --git a/front/src/Stores/UserInputStore.ts b/front/src/Stores/UserInputStore.ts index cbb7f0c3..993d8795 100644 --- a/front/src/Stores/UserInputStore.ts +++ b/front/src/Stores/UserInputStore.ts @@ -1,10 +1,11 @@ -import {derived} from "svelte/store"; -import {consoleGlobalMessageManagerFocusStore} from "./ConsoleGlobalMessageManagerStore"; +import { derived } from "svelte/store"; +import { consoleGlobalMessageManagerFocusStore } from "./ConsoleGlobalMessageManagerStore"; +import { chatInputFocusStore } from "./ChatStore"; //derived from the focus on Menu, ConsoleGlobal, Chat and ... export const enableUserInputsStore = derived( - consoleGlobalMessageManagerFocusStore, - ($consoleGlobalMessageManagerFocusStore) => { - return !$consoleGlobalMessageManagerFocusStore; + [consoleGlobalMessageManagerFocusStore, chatInputFocusStore], + ([$consoleGlobalMessageManagerFocusStore, $chatInputFocusStore]) => { + return !$consoleGlobalMessageManagerFocusStore && !$chatInputFocusStore; } -); \ No newline at end of file +); diff --git a/front/src/WebRtc/ColorGenerator.ts b/front/src/WebRtc/ColorGenerator.ts new file mode 100644 index 00000000..be192f9f --- /dev/null +++ b/front/src/WebRtc/ColorGenerator.ts @@ -0,0 +1,52 @@ +export function getRandomColor(): string { + const golden_ratio_conjugate = 0.618033988749895; + let hue = Math.random(); + hue += golden_ratio_conjugate; + hue %= 1; + return hsv_to_rgb(hue, 0.5, 0.95); +} + +//todo: test this. +function hsv_to_rgb(hue: number, saturation: number, brightness: number): string { + const h_i = Math.floor(hue * 6); + const f = hue * 6 - h_i; + const p = brightness * (1 - saturation); + const q = brightness * (1 - f * saturation); + const t = brightness * (1 - (1 - f) * saturation); + let r: number, g: number, b: number; + switch (h_i) { + case 0: + r = brightness; + g = t; + b = p; + break; + case 1: + r = q; + g = brightness; + b = p; + break; + case 2: + r = p; + g = brightness; + b = t; + break; + case 3: + r = p; + g = q; + b = brightness; + break; + case 4: + r = t; + g = p; + b = brightness; + break; + case 5: + r = brightness; + g = p; + b = q; + break; + default: + throw "h_i cannot be " + h_i; + } + return "#" + Math.floor(r * 256).toString(16) + Math.floor(g * 256).toString(16) + Math.floor(b * 256).toString(16); +} diff --git a/front/src/WebRtc/DiscussionManager.ts b/front/src/WebRtc/DiscussionManager.ts index ae351f76..a3c928f4 100644 --- a/front/src/WebRtc/DiscussionManager.ts +++ b/front/src/WebRtc/DiscussionManager.ts @@ -1,232 +1,12 @@ -import { HtmlUtils } from "./HtmlUtils"; -import type { UserInputManager } from "../Phaser/UserInput/UserInputManager"; -import { connectionManager } from "../Connexion/ConnectionManager"; -import { GameConnexionTypes } from "../Url/UrlManager"; import { iframeListener } from "../Api/IframeListener"; -import { showReportScreenStore } from "../Stores/ShowReportScreenStore"; - -export type SendMessageCallback = (message: string) => void; +import { chatMessagesStore, chatVisibilityStore } from "../Stores/ChatStore"; export class DiscussionManager { - private mainContainer: HTMLDivElement; - - private divDiscuss?: HTMLDivElement; - private divParticipants?: HTMLDivElement; - private nbpParticipants?: HTMLParagraphElement; - private divMessages?: HTMLParagraphElement; - - private participants: Map = new Map(); - - private activeDiscussion: boolean = false; - - private sendMessageCallBack: Map = new Map< - number | string, - SendMessageCallback - >(); - - private userInputManager?: UserInputManager; - constructor() { - this.mainContainer = HtmlUtils.getElementByIdOrFail("main-container"); - this.createDiscussPart(""); //todo: why do we always use empty string? - iframeListener.chatStream.subscribe((chatEvent) => { - this.addMessage(chatEvent.author, chatEvent.message, false); - this.showDiscussion(); + chatMessagesStore.addExternalMessage(parseInt(chatEvent.author), chatEvent.message); + chatVisibilityStore.set(true); }); - this.onSendMessageCallback("iframe_listener", (message) => { - iframeListener.sendUserInputChat(message); - }); - } - - private createDiscussPart(name: string) { - this.divDiscuss = document.createElement("div"); - this.divDiscuss.classList.add("discussion"); - - const buttonCloseDiscussion: HTMLButtonElement = document.createElement("button"); - buttonCloseDiscussion.classList.add("close-btn"); - buttonCloseDiscussion.innerHTML = ``; - buttonCloseDiscussion.addEventListener("click", () => { - this.hideDiscussion(); - }); - this.divDiscuss.appendChild(buttonCloseDiscussion); - - const myName: HTMLParagraphElement = document.createElement("p"); - myName.innerText = name.toUpperCase(); - this.nbpParticipants = document.createElement("p"); - this.nbpParticipants.innerText = "PARTICIPANTS (1)"; - - this.divParticipants = document.createElement("div"); - this.divParticipants.classList.add("participants"); - - this.divMessages = document.createElement("div"); - this.divMessages.classList.add("messages"); - this.divMessages.innerHTML = "

Local messages

"; - - this.divDiscuss.appendChild(myName); - this.divDiscuss.appendChild(this.nbpParticipants); - this.divDiscuss.appendChild(this.divParticipants); - this.divDiscuss.appendChild(this.divMessages); - - const sendDivMessage: HTMLDivElement = document.createElement("div"); - sendDivMessage.classList.add("send-message"); - const inputMessage: HTMLInputElement = document.createElement("input"); - inputMessage.onfocus = () => { - if (this.userInputManager) { - this.userInputManager.disableControls(); - } - }; - inputMessage.onblur = () => { - if (this.userInputManager) { - this.userInputManager.restoreControls(); - } - }; - inputMessage.type = "text"; - inputMessage.addEventListener("keyup", (event: KeyboardEvent) => { - if (event.key === "Enter") { - event.preventDefault(); - if (inputMessage.value === null || inputMessage.value === "" || inputMessage.value === undefined) { - return; - } - this.addMessage(name, inputMessage.value, true); - for (const callback of this.sendMessageCallBack.values()) { - callback(inputMessage.value); - } - inputMessage.value = ""; - } - }); - sendDivMessage.appendChild(inputMessage); - this.divDiscuss.appendChild(sendDivMessage); - - //append in main container - this.mainContainer.appendChild(this.divDiscuss); - - this.addParticipant("me", "Moi", undefined, true); - } - - public addParticipant( - userId: number | "me", - name: string | undefined, - img?: string | undefined, - isMe: boolean = false - ) { - const divParticipant: HTMLDivElement = document.createElement("div"); - divParticipant.classList.add("participant"); - divParticipant.id = `participant-${userId}`; - - const divImgParticipant: HTMLImageElement = document.createElement("img"); - divImgParticipant.src = "resources/logos/boy.svg"; - if (img !== undefined) { - divImgParticipant.src = img; - } - const divPParticipant: HTMLParagraphElement = document.createElement("p"); - if (!name) { - name = "Anonymous"; - } - divPParticipant.innerText = name; - - divParticipant.appendChild(divImgParticipant); - divParticipant.appendChild(divPParticipant); - - if ( - !isMe && - connectionManager.getConnexionType && - connectionManager.getConnexionType !== GameConnexionTypes.anonymous && - userId !== "me" - ) { - const reportBanUserAction: HTMLButtonElement = document.createElement("button"); - reportBanUserAction.classList.add("report-btn"); - reportBanUserAction.innerText = "Report"; - reportBanUserAction.addEventListener("click", () => { - showReportScreenStore.set({ userId: userId, userName: name ? name : "" }); - }); - divParticipant.appendChild(reportBanUserAction); - } - - this.divParticipants?.appendChild(divParticipant); - - this.participants.set(userId, divParticipant); - - this.updateParticipant(this.participants.size); - } - - public updateParticipant(nb: number) { - if (!this.nbpParticipants) { - return; - } - this.nbpParticipants.innerText = `PARTICIPANTS (${nb})`; - } - - public addMessage(name: string, message: string, isMe: boolean = false) { - const divMessage: HTMLDivElement = document.createElement("div"); - divMessage.classList.add("message"); - if (isMe) { - divMessage.classList.add("me"); - } - - const pMessage: HTMLParagraphElement = document.createElement("p"); - const date = new Date(); - if (isMe) { - name = "Me"; - } else { - name = HtmlUtils.escapeHtml(name); - } - pMessage.innerHTML = `${name} - - ${date.getHours()}:${date.getMinutes()} - `; - divMessage.appendChild(pMessage); - - const userMessage: HTMLParagraphElement = document.createElement("p"); - userMessage.innerHTML = HtmlUtils.urlify(message); - userMessage.classList.add("body"); - divMessage.appendChild(userMessage); - this.divMessages?.appendChild(divMessage); - - //automatic scroll when there are new message - setTimeout(() => { - this.divMessages?.scroll({ - top: this.divMessages?.scrollTop + divMessage.getBoundingClientRect().y, - behavior: "smooth", - }); - }, 200); - } - - public removeParticipant(userId: number | string) { - const element = this.participants.get(userId); - if (element) { - element.remove(); - this.participants.delete(userId); - } - //if all participant leave, hide discussion button - - this.sendMessageCallBack.delete(userId); - } - - public onSendMessageCallback(userId: string | number, callback: SendMessageCallback): void { - this.sendMessageCallBack.set(userId, callback); - } - - get activatedDiscussion() { - return this.activeDiscussion; - } - - private showDiscussion() { - this.activeDiscussion = true; - this.divDiscuss?.classList.add("active"); - } - - private hideDiscussion() { - this.activeDiscussion = false; - this.divDiscuss?.classList.remove("active"); - } - - public setUserInputManager(userInputManager: UserInputManager) { - this.userInputManager = userInputManager; - } - - public showDiscussionPart() { - this.showDiscussion(); } } diff --git a/front/src/WebRtc/HtmlUtils.ts b/front/src/WebRtc/HtmlUtils.ts index 942e553f..530eca19 100644 --- a/front/src/WebRtc/HtmlUtils.ts +++ b/front/src/WebRtc/HtmlUtils.ts @@ -2,9 +2,9 @@ export class HtmlUtils { public static getElementByIdOrFail(id: string): T { const elem = document.getElementById(id); if (HtmlUtils.isHtmlElement(elem)) { - return elem; + return elem; } - throw new Error("Cannot find HTML element with id '"+id+"'"); + throw new Error("Cannot find HTML element with id '" + id + "'"); } public static querySelectorOrFail(selector: string): T { @@ -12,7 +12,7 @@ export class HtmlUtils { if (HtmlUtils.isHtmlElement(elem)) { return elem; } - throw new Error("Cannot find HTML element with selector '"+selector+"'"); + throw new Error("Cannot find HTML element with selector '" + selector + "'"); } public static removeElementByIdOrFail(id: string): T { @@ -21,12 +21,12 @@ export class HtmlUtils { elem.remove(); return elem; } - throw new Error("Cannot find HTML element with id '"+id+"'"); + throw new Error("Cannot find HTML element with id '" + id + "'"); } public static escapeHtml(html: string): string { - const text = document.createTextNode(html); - const p = document.createElement('p'); + const text = document.createTextNode(html.replace(/(\r\n|\r|\n)/g, "
")); + const p = document.createElement("p"); p.appendChild(text); return p.innerHTML; } @@ -35,7 +35,7 @@ export class HtmlUtils { const urlRegex = /(https?:\/\/[^\s]+)/g; text = HtmlUtils.escapeHtml(text); return text.replace(urlRegex, (url: string) => { - const link = document.createElement('a'); + const link = document.createElement("a"); link.href = url; link.target = "_blank"; const text = document.createTextNode(url); diff --git a/front/src/WebRtc/MediaManager.ts b/front/src/WebRtc/MediaManager.ts index d9847f44..d7e9f514 100644 --- a/front/src/WebRtc/MediaManager.ts +++ b/front/src/WebRtc/MediaManager.ts @@ -1,16 +1,10 @@ -import { DivImportance, layoutManager } from "./LayoutManager"; +import { layoutManager } from "./LayoutManager"; import { HtmlUtils } from "./HtmlUtils"; -import { discussionManager, SendMessageCallback } from "./DiscussionManager"; import type { UserInputManager } from "../Phaser/UserInput/UserInputManager"; -import { localUserStore } from "../Connexion/LocalUserStore"; -import type { UserSimplePeerInterface } from "./SimplePeer"; -import { SoundMeter } from "../Phaser/Components/SoundMeter"; -import { DISABLE_NOTIFICATIONS } from "../Enum/EnvironmentVariable"; import { localStreamStore } from "../Stores/MediaStore"; import { screenSharingLocalStreamStore } from "../Stores/ScreenSharingStore"; import { helpCameraSettingsVisibleStore } from "../Stores/HelpCameraSettingsStore"; -export type UpdatedLocalStreamCallback = (media: MediaStream | null) => void; export type StartScreenSharingCallback = (media: MediaStream) => void; export type StopScreenSharingCallback = (media: MediaStream) => void; @@ -21,16 +15,11 @@ export class MediaManager { startScreenSharingCallBacks: Set = new Set(); stopScreenSharingCallBacks: Set = new Set(); - private focused: boolean = true; - private triggerCloseJistiFrame: Map = new Map(); private userInputManager?: UserInputManager; constructor() { - //Check of ask notification navigator permission - this.getNotification(); - localStreamStore.subscribe((result) => { if (result.type === "error") { console.error(result.error); @@ -182,67 +171,35 @@ export class MediaManager { } } - public addNewMessage(name: string, message: string, isMe: boolean = false) { - discussionManager.addMessage(name, message, isMe); - - //when there are new message, show discussion - if (!discussionManager.activatedDiscussion) { - discussionManager.showDiscussionPart(); - } - } - - public addSendMessageCallback(userId: string | number, callback: SendMessageCallback) { - discussionManager.onSendMessageCallback(userId, callback); - } - public setUserInputManager(userInputManager: UserInputManager) { this.userInputManager = userInputManager; - discussionManager.setUserInputManager(userInputManager); } - public getNotification() { - //Get notification - if (!DISABLE_NOTIFICATIONS && window.Notification && Notification.permission !== "granted") { - if (this.checkNotificationPromise()) { - Notification.requestPermission().catch((err) => { - console.error(`Notification permission error`, err); - }); - } else { - Notification.requestPermission(); - } - } + public hasNotification(): boolean { + return Notification.permission === "granted"; } - /** - * Return true if the browser supports the modern version of the Notification API (which is Promise based) or false - * if we are on Safari... - * - * See https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API - */ - private checkNotificationPromise(): boolean { - try { - Notification.requestPermission().then(); - } catch (e) { - return false; + public requestNotification() { + if (window.Notification && Notification.permission !== "granted") { + return Notification.requestPermission(); + } else { + return Promise.reject(); } - - return true; } public createNotification(userName: string) { - if (this.focused) { + if (document.hasFocus()) { return; } - if (window.Notification && Notification.permission === "granted") { - const title = "WorkAdventure"; + + if (this.hasNotification()) { + const title = `${userName} wants to discuss with you`; const options = { - body: `Hi! ${userName} wants to discuss with you, don't be afraid!`, icon: "/resources/logos/logo-WA-min.png", image: "/resources/logos/logo-WA-min.png", badge: "/resources/logos/logo-WA-min.png", }; new Notification(title, options); - //new Notification(`Hi! ${userName} wants to discuss with you, don't be afraid!`); } } } diff --git a/front/src/WebRtc/ScreenSharingPeer.ts b/front/src/WebRtc/ScreenSharingPeer.ts index 9beab732..18810182 100644 --- a/front/src/WebRtc/ScreenSharingPeer.ts +++ b/front/src/WebRtc/ScreenSharingPeer.ts @@ -1,11 +1,10 @@ import type * as SimplePeerNamespace from "simple-peer"; -import { mediaManager } from "./MediaManager"; -import { STUN_SERVER, TURN_PASSWORD, TURN_SERVER, TURN_USER } from "../Enum/EnvironmentVariable"; import type { RoomConnection } from "../Connexion/RoomConnection"; import { MESSAGE_TYPE_CONSTRAINT, PeerStatus } from "./VideoPeer"; import type { UserSimplePeerInterface } from "./SimplePeer"; -import { Readable, readable, writable, Writable } from "svelte/store"; +import { Readable, readable } from "svelte/store"; import { videoFocusStore } from "../Stores/VideoFocusStore"; +import { getIceServersConfig } from "../Components/Video/utils"; const Peer: SimplePeerNamespace.SimplePeer = require("simple-peer"); @@ -32,21 +31,9 @@ export class ScreenSharingPeer extends Peer { stream: MediaStream | null ) { super({ - initiator: initiator ? initiator : false, - //reconnectTimer: 10000, + initiator, config: { - iceServers: [ - { - urls: STUN_SERVER.split(","), - }, - TURN_SERVER !== "" - ? { - urls: TURN_SERVER.split(","), - username: user.webRtcUser || TURN_USER, - credential: user.webRtcPassword || TURN_PASSWORD, - } - : undefined, - ].filter((value) => value !== undefined), + iceServers: getIceServersConfig(user), }, }); diff --git a/front/src/WebRtc/SimplePeer.ts b/front/src/WebRtc/SimplePeer.ts index 5045a5a3..e30f1b1f 100644 --- a/front/src/WebRtc/SimplePeer.ts +++ b/front/src/WebRtc/SimplePeer.ts @@ -12,6 +12,7 @@ import { localStreamStore, LocalStreamStoreValue, obtainedMediaConstraintStore } import { screenSharingLocalStreamStore } from "../Stores/ScreenSharingStore"; import { discussionManager } from "./DiscussionManager"; import { playersStore } from "../Stores/PlayersStore"; +import { newChatMessageStore } from "../Stores/ChatStore"; export interface UserSimplePeerInterface { userId: number; @@ -155,27 +156,11 @@ export class SimplePeer { const name = this.getName(user.userId); - discussionManager.removeParticipant(user.userId); - this.lastWebrtcUserName = user.webRtcUser; this.lastWebrtcPassword = user.webRtcPassword; const peer = new VideoPeer(user, user.initiator ? user.initiator : false, name, this.Connection, localStream); - //permit to send message - mediaManager.addSendMessageCallback(user.userId, (message: string) => { - peer.write( - new Buffer( - JSON.stringify({ - type: MESSAGE_TYPE_MESSAGE, - name: this.myName.toUpperCase(), - userId: this.userId, - message: message, - }) - ) - ); - }); - peer.toClose = false; // When a connection is established to a video stream, and if a screen sharing is taking place, // the user sharing screen should also initiate a connection to the remote user! diff --git a/front/src/WebRtc/VideoPeer.ts b/front/src/WebRtc/VideoPeer.ts index bde0bcde..9fadef8c 100644 --- a/front/src/WebRtc/VideoPeer.ts +++ b/front/src/WebRtc/VideoPeer.ts @@ -1,14 +1,14 @@ import type * as SimplePeerNamespace from "simple-peer"; import { mediaManager } from "./MediaManager"; -import { STUN_SERVER, TURN_PASSWORD, TURN_SERVER, TURN_USER } from "../Enum/EnvironmentVariable"; import type { RoomConnection } from "../Connexion/RoomConnection"; import { blackListManager } from "./BlackListManager"; import type { Subscription } from "rxjs"; import type { UserSimplePeerInterface } from "./SimplePeer"; -import { get, readable, Readable } from "svelte/store"; +import { get, readable, Readable, Unsubscriber } from "svelte/store"; import { obtainedMediaConstraintStore } from "../Stores/MediaStore"; -import { discussionManager } from "./DiscussionManager"; import { playersStore } from "../Stores/PlayersStore"; +import { chatMessagesStore, chatVisibilityStore, newChatMessageStore } from "../Stores/ChatStore"; +import { getIceServersConfig } from "../Components/Video/utils"; const Peer: SimplePeerNamespace.SimplePeer = require("simple-peer"); @@ -34,6 +34,8 @@ export class VideoPeer extends Peer { public readonly streamStore: Readable; public readonly statusStore: Readable; public readonly constraintsStore: Readable; + private newMessageunsubscriber: Unsubscriber | null = null; + private closing: Boolean = false; //this is used to prevent destroy() from being called twice constructor( public user: UserSimplePeerInterface, @@ -43,21 +45,9 @@ export class VideoPeer extends Peer { localStream: MediaStream | null ) { super({ - initiator: initiator ? initiator : false, - //reconnectTimer: 10000, + initiator, config: { - iceServers: [ - { - urls: STUN_SERVER.split(","), - }, - TURN_SERVER !== "" - ? { - urls: TURN_SERVER.split(","), - username: user.webRtcUser || TURN_USER, - credential: user.webRtcPassword || TURN_PASSWORD, - } - : undefined, - ].filter((value) => value !== undefined), + iceServers: getIceServersConfig(user), }, }); @@ -147,6 +137,20 @@ export class VideoPeer extends Peer { this.on("connect", () => { this._connected = true; + chatMessagesStore.addIncomingUser(this.userId); + + this.newMessageunsubscriber = newChatMessageStore.subscribe((newMessage) => { + if (!newMessage) return; + this.write( + new Buffer( + JSON.stringify({ + type: MESSAGE_TYPE_MESSAGE, + message: newMessage, + }) + ) + ); //send more data + newChatMessageStore.set(null); //This is to prevent a newly created SimplePeer to send an old message a 2nd time. Is there a better way? + }); }); this.on("data", (chunk: Buffer) => { @@ -164,8 +168,9 @@ export class VideoPeer extends Peer { mediaManager.disabledVideoByUserId(this.userId); } } else if (message.type === MESSAGE_TYPE_MESSAGE) { - if (!blackListManager.isBlackListed(message.userId)) { - mediaManager.addNewMessage(message.name, message.message); + if (!blackListManager.isBlackListed(this.userUuid)) { + chatMessagesStore.addExternalMessage(this.userId, message.message); + chatVisibilityStore.set(true); } } 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. @@ -245,18 +250,18 @@ export class VideoPeer extends Peer { /** * This is triggered twice. Once by the server, and once by a remote client disconnecting */ - public destroy(error?: Error): void { + public destroy(): void { try { this._connected = false; - if (!this.toClose) { + if (!this.toClose || this.closing) { return; } + this.closing = true; this.onBlockSubscribe.unsubscribe(); this.onUnBlockSubscribe.unsubscribe(); - discussionManager.removeParticipant(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. - super.destroy(error); + if (this.newMessageunsubscriber) this.newMessageunsubscriber(); + chatMessagesStore.addOutcomingUser(this.userId); + super.destroy(); } catch (err) { console.error("VideoPeer::destroy", err); } diff --git a/front/src/index.ts b/front/src/index.ts index 59e748b4..da243bde 100644 --- a/front/src/index.ts +++ b/front/src/index.ts @@ -1,35 +1,34 @@ -import 'phaser'; +import "phaser"; import GameConfig = Phaser.Types.Core.GameConfig; import "../style/index.scss"; -import {DEBUG_MODE, isMobile} from "./Enum/EnvironmentVariable"; -import {LoginScene} from "./Phaser/Login/LoginScene"; -import {ReconnectingScene} from "./Phaser/Reconnecting/ReconnectingScene"; -import {SelectCharacterScene} from "./Phaser/Login/SelectCharacterScene"; -import {SelectCompanionScene} from "./Phaser/Login/SelectCompanionScene"; -import {EnableCameraScene} from "./Phaser/Login/EnableCameraScene"; -import {CustomizeScene} from "./Phaser/Login/CustomizeScene"; -import WebFontLoaderPlugin from 'phaser3-rex-plugins/plugins/webfontloader-plugin.js'; -import OutlinePipelinePlugin from 'phaser3-rex-plugins/plugins/outlinepipeline-plugin.js'; -import {EntryScene} from "./Phaser/Login/EntryScene"; -import {coWebsiteManager} from "./WebRtc/CoWebsiteManager"; -import {MenuScene} from "./Phaser/Menu/MenuScene"; -import {localUserStore} from "./Connexion/LocalUserStore"; -import {ErrorScene} from "./Phaser/Reconnecting/ErrorScene"; -import {iframeListener} from "./Api/IframeListener"; -import { SelectCharacterMobileScene } from './Phaser/Login/SelectCharacterMobileScene'; -import {HdpiManager} from "./Phaser/Services/HdpiManager"; -import {waScaleManager} from "./Phaser/Services/WaScaleManager"; -import {Game} from "./Phaser/Game/Game"; -import App from './Components/App.svelte'; -import {HtmlUtils} from "./WebRtc/HtmlUtils"; +import { DEBUG_MODE, isMobile } from "./Enum/EnvironmentVariable"; +import { LoginScene } from "./Phaser/Login/LoginScene"; +import { ReconnectingScene } from "./Phaser/Reconnecting/ReconnectingScene"; +import { SelectCharacterScene } from "./Phaser/Login/SelectCharacterScene"; +import { SelectCompanionScene } from "./Phaser/Login/SelectCompanionScene"; +import { EnableCameraScene } from "./Phaser/Login/EnableCameraScene"; +import { CustomizeScene } from "./Phaser/Login/CustomizeScene"; +import WebFontLoaderPlugin from "phaser3-rex-plugins/plugins/webfontloader-plugin.js"; +import OutlinePipelinePlugin from "phaser3-rex-plugins/plugins/outlinepipeline-plugin.js"; +import { EntryScene } from "./Phaser/Login/EntryScene"; +import { coWebsiteManager } from "./WebRtc/CoWebsiteManager"; +import { MenuScene } from "./Phaser/Menu/MenuScene"; +import { localUserStore } from "./Connexion/LocalUserStore"; +import { ErrorScene } from "./Phaser/Reconnecting/ErrorScene"; +import { iframeListener } from "./Api/IframeListener"; +import { SelectCharacterMobileScene } from "./Phaser/Login/SelectCharacterMobileScene"; +import { HdpiManager } from "./Phaser/Services/HdpiManager"; +import { waScaleManager } from "./Phaser/Services/WaScaleManager"; +import { Game } from "./Phaser/Game/Game"; +import App from "./Components/App.svelte"; +import { HtmlUtils } from "./WebRtc/HtmlUtils"; import WebGLRenderer = Phaser.Renderer.WebGL.WebGLRenderer; - -const {width, height} = coWebsiteManager.getGameSize(); +const { width, height } = coWebsiteManager.getGameSize(); const valueGameQuality = localUserStore.getGameQualityValue(); -const fps : Phaser.Types.Core.FPSConfig = { +const fps: Phaser.Types.Core.FPSConfig = { /** * The minimum acceptable rendering rate, in frames per second. */ @@ -53,30 +52,30 @@ const fps : Phaser.Types.Core.FPSConfig = { /** * Apply delta smoothing during the game update to help avoid spikes? */ - smoothStep: false -} + smoothStep: false, +}; // the ?phaserMode=canvas parameter can be used to force Canvas usage const params = new URLSearchParams(document.location.search.substring(1)); const phaserMode = params.get("phaserMode"); let mode: number; switch (phaserMode) { - case 'auto': + case "auto": case null: mode = Phaser.AUTO; break; - case 'canvas': + case "canvas": mode = Phaser.CANVAS; break; - case 'webgl': + case "webgl": mode = Phaser.WEBGL; break; default: throw new Error('phaserMode parameter must be one of "auto", "canvas" or "webgl"'); } -const hdpiManager = new HdpiManager(640*480, 196*196); -const { game: gameSize, real: realSize } = hdpiManager.getOptimalGameSize({width, height}); +const hdpiManager = new HdpiManager(640 * 480, 196 * 196); +const { game: gameSize, real: realSize } = hdpiManager.getOptimalGameSize({ width, height }); const config: GameConfig = { type: mode, @@ -87,9 +86,10 @@ const config: GameConfig = { height: gameSize.height, zoom: realSize.width / gameSize.width, autoRound: true, - resizeInterval: 999999999999 + resizeInterval: 999999999999, }, - scene: [EntryScene, + scene: [ + EntryScene, LoginScene, isMobile() ? SelectCharacterMobileScene : SelectCharacterScene, SelectCompanionScene, @@ -102,37 +102,39 @@ const config: GameConfig = { //resolution: window.devicePixelRatio / 2, fps: fps, dom: { - createContainer: true + createContainer: true, }, render: { pixelArt: true, roundPixels: true, - antialias: false + antialias: false, }, plugins: { - global: [{ - key: 'rexWebFontLoader', - plugin: WebFontLoaderPlugin, - start: true - }] + global: [ + { + key: "rexWebFontLoader", + plugin: WebFontLoaderPlugin, + start: true, + }, + ], }, physics: { default: "arcade", arcade: { debug: DEBUG_MODE, - } + }, }, // Instruct systems with 2 GPU to choose the low power one. We don't need that extra power and we want to save battery powerPreference: "low-power", callbacks: { - postBoot: game => { + postBoot: (game) => { // Install rexOutlinePipeline only if the renderer is WebGL. const renderer = game.renderer; if (renderer instanceof WebGLRenderer) { - game.plugins.install('rexOutlinePipeline', OutlinePipelinePlugin, true); + game.plugins.install("rexOutlinePipeline", OutlinePipelinePlugin, true); } - } - } + }, + }, }; //const game = new Phaser.Game(config); @@ -140,7 +142,7 @@ const game = new Game(config); waScaleManager.setGame(game); -window.addEventListener('resize', function (event) { +window.addEventListener("resize", function (event) { coWebsiteManager.resetStyle(); waScaleManager.applyNewSize(); @@ -153,10 +155,23 @@ coWebsiteManager.onResize.subscribe(() => { iframeListener.init(); const app = new App({ - target: HtmlUtils.getElementByIdOrFail('svelte-overlay'), + target: HtmlUtils.getElementByIdOrFail("svelte-overlay"), props: { - game: game + game: game, }, -}) +}); -export default app +export default app; + +if ("serviceWorker" in navigator) { + window.addEventListener("load", function () { + navigator.serviceWorker + .register("/resources/service-worker.js") + .then((serviceWorker) => { + console.log("Service Worker registered: ", serviceWorker); + }) + .catch((error) => { + console.error("Error registering the Service Worker: ", error); + }); + }); +} diff --git a/front/style/fonts.scss b/front/style/fonts.scss index a49d3967..526f6615 100644 --- a/front/style/fonts.scss +++ b/front/style/fonts.scss @@ -1,9 +1,5 @@ @import "~@fontsource/press-start-2p/index.css"; -*{ - font-family: PixelFont-7,monospace; -} - .nes-btn { font-family: "Press Start 2P"; } diff --git a/front/style/style.scss b/front/style/style.scss index eb34287a..24da5a96 100644 --- a/front/style/style.scss +++ b/front/style/style.scss @@ -1,5 +1,5 @@ *{ - font-family: 'Open Sans', sans-serif; + font-family: Lato; cursor: url('./images/cursor_normal.png'), auto; } * a, button, select{ diff --git a/front/tests/Phaser/Game/RoomTest.ts b/front/tests/Phaser/Game/RoomTest.ts deleted file mode 100644 index 4bd4283a..00000000 --- a/front/tests/Phaser/Game/RoomTest.ts +++ /dev/null @@ -1,58 +0,0 @@ -import "jasmine"; -import { Room } from "../../../src/Connexion/Room"; - -describe("Room getIdFromIdentifier()", () => { - it("should work with an absolute room id and no hash as parameter", () => { - const { roomId, hash } = Room.getIdFromIdentifier('/_/global/maps.workadventu.re/test2.json', '', ''); - expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json'); - expect(hash).toEqual(null); - }); - it("should work with an absolute room id and a hash as parameters", () => { - const { roomId, hash } = Room.getIdFromIdentifier('/_/global/maps.workadventu.re/test2.json#start', '', ''); - expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json'); - expect(hash).toEqual("start"); - }); - it("should work with an absolute room id, regardless of baseUrl or instance", () => { - const { roomId, hash } = Room.getIdFromIdentifier('/_/global/maps.workadventu.re/test2.json', 'https://another.domain/_/global/test.json', 'lol'); - expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json'); - expect(hash).toEqual(null); - }); - - - it("should work with a relative file link and no hash as parameters", () => { - const { roomId, hash } = Room.getIdFromIdentifier('./test2.json', 'https://maps.workadventu.re/test.json', 'global'); - expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json'); - expect(hash).toEqual(null); - }); - it("should work with a relative file link with no dot", () => { - const { roomId, hash } = Room.getIdFromIdentifier('test2.json', 'https://maps.workadventu.re/test.json', 'global'); - expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json'); - expect(hash).toEqual(null); - }); - it("should work with a relative file link two levels deep", () => { - const { roomId, hash } = Room.getIdFromIdentifier('../floor1/Floor1.json', 'https://maps.workadventu.re/floor0/Floor0.json', 'global'); - expect(roomId).toEqual('_/global/maps.workadventu.re/floor1/Floor1.json'); - expect(hash).toEqual(null); - }); - it("should work with a relative file link that rewrite the map domain", () => { - const { roomId, hash } = Room.getIdFromIdentifier('../../maps.workadventure.localhost/Floor1/floor1.json', 'https://maps.workadventu.re/floor0/Floor0.json', 'global'); - expect(roomId).toEqual('_/global/maps.workadventure.localhost/Floor1/floor1.json'); - expect(hash).toEqual(null); - }); - it("should work with a relative file link that rewrite the map instance", () => { - const { roomId, hash } = Room.getIdFromIdentifier('../../../notglobal/maps.workadventu.re/Floor1/floor1.json', 'https://maps.workadventu.re/floor0/Floor0.json', 'global'); - expect(roomId).toEqual('_/notglobal/maps.workadventu.re/Floor1/floor1.json'); - expect(hash).toEqual(null); - }); - it("should work with a relative file link that change the map type", () => { - const { roomId, hash } = Room.getIdFromIdentifier('../../../../@/tcm/is/great', 'https://maps.workadventu.re/floor0/Floor0.json', 'global'); - expect(roomId).toEqual('@/tcm/is/great'); - expect(hash).toEqual(null); - }); - - it("should work with a relative file link and a hash as parameters", () => { - const { roomId, hash } = Room.getIdFromIdentifier('./test2.json#start', 'https://maps.workadventu.re/test.json', 'global'); - expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json'); - expect(hash).toEqual("start"); - }); -}); \ No newline at end of file diff --git a/front/webpack.config.ts b/front/webpack.config.ts index b6efb389..37362baf 100644 --- a/front/webpack.config.ts +++ b/front/webpack.config.ts @@ -7,7 +7,6 @@ import MiniCssExtractPlugin from "mini-css-extract-plugin"; import sveltePreprocess from "svelte-preprocess"; import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin"; import NodePolyfillPlugin from "node-polyfill-webpack-plugin"; -import { DISPLAY_TERMS_OF_USE } from "./src/Enum/EnvironmentVariable"; const mode = process.env.NODE_ENV ?? "development"; const buildNpmTypingsForApi = !!process.env.BUILD_TYPINGS; diff --git a/front/yarn.lock b/front/yarn.lock index fec87661..6ee607d3 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -262,10 +262,10 @@ "@types/mime" "^1" "@types/node" "*" -"@types/simple-peer@^9.6.0": - version "9.6.3" - resolved "https://registry.yarnpkg.com/@types/simple-peer/-/simple-peer-9.6.3.tgz#aa118a57e036f4ce2059a7e25367526a4764206d" - integrity sha512-zrXEBch9tF4NgkZDsGR3c1D0kq99M1bBCjzEyL0PVfEWzCIXrK64TuxRz3XKOx1B0KoEQ9kTs+AhMDuQaHy5RQ== +"@types/simple-peer@^9.11.1": + version "9.11.1" + resolved "https://registry.yarnpkg.com/@types/simple-peer/-/simple-peer-9.11.1.tgz#bef6ff1e75178d83438e33aa6a4df2fd98fded1d" + integrity sha512-Pzqbau/WlivSXdRC0He2Wz/ANj2wbi4gzJrtysZz93jvOyI2jo/ibMjUe6AvPllFl/UO6QXT/A0Rcp44bDQB5A== dependencies: "@types/node" "*" @@ -5008,7 +5008,7 @@ signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== -simple-peer@^9.6.2: +simple-peer@^9.11.0: version "9.11.0" resolved "https://registry.yarnpkg.com/simple-peer/-/simple-peer-9.11.0.tgz#e8d27609c7a610c3ddd75767da868e8daab67571" integrity sha512-qvdNu/dGMHBm2uQ7oLhQBMhYlrOZC1ywXNCH/i8I4etxR1vrjCnU6ZSQBptndB1gcakjo2+w4OHo7Sjza1SHxg== diff --git a/maps/Floor1/floor1.json b/maps/Floor1/floor1.json index 1894ed42..fec5937e 100644 --- a/maps/Floor1/floor1.json +++ b/maps/Floor1/floor1.json @@ -1,11 +1,4 @@ { "compressionlevel":-1, - "editorsettings": - { - "export": - { - "target":"." - } - }, "height":26, "infinite":false, "layers":[ @@ -101,7 +94,7 @@ "opacity":1, "properties":[ { - "name":"exitSceneUrl", + "name":"exitUrl", "type":"string", "value":"\/@\/tcm\/workadventure\/floor2#down-the-stairs" }], @@ -119,7 +112,7 @@ "opacity":1, "properties":[ { - "name":"exitSceneUrl", + "name":"exitUrl", "type":"string", "value":"\/@\/tcm\/workadventure\/floor2#down-the-stairs-secours" }], @@ -264,7 +257,7 @@ "nextobjectid":1, "orientation":"orthogonal", "renderorder":"right-down", - "tiledversion":"1.3.3", + "tiledversion":"2021.03.23", "tileheight":32, "tilesets":[ { @@ -1959,6 +1952,6 @@ }], "tilewidth":32, "type":"map", - "version":1.2, + "version":1.5, "width":46 } \ No newline at end of file diff --git a/pusher/src/Controller/AuthenticateController.ts b/pusher/src/Controller/AuthenticateController.ts index 3012e275..e937ece9 100644 --- a/pusher/src/Controller/AuthenticateController.ts +++ b/pusher/src/Controller/AuthenticateController.ts @@ -39,9 +39,7 @@ export class AuthenticateController extends BaseController { if (typeof organizationMemberToken != "string") throw new Error("No organization token"); const data = await adminApi.fetchMemberDataByToken(organizationMemberToken); const userUuid = data.userUuid; - const organizationSlug = data.organizationSlug; - const worldSlug = data.worldSlug; - const roomSlug = data.roomSlug; + const roomUrl = data.roomUrl; const mapUrlStart = data.mapUrlStart; const textures = data.textures; @@ -52,9 +50,7 @@ export class AuthenticateController extends BaseController { JSON.stringify({ authToken, userUuid, - organizationSlug, - worldSlug, - roomSlug, + roomUrl, mapUrlStart, organizationMemberToken, textures, diff --git a/pusher/src/Controller/IoSocketController.ts b/pusher/src/Controller/IoSocketController.ts index a6fddb34..0d5edea2 100644 --- a/pusher/src/Controller/IoSocketController.ts +++ b/pusher/src/Controller/IoSocketController.ts @@ -16,19 +16,21 @@ import { SendUserMessage, ServerToClientMessage, CompanionMessage, - EmotePromptMessage, VariableMessage, + EmotePromptMessage, + VariableMessage, } from "../Messages/generated/messages_pb"; import { UserMovesMessage } from "../Messages/generated/messages_pb"; import { TemplatedApp } from "uWebSockets.js"; import { parse } from "query-string"; import { jwtTokenManager } from "../Services/JWTTokenManager"; -import { adminApi, CharacterTexture, FetchMemberDataByUuidResponse } from "../Services/AdminApi"; +import { adminApi, FetchMemberDataByUuidResponse } from "../Services/AdminApi"; import { SocketManager, socketManager } from "../Services/SocketManager"; import { emitInBatch } from "../Services/IoSocketHelpers"; import { ADMIN_API_TOKEN, ADMIN_API_URL, SOCKET_IDLE_TIMER } from "../Enum/EnvironmentVariable"; import { Zone } from "_Model/Zone"; import { ExAdminSocketInterface } from "_Model/Websocket/ExAdminSocketInterface"; import { v4 } from "uuid"; +import { CharacterTexture } from "../Services/AdminApi/CharacterTexture"; export class IoSocketController { private nextUserId: number = 1; @@ -221,14 +223,12 @@ export class IoSocketController { memberVisitCardUrl = userData.visitCardUrl; memberTextures = userData.textures; if ( - !room.public && room.policyType === GameRoomPolicyTypes.USE_TAGS_POLICY && (userData.anonymous === true || !room.canAccess(memberTags)) ) { throw new Error("Insufficient privileges to access this room"); } if ( - !room.public && room.policyType === GameRoomPolicyTypes.MEMBERS_ONLY_POLICY && userData.anonymous === true ) { diff --git a/pusher/src/Controller/MapController.ts b/pusher/src/Controller/MapController.ts index 1df828d4..6ea2f19d 100644 --- a/pusher/src/Controller/MapController.ts +++ b/pusher/src/Controller/MapController.ts @@ -2,6 +2,9 @@ import { HttpRequest, HttpResponse, TemplatedApp } from "uWebSockets.js"; import { BaseController } from "./BaseController"; import { parse } from "query-string"; import { adminApi } from "../Services/AdminApi"; +import { ADMIN_API_URL } from "../Enum/EnvironmentVariable"; +import { GameRoomPolicyTypes } from "../Model/PusherRoom"; +import { MapDetailsData } from "../Services/AdminApi/MapDetailsData"; export class MapController extends BaseController { constructor(private App: TemplatedApp) { @@ -25,35 +28,46 @@ export class MapController extends BaseController { const query = parse(req.getQuery()); - if (typeof query.organizationSlug !== "string") { - console.error("Expected organizationSlug parameter"); + if (typeof query.playUri !== "string") { + console.error("Expected playUri parameter in /map endpoint"); res.writeStatus("400 Bad request"); this.addCorsHeaders(res); - res.end("Expected organizationSlug parameter"); + res.end("Expected playUri parameter"); return; } - if (typeof query.worldSlug !== "string") { - console.error("Expected worldSlug parameter"); - res.writeStatus("400 Bad request"); + + // If no admin URL is set, let's react on '/_/[instance]/[map url]' URLs + if (!ADMIN_API_URL) { + const roomUrl = new URL(query.playUri); + + const match = /\/_\/[^/]+\/(.+)/.exec(roomUrl.pathname); + if (!match) { + res.writeStatus("404 Not Found"); + this.addCorsHeaders(res); + res.end(JSON.stringify({})); + return; + } + + const mapUrl = roomUrl.protocol + "//" + match[1]; + + res.writeStatus("200 OK"); this.addCorsHeaders(res); - res.end("Expected worldSlug parameter"); - return; - } - if (typeof query.roomSlug !== "string" && query.roomSlug !== undefined) { - console.error("Expected only one roomSlug parameter"); - res.writeStatus("400 Bad request"); - this.addCorsHeaders(res); - res.end("Expected only one roomSlug parameter"); + res.end( + JSON.stringify({ + mapUrl, + policy_type: GameRoomPolicyTypes.ANONYMOUS_POLICY, + roomSlug: "", // Deprecated + tags: [], + textures: [], + } as MapDetailsData) + ); + return; } (async () => { try { - const mapDetails = await adminApi.fetchMapDetails( - query.organizationSlug as string, - query.worldSlug as string, - query.roomSlug as string | undefined - ); + const mapDetails = await adminApi.fetchMapDetails(query.playUri as string); res.writeStatus("200 OK"); this.addCorsHeaders(res); diff --git a/pusher/src/Model/PusherRoom.ts b/pusher/src/Model/PusherRoom.ts index f0dd0e8f..f2d656c6 100644 --- a/pusher/src/Model/PusherRoom.ts +++ b/pusher/src/Model/PusherRoom.ts @@ -1,19 +1,26 @@ import { ExSocketInterface } from "_Model/Websocket/ExSocketInterface"; import { PositionDispatcher } from "./PositionDispatcher"; import { ViewportInterface } from "_Model/Websocket/ViewportMessage"; -import { extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous } from "./RoomIdentifier"; import { arrayIntersect } from "../Services/ArrayHelper"; -import {GroupDescriptor, UserDescriptor, ZoneEventListener} from "_Model/Zone"; -import {apiClientRepository} from "../Services/ApiClientRepository"; +import { GroupDescriptor, UserDescriptor, ZoneEventListener } from "_Model/Zone"; +import { apiClientRepository } from "../Services/ApiClientRepository"; import { - BatchToPusherMessage, BatchToPusherRoomMessage, EmoteEventMessage, GroupLeftZoneMessage, - GroupUpdateZoneMessage, RoomMessage, SubMessage, - UserJoinedZoneMessage, UserLeftZoneMessage, UserMovedMessage, VariableMessage, - ZoneMessage + BatchToPusherMessage, + BatchToPusherRoomMessage, + EmoteEventMessage, + GroupLeftZoneMessage, + GroupUpdateZoneMessage, + RoomMessage, + SubMessage, + UserJoinedZoneMessage, + UserLeftZoneMessage, + UserMovedMessage, + VariableMessage, + ZoneMessage, } from "../Messages/generated/messages_pb"; import Debug from "debug"; -import {ClientReadableStream} from "grpc"; -import {ExAdminSocketInterface} from "_Model/Websocket/ExAdminSocketInterface"; +import { ClientReadableStream } from "grpc"; +import { ExAdminSocketInterface } from "_Model/Websocket/ExAdminSocketInterface"; const debug = Debug("room"); @@ -25,20 +32,15 @@ export enum GameRoomPolicyTypes { export class PusherRoom { private readonly positionNotifier: PositionDispatcher; - public readonly public: boolean; public tags: string[]; public policyType: GameRoomPolicyTypes; - public readonly roomSlug: string; - public readonly worldSlug: string = ""; - public readonly organizationSlug: string = ""; private versionNumber: number = 1; private backConnection!: ClientReadableStream; private isClosing: boolean = false; private listeners: Set = new Set(); public readonly variables = new Map(); - constructor(public readonly roomId: string, private socketListener: ZoneEventListener) { - this.public = isRoomAnonymous(roomId); + constructor(public readonly roomUrl: string, private socketListener: ZoneEventListener) { this.tags = []; this.policyType = GameRoomPolicyTypes.ANONYMOUS_POLICY; @@ -52,7 +54,7 @@ export class PusherRoom { } // A zone is 10 sprites wide. - this.positionNotifier = new PositionDispatcher(this.roomId, 320, 320, this.socketListener); + this.positionNotifier = new PositionDispatcher(this.roomUrl, 320, 320, this.socketListener); } public setViewport(socket: ExSocketInterface, viewport: ViewportInterface): void { @@ -121,7 +123,7 @@ export class PusherRoom { // Let's close all connections linked to that room for (const listener of this.listeners) { listener.disconnecting = true; - listener.end(1011, "Connection error between pusher and back server") + listener.end(1011, "Connection error between pusher and back server"); } } }); @@ -132,7 +134,7 @@ export class PusherRoom { // Let's close all connections linked to that room for (const listener of this.listeners) { listener.disconnecting = true; - listener.end(1011, "Connection closed between pusher and back server") + listener.end(1011, "Connection closed between pusher and back server"); } } }); diff --git a/pusher/src/Model/RoomIdentifier.ts b/pusher/src/Model/RoomIdentifier.ts deleted file mode 100644 index d1de8800..00000000 --- a/pusher/src/Model/RoomIdentifier.ts +++ /dev/null @@ -1,30 +0,0 @@ -//helper functions to parse room IDs - -export const isRoomAnonymous = (roomID: string): boolean => { - if (roomID.startsWith("_/")) { - return true; - } else if (roomID.startsWith("@/")) { - return false; - } else { - throw new Error("Incorrect room ID: " + roomID); - } -}; - -export const extractRoomSlugPublicRoomId = (roomId: string): string => { - const idParts = roomId.split("/"); - if (idParts.length < 3) throw new Error("Incorrect roomId: " + roomId); - return idParts.slice(2).join("/"); -}; -export interface extractDataFromPrivateRoomIdResponse { - organizationSlug: string; - worldSlug: string; - roomSlug: string; -} -export const extractDataFromPrivateRoomId = (roomId: string): extractDataFromPrivateRoomIdResponse => { - const idParts = roomId.split("/"); - if (idParts.length < 4) throw new Error("Incorrect roomId: " + roomId); - const organizationSlug = idParts[1]; - const worldSlug = idParts[2]; - const roomSlug = idParts[3]; - return { organizationSlug, worldSlug, roomSlug }; -}; diff --git a/pusher/src/Model/Websocket/ExAdminSocketInterface.ts b/pusher/src/Model/Websocket/ExAdminSocketInterface.ts index 7599c82c..572bd0fe 100644 --- a/pusher/src/Model/Websocket/ExAdminSocketInterface.ts +++ b/pusher/src/Model/Websocket/ExAdminSocketInterface.ts @@ -10,7 +10,6 @@ import { SubMessage, } from "../../Messages/generated/messages_pb"; import { WebSocket } from "uWebSockets.js"; -import { CharacterTexture } from "../../Services/AdminApi"; import { ClientDuplexStream } from "grpc"; import { Zone } from "_Model/Zone"; diff --git a/pusher/src/Model/Websocket/ExSocketInterface.ts b/pusher/src/Model/Websocket/ExSocketInterface.ts index 9a92a0e7..ff5ed211 100644 --- a/pusher/src/Model/Websocket/ExSocketInterface.ts +++ b/pusher/src/Model/Websocket/ExSocketInterface.ts @@ -9,9 +9,9 @@ import { SubMessage, } from "../../Messages/generated/messages_pb"; import { WebSocket } from "uWebSockets.js"; -import { CharacterTexture } from "../../Services/AdminApi"; import { ClientDuplexStream } from "grpc"; import { Zone } from "_Model/Zone"; +import { CharacterTexture } from "../../Services/AdminApi/CharacterTexture"; export type BackConnection = ClientDuplexStream; diff --git a/pusher/src/Services/AdminApi.ts b/pusher/src/Services/AdminApi.ts index 2cbac52c..30fa8e5d 100644 --- a/pusher/src/Services/AdminApi.ts +++ b/pusher/src/Services/AdminApi.ts @@ -1,11 +1,12 @@ import { ADMIN_API_TOKEN, ADMIN_API_URL } from "../Enum/EnvironmentVariable"; import Axios from "axios"; import { GameRoomPolicyTypes } from "_Model/PusherRoom"; +import { CharacterTexture } from "./AdminApi/CharacterTexture"; +import { MapDetailsData } from "./AdminApi/MapDetailsData"; +import { RoomRedirect } from "./AdminApi/RoomRedirect"; export interface AdminApiData { - organizationSlug: string; - worldSlug: string; - roomSlug: string; + roomUrl: string; mapUrlStart: string; tags: string[]; policy_type: number; @@ -14,25 +15,11 @@ export interface AdminApiData { textures: CharacterTexture[]; } -export interface MapDetailsData { - roomSlug: string; - mapUrl: string; - policy_type: GameRoomPolicyTypes; - tags: string[]; -} - export interface AdminBannedData { is_banned: boolean; message: string; } -export interface CharacterTexture { - id: number; - level: number; - url: string; - rights: string; -} - export interface FetchMemberDataByUuidResponse { uuid: string; tags: string[]; @@ -43,24 +30,15 @@ export interface FetchMemberDataByUuidResponse { } class AdminApi { - async fetchMapDetails( - organizationSlug: string, - worldSlug: string, - roomSlug: string | undefined - ): Promise { + async fetchMapDetails(playUri: string): Promise { if (!ADMIN_API_URL) { return Promise.reject(new Error("No admin backoffice set!")); } - const params: { organizationSlug: string; worldSlug: string; roomSlug?: string } = { - organizationSlug, - worldSlug, + const params: { playUri: string } = { + playUri, }; - if (roomSlug) { - params.roomSlug = roomSlug; - } - const res = await Axios.get(ADMIN_API_URL + "/api/map", { headers: { Authorization: `${ADMIN_API_TOKEN}` }, params, @@ -121,26 +99,20 @@ class AdminApi { ); } - async verifyBanUser( - organizationMemberToken: string, - ipAddress: string, - organization: string, - world: string - ): Promise { + async verifyBanUser(userUuid: string, ipAddress: string, roomUrl: string): Promise { if (!ADMIN_API_URL) { return Promise.reject(new Error("No admin backoffice set!")); } //todo: this call can fail if the corresponding world is not activated or if the token is invalid. Handle that case. return Axios.get( ADMIN_API_URL + - "/api/check-moderate-user/" + - organization + - "/" + - world + + "/api/ban" + "?ipAddress=" + - ipAddress + + encodeURIComponent(ipAddress) + "&token=" + - organizationMemberToken, + encodeURIComponent(userUuid) + + "&roomUrl=" + + encodeURIComponent(roomUrl), { headers: { Authorization: `${ADMIN_API_TOKEN}` } } ).then((data) => { return data.data; diff --git a/pusher/src/Services/AdminApi/CharacterTexture.ts b/pusher/src/Services/AdminApi/CharacterTexture.ts new file mode 100644 index 00000000..055b3033 --- /dev/null +++ b/pusher/src/Services/AdminApi/CharacterTexture.ts @@ -0,0 +1,11 @@ +import * as tg from "generic-type-guard"; + +export const isCharacterTexture = new tg.IsInterface() + .withProperties({ + id: tg.isNumber, + level: tg.isNumber, + url: tg.isString, + rights: tg.isString, + }) + .get(); +export type CharacterTexture = tg.GuardedType; diff --git a/pusher/src/Services/AdminApi/MapDetailsData.ts b/pusher/src/Services/AdminApi/MapDetailsData.ts new file mode 100644 index 00000000..bf711799 --- /dev/null +++ b/pusher/src/Services/AdminApi/MapDetailsData.ts @@ -0,0 +1,20 @@ +import * as tg from "generic-type-guard"; +import { GameRoomPolicyTypes } from "_Model/PusherRoom"; +import { isCharacterTexture } from "./CharacterTexture"; +import { isAny, isNumber } from "generic-type-guard"; + +/*const isNumericEnum = + (vs: T) => + (v: any): v is T => + typeof v === "number" && v in vs;*/ + +export const isMapDetailsData = new tg.IsInterface() + .withProperties({ + roomSlug: tg.isOptional(tg.isString), // deprecated + mapUrl: tg.isString, + policy_type: isNumber, //isNumericEnum(GameRoomPolicyTypes), + tags: tg.isArray(tg.isString), + textures: tg.isArray(isCharacterTexture), + }) + .get(); +export type MapDetailsData = tg.GuardedType; diff --git a/pusher/src/Services/AdminApi/RoomRedirect.ts b/pusher/src/Services/AdminApi/RoomRedirect.ts new file mode 100644 index 00000000..7257ebd3 --- /dev/null +++ b/pusher/src/Services/AdminApi/RoomRedirect.ts @@ -0,0 +1,8 @@ +import * as tg from "generic-type-guard"; + +export const isRoomRedirect = new tg.IsInterface() + .withProperties({ + redirectUrl: tg.isString, + }) + .get(); +export type RoomRedirect = tg.GuardedType; diff --git a/pusher/src/Services/JWTTokenManager.ts b/pusher/src/Services/JWTTokenManager.ts index 2e82929e..cc44fc08 100644 --- a/pusher/src/Services/JWTTokenManager.ts +++ b/pusher/src/Services/JWTTokenManager.ts @@ -9,7 +9,7 @@ class JWTTokenManager { return Jwt.sign({ userUuid: userUuid }, SECRET_KEY, { expiresIn: "200d" }); //todo: add a mechanic to refresh or recreate token } - public async getUserUuidFromToken(token: unknown, ipAddress?: string, room?: string): Promise { + public async getUserUuidFromToken(token: unknown, ipAddress?: string, roomUrl?: string): Promise { if (!token) { throw new Error("An authentication error happened, a user tried to connect without a token."); } @@ -50,8 +50,8 @@ class JWTTokenManager { if (ADMIN_API_URL) { //verify user in admin let promise = new Promise((resolve) => resolve()); - if (ipAddress && room) { - promise = this.verifyBanUser(tokenInterface.userUuid, ipAddress, room); + if (ipAddress && roomUrl) { + promise = this.verifyBanUser(tokenInterface.userUuid, ipAddress, roomUrl); } promise .then(() => { @@ -79,19 +79,9 @@ class JWTTokenManager { }); } - private verifyBanUser(userUuid: string, ipAddress: string, room: string): Promise { - const parts = room.split("/"); - if (parts.length < 3 || parts[0] !== "@") { - return Promise.resolve({ - is_banned: false, - message: "", - }); - } - - const organization = parts[1]; - const world = parts[2]; + private verifyBanUser(userUuid: string, ipAddress: string, roomUrl: string): Promise { return adminApi - .verifyBanUser(userUuid, ipAddress, organization, world) + .verifyBanUser(userUuid, ipAddress, roomUrl) .then((data: AdminBannedData) => { if (data && data.is_banned) { throw new Error("User was banned"); diff --git a/pusher/src/Services/SocketManager.ts b/pusher/src/Services/SocketManager.ts index 8af53831..12597b26 100644 --- a/pusher/src/Services/SocketManager.ts +++ b/pusher/src/Services/SocketManager.ts @@ -33,8 +33,8 @@ import { VariableMessage, } from "../Messages/generated/messages_pb"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; -import { JITSI_ISS, SECRET_JITSI_KEY } from "../Enum/EnvironmentVariable"; -import { adminApi, CharacterTexture } from "./AdminApi"; +import { ADMIN_API_URL, JITSI_ISS, SECRET_JITSI_KEY } from "../Enum/EnvironmentVariable"; +import { adminApi } from "./AdminApi"; import { emitInBatch } from "./IoSocketHelpers"; import Jwt from "jsonwebtoken"; import { JITSI_URL } from "../Enum/EnvironmentVariable"; @@ -45,6 +45,8 @@ import { GroupDescriptor, UserDescriptor, ZoneEventListener } from "_Model/Zone" import Debug from "debug"; import { ExAdminSocketInterface } from "_Model/Websocket/ExAdminSocketInterface"; import { WebSocket } from "uWebSockets.js"; +import { isRoomRedirect } from "./AdminApi/RoomRedirect"; +import { CharacterTexture } from "./AdminApi/CharacterTexture"; const debug = Debug("socket"); @@ -370,24 +372,30 @@ export class SocketManager implements ZoneEventListener { } } - async getOrCreateRoom(roomId: string): Promise { + async getOrCreateRoom(roomUrl: string): Promise { //check and create new world for a room - let world = this.rooms.get(roomId); - if (world === undefined) { - world = new PusherRoom(roomId, this); - if (!world.public) { - await this.updateRoomWithAdminData(world); + let room = this.rooms.get(roomUrl); + if (room === undefined) { + room = new PusherRoom(roomUrl, this); + if (ADMIN_API_URL) { + await this.updateRoomWithAdminData(room); } await world.init(); - this.rooms.set(roomId, world); + this.rooms.set(roomUrl, room); } - return world; + return room; } - public async updateRoomWithAdminData(world: PusherRoom): Promise { - const data = await adminApi.fetchMapDetails(world.organizationSlug, world.worldSlug, world.roomSlug); - world.tags = data.tags; - world.policyType = Number(data.policy_type); + public async updateRoomWithAdminData(room: PusherRoom): Promise { + const data = await adminApi.fetchMapDetails(room.roomUrl); + + if (isRoomRedirect(data)) { + // TODO: if the updated room data is actually a redirect, we need to take everybody on the map + // and redirect everybody to the new location (so we need to close the connection for everybody) + } else { + room.tags = data.tags; + room.policyType = Number(data.policy_type); + } } emitPlayGlobalMessage(client: ExSocketInterface, playglobalmessage: PlayGlobalMessage) { diff --git a/pusher/tests/RoomIdentifierTest.ts b/pusher/tests/RoomIdentifierTest.ts deleted file mode 100644 index c3817ff7..00000000 --- a/pusher/tests/RoomIdentifierTest.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous} from "../src/Model/RoomIdentifier"; - -describe("RoomIdentifier", () => { - it("should flag public id as anonymous", () => { - expect(isRoomAnonymous('_/global/test')).toBe(true); - }); - it("should flag public id as not anonymous", () => { - expect(isRoomAnonymous('@/afup/afup2020/1floor')).toBe(false); - }); - it("should extract roomSlug from public ID", () => { - expect(extractRoomSlugPublicRoomId('_/global/npeguin/test.json')).toBe('npeguin/test.json'); - }); - it("should extract correct from private ID", () => { - const {organizationSlug, worldSlug, roomSlug} = extractDataFromPrivateRoomId('@/afup/afup2020/1floor'); - expect(organizationSlug).toBe('afup'); - expect(worldSlug).toBe('afup2020'); - expect(roomSlug).toBe('1floor'); - }); -}) \ No newline at end of file