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/package-lock.json b/package-lock.json
index 0bbad51e844badf4f715528348442ed89792212a..fef46892ec8d06f32d25cd623b14842a31d87b8e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -518,7 +518,9 @@
       "requires": {
         "@tweenjs/tween.js": "^16.8.0",
         "browserify-css": "^0.8.2",
+        "debug": "github:ngokevin/debug#ef5f8e66d49ce8bc64c6f282c15f8b7164409e3a",
         "deep-assign": "^2.0.0",
+        "document-register-element": "github:dmarcos/document-register-element#8ccc532b7f3744be954574caf3072a5fd260ca90",
         "envify": "^3.4.1",
         "load-bmfont": "^1.2.3",
         "object-assign": "^4.0.1",
@@ -532,11 +534,7 @@
       "dependencies": {
         "debug": {
           "version": "github:ngokevin/debug#ef5f8e66d49ce8bc64c6f282c15f8b7164409e3a",
-          "from": "github:ngokevin/debug#ef5f8e66d49ce8bc64c6f282c15f8b7164409e3a"
-        },
-        "document-register-element": {
-          "version": "github:dmarcos/document-register-element#8ccc532b7f3744be954574caf3072a5fd260ca90",
-          "from": "github:dmarcos/document-register-element#8ccc532b7f3744be954574caf3072a5fd260ca90"
+          "from": "github:ngokevin/debug#noTimestamp"
         },
         "three": {
           "version": "0.94.0",
@@ -3906,6 +3904,10 @@
         "esutils": "^2.0.2"
       }
     },
+    "document-register-element": {
+      "version": "github:dmarcos/document-register-element#8ccc532b7f3744be954574caf3072a5fd260ca90",
+      "from": "github:dmarcos/document-register-element#8ccc532b7"
+    },
     "dom-converter": {
       "version": "0.1.4",
       "resolved": "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.1.4.tgz",
@@ -10542,6 +10544,12 @@
         }
       }
     },
+    "raw-loader": {
+      "version": "0.5.1",
+      "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-0.5.1.tgz",
+      "integrity": "sha1-DD0L6u2KAclm2Xh793goElKpeao=",
+      "dev": true
+    },
     "react": {
       "version": "16.4.1",
       "resolved": "https://registry.yarnpkg.com/react/-/react-16.4.1.tgz",
diff --git a/package.json b/package.json
index a49518cae8a9d196aa0c0b6411c5b5b1d2f78002..40a1584087063e2ddac674079394694a76783491 100644
--- a/package.json
+++ b/package.json
@@ -87,6 +87,7 @@
     "htmlhint": "^0.9.13",
     "node-sass": "^4.9.3",
     "prettier": "^1.7.0",
+    "raw-loader": "^0.5.1",
     "rimraf": "^2.6.2",
     "sass-loader": "^6.0.7",
     "selfsigned": "^1.10.2",
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/share_message.png b/src/assets/share_message.png
new file mode 100644
index 0000000000000000000000000000000000000000..6fcc189753d076a7fb23e12b73a8f5bd9a59e1df
Binary files /dev/null and b/src/assets/share_message.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/presence-log.scss b/src/assets/stylesheets/presence-log.scss
index bed6d5cd50cae9b07dd406009c01917ac48477d8..c8ee0d644c111238f3c82759443c7afabc69f371 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,40 @@
       max-width: 75%;
     }
 
