diff --git a/src/assets/stylesheets/audio.scss b/src/assets/stylesheets/audio.scss index 5e3582fd4b5383a8a0cef94c73f6d2591b242a14..d73a85eddc926c4b9a2f58c21fd4a97521822fd8 100644 --- a/src/assets/stylesheets/audio.scss +++ b/src/assets/stylesheets/audio.scss @@ -104,7 +104,7 @@ @extend %top-subtitle; } - &__icon { + &__button-container { flex: 10; display: flex; justify-content: center; @@ -114,9 +114,16 @@ height: 111px; } + &__button { + background: none; + border: none; + cursor: pointer; + } + &__next { @extend %bottom-button; - margin: auto; - flex: 1 1 20px; + padding-top: 0; + padding-bottom: 0; + flex: 1 1; } } diff --git a/src/assets/stylesheets/entry.scss b/src/assets/stylesheets/entry.scss index bdd20d1ee7af717ac0b2808b696c85fe49c8d775..abed31db312891d5d0d4c425b42310851df21553 100644 --- a/src/assets/stylesheets/entry.scss +++ b/src/assets/stylesheets/entry.scss @@ -20,28 +20,18 @@ justify-content: center; &__screen-sharing { - font-size: 1.4em; - margin-left: 2.95em; - margin-top: 0.6em; - } + font-size: 1.4em; + margin-left: 2.95em; + margin-top: 0.6em; - &__screen-sharing-checkbox { - appearance: none; - -moz-appearance: none; - -webkit-appearance: none; - width: 2em; - height: 2em; - border: 3px solid white; - border-radius: 9px; - vertical-align: sub; - margin: 0 0.6em + &__checkbox { + @extend %checkbox; + } + &__checkbox:checked { + @extend %checkbox-checked; + } } - &__screen-sharing-checkbox:checked { - border: 9px double white; - outline: 9px solid white; - outline-offset: -18px; - } &__secondary { width: 100%; @@ -58,6 +48,10 @@ margin-top: 10px; margin-bottom: 10px; cursor: pointer; + background: none; + color: white; + border: none; + @extend %default-font; &__icon { flex: 1 1 90px; diff --git a/src/assets/stylesheets/exited.scss b/src/assets/stylesheets/exited.scss index 72959090e6cf5ed18d29c2750beb7bbb4280fcae..693d6d38798705979478930f0b175ea57aa6e183 100644 --- a/src/assets/stylesheets/exited.scss +++ b/src/assets/stylesheets/exited.scss @@ -1,4 +1,6 @@ .exited-panel { + position: absolute; + color: white; background-color: black; width: 100%; height: 100%; diff --git a/src/assets/stylesheets/profile.scss b/src/assets/stylesheets/profile.scss index ad6c30bfe62ac172bbdeba78fb95e287f0a88016..95f2caa2629d34e3f99fbab242f5cbf28b498038 100644 --- a/src/assets/stylesheets/profile.scss +++ b/src/assets/stylesheets/profile.scss @@ -41,6 +41,10 @@ color: $grey-text; } + &__display-name-label { + font-size: 1.2em; + margin-right: 0.5em; + } &__form-field-text { @extend %rounded-border; @extend %default-font; @@ -54,19 +58,34 @@ margin: 0.5em 0; } - &__form-submit { - @extend %default-font; - border: none; + &__terms { + margin-bottom: 16px; - margin: 8px; - width: 100px; - line-height: 1.5em; - font-size: 1.0em; + &__checkbox { + @extend %checkbox; + vertical-align: unset; + } + &__checkbox:checked { + @extend %checkbox-checked; + } - background-color: transparent; - font-weight: bold; - color: white; - cursor: pointer; + &__text { + display: inline-block; + max-width: 20em; + } + + &__link { + color: white; + } + + &__link:visited { + color: grey; + } + } + + &__form-submit { + @extend %bottom-button; + margin: 0; } } diff --git a/src/assets/stylesheets/shared.scss b/src/assets/stylesheets/shared.scss index c2d4f013de5b853664b751400969602b356a53f9..f959943585bbfacf37791586f5485add3e524cd1 100644 --- a/src/assets/stylesheets/shared.scss +++ b/src/assets/stylesheets/shared.scss @@ -17,11 +17,17 @@ $darker-grey: rgba(64, 64, 64, 1.0); } %bottom-button { + @extend %default-font; font-size: 1em; font-weight: bold; margin-top: auto; margin-bottom: 30px; cursor: pointer; + border: 3px solid white; + border-radius: 14px; + padding: 12px; + background: none; + color: white; } %top-title { @@ -42,3 +48,21 @@ $darker-grey: rgba(64, 64, 64, 1.0); border: none; font-size: 64pt; } + +%checkbox { + appearance: none; + -moz-appearance: none; + -webkit-appearance: none; + width: 2em; + height: 2em; + border: 3px solid white; + border-radius: 9px; + vertical-align: sub; + margin: 0 0.6em +} + +%checkbox-checked { + border: 9px double white; + outline: 9px solid white; + outline-offset: -18px; +} diff --git a/src/assets/translations.data.json b/src/assets/translations.data.json index 1757ec61cce5b97855750537a7812efe5349a57e..1b9844c8abf2dc7d9ed7c089f84923faa601fd96 100644 --- a/src/assets/translations.data.json +++ b/src/assets/translations.data.json @@ -14,8 +14,14 @@ "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.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", + "profile.terms.tou": "terms of use", + "profile.terms.suffix": ".", "profile.avatar-selector.loading": "Loading Avatars...", "audio.title": "Test your audio", "audio.subtitle-desktop": "Confirm HMD speaker output", @@ -26,7 +32,6 @@ "audio.grant-subtitle": "Mic access needed to be heard by others", "audio.granted-title": "Mic permissions granted", "audio.granted-subtitle": "You can still mute yourself in-game", - "audio.grant-next": " ", "audio.granted-next": "NEXT", "exit.subtitle": "Your session has ended.", "autoexit.title": "Auto-ending session in ", diff --git a/src/hub.html b/src/hub.html index 0f6df33ece622b7d5f3bea8ff007628213623893..edb7395d4641f10c27994b3399dc580e72f6952b 100644 --- a/src/hub.html +++ b/src/hub.html @@ -3,10 +3,11 @@ <head> <meta charset="utf-8"> - <title>moz://a duck</title> - <meta name="viewport" content="width=device-width, initial-scale=1"> <meta http-equiv="origin-trial" data-feature="WebVR (For Chrome M62+)" data-expires="<%= ORIGIN_TRIAL_EXPIRES %>" content="<%= ORIGIN_TRIAL_TOKEN %>"> + <title>moz://a duck</title> + <link href="https://fonts.googleapis.com/css?family=Zilla+Slab:300,300i,400,400i,700" rel="stylesheet"> + <% if(NODE_ENV === "production") { %> <script src="https://cdn.rawgit.com/brianpeiris/aframe/845825ae694449524c185c44a314d361eead4680/dist/aframe-master.min.js"></script> <% } else { %> diff --git a/src/hub.js b/src/hub.js index abc6f392813bc5c7be90c5cdc0671d9ca289e4c2..75111f9c6b4abdee9ef8ecb771d195e02f17be38 100644 --- a/src/hub.js +++ b/src/hub.js @@ -226,16 +226,14 @@ async function enterScene(mediaStream, enterInVR, janusRoomId) { } } -function mountUI(scene) { +function mountUI(scene, props = {}) { const disableAutoExitOnConcurrentLoad = qsTruthy("allow_multi"); const forcedVREntryType = qs.vr_entry_type || null; const enableScreenSharing = qsTruthy("enable_screen_sharing"); const htmlPrefix = document.body.dataset.htmlPrefix || ""; const showProfileEntry = !store.state.profile.has_changed_name; - // TODO: Refactor to avoid using return value - /* eslint-disable react/no-render-return-value */ - const uiRoot = ReactDOM.render( + ReactDOM.render( <UIRoot {...{ scene, @@ -247,14 +245,12 @@ function mountUI(scene) { enableScreenSharing, store, htmlPrefix, - showProfileEntry + showProfileEntry, + ...props }} />, document.getElementById("ui-root") ); - /* eslint-enable react/no-render-return-value */ - - return uiRoot; } const onReady = async () => { @@ -264,18 +260,23 @@ const onReady = async () => { registerNetworkSchemas(); - const uiRoot = mountUI(scene); + mountUI(scene); + + let modifiedProps = {}; + const remountUI = props => { + modifiedProps = { ...modifiedProps, ...props }; + mountUI(scene, modifiedProps); + }; getAvailableVREntryTypes().then(availableVREntryTypes => { - uiRoot.setState({ availableVREntryTypes }); - uiRoot.handleForcedVREntryType(); + remountUI({ availableVREntryTypes }); }); const environmentRoot = document.querySelector("#environment-root"); const initialEnvironmentEl = document.createElement("a-entity"); initialEnvironmentEl.addEventListener("bundleloaded", () => { - uiRoot.setState({ initialEnvironmentLoaded: true }); + remountUI({ initialEnvironmentLoaded: true }); // Wait a tick plus some margin so that the environments actually render. setTimeout(() => scene.renderer.animate(null), 100); }); @@ -283,7 +284,7 @@ const onReady = async () => { if (qs.room) { // If ?room is set, this is `yarn start`, so just use a default environment and query string room. - uiRoot.setState({ janusRoomId: qs.room && !isNaN(parseInt(qs.room)) ? parseInt(qs.room) : 1 }); + remountUI({ janusRoomId: qs.room && !isNaN(parseInt(qs.room)) ? parseInt(qs.room) : 1 }); initialEnvironmentEl.setAttribute("gltf-bundle", { src: "https://asset-bundles-prod.reticulum.io/rooms/meetingroom/MeetingRoom.bundle.json" // src: "https://asset-bundles-prod.reticulum.io/rooms/theater/TheaterMeshes.bundle.json" @@ -314,7 +315,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; - uiRoot.setState({ janusRoomId: defaultSpaceTopic.janus_room_id }); + remountUI({ janusRoomId: defaultSpaceTopic.janus_room_id }); initialEnvironmentEl.setAttribute("gltf-bundle", `src: ${gltfBundleUrl}`); hubChannel.setPhoenixChannel(channel); }) diff --git a/src/react-components/entry-buttons.js b/src/react-components/entry-buttons.js index 8ed8b171d6e7ec08d82d59af2c5314d9353a7910..92d0ef5ef8ccd4992d9913bfb30144fff018c0b9 100644 --- a/src/react-components/entry-buttons.js +++ b/src/react-components/entry-buttons.js @@ -12,7 +12,7 @@ import DaydreamEntyImg from "../assets/images/daydream_entry.svg"; const mobiledetect = new MobileDetect(navigator.userAgent); const EntryButton = props => ( - <div className="entry-button" onClick={props.onClick}> + <button className="entry-button" onClick={props.onClick}> <img src={props.iconSrc} className="entry-button__icon" /> <div className="entry-button__label"> <div className="entry-button__label__contents"> @@ -25,7 +25,7 @@ const EntryButton = props => ( {props.subtitle && <div className="entry-button__subtitle">{props.subtitle}</div>} </div> </div> - </div> + </button> ); EntryButton.propTypes = { diff --git a/src/react-components/profile-entry-panel.js b/src/react-components/profile-entry-panel.js index b841022ba1b0b8f4226593be3b4c21bb88e097e1..2732f3ca9bd173cc3390bd1af05316b8f6614747 100644 --- a/src/react-components/profile-entry-panel.js +++ b/src/react-components/profile-entry-panel.js @@ -26,10 +26,13 @@ class ProfileEntryPanel extends Component { saveStateAndFinish = e => { e.preventDefault(); + const has_agreed_to_terms = this.props.store.state.profile.has_agreed_to_terms || this.state.has_agreed_to_terms; + if (!has_agreed_to_terms) return; const { has_changed_name, display_name } = this.props.store.state.profile; const hasChangedName = has_changed_name || this.state.display_name !== display_name; this.props.store.update({ profile: { + has_agreed_to_terms: true, has_changed_name: hasChangedName, ...this.state } @@ -74,20 +77,47 @@ class ProfileEntryPanel extends Component { <div className="profile-entry__subtitle"> <FormattedMessage id="profile.header" /> </div> - <input - className="profile-entry__form-field-text" - value={this.state.display_name} - onChange={e => this.setState({ display_name: e.target.value })} - required - pattern={SCHEMA.definitions.profile.properties.display_name.pattern} - title={formatMessage({ id: "profile.display_name.validation_warning" })} - ref={inp => (this.nameInput = inp)} - /> + <label> + <span className="profile-entry__display-name-label"> + <FormattedMessage id="profile.display_name.label" /> + </span> + <input + className="profile-entry__form-field-text" + value={this.state.display_name} + onChange={e => this.setState({ display_name: e.target.value })} + required + pattern={SCHEMA.definitions.profile.properties.display_name.pattern} + title={formatMessage({ id: "profile.display_name.validation_warning" })} + ref={inp => (this.nameInput = inp)} + /> + </label> <iframe className="profile-entry__avatar-selector" src={`/${this.props.htmlPrefix}avatar-selector.html#avatar_id=${this.state.avatar_id}`} ref={ifr => (this.avatarSelector = ifr)} /> + {!this.props.store.state.profile.has_agreed_to_terms && ( + <label className="profile-entry__terms"> + <input + className="profile-entry__terms__checkbox" + type="checkbox" + required + value={this.state.has_agreed_to_terms} + onChange={e => this.setState({ has_agreed_to_terms: e.target.checked })} + /> + <span className="profile-entry__terms__text"> + <FormattedMessage id="profile.terms.prefix" />{" "} + <a className="profile-entry__terms__link" target="_blank" href="/privacy"> + <FormattedMessage id="profile.terms.privacy" /> + </a>{" "} + <FormattedMessage id="profile.terms.conjunction" />{" "} + <a className="profile-entry__terms__link" target="_blank" href="/terms"> + <FormattedMessage id="profile.terms.tou" /> + </a> + <FormattedMessage id="profile.terms.suffix" /> + </span> + </label> + )} <input className="profile-entry__form-submit" type="submit" value={formatMessage({ id: "profile.save" })} /> </div> </form> diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js index 4dbcc8baf1a7c405b171fcab493b3b4d06912a58..3e22208dacc6219b59cc9276562c71d43f129f03 100644 --- a/src/react-components/ui-root.js +++ b/src/react-components/ui-root.js @@ -61,11 +61,13 @@ class UIRoot extends Component { store: PropTypes.object, scene: PropTypes.object, htmlPrefix: PropTypes.string, - showProfileEntry: PropTypes.bool + showProfileEntry: PropTypes.bool, + availableVREntryTypes: PropTypes.object, + initialEnvironmentLoaded: PropTypes.bool, + janusRoomId: PropTypes.number }; state = { - availableVREntryTypes: null, entryStep: ENTRY_STEPS.start, enterInVR: false, @@ -88,12 +90,9 @@ class UIRoot extends Component { autoExitTimerInterval: null, secondsRemainingBeforeAutoExit: Infinity, - initialEnvironmentLoaded: false, exited: false, - showProfileEntry: false, - - janusRoomId: null + showProfileEntry: false }; constructor(props) { @@ -110,8 +109,10 @@ class UIRoot extends Component { this.props.scene.addEventListener("stateremoved", this.onAframeStateChanged); } - componentWillUnmount() { - this.props.scene.removeEventListener("loaded", this.onSceneLoaded); + componentDidUpdate(prevProps) { + if (this.props.availableVREntryTypes && prevProps.availableVREntryTypes !== this.props.availableVREntryTypes) { + this.handleForcedVREntryType(); + } } onSceneLoaded = () => { @@ -256,7 +257,7 @@ class UIRoot extends Component { }; enterGearVR = async () => { - if (this.state.availableVREntryTypes.gearvr === VR_DEVICE_AVAILABILITY.yes) { + if (this.props.availableVREntryTypes.gearvr === VR_DEVICE_AVAILABILITY.yes) { await this.performDirectEntryFlow(true); } else { this.exit(); @@ -275,7 +276,7 @@ class UIRoot extends Component { }; enterDaydream = async () => { - if (this.state.availableVREntryTypes.daydream == VR_DEVICE_AVAILABILITY.maybe) { + if (this.props.availableVREntryTypes.daydream == VR_DEVICE_AVAILABILITY.maybe) { this.exit(); // We are not in mobile chrome, so launch into chrome via an Intent URL @@ -473,7 +474,7 @@ class UIRoot extends Component { }; onAudioReadyButton = () => { - this.props.enterScene(this.state.mediaStream, this.state.enterInVR, this.state.janusRoomId); + this.props.enterScene(this.state.mediaStream, this.state.enterInVR, this.props.janusRoomId); const mediaStream = this.state.mediaStream; @@ -498,7 +499,7 @@ class UIRoot extends Component { }; render() { - if (!this.state.initialEnvironmentLoaded || !this.state.availableVREntryTypes || !this.state.janusRoomId) { + if (!this.props.initialEnvironmentLoaded || !this.props.availableVREntryTypes || !this.props.janusRoomId) { return ( <IntlProvider locale={lang} messages={messages}> <div className="loading-panel"> @@ -539,7 +540,7 @@ class UIRoot extends Component { /firefox/i.test(navigator.userAgent) && ( <label className="entry-panel__screen-sharing"> <input - className="entry-panel__screen-sharing-checkbox" + className="entry-panel__screen-sharing__checkbox" type="checkbox" value={this.state.shareScreen} onChange={this.setStateAndRequestScreen} @@ -552,21 +553,21 @@ class UIRoot extends Component { this.state.entryStep === ENTRY_STEPS.start ? ( <div className="entry-panel"> <TwoDEntryButton onClick={this.enter2D} /> - {this.state.availableVREntryTypes.generic !== VR_DEVICE_AVAILABILITY.no && ( + {this.props.availableVREntryTypes.generic !== VR_DEVICE_AVAILABILITY.no && ( <GenericEntryButton onClick={this.enterVR} /> )} - {this.state.availableVREntryTypes.gearvr !== VR_DEVICE_AVAILABILITY.no && ( + {this.props.availableVREntryTypes.gearvr !== VR_DEVICE_AVAILABILITY.no && ( <GearVREntryButton onClick={this.enterGearVR} /> )} - {this.state.availableVREntryTypes.daydream !== VR_DEVICE_AVAILABILITY.no && ( + {this.props.availableVREntryTypes.daydream !== VR_DEVICE_AVAILABILITY.no && ( <DaydreamEntryButton onClick={this.enterDaydream} subtitle={ - this.state.availableVREntryTypes.daydream == VR_DEVICE_AVAILABILITY.maybe ? daydreamMaybeSubtitle : "" + this.props.availableVREntryTypes.daydream == VR_DEVICE_AVAILABILITY.maybe ? daydreamMaybeSubtitle : "" } /> )} - {this.state.availableVREntryTypes.cardboard !== VR_DEVICE_AVAILABILITY.no && ( + {this.props.availableVREntryTypes.cardboard !== VR_DEVICE_AVAILABILITY.no && ( <div className="entry-panel__secondary" onClick={this.enterVR}> <FormattedMessage id="entry.cardboard" /> </div> @@ -588,26 +589,22 @@ class UIRoot extends Component { id={this.state.entryStep == ENTRY_STEPS.mic_grant ? "audio.grant-subtitle" : "audio.granted-subtitle"} /> </div> - <div className="mic-grant-panel__icon"> + <div className="mic-grant-panel__button-container"> {this.state.entryStep == ENTRY_STEPS.mic_grant ? ( - <img - onClick={this.onMicGrantButton} - src="../assets/images/mic_denied.png" - srcSet="../assets/images/mic_denied@2x.png 2x" - /> + <button className="mic-grant-panel__button" onClick={this.onMicGrantButton}> + <img src="../assets/images/mic_denied.png" srcSet="../assets/images/mic_denied@2x.png 2x" /> + </button> ) : ( - <img - onClick={this.onMicGrantButton} - src="../assets/images/mic_granted.png" - srcSet="../assets/images/mic_granted@2x.png 2x" - /> + <button className="mic-grant-panel__button" onClick={this.onMicGrantButton}> + <img src="../assets/images/mic_granted.png" srcSet="../assets/images/mic_granted@2x.png 2x" /> + </button> )} </div> - <div className="mic-grant-panel__next" onClick={this.onMicGrantButton}> - <FormattedMessage - id={this.state.entryStep == ENTRY_STEPS.mic_grant ? "audio.grant-next" : "audio.granted-next"} - /> - </div> + {this.state.entryStep == ENTRY_STEPS.mic_granted && ( + <button className="mic-grant-panel__next" onClick={this.onMicGrantButton}> + <FormattedMessage id="audio.granted-next" /> + </button> + )} </div> ) : null; @@ -711,9 +708,9 @@ class UIRoot extends Component { </span> </div> )} - <div className="audio-setup-panel__enter-button" onClick={this.onAudioReadyButton}> + <button className="audio-setup-panel__enter-button" onClick={this.onAudioReadyButton}> <FormattedMessage id="audio.enter-now" /> - </div> + </button> </div> ) : null; diff --git a/src/storage/store.js b/src/storage/store.js index 299ff0c4050916e7e19af35ceaa59c791d19b4c5..4351ebeda99e9f9665fcbbf395858296cd2e56a4 100644 --- a/src/storage/store.js +++ b/src/storage/store.js @@ -17,6 +17,7 @@ export const SCHEMA = { type: "object", additionalProperties: false, properties: { + has_agreed_to_terms: { type: "boolean" }, has_changed_name: { type: "boolean" }, display_name: { type: "string", pattern: "^[A-Za-z0-9-]{3,32}$" }, avatar_id: { type: "string" } diff --git a/src/utils/identity.js b/src/utils/identity.js index 639b0c57a9e1eb0c7ff6666678d036a8681ba8ee..db78b027e3e851aa254532f264438e362062576c 100644 --- a/src/utils/identity.js +++ b/src/utils/identity.js @@ -101,6 +101,7 @@ export const avatarIds = avatars.map(av => av.id); export function generateDefaultProfile() { return { + has_agreed_to_terms: false, has_changed_name: false, avatar_id: selectRandom(avatarIds) };