diff --git a/PRIVACY.md b/PRIVACY.md
index cc2b0c62ddc7d4309ee2e79ddf6b2815d179144b..3fdb4ebb0b6e1ac8128386f3358d1e2151d67e76 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -1,10 +1,10 @@
-# Privacy Notice for Hubs by Mozilla
+# Privacy Notice for Hubs and Spoke
 
-Version 2.0, Effective July 23, 2018
+Version 3.0, October 16, 2018
 
 ## At Mozilla (that’s us), we believe that privacy is fundamental to a healthy internet.
 
-In this Privacy Notice, we explain what data may be accessible to Mozilla or others when you use [Hubs by Mozilla](https://hubs.mozilla.com). We also adhere to the practices outlined in the Mozilla [privacy policy](https://www.mozilla.org/en-US/privacy/) for how we receive, handle, and share information we collect from Hubs.
+In this Privacy Notice, we explain what data may be accessible to Mozilla or others when you use [Hubs](https://hubs.mozilla.com) or [Spoke](https://hubs.mozilla.com/spoke). We also adhere to the practices outlined in the Mozilla [privacy policy](https://www.mozilla.org/en-US/privacy/) for how we receive, handle, and share information we collect from Hubs.
 
 ## Things you should know:
 
@@ -16,17 +16,26 @@ In this Privacy Notice, we explain what data may be accessible to Mozilla or oth
 - **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.   
 - **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. [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)
+- 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>
 
 <details open>
   <summary>
-    <strong>Mozilla receives data you share to display to the room.</strong>
+    <strong>Mozilla receives data you create and share with Spoke and Hubs.</strong>
   </summary>
 
-- **Images and Video**: Mozilla receives video and image file links to process and display them in the Room. Mozilla stores this data as long as you remain in the Room.
-- **Scenes**: Mozilla receives 3D Room model links and the name of the Room in order to process and display the Room. Mozilla stores the name and the URL for the link you share so you and others with the link to the Room can use it again.
-- You can learn more by looking at the code itself.  [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)
+- **Images and Video**: Mozilla receives video and image file links to process and display them in the Hubs Room. Mozilla stores this data as long as you remain in the Room. 
+- **Scenes You Share**: Mozilla receives 3D Room model links and the name of the Room in order to process and display the Room. Mozilla stores the name and the URL for the link you share so you and others with the link to the Room can use it again. 
+
+<details open>
+  <summary>
+    <strong>Mozilla receives data when you create and publish Scenes with Spoke.</strong>
+  </summary>
+
+- **Scenes You Create**: When you create a scene with Spoke, Mozilla receives a copy of that scene. Mozilla stores that data in order to be able to process and display the scene through Hubs.
+- **Publishing Your Scene**: When you publish a scene to Hubs using Spoke, Mozilla will ask for your email address to send you a link to verify your scene. Mozilla will receive and store your email address to allow you to log in and view your 3D Room models. Mozilla stores a hashed version of email addresses you use to publish a scene, so the stored versions are not available in readable form.
+- **Remixing and Promotion**: When you use Spoke to publish a scene to Hubs, you have the option to “Allow Remixing with Creative Commons [CC-BY 3.0](https://creativecommons.org/licenses/by/3.0/)” or “allow Mozilla to promote my scene.” If you choose one or both of these options, Mozilla will share your scene publicly and you will have the option of including attribution information, which will also be publicly available.
+- You can learn more by looking at the [code itself](https://github.com/mozillareality/spoke) for Spoke. 
 </details>
 
 <details open>
@@ -37,5 +46,5 @@ In this Privacy Notice, we explain what data may be accessible to Mozilla or oth
 - **Technical data**: We receive and store data about Room URLs and names; the type of device you use to interact with Hubs, as well as its operating system, language, the name and version of browser; and other data to load and operate the Room. 
 - **Interaction data**: We receive data about your interactions with the Hubs service itself such as the number of Rooms created, the maximum number of users in a particular room at one same time, the start and end time of a user’s interaction with Hubs, the amount of time a user interacts with Hubs through Virtual Reality, the first time in a particular month or day that a user begins to use Hubs. Mozilla uses third party services to store and analyze these operational messages. 
 - **Error Data**: In order to diagnose problems, Hubs sends Mozilla logs of error messages (which include the Room URL, response time for requests, the page you were on when you encountered the error, your operating system, browser information, and may include your IP address). 
-- You can learn more by looking at the code itself.  [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)
+- 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/TERMS.md b/TERMS.md
index 2e5a317cd498ff2c78cd528aed090617adc95776..930b81ceeac0dec4142c400308bec43ec4810566 100644
--- a/TERMS.md
+++ b/TERMS.md
@@ -1,22 +1,34 @@
-# Terms of Service for Hubs by Mozilla
+# Terms of Service for Hubs and Spoke
 
-Version 2.0, Effective July 23, 2018
+Version 3.0, Effective October 16, 2018
 
-[Hubs by Mozilla](https://hubs.mozilla.com) is a real-time communications platform for Virtual Reality, Augmented Reality, Desktop, Laptop, Mobile, or however else you browse the internet. These Terms of Service explain your rights and responsibilities when you use Hubs.
+[Hubs by Mozilla](https://hubs.mozilla.com) is a real-time communications platform for Virtual Reality, Augmented Reality, Desktop, Laptop, Mobile, or however else you browse the internet.
+
+Spoke is a tool to arrange 3D models into scenes for use in Hubs. 
+
+These Terms of Service explain your rights and responsibilities when you use Hubs.
 
 ### 1. Privacy Policy
 The Hubs [Privacy Notice](https://github.com/mozilla/hubs/blob/master/PRIVACY.md) explains what information we collect when you use Hubs by Mozilla and how that information is handled and shared.
 
 ### 2. Communications and Content
-Hubs allows users to send information (such as audio, video, images, and 3D models) to other users. By using Hubs, you agree to give Mozilla all rights necessary to operate Hubs by Mozilla. This includes, but is not limited to, a license and permission to transmit and display the information you send through Hubs and to gather and share information as described in the [Privacy Notice](https://github.com/mozilla/hubs/blob/master/PRIVACY.md) for Hubs by Mozilla. 
 
-When you submit information to Hubs, you grant us a worldwide, royalty-free, perpetual, irrevocable, non-exclusive, transferable, and sublicensable license to use, copy, modify, adapt, prepare derivative works from, distribute, perform, and display that information, audio, video, images, or 3D models. You also agree that we may remove metadata associated with the information or data you submit, and you irrevocably waive any claims and assertions of moral rights or attribution with respect to the data you submit.
+Hubs allows users to send information (such as audio, video, images, 3D models, and scenes) to other users. 
+
+Spoke allows users to arrange 3D Room models into scenes that can appear in Hubs. 
+
+By using Hubs or Spoke, you agree to give Mozilla all rights necessary to operate Hubs and Spoke. This includes, but is not limited to, a license and permission to process, transmit, and display the information you send through Hubs or Spoke. It also includes permission to gather and share information as described in the [Privacy Notice](https://github.com/mozilla/hubs/blob/master/PRIVACY.md) for Hubs and Spoke. 
+
+When you submit information to Hubs or Spoke, you grant us a worldwide, royalty-free, perpetual, irrevocable, non-exclusive, transferable, and sublicensable license to use, copy, modify, adapt, prepare derivative works from, distribute, perform, and display that information, audio, video, images, or 3D models. You also agree that we may remove metadata associated with the information or data you submit, and you irrevocably waive any claims and assertions of moral rights or attribution with respect to the data you submit. If you allow allow remixing of a scene you create using Spoke, you agree to license your scene under a [CC-BY 3.0](https://creativecommons.org/licenses/by/3.0/legalcode) license. 
+
+You also represent and warrant that you have the authority to grant Mozilla all rights and permissions necessary for the operation of Hubs and Spoke. 
 
-You also represent and warrant that you have the authority to grant Mozilla all rights and permissions necessary for the operation of Hubs by Mozilla. To learn more about how Hubs operates, you can see the source code [here](https://github.com/mozilla/hubs).
+To learn more about how Hubs operates, you can see the source code [here](https://github.com/mozilla/hubs).
+To learn more about how Spoke operates, you can see the source code [here](https://github.com/mozillareality/spoke).
 
-Any ideas, suggestions, and feedback about Hubs that you provide to us are entirely voluntary, and you agree that Mozilla may use such ideas, suggestions, and feedback without compensation or obligation to you.
+Any ideas, suggestions, and feedback about Hubs or Spoke that you provide to us are entirely voluntary, and you agree that Mozilla may use such ideas, suggestions, and feedback without compensation or obligation to you.
 
-You are solely responsible for the information you send using Hubs and the consequences of sending that information. 
+You are solely responsible for the information you send, create, or edit using Hubs or Spoke, and the consequences of sending, creating, or editing that information. 
 
 ### 3. Conditions of Use 
 By using Mozilla Hubs, you agree that your use will comply with Mozilla’s [Conditions of Use](https://www.mozilla.org/en-US/about/legal/acceptable-use/). Mozilla reserves the right to remove any content, suspend any users, and shut down any room it reasonably believes has violated these conditions.
@@ -24,21 +36,21 @@ By using Mozilla Hubs, you agree that your use will comply with Mozilla’s [Con
 Please also be aware of [Mozilla’s Community Participation Guidelines](https://www.mozilla.org/en-US/about/governance/policies/participation/), which address participation in Mozilla communities. 
 
 ### 4. Mozilla's Rights
-Mozilla does not grant you any intellectual property rights in Hubs unless these Terms specifically say otherwise. For example, these Terms do not provide the right to use any of Mozilla’s copyrights, trade names, trademarks, service marks, logos, domain names, or other distinctive brand features.
+Mozilla does not grant you any intellectual property rights in Hubs or Spoke unless these Terms specifically say otherwise. For example, these Terms do not provide the right to use any of Mozilla’s copyrights, trade names, trademarks, service marks, logos, domain names, or other distinctive brand features.
 
-Mozilla distributes the Hubs software under an open source license. To learn more, you can read the [license itself](https://github.com/mozilla/hubs/blob/master/LICENSE) or read the [FAQ](https://www.mozilla.org/en-US/MPL/2.0/FAQ/).
+Mozilla distributes the Hubs and Spoke software under an open source license. To learn more, you can read the [license for Spoke]((https://github.com/mozillareality/spoke/blob/master/LICENSE)), and you can read the [license for Hubs](https://github.com/mozilla/hubs/blob/master/LICENSE) or read the [FAQ](https://www.mozilla.org/en-US/MPL/2.0/FAQ/).
 
 ### 5. Services Interruption; Term; Termination
-We are continuing to develop Hubs. As a result, we plan to upgrade and change Hubs over time. To do this, we might have to temporarily suspend Hubs and it is not always possible for us to give notice. You will not be entitled to claim expenses or damages for such suspension or limitation of the use of Hubs.
+We are continuing to develop Hubs and Spoke. As a result, we plan to upgrade and change them over time. To do this, we might have to temporarily suspend their service and it is not always possible for us to give notice. You will not be entitled to claim expenses or damages for such suspension or limitation of the use of Hubs or Spoke.
 
-These Terms apply to your use of Hubs and will continue to apply until ended by either you or upon notice from Mozilla. You can choose to end them at any time for any reason by discontinuing your use of Hubs.
+These Terms apply to your use of Hubs and Spoke and will continue to apply until ended by either you or upon notice from Mozilla. You can choose to end them at any time for any reason by discontinuing your use of Hubs and Spoke.
 
-We may cut off your access to Hubs, either temporarily or permanently at any time for any reason. This includes, but is not limited to, situations where we reasonably believe: (i) you have violated these Terms (ii) you create risk or possible legal exposure for Mozilla; or (iii) providing and operating Hubs is no longer commercially viable. If possible, we will make reasonable efforts to notify you through Hubs.
+We may cut off your access to Hubs or Spoke, either temporarily or permanently at any time for any reason. This includes, but is not limited to, situations where we reasonably believe: (i) you have violated these Terms (ii) you create risk or possible legal exposure for Mozilla; or (iii) providing and operating Hubs is no longer commercially viable. If possible, we will make reasonable efforts to notify you through the relevant program, either Hubs or Spoke .
 
-In all such cases, these Terms shall terminate, including, without limitation, your license to use Hubs, except that the sections with the following titles shall continue to apply: Indemnification, Disclaimer; Limitation of Liability and Miscellaneous.
+In all such cases, these Terms shall terminate, including, without limitation, your license to use Hubs and Spoke, except that the sections with the following titles shall continue to apply: Indemnification, Disclaimer; Limitation of Liability and Miscellaneous.
 
 ### 6. Indemnification
-You agree to defend, indemnify and hold harmless Mozilla, and its respective parent and affiliate companies, contractors, contributors, licensors, partners, directors, officers, employees and agents ("Indemnified Parties") from and against any and all third party claims and expenses, including attorneys' fees, arising out of or related to your use of Hubs. This includes, but is not limited to, claims and expenses from any content you transmit using Hubs.
+You agree to defend, indemnify and hold harmless Mozilla, and its respective parent and affiliate companies, contractors, contributors, licensors, partners, directors, officers, employees and agents ("Indemnified Parties") from and against any and all third party claims and expenses, including attorneys' fees, arising out of or related to your use of Hubs or Spoke. This includes, but is not limited to, claims and expenses from any content you transmit, edit, or create using Hubs or Spoke.
 
 ### 7. Disclaimer; Limitation of Liability
 THE SERVICES ARE PROVIDED "AS IS" WITH ALL FAULTS. TO THE EXTENT PERMITTED BY LAW, THE INDEMNIFIED PARTIES, HEREBY DISCLAIM ALL WARRANTIES, WHETHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES THAT THE SERVICES ARE FREE OF DEFECTS, MERCHANTABLE, FIT FOR A PARTICULAR PURPOSE, AND NON-INFRINGING.
@@ -49,17 +61,17 @@ THIS LIMITATION WILL APPLY NOTWITHSTANDING THE FAILURE OF ESSENTIAL PURPOSE OF A
 EXCEPT AS REQUIRED BY LAW, THE INDEMNIFIED PARTIES, WILL NOT BE LIABLE FOR ANY INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL, OR EXEMPLARY DAMAGES ARISING OUT OF OR IN ANY WAY RELATING TO THESE TERMS OR THE USE OF OR INABILITY TO USE THE SERVICES, INCLUDING WITHOUT LIMITATION DIRECT AND INDIRECT DAMAGES FOR LOSS OF GOODWILL, WORK STOPPAGE, LOST PROFITS, LOSS OF DATA, AND COMPUTER FAILURE OR MALFUNCTION, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES AND REGARDLESS OF THE THEORY (CONTRACT, TORT, OR OTHERWISE) UPON WHICH SUCH CLAIM IS BASED. THE COLLECTIVE LIABILITY OF THE INDEMNIFIED PARTIES, UNDER THIS AGREEMENT WILL NOT EXCEED $500 (FIVE HUNDRED DOLLARS). SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OR LIMITATION OF INCIDENTAL, CONSEQUENTIAL, OR SPECIAL DAMAGES, SO THIS EXCLUSION AND LIMITATION MAY NOT APPLY TO YOU.
 
 ### 8. Modifications to These Terms
-Mozilla may update these Terms from time to time. We will post the updated Terms online. If the changes are substantive, we will announce the update through Mozilla's usual channels for such announcements such as blog posts, forums, or in the particular service itself, in this case: Hubs by Mozilla.
+Mozilla may update these Terms from time to time. We will post the updated Terms online. If the changes are substantive, we may announce the update through Mozilla's usual channels for such announcements such as blog posts, forums, or in the particular service itself, in this case: Hubs and Spoke.
 
-Your continued use of Hubs after we post the new Terms constitutes your acceptance of the new Terms. To make your review more convenient, we will post an effective date at the top of this page.
+Your continued use of Hubs or Spoke after we post the new Terms constitutes your acceptance of the new Terms. To make your review more convenient, we will post an effective date at the top of this page.
 
 ### 9. Miscellaneous
-These Terms make up the entire agreement between you and Mozilla concerning Hubs. The laws of the state of California, U.S.A (excluding its conflict of law provisions) govern this agreement.
+These Terms make up the entire agreement between you and Mozilla concerning Hubs and Spoke. The laws of the state of California, U.S.A (excluding its conflict of law provisions) govern this agreement.
 
 If any portion of these Terms is held to be invalid or unenforceable, the remaining portions remain in full force and effect. If there is a conflict or ambiguity between a translated version of these terms and the English language version, the English language version applies.
 
 ### 10. Contact Us
-For support, to provide feedback, or to report abuse of Hubs or violations of the Conditions of Use, you can email us at [hubs@mozilla.com](mailto:hubs@mozilla.com). 
+For support, to provide feedback, or to report abuse of Hubs or Spoke or violations of the Conditions of Use, you can email us at [hubs@mozilla.com](mailto:hubs@mozilla.com). 
 
 To report a claim of copyright or trademark infringement, see [our policy](https://www.mozilla.org/en-US/about/legal/report-infringement/). 
 
diff --git a/package-lock.json b/package-lock.json
index 077261637246369a3b7458d86bb223f1489bdfb6..35c52ccb91e8c416cf7897d217d553390281fda4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4094,6 +4094,11 @@
         "minimalistic-crypto-utils": "^1.0.0"
       }
     },
+    "emoji-regex": {
+      "version": "6.5.1",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-6.5.1.tgz",
+      "integrity": "sha512-PAHp6TxrCy7MGMFidro8uikr+zlJJKJ/Q6mm2ExZ7HwkyR9lSVFfE3kt36qcwa24BQL7y0G9axycGjK1A/0uNQ=="
+    },
     "emojis-list": {
       "version": "2.1.0",
       "resolved": "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz",
@@ -7939,6 +7944,14 @@
         "immediate": "~3.0.5"
       }
     },
+    "linkify-it": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.0.3.tgz",
+      "integrity": "sha1-2UpGSPmxwXnWT6lykSaL22zpQ08=",
+      "requires": {
+        "uc.micro": "^1.0.1"
+      }
+    },
     "listr": {
       "version": "0.14.1",
       "resolved": "https://registry.yarnpkg.com/listr/-/listr-0.14.1.tgz",
@@ -8153,6 +8166,11 @@
         }
       }
     },
+    "load-script": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz",
+      "integrity": "sha1-BJGTngvuVkPuSUp+PaPSuscMbKQ="
+    },
     "loader-runner": {
       "version": "2.3.0",
       "resolved": "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.0.tgz",
@@ -8217,6 +8235,16 @@
       "resolved": "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
       "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
     },
+    "lodash.flatten": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
+      "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8="
+    },
+    "lodash.isstring": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+      "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE="
+    },
     "lodash.mergewith": {
       "version": "4.6.1",
       "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz",
@@ -10540,6 +10568,18 @@
         "prop-types": "^15.6.0"
       }
     },
+    "react-emoji-render": {
+      "version": "0.4.6",
+      "resolved": "https://registry.npmjs.org/react-emoji-render/-/react-emoji-render-0.4.6.tgz",
+      "integrity": "sha512-ARB8E4j/dndQxC7Bn4b+Oymt7pqhh9GjP87NYcxC8KONejysnXD5O9KpnJeW/U3Ke3+XsWrWAr9K5riVA6emfg==",
+      "requires": {
+        "classnames": "^2.2.5",
+        "emoji-regex": "^6.4.1",
+        "lodash.flatten": "^4.4.0",
+        "prop-types": "^15.5.8",
+        "string-replace-to-array": "^1.0.1"
+      }
+    },
     "react-file-reader-input": {
       "version": "1.1.4",
       "resolved": "https://registry.npmjs.org/react-file-reader-input/-/react-file-reader-input-1.1.4.tgz",
@@ -10567,6 +10607,16 @@
         "invariant": "^2.1.1"
       }
     },
+    "react-linkify": {
+      "version": "0.2.2",
+      "resolved": "https://registry.npmjs.org/react-linkify/-/react-linkify-0.2.2.tgz",
+      "integrity": "sha512-0S8cvUNtEgfJpIGDPKklyrnrTffJ63WuJAc4KaYLBihl5TjgH5cHUmYD+AXLpsV+CVmfoo/56SUNfrZcY4zYMQ==",
+      "requires": {
+        "linkify-it": "^2.0.3",
+        "prop-types": "^15.5.8",
+        "tlds": "^1.57.0"
+      }
+    },
     "react-select": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/react-select/-/react-select-1.3.0.tgz",
@@ -10577,6 +10627,16 @@
         "react-input-autosize": "^2.1.2"
       }
     },
+    "react-youtube": {
+      "version": "7.8.0",
+      "resolved": "https://registry.npmjs.org/react-youtube/-/react-youtube-7.8.0.tgz",
+      "integrity": "sha512-kQFR0XTpgGRtzJ54HKDaCtAGr34mgB/BVFeCAL0WUjpIKZBcWtFrKJhYkoKfvWK7aTzJuQ57xojTjG7V6JzILA==",
+      "requires": {
+        "fast-deep-equal": "^2.0.1",
+        "prop-types": "^15.5.3",
+        "youtube-player": "^5.5.1"
+      }
+    },
     "read-chunk": {
       "version": "2.1.0",
       "resolved": "https://registry.yarnpkg.com/read-chunk/-/read-chunk-2.1.0.tgz",
@@ -11542,6 +11602,11 @@
         "simple-concat": "^1.0.0"
       }
     },
+    "sister": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/sister/-/sister-3.0.1.tgz",
+      "integrity": "sha512-aG41gNRHRRxPq52MpX4vtm9tapnr6ENmHUx8LMAJWCOplEMwXzh/dp5WIo52Wl8Zlc/VUyHLJ2snX0ck+Nma9g=="
+    },
     "slash": {
       "version": "1.0.0",
       "resolved": "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz",
@@ -12018,6 +12083,16 @@
       "resolved": "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz",
       "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM="
     },
+    "string-replace-to-array": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/string-replace-to-array/-/string-replace-to-array-1.0.3.tgz",
+      "integrity": "sha1-yT66mZpe4k1zGuu69auja18Y978=",
+      "requires": {
+        "invariant": "^2.2.1",
+        "lodash.flatten": "^4.2.0",
+        "lodash.isstring": "^4.0.1"
+      }
+    },
     "string-template": {
       "version": "0.2.1",
       "resolved": "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz",
@@ -12680,6 +12755,11 @@
       "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.0.2.tgz",
       "integrity": "sha512-2NM0auVBGft5tee/OxP4PI3d8WItkDM+fPnaRAVo6xTDI2knbz9eC5ArWGqtGlYqiH3RU5yMpdyTTO7MguC4ow=="
     },
+    "tlds": {
+      "version": "1.203.1",
+      "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.203.1.tgz",
+      "integrity": "sha512-7MUlYyGJ6rSitEZ3r1Q1QNV8uSIzapS8SmmhSusBuIc7uIxPPwsKllEP0GRp1NS6Ik6F+fRZvnjDWm3ecv2hDw=="
+    },
     "tmp": {
       "version": "0.0.33",
       "resolved": "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz",
@@ -12881,6 +12961,11 @@
       "resolved": "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.18.tgz",
       "integrity": "sha1-p7/ZL1bt+xFwg7aeMdKqiILUse0="
     },
+    "uc.micro": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.5.tgz",
+      "integrity": "sha512-JoLI4g5zv5qNyT09f4YAvEZIIV1oOjqnewYg5D38dkQljIzpPT296dbIGvKro3digYI1bkb7W6EP1y4uDlmzLg=="
+    },
     "uglify-es": {
       "version": "3.3.9",
       "resolved": "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.3.9.tgz",
@@ -14281,6 +14366,16 @@
           "dev": true
         }
       }
+    },
+    "youtube-player": {
+      "version": "5.5.1",
+      "resolved": "https://registry.npmjs.org/youtube-player/-/youtube-player-5.5.1.tgz",
+      "integrity": "sha512-1d62W9She0B1uKNyY6K7jtWFbOW3dYsm6hyKzrh11BLOuYFzkt8K6AcQ3QdPF3aU47dzhZ82clzOJVVWus4HTw==",
+      "requires": {
+        "debug": "^2.6.6",
+        "load-script": "^1.0.0",
+        "sister": "^3.0.0"
+      }
     }
   }
 }
