diff --git a/package.json b/package.json index 525603b4ea4b139f4d45c7de964a48d45e68745a..89c2b86a1b3fdc077a6e1c02a6f06cd7c26efbcd 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "aframe-teleport-controls": "^0.3.1", "aframe-xr": "github:brianpeiris/aframe-xr#3162aed", "classnames": "^2.2.5", + "copy-to-clipboard": "^3.0.8", "detect-browser": "^2.1.0", "event-target-shim": "^3.0.1", "form-urlencoded": "^2.0.4", diff --git a/src/assets/stylesheets/2d-hud.css b/src/assets/stylesheets/2d-hud.css index a50436114181d18997248dce77a3cc1d9500363f..024daffb0b6463f58a70d5e7d2ef9565d840de91 100644 --- a/src/assets/stylesheets/2d-hud.css +++ b/src/assets/stylesheets/2d-hud.css @@ -38,7 +38,7 @@ display: flex; align-items: center; justify-content: center; - z-index: 10; + z-index: 1; } :local(.panel) { diff --git a/src/assets/stylesheets/footer.scss b/src/assets/stylesheets/footer.scss new file mode 100644 index 0000000000000000000000000000000000000000..0e2e957677e8c5ebde65ab04c0ab7736890e2c41 --- /dev/null +++ b/src/assets/stylesheets/footer.scss @@ -0,0 +1,125 @@ +@import 'shared'; + +:local(.container) { + position: absolute; + width: 100%; + bottom: 0; + font-size: 1.3em; + display: flex; + flex-direction: column; + pointer-events: auto; + // Position above virtual gamepad controls on mobile + z-index: 1; + +} +:local(.floatingButton) { + display: flex; + justify-content: center; +} +:local(.header), :local(.menu-header) { + display: flex; +} +:local(.header) { + border-bottom: 1px solid rgba(32, 32, 32, 0.65); + + @media (max-width: 768px) { + border-bottom: none; + } +} +:local(.menu-header) { + background-color: transparent; + border-bottom: 1px solid rgba(32, 32, 32, 0.65); + + @media (min-width: 768px) { + display: none; + } +} +:local(.header) { + background-color: rgba(0, 0, 0, 0.65); + + @media (max-width: 768px) { + background-color: transparent; + } + + :local(.hub-info) { + @media (max-width: 768px) { + display: none; + } + } + + :local(.hub-stats) { + @media (max-width: 768px) { + display: none; + } + } +} + +:local(.hub-info) { + flex: 1; + margin: 16px 24px; + display: flex; + align-items: center; + @media (max-width: 768px) { + margin: 16px 8px; + font-size: 0.9em; + } +} +:local(.hub-stats) { + text-align: right; + margin: 16px 24px; + display: flex; + align-items: center; + justify-content: flex-end; + @media (min-width: 768px) { + flex: 1; + } + @media (max-width: 768px) { + margin: 16px 8px; + } + :local(.hub-participant-count) { + margin: 0 12px; + } +} + +:local(.menu) { + padding: 5px 0; + background-color: rgba(0, 0, 0, 0.85); + display: flex; + flex-direction: column; +} +:local(.menu-buttons) { + margin: 0 auto; +} +:local(.menu-button) { + @extend %default-font; + margin: 16px 0; + padding: 0; + display: block; + background: none; + border: none; + color: white; + cursor: pointer; + font-size: 0.8em; + + @media (max-width: 768px) { + flex: 1; + align-self: center; + } + :local(.menu-button__icon) { + background: black; + width: 40px !important; + height: 40px; + border: 3px solid white; + border-radius: 40px; + display: inline-block; + font-size: 22px; + vertical-align: sub; + line-height: 42px; + svg { + margin-left: 0px; + } + } + :local(.menu-button__text) { + margin-left: 16px; + } +} diff --git a/src/assets/stylesheets/hub.scss b/src/assets/stylesheets/hub.scss index c10db8dd196ca6b3dce999605e002893cacd05e6..b7d6a928ceb38e36282373ca184bbef631705025 100644 --- a/src/assets/stylesheets/hub.scss +++ b/src/assets/stylesheets/hub.scss @@ -6,20 +6,16 @@ @import 'profile'; @import 'entry'; @import 'audio'; +@import 'info-dialog'; .a-enter-vr { display: none; } -.rs-base { - top: auto; - bottom: 20px; -} - .a-canvas.a-grab-cursor:hover { - cursor: none; + cursor: none; } .a-canvas.a-grab-cursor:active { - cursor: none; + cursor: none; } diff --git a/src/assets/stylesheets/index.scss b/src/assets/stylesheets/index.scss index b09114616dd142d6272899ea88ca70564511f741..c7e9b5099b4c16841f4cb61a3f7deddb185f25bc 100644 --- a/src/assets/stylesheets/index.scss +++ b/src/assets/stylesheets/index.scss @@ -1,5 +1,6 @@ @import 'shared'; @import 'hub-create'; +@import 'info-dialog'; * { box-sizing: border-box; @@ -226,110 +227,3 @@ body { } } -.overlay { - width: 100%; - height: 100%; - top: 0; - left: 0; - position: absolute; - pointer-events: none; - color: white; - z-index: 2; -} - -.mailing-list-form { - display: flex; - height: 100%; - flex-direction: column; - justify-content: center; - text-align: center; - margin: 0; - - &__first { - width: 100%; - } - - &__email_field { - @extend %rounded-border; - @extend %default-font; - color: $light-text; - font-size: 1.2em; - background-color: transparent; - line-height: 2.0em; - padding-left: 1.25em; - padding-right: 1.25em; - margin: 0.5em 0; - width: 100%; - } - - &__submit { - @extend %bottom-button; - border: 0; - margin-top: 16px; - } - - &__privacy { - margin-top: 10px; - font-size: 0.7em; - } -} - -.dialog { - display: grid; - grid-template-columns: 1fr 20px minmax(200px,500px) 20px 1fr; - grid-template-rows: 1fr 20px 275px 20px 1fr; - width: 100%; - height: 100%; - background-color: rgba(0,0,0,.6); - - &__box { - grid-column: 3; - grid-row: 3; - position: relative; - pointer-events: auto; - - &__contents { - background-color: rgba(0,0,0,0.8); - border-radius: 8px; - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - position: relative; - - &__title { - @extend %top-title; - margin-top: 20px; - } - - &__body { - margin: 40px; - font-size: 1.1em; - margin-top: 20px; - color: $grey-text; - display: flex; - flex-direction: column; - - a { color: white } - } - - &__close { - position: absolute; - left: 12px; - top: 6px; - color: white; - font-size: 1.4em; - - background: none; - cursor: pointer; - border: none; - } - } - } -} - - - diff --git a/src/assets/stylesheets/info-dialog.scss b/src/assets/stylesheets/info-dialog.scss new file mode 100644 index 0000000000000000000000000000000000000000..8312010fc7602c17b5b1221ee423e910f7cdb8cc --- /dev/null +++ b/src/assets/stylesheets/info-dialog.scss @@ -0,0 +1,150 @@ +.dialog-overlay { + width: 100%; + height: 100%; + top: 0; + left: 0; + position: absolute; + pointer-events: none; + color: white; + z-index: 2; +} + +.dialog { + display: grid; + grid-template-columns: 1fr 20px minmax(200px,500px) 20px 1fr; + grid-template-rows: 1fr 20px 275px 20px 1fr; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,.6); + + &__box { + grid-column: 3; + grid-row: 3; + position: relative; + pointer-events: auto; + + &__contents { + background-color: rgba(0,0,0,0.8); + border-radius: 8px; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + position: relative; + + &__title { + @extend %top-title; + } + + &__body { + margin: 40px; + margin-bottom: 0px; + font-size: 1.1em; + margin-top: 20px; + color: $grey-text; + display: flex; + flex-direction: column; + + a { color: white } + } + + &__close { + position: absolute; + left: 12px; + top: 6px; + color: white; + font-size: 1.4em; + + background: none; + cursor: pointer; + border: none; + } + } + } +} + +.invite-form { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + margin: 0; + + &__buttons { + display: flex; + flex-direction: row; + align-items: center; + } + + &__link { + display: flex; + flex-direction: row; + } + + &__link_field { + @extend %rounded-border; + @extend %default-font; + color: $light-text; + font-size: 1.2em; + background-color: transparent; + line-height: 2.0em; + padding-left: 1.25em; + padding-right: 1.25em; + margin: 0.5em 0; + width: 100%; + } + + &__action-button { + @extend %bottom-button; + margin-left: 6px; + margin-right: 6px; + appearance: none; + width: 128px; + text-align: center; + -moz-appearance: none; + -webkit-appearance: none; + } +} + +.mailing-list-form { + display: flex; + height: 100%; + flex-direction: column; + justify-content: center; + text-align: center; + margin: 0; + + &__first { + width: 100%; + } + + &__email_field { + @extend %rounded-border; + @extend %default-font; + color: $light-text; + font-size: 1.2em; + background-color: transparent; + line-height: 2.0em; + padding-left: 1.25em; + padding-right: 1.25em; + margin: 0.5em 0; + width: 100%; + } + + &__submit { + @extend %bottom-button; + border: 0; + margin-top: 16px; + } + + &__privacy { + margin-top: 10px; + font-size: 0.7em; + } +} + + + diff --git a/src/assets/stylesheets/profile.scss b/src/assets/stylesheets/profile.scss index 95f2caa2629d34e3f99fbab242f5cbf28b498038..d392c99abaeb15bfd047ccce1effa7ca501b5d43 100644 --- a/src/assets/stylesheets/profile.scss +++ b/src/assets/stylesheets/profile.scss @@ -16,6 +16,10 @@ margin: 1em 0; } + &__form { + height: 100%; + } + &__box { border-radius: 8px; display: flex; @@ -27,7 +31,9 @@ width: 60vw; min-width: 300px; max-width: 700px; - height: 500px; + min-height: 300px; + max-height: 1000px; + height: 90%; &--darkened { background-color: $darkest-transparent; diff --git a/src/assets/translations.data.json b/src/assets/translations.data.json index 6d29a6306cfb0aa0438a456099ab93f986704fab..5a1ad32fa33a1dce374dedcf9694d01d4793d044 100644 --- a/src/assets/translations.data.json +++ b/src/assets/translations.data.json @@ -13,9 +13,8 @@ "entry.daydream-via-chrome": "Using Google Chrome", "entry.enable-screen-sharing": "Share my desktop", "profile.save": "SAVE", - "profile.display_name.label": "Display name:", "profile.display_name.validation_warning": "Alphanumerics and hyphens. At least 3 characters, no more than 32", - "profile.header": "Your identity", + "profile.header": "Your display name:", "profile.terms.prefix": "I confirm that I am over the age of 13 and agree to the", "profile.terms.privacy": "privacy policy", "profile.terms.conjunction": "and", @@ -33,6 +32,7 @@ "audio.granted-subtitle": "You can still mute yourself in-game", "audio.granted-next": "NEXT", "exit.subtitle.exited": "Your session has ended. Refresh your browser to start a new one.", + "exit.subtitle.closed": "This room is no longer available.", "exit.subtitle.full": "This room is full, please try again later.", "exit.subtitle.connect_error": "Unable to connect to this room, please try again later.", "autoexit.title": "Auto-ending session in ", diff --git a/src/components/stats-plus.css b/src/components/stats-plus.css index fc4417bf506b5a5916c286b72aeaf86189790732..9fc5a1908dbc72abadc6a67c0479e3cfe8d01db3 100644 --- a/src/components/stats-plus.css +++ b/src/components/stats-plus.css @@ -11,16 +11,18 @@ } :global(.rs-fps-counter) { + font-family: monospace; cursor: pointer; position: absolute; - bottom: 0; - left: 0; - padding: 8px; + top: 0; + right: 0; + padding: 8px 12px; color: #aaa; font-size: 10px; } -:global(.rs-mobile) { - bottom: auto; - top: 0; -} \ No newline at end of file +:global(.rs-base) { + right: 10px; + left: auto; + top: 10px; +} diff --git a/src/components/stats-plus.js b/src/components/stats-plus.js index c64100eeafa0e069a10ff2b005181a2dc446435e..2c4c148001c65cb2523bfa564024615a402392a8 100644 --- a/src/components/stats-plus.js +++ b/src/components/stats-plus.js @@ -53,6 +53,7 @@ AFRAME.registerComponent("stats-plus", { this.fpsEl.classList.add("rs-fps-counter"); document.body.appendChild(this.fpsEl); this.lastFpsUpdate = performance.now(); + this.lastFps = 0; this.frameCount = 0; if (scene.isMobile) { @@ -88,8 +89,11 @@ AFRAME.registerComponent("stats-plus", { // Update the fps counter text once a second if (now >= this.lastFpsUpdate + 1000) { - const fps = this.frameCount / ((now - this.lastFpsUpdate) / 1000); - this.fpsEl.innerHTML = Math.round(fps) + " FPS"; + const fps = Math.round(this.frameCount / ((now - this.lastFpsUpdate) / 1000)); + if (fps !== this.lastFps) { + this.fpsEl.innerHTML = Math.round(fps) + " FPS"; + this.lastFps = fps; + } this.lastFpsUpdate = now; this.frameCount = 0; } diff --git a/src/hub.js b/src/hub.js index d7ab7351a4f4b998637a84efc24e8405a46400f9..3cd164eb07a952ee61380bfc96190f84fc3ae83a 100644 --- a/src/hub.js +++ b/src/hub.js @@ -204,9 +204,7 @@ const onReady = async () => { serverURL: process.env.JANUS_SERVER }); - if (!qsTruthy("no_stats")) { - scene.setAttribute("stats-plus", false); - } + scene.setAttribute("stats-plus", false); if (isMobile || qsTruthy("mobile")) { playerRig.setAttribute("virtual-gamepad-controls", {}); @@ -248,6 +246,19 @@ const onReady = async () => { hubChannel.sendEntryEvent().then(() => { store.update({ lastEnteredAt: moment().toJSON() }); }); + 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 + }); }); scene.components["networked-scene"].connect().catch(connectError => { @@ -313,10 +324,10 @@ const onReady = async () => { console.log(`Hub ID: ${hubId}`); const socketProtocol = document.location.protocol === "https:" ? "wss:" : "ws:"; - const socketPort = qs.phx_port || (process.env.NODE_ENV === "production" ? document.location.port : 443); - const socketHost = - qs.phx_host || - (process.env.NODE_ENV === "production" ? document.location.hostname : process.env.DEV_RETICULUM_SERVER); + const [retHost, retPort] = (process.env.DEV_RETICULUM_SERVER || "").split(":"); + const isProd = process.env.NODE_ENV === "production"; + const socketPort = qs.phx_port || (isProd ? document.location.port : retPort) || "443"; + const socketHost = qs.phx_host || (isProd ? document.location.hostname : retHost) || ""; const socketUrl = `${socketProtocol}//${socketHost}${socketPort ? `:${socketPort}` : ""}/socket`; console.log(`Phoenix Channel URL: ${socketUrl}`); @@ -331,7 +342,7 @@ const onReady = async () => { const hub = data.hubs[0]; const defaultSpaceTopic = hub.topics[0]; const gltfBundleUrl = defaultSpaceTopic.assets.find(a => a.asset_type === "gltf_bundle").src; - remountUI({ janusRoomId: defaultSpaceTopic.janus_room_id }); + remountUI({ janusRoomId: defaultSpaceTopic.janus_room_id, hubName: hub.name }); initialEnvironmentEl.setAttribute("gltf-bundle", `src: ${gltfBundleUrl}`); hubChannel.setPhoenixChannel(channel); }) diff --git a/src/react-components/footer.js b/src/react-components/footer.js new file mode 100644 index 0000000000000000000000000000000000000000..3fa68d53f1655ba09f6c42d744c312281ad306fe --- /dev/null +++ b/src/react-components/footer.js @@ -0,0 +1,74 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import FontAwesomeIcon from "@fortawesome/react-fontawesome"; +import faUsers from "@fortawesome/fontawesome-free-solid/faUsers"; +import faEllipsisH from "@fortawesome/fontawesome-free-solid/faEllipsisH"; +import faShareAlt from "@fortawesome/fontawesome-free-solid/faShareAlt"; +import faExclamation from "@fortawesome/fontawesome-free-solid/faExclamation"; +import faTimes from "@fortawesome/fontawesome-free-solid/faTimes"; + +import styles from "../assets/stylesheets/footer.scss"; + +export default class Footer extends Component { + static propTypes = { + hubName: PropTypes.string, + occupantCount: PropTypes.number, + onClickInvite: PropTypes.func, + onClickReport: PropTypes.func + }; + state = { + menuVisible: false + }; + render() { + const menuVisible = this.state.menuVisible; + return ( + <div className={styles.container}> + <div className={styles.header}> + <div className={styles.hubInfo}> + <span>{this.props.hubName}</span> + </div> + <button className={styles.menuButton} onClick={() => this.setState({ menuVisible: !menuVisible })}> + <i className={styles.menuButtonIcon}> + <FontAwesomeIcon icon={menuVisible ? faTimes : faEllipsisH} /> + </i> + </button> + <div className={styles.hubStats}> + <FontAwesomeIcon icon={faUsers} /> + <span className={styles.hubParticipantCount}>{this.props.occupantCount || "-"}</span> + </div> + </div> + {menuVisible && ( + <div className={styles.menu}> + <div className={styles.menuHeader}> + <div className={styles.hubInfo}> + <span>{this.props.hubName}</span> + </div> + <div className={styles.hubStats}> + <FontAwesomeIcon icon={faUsers} /> + <span className={styles.hubParticipantCount}>{this.props.occupantCount || "-"}</span> + </div> + </div> + <div className={styles.menuButtons}> + <button className={styles.menuButton} onClick={this.props.onClickInvite}> + <i className={styles.menuButtonIcon}> + <FontAwesomeIcon icon={faShareAlt} /> + </i> + <span className={styles.menuButtonText}> + <strong>Invite</strong> Others + </span> + </button> + <button className={styles.menuButton} onClick={this.props.onClickReport}> + <i className={styles.menuButtonIcon}> + <FontAwesomeIcon icon={faExclamation} /> + </i> + <span className={styles.menuButtonText}> + <strong>Report</strong> an Issue + </span> + </button> + </div> + </div> + )} + </div> + ); + } +} diff --git a/src/react-components/home-root.js b/src/react-components/home-root.js index 88d697a2d15ffc1ebfb4528f0e38f6de7134656e..b95b9d58a0bbe692c44943c860b8784b65737e19 100644 --- a/src/react-components/home-root.js +++ b/src/react-components/home-root.js @@ -4,9 +4,9 @@ import { IntlProvider, FormattedMessage, addLocaleData } from "react-intl"; import en from "react-intl/locale-data/en"; import homeVideo from "../assets/video/home.webm"; import classNames from "classnames"; -import formurlencoded from "form-urlencoded"; import HubCreatePanel from "./hub-create-panel.js"; +import InfoDialog from "./info-dialog.js"; const navigatorLang = (navigator.languages && navigator.languages[0]) || navigator.language || navigator.userLanguage; @@ -50,32 +50,6 @@ class HomeRoot extends Component { }; }; - closeDialog = () => { - this.setState({ dialogType: null }); - }; - - signUpForMailingList = async e => { - e.preventDefault(); - e.stopPropagation(); - if (!this.state.mailingListPrivacy) return; - - const url = "https://www.mozilla.org/en-US/newsletter/"; - - const payload = { - email: this.state.mailingListEmail, - newsletters: "mixed-reality", - privacy: true, - fmt: "H", - source_url: document.location.href - }; - - await fetch(url, { - body: formurlencoded(payload), - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" } - }).then(() => this.setState({ dialogType: "email_submitted" })); - }; - loadEnvironments = () => { const environments = []; @@ -92,93 +66,11 @@ class HomeRoot extends Component { }; render() { - let dialogTitle = null; - let dialogBody = null; - - switch (this.state.dialogType) { - // TODO i18n, FormattedMessage doesn't play nicely with links - case "slack": - dialogTitle = "Get in Touch"; - dialogBody = ( - <span> - Want to join the conversation? - <p /> - Join us on the{" "} - <a href="https://webvr-slack.herokuapp.com/" target="_blank" rel="noopener noreferrer"> - WebVR Slack - </a>{" "} - in the #social channel.<br />VR meetups every Friday at noon PST! - <p /> Or, tweet at{" "} - <a href="https://twitter.com/mozillareality" target="_blank" rel="noopener noreferrer"> - @mozillareality - </a>{" "} - on Twitter. - </span> - ); - break; - case "email_submitted": - dialogTitle = ""; - dialogBody = "Great! Please check your e-mail to confirm your subscription."; - break; - case "updates": - dialogTitle = ""; - dialogBody = ( - <span> - Sign up to get release notes about new features. - <p /> - <form onSubmit={this.signUpForMailingList}> - <div className="mailing-list-form"> - <input - type="email" - value={this.state.mailingListEmail} - onChange={e => this.setState({ mailingListEmail: e.target.value })} - className="mailing-list-form__email_field" - required - placeholder="Your email here" - /> - <label className="mailing-list-form__privacy"> - <input - className="mailing-list-form__privacy_checkbox" - type="checkbox" - required - value={this.state.mailingListPrivacy} - onChange={e => this.setState({ mailingListPrivacy: e.target.checked })} - /> - <span className="mailing-list-form__privacy_label"> - <FormattedMessage id="mailing_list.privacy_label" />{" "} - <a target="_blank" rel="noopener noreferrer" href="https://www.mozilla.org/en-US/privacy/"> - <FormattedMessage id="mailing_list.privacy_link" /> - </a> - </span> - </label> - <input className="mailing-list-form__submit" type="submit" value="Sign Up Now" /> - </div> - </form> - </span> - ); - break; - case "report": - dialogTitle = "Report an Issue"; - dialogBody = ( - <span> - Need to report a problem? - <p /> - You can file a{" "} - <a href="https://github.com/mozilla/mr-social-client/issues" target="_blank" rel="noopener noreferrer"> - Github Issue - </a>{" "} - or e-mail us for support at <a href="mailto:hubs@mozilla.com">hubs@mozilla.com</a>. - <p /> - You can also find us in #social on the{" "} - <a href="http://webvr-slack.herokuapp.com/" target="_blank" rel="noopener noreferrer"> - WebVR Slack - </a>. - </span> - ); - break; - } - - const mainContentClassNames = classNames({ "main-content": true, "main-content--noninteractive": !!dialogTitle }); + const mainContentClassNames = classNames({ + "main-content": true, + "main-content--noninteractive": !!this.state.dialogType + }); + const dialogTypes = InfoDialog.dialogTypes; return ( <IntlProvider locale={lang} messages={messages}> @@ -242,7 +134,7 @@ class HomeRoot extends Component { className="footer-content__links__link" rel="noopener noreferrer" href="#" - onClick={this.showDialog("slack")} + onClick={this.showDialog(dialogTypes.slack)} > <FormattedMessage id="home.join_us" /> </a> @@ -250,7 +142,7 @@ class HomeRoot extends Component { className="footer-content__links__link" rel="noopener noreferrer" href="#" - onClick={this.showDialog("updates")} + onClick={this.showDialog(dialogTypes.updates)} > <FormattedMessage id="home.get_updates" /> </a> @@ -258,7 +150,7 @@ class HomeRoot extends Component { className="footer-content__links__link" rel="noopener noreferrer" href="#" - onClick={this.showDialog("report")} + onClick={this.showDialog(dialogTypes.report)} > <FormattedMessage id="home.report_issue" /> </a> @@ -284,20 +176,11 @@ class HomeRoot extends Component { <source src={homeVideo} type="video/webm" /> </video> {this.state.dialogType && ( - <div className="overlay"> - <div className="dialog"> - <div className="dialog__box"> - <div className="dialog__box__contents"> - <button className="dialog__box__contents__close" onClick={this.closeDialog}> - <span>🗙</span> - </button> - <div className="dialog__box__contents__title">{dialogTitle}</div> - <div className="dialog__box__contents__body">{dialogBody}</div> - <div className="dialog__box__contents__button-container" /> - </div> - </div> - </div> - </div> + <InfoDialog + dialogType={this.state.dialogType} + onCloseDialog={() => this.setState({ dialogType: null })} + onSubmittedEmail={() => this.setState({ dialogType: dialogTypes.email_submitted })} + /> )} </div> </IntlProvider> diff --git a/src/react-components/hub-create-panel.js b/src/react-components/hub-create-panel.js index 6e689653bcc4129547ec780cacbe220cb9f954da..0b74d55b2698d51f6688e23e02e871177ecea436 100644 --- a/src/react-components/hub-create-panel.js +++ b/src/react-components/hub-create-panel.js @@ -7,7 +7,7 @@ import faAngleLeft from "@fortawesome/fontawesome-free-solid/faAngleLeft"; import faAngleRight from "@fortawesome/fontawesome-free-solid/faAngleRight"; import FontAwesomeIcon from "@fortawesome/react-fontawesome"; -import deafault_scene_preview_thumbnail from "../assets/images/default_thumbnail.png"; +import default_scene_preview_thumbnail from "../assets/images/default_thumbnail.png"; const HUB_NAME_PATTERN = "^[A-Za-z0-9-'\":!@#$%^&*(),.?~ ]{4,64}$"; @@ -21,15 +21,34 @@ class HubCreatePanel extends Component { super(props); this.state = { + ready: false, name: generateHubName(), environmentIndex: Math.floor(Math.random() * props.environments.length), - // HACK: expand on small screens by default to ensure scene selection possible. // Eventually this could/should be done via media queries. expanded: window.innerWidth < 420 }; + + // Optimisticly preload all environment thumbnails + (async () => { + const environmentThumbnails = props.environments.map((_, i) => this._getEnvironmentThumbnail(i)); + await Promise.all( + environmentThumbnails.map(environmentThumbnail => this._preloadImage(environmentThumbnail.srcset)) + ); + this.setState({ ready: true }); + })(); } + _getEnvironmentThumbnail = environmentIndex => { + const environment = this.props.environments[environmentIndex]; + const meta = environment.meta || {}; + return ( + (meta.images || []).find(i => i.type === "preview-thumbnail") || { + srcset: default_scene_preview_thumbnail + } + ); + }; + createHub = async e => { e.preventDefault(); const environment = this.props.environments[this.state.environmentIndex]; @@ -64,21 +83,28 @@ class HubCreatePanel extends Component { return new RegExp(HUB_NAME_PATTERN).test(this.state.name) && new RegExp(hubAlphaPattern).test(this.state.name); }; - setToEnvironmentOffset = offset => { + _preloadImage = async srcset => { + const img = new Image(); + const imgLoad = new Promise(resolve => img.addEventListener("load", resolve)); + img.srcset = srcset; + await imgLoad; + }; + + setToEnvironmentOffset = async offset => { const numEnvs = this.props.environments.length; - this.setState(state => ({ - environmentIndex: ((state.environmentIndex + offset) % this.props.environments.length + numEnvs) % numEnvs - })); + const environmentIndex = ((this.state.environmentIndex + offset) % numEnvs + numEnvs) % numEnvs; + const environmentThumbnail = this._getEnvironmentThumbnail(environmentIndex); + await this._preloadImage(environmentThumbnail.srcset); + + this.setState({ environmentIndex }); }; - setToNextEnvironment = e => { - e.preventDefault(); + setToNextEnvironment = () => { this.setToEnvironmentOffset(1); }; - setToPreviousEnvironment = e => { - e.preventDefault(); + setToPreviousEnvironment = () => { this.setToEnvironmentOffset(-1); }; @@ -90,6 +116,7 @@ class HubCreatePanel extends Component { }; render() { + if (!this.state.ready) return null; const { formatMessage } = this.props.intl; if (this.props.environments.length == 0) { @@ -101,9 +128,7 @@ class HubCreatePanel extends Component { const environmentTitle = meta.title || environment.name; const environmentAuthor = (meta.authors || [])[0]; - const environmentThumbnail = (meta.images || []).find(i => i.type === "preview-thumbnail") || { - srcset: deafault_scene_preview_thumbnail - }; + const environmentThumbnail = this._getEnvironmentThumbnail(this.state.environmentIndex); const formNameClassNames = classNames("create-panel__form__name", { "create-panel__form__name--expanded": this.state.expanded @@ -120,17 +145,16 @@ class HubCreatePanel extends Component { <div className="create-panel__form"> <div className="create-panel__form__left-container" - onClick={e => { - e.preventDefault(); - + onClick={async () => { if (this.state.expanded) { this.shuffle(); } else { + await this._preloadImage(this._getEnvironmentThumbnail(this.state.environmentIndex).srcset); this.setState({ expanded: true }); } }} > - <button className="create-panel__form__rotate-button"> + <button type="button" tabIndex="3" className="create-panel__form__rotate-button"> {this.state.expanded ? ( <img src="../assets/images/dice_icon.svg" /> ) : ( @@ -138,8 +162,8 @@ class HubCreatePanel extends Component { )} </button> </div> - <div className="create-panel__form__right-container" onClick={this.createHub}> - <button className="create-panel__form__submit-button"> + <div className="create-panel__form__right-container"> + <button type="submit" tabIndex="5" className="create-panel__form__submit-button"> {this.isHubNameValid() ? ( <img src="../assets/images/hub_create_button_enabled.svg" /> ) : ( @@ -184,6 +208,8 @@ class HubCreatePanel extends Component { <div className="create-panel__form__environment__picker__controls"> <button className="create-panel__form__environment__picker__controls__prev" + type="button" + tabIndex="1" onClick={this.setToPreviousEnvironment} > <FontAwesomeIcon icon={faAngleLeft} /> @@ -191,6 +217,8 @@ class HubCreatePanel extends Component { <button className="create-panel__form__environment__picker__controls__next" + type="button" + tabIndex="2" onClick={this.setToNextEnvironment} > <FontAwesomeIcon icon={faAngleRight} /> @@ -200,6 +228,7 @@ class HubCreatePanel extends Component { </div> )} <input + tabIndex="4" className={formNameClassNames} value={this.state.name} onChange={e => this.setState({ name: e.target.value })} diff --git a/src/react-components/info-dialog.js b/src/react-components/info-dialog.js new file mode 100644 index 0000000000000000000000000000000000000000..20032dcd92490c381d3eddc3a3b3218707029cd1 --- /dev/null +++ b/src/react-components/info-dialog.js @@ -0,0 +1,207 @@ +import React, { Component } from "react"; +import copy from "copy-to-clipboard"; +import PropTypes from "prop-types"; +import { FormattedMessage } from "react-intl"; +import formurlencoded from "form-urlencoded"; + +// TODO i18n + +class InfoDialog extends Component { + static dialogTypes = { + slack: Symbol("slack"), + email_submitted: Symbol("email_submitted"), + invite: Symbol("invite"), + updates: Symbol("updates"), + report: Symbol("report") + }; + static propTypes = { + dialogType: PropTypes.oneOf(Object.values(InfoDialog.dialogTypes)), + onCloseDialog: PropTypes.func, + onSubmittedEmail: PropTypes.func + }; + + constructor(props) { + super(props); + + const loc = document.location; + this.shareLink = `${loc.protocol}//${loc.host}${loc.pathname}`; + } + + shareLinkClicked = () => { + navigator.share({ + title: document.title, + url: this.shareLink + }); + }; + + copyLinkClicked = () => { + copy(this.shareLink); + this.setState({ copyLinkButtonText: "Copied!" }); + }; + + state = { + mailingListEmail: "", + mailingListPrivacy: false, + copyLinkButtonText: "Copy" + }; + + signUpForMailingList = async e => { + e.preventDefault(); + e.stopPropagation(); + if (!this.state.mailingListPrivacy) return; + + const url = "https://www.mozilla.org/en-US/newsletter/"; + + const payload = { + email: this.state.mailingListEmail, + newsletters: "mixed-reality", + privacy: true, + fmt: "H", + source_url: document.location.href + }; + + await fetch(url, { + body: formurlencoded(payload), + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" } + }).then(this.props.onSubmittedEmail); + }; + + render() { + if (!this.props.dialogType) { + return <div />; + } + + let dialogTitle = null; + let dialogBody = null; + + switch (this.props.dialogType) { + // TODO i18n, FormattedMessage doesn't play nicely with links + case InfoDialog.dialogTypes.slack: + dialogTitle = "Get in Touch"; + dialogBody = ( + <span> + Want to join the conversation? + <p /> + Join us on the{" "} + <a href="https://webvr-slack.herokuapp.com/" target="_blank" rel="noopener noreferrer"> + WebVR Slack + </a>{" "} + in the #social channel.<br />VR meetups every Friday at noon PST! + <p /> Or, tweet at{" "} + <a href="https://twitter.com/mozillareality" target="_blank" rel="noopener noreferrer"> + @mozillareality + </a>{" "} + on Twitter. + </span> + ); + break; + case InfoDialog.dialogTypes.email_submitted: + dialogTitle = ""; + dialogBody = "Great! Please check your e-mail to confirm your subscription."; + break; + case InfoDialog.dialogTypes.invite: + dialogTitle = "Invite Others"; + dialogBody = ( + <div> + <div>Just share the link to have others join you.</div> + <div className="invite-form"> + <input + type="text" + readOnly + onFocus={e => e.target.select()} + value={this.shareLink} + className="invite-form__link_field" + /> + <div className="invite-form__buttons"> + {navigator.share && ( + <button className="invite-form__action-button" onClick={this.shareLinkClicked}> + <span>Share</span> + </button> + )} + <button className="invite-form__action-button" onClick={this.copyLinkClicked}> + <span>{this.state.copyLinkButtonText}</span> + </button> + </div> + </div> + </div> + ); + break; + case InfoDialog.dialogTypes.updates: + dialogTitle = ""; + dialogBody = ( + <span> + Sign up to get release notes about new features. + <p /> + <form onSubmit={this.signUpForMailingList}> + <div className="mailing-list-form"> + <input + type="email" + value={this.state.mailingListEmail} + onChange={e => this.setState({ mailingListEmail: e.target.value })} + className="mailing-list-form__email_field" + required + placeholder="Your email here" + /> + <label className="mailing-list-form__privacy"> + <input + className="mailing-list-form__privacy_checkbox" + type="checkbox" + required + value={this.state.mailingListPrivacy} + onChange={e => this.setState({ mailingListPrivacy: e.target.checked })} + /> + <span className="mailing-list-form__privacy_label"> + <FormattedMessage id="mailing_list.privacy_label" />{" "} + <a target="_blank" rel="noopener noreferrer" href="https://www.mozilla.org/en-US/privacy/"> + <FormattedMessage id="mailing_list.privacy_link" /> + </a> + </span> + </label> + <input className="mailing-list-form__submit" type="submit" value="Sign Up Now" /> + </div> + </form> + </span> + ); + break; + case InfoDialog.dialogTypes.report: + dialogTitle = "Report an Issue"; + dialogBody = ( + <span> + Need to report a problem? + <p /> + You can file a{" "} + <a href="https://github.com/mozilla/mr-social-client/issues" target="_blank" rel="noopener noreferrer"> + Github Issue + </a>{" "} + or e-mail us for support at <a href="mailto:hubs@mozilla.com">hubs@mozilla.com</a>. + <p /> + You can also find us in #social on the{" "} + <a href="http://webvr-slack.herokuapp.com/" target="_blank" rel="noopener noreferrer"> + WebVR Slack + </a>. + </span> + ); + break; + } + + return ( + <div className="dialog-overlay"> + <div className="dialog"> + <div className="dialog__box"> + <div className="dialog__box__contents"> + <button className="dialog__box__contents__close" onClick={this.props.onCloseDialog}> + <span>×</span> + </button> + <div className="dialog__box__contents__title">{dialogTitle}</div> + <div className="dialog__box__contents__body">{dialogBody}</div> + <div className="dialog__box__contents__button-container" /> + </div> + </div> + </div> + </div> + ); + } +} + +export default InfoDialog; diff --git a/src/react-components/profile-entry-panel.js b/src/react-components/profile-entry-panel.js index 2732f3ca9bd173cc3390bd1af05316b8f6614747..d38ac55e33c5294ccf262db97fbd2b11c5a20b32 100644 --- a/src/react-components/profile-entry-panel.js +++ b/src/react-components/profile-entry-panel.js @@ -72,16 +72,14 @@ class ProfileEntryPanel extends Component { return ( <div className="profile-entry"> - <form onSubmit={this.saveStateAndFinish}> + <form onSubmit={this.saveStateAndFinish} className="profile-entry__form"> <div className="profile-entry__box profile-entry__box--darkened"> - <div className="profile-entry__subtitle"> + <label htmlFor="#profile-entry-display-name" className="profile-entry__subtitle"> <FormattedMessage id="profile.header" /> - </div> + </label> <label> - <span className="profile-entry__display-name-label"> - <FormattedMessage id="profile.display_name.label" /> - </span> <input + id="profile-entry-display-name" className="profile-entry__form-field-text" value={this.state.display_name} onChange={e => this.setState({ display_name: e.target.value })} diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js index 512cb62c14750d01c8cd9cbbff7c3bc2b8b9cc6d..16b0b8944bf866f6d82c757d77bed73f8459e956 100644 --- a/src/react-components/ui-root.js +++ b/src/react-components/ui-root.js @@ -12,7 +12,9 @@ import AutoExitWarning from "./auto-exit-warning"; import { TwoDEntryButton, GenericEntryButton, GearVREntryButton, DaydreamEntryButton } from "./entry-buttons.js"; import { ProfileInfoHeader } from "./profile-info-header.js"; import ProfileEntryPanel from "./profile-entry-panel"; +import InfoDialog from "./info-dialog.js"; import TwoDHUD from "./2d-hud"; +import Footer from "./footer"; const mobiledetect = new MobileDetect(navigator.userAgent); @@ -65,12 +67,15 @@ class UIRoot extends Component { availableVREntryTypes: PropTypes.object, initialEnvironmentLoaded: PropTypes.bool, janusRoomId: PropTypes.number, - roomUnavailableReason: PropTypes.string + roomUnavailableReason: PropTypes.string, + hubName: PropTypes.string, + occupantCount: PropTypes.number }; state = { entryStep: ENTRY_STEPS.start, enterInVR: false, + infoDialogType: null, shareScreen: false, requestedScreen: false, @@ -760,7 +765,7 @@ class UIRoot extends Component { "ui-dialog--darkened": this.state.entryStep !== ENTRY_STEPS.finished }); - const dialogBoxClassNames = classNames("ui-interactive", "ui-dialog-box"); + const dialogBoxClassNames = classNames({ "ui-interactive": !this.state.infoDialogType, "ui-dialog-box": true }); const dialogBoxContentsClassNames = classNames({ "ui-dialog-box-contents": true, @@ -770,6 +775,11 @@ class UIRoot extends Component { return ( <IntlProvider locale={lang} messages={messages}> <div className="ui"> + <InfoDialog + dialogType={this.state.infoDialogType} + onCloseDialog={() => this.setState({ infoDialogType: null })} + /> + <div className={dialogClassNames}> {(this.state.entryStep !== ENTRY_STEPS.finished || this.isWaitingForAutoExit()) && ( <div className={dialogBoxClassNames}> @@ -786,7 +796,15 @@ class UIRoot extends Component { )} </div> {this.state.entryStep === ENTRY_STEPS.finished ? ( - <TwoDHUD muted={this.state.muted} onToggleMute={this.toggleMute} /> + <div> + <TwoDHUD muted={this.state.muted} onToggleMute={this.toggleMute} /> + <Footer + hubName={this.props.hubName} + occupantCount={this.props.occupantCount} + onClickInvite={() => this.setState({ infoDialogType: InfoDialog.dialogTypes.invite })} + onClickReport={() => this.setState({ infoDialogType: InfoDialog.dialogTypes.report })} + /> + </div> ) : null} </div> </IntlProvider> diff --git a/webpack.config.js b/webpack.config.js index 55bae82132e8d8611ba8b01ffcddf1ac49109c4b..3a70b848d2f763d1ff8b37d8a698fc682f7a54d4 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -141,7 +141,9 @@ const config = { loader: "css-loader", options: { name: "[path][name]-[hash].[ext]", - minimize: process.env.NODE_ENV === "production" + minimize: process.env.NODE_ENV === "production", + localIdentName: "[name]__[local]__[hash:base64:5]", + camelCase: true } }, "sass-loader" @@ -156,7 +158,9 @@ const config = { loader: "css-loader", options: { name: "[path][name]-[hash].[ext]", - minimize: process.env.NODE_ENV === "production" + minimize: process.env.NODE_ENV === "production", + localIdentName: "[name]__[local]__[hash:base64:5]", + camelCase: true } } }) diff --git a/yarn.lock b/yarn.lock index d42ec6a46cdd0953f94855b7cea8a59c3bc3e2ad..63079940bac14f9025e01f590ec2de5b7eca9710 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2170,6 +2170,12 @@ copy-descriptor@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" +copy-to-clipboard@^3.0.8: + version "3.0.8" + resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.0.8.tgz#f4e82f4a8830dce4666b7eb8ded0c9bcc313aba9" + dependencies: + toggle-selection "^1.0.3" + core-js@^1.0.0: version "1.2.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" @@ -7913,6 +7919,10 @@ to-regex@^3.0.1: extend-shallow "^2.0.1" regex-not "^1.0.0" +toggle-selection@^1.0.3: + version "1.0.6" + resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" + toposort@^1.0.0: version "1.0.6" resolved "https://registry.yarnpkg.com/toposort/-/toposort-1.0.6.tgz#c31748e55d210effc00fdcdc7d6e68d7d7bb9cec"