diff --git a/scripts/hab-build-and-push.sh b/scripts/hab-build-and-push.sh index f9ffb81ad1ef8e780bdc4efd8499eee9c0eab4c4..1f0b129f00851cfae504fc347b17adfdde7ffb3f 100755 --- a/scripts/hab-build-and-push.sh +++ b/scripts/hab-build-and-push.sh @@ -31,6 +31,7 @@ npm rebuild node-sass # HACK sometimes node-sass build fails npm run build mkdir dist/pages mv dist/*.html dist/pages +mv dist/hub.service.js dist/pages aws s3 sync --acl public-read --cache-control "max-age=31556926" dist/assets "$TARGET_S3_URL/assets" aws s3 sync --acl public-read --cache-control "no-cache" --delete dist/pages "$TARGET_S3_URL/pages/latest" diff --git a/src/assets/stylesheets/entry.scss b/src/assets/stylesheets/entry.scss index f0424741082257a992e0adfd24de80703d1899c7..d94b199b07ffcaf6b6af11904fb8a779804ea13a 100644 --- a/src/assets/stylesheets/entry.scss +++ b/src/assets/stylesheets/entry.scss @@ -184,4 +184,24 @@ cursor: pointer; color: $dark-grey-text; } + + :local(.subscribe) { + margin-bottom: 24px; + display: flex; + align-items: center; + justify-content: center; + color: $darker-grey; + + input { + @extend %checkbox; + } + + input:checked { + @extend %checkbox-checked; + } + + label { + margin-right: 8px; + } + } } diff --git a/src/assets/stylesheets/hub-create.scss b/src/assets/stylesheets/hub-create.scss index bc9145df6e1246e5a34e60f29efc7fffe7297414..2af9cb72e0c16e35f58b5df5ffd16faadd90dca0 100644 --- a/src/assets/stylesheets/hub-create.scss +++ b/src/assets/stylesheets/hub-create.scss @@ -51,12 +51,12 @@ border-radius: 0px 0px 14px 14px; &::selection { - background-color: #2F80ED; + background-color: $bright-blue; color: white; } &::-moz-selection { - background-color: #2F80ED; + background-color: $bright-blue; color: white; } diff --git a/src/assets/stylesheets/index.scss b/src/assets/stylesheets/index.scss index 66595775efe1e43c7a835987940ff288eee63ef1..46029a3b32be5675fd1448ceb65ca9375e6ddd66 100644 --- a/src/assets/stylesheets/index.scss +++ b/src/assets/stylesheets/index.scss @@ -172,7 +172,7 @@ body { :local(.create) { padding: 2.1em; - padding-bottom: 3.5vw; + padding-bottom: 2vw; position: relative; @media (max-width: 768px) { @@ -189,6 +189,17 @@ body { @extend %action-button; } } + + :local(.spoke-button) { + display: flex; + justify-content: center; + + a { + margin-top: 16px; + @extend %action-button; + background: $spoke-action-color; + } + } } :local(.footer-content) { diff --git a/src/assets/stylesheets/shared.scss b/src/assets/stylesheets/shared.scss index 79a12fc6febb433dfa5453fa2729454971cbb690..d44b6b31916977101aea39ee8824cee35e388559 100644 --- a/src/assets/stylesheets/shared.scss +++ b/src/assets/stylesheets/shared.scss @@ -10,10 +10,12 @@ $light-grey: lightgrey; $dark-grey: rgba(128, 128, 128, 1.0); $darker-grey: rgba(64, 64, 64, 1.0); $darkest-grey: rgba(32, 32, 32, 1.0); +$bright-blue: #2F80ED; $action-color: #FF3464; $action-color-light: #FF74A4; $action-color-transparent: rgba(255, 52, 100, 0.9); $hud-panel-background: rgba(79, 79, 79, 0.45); +$spoke-action-color: $bright-blue; %unselectable { -moz-user-select: none; @@ -153,16 +155,15 @@ $hud-panel-background: rgba(79, 79, 79, 0.45); -webkit-appearance: none; width: 2em; height: 2em; - border: 1px solid #e2e2e2; - border-radius: 9px; + border: 3px solid #aaa; + border-radius: 6px; vertical-align: sub; margin: 0 0.6em } %checkbox-checked { - border: 9px double #aaa; - outline: 9px solid #aaa; - outline-offset: -18px; + outline: 9px solid $action-color; + outline-offset: -15px; } %background-agnostic { diff --git a/src/assets/stylesheets/spoke.scss b/src/assets/stylesheets/spoke.scss index 68f850879027e250f7bcce75a16a88692cf4a6e3..0219502e7a00014e7cbb07530ca3a78e06353b73 100644 --- a/src/assets/stylesheets/spoke.scss +++ b/src/assets/stylesheets/spoke.scss @@ -1,7 +1,6 @@ @import 'shared'; @import 'loader'; -$spoke-action-color: #2F80ED; $breakpoint: 1280px; $mobile-breakpoint-width: 450px; diff --git a/src/assets/translations.data.json b/src/assets/translations.data.json index 47fef3295a0b67e0216c506bb504b8e96c9bf2d0..819a5a04649cf73d1cc12f9d8b64f6cd1b5ababd 100644 --- a/src/assets/translations.data.json +++ b/src/assets/translations.data.json @@ -32,6 +32,7 @@ "entry.enable-screen-sharing": "Share my desktop", "entry.return-to-vr": "Enter in VR", "entry.lobby": "Lobby", + "entry.notify_me": "Notify me when others are here", "profile.save": "Accept", "profile.display_name.validation_warning": "Alphanumerics and hyphens. At least 3 characters, no more than 32", "profile.header": "Name & Avatar", @@ -68,6 +69,7 @@ "home.create_name.validation_warning": "Invalid name, limited to 4 to 64 characters and limited symbols.", "home.join_us": "Join the Conversation", "home.join_room": "Join Room", + "home.create_with_spoke": "Create a Scene", "home.report_issue": "Report Issues", "home.source_link": "Source", "home.spoke_link": "Spoke", diff --git a/src/hub.js b/src/hub.js index ef6e6eb9d9326b8cad54f431cc80816f85a1be24..f16044fa877f9afc61ee9c4b4b5a7aedd321a5fa 100644 --- a/src/hub.js +++ b/src/hub.js @@ -70,6 +70,7 @@ import { connectToReticulum } from "./utils/phoenix-utils"; import { disableiOSZoom } from "./utils/disable-ios-zoom"; import { proxiedUrlFor } from "./utils/media-utils"; import SceneEntryManager from "./scene-entry-manager"; +import Subscriptions from "./subscriptions"; import "./systems/nav"; import "./systems/personal-space-bubble"; @@ -132,7 +133,6 @@ if (!isBotMode && !isTelemetryDisabled) { disableiOSZoom(); const concurrentLoadDetector = new ConcurrentLoadDetector(); - concurrentLoadDetector.start(); store.init(); @@ -286,6 +286,26 @@ async function runBotMode(scene, entryManager) { } document.addEventListener("DOMContentLoaded", async () => { + const hubId = qs.get("hub_id") || document.location.pathname.substring(1).split("/")[0]; + console.log(`Hub ID: ${hubId}`); + + const subscriptions = new Subscriptions(hubId); + + if (navigator.serviceWorker) { + try { + navigator.serviceWorker + .register("/hub.service.js") + .then(() => { + navigator.serviceWorker.ready + .then(registration => subscriptions.setRegistration(registration)) + .catch(() => subscriptions.setRegistrationFailed()); + }) + .catch(() => subscriptions.setRegistrationFailed()); + } catch (e) { + subscriptions.setRegistrationFailed(); + } + } + const scene = document.querySelector("a-scene"); const hubChannel = new HubChannel(store); const entryManager = new SceneEntryManager(hubChannel); @@ -296,7 +316,16 @@ document.addEventListener("DOMContentLoaded", async () => { window.APP.scene = scene; registerNetworkSchemas(); - remountUI({ hubChannel, linkChannel, enterScene: entryManager.enterScene, exitScene: entryManager.exitScene }); + + remountUI({ + hubChannel, + linkChannel, + subscriptions, + enterScene: entryManager.enterScene, + exitScene: entryManager.exitScene, + initialIsSubscribed: subscriptions.isSubscribed() + }); + scene.addEventListener("action_focus_chat", () => document.querySelector(".chat-focus-target").focus()); pollForSupportAvailability(isSupportAvailable => remountUI({ isSupportAvailable })); @@ -345,10 +374,6 @@ document.addEventListener("DOMContentLoaded", async () => { } }); - // Connect to reticulum over phoenix channels to get hub info. - const hubId = qs.get("hub_id") || document.location.pathname.substring(1).split("/")[0]; - console.log(`Hub ID: ${hubId}`); - const socket = connectToReticulum(isDebug); remountUI({ sessionId: socket.params().session_id }); @@ -358,13 +383,27 @@ document.addEventListener("DOMContentLoaded", async () => { hmd: availableVREntryTypes.isInHMD }; - const joinPayload = { profile: store.state.profile, context }; + // Reticulum global channel + const retPhxChannel = socket.channel(`ret`, { hub_id: hubId }); + retPhxChannel + .join() + .receive("ok", async data => subscriptions.setVapidPublicKey(data.vapid_public_key)) + .receive("error", res => { + subscriptions.setVapidPublicKey(null); + console.error(res); + }); + + const pushSubscriptionEndpoint = await subscriptions.getCurrentEndpoint(); + const joinPayload = { profile: store.state.profile, push_subscription_endpoint: pushSubscriptionEndpoint, context }; const hubPhxChannel = socket.channel(`hub:${hubId}`, joinPayload); hubPhxChannel .join() .receive("ok", async data => { hubChannel.setPhoenixChannel(hubPhxChannel); + subscriptions.setHubChannel(hubChannel); + subscriptions.setSubscribed(data.subscriptions.web_push); + remountUI({ initialIsSubscribed: subscriptions.isSubscribed() }); await handleHubChannelJoined(entryManager, hubChannel, data); }) .receive("error", res => { @@ -470,8 +509,5 @@ document.addEventListener("DOMContentLoaded", async () => { addToPresenceLog({ name: userInfo.metas[0].profile.displayName, type, body, maySpawn }); }); - // Reticulum global channel - const retPhxChannel = socket.channel(`ret`, { hub_id: hubId }); - retPhxChannel.join().receive("error", res => console.error(res)); linkChannel.setSocket(socket); }); diff --git a/src/hub.service.js b/src/hub.service.js new file mode 100644 index 0000000000000000000000000000000000000000..73a51a649b07c95425d98c389b0b848a56070968 --- /dev/null +++ b/src/hub.service.js @@ -0,0 +1,46 @@ +self.addEventListener("install", function(e) { + return e.waitUntil(self.skipWaiting()); +}); + +self.addEventListener("activate", function(e) { + return e.waitUntil(self.clients.claim()); +}); + +self.addEventListener("push", function(e) { + const payload = JSON.parse(e.data.text()); + + return e.waitUntil( + self.clients.matchAll({ type: "window" }).then(function(clientList) { + for (let i = 0; i < clientList.length; i++) { + const client = clientList[i]; + if (client.url.indexOf(e.notification.data.hub_id) >= 0) return; + } + + return self.registration.showNotification("Hubs by Mozilla", { + body: "Someone has joined " + payload.hub_name, + image: payload.image, + icon: "/favicon.ico", + badge: "/favicon.ico", + tag: payload.hub_id, + data: { hub_url: payload.hub_url } + }); + }) + ); +}); + +self.addEventListener("notificationclick", function(e) { + e.notification.close(); + + e.waitUntil( + self.clients.matchAll({ type: "window" }).then(function(clientList) { + for (let i = 0; i < clientList.length; i++) { + const client = clientList[i]; + if (client.url.indexOf(e.notification.data.hub_url) >= 0 && "focus" in client) return client.focus(); + } + + if (self.clients.openWindow) { + return self.clients.openWindow(e.notification.data.hub_url); + } + }) + ); +}); diff --git a/src/react-components/home-root.js b/src/react-components/home-root.js index 8dfc8bd5c652b162b8518651107770d0a41554ca..c95a342fc2d4ac12755a48321a3bcb97848aa4bb 100644 --- a/src/react-components/home-root.js +++ b/src/react-components/home-root.js @@ -218,10 +218,17 @@ class HomeRoot extends Component { /> </div> {this.state.environments.length > 1 && ( - <div className={styles.joinButton}> - <a href="/link"> - <FormattedMessage id="home.join_room" /> - </a> + <div> + <div className={styles.joinButton}> + <a href="/link"> + <FormattedMessage id="home.join_room" /> + </a> + </div> + <div className={styles.spokeButton}> + <a href="/spoke"> + <FormattedMessage id="home.create_with_spoke" /> + </a> + </div> </div> )} </div> diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js index c779d33fd89b4fe400e3520b0d93c488b0e47a63..e35b309721c1f21e30f7a366a5a2c683522cb618 100644 --- a/src/react-components/ui-root.js +++ b/src/react-components/ui-root.js @@ -89,7 +89,9 @@ class UIRoot extends Component { isSupportAvailable: PropTypes.bool, presenceLogEntries: PropTypes.array, presences: PropTypes.object, - sessionId: PropTypes.string + sessionId: PropTypes.string, + subscriptions: PropTypes.object, + initialIsSubscribed: PropTypes.bool }; state = { @@ -147,6 +149,11 @@ class UIRoot extends Component { this.props.scene.removeEventListener("exit", this.exit); } + updateSubscribedState = () => { + const isSubscribed = this.props.subscriptions && this.props.subscriptions.isSubscribed(); + this.setState({ isSubscribed }); + }; + onSceneLoaded = () => { this.setState({ sceneLoaded: true }); }; @@ -175,6 +182,13 @@ class UIRoot extends Component { this.props.scene.emit("penButtonPressed"); }; + onSubscribeChanged = async () => { + if (!this.props.subscriptions) return; + + await this.props.subscriptions.toggle(); + this.updateSubscribedState(); + }; + handleStartEntry = () => { const promptForNameAndAvatarBeforeEntry = !this.props.store.state.activity.hasChangedName; @@ -673,9 +687,13 @@ class UIRoot extends Component { }; renderEntryStartPanel = () => { +<<<<<<< HEAD const textRows = this.state.pendingMessage.split("\n").length; const pendingMessageTextareaHeight = textRows * 28 + "px"; const pendingMessageFieldHeight = textRows * 28 + 20 + "px"; +======= + const hasPush = navigator.serviceWorker && "PushManager" in window; +>>>>>>> 58f4eba535de1894589cb9dfa455da3e3376fb8d return ( <div className={entryStyles.entryPanel}> @@ -710,6 +728,20 @@ class UIRoot extends Component { </form> </div> + {hasPush && ( + <div className={entryStyles.subscribe}> + <input + id="subscribe" + type="checkbox" + onChange={this.onSubscribeChanged} + checked={this.state.isSubscribed === undefined ? this.props.initialIsSubscribed : this.state.isSubscribed} + /> + <label htmlFor="subscribe"> + <FormattedMessage id="entry.notify_me" /> + </label> + </div> + )} + <div className={entryStyles.buttonContainer}> <button className={classNames([entryStyles.actionButton, entryStyles.wideButton])} diff --git a/src/subscriptions.js b/src/subscriptions.js new file mode 100644 index 0000000000000000000000000000000000000000..a5d9fc8afab5ad7b81fdeaf54fc9ecd90bab17f2 --- /dev/null +++ b/src/subscriptions.js @@ -0,0 +1,101 @@ +import nextTick from "./utils/next-tick.js"; + +// Manages web push subscriptions +// +function urlBase64ToUint8Array(base64String) { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +export default class Subscriptions { + constructor(hubId) { + this.hubId = hubId; + } + + setHubChannel = hubChannel => { + this.hubChannel = hubChannel; + }; + + setRegistration = registration => { + this.registration = registration; + }; + + setRegistrationFailed = () => { + this.registration = null; + }; + + setVapidPublicKey = vapidPublicKey => { + this.vapidPublicKey = vapidPublicKey; + }; + + setSubscribed = isSubscribed => { + this._isSubscribed = isSubscribed; + }; + + isSubscribed = () => { + return this._isSubscribed; + }; + + getCurrentEndpoint = async () => { + if (!navigator.serviceWorker) return null; + + // registration becomes null if failed, non null if registered + while (this.registration === undefined) await nextTick(); + if (!this.registration || !this.registration.pushManager) return null; + + while (this.vapidPublicKey === undefined) await nextTick(); + if (this.vapidPublicKey === null) return null; + + try { + const convertedVapidKey = urlBase64ToUint8Array(this.vapidPublicKey); + + if ( + (await this.registration.pushManager.permissionState({ + userVisibleOnly: true, + applicationServerKey: convertedVapidKey + })) !== "granted" + ) + return null; + } catch (e) { + return null; // Chrome can throw here complaining about userVisible if push is not right + } + const sub = await this.registration.pushManager.getSubscription(); + if (!sub) return null; + + return sub.endpoint; + }; + + toggle = async () => { + if (this._isSubscribed) { + const pushSubscription = await this.registration.pushManager.getSubscription(); + const res = await this.hubChannel.unsubscribe(pushSubscription); + + if (res && res.has_remaining_subscriptions === false) { + pushSubscription.unsubscribe(); + } + } else { + let pushSubscription = await this.registration.pushManager.getSubscription(); + + if (!pushSubscription) { + const convertedVapidKey = urlBase64ToUint8Array(this.vapidPublicKey); + + pushSubscription = await this.registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: convertedVapidKey + }); + } + + this.hubChannel.subscribe(pushSubscription); + } + + this._isSubscribed = !this._isSubscribed; + }; +} diff --git a/src/systems/userinput/devices/mouse.js b/src/systems/userinput/devices/mouse.js index 0ab7a3ac2aea0ce0ec44ad55fb5804348443bcae..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 --- a/src/systems/userinput/devices/mouse.js +++ b/src/systems/userinput/devices/mouse.js @@ -1,77 +0,0 @@ -import { paths } from "../paths"; - -// TODO: Where do these values (500, 10, 2) come from? -const modeMod = { - [WheelEvent.DOM_DELTA_PIXEL]: 500, - [WheelEvent.DOM_DELTA_LINE]: 10, - [WheelEvent.DOM_DELTA_PAGE]: 2 -}; - -export class MouseDevice { - constructor() { - this.events = []; - this.coords = [0, 0]; // normalized screenspace coordinates in [(-1, 1), (-1, 1)] - this.movementXY = [0, 0]; // deltas - this.buttonLeft = false; - this.buttonRight = false; - this.wheel = 0; // delta - - const queueEvent = this.events.push.bind(this.events); - const canvas = document.querySelector("canvas"); - ["mousedown", "mouseup", "mousemove", "wheel"].map(x => canvas.addEventListener(x, queueEvent)); - ["mouseout", "blur"].map(x => document.addEventListener(x, queueEvent)); - } - - process(event) { - if (event.type === "wheel") { - this.wheel += event.deltaY / modeMod[event.deltaMode]; - return; - } - if (event.type === "mouseout" || event.type === "blur") { - this.coords[0] = 0; - this.coords[1] = 0; - this.movementXY[0] = 0; - this.movementXY[1] = 0; - this.buttonLeft = false; - this.buttonRight = false; - this.wheel = 0; - } - const left = event.button === 0; - const right = event.button === 2; - this.coords[0] = (event.clientX / window.innerWidth) * 2 - 1; - this.coords[1] = -(event.clientY / window.innerHeight) * 2 + 1; - this.movementXY[0] += event.movementX; - this.movementXY[1] += event.movementY; - if (event.type === "mousedown" && left) { - this.buttonLeft = true; - } else if (event.type === "mousedown" && right) { - this.buttonRight = true; - } else if (event.type === "mouseup" && left) { - this.buttonLeft = false; - } else if (event.type === "mouseup" && right) { - this.buttonRight = false; - } - } - - write(frame) { - this.movementXY = [0, 0]; // deltas - this.wheel = 0; // delta - this.events.forEach(event => { - this.process(event, frame); - }); - - while (this.events.length) { - this.events.pop(); - } - - frame[paths.device.mouse.coords] = this.coords; - frame[paths.device.mouse.movementXY] = this.movementXY; - frame[paths.device.mouse.buttonLeft] = this.buttonLeft; - frame[paths.device.mouse.buttonRight] = this.buttonRight; - frame[paths.device.mouse.wheel] = this.wheel; - } -} - -window.oncontextmenu = e => { - e.preventDefault(); -}; diff --git a/src/utils/hub-channel.js b/src/utils/hub-channel.js index 734cafa5fcc11c8ef84d708da4e16c32b54396aa..81714efc2fe069ce43cc8d0721c4550a2f42667d 100644 --- a/src/utils/hub-channel.js +++ b/src/utils/hub-channel.js @@ -91,6 +91,14 @@ export default class HubChannel { this.channel.push("events:profile_updated", { profile: this.store.state.profile }); }; + subscribe = subscription => { + this.channel.push("subscribe", { subscription }); + }; + + unsubscribe = subscription => { + return new Promise(resolve => this.channel.push("unsubscribe", { subscription }).receive("ok", resolve)); + }; + sendMessage = (body, type = "chat") => { if (!body) return; this.channel.push("message", { body, type }); diff --git a/webpack.config.js b/webpack.config.js index e6567532eb15a79f243b7e8a5df1b9064e5eeefd..55afb6708d844a885ade6473327360ca6198c243 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -240,6 +240,12 @@ module.exports = (env, argv) => ({ to: "hub-preview.png" } ]), + new CopyWebpackPlugin([ + { + from: "src/hub.service.js", + to: "hub.service.js" + } + ]), // Extract required css and add a content hash. new ExtractTextPlugin({ filename: "assets/stylesheets/[name]-[md5:contenthash:hex:20].css",