+    :local(.icon-button) {
+      appearance: none;
+      -moz-appearance: none;
+      -webkit-appearance: none;
+      outline-style: none;
+      width: 24px;
+      height: 24px;
+      background-size: 20px;
+      background-position: center;
+      background-repeat: no-repeat;
+      border: 0;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      align-self: flex-start;
+      cursor: pointer;
+      margin-right: 6px;
+      border-radius: 12px;
+      background-color: transparent;
+
+      &:hover {
+        background-color: $action-color;
+      }
+    }
+
+    :local(.spawn-message) {
+      background-image: url(../spawn_message.png);
+    }
+
+    // TODO replace these icons with share button
+    :local(.share) {
+      background-image: url(../share_message.png);
+    }
+
     &:local(.media) {
       display: flex;
       align-items: center;
@@ -47,7 +110,7 @@
 
       img {
         height: 35px;
-        margin-right: 8px;
+        margin-left: 8px;
         border: 2px solid rgba(255,255,255,0.15);
         display: block;
         border-radius: 5px;
@@ -62,26 +125,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: black;
+    color: white;
+    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/scene-ui.scss b/src/assets/stylesheets/scene-ui.scss
index bd3abf0a6d82cf898081da35c23add7ce692b024..99f3ddae3255817ec0d7ad22e2bd15a4fa6d9a7f 100644
--- a/src/assets/stylesheets/scene-ui.scss
+++ b/src/assets/stylesheets/scene-ui.scss
@@ -38,10 +38,6 @@
   justify-content: center;
   pointer-events: auto;
 
-  button {
-    @extend %action-button;
-    border: 0;
-  }
 }
 
 :local(.logoTagline) {
@@ -95,6 +91,12 @@
 :local(.attribution) {
   font-size: 1.0em;
   white-space: wrap;
+
+  a {
+    font-size: 0.8em;
+    color: black;
+    pointer-events: auto;
+  }
 }
 
 :local(.screenshot) {
@@ -147,3 +149,23 @@
     width: 200px;
   }
 }
+
+:local(.createButtons) {
+  position: relative;
+  display: flex;
+}
+
+:local(.createButton) {
+  @extend %action-button;
+  width: 100%;
+  border: 0;
+}
+
+:local(.optionsButton) {
+  @extend %fa-icon-button;
+  @extend %fa-icon-big;
+  position: absolute;
+  right: 10px;
+  top: -12px;
+  color: white;
+}
diff --git a/src/assets/stylesheets/scene.scss b/src/assets/stylesheets/scene.scss
index 44e6591aacfa994bb79a4a0354b129ae171c4741..49f588251e3067da70aae0151e0463d6508910e1 100644
--- a/src/assets/stylesheets/scene.scss
+++ b/src/assets/stylesheets/scene.scss
@@ -1,2 +1,3 @@
 @import 'shared';
 @import 'loader';
+@import 'info-dialog';
diff --git a/src/assets/stylesheets/shared.scss b/src/assets/stylesheets/shared.scss
index 6a0ef8e55878ae40406566750d081eaea7058408..d44b6b31916977101aea39ee8824cee35e388559 100644
--- a/src/assets/stylesheets/shared.scss
+++ b/src/assets/stylesheets/shared.scss
@@ -155,16 +155,15 @@ $spoke-action-color: $bright-blue;
   -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/ui-root.scss b/src/assets/stylesheets/ui-root.scss
index 11de7f546b713ca297e99e92e090bb355e55e334..b7761c48d938ea6e19504ef940bf84ad3a718c37 100644
--- a/src/assets/stylesheets/ui-root.scss
+++ b/src/assets/stylesheets/ui-root.scss
@@ -204,10 +204,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;
@@ -224,9 +226,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) {
@@ -246,11 +250,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) {
@@ -259,11 +281,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 f1114c4069a4da2ae2c62ade41afbf55a48a0e93..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",
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/hover-visuals.js b/src/components/hover-visuals.js
new file mode 100644
index 0000000000000000000000000000000000000000..ec0111e26988b3a7fb10461c03ca496d9bb7c135
--- /dev/null
+++ b/src/components/hover-visuals.js
@@ -0,0 +1,40 @@
+const interactorTransform = [];
+
+/**
+ * Applies effects to a hoverer based on hover state.
+ * @namespace interactables
+ * @component hover-visuals
+ */
+AFRAME.registerComponent("hover-visuals", {
+  schema: {
+    hand: { type: "string" },
+    controller: { type: "selector" }
+  },
+  init() {
+    // uniforms are set from the component responsible for loading the mesh.
+    this.uniforms = null;
+  },
+  remove() {
+    this.uniforms = null;
+  },
+  tick() {
+    if (!this.uniforms || !this.uniforms.size) return;
+
+    this.el.object3D.matrixWorld.toArray(interactorTransform);
+    const hovering = this.data.controller.components["super-hands"].state.has("hover-start");
+
+    for (const uniform of this.uniforms.values()) {
+      if (this.data.hand === "left") {
+        uniform.hubs_HighlightInteractorOne.value = hovering;
+        uniform.hubs_InteractorOnePos.value[0] = interactorTransform[12];
+        uniform.hubs_InteractorOnePos.value[1] = interactorTransform[13];
+        uniform.hubs_InteractorOnePos.value[2] = interactorTransform[14];
+      } else {
+        uniform.hubs_HighlightInteractorTwo.value = hovering;
+        uniform.hubs_InteractorTwoPos.value[0] = interactorTransform[12];
+        uniform.hubs_InteractorTwoPos.value[1] = interactorTransform[13];
+        uniform.hubs_InteractorTwoPos.value[2] = interactorTransform[14];
+      }
+    }
+  }
+});
diff --git a/src/components/hoverable-visuals.js b/src/components/hoverable-visuals.js
new file mode 100644
index 0000000000000000000000000000000000000000..aa33cd4414b611055e592fb520c9b013b945099b
--- /dev/null
+++ b/src/components/hoverable-visuals.js
@@ -0,0 +1,76 @@
+const interactorOneTransform = [];
+const interactorTwoTransform = [];
+
+/**
+ * Applies effects to a hoverable based on hover state.
+ * @namespace interactables
+ * @component hoverable-visuals
+ */
+AFRAME.registerComponent("hoverable-visuals", {
+  schema: {
+    cursorController: { type: "selector" },
+    enableSweepingEffect: { type: "boolean", default: true }
+  },
+  init() {
+    // uniforms and boundingSphere are set from the component responsible for loading the mesh.
+    this.uniforms = null;
+    this.boundingSphere = new THREE.Sphere();
+
+    this.sweepParams = [0, 0];
+  },
+  remove() {
+    this.uniforms = null;
+    this.boundingBox = null;
+  },
+  tick(time) {
+    if (!this.uniforms || !this.uniforms.size) return;
+
+    const { hoverers } = this.el.components["hoverable"];
+
+    let interactorOne, interactorTwo;
+    for (const hoverer of hoverers) {
+      if (hoverer.id === "player-left-controller") {
+        interactorOne = hoverer.object3D;
+      } else if (hoverer.id === "cursor") {
+        if (this.data.cursorController.components["cursor-controller"].enabled) {
+          interactorTwo = hoverer.object3D;
+        }
+      } else {
+        interactorTwo = hoverer.object3D;
+      }
+    }
+
+    if (interactorOne) {
+      interactorOne.matrixWorld.toArray(interactorOneTransform);
+    }
+    if (interactorTwo) {
+      interactorTwo.matrixWorld.toArray(interactorTwoTransform);
+    }
+
+    if (interactorOne || interactorTwo) {
+      const worldY = this.el.object3D.matrixWorld.elements[13];
+      const scaledRadius = this.el.object3D.scale.y * this.boundingSphere.radius;
+      this.sweepParams[0] = worldY - scaledRadius;
+      this.sweepParams[1] = worldY + scaledRadius;
+    }
+
+    for (const uniform of this.uniforms.values()) {
+      uniform.hubs_EnableSweepingEffect.value = this.data.enableSweepingEffect;
+      uniform.hubs_SweepParams.value = this.sweepParams;
+
+      uniform.hubs_HighlightInteractorOne.value = !!interactorOne;
+      uniform.hubs_InteractorOnePos.value[0] = interactorOneTransform[12];
+      uniform.hubs_InteractorOnePos.value[1] = interactorOneTransform[13];
+      uniform.hubs_InteractorOnePos.value[2] = interactorOneTransform[14];
+
+      uniform.hubs_HighlightInteractorTwo.value = !!interactorTwo;
+      uniform.hubs_InteractorTwoPos.value[0] = interactorTwoTransform[12];
+      uniform.hubs_InteractorTwoPos.value[1] = interactorTwoTransform[13];
+      uniform.hubs_InteractorTwoPos.value[2] = interactorTwoTransform[14];
+
+      if (interactorOne || interactorTwo) {
+        uniform.hubs_Time.value = time;
+      }
+    }
+  }
+});
diff --git a/src/components/media-loader.js b/src/components/media-loader.js
index 5dcd47beef202851c03b2cd28d8089875873914f..d3f595cf2250aab50b9b7d9d7d4a134fb613e743 100644
--- a/src/components/media-loader.js
+++ b/src/components/media-loader.js
@@ -1,9 +1,10 @@
 import { getBox, getScaleCoefficient } from "../utils/auto-box-collider";
-import { guessContentType, proxiedUrlFor, resolveUrl } from "../utils/media-utils";
+import { guessContentType, proxiedUrlFor, resolveUrl, injectCustomShaderChunks } from "../utils/media-utils";
 import { addAnimationComponents } from "../utils/animation";
 
 import "three/examples/js/loaders/GLTFLoader";
 import loadingObjectSrc from "../assets/LoadingObject_Atom.glb";
+
 const gltfLoader = new THREE.GLTFLoader();
 let loadingObject;
 gltfLoader.load(loadingObjectSrc, gltf => {
@@ -18,6 +19,8 @@ const fetchMaxContentIndex = url => {
   return fetch(url).then(r => parseInt(r.headers.get("x-max-content-index")));
 };
 
+const boundingBox = new THREE.Box3();
+
 AFRAME.registerComponent("media-loader", {
   schema: {
     src: { type: "string" },
@@ -30,6 +33,7 @@ AFRAME.registerComponent("media-loader", {
     this.onError = this.onError.bind(this);
     this.showLoader = this.showLoader.bind(this);
     this.clearLoadingTimeout = this.clearLoadingTimeout.bind(this);
+    this.onMediaLoaded = this.onMediaLoaded.bind(this);
     this.shapeAdded = false;
     this.hasBakedShapes = false;
   },
@@ -100,6 +104,20 @@ AFRAME.registerComponent("media-loader", {
     delete this.showLoaderTimeout;
   },
 
+  setupHoverableVisuals() {
+    const hoverableVisuals = this.el.components["hoverable-visuals"];
+    if (hoverableVisuals) {
+      hoverableVisuals.uniforms = injectCustomShaderChunks(this.el.object3D);
+      boundingBox.setFromObject(this.el.object3DMap.mesh);
+      boundingBox.getBoundingSphere(hoverableVisuals.boundingSphere);
+    }
+  },
+
+  onMediaLoaded() {
+    this.clearLoadingTimeout();
+    this.setupHoverableVisuals();
+  },
+
   async update(oldData) {
     try {
       const { src } = this.data;
@@ -135,13 +153,13 @@ AFRAME.registerComponent("media-loader", {
       if (contentType.startsWith("video/") || contentType.startsWith("audio/")) {
         this.el.removeAttribute("gltf-model-plus");
         this.el.removeAttribute("media-image");
-        this.el.addEventListener("video-loaded", this.clearLoadingTimeout, { once: true });
+        this.el.addEventListener("video-loaded", this.onMediaLoaded, { once: true });
         this.el.setAttribute("media-video", { src: accessibleUrl });
         this.el.setAttribute("position-at-box-shape-border", { dirs: ["forward", "back"] });
       } else if (contentType.startsWith("image/")) {
         this.el.removeAttribute("gltf-model-plus");
         this.el.removeAttribute("media-video");
-        this.el.addEventListener("image-loaded", this.clearLoadingTimeout, { once: true });
+        this.el.addEventListener("image-loaded", this.onMediaLoaded, { once: true });
         this.el.removeAttribute("media-pager");
         this.el.setAttribute("media-image", { src: accessibleUrl, contentType });
         this.el.setAttribute("position-at-box-shape-border", { dirs: ["forward", "back"] });
@@ -152,7 +170,7 @@ AFRAME.registerComponent("media-loader", {
         // 1. we pass the canonical URL to the pager so it can easily make subresource URLs
         // 2. we don't remove the media-image component -- media-pager uses that internally
         this.el.setAttribute("media-pager", { src: canonicalUrl });
-        this.el.addEventListener("preview-loaded", this.clearLoadingTimeout, { once: true });
+        this.el.addEventListener("preview-loaded", this.onMediaLoaded, { once: true });
         this.el.setAttribute("position-at-box-shape-border", { dirs: ["forward", "back"] });
       } else if (
         contentType.includes("application/octet-stream") ||
@@ -168,6 +186,7 @@ AFRAME.registerComponent("media-loader", {
             this.clearLoadingTimeout();
             this.hasBakedShapes = !!(this.el.body && this.el.body.shapes.length > (this.shapeAdded ? 1 : 0));
             this.setShapeAndScale(this.data.resize);
+            this.setupHoverableVisuals();
             addAnimationComponents(this.el);
           },
           { once: true }
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/player-info.js b/src/components/player-info.js
index a7e0812f8810c56544f14f2ed0c93602050f2a13..7c5cbd89e1f97cd0a61cda24187d24a01858284e 100644
--- a/src/components/player-info.js
+++ b/src/components/player-info.js
@@ -1,3 +1,5 @@
+import { injectCustomShaderChunks } from "../utils/media-utils";
+
 /**
  * Sets player info state, including avatar choice and display name.
  * @namespace avatar
@@ -32,5 +34,10 @@ AFRAME.registerComponent("player-info", {
     if (this.data.avatarSrc && modelEl) {
       modelEl.setAttribute("gltf-model-plus", "src", this.data.avatarSrc);
     }
+
+    const uniforms = injectCustomShaderChunks(this.el.object3D);
+    this.el.querySelectorAll("[hover-visuals]").forEach(el => {
+      el.components["hover-visuals"].uniforms = uniforms;
+    });
   }
 });
diff --git a/src/components/super-networked-interactable.js b/src/components/super-networked-interactable.js
index 4c96f2452f9945c0b9bf29be259c487b8d665c3d..1fbbaa37c6f0786b91f9d8cc109df19e0716829d 100644
--- a/src/components/super-networked-interactable.js
+++ b/src/components/super-networked-interactable.js
@@ -98,6 +98,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..c5e557401cf5ea9e57e471e7b83a1b638f8cb4b7 100644
--- a/src/components/super-spawner.js
+++ b/src/components/super-spawner.js
@@ -85,6 +85,8 @@ AFRAME.registerComponent("super-spawner", {
     this.onSpawnEvent = this.onSpawnEvent.bind(this);
 
     this.sceneEl = document.querySelector("a-scene");
+
+    this.el.setAttribute("hoverable-visuals", { cursorController: "#cursor-controller", enableSweepingEffect: false });
   },
 
   play() {
@@ -116,8 +118,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 bc6e329c4c2effbf7672c025f747b8d7b570c0d8..62d8b2c5eeb0838b8a18b4dd699708114ce317c0 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>
@@ -144,6 +145,7 @@
                     grabbable
                     stretchable="useWorldPosition: true; usePhysics: never"
                     hoverable
+                    hoverable-visuals="cursorController: #cursor-controller"
                     auto-scale-cannon-physics-body
                     sticky-object="autoLockOnRelease: true; autoLockOnLoad: true;"
                     position-at-box-shape-border="target:.delete-button"
@@ -429,12 +431,12 @@
                     </a-entity>
                 </template>
 
-                <template data-name="Head">
-                    <a-entity id="player-head" visible="false" bone-visibility></a-entity>
+                <template data-name="LeftHand">
+                    <a-entity bone-visibility hover-visuals="hand: left; controller: #player-left-controller"></a-entity>
                 </template>
 
-                <template data-name="LeftHand">
-                    <a-entity bone-visibility></a-entity>
+                <template data-name="RightHand">
+                    <a-entity bone-visibility hover-visuals="hand: right; controller: #player-right-controller"></a-entity>
                 </template>
 
                 <template data-name="RightHand">
diff --git a/src/hub.js b/src/hub.js
index a054c1bf211022fe470dc2f8944928a89f0e5e4e..5483dfa13afcf125ece5a026b17ac541a07852de 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -33,6 +33,8 @@ import "./components/virtual-gamepad-controls";
 import "./components/ik-controller";
 import "./components/hand-controls2";
 import "./components/character-controller";
+import "./components/hoverable-visuals";
+import "./components/hover-visuals";
 import "./components/haptic-feedback";
 import "./components/networked-video-player";
 import "./components/offset-relative-to";
@@ -70,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";
@@ -132,7 +135,6 @@ if (!isBotMode && !isTelemetryDisabled) {
 disableiOSZoom();
 
 const concurrentLoadDetector = new ConcurrentLoadDetector();
-
 concurrentLoadDetector.start();
 
 store.init();
@@ -286,6 +288,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 +318,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 }));
 
@@ -344,10 +376,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 });
 
@@ -357,13 +385,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 => {
@@ -393,7 +435,7 @@ document.addEventListener("DOMContentLoaded", async () => {
         presenceLogEntries.splice(presenceLogEntries.indexOf(entry), 1);
         remountUI({ presenceLogEntries });
       }, 5000);
-    }, entryManager.hasEntered() ? 10000 : 30000); // Fade out things faster once entered.
+    }, 20000);
   };
 
   let isInitialSync = true;
@@ -464,12 +506,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/materials/MobileStandardMaterial.js b/src/materials/MobileStandardMaterial.js
index aa9e10de101be73807c0259e1eb75f222e4d974d..10657605f2818c8ddad3eb723fe1d7dc7c4babac 100644
--- a/src/materials/MobileStandardMaterial.js
+++ b/src/materials/MobileStandardMaterial.js
@@ -74,6 +74,8 @@ void main() {
 `;
 
 export default class MobileStandardMaterial extends THREE.ShaderMaterial {
+  type = "MobileStandardMaterial";
+  isMobileStandardMaterial = true;
   static fromStandardMaterial(material) {
     const parameters = {
       vertexShader: VERTEX_SHADER,
@@ -107,4 +109,7 @@ export default class MobileStandardMaterial extends THREE.ShaderMaterial {
 
     return mobileMaterial;
   }
+  clone() {
+    return MobileStandardMaterial.fromStandardMaterial(this);
+  }
 }
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/create-object-dialog.js b/src/react-components/create-object-dialog.js
index 5ccfe2ce692a234fff6e76d8657516c1ac1d8816..6fa624612dcf4eadb658fa7f74f069983a9aeb53 100644
--- a/src/react-components/create-object-dialog.js
+++ b/src/react-components/create-object-dialog.js
@@ -15,12 +15,33 @@ const attributionHostnames = {
 
 const DEFAULT_OBJECT_URL = "https://asset-bundles-prod.reticulum.io/interactables/Ducky/DuckyMesh-438ff8e022.gltf";
 const isMobile = AFRAME.utils.device.isMobile();
-const instructions = "Paste a URL or upload a file.";
-const desktopTips = "Tip: You can paste links directly into Hubs with Ctrl+V";
-const mobileInstructions = <div>{instructions}</div>;
+const instructions = "Paste a URL to an image, video, model, or upload a file.";
+const desktopTips = "Tip: You can paste media directly into Hubs with Ctrl+V";
+const references = (
+  <span>
+    For models, try{" "}
+    <a href="https://sketchfab.com/search?features=downloadable&type=models" target="_blank" rel="noopener noreferrer">
+      Sketchfab
+    </a>,{" "}
+    <a href="http://poly.google.com/" target="_blank" rel="noopener noreferrer">
+      Google Poly
+    </a>, or our{" "}
+    <a href="https://sketchfab.com/mozillareality" target="_blank" rel="noopener noreferrer">
+      collection
+    </a>.
+  </span>
+);
+
+const mobileInstructions = (
+  <div>
+    <p>{instructions}</p>
+    <p>{references}</p>
+  </div>
+);
 const desktopInstructions = (
   <div>
     <p>{instructions}</p>
+    <p>{references}</p>
     <p>{desktopTips}</p>
   </div>
 );
diff --git a/src/react-components/create-room-dialog.js b/src/react-components/create-room-dialog.js
index 756cc9bf43736059999a1a66b2fdfe5d856a4e96..a42a2f36be7fea6bdfef0d74c6120ce59e24df01 100644
--- a/src/react-components/create-room-dialog.js
+++ b/src/react-components/create-room-dialog.js
@@ -4,8 +4,9 @@ import DialogContainer from "./dialog-container.js";
 
 const HUB_NAME_PATTERN = "^[A-Za-z0-9-'\":!@#$%^&*(),.?~ ]{4,64}$";
 
-export default class CreateObjectDialog extends Component {
+export default class CreateRoomDialog extends Component {
   static propTypes = {
+    includeScenePrompt: PropTypes.bool,
     onCustomScene: PropTypes.func,
     onClose: PropTypes.func
   };
@@ -25,7 +26,12 @@ export default class CreateObjectDialog extends Component {
     return (
       <DialogContainer title="Create a Room" onClose={onClose} {...other}>
         <div>
-          <div>Choose a name and GLTF URL for your room&apos;s scene:</div>
+          {this.props.includeScenePrompt ? (
+            <div>Choose a name and GLTF URL for your room&apos;s scene:</div>
+          ) : (
+            <div>Choose a name for your room:</div>
+          )}
+
           <form onSubmit={onCustomSceneClicked}>
             <div className="custom-scene-form">
               <input
@@ -38,16 +44,18 @@ export default class CreateObjectDialog extends Component {
                 onChange={e => this.setState({ customRoomName: e.target.value })}
                 required
               />
-              <input
-                type="url"
-                placeholder="URL to Scene GLTF or GLB (Optional)"
-                className="custom-scene-form__link_field"
-                value={this.state.customSceneUrl}
-                onChange={e => this.setState({ customSceneUrl: e.target.value })}
-              />
+              {this.props.includeScenePrompt && (
+                <input
+                  type="url"
+                  placeholder="URL to Scene GLTF or GLB (Optional)"
+                  className="custom-scene-form__link_field"
+                  value={this.state.customSceneUrl}
+                  onChange={e => this.setState({ customSceneUrl: e.target.value })}
+                />
+              )}
               <div className="custom-scene-form__buttons">
                 <button className="custom-scene-form__action-button">
-                  <span>create</span>
+                  <span>Create Room</span>
                 </button>
               </div>
             </div>
diff --git a/src/react-components/hub-create-panel.js b/src/react-components/hub-create-panel.js
index 8a9ca35e28edb3d99c3685e483e3f031c755c557..640f00d5d790516e0d8cd2d71f9bb71f1a13e89c 100644
--- a/src/react-components/hub-create-panel.js
+++ b/src/react-components/hub-create-panel.js
@@ -235,6 +235,7 @@ class HubCreatePanel extends Component {
         </form>
         {this.state.showCustomSceneDialog && (
           <CreateRoomDialog
+            includeScenePrompt={true}
             onClose={() => this.setState({ showCustomSceneDialog: false })}
             onCustomScene={(name, url) => {
               this.setState({ showCustomSceneDialog: false, name: name, customSceneUrl: url }, () => this.createHub());
diff --git a/src/react-components/invite-dialog.js b/src/react-components/invite-dialog.js
index 414194081d417d6185cd1041bdb1ed1a7be355ab..62fe7975b1b23fdfa8219407d2ba69652bfb84c7 100644
--- a/src/react-components/invite-dialog.js
+++ b/src/react-components/invite-dialog.js
@@ -3,6 +3,7 @@ import PropTypes from "prop-types";
 import copy from "copy-to-clipboard";
 import classNames from "classnames";
 import { FormattedMessage } from "react-intl";
+import { share } from "../utils/share";
 
 import styles from "../assets/stylesheets/invite-dialog.scss";
 
@@ -25,11 +26,11 @@ export default class InviteDialog extends Component {
     shareButtonActive: false
   };
 
-  shareClicked = link => {
+  shareClicked = url => {
     this.setState({ shareButtonActive: true });
-    setTimeout(() => this.setState({ shareButtonActive: false }), 5000);
-
-    navigator.share({ title: "Join me now in #hubs!", url: link });
+    share({ url, title: "Join me now in #hubs!" }).then(() => {
+      this.setState({ shareButtonActive: false });
+    });
   };
 
   copyClicked = link => {
@@ -46,11 +47,6 @@ export default class InviteDialog extends Component {
     const shortLinkText = `hub.link/${this.props.hubId}`;
     const shortLink = "https://" + shortLinkText;
 
-    const tweetText = `Join me now in #hubs!`;
-    const tweetLink = `https://twitter.com/share?url=${encodeURIComponent(shortLink)}&text=${encodeURIComponent(
-      tweetText
-    )}`;
-
     return (
       <div className={styles.dialog}>
         <div className={styles.attachPoint} />
@@ -89,9 +85,9 @@ export default class InviteDialog extends Component {
             )}
           {this.props.allowShare &&
             !navigator.share && (
-              <a href={tweetLink} className={styles.linkButton} target="_blank" rel="noopener noreferrer">
+              <button className={styles.linkButton} onClick={this.shareClicked.bind(this, shortLink)}>
                 <FormattedMessage id="invite.tweet" />
-              </a>
+              </button>
             )}
         </div>
       </div>
diff --git a/src/react-components/photo-message.js b/src/react-components/photo-message.js
new file mode 100644
index 0000000000000000000000000000000000000000..ac4cfbceb03b7b7b339364b7b1293a2e66effd7d
--- /dev/null
+++ b/src/react-components/photo-message.js
@@ -0,0 +1,44 @@
+import React from "react";
+import PropTypes from "prop-types";
+
+import styles from "../assets/stylesheets/presence-log.scss";
+import classNames from "classnames";
+
+import { share } from "../utils/share";
+import { getLandingPageForPhoto } from "../utils/phoenix-utils";
+
+export default function PhotoMessage({ name, body: { src: url }, className, maySpawn, hubId }) {
+  const landingPageUrl = getLandingPageForPhoto(url);
+  const onShareClicked = share.bind(null, {
+    url: landingPageUrl,
+    title: `Taken in #hubs, join me at https://hub.link/${hubId}`
+  });
+  return (
+    <div className={className}>
+      {maySpawn && <button className={classNames(styles.iconButton, styles.share)} onClick={onShareClicked} />}
+      <div className={styles.mediaBody}>
+        <span>
+          <b>{name}</b>
+        </span>
+        <span>
+          {"took a "}
+          <b>
+            <a href={landingPageUrl} target="_blank" rel="noopener noreferrer">
+              photo
+            </a>
+          </b>.
+        </span>
+      </div>
+      <a href={landingPageUrl} target="_blank" rel="noopener noreferrer">
+        <img src={url} />
+      </a>
+    </div>
+  );
+}
+PhotoMessage.propTypes = {
+  name: PropTypes.string,
+  maySpawn: PropTypes.bool,
+  body: PropTypes.object,
+  className: PropTypes.string,
+  hubId: PropTypes.string
+};
diff --git a/src/react-components/presence-log.js b/src/react-components/presence-log.js
index d46550957f1eb7918b9310263ada1a2f64537dfb..4426ac46e1e86d22e82b01de4f59152bb8b2184f 100644
--- a/src/react-components/presence-log.js
+++ b/src/react-components/presence-log.js
@@ -2,14 +2,16 @@ 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";
+import PhotoMessage from "./photo-message";
+
 export default class PresenceLog extends Component {
   static propTypes = {
     entries: PropTypes.array,
-    inRoom: PropTypes.bool
+    inRoom: PropTypes.bool,
+    hubId: PropTypes.string
   };
 
   constructor(props) {
@@ -19,6 +21,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,47 +31,41 @@ 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;
         return (
-          <div key={e.key} className={classNames(entryClasses, styles.media)}>
-            <a href={src} target="_blank" rel="noopener noreferrer">
-              <img src={src} />
-            </a>
-            <div className={styles.mediaBody}>
-              <span>
-                <b>{e.name}</b>:
-              </span>
-              <span>
-                {"took a "}
-                <a href={src} target="_blank" rel="noopener noreferrer">
-                  photo
-                </a>
-              </span>
-            </div>
-          </div>
+          <PhotoMessage
+            key={e.key}
+            name={e.name}
+            className={classNames(entryClasses, styles.media)}
+            body={e.body}
+            maySpawn={e.maySpawn}
+            hubId={this.props.hubId}
+          />
         );
       }
     }
diff --git a/src/react-components/scene-ui.js b/src/react-components/scene-ui.js
index 2db97a04c5a0d56824f8b4677474b6ddb97540d9..168c6d517c68ee4f3520c2ea7fb770c45417eb1d 100644
--- a/src/react-components/scene-ui.js
+++ b/src/react-components/scene-ui.js
@@ -8,6 +8,9 @@ import hubLogo from "../assets/images/hub-preview-white.png";
 import spokeLogo from "../assets/images/spoke_logo_black.png";
 import { getReticulumFetchUrl } from "../utils/phoenix-utils";
 import { generateHubName } from "../utils/name-generation";
+import CreateRoomDialog from "./create-room-dialog.js";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faEllipsisH } from "@fortawesome/free-solid-svg-icons/faEllipsisH";
 
 import { lang, messages } from "../utils/i18n";
 
@@ -20,12 +23,13 @@ class SceneUI extends Component {
     sceneId: PropTypes.string,
     sceneName: PropTypes.string,
     sceneDescription: PropTypes.string,
-    sceneAttribution: PropTypes.string,
+    sceneAttributions: PropTypes.object,
     sceneScreenshotURL: PropTypes.string
   };
 
   state = {
-    showScreenshot: false
+    showScreenshot: false,
+    showCustomRoomDialog: false
   };
 
   constructor(props) {
@@ -48,7 +52,7 @@ class SceneUI extends Component {
   }
 
   createRoom = async () => {
-    const payload = { hub: { name: generateHubName(), scene_id: this.props.sceneId } };
+    const payload = { hub: { name: this.state.customRoomName || generateHubName(), scene_id: this.props.sceneId } };
     const createUrl = getReticulumFetchUrl("/api/v1/hubs");
 
     const res = await fetch(createUrl, {
@@ -73,6 +77,47 @@ class SceneUI extends Component {
       tweetText
     )}`;
 
+    let attributions;
+
+    const toAttributionSpan = a => {
+      if (a.url) {
+        const source = a.url.indexOf("sketchfab.com")
+          ? "on Sketchfab"
+          : a.url.indexOf("poly.google.com")
+            ? "on Google Poly"
+            : "";
+
+        return (
+          <span key={a.url}>
+            <a href={a.url} target="_blank" rel="noopener noreferrer">
+              {a.name} by {a.author} {source}
+            </a>&nbsp;
+          </span>
+        );
+      } else {
+        return (
+          <span key={`${a.name} ${a.author}`}>
+            {a.name} by {a.author}&nbsp;
+          </span>
+        );
+      }
+    };
+
+    if (this.props.sceneAttributions) {
+      if (!this.props.sceneAttributions.extras) {
+        attributions = (
+          <span>
+            <span>by {this.props.sceneAttributions.creator}</span>&nbsp;
+            <br />
+            {this.props.sceneAttributions.content && this.props.sceneAttributions.content.map(toAttributionSpan)}
+          </span>
+        );
+      } else {
+        // Legacy
+        attributions = <span>{this.props.sceneAttributions.extras}</span>;
+      }
+    }
+
     return (
       <IntlProvider locale={lang} messages={messages}>
         <div className={styles.ui}>
@@ -93,9 +138,14 @@ class SceneUI extends Component {
               <div className={styles.logoTagline}>
                 <FormattedMessage id="scene.logo_tagline" />
               </div>
-              <button onClick={this.createRoom}>
-                <FormattedMessage id="scene.create_button" />
-              </button>
+              <div className={styles.createButtons}>
+                <button className={styles.createButton} onClick={this.createRoom}>
+                  <FormattedMessage id="scene.create_button" />
+                </button>
+                <button className={styles.optionsButton} onClick={() => this.setState({ showCustomRoomDialog: true })}>
+                  <FontAwesomeIcon icon={faEllipsisH} />
+                </button>
+              </div>
               <a href={tweetLink} rel="noopener noreferrer" target="_blank" className={styles.tweetButton}>
                 <img src="../assets/images/twitter.svg" />
                 <div>
@@ -106,7 +156,7 @@ class SceneUI extends Component {
           </div>
           <div className={styles.info}>
             <div className={styles.name}>{this.props.sceneName}</div>
-            <div className={styles.attribution}>{this.props.sceneAttribution}</div>
+            <div className={styles.attribution}>{attributions}</div>
           </div>
           <div className={styles.spoke}>
             <div className={styles.madeWith}>made with</div>
@@ -114,6 +164,15 @@ class SceneUI extends Component {
               <img src={spokeLogo} />
             </a>
           </div>
+          {this.state.showCustomRoomDialog && (
+            <CreateRoomDialog
+              includeScenePrompt={false}
+              onClose={() => this.setState({ showCustomRoomDialog: false })}
+              onCustomScene={name => {
+                this.setState({ showCustomRoomDialog: false, customRoomName: name }, () => this.createRoom());
+              }}
+            />
+          )}
         </div>
       </IntlProvider>
     );
diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js
index a24030d15c325e1aea4fe71673777da617bfe5b9..012afee70e1e2aa4a590a49f800512a5944a7cc4 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;
 
@@ -440,7 +455,7 @@ class UIRoot extends Component {
     if (!this.props.forcedVREntryType || !this.props.forcedVREntryType.endsWith("_now")) {
       this.goToEntryStep(ENTRY_STEPS.audio_setup);
     } else {
-      setTimeout(this.onAudioReadyButton, 3000); // Need to wait otherwise input doesn't work :/
+      this.onAudioReadyButton();
     }
   };
 
@@ -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}>
@@ -980,23 +1027,41 @@ class UIRoot extends Component {
 
           {(!entryFinished || this.isWaitingForAutoExit()) && (
             <div className={styles.uiDialog}>
-              <PresenceLog entries={this.props.presenceLogEntries || []} />
+              <PresenceLog entries={this.props.presenceLogEntries || []} hubId={this.props.hubId} />
               <div className={dialogBoxContentsClassNames}>{dialogContents}</div>
             </div>
           )}
 
-          {entryFinished && <PresenceLog inRoom={true} entries={this.props.presenceLogEntries || []} />}
+          {entryFinished && (
+            <PresenceLog inRoom={true} entries={this.props.presenceLogEntries || []} hubId={this.props.hubId} />
+          )}
           {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 +1069,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 6974195ca86bd67bd6f60d2c75f67e9593d7f744..461f42107a6c25daa5c740a6dd2ce25a3b9b09c1 100644
--- a/src/scene-entry-manager.js
+++ b/src/scene-entry-manager.js
@@ -218,7 +218,7 @@ export default class SceneEntryManager {
     });
 
     document.addEventListener("paste", e => {
-      if (e.target.nodeName === "INPUT" && document.activeElement === e.target) return;
+      if (e.target.matches("input, textarea") && document.activeElement === e.target) return;
 
       const url = e.clipboardData.getData("text");
       const files = e.clipboardData.files && e.clipboardData.files;
@@ -259,7 +259,6 @@ export default class SceneEntryManager {
     });
 
     this.scene.addEventListener("photo_taken", e => {
-      console.log(e);
       this.hubChannel.sendMessage({ src: e.detail }, "spawn");
     });
   };
diff --git a/src/scene.js b/src/scene.js
index 6f2f0732d0da231b0bebef56b8ce526326aa5ea4..4172e624771d7fbd3ca5ce217052023ed113c930 100644
--- a/src/scene.js
+++ b/src/scene.js
@@ -102,7 +102,7 @@ const onReady = async () => {
   remountUI({
     sceneName: sceneInfo.name,
     sceneDescription: sceneInfo.description,
-    sceneAttribution: sceneInfo.attribution,
+    sceneAttributions: sceneInfo.attributions,
     sceneScreenshotURL: sceneInfo.screenshot_url
   });
 };
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 1f067d4447effc86541b93d26fb32ffaba5e471b..3179ade73b65105bd2410e94505bf6b79653aecb 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") },
@@ -87,7 +109,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" },
@@ -108,6 +130,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")
@@ -116,6 +147,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 d386016c27497b4c3faeae1fcc3d413f3a0476fd..1d557129c05e1d7ec718dbadfb08c7c22543b84c 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";
@@ -224,6 +227,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"),
@@ -231,18 +244,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 0e20233c7ff606ee29914e0d5293173404302491..39954438401c372a7070d876f8b04991b198817a 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 = [
@@ -314,6 +317,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"),
@@ -321,18 +334,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 d9f74a9ac6e437ead10d2e57237275c2de44aa44..4d5b0fd9dbb242be53f9d8fb938353add5e353de 100644
--- a/src/systems/userinput/paths.js
+++ b/src/systems/userinput/paths.js
@@ -13,6 +13,7 @@ paths.actions.startGazeTeleport = "/actions/startTeleport";
 paths.actions.stopGazeTeleport = "/actions/stopTeleport";
 paths.actions.spawnPen = "/actions/spawnPen";
 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 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/src/utils/media-highlight-frag.glsl b/src/utils/media-highlight-frag.glsl
new file mode 100644
index 0000000000000000000000000000000000000000..82214980d57847926b5a1edbb8c9deef01b4721e
--- /dev/null
+++ b/src/utils/media-highlight-frag.glsl
@@ -0,0 +1,33 @@
+if (hubs_HighlightInteractorOne || hubs_HighlightInteractorTwo) {
+  float ratio = 0.0;
+
+  if (hubs_EnableSweepingEffect) {
+    float size = hubs_SweepParams.t - hubs_SweepParams.s;
+    float line = mod(hubs_Time / 3000.0 * size, size * 2.0) + hubs_SweepParams.s - size / 2.0;
+
+    if (hubs_WorldPosition.y < line) {
+      // Highlight with a sweeping gradient.
+      ratio = max(0.0, 1.0 - (line - hubs_WorldPosition.y) / size * 3.0);
+    }
+  }
+
+  // Highlight with a gradient falling off with distance.
+  float pulse = 9.0 + 3.0 * (sin(hubs_Time / 1000.0) + 1.0);
+
+  if (hubs_HighlightInteractorOne) {
+    float dist1 = distance(hubs_WorldPosition, hubs_InteractorOnePos);
+    ratio += -min(1.0, pow(dist1 * pulse, 3.0)) + 1.0;
+  } 
+
+  if (hubs_HighlightInteractorTwo) {
+    float dist2 = distance(hubs_WorldPosition, hubs_InteractorTwoPos);
+    ratio += -min(1.0, pow(dist2 * pulse, 3.0)) + 1.0;
+  }
+
+  ratio = min(1.0, ratio);
+
+  // Gamma corrected highlight color
+  vec3 highlightColor = vec3(0.184, 0.499, 0.933);
+
+  gl_FragColor.rgb = (gl_FragColor.rgb * (1.0 - ratio)) + (highlightColor * ratio);
+}
diff --git a/src/utils/media-utils.js b/src/utils/media-utils.js
index d72a453fa31707371dced4fb5a0151ee5577634e..4750ba603974604b892f68019f4c5c295a616532 100644
--- a/src/utils/media-utils.js
+++ b/src/utils/media-utils.js
@@ -1,5 +1,7 @@
 import { objectTypeForOriginAndContentType } from "../object-types";
 import { getReticulumFetchUrl } from "./phoenix-utils";
+import mediaHighlightFrag from "./media-highlight-frag.glsl";
+
 const mediaAPIEndpoint = getReticulumFetchUrl("/api/v1/media");
 
 const commonKnownContentTypes = {
@@ -136,3 +138,64 @@ export const addMedia = (src, template, contentOrigin, resolve = false, resize =
 
   return { entity, orientation };
 };
+
+export function injectCustomShaderChunks(obj) {
+  const vertexRegex = /\bskinning_vertex\b/;
+  const fragRegex = /\bgl_FragColor\b/;
+  const validMaterials = ["MeshStandardMaterial", "MeshBasicMaterial", "MobileStandardMaterial"];
+
+  const shaderUniforms = new Map();
+
+  obj.traverse(object => {
+    if (!object.material || !validMaterials.includes(object.material.type)) {
+      return;
+    }
+    object.material = object.material.clone();
+    object.material.onBeforeCompile = shader => {
+      if (!vertexRegex.test(shader.vertexShader)) return;
+
+      shader.uniforms.hubs_EnableSweepingEffect = { value: false };
+      shader.uniforms.hubs_SweepParams = { value: [0, 0] };
+      shader.uniforms.hubs_InteractorOnePos = { value: [0, 0, 0] };
+      shader.uniforms.hubs_InteractorTwoPos = { value: [0, 0, 0] };
+      shader.uniforms.hubs_HighlightInteractorOne = { value: false };
+      shader.uniforms.hubs_HighlightInteractorTwo = { value: false };
+      shader.uniforms.hubs_Time = { value: 0 };
+
+      const vchunk = `
+        if (hubs_HighlightInteractorOne || hubs_HighlightInteractorTwo) {
+          vec4 wt = modelMatrix * vec4(transformed, 1);
+
+          // Used in the fragment shader below.
+          hubs_WorldPosition = wt.xyz;
+        }
+      `;
+
+      const vlines = shader.vertexShader.split("\n");
+      const vindex = vlines.findIndex(line => vertexRegex.test(line));
+      vlines.splice(vindex + 1, 0, vchunk);
+      vlines.unshift("varying vec3 hubs_WorldPosition;");
+      vlines.unshift("uniform bool hubs_HighlightInteractorOne;");
+      vlines.unshift("uniform bool hubs_HighlightInteractorTwo;");
+      shader.vertexShader = vlines.join("\n");
+
+      const flines = shader.fragmentShader.split("\n");
+      const findex = flines.findIndex(line => fragRegex.test(line));
+      flines.splice(findex + 1, 0, mediaHighlightFrag);
+      flines.unshift("varying vec3 hubs_WorldPosition;");
+      flines.unshift("uniform bool hubs_EnableSweepingEffect;");
+      flines.unshift("uniform vec2 hubs_SweepParams;");
+      flines.unshift("uniform bool hubs_HighlightInteractorOne;");
+      flines.unshift("uniform vec3 hubs_InteractorOnePos;");
+      flines.unshift("uniform bool hubs_HighlightInteractorTwo;");
+      flines.unshift("uniform vec3 hubs_InteractorTwoPos;");
+      flines.unshift("uniform float hubs_Time;");
+      shader.fragmentShader = flines.join("\n");
+
+      shaderUniforms.set(object.material.uuid, shader.uniforms);
+    };
+    object.material.needsUpdate = true;
+  });
+
+  return shaderUniforms;
+}
diff --git a/src/utils/phoenix-utils.js b/src/utils/phoenix-utils.js
index 19619599af4803c4d537b764888fb4de6b21491f..5d5307db881d1f8be8415908d04478d83ebb43ad 100644
--- a/src/utils/phoenix-utils.js
+++ b/src/utils/phoenix-utils.js
@@ -43,3 +43,8 @@ export function getReticulumFetchUrl(path) {
     return path;
   }
 }
+
+export function getLandingPageForPhoto(photoUrl) {
+  const parsedUrl = new URL(photoUrl);
+  return getReticulumFetchUrl(parsedUrl.pathname.replace(".png", ".html") + parsedUrl.search);
+}
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/src/utils/share.js b/src/utils/share.js
new file mode 100644
index 0000000000000000000000000000000000000000..e563431ff06fd7a77ba5a8b1feedff85ba211dfb
--- /dev/null
+++ b/src/utils/share.js
@@ -0,0 +1,20 @@
+/**
+ * Wraps navigator.share with a fallback to twitter for unsupported browsers
+ */
+export function share(opts) {
+  if (navigator.share) {
+    return navigator.share(opts);
+  } else {
+    const { title, url } = opts;
+    const width = 550;
+    const height = 420;
+    const left = (screen.width - width) / 2;
+    const top = (screen.height - height) / 2;
+    const params = `scrollbars=no,menubar=no,toolbar=no,status=no,width=${width},height=${height},top=${top},left=${left}`;
+    const tweetLink = `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(
+      title
+    )}`;
+    window.open(tweetLink, "_blank", params);
+    return Promise.resolve();
+  }
+}
diff --git a/webpack.config.js b/webpack.config.js
index e6567532eb15a79f243b7e8a5df1b9064e5eeefd..e7b6e4d8e3cdc6cd918fb3c66c1fd9718f58f39e 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -77,6 +77,7 @@ module.exports = (env, argv) => ({
   devServer: {
     https: createHTTPSConfig(),
     host: "0.0.0.0",
+    public: "hubs.local:8080",
     useLocalIp: true,
     allowedHosts: ["hubs.local"],
     before: function(app) {
@@ -153,6 +154,10 @@ module.exports = (env, argv) => ({
             context: path.join(__dirname, "src")
           }
         }
+      },
+      {
+        test: /\.(glsl)$/,
+        use: { loader: "raw-loader" }
       }
     ]
   },
@@ -240,6 +245,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",