diff --git a/PRIVACY.md b/PRIVACY.md
index 3fdb4ebb0b6e1ac8128386f3358d1e2151d67e76..da4bbfbcced7355db31ef78a1f3d959936993668 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -1,6 +1,6 @@
 # Privacy Notice for Hubs and Spoke
 
-Version 3.0, October 16, 2018
+Version 3.1, October 16, 2018
 
 ## At Mozilla (that’s us), we believe that privacy is fundamental to a healthy internet.
 
@@ -14,7 +14,7 @@ In this Privacy Notice, we explain what data may be accessible to Mozilla or oth
   </summary>
 
 - **Avatar data**: We receive and send to others in the Room the name of your Avatar, its position in the Room, and your interactions with objects in the Room.  Mozilla does not record or store this data. You can optionally store information about your Avatar in your browser’s local storage.  
-- **Room data**: Rooms are publicly accessible to anyone with the URL. Mozilla receives data about the virtual objects and Avatars in a Room and shares that data with others in the Room.   
+- **Room data**: Rooms are publicly accessible to anyone with the URL. Mozilla receives data about the virtual objects and Avatars in a Room and shares that data with others in the Room or who have been in the room.   
 - **Voice data**: If your microphone is on, Mozilla receives and sends audio to other users in the Room. Mozilla does not record or store the audio.  *Be aware that once you agree to let Hubs use your microphone, it will stay on as long as you remain in a Hubs room, unless you turn it off.*
 - You can learn more by looking at the [code itself](https://github.com/mozilla/hubs) for Hubs. [Janus SFU](https://github.com/mozilla/janus-plugin-sfu), [Reticulum](https://github.com/mozilla/reticulum), [Hubs](https://github.com/mozilla/hubs), [Hubs-Ops](https://github.com/mozilla/hubs-ops)
 </details>
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/spawn_message-hover.png b/src/assets/spawn_message-hover.png
new file mode 100644
index 0000000000000000000000000000000000000000..ce99dd993b8f655ab0c1552726681dd40537b557
Binary files /dev/null and b/src/assets/spawn_message-hover.png differ
diff --git a/src/assets/spawn_message.png b/src/assets/spawn_message.png
new file mode 100644
index 0000000000000000000000000000000000000000..8f6ce1e2064e6ec10e00cc64b99d0660e4ed1ee3
Binary files /dev/null and b/src/assets/spawn_message.png differ
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/presence-log.scss b/src/assets/stylesheets/presence-log.scss
index bed6d5cd50cae9b07dd406009c01917ac48477d8..aefc553c9e9a959081ade436eb7b5383306e521c 100644
--- a/src/assets/stylesheets/presence-log.scss
+++ b/src/assets/stylesheets/presence-log.scss
@@ -25,7 +25,36 @@
     margin: 8px 64px 8px 16px;
     font-size: 0.8em;
     padding: 8px 16px;
-    border-radius: 16px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+
+    :local(.message-body) {
+      margin-left: 4px;
+      white-space: pre;
+    }
+
+    :local(.message-body-multi) {
+      margin-left: 0px;
+    }
+
+    :local(.message-body-mono) {
+      font-family: monospace;
+      font-size: 14px;
+    }
+
+    :local(.message-wrap) {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+
+    :local(.message-wrap-multi) {
+      display: flex;
+      align-items: flex-start;
+      justify-content: center;
+      flex-direction: column;
+    }
 
     a {
       color: $action-color;
@@ -35,6 +64,29 @@
       max-width: 75%;
     }
 
+    :local(.spawn-message) {
+      appearance: none;
+      -moz-appearance: none;
+      -webkit-appearance: none;
+      outline-style: none;
+      width: 24px;
+      height: 24px;
+      background-size: 100%;
+      border: 0;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      align-self: flex-start;
+      cursor: pointer;
+      background-image: url(../spawn_message.png);
+      margin-right: 6px;
+      background-color: transparent;
+    }
+
+    :local(.spawn-message):hover {
+      background-image: url(../spawn_message-hover.png);
+    }
+
     &:local(.media) {
       display: flex;
       align-items: center;
@@ -62,26 +114,66 @@
     transition: visibility 0s 0.5s, opacity 0.5s linear, transform 0.5s;
   }
 
+  :local(.presence-log-entry-with-button) {
+    padding: 8px 18px 8px 10px;
+  }
 }
 
 :local(.presence-log-in-room) {
-  max-height: 200px;
-
-  @media(min-height: 800px) and (min-width: 600px) {
-    max-height: 400px;
-  }
-
   position: absolute;
   bottom: 165px;
 
   :local(.presence-log-entry) {
     background-color: $hud-panel-background;
     color: $light-text;
+    min-height: 18px;
 
     user-select: none;
     -moz-user-select: none;
     -webkit-user-select: none;
     -ms-user-select: none;
+
+    a {
+      color: white;
+    }
+  }
+}
+
+:local(.presence-log-spawn) {
+  position: absolute;
+  top: 0;
+  z-index: -10;
+  width: auto;
+  margin: 0;
+
+  :local(.presence-log-entry) {
+    background-color: white;
+    color: black;
+    min-height: 18px;
+    padding: 8px 16px;
+    border-radius: 16px;
+    line-height: 18px;
+    margin: 0;
+
+    :local(.message-body) {
+      margin-left: 0;
+    }
+
+    a {
+      color: white;
+    }
+  }
+
+  :local(.presence-log-entry-one-line) {
+    font-weight: bold;
+    line-height: 19px;
+    text-align: center;
+  }
+
+  :local(.presence-log-emoji) {
+    background-color: transparent;
+    padding: 0;
+    margin: 0;
   }
 }
 
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/stylesheets/ui-root.scss b/src/assets/stylesheets/ui-root.scss
index 74e74a4ada46d2872920f378ec50c82d5110bfea..fa3c272079bae721590ac2878f1f964f8952d713 100644
--- a/src/assets/stylesheets/ui-root.scss
+++ b/src/assets/stylesheets/ui-root.scss
@@ -203,10 +203,12 @@
   -moz-appearance: none;
   -webkit-appearance: none;
   outline-style: none;
+  overflow: hidden;
+  resize: none;
   background-color: transparent;
   color: black;
   padding: 8px 1.25em;
-  line-height: 2em;
+  line-height: 28px;
   font-size: 1.1em;
   width: 100%;
   border: 0px;
@@ -223,9 +225,11 @@
 :local(.message-entry-submit) {
   @extend %action-button;
   position: absolute;
-  right: 12px;
+  right: 10px;
   height: 32px;
   min-width: 80px;
+  bottom: 8px;
+  border-radius: 10px;
 }
 
 :local(.message-entry-in-room) {
@@ -245,11 +249,29 @@
   border-radius: 16px;
   pointer-events: auto;
   opacity: 0.3;
-  transition: opacity 0.25s linear;
+  transition: opacity 0.15s linear;
 
   :local(.message-entry-input-in-room) {
     color: white;
     padding: 8px 1.25em;
+    margin-left: 32px;
+  }
+
+  :local(.message-entry-spawn) {
+    @extend %action-button;
+    position: absolute;
+    left: 12px;
+    height: 32px;
+    width: 32px;
+    bottom: 8px;
+    min-width: auto;
+    background-size: 90%;
+    background-image: url(../spawn_message.png);
+    background-position-x: 1px;
+    background-position-y: 1px;
+    padding: 0;
+    border-radius: 16px;
+    visibility: hidden;
   }
 
   :local(.message-entry-submit-in-room) {
@@ -258,11 +280,15 @@
   }
 }
 
-:local(.message-entry-in-room):hover {
+:local(.message-entry-in-room):focus-within {
   opacity: 1.0;
-  transition: opacity 0.25s linear;
+  transition: opacity 0.15s linear;
 
   :local(.message-entry-submit-in-room) {
     visibility: visible;
   }
+
+  :local(.message-entry-spawn) {
+    visibility: visible;
+  }
 }
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/components/action-to-event.js b/src/components/action-to-event.js
index ee6f397445fea8d556f268ca81ea978f366df6d0..8bcd22e43dcbc30759f7ec3ec17692f0bf75a161 100644
--- a/src/components/action-to-event.js
+++ b/src/components/action-to-event.js
@@ -8,7 +8,7 @@ AFRAME.registerComponent("action-to-event", {
 
   tick() {
     const userinput = AFRAME.scenes[0].systems.userinput;
-    if (userinput.readFrameValueAtPath(this.data.path)) {
+    if (userinput.get(this.data.path)) {
       this.el.emit(this.data.event);
     }
   }
diff --git a/src/components/camera-tool.js b/src/components/camera-tool.js
index eacfaf8f443a0131cef4cd189f0a05e5c9a58a20..9882eb88f481126ce63b9269f7a3dda44cbbfaab 100644
--- a/src/components/camera-tool.js
+++ b/src/components/camera-tool.js
@@ -110,7 +110,7 @@ AFRAME.registerComponent("camera-tool", {
     const grabber = this.el.components.grabbable.grabbers[0];
     if (grabber && !!pathsMap[grabber.id]) {
       const paths = pathsMap[grabber.id];
-      if (AFRAME.scenes[0].systems.userinput.readFrameValueAtPath(paths.takeSnapshot)) {
+      if (AFRAME.scenes[0].systems.userinput.get(paths.takeSnapshot)) {
         this.takeSnapshotNextTick = true;
       }
     }
diff --git a/src/components/character-controller.js b/src/components/character-controller.js
index 910123ba8bb2f5e54e64037ac478a1ac2e57e577..083f47e195e7453136c958ecad62762be882c614 100644
--- a/src/components/character-controller.js
+++ b/src/components/character-controller.js
@@ -45,8 +45,6 @@ AFRAME.registerComponent("character-controller", {
     const eventSrc = this.el.sceneEl;
     eventSrc.addEventListener("move", this.setAccelerationInput);
     eventSrc.addEventListener("rotateY", this.setAngularVelocity);
-    eventSrc.addEventListener("snap_rotate_left", this.snapRotateLeft);
-    eventSrc.addEventListener("snap_rotate_right", this.snapRotateRight);
     eventSrc.addEventListener("teleported", this.handleTeleport);
   },
 
@@ -54,8 +52,6 @@ AFRAME.registerComponent("character-controller", {
     const eventSrc = this.el.sceneEl;
     eventSrc.removeEventListener("move", this.setAccelerationInput);
     eventSrc.removeEventListener("rotateY", this.setAngularVelocity);
-    eventSrc.removeEventListener("snap_rotate_left", this.snapRotateLeft);
-    eventSrc.removeEventListener("snap_rotate_right", this.snapRotateRight);
     eventSrc.removeEventListener("teleported", this.handleTeleport);
     this.reset();
   },
@@ -78,10 +74,12 @@ AFRAME.registerComponent("character-controller", {
 
   snapRotateLeft: function() {
     this.pendingSnapRotationMatrix.copy(this.leftRotationMatrix);
+    this.el.emit("snap_rotate_left");
   },
 
   snapRotateRight: function() {
     this.pendingSnapRotationMatrix.copy(this.rightRotationMatrix);
+    this.el.emit("snap_rotate_right");
   },
 
   handleTeleport: function(event) {
@@ -119,13 +117,13 @@ AFRAME.registerComponent("character-controller", {
       root.updateMatrix();
 
       const userinput = AFRAME.scenes[0].systems.userinput;
-      if (userinput.readFrameValueAtPath(paths.actions.snapRotateLeft)) {
+      if (userinput.get(paths.actions.snapRotateLeft)) {
         this.snapRotateLeft();
       }
-      if (userinput.readFrameValueAtPath(paths.actions.snapRotateRight)) {
+      if (userinput.get(paths.actions.snapRotateRight)) {
         this.snapRotateRight();
       }
-      const acc = userinput.readFrameValueAtPath(paths.actions.characterAcceleration);
+      const acc = userinput.get(paths.actions.characterAcceleration);
       if (acc) {
         this.accelerationInput.set(
           this.accelerationInput.x + acc[0],
@@ -145,7 +143,7 @@ AFRAME.registerComponent("character-controller", {
       this.updateVelocity(deltaSeconds);
       this.accelerationInput.set(0, 0, 0);
 
-      const boost = userinput.readFrameValueAtPath(paths.actions.boost) ? 2 : 1;
+      const boost = userinput.get(paths.actions.boost) ? 2 : 1;
       move.makeTranslation(
         this.velocity.x * distance * boost,
         this.velocity.y * distance * boost,
diff --git a/src/components/cursor-controller.js b/src/components/cursor-controller.js
index c5113dba822b23944026cff06b41497deecf55f0..1b095fc05948d88e38cbbcebd8b3141046ffb69f 100644
--- a/src/components/cursor-controller.js
+++ b/src/components/cursor-controller.js
@@ -94,8 +94,8 @@ AFRAME.registerComponent("cursor-controller", {
       }
 
       const userinput = AFRAME.scenes[0].systems.userinput;
-      const cursorPose = userinput.readFrameValueAtPath(paths.actions.cursor.pose);
-      const rightHandPose = userinput.readFrameValueAtPath(paths.actions.rightHand.pose);
+      const cursorPose = userinput.get(paths.actions.cursor.pose);
+      const rightHandPose = userinput.get(paths.actions.rightHand.pose);
 
       this.data.cursor.object3D.visible = this.enabled && !!cursorPose;
       this.el.setAttribute("line", "visible", this.enabled && !!rightHandPose);
@@ -119,7 +119,7 @@ AFRAME.registerComponent("cursor-controller", {
 
       const { cursor, near, far, camera, cursorColorHovered, cursorColorUnhovered } = this.data;
 
-      const cursorModDelta = userinput.readFrameValueAtPath(paths.actions.cursor.modDelta);
+      const cursorModDelta = userinput.get(paths.actions.cursor.modDelta);
       if (isGrabbing && cursorModDelta) {
         this.distance = THREE.Math.clamp(this.distance - cursorModDelta, near, far);
       }
diff --git a/src/components/hand-controls2.js b/src/components/hand-controls2.js
index 534f796c45c304097768d5e486cac12db4d76664..3a97888b172687838581fc720f516e50ceb6b248 100644
--- a/src/components/hand-controls2.js
+++ b/src/components/hand-controls2.js
@@ -106,10 +106,10 @@ AFRAME.registerComponent("hand-controls2", {
     const hand = this.data;
     const userinput = AFRAME.scenes[0].systems.userinput;
     const subpath = hand === "left" ? paths.actions.leftHand : paths.actions.rightHand;
-    const hasPose = userinput.readFrameValueAtPath(subpath.pose);
-    const thumb = userinput.readFrameValueAtPath(subpath.thumb);
-    const index = userinput.readFrameValueAtPath(subpath.index);
-    const middleRingPinky = userinput.readFrameValueAtPath(subpath.middleRingPinky);
+    const hasPose = userinput.get(subpath.pose);
+    const thumb = userinput.get(subpath.thumb);
+    const index = userinput.get(subpath.index);
+    const middleRingPinky = userinput.get(subpath.middleRingPinky);
     const pose = this.poseForFingers(thumb, index, middleRingPinky);
     if (pose !== this.pose) {
       this.el.emit("hand-pose", { previous: this.pose, current: pose });
diff --git a/src/components/pinch-to-move.js b/src/components/pinch-to-move.js
index 28dc43cb5d2584532bf549fc3b08aaf01c1ce953..bc5c57a7631e8070c4eaafba13f52c0ceb757bca 100644
--- a/src/components/pinch-to-move.js
+++ b/src/components/pinch-to-move.js
@@ -9,7 +9,7 @@ AFRAME.registerComponent("pinch-to-move", {
   },
   tick() {
     const userinput = AFRAME.scenes[0].systems.userinput;
-    const pinch = userinput.readFrameValueAtPath(paths.device.touchscreen.pinchDelta);
+    const pinch = userinput.get(paths.device.touchscreen.pinchDelta);
     if (pinch) {
       this.axis[1] = pinch * this.data.speed;
       this.el.emit("move", { axis: this.axis });
diff --git a/src/components/pitch-yaw-rotator.js b/src/components/pitch-yaw-rotator.js
index 297bf4f81b5105ca229a5d34bdf527b2389725bb..a2c2ebdead1d580c4480e2578e59c918289941f0 100644
--- a/src/components/pitch-yaw-rotator.js
+++ b/src/components/pitch-yaw-rotator.js
@@ -36,7 +36,7 @@ AFRAME.registerComponent("pitch-yaw-rotator", {
 
   tick() {
     const userinput = AFRAME.scenes[0].systems.userinput;
-    const cameraDelta = userinput.readFrameValueAtPath(paths.actions.cameraDelta);
+    const cameraDelta = userinput.get(paths.actions.cameraDelta);
     let lookX = this.pendingXRotation;
     let lookY = 0;
     if (cameraDelta) {
diff --git a/src/components/super-networked-interactable.js b/src/components/super-networked-interactable.js
index 9a8f8cfa0cbd186ddf223b55476498f43ac5faeb..a33e41476fbf569cd4a64ae3a0cb329ad00aa36d 100644
--- a/src/components/super-networked-interactable.js
+++ b/src/components/super-networked-interactable.js
@@ -125,6 +125,6 @@ AFRAME.registerComponent("super-networked-interactable", {
     if (!(grabber && pathsMap[grabber.id])) return;
 
     const userinput = AFRAME.scenes[0].systems.userinput;
-    this._changeScale(userinput.readFrameValueAtPath(pathsMap[grabber.id].scaleGrabbedGrabbable));
+    this._changeScale(userinput.get(pathsMap[grabber.id].scaleGrabbedGrabbable));
   }
 });
diff --git a/src/components/super-spawner.js b/src/components/super-spawner.js
index a786639e1b828e34d1daf36bedd10e500d275ed1..7ff4e1189f589d9d026ba63e920dfc5d47a78760 100644
--- a/src/components/super-spawner.js
+++ b/src/components/super-spawner.js
@@ -116,8 +116,8 @@ AFRAME.registerComponent("super-spawner", {
 
   async onSpawnEvent() {
     const userinput = AFRAME.scenes[0].systems.userinput;
-    const leftPose = userinput.readFrameValueAtPath(paths.actions.leftHand.pose);
-    const rightPose = userinput.readFrameValueAtPath(paths.actions.rightHand.pose);
+    const leftPose = userinput.get(paths.actions.leftHand.pose);
+    const rightPose = userinput.get(paths.actions.rightHand.pose);
     const controllerCount = leftPose && rightPose ? 2 : leftPose || rightPose ? 1 : 0;
     const using6DOF = controllerCount > 1 && this.el.sceneEl.is("vr-mode");
     const hand = using6DOF ? this.data.superHand : this.data.cursorSuperHand;
diff --git a/src/components/tools/pen.js b/src/components/tools/pen.js
index dd52ca4ab14030ddf064a9d5ae9f0ef9a7d4456a..f09e63101f4366440a06ba4d60481999edaba6b8 100644
--- a/src/components/tools/pen.js
+++ b/src/components/tools/pen.js
@@ -103,20 +103,20 @@ AFRAME.registerComponent("pen", {
     const userinput = AFRAME.scenes[0].systems.userinput;
     if (grabber && pathsMap[grabber.id]) {
       const paths = pathsMap[grabber.id];
-      if (userinput.readFrameValueAtPath(paths.startDrawing)) {
+      if (userinput.get(paths.startDrawing)) {
         this._startDraw();
       }
-      if (userinput.readFrameValueAtPath(paths.stopDrawing)) {
+      if (userinput.get(paths.stopDrawing)) {
         this._endDraw();
       }
-      const penScaleMod = userinput.readFrameValueAtPath(paths.scalePenTip);
+      const penScaleMod = userinput.get(paths.scalePenTip);
       if (penScaleMod) {
         this._changeRadius(penScaleMod);
       }
-      if (userinput.readFrameValueAtPath(paths.penNextColor)) {
+      if (userinput.get(paths.penNextColor)) {
         this._changeColor(1);
       }
-      if (userinput.readFrameValueAtPath(paths.penPrevColor)) {
+      if (userinput.get(paths.penPrevColor)) {
         this._changeColor(-1);
       }
     }
diff --git a/src/hub.html b/src/hub.html
index ce88ff5d12c25bce33f3ddf4ef0b7e46841511a4..b67f2281f568fbef8ef3e01c3b023758f4edae5d 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -32,6 +32,7 @@
         vr-mode-ui="enabled: false"
         stats-plus="false"
         action-to-event__mute="path: /actions/muteMic; event: action_mute;"
+        action-to-event__focus_chat="path: /actions/focusChat; event: action_focus_chat;"
     >
 
         <a-assets>
diff --git a/src/hub.js b/src/hub.js
index 9ffcc05046c34502883eb43c20514dcfb083a3f4..493a053d14c796781e33db67b60f159c07872e42 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -72,6 +72,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";
@@ -135,7 +136,6 @@ if (!isBotMode && !isTelemetryDisabled) {
 disableiOSZoom();
 
 const concurrentLoadDetector = new ConcurrentLoadDetector();
-
 concurrentLoadDetector.start();
 
 store.init();
@@ -297,6 +297,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");
   scene.removeAttribute("keyboard-shortcuts"); // Remove F and ESC hotkeys from aframe
 
@@ -309,7 +329,17 @@ 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 }));
 
@@ -357,10 +387,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 });
 
@@ -370,13 +396,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 => {
@@ -477,12 +517,10 @@ document.addEventListener("DOMContentLoaded", async () => {
   hubPhxChannel.on("message", ({ session_id, type, body }) => {
     const userInfo = hubPhxPresence.state[session_id];
     if (!userInfo) return;
+    const maySpawn = scene.is("entered");
 
-    addToPresenceLog({ name: userInfo.metas[0].profile.displayName, type, body });
+    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/chat-message.js b/src/react-components/chat-message.js
new file mode 100644
index 0000000000000000000000000000000000000000..992f311f571298b4a2794c803ad56c28a70b42b6
--- /dev/null
+++ b/src/react-components/chat-message.js
@@ -0,0 +1,113 @@
+import React from "react";
+import ReactDOM from "react-dom";
+import PropTypes from "prop-types";
+import styles from "../assets/stylesheets/presence-log.scss";
+import classNames from "classnames";
+import Linkify from "react-linkify";
+import { toArray as toEmojis } from "react-emoji-render";
+import serializeElement from "../utils/serialize-element";
+
+const messageCanvas = document.createElement("canvas");
+const emojiRegex = /(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|[\ud83c[\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|[\ud83c[\ude32-\ude3a]|[\ud83c[\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])/;
+const urlRegex = /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)$/;
+
+const messageBodyDom = body => {
+  // Support wrapping text in ` to get monospace, and multiline.
+  const multiLine = body.split("\n").length > 1;
+  const mono = body.startsWith("`") && body.endsWith("`");
+  const messageBodyClasses = {
+    [styles.messageBody]: true,
+    [styles.messageBodyMulti]: multiLine,
+    [styles.messageBodyMono]: mono
+  };
+
+  const cleanedBody = (mono ? body.substring(1, body.length - 1) : body).trim();
+
+  return (
+    <div className={classNames(messageBodyClasses)}>
+      <Linkify properties={{ target: "_blank", rel: "noopener referrer" }}>{toEmojis(cleanedBody)}</Linkify>
+    </div>
+  );
+};
+
+export function spawnChatMessage(body) {
+  if (body.length === 0) return;
+
+  if (body.match(urlRegex)) {
+    document.querySelector("a-scene").emit("add_media", body);
+    return;
+  }
+
+  const isOneLine = body.split("\n").length === 1;
+  const context = messageCanvas.getContext("2d");
+  const emoji = toEmojis(body);
+  const isEmoji =
+    emoji.length === 1 && emoji[0].props && emoji[0].props.children.match && emoji[0].props.children.match(emojiRegex);
+
+  const el = document.createElement("div");
+  el.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
+  el.setAttribute("class", `${styles.presenceLog} ${styles.presenceLogSpawn}`);
+
+  // The element is added to the DOM in order to have layout compute the width & height,
+  // and then it is removed after being rendered.
+  document.body.appendChild(el);
+
+  const entryDom = (
+    <div
+      className={classNames({
+        [styles.presenceLogEntry]: !isEmoji,
+        [styles.presenceLogEntryOneLine]: !isEmoji && isOneLine,
+        [styles.presenceLogEmoji]: isEmoji
+      })}
+    >
+      {messageBodyDom(body)}
+    </div>
+  );
+
+  ReactDOM.render(entryDom, el, () => {
+    // Scale by 12x
+    messageCanvas.width = el.offsetWidth * 12.1;
+    messageCanvas.height = el.offsetHeight * 12.1;
+
+    const xhtml = encodeURIComponent(`
+        <svg xmlns="http://www.w3.org/2000/svg" width="${messageCanvas.width}" height="${messageCanvas.height}">
+          <foreignObject width="8.333%" height="8.333%" style="transform: scale(12.0);">
+            ${serializeElement(el)}
+          </foreignObject>
+        </svg>
+  `);
+    const img = new Image();
+
+    img.onload = async () => {
+      context.drawImage(img, 0, 0);
+      const blob = await new Promise(resolve => messageCanvas.toBlob(resolve));
+      document.querySelector("a-scene").emit("add_media", new File([blob], "message.png", { type: "image/png" }));
+      el.parentNode.removeChild(el);
+    };
+
+    img.src = "data:image/svg+xml," + xhtml;
+  });
+}
+
+export default function ChatMessage(props) {
+  const isOneLine = props.body.split("\n").length === 1;
+
+  return (
+    <div className={props.className}>
+      {props.maySpawn && <button className={styles.spawnMessage} onClick={() => spawnChatMessage(props.body)} />}
+      <div className={isOneLine ? styles.messageWrap : styles.messageWrapMulti}>
+        <div className={styles.messageSource}>
+          <b>{props.name}</b>:
+        </div>
+        {messageBodyDom(props.body)}
+      </div>
+    </div>
+  );
+}
+
+ChatMessage.propTypes = {
+  name: PropTypes.string,
+  maySpawn: PropTypes.bool,
+  body: PropTypes.string,
+  className: PropTypes.string
+};
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/presence-log.js b/src/react-components/presence-log.js
index d46550957f1eb7918b9310263ada1a2f64537dfb..491b98aa03d5586af55622ef905418f667773adc 100644
--- a/src/react-components/presence-log.js
+++ b/src/react-components/presence-log.js
@@ -2,9 +2,8 @@ import React, { Component } from "react";
 import PropTypes from "prop-types";
 import styles from "../assets/stylesheets/presence-log.scss";
 import classNames from "classnames";
-import Linkify from "react-linkify";
-import { toArray as toEmojis } from "react-emoji-render";
 import { FormattedMessage } from "react-intl";
+import ChatMessage from "./chat-message";
 
 export default class PresenceLog extends Component {
   static propTypes = {
@@ -19,6 +18,8 @@ export default class PresenceLog extends Component {
   domForEntry = e => {
     const entryClasses = {
       [styles.presenceLogEntry]: true,
+      [styles.presenceLogEntryWithButton]: e.type === "chat" && e.maySpawn,
+      [styles.presenceLogChat]: e.type === "chat",
       [styles.expired]: !!e.expired
     };
 
@@ -27,27 +28,30 @@ export default class PresenceLog extends Component {
       case "entered":
         return (
           <div key={e.key} className={classNames(entryClasses)}>
-            <b>{e.name}</b> <FormattedMessage id={`presence.${e.type}_${e.presence}`} />
+            <b>{e.name}</b>&nbsp;<FormattedMessage id={`presence.${e.type}_${e.presence}`} />
           </div>
         );
       case "leave":
         return (
           <div key={e.key} className={classNames(entryClasses)}>
-            <b>{e.name}</b> <FormattedMessage id={`presence.${e.type}`} />
+            <b>{e.name}</b>&nbsp;<FormattedMessage id={`presence.${e.type}`} />
           </div>
         );
       case "display_name_changed":
         return (
           <div key={e.key} className={classNames(entryClasses)}>
-            <b>{e.oldName}</b> <FormattedMessage id="presence.name_change" /> <b>{e.newName}</b>.
+            <b>{e.oldName}</b>&nbsp;<FormattedMessage id="presence.name_change" />&nbsp;<b>{e.newName}</b>.
           </div>
         );
       case "chat":
         return (
-          <div key={e.key} className={classNames(entryClasses)}>
-            <b>{e.name}</b>:{" "}
-            <Linkify properties={{ target: "_blank", rel: "noopener referrer" }}>{toEmojis(e.body)}</Linkify>
-          </div>
+          <ChatMessage
+            key={e.key}
+            name={e.name}
+            className={classNames(entryClasses)}
+            body={e.body}
+            maySpawn={e.maySpawn}
+          />
         );
       case "spawn": {
         const { src } = e.body;
@@ -58,13 +62,15 @@ export default class PresenceLog extends Component {
             </a>
             <div className={styles.mediaBody}>
               <span>
-                <b>{e.name}</b>:
+                <b>{e.name}</b>
               </span>
               <span>
                 {"took a "}
-                <a href={src} target="_blank" rel="noopener noreferrer">
-                  photo
-                </a>
+                <b>
+                  <a href={src} target="_blank" rel="noopener noreferrer">
+                    photo
+                  </a>
+                </b>.
               </span>
             </div>
           </div>
diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js
index bc249694ad94a002fd141c005f98d483a1c4df93..7a3987d2bb15f8b8dfa6ec8fafcdbac88d8478c4 100644
--- a/src/react-components/ui-root.js
+++ b/src/react-components/ui-root.js
@@ -30,6 +30,7 @@ import CreateObjectDialog from "./create-object-dialog.js";
 import PresenceLog from "./presence-log.js";
 import PresenceList from "./presence-list.js";
 import TwoDHUD from "./2d-hud";
+import { spawnChatMessage } from "./chat-message";
 import { faUsers } from "@fortawesome/free-solid-svg-icons/faUsers";
 
 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -89,7 +90,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 +150,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 +183,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,6 +688,11 @@ class UIRoot extends Component {
   };
 
   renderEntryStartPanel = () => {
+    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;
+
     return (
       <div className={entryStyles.entryPanel}>
         <div className={entryStyles.name}>{this.props.hubName}</div>
@@ -684,12 +704,21 @@ class UIRoot extends Component {
           </div>
 
           <form onSubmit={this.sendMessage}>
-            <div className={styles.messageEntry}>
-              <input
-                className={styles.messageEntryInput}
+            <div className={styles.messageEntry} style={{ height: pendingMessageFieldHeight }}>
+              <textarea
+                className={classNames([styles.messageEntryInput, "chat-focus-target"])}
                 value={this.state.pendingMessage}
+                rows={textRows}
+                style={{ height: pendingMessageTextareaHeight }}
                 onFocus={e => e.target.select()}
                 onChange={e => this.setState({ pendingMessage: e.target.value })}
+                onKeyDown={e => {
+                  if (e.keyCode === 13 && !e.shiftKey) {
+                    this.sendMessage(e);
+                  } else if (e.keyCode === 27) {
+                    e.target.blur();
+                  }
+                }}
                 placeholder="Send a message..."
               />
               <input className={styles.messageEntrySubmit} type="submit" value="send" />
@@ -697,6 +726,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])}
@@ -969,6 +1012,10 @@ class UIRoot extends Component {
     const entryFinished = this.state.entryStep === ENTRY_STEPS.finished;
     const showVREntryButton = entryFinished && this.props.availableVREntryTypes.isInHMD;
 
+    const textRows = this.state.pendingMessage.split("\n").length;
+    const pendingMessageTextareaHeight = textRows * 28 + "px";
+    const pendingMessageFieldHeight = textRows * 28 + 20 + "px";
+
     return (
       <IntlProvider locale={lang} messages={messages}>
         <div className={styles.ui}>
@@ -988,15 +1035,31 @@ class UIRoot extends Component {
           {entryFinished && <PresenceLog inRoom={true} entries={this.props.presenceLogEntries || []} />}
           {entryFinished && (
             <form onSubmit={this.sendMessage}>
-              <div className={styles.messageEntryInRoom}>
-                <input
-                  className={classNames([styles.messageEntryInput, styles.messageEntryInputInRoom])}
+              <div className={styles.messageEntryInRoom} style={{ height: pendingMessageFieldHeight }}>
+                <textarea
+                  style={{ height: pendingMessageTextareaHeight }}
+                  className={classNames([
+                    styles.messageEntryInput,
+                    styles.messageEntryInputInRoom,
+                    "chat-focus-target"
+                  ])}
                   value={this.state.pendingMessage}
+                  rows={textRows}
                   onFocus={e => e.target.select()}
                   onChange={e => {
                     e.stopPropagation();
                     this.setState({ pendingMessage: e.target.value });
                   }}
+                  onKeyDown={e => {
+                    if (e.keyCode === 13 && !e.shiftKey) {
+                      this.sendMessage(e);
+                    } else if (e.keyCode === 13 && e.shiftKey && e.ctrlKey) {
+                      spawnChatMessage(e.target.value);
+                      this.setState({ pendingMessage: "" });
+                    } else if (e.keyCode === 27) {
+                      e.target.blur();
+                    }
+                  }}
                   placeholder="Send a message..."
                 />
                 <input
@@ -1004,6 +1067,17 @@ class UIRoot extends Component {
                   type="submit"
                   value="send"
                 />
+                <button
+                  className={classNames([styles.messageEntrySpawn])}
+                  onClick={() => {
+                    if (this.state.pendingMessage.length > 0) {
+                      spawnChatMessage(this.state.pendingMessage);
+                      this.setState({ pendingMessage: "" });
+                    } else {
+                      this.showCreateObjectDialog();
+                    }
+                  }}
+                />
               </div>
             </form>
           )}
diff --git a/src/scene-entry-manager.js b/src/scene-entry-manager.js
index 92306d5a091d9b8d652d318cfe3a070cb32dbae6..4b5f0fa8362360e42cf7c9e9fb39c6808f8ef2fa 100644
--- a/src/scene-entry-manager.js
+++ b/src/scene-entry-manager.js
@@ -241,7 +241,7 @@ export default class SceneEntryManager {
     });
 
     document.addEventListener("paste", e => {
-      if (e.target.nodeName === "INPUT" && document.activeElement === e.target) return;
+      if (e.matches("input, textarea") && document.activeElement === e.target) return;
 
       const url = e.clipboardData.getData("text");
       const files = e.clipboardData.files && e.clipboardData.files;
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/bindings/keyboard-mouse-user.js b/src/systems/userinput/bindings/keyboard-mouse-user.js
index d38fb69f82063ba778924edd82eac1aadd0a6730..46e5edaf7a22f543e3fbcab3b52ffb8b69312f80 100644
--- a/src/systems/userinput/bindings/keyboard-mouse-user.js
+++ b/src/systems/userinput/bindings/keyboard-mouse-user.js
@@ -3,6 +3,8 @@ import { sets } from "../sets";
 import { xforms } from "./xforms";
 
 const wasd_vec2 = "/var/mouse-and-keyboard/wasd_vec2";
+const keyboardCharacterAcceleration = "/var/mouse-and-keyboard/keyboardCharacterAcceleration";
+const arrows_vec2 = "/var/mouse-and-keyboard/arrows_vec2";
 const dropWithRMB = "/vars/mouse-and-keyboard/drop_with_RMB";
 const dropWithEsc = "/vars/mouse-and-keyboard/drop_with_esc";
 
@@ -10,7 +12,9 @@ const dropWithRMBorEscBindings = [
   {
     src: { value: paths.device.mouse.buttonRight },
     dest: { value: dropWithRMB },
-    xform: xforms.falling
+    xform: xforms.falling,
+    root: "rmb",
+    priority: 200
   },
   {
     src: { value: paths.device.keyboard.key("Escape") },
@@ -35,6 +39,16 @@ export const keyboardMouseUserBindings = {
       },
       xform: xforms.rising
     },
+    {
+      src: {
+        w: paths.device.keyboard.key("arrowup"),
+        a: paths.device.keyboard.key("arrowleft"),
+        s: paths.device.keyboard.key("arrowdown"),
+        d: paths.device.keyboard.key("arrowright")
+      },
+      dest: { vec2: arrows_vec2 },
+      xform: xforms.wasd_to_vec2
+    },
     {
       src: {
         w: paths.device.keyboard.key("w"),
@@ -46,9 +60,17 @@ export const keyboardMouseUserBindings = {
       xform: xforms.wasd_to_vec2
     },
     {
-      src: { value: wasd_vec2 },
+      src: {
+        first: wasd_vec2,
+        second: arrows_vec2
+      },
+      dest: { value: keyboardCharacterAcceleration },
+      xform: xforms.max_vec2
+    },
+    {
+      src: { value: keyboardCharacterAcceleration },
       dest: { value: paths.actions.characterAcceleration },
-      xform: xforms.copy
+      xform: xforms.normalize_vec2
     },
     {
       src: { value: paths.device.keyboard.key("shift") },
@@ -97,7 +119,7 @@ export const keyboardMouseUserBindings = {
     {
       src: { value: "/var/smartMouseCamDeltaX" },
       dest: { value: "/var/smartMouseCamDeltaXScaled" },
-      xform: xforms.scale(-0.06)
+      xform: xforms.scale(-0.2)
     },
     {
       src: { value: "/var/smartMouseCamDeltaY" },
@@ -118,6 +140,15 @@ export const keyboardMouseUserBindings = {
       },
       xform: xforms.rising
     },
+    {
+      src: {
+        value: paths.device.keyboard.key("t")
+      },
+      dest: {
+        value: paths.actions.focusChat
+      },
+      xform: xforms.rising
+    },
     {
       src: {
         value: paths.device.keyboard.key("l")
@@ -126,6 +157,26 @@ export const keyboardMouseUserBindings = {
         value: paths.actions.logDebugFrame
       },
       xform: xforms.rising
+    },
+    {
+      src: {
+        value: paths.device.mouse.buttonRight
+      },
+      dest: {
+        value: paths.actions.startGazeTeleport
+      },
+      xform: xforms.rising,
+      root: "rmb",
+      priority: 100
+    },
+    {
+      src: {
+        value: paths.device.mouse.buttonRight
+      },
+      dest: {
+        value: paths.actions.stopGazeTeleport
+      },
+      xform: xforms.falling
     }
   ],
 
diff --git a/src/systems/userinput/bindings/oculus-touch-user.js b/src/systems/userinput/bindings/oculus-touch-user.js
index beb0108e04ea68b8be2fd63c1725286ab9157423..973dc4f0b79435d4cd92eb7e01a6b7a19f8c0fad 100644
--- a/src/systems/userinput/bindings/oculus-touch-user.js
+++ b/src/systems/userinput/bindings/oculus-touch-user.js
@@ -47,6 +47,9 @@ const leftJoyY = `${name}left/joyY`;
 const leftJoyYCursorMod = `${name}left/joyYCursorMod`;
 const oculusTouchCharacterAcceleration = `${name}characterAcceleration`;
 const keyboardCharacterAcceleration = "/var/keyboard/characterAcceleration";
+const characterAcceleration = "/var/oculus-touch/nonNormalizedCharacterAcceleration";
+const wasd_vec2 = "/var/keyboard/wasd_vec2";
+const arrows_vec2 = "/var/keyboard/arrows_vec2";
 const keyboardBoost = "/var/keyboard-oculus/boost";
 const rightBoost = "/var/right-oculus/boost";
 const leftBoost = "/var/left-oculus/boost";
@@ -234,6 +237,16 @@ export const oculusTouchUserBindings = {
       dest: { value: oculusTouchCharacterAcceleration },
       xform: xforms.compose_vec2
     },
+    {
+      src: {
+        w: paths.device.keyboard.key("arrowup"),
+        a: paths.device.keyboard.key("arrowleft"),
+        s: paths.device.keyboard.key("arrowdown"),
+        d: paths.device.keyboard.key("arrowright")
+      },
+      dest: { vec2: arrows_vec2 },
+      xform: xforms.wasd_to_vec2
+    },
     {
       src: {
         w: paths.device.keyboard.key("w"),
@@ -241,18 +254,31 @@ export const oculusTouchUserBindings = {
         s: paths.device.keyboard.key("s"),
         d: paths.device.keyboard.key("d")
       },
-      dest: { vec2: keyboardCharacterAcceleration },
+      dest: { vec2: wasd_vec2 },
       xform: xforms.wasd_to_vec2
     },
+    {
+      src: {
+        first: wasd_vec2,
+        second: arrows_vec2
+      },
+      dest: { value: keyboardCharacterAcceleration },
+      xform: xforms.max_vec2
+    },
     {
       src: {
         first: oculusTouchCharacterAcceleration,
         second: keyboardCharacterAcceleration
       },
       dest: {
-        value: paths.actions.characterAcceleration
+        value: characterAcceleration
       },
-      xform: xforms.add_vec2
+      xform: xforms.max_vec2
+    },
+    {
+      src: { value: characterAcceleration },
+      dest: { value: paths.actions.characterAcceleration },
+      xform: xforms.normalize_vec2
     },
     {
       src: { value: paths.device.keyboard.key("shift") },
diff --git a/src/systems/userinput/bindings/vive-user.js b/src/systems/userinput/bindings/vive-user.js
index dd3eae36b615493cf232ed0d0d48a48a85c65f29..3f018994d543c6d5ba8132e50f1d36382398723e 100644
--- a/src/systems/userinput/bindings/vive-user.js
+++ b/src/systems/userinput/bindings/vive-user.js
@@ -26,6 +26,7 @@ const lTriggerRisingGrab = v("right/trigger/rising/grab");
 const lGripRisingGrab = v("right/grab/rising/grab");
 const lTouchpadRising = v("left/touchpad/rising");
 const lCharacterAcceleration = v("left/characterAcceleration");
+const characterAcceleration = v("nonNormalizedCharacterAcceleration");
 const lGripFalling = v("left/grip/falling");
 const lGripRising = v("left/grip/rising");
 const leftBoost = v("left/boost");
@@ -69,6 +70,8 @@ const k = name => {
 const keyboardSnapRight = k("snap-right");
 const keyboardSnapLeft = k("snap-left");
 const keyboardCharacterAcceleration = k("characterAcceleration");
+const wasd_vec2 = k("wasd_vec2");
+const arrows_vec2 = k("arrows_vec2");
 const keyboardBoost = k("boost");
 
 const teleportLeft = [
@@ -324,6 +327,16 @@ export const viveUserBindings = {
       dest: { value: lCharacterAcceleration },
       xform: xforms.copyIfTrue
     },
+    {
+      src: {
+        w: paths.device.keyboard.key("arrowup"),
+        a: paths.device.keyboard.key("arrowleft"),
+        s: paths.device.keyboard.key("arrowdown"),
+        d: paths.device.keyboard.key("arrowright")
+      },
+      dest: { vec2: arrows_vec2 },
+      xform: xforms.wasd_to_vec2
+    },
     {
       src: {
         w: paths.device.keyboard.key("w"),
@@ -331,18 +344,31 @@ export const viveUserBindings = {
         s: paths.device.keyboard.key("s"),
         d: paths.device.keyboard.key("d")
       },
-      dest: { vec2: keyboardCharacterAcceleration },
+      dest: { vec2: wasd_vec2 },
       xform: xforms.wasd_to_vec2
     },
+    {
+      src: {
+        first: wasd_vec2,
+        second: arrows_vec2
+      },
+      dest: { value: keyboardCharacterAcceleration },
+      xform: xforms.max_vec2
+    },
     {
       src: {
         first: lCharacterAcceleration,
         second: keyboardCharacterAcceleration
       },
       dest: {
-        value: paths.actions.characterAcceleration
+        value: characterAcceleration
       },
-      xform: xforms.add_vec2
+      xform: xforms.max_vec2
+    },
+    {
+      src: { value: characterAcceleration },
+      dest: { value: paths.actions.characterAcceleration },
+      xform: xforms.normalize_vec2
     },
     {
       src: { value: paths.device.keyboard.key("shift") },
diff --git a/src/systems/userinput/bindings/xforms.js b/src/systems/userinput/bindings/xforms.js
index 444134675a842316f0f5b52f55ed8ff30083ca15..0ab4cdf5226a3840a26475cf5cd93390b7723a22 100644
--- a/src/systems/userinput/bindings/xforms.js
+++ b/src/systems/userinput/bindings/xforms.js
@@ -104,6 +104,29 @@ export const xforms = {
       frame[dest.value] = first;
     }
   },
+  max_vec2: function(frame, src, dest) {
+    const first = frame[src.first];
+    const second = frame[src.second];
+    if (first && second) {
+      frame[dest.value] =
+        first[0] * first[0] + first[1] * first[1] > second[0] * second[0] + second[1] * second[1] ? first : second;
+    } else if (second) {
+      frame[dest.value] = second;
+    } else if (first) {
+      frame[dest.value] = first;
+    }
+  },
+  normalize_vec2: function(frame, src, dest) {
+    const vec2 = frame[src.value];
+    if (vec2) {
+      if (vec2[0] === 0 && vec2[0] === 0) {
+        frame[dest.value] = vec2;
+      } else {
+        const l = Math.sqrt(vec2[0] * vec2[0] + vec2[1] * vec2[1]);
+        frame[dest.value] = [vec2[0] / l, vec2[1] / l];
+      }
+    }
+  },
   any: function(frame, src, dest) {
     for (const path in src) {
       if (frame[src[path]]) {
diff --git a/src/systems/userinput/devices/app-aware-mouse.js b/src/systems/userinput/devices/app-aware-mouse.js
index 4afd124e7006b86869309aaa3626a756b7f20292..66e006d44c56a98c07a39a498cc68581c4d0e6b9 100644
--- a/src/systems/userinput/devices/app-aware-mouse.js
+++ b/src/systems/userinput/devices/app-aware-mouse.js
@@ -30,13 +30,6 @@ export class AppAwareMouseDevice {
       this.camera = document.querySelector("#player-camera").components.camera.camera;
     }
 
-    const coords = frame[paths.device.mouse.coords];
-    const isCursorGrabbing = this.cursorController.data.cursor.components["super-hands"].state.has("grab-start");
-    if (isCursorGrabbing) {
-      frame[paths.device.smartMouse.cursorPose] = calculateCursorPose(this.camera, coords);
-      return;
-    }
-
     const buttonLeft = frame[paths.device.mouse.buttonLeft];
     if (buttonLeft && !this.prevButtonLeft) {
       const rawIntersections = [];
@@ -54,8 +47,9 @@ export class AppAwareMouseDevice {
 
     if (!this.clickedOnAnything && buttonLeft) {
       frame[paths.device.smartMouse.cameraDelta] = frame[paths.device.mouse.movementXY];
-    } else {
-      frame[paths.device.smartMouse.cursorPose] = calculateCursorPose(this.camera, coords);
     }
+
+    const coords = frame[paths.device.mouse.coords];
+    frame[paths.device.smartMouse.cursorPose] = calculateCursorPose(this.camera, coords);
   }
 }
diff --git a/src/systems/userinput/devices/mouse.js b/src/systems/userinput/devices/mouse.js
index 0ab7a3ac2aea0ce0ec44ad55fb5804348443bcae..3252fb7372a53406da0992b8b3445a9f854dd065 100644
--- a/src/systems/userinput/devices/mouse.js
+++ b/src/systems/userinput/devices/mouse.js
@@ -18,24 +18,19 @@ export class MouseDevice {
 
     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));
+    ["mousedown", "wheel"].map(x => canvas.addEventListener(x, queueEvent));
+    ["mousemove", "mouseup"].map(x => window.addEventListener(x, queueEvent));
+    document.addEventListener("wheel", e => {
+      e.preventDefault();
+    });
   }
 
   process(event) {
     if (event.type === "wheel") {
-      this.wheel += event.deltaY / modeMod[event.deltaMode];
+      this.wheel += (event.deltaX + 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;
diff --git a/src/systems/userinput/paths.js b/src/systems/userinput/paths.js
index 75f655055b4578c0ce38f7781a561955f3ba5100..7f38c58cbefcd40e9f9b08c0e47114f6ebab3ee2 100644
--- a/src/systems/userinput/paths.js
+++ b/src/systems/userinput/paths.js
@@ -15,6 +15,7 @@ paths.actions.spawnPen = "/actions/spawnPen";
 paths.actions.ensureFrozen = "/actions/ensureFrozen";
 paths.actions.thaw = "/actions/thaw";
 paths.actions.muteMic = "/actions/muteMic";
+paths.actions.focusChat = "/actions/focusChat";
 paths.actions.cursor = {};
 paths.actions.cursor.pose = "/actions/cursorPose";
 paths.actions.cursor.grab = "/actions/cursorGrab";
diff --git a/src/systems/userinput/resolve-action-sets.js b/src/systems/userinput/resolve-action-sets.js
index d1375f1b02df1c6f3d56463824a13600383fea6b..4341064a1085ba55f3584d4a3a5e00bcd5a8d6d7 100644
--- a/src/systems/userinput/resolve-action-sets.js
+++ b/src/systems/userinput/resolve-action-sets.js
@@ -136,30 +136,30 @@ export function updateActionSetsBasedOnSuperhands() {
     !cursorHoveringOnUI;
 
   const userinput = AFRAME.scenes[0].systems.userinput;
-  userinput.toggleActive(sets.leftHandHoveringOnInteractable, leftHandHoveringOnInteractable);
-  userinput.toggleActive(sets.leftHandHoveringOnPen, leftHandHoveringOnPen);
-  userinput.toggleActive(sets.leftHandHoveringOnCamera, leftHandHoveringOnCamera);
-  userinput.toggleActive(sets.leftHandHoveringOnNothing, leftHandHoveringOnNothing);
-  userinput.toggleActive(sets.leftHandHoldingPen, leftHandHoldingPen);
-  userinput.toggleActive(sets.leftHandHoldingInteractable, leftHandHoldingInteractable);
-  userinput.toggleActive(sets.leftHandHoldingCamera, leftHandHoldingCamera);
-  userinput.toggleActive(sets.leftHandTeleporting, leftHandTeleporting);
+  userinput.toggleSet(sets.leftHandHoveringOnInteractable, leftHandHoveringOnInteractable);
+  userinput.toggleSet(sets.leftHandHoveringOnPen, leftHandHoveringOnPen);
+  userinput.toggleSet(sets.leftHandHoveringOnCamera, leftHandHoveringOnCamera);
+  userinput.toggleSet(sets.leftHandHoveringOnNothing, leftHandHoveringOnNothing);
+  userinput.toggleSet(sets.leftHandHoldingPen, leftHandHoldingPen);
+  userinput.toggleSet(sets.leftHandHoldingInteractable, leftHandHoldingInteractable);
+  userinput.toggleSet(sets.leftHandHoldingCamera, leftHandHoldingCamera);
+  userinput.toggleSet(sets.leftHandTeleporting, leftHandTeleporting);
 
-  userinput.toggleActive(sets.rightHandHoveringOnInteractable, rightHandHoveringOnInteractable);
-  userinput.toggleActive(sets.rightHandHoveringOnPen, rightHandHoveringOnPen);
-  userinput.toggleActive(sets.rightHandHoveringOnNothing, rightHandHoveringOnNothing);
-  userinput.toggleActive(sets.rightHandHoveringOnCamera, rightHandHoveringOnCamera);
-  userinput.toggleActive(sets.rightHandHoldingPen, rightHandHoldingPen);
-  userinput.toggleActive(sets.rightHandHoldingInteractable, rightHandHoldingInteractable);
-  userinput.toggleActive(sets.rightHandTeleporting, rightHandTeleporting);
-  userinput.toggleActive(sets.rightHandHoldingCamera, rightHandHoldingCamera);
+  userinput.toggleSet(sets.rightHandHoveringOnInteractable, rightHandHoveringOnInteractable);
+  userinput.toggleSet(sets.rightHandHoveringOnPen, rightHandHoveringOnPen);
+  userinput.toggleSet(sets.rightHandHoveringOnNothing, rightHandHoveringOnNothing);
+  userinput.toggleSet(sets.rightHandHoveringOnCamera, rightHandHoveringOnCamera);
+  userinput.toggleSet(sets.rightHandHoldingPen, rightHandHoldingPen);
+  userinput.toggleSet(sets.rightHandHoldingInteractable, rightHandHoldingInteractable);
+  userinput.toggleSet(sets.rightHandTeleporting, rightHandTeleporting);
+  userinput.toggleSet(sets.rightHandHoldingCamera, rightHandHoldingCamera);
 
-  userinput.toggleActive(sets.cursorHoveringOnPen, cursorHoveringOnPen);
-  userinput.toggleActive(sets.cursorHoveringOnCamera, cursorHoveringOnCamera);
-  userinput.toggleActive(sets.cursorHoveringOnInteractable, cursorHoveringOnInteractable);
-  userinput.toggleActive(sets.cursorHoveringOnUI, cursorHoveringOnUI);
-  userinput.toggleActive(sets.cursorHoveringOnNothing, cursorHoveringOnNothing);
-  userinput.toggleActive(sets.cursorHoldingPen, cursorHoldingPen);
-  userinput.toggleActive(sets.cursorHoldingCamera, cursorHoldingCamera);
-  userinput.toggleActive(sets.cursorHoldingInteractable, cursorHoldingInteractable);
+  userinput.toggleSet(sets.cursorHoveringOnPen, cursorHoveringOnPen);
+  userinput.toggleSet(sets.cursorHoveringOnCamera, cursorHoveringOnCamera);
+  userinput.toggleSet(sets.cursorHoveringOnInteractable, cursorHoveringOnInteractable);
+  userinput.toggleSet(sets.cursorHoveringOnUI, cursorHoveringOnUI);
+  userinput.toggleSet(sets.cursorHoveringOnNothing, cursorHoveringOnNothing);
+  userinput.toggleSet(sets.cursorHoldingPen, cursorHoldingPen);
+  userinput.toggleSet(sets.cursorHoldingCamera, cursorHoldingCamera);
+  userinput.toggleSet(sets.cursorHoldingInteractable, cursorHoldingInteractable);
 }
diff --git a/src/systems/userinput/userinput.js b/src/systems/userinput/userinput.js
index 2ffbdca79db0e2d07d464f32ea320fc8cb31c329..8442cd73a763cf2fa8881e04267fb68d653ac0f1 100644
--- a/src/systems/userinput/userinput.js
+++ b/src/systems/userinput/userinput.js
@@ -26,29 +26,27 @@ import { updateActionSetsBasedOnSuperhands } from "./resolve-action-sets";
 import { GamepadDevice } from "./devices/gamepad";
 import { gamepadBindings } from "./bindings/generic-gamepad";
 
-const prioritizedBindings = new Map();
+const priorityMap = new Map();
 function prioritizeBindings(registeredMappings, activeSets) {
   const activeBindings = new Set();
-  prioritizedBindings.clear();
+  priorityMap.clear();
   for (const mapping of registeredMappings) {
     for (const setName in mapping) {
       if (!activeSets.has(setName) || !mapping[setName]) continue;
       for (const binding of mapping[setName]) {
         const { root, priority } = binding;
+        const prevBinding = priorityMap.get(root);
         if (!root || !priority) {
           activeBindings.add(binding);
-        } else if (!prioritizedBindings.has(root)) {
+        } else if (!prevBinding) {
           activeBindings.add(binding);
-          prioritizedBindings.set(root, binding);
-        } else {
-          const prevPriority = prioritizedBindings.get(root).priority;
-          if (priority > prevPriority) {
-            activeBindings.delete(prioritizedBindings.get(root));
-            activeBindings.add(binding);
-            prioritizedBindings.set(root, binding);
-          } else if (prevPriority === priority) {
-            console.error("equal priorities on same root", binding, prioritizedBindings.get(root));
-          }
+          priorityMap.set(root, binding);
+        } else if (priority > prevBinding.priority) {
+          activeBindings.delete(priorityMap.get(root));
+          activeBindings.add(binding);
+          priorityMap.set(root, binding);
+        } else if (prevBinding.priority === priority) {
+          console.error("equal priorities on same root", binding, priorityMap.get(root));
         }
       }
     }
@@ -57,11 +55,11 @@ function prioritizeBindings(registeredMappings, activeSets) {
 }
 
 AFRAME.registerSystem("userinput", {
-  readFrameValueAtPath(path) {
+  get(path) {
     return this.frame && this.frame[path];
   },
 
-  toggleActive(set, value) {
+  toggleSet(set, value) {
     this.pendingSetChanges.push({ set, value });
   },
 
diff --git a/src/systems/userinput/userinput.md b/src/systems/userinput/userinput.md
index d3701d5c93dd34a4be3828108d29a7a5c094c2a0..b8ceb54539665f0130863a6018b187bf0562a8aa 100644
--- a/src/systems/userinput/userinput.md
+++ b/src/systems/userinput/userinput.md
@@ -1,19 +1,4 @@
 
-# Table of Contents
-
-1.  [The userinput system](#org6030eab)
-    1.  [Overview](#org2da9acd)
-    2.  [Terms and Conventions](#org4721ce9)
-        1.  [path](#orgd62cc68)
-        2.  [action](#orgb8066a6)
-        3.  [frame](#org15eafde)
-        4.  [device](#orgea2f123)
-        5.  [binding](#org47c9c20)
-        6.  [xforms](#org876e7b0)
-        7.  [set](#orgbe4669b)
-        8.  [priority and root](#orgdd3c0c5)
-
-
 <a id="org6030eab"></a>
 
 # The userinput system
@@ -25,7 +10,7 @@ The userinput system is a module that manages mappings from device state changes
 
 ## Overview
 
-The userinput system happens to be an `aframe` `system`; its `tick` is called once a frame within the `aframe` `scene`'s `tick`. When the userinput system `tick` happens, it is responsible for creating a map called the frame. The keys of the frame are called "paths". The values stored in the frame can be any type, but are usually one of: bool, number, vec2, vec3, vec4, pose. On each tick, each connected `device` writes "raw" input values to known "device paths" within the frame. Configuration units called `bindings` are then applied to transform "raw" input values to app-specific "actions". The userinput system exposes the state of a given `action` in the current frame via `readFrameValueAtPath`. The `bindings` that are applied to transform input to "actions" must be `available`, `active`, and `prioritized`.
+The userinput system happens to be an `aframe` `system`; its `tick` is called once a frame within the `aframe` `scene`'s `tick`. When the userinput system `tick` happens, it is responsible for creating a map called the frame. The keys of the frame are called "paths". The values stored in the frame can be any type, but are usually one of: bool, number, vec2, vec3, vec4, pose. On each tick, each connected `device` writes "raw" input values to known "device paths" within the frame. Configuration units called `bindings` are then applied to transform "raw" input values to app-specific "actions". The userinput system exposes the state of a given `action` in the current frame via `get`. The `bindings` that are applied to transform input to "actions" must be `available`, `active`, and `prioritized`.
 
 1.  A `binding` is made `available` when the userinput system detects a change to the user's device configuration that matches certain criteria. A touchscreen user only has `availableBindings` related to touchscreen input. A mouse-and-keyboard user only has `availableBindings` related to mouse-and-keyboard input. An oculus/vive user has `bindings` related to mouse, keyboard, and oculus/vive controllers.
 
@@ -70,14 +55,14 @@ A path is used as a key when writing or querying the state a user input frame. P
 A path used by app code when reading a user input frame.
 
     const userinput = AFRAME.scenes[0].systems.userinput;
-    if (userinput.readFrameValueAtPath("/actions/rightHandGrab")) {
+    if (userinput.get("/actions/rightHandGrab")) {
       this.startInteraction();
     }
 
 The value in the frame can be of any type, but we have tried to keep it to simple types like bool, number, vec2, vec3, and pose.
 
     const userinput = AFRAME.scenes[0].systems.userinput;
-    const acceleration = userinput.readFrameValueAtPath("/actions/characterAcceleration");
+    const acceleration = userinput.get("/actions/characterAcceleration");
     this.updateVelocity( this.velocity, acceleration || zero );
     this.move( this.velocity );
 
diff --git a/src/utils/hub-channel.js b/src/utils/hub-channel.js
index 350be0aa1facf770f21e4ba26cd1c0b34d9b15db..96d8860acfd9137e0d2b7e9708b8506cc828584a 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/src/utils/serialize-element.js b/src/utils/serialize-element.js
new file mode 100644
index 0000000000000000000000000000000000000000..7782d331bc98aef445b143e4a43af6a5c71fd9a4
--- /dev/null
+++ b/src/utils/serialize-element.js
@@ -0,0 +1,191 @@
+// https://stackoverflow.com/questions/6209161/extract-the-current-dom-and-print-it-as-a-string-with-styles-intact
+//
+// Mapping between tag names and css default values lookup tables. This allows to exclude default values in the result.
+const defaultStylesByTagName = {};
+
+// Styles inherited from style sheets will not be rendered for elements with these tag names
+const noStyleTags = {
+  BASE: true,
+  HEAD: true,
+  HTML: true,
+  META: true,
+  NOFRAME: true,
+  NOSCRIPT: true,
+  PARAM: true,
+  SCRIPT: true,
+  STYLE: true,
+  TITLE: true
+};
+
+// This list determines which css default values lookup tables are precomputed at load time
+// Lookup tables for other tag names will be automatically built at runtime if needed
+const tagNames = [
+  "A",
+  "ABBR",
+  "ADDRESS",
+  "AREA",
+  "ARTICLE",
+  "ASIDE",
+  "AUDIO",
+  "B",
+  "BASE",
+  "BDI",
+  "BDO",
+  "BLOCKQUOTE",
+  "BODY",
+  "BR",
+  "BUTTON",
+  "CANVAS",
+  "CAPTION",
+  "CENTER",
+  "CITE",
+  "CODE",
+  "COL",
+  "COLGROUP",
+  "COMMAND",
+  "DATALIST",
+  "DD",
+  "DEL",
+  "DETAILS",
+  "DFN",
+  "DIV",
+  "DL",
+  "DT",
+  "EM",
+  "EMBED",
+  "FIELDSET",
+  "FIGCAPTION",
+  "FIGURE",
+  "FONT",
+  "FOOTER",
+  "FORM",
+  "H1",
+  "H2",
+  "H3",
+  "H4",
+  "H5",
+  "H6",
+  "HEAD",
+  "HEADER",
+  "HGROUP",
+  "HR",
+  "HTML",
+  "I",
+  "IFRAME",
+  "IMG",
+  "INPUT",
+  "INS",
+  "KBD",
+  "KEYGEN",
+  "LABEL",
+  "LEGEND",
+  "LI",
+  "LINK",
+  "MAP",
+  "MARK",
+  "MATH",
+  "MENU",
+  "META",
+  "METER",
+  "NAV",
+  "NOBR",
+  "NOSCRIPT",
+  "OBJECT",
+  "OL",
+  "OPTION",
+  "OPTGROUP",
+  "OUTPUT",
+  "P",
+  "PARAM",
+  "PRE",
+  "PROGRESS",
+  "Q",
+  "RP",
+  "RT",
+  "RUBY",
+  "S",
+  "SAMP",
+  "SCRIPT",
+  "SECTION",
+  "SELECT",
+  "SMALL",
+  "SOURCE",
+  "SPAN",
+  "STRONG",
+  "STYLE",
+  "SUB",
+  "SUMMARY",
+  "SUP",
+  "SVG",
+  "TABLE",
+  "TBODY",
+  "TD",
+  "TEXTAREA",
+  "TFOOT",
+  "TH",
+  "THEAD",
+  "TIME",
+  "TITLE",
+  "TR",
+  "TRACK",
+  "U",
+  "UL",
+  "VAR",
+  "VIDEO",
+  "WBR"
+];
+
+function computeDefaultStyleByTagName(tagName) {
+  const defaultStyle = {};
+  const element = document.body.appendChild(document.createElement(tagName));
+  const computedStyle = getComputedStyle(element);
+  for (let i = 0; i < computedStyle.length; i++) {
+    defaultStyle[computedStyle[i]] = computedStyle[computedStyle[i]];
+  }
+  document.body.removeChild(element);
+  return defaultStyle;
+}
+
+function getDefaultStyleByTagName(tagName) {
+  tagName = tagName.toUpperCase();
+  if (!defaultStylesByTagName[tagName]) {
+    defaultStylesByTagName[tagName] = computeDefaultStyleByTagName(tagName);
+  }
+  return defaultStylesByTagName[tagName];
+}
+
+export default function serializeElement(el) {
+  if (Object.keys(defaultStylesByTagName).length === 0) {
+    // Precompute the lookup tables.
+    for (let i = 0; i < tagNames.length; i++) {
+      if (!noStyleTags[tagNames[i]]) {
+        defaultStylesByTagName[tagNames[i]] = computeDefaultStyleByTagName(tagNames[i]);
+      }
+    }
+  }
+
+  if (el.nodeType !== Node.ELEMENT_NODE) {
+    throw new TypeError();
+  }
+  const cssTexts = [];
+  const elements = el.querySelectorAll("*");
+  for (let i = 0; i < elements.length; i++) {
+    const e = elements[i];
+    if (!noStyleTags[e.tagName]) {
+      const computedStyle = getComputedStyle(e);
+      const defaultStyle = getDefaultStyleByTagName(e.tagName);
+      cssTexts[i] = e.style.cssText;
+      for (let ii = 0; ii < computedStyle.length; ii++) {
+        const cssPropName = computedStyle[ii];
+        if (computedStyle[cssPropName] !== defaultStyle[cssPropName]) {
+          e.style[cssPropName] = computedStyle[cssPropName];
+        }
+      }
+    }
+  }
+  const result = el.outerHTML;
+  for (let i = 0; i < elements.length; i++) {
+    elements[i].style.cssText = cssTexts[i];
+  }
+  return result;
+}
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",