diff --git a/scripts/build_local_reticulum.sh b/scripts/build_local_reticulum.sh index f883958f8f18e028a32916f50c6af38002938893..3f5e3a00136a84adf99b68926ee0dd1b17a09af3 100755 --- a/scripts/build_local_reticulum.sh +++ b/scripts/build_local_reticulum.sh @@ -4,4 +4,4 @@ if [ ! -e ../reticulum ]; then echo "This script assumes reticulum is checked out in a sibling to this folder." fi -rm -rf ../reticulum/priv/static ; GENERATE_SMOKE_TESTS=true BASE_ASSETS_PATH=http://localhost:4000/ yarn build -- --output-path ../reticulum/priv/static +rm -rf ../reticulum/priv/static ; GENERATE_SMOKE_TESTS=true BASE_ASSETS_PATH=https://localhost:4000/ yarn build -- --output-path ../reticulum/priv/static diff --git a/src/assets/images/device_entry.svg b/src/assets/images/device_entry.svg new file mode 100755 index 0000000000000000000000000000000000000000..f406f0c8058b65ca14a04aee952f1cccc071ae6d --- /dev/null +++ b/src/assets/images/device_entry.svg @@ -0,0 +1,17 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="94" height="94"> + <g fill="none"> + <path fill="#D8D8D8" fill-opacity=".01" fill-rule="evenodd" d="M47 92c24.853 0 45-20.147 45-45S71.853 2 47 2 2 22.147 2 47s20.147 45 45 45z" clip-rule="evenodd"/> + <path stroke="#fff" stroke-width="3" d="M47 92c24.853 0 45-20.147 45-45S71.853 2 47 2 2 22.147 2 47s20.147 45 45 45z"/> + <path fill="#C4C4C4" d="M59.5 39c1.792.29 5.44 1.047 6.43 1.549C65.525 28.767 59.048 26.819 55.5 29c4.222 1.121 4.102 7.456 4 10z"/> + <path fill="#C4C4C4" d="M59.5 39c1.792.29 5.44 1.047 6.43 1.549C65.525 28.767 59.048 26.819 55.5 29c4.222 1.121 4.102 7.456 4 10z"/> + <path fill="#fff" fill-rule="evenodd" stroke="#fff" d="M49.52 43.729a3 3 0 0 0 3.16-1.846 49.424 49.424 0 0 1 1.577-3.51c1.206.038 2.299.077 3.243.127 5.107.27 8.46 1.781 10.542 3.82-1.43.633-3.15 1.481-4.801 2.328a238.752 238.752 0 0 0-6.04 3.212l-.41.225-.147.081a1.5 1.5 0 0 0-.76 1.499l.01 8.465c.06.474.34.89.757 1.123.422.24.918.178 1.4.034l.13-.06a120.01 120.01 0 0 0 1.345-.628c.269-.127.567-.268.889-.422l.242-.116c1.51-.72 4.234-2.02 6.072-2.974a95.31 95.31 0 0 0 3.542-1.926c-.385 1.003-.908 1.845-1.501 2.425L51.5 65c-1.417-1.043-.933-1.604-.068-2.603 1.538-1.778 4.278-4.946-.35-14.69-5.62-4.425-18.234-4.507-23.614-4.543-1.739-.01-2.722-.017-2.468-.164 1.715-.99 12.942-4.158 16.658-5.076a149.626 149.626 0 0 1-.37.794 3 3 0 0 0 2.332 4.258l5.9.753z" clip-rule="evenodd"/> + <path fill="#fff" d="M44 40l5.9.753c7.82-19.229 15.691-11.946 16.2.548-.05-8.196-2.693-14.35-10.397-14.3C49.539 27.04 46.127 35.5 44 40z"/> + <mask id="a" fill="#fff"> + <path d="M17.038 1.115C4.277 2.707.865 1.717 0 14.395c0 13.783 7.5 9.45 19.16 7.594 8.824-1.89 13.932-1.037 13.932-13.732C32.243-2.03 29.087-.39 17.038 1.115z"/> + </mask> + <g mask="url(#a)" transform="matrix(-1 0 0 1 55.092 41.876)"> + <path fill="#fff" d="M0 14.395l-3.99-.272-.01.136v.136h4zm17.038-13.28l-.495-3.97.495 3.97zm16.054 7.142h4v-.165l-.014-.164-3.986.329zM19.159 21.989l.63 3.95.104-.016.104-.023-.838-3.911zM3.991 14.668c.436-6.386 1.492-7.26 2.128-7.642.579-.348 1.58-.681 3.606-.994 2.071-.32 4.408-.524 7.808-.948l-.99-7.939c-2.98.372-5.856.643-8.04.98-2.23.345-4.529.856-6.506 2.044C-2.479 2.86-3.56 7.83-3.99 14.123l7.982.545zm13.542-9.584c3.216-.401 5.411-.754 7.384-.955 1.975-.202 2.849-.133 3.247-.02.035.01.005-.093.144.198.276.582.593 1.798.797 4.279l7.973-.658c-.22-2.663-.615-5.099-1.546-7.056-1.068-2.247-2.8-3.776-5.166-4.453-2.003-.574-4.23-.456-6.262-.249-2.034.208-4.752.625-7.561.975l.99 7.939zm11.559 3.173c0 6.022-1.247 7.164-1.898 7.605-.552.373-1.418.71-3.007 1.065-.775.174-1.624.332-2.624.517-.978.181-2.076.384-3.241.634l1.675 7.822c1.04-.223 2.033-.406 3.023-.59.969-.18 1.967-.364 2.911-.575 1.852-.413 3.917-1.01 5.745-2.247 4.109-2.78 5.416-7.559 5.416-14.231h-8zM18.53 18.039c-2.982.475-5.911 1.147-8.04 1.557-2.36.456-3.825.61-4.797.49-.676-.082-.754-.224-.892-.459C4.465 19.057 4 17.62 4 14.395h-8c0 3.667.473 6.856 1.908 9.293 1.632 2.77 4.158 4.014 6.814 4.34 2.362.288 4.958-.128 7.283-.576 2.556-.494 4.937-1.06 7.783-1.513l-1.258-7.9z"/> + </g> + <path fill="#fff" d="M58.692 56v-5.897C62.741 48.19 67.26 45.81 70 44.5c1.86-.889 3.371-.179 3.74 1.69.26 1.31-.586 2.684-1.74 3.31-1.154.626-13.308 6.5-13.308 6.5z"/> + </g> +</svg> diff --git a/src/assets/stylesheets/info-dialog.scss b/src/assets/stylesheets/info-dialog.scss index 47b06a43623b109a9fa27060d83f7da44889e59c..690db56c83a0fee301b0bb95952f6a1622ec9140 100644 --- a/src/assets/stylesheets/info-dialog.scss +++ b/src/assets/stylesheets/info-dialog.scss @@ -182,3 +182,17 @@ } } } + + +.info-dialog--action-button { + @extend %bottom-button; + margin-left: 6px; + margin-right: 6px; + appearance: none; + width: 168px; + text-align: center; + -moz-appearance: none; + -webkit-appearance: none; + margin: auto; + text-decoration: none; +} diff --git a/src/assets/stylesheets/link-dialog.scss b/src/assets/stylesheets/link-dialog.scss new file mode 100644 index 0000000000000000000000000000000000000000..aafcac281e33a362fc69871f9474fff32ddd73b7 --- /dev/null +++ b/src/assets/stylesheets/link-dialog.scss @@ -0,0 +1,29 @@ +:local(.domain) , :local(.code) { + color: white; + font-family: monospace; + font-weight: bold; + text-decoration: none; +} + +:local(.domain) { + font-size: 3em; + padding: 14px; + display: block; +} + +:local(.code) { + font-size: 4.0em; + padding: 8px; +} + +:local(.keep-open) { + font-size: 0.8em; +} + +:local(.digit) { + padding: 0 8px; +} + +:local(.code-loading-panel) { + background: none; +} diff --git a/src/assets/stylesheets/link.scss b/src/assets/stylesheets/link.scss new file mode 100644 index 0000000000000000000000000000000000000000..63da92c9709fa72367461af1006d85de03e4873a --- /dev/null +++ b/src/assets/stylesheets/link.scss @@ -0,0 +1,181 @@ +@import 'shared'; +@import 'loader'; + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + background-color: black; + color: white; +} + +a { + color: white; +} + +.link-root { + @extend %default-font; + + width: 100%; + height: 100%; + margin: 0; + padding: 0; + + position: absolute; +} + +:local(.link) { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +:local(.link-contents) { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + color: $grey-text; + font-size: 1.4em; + + @media (max-width: 690px) { + flex-direction: column; + } +} + +:local(.entered-footer) { + margin: 16px; + font-size: 0.8em; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + @media (max-width: 690px) { + display: none; + } +} + +:local(.entered-contents) { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +:local(.code-loading-panel) { + background: rgba(0.4, 0.4, 0.4, 0.85); +} + +:local(.entry-footer-image) { + width: 200px; + margin: 12px; +} + + +:local(.footer-image) { + width: 200px; + margin: 12px; + + @media (max-height: 719px) { + display: none; + } +} + +:local(.header) { + margin: 16px; +} + +:local(.footer) { + margin: 16px; + font-size: 0.8em; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + @media (min-width: 690px) , (max-height: 650px) { + display: none; + } +} + +:local(.keypad) { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: 1fr 1fr 1fr 1fr; +} + +:local(.keypad-button) { + @extend %big-icon-button; + font-size: 1.8em; + font-family: sans-serif; + border: 4px $light-grey solid; + border-radius: 128px; + min-width: 88px; + min-height: 88px; + cursor: pointer; + line-height: 68px; + margin: 8px; +} + +:local(.keypad-button):active { + background-color: $darker-grey; +} + +:local(.keypad-zero-button) { + grid-column: 2; +} + +:local(.keypad-button):disabled { + color: $light-grey; + border: 6px $dark-grey solid; +} + +:local(.keypad-backspace) , :local(.keypad-backspace):disabled , :local(.keypad-backspace):active { + border: none; +} + +:local(.keypad-backspace):active { + background-color: transparent; + color: $light-grey; +} + +:local(.entered-digits) { + font-face: monospace; + height: 100px; + width: 300px; + text-align: center; + font-size: 3.0em; + color: white; + display: flex; + justify-content: center; +} + +:local(.digit) { + margin: 8px; +} + +:local(.digit-input) { + outline-style: none; + appearance:textfield; + -moz-appearance:textfield; + -webkit-appearance:textfield; + background: transparent; + color: white; + margin: 0; + font-size: 64pt; + border: 0; + width: 225px; + letter-spacing: 0.08em; + text-align: center; +} + +:local(.digit-input::placeholder) { + letter-spacing: 0; +} + diff --git a/src/assets/translations.data.json b/src/assets/translations.data.json index 7dcd32f8f81d394ed06aa11ee117823a721b0f1f..c6ef31c5c944ea85568fb6fe1cded6c47e0341b7 100644 --- a/src/assets/translations.data.json +++ b/src/assets/translations.data.json @@ -5,15 +5,19 @@ "entry.mobile-screen": "Phone", "entry.generic-prefix": "Enter in ", "entry.generic-medium": "VR", + "entry.generic-subtitle-desktop": "Oculus or SteamVR", "entry.gearvr-prefix": "Enter on ", - "entry.gearvr-medium": "GearVR", + "entry.gearvr-medium": "Gear VR", + "entry.device-prefix-desktop": "Send to ", + "entry.device-prefix-mobile": "Enter on ", + "entry.device-medium": "Device", + "entry.device-subtitle-desktop": "Standalone Headset or Phone", + "entry.device-subtitle-mobile": "Mobile Headset or PC", "entry.cardboard": "Enter on Google Cardboard", "entry.daydream-prefix": "Enter on ", "entry.daydream-medium": "Daydream", "entry.daydream-via-chrome": "Using Google Chrome", "entry.enable-screen-sharing": "Share my desktop", - "entry.webvr-link-preamble": "New to WebVR?", - "entry.webvr-link": "Learn more", "profile.save": "SAVE", "profile.display_name.validation_warning": "Alphanumerics and hyphens. At least 3 characters, no more than 32", "profile.header": "Your display name:", @@ -56,6 +60,13 @@ "home.environment_author_by": " by ", "home.dialog.close": "CLOSE", "mailing_list.privacy_label": "I'm okay with Mozilla handling my info as explained in", - "mailing_list.privacy_link": "this Privacy Notice" + "mailing_list.privacy_link": "this Privacy Notice", + "link.in_your_browser": "In your device's browser, go to:", + "link.enter_code": "Then, enter this code:", + "link.do_not_close": "Keep this dialog open to use this code.", + "link.link_page_header": "Enter your code:", + "link.dont_have_a_code": "Don't have a code?", + "link.create_a_room": "Create a Room", + "link.try_again": "We couldn't find that code. Please try again." } } diff --git a/src/hub.js b/src/hub.js index 27963943373e9b748120b861f902fe64adeda2b3..f85a4980f3dd50dbda64fc368627d77fe42c0d1a 100644 --- a/src/hub.js +++ b/src/hub.js @@ -1,8 +1,6 @@ import "./assets/stylesheets/hub.scss"; import moment from "moment-timezone"; -import uuid from "uuid/v4"; import queryString from "query-string"; -import { Socket } from "phoenix"; import { patchWebGLRenderingContext } from "./utils/webgl"; patchWebGLRenderingContext(); @@ -69,6 +67,8 @@ import ReactDOM from "react-dom"; import React from "react"; import UIRoot from "./react-components/ui-root"; import HubChannel from "./utils/hub-channel"; +import LinkChannel from "./utils/link-channel"; +import { connectToReticulum } from "./utils/phoenix-utils"; import "./systems/personal-space-bubble"; import "./systems/app-mode"; @@ -118,7 +118,6 @@ import registerNetworkSchemas from "./network-schemas"; import { inGameActions, config as inputConfig } from "./input-mappings"; import registerTelemetry from "./telemetry"; -import { generateDefaultProfile, generateRandomName } from "./utils/identity.js"; import { getAvailableVREntryTypes, VR_DEVICE_AVAILABILITY } from "./utils/vr-caps-detect.js"; import ConcurrentLoadDetector from "./utils/concurrent-load-detector.js"; @@ -145,13 +144,7 @@ const concurrentLoadDetector = new ConcurrentLoadDetector(); concurrentLoadDetector.start(); -// Always layer in any new default profile bits -store.update({ activity: {}, settings: {}, profile: { ...generateDefaultProfile(), ...(store.state.profile || {}) } }); - -// Regenerate name to encourage users to change it. -if (!store.state.activity.hasChangedName) { - store.update({ profile: { displayName: generateRandomName() } }); -} +store.init(); function mountUI(scene, props = {}) { const disableAutoExitOnConcurrentLoad = qsTruthy("allow_multi"); @@ -181,13 +174,14 @@ function mountUI(scene, props = {}) { const onReady = async () => { const scene = document.querySelector("a-scene"); const hubChannel = new HubChannel(store); + const linkChannel = new LinkChannel(store); document.querySelector("canvas").classList.add("blurred"); window.APP.scene = scene; registerNetworkSchemas(); - let uiProps = {}; + let uiProps = { linkChannel }; mountUI(scene); @@ -421,17 +415,7 @@ const onReady = async () => { const hubId = qs.hub_id || document.location.pathname.substring(1).split("/")[0]; console.log(`Hub ID: ${hubId}`); - const socketProtocol = document.location.protocol === "https:" ? "wss:" : "ws:"; - 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}`); - - const socket = new Socket(socketUrl, { params: { session_id: uuid() } }); - socket.connect(); - + const socket = connectToReticulum(); const channel = socket.channel(`hub:${hubId}`, {}); channel @@ -452,6 +436,8 @@ const onReady = async () => { console.error(res); }); + + linkChannel.setSocket(socket); }; document.addEventListener("DOMContentLoaded", onReady); diff --git a/src/link.html b/src/link.html new file mode 100644 index 0000000000000000000000000000000000000000..8f44654c0562b9ce2afd5b3bb95f482fffc213ab --- /dev/null +++ b/src/link.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <link rel="shortcut icon" type="image/png" href="/favicon.ico"/> + <title>Enter Code | Hubs by Mozilla</title> + <link href="https://fonts.googleapis.com/css?family=Zilla+Slab:300,300i,400,400i,700" rel="stylesheet"> +</head> + +<body> + <div id="link-root" class="link-root"></div> +</body> + +</html> diff --git a/src/link.js b/src/link.js new file mode 100644 index 0000000000000000000000000000000000000000..401fe54d9b8b9bd91df1c2140257710502a23add --- /dev/null +++ b/src/link.js @@ -0,0 +1,20 @@ +import "./assets/stylesheets/link.scss"; +import React from "react"; +import ReactDOM from "react-dom"; +import registerTelemetry from "./telemetry"; +import LinkRoot from "./react-components/link-root"; +import LinkChannel from "./utils/link-channel"; +import { connectToReticulum } from "./utils/phoenix-utils"; +import Store from "./storage/store"; + +registerTelemetry(); + +const socket = connectToReticulum(); +const store = new Store(); +store.init(); + +const linkChannel = new LinkChannel(store); + +linkChannel.setSocket(socket); + +ReactDOM.render(<LinkRoot store={store} linkChannel={linkChannel} />, document.getElementById("link-root")); diff --git a/src/react-components/entry-buttons.js b/src/react-components/entry-buttons.js index 92d0ef5ef8ccd4992d9913bfb30144fff018c0b9..90dd40cfd23724418228cc7bb3b46fe45dc8e66f 100644 --- a/src/react-components/entry-buttons.js +++ b/src/react-components/entry-buttons.js @@ -7,7 +7,8 @@ import MobileScreenEntryImg from "../assets/images/mobile_screen_entry.svg"; import DesktopScreenEntryImg from "../assets/images/desktop_screen_entry.svg"; import GenericVREntryImg from "../assets/images/generic_vr_entry.svg"; import GearVREntryImg from "../assets/images/gearvr_entry.svg"; -import DaydreamEntyImg from "../assets/images/daydream_entry.svg"; +import DaydreamEntryImg from "../assets/images/daydream_entry.svg"; +import DeviceEntryImg from "../assets/images/device_entry.svg"; const mobiledetect = new MobileDetect(navigator.userAgent); @@ -22,7 +23,11 @@ const EntryButton = props => ( <span className="entry-button--bolded"> <FormattedMessage id={props.mediumMessageId} /> </span> - {props.subtitle && <div className="entry-button__subtitle">{props.subtitle}</div>} + {props.subtitle && ( + <div className="entry-button__subtitle"> + <FormattedMessage id={props.subtitle} /> + </div> + )} </div> </div> </button> @@ -52,7 +57,8 @@ export const GenericEntryButton = props => { ...props, iconSrc: GenericVREntryImg, prefixMessageId: "entry.generic-prefix", - mediumMessageId: "entry.generic-medium" + mediumMessageId: "entry.generic-medium", + subtitle: mobiledetect.mobile() ? null : "entry.generic-subtitle-desktop" }; return <EntryButton {...entryButtonProps} />; @@ -72,10 +78,22 @@ export const GearVREntryButton = props => { export const DaydreamEntryButton = props => { const entryButtonProps = { ...props, - iconSrc: DaydreamEntyImg, + iconSrc: DaydreamEntryImg, prefixMessageId: "entry.daydream-prefix", mediumMessageId: "entry.daydream-medium" }; return <EntryButton {...entryButtonProps} />; }; + +export const DeviceEntryButton = props => { + const entryButtonProps = { + ...props, + iconSrc: DeviceEntryImg, + prefixMessageId: mobiledetect.mobile() ? "entry.device-prefix-mobile" : "entry.device-prefix-desktop", + mediumMessageId: "entry.device-medium", + subtitle: mobiledetect.mobile() ? "entry.device-subtitle-mobile" : "entry.device-subtitle-desktop" + }; + + return <EntryButton {...entryButtonProps} />; +}; diff --git a/src/react-components/info-dialog.js b/src/react-components/info-dialog.js index 6e27e39c91262cf7e23a2597d00793f5f172f34b..7a48099b400203abcadced08ac5c5df6cb41552d 100644 --- a/src/react-components/info-dialog.js +++ b/src/react-components/info-dialog.js @@ -4,6 +4,7 @@ import classNames from "classnames"; import PropTypes from "prop-types"; import { FormattedMessage } from "react-intl"; import formurlencoded from "form-urlencoded"; +import LinkDialog from "./link-dialog.js"; // TODO i18n @@ -14,12 +15,15 @@ class InfoDialog extends Component { invite: Symbol("invite"), updates: Symbol("updates"), report: Symbol("report"), - help: Symbol("help") + help: Symbol("help"), + link: Symbol("link"), + webvr_recommend: Symbol("webvr_recommend") }; static propTypes = { dialogType: PropTypes.oneOf(Object.values(InfoDialog.dialogTypes)), onCloseDialog: PropTypes.func, - onSubmittedEmail: PropTypes.func + onSubmittedEmail: PropTypes.func, + linkCode: PropTypes.string }; constructor(props) { @@ -248,6 +252,27 @@ class InfoDialog extends Component { </div> ); break; + case InfoDialog.dialogTypes.webvr_recommend: + dialogTitle = "Enter in VR"; + dialogBody = ( + <div> + <p>To enter Hubs with Oculus or SteamVR, you can use Firefox.</p> + <a className="info-dialog--action-button" href="https://www.mozilla.org/firefox"> + Download Firefox + </a> + <p style={{ fontSize: "0.8em" }}> + For a full list of browsers with experimental VR support, visit{" "} + <a href="https://webvr.rocks" target="_blank" rel="noopener noreferrer"> + WebVR Rocks + </a>. + </p> + </div> + ); + break; + case InfoDialog.dialogTypes.link: + dialogTitle = "Send Link to Device"; + dialogBody = <LinkDialog linkCode={this.props.linkCode} />; + break; } const dialogClasses = classNames({ diff --git a/src/react-components/link-dialog.js b/src/react-components/link-dialog.js new file mode 100644 index 0000000000000000000000000000000000000000..80ab360d50d54da5738a9c3c771b090b0d4bc9fa --- /dev/null +++ b/src/react-components/link-dialog.js @@ -0,0 +1,54 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { FormattedMessage } from "react-intl"; + +import styles from "../assets/stylesheets/link-dialog.scss"; + +class LinkDialog extends Component { + static propTypes = { + linkCode: PropTypes.string + }; + + render() { + if (!this.props.linkCode) { + return ( + <div> + <div className={classNames("loading-panel", styles.codeLoadingPanel)}> + <div className="loader-wrap"> + <div className="loader"> + <div className="loader-center" /> + </div> + </div> + </div> + </div> + ); + } + + return ( + <div> + <div> + <FormattedMessage id="link.in_your_browser" /> + </div> + <a href="https://hub.link" className={styles.domain} target="_blank" rel="noopener noreferrer"> + hub.link + </a> + <div> + <FormattedMessage id="link.enter_code" /> + </div> + <div className={styles.code}> + {this.props.linkCode.split("").map((d, i) => ( + <span className={styles.digit} key={`link_code_${i}`}> + {d} + </span> + ))} + </div> + <div className={styles.keepOpen}> + <FormattedMessage id="link.do_not_close" /> + </div> + </div> + ); + } +} + +export default LinkDialog; diff --git a/src/react-components/link-root.js b/src/react-components/link-root.js new file mode 100644 index 0000000000000000000000000000000000000000..1ab8aa47829ad5181f3796b72502d3f587241b55 --- /dev/null +++ b/src/react-components/link-root.js @@ -0,0 +1,176 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { IntlProvider, FormattedMessage, addLocaleData } from "react-intl"; +import en from "react-intl/locale-data/en"; + +import { lang, messages } from "../utils/i18n"; +import classNames from "classnames"; +import styles from "../assets/stylesheets/link.scss"; + +const MAX_DIGITS = 4; + +addLocaleData([...en]); + +class LinkRoot extends Component { + static propTypes = { + intl: PropTypes.object, + store: PropTypes.object, + linkChannel: PropTypes.object + }; + + state = { + enteredDigits: "", + failedAtLeastOnce: false + }; + + componentWillMount = () => { + document.addEventListener("keydown", this.handleKeyDown); + }; + + componentWillUnmount = () => { + document.removeEventListener("keydown", this.handleKeyDown); + }; + + handleKeyDown = e => { + // Number keys 0-9 + if (e.keyCode < 48 || e.keyCode > 57) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + this.addDigit(e.keyCode - 48); + }; + + addDigit = digit => { + if (this.state.enteredDigits.length >= MAX_DIGITS) return; + const newDigits = `${this.state.enteredDigits}${digit}`; + + if (newDigits.length === MAX_DIGITS) { + this.attemptLink(newDigits); + } + + this.setState({ enteredDigits: newDigits }); + }; + + removeDigit = () => { + const enteredDigits = this.state.enteredDigits; + if (enteredDigits.length === 0) return; + this.setState({ enteredDigits: enteredDigits.substring(0, enteredDigits.length - 1) }); + }; + + attemptLink = code => { + this.props.linkChannel + .attemptLink(code) + .then(response => { + // If there is a profile from the linked device, copy it over if we don't have one yet. + if (response.profile) { + const { hasChangedName } = this.props.store.state.activity; + + if (!hasChangedName) { + this.props.store.update({ activity: { hasChangedName: true }, profile: response.profile }); + } + } + + if (response.path) { + window.location.href = response.path; + } + }) + .catch(e => { + console.error(e); + this.setState({ failedAtLeastOnce: true, enteredDigits: "" }); + }); + }; + + render() { + return ( + <IntlProvider locale={lang} messages={messages}> + <div className={styles.link}> + <div className={styles.linkContents}> + {this.state.enteredDigits.length === MAX_DIGITS && ( + <div className={classNames("loading-panel", styles.codeLoadingPanel)}> + <div className="loader-wrap"> + <div className="loader"> + <div className="loader-center" /> + </div> + </div> + </div> + )} + + <div className={styles.enteredContents}> + <div className={styles.header}> + <FormattedMessage id={this.state.failedAtLeastOnce ? "link.try_again" : "link.link_page_header"} /> + </div> + + <div className={styles.enteredDigits}> + <input + className={styles.digitInput} + type="number" + value={this.state.enteredDigits} + onChange={ev => { + this.setState({ enteredDigits: ev.target.value }); + }} + placeholder="- - - -" + /> + </div> + + <div className={styles.enteredFooter}> + <span> + <FormattedMessage id="link.dont_have_a_code" /> + </span>{" "} + <span> + <a href="/"> + <FormattedMessage id="link.create_a_room" /> + </a> + </span> + <img className={styles.entryFooterImage} src="../assets/images/logo.svg" /> + </div> + </div> + + <div className={styles.keypad}> + {[1, 2, 3, 4, 5, 6, 7, 8, 9].map((d, i) => ( + <button + disabled={this.state.enteredDigits.length === MAX_DIGITS} + key={`digit_${i}`} + className={styles.keypadButton} + onClick={() => this.addDigit(d)} + > + {d} + </button> + ))} + <button + disabled={this.state.enteredDigits.length === MAX_DIGITS} + className={classNames(styles.keypadButton, styles.keypadZeroButton)} + onClick={() => this.addDigit(0)} + > + 0 + </button> + <button + disabled={this.state.enteredDigits.length === 0 || this.state.enteredDigits.length === MAX_DIGITS} + className={classNames(styles.keypadButton, styles.keypadBackspace)} + onClick={() => this.removeDigit()} + > + ⌫ + </button> + </div> + + <div className={styles.footer}> + <span> + <FormattedMessage id="link.dont_have_a_code" /> + </span>{" "} + <span> + <a href="/"> + <FormattedMessage id="link.create_a_room" /> + </a> + </span> + <img className={styles.footerImage} src="../assets/images/logo.svg" alt="Logo" /> + </div> + </div> + </div> + </IntlProvider> + ); + } +} + +export default LinkRoot; diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js index 849cd21eb49d0bf27c4eea7c08d7ad7e2de909bc..803c28acded3354c4065be0fa380424e0b7c2237 100644 --- a/src/react-components/ui-root.js +++ b/src/react-components/ui-root.js @@ -11,7 +11,7 @@ import screenfull from "screenfull"; import { lang, messages } from "../utils/i18n"; import AutoExitWarning from "./auto-exit-warning"; -import { TwoDEntryButton, GenericEntryButton, GearVREntryButton, DaydreamEntryButton } from "./entry-buttons.js"; +import { TwoDEntryButton, DeviceEntryButton, GenericEntryButton, DaydreamEntryButton } from "./entry-buttons.js"; import { ProfileInfoHeader } from "./profile-info-header.js"; import ProfileEntryPanel from "./profile-entry-panel"; import InfoDialog from "./info-dialog.js"; @@ -60,6 +60,7 @@ class UIRoot extends Component { enableScreenSharing: PropTypes.bool, store: PropTypes.object, scene: PropTypes.object, + linkChannel: PropTypes.object, htmlPrefix: PropTypes.string, showProfileEntry: PropTypes.bool, availableVREntryTypes: PropTypes.object, @@ -75,6 +76,8 @@ class UIRoot extends Component { entryStep: ENTRY_STEPS.start, enterInVR: false, infoDialogType: null, + linkCode: null, + linkCodeCancel: null, shareScreen: false, requestedScreen: false, @@ -158,8 +161,6 @@ class UIRoot extends Component { if (this.props.forcedVREntryType === "daydream") { this.enterDaydream(); - } else if (this.props.forcedVREntryType === "gearvr") { - this.enterGearVR(); } else if (this.props.forcedVREntryType === "vr") { this.enterVR(); } else if (this.props.forcedVREntryType === "2d") { @@ -260,25 +261,10 @@ class UIRoot extends Component { }; enterVR = async () => { - await this.performDirectEntryFlow(true); - }; - - enterGearVR = async () => { - if (this.props.availableVREntryTypes.gearvr === VR_DEVICE_AVAILABILITY.yes) { + if (this.props.availableVREntryTypes.generic !== VR_DEVICE_AVAILABILITY.maybe) { await this.performDirectEntryFlow(true); } else { - this.exit(); - - // Launch via Oculus Browser - const location = window.location; - const qs = queryString.parse(location.search); - qs.vr_entry_type = "gearvr"; // Auto-choose 'gearvr' after landing in Oculus Browser - - const ovrwebUrl = - `ovrweb://${location.protocol || "http:"}//${location.host}` + - `${location.pathname || ""}?${queryString.stringify(qs)}#${location.hash || ""}`; - - window.location = ovrwebUrl; + this.setState({ infoDialogType: InfoDialog.dialogTypes.webvr_recommend }); } }; @@ -513,6 +499,21 @@ class UIRoot extends Component { this.setState({ entryStep: ENTRY_STEPS.finished }); }; + attemptLink = async () => { + this.setState({ infoDialogType: InfoDialog.dialogTypes.link }); + const { code, cancel, onFinished } = await this.props.linkChannel.generateCode(); + this.setState({ linkCode: code, linkCodeCancel: cancel }); + onFinished.then(this.handleCloseDialog); + }; + + handleCloseDialog = async () => { + if (this.state.linkCodeCancel) { + this.state.linkCodeCancel(); + } + + this.setState({ infoDialogType: null, linkCode: null, linkCodeCancel: null }); + }; + render() { if (this.state.exited || this.props.roomUnavailableReason || this.props.platformUnsupportedReason) { let subtitle = null; @@ -543,7 +544,10 @@ class UIRoot extends Component { rel="noreferrer noopener" > WebRTC Data Channels - </a>, which is required to use Hubs. + </a>, which is required to use Hubs.<br />If you"d like to use Hubs with Oculus or SteamVR, you can{" "} + <a href="https://www.mozilla.org/firefox" rel="noreferrer noopener"> + Download Firefox + </a>. </div> ); } else { @@ -588,8 +592,6 @@ class UIRoot extends Component { ); } - const daydreamMaybeSubtitle = messages["entry.daydream-via-chrome"]; - // Only show this in desktop firefox since other browsers/platforms will ignore the "screen" media constraint and // will attempt to share your webcam instead! const screenSharingCheckbox = this.props.enableScreenSharing && @@ -614,17 +616,17 @@ class UIRoot extends Component { {this.props.availableVREntryTypes.generic !== VR_DEVICE_AVAILABILITY.no && ( <GenericEntryButton onClick={this.enterVR} /> )} - {this.props.availableVREntryTypes.gearvr !== VR_DEVICE_AVAILABILITY.no && ( - <GearVREntryButton onClick={this.enterGearVR} /> - )} {this.props.availableVREntryTypes.daydream !== VR_DEVICE_AVAILABILITY.no && ( <DaydreamEntryButton onClick={this.enterDaydream} subtitle={ - this.props.availableVREntryTypes.daydream == VR_DEVICE_AVAILABILITY.maybe ? daydreamMaybeSubtitle : "" + this.props.availableVREntryTypes.daydream == VR_DEVICE_AVAILABILITY.maybe + ? "entry.daydream-via-chrome" + : null } /> )} + <DeviceEntryButton onClick={this.attemptLink} /> {this.props.availableVREntryTypes.cardboard !== VR_DEVICE_AVAILABILITY.no && ( <div className="entry-panel__secondary" onClick={this.enterVR}> <FormattedMessage id="entry.cardboard" /> @@ -632,19 +634,6 @@ class UIRoot extends Component { )} {screenSharingCheckbox} </div> - {!mobiledetect.mobile() && ( - <div className="entry-panel__webvr-link-container"> - <FormattedMessage id="entry.webvr-link-preamble" />{" "} - <a - className="entry-panel__webvr-link" - target="_blank" - rel="noopener noreferrer" - href="https://webvr.rocks/" - > - <FormattedMessage id="entry.webvr-link" /> - </a> - </div> - )} </div> ) : null; @@ -675,12 +664,7 @@ class UIRoot extends Component { </div> </div> <div className="mic-grant-panel__next-container"> - <button - className={classNames("mic-grant-panel__next", { - invisible: this.state.entryStep === ENTRY_STEPS.mic_grant - })} - onClick={this.onMicGrantButton} - > + <button className={classNames("mic-grant-panel__next")} onClick={this.onMicGrantButton}> <FormattedMessage id="audio.granted-next" /> </button> </div> @@ -829,8 +813,9 @@ class UIRoot extends Component { <div className="ui"> <InfoDialog dialogType={this.state.infoDialogType} + linkCode={this.state.linkCode} onSubmittedEmail={() => this.setState({ infoDialogType: InfoDialog.dialogTypes.email_submitted })} - onCloseDialog={() => this.setState({ infoDialogType: null })} + onCloseDialog={this.handleCloseDialog} /> {this.state.entryStep === ENTRY_STEPS.finished && ( diff --git a/src/storage/store.js b/src/storage/store.js index 23f3168198f11a6824d0f1a4bdd63d67a0ef5961..e4e509ba3c1f1fc41b45d6816ba31f542f9c09cd 100644 --- a/src/storage/store.js +++ b/src/storage/store.js @@ -5,6 +5,7 @@ const LOCAL_STORE_KEY = "___hubs_store"; const STORE_STATE_CACHE_KEY = Symbol(); const validator = new Validator(); import { EventTarget } from "event-target-shim"; +import { generateDefaultProfile, generateRandomName } from "../utils/identity.js"; // Durable (via local-storage) schema-enforced state that is meant to be consumed via forward data flow. // (Think flux but with way less incidental complexity, at least for now :)) @@ -60,6 +61,20 @@ export default class Store extends EventTarget { } } + // Initializes store with any default bits + init = () => { + this.update({ + activity: {}, + settings: {}, + profile: { ...generateDefaultProfile(), ...(this.state.profile || {}) } + }); + + // Regenerate name to encourage users to change it. + if (!this.state.activity.hasChangedName) { + this.update({ profile: { displayName: generateRandomName() } }); + } + }; + get state() { if (!this.hasOwnProperty(STORE_STATE_CACHE_KEY)) { this[STORE_STATE_CACHE_KEY] = JSON.parse(localStorage.getItem(LOCAL_STORE_KEY)); diff --git a/src/utils/crypto.js b/src/utils/crypto.js new file mode 100644 index 0000000000000000000000000000000000000000..53fd606e98657c896da161edb97f93272a684ef7 --- /dev/null +++ b/src/utils/crypto.js @@ -0,0 +1,76 @@ +// NOTE: We do not use an IV since we generate a new keypair each time we use these routines. + +async function deriveKey(privateKey, publicKey) { + return crypto.subtle.deriveKey( + { name: "ECDH", public: publicKey }, + privateKey, + { name: "AES-CBC", length: 256 }, + true, + ["encrypt", "decrypt"] + ); +} + +async function publicKeyToString(key) { + return JSON.stringify(await crypto.subtle.exportKey("jwk", key)); +} + +async function stringToPublicKey(s) { + return await crypto.subtle.importKey("jwk", JSON.parse(s), { name: "ECDH", namedCurve: "P-256" }, true, []); +} + +function stringToArrayBuffer(s) { + const buf = new Uint8Array(s.length); + + for (let i = 0; i < s.length; i++) { + buf[i] = s.charCodeAt(i); + } + + return buf; +} + +function arrayBufferToString(b) { + const buf = new Uint8Array(b); + let s = ""; + + for (let i = 0; i < buf.byteLength; i++) { + s += String.fromCharCode(buf[i]); + } + + return s; +} + +// This allows a single object to be passed encrypted from a receiver in a req -> response flow + +// Requestor generates a public key and private key, and should send the public key to receiver. +export async function generateKeys() { + const keyPair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveKey"]); + const publicKeyString = await publicKeyToString(keyPair.publicKey); + return { publicKeyString, privateKey: keyPair.privateKey }; +} + +// Receiver takes the public key from requestor and passes obj to get a response public key and the encrypted data to return. +export async function generatePublicKeyAndEncryptedObject(incomingPublicKeyString, obj) { + const iv = new Uint8Array(16); + const incomingPublicKey = await stringToPublicKey(incomingPublicKeyString); + const keyPair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveKey"]); + const publicKeyString = await publicKeyToString(keyPair.publicKey); + const secret = await deriveKey(keyPair.privateKey, incomingPublicKey); + + const encryptedData = btoa( + arrayBufferToString( + await crypto.subtle.encrypt({ name: "AES-CBC", iv }, secret, stringToArrayBuffer(JSON.stringify(obj))) + ) + ); + + return { publicKeyString, encryptedData }; +} + +// Requestor then takes the receiver's public key, the private key (returned from generateKeys()), and the data from the receiver. +export async function decryptObject(publicKeyString, privateKey, base64value) { + const iv = new Uint8Array(16); + const publicKey = await stringToPublicKey(publicKeyString); + const secret = await deriveKey(privateKey, publicKey); + const ciphertext = stringToArrayBuffer(atob(base64value)); + const data = await crypto.subtle.decrypt({ name: "AES-CBC", iv }, secret, ciphertext); + return JSON.parse(arrayBufferToString(data)); +} diff --git a/src/utils/link-channel.js b/src/utils/link-channel.js new file mode 100644 index 0000000000000000000000000000000000000000..4da4e1eb2795c248cd7b4ddeb134549706d1ac0a --- /dev/null +++ b/src/utils/link-channel.js @@ -0,0 +1,143 @@ +import { generatePublicKeyAndEncryptedObject, generateKeys, decryptObject } from "./crypto"; + +const LINK_ACTION_TIMEOUT = 10000; + +export default class LinkChannel { + constructor(store) { + this.store = store; + } + + setSocket = socket => { + this.socket = socket; + }; + + // Returns a promise that, when resolved, will forward an object with three keys: + // + // code: The code that was made available to use for link. + // + // cancel: A function that the caller can call to cancel the use of the code. + // + // onFinished: A promise that, when resolved, indicates the code is no longer usable, + // because it was either successfully used by the remote device or it has expired + // ("used" or "expired" is passed to the callback). + generateCode = () => { + return new Promise(resolve => { + const onFinished = new Promise(finished => { + const step = () => { + const code = Math.floor(Math.random() * 9999) + .toString() + .padStart(4, "0"); + + // Only respond to one link_request in this channel. + let readyToSend = false; + let leftChannel = false; + + const channel = this.socket.channel(`link:${code}`, { timeout: LINK_ACTION_TIMEOUT }); + + const leave = () => { + if (!leftChannel) channel.leave(); + leftChannel = true; + }; + + const cancel = () => leave(); + + channel.on("link_expired", () => finished("expired")); + + channel.on("presence_state", state => { + if (readyToSend) return; + + if (Object.keys(state).length > 0) { + // Code is in use by someone else, try a new one + step(); + } else { + readyToSend = true; + resolve({ code, cancel, onFinished }); + } + }); + + channel.on("link_request", incoming => { + if (readyToSend) { + const data = { path: location.pathname }; + + // Copy profile data to link'ed device if it's been set. + if (this.store.state.activity.hasChangedName) { + data.profile = { ...this.store.state.profile }; + } + + generatePublicKeyAndEncryptedObject(incoming.public_key, data).then( + ({ publicKeyString, encryptedData }) => { + const payload = { + target_session_id: incoming.reply_to_session_id, + public_key: publicKeyString, + data: encryptedData + }; + + if (!leftChannel) { + channel.push("link_response", payload); + } + + leave(); + + finished("used"); + readyToSend = false; + } + ); + } + }); + + channel.join().receive("error", r => console.error(r)); + }; + + step(); + }); + }); + }; + + // Attempts to receive a link payload from a remote device using the given code. + // + // Promise rejects if the code is invalid or there is a problem with the channel. + // Promise resolves and passes payload of link source on successful link. + attemptLink = code => { + return new Promise((resolve, reject) => { + const channel = this.socket.channel(`link:${code}`, { timeout: LINK_ACTION_TIMEOUT }); + let finished = false; + + generateKeys().then(({ publicKeyString, privateKey }) => { + channel.on("presence_state", state => { + const numOccupants = Object.keys(state).length; + + if (numOccupants === 1) { + // Great, only sender is in topic, request link + channel.push("link_request", { + reply_to_session_id: this.socket.params.session_id, + public_key: publicKeyString + }); + + setTimeout(() => { + if (finished) return; + channel.leave(); + reject(new Error("no_response")); + }, LINK_ACTION_TIMEOUT); + } else if (numOccupants === 0) { + // Nobody in this channel, probably a bad code. + channel.leave(); + reject(new Error("failed")); + } else { + console.warn("link code channel already has 2 or more occupants, something fishy is going on."); + channel.leave(); + reject(new Error("in_use")); + } + }); + + channel.on("link_response", payload => { + finished = true; + channel.leave(); + + decryptObject(payload.public_key, privateKey, payload.data).then(resolve); + }); + + channel.join().receive("error", r => console.error(r)); + }); + }); + }; +} diff --git a/src/utils/phoenix-utils.js b/src/utils/phoenix-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..3c20f8db96aa261e4d8dd27d727b89b499728e53 --- /dev/null +++ b/src/utils/phoenix-utils.js @@ -0,0 +1,20 @@ +import queryString from "query-string"; +import uuid from "uuid/v4"; +import { Socket } from "phoenix"; + +export function connectToReticulum() { + const qs = queryString.parse(location.search); + + const socketProtocol = qs.phx_protocol || (document.location.protocol === "https:" ? "wss:" : "ws:"); + 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 Socket URL: ${socketUrl}`); + + const socket = new Socket(socketUrl, { params: { session_id: uuid() } }); + socket.connect(); + + return socket; +} diff --git a/src/utils/vr-caps-detect.js b/src/utils/vr-caps-detect.js index 5f312c23b7a20ca33f7f2d88fc9bd192d90df609..79c425da26428b227daa71890b443f913640abc2 100644 --- a/src/utils/vr-caps-detect.js +++ b/src/utils/vr-caps-detect.js @@ -1,5 +1,8 @@ const { detect } = require("detect-browser"); +import MobileDetect from "mobile-detect"; + const browser = detect(); +const mobiledetect = new MobileDetect(navigator.userAgent); // Precision on device detection is fuzzy -- we can sometimes know if a device is definitely // available, or definitely *not* available, and assume it may be available otherwise. @@ -39,12 +42,16 @@ const GENERIC_ENTRY_TYPE_DEVICE_BLACKLIST = [/cardboard/i]; // - gearvr: Oculus GearVR // export async function getAvailableVREntryTypes() { - const isWebVRCapableBrowser = !!navigator.getVRDisplays; const isSamsungBrowser = browser.name === "chrome" && navigator.userAgent.match(/SamsungBrowser/); const isOculusBrowser = navigator.userAgent.match(/Oculus/); + + // This needs to be kept up-to-date with the latest browsers that can support VR and Hubs. + // Checking for navigator.getVRDisplays always passes b/c of polyfill. + const isWebVRCapableBrowser = window.hasNativeWebVRImplementation; + const isDaydreamCapableBrowser = !!(isWebVRCapableBrowser && browser.name === "chrome" && !isSamsungBrowser); - let generic = VR_DEVICE_AVAILABILITY.no; + let generic = mobiledetect.mobile() ? VR_DEVICE_AVAILABILITY.no : VR_DEVICE_AVAILABILITY.maybe; let cardboard = VR_DEVICE_AVAILABILITY.no; // We only consider GearVR support as "maybe" and never "yes". The only browser diff --git a/webpack.config.js b/webpack.config.js index b77adbc4c7b14e8a1acd5ed4dfa0ba271c143734..17fbb4f8b666a03a7d1577f07d011040b6125f40 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -78,6 +78,7 @@ const config = { entry: { index: path.join(__dirname, "src", "index.js"), hub: path.join(__dirname, "src", "hub.js"), + link: path.join(__dirname, "src", "link.js"), "avatar-selector": path.join(__dirname, "src", "avatar-selector.js") }, output: { @@ -194,6 +195,11 @@ const config = { chunks: ["hub"], inject: "head" }), + new HTMLWebpackPlugin({ + filename: "link.html", + template: path.join(__dirname, "src", "link.html"), + chunks: ["link"] + }), new HTMLWebpackPlugin({ filename: "avatar-selector.html", template: path.join(__dirname, "src", "avatar-selector.html"),