diff --git a/package.json b/package.json
index c6c9a63532aa1c678267cc52a3ac1234651853e0..bee3e1b3d3c7b14a9d5f939f369ade4f0b49c437 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,10 @@
     "raven-js": "^3.20.1",
     "react": "^16.1.1",
     "react-dom": "^16.1.1",
+    "react-emoji-render": "^0.4.6",
     "react-intl": "^2.4.0",
+    "react-youtube": "^7.8.0",
+    "react-linkify": "^0.2.2",
     "screenfull": "^3.3.2",
     "super-hands": "github:mozillareality/aframe-super-hands-component#feature/drawing",
     "three": "github:mozillareality/three.js#8b1886c384371c3e6305b757d1db7577c5201a9b",
diff --git a/scripts/hab-build-and-push.sh b/scripts/hab-build-and-push.sh
index d5af935323f9611b5efa48965ee9bb13de39dbc8..f9ffb81ad1ef8e780bdc4efd8499eee9c0eab4c4 100755
--- a/scripts/hab-build-and-push.sh
+++ b/scripts/hab-build-and-push.sh
@@ -25,7 +25,9 @@ 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
 
 npm ci --verbose --no-progress
-npm rebuild node-sass # TODO remove
+npm rebuild node-sass # HACK sometimes node-sass build fails
+npm rebuild node-sass # HACK sometimes node-sass build fails
+npm rebuild node-sass # HACK sometimes node-sass build fails
 npm run build
 mkdir dist/pages
 mv dist/*.html dist/pages
diff --git a/src/assets/images/presence_desktop.png b/src/assets/images/presence_desktop.png
new file mode 100755
index 0000000000000000000000000000000000000000..4dbaafa1733fb55971581d9c2fd368f4ca9e0971
Binary files /dev/null and b/src/assets/images/presence_desktop.png differ
diff --git a/src/assets/images/presence_phone.png b/src/assets/images/presence_phone.png
new file mode 100755
index 0000000000000000000000000000000000000000..4b18d742ad8c9ddbb71fe7e1b9d897e48c73d5bb
Binary files /dev/null and b/src/assets/images/presence_phone.png differ
diff --git a/src/assets/images/presence_vr.png b/src/assets/images/presence_vr.png
new file mode 100755
index 0000000000000000000000000000000000000000..fde03d7020a2252a3722c76ee1ebe21dc6f488ef
Binary files /dev/null and b/src/assets/images/presence_vr.png differ
diff --git a/src/assets/images/spoke_logo.png b/src/assets/images/spoke_logo.png
new file mode 100755
index 0000000000000000000000000000000000000000..f331e87ddbc8e429207dcd2879c2670419041cac
Binary files /dev/null and b/src/assets/images/spoke_logo.png differ
diff --git a/src/assets/images/spoke_logo_black.png b/src/assets/images/spoke_logo_black.png
new file mode 100755
index 0000000000000000000000000000000000000000..10abf285713f8a6860a37c20008ab54d5348047c
Binary files /dev/null and b/src/assets/images/spoke_logo_black.png differ
diff --git a/src/assets/images/twitter.svg b/src/assets/images/twitter.svg
new file mode 100755
index 0000000000000000000000000000000000000000..4f16790673574fb41a23496659eb9659c910f7ba
--- /dev/null
+++ b/src/assets/images/twitter.svg
@@ -0,0 +1,3 @@
+<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M153.6 301.6C247.9 301.6 299.5 223.4 299.5 155.7C299.5 153.5 299.5 151.3 299.4 149.1C309.4 141.9 318.1 132.8 325 122.5C315.8 126.6 305.9 129.3 295.5 130.6C306.1 124.3 314.2 114.2 318.1 102.2C308.2 108.1 297.2 112.3 285.5 114.6C276.1 104.6 262.8 98.4 248.1 98.4C219.8 98.4 196.8 121.4 196.8 149.7C196.8 153.7 197.3 157.6 198.1 161.4C155.5 159.3 117.7 138.8 92.4 107.8C88 115.4 85.5 124.2 85.5 133.6C85.5 151.4 94.6 167.1 108.3 176.3C99.9 176 92 173.7 85.1 169.9C85.1 170.1 85.1 170.3 85.1 170.6C85.1 195.4 102.8 216.2 126.2 220.9C121.9 222.1 117.4 222.7 112.7 222.7C109.4 222.7 106.2 222.4 103.1 221.8C109.6 242.2 128.6 257 151 257.4C133.4 271.2 111.3 279.4 87.3 279.4C83.2 279.4 79.1 279.2 75.1 278.7C97.7 293.1 124.7 301.6 153.6 301.6Z" fill="white"/>
+</svg>
diff --git a/src/assets/stylesheets/2d-hud.scss b/src/assets/stylesheets/2d-hud.scss
index 513ba499f7600ae67d14ce1e01372a9f221e8a37..197c036f569d78ac8b81e8271eb3aeadb1a477ff 100644
--- a/src/assets/stylesheets/2d-hud.scss
+++ b/src/assets/stylesheets/2d-hud.scss
@@ -14,7 +14,7 @@
 
   &:local(.column) {
     flex-direction: column;
-    bottom: 20px;
+    bottom: 0;
     z-index: 1;
   }
 }
diff --git a/src/assets/stylesheets/entry.scss b/src/assets/stylesheets/entry.scss
index b2d1727240b8c1a93775ec0a396b08e15489879a..f0424741082257a992e0adfd24de80703d1899c7 100644
--- a/src/assets/stylesheets/entry.scss
+++ b/src/assets/stylesheets/entry.scss
@@ -84,6 +84,7 @@
   margin: 24px;
   min-height: 150px;
   height: 100%;
+  width: 100%;
 
   :local(.title) {
     @extend %top-title;
@@ -93,14 +94,30 @@
     margin-left: 8px;
   }
 
+  :local(.name) {
+    @extend %top-title;
+    @extend %glass-text;
+    margin-bottom: 4px;
+    margin-right: 8px;
+    margin-left: 8px;
+  }
+
+  :local(.lobby) {
+    margin-bottom: 24px;
+    margin-right: 8px;
+    margin-left: 8px;
+    font-size: 0.9em;
+  }
+
   :local(.center) {
     @extend %glass-text;
     flex: 10;
+    width: 100%;
   }
 
   :local(.profile-name) {
     margin-top: 4px;
-    margin-bottom: 16px;
+    margin-bottom: 32px;
     @extend %default-font;
     font-size: 1.1em;
     color: $action-color;
diff --git a/src/assets/stylesheets/index.scss b/src/assets/stylesheets/index.scss
index eb49756ff8210513f99484748211c11a1b17ca4b..66595775efe1e43c7a835987940ff288eee63ef1 100644
--- a/src/assets/stylesheets/index.scss
+++ b/src/assets/stylesheets/index.scss
@@ -61,7 +61,7 @@ body {
   height: 65px;
   display: flex;
 
-  @media (max-width: 768px) {
+  @media (max-width: 768px), (max-height: 715px) {
     display: none;
   }
 
@@ -112,12 +112,6 @@ body {
       }
     }
   }
-
-  :local(.ident) {
-    text-align: right;
-    flex: 1 1 $header-section-width;
-    width: $header-section-width;
-  }
 }
 
 :local(.hero-content) {
@@ -133,6 +127,10 @@ body {
     min-height: 490px;
   }
 
+  @media (max-height: 715px) {
+    justify-content: flex-start;
+  }
+
   :local(.attribution) {
     position: absolute;
     right: 12px;
@@ -165,6 +163,10 @@ body {
       text-align: center;
       font-size: 1.5em;
       font-weight: bold;
+
+      @media (max-height: 715px) {
+        display: none;
+      }
     }
   }
 
diff --git a/src/assets/stylesheets/invite-dialog.scss b/src/assets/stylesheets/invite-dialog.scss
index 2466630cef1cbec9e3c128abd2b8ad7d647e5894..f28210e2492ad663f31aa1f08874c3f89c5ac755 100644
--- a/src/assets/stylesheets/invite-dialog.scss
+++ b/src/assets/stylesheets/invite-dialog.scss
@@ -22,8 +22,11 @@
   }
 
   :local(.link-button) {
+    @extend %action-button;
     @extend %action-button-selected;
+    min-width: auto;
     margin-top: 4px;
+    flex: 1;
 
     @media (max-height: 370px) {
       display: none;
@@ -115,8 +118,9 @@
 :local(.buttons) {
   display: flex;
   justify-content: space-between;
+  width: 100%;
 
-  button {
+  button, a {
     margin: 0 12px;
   }
 }
diff --git a/src/assets/stylesheets/presence-list.scss b/src/assets/stylesheets/presence-list.scss
new file mode 100644
index 0000000000000000000000000000000000000000..3e7ab0517586fff3db4a83c19f6202b5de1d247d
--- /dev/null
+++ b/src/assets/stylesheets/presence-list.scss
@@ -0,0 +1,82 @@
+@import 'shared.scss';
+
+:local(.attach-point) {
+  width: 0; 
+  height: 0; 
+  border-left: 5px solid transparent;
+  border-right: 5px solid transparent;
+  border-bottom: 5px solid $white-transparent;
+  position: absolute;
+  top: -5px;
+  left: 44px;
+
+  @media(max-width: 768px), (max-height: 420px) {
+    left: 34px;
+  }
+}
+
+:local(.presence-list) {
+  position: absolute;
+  top: 72px;
+  left: 16px;
+  bottom: 0;
+  z-index: 5;
+}
+
+:local(.contents) {
+  background-color: white;
+  border-radius: 12px;
+  padding: 12px 18px;
+  min-width: 308px;
+  max-height: 75%;
+  overflow-y: auto;
+  pointer-events: auto;
+}
+
+:local(.rows) {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+:local(.row) {
+  width: 100%;
+  display: flex;
+  flex-direction: row;
+  font-weight: bold;
+  justify-content: space-between;
+  align-items: center;
+  margin: 6px 0;
+}
+
+:local(.device) {
+  width: 32px;
+  height: 32px;
+  position: relative;
+  margin: 0px 12px 0px 0px;
+
+  img {
+    position: absolute;
+    left: 2px;
+    width: 32px;
+    height: 32px;
+  }
+}
+
+:local(.display-name) {
+  flex: 10;
+  white-space: nowrap;
+  margin-right: 24px;
+  max-width: 45vw;
+  overflow: hidden;
+}
+
+:local(.self-display-name) {
+  text-decoration: underline;
+}
+
+:local(.presence) {
+  flex: 1;
+  white-space: nowrap;
+  text-align: right;
+}
diff --git a/src/assets/stylesheets/presence-log.scss b/src/assets/stylesheets/presence-log.scss
new file mode 100644
index 0000000000000000000000000000000000000000..2738cb3583eee8fe193d652232af609773a983a5
--- /dev/null
+++ b/src/assets/stylesheets/presence-log.scss
@@ -0,0 +1,73 @@
+@import 'shared.scss';
+
+:local(.presence-log) {
+  align-self: flex-start;
+  flex: 10;
+  display: flex;
+  flex-direction: column;
+  justify-content: flex-end;
+  align-items: flex-start;
+  margin-bottom: 8px;
+  margin-top: 90px;
+  overflow: hidden;
+  width: 100%;
+
+  :local(.presence-log-entry) {
+    @extend %default-font;
+    pointer-events: auto;
+
+    user-select: text;
+    -moz-user-select: text;
+    -webkit-user-select: text;
+    -ms-user-select: text;
+
+    background-color: $white-transparent;
+    margin: 8px 64px 8px 16px;
+    font-size: 0.8em;
+    padding: 8px 16px;
+    border-radius: 16px;
+
+    a {
+      color: $action-color;
+    }
+
+    @media (max-width: 1000px) {
+      max-width: 75%;
+    }
+  }
+
+  :local(.expired) {
+    visibility: hidden;
+    opacity: 0;
+    transform: translateY(-8px);
+    transition: visibility 0s 0.5s, opacity 0.5s linear, transform 0.5s;
+  }
+
+}
+
+:local(.presence-log-in-room) {
+  max-height: 200px;
+
+  @media(min-height: 800px) and (min-width: 600px) {
+    max-height: 400px;
+  }
+
+  position: absolute;
+  bottom: 165px;
+
+  :local(.presence-log-entry) {
+    background-color: $hud-panel-background;
+    color: $light-text;
+
+    user-select: none;
+    -moz-user-select: none;
+    -webkit-user-select: none;
+    -ms-user-select: none;
+  }
+}
+
+:local(.emoji) {
+  // Undo annoying CSS in emoji plugin
+  margin: auto !important;
+  vertical-align: 0em !important;
+}
diff --git a/src/assets/stylesheets/scene-ui.scss b/src/assets/stylesheets/scene-ui.scss
index 2f48ccee5dae1e9abc7a63258d5955838f1c16c1..bd3abf0a6d82cf898081da35c23add7ce692b024 100644
--- a/src/assets/stylesheets/scene-ui.scss
+++ b/src/assets/stylesheets/scene-ui.scss
@@ -52,6 +52,22 @@
   margin-bottom: 32px;
 }
 
+:local(.tweetButton) {
+  @extend %action-button;
+  margin-top: 12px;
+  background-color: #1b95e0;
+  align-self: center;
+  padding-right: 32px;
+  display: flex;
+  flex-direction: row;
+
+  img {
+    width: 42px;
+    height: 42px;
+    margin-right: 6px;
+  }
+}
+
 :local(.logo) {
   width: 100%;
   display: block;
@@ -102,3 +118,32 @@
   opacity: 0;
   transition: visibility 0s 0.5s, opacity 0.5s linear;
 }
+
+:local(.spoke) {
+  @media(max-width: 768px) {
+    display: none;
+  }
+
+  pointer-events: auto;
+  position: absolute;
+  right: 12px;
+  bottom: 8px;
+  display: flex;
+  flex-direction: column;
+  text-align: left;
+  font-size: 0.8em;
+  font-weight: bold;
+
+  :local(.madeWith) {
+    color: black;
+    text-shadow: 0px 0px 4px #333;
+    margin-left: 4px;
+    position: absolute;
+    top: 0px;
+    left: 0;
+  }
+
+  img {
+    width: 200px;
+  }
+}
diff --git a/src/assets/stylesheets/spoke.scss b/src/assets/stylesheets/spoke.scss
new file mode 100644
index 0000000000000000000000000000000000000000..68f850879027e250f7bcce75a16a88692cf4a6e3
--- /dev/null
+++ b/src/assets/stylesheets/spoke.scss
@@ -0,0 +1,238 @@
+@import 'shared';
+@import 'loader';
+
+$spoke-action-color: #2F80ED;
+$breakpoint: 1280px;
+$mobile-breakpoint-width: 450px;
+
+body {
+  @extend %default-font;
+  background: black;
+  color: white;
+  margin: 0;
+  overflow: hidden;
+}
+
+:local(.bg) {
+  background: radial-gradient(#222C41 0%, black 100%); 
+  top: 0;
+  left: 0;
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  z-index: -2;
+}
+
+:local(.ui) {
+  display: flex;
+  position: relative;
+  flex-direction: column;
+}
+
+:local(.content) {
+  display: flex;
+  flex-direction: column;
+  height: calc(100vh - 72px);
+  align-items: center;
+  justify-content: center;
+}
+
+:local(.header) {
+  font-size: 1.1em;
+  font-weight: bold;
+  color: white;
+}
+
+:local(.header-links) {
+  display: flex;
+  margin: 24px 12px;
+
+  @media(max-width: $mobile-breakpoint-width) {
+    justify-content: space-between;
+  }
+
+  a {
+    color: white;
+    text-decoration: none;
+    margin: 0px 18px;
+  }
+}
+
+:local(.hero-pane) {
+  display: flex;
+  height: 100%;
+  position: relative;
+  justify-content: center;
+  align-items: center;
+
+  @media(max-width: $breakpoint) {
+    flex-direction: column;
+  }
+}
+
+:local(.spoke-logo) {
+  position: relative;
+
+  img {
+    width: 400px;
+
+    @media(max-width: $mobile-breakpoint-width) {
+      width: 320px;
+    }
+  }
+}
+
+:local(.primary-tagline) {
+  position: absolute;
+  right: 6px;
+  bottom: -8px;
+  font-weight: bold;
+  font-size: 1.7em;
+}
+
+:local(.secondary-tagline) {
+  font-weight: lighter;
+  font-size: 1.5em;
+  text-align: center;
+  margin-top: 48px;
+  margin-left: 12px;
+  margin-right: 12px;
+
+  @media(max-width: $mobile-breakpoint-width) {
+    font-size: 1.3em;
+  }
+
+  a {
+    color: white;
+  }
+}
+
+:local(.hero-message) {
+  width: 600px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+  margin: 0 36px;
+
+  @media(max-width: $breakpoint) {
+    text-shadow: 0px 0px 4px rgba(0, 0, 0, 1.0);
+    order: 2;
+    width: 100%;
+    margin: 32px 0;
+    height: auto;
+  }
+}
+
+:local(.hero-video) {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  position: relative;
+  margin-left: 32px;
+  flex: 1;
+  z-index: -1;
+  border-radius: 8px;
+
+  @media(max-width: $breakpoint) {
+    display: none;
+  }
+}
+
+:local(.preview-video) {
+  width: 90%;
+  border-radius: 12px 0 0 12px;
+  z-index: -1;
+}
+
+:local(.download-button) {
+  @extend %action-button;
+  @extend %wide-button;
+  height: 64px;
+  border-radius: 32px;
+
+  background-color: $spoke-action-color;
+  margin-top: 64px;
+  display: flex;
+  flex-direction: column;
+
+  :local(.version) {
+    font-size: 0.8em;
+    font-weight: lighter;
+  }
+}
+
+:local(.play-button), :local(.close-video) {
+  @extend %action-button;
+  background-color: $darker-grey;
+  margin: auto;
+  margin-top: 64px;
+  padding: 0px 82px;
+}
+
+:local(.close-video) {
+  margin-top: 12px;
+}
+
+:local(.browse-versions) {
+  color: $grey-text;
+  margin-top: 12px;
+  display: block;
+  text-align: center;
+  width: 100%;
+}
+
+:local(.thank-you) {
+  text-align: center;
+
+  a {
+    color: $grey-text;
+  }
+}
+
+:local(.player-overlay) {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background-color: rgba(0, 0, 0, 0.8);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+:local(.player-content) {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+}
+
+:local(.player-video) {
+  width: 960px;
+  height: 540px;
+
+  @media (max-width: 980px), (max-height: 760px) {
+    width: 640px;
+    height: 360px;
+  }
+
+  @media (max-width: 650px), (max-height: 580px) {
+    width: 480px;
+    height: 270px;
+  }
+
+  @media (max-width: 490px), (max-height: 480px) {
+    width: 320px;
+    height: 180px;
+  }
+}
+
+:local(.attribution) {
+  position: absolute;
+  bottom: -40px;
+  right: 84px;
+  color: $darker-grey;
+}
diff --git a/src/assets/stylesheets/ui-root.scss b/src/assets/stylesheets/ui-root.scss
index 7781143575ff539be1b750da439ad6ea8ddac96d..11de7f546b713ca297e99e92e090bb355e55e334 100644
--- a/src/assets/stylesheets/ui-root.scss
+++ b/src/assets/stylesheets/ui-root.scss
@@ -50,6 +50,7 @@
   width: 100%;
   max-width: 600px;
   z-index: 2;
+  position: relative;
 
   :local(.backgrounded) {
     filter: blur(1px);
@@ -164,6 +165,8 @@
   border-radius: 24px;
   font-weight: bold;
   padding: 8px 18px;
+  pointer-events: auto;
+  cursor: pointer;
 
   @media (min-width: 769px) and (min-height: 421px) {
     flex: 1;
@@ -177,3 +180,90 @@
     margin: 0 12px;
   }
 }
+
+:local(.presence-info-selected) {
+  color: $action-color;
+}
+
+:local(.message-entry) {
+  position: relative;
+  margin: 8px 24px 24px 24px;
+  height: 48px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-color: white;
+  border: 1px solid #e2e2e2;
+  border-radius: 16px;
+}
+
+:local(.message-entry-input) {
+  @extend %default-font;
+  pointer-events: auto;
+  appearance: none;
+  -moz-appearance: none;
+  -webkit-appearance: none;
+  outline-style: none;
+  background-color: transparent;
+  color: black;
+  padding: 8px 1.25em;
+  line-height: 2em;
+  font-size: 1.1em;
+  width: 100%;
+  border: 0px;
+  height: 32px;
+  margin-right: 100px;
+}
+
+:local(.message-entry-input)::placeholder{
+  color: $dark-grey;
+  font-weight: 300;
+  font-style: italic;
+}
+
+:local(.message-entry-submit) {
+  @extend %action-button;
+  position: absolute;
+  right: 12px;
+  height: 32px;
+  min-width: 80px;
+}
+
+:local(.message-entry-in-room) {
+  @media(max-width: 900px) {
+    display:none;
+  }
+
+  position: absolute;
+  left: 16px;
+  bottom: 20px;
+  width: 33%;
+  height: 48px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-color: $darker-grey;
+  border-radius: 16px;
+  pointer-events: auto;
+  opacity: 0.3;
+  transition: opacity 0.25s linear;
+
+  :local(.message-entry-input-in-room) {
+    color: white;
+    padding: 8px 1.25em;
+  }
+
+  :local(.message-entry-submit-in-room) {
+    border: 0;
+    visibility: hidden;
+  }
+}
+
+:local(.message-entry-in-room):hover {
+  opacity: 1.0;
+  transition: opacity 0.25s linear;
+
+  :local(.message-entry-submit-in-room) {
+    visibility: visible;
+  }
+}
diff --git a/src/assets/translations.data.json b/src/assets/translations.data.json
index 6213bc174429e46a1ac7f770f64a56a907de3449..47fef3295a0b67e0216c506bb504b8e96c9bf2d0 100644
--- a/src/assets/translations.data.json
+++ b/src/assets/translations.data.json
@@ -10,17 +10,17 @@
     "entry.desktop-screen": "Screen",
     "entry.mobile-screen": "Phone",
     "entry.mobile-safari": "Safari",
-    "entry.generic-prefix": "Enter with ",
-    "entry.generic-medium": "PC VR",
+    "entry.generic-prefix": " ",
+    "entry.generic-medium": "Connected Headset",
     "entry.generic-subtitle-desktop": "Oculus or SteamVR",
     "entry.gearvr-prefix": "Enter on ",
     "entry.gearvr-medium": "Gear VR",
     "entry.choose-device": "Choose Device",
-    "entry.device-prefix-desktop": "Use a ",
-    "entry.device-prefix-mobile": "Use a ",
+    "entry.device-prefix-desktop": " ",
+    "entry.device-prefix-mobile": " ",
     "entry.device-medium": "Mobile Headset",
-    "entry.device-subtitle-desktop": "Standalone or Phone Clip-in",
-    "entry.device-subtitle-mobile": "Standalone or Phone Clip-in",
+    "entry.device-subtitle-desktop": "Standalone or Mobile VR",
+    "entry.device-subtitle-mobile": "Standalone or Mobile VR",
     "entry.device-subtitle-vr": "Phone or PC",
     "entry.cardboard": "Enter on Google Cardboard",
     "entry.daydream-prefix": "Enter on ",
@@ -31,6 +31,7 @@
     "entry.invite-team-nag": "Invite a hubs team member",
     "entry.enable-screen-sharing": "Share my desktop",
     "entry.return-to-vr": "Enter in VR",
+    "entry.lobby": "Lobby",
     "profile.save": "Accept",
     "profile.display_name.validation_warning": "Alphanumerics and hyphens. At least 3 characters, no more than 32",
     "profile.header": "Name & Avatar",
@@ -56,6 +57,12 @@
     "autoexit.title_units": " seconds",
     "autoexit.subtitle": "You have started another session.",
     "autoexit.cancel": "CANCEL",
+    "presence.entered_room": "entered the room.",
+    "presence.join_lobby": "joined the lobby.",
+    "presence.leave": "left.",
+    "presence.name_change": "is now known as",
+    "presence.in_lobby": "Lobby",
+    "presence.in_room": "In Room",
     "home.room_create_options": "options",
     "home.room_create_button": "Create Room",
     "home.create_name.validation_warning": "Invalid name, limited to 4 to 64 characters and limited symbols.",
@@ -63,7 +70,9 @@
     "home.join_room": "Join Room",
     "home.report_issue": "Report Issues",
     "home.source_link": "Source",
+    "home.spoke_link": "Spoke",
     "home.about_link": "About",
+    "home.community_link": "Community",
     "home.privacy_notice": "Privacy Notice",
     "home.terms_of_use": "Terms of Use",
     "home.get_updates": "Get Updates",
@@ -80,13 +89,25 @@
     "help.report_issue": "Report an Issue",
     "scene.logo_tagline": "A new way to get together",
     "scene.create_button": "Create a room with this scene",
+    "scene.tweet_button": "Share on Twitter",
     "link.in_your_browser": "In your headset's browser, go to:",
     "link.enter_code": "Then, enter this one-time link code:",
     "link.do_not_close": "Keep this open to use this code.",
-    "link.connect_headset": "Connect Mobile Headset",
+    "link.connect_headset": "Link VR Headset",
     "link.cancel": "cancel",
     "invite.enter_via": "Enter via ",
+    "invite.tweet": "tweet",
     "invite.and_enter_code": " with code:",
-    "invite.or_visit": "or visit"
+    "invite.or_visit": "or share permalink",
+    "spoke.primary_tagline": "make your space",
+    "spoke.secondary_tagline": "Create 3D social scenes for ",
+    "spoke.thank_you": "Thank you for downloading Spoke!",
+    "spoke.download_win": "Download for Windows",
+    "spoke.download_macos": "Download for Mac",
+    "spoke.download_linux": "Download for Linux",
+    "spoke.download_unsupported": "View Releases",
+    "spoke.browse_all_versions": "Browse All Versions",
+    "spoke.close": "Close",
+    "spoke.play_button": "Learn Spoke in 5 Minutes"
   }
 }
diff --git a/src/assets/video/spoke.mp4 b/src/assets/video/spoke.mp4
new file mode 100755
index 0000000000000000000000000000000000000000..00ecc3251d7c6cc73560e38676d71792889652c2
Binary files /dev/null and b/src/assets/video/spoke.mp4 differ
diff --git a/src/assets/video/spoke.webm b/src/assets/video/spoke.webm
new file mode 100644
index 0000000000000000000000000000000000000000..dad54ce9aed95f9e579816b0e7292e96263534ae
Binary files /dev/null and b/src/assets/video/spoke.webm differ
diff --git a/src/components/audio-feedback.js b/src/components/audio-feedback.js
index 71798ba4e05f81398f73942176fa1a94671bfef6..26726d30f57fb970e54c39df01198e7178ffe584 100644
--- a/src/components/audio-feedback.js
+++ b/src/components/audio-feedback.js
@@ -46,7 +46,7 @@ AFRAME.registerComponent("networked-audio-analyser", {
 AFRAME.registerComponent("scale-audio-feedback", {
   schema: {
     minScale: { default: 1 },
-    maxScale: { default: 2 }
+    maxScale: { default: 1.5 }
   },
 
   tick() {
diff --git a/src/components/character-controller.js b/src/components/character-controller.js
index 28e48e6c2cb1d37615a84a98c5b793f89efe899d..b0dd84323e3d6eb7027df2d938fbb66a37705789 100644
--- a/src/components/character-controller.js
+++ b/src/components/character-controller.js
@@ -192,10 +192,10 @@ AFRAME.registerComponent("character-controller", {
   resetPositionOnNavMesh: function(position, navPosition, object3D) {
     const { pathfinder } = this.el.sceneEl.systems.nav;
     if (!(this.navZone in pathfinder.zones)) return;
-    this.navGroup = pathfinder.getGroup(this.navZone, navPosition, true);
+    this.navGroup = pathfinder.getGroup(this.navZone, navPosition, true, true);
     this.navNode = null;
     this._setNavNode(navPosition);
-    object3D.position.copy(navPosition);
+    pathfinder.clampStep(position, navPosition, this.navNode, this.navZone, this.navGroup, object3D.position);
   },
 
   updateVelocity: function(dt) {
diff --git a/src/components/gltf-model-plus.js b/src/components/gltf-model-plus.js
index 912490b97e552d7c4aff1b6b69c519c29a66869d..b4d25bb3989aea9619a3ebc2d4f6b3bc8e5de9cf 100644
--- a/src/components/gltf-model-plus.js
+++ b/src/components/gltf-model-plus.js
@@ -1,5 +1,6 @@
 import nextTick from "../utils/next-tick";
 import SketchfabZipWorker from "../workers/sketchfab-zip.worker.js";
+import MobileStandardMaterial from "../materials/MobileStandardMaterial";
 import cubeMapPosX from "../assets/images/cubemap/posx.jpg";
 import cubeMapNegX from "../assets/images/cubemap/negx.jpg";
 import cubeMapPosY from "../assets/images/cubemap/posy.jpg";
@@ -255,8 +256,12 @@ async function loadGLTF(src, contentType, preferredTechnique, onProgress) {
 
   gltf.scene.traverse(object => {
     if (object.material && object.material.type === "MeshStandardMaterial") {
-      object.material.envMap = envMap;
-      object.material.needsUpdate = true;
+      if (preferredTechnique === "KHR_materials_unlit") {
+        object.material = MobileStandardMaterial.fromStandardMaterial(object.material);
+      } else {
+        object.material.envMap = envMap;
+        object.material.needsUpdate = true;
+      }
     }
   });
 
diff --git a/src/components/scene-preview-camera.js b/src/components/scene-preview-camera.js
index 0f3b534f978a05023843c92fee41cdedb769406d..2576d487b7932670bf5c9349649f392feda27372 100644
--- a/src/components/scene-preview-camera.js
+++ b/src/components/scene-preview-camera.js
@@ -34,6 +34,7 @@ AFRAME.registerComponent("scene-preview-camera", {
 
   tick: function() {
     let t = (new Date().getTime() - this.startTime) / (1000.0 * this.data.duration);
+    t = Math.min(1.0, Math.max(0.0, t));
 
     if (!this.ranOnePass) {
       t = t * (2 - t);
diff --git a/src/hub.js b/src/hub.js
index b7a1b3d8f7854dbb19260fdd4fd15702d5e874c9..9b2c10d85e0e6883038d7ff8307fc33799c49706 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -29,6 +29,7 @@ import joystick_dpad4 from "./behaviours/joystick-dpad4";
 import msft_mr_axis_with_deadzone from "./behaviours/msft-mr-axis-with-deadzone";
 import { PressedMove } from "./activators/pressedmove";
 import { ReverseY } from "./activators/reversey";
+import { Presence } from "phoenix";
 
 import "./activators/shortpress";
 
@@ -255,7 +256,12 @@ async function handleHubChannelJoined(entryManager, hubChannel, data) {
     environmentScene.setAttribute("gltf-bundle", `src: ${sceneUrl}`);
   }
 
-  remountUI({ hubId: hub.hub_id, hubName: hub.name, hubEntryCode: hub.entry_code });
+  remountUI({
+    hubId: hub.hub_id,
+    hubName: hub.name,
+    hubEntryCode: hub.entry_code,
+    onSendMessage: hubChannel.sendMessage
+  });
 
   document
     .querySelector("#hud-hub-entry-link")
@@ -301,7 +307,7 @@ async function runBotMode(scene, entryManager) {
   entryManager.enterSceneWhenLoaded(new MediaStream(), false);
 }
 
-document.addEventListener("DOMContentLoaded", () => {
+document.addEventListener("DOMContentLoaded", async () => {
   const scene = document.querySelector("a-scene");
   const hubChannel = new HubChannel(store);
   const entryManager = new SceneEntryManager(hubChannel);
@@ -316,22 +322,6 @@ document.addEventListener("DOMContentLoaded", () => {
 
   pollForSupportAvailability(isSupportAvailable => remountUI({ isSupportAvailable }));
 
-  document.body.addEventListener("connected", () =>
-    remountUI({ occupantCount: NAF.connection.adapter.publisher.initialOccupants.length + 1 })
-  );
-
-  document.body.addEventListener("clientConnected", () =>
-    remountUI({
-      occupantCount: Object.keys(NAF.connection.adapter.occupants).length + 1
-    })
-  );
-
-  document.body.addEventListener("clientDisconnected", () =>
-    remountUI({
-      occupantCount: Object.keys(NAF.connection.adapter.occupants).length + 1
-    })
-  );
-
   const platformUnsupportedReason = getPlatformUnsupportedReason();
 
   if (platformUnsupportedReason) {
@@ -351,13 +341,13 @@ document.addEventListener("DOMContentLoaded", () => {
     }
   }
 
-  getAvailableVREntryTypes().then(availableVREntryTypes => {
-    if (availableVREntryTypes.isInHMD) {
-      remountUI({ availableVREntryTypes, forcedVREntryType: "vr" });
-    } else {
-      remountUI({ availableVREntryTypes });
-    }
-  });
+  const availableVREntryTypes = await getAvailableVREntryTypes();
+
+  if (availableVREntryTypes.isInHMD) {
+    remountUI({ availableVREntryTypes, forcedVREntryType: "vr" });
+  } else {
+    remountUI({ availableVREntryTypes });
+  }
 
   const environmentScene = document.querySelector("#environment-scene");
 
@@ -381,12 +371,21 @@ document.addEventListener("DOMContentLoaded", () => {
   console.log(`Hub ID: ${hubId}`);
 
   const socket = connectToReticulum(isDebug);
-  const channel = socket.channel(`hub:${hubId}`, {});
+  remountUI({ sessionId: socket.params().session_id });
+
+  // Hub local channel
+  const context = {
+    mobile: isMobile,
+    hmd: availableVREntryTypes.isInHMD
+  };
+
+  const joinPayload = { profile: store.state.profile, context };
+  const hubPhxChannel = socket.channel(`hub:${hubId}`, joinPayload);
 
-  channel
+  hubPhxChannel
     .join()
     .receive("ok", async data => {
-      hubChannel.setPhoenixChannel(channel);
+      hubChannel.setPhoenixChannel(hubPhxChannel);
       await handleHubChannelJoined(entryManager, hubChannel, data);
     })
     .receive("error", res => {
@@ -398,10 +397,101 @@ document.addEventListener("DOMContentLoaded", () => {
       console.error(res);
     });
 
-  channel.on("naf", data => {
+  const hubPhxPresence = new Presence(hubPhxChannel);
+  const presenceLogEntries = [];
+
+  const addToPresenceLog = entry => {
+    entry.key = Date.now().toString();
+
+    presenceLogEntries.push(entry);
+    remountUI({ presenceLogEntries });
+
+    // Fade out and then remove
+    setTimeout(() => {
+      entry.expired = true;
+      remountUI({ presenceLogEntries });
+
+      setTimeout(() => {
+        presenceLogEntries.splice(presenceLogEntries.indexOf(entry), 1);
+        remountUI({ presenceLogEntries });
+      }, 5000);
+    }, entryManager.hasEntered() ? 10000 : 30000); // Fade out things faster once entered.
+  };
+
+  let isInitialSync = true;
+
+  hubPhxPresence.onSync(() => {
+    remountUI({ presences: hubPhxPresence.state });
+
+    if (!isInitialSync) return;
+    // Wire up join/leave event handlers after initial sync.
+    isInitialSync = false;
+
+    hubPhxPresence.onJoin((sessionId, current, info) => {
+      const meta = info.metas[info.metas.length - 1];
+
+      if (current) {
+        // Change to existing presence
+        const isSelf = sessionId === socket.params().session_id;
+        const currentMeta = current.metas[0];
+
+        if (!isSelf && currentMeta.presence !== meta.presence && meta.profile.displayName) {
+          addToPresenceLog({
+            type: "entered",
+            presence: meta.presence,
+            name: meta.profile.displayName
+          });
+        }
+
+        if (currentMeta.profile && meta.profile && currentMeta.profile.displayName !== meta.profile.displayName) {
+          addToPresenceLog({
+            type: "display_name_changed",
+            oldName: currentMeta.profile.displayName,
+            newName: meta.profile.displayName
+          });
+        }
+      } else {
+        // New presence
+        const meta = info.metas[0];
+
+        if (meta.presence && meta.profile.displayName) {
+          addToPresenceLog({
+            type: "join",
+            presence: meta.presence,
+            name: meta.profile.displayName
+          });
+        }
+      }
+    });
+
+    hubPhxPresence.onLeave((sessionId, current, info) => {
+      if (current && current.metas.length > 0) return;
+
+      const meta = info.metas[0];
+
+      if (meta.profile.displayName) {
+        addToPresenceLog({
+          type: "leave",
+          name: meta.profile.displayName
+        });
+      }
+    });
+  });
+
+  hubPhxChannel.on("naf", data => {
     if (!NAF.connection.adapter) return;
     NAF.connection.adapter.onData(data);
   });
 
+  hubPhxChannel.on("message", data => {
+    const userInfo = hubPhxPresence.state[data.session_id];
+    if (!userInfo) return;
+
+    addToPresenceLog({ type: "message", name: userInfo.metas[0].profile.displayName, body: data.body });
+  });
+
+  // Reticulum global channel
+  const retPhxChannel = socket.channel(`ret`, { hub_id: hubId });
+  retPhxChannel.join().receive("error", res => console.error(res));
   linkChannel.setSocket(socket);
 });
diff --git a/src/link.js b/src/link.js
index 401fe54d9b8b9bd91df1c2140257710502a23add..7f803b75a780d22bd85e51f6902806efebea1b5e 100644
--- a/src/link.js
+++ b/src/link.js
@@ -6,6 +6,7 @@ import LinkRoot from "./react-components/link-root";
 import LinkChannel from "./utils/link-channel";
 import { connectToReticulum } from "./utils/phoenix-utils";
 import Store from "./storage/store";
+import { detectInHMD } from "./utils/vr-caps-detect.js";
 
 registerTelemetry();
 
@@ -17,4 +18,7 @@ const linkChannel = new LinkChannel(store);
 
 linkChannel.setSocket(socket);
 
-ReactDOM.render(<LinkRoot store={store} linkChannel={linkChannel} />, document.getElementById("link-root"));
+ReactDOM.render(
+  <LinkRoot store={store} linkChannel={linkChannel} showHeadsetLinkOption={detectInHMD()} />,
+  document.getElementById("link-root")
+);
diff --git a/src/materials/MobileStandardMaterial.js b/src/materials/MobileStandardMaterial.js
new file mode 100644
index 0000000000000000000000000000000000000000..aa9e10de101be73807c0259e1eb75f222e4d974d
--- /dev/null
+++ b/src/materials/MobileStandardMaterial.js
@@ -0,0 +1,110 @@
+const VERTEX_SHADER = `
+#include <common>
+#include <uv_pars_vertex>
+#include <uv2_pars_vertex>
+#include <color_pars_vertex>
+#include <fog_pars_vertex>
+#include <morphtarget_pars_vertex>
+#include <skinning_pars_vertex>
+#include <logdepthbuf_pars_vertex>
+#include <clipping_planes_pars_vertex>
+
+void main() {
+  #include <uv_vertex>
+  #include <uv2_vertex>
+  #include <color_vertex>
+  #include <skinbase_vertex>
+
+  #include <begin_vertex>
+  #include <morphtarget_vertex>
+  #include <skinning_vertex>
+  #include <project_vertex>
+  #include <logdepthbuf_vertex>
+
+  #include <worldpos_vertex>
+  #include <clipping_planes_vertex>
+  #include <fog_vertex>
+}
+`;
+
+const FRAGMENT_SHADER = `
+uniform vec3 diffuse;
+uniform vec3 emissive;
+uniform float opacity;
+
+#include <common>
+#include <color_pars_fragment>
+#include <uv_pars_fragment>
+#include <uv2_pars_fragment>
+#include <map_pars_fragment>
+#include <aomap_pars_fragment>
+#include <emissivemap_pars_fragment>
+#include <fog_pars_fragment>
+#include <logdepthbuf_pars_fragment>
+#include <clipping_planes_pars_fragment>
+
+void main() {
+  #include <clipping_planes_fragment>
+
+  vec4 diffuseColor = vec4(diffuse, opacity);
+  ReflectedLight reflectedLight = ReflectedLight(vec3(0.0), vec3(0.0), vec3(0.0), vec3(0.0));
+  vec3 totalEmissiveRadiance = emissive;
+
+  #include <logdepthbuf_fragment>
+  #include <map_fragment>
+  #include <color_fragment>
+  #include <alphatest_fragment>
+  #include <emissivemap_fragment>
+
+  reflectedLight.indirectDiffuse += vec3(1.0);
+
+  #include <aomap_fragment>
+
+  reflectedLight.indirectDiffuse *= diffuseColor.rgb;
+
+  vec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular + reflectedLight.indirectSpecular + totalEmissiveRadiance;
+
+  gl_FragColor = vec4(outgoingLight, diffuseColor.a);
+
+  #include <premultiplied_alpha_fragment>
+  #include <tonemapping_fragment>
+  #include <encodings_fragment>
+  #include <fog_fragment>
+}
+`;
+
+export default class MobileStandardMaterial extends THREE.ShaderMaterial {
+  static fromStandardMaterial(material) {
+    const parameters = {
+      vertexShader: VERTEX_SHADER,
+      fragmentShader: FRAGMENT_SHADER,
+      uniforms: {
+        uvTransform: { value: new THREE.Matrix3() },
+        diffuse: { value: material.color },
+        opacity: { value: material.opacity },
+        map: { value: material.map },
+        aoMapIntensity: { value: material.aoMapIntensity },
+        aoMap: { value: material.aoMap },
+        emissive: { value: material.emissive },
+        emissiveMap: { value: material.emissiveMap }
+      },
+      fog: true,
+      lights: false,
+      opacity: material.opacity,
+      transparent: material.transparent,
+      skinning: material.skinning,
+      morphTargets: material.morphTargets
+    };
+
+    const mobileMaterial = new MobileStandardMaterial(parameters);
+
+    mobileMaterial.color = material.color;
+    mobileMaterial.map = material.map;
+    mobileMaterial.aoMap = material.aoMap;
+    mobileMaterial.aoMapIntensity = material.aoMapIntensity;
+    mobileMaterial.emissive = material.emissive;
+    mobileMaterial.emissiveMap = material.emissiveMap;
+
+    return mobileMaterial;
+  }
+}
diff --git a/src/react-components/help-dialog.js b/src/react-components/help-dialog.js
index a54947d301011dd2d829704c260f2c9083b13964..0e20c0aa98499e87f8e9fa4e52858d610d183f7f 100644
--- a/src/react-components/help-dialog.js
+++ b/src/react-components/help-dialog.js
@@ -8,6 +8,15 @@ export default class HelpDialog extends Component {
     return (
       <DialogContainer title="Getting Started" {...this.props}>
         <div className="info-dialog__help">
+          <p style={{ textAlign: "center" }}>
+            Join the Hubs community on{" "}
+            <WithHoverSound>
+              <a target="_blank" rel="noopener noreferrer" href="https://discord.gg/XzrGUY8">
+                Discord
+              </a>
+            </WithHoverSound>
+            !
+          </p>
           <p>When in a room, other avatars can see and hear you.</p>
           <p>
             Use your controller&apos;s action button to teleport from place to place. If it has a trigger, use it to
diff --git a/src/react-components/home-root.js b/src/react-components/home-root.js
index 86d7217a7f22f2df0791c848b5c95c11d62e7bad..c975d38316c00b4835e1736bb07e1e6723eaf0d4 100644
--- a/src/react-components/home-root.js
+++ b/src/react-components/home-root.js
@@ -4,6 +4,7 @@ import { IntlProvider, FormattedMessage, addLocaleData } from "react-intl";
 import en from "react-intl/locale-data/en";
 
 import { lang, messages } from "../utils/i18n";
+import { playVideoWithStopOnBlur } from "../utils/video-utils.js";
 import homeVideoWebM from "../assets/video/home.webm";
 import homeVideoMp4 from "../assets/video/home.mp4";
 import hubLogo from "../assets/images/hub-preview-light-no-shadow.png";
@@ -17,7 +18,7 @@ import styles from "../assets/stylesheets/index.scss";
 import HubCreatePanel from "./hub-create-panel.js";
 import AuthDialog from "./auth-dialog.js";
 import ReportDialog from "./report-dialog.js";
-import SlackDialog from "./slack-dialog.js";
+import JoinUsDialog from "./join-us-dialog.js";
 import UpdatesDialog from "./updates-dialog.js";
 import DialogContainer from "./dialog-container.js";
 import { WithHoverSound } from "./wrap-with-audio";
@@ -83,27 +84,15 @@ class HomeRoot extends Component {
   loadHomeVideo = () => {
     const videoEl = document.querySelector("#background-video");
     videoEl.playbackRate = 0.9;
-    function toggleVideo() {
-      // Play the video if the window/tab is visible.
-      if (document.hasFocus()) {
-        videoEl.play();
-      } else {
-        videoEl.pause();
-      }
-    }
-    if ("hasFocus" in document) {
-      document.addEventListener("visibilitychange", toggleVideo);
-      window.addEventListener("focus", toggleVideo);
-      window.addEventListener("blur", toggleVideo);
-    }
+    playVideoWithStopOnBlur(videoEl);
   };
 
   closeDialog() {
     this.setState({ dialog: null });
   }
 
-  showSlackDialog() {
-    this.setState({ dialog: <SlackDialog onClose={this.closeDialog} /> });
+  showJoinUsDialog() {
+    this.setState({ dialog: <JoinUsDialog onClose={this.closeDialog} /> });
   }
 
   showReportDialog() {
@@ -194,21 +183,22 @@ class HomeRoot extends Component {
                 </WithHoverSound>
                 <div className={styles.links}>
                   <WithHoverSound>
-                    <a
-                      href="https://blog.mozvr.com/introducing-hubs-a-new-way-to-get-together-online/"
-                      rel="noreferrer noopener"
-                    >
-                      <FormattedMessage id="home.about_link" />
+                    <a href="https://github.com/mozilla/hubs" rel="noreferrer noopener">
+                      <FormattedMessage id="home.source_link" />
                     </a>
                   </WithHoverSound>
                   <WithHoverSound>
-                    <a href="https://github.com/mozilla/hubs" rel="noreferrer noopener">
-                      <FormattedMessage id="home.source_link" />
+                    <a href="https://discord.gg/XzrGUY8" rel="noreferrer noopener">
+                      <FormattedMessage id="home.community_link" />
+                    </a>
+                  </WithHoverSound>
+                  <WithHoverSound>
+                    <a href="/spoke" rel="noreferrer noopener">
+                      Spoke
                     </a>
                   </WithHoverSound>
                 </div>
               </div>
-              <div className={styles.ident} />
             </div>
             <div className={styles.heroContent}>
               <div className={styles.attribution}>
@@ -258,7 +248,7 @@ class HomeRoot extends Component {
                       className={styles.link}
                       rel="noopener noreferrer"
                       href="#"
-                      onClick={this.onDialogLinkClicked(this.showSlackDialog.bind(this))}
+                      onClick={this.onDialogLinkClicked(this.showJoinUsDialog.bind(this))}
                     >
                       <FormattedMessage id="home.join_us" />
                     </a>
diff --git a/src/react-components/invite-dialog.js b/src/react-components/invite-dialog.js
index 8a913d7b7702cc13d0833031bbadd134528f686e..eeb00fb22a0ef1412c7e19f9727c473ec4c3e563 100644
--- a/src/react-components/invite-dialog.js
+++ b/src/react-components/invite-dialog.js
@@ -16,6 +16,7 @@ function pad(num, size) {
 export default class InviteDialog extends Component {
   static propTypes = {
     entryCode: PropTypes.number,
+    hubId: PropTypes.string,
     allowShare: PropTypes.bool,
     onClose: PropTypes.func
   };
@@ -29,7 +30,7 @@ export default class InviteDialog extends Component {
     this.setState({ shareButtonActive: true });
     setTimeout(() => this.setState({ shareButtonActive: false }), 5000);
 
-    navigator.share({ title: document.title, url: link });
+    navigator.share({ title: "Join me now in #hubs!", url: link });
   };
 
   copyClicked = link => {
@@ -43,7 +44,13 @@ export default class InviteDialog extends Component {
     const { entryCode } = this.props;
 
     const entryCodeString = pad(entryCode, 6);
-    const shareLink = `hub.link/${entryCodeString}`;
+    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}>
@@ -73,22 +80,30 @@ export default class InviteDialog extends Component {
           <FormattedMessage id="invite.or_visit" />
         </div>
         <div className={styles.domain}>
-          <input type="text" readOnly onFocus={e => e.target.select()} value={shareLink} />
+          <input type="text" readOnly onFocus={e => e.target.select()} value={shortLinkText} />
         </div>
         <div className={styles.buttons}>
           <WithHoverSound>
-            <button className={styles.linkButton} onClick={this.copyClicked.bind(this, "https://" + shareLink)}>
+            <button className={styles.linkButton} onClick={this.copyClicked.bind(this, shortLink)}>
               <span>{this.state.copyButtonActive ? "copied!" : "copy"}</span>
             </button>
           </WithHoverSound>
           {this.props.allowShare &&
             navigator.share && (
               <WithHoverSound>
-                <button className={styles.linkButton} onClick={this.shareClicked.bind(this, "https://" + shareLink)}>
+                <button className={styles.linkButton} onClick={this.shareClicked.bind(this, shortLink)}>
                   <span>{this.state.shareButtonActive ? "sharing..." : "share"}</span>
                 </button>
               </WithHoverSound>
             )}
+          {this.props.allowShare &&
+            !navigator.share && (
+              <WithHoverSound>
+                <a href={tweetLink} className={styles.linkButton} target="_blank" rel="noopener noreferrer">
+                  <FormattedMessage id="invite.tweet" />
+                </a>
+              </WithHoverSound>
+            )}
         </div>
       </div>
     );
diff --git a/src/react-components/join-us-dialog.js b/src/react-components/join-us-dialog.js
new file mode 100644
index 0000000000000000000000000000000000000000..7773f27b2e89f3c310df59def91e56d3ff8ead0b
--- /dev/null
+++ b/src/react-components/join-us-dialog.js
@@ -0,0 +1,28 @@
+import React, { Component } from "react";
+import DialogContainer from "./dialog-container.js";
+
+export default class JoinUsDialog extends Component {
+  render() {
+    return (
+      <DialogContainer title="Join Us" {...this.props}>
+        <span>
+          <p>
+            Join us in the{" "}
+            <a href="https://discord.gg/XzrGUY8" target="_blank" rel="noopener noreferrer">
+              Hubs community
+            </a>{" "}
+            on Discord.
+            <br />
+          </p>
+          <p>VR meetups every Friday at noon PDT!</p>
+          <p>
+            You can also follow us on Twitter at{" "}
+            <a href="https://twitter.com/mozillareality" target="_blank" rel="noopener noreferrer">
+              @mozillareality
+            </a>.
+          </p>
+        </span>
+      </DialogContainer>
+    );
+  }
+}
diff --git a/src/react-components/link-root.js b/src/react-components/link-root.js
index ce0105e493766e05d4b1bedbf1e28818d8f896a2..130625b8ddb867978a9db24297d05f79305da646 100644
--- a/src/react-components/link-root.js
+++ b/src/react-components/link-root.js
@@ -21,7 +21,8 @@ class LinkRoot extends Component {
   static propTypes = {
     intl: PropTypes.object,
     store: PropTypes.object,
-    linkChannel: PropTypes.object
+    linkChannel: PropTypes.object,
+    showHeadsetLinkOption: PropTypes.bool
   };
 
   state = {
@@ -179,19 +180,20 @@ class LinkRoot extends Component {
               </div>
 
               <div className={styles.enteredFooter}>
-                {!this.state.isAlphaMode && (
-                  <img onClick={() => this.toggleMode()} src={HeadsetIcon} className={styles.headsetIcon} />
-                )}
-                {!this.state.isAlphaMode && (
-                  <span>
-                    {" "}
-                    <WithHoverSound>
-                      <a href="#" onClick={() => this.toggleMode()}>
-                        <FormattedMessage id="link.linking_a_headset" />
-                      </a>
-                    </WithHoverSound>
-                  </span>
-                )}
+                {!this.state.isAlphaMode &&
+                  this.props.showHeadsetLinkOption && (
+                    <img onClick={() => this.toggleMode()} src={HeadsetIcon} className={styles.headsetIcon} />
+                  )}
+                {!this.state.isAlphaMode &&
+                  this.props.showHeadsetLinkOption && (
+                    <span>
+                      <WithHoverSound>
+                        <a href="#" onClick={() => this.toggleMode()}>
+                          <FormattedMessage id="link.linking_a_headset" />
+                        </a>
+                      </WithHoverSound>
+                    </span>
+                  )}
               </div>
             </div>
 
@@ -200,11 +202,11 @@ class LinkRoot extends Component {
                 ? ["A", "B", "C", "D", "E", "F", "G", "H", "I"]
                 : [1, 2, 3, 4, 5, 6, 7, 8, 9]
               ).map((d, i) => (
-                <WithHoverSound>
+                <WithHoverSound key={`char_${i}`}>
                   <button
                     disabled={this.state.entered.length === this.maxAllowedChars()}
-                    key={`char_${i}`}
                     className={styles.keypadButton}
+                    key={`char_${i}`}
                     onClick={() => {
                       if (!hasTouchEvents) this.addToEntry(d);
                     }}
@@ -214,17 +216,21 @@ class LinkRoot extends Component {
                   </button>
                 </WithHoverSound>
               ))}
-              <WithHoverSound>
-                <button
-                  className={classNames(styles.keypadButton, styles.keypadToggleMode)}
-                  onTouchStart={() => this.toggleMode()}
-                  onClick={() => {
-                    if (!hasTouchEvents) this.toggleMode();
-                  }}
-                >
-                  {this.state.isAlphaMode ? "123" : "ABC"}
-                </button>
-              </WithHoverSound>
+              {this.props.showHeadsetLinkOption ? (
+                <WithHoverSound>
+                  <button
+                    className={classNames(styles.keypadButton, styles.keypadToggleMode)}
+                    onTouchStart={() => this.toggleMode()}
+                    onClick={() => {
+                      if (!hasTouchEvents) this.toggleMode();
+                    }}
+                  >
+                    {this.state.isAlphaMode ? "123" : "ABC"}
+                  </button>
+                </WithHoverSound>
+              ) : (
+                <div />
+              )}
               {!this.state.isAlphaMode && (
                 <WithHoverSound>
                   <button
@@ -254,21 +260,24 @@ class LinkRoot extends Component {
             </div>
 
             <div className={styles.footer}>
-              <div
-                className={styles.linkHeadsetFooterLink}
-                style={{ visibility: this.state.isAlphaMode ? "hidden" : "visible" }}
-              >
-                <WithHoverSound>
-                  <img onClick={() => this.toggleMode()} src={HeadsetIcon} className={styles.headsetIcon} />
-                </WithHoverSound>
-                <span>
-                  <WithHoverSound>
-                    <a href="#" onClick={() => this.toggleMode()}>
-                      <FormattedMessage id="link.linking_a_headset" />
-                    </a>
-                  </WithHoverSound>
-                </span>
-              </div>
+              {!this.state.isAlphaMode &&
+                this.props.showHeadsetLinkOption && (
+                  <div
+                    className={styles.linkHeadsetFooterLink}
+                    style={{ visibility: this.state.isAlphaMode ? "hidden" : "visible" }}
+                  >
+                    <WithHoverSound>
+                      <img onClick={() => this.toggleMode()} src={HeadsetIcon} className={styles.headsetIcon} />
+                    </WithHoverSound>
+                    <span>
+                      <WithHoverSound>
+                        <a href="#" onClick={() => this.toggleMode()}>
+                          <FormattedMessage id="link.linking_a_headset" />
+                        </a>
+                      </WithHoverSound>
+                    </span>
+                  </div>
+                )}
             </div>
           </div>
         </div>
diff --git a/src/react-components/presence-list.js b/src/react-components/presence-list.js
new file mode 100644
index 0000000000000000000000000000000000000000..40d18b9d34b63e0a1f4871cf75b9c6a63923ce55
--- /dev/null
+++ b/src/react-components/presence-list.js
@@ -0,0 +1,64 @@
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import styles from "../assets/stylesheets/presence-list.scss";
+import classNames from "classnames";
+import PhoneImage from "../assets/images/presence_phone.png";
+import DesktopImage from "../assets/images/presence_desktop.png";
+import HMDImage from "../assets/images/presence_vr.png";
+import { FormattedMessage } from "react-intl";
+import { WithHoverSound } from "./wrap-with-audio";
+
+export default class PresenceList extends Component {
+  static propTypes = {
+    presences: PropTypes.object,
+    sessionId: PropTypes.string
+  };
+
+  domForPresence = ([sessionId, data]) => {
+    const meta = data.metas[0];
+    const context = meta.context;
+    const profile = meta.profile;
+
+    const image = context && context.mobile ? PhoneImage : context && context.hmd ? HMDImage : DesktopImage;
+
+    return (
+      <WithHoverSound>
+        <div className={styles.row} key={sessionId}>
+          <div className={styles.device}>
+            <img src={image} />
+          </div>
+          <div
+            className={classNames({
+              [styles.displayName]: true,
+              [styles.selfDisplayName]: sessionId === this.props.sessionId
+            })}
+          >
+            {profile && profile.displayName}
+          </div>
+          <div className={styles.presence}>
+            <FormattedMessage id={`presence.in_${meta.presence}`} />
+          </div>
+        </div>
+      </WithHoverSound>
+    );
+  };
+
+  render() {
+    // Draw self first
+    return (
+      <div className={styles.presenceList}>
+        <div className={styles.attachPoint} />
+        <div className={styles.contents}>
+          <div className={styles.rows}>
+            {Object.entries(this.props.presences || {})
+              .filter(([k]) => k === this.props.sessionId)
+              .map(this.domForPresence)}
+            {Object.entries(this.props.presences || {})
+              .filter(([k]) => k !== this.props.sessionId)
+              .map(this.domForPresence)}
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
diff --git a/src/react-components/presence-log.js b/src/react-components/presence-log.js
new file mode 100644
index 0000000000000000000000000000000000000000..bf9a7b86cb2324546eba0d75c3c152d0b01db4c5
--- /dev/null
+++ b/src/react-components/presence-log.js
@@ -0,0 +1,63 @@
+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";
+
+export default class PresenceLog extends Component {
+  static propTypes = {
+    entries: PropTypes.array,
+    inRoom: PropTypes.bool
+  };
+
+  constructor(props) {
+    super(props);
+  }
+
+  domForEntry = e => {
+    const entryClasses = {
+      [styles.presenceLogEntry]: true,
+      [styles.expired]: !!e.expired
+    };
+
+    switch (e.type) {
+      case "join":
+      case "entered":
+        return (
+          <div key={e.key} className={classNames(entryClasses)}>
+            <b>{e.name}</b> <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}`} />
+          </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>.
+          </div>
+        );
+      case "message":
+        return (
+          <div key={e.key} className={classNames(entryClasses)}>
+            <b>{e.name}</b>:{" "}
+            <Linkify properties={{ target: "_blank", rel: "noopener referrer" }}>{toEmojis(e.body)}</Linkify>
+          </div>
+        );
+    }
+  };
+
+  render() {
+    const presenceClasses = {
+      [styles.presenceLog]: true,
+      [styles.presenceLogInRoom]: this.props.inRoom
+    };
+
+    return <div className={classNames(presenceClasses)}>{this.props.entries.map(this.domForEntry)}</div>;
+  }
+}
diff --git a/src/react-components/scene-ui.js b/src/react-components/scene-ui.js
index 08bf8bbb57368c4c1b5e7d4ef01d7c90ecb6f363..1b1326932c0849c75c722e5b8b2cca90a121f69c 100644
--- a/src/react-components/scene-ui.js
+++ b/src/react-components/scene-ui.js
@@ -5,6 +5,7 @@ import { IntlProvider, FormattedMessage, addLocaleData } from "react-intl";
 import en from "react-intl/locale-data/en";
 import styles from "../assets/stylesheets/scene-ui.scss";
 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 { WithHoverSound } from "./wrap-with-audio";
@@ -67,6 +68,12 @@ class SceneUI extends Component {
   };
 
   render() {
+    const sceneUrl = [location.protocol, "//", location.host, location.pathname].join("");
+    const tweetText = `${this.props.sceneName} in #hubs`;
+    const tweetLink = `https://twitter.com/share?url=${encodeURIComponent(sceneUrl)}&text=${encodeURIComponent(
+      tweetText
+    )}`;
+
     return (
       <IntlProvider locale={lang} messages={messages}>
         <div className={styles.ui}>
@@ -94,12 +101,26 @@ class SceneUI extends Component {
                   <FormattedMessage id="scene.create_button" />
                 </button>
               </WithHoverSound>
+              <WithHoverSound>
+                <a href={tweetLink} rel="noopener noreferrer" target="_blank" className={styles.tweetButton}>
+                  <img src="../assets/images/twitter.svg" />
+                  <div>
+                    <FormattedMessage id="scene.tweet_button" />
+                  </div>
+                </a>
+              </WithHoverSound>
             </div>
           </div>
           <div className={styles.info}>
             <div className={styles.name}>{this.props.sceneName}</div>
             <div className={styles.attribution}>{this.props.sceneAttribution}</div>
           </div>
+          <div className={styles.spoke}>
+            <div className={styles.madeWith}>made with</div>
+            <a href="/spoke">
+              <img src={spokeLogo} />
+            </a>
+          </div>
         </div>
       </IntlProvider>
     );
diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js
index 722c118b8a0225df52e9604ba83bfe9a5c344b0b..72bd5397e31ecb0b92229dd5e80ea4bcdb4d04c7 100644
--- a/src/react-components/ui-root.js
+++ b/src/react-components/ui-root.js
@@ -28,6 +28,8 @@ import InviteTeamDialog from "./invite-team-dialog.js";
 import InviteDialog from "./invite-dialog.js";
 import LinkDialog from "./link-dialog.js";
 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 { faUsers } from "@fortawesome/free-solid-svg-icons/faUsers";
 
@@ -68,6 +70,7 @@ class UIRoot extends Component {
   static propTypes = {
     enterScene: PropTypes.func,
     exitScene: PropTypes.func,
+    onSendMessage: PropTypes.func,
     concurrentLoadDetector: PropTypes.object,
     disableAutoExitOnConcurrentLoad: PropTypes.bool,
     forcedVREntryType: PropTypes.string,
@@ -84,8 +87,10 @@ class UIRoot extends Component {
     platformUnsupportedReason: PropTypes.string,
     hubId: PropTypes.string,
     hubName: PropTypes.string,
-    occupantCount: PropTypes.number,
-    isSupportAvailable: PropTypes.bool
+    isSupportAvailable: PropTypes.bool,
+    presenceLogEntries: PropTypes.array,
+    presences: PropTypes.object,
+    sessionId: PropTypes.string
   };
 
   state = {
@@ -94,6 +99,7 @@ class UIRoot extends Component {
     dialog: null,
     showInviteDialog: false,
     showLinkDialog: false,
+    showPresenceList: false,
     linkCode: null,
     linkCodeCancel: null,
     miniInviteActivated: false,
@@ -124,7 +130,8 @@ class UIRoot extends Component {
 
     exited: false,
 
-    showProfileEntry: false
+    showProfileEntry: false,
+    pendingMessage: ""
   };
 
   componentDidMount() {
@@ -438,6 +445,7 @@ class UIRoot extends Component {
 
   onProfileFinished = () => {
     this.setState({ showProfileEntry: false });
+    this.props.hubChannel.sendProfileUpdate();
   };
 
   beginOrSkipAudioSetup = () => {
@@ -575,7 +583,7 @@ class UIRoot extends Component {
   }
 
   onMiniInviteClicked = () => {
-    const link = "https://hub.link/" + this.props.hubEntryCode;
+    const link = "https://hub.link/" + this.props.hubId;
 
     this.setState({ miniInviteActivated: true });
     setTimeout(() => {
@@ -589,6 +597,16 @@ class UIRoot extends Component {
     }
   };
 
+  sendMessage = e => {
+    e.preventDefault();
+    this.props.onSendMessage(this.state.pendingMessage);
+    this.setState({ pendingMessage: "" });
+  };
+
+  occupantCount = () => {
+    return this.props.presences ? Object.entries(this.props.presences).length : 0;
+  };
+
   renderExitedPane = () => {
     let subtitle = null;
     if (this.props.roomUnavailableReason === "closed") {
@@ -697,7 +715,7 @@ class UIRoot extends Component {
   renderEntryStartPanel = () => {
     return (
       <div className={entryStyles.entryPanel}>
-        <div className={entryStyles.title}>{this.props.hubName}</div>
+        <div className={entryStyles.name}>{this.props.hubName}</div>
 
         <div className={entryStyles.center}>
           <WithHoverSound>
@@ -706,6 +724,21 @@ class UIRoot extends Component {
               <div title={this.props.store.state.profile.displayName}>{this.props.store.state.profile.displayName}</div>
             </div>
           </WithHoverSound>
+
+          <form onSubmit={this.sendMessage}>
+            <div className={styles.messageEntry}>
+              <input
+                className={styles.messageEntryInput}
+                value={this.state.pendingMessage}
+                onFocus={e => e.target.select()}
+                onChange={e => this.setState({ pendingMessage: e.target.value })}
+                placeholder="Send a message..."
+              />
+              <WithHoverSound>
+                <input className={styles.messageEntrySubmit} type="submit" value="send" />
+              </WithHoverSound>
+            </div>
+          </form>
         </div>
 
         <div className={entryStyles.buttonContainer}>
@@ -751,11 +784,9 @@ class UIRoot extends Component {
           )}
           <DeviceEntryButton onClick={() => this.attemptLink()} isInHMD={this.props.availableVREntryTypes.isInHMD} />
           {this.props.availableVREntryTypes.cardboard !== VR_DEVICE_AVAILABILITY.no && (
-            <WithHoverSound>
-              <div className={entryStyles.secondary} onClick={this.enterVR}>
-                <FormattedMessage id="entry.cardboard" />
-              </div>
-            </WithHoverSound>
+            <div className={entryStyles.secondary} onClick={this.enterVR}>
+              <FormattedMessage id="entry.cardboard" />
+            </div>
           )}
           {screenSharingCheckbox}
         </div>
@@ -1013,10 +1044,34 @@ class UIRoot extends Component {
 
             {(!entryFinished || this.isWaitingForAutoExit()) && (
               <div className={styles.uiDialog}>
+                <PresenceLog entries={this.props.presenceLogEntries || []} />
                 <div className={dialogBoxContentsClassNames}>{dialogContents}</div>
               </div>
             )}
 
+            {entryFinished && <PresenceLog inRoom={true} entries={this.props.presenceLogEntries || []} />}
+            {entryFinished && (
+              <form onSubmit={this.sendMessage}>
+                <div className={styles.messageEntryInRoom}>
+                  <input
+                    className={classNames([styles.messageEntryInput, styles.messageEntryInputInRoom])}
+                    value={this.state.pendingMessage}
+                    onFocus={e => e.target.select()}
+                    onChange={e => {
+                      e.stopPropagation();
+                      this.setState({ pendingMessage: e.target.value });
+                    }}
+                    placeholder="Send a message..."
+                  />
+                  <input
+                    className={classNames([styles.messageEntrySubmit, styles.messageEntrySubmitInRoom])}
+                    type="submit"
+                    value="send"
+                  />
+                </div>
+              </form>
+            )}
+
             <div
               className={classNames({
                 [styles.inviteContainer]: true,
@@ -1027,9 +1082,7 @@ class UIRoot extends Component {
               {!showVREntryButton && (
                 <WithHoverSound>
                   <button
-                    className={classNames({
-                      [styles.hideSmallScreens]: this.props.occupantCount > 1 && entryFinished
-                    })}
+                    className={classNames({ [styles.hideSmallScreens]: this.occupantCount() > 1 && entryFinished })}
                     onClick={() => this.toggleInviteDialog()}
                   >
                     <FormattedMessage id="entry.invite-others-nag" />
@@ -1037,7 +1090,7 @@ class UIRoot extends Component {
                 </WithHoverSound>
               )}
               {!showVREntryButton &&
-                this.props.occupantCount > 1 &&
+                this.occupantCount() > 1 &&
                 entryFinished && (
                   <WithHoverSound>
                     <button onClick={this.onMiniInviteClicked} className={styles.inviteMiniButton}>
@@ -1046,7 +1099,7 @@ class UIRoot extends Component {
                           ? navigator.share
                             ? "sharing..."
                             : "copied!"
-                          : "hub.link/" + this.props.hubEntryCode}
+                          : "hub.link/" + this.props.hubId}
                       </span>
                     </button>
                   </WithHoverSound>
@@ -1062,6 +1115,7 @@ class UIRoot extends Component {
                 <InviteDialog
                   allowShare={!this.props.availableVREntryTypes.isInHMD}
                   entryCode={this.props.hubEntryCode}
+                  hubId={this.props.hubId}
                   onClose={() => this.setState({ showInviteDialog: false })}
                 />
               )}
@@ -1086,12 +1140,22 @@ class UIRoot extends Component {
             </WithHoverSound>
 
             <WithHoverSound>
-              <div className={styles.presenceInfo}>
+              <div
+                onClick={() => this.setState({ showPresenceList: !this.state.showPresenceList })}
+                className={classNames({
+                  [styles.presenceInfo]: true,
+                  [styles.presenceInfoSelected]: this.state.showPresenceList
+                })}
+              >
                 <FontAwesomeIcon icon={faUsers} />
-                <span className={styles.occupantCount}>{this.props.occupantCount || "-"}</span>
+                <span className={styles.occupantCount}>{this.occupantCount()}</span>
               </div>
             </WithHoverSound>
 
+            {this.state.showPresenceList && (
+              <PresenceList presences={this.props.presences} sessionId={this.props.sessionId} />
+            )}
+
             {this.state.entryStep === ENTRY_STEPS.finished ? (
               <div>
                 <TwoDHUD.TopHUD
diff --git a/src/react-components/wrap-with-audio.js b/src/react-components/wrap-with-audio.js
index 7d44c9a70aa150677a811bdd966442b96b165d4e..89080099e32b041aec3a23485ed34f22ade3e616 100644
--- a/src/react-components/wrap-with-audio.js
+++ b/src/react-components/wrap-with-audio.js
@@ -1,4 +1,5 @@
 import React from "react";
+import PropTypes from "prop-types";
 
 export const AudioContext = React.createContext({});
 
@@ -18,3 +19,8 @@ export const WithHoverSound = ({ sound, children }) => {
     </AudioContext.Consumer>
   );
 };
+
+WithHoverSound.propTypes = {
+  children: PropTypes.object,
+  sound: PropTypes.string
+};
diff --git a/src/scene-entry-manager.js b/src/scene-entry-manager.js
index f6fc7e1a9787062f4e06d81c75b2a2bcd085297f..b39159838952cb60917d471b4d31ce65f0569c3b 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 { inGameActions } from "./input-mappings";
+import nextTick from "./utils/next-tick";
 
 const playerHeight = 1.6;
 const isBotMode = qsTruthy("bot");
@@ -23,6 +24,7 @@ export default class SceneEntryManager {
     this.scene = document.querySelector("a-scene");
     this.cursorController = document.querySelector("#cursor-controller");
     this.playerRig = document.querySelector("#player-rig");
+    this._entered = false;
   }
 
   init = () => {
@@ -31,6 +33,10 @@ export default class SceneEntryManager {
     });
   };
 
+  hasEntered = () => {
+    return this._entered;
+  };
+
   enterScene = async (mediaStream, enterInVR) => {
     const playerCamera = document.querySelector("#player-camera");
     playerCamera.removeAttribute("scene-preview-camera");
@@ -81,10 +87,18 @@ export default class SceneEntryManager {
     const cursor = this.cursorController.components["cursor-controller"];
     cursor.enable();
     cursor.setCursorVisibility(true);
+    this._entered = true;
 
-    this.hubChannel.sendEntryEvent().then(() => {
-      this.store.update({ activity: { lastEnteredAt: new Date().toISOString() } });
-    });
+    // Delay sending entry event telemetry until VR display is presenting.
+    (async () => {
+      while (enterInVR && !(await navigator.getVRDisplays()).find(d => d.isPresenting)) {
+        await nextTick();
+      }
+
+      this.hubChannel.sendEntryEvent().then(() => {
+        this.store.update({ activity: { lastEnteredAt: new Date().toISOString() } });
+      });
+    })();
   };
 
   whenSceneLoaded = callback => {
@@ -207,7 +221,7 @@ export default class SceneEntryManager {
     });
 
     document.addEventListener("paste", e => {
-      if (e.target.nodeName === "INPUT") return;
+      if (e.target.nodeName === "INPUT" && document.activeElement === e.target) return;
 
       const url = e.clipboardData.getData("text");
       const files = e.clipboardData.files && e.clipboardData.files;
diff --git a/src/spoke.html b/src/spoke.html
new file mode 100644
index 0000000000000000000000000000000000000000..4cdea285a2a48fe96ef7ae28b8bc8826f328bd4b
--- /dev/null
+++ b/src/spoke.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+    <meta property="og:url" content="https://hubs.mozilla.com/spoke">
+    <meta property="og:title" content="Spoke by Mozilla">
+    <meta property="og:description" content="Create custom social VR scenes, right in your browser.">
+    <meta property="og:image" content="https://hubs.mozilla.com/spoke-preview.png">
+    <meta name="twitter:card" content="summary_large_image">
+    <meta name="twitter:domain" value="hubs.mozilla.com">
+    <meta name="twitter:title" value="Spoke by Mozilla">
+    <meta name="twitter:description" content="Create custom social VR scenes, right in your browser.">
+    <meta property="twitter:image" content="https://hubs.mozilla.com/spoke-preview.png">
+    <meta name="twitter:url" value="https://hubs.mozilla.com/spoke">
+    <link rel="shortcut icon" type="image/png" href="/favicon-spoke.ico">
+    <title>Spoke by Mozilla</title>
+    <link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,700" rel="stylesheet">
+</head>
+
+<body>
+  <div id="ui-root"></div>
+</body>
+
+</html>
diff --git a/src/spoke.js b/src/spoke.js
new file mode 100644
index 0000000000000000000000000000000000000000..c82a3c898a3c4080af29f8046f20f09a7ca3a9f6
--- /dev/null
+++ b/src/spoke.js
@@ -0,0 +1,238 @@
+import ReactDOM from "react-dom";
+import React, { Component } from "react";
+//import PropTypes from "prop-types";
+//import classNames from "classnames";
+import { playVideoWithStopOnBlur } from "./utils/video-utils.js";
+import { IntlProvider, FormattedMessage, addLocaleData } from "react-intl";
+import styles from "./assets/stylesheets/spoke.scss";
+import spokeLogo from "./assets/images/spoke_logo.png";
+import spokeVideoMp4 from "./assets/video/spoke.mp4";
+import spokeVideoWebm from "./assets/video/spoke.webm";
+import YouTube from "react-youtube";
+
+//const qs = new URLSearchParams(location.search);
+
+import registerTelemetry from "./telemetry";
+
+registerTelemetry();
+
+import en from "react-intl/locale-data/en";
+import { lang, messages } from "./utils/i18n";
+
+addLocaleData([...en]);
+
+function getPlatform() {
+  const platform = window.navigator.platform;
+
+  if (["Macintosh", "MacIntel", "MacPPC", "Mac68K"].indexOf(platform) >= 0) {
+    return "macos";
+  } else if (["Win32", "Win64", "Windows"].indexOf(platform) >= 0) {
+    return "win";
+  } else if (/Linux/.test(platform) && !/\WAndroid\W/.test(navigator.userAgent)) {
+    return "linux";
+  }
+
+  return "unsupported";
+}
+
+class SpokeLanding extends Component {
+  static propTypes = {};
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      platform: getPlatform(),
+      downloadClicked: false,
+      downloadLinkForCurrentPlatform: {},
+      showPlayer: false
+    };
+  }
+
+  componentDidMount() {
+    this.loadVideo();
+    this.fetchReleases();
+  }
+
+  tryGetJson = async request => {
+    const text = await request.text();
+    try {
+      return JSON.parse(text);
+    } catch (e) {
+      console.log(`JSON error parsing response from ${request.url} "${text}"`, e);
+    }
+  };
+
+  getDownloadUrlForPlatform = (assets, platform) => {
+    return assets.find(asset => asset.name.includes(platform)).downloadUrl;
+  };
+
+  fetchReleases = async () => {
+    // Read-only, public access token.
+    const token = "de8cbfb4cc0281c7b731c891df431016c29b0ace";
+    const result = await fetch("https://api.github.com/graphql", {
+      timeout: 5000,
+      method: "POST",
+      headers: { authorization: `bearer ${token}` },
+      body: JSON.stringify({
+        query: `
+          {
+            repository(owner: "mozillareality", name: "spoke") {
+          releases(
+                orderBy: { field: CREATED_AT, direction: DESC },
+                first: 5
+              ) {
+                nodes {
+                  isPrerelease,
+                  isDraft,
+                  tag { name },
+                  releaseAssets(last: 3) {
+                    nodes { name, downloadUrl }
+                  }
+                },
+                pageInfo { endCursor, hasNextPage }
+              }
+            }
+          }
+        `
+      })
+    }).then(this.tryGetJson);
+
+    if (!result || !result.data) {
+      this.setState({ platform: "unsupported" });
+      return;
+    }
+
+    const releases = result.data.repository.releases;
+    const release = releases.nodes.find(release => /*!release.isPrerelease && */ !release.isDraft);
+
+    if (!release) {
+      this.setState({ platform: "unsupported" });
+      return;
+    }
+
+    this.setState({
+      downloadLinkForCurrentPlatform: this.getDownloadUrlForPlatform(release.releaseAssets.nodes, this.state.platform),
+      spokeVersion: release.tag.name
+    });
+  };
+
+  loadVideo() {
+    const videoEl = document.querySelector("#preview-video");
+    playVideoWithStopOnBlur(videoEl);
+  }
+
+  render() {
+    const platform = this.state.platform;
+    const releasesLink = "https://github.com/MozillaReality/Spoke/releases/latest";
+    const downloadLink = platform === "unsupported" ? releasesLink : this.state.downloadLinkForCurrentPlatform;
+
+    return (
+      <IntlProvider locale={lang} messages={messages}>
+        <div className={styles.ui}>
+          <div className={styles.header}>
+            <div className={styles.headerLinks}>
+              <a href="https://github.com/mozillareality/spoke" rel="noopener noreferrer">
+                <FormattedMessage id="home.source_link" />
+              </a>
+              <a href="https://discord.gg/XzrGUY8" rel="noreferrer noopener">
+                <FormattedMessage id="home.community_link" />
+              </a>
+              <a href="/" rel="noreferrer noopener">
+                Hubs
+              </a>
+            </div>
+          </div>
+          <div className={styles.content}>
+            <div className={styles.heroPane}>
+              <div className={styles.heroMessage}>
+                <div className={styles.spokeLogo}>
+                  <img src={spokeLogo} />
+                  <div className={styles.primaryTagline}>
+                    <FormattedMessage id="spoke.primary_tagline" />
+                  </div>
+                </div>
+                <div className={styles.secondaryTagline}>
+                  <FormattedMessage id="spoke.secondary_tagline" />
+                  <a style={{ fontWeight: "bold" }} href="/">
+                    Hubs
+                  </a>
+                </div>
+                <div className={styles.actionButtons}>
+                  {!this.state.downloadClicked ? (
+                    <a
+                      href={downloadLink}
+                      onClick={() => this.setState({ downloadClicked: platform !== "unsupported" })}
+                      className={styles.downloadButton}
+                    >
+                      <div>
+                        <FormattedMessage id={"spoke.download_" + this.state.platform} />
+                      </div>
+                      {platform !== "unsupported" && (
+                        <div className={styles.version}>{this.state.spokeVersion} Beta</div>
+                      )}
+                    </a>
+                  ) : (
+                    <div className={styles.thankYou}>
+                      <p>
+                        <FormattedMessage id="spoke.thank_you" />
+                      </p>
+
+                      <p>
+                        You can also <a href="https://discord.gg/XzrGUY8/">join our community</a> on Discord.
+                      </p>
+                    </div>
+                  )}
+
+                  {platform !== "unsupported" &&
+                    !this.state.downloadClicked && (
+                      <a href={releasesLink} className={styles.browseVersions}>
+                        <FormattedMessage id="spoke.browse_all_versions" />
+                      </a>
+                    )}
+                  <button className={styles.playButton} onClick={() => this.setState({ showPlayer: true })}>
+                    <FormattedMessage id="spoke.play_button" />
+                  </button>
+                </div>
+              </div>
+              <div className={styles.heroVideo}>
+                <video playsInline muted loop autoPlay className={styles.previewVideo} id="preview-video">
+                  <source src={spokeVideoMp4} type="video/mp4" />
+                  <source src={spokeVideoWebm} type="video/webm" />
+                </video>
+                <div className={styles.attribution}>Low Poly Campfire by Minzkraut</div>
+              </div>
+            </div>
+          </div>
+          <div className={styles.bg} />
+          {this.state.showPlayer && (
+            <div className={styles.playerOverlay}>
+              <div className={styles.playerContent}>
+                <YouTube
+                  className={styles.playerVideo}
+                  opts={{ rel: 0 }}
+                  videoId="WmQKZJPhV7s"
+                  onReady={e => e.target.playVideo()}
+                />
+                {platform !== "unsupported" && (
+                  <a href={downloadLink} className={styles.downloadButton}>
+                    <div>
+                      <FormattedMessage id={"spoke.download_" + this.state.platform} />
+                    </div>
+                    <div className={styles.version}>{this.state.spokeVersion} Beta</div>
+                  </a>
+                )}
+                <a onClick={() => this.setState({ showPlayer: false })} className={styles.closeVideo}>
+                  <FormattedMessage id="spoke.close" />
+                </a>
+              </div>
+            </div>
+          )}
+        </div>
+      </IntlProvider>
+    );
+  }
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+  ReactDOM.render(<SpokeLanding />, document.getElementById("ui-root"));
+});
diff --git a/src/utils/hub-channel.js b/src/utils/hub-channel.js
index 8e8b50b36c8b5e35e687889b2e14d1a6c7237ffc..328a76a36e46dd2578bc0595ea0280ccfb93eb4d 100644
--- a/src/utils/hub-channel.js
+++ b/src/utils/hub-channel.js
@@ -87,6 +87,15 @@ export default class HubChannel {
     this.channel.push("events:object_spawned", spawnEvent);
   };
 
