diff --git a/.stylelintrc b/.stylelintrc
index 806eeab4265711a76a9ce0eca891ea5d97b7c372..e0ae6ebc3af483a2cae7c97db51908b7680b9cff 100644
--- a/.stylelintrc
+++ b/.stylelintrc
@@ -3,6 +3,7 @@
   "rules": {
     "indentation": 2,
 		"selector-pseudo-class-no-unknown": [true, { "ignorePseudoClasses": ["local"] }],
+		"selector-type-no-unknown": [true, { "ignoreTypes": ["/^a-/"] }],
 		"no-descending-specificity": false
   }
 }
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..b71dbe9e1608560a4162ddb75d111bbda7e427ac 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",
@@ -8984,7 +8986,7 @@
       "dev": true
     },
     "networked-aframe": {
-      "version": "github:mozillareality/networked-aframe#1dd7e0aa62bd119c214fec7e9137d4447f40cba0",
+      "version": "github:mozillareality/networked-aframe#f91ad0132e3622469b2f958a24d3cc7e43afac39",
       "from": "github:mozillareality/networked-aframe#master",
       "requires": {
         "buffered-interpolation": "^0.2.4",
@@ -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 d9e3d59c08ecdbf2adc92df5046aa149331bae1f..40a1584087063e2ddac674079394694a76783491 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,7 @@
     "doc": "node ./scripts/doc/build.js",
     "prettier": "prettier --write '*.js' 'src/**/*.js'",
     "lint:js": "eslint '*.js' 'scripts/**/*.js' 'src/**/*.js'",
-    "lint:html": "htmlhint 'src/**/*.html'",
+    "lint:html": "htmlhint 'src/**/*.html' && node scripts/indent-linter.js 'src/**/*.html'",
     "lint": "npm run lint:js && npm run lint:html",
     "test": "npm run lint && npm run build"
   },
@@ -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 1f0b129f00851cfae504fc347b17adfdde7ffb3f..c4de33cfb9e4e50bfd9132536b6b44f3ed0262f5 100755
--- a/scripts/hab-build-and-push.sh
+++ b/scripts/hab-build-and-push.sh
@@ -22,7 +22,7 @@ pushd "$DIR/.."
 
 rm /usr/bin/env
 ln -s "$(hab pkg path core/coreutils)/bin/env" /usr/bin/env
-hab pkg install -b core/coreutils core/bash core/node core/git core/aws-cli core/python2
+hab pkg install -b core/coreutils core/bash core/node10 core/git core/aws-cli core/python2
 
 npm ci --verbose --no-progress
 npm rebuild node-sass # HACK sometimes node-sass build fails
diff --git a/scripts/indent-linter.js b/scripts/indent-linter.js
new file mode 100644
index 0000000000000000000000000000000000000000..33a23bcbed300d6dc5e066084745f3116ed61ed0
--- /dev/null
+++ b/scripts/indent-linter.js
@@ -0,0 +1,55 @@
+/*
+ * indent-linter <glob> <num-spaces>
+ * Generic, syntax-unaware indentation linter that checks if indentation is even and does not skip indentation levels.
+ */
+
+const fs = require("fs");
+const glob = require("glob");
+
+function lintFile(filename, spaces) {
+  const file = fs.readFileSync(filename, { encoding: "utf8" });
+  const lines = file.split("\n");
+
+  const errors = [];
+  let level = 0;
+
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i];
+    const firstNonSpaceIndex = (line.match(/[^ ]/) || { index: 0 }).index;
+
+    const indentation = firstNonSpaceIndex;
+    const indentationDividesCleanly = indentation % spaces === 0;
+    const indentationIsNoMoreThanOneLevelHigher = (indentation - level) / spaces <= 1;
+
+    if (indentationDividesCleanly && indentationIsNoMoreThanOneLevelHigher) {
+      if (indentation !== 0) {
+        level = indentation;
+      }
+    } else {
+      const expected = level;
+      const delta = indentation - expected;
+      const postfix = delta < 0 ? "fewer" : "extra";
+      errors.push(
+        `  ${i + 1}\tExpected ${expected / spaces} levels of indentation, saw ${Math.abs(delta)} space(s) ${postfix}.`
+      );
+    }
+  }
+
+  if (errors.length) {
+    console.log(filename);
+    console.log(errors.join("\n"));
+    console.log(`  ${errors.length} indentation error(s).\n`);
+  }
+
+  return errors.length;
+}
+
+glob(process.argv[2], (err, files) => {
+  console.log("");
+  const spaces = parseInt(process.argv[3] || "4", 10);
+
+  const errorCount = files.map(file => lintFile(file, spaces)).reduce((a, c) => a + c, 0);
+
+  console.log(`${errorCount} total indentation error(s).\n`);
+  process.exit(errorCount > 0 ? 1 : 0);
+});
diff --git a/src/assets/hud/action_button.9.png b/src/assets/hud/action_button.9.png
new file mode 100644
index 0000000000000000000000000000000000000000..69af3b472d380dbea1e3d1df7ecd87a176638084
Binary files /dev/null and b/src/assets/hud/action_button.9.png differ
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/audio.scss b/src/assets/stylesheets/audio.scss
index 7b784427543ab8e9baab0179930aef73474890f1..6fcd1c0f523df6916465a4b3d96fd5d819505b47 100644
--- a/src/assets/stylesheets/audio.scss
+++ b/src/assets/stylesheets/audio.scss
@@ -50,9 +50,8 @@
       padding: 6px;
       font-weight: bold;
       font-size: 0.9em;
-      padding-left: 15px;
+      padding-left: 25px;
       padding-right: 30px;
-      color: white;
       width: 90%;
       height: 50px;
     }
diff --git a/src/assets/stylesheets/hub.scss b/src/assets/stylesheets/hub.scss
index 8ce382b26542abc5df4cc138c0846035c700f81a..a562a8013f81fabbd400a925209eb24dd5ea6650 100644
--- a/src/assets/stylesheets/hub.scss
+++ b/src/assets/stylesheets/hub.scss
@@ -7,7 +7,14 @@
 @import 'entry';
 @import 'audio';
 @import 'info-dialog';
-@import 'shared';
+
+body.vr-mode {
+  a-scene {
+    .a-canvas {
+      width: 200% !important;
+    }
+  }
+}
 
 .a-enter-vr, .a-orientation-modal {
   display: none;
diff --git a/src/assets/stylesheets/invite-dialog.scss b/src/assets/stylesheets/invite-dialog.scss
index f28210e2492ad663f31aa1f08874c3f89c5ac755..9eb7e4237adfcd367ae5042dcfd9d1f37648f965 100644
--- a/src/assets/stylesheets/invite-dialog.scss
+++ b/src/assets/stylesheets/invite-dialog.scss
@@ -13,6 +13,7 @@
   position: relative;
   font-size: 1.0em;
   color: white;
+  pointer-events: auto;
   z-index: 3;
   
   a {
diff --git a/src/assets/stylesheets/presence-log.scss b/src/assets/stylesheets/presence-log.scss
index bed6d5cd50cae9b07dd406009c01917ac48477d8..a1c351a387ac680f6dcd3a58eea22f13fb1433ba 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,67 @@
     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;
+  z-index: 1;
 
   :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/ui-root.scss b/src/assets/stylesheets/ui-root.scss
index 11de7f546b713ca297e99e92e090bb355e55e334..89dc548691ea826e12d08cb916053da36a18610e 100644
--- a/src/assets/stylesheets/ui-root.scss
+++ b/src/assets/stylesheets/ui-root.scss
@@ -14,6 +14,7 @@
     @extend %fa-icon-button;
     pointer-events: auto;
     position: absolute;
+    z-index: 1;
     top: 0px;
     right: 14px;
 
@@ -31,6 +32,12 @@
   }
 }
 
