diff --git a/package-lock.json b/package-lock.json index 077261637246369a3b7458d86bb223f1489bdfb6..0b2f666cca0e7484cf7e8a28e8ff3eaa1b716ea9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4094,6 +4094,11 @@ "minimalistic-crypto-utils": "^1.0.0" } }, + "emoji-regex": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-6.5.1.tgz", + "integrity": "sha512-PAHp6TxrCy7MGMFidro8uikr+zlJJKJ/Q6mm2ExZ7HwkyR9lSVFfE3kt36qcwa24BQL7y0G9axycGjK1A/0uNQ==" + }, "emojis-list": { "version": "2.1.0", "resolved": "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz", @@ -7939,6 +7944,14 @@ "immediate": "~3.0.5" } }, + "linkify-it": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.0.3.tgz", + "integrity": "sha1-2UpGSPmxwXnWT6lykSaL22zpQ08=", + "requires": { + "uc.micro": "^1.0.1" + } + }, "listr": { "version": "0.14.1", "resolved": "https://registry.yarnpkg.com/listr/-/listr-0.14.1.tgz", @@ -8217,6 +8230,16 @@ "resolved": "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=" }, + "lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, "lodash.mergewith": { "version": "4.6.1", "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz", @@ -10540,6 +10563,18 @@ "prop-types": "^15.6.0" } }, + "react-emoji-render": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/react-emoji-render/-/react-emoji-render-0.4.6.tgz", + "integrity": "sha512-ARB8E4j/dndQxC7Bn4b+Oymt7pqhh9GjP87NYcxC8KONejysnXD5O9KpnJeW/U3Ke3+XsWrWAr9K5riVA6emfg==", + "requires": { + "classnames": "^2.2.5", + "emoji-regex": "^6.4.1", + "lodash.flatten": "^4.4.0", + "prop-types": "^15.5.8", + "string-replace-to-array": "^1.0.1" + } + }, "react-file-reader-input": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/react-file-reader-input/-/react-file-reader-input-1.1.4.tgz", @@ -10567,6 +10602,16 @@ "invariant": "^2.1.1" } }, + "react-linkify": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/react-linkify/-/react-linkify-0.2.2.tgz", + "integrity": "sha512-0S8cvUNtEgfJpIGDPKklyrnrTffJ63WuJAc4KaYLBihl5TjgH5cHUmYD+AXLpsV+CVmfoo/56SUNfrZcY4zYMQ==", + "requires": { + "linkify-it": "^2.0.3", + "prop-types": "^15.5.8", + "tlds": "^1.57.0" + } + }, "react-select": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/react-select/-/react-select-1.3.0.tgz", @@ -12018,6 +12063,16 @@ "resolved": "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=" }, + "string-replace-to-array": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string-replace-to-array/-/string-replace-to-array-1.0.3.tgz", + "integrity": "sha1-yT66mZpe4k1zGuu69auja18Y978=", + "requires": { + "invariant": "^2.2.1", + "lodash.flatten": "^4.2.0", + "lodash.isstring": "^4.0.1" + } + }, "string-template": { "version": "0.2.1", "resolved": "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz", @@ -12680,6 +12735,11 @@ "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.0.2.tgz", "integrity": "sha512-2NM0auVBGft5tee/OxP4PI3d8WItkDM+fPnaRAVo6xTDI2knbz9eC5ArWGqtGlYqiH3RU5yMpdyTTO7MguC4ow==" }, + "tlds": { + "version": "1.203.1", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.203.1.tgz", + "integrity": "sha512-7MUlYyGJ6rSitEZ3r1Q1QNV8uSIzapS8SmmhSusBuIc7uIxPPwsKllEP0GRp1NS6Ik6F+fRZvnjDWm3ecv2hDw==" + }, "tmp": { "version": "0.0.33", "resolved": "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz", @@ -12881,6 +12941,11 @@ "resolved": "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.18.tgz", "integrity": "sha1-p7/ZL1bt+xFwg7aeMdKqiILUse0=" }, + "uc.micro": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.5.tgz", + "integrity": "sha512-JoLI4g5zv5qNyT09f4YAvEZIIV1oOjqnewYg5D38dkQljIzpPT296dbIGvKro3digYI1bkb7W6EP1y4uDlmzLg==" + }, "uglify-es": { "version": "3.3.9", "resolved": "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.3.9.tgz", diff --git a/package.json b/package.json index c6c9a63532aa1c678267cc52a3ac1234651853e0..083ee4d6562cc2711630de06b0be9f424c241157 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,9 @@ "raven-js": "^3.20.1", "react": "^16.1.1", "react-dom": "^16.1.1", + "react-emoji-render": "^0.4.6", "react-intl": "^2.4.0", + "react-linkify": "^0.2.2", "screenfull": "^3.3.2", "super-hands": "github:mozillareality/aframe-super-hands-component#feature/drawing", "three": "github:mozillareality/three.js#8b1886c384371c3e6305b757d1db7577c5201a9b", diff --git a/src/assets/images/presence_desktop.png b/src/assets/images/presence_desktop.png new file mode 100755 index 0000000000000000000000000000000000000000..4dbaafa1733fb55971581d9c2fd368f4ca9e0971 Binary files /dev/null and b/src/assets/images/presence_desktop.png differ diff --git a/src/assets/images/presence_phone.png b/src/assets/images/presence_phone.png new file mode 100755 index 0000000000000000000000000000000000000000..4b18d742ad8c9ddbb71fe7e1b9d897e48c73d5bb Binary files /dev/null and b/src/assets/images/presence_phone.png differ diff --git a/src/assets/images/presence_vr.png b/src/assets/images/presence_vr.png new file mode 100755 index 0000000000000000000000000000000000000000..fde03d7020a2252a3722c76ee1ebe21dc6f488ef Binary files /dev/null and b/src/assets/images/presence_vr.png differ diff --git a/src/assets/stylesheets/2d-hud.scss b/src/assets/stylesheets/2d-hud.scss index 513ba499f7600ae67d14ce1e01372a9f221e8a37..197c036f569d78ac8b81e8271eb3aeadb1a477ff 100644 --- a/src/assets/stylesheets/2d-hud.scss +++ b/src/assets/stylesheets/2d-hud.scss @@ -14,7 +14,7 @@ &:local(.column) { flex-direction: column; - bottom: 20px; + bottom: 0; z-index: 1; } } diff --git a/src/assets/stylesheets/entry.scss b/src/assets/stylesheets/entry.scss index b2d1727240b8c1a93775ec0a396b08e15489879a..f0424741082257a992e0adfd24de80703d1899c7 100644 --- a/src/assets/stylesheets/entry.scss +++ b/src/assets/stylesheets/entry.scss @@ -84,6 +84,7 @@ margin: 24px; min-height: 150px; height: 100%; + width: 100%; :local(.title) { @extend %top-title; @@ -93,14 +94,30 @@ margin-left: 8px; } + :local(.name) { + @extend %top-title; + @extend %glass-text; + margin-bottom: 4px; + margin-right: 8px; + margin-left: 8px; + } + + :local(.lobby) { + margin-bottom: 24px; + margin-right: 8px; + margin-left: 8px; + font-size: 0.9em; + } + :local(.center) { @extend %glass-text; flex: 10; + width: 100%; } :local(.profile-name) { margin-top: 4px; - margin-bottom: 16px; + margin-bottom: 32px; @extend %default-font; font-size: 1.1em; color: $action-color; diff --git a/src/assets/stylesheets/presence-list.scss b/src/assets/stylesheets/presence-list.scss new file mode 100644 index 0000000000000000000000000000000000000000..3e7ab0517586fff3db4a83c19f6202b5de1d247d --- /dev/null +++ b/src/assets/stylesheets/presence-list.scss @@ -0,0 +1,82 @@ +@import 'shared.scss'; + +:local(.attach-point) { + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-bottom: 5px solid $white-transparent; + position: absolute; + top: -5px; + left: 44px; + + @media(max-width: 768px), (max-height: 420px) { + left: 34px; + } +} + +:local(.presence-list) { + position: absolute; + top: 72px; + left: 16px; + bottom: 0; + z-index: 5; +} + +:local(.contents) { + background-color: white; + border-radius: 12px; + padding: 12px 18px; + min-width: 308px; + max-height: 75%; + overflow-y: auto; + pointer-events: auto; +} + +:local(.rows) { + display: flex; + flex-direction: column; + align-items: center; +} + +:local(.row) { + width: 100%; + display: flex; + flex-direction: row; + font-weight: bold; + justify-content: space-between; + align-items: center; + margin: 6px 0; +} + +:local(.device) { + width: 32px; + height: 32px; + position: relative; + margin: 0px 12px 0px 0px; + + img { + position: absolute; + left: 2px; + width: 32px; + height: 32px; + } +} + +:local(.display-name) { + flex: 10; + white-space: nowrap; + margin-right: 24px; + max-width: 45vw; + overflow: hidden; +} + +:local(.self-display-name) { + text-decoration: underline; +} + +:local(.presence) { + flex: 1; + white-space: nowrap; + text-align: right; +} diff --git a/src/assets/stylesheets/presence-log.scss b/src/assets/stylesheets/presence-log.scss new file mode 100644 index 0000000000000000000000000000000000000000..2738cb3583eee8fe193d652232af609773a983a5 --- /dev/null +++ b/src/assets/stylesheets/presence-log.scss @@ -0,0 +1,73 @@ +@import 'shared.scss'; + +:local(.presence-log) { + align-self: flex-start; + flex: 10; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: flex-start; + margin-bottom: 8px; + margin-top: 90px; + overflow: hidden; + width: 100%; + + :local(.presence-log-entry) { + @extend %default-font; + pointer-events: auto; + + user-select: text; + -moz-user-select: text; + -webkit-user-select: text; + -ms-user-select: text; + + background-color: $white-transparent; + margin: 8px 64px 8px 16px; + font-size: 0.8em; + padding: 8px 16px; + border-radius: 16px; + + a { + color: $action-color; + } + + @media (max-width: 1000px) { + max-width: 75%; + } + } + + :local(.expired) { + visibility: hidden; + opacity: 0; + transform: translateY(-8px); + transition: visibility 0s 0.5s, opacity 0.5s linear, transform 0.5s; + } + +} + +:local(.presence-log-in-room) { + max-height: 200px; + + @media(min-height: 800px) and (min-width: 600px) { + max-height: 400px; + } + + position: absolute; + bottom: 165px; + + :local(.presence-log-entry) { + background-color: $hud-panel-background; + color: $light-text; + + user-select: none; + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + } +} + +:local(.emoji) { + // Undo annoying CSS in emoji plugin + margin: auto !important; + vertical-align: 0em !important; +} diff --git a/src/assets/stylesheets/ui-root.scss b/src/assets/stylesheets/ui-root.scss index 7781143575ff539be1b750da439ad6ea8ddac96d..11de7f546b713ca297e99e92e090bb355e55e334 100644 --- a/src/assets/stylesheets/ui-root.scss +++ b/src/assets/stylesheets/ui-root.scss @@ -50,6 +50,7 @@ width: 100%; max-width: 600px; z-index: 2; + position: relative; :local(.backgrounded) { filter: blur(1px); @@ -164,6 +165,8 @@ border-radius: 24px; font-weight: bold; padding: 8px 18px; + pointer-events: auto; + cursor: pointer; @media (min-width: 769px) and (min-height: 421px) { flex: 1; @@ -177,3 +180,90 @@ margin: 0 12px; } } + +:local(.presence-info-selected) { + color: $action-color; +} + +:local(.message-entry) { + position: relative; + margin: 8px 24px 24px 24px; + height: 48px; + display: flex; + justify-content: center; + align-items: center; + background-color: white; + border: 1px solid #e2e2e2; + border-radius: 16px; +} + +:local(.message-entry-input) { + @extend %default-font; + pointer-events: auto; + appearance: none; + -moz-appearance: none; + -webkit-appearance: none; + outline-style: none; + background-color: transparent; + color: black; + padding: 8px 1.25em; + line-height: 2em; + font-size: 1.1em; + width: 100%; + border: 0px; + height: 32px; + margin-right: 100px; +} + +:local(.message-entry-input)::placeholder{ + color: $dark-grey; + font-weight: 300; + font-style: italic; +} + +:local(.message-entry-submit) { + @extend %action-button; + position: absolute; + right: 12px; + height: 32px; + min-width: 80px; +} + +:local(.message-entry-in-room) { + @media(max-width: 900px) { + display:none; + } + + position: absolute; + left: 16px; + bottom: 20px; + width: 33%; + height: 48px; + display: flex; + justify-content: center; + align-items: center; + background-color: $darker-grey; + border-radius: 16px; + pointer-events: auto; + opacity: 0.3; + transition: opacity 0.25s linear; + + :local(.message-entry-input-in-room) { + color: white; + padding: 8px 1.25em; + } + + :local(.message-entry-submit-in-room) { + border: 0; + visibility: hidden; + } +} + +:local(.message-entry-in-room):hover { + opacity: 1.0; + transition: opacity 0.25s linear; + + :local(.message-entry-submit-in-room) { + visibility: visible; + } +} diff --git a/src/assets/translations.data.json b/src/assets/translations.data.json index 0281dcd9f8d798366a072898e13230e3dc120faf..befeda34041803b7e7320031b776703f372e5129 100644 --- a/src/assets/translations.data.json +++ b/src/assets/translations.data.json @@ -31,6 +31,7 @@ "entry.invite-team-nag": "Invite a hubs team member", "entry.enable-screen-sharing": "Share my desktop", "entry.return-to-vr": "Enter in VR", + "entry.lobby": "Lobby", "profile.save": "Accept", "profile.display_name.validation_warning": "Alphanumerics and hyphens. At least 3 characters, no more than 32", "profile.header": "Name & Avatar", @@ -56,6 +57,12 @@ "autoexit.title_units": " seconds", "autoexit.subtitle": "You have started another session.", "autoexit.cancel": "CANCEL", + "presence.entered_room": "entered the room.", + "presence.join_lobby": "joined the lobby.", + "presence.leave": "left.", + "presence.name_change": "is now known as", + "presence.in_lobby": "Lobby", + "presence.in_room": "In Room", "home.room_create_options": "options", "home.room_create_button": "Create Room", "home.create_name.validation_warning": "Invalid name, limited to 4 to 64 characters and limited symbols.", diff --git a/src/components/scene-preview-camera.js b/src/components/scene-preview-camera.js index 0f3b534f978a05023843c92fee41cdedb769406d..2576d487b7932670bf5c9349649f392feda27372 100644 --- a/src/components/scene-preview-camera.js +++ b/src/components/scene-preview-camera.js @@ -34,6 +34,7 @@ AFRAME.registerComponent("scene-preview-camera", { tick: function() { let t = (new Date().getTime() - this.startTime) / (1000.0 * this.data.duration); + t = Math.min(1.0, Math.max(0.0, t)); if (!this.ranOnePass) { t = t * (2 - t); diff --git a/src/hub.js b/src/hub.js index 9685b24749ceb07f42b8afa778469b048a0a85b8..1e0af568b454a87fef89470404164250250b13b6 100644 --- a/src/hub.js +++ b/src/hub.js @@ -29,6 +29,7 @@ import joystick_dpad4 from "./behaviours/joystick-dpad4"; import msft_mr_axis_with_deadzone from "./behaviours/msft-mr-axis-with-deadzone"; import { PressedMove } from "./activators/pressedmove"; import { ReverseY } from "./activators/reversey"; +import { Presence } from "phoenix"; import "./activators/shortpress"; @@ -253,7 +254,12 @@ async function handleHubChannelJoined(entryManager, hubChannel, data) { environmentScene.setAttribute("gltf-bundle", `src: ${sceneUrl}`); } - remountUI({ hubId: hub.hub_id, hubName: hub.name, hubEntryCode: hub.entry_code }); + remountUI({ + hubId: hub.hub_id, + hubName: hub.name, + hubEntryCode: hub.entry_code, + onSendMessage: hubChannel.sendMessage + }); document .querySelector("#hud-hub-entry-link") @@ -299,7 +305,7 @@ async function runBotMode(scene, entryManager) { entryManager.enterSceneWhenLoaded(new MediaStream(), false); } -document.addEventListener("DOMContentLoaded", () => { +document.addEventListener("DOMContentLoaded", async () => { const scene = document.querySelector("a-scene"); const hubChannel = new HubChannel(store); const entryManager = new SceneEntryManager(hubChannel); @@ -314,22 +320,6 @@ document.addEventListener("DOMContentLoaded", () => { pollForSupportAvailability(isSupportAvailable => remountUI({ isSupportAvailable })); - document.body.addEventListener("connected", () => - remountUI({ occupantCount: NAF.connection.adapter.publisher.initialOccupants.length + 1 }) - ); - - document.body.addEventListener("clientConnected", () => - remountUI({ - occupantCount: Object.keys(NAF.connection.adapter.occupants).length + 1 - }) - ); - - document.body.addEventListener("clientDisconnected", () => - remountUI({ - occupantCount: Object.keys(NAF.connection.adapter.occupants).length + 1 - }) - ); - const platformUnsupportedReason = getPlatformUnsupportedReason(); if (platformUnsupportedReason) { @@ -349,13 +339,13 @@ document.addEventListener("DOMContentLoaded", () => { } } - getAvailableVREntryTypes().then(availableVREntryTypes => { - if (availableVREntryTypes.isInHMD) { - remountUI({ availableVREntryTypes, forcedVREntryType: "vr" }); - } else { - remountUI({ availableVREntryTypes }); - } - }); + const availableVREntryTypes = await getAvailableVREntryTypes(); + + if (availableVREntryTypes.isInHMD) { + remountUI({ availableVREntryTypes, forcedVREntryType: "vr" }); + } else { + remountUI({ availableVREntryTypes }); + } const environmentScene = document.querySelector("#environment-scene"); @@ -379,9 +369,16 @@ document.addEventListener("DOMContentLoaded", () => { console.log(`Hub ID: ${hubId}`); const socket = connectToReticulum(isDebug); + remountUI({ sessionId: socket.params().session_id }); // Hub local channel - const hubPhxChannel = socket.channel(`hub:${hubId}`, {}); + const context = { + mobile: isMobile, + hmd: availableVREntryTypes.isInHMD + }; + + const joinPayload = { profile: store.state.profile, context }; + const hubPhxChannel = socket.channel(`hub:${hubId}`, joinPayload); hubPhxChannel .join() @@ -398,16 +395,101 @@ document.addEventListener("DOMContentLoaded", () => { console.error(res); }); + const hubPhxPresence = new Presence(hubPhxChannel); + const presenceLogEntries = []; + + const addToPresenceLog = entry => { + entry.key = Date.now().toString(); + + presenceLogEntries.push(entry); + remountUI({ presenceLogEntries }); + + // Fade out and then remove + setTimeout(() => { + entry.expired = true; + remountUI({ presenceLogEntries }); + + setTimeout(() => { + presenceLogEntries.splice(presenceLogEntries.indexOf(entry), 1); + remountUI({ presenceLogEntries }); + }, 5000); + }, entryManager.hasEntered() ? 10000 : 30000); // Fade out things faster once entered. + }; + + let isInitialSync = true; + + hubPhxPresence.onSync(() => { + remountUI({ presences: hubPhxPresence.state }); + + if (!isInitialSync) return; + // Wire up join/leave event handlers after initial sync. + isInitialSync = false; + + hubPhxPresence.onJoin((sessionId, current, info) => { + const meta = info.metas[info.metas.length - 1]; + + if (current) { + // Change to existing presence + const isSelf = sessionId === socket.params().session_id; + const currentMeta = current.metas[0]; + + if (!isSelf && currentMeta.presence !== meta.presence && meta.profile.displayName) { + addToPresenceLog({ + type: "entered", + presence: meta.presence, + name: meta.profile.displayName + }); + } + + if (currentMeta.profile && meta.profile && currentMeta.profile.displayName !== meta.profile.displayName) { + addToPresenceLog({ + type: "display_name_changed", + oldName: currentMeta.profile.displayName, + newName: meta.profile.displayName + }); + } + } else { + // New presence + const meta = info.metas[0]; + + if (meta.presence && meta.profile.displayName) { + addToPresenceLog({ + type: "join", + presence: meta.presence, + name: meta.profile.displayName + }); + } + } + }); + + hubPhxPresence.onLeave((sessionId, current, info) => { + if (current && current.metas.length > 0) return; + + const meta = info.metas[0]; + + if (meta.profile.displayName) { + addToPresenceLog({ + type: "leave", + name: meta.profile.displayName + }); + } + }); + }); + hubPhxChannel.on("naf", data => { if (!NAF.connection.adapter) return; NAF.connection.adapter.onData(data); }); - // Reticulum global channel - const retPhxChannel = socket.channel(`ret`, { hub_id: hubId }); - retPhxChannel.join().receive("error", res => { - console.error(res); + hubPhxChannel.on("message", data => { + const userInfo = hubPhxPresence.state[data.session_id]; + if (!userInfo) return; + + addToPresenceLog({ type: "message", name: userInfo.metas[0].profile.displayName, body: data.body }); }); + // Reticulum global channel + const retPhxChannel = socket.channel(`ret`, { hub_id: hubId }); + retPhxChannel.join().receive("error", res => console.error(res)); linkChannel.setSocket(socket); }); diff --git a/src/react-components/presence-list.js b/src/react-components/presence-list.js new file mode 100644 index 0000000000000000000000000000000000000000..9770da2fdbd950b442314d6fbb39f181bdb75ed2 --- /dev/null +++ b/src/react-components/presence-list.js @@ -0,0 +1,61 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import styles from "../assets/stylesheets/presence-list.scss"; +import classNames from "classnames"; +import PhoneImage from "../assets/images/presence_phone.png"; +import DesktopImage from "../assets/images/presence_desktop.png"; +import HMDImage from "../assets/images/presence_vr.png"; +import { FormattedMessage } from "react-intl"; + +export default class PresenceList extends Component { + static propTypes = { + presences: PropTypes.object, + sessionId: PropTypes.string + }; + + domForPresence = ([sessionId, data]) => { + const meta = data.metas[0]; + const context = meta.context; + const profile = meta.profile; + + const image = context && context.mobile ? PhoneImage : context && context.hmd ? HMDImage : DesktopImage; + + return ( + <div className={styles.row} key={sessionId}> + <div className={styles.device}> + <img src={image} /> + </div> + <div + className={classNames({ + [styles.displayName]: true, + [styles.selfDisplayName]: sessionId === this.props.sessionId + })} + > + {profile && profile.displayName} + </div> + <div className={styles.presence}> + <FormattedMessage id={`presence.in_${meta.presence}`} /> + </div> + </div> + ); + }; + + render() { + // Draw self first + return ( + <div className={styles.presenceList}> + <div className={styles.attachPoint} /> + <div className={styles.contents}> + <div className={styles.rows}> + {Object.entries(this.props.presences || {}) + .filter(([k]) => k === this.props.sessionId) + .map(this.domForPresence)} + {Object.entries(this.props.presences || {}) + .filter(([k]) => k !== this.props.sessionId) + .map(this.domForPresence)} + </div> + </div> + </div> + ); + } +} diff --git a/src/react-components/presence-log.js b/src/react-components/presence-log.js new file mode 100644 index 0000000000000000000000000000000000000000..bf9a7b86cb2324546eba0d75c3c152d0b01db4c5 --- /dev/null +++ b/src/react-components/presence-log.js @@ -0,0 +1,63 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import styles from "../assets/stylesheets/presence-log.scss"; +import classNames from "classnames"; +import Linkify from "react-linkify"; +import { toArray as toEmojis } from "react-emoji-render"; +import { FormattedMessage } from "react-intl"; + +export default class PresenceLog extends Component { + static propTypes = { + entries: PropTypes.array, + inRoom: PropTypes.bool + }; + + constructor(props) { + super(props); + } + + domForEntry = e => { + const entryClasses = { + [styles.presenceLogEntry]: true, + [styles.expired]: !!e.expired + }; + + switch (e.type) { + case "join": + case "entered": + return ( + <div key={e.key} className={classNames(entryClasses)}> + <b>{e.name}</b> <FormattedMessage id={`presence.${e.type}_${e.presence}`} /> + </div> + ); + case "leave": + return ( + <div key={e.key} className={classNames(entryClasses)}> + <b>{e.name}</b> <FormattedMessage id={`presence.${e.type}`} /> + </div> + ); + case "display_name_changed": + return ( + <div key={e.key} className={classNames(entryClasses)}> + <b>{e.oldName}</b> <FormattedMessage id="presence.name_change" /> <b>{e.newName}</b>. + </div> + ); + case "message": + return ( + <div key={e.key} className={classNames(entryClasses)}> + <b>{e.name}</b>:{" "} + <Linkify properties={{ target: "_blank", rel: "noopener referrer" }}>{toEmojis(e.body)}</Linkify> + </div> + ); + } + }; + + render() { + const presenceClasses = { + [styles.presenceLog]: true, + [styles.presenceLogInRoom]: this.props.inRoom + }; + + return <div className={classNames(presenceClasses)}>{this.props.entries.map(this.domForEntry)}</div>; + } +} diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js index 153bd64b309abddc14cea0165aeab6cad3f5f3c6..c38b79d5edd9c66345dce6db5c598ca70dc3082e 100644 --- a/src/react-components/ui-root.js +++ b/src/react-components/ui-root.js @@ -27,6 +27,8 @@ import InviteTeamDialog from "./invite-team-dialog.js"; import InviteDialog from "./invite-dialog.js"; import LinkDialog from "./link-dialog.js"; import CreateObjectDialog from "./create-object-dialog.js"; +import PresenceLog from "./presence-log.js"; +import PresenceList from "./presence-list.js"; import TwoDHUD from "./2d-hud"; import { faUsers } from "@fortawesome/free-solid-svg-icons/faUsers"; @@ -67,6 +69,7 @@ class UIRoot extends Component { static propTypes = { enterScene: PropTypes.func, exitScene: PropTypes.func, + onSendMessage: PropTypes.func, concurrentLoadDetector: PropTypes.object, disableAutoExitOnConcurrentLoad: PropTypes.bool, forcedVREntryType: PropTypes.string, @@ -83,8 +86,10 @@ class UIRoot extends Component { platformUnsupportedReason: PropTypes.string, hubId: PropTypes.string, hubName: PropTypes.string, - occupantCount: PropTypes.number, - isSupportAvailable: PropTypes.bool + isSupportAvailable: PropTypes.bool, + presenceLogEntries: PropTypes.array, + presences: PropTypes.object, + sessionId: PropTypes.string }; state = { @@ -93,6 +98,7 @@ class UIRoot extends Component { dialog: null, showInviteDialog: false, showLinkDialog: false, + showPresenceList: false, linkCode: null, linkCodeCancel: null, miniInviteActivated: false, @@ -123,7 +129,8 @@ class UIRoot extends Component { exited: false, - showProfileEntry: false + showProfileEntry: false, + pendingMessage: "" }; componentDidMount() { @@ -426,6 +433,7 @@ class UIRoot extends Component { onProfileFinished = () => { this.setState({ showProfileEntry: false }); + this.props.hubChannel.sendProfileUpdate(); }; beginOrSkipAudioSetup = () => { @@ -567,6 +575,16 @@ class UIRoot extends Component { } }; + sendMessage = e => { + e.preventDefault(); + this.props.onSendMessage(this.state.pendingMessage); + this.setState({ pendingMessage: "" }); + }; + + occupantCount = () => { + return this.props.presences ? Object.entries(this.props.presences).length : 0; + }; + renderExitedPane = () => { let subtitle = null; if (this.props.roomUnavailableReason === "closed") { @@ -657,13 +675,26 @@ class UIRoot extends Component { renderEntryStartPanel = () => { return ( <div className={entryStyles.entryPanel}> - <div className={entryStyles.title}>{this.props.hubName}</div> + <div className={entryStyles.name}>{this.props.hubName}</div> <div className={entryStyles.center}> <div onClick={() => this.setState({ showProfileEntry: true })} className={entryStyles.profileName}> <img src="../assets/images/account.svg" className={entryStyles.profileIcon} /> <div title={this.props.store.state.profile.displayName}>{this.props.store.state.profile.displayName}</div> </div> + + <form onSubmit={this.sendMessage}> + <div className={styles.messageEntry}> + <input + className={styles.messageEntryInput} + value={this.state.pendingMessage} + onFocus={e => e.target.select()} + onChange={e => this.setState({ pendingMessage: e.target.value })} + placeholder="Send a message..." + /> + <input className={styles.messageEntrySubmit} type="submit" value="send" /> + </div> + </form> </div> <div className={entryStyles.buttonContainer}> @@ -949,10 +980,34 @@ class UIRoot extends Component { {(!entryFinished || this.isWaitingForAutoExit()) && ( <div className={styles.uiDialog}> + <PresenceLog entries={this.props.presenceLogEntries || []} /> <div className={dialogBoxContentsClassNames}>{dialogContents}</div> </div> )} + {entryFinished && <PresenceLog inRoom={true} entries={this.props.presenceLogEntries || []} />} + {entryFinished && ( + <form onSubmit={this.sendMessage}> + <div className={styles.messageEntryInRoom}> + <input + className={classNames([styles.messageEntryInput, styles.messageEntryInputInRoom])} + value={this.state.pendingMessage} + onFocus={e => e.target.select()} + onChange={e => { + e.stopPropagation(); + this.setState({ pendingMessage: e.target.value }); + }} + placeholder="Send a message..." + /> + <input + className={classNames([styles.messageEntrySubmit, styles.messageEntrySubmitInRoom])} + type="submit" + value="send" + /> + </div> + </form> + )} + <div className={classNames({ [styles.inviteContainer]: true, @@ -962,14 +1017,14 @@ class UIRoot extends Component { > {!showVREntryButton && ( <button - className={classNames({ [styles.hideSmallScreens]: this.props.occupantCount > 1 && entryFinished })} + className={classNames({ [styles.hideSmallScreens]: this.occupantCount() > 1 && entryFinished })} onClick={() => this.toggleInviteDialog()} > <FormattedMessage id="entry.invite-others-nag" /> </button> )} {!showVREntryButton && - this.props.occupantCount > 1 && + this.occupantCount() > 1 && entryFinished && ( <button onClick={this.onMiniInviteClicked} className={styles.inviteMiniButton}> <span> @@ -1012,11 +1067,21 @@ class UIRoot extends Component { </i> </button> - <div className={styles.presenceInfo}> + <div + onClick={() => this.setState({ showPresenceList: !this.state.showPresenceList })} + className={classNames({ + [styles.presenceInfo]: true, + [styles.presenceInfoSelected]: this.state.showPresenceList + })} + > <FontAwesomeIcon icon={faUsers} /> - <span className={styles.occupantCount}>{this.props.occupantCount || "-"}</span> + <span className={styles.occupantCount}>{this.occupantCount()}</span> </div> + {this.state.showPresenceList && ( + <PresenceList presences={this.props.presences} sessionId={this.props.sessionId} /> + )} + {this.state.entryStep === ENTRY_STEPS.finished ? ( <div> <TwoDHUD.TopHUD diff --git a/src/scene-entry-manager.js b/src/scene-entry-manager.js index 89ad844a76410b3ece79161763ca6be73c7eb733..09f8a1ef5d210dac0417ae247ed02ea51e361edc 100644 --- a/src/scene-entry-manager.js +++ b/src/scene-entry-manager.js @@ -24,6 +24,7 @@ export default class SceneEntryManager { this.scene = document.querySelector("a-scene"); this.cursorController = document.querySelector("#cursor-controller"); this.playerRig = document.querySelector("#player-rig"); + this._entered = false; } init = () => { @@ -32,6 +33,10 @@ export default class SceneEntryManager { }); }; + hasEntered = () => { + return this._entered; + }; + enterScene = async (mediaStream, enterInVR) => { const playerCamera = document.querySelector("#player-camera"); playerCamera.removeAttribute("scene-preview-camera"); @@ -82,6 +87,7 @@ export default class SceneEntryManager { const cursor = this.cursorController.components["cursor-controller"]; cursor.enable(); cursor.setCursorVisibility(true); + this._entered = true; // Delay sending entry event telemetry until VR display is presenting. (async () => { diff --git a/src/utils/hub-channel.js b/src/utils/hub-channel.js index 8e8b50b36c8b5e35e687889b2e14d1a6c7237ffc..328a76a36e46dd2578bc0595ea0280ccfb93eb4d 100644 --- a/src/utils/hub-channel.js +++ b/src/utils/hub-channel.js @@ -87,6 +87,15 @@ export default class HubChannel { this.channel.push("events:object_spawned", spawnEvent); }; + sendProfileUpdate = () => { + this.channel.push("events:profile_updated", { profile: this.store.state.profile }); + }; + + sendMessage = body => { + if (body === "") return; + this.channel.push("message", { body }); + }; + requestSupport = () => { this.channel.push("events:request_support", {}); };