+  sendProfileUpdate = () => {
+    this.channel.push("events:profile_updated", { profile: this.store.state.profile });
+  };
+
+  sendMessage = body => {
+    if (body === "") return;
+    this.channel.push("message", { body });
+  };
+
   requestSupport = () => {
     this.channel.push("events:request_support", {});
   };
diff --git a/src/utils/video-utils.js b/src/utils/video-utils.js
new file mode 100644
index 0000000000000000000000000000000000000000..74fdb626f52901b4b636f9fce7ccf9106bce20a4
--- /dev/null
+++ b/src/utils/video-utils.js
@@ -0,0 +1,15 @@
+export function playVideoWithStopOnBlur(videoEl) {
+  function toggleVideo() {
+    // Play the video if the window/tab is visible.
+    if (document.hasFocus()) {
+      videoEl.play();
+    } else {
+      videoEl.pause();
+    }
+  }
+  if ("hasFocus" in document) {
+    document.addEventListener("visibilitychange", toggleVideo);
+    window.addEventListener("focus", toggleVideo);
+    window.addEventListener("blur", toggleVideo);
+  }
+}
diff --git a/src/utils/vr-caps-detect.js b/src/utils/vr-caps-detect.js
index d5ad87eff808d1cb528c76b068de0c855d0bb3a0..fc2737ad41519798326d1f2ce6cec57e5a8e5b23 100644
--- a/src/utils/vr-caps-detect.js
+++ b/src/utils/vr-caps-detect.js
@@ -22,6 +22,11 @@ function isMaybeDaydreamCompatibleDevice(ua) {
 // that can be entered into as a "generic" entry flow.
 const GENERIC_ENTRY_TYPE_DEVICE_BLACKLIST = [/cardboard/i];
 
+export function detectInHMD() {
+  const isOculusBrowser = /Oculus/.test(navigator.userAgent);
+  return isOculusBrowser;
+}
+
 // Tries to determine VR entry compatibility regardless of the current browser.
 //
 // For each VR "entry type", returns VR_DEVICE_AVAILABILITY.yes if that type can be launched into directly from this browser
@@ -45,7 +50,6 @@ const GENERIC_ENTRY_TYPE_DEVICE_BLACKLIST = [/cardboard/i];
 export async function getAvailableVREntryTypes() {
   const ua = navigator.userAgent;
   const isSamsungBrowser = browser.name === "chrome" && /SamsungBrowser/.test(ua);
-  const isOculusBrowser = /Oculus/.test(ua);
 
   // This needs to be kept up-to-date with the latest browsers that can support VR and Hubs.
   // Checking for navigator.getVRDisplays always passes b/c of polyfill.
@@ -63,7 +67,9 @@ export async function getAvailableVREntryTypes() {
     : VR_DEVICE_AVAILABILITY.no;
 
   const displays = isWebVRCapableBrowser ? await navigator.getVRDisplays() : [];
-  const isInHMD = isOculusBrowser;
+
+  const isOculusBrowser = /Oculus/.test(ua);
+  const isInHMD = detectInHMD();
 
   const screen = isInHMD
     ? VR_DEVICE_AVAILABILITY.no
diff --git a/webpack.config.js b/webpack.config.js
index adb61bc8f3ba81bff092c7dc61c30f129f56068e..e6567532eb15a79f243b7e8a5df1b9064e5eeefd 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -66,6 +66,7 @@ module.exports = (env, argv) => ({
     hub: path.join(__dirname, "src", "hub.js"),
     scene: path.join(__dirname, "src", "scene.js"),
     link: path.join(__dirname, "src", "link.js"),
+    spoke: path.join(__dirname, "src", "spoke.js"),
     "avatar-selector": path.join(__dirname, "src", "avatar-selector.js")
   },
   output: {
@@ -216,6 +217,11 @@ module.exports = (env, argv) => ({
       template: path.join(__dirname, "src", "link.html"),
       chunks: ["vendor", "link"]
     }),
+    new HTMLWebpackPlugin({
+      filename: "spoke.html",
+      template: path.join(__dirname, "src", "spoke.html"),
+      chunks: ["vendor", "spoke"]
+    }),
     new HTMLWebpackPlugin({
       filename: "avatar-selector.html",
       template: path.join(__dirname, "src", "avatar-selector.html"),