+body.vr-mode {
+  :local(.ui) {
+    pointer-events: auto;
+  }
+}
+
 :local(.ui-dialog) {
   position: absolute;
   top: 0;
@@ -75,7 +82,6 @@
   flex-direction: column;
   align-items: center;
   justify-content: center;
-  pointer-events: auto;
 
   button {
     @extend %action-button;
@@ -118,6 +124,7 @@
 
 :local(.nag-corner-button) {
   position: absolute;
+  z-index: 1;
   bottom: 42px;
   width: 100%;
   display: flex;
@@ -154,6 +161,7 @@
   @extend %unselectable;
   text-align: right;
   position: absolute;
+  z-index: 1;
   top: 0;
   left: 16px;
   margin: 16px 0;
@@ -204,10 +212,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 +234,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) {
@@ -235,6 +247,7 @@
   }
 
   position: absolute;
+  z-index: 2;
   left: 16px;
   bottom: 20px;
   width: 33%;
@@ -246,11 +259,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 +290,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/avatar-selector.html b/src/avatar-selector.html
index 44e92f4e6d19b5d7cc2258e56cfa76c08a20558a..ca3eb4028aa3753b34210ce3c5e7e6570bed2130 100644
--- a/src/avatar-selector.html
+++ b/src/avatar-selector.html
@@ -2,13 +2,13 @@
 <html>
 
 <head>
-  <meta charset="utf-8">
-  <title>avatar selector</title>
-  <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
+    <meta charset="utf-8">
+    <title>avatar selector</title>
+    <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
 </head>
 
 <body>
-  <div id="selector-root"></div>
+    <div id="selector-root"></div>
 </body>
 
 </html>
diff --git a/src/components/block-button.js b/src/components/block-button.js
index 603de3bc6dc649e90165cf65b323b66431836839..8d32ef647c5332a056cb03fd363948bd559c45be 100644
--- a/src/components/block-button.js
+++ b/src/components/block-button.js
@@ -14,11 +14,11 @@ AFRAME.registerComponent("block-button", {
   },
 
   play() {
-    this.el.addEventListener("click", this.onClick);
+    this.el.addEventListener("grab-start", this.onClick);
   },
 
   pause() {
-    this.el.removeEventListener("click", this.onClick);
+    this.el.removeEventListener("grab-start", this.onClick);
   },
 
   block(clientId) {
diff --git a/src/components/controls-shape-offset.js b/src/components/controls-shape-offset.js
index d234b971ac69d3379c740d414e84831d0ba9cf90..49bfaf4ce38ac0107e5c87510bd046f9ff70eb7b 100644
--- a/src/components/controls-shape-offset.js
+++ b/src/components/controls-shape-offset.js
@@ -1,4 +1,4 @@
-import { CONTROLLER_OFFSETS } from "./hand-controls2.js";
+import { LEFT_CONTROLLER_OFFSETS, RIGHT_CONTROLLER_OFFSETS } from "./hand-controls2.js";
 
 /**
  * Sets the offset of the aframe-physics shape on this entity based on the current VR controller type
@@ -7,11 +7,12 @@ import { CONTROLLER_OFFSETS } from "./hand-controls2.js";
  */
 AFRAME.registerComponent("controls-shape-offset", {
   schema: {
-    additionalOffset: { type: "vec3", default: { x: 0, y: -0.03, z: -0.04 } }
+    additionalOffset: { type: "vec3", default: { x: 0, y: 0, z: -0.04 } }
   },
   init: function() {
     this.controller = null;
     this.shapeAdded = false;
+    this.isLeft = this.el.getAttribute("hand-controls2") === "left";
 
     this._handleControllerConnected = this._handleControllerConnected.bind(this);
     this.el.addEventListener("controllerconnected", this._handleControllerConnected);
@@ -24,8 +25,9 @@ AFRAME.registerComponent("controls-shape-offset", {
   tick: function() {
     if (!this.shapeAdded && this.controller) {
       this.shapeAdded = true;
-      const hasOffset = CONTROLLER_OFFSETS.hasOwnProperty(this.controller);
-      const offset = hasOffset ? CONTROLLER_OFFSETS[this.controller] : CONTROLLER_OFFSETS.default;
+      const offsets = this.isLeft ? LEFT_CONTROLLER_OFFSETS : RIGHT_CONTROLLER_OFFSETS;
+      const hasOffset = offsets.hasOwnProperty(this.controller);
+      const offset = hasOffset ? offsets[this.controller] : offsets.default;
       const position = new THREE.Vector3();
       const quaternion = new THREE.Quaternion();
       const scale = new THREE.Vector3();
@@ -34,8 +36,8 @@ AFRAME.registerComponent("controls-shape-offset", {
       quaternion.conjugate();
 
       const shape = {
-        shape: "sphere",
-        radius: "0.02",
+        shape: "box",
+        halfExtents: { x: 0.03, y: 0.04, z: 0.05 },
         orientation: quaternion,
         offset: position
       };
diff --git a/src/components/cursor-controller.js b/src/components/cursor-controller.js
index 1b095fc05948d88e38cbbcebd8b3141046ffb69f..79289c07d7cffdcb3ab2af9cdceb0585bef5aeeb 100644
--- a/src/components/cursor-controller.js
+++ b/src/components/cursor-controller.js
@@ -13,7 +13,7 @@ AFRAME.registerComponent("cursor-controller", {
     cursor: { type: "selector" },
     camera: { type: "selector" },
     far: { default: 3 },
-    near: { default: 0 },
+    near: { default: 0.06 },
     cursorColorHovered: { default: "#2F80ED" },
     cursorColorUnhovered: { default: "#FFFFFF" },
     rayObject: { type: "selector" },
diff --git a/src/components/freeze-controller.js b/src/components/freeze-controller.js
index b7305ea720660644837192a89d85d27c3072397e..db434dec0f6bf42ec7bd5ff1bb0496de24d2ba65 100644
--- a/src/components/freeze-controller.js
+++ b/src/components/freeze-controller.js
@@ -1,3 +1,5 @@
+import { paths } from "../systems/userinput/paths";
+
 /**
  * Toggles freezing of network traffic on the given event.
  * @namespace network
@@ -20,6 +22,18 @@ AFRAME.registerComponent("freeze-controller", {
     this.el.removeEventListener(this.data.toggleEvent, this.onToggle);
   },
 
+  tick: function() {
+    const userinput = AFRAME.scenes[0].systems.userinput;
+    const ensureFrozen = userinput.frame[paths.actions.ensureFrozen];
+    const thaw = userinput.frame[paths.actions.thaw];
+
+    const toggleFreezeDueToInput = (this.el.is("frozen") && thaw) || (!this.el.is("frozen") && ensureFrozen);
+
+    if (toggleFreezeDueToInput) {
+      this.onToggle();
+    }
+  },
+
   onToggle: function() {
     window.APP.store.update({ activity: { hasFoundFreeze: true } });
     NAF.connection.adapter.toggleFreeze();
diff --git a/src/components/gltf-model-plus.js b/src/components/gltf-model-plus.js
index b4d25bb3989aea9619a3ebc2d4f6b3bc8e5de9cf..a83bd35e144b7447c3f6c4d598e29e7242627059 100644
--- a/src/components/gltf-model-plus.js
+++ b/src/components/gltf-model-plus.js
@@ -366,8 +366,24 @@ AFRAME.registerComponent("gltf-model-plus", {
         }
       }
 
+      // The call to setObject3D below recursively clobbers any `el` backreferences to entities
+      // in the entire inflated entity graph to point to `object3DToSet`.
+      //
+      // We don't want those overwritten, since lots of code assumes `object3d.el` points to the relevant
+      // A-Frame entity for that three.js object, so we back them up and re-wire them here. If we didn't do
+      // this, all the `el` properties on these object3ds would point to the `object3DToSet` which is either
+      // the model or the root GLTF inflated entity.
+      const rewires = [];
+
+      object3DToSet.traverse(o => {
+        const el = o.el;
+        if (el) rewires.push(() => (o.el = el));
+      });
+
       this.el.setObject3D("mesh", object3DToSet);
 
+      rewires.forEach(f => f());
+
       this.el.emit("model-loaded", { format: "gltf", model: this.model });
     } catch (e) {
       delete GLTFCache[src];
diff --git a/src/components/hand-controls2.js b/src/components/hand-controls2.js
index 3a97888b172687838581fc720f516e50ceb6b248..ff1d2e87bbbd596e3381017b7695c5b2f2dfedc5 100644
--- a/src/components/hand-controls2.js
+++ b/src/components/hand-controls2.js
@@ -12,17 +12,33 @@ const POSES = {
   mrpDown: "mrpDown"
 };
 
-// TODO: If the hands or controllers are mispositioned, then rightHand.controllerPose and rightHand.pose
-//       should be bound differently.
-export const CONTROLLER_OFFSETS = {
+export const LEFT_CONTROLLER_OFFSETS = {
   default: new THREE.Matrix4(),
-  "oculus-touch-controls": new THREE.Matrix4().makeTranslation(0, -0.015, 0.04),
+  "oculus-touch-controls": new THREE.Matrix4().makeTranslation(-0.025, -0.03, 0.1),
   "oculus-go-controls": new THREE.Matrix4(),
   "vive-controls": new THREE.Matrix4().compose(
+    new THREE.Vector3(0, 0, 0.13),
+    new THREE.Quaternion().setFromEuler(new THREE.Euler(-40 * THREE.Math.DEG2RAD, 0, 0)),
+    new THREE.Vector3(1, 1, 1)
+  ),
+  "windows-motion-controls": new THREE.Matrix4().compose(
     new THREE.Vector3(0, -0.017, 0.13),
     new THREE.Quaternion().setFromEuler(new THREE.Euler(-40 * THREE.Math.DEG2RAD, 0, 0)),
     new THREE.Vector3(1, 1, 1)
   ),
+  "daydream-controls": new THREE.Matrix4().makeTranslation(0, 0, -0.04),
+  "gearvr-controls": new THREE.Matrix4()
+};
+
+export const RIGHT_CONTROLLER_OFFSETS = {
+  default: new THREE.Matrix4(),
+  "oculus-touch-controls": new THREE.Matrix4().makeTranslation(0.025, -0.03, 0.1),
+  "oculus-go-controls": new THREE.Matrix4(),
+  "vive-controls": new THREE.Matrix4().compose(
+    new THREE.Vector3(0, 0, 0.13),
+    new THREE.Quaternion().setFromEuler(new THREE.Euler(-40 * THREE.Math.DEG2RAD, 0, 0)),
+    new THREE.Vector3(1, 1, 1)
+  ),
   "windows-motion-controls": new THREE.Matrix4().compose(
     new THREE.Vector3(0, -0.017, 0.13),
     new THREE.Quaternion().setFromEuler(new THREE.Euler(-40 * THREE.Math.DEG2RAD, 0, 0)),
@@ -40,11 +56,18 @@ export const CONTROLLER_OFFSETS = {
 AFRAME.registerComponent("hand-controls2", {
   schema: { default: "left" },
 
-  getControllerOffset() {
-    if (CONTROLLER_OFFSETS[this.connectedController] === undefined) {
-      return CONTROLLER_OFFSETS.default;
+  getLeftControllerOffset() {
+    if (LEFT_CONTROLLER_OFFSETS[this.connectedController] === undefined) {
+      return LEFT_CONTROLLER_OFFSETS.default;
+    }
+    return LEFT_CONTROLLER_OFFSETS[this.connectedController];
+  },
+
+  getRightControllerOffset() {
+    if (RIGHT_CONTROLLER_OFFSETS[this.connectedController] === undefined) {
+      return RIGHT_CONTROLLER_OFFSETS.default;
     }
-    return CONTROLLER_OFFSETS[this.connectedController];
+    return RIGHT_CONTROLLER_OFFSETS[this.connectedController];
   },
 
   init() {
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/ik-controller.js b/src/components/ik-controller.js
index e7c9260f80b233f314f483c42b94f9177baded99..be7b8a93d370c489640c220bed6d25743be36720 100644
--- a/src/components/ik-controller.js
+++ b/src/components/ik-controller.js
@@ -180,11 +180,11 @@ AFRAME.registerComponent("ik-controller", {
     rootToChest.multiplyMatrices(hips.matrix, chest.matrix);
     invRootToChest.getInverse(rootToChest);
 
-    this.updateHand(this.hands.left, leftHand, leftController);
-    this.updateHand(this.hands.right, rightHand, rightController);
+    this.updateHand(this.hands.left, leftHand, leftController, true);
+    this.updateHand(this.hands.right, rightHand, rightController, false);
   },
 
-  updateHand(handState, handObject3D, controller) {
+  updateHand(handState, handObject3D, controller, isLeft) {
     const hand = handObject3D.el;
     const handMatrix = handObject3D.matrix;
     const controllerObject3D = controller.object3D;
@@ -201,7 +201,7 @@ AFRAME.registerComponent("ik-controller", {
       const handControls = controller.components["hand-controls2"];
 
       if (handControls) {
-        handMatrix.multiply(handControls.getControllerOffset());
+        handMatrix.multiply(isLeft ? handControls.getLeftControllerOffset() : handControls.getRightControllerOffset());
       }
 
       handMatrix.multiply(handState.rotation);
diff --git a/src/components/media-loader.js b/src/components/media-loader.js
index 5dcd47beef202851c03b2cd28d8089875873914f..121bc46ad23d089904c778a57ddc8243d988c2a0 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;
   },
@@ -42,7 +46,7 @@ AFRAME.registerComponent("media-loader", {
     if (this.el.body && this.shapeAdded && this.el.body.shapes.length > 1) {
       this.el.removeAttribute("shape");
       this.shapeAdded = false;
-    } else if (!this.hasBakedShapes) {
+    } else if (!this.hasBakedShapes && !box.isEmpty()) {
       const center = new THREE.Vector3();
       const { min, max } = box;
       const halfExtents = {
@@ -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 }
@@ -205,7 +224,7 @@ AFRAME.registerComponent("media-pager", {
       // if this is the first image we ever loaded, set up the UI
       if (this.toolbar == null) {
         const template = document.getElementById("paging-toolbar");
-        this.el.appendChild(document.importNode(template.content, true));
+        this.el.querySelector(".interactable-ui").appendChild(document.importNode(template.content, true));
         this.toolbar = this.el.querySelector(".paging-toolbar");
         // we have to wait a tick for the attach callbacks to get fired for the elements in a template
         setTimeout(() => {
@@ -213,8 +232,8 @@ AFRAME.registerComponent("media-pager", {
           this.prevButton = this.el.querySelector(".prev-button [text-button]");
           this.pageLabel = this.el.querySelector(".page-label");
 
-          this.nextButton.addEventListener("click", this.onNext);
-          this.prevButton.addEventListener("click", this.onPrev);
+          this.nextButton.addEventListener("grab-start", this.onNext);
+          this.prevButton.addEventListener("grab-start", this.onPrev);
 
           this.update();
           this.el.emit("preview-loaded");
@@ -237,16 +256,18 @@ AFRAME.registerComponent("media-pager", {
 
   remove() {
     if (this.toolbar) {
-      this.el.removeChild(this.toolbar);
+      this.toolbar.parentNode.removeChild(this.toolbar);
     }
   },
 
   onNext() {
     this.el.setAttribute("media-pager", "index", Math.min(this.data.index + 1, this.maxIndex));
+    this.el.emit("pager-page-changed");
   },
 
   onPrev() {
     this.el.setAttribute("media-pager", "index", Math.max(this.data.index - 1, 0));
+    this.el.emit("pager-page-changed");
   },
 
   repositionToolbar() {
diff --git a/src/components/media-views.js b/src/components/media-views.js
index 37afb0d8c7bbc8f536c12fbc7bed5a4268716932..ae72461f1d69bb73af6c29b3a777f2a1d6487681 100644
--- a/src/components/media-views.js
+++ b/src/components/media-views.js
@@ -83,6 +83,7 @@ function createVideoEl(src) {
   const videoEl = document.createElement("video");
   videoEl.setAttribute("playsinline", "");
   videoEl.setAttribute("webkit-playsinline", "");
+  videoEl.preload = "auto";
   videoEl.loop = true;
   videoEl.crossOrigin = "anonymous";
   videoEl.src = src;
@@ -230,13 +231,25 @@ AFRAME.registerComponent("media-video", {
 
   init() {
     this.onPauseStateChange = this.onPauseStateChange.bind(this);
-    this.togglePlayingIfOwner = this.togglePlayingIfOwner.bind(this);
+
+    this._grabStart = this._grabStart.bind(this);
+    this._grabEnd = this._grabEnd.bind(this);
 
     this.lastUpdate = 0;
 
     NAF.utils.getNetworkedEntity(this.el).then(networkedEl => {
       this.networkedEl = networkedEl;
       this.updatePlaybackState();
+
+      // For scene-owned videos, take ownership after a random delay if nobody
+      // else has so there is a timekeeper.
+      if (NAF.utils.getNetworkOwner(this.networkedEl) === "scene") {
+        setTimeout(() => {
+          if (NAF.utils.getNetworkOwner(this.networkedEl) === "scene") {
+            NAF.utils.takeOwnership(this.networkedEl);
+          }
+        }, 2000 + Math.floor(Math.random() * 2000));
+      }
     });
 
     // from a-sound
@@ -252,12 +265,27 @@ AFRAME.registerComponent("media-video", {
 
   // aframe component play, unrelated to video
   play() {
-    this.el.addEventListener("click", this.togglePlayingIfOwner);
+    this.el.addEventListener("grab-start", this._grabStart);
+    this.el.addEventListener("grab-end", this._grabEnd);
   },
 
   // aframe component pause, unrelated to video
   pause() {
-    this.el.removeEventListener("click", this.togglePlayingIfOwner);
+    this.el.removeEventListener("grab-start", this._grabStart);
+    this.el.removeEventListener("grab-end", this._grabEnd);
+  },
+
+  _grabStart() {
+    if (!this.el.components.grabbable || this.el.components.grabbable.data.maxGrabbers === 0) return;
+
+    this.grabStartPosition = this.el.object3D.position.clone();
+  },
+
+  _grabEnd() {
+    if (this.grabStartPosition && this.grabStartPosition.distanceToSquared(this.el.object3D.position) < 0.01 * 0.01) {
+      this.togglePlayingIfOwner();
+      this.grabStartPosition = null;
+    }
   },
 
   togglePlayingIfOwner() {
diff --git a/src/components/pin-networked-object-button.js b/src/components/pin-networked-object-button.js
new file mode 100644
index 0000000000000000000000000000000000000000..8f70dee186adc97ab16d49d9f2ca1616cd4f9f07
--- /dev/null
+++ b/src/components/pin-networked-object-button.js
@@ -0,0 +1,69 @@
+AFRAME.registerComponent("pin-networked-object-button", {
+  schema: {
+    // Selector for root of all UI that needs to be clickable when pinned
+    uiSelector: { type: "string" },
+
+    // Selector for label to change when pinned/unpinned, must be sibling of this components element
+    labelSelector: { type: "string" },
+
+    // Selector for items to hide iff pinned
+    hideWhenPinnedSelector: { type: "string" }
+  },
+
+  init() {
+    this._updateUI = this._updateUI.bind(this);
+    this._updateUIOnStateChange = this._updateUIOnStateChange.bind(this);
+    this.el.sceneEl.addEventListener("stateadded", this._updateUIOnStateChange);
+    this.el.sceneEl.addEventListener("stateremoved", this._updateUIOnStateChange);
+
+    this.labelEl = this.el.parentNode.querySelector(this.data.labelSelector);
+
+    NAF.utils.getNetworkedEntity(this.el).then(networkedEl => {
+      this.targetEl = networkedEl;
+
+      this._updateUI();
+      this.targetEl.addEventListener("pinned", this._updateUI);
+      this.targetEl.addEventListener("unpinned", this._updateUI);
+    });
+
+    this.onClick = () => {
+      if (!NAF.utils.isMine(this.targetEl) && !NAF.utils.takeOwnership(this.targetEl)) return;
+
+      const wasPinned = this.targetEl.components.pinnable && this.targetEl.components.pinnable.data.pinned;
+      this.targetEl.setAttribute("pinnable", "pinned", !wasPinned);
+    };
+  },
+
+  play() {
+    this.el.addEventListener("grab-start", this.onClick);
+  },
+
+  pause() {
+    this.el.removeEventListener("grab-start", this.onClick);
+  },
+
+  remove() {
+    this.el.sceneEl.removeEventListener("stateadded", this._updateUIOnStateChange);
+    this.el.sceneEl.removeEventListener("stateremoved", this._updateUIOnStateChange);
+
+    if (this.targetEl) {
+      this.targetEl.removeEventListener("pinned", this._updateUI);
+      this.targetEl.removeEventListener("unpinned", this._updateUI);
+    }
+  },
+
+  _updateUIOnStateChange(e) {
+    if (e.detail !== "frozen") return;
+    this._updateUI();
+  },
+
+  _updateUI() {
+    const isPinned = this.targetEl.getAttribute("pinnable") && this.targetEl.getAttribute("pinnable").pinned;
+
+    this.labelEl.setAttribute("text", "value", isPinned ? "un-pin" : "pin");
+
+    this.el.parentNode.querySelectorAll(this.data.hideWhenPinnedSelector).forEach(hideEl => {
+      hideEl.setAttribute("visible", !isPinned);
+    });
+  }
+});
diff --git a/src/components/pinnable.js b/src/components/pinnable.js
new file mode 100644
index 0000000000000000000000000000000000000000..1ab9573bcd06aca7316b974b3c789eacbc063780
--- /dev/null
+++ b/src/components/pinnable.js
@@ -0,0 +1,78 @@
+AFRAME.registerComponent("pinnable", {
+  schema: {
+    pinned: { default: false }
+  },
+
+  init() {
+    this._applyState = this._applyState.bind(this);
+    this._fireEvents = this._fireEvents.bind(this);
+    this._allowApplyOnceComponentsReady = this._allowApplyOnceComponentsReady.bind(this);
+    this._allowApply = false;
+
+    this.el.sceneEl.addEventListener("stateadded", this._applyState);
+    this.el.sceneEl.addEventListener("stateremoved", this._applyState);
+
+    // Fire pinned events when we drag and drop or scale in freeze mode,
+    // so transform gets updated.
+    this.el.addEventListener("grab-end", this._fireEvents);
+
+    // Fire pinned events when page changes so we can persist the page.
+    this.el.addEventListener("pager-page-changed", this._fireEvents);
+
+    // Hack: need to wait for the initial grabbable and stretchable components
+    // to show up from the template before applying.
+    this.el.addEventListener("componentinitialized", this._allowApplyOnceComponentsReady);
+    this._allowApplyOnceComponentsReady();
+  },
+
+  remove() {
+    this.el.sceneEl.removeEventListener("stateadded", this._applyState);
+    this.el.sceneEl.removeEventListener("stateremoved", this._applyState);
+    this.el.removeEventListener("componentinitialized", this._allowApplyOnceComponentsReady);
+  },
+
+  update() {
+    this._applyState();
+    this._fireEvents();
+  },
+
+  _fireEvents() {
+    if (this.data.pinned) {
+      this.el.emit("pinned", { el: this.el });
+    } else {
+      this.el.emit("unpinned", { el: this.el });
+    }
+  },
+
+  _allowApplyOnceComponentsReady() {
+    if (!this._allowApply && this.el.components.grabbable && this.el.components.stretchable) {
+      this._allowApply = true;
+      this._applyState();
+    }
+  },
+
+  _applyState() {
+    if (!this._allowApply) return;
+    const isFrozen = this.el.sceneEl.is("frozen");
+
+    if (this.data.pinned && !isFrozen) {
+      if (this.el.components.stretchable) {
+        this.el.removeAttribute("stretchable");
+      }
+
+      this.el.setAttribute("body", { type: "static" });
+
+      if (this.el.components.grabbable.data.maxGrabbers !== 0) {
+        this.prevMaxGrabbers = this.el.components.grabbable.data.maxGrabbers;
+      }
+
+      this.el.setAttribute("grabbable", { maxGrabbers: 0 });
+    } else {
+      this.el.setAttribute("grabbable", { maxGrabbers: this.prevMaxGrabbers });
+
+      if (!this.el.components.stretchable) {
+        this.el.setAttribute("stretchable", "");
+      }
+    }
+  }
+});
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/remove-networked-object-button.js b/src/components/remove-networked-object-button.js
index 39ec27b754a2dfbb5476c9490ee40e3c30f12f79..e3f4aa2c020df42fb9603475441f09c53c1e47e6 100644
--- a/src/components/remove-networked-object-button.js
+++ b/src/components/remove-networked-object-button.js
@@ -1,18 +1,21 @@
 AFRAME.registerComponent("remove-networked-object-button", {
   init() {
     this.onClick = () => {
+      if (!NAF.utils.isMine(this.targetEl) && !NAF.utils.takeOwnership(this.targetEl)) return;
+
       this.targetEl.parentNode.removeChild(this.targetEl);
     };
+
     NAF.utils.getNetworkedEntity(this.el).then(networkedEl => {
       this.targetEl = networkedEl;
     });
   },
 
   play() {
-    this.el.addEventListener("click", this.onClick);
+    this.el.addEventListener("grab-start", this.onClick);
   },
 
   pause() {
-    this.el.removeEventListener("click", this.onClick);
+    this.el.removeEventListener("grab-start", this.onClick);
   }
 });
diff --git a/src/components/set-yxz-order.js b/src/components/set-yxz-order.js
new file mode 100644
index 0000000000000000000000000000000000000000..df03d62978f3fef2934499478d4c9f532f4f9fe2
--- /dev/null
+++ b/src/components/set-yxz-order.js
@@ -0,0 +1,5 @@
+AFRAME.registerComponent("set-yxz-order", {
+  init: function() {
+    this.el.object3D.rotation.order = "YXZ";
+  }
+});
diff --git a/src/components/sticky-object.js b/src/components/sticky-object.js
index 61d04814a95b2e30cc79743a039dec1c0a6d5235..60f23501936007a6685363f538b803da50291559 100644
--- a/src/components/sticky-object.js
+++ b/src/components/sticky-object.js
@@ -5,7 +5,7 @@ AFRAME.registerComponent("sticky-object", {
   schema: {
     autoLockOnLoad: { default: false },
     autoLockOnRelease: { default: false },
-    autoLockSpeedLimit: { default: 0.25 }
+    autoLockSpeedLimit: { default: 0.5 } // Set to 0 to always autolock on release
   },
 
   init() {
@@ -52,7 +52,8 @@ AFRAME.registerComponent("sticky-object", {
 
     if (
       this.data.autoLockOnRelease &&
-      this.el.body.velocity.lengthSquared() < this.data.autoLockSpeedLimit * this.data.autoLockSpeedLimit
+      (this.data.autoLockSpeedLimit === 0 ||
+        this.el.body.velocity.lengthSquared() < this.data.autoLockSpeedLimit * this.data.autoLockSpeedLimit)
     ) {
       this.setLocked(true);
     }
@@ -60,6 +61,8 @@ AFRAME.registerComponent("sticky-object", {
   },
 
   _onGrab() {
+    if (!this.el.components.grabbable || this.el.components.grabbable.data.maxGrabbers === 0) return;
+
     this.setLocked(false);
     this.el.body.collisionResponse = false;
   },
diff --git a/src/components/stop-event-propagation.js b/src/components/stop-event-propagation.js
new file mode 100644
index 0000000000000000000000000000000000000000..b12be3fa99da119e4e548ae77e9b5f991f6825ac
--- /dev/null
+++ b/src/components/stop-event-propagation.js
@@ -0,0 +1,20 @@
+const handler = e => {
+  e.stopPropagation();
+  e.preventDefault();
+};
+
+AFRAME.registerComponent("stop-event-propagation", {
+  multiple: true,
+
+  schema: {
+    event: { type: "string" }
+  },
+
+  play() {
+    this.el.addEventListener(this.data.event, handler);
+  },
+
+  pause() {
+    this.el.removeEventListener(this.data.event, handler);
+  }
+});
diff --git a/src/components/super-networked-interactable.js b/src/components/super-networked-interactable.js
index 1fbbaa37c6f0786b91f9d8cc109df19e0716829d..3943725e4f85801edf94402dcf13064bc2925d66 100644
--- a/src/components/super-networked-interactable.js
+++ b/src/components/super-networked-interactable.js
@@ -35,18 +35,20 @@ AFRAME.registerComponent("super-networked-interactable", {
 
     NAF.utils.getNetworkedEntity(this.el).then(networkedEl => {
       this.networkedEl = networkedEl;
+      this._syncCounterRegistration(networkedEl);
       if (!NAF.utils.isMine(networkedEl)) {
         this.el.setAttribute("body", { type: "static" });
-      } else {
-        this.counter.register(networkedEl);
       }
     });
 
     this._onGrabStart = this._onGrabStart.bind(this);
     this._onGrabEnd = this._onGrabEnd.bind(this);
     this._onOwnershipLost = this._onOwnershipLost.bind(this);
+    this._syncCounterRegistration = this._syncCounterRegistration.bind(this);
     this.el.addEventListener("grab-start", this._onGrabStart);
     this.el.addEventListener("grab-end", this._onGrabEnd);
+    this.el.addEventListener("pinned", this._syncCounterRegistration);
+    this.el.addEventListener("unpinned", this._syncCounterRegistration);
     this.el.addEventListener("ownership-lost", this._onOwnershipLost);
     this.system.addComponent(this);
   },
@@ -60,12 +62,14 @@ AFRAME.registerComponent("super-networked-interactable", {
   },
 
   _onGrabStart: function(e) {
+    if (!this.el.components.grabbable || this.el.components.grabbable.data.maxGrabbers === 0) return;
+
     this.hand = e.detail.hand;
     this.hand.emit("haptic_pulse", { intensity: "high" });
     if (this.networkedEl && !NAF.utils.isMine(this.networkedEl)) {
       if (NAF.utils.takeOwnership(this.networkedEl)) {
         this.el.setAttribute("body", { type: "dynamic" });
-        this.counter.register(this.networkedEl);
+        this._syncCounterRegistration(this.networkedEl);
       } else {
         this.el.emit("grab-end", { hand: this.hand });
         this.hand = null;
@@ -82,7 +86,7 @@ AFRAME.registerComponent("super-networked-interactable", {
     this.el.setAttribute("body", { type: "static" });
     this.el.emit("grab-end", { hand: this.hand });
     this.hand = null;
-    this.counter.deregister(this.el);
+    this._syncCounterRegistration();
   },
 
   _changeScale: function(delta) {
@@ -93,6 +97,19 @@ AFRAME.registerComponent("super-networked-interactable", {
     }
   },
 
+  _syncCounterRegistration: function() {
+    const el = this.networkedEl;
+    if (!el) return;
+
+    const isPinned = el.components["pinnable"] && el.components["pinnable"].data.pinned;
+
+    if (NAF.utils.isMine(el) && !isPinned) {
+      this.counter.register(el);
+    } else {
+      this.counter.deregister(el);
+    }
+  },
+
   tick: function() {
     const grabber = this.el.components.grabbable.grabbers[0];
     if (!(grabber && pathsMap[grabber.id])) return;
diff --git a/src/components/super-spawner.js b/src/components/super-spawner.js
index 7ff4e1189f589d9d026ba63e920dfc5d47a78760..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() {
diff --git a/src/components/text-button.js b/src/components/text-button.js
index 491b482a248099ae8a277ed5cb04dbb638df6f83..bee22c75048855802fecf17d15b5276a55434318 100644
--- a/src/components/text-button.js
+++ b/src/components/text-button.js
@@ -38,13 +38,13 @@ AFRAME.registerComponent("text-button", {
     this.updateButtonState();
     this.el.addEventListener("mouseover", this.onHover);
     this.el.addEventListener("mouseout", this.onHoverOut);
-    this.el.addEventListener("click", this.onClick);
+    this.el.addEventListener("grab-start", this.onClick);
   },
 
   pause() {
     this.el.removeEventListener("mouseover", this.onHover);
     this.el.removeEventListener("mouseout", this.onHoverOut);
-    this.el.removeEventListener("click", this.onClick);
+    this.el.removeEventListener("grab-start", this.onClick);
   },
 
   update() {
diff --git a/src/gltf-component-mappings.js b/src/gltf-component-mappings.js
index 73c7d628ee70e5b1182f6a035a0aeef701488221..1bca96496e0eb7ee726e66c49af6b4406d4a61e4 100644
--- a/src/gltf-component-mappings.js
+++ b/src/gltf-component-mappings.js
@@ -72,3 +72,21 @@ AFRAME.GLTFModelPlus.registerComponent("nav-mesh", "nav-mesh", (el, _componentNa
   // nav-mesh-helper will query for it later.
   el.setAttribute("nav-mesh");
 });
+
+AFRAME.GLTFModelPlus.registerComponent("pinnable", "pinnable");
+
+AFRAME.GLTFModelPlus.registerComponent("media", "media", (el, componentName, componentData) => {
+  if (componentData.id) {
+    el.setAttribute("networked", {
+      template: "#interactable-media",
+      owner: "scene",
+      networkId: componentData.id
+    });
+  }
+
+  el.setAttribute("media-loader", { src: componentData.src, resize: true, resolve: true });
+
+  if (componentData.pageIndex) {
+    el.setAttribute("media-pager", { index: componentData.pageIndex });
+  }
+});
diff --git a/src/hub.html b/src/hub.html
index 99232684d4d2da80d13a3e953ffad076f3eeb748..28f1bd1ebe8fa6a45953369036e30d411ea92734 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -32,9 +32,11 @@
         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>
+            <img id="action-button" crossorigin="anonymous" src="./assets/hud/action_button.9.png">
             <img id="tooltip" crossorigin="anonymous" src="./assets/hud/tooltip.9.png">
             <img id="mute-off" crossorigin="anonymous" src="./assets/hud/mute_off.png">
             <img id="mute-off-hover" crossorigin="anonymous" src="./assets/hud/mute_off-hover.png">
@@ -90,22 +92,22 @@
                         <template data-name="Neck">
                             <a-entity>
                                 <a-entity
-                                   class="nametag"
-                                   billboard
-                                   text="side: double; align: center; color: #ddd"
-                                   position="0 1 0"
-                                   scale="6 6 6"
-                               ></a-entity>
+                                    class="nametag"
+                                    billboard
+                                    text="side: double; align: center; color: #ddd"
+                                    position="0 1 0"
+                                    scale="6 6 6"
+                                ></a-entity>
                             </a-entity>
                         </template>
 
                         <template data-name="Chest">
-                          <a-entity personal-space-invader="radius: 0.2; useMaterial: true;" bone-visibility>
-                            <a-entity billboard>
-                              <a-entity mixin="rounded-text-button" block-button visible-while-frozen ui-class-while-frozen position="0 0 .35"> </a-entity>
-                              <a-entity visible-while-frozen text="value:Block; width:2.5; align:center;" position="0 0 0.36"></a-entity>
+                            <a-entity personal-space-invader="radius: 0.2; useMaterial: true;" bone-visibility>
+                                <a-entity billboard>
+                                    <a-entity mixin="rounded-text-button" block-button visible-while-frozen ui-class-while-frozen position="0 0 .35"> </a-entity>
+                                    <a-entity visible-while-frozen text="value:Block; width:2.5; align:center;" position="0 0 0.36"></a-entity>
+                                </a-entity>
                             </a-entity>
-                          </a-entity>
                         </template>
 
                         <template data-name="Head">
@@ -115,22 +117,22 @@
                                 personal-space-invader="radius: 0.15; useMaterial: true;"
                                 bone-visibility
                             >
-                              <a-cylinder
-                                  static-body
-                                  radius="0.13"
-                                  height="0.2"
-                                  position="0 0.07 0.05"
-                                  visible="false"
-                              ></a-cylinder>
+                                <a-cylinder
+                                    static-body
+                                    radius="0.13"
+                                    height="0.2"
+                                    position="0 0.07 0.05"
+                                    visible="false"
+                                ></a-cylinder>
                             </a-entity>
                         </template>
 
                         <template data-name="LeftHand">
-                          <a-entity personal-space-invader="radius: 0.1" bone-visibility></a-entity>
+                            <a-entity personal-space-invader="radius: 0.1" bone-visibility></a-entity>
                         </template>
 
                         <template data-name="RightHand">
-                          <a-entity personal-space-invader="radius: 0.1" bone-visibility></a-entity>
+                            <a-entity personal-space-invader="radius: 0.1" bone-visibility></a-entity>
                         </template>
                     </a-entity>
                 </a-entity>
@@ -144,16 +146,21 @@
                     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"
+                    position-at-box-shape-border="target:.freeze-menu"
                     destroy-at-extreme-distances
-                    rotation
+                    set-yxz-order
+                    pinnable
                 >
-                    <!-- HACK: rotation component above is required for its side effect of setting YXZ order -->
-                    <a-entity class="delete-button" visible-while-frozen>
-                        <a-entity mixin="rounded-text-button" remove-networked-object-button position="0 0 0"> </a-entity>
-                        <a-entity text=" value:Remove; width:2.5; align:center;" text-raycast-hack position="0 0 0.01"></a-entity>
+                    <a-entity class="interactable-ui" stop-event-propagation__grab-start="event: grab-start" stop-event-propagation__grab-end="event: grab-end">
+                        <a-entity class="freeze-menu" visible-while-frozen>
+                            <a-entity mixin="rounded-text-action-button" pin-networked-object-button="labelSelector:.pin-button-label; hideWhenPinnedSelector:.hide-when-pinned; uiSelector:.interactable-ui" position="0 0.125 0.01"> </a-entity>
+                            <a-entity class="pin-button-label" text=" value:pin; width:1.75; align:center;" text-raycast-hack position="0 0.125 0.02"></a-entity>
+                            <a-entity mixin="rounded-text-button" class="hide-when-pinned" remove-networked-object-button position="0 -0.125 0.01"> </a-entity>
+                            <a-entity text=" value:remove; width:1.75; align:center;" class="hide-when-pinned" text-raycast-hack position="0 -0.125 0.02"></a-entity>
+                        </a-entity>
                     </a-entity>
                 </a-entity>
             </template>
@@ -164,7 +171,7 @@
                     super-networked-interactable="counter: #pen-counter;"
                     body="type: dynamic; shape: none; mass: 1;"
                     grabbable="maxGrabbers: 1"
-                    sticky-object="autoLockOnRelease: true; autoLockOnLoad: true;"
+                    sticky-object="autoLockOnRelease: true; autoLockOnLoad: true; autoLockSpeedLimit: 0;"
                     hoverable
                     scale="0.5 0.5 0.5"
                 >
@@ -180,7 +187,7 @@
                     ></a-sphere>
                     <a-entity class="delete-button" visible-while-frozen>
                         <a-entity mixin="rounded-text-button" remove-networked-object-button position="0 0 0"> </a-entity>
-                        <a-entity text=" value:Remove; width:2.5; align:center;" text-raycast-hack position="0 0 0.01"></a-entity>
+                        <a-entity text=" value:remove; width:2.5; align:center;" text-raycast-hack position="0 0 0.01"></a-entity>
                     </a-entity>
                 </a-entity>
             </template>
@@ -194,15 +201,15 @@
                     camera-tool
                     body="type: dynamic; shape: none; mass: 1;"
                     shape="shape: box; halfExtents: 0.22 0.145 0.1; offset: 0 0.02 0"
-                    sticky-object="autoLockOnRelease: true; autoLockOnLoad: true;"
+                    sticky-object="autoLockOnRelease: true; autoLockOnLoad: true; autoLockSpeedLimit: 0;"
                     super-networked-interactable="counter: #camera-counter;"
                     position-at-box-shape-border="target:.delete-button"
-                    rotation
+                    set-yxz-order
                     auto-scale-cannon-physics-body
                 >
                     <a-entity class="delete-button" visible-while-frozen>
                         <a-entity mixin="rounded-text-button" remove-networked-object-button position="0 0 0"> </a-entity>
-                        <a-entity text=" value:Remove; width:2.5; align:center;" text-raycast-hack position="0 0 0.01"></a-entity>
+                        <a-entity text=" value:remove; width:2.5; align:center;" text-raycast-hack position="0 0 0.01"></a-entity>
                     </a-entity>
                 </a-entity>
             </template>
@@ -232,35 +239,53 @@
                     haptic:#player-right-controller;
                     textHoverColor: #fff;
                     textColor: #fff;
-                    backgroundHoverColor: #ea4b54;
+                    backgroundHoverColor: #aaa;
                     backgroundColor: #fff;"
                 slice9="
                     width: 0.45;
                     height: 0.2;
-                    left: 53;
-                    top: 53;
-                    right: 10;
-                    bottom: 10;
-                    opacity: 1.3;
-                    src: #tooltip"
+                    left: 64;
+                    top: 64;
+                    right: 66;
+                    bottom: 66;
+                    opacity: 1.0;
+                    src: #action-button"
+            ></a-mixin>
+
+            <a-mixin id="rounded-text-action-button"
+                text-button="
+                    haptic:#player-right-controller;
+                    textHoverColor: #fff;
+                    textColor: #fff;
+                    backgroundHoverColor: #ff0434;
+                    backgroundColor: #ff3464;"
+                slice9="
+                    width: 0.45;
+                    height: 0.2;
+                    left: 64;
+                    top: 64;
+                    right: 66;
+                    bottom: 66;
+                    opacity: 1.0;
+                    src: #action-button"
             ></a-mixin>
 
             <a-mixin id="controller-super-hands"
-                     super-hands="
-                         colliderEvent: collisions;
-                         colliderEventProperty: els;
-                         colliderEndEvent: collisions;
-                         colliderEndEventProperty: clearedEls;
-                         grabStartButtons: primary_hand_grab, secondary_hand_grab;
-                         grabEndButtons: primary_hand_release, secondary_hand_release;
-                         stretchStartButtons: primary_hand_grab, secondary_hand_grab;
-                         stretchEndButtons: primary_hand_release, secondary_hand_release;
-                         dragDropStartButtons: hand_grab, secondary_hand_grab;
-                         dragDropEndButtons: hand_release, secondary_hand_release;
-                         activateStartButtons: secondary_hand_grab, next_color, previous_color, increase_radius, decrease_radius, scroll_up, scroll_down, scroll_left, scroll_right;
-                         activateEndButtons: secondary_hand_release, vertical_scroll_release, horizontal_scroll_release, thumb_up;"
-                     collision-filter="collisionForces: false"
-                     physics-collider
+                super-hands="
+                    colliderEvent: collisions;
+                    colliderEventProperty: els;
+                    colliderEndEvent: collisions;
+                    colliderEndEventProperty: clearedEls;
+                    grabStartButtons: primary_hand_grab, secondary_hand_grab;
+                    grabEndButtons: primary_hand_release, secondary_hand_release;
+                    stretchStartButtons: primary_hand_grab, secondary_hand_grab;
+                    stretchEndButtons: primary_hand_release, secondary_hand_release;
+                    dragDropStartButtons: hand_grab, secondary_hand_grab;
+                    dragDropEndButtons: hand_release, secondary_hand_release;
+                    activateStartButtons: secondary_hand_grab, next_color, previous_color, increase_radius, decrease_radius, scroll_up, scroll_down, scroll_left, scroll_right;
+                    activateEndButtons: secondary_hand_release, vertical_scroll_release, horizontal_scroll_release, thumb_up;"
+                collision-filter="collisionForces: false"
+                physics-collider
             ></a-mixin>
         </a-assets>
 
@@ -315,134 +340,135 @@
             player-info
             cardboard-controls
         >
-          <a-entity
-              id="player-hud"
-              hud-controller="head: #player-camera;"
-              vr-mode-toggle-visibility
-              vr-mode-toggle-playing__hud-controller
-          >
-            <a-entity in-world-hud="haptic:#player-right-controller;raycaster:#player-right-controller;" rotation="30 0 0">
-              <a-rounded height="0.08" width="0.5" color="#000000" position="-0.20 0.125 0" radius="0.040" opacity="0.35" class="hud bg"></a-rounded>
-              <a-entity id="hud-hub-entry-link" text=" value:; width:1.1; align:center;" position="0.05 0.165 0"></a-entity>
-              <a-rounded height="0.13" width="0.59" color="#000000" position="-0.24 -0.065 0" radius="0.065" opacity="0.35" class="hud bg"></a-rounded>
-              <a-image icon-button="tooltip: #hud-tooltip; tooltipText: Mute Mic; activeTooltipText: Unmute Mic; image: #mute-off; hoverImage: #mute-off-hover; activeImage: #mute-on; activeHoverImage: #mute-on-hover" scale="0.1 0.1 0.1" position="-0.17 0 0.001" class="ui hud mic" material="alphaTest:0.1;" hoverable></a-image>
-              <a-image icon-button="tooltip: #hud-tooltip; tooltipText: Pause; activeTooltipText: Resume; image: #freeze-off; hoverImage: #freeze-off-hover; activeImage: #freeze-on; activeHoverImage: #freeze-on-hover" scale="0.2 0.2 0.2" position="0 0 0.005" class="ui hud freeze" hoverable></a-image>
-              <a-image icon-button="tooltip: #hud-tooltip; tooltipText: Pen; activeTooltipText: Pen; image: #spawn-pen; hoverImage: #spawn-pen-hover; activeImage: #spawn-pen; activeHoverImage: #spawn-pen-hover" scale="0.1 0.1 0.1" position="0.17 0 0.001" class="ui hud penhud" material="alphaTest:0.1;" hoverable></a-image>
-              <a-image icon-button="tooltip: #hud-tooltip; tooltipText: Camera; activeTooltipText: Camera; image: #spawn-camera; hoverImage: #spawn-camera-hover; activeImage: #spawn-camera; activeHoverImage: #spawn-camera-hover" scale="0.1 0.1 0.1" position="0.28 0 0.001" class="ui hud cameraBtn" material="alphaTest:0.1;" hoverable></a-image>
-              <a-rounded visible="false" id="hud-tooltip" height="0.08" width="0.3" color="#000000" position="-0.15 -0.2 0" rotation="-20 0 0" radius="0.025" opacity="0.35" class="hud bg">
-                <a-entity text="value: Mute Mic; align:center;" position="0.15 0.04 0.001" ></a-entity>
-              </a-rounded>
+            <a-entity
+                id="player-hud"
+                hud-controller="head: #player-camera;"
+                vr-mode-toggle-visibility
+                vr-mode-toggle-playing__hud-controller
+            >
+                <a-entity in-world-hud="haptic:#player-right-controller;raycaster:#player-right-controller;" rotation="30 0 0">
+                    <a-rounded height="0.08" width="0.5" color="#000000" position="-0.20 0.125 0" radius="0.040" opacity="0.35" class="hud bg"></a-rounded>
+                    <a-entity id="hud-hub-entry-link" text=" value:; width:1.1; align:center;" position="0.05 0.165 0"></a-entity>
+                    <a-rounded height="0.13" width="0.59" color="#000000" position="-0.24 -0.065 0" radius="0.065" opacity="0.35" class="hud bg"></a-rounded>
+                    <a-image icon-button="tooltip: #hud-tooltip; tooltipText: Mute Mic; activeTooltipText: Unmute Mic; image: #mute-off; hoverImage: #mute-off-hover; activeImage: #mute-on; activeHoverImage: #mute-on-hover" scale="0.1 0.1 0.1" position="-0.17 0 0.001" class="ui hud mic" material="alphaTest:0.1;" hoverable></a-image>
+                    <a-image icon-button="tooltip: #hud-tooltip; tooltipText: Pause; activeTooltipText: Resume; image: #freeze-off; hoverImage: #freeze-off-hover; activeImage: #freeze-on; activeHoverImage: #freeze-on-hover" scale="0.2 0.2 0.2" position="0 0 0.005" class="ui hud freeze" hoverable></a-image>
+                    <a-image icon-button="tooltip: #hud-tooltip; tooltipText: Pen; activeTooltipText: Pen; image: #spawn-pen; hoverImage: #spawn-pen-hover; activeImage: #spawn-pen; activeHoverImage: #spawn-pen-hover" scale="0.1 0.1 0.1" position="0.17 0 0.001" class="ui hud penhud" material="alphaTest:0.1;" hoverable></a-image>
+                    <a-image icon-button="tooltip: #hud-tooltip; tooltipText: Camera; activeTooltipText: Camera; image: #spawn-camera; hoverImage: #spawn-camera-hover; activeImage: #spawn-camera; activeHoverImage: #spawn-camera-hover" scale="0.1 0.1 0.1" position="0.28 0 0.001" class="ui hud cameraBtn" material="alphaTest:0.1;" hoverable></a-image>
+                    <a-rounded visible="false" id="hud-tooltip" height="0.08" width="0.3" color="#000000" position="-0.15 -0.2 0" rotation="-20 0 0" radius="0.025" opacity="0.35" class="hud bg">
+                        <a-entity text="value: Mute Mic; align:center;" position="0.15 0.04 0.001" ></a-entity>
+                    </a-rounded>
+                </a-entity>
+            </a-entity>
+
+            <a-entity
+                id="player-camera"
+                class="camera"
+                camera
+                personal-space-bubble="radius: 0.4;"
+                rotation
+                pitch-yaw-rotator
+                set-yxz-order
+            >
+                <a-entity
+                    id="gaze-teleport"
+                    position = "0.15 0 0"
+                    teleport-controls="
+                        cameraRig: #player-rig;
+                        teleportOrigin: #player-camera;
+                        button: gaze-teleport_;
+                        collisionEntities: [nav-mesh];
+                        drawIncrementally: true;
+                        incrementalDrawMs: 300;
+                        hitOpacity: 0.3;
+                        missOpacity: 0.1;
+                        curveShootingSpeed: 12;"
+                    action-to-event__start-teleport="path: /actions/startTeleport; event: gaze-teleport_down"
+                    action-to-event__stop-teleport="path: /actions/stopTeleport; event: gaze-teleport_up"
+                ></a-entity>
+            </a-entity>
+
+            <a-entity
+                id="player-left-controller"
+                class="left-controller"
+                hand-controls2="left"
+                tracked-controls
+                teleport-controls="
+                    cameraRig: #player-rig;
+                    teleportOrigin: #player-camera;
+                    button: left-teleport_;
+                    collisionEntities: [nav-mesh];
+                    drawIncrementally: true;
+                    incrementalDrawMs: 300;
+                    hitOpacity: 0.3;
+                    missOpacity: 0.1;
+                    curveShootingSpeed: 12;"
+                haptic-feedback
+                body="type: static; shape: none;"
+                mixin="controller-super-hands"
+                controls-shape-offset
+                action-to-event__a="path: /actions/leftHandStartTeleport; event: left-teleport_down;"
+                action-to-event__b="path: /actions/leftHandStopTeleport; event: left-teleport_up;"
+                action-to-event__c="path: /actions/leftHandGrab; event: primary_hand_grab;"
+                action-to-event__d="path: /actions/leftHandDrop; event: primary_hand_release;"
+            >
             </a-entity>
-          </a-entity>
-
-          <a-entity
-              id="player-camera"
-              class="camera"
-              camera
-              personal-space-bubble="radius: 0.4;"
-              pitch-yaw-rotator
-          >
+
             <a-entity
-                id="gaze-teleport"
-                position = "0.15 0 0"
+                id="player-right-controller"
+                class="right-controller"
+                hand-controls2="right"
+                tracked-controls
                 teleport-controls="
                     cameraRig: #player-rig;
                     teleportOrigin: #player-camera;
-                    button: gaze-teleport_;
+                    button: right-teleport_;
                     collisionEntities: [nav-mesh];
                     drawIncrementally: true;
                     incrementalDrawMs: 300;
                     hitOpacity: 0.3;
                     missOpacity: 0.1;
                     curveShootingSpeed: 12;"
-                action-to-event__start-teleport="path: /actions/startTeleport; event: gaze-teleport_down"
-                action-to-event__stop-teleport="path: /actions/stopTeleport; event: gaze-teleport_up"
-            ></a-entity>
-          </a-entity>
-
-          <a-entity
-              id="player-left-controller"
-              class="left-controller"
-              hand-controls2="left"
-              tracked-controls
-              teleport-controls="
-                  cameraRig: #player-rig;
-                  teleportOrigin: #player-camera;
-                  button: left-teleport_;
-                  collisionEntities: [nav-mesh];
-                  drawIncrementally: true;
-                  incrementalDrawMs: 300;
-                  hitOpacity: 0.3;
-                  missOpacity: 0.1;
-                  curveShootingSpeed: 12;"
-              haptic-feedback
-              body="type: static; shape: none;"
-              mixin="controller-super-hands"
-              controls-shape-offset
-              action-to-event__a="path: /actions/leftHandStartTeleport; event: left-teleport_down;"
-              action-to-event__b="path: /actions/leftHandStopTeleport; event: left-teleport_up;"
-              action-to-event__c="path: /actions/leftHandGrab; event: primary_hand_grab;"
-              action-to-event__d="path: /actions/leftHandDrop; event: primary_hand_release;"
-          >
-          </a-entity>
-
-          <a-entity
-              id="player-right-controller"
-              class="right-controller"
-              hand-controls2="right"
-              tracked-controls
-              teleport-controls="
-                  cameraRig: #player-rig;
-                  teleportOrigin: #player-camera;
-                  button: right-teleport_;
-                  collisionEntities: [nav-mesh];
-                  drawIncrementally: true;
-                  incrementalDrawMs: 300;
-                  hitOpacity: 0.3;
-                  missOpacity: 0.1;
-                  curveShootingSpeed: 12;"
-              haptic-feedback
-              body="type: static; shape: none;"
-              mixin="controller-super-hands"
-              controls-shape-offset
-              action-to-event__a="path: /actions/rightHandStartTeleport; event: right-teleport_down;"
-              action-to-event__b="path: /actions/rightHandStopTeleport; event: right-teleport_up;"
-              action-to-event__c="path: /actions/rightHandGrab; event: primary_hand_grab;"
-              action-to-event__d="path: /actions/rightHandDrop; event: primary_hand_release;"
-          >
-          </a-entity>
-
-          <a-entity gltf-model-plus="inflate: true;"
-                    class="model">
-            <template data-name="RootScene">
-              <a-entity
-                  ik-controller
-                  hand-pose__left
-                  hand-pose__right
-                  hand-pose-controller__left="networkedAvatar:#player-rig;eventSrc:#player-left-controller"
-                  hand-pose-controller__right="networkedAvatar:#player-rig;eventSrc:#player-right-controller"
-              ></a-entity>
-            </template>
+                haptic-feedback
+                body="type: static; shape: none;"
+                mixin="controller-super-hands"
+                controls-shape-offset
+                action-to-event__a="path: /actions/rightHandStartTeleport; event: right-teleport_down;"
+                action-to-event__b="path: /actions/rightHandStopTeleport; event: right-teleport_up;"
+                action-to-event__c="path: /actions/rightHandGrab; event: primary_hand_grab;"
+                action-to-event__d="path: /actions/rightHandDrop; event: primary_hand_release;"
+            >
+            </a-entity>
 
-            <template data-name="Neck">
-              <a-entity>
-                <a-entity class="nametag" visible="false" text ></a-entity>
-              </a-entity>
-            </template>
+            <a-entity gltf-model-plus="inflate: true;" class="model">
+                <template data-name="RootScene">
+                    <a-entity
+                        ik-controller
+                        hand-pose__left
+                        hand-pose__right
+                        hand-pose-controller__left="networkedAvatar:#player-rig;eventSrc:#player-left-controller"
+                        hand-pose-controller__right="networkedAvatar:#player-rig;eventSrc:#player-right-controller"
+                    ></a-entity>
+                </template>
+
+                <template data-name="Neck">
+                    <a-entity>
+                        <a-entity class="nametag" visible="false" text ></a-entity>
+                    </a-entity>
+                </template>
 
-            <template data-name="Head">
-              <a-entity id="player-head" visible="false" bone-visibility></a-entity>
-            </template>
+                <template data-name="Head">
+                    <a-entity id="player-head" visible="false" bone-visibility></a-entity>
+                </template>
 
-            <template data-name="LeftHand">
-              <a-entity bone-visibility></a-entity>
-            </template>
+                <template data-name="LeftHand">
+                    <a-entity bone-visibility hover-visuals="hand: left; controller: #player-left-controller"></a-entity>
+                </template>
 
-            <template data-name="RightHand">
-              <a-entity bone-visibility></a-entity>
-            </template>
+                <template data-name="RightHand">
+                    <a-entity bone-visibility hover-visuals="hand: right; controller: #player-right-controller"></a-entity>
+                </template>
 
-          </a-entity>
+            </a-entity>
         </a-entity>
 
         <!-- Environment -->
@@ -451,7 +477,12 @@
             nav-mesh-helper
             static-body="shape: none;"
         >
-            <a-entity id="environment-scene"/>
+            <a-entity id="environment-scene"></a-entity>
+        </a-entity>
+
+        <!-- Objects -->
+        <a-entity id="objects-root">
+            <a-entity id="objects-scene"></a-entity>
         </a-entity>
 
         <a-entity
diff --git a/src/hub.js b/src/hub.js
index edde9177a6142da53f0b701e67e1d605dcd53c7d..e8d69c7b2e297ff03e37df868e16ed94b1be457c 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";
@@ -54,12 +56,15 @@ import "./components/pinch-to-move";
 import "./components/pitch-yaw-rotator";
 import "./components/auto-scale-cannon-physics-body";
 import "./components/position-at-box-shape-border";
+import "./components/pinnable";
+import "./components/pin-networked-object-button";
 import "./components/remove-networked-object-button";
 import "./components/destroy-at-extreme-distances";
 import "./components/gamma-factor";
 import "./components/visible-to-owner";
 import "./components/camera-tool";
 import "./components/action-to-event";
+import "./components/stop-event-propagation";
 
 import ReactDOM from "react-dom";
 import React from "react";
@@ -103,6 +108,7 @@ import "./components/super-networked-interactable";
 import "./components/networked-counter";
 import "./components/event-repeater";
 import "./components/controls-shape-offset";
+import "./components/set-yxz-order";
 
 import "./components/cardboard-controls";
 
@@ -224,6 +230,11 @@ async function handleHubChannelJoined(entryManager, hubChannel, data) {
 
   console.log(`Scene URL: ${sceneUrl}`);
   const environmentScene = document.querySelector("#environment-scene");
+  const objectsScene = document.querySelector("#objects-scene");
+  const objectsUrl = getReticulumFetchUrl(`/${hub.hub_id}/objects.gltf`);
+  const objectsEl = document.createElement("a-entity");
+  objectsEl.setAttribute("gltf-model-plus", { src: objectsUrl, useCache: false, inflate: true });
+  objectsScene.appendChild(objectsEl);
 
   if (glbAsset || hasExtension) {
     const gltfEl = document.createElement("a-entity");
@@ -246,36 +257,41 @@ async function handleHubChannelJoined(entryManager, hubChannel, data) {
     .querySelector("#hud-hub-entry-link")
     .setAttribute("text", { value: `hub.link/${hub.entry_code}`, width: 1.1, align: "center" });
 
-  scene.setAttribute("networked-scene", {
-    room: hub.hub_id,
-    serverURL: process.env.JANUS_SERVER,
-    debug: !!isDebug
-  });
-
-  while (!scene.components["networked-scene"] || !scene.components["networked-scene"].data) await nextTick();
-
-  scene.components["networked-scene"]
-    .connect()
-    .then(() => {
-      NAF.connection.adapter.reliableTransport = (clientId, dataType, data) => {
-        const payload = { dataType, data };
+  // Wait for scene objects to load before connecting, so there is no race condition on network state.
+  objectsEl.addEventListener("model-loaded", async el => {
+    if (el.target !== objectsEl) return;
 
-        if (clientId) {
-          payload.clientId = clientId;
-        }
+    scene.setAttribute("networked-scene", {
+      room: hub.hub_id,
+      serverURL: process.env.JANUS_SERVER,
+      debug: !!isDebug
+    });
 
-        hubChannel.channel.push("naf", payload);
-      };
-    })
-    .catch(connectError => {
-      // hacky until we get return codes
-      const isFull = connectError.error && connectError.error.msg.match(/\bfull\b/i);
-      console.error(connectError);
-      remountUI({ roomUnavailableReason: isFull ? "full" : "connect_error" });
-      entryManager.exitScene();
+    while (!scene.components["networked-scene"] || !scene.components["networked-scene"].data) await nextTick();
+
+    scene.components["networked-scene"]
+      .connect()
+      .then(() => {
+        NAF.connection.adapter.reliableTransport = (clientId, dataType, data) => {
+          const payload = { dataType, data };
+
+          if (clientId) {
+            payload.clientId = clientId;
+          }
+
+          hubChannel.channel.push("naf", payload);
+        };
+      })
+      .catch(connectError => {
+        // hacky until we get return codes
+        const isFull = connectError.error && connectError.error.msg.match(/\bfull\b/i);
+        console.error(connectError);
+        remountUI({ roomUnavailableReason: isFull ? "full" : "connect_error" });
+        entryManager.exitScene();
 
-      return;
-    });
+        return;
+      });
+  });
 }
 
 async function runBotMode(scene, entryManager) {
@@ -308,6 +324,8 @@ document.addEventListener("DOMContentLoaded", async () => {
   }
 
   const scene = document.querySelector("a-scene");
+  scene.removeAttribute("keyboard-shortcuts"); // Remove F and ESC hotkeys from aframe
+
   const hubChannel = new HubChannel(store);
   const entryManager = new SceneEntryManager(hubChannel);
   entryManager.init();
@@ -316,7 +334,21 @@ document.addEventListener("DOMContentLoaded", async () => {
 
   window.APP.scene = scene;
 
+  scene.addEventListener("enter-vr", () => {
+    document.body.classList.add("vr-mode");
+
+    if (!scene.is("entered")) {
+      // If VR headset is activated, refreshing page will fire vrdisplayactivate
+      // which puts A-Frame in VR mode, so exit VR mode whenever it is attempted
+      // to be entered and we haven't entered the room yet.
+      scene.exitVR();
+    }
+  });
+
+  scene.addEventListener("exit-vr", () => document.body.classList.remove("vr-mode"));
+
   registerNetworkSchemas();
+
   remountUI({
     hubChannel,
     linkChannel,
@@ -326,6 +358,8 @@ document.addEventListener("DOMContentLoaded", async () => {
     initialIsSubscribed: subscriptions.isSubscribed()
   });
 
+  scene.addEventListener("action_focus_chat", () => document.querySelector(".chat-focus-target").focus());
+
   pollForSupportAvailability(isSupportAvailable => remountUI({ isSupportAvailable }));
 
   const platformUnsupportedReason = getPlatformUnsupportedReason();
@@ -431,7 +465,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;
@@ -502,8 +536,9 @@ 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 });
   });
 
   linkChannel.setSocket(socket);
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/network-schemas.js b/src/network-schemas.js
index 97f55c606d9b668c260f4ba82a061325e1711181..f460f6e5ddce701e584b79b8f87b822114a04a31 100644
--- a/src/network-schemas.js
+++ b/src/network-schemas.js
@@ -111,7 +111,8 @@ function registerNetworkSchemas() {
       {
         component: "media-pager",
         property: "index"
-      }
+      },
+      "pinnable"
     ]
   });
 
diff --git a/src/react-components/chat-message.js b/src/react-components/chat-message.js
new file mode 100644
index 0000000000000000000000000000000000000000..a7aa72b55cedb4a70c4905de8ffdf678797ff377
--- /dev/null
+++ b/src/react-components/chat-message.js
@@ -0,0 +1,118 @@
+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={classNames(styles.iconButton, 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 fe99b50763f2aa4d7cc9394b8a8ecd19565754bf..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";
@@ -454,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();
     }
   };
 
@@ -687,6 +688,9 @@ 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 (
@@ -700,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" />
@@ -999,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}>
@@ -1010,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
@@ -1034,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..0bc3548b3df4f68962cc48c93c8856b290ac1521 100644
--- a/src/scene-entry-manager.js
+++ b/src/scene-entry-manager.js
@@ -1,6 +1,7 @@
 import qsTruthy from "./utils/qs_truthy";
 import screenfull from "screenfull";
 import nextTick from "./utils/next-tick";
+import pinnedEntityToGltf from "./utils/pinned-entity-to-gltf";
 
 const playerHeight = 1.6;
 const isBotMode = qsTruthy("bot");
@@ -213,12 +214,34 @@ export default class SceneEntryManager {
       spawnMediaInfrontOfPlayer(e.detail, contentOrigin);
     });
 
+    this.scene.addEventListener("pinned", e => {
+      const el = e.detail.el;
+      const networkId = el.components.networked.data.networkId;
+      const gltfNode = pinnedEntityToGltf(el);
+      el.setAttribute("networked", { persistent: true });
+
+      this.hubChannel.pin(networkId, gltfNode);
+    });
+
+    this.scene.addEventListener("unpinned", e => {
+      const el = e.detail.el;
+      const components = el.components;
+      const networked = components.networked;
+
+      if (!networked || !networked.data || !NAF.utils.isMine(el)) return;
+
+      const networkId = components.networked.data.networkId;
+      el.setAttribute("networked", { persistent: false });
+
+      this.hubChannel.unpin(networkId);
+    });
+
     this.scene.addEventListener("object_spawned", e => {
       this.hubChannel.sendObjectSpawnedEvent(e.detail.objectType);
     });
 
     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 +282,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/spoke.html b/src/spoke.html
index 4cdea285a2a48fe96ef7ae28b8bc8826f328bd4b..2a93ef9d2327135e17ec2481a1f767d7d0b1efdd 100644
--- a/src/spoke.html
+++ b/src/spoke.html
@@ -20,7 +20,7 @@
 </head>
 
 <body>
-  <div id="ui-root"></div>
+    <div id="ui-root"></div>
 </body>
 
 </html>
diff --git a/src/spoke.js b/src/spoke.js
index c82a3c898a3c4080af29f8046f20f09a7ca3a9f6..d1f4fb3aa7322667d62faa8edf2b57adc296f48a 100644
--- a/src/spoke.js
+++ b/src/spoke.js
@@ -77,7 +77,7 @@ class SpokeLanding extends Component {
         query: `
           {
             repository(owner: "mozillareality", name: "spoke") {
-          releases(
+              releases(
                 orderBy: { field: CREATED_AT, direction: DESC },
                 first: 5
               ) {
diff --git a/src/systems/tunnel-effect.js b/src/systems/tunnel-effect.js
index 8375964060a8c85977fe999ee4f73086f74b75d8..88217e6757d8898c89009bef89b2a7c201b28b14 100644
--- a/src/systems/tunnel-effect.js
+++ b/src/systems/tunnel-effect.js
@@ -25,7 +25,7 @@ AFRAME.registerSystem("tunneleffect", {
   schema: {
     targetComponent: { type: "string", default: "character-controller" },
     radius: { type: "number", default: 1.0, min: 0.25 },
-    minRadius: { type: "number", default: 0.25, min: 0.1 },
+    minRadius: { type: "number", default: 0.3, min: 0.1 },
     maxSpeed: { type: "number", default: 0.5, min: 0.1 },
     softest: { type: "number", default: 0.1, min: 0.0 },
     opacity: { type: "number", default: 1, min: 0.0 }
diff --git a/src/systems/userinput/bindings/keyboard-mouse-user.js b/src/systems/userinput/bindings/keyboard-mouse-user.js
index 351b9e3e097205ef9ba10cd88a7140e0d045b993..f0166dc4b602022720cd3e09049eecc4b98fb59a 100644
--- a/src/systems/userinput/bindings/keyboard-mouse-user.js
+++ b/src/systems/userinput/bindings/keyboard-mouse-user.js
@@ -74,6 +74,16 @@ export const keyboardMouseUserBindings = addSetsToBindings({
       dest: { value: paths.actions.snapRotateRight },
       xform: xforms.rising
     },
+    {
+      src: { value: paths.device.keyboard.key(" ") },
+      dest: { value: paths.actions.ensureFrozen },
+      xform: xforms.copy
+    },
+    {
+      src: { value: paths.device.keyboard.key(" ") },
+      dest: { value: paths.actions.thaw },
+      xform: xforms.falling
+    },
     {
       src: { value: paths.device.hud.penButton },
       dest: { value: paths.actions.spawnPen },
@@ -113,6 +123,15 @@ export const keyboardMouseUserBindings = addSetsToBindings({
       },
       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")
diff --git a/src/systems/userinput/bindings/oculus-go-user.js b/src/systems/userinput/bindings/oculus-go-user.js
index 15c4604fa6c71f2775201049ed9e3ef706ef19d6..048eaf750b9fee0a8ee8d39dfacf4c0715649221 100644
--- a/src/systems/userinput/bindings/oculus-go-user.js
+++ b/src/systems/userinput/bindings/oculus-go-user.js
@@ -5,16 +5,19 @@ import { addSetsToBindings } from "./utils";
 
 const touchpad = "/vars/oculusgo/touchpad";
 const touchpadPressed = "/vars/oculusgo/touchpadPressed";
+const touchpadReleased = "/vars/oculusgo/touchpadReleased";
 const dpadNorth = "/vars/oculusgo/dpad/north";
 const dpadSouth = "/vars/oculusgo/dpad/south";
 const dpadEast = "/vars/oculusgo/dpad/east";
 const dpadWest = "/vars/oculusgo/dpad/west";
 const dpadCenter = "/vars/oculusgo/dpad/center";
+const dpadCenterStrip = "/vars/oculusgo/dpad/centerStrip";
 
 const triggerRisingRoot = "oculusGoTriggerRising";
 const triggerFallingRoot = "oculusGoTriggerFalling";
 const dpadEastRoot = "oculusGoDpadEast";
 const dpadWestRoot = "oculusGoDpadWest";
+const rootForFrozenOverrideWhenHolding = "rootForFrozenOverrideWhenHolding";
 
 const grabBinding = {
   src: {
@@ -43,6 +46,13 @@ export const oculusGoUserBindings = addSetsToBindings({
       dest: { value: touchpadPressed },
       xform: xforms.rising
     },
+    {
+      src: {
+        value: paths.device.oculusgo.button("touchpad").pressed
+      },
+      dest: { value: touchpadReleased },
+      xform: xforms.falling
+    },
     {
       src: {
         value: touchpad
@@ -56,6 +66,30 @@ export const oculusGoUserBindings = addSetsToBindings({
       },
       xform: xforms.vec2dpad(0.8)
     },
+    {
+      src: [dpadNorth, dpadSouth, dpadCenter],
+      dest: { value: dpadCenterStrip },
+      xform: xforms.any
+    },
+    {
+      src: {
+        value: dpadCenterStrip,
+        bool: paths.device.oculusgo.button("touchpad").pressed
+      },
+      dest: {
+        value: paths.actions.ensureFrozen
+      },
+      root: rootForFrozenOverrideWhenHolding,
+      priority: 100,
+      xform: xforms.copyIfTrue
+    },
+    {
+      src: { value: touchpadReleased },
+      dest: {
+        value: paths.actions.thaw
+      },
+      xform: xforms.copy
+    },
     {
       src: {
         value: dpadEast,
@@ -139,6 +173,13 @@ export const oculusGoUserBindings = addSetsToBindings({
       },
       dest: { value: paths.actions.cursor.modDelta },
       xform: xforms.touch_axis_scroll()
+    },
+    {
+      src: null,
+      dest: { value: paths.actions.ensureFrozen },
+      root: rootForFrozenOverrideWhenHolding,
+      priority: 200,
+      xform: xforms.always(false)
     }
   ],
 
@@ -183,8 +224,8 @@ export const oculusGoUserBindings = addSetsToBindings({
     },
     {
       src: {
-        value: dpadCenter,
-        bool: touchpadPressed
+        value: dpadCenterStrip,
+        bool: touchpadReleased
       },
       dest: { value: paths.actions.cursor.drop },
       xform: xforms.copyIfTrue
@@ -235,8 +276,8 @@ export const oculusGoUserBindings = addSetsToBindings({
     },
     {
       src: {
-        value: dpadCenter,
-        bool: touchpadPressed
+        value: dpadCenterStrip,
+        bool: touchpadReleased
       },
       dest: { value: paths.actions.cursor.drop },
       xform: xforms.copyIfTrue
diff --git a/src/systems/userinput/bindings/oculus-touch-user.js b/src/systems/userinput/bindings/oculus-touch-user.js
index 4688dac41123f9e5fe66cfcbb57f17e2ba8ce7ac..bbc01d771a1bf7d5137d158978d80709457fddde 100644
--- a/src/systems/userinput/bindings/oculus-touch-user.js
+++ b/src/systems/userinput/bindings/oculus-touch-user.js
@@ -59,6 +59,16 @@ const rightTouchSnapLeft = `${name}/right/snap-left`;
 const keyboardSnapRight = `${name}/keyboard/snap-right`;
 const keyboardSnapLeft = `${name}/keyboard/snap-left`;
 
+const rootForFrozenOverrideWhenHolding = "rootForFrozenOverrideWhenHolding";
+
+const lowerButtons = `${name}buttons/lower`;
+
+const ensureFrozenViaButtons = `${name}buttons/ensureFrozen`;
+const ensureFrozenViaKeyboard = `${name}keyboard/ensureFrozen`;
+
+const thawViaButtons = `${name}buttons/thaw`;
+const thawViaKeyboard = `${name}keyboard/thaw`;
+
 export const oculusTouchUserBindings = addSetsToBindings({
   [sets.global]: [
     {
@@ -183,6 +193,32 @@ export const oculusTouchUserBindings = addSetsToBindings({
       dest: { value: paths.actions.snapRotateRight },
       xform: xforms.any
     },
+    {
+      src: { value: paths.device.keyboard.key(" ") },
+      dest: { value: ensureFrozenViaKeyboard },
+      xform: xforms.copy
+    },
+    {
+      src: [leftButton("x").pressed, rightButton("a").pressed],
+      dest: { value: lowerButtons },
+      xform: xforms.any
+    },
+    {
+      src: { value: lowerButtons },
+      dest: { value: ensureFrozenViaButtons },
+      root: rootForFrozenOverrideWhenHolding,
+      xform: xforms.copy
+    },
+    {
+      src: { value: lowerButtons },
+      dest: { value: thawViaButtons },
+      xform: xforms.falling
+    },
+    {
+      src: { value: paths.device.keyboard.key(" ") },
+      dest: { value: thawViaKeyboard },
+      xform: xforms.falling
+    },
     {
       src: {
         value: rightDpadWest
@@ -248,6 +284,33 @@ export const oculusTouchUserBindings = addSetsToBindings({
       dest: { vec2: wasd_vec2 },
       xform: xforms.wasd_to_vec2
     },
+    {
+      src: {
+        value: paths.device.keyboard.key("t")
+      },
+      dest: {
+        value: paths.actions.focusChat
+      },
+      xform: xforms.rising
+    },
+    {
+      src: {
+        value: paths.device.keyboard.key("l")
+      },
+      dest: {
+        value: paths.actions.logDebugFrame
+      },
+      xform: xforms.rising
+    },
+    {
+      src: {
+        value: paths.device.keyboard.key("m")
+      },
+      dest: {
+        value: paths.actions.muteMic
+      },
+      xform: xforms.rising
+    },
     {
       src: {
         first: wasd_vec2,
@@ -278,7 +341,7 @@ export const oculusTouchUserBindings = addSetsToBindings({
     },
     {
       src: {
-        value: leftButton("x").pressed
+        value: leftButton("y").pressed
       },
       dest: {
         value: leftBoost
@@ -287,7 +350,7 @@ export const oculusTouchUserBindings = addSetsToBindings({
     },
     {
       src: {
-        value: rightButton("a").pressed
+        value: rightButton("b").pressed
       },
       dest: {
         value: rightBoost
@@ -389,6 +452,13 @@ export const oculusTouchUserBindings = addSetsToBindings({
       xform: xforms.falling,
       root: leftGripFalling,
       priority: 200
+    },
+    {
+      src: null,
+      dest: { value: ensureFrozenViaButtons },
+      root: rootForFrozenOverrideWhenHolding,
+      priority: 100,
+      xform: xforms.always(false)
     }
   ],
 
@@ -499,6 +569,13 @@ export const oculusTouchUserBindings = addSetsToBindings({
       src: [cursorDrop1, cursorDrop2],
       dest: { value: paths.actions.cursor.drop },
       xform: xforms.any
+    },
+    {
+      src: null,
+      dest: { value: ensureFrozenViaButtons },
+      root: rootForFrozenOverrideWhenHolding,
+      priority: 100,
+      xform: xforms.always(false)
     }
   ],
 
@@ -562,6 +639,13 @@ export const oculusTouchUserBindings = addSetsToBindings({
       src: [rightHandDrop1, rightHandDrop2],
       dest: { value: paths.actions.rightHand.drop },
       xform: xforms.any
+    },
+    {
+      src: null,
+      dest: { value: ensureFrozenViaButtons },
+      root: rootForFrozenOverrideWhenHolding,
+      priority: 100,
+      xform: xforms.always(false)
     }
   ],
   [sets.rightHandHoveringOnPen]: [],
@@ -665,5 +749,17 @@ export const oculusTouchUserBindings = addSetsToBindings({
     }
   ],
 
-  [sets.rightHandHoveringOnNothing]: []
+  [sets.rightHandHoveringOnNothing]: [],
+  [sets.globalPost]: [
+    {
+      src: [ensureFrozenViaButtons, ensureFrozenViaKeyboard],
+      dest: { value: paths.actions.ensureFrozen },
+      xform: xforms.any
+    },
+    {
+      src: [thawViaButtons, thawViaKeyboard],
+      dest: { value: paths.actions.thaw },
+      xform: xforms.any
+    }
+  ]
 });
diff --git a/src/systems/userinput/bindings/vive-user.js b/src/systems/userinput/bindings/vive-user.js
index 8a47985e8803fb7e3c78a6f9636b2e346547dfe0..0a1702c19d3e123245e10fdb6b6a9e0ad31ef1aa 100644
--- a/src/systems/userinput/bindings/vive-user.js
+++ b/src/systems/userinput/bindings/vive-user.js
@@ -31,10 +31,6 @@ const characterAcceleration = v("nonNormalizedCharacterAcceleration");
 const lGripFalling = v("left/grip/falling");
 const lGripRising = v("left/grip/rising");
 const leftBoost = v("left/boost");
-const lTriggerStartTeleport = v("left/trigger/startTeleport");
-const lDpadCenterStartTeleport = v("left/dpadCenter/startTeleport");
-const lTriggerStopTeleport = v("left/trigger/stopTeleport");
-const lTouchpadStopTeleport = v("left/touchpad/stopTeleport");
 
 const rButton = paths.device.vive.right.button;
 const rAxis = paths.device.vive.right.axis;
@@ -45,22 +41,29 @@ const rDpadSouth = v("right/dpad/south");
 const rDpadEast = v("right/dpad/east");
 const rDpadWest = v("right/dpad/west");
 const rDpadCenter = v("right/dpad/center");
+const rDpadCenterStrip = v("right/dpad/centerStrip");
 const rTriggerFalling = v("right/trigger/falling");
 const rTriggerRising = v("right/trigger/rising");
 const rTouchpadRising = v("right/touchpad/rising");
+const rTouchpadFalling = v("right/touchpad/falling");
 const rightBoost = v("right/boost");
 const rGripRising = v("right/grip/rising");
 const rTriggerRisingGrab = v("right/trigger/rising/grab");
 const rGripRisingGrab = v("right/grab/rising/grab");
-const rGripFalling = v("right/grip/rising");
+const rGripFalling = v("right/grip/falling");
 const cursorDrop1 = v("right/cursorDrop1");
 const cursorDrop2 = v("right/cursorDrop2");
 const rHandDrop1 = v("right/drop1");
 const rHandDrop2 = v("right/drop2");
-const rTriggerStartTeleport = v("right/trigger/startTeleport");
-const rDpadCenterStartTeleport = v("right/dpadCenter/startTeleport");
 const rTriggerStopTeleport = v("right/trigger/stopTeleport");
 const rTouchpadStopTeleport = v("right/touchpad/stopTeleport");
+const rootForFrozenOverrideWhenHolding = "rootForFrozenOverrideWhenHolding";
+
+const ensureFrozenViaDpad = v("dpad/ensureFrozen");
+const ensureFrozenViaKeyboard = v("keyboard/ensureFrozen");
+
+const thawViaDpad = v("dpad/thaw");
+const thawViaKeyboard = v("keyboard/thaw");
 
 const rSnapRight = v("right/snap-right");
 const rSnapLeft = v("right/snap-left");
@@ -75,48 +78,22 @@ const wasd_vec2 = k("wasd_vec2");
 const arrows_vec2 = k("arrows_vec2");
 const keyboardBoost = k("boost");
 
-const teleportLeft = [
+const nothingHeldLeft = [
   {
     src: { value: lButton("trigger").pressed },
-    dest: { value: lTriggerStartTeleport },
+    dest: { value: paths.actions.leftHand.startTeleport },
     xform: xforms.rising,
     root: lTriggerRising,
     priority: 100
-  },
-  {
-    src: {
-      bool: lTouchpadRising,
-      value: lDpadCenter
-    },
-    dest: { value: lDpadCenterStartTeleport },
-    xform: xforms.copyIfTrue
-  },
-  {
-    src: [lTriggerStartTeleport, lDpadCenterStartTeleport],
-    dest: { value: paths.actions.leftHand.startTeleport },
-    xform: xforms.any
   }
 ];
-const teleportRight = [
+const nothingHeldRight = [
   {
     src: { value: rButton("trigger").pressed },
-    dest: { value: rTriggerStartTeleport },
+    dest: { value: paths.actions.rightHand.startTeleport },
     xform: xforms.rising,
     root: rTriggerRising,
     priority: 100
-  },
-  {
-    src: {
-      bool: rTouchpadRising,
-      value: rDpadCenter
-    },
-    dest: { value: rDpadCenterStartTeleport },
-    xform: xforms.copyIfTrue
-  },
-  {
-    src: [rTriggerStartTeleport, rDpadCenterStartTeleport],
-    dest: { value: paths.actions.rightHand.startTeleport },
-    xform: xforms.any
   }
 ];
 
@@ -230,7 +207,12 @@ export const viveUserBindings = addSetsToBindings({
         west: rDpadWest,
         center: rDpadCenter
       },
-      xform: xforms.vec2dpad(0.35)
+      xform: xforms.vec2dpad(0.35, false, true)
+    },
+    {
+      src: [rDpadNorth, rDpadSouth, rDpadCenter],
+      dest: { value: rDpadCenterStrip },
+      xform: xforms.any
     },
     {
       src: {
@@ -241,6 +223,15 @@ export const viveUserBindings = addSetsToBindings({
       },
       xform: xforms.rising
     },
+    {
+      src: {
+        value: rButton("touchpad").pressed
+      },
+      dest: {
+        value: rTouchpadFalling
+      },
+      xform: xforms.falling
+    },
     {
       src: {
         bool: rTouchpadRising,
@@ -263,6 +254,27 @@ export const viveUserBindings = addSetsToBindings({
       dest: { value: paths.actions.snapRotateRight },
       xform: xforms.any
     },
+    {
+      src: { value: paths.device.keyboard.key(" ") },
+      dest: { value: ensureFrozenViaKeyboard },
+      xform: xforms.copy
+    },
+    {
+      src: { value: paths.device.keyboard.key(" ") },
+      dest: { value: thawViaKeyboard },
+      xform: xforms.falling
+    },
+    {
+      src: { value: rButton("touchpad").pressed, bool: rDpadCenterStrip },
+      dest: { value: ensureFrozenViaDpad },
+      root: rootForFrozenOverrideWhenHolding,
+      xform: xforms.copyIfTrue
+    },
+    {
+      src: { value: rTouchpadFalling },
+      dest: { value: thawViaDpad },
+      xform: xforms.copy
+    },
     {
       src: {
         bool: rTouchpadRising,
@@ -338,6 +350,33 @@ export const viveUserBindings = addSetsToBindings({
       dest: { vec2: wasd_vec2 },
       xform: xforms.wasd_to_vec2
     },
+    {
+      src: {
+        value: paths.device.keyboard.key("t")
+      },
+      dest: {
+        value: paths.actions.focusChat
+      },
+      xform: xforms.rising
+    },
+    {
+      src: {
+        value: paths.device.keyboard.key("l")
+      },
+      dest: {
+        value: paths.actions.logDebugFrame
+      },
+      xform: xforms.rising
+    },
+    {
+      src: {
+        value: paths.device.keyboard.key("m")
+      },
+      dest: {
+        value: paths.actions.muteMic
+      },
+      xform: xforms.rising
+    },
     {
       src: {
         first: wasd_vec2,
@@ -424,29 +463,19 @@ export const viveUserBindings = addSetsToBindings({
       xform: xforms.any
     }
   ],
-  [sets.leftHandHoveringOnNothing]: [...teleportLeft],
+  [sets.leftHandHoveringOnNothing]: [...nothingHeldLeft],
 
   [sets.leftHandTeleporting]: [
     {
       src: { value: lButton("trigger").pressed },
-      dest: { value: lTriggerStopTeleport },
+      dest: { value: paths.actions.leftHand.stopTeleport },
       xform: xforms.falling,
       root: lTriggerFalling,
       priority: 100
-    },
-    {
-      src: { value: lButton("touchpad").pressed },
-      dest: { value: lTouchpadStopTeleport },
-      xform: xforms.falling
-    },
-    {
-      src: [lTriggerStopTeleport, lTouchpadStopTeleport],
-      dest: { value: paths.actions.leftHand.stopTeleport },
-      xform: xforms.any
     }
   ],
 
-  [sets.rightHandHoveringOnNothing]: [...teleportRight],
+  [sets.rightHandHoveringOnNothing]: [...nothingHeldRight],
 
   [sets.cursorHoveringOnNothing]: [],
 
@@ -607,6 +636,13 @@ export const viveUserBindings = addSetsToBindings({
       src: [cursorDrop1, cursorDrop2],
       dest: { value: paths.actions.cursor.drop },
       xform: xforms.any
+    },
+    {
+      src: null,
+      dest: { value: ensureFrozenViaDpad },
+      root: rootForFrozenOverrideWhenHolding,
+      priority: 100,
+      xform: xforms.always(false)
     }
   ],
 
@@ -710,6 +746,13 @@ export const viveUserBindings = addSetsToBindings({
       src: [rHandDrop1, rHandDrop2],
       dest: { value: paths.actions.rightHand.drop },
       xform: xforms.any
+    },
+    {
+      src: null,
+      dest: { value: ensureFrozenViaDpad },
+      root: rootForFrozenOverrideWhenHolding,
+      priority: 100,
+      xform: xforms.always(false)
     }
   ],
   [sets.rightHandHoveringOnPen]: [],
@@ -806,5 +849,17 @@ export const viveUserBindings = addSetsToBindings({
       root: rTriggerFalling,
       priority: 400
     }
+  ],
+  [sets.globalPost]: [
+    {
+      src: [ensureFrozenViaDpad, ensureFrozenViaKeyboard],
+      dest: { value: paths.actions.ensureFrozen },
+      xform: xforms.any
+    },
+    {
+      src: [thawViaDpad, thawViaKeyboard],
+      dest: { value: paths.actions.thaw },
+      xform: xforms.any
+    }
   ]
 });
diff --git a/src/systems/userinput/paths.js b/src/systems/userinput/paths.js
index d9f74a9ac6e437ead10d2e57237275c2de44aa44..7f38c58cbefcd40e9f9b08c0e47114f6ebab3ee2 100644
--- a/src/systems/userinput/paths.js
+++ b/src/systems/userinput/paths.js
@@ -12,7 +12,10 @@ paths.actions.boost = "/actions/boost";
 paths.actions.startGazeTeleport = "/actions/startTeleport";
 paths.actions.stopGazeTeleport = "/actions/stopTeleport";
 paths.actions.spawnPen = "/actions/spawnPen";
+paths.actions.ensureFrozen = "/actions/ensureFrozen";
+paths.actions.thaw = "/actions/thaw";
 paths.actions.muteMic = "/actions/muteMic";
+paths.actions.focusChat = "/actions/focusChat";
 paths.actions.cursor = {};
 paths.actions.cursor.pose = "/actions/cursorPose";
 paths.actions.cursor.grab = "/actions/cursorGrab";
diff --git a/src/systems/userinput/sets.js b/src/systems/userinput/sets.js
index e29d0489e0d091edf40ec8746d782237b3fa263c..293179f1bb998d3bb4f780c71ae2016241bba78b 100644
--- a/src/systems/userinput/sets.js
+++ b/src/systems/userinput/sets.js
@@ -25,3 +25,4 @@ sets.leftHandHoldingPen = "leftHandHoldingPen";
 sets.leftHandHoldingCamera = "leftHandHoldingCamera";
 sets.leftHandHoldingInteractable = "leftHandHoldingInteractable";
 sets.leftHandHoveringOnNothing = "leftHandHoveringOnNothing";
+sets.globalPost = "globalPost";
diff --git a/src/systems/userinput/userinput.js b/src/systems/userinput/userinput.js
index 45a25519747488df70b96226d43f1c734b88a38c..48df3c938a3f1570326781935918e7ceb4896187 100644
--- a/src/systems/userinput/userinput.js
+++ b/src/systems/userinput/userinput.js
@@ -157,30 +157,45 @@ AFRAME.registerSystem("userinput", {
 
   init() {
     this.frame = {};
-    this.activeSets = new Set([sets.global]);
+
+    this.activeSets = new Set([sets.global, sets.globalPost]);
     this.pendingSetChanges = [];
     this.xformStates = new Map();
     this.activeDevices = new Set([new MouseDevice(), new AppAwareMouseDevice(), new KeyboardDevice(), new HudDevice()]);
     this.registeredMappings = new Set([keyboardDebuggingBindings]);
     this.registeredMappingsChanged = true;
 
+    let connectedGamepadBindings;
+
     const appAwareTouchscreenDevice = new AppAwareTouchscreenDevice();
+
+    const disableNonGamepadBindings = () => {
+      if (AFRAME.utils.device.isMobile()) {
+        this.activeDevices.delete(appAwareTouchscreenDevice);
+        this.registeredMappings.delete(touchscreenUserBindings);
+      } else {
+        this.registeredMappings.delete(keyboardMouseUserBindings);
+      }
+    };
+
+    const enableNonGamepadBindings = () => {
+      if (AFRAME.utils.device.isMobile()) {
+        this.activeDevices.add(appAwareTouchscreenDevice);
+        this.registeredMappings.add(touchscreenUserBindings);
+      } else {
+        this.registeredMappings.add(keyboardMouseUserBindings);
+      }
+    };
+
     const updateBindingsForVRMode = () => {
       const inVRMode = this.el.sceneEl.is("vr-mode");
-      if (AFRAME.utils.device.isMobile()) {
-        if (inVRMode) {
-          this.activeDevices.delete(appAwareTouchscreenDevice);
-          this.registeredMappings.delete(touchscreenUserBindings);
-        } else {
-          this.activeDevices.add(appAwareTouchscreenDevice);
-          this.registeredMappings.add(touchscreenUserBindings);
-        }
+
+      if (inVRMode) {
+        disableNonGamepadBindings();
+        this.registeredMappings.add(connectedGamepadBindings);
       } else {
-        if (inVRMode) {
-          this.registeredMappings.delete(keyboardMouseUserBindings);
-        } else {
-          this.registeredMappings.add(keyboardMouseUserBindings);
-        }
+        enableNonGamepadBindings();
+        this.registeredMappings.delete(connectedGamepadBindings);
       }
       this.registeredMappingsChanged = true;
     };
@@ -192,6 +207,7 @@ AFRAME.registerSystem("userinput", {
       "gamepadconnected",
       e => {
         let gamepadDevice;
+        const entered = this.el.sceneEl.is("entered");
         for (let i = 0; i < this.activeDevices.length; i++) {
           const activeDevice = this.activeDevices[i];
           if (activeDevice.gamepad && activeDevice.gamepad === e.gamepad) {
@@ -201,23 +217,28 @@ AFRAME.registerSystem("userinput", {
         }
         if (e.gamepad.id === "OpenVR Gamepad") {
           gamepadDevice = new ViveControllerDevice(e.gamepad);
-          this.registeredMappings.add(viveUserBindings);
+          connectedGamepadBindings = viveUserBindings;
         } else if (e.gamepad.id.startsWith("Oculus Touch")) {
           gamepadDevice = new OculusTouchControllerDevice(e.gamepad);
-          this.registeredMappings.add(oculusTouchUserBindings);
+          connectedGamepadBindings = oculusTouchUserBindings;
         } else if (e.gamepad.id === "Oculus Go Controller") {
           gamepadDevice = new OculusGoControllerDevice(e.gamepad);
-          this.registeredMappings.add(oculusGoUserBindings);
+          connectedGamepadBindings = oculusGoUserBindings;
         } else if (e.gamepad.id === "Daydream Controller") {
           gamepadDevice = new DaydreamControllerDevice(e.gamepad);
-          this.registeredMappings.add(daydreamUserBindings);
+          connectedGamepadBindings = daydreamUserBindings;
         } else if (e.gamepad.id.includes("Xbox")) {
           gamepadDevice = new XboxControllerDevice(e.gamepad);
-          this.registeredMappings.add(xboxControllerUserBindings);
+          connectedGamepadBindings = xboxControllerUserBindings;
         } else {
           gamepadDevice = new GamepadDevice(e.gamepad);
-          this.registeredMappings.add(gamepadBindings);
+          connectedGamepadBindings = gamepadBindings;
         }
+
+        if (entered) {
+          this.registeredMappings.add(connectedGamepadBindings);
+        }
+
         this.activeDevices.add(gamepadDevice);
         this.registeredMappingsChanged = true;
       },
diff --git a/src/utils/auto-box-collider.js b/src/utils/auto-box-collider.js
index 0a8b69037f59147065a141273616e30a034d6ee6..cfb8882fae100ca7097bf36caa9d4d625587dc93 100644
--- a/src/utils/auto-box-collider.js
+++ b/src/utils/auto-box-collider.js
@@ -1,17 +1,25 @@
 const rotation = new THREE.Euler();
 export function getBox(entity, boxRoot) {
   const box = new THREE.Box3();
+
   rotation.copy(entity.object3D.rotation);
   entity.object3D.rotation.set(0, 0, 0);
   entity.object3D.updateMatrixWorld(true);
+
   box.setFromObject(boxRoot);
-  entity.object3D.worldToLocal(box.min);
-  entity.object3D.worldToLocal(box.max);
-  entity.object3D.rotation.copy(rotation);
+
+  if (!box.isEmpty()) {
+    entity.object3D.worldToLocal(box.min);
+    entity.object3D.worldToLocal(box.max);
+    entity.object3D.rotation.copy(rotation);
+  }
+
   return box;
 }
 
 export function getScaleCoefficient(length, box) {
+  if (box.isEmpty()) return 1.0;
+
   const { max, min } = box;
   const dX = Math.abs(max.x - min.x);
   const dY = Math.abs(max.y - min.y);
diff --git a/src/utils/hub-channel.js b/src/utils/hub-channel.js
index 81714efc2fe069ce43cc8d0721c4550a2f42667d..96d8860acfd9137e0d2b7e9708b8506cc828584a 100644
--- a/src/utils/hub-channel.js
+++ b/src/utils/hub-channel.js
@@ -104,6 +104,14 @@ export default class HubChannel {
     this.channel.push("message", { body, type });
   };
 
+  pin = (id, gltfNode) => {
+    this.channel.push("pin", { id, gltf_node: gltfNode });
+  };
+
+  unpin = id => {
+    this.channel.push("unpin", { id });
+  };
+
   requestSupport = () => {
     this.channel.push("events:request_support", {});
   };
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..7a05a2c725b9ade88eb21bdd0df6e075459f8920 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,71 @@ 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;
+    }
+
+    // HACK, this routine inadvertently leaves the A-Frame shaders wired to the old, dark
+    // material, so maps cannot be updated at runtime. This breaks UI elements who have
+    // hover/toggle state, so for now just skip these while we figure out a more correct
+    // solution.
+    if (object.el.classList.contains("ui")) 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..7cbca9fa0490fbe11029cebba588f8f09598d8db 100644
--- a/src/utils/phoenix-utils.js
+++ b/src/utils/phoenix-utils.js
@@ -36,10 +36,19 @@ export function connectToReticulum(debug = false) {
   return socket;
 }
 
-export function getReticulumFetchUrl(path) {
+const resolverLink = document.createElement("a");
+export function getReticulumFetchUrl(path, absolute = false) {
   if (process.env.RETICULUM_SERVER) {
     return `https://${process.env.RETICULUM_SERVER}${path}`;
+  } else if (absolute) {
+    resolverLink.href = path;
+    return resolverLink.href;
   } else {
     return path;
   }
 }
+
+export function getLandingPageForPhoto(photoUrl) {
+  const parsedUrl = new URL(photoUrl);
+  return getReticulumFetchUrl(parsedUrl.pathname.replace(".png", ".html") + parsedUrl.search, true);
+}
diff --git a/src/utils/pinned-entity-to-gltf.js b/src/utils/pinned-entity-to-gltf.js
new file mode 100644
index 0000000000000000000000000000000000000000..c820dd786c5d5714c2faf008b49182c08bc63513
--- /dev/null
+++ b/src/utils/pinned-entity-to-gltf.js
@@ -0,0 +1,33 @@
+export default function pinnedEntityToGltf(el) {
+  if (!NAF.utils.isMine(el)) return;
+
+  // Construct a GLTF node from this entity
+  const object3D = el.object3D;
+  const components = el.components;
+  const networkId = components.networked.data.networkId;
+
+  const gltfComponents = {};
+  const gltfNode = { name: networkId, extensions: { HUBS_components: gltfComponents } };
+
+  // Adapted from three.js GLTFExporter
+  const equalArray = (x, y) => x.length === y.length && x.every((v, i) => v === y[i]);
+  const rotation = object3D.quaternion.toArray();
+  const position = object3D.position.toArray();
+  const scale = object3D.scale.toArray();
+
+  if (!equalArray(rotation, [0, 0, 0, 1])) gltfNode.rotation = rotation;
+  if (!equalArray(position, [0, 0, 0])) gltfNode.translation = position;
+  if (!equalArray(scale, [1, 1, 1])) gltfNode.scale = scale;
+
+  if (components["media-loader"]) {
+    gltfComponents.media = { src: components["media-loader"].data.src, id: networkId };
+
+    if (components["media-pager"]) {
+      gltfComponents.media.pageIndex = components["media-pager"].data.index;
+    }
+  }
+
+  gltfComponents.pinnable = { pinned: true };
+
+  return gltfNode;
+}
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/src/utils/vr-caps-detect.js b/src/utils/vr-caps-detect.js
index fc2737ad41519798326d1f2ce6cec57e5a8e5b23..c2869cb62ed4def83dec0c34891f0eebfd53297d 100644
--- a/src/utils/vr-caps-detect.js
+++ b/src/utils/vr-caps-detect.js
@@ -23,8 +23,10 @@ function isMaybeDaydreamCompatibleDevice(ua) {
 const GENERIC_ENTRY_TYPE_DEVICE_BLACKLIST = [/cardboard/i];
 
 export function detectInHMD() {
-  const isOculusBrowser = /Oculus/.test(navigator.userAgent);
-  return isOculusBrowser;
+  const ua = navigator.userAgent;
+  const isFirefoxReality = /Firefox/.test(ua) && /Android/.test(ua) && window.hasNativeWebVRImplementation;
+  const isOculusBrowser = /Oculus/.test(ua);
+  return isOculusBrowser || isFirefoxReality;
 }
 
 // Tries to determine VR entry compatibility regardless of the current browser.
diff --git a/webpack.config.js b/webpack.config.js
index 55afb6708d844a885ade6473327360ca6198c243..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" }
       }
     ]
   },