diff --git a/.travis.yml b/.travis.yml
index 62961abc4..a3571378a 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -41,7 +41,7 @@ cache:
 
 before_install:
   - gem install bundler
-  - npm i -g eslint babel-eslint
+  - npm i -g eslint@1.3.1 babel-eslint
   - eslint app/assets/javascripts
   - eslint --ext .es6 app/assets/javascripts
   - eslint --ext .es6 test/javascripts
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f00d5d078..98aa05e4a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,129 +1,27 @@
 # Contributing to Discourse
 
-## Before You Start
+## Important note for Developers
 
-Anyone wishing to contribute to the **[Discourse/Discourse](https://github.com/discourse/discourse)** project **MUST read & sign the [Electronic Discourse Forums Contribution License Agreement](http://www.discourse.org/cla)**. The Discourse team is legally prevented from accepting any pull requests from users who have not signed the CLA first.
+Anyone wishing to contribute to the [github.com/discourse/discourse](https://github.com/discourse/discourse) project **must read & sign our [Contributor License Agreement](http://www.discourse.org/cla)**. The Discourse team is legally prevented from accepting any pull requests from users who have not signed the CLA first.
 
-## Reporting Bugs
+For more information on
 
-1. Always update to the most recent master release; the bug may already be resolved.
+- how to set up your development environment
+- first-time project suggestions
+- code conventions
+- step-by-step guide for GitHub commits
 
-2. Search for similar issues on the [Discourse meta forum][m]; it may already be an identified problem.
+**please read our [Discourse Development Contribution Guidelines](https://meta.discourse.org/t/discourse-development-contribution-guidelines/3823)**
 
-3. Make sure you can reproduce your problem on our sandbox at [try.discourse.org](http://try.discourse.org)
+## Everything Else
 
-4. If this is a bug or problem that **requires any kind of extended discussion -- open [a topic on meta][m] about it**.
+There are many other ways to contribute to Discourse besides code. We've outlined the most common ones below.
 
-5. If possible, submit a Pull Request with a failing test. If you'd rather take matters into your own hands, fix the bug yourself (jump down to the "Contributing (Step-by-step)" section).
+- [Reporting Bugs](https://meta.discourse.org/t/how-to-make-bug-reports-for-discourse/33070)
+- [Requesting Features](https://meta.discourse.org/t/how-to-request-new-features-for-discourse/32986)
+- [Translation](https://meta.discourse.org/t/contribute-a-translation-to-discourse/14882)
+- Documentation (TBA)
 
-6. When the bug is fixed, we will do our best to update the Discourse topic.
+For anything else, just start a new topic on [Meta](https://meta.discourse.org/) and let us know what you're interested in working on.
 
-## Requesting New Features
-
-1. Do not submit a feature request on GitHub; all feature requests on GitHub will be closed. Instead, visit the **[Discourse meta forum, features category](http://meta.discourse.org/category/feature)**, and search this list for similar feature requests. It's possible somebody has already asked for this feature or provided a pull request that we're still discussing.
-
-2. Provide a clear and detailed explanation of the feature you want and why it's important to add. The feature must apply to a wide array of users of Discourse; for smaller, more targeted "one-off" features, you might consider writing a plugin for Discourse. You may also want to provide us with some advance documentation on the feature, which will help the community to better understand where it will fit.
-
-3. If you're a Rock Star programmer, build the feature yourself (refer to the "Contributing (Step-by-step)" section below).
-
-## Contributing (Step-by-step)
-
-1. Clone the Repo:
-
-        git clone git://github.com/discourse/discourse.git
-
-2. Create a new Branch:
-
-        cd discourse
-        git checkout -b new_discourse_branch
-
- > Please keep your code clean: one feature or bug-fix per branch. If you find another bug, you want to fix while being in a new branch, please fix it in a separated branch instead.
-
-3. Code
-  * Adhere to common conventions you see in the existing code
-  * Include tests, and ensure they pass
-  * Search to see if your new functionality has been discussed on [the Discourse meta forum](http://meta.discourse.org), and include updates as appropriate
-
-4. Follow the Coding Conventions
-  * two spaces, no tabs
-  * no trailing whitespaces, blank lines should have no spaces
-  * use spaces around operators, after commas, colons, semicolons, around `{` and before `}`
-  * no space after `(`, `[` or before `]`, `)`
-  * use Ruby 1.9 hash syntax: prefer `{ a: 1 }` over `{ :a => 1 }`
-  * prefer `class << self; def method; end` over `def self.method` for class methods
-  * prefer `{ ... }` over `do ... end` for single-line blocks, avoid using `{ ... }` for multi-line blocks
-  * avoid `return` when not required
-
-  > However, please note that **pull requests consisting entirely of style changes are not welcome on this project**. Style changes in the context of pull requests that also refactor code, fix bugs, improve functionality *are* welcome.
-
-5. Commit
-
-  For every commit please write a short (max 72 characters) summary in the first line followed with a blank line and then more detailed descriptions of the change. Use markdown syntax for simple styling.
-
-  **NEVER leave the commit message blank!** Provide a detailed, clear, and complete description of your commit!
-
-
-6. Update your branch
-
-  ```
-  git fetch origin
-  git rebase origin/master
-  ```
-
-7. Fork
-
-  ```
-  git remote add mine git@github.com:<your user name>/discourse.git
-  ```
-
-8. Push to your remote
-
-  ```
-  git push mine new_discourse_branch
-  ```
-
-9. Issue a Pull Request
-
-  Before submitting a pull-request, clean up the history, go over your commits and squash together minor changes and fixes into the corresponding commits. You can squash commits with the interactive rebase command:
-
-  ```
-  git fetch origin
-  git checkout new_discourse_branch
-  git rebase origin/master
-  git rebase -i
-
-  < the editor opens and allows you to change the commit history >
-  < follow the instructions on the bottom of the editor >
-
-  git push -f mine new_discourse_branch
-  ```
-
-
-  In order to make a pull request,
-  * Navigate to the Discourse repository you just pushed to (e.g. https://github.com/your-user-name/discourse)
-  * Click "Pull Request".
-  * Write your branch name in the branch field (this is filled with "master" by default)
-  * Click "Update Commit Range".
-  * Ensure the changesets you introduced are included in the "Commits" tab.
-  * Ensure that the "Files Changed" incorporate all of your changes.
-  * Fill in some details about your potential patch including a meaningful title.
-  * Click "Send pull request".
-
-  Thanks for that -- we'll get to your pull request ASAP, we love pull requests!
-
-10. Responding to Feedback
-
-  The Discourse team may recommend adjustments to your code. Part of interacting with a healthy open-source community requires you to be open to learning new techniques and strategies; *don't get discouraged!* Remember: if the Discourse team suggest changes to your code, **they care enough about your work that they want to include it**, and hope that you can assist by implementing those revisions on your own.
-
-  > Though we ask you to clean your history and squash commit before submitting a pull-request, please do not change any commits you've submitted already (as other work might be build on top).
-
-## Translations
-
-Translators can do their work in our [Transifex project](https://www.transifex.com/projects/p/discourse-org/). For more information, please see these how-to topics:
-
-* [Contributing a translation to Discourse](https://meta.discourse.org/t/contribute-a-translation-to-discourse/14882)
-* [How to add a new language](https://meta.discourse.org/t/how-to-add-a-new-language/14970)
-
-
-
-[m]: http://meta.discourse.org
+*Thanks for contributing!*
diff --git a/Gemfile.lock b/Gemfile.lock
index 44397f9df..e33b7a10e 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -209,7 +209,7 @@ GEM
     omniauth-twitter (1.0.1)
       multi_json (~> 1.3)
       omniauth-oauth (~> 1.0)
-    onebox (1.5.24)
+    onebox (1.5.25)
       moneta (~> 0.8)
       multi_json (~> 1.11)
       mustache
diff --git a/app/assets/javascripts/discourse/components/avatar-uploader.js.es6 b/app/assets/javascripts/discourse/components/avatar-uploader.js.es6
index 837960646..539171bc9 100644
--- a/app/assets/javascripts/discourse/components/avatar-uploader.js.es6
+++ b/app/assets/javascripts/discourse/components/avatar-uploader.js.es6
@@ -1,3 +1,4 @@
+import computed from "ember-addons/ember-computed-decorators";
 import UploadMixin from "discourse/mixins/upload";
 
 export default Em.Component.extend(UploadMixin, {
@@ -5,21 +6,23 @@ export default Em.Component.extend(UploadMixin, {
   tagName: "span",
   imageIsNotASquare: false,
 
-  uploadButtonText: function() {
-    return this.get("uploading") ? I18n.t("uploading") : I18n.t("user.change_avatar.upload_picture");
-  }.property("uploading"),
+  @computed("uploading")
+  uploadButtonText(uploading) {
+    return uploading ? I18n.t("uploading") : I18n.t("user.change_avatar.upload_picture");
+  },
 
   uploadDone(upload) {
     this.setProperties({
       imageIsNotASquare: upload.width !== upload.height,
       uploadedAvatarTemplate: upload.url,
-      custom_avatar_upload_id: upload.id,
+      uploadedAvatarId: upload.id,
     });
 
     this.sendAction("done");
   },
 
-  data: function() {
-    return { user_id: this.get("user_id") };
-  }.property("user_id")
+  @computed("user_id")
+  data(user_id) {
+    return { user_id };
+  }
 });
diff --git a/app/assets/javascripts/discourse/components/edit-category-security.js.es6 b/app/assets/javascripts/discourse/components/edit-category-security.js.es6
index 593a604e7..60c3f4ddc 100644
--- a/app/assets/javascripts/discourse/components/edit-category-security.js.es6
+++ b/app/assets/javascripts/discourse/components/edit-category-security.js.es6
@@ -7,16 +7,24 @@ export default buildCategoryPanel('security', {
 
   actions: {
     editPermissions() {
-      this.set('editingPermissions', true);
+      if (!this.get('category.is_special')) {
+        this.set('editingPermissions', true);
+      }
     },
 
     addPermission(group, id) {
-      this.get('category').addPermission({group_name: group + "",
-                                       permission: Discourse.PermissionType.create({id})});
+      if (!this.get('category.is_special')) {
+        this.get('category').addPermission({
+          group_name: group + "",
+          permission: Discourse.PermissionType.create({id})
+        });
+      }
     },
 
     removePermission(permission) {
-      this.get('category').removePermission(permission);
+      if (!this.get('category.is_special')) {
+        this.get('category').removePermission(permission);
+      }
     },
   }
 });
diff --git a/app/assets/javascripts/discourse/components/menu-panel.js.es6 b/app/assets/javascripts/discourse/components/menu-panel.js.es6
index 8a5b2ea27..02304afee 100644
--- a/app/assets/javascripts/discourse/components/menu-panel.js.es6
+++ b/app/assets/javascripts/discourse/components/menu-panel.js.es6
@@ -54,7 +54,7 @@ export default Ember.Component.extend({
       }
 
       $panelBody.height('100%');
-      this.$().css({ left: "auto", top: (menuTop) + "px", height });
+      this.$().css({ top: menuTop + "px", height });
       $('body').removeClass('drop-down-visible');
     }
 
diff --git a/app/assets/javascripts/discourse/components/search-menu.js.es6 b/app/assets/javascripts/discourse/components/search-menu.js.es6
index 8a4f2ddec..413525280 100644
--- a/app/assets/javascripts/discourse/components/search-menu.js.es6
+++ b/app/assets/javascripts/discourse/components/search-menu.js.es6
@@ -1,4 +1,4 @@
-import {searchForTerm, searchContextDescription} from 'discourse/lib/search';
+import {searchForTerm, searchContextDescription, isValidSearchTerm } from 'discourse/lib/search';
 import DiscourseURL from 'discourse/lib/url';
 import { default as computed, observes } from 'ember-addons/ember-computed-decorators';
 import showModal from 'discourse/lib/show-modal';
@@ -61,8 +61,8 @@ export default Ember.Component.extend({
   @observes('searchService.term', 'typeFilter')
   newSearchNeeded() {
     this.set('noResults', false);
-    const term = (this.get('searchService.term') || '').trim();
-    if (term.length >= Discourse.SiteSettings.min_search_term_length) {
+    const term = this.get('searchService.term')
+    if (isValidSearchTerm(term)) {
       this.set('loading', true);
       Ember.run.debounce(this, 'searchTerm', term, this.get('typeFilter'), 400);
     } else {
@@ -134,7 +134,7 @@ export default Ember.Component.extend({
     },
 
     showedSearch() {
-      $('#search-term').focus();
+      $('#search-term').focus().select();
     },
 
     showSearchHelp() {
@@ -154,8 +154,7 @@ export default Ember.Component.extend({
   },
 
   keyDown(e) {
-    const term = this.get('searchService.term');
-    if (e.which === 13 && term && term.length >= this.siteSettings.min_search_term_length) {
+    if (e.which === 13 && isValidSearchTerm(this.get('searchService.term'))) {
       this.set('visible', false);
       this.send('fullSearch');
     }
diff --git a/app/assets/javascripts/discourse/components/search-text-field.js.es6 b/app/assets/javascripts/discourse/components/search-text-field.js.es6
index bf46ddf33..80dcc0ba2 100644
--- a/app/assets/javascripts/discourse/components/search-text-field.js.es6
+++ b/app/assets/javascripts/discourse/components/search-text-field.js.es6
@@ -5,5 +5,13 @@ export default TextField.extend({
   @computed('searchService.searchContextEnabled')
   placeholder: function(searchContextEnabled) {
     return searchContextEnabled ? "" : I18n.t('search.title');
-  }
+  },
+
+  focusIn: function() {
+    Em.run.later(() => { this.$().select(); });
+  },
+
+  becomeFocused: function() {
+    if (this.get('hasAutofocus')) this.$().focus();
+  }.on('didInsertElement')
 });
diff --git a/app/assets/javascripts/discourse/components/who-liked.js.es6 b/app/assets/javascripts/discourse/components/who-liked.js.es6
index 34ba67223..5c12a91d9 100644
--- a/app/assets/javascripts/discourse/components/who-liked.js.es6
+++ b/app/assets/javascripts/discourse/components/who-liked.js.es6
@@ -13,7 +13,7 @@ export default Ember.Component.extend(StringBuffer, {
         iconsHtml += "<a href=\"" + Discourse.getURL("/users/") + u.get('username_lower') + "\" data-user-card=\"" + u.get('username_lower') + "\">";
         iconsHtml += Discourse.Utilities.avatarImg({
           size: 'small',
-          avatarTemplate: u.get('avatarTemplate'),
+          avatarTemplate: u.get('avatar_template'),
           title: u.get('username')
         });
         iconsHtml += "</a>";
diff --git a/app/assets/javascripts/discourse/controllers/avatar-selector.js.es6 b/app/assets/javascripts/discourse/controllers/avatar-selector.js.es6
index f0bb9fcb3..29aae8bf6 100644
--- a/app/assets/javascripts/discourse/controllers/avatar-selector.js.es6
+++ b/app/assets/javascripts/discourse/controllers/avatar-selector.js.es6
@@ -1,21 +1,29 @@
-import ModalFunctionality from 'discourse/mixins/modal-functionality';
+import computed from "ember-addons/ember-computed-decorators";
+import ModalFunctionality from "discourse/mixins/modal-functionality";
 
 export default Ember.Controller.extend(ModalFunctionality, {
-  uploadedAvatarTemplate: null,
-  saveDisabled: Em.computed.alias("uploading"),
-  hasUploadedAvatar: Em.computed.or('uploadedAvatarTemplate', 'custom_avatar_upload_id'),
-
-  selectedUploadId: function() {
-    switch (this.get("selected")) {
-      case "system": return this.get("system_avatar_upload_id");
-      case "gravatar": return this.get("gravatar_avatar_upload_id");
-      default: return this.get("custom_avatar_upload_id");
+  @computed("selected", "system_avatar_upload_id", "gravatar_avatar_upload_id", "custom_avatar_upload_id")
+  selectedUploadId(selected, system, gravatar, custom) {
+    switch (selected) {
+      case "system": return system;
+      case "gravatar": return gravatar;
+      default: return custom;
     }
-  }.property('selected', 'system_avatar_upload_id', 'gravatar_avatar_upload_id', 'custom_avatar_upload_id'),
+  },
 
-  allowImageUpload: function() {
+  @computed("selected", "system_avatar_template", "gravatar_avatar_template", "custom_avatar_template")
+  selectedAvatarTemplate(selected, system, gravatar, custom) {
+    switch (selected) {
+      case "system": return system;
+      case "gravatar": return gravatar;
+      default: return custom;
+    }
+  },
+
+  @computed()
+  allowImageUpload() {
     return Discourse.Utilities.allowsImages();
-  }.property(),
+  },
 
   actions: {
     useUploadedAvatar() { this.set("selected", "uploaded"); },
@@ -25,8 +33,11 @@ export default Ember.Controller.extend(ModalFunctionality, {
     refreshGravatar() {
       this.set("gravatarRefreshDisabled", true);
       return Discourse
-        .ajax("/user_avatar/" + this.get("username") + "/refresh_gravatar.json", { method: 'POST' })
-        .then(result => this.set("gravatar_avatar_upload_id", result.upload_id))
+        .ajax(`/user_avatar/${this.get("username")}/refresh_gravatar.json`, { method: "POST" })
+        .then(result => this.setProperties({
+          gravatar_avatar_template: result.gravatar_avatar_template,
+          gravatar_upload_id: result.gravatar_upload_id,
+        }))
         .finally(() => this.set("gravatarRefreshDisabled", false));
     }
   }
diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6
index 843aae4ee..d6d1224f5 100644
--- a/app/assets/javascripts/discourse/controllers/composer.js.es6
+++ b/app/assets/javascripts/discourse/controllers/composer.js.es6
@@ -3,6 +3,7 @@ import DiscourseURL from 'discourse/lib/url';
 import Quote from 'discourse/lib/quote';
 import Draft from 'discourse/models/draft';
 import Composer from 'discourse/models/composer';
+import computed from 'ember-addons/ember-computed-decorators';
 
 function loadDraft(store, opts) {
   opts = opts || {};
@@ -64,6 +65,11 @@ export default Ember.Controller.extend({
     this.set('similarTopics', []);
   }.on('init'),
 
+  @computed('model.action')
+  canWhisper(action) {
+    return this.siteSettings.enable_whispers && action === Composer.REPLY;
+  },
+
   showWarning: function() {
     if (!Discourse.User.currentProp('staff')) { return false; }
 
@@ -132,7 +138,6 @@ export default Ember.Controller.extend({
     },
 
     hitEsc() {
-
       const messages = this.get('controllers.composer-messages.model');
       if (messages.length) {
         messages.popObject();
diff --git a/app/assets/javascripts/discourse/controllers/full-page-search.js.es6 b/app/assets/javascripts/discourse/controllers/full-page-search.js.es6
index fce0fecd8..f4fb1e787 100644
--- a/app/assets/javascripts/discourse/controllers/full-page-search.js.es6
+++ b/app/assets/javascripts/discourse/controllers/full-page-search.js.es6
@@ -1,4 +1,4 @@
-import { translateResults, searchContextDescription, getSearchKey } from "discourse/lib/search";
+import { translateResults, searchContextDescription, getSearchKey, isValidSearchTerm } from "discourse/lib/search";
 import showModal from 'discourse/lib/show-modal';
 import { default as computed, observes } from 'ember-addons/ember-computed-decorators';
 import Category from 'discourse/models/category';
@@ -13,6 +13,11 @@ export default Ember.Controller.extend({
   context_id: null,
   context: null,
 
+  @computed('q')
+  hasAutofocus(q) {
+    return Em.isEmpty(q);
+  },
+
   @computed('skip_context', 'context')
   searchContextEnabled: {
     get(skip,context){
@@ -37,7 +42,12 @@ export default Ember.Controller.extend({
 
   @computed('q')
   searchActive(q){
-    return q && q.length > 0;
+    return isValidSearchTerm(q);
+  },
+
+  @computed('searchTerm')
+  isNotValidSearchTerm(searchTerm) {
+    return !isValidSearchTerm(searchTerm);
   },
 
   @observes('model')
@@ -129,6 +139,7 @@ export default Ember.Controller.extend({
     },
 
     search() {
+      if (this.get("isNotValidSearchTerm")) return;
       this.search();
     }
   }
diff --git a/app/assets/javascripts/discourse/controllers/preferences.js.es6 b/app/assets/javascripts/discourse/controllers/preferences.js.es6
index ee3e86e9d..a7aa333fb 100644
--- a/app/assets/javascripts/discourse/controllers/preferences.js.es6
+++ b/app/assets/javascripts/discourse/controllers/preferences.js.es6
@@ -1,6 +1,7 @@
 import { setting } from 'discourse/lib/computed';
 import CanCheckEmails from 'discourse/mixins/can-check-emails';
 import { popupAjaxError } from 'discourse/lib/ajax-error';
+import computed from "ember-addons/ember-computed-decorators";
 
 export default Ember.Controller.extend(CanCheckEmails, {
 
@@ -10,18 +11,18 @@ export default Ember.Controller.extend(CanCheckEmails, {
   allowBackgrounds: setting('allow_profile_backgrounds'),
   editHistoryVisible: setting('edit_history_visible_to_public'),
 
-  selectedCategories: function(){
-    return [].concat(this.get("model.watchedCategories"),
-                     this.get("model.trackedCategories"),
-                     this.get("model.mutedCategories"));
-  }.property("model.watchedCategories", "model.trackedCategories", "model.mutedCategories"),
+  @computed("model.watchedCategories", "model.trackedCategories", "model.mutedCategories")
+  selectedCategories(watched, tracked, muted) {
+    return [].concat(watched, tracked, muted);
+  },
 
   // By default we haven't saved anything
   saved: false,
 
   newNameInput: null,
 
-  userFields: function() {
+  @computed("model.user_fields.@each.value")
+  userFields() {
     let siteUserFields = this.site.get('user_fields');
     if (!Ember.isEmpty(siteUserFields)) {
       const userFields = this.get('model.user_fields');
@@ -35,34 +36,37 @@ export default Ember.Controller.extend(CanCheckEmails, {
         return Ember.Object.create({ value, field });
       });
     }
-  }.property('model.user_fields.@each.value'),
+  },
 
   cannotDeleteAccount: Em.computed.not('can_delete_account'),
   deleteDisabled: Em.computed.or('saving', 'deleting', 'cannotDeleteAccount'),
 
   canEditName: setting('enable_names'),
 
-  nameInstructions: function() {
+  @computed()
+  nameInstructions() {
     return I18n.t(Discourse.SiteSettings.full_name_required ? 'user.name.instructions_required' : 'user.name.instructions');
-  }.property(),
+  },
 
-  canSelectTitle: function() {
-    return this.siteSettings.enable_badges && this.get('model.has_title_badges');
-  }.property('model.badge_count'),
+  @computed("model.has_title_badges")
+  canSelectTitle(hasTitleBadges) {
+    return this.siteSettings.enable_badges && hasTitleBadges;
+  },
 
-  canChangePassword: function() {
+  @computed()
+  canChangePassword() {
     return !this.siteSettings.enable_sso && this.siteSettings.enable_local_logins;
-  }.property(),
+  },
 
-  canReceiveDigest: function() {
+  @computed()
+  canReceiveDigest() {
     return !this.siteSettings.disable_digest_emails;
-  }.property(),
+  },
 
-  availableLocales: function() {
-    return this.siteSettings.available_locales.split('|').map( function(s) {
-      return {name: s, value: s};
-    });
-  }.property(),
+  @computed()
+  availableLocales() {
+    return this.siteSettings.available_locales.split('|').map(s => ({ name: s, value: s }));
+  },
 
   digestFrequencies: [{ name: I18n.t('user.email_digests.daily'), value: 1 },
                       { name: I18n.t('user.email_digests.every_three_days'), value: 3 },
@@ -86,16 +90,16 @@ export default Ember.Controller.extend(CanCheckEmails, {
                             { name: I18n.t('user.new_topic_duration.after_2_weeks'), value: 2 * 7 * 60 * 24 },
                             { name: I18n.t('user.new_topic_duration.last_here'), value: -2 }],
 
-  saveButtonText: function() {
-    return this.get('model.isSaving') ? I18n.t('saving') : I18n.t('save');
-  }.property('model.isSaving'),
+  @computed("model.isSaving")
+  saveButtonText(isSaving) {
+    return isSaving ? I18n.t('saving') : I18n.t('save');
+  },
 
   passwordProgress: null,
 
   actions: {
 
     save() {
-      const self = this;
       this.set('saved', false);
 
       const model = this.get('model');
@@ -113,28 +117,27 @@ export default Ember.Controller.extend(CanCheckEmails, {
 
       // Cook the bio for preview
       model.set('name', this.get('newNameInput'));
-      return model.save().then(function() {
+      return model.save().then(() => {
         if (Discourse.User.currentProp('id') === model.get('id')) {
           Discourse.User.currentProp('name', model.get('name'));
         }
         model.set('bio_cooked', Discourse.Markdown.cook(Discourse.Markdown.sanitize(model.get('bio_raw'))));
-        self.set('saved', true);
+        this.set('saved', true);
       }).catch(popupAjaxError);
     },
 
     changePassword() {
-      const self = this;
       if (!this.get('passwordProgress')) {
         this.set('passwordProgress', I18n.t("user.change_password.in_progress"));
-        return this.get('model').changePassword().then(function() {
+        return this.get('model').changePassword().then(() => {
           // password changed
-          self.setProperties({
+          this.setProperties({
             changePasswordProgress: false,
             passwordProgress: I18n.t("user.change_password.success")
           });
-        }, function() {
+        }).catch(() => {
           // password failed to change
-          self.setProperties({
+          this.setProperties({
             changePasswordProgress: false,
             passwordProgress: I18n.t("user.change_password.error")
           });
diff --git a/app/assets/javascripts/discourse/helpers/application.js.es6 b/app/assets/javascripts/discourse/helpers/application.js.es6
index cac2ea1f0..2f4ea292c 100644
--- a/app/assets/javascripts/discourse/helpers/application.js.es6
+++ b/app/assets/javascripts/discourse/helpers/application.js.es6
@@ -1,37 +1,29 @@
 import registerUnbound from 'discourse/helpers/register-unbound';
-import avatarTemplate from 'discourse/lib/avatar-template';
 import { longDate, autoUpdatingRelativeAge, number } from 'discourse/lib/formatter';
 
 const safe = Handlebars.SafeString;
 
-Em.Handlebars.helper('bound-avatar', function(user, size, uploadId) {
+Em.Handlebars.helper('bound-avatar', (user, size) => {
   if (Em.isEmpty(user)) {
     return new safe("<div class='avatar-placeholder'></div>");
   }
 
-  const username = Em.get(user, 'username');
-  if (arguments.length < 4) { uploadId = Em.get(user, 'uploaded_avatar_id'); }
-  const avatar = Em.get(user, 'avatar_template') || avatarTemplate(username, uploadId);
-
+  const avatar = Em.get(user, 'avatar_template');
   return new safe(Discourse.Utilities.avatarImg({ size: size, avatarTemplate: avatar }));
-}, 'username', 'uploaded_avatar_id', 'avatar_template');
+}, 'username', 'avatar_template');
 
 /*
  * Used when we only have a template
  */
-Em.Handlebars.helper('bound-avatar-template', function(at, size) {
+Em.Handlebars.helper('bound-avatar-template', (at, size) => {
   return new safe(Discourse.Utilities.avatarImg({ size: size, avatarTemplate: at }));
 });
 
-registerUnbound('raw-date', function(dt) {
-  return longDate(new Date(dt));
-});
+registerUnbound('raw-date', dt => longDate(new Date(dt)));
 
-registerUnbound('age-with-tooltip', function(dt) {
-  return new safe(autoUpdatingRelativeAge(new Date(dt), {title: true}));
-});
+registerUnbound('age-with-tooltip', dt => new safe(autoUpdatingRelativeAge(new Date(dt), {title: true})));
 
-registerUnbound('number', function(orig, params) {
+registerUnbound('number', (orig, params) => {
   orig = parseInt(orig, 10);
   if (isNaN(orig)) { orig = 0; }
 
diff --git a/app/assets/javascripts/discourse/helpers/user-avatar.js.es6 b/app/assets/javascripts/discourse/helpers/user-avatar.js.es6
index 1ab668ffc..c5eac31ad 100644
--- a/app/assets/javascripts/discourse/helpers/user-avatar.js.es6
+++ b/app/assets/javascripts/discourse/helpers/user-avatar.js.es6
@@ -1,24 +1,23 @@
 import registerUnbound from 'discourse/helpers/register-unbound';
-import avatarTemplate from 'discourse/lib/avatar-template';
 
 function renderAvatar(user, options) {
   options = options || {};
 
   if (user) {
-    var username = Em.get(user, 'username');
-    if (!username) {
-      if (!options.usernamePath) { return ''; }
-      username = Em.get(user, options.usernamePath);
-    }
 
-    var title;
+    const username = Em.get(user, options.usernamePath || 'username');
+    const avatarTemplate = Em.get(user, options.avatarTemplatePath || 'avatar_template');
+
+    if (!username || !avatarTemplate) { return ''; }
+
+    let title;
     if (!options.ignoreTitle) {
       // first try to get a title
       title = Em.get(user, 'title');
       // if there was no title provided
       if (!title) {
         // try to retrieve a description
-        var description = Em.get(user, 'description');
+        const description = Em.get(user, 'description');
         // if a description has been provided
         if (description && description.length > 0) {
           // preprend the username before the description
@@ -27,14 +26,11 @@ function renderAvatar(user, options) {
       }
     }
 
-    // this is simply done to ensure we cache images correctly
-    var uploadedAvatarId = Em.get(user, 'uploaded_avatar_id') || Em.get(user, 'user.uploaded_avatar_id');
-
     return Discourse.Utilities.avatarImg({
       size: options.imageSize,
       extraClasses: Em.get(user, 'extras') || options.extraClasses,
       title: title || username,
-      avatarTemplate: avatarTemplate(username, uploadedAvatarId)
+      avatarTemplate: avatarTemplate
     });
   } else {
     return '';
diff --git a/app/assets/javascripts/discourse/initializers/click-interceptor.js.es6 b/app/assets/javascripts/discourse/initializers/click-interceptor.js.es6
index d74e5264e..763a06b2e 100644
--- a/app/assets/javascripts/discourse/initializers/click-interceptor.js.es6
+++ b/app/assets/javascripts/discourse/initializers/click-interceptor.js.es6
@@ -1,8 +1,10 @@
 import interceptClick from 'discourse/lib/intercept-click';
+import DiscourseURL from 'discourse/lib/url';
 
 export default {
   name: "click-interceptor",
   initialize() {
     $('#main').on('click.discourse', 'a', interceptClick);
+    $(window).on('hashchange', () => DiscourseURL.routeTo(document.location.hash));
   }
 };
diff --git a/app/assets/javascripts/discourse/lib/avatar-template.js.es6 b/app/assets/javascripts/discourse/lib/avatar-template.js.es6
deleted file mode 100644
index 542e97959..000000000
--- a/app/assets/javascripts/discourse/lib/avatar-template.js.es6
+++ /dev/null
@@ -1,32 +0,0 @@
-import { hashString } from 'discourse/lib/hash';
-
-let _splitAvatars;
-
-function defaultAvatar(username) {
-  const defaultAvatars = Discourse.SiteSettings.default_avatars;
-  if (defaultAvatars && defaultAvatars.length) {
-    _splitAvatars = _splitAvatars || defaultAvatars.split("\n");
-
-    if (_splitAvatars.length) {
-      const hash = hashString(username);
-      return _splitAvatars[Math.abs(hash) % _splitAvatars.length];
-    }
-  }
-
-  return Discourse.getURLWithCDN("/letter_avatar/" +
-                                 username.toLowerCase() +
-                                 "/{size}/" +
-                                 Discourse.LetterAvatarVersion + ".png");
-}
-
-export default function(username, uploadedAvatarId) {
-  if (uploadedAvatarId) {
-    return Discourse.getURLWithCDN("/user_avatar/" +
-                                   Discourse.BaseUrl +
-                                   "/" +
-                                   username.toLowerCase() +
-                                   "/{size}/" +
-                                   uploadedAvatarId + ".png");
-  }
-  return defaultAvatar(username);
-}
diff --git a/app/assets/javascripts/discourse/lib/key-value-store.js.es6 b/app/assets/javascripts/discourse/lib/key-value-store.js.es6
index 30f86b16e..243146833 100644
--- a/app/assets/javascripts/discourse/lib/key-value-store.js.es6
+++ b/app/assets/javascripts/discourse/lib/key-value-store.js.es6
@@ -32,6 +32,7 @@ KeyValueStore.prototype = {
   },
 
   remove(key) {
+    if (!safeLocalStorage) { return; }
     return safeLocalStorage.removeItem(this.context + key);
   },
 
diff --git a/app/assets/javascripts/discourse/lib/search.js.es6 b/app/assets/javascripts/discourse/lib/search.js.es6
index 21fd8afeb..9d7bec2d6 100644
--- a/app/assets/javascripts/discourse/lib/search.js.es6
+++ b/app/assets/javascripts/discourse/lib/search.js.es6
@@ -106,4 +106,12 @@ const getSearchKey = function(args){
                       ((args.searchContext && args.searchContext.id) || "")
 };
 
-export { searchForTerm, searchContextDescription, getSearchKey };
+const isValidSearchTerm = function(searchTerm) {
+  if (searchTerm) {
+    return searchTerm.trim().length >= Discourse.SiteSettings.min_search_term_length;
+  } else {
+    return false;
+  }
+};
+
+export { searchForTerm, searchContextDescription, getSearchKey, isValidSearchTerm };
diff --git a/app/assets/javascripts/discourse/lib/url.js.es6 b/app/assets/javascripts/discourse/lib/url.js.es6
index f94c4c2bd..7f2d1bc6c 100644
--- a/app/assets/javascripts/discourse/lib/url.js.es6
+++ b/app/assets/javascripts/discourse/lib/url.js.es6
@@ -105,7 +105,7 @@ const DiscourseURL = Ember.Object.createWithMixins({
     It contains the logic necessary to route within a topic using replaceState to
     keep the history intact.
   **/
-  routeTo: function(path, opts) {
+  routeTo(path, opts) {
     if (Em.isEmpty(path)) { return; }
 
     if (Discourse.get('requiresRefresh')) {
@@ -122,6 +122,7 @@ const DiscourseURL = Ember.Object.createWithMixins({
     // Scroll to the same page, different anchor
     if (path.indexOf('#') === 0) {
       this.scrollToId(path);
+      history.replaceState(undefined, undefined, path);
       return;
     }
 
diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6
index 12cba4936..576bec397 100644
--- a/app/assets/javascripts/discourse/models/composer.js.es6
+++ b/app/assets/javascripts/discourse/models/composer.js.es6
@@ -24,6 +24,7 @@ const CLOSED = 'closed',
         category: 'categoryId',
         topic_id: 'topic.id',
         is_warning: 'isWarning',
+        whisper: 'whisper',
         archetype: 'archetypeId',
         target_usernames: 'targetUsernames',
         typing_duration_msecs: 'typingTime',
@@ -557,6 +558,9 @@ const Composer = RestModel.extend({
 
     let addedToStream = false;
 
+    const postTypes = this.site.get('post_types');
+    const postType = this.get('whisper') ? postTypes.whisper : postTypes.regular;
+
     // Build the post object
     const createdPost = this.store.createRecord('post', {
       imageSizes: opts.imageSizes,
@@ -567,9 +571,9 @@ const Composer = RestModel.extend({
       username: user.get('username'),
       user_id: user.get('id'),
       user_title: user.get('title'),
-      uploaded_avatar_id: user.get('uploaded_avatar_id'),
+      avatar_template: user.get('avatar_template'),
       user_custom_fields: user.get('custom_fields'),
-      post_type: this.site.get('post_types.regular'),
+      post_type: postType,
       actions_summary: [],
       moderator: user.get('moderator'),
       admin: user.get('admin'),
@@ -587,7 +591,7 @@ const Composer = RestModel.extend({
         reply_to_post_number: post.get('post_number'),
         reply_to_user: {
           username: post.get('username'),
-          uploaded_avatar_id: post.get('uploaded_avatar_id')
+          avatar_template: post.get('avatar_template')
         }
       });
     }
diff --git a/app/assets/javascripts/discourse/models/post.js.es6 b/app/assets/javascripts/discourse/models/post.js.es6
index c332836f6..6ba2606aa 100644
--- a/app/assets/javascripts/discourse/models/post.js.es6
+++ b/app/assets/javascripts/discourse/models/post.js.es6
@@ -1,7 +1,7 @@
 import RestModel from 'discourse/models/rest';
 import { popupAjaxError } from 'discourse/lib/ajax-error';
 import ActionSummary from 'discourse/models/action-summary';
-import { url, fmt, propertyEqual } from 'discourse/lib/computed';
+import { url, propertyEqual } from 'discourse/lib/computed';
 import Quote from 'discourse/lib/quote';
 import computed from 'ember-addons/ember-computed-decorators';
 
@@ -77,7 +77,6 @@ const Post = RestModel.extend({
 
   topicOwner: propertyEqual('topic.details.created_by.id', 'user_id'),
   hasHistory: Em.computed.gt('version', 1),
-  postElementId: fmt('post_number', 'post_%@'),
 
   canViewRawEmail: function() {
     return this.get("user_id") === Discourse.User.currentProp("id") || Discourse.User.currentProp('staff');
diff --git a/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 b/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6
index ded0d824c..11c8d67fe 100644
--- a/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6
+++ b/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6
@@ -174,8 +174,8 @@ const TopicTrackingState = Discourse.Model.extend({
         if (filter === "new") {
           list.topics.splice(i, 1);
         } else {
-          list.topics[i].unseen = false;
-          list.topics[i].dont_sync = true;
+          list.topics[i].set('unseen', false);
+          list.topics[i].set('dont_sync', true);
         }
       }
     }
diff --git a/app/assets/javascripts/discourse/models/user-action.js.es6 b/app/assets/javascripts/discourse/models/user-action.js.es6
index 2d273c43e..f03d81908 100644
--- a/app/assets/javascripts/discourse/models/user-action.js.es6
+++ b/app/assets/javascripts/discourse/models/user-action.js.es6
@@ -1,5 +1,7 @@
 import RestModel from 'discourse/models/rest';
 import { url } from 'discourse/lib/computed';
+import { on } from 'ember-addons/ember-computed-decorators';
+import computed from 'ember-addons/ember-computed-decorators';
 
 const UserActionTypes = {
   likes_given: 1,
@@ -17,21 +19,22 @@ const UserActionTypes = {
 };
 const InvertedActionTypes = {};
 
-_.each(UserActionTypes, function (k, v) {
+_.each(UserActionTypes, (k, v) => {
   InvertedActionTypes[k] = v;
 });
 
 const UserAction = RestModel.extend({
 
-  _attachCategory: function() {
+  @on("init")
+  _attachCategory() {
     const categoryId = this.get('category_id');
     if (categoryId) {
       this.set('category', Discourse.Category.findById(categoryId));
     }
-  }.on('init'),
+  },
 
-  descriptionKey: function() {
-    const action = this.get('action_type');
+  @computed("action_type")
+  descriptionKey(action) {
     if (action === null || Discourse.UserAction.TO_SHOW.indexOf(action) >= 0) {
       if (this.get('isPM')) {
         return this.get('sameUser') ? 'sent_by_you' : 'sent_by_user';
@@ -59,34 +62,39 @@ const UserAction = RestModel.extend({
         return this.get('targetUser') ? 'user_mentioned_you' : 'user_mentioned_user';
       }
     }
-  }.property('action_type'),
+  },
 
-  sameUser: function() {
-    return this.get('username') === Discourse.User.currentProp('username');
-  }.property('username'),
+  @computed("username")
+  sameUser(username) {
+    return username === Discourse.User.currentProp('username');
+  },
 
-  targetUser: function() {
-    return this.get('target_username') === Discourse.User.currentProp('username');
-  }.property('target_username'),
+  @computed("target_username")
+  targetUser(targetUsername) {
+    return targetUsername === Discourse.User.currentProp('username');
+  },
 
   presentName: Em.computed.any('name', 'username'),
   targetDisplayName: Em.computed.any('target_name', 'target_username'),
   actingDisplayName: Em.computed.any('acting_name', 'acting_username'),
   targetUserUrl: url('target_username', '/users/%@'),
 
-  usernameLower: function() {
-    return this.get('username').toLowerCase();
-  }.property('username'),
+  @computed("username")
+  usernameLower(username) {
+    return username.toLowerCase();
+  },
 
   userUrl: url('usernameLower', '/users/%@'),
 
-  postUrl: function() {
+  @computed()
+  postUrl() {
     return Discourse.Utilities.postUrl(this.get('slug'), this.get('topic_id'), this.get('post_number'));
-  }.property(),
+  },
 
-  replyUrl: function() {
+  @computed()
+  replyUrl() {
     return Discourse.Utilities.postUrl(this.get('slug'), this.get('topic_id'), this.get('reply_to_post_number'));
-  }.property(),
+  },
 
   replyType: Em.computed.equal('action_type', UserActionTypes.replies),
   postType: Em.computed.equal('action_type', UserActionTypes.posts),
@@ -99,7 +107,7 @@ const UserAction = RestModel.extend({
   postReplyType: Em.computed.or('postType', 'replyType'),
   removableBookmark: Em.computed.and('bookmarkType', 'sameUser'),
 
-  addChild: function(action) {
+  addChild(action) {
     let groups = this.get("childGroups");
     if (!groups) {
       groups = {
@@ -143,22 +151,21 @@ const UserAction = RestModel.extend({
     "childGroups.edits.items", "childGroups.edits.items.@each",
     "childGroups.bookmarks.items", "childGroups.bookmarks.items.@each"),
 
-  switchToActing: function() {
+  switchToActing() {
     this.setProperties({
       username: this.get('acting_username'),
-      uploaded_avatar_id: this.get('acting_uploaded_avatar_id'),
       name: this.get('actingDisplayName')
     });
   }
 });
 
 UserAction.reopenClass({
-  collapseStream: function(stream) {
+  collapseStream(stream) {
     const uniq = {};
     const collapsed = [];
     let pos = 0;
 
-    stream.forEach(function(item) {
+    stream.forEach(item => {
       const key = "" + item.topic_id + "-" + item.post_number;
       const found = uniq[key];
       if (found === void 0) {
diff --git a/app/assets/javascripts/discourse/models/user.js.es6 b/app/assets/javascripts/discourse/models/user.js.es6
index 01aef870a..bd0e36c5f 100644
--- a/app/assets/javascripts/discourse/models/user.js.es6
+++ b/app/assets/javascripts/discourse/models/user.js.es6
@@ -1,11 +1,11 @@
 import { url } from 'discourse/lib/computed';
 import RestModel from 'discourse/models/rest';
-import avatarTemplate from 'discourse/lib/avatar-template';
 import UserStream from 'discourse/models/user-stream';
 import UserPostsStream from 'discourse/models/user-posts-stream';
 import Singleton from 'discourse/mixins/singleton';
 import { longDate } from 'discourse/lib/formatter';
 import computed from 'ember-addons/ember-computed-decorators';
+import { observes } from 'ember-addons/ember-computed-decorators';
 import Badge from 'discourse/models/badge';
 import UserBadge from 'discourse/models/user-badge';
 
@@ -18,13 +18,15 @@ const User = RestModel.extend({
   hasNotPosted: Em.computed.not("hasPosted"),
   canBeDeleted: Em.computed.and("can_be_deleted", "hasNotPosted"),
 
-  stream: function() {
+  @computed()
+  stream() {
     return UserStream.create({ user: this });
-  }.property(),
+  },
 
-  postsStream: function() {
+  @computed()
+  postsStream() {
     return UserPostsStream.create({ user: this });
-  }.property(),
+  },
 
   staff: Em.computed.or('admin', 'moderator'),
 
@@ -32,27 +34,22 @@ const User = RestModel.extend({
     return Discourse.ajax(`/session/${this.get('username')}`, { type: 'DELETE'});
   },
 
-  searchContext: function() {
+  @computed("username_lower")
+  searchContext(username) {
     return {
       type: 'user',
-      id: this.get('username_lower'),
+      id: username,
       user: this
     };
-  }.property('username_lower'),
+  },
 
-  /**
-    This user's display name. Returns the name if possible, otherwise returns the
-    username.
-
-    @property displayName
-    @type {String}
-  **/
-  displayName: function() {
-    if (Discourse.SiteSettings.enable_names && !Ember.isEmpty(this.get('name'))) {
-      return this.get('name');
+  @computed("username", "name")
+  displayName(username, name) {
+    if (Discourse.SiteSettings.enable_names && !Ember.isEmpty(name)) {
+      return name;
     }
-    return this.get('username');
-  }.property('username', 'name'),
+    return username;
+  },
 
   @computed('profile_background')
   profileBackground(bgUrl) {
@@ -60,38 +57,23 @@ const User = RestModel.extend({
     return ('background-image: url(' + Discourse.getURLWithCDN(bgUrl) + ')').htmlSafe();
   },
 
-  path: function(){
-    return Discourse.getURL('/users/' + this.get('username_lower'));
+  @computed()
+  path() {
     // no need to observe, requires a hard refresh to update
-  }.property(),
+    return Discourse.getURL(`/users/${this.get('username_lower')}`);
+  },
 
-  /**
-    Path to this user's administration
-
-    @property adminPath
-    @type {String}
-  **/
   adminPath: url('username_lower', "/admin/users/%@"),
 
-  /**
-    This user's username in lowercase.
+  @computed("username")
+  username_lower(username) {
+    return username.toLowerCase();
+  },
 
-    @property username_lower
-    @type {String}
-  **/
-  username_lower: function() {
-    return this.get('username').toLowerCase();
-  }.property('username'),
-
-  /**
-    This user's trust level.
-
-    @property trustLevel
-    @type {Integer}
-  **/
-  trustLevel: function() {
-    return Discourse.Site.currentProp('trustLevels').findProperty('id', parseInt(this.get('trust_level'), 10));
-  }.property('trust_level'),
+  @computed("trust_level")
+  trustLevel(trustLevel) {
+    return Discourse.Site.currentProp('trustLevels').findProperty('id', parseInt(trustLevel, 10));
+  },
 
   isBasic: Em.computed.equal('trust_level', 0),
   isLeader: Em.computed.equal('trust_level', 3),
@@ -100,61 +82,36 @@ const User = RestModel.extend({
 
   isSuspended: Em.computed.equal('suspended', true),
 
-  suspended: function() {
-    return this.get('suspended_till') && moment(this.get('suspended_till')).isAfter();
-  }.property('suspended_till'),
+  @computed("suspended_till")
+  suspended(suspendedTill) {
+    return suspendedTill && moment(suspendedTill).isAfter();
+  },
 
-  suspendedTillDate: function() {
-    return longDate(this.get('suspended_till'));
-  }.property('suspended_till'),
+  @computed("suspended_till")
+  suspendedTillDate(suspendedTill) {
+    return longDate(suspendedTill);
+  },
 
-  /**
-    Changes this user's username.
-
-    @method changeUsername
-    @param {String} newUsername The user's new username
-    @returns Result of ajax call
-  **/
-  changeUsername: function(newUsername) {
-    return Discourse.ajax("/users/" + this.get('username_lower') + "/preferences/username", {
+  changeUsername(new_username) {
+    return Discourse.ajax(`/users/${this.get('username_lower')}/preferences/username`, {
       type: 'PUT',
-      data: { new_username: newUsername }
+      data: { new_username }
     });
   },
 
-  /**
-    Changes this user's email address.
-
-    @method changeEmail
-    @param {String} email The user's new email address\
-    @returns Result of ajax call
-  **/
-  changeEmail: function(email) {
-    return Discourse.ajax("/users/" + this.get('username_lower') + "/preferences/email", {
+  changeEmail(email) {
+    return Discourse.ajax(`/users/${this.get('username_lower')}/preferences/email`, {
       type: 'PUT',
-      data: { email: email }
+      data: { email }
     });
   },
 
-  /**
-    Returns a copy of this user.
-
-    @method copy
-    @returns {User}
-  **/
-  copy: function() {
+  copy() {
     return Discourse.User.create(this.getProperties(Ember.keys(this)));
   },
 
-  /**
-    Save's this user's properties over AJAX via a PUT request.
-
-    @method save
-    @returns {Promise} the result of the operation
-  **/
-  save: function() {
-    const self = this,
-          data = this.getProperties(
+  save() {
+    const data = this.getProperties(
             'auto_track_topics_after_msecs',
             'bio_raw',
             'website',
@@ -179,10 +136,10 @@ const User = RestModel.extend({
             'card_background'
           );
 
-    ['muted','watched','tracked'].forEach(function(s){
-      var cats = self.get(s + 'Categories').map(function(c){ return c.get('id')});
+    ['muted','watched','tracked'].forEach(s => {
+      let cats = this.get(s + 'Categories').map(c => c.get('id'));
       // HACK: denote lack of categories
-      if(cats.length === 0) { cats = [-1]; }
+      if (cats.length === 0) { cats = [-1]; }
       data[s + '_category_ids'] = cats;
     });
 
@@ -192,26 +149,19 @@ const User = RestModel.extend({
 
     // TODO: We can remove this when migrated fully to rest model.
     this.set('isSaving', true);
-    return Discourse.ajax("/users/" + this.get('username_lower'), {
+    return Discourse.ajax(`/users/${this.get('username_lower')}`, {
       data: data,
       type: 'PUT'
-    }).then(function(result) {
-      self.set('bio_excerpt', result.user.bio_excerpt);
-
-      const userProps = self.getProperties('enable_quoting', 'external_links_in_new_tab', 'dynamic_favicon');
+    }).then(result => {
+      this.set('bio_excerpt', result.user.bio_excerpt);
+      const userProps = this.getProperties('enable_quoting', 'external_links_in_new_tab', 'dynamic_favicon');
       Discourse.User.current().setProperties(userProps);
     }).finally(() => {
       this.set('isSaving', false);
     });
   },
 
-  /**
-    Changes the password and calls the callback function on AJAX.complete.
-
-    @method changePassword
-    @returns {Promise} the result of the change password operation
-  **/
-  changePassword: function() {
+  changePassword() {
     return Discourse.ajax("/session/forgot_password", {
       dataType: 'json',
       data: { login: this.get('username') },
@@ -219,73 +169,63 @@ const User = RestModel.extend({
     });
   },
 
-  /**
-    Loads a single user action by id.
-
-    @method loadUserAction
-    @param {Integer} id The id of the user action being loaded
-    @returns A stream of the user's actions containing the action of id
-  **/
-  loadUserAction: function(id) {
-    var self = this,
-        stream = this.get('stream');
-    return Discourse.ajax("/user_actions/" + id + ".json", { cache: 'false' }).then(function(result) {
+  loadUserAction(id) {
+    const stream = this.get('stream');
+    return Discourse.ajax(`/user_actions/${id}.json`, { cache: 'false' }).then(result => {
       if (result && result.user_action) {
-        var ua = result.user_action;
+        const ua = result.user_action;
 
-        if ((self.get('stream.filter') || ua.action_type) !== ua.action_type) return;
-        if (!self.get('stream.filter') && !self.inAllStream(ua)) return;
+        if ((this.get('stream.filter') || ua.action_type) !== ua.action_type) return;
+        if (!this.get('stream.filter') && !this.inAllStream(ua)) return;
 
-        var action = Discourse.UserAction.collapseStream([Discourse.UserAction.create(ua)]);
+        const action = Discourse.UserAction.collapseStream([Discourse.UserAction.create(ua)]);
         stream.set('itemsLoaded', stream.get('itemsLoaded') + 1);
         stream.get('content').insertAt(0, action[0]);
       }
     });
   },
 
-  inAllStream: function(ua) {
+  inAllStream(ua) {
     return ua.action_type === Discourse.UserAction.TYPES.posts ||
            ua.action_type === Discourse.UserAction.TYPES.topics;
   },
 
   // The user's stat count, excluding PMs.
-  statsCountNonPM: function() {
-    var self = this;
-
+  @computed("statsExcludingPms.@each.count")
+  statsCountNonPM() {
     if (Ember.isEmpty(this.get('statsExcludingPms'))) return 0;
-    var count = 0;
-    _.each(this.get('statsExcludingPms'), function(val) {
-      if (self.inAllStream(val)){
+    let count = 0;
+    _.each(this.get('statsExcludingPms'), val => {
+      if (this.inAllStream(val)) {
         count += val.count;
       }
     });
     return count;
-  }.property('statsExcludingPms.@each.count'),
+  },
 
   // The user's stats, excluding PMs.
-  statsExcludingPms: function() {
+  @computed("stats.@each.isPM")
+  statsExcludingPms() {
     if (Ember.isEmpty(this.get('stats'))) return [];
     return this.get('stats').rejectProperty('isPM');
-  }.property('stats.@each.isPM'),
+  },
 
-  findDetails: function(options) {
-    var user = this;
+  findDetails(options) {
+    const user = this;
 
-    return PreloadStore.getAndRemove("user_" + user.get('username'), function() {
-      return Discourse.ajax("/users/" + user.get('username') + '.json', {data: options});
-    }).then(function (json) {
+    return PreloadStore.getAndRemove(`user_${user.get('username')}`, () => {
+      return Discourse.ajax(`/users/${user.get('username')}.json`, { data: options });
+    }).then(json => {
 
       if (!Em.isEmpty(json.user.stats)) {
-        json.user.stats = Discourse.User.groupStats(_.map(json.user.stats,function(s) {
+        json.user.stats = Discourse.User.groupStats(_.map(json.user.stats, s => {
           if (s.count) s.count = parseInt(s.count, 10);
           return Discourse.UserActionStat.create(s);
         }));
       }
 
       if (!Em.isEmpty(json.user.custom_groups)) {
-        json.user.custom_groups = json.user.custom_groups.map(function (g) {
-          return Discourse.Group.create(g);
-        });
+        json.user.custom_groups = json.user.custom_groups.map(g => Discourse.Group.create(g));
       }
 
       if (json.user.invited_by) {
@@ -294,12 +234,10 @@ const User = RestModel.extend({
 
       if (!Em.isEmpty(json.user.featured_user_badge_ids)) {
         const userBadgesMap = {};
-        UserBadge.createFromJson(json).forEach(function(userBadge) {
+        UserBadge.createFromJson(json).forEach(userBadge => {
           userBadgesMap[ userBadge.get('id') ] = userBadge;
         });
-        json.user.featured_user_badges = json.user.featured_user_badge_ids.map(function(id) {
-          return userBadgesMap[id];
-        });
+        json.user.featured_user_badges = json.user.featured_user_badge_ids.map(id => userBadgesMap[id]);
       }
 
       if (json.user.card_badge) {
@@ -311,81 +249,62 @@ const User = RestModel.extend({
     });
   },
 
-  findStaffInfo: function() {
+  findStaffInfo() {
     if (!Discourse.User.currentProp("staff")) { return Ember.RSVP.resolve(null); }
-    var self = this;
-    return Discourse.ajax("/users/" + this.get("username_lower") + "/staff-info.json").then(function(info) {
-      self.setProperties(info);
+    return Discourse.ajax(`/users/${this.get("username_lower")}/staff-info.json`).then(info => {
+      this.setProperties(info);
     });
   },
 
-  avatarTemplate: function() {
-    return avatarTemplate(this.get('username'), this.get('uploaded_avatar_id'));
-  }.property('uploaded_avatar_id', 'username'),
-
-  /*
-    Change avatar selection
-  */
-  pickAvatar: function(uploadId) {
-    var self = this;
-
-    return Discourse.ajax("/users/" + this.get("username_lower") + "/preferences/avatar/pick", {
+  pickAvatar(upload_id, type, avatar_template) {
+    return Discourse.ajax(`/users/${this.get("username_lower")}/preferences/avatar/pick`, {
       type: 'PUT',
-      data: { upload_id: uploadId }
-    }).then(function(){
-      self.set('uploaded_avatar_id', uploadId);
-    });
+      data: { upload_id, type }
+    }).then(() => this.setProperties({
+      avatar_template,
+      uploaded_avatar_id: upload_id
+    }));
   },
 
-  /**
-    Determines whether the current user is allowed to upload a file.
-
-    @method isAllowedToUploadAFile
-    @param {String} type The type of the upload (image, attachment)
-    @returns true if the current user is allowed to upload a file
-  **/
-  isAllowedToUploadAFile: function(type) {
+  isAllowedToUploadAFile(type) {
     return this.get('staff') ||
            this.get('trust_level') > 0 ||
            Discourse.SiteSettings['newuser_max_' + type + 's'] > 0;
   },
 
-  /**
-    Invite a user to the site
-
-    @method createInvite
-    @param {String} email The email address of the user to invite to the site
-    @returns {Promise} the result of the server call
-  **/
-  createInvite: function(email, groupNames) {
+  createInvite(email, group_names) {
     return Discourse.ajax('/invites', {
       type: 'POST',
-      data: {email: email, group_names: groupNames}
+      data: { email, group_names }
     });
   },
 
-  generateInviteLink: function(email, groupNames, topicId) {
+  generateInviteLink(email, group_names, topic_id) {
     return Discourse.ajax('/invites/link', {
       type: 'POST',
-      data: {email: email, group_names: groupNames, topic_id: topicId}
+      data: { email, group_names, topic_id }
     });
   },
 
-  updateMutedCategories: function() {
+  @observes("muted_category_ids")
+  updateMutedCategories() {
     this.set("mutedCategories", Discourse.Category.findByIds(this.muted_category_ids));
-  }.observes("muted_category_ids"),
+  },
 
-  updateTrackedCategories: function() {
+  @observes("tracked_category_ids")
+  updateTrackedCategories() {
     this.set("trackedCategories", Discourse.Category.findByIds(this.tracked_category_ids));
-  }.observes("tracked_category_ids"),
+  },
 
-  updateWatchedCategories: function() {
+  @observes("watched_category_ids")
+  updateWatchedCategories() {
     this.set("watchedCategories", Discourse.Category.findByIds(this.watched_category_ids));
-  }.observes("watched_category_ids"),
+  },
 
-  canDeleteAccount: function() {
-    return !Discourse.SiteSettings.enable_sso && this.get('can_delete_account') && ((this.get('reply_count')||0) + (this.get('topic_count')||0)) <= 1;
-  }.property('can_delete_account', 'reply_count', 'topic_count'),
+  @computed("can_delete_account", "reply_count", "topic_count")
+  canDeleteAccount(canDeleteAccount, replyCount, topicCount) {
+    return !Discourse.SiteSettings.enable_sso && canDeleteAccount && ((replyCount || 0) + (topicCount || 0)) <= 1;
+  },
 
   "delete": function() {
     if (this.get('can_delete_account')) {
@@ -398,27 +317,26 @@ const User = RestModel.extend({
     }
   },
 
-  dismissBanner: function (bannerKey) {
+  dismissBanner(bannerKey) {
     this.set("dismissed_banner_key", bannerKey);
-    Discourse.ajax("/users/" + this.get('username'), {
+    Discourse.ajax(`/users/${this.get('username')}`, {
       type: 'PUT',
       data: { dismissed_banner_key: bannerKey }
     });
   },
 
-  checkEmail: function () {
-    var self = this;
-    return Discourse.ajax("/users/" + this.get("username_lower") + "/emails.json", {
+  checkEmail() {
+    return Discourse.ajax(`/users/${this.get("username_lower")}/emails.json`, {
       type: "PUT",
       data: { context: window.location.pathname }
-    }).then(function (result) {
+    }).then(result => {
       if (result) {
-        self.setProperties({
+        this.setProperties({
           email: result.email,
           associated_accounts: result.associated_accounts
         });
       }
-    }, function () {});
+    });
   }
 
 });
@@ -426,14 +344,14 @@ const User = RestModel.extend({
 User.reopenClass(Singleton, {
 
   // Find a `Discourse.User` for a given username.
-  findByUsername: function(username, options) {
+  findByUsername(username, options) {
     const user = User.create({username: username});
     return user.findDetails(options);
   },
 
   // TODO: Use app.register and junk Singleton
-  createCurrent: function() {
-    var userJson = PreloadStore.get('currentUser');
+  createCurrent() {
+    const userJson = PreloadStore.get('currentUser');
     if (userJson) {
       const store = Discourse.__container__.lookup('store:main');
       return store.createRecord('user', userJson);
@@ -441,56 +359,38 @@ User.reopenClass(Singleton, {
     return null;
   },
 
-  /**
-    Checks if given username is valid for this email address
-
-    @method checkUsername
-    @param {String} username A username to check
-    @param {String} email An email address to check
-    @param {Number} forUserId user id - provide when changing username
-  **/
-  checkUsername: function(username, email, forUserId) {
+  checkUsername(username, email, for_user_id) {
     return Discourse.ajax('/users/check_username', {
-      data: { username: username, email: email, for_user_id: forUserId }
+      data: { username, email, for_user_id }
     });
   },
 
-  /**
-    Groups the user's statistics
-
-    @method groupStats
-    @param {Array} stats Given stats
-    @returns {Object}
-  **/
-  groupStats: function(stats) {
-    var responses = Discourse.UserActionStat.create({
+  groupStats(stats) {
+    const responses = Discourse.UserActionStat.create({
       count: 0,
       action_type: Discourse.UserAction.TYPES.replies
     });
 
-    stats.filterProperty('isResponse').forEach(function (stat) {
+    stats.filterProperty('isResponse').forEach(stat => {
       responses.set('count', responses.get('count') + stat.get('count'));
     });
 
-    var result = Em.A();
+    const result = Em.A();
     result.pushObjects(stats.rejectProperty('isResponse'));
 
-    var insertAt = 0;
-    result.forEach(function(item, index){
-     if(item.action_type === Discourse.UserAction.TYPES.topics || item.action_type === Discourse.UserAction.TYPES.posts){
+    let insertAt = 0;
+    result.forEach((item, index) => {
+     if (item.action_type === Discourse.UserAction.TYPES.topics || item.action_type === Discourse.UserAction.TYPES.posts) {
        insertAt = index + 1;
      }
     });
-    if(responses.count > 0) {
+    if (responses.count > 0) {
       result.insertAt(insertAt, responses);
     }
-    return(result);
+    return result;
   },
 
-  /**
-    Creates a new account
-  **/
-  createAccount: function(attrs) {
+  createAccount(attrs) {
     return Discourse.ajax("/users", {
       data: {
         name: attrs.accountName,
diff --git a/app/assets/javascripts/discourse/routes/full-page-search.js.es6 b/app/assets/javascripts/discourse/routes/full-page-search.js.es6
index 10072d99f..3464250a7 100644
--- a/app/assets/javascripts/discourse/routes/full-page-search.js.es6
+++ b/app/assets/javascripts/discourse/routes/full-page-search.js.es6
@@ -1,4 +1,4 @@
-import { translateResults, getSearchKey } from "discourse/lib/search";
+import { translateResults, getSearchKey, isValidSearchTerm } from "discourse/lib/search";
 
 export default Discourse.Route.extend({
   queryParams: { q: {}, context_id: {}, context: {} },
@@ -23,7 +23,7 @@ export default Discourse.Route.extend({
     }
 
     return PreloadStore.getAndRemove("search", function() {
-      if (params.q && params.q.length > 2) {
+      if (isValidSearchTerm(params.q)) {
         return Discourse.ajax("/search", { data: args });
       } else {
         return null;
diff --git a/app/assets/javascripts/discourse/routes/preferences.js.es6 b/app/assets/javascripts/discourse/routes/preferences.js.es6
index d748689f4..3ae6aed00 100644
--- a/app/assets/javascripts/discourse/routes/preferences.js.es6
+++ b/app/assets/javascripts/discourse/routes/preferences.js.es6
@@ -18,50 +18,52 @@ export default RestrictedUserRoute.extend({
       showModal('avatar-selector');
 
       // all the properties needed for displaying the avatar selector modal
-      const controller = this.controllerFor('avatar-selector'),
-            props = this.modelFor('user').getProperties(
+      const props = this.modelFor('user').getProperties(
               'id',
               'email',
               'username',
-              'uploaded_avatar_id',
+              'avatar_template',
+              'system_avatar_template',
+              'gravatar_avatar_template',
+              'custom_avatar_template',
               'system_avatar_upload_id',
               'gravatar_avatar_upload_id',
               'custom_avatar_upload_id'
             );
 
-      switch (props.uploaded_avatar_id) {
-        case props.system_avatar_upload_id:
+      switch (props.avatar_template) {
+        case props.system_avatar_template:
           props.selected = "system";
           break;
-        case props.gravatar_avatar_upload_id:
+        case props.gravatar_avatar_template:
           props.selected = "gravatar";
           break;
         default:
           props.selected = "uploaded";
       }
 
-      controller.setProperties(props);
+      this.controllerFor('avatar-selector').setProperties(props);
     },
 
     saveAvatarSelection() {
       const user = this.modelFor('user'),
-            avatarSelector = this.controllerFor('avatar-selector');
+            controller = this.controllerFor('avatar-selector'),
+            selectedUploadId = controller.get("selectedUploadId"),
+            selectedAvatarTemplate = controller.get("selectedAvatarTemplate"),
+            type = controller.get("selected");
 
-      // sends the information to the server if it has changed
-      if (avatarSelector.get('selectedUploadId') !== user.get('uploaded_avatar_id')) {
-        user.pickAvatar(avatarSelector.get('selectedUploadId'))
-            .then(() => {
-              user.setProperties(avatarSelector.getProperties(
-                'system_avatar_upload_id',
-                'gravatar_avatar_upload_id',
-                'custom_avatar_upload_id'
-              ));
-              bootbox.alert(I18n.t("user.change_avatar.cache_notice"));
-            });
-      }
+      user.pickAvatar(selectedUploadId, type, selectedAvatarTemplate)
+          .then(() => {
+            user.setProperties(controller.getProperties(
+              'system_avatar_template',
+              'gravatar_avatar_template',
+              'custom_avatar_template'
+            ));
+            bootbox.alert(I18n.t("user.change_avatar.cache_notice"));
+          });
 
       // saves the data back
-      avatarSelector.send('closeModal');
+      controller.send('closeModal');
     },
 
   }
diff --git a/app/assets/javascripts/discourse/templates/components/edit-category-security.hbs b/app/assets/javascripts/discourse/templates/components/edit-category-security.hbs
index 02361e3cf..1b323792a 100644
--- a/app/assets/javascripts/discourse/templates/components/edit-category-security.hbs
+++ b/app/assets/javascripts/discourse/templates/components/edit-category-security.hbs
@@ -1,4 +1,7 @@
 <section class='field'>
+  {{#if category.is_special}}
+    <p class="warning">{{i18n 'category.special_warning'}}</p>
+  {{/if}}
   <ul class='permission-list'>
     {{#each category.permissions as |p|}}
       <li>
@@ -16,6 +19,8 @@
     {{view 'select' class="permission-selector" optionValuePath="content.id" optionLabelPath="content.description" content=category.availablePermissions value=selectedPermission}}
     <button {{action "addPermission" selectedGroup selectedPermission}} class="btn btn-small">{{i18n 'category.add_permission'}}</button>
   {{else}}
-    <button {{action "editPermissions"}} class="btn btn-small">{{i18n 'category.edit_permissions'}}</button>
+    {{#unless category.is_special}}
+      <button {{action "editPermissions"}} class="btn btn-small">{{i18n 'category.edit_permissions'}}</button>
+    {{/unless}}
   {{/if}}
 </section>
diff --git a/app/assets/javascripts/discourse/templates/components/stream-item.hbs b/app/assets/javascripts/discourse/templates/components/stream-item.hbs
index c84082519..22900bbb2 100644
--- a/app/assets/javascripts/discourse/templates/components/stream-item.hbs
+++ b/app/assets/javascripts/discourse/templates/components/stream-item.hbs
@@ -23,7 +23,7 @@
           {{fa-icon 'times'}} {{i18n "bookmarks.remove"}}
         </button>
       {{else}}
-          <a href={{grandChild.userUrl}} data-user-card={{grandChild.username}} class='avatar-link'><div class='avatar-wrapper'>{{avatar grandChild imageSize="tiny" extraClasses="actor" ignoreTitle="true"}}</div></a>
+          <a href={{grandChild.userUrl}} data-user-card={{grandChild.username}} class='avatar-link'><div class='avatar-wrapper'>{{avatar grandChild imageSize="tiny" extraClasses="actor" ignoreTitle="true" avatarTemplatePath="acting_avatar_template"}}</div></a>
           {{#if grandChild.edit_reason}} &mdash; <span class="edit-reason">{{grandChild.edit_reason}}</span>{{/if}}
       {{/if}}
     {{/each}}
diff --git a/app/assets/javascripts/discourse/templates/composer.hbs b/app/assets/javascripts/discourse/templates/composer.hbs
index 711996431..362df1279 100644
--- a/app/assets/javascripts/discourse/templates/composer.hbs
+++ b/app/assets/javascripts/discourse/templates/composer.hbs
@@ -60,6 +60,16 @@
               {{/unless}}
             </div>
           {{/if}}
+
+          {{#if canWhisper}}
+            <div class='form-element clearfix'>
+              <label>
+                {{input type="checkbox" checked=model.whisper tabindex="3"}}
+                {{i18n "composer.add_whisper"}}
+              </label>
+            </div>
+          {{/if}}
+
           {{plugin-outlet "composer-fields"}}
         </div>
 
diff --git a/app/assets/javascripts/discourse/templates/full-page-search.hbs b/app/assets/javascripts/discourse/templates/full-page-search.hbs
index db60849ff..8f1874345 100644
--- a/app/assets/javascripts/discourse/templates/full-page-search.hbs
+++ b/app/assets/javascripts/discourse/templates/full-page-search.hbs
@@ -1,6 +1,6 @@
 <div class="search row clearfix">
-  {{input type="text" value=searchTerm class="input-xxlarge search no-blur" action="search"}}
-  {{d-button action="search" icon="search" class="btn-primary"}}
+  {{search-text-field value=searchTerm class="input-xxlarge search no-blur" action="search" hasAutofocus=hasAutofocus}}
+  {{d-button action="search" icon="search" class="btn-primary" disabled=isNotValidSearchTerm}}
   {{#if canBulkSelect}}
     {{#if model.posts}}
       {{d-button icon="list" class="bulk-select" title="topics.bulk.toggle" action="toggleBulkSelect"}}
diff --git a/app/assets/javascripts/discourse/templates/list/post-count-or-badges.raw.hbs b/app/assets/javascripts/discourse/templates/list/post-count-or-badges.raw.hbs
new file mode 100644
index 000000000..6ac240e75
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/list/post-count-or-badges.raw.hbs
@@ -0,0 +1,5 @@
+{{#if view.showBadges}}
+  {{raw "topic-post-badges" unread=topic.unread newPosts=topic.displayNewPosts unseen=topic.unseen url=topic.lastUnreadUrl}}
+{{else}}
+  {{raw "list/posts-count-column" topic=topic tagName="div"}}
+{{/if}}
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/templates/list/posters-column.raw.hbs b/app/assets/javascripts/discourse/templates/list/posters-column.raw.hbs
index 1b837fb5a..5adbfd3d7 100644
--- a/app/assets/javascripts/discourse/templates/list/posters-column.raw.hbs
+++ b/app/assets/javascripts/discourse/templates/list/posters-column.raw.hbs
@@ -1,5 +1,5 @@
 <td class='posters'>
 {{#each poster in posters}}
-<a href="{{poster.user.path}}" data-user-card="{{poster.user.username}}" class="{{poster.extras}}">{{avatar poster usernamePath="user.username" imageSize="small"}}</a>
+<a href="{{poster.user.path}}" data-user-card="{{poster.user.username}}" class="{{poster.extras}}">{{avatar poster avatarTemplatePath="user.avatar_template" usernamePath="user.username" imageSize="small"}}</a>
 {{/each}}
 </td>
diff --git a/app/assets/javascripts/discourse/templates/mobile/components/basic-topic-list.hbs b/app/assets/javascripts/discourse/templates/mobile/components/basic-topic-list.hbs
index 611d012cc..06af1df5d 100644
--- a/app/assets/javascripts/discourse/templates/mobile/components/basic-topic-list.hbs
+++ b/app/assets/javascripts/discourse/templates/mobile/components/basic-topic-list.hbs
@@ -5,13 +5,9 @@
       {{#each t in topics}}
         <tr {{bind-attr class="t.archived"}}>
           <td>
-            <div class='main-link clearfix'>
+            <div class='main-link'>
               {{topic-status topic=t}}
               {{topic-link t}}
-              {{topic-post-badges unread=t.unread
-                                  newPosts=t.new_posts
-                                  unseen=t.unseen
-                                  url=t.lastUnreadUrl}}
 
               {{#if t.hasExcerpt}}
                 <div class="topic-excerpt">
@@ -25,10 +21,14 @@
                 </div>
               {{/if}}
             </div>
+            <div class='pull-right'>
+              {{raw "list/post-count-or-badges" topic=t postBadgesEnabled="true"}}
+            </div>
+            <div class='clearfix'></div>
             <div class="topic-item-stats clearfix">
               <div class="pull-right">
-                {{raw "list/posts-count-column" topic=t tagName="div"}}
                 {{raw "list/activity-column" topic=t tagName="div" class="num activity last"}}
+                <a href="{{t.lastPostUrl}}" title='{{i18n 'last_post'}}: {{{raw-date t.bumped_at}}}'>{{t.last_poster_username}}</a>
               </div>
               {{#unless controller.hideCategory}}
                 <div class='category'>
diff --git a/app/assets/javascripts/discourse/templates/mobile/list/topic_list_item.raw.hbs b/app/assets/javascripts/discourse/templates/mobile/list/topic_list_item.raw.hbs
index 01937421a..aa4370c96 100644
--- a/app/assets/javascripts/discourse/templates/mobile/list/topic_list_item.raw.hbs
+++ b/app/assets/javascripts/discourse/templates/mobile/list/topic_list_item.raw.hbs
@@ -2,13 +2,10 @@
 <div class='main-link'>
   {{raw "topic-status" topic=content}}
   {{topic-link content}}
-  {{#if controller.showTopicPostBadges}}
-    {{raw "topic-post-badges" unread=content.unread newPosts=content.displayNewPosts unseen=content.unseen url=content.lastUnreadUrl}}
-  {{/if}}
   {{raw "list/topic-excerpt" topic=content}}
 </div>
 <div class='pull-right'>
-  {{raw "list/posts-count-column" topic=content tagName="div"}}
+  {{raw "list/post-count-or-badges" topic=content postBadgesEnabled=controller.showTopicPostBadges}}
 </div>
 <div class="clearfix"></div>
 
@@ -22,8 +19,8 @@
 
   <div class="pull-right">
     <div class='num activity last'>
-      <a href="{{content.lastPostUrl}}" title='{{i18n 'last_post'}}: {{{raw-date content.bumped_at}}}'>{{content.last_poster_username}}</a>
       {{raw "list/activity-column" topic=content tagName="span" class="age"}}
+      <a href="{{content.lastPostUrl}}" title='{{i18n 'last_post'}}: {{{raw-date content.bumped_at}}}'>{{content.last_poster_username}}</a>
     </div>
   </div>
   <div class="clearfix"></div>
diff --git a/app/assets/javascripts/discourse/templates/modal/avatar_selector.hbs b/app/assets/javascripts/discourse/templates/modal/avatar_selector.hbs
index 12f0309d6..e7d070d81 100644
--- a/app/assets/javascripts/discourse/templates/modal/avatar_selector.hbs
+++ b/app/assets/javascripts/discourse/templates/modal/avatar_selector.hbs
@@ -2,32 +2,27 @@
   <div>
     <div>
       <input type="radio" id="system-avatar" name="avatar" value="system" {{action "useSystem"}}>
-      <label class="radio" for="system-avatar">{{bound-avatar controller "large" system_avatar_upload_id}} {{{i18n 'user.change_avatar.letter_based'}}}</label>
+      <label class="radio" for="system-avatar">{{bound-avatar-template system_avatar_template "large"}} {{{i18n 'user.change_avatar.letter_based'}}}</label>
     </div>
     <div>
       <input type="radio" id="gravatar" name="avatar" value="gravatar" {{action "useGravatar"}}>
-      <label class="radio" for="gravatar">{{bound-avatar controller "large" gravatar_avatar_upload_id}} {{{i18n 'user.change_avatar.gravatar'}}} {{email}}</label>
+      <label class="radio" for="gravatar">{{bound-avatar-template gravatar_avatar_template "large"}} {{{i18n 'user.change_avatar.gravatar'}}} {{email}}</label>
       {{d-button action="refreshGravatar" title="user.change_avatar.refresh_gravatar_title" disabled=gravatarRefreshDisabled icon="refresh"}}
     </div>
     {{#if allowImageUpload}}
       <div>
         <input type="radio" id="uploaded_avatar" name="avatar" value="uploaded" {{action "useUploadedAvatar"}}>
         <label class="radio" for="uploaded_avatar">
-          {{#if hasUploadedAvatar}}
-            {{#if uploadedAvatarTemplate}}
-              {{bound-avatar-template uploadedAvatarTemplate "large"}}
-            {{else}}
-              {{bound-avatar controller "large" custom_avatar_upload_id}}
-            {{/if}}
+          {{#if custom_avatar_template}}
+            {{bound-avatar-template custom_avatar_template "large"}}
             {{i18n 'user.change_avatar.uploaded_avatar'}}
           {{else}}
             {{i18n 'user.change_avatar.uploaded_avatar_empty'}}
           {{/if}}
         </label>
-        {{avatar-uploader username=username
-                          user_id=id
-                          uploadedAvatarTemplate=uploadedAvatarTemplate
-                          custom_avatar_upload_id=custom_avatar_upload_id
+        {{avatar-uploader user_id=id
+                          uploadedAvatarTemplate=custom_avatar_template
+                          uploadedAvatarId=custom_avatar_upload_id
                           uploading=uploading
                           done="useUploadedAvatar"}}
       </div>
@@ -36,6 +31,6 @@
 </div>
 
 <div class="modal-footer">
-  {{d-button action="saveAvatarSelection" class="btn-primary" disabled=saveDisabled label="save"}}
+  {{d-button action="saveAvatarSelection" class="btn-primary" disabled=uploading label="save"}}
   <a {{action "closeModal"}}>{{i18n 'cancel'}}</a>
 </div>
diff --git a/app/assets/javascripts/discourse/templates/post.hbs b/app/assets/javascripts/discourse/templates/post.hbs
index 29b95d5aa..ff990eb66 100644
--- a/app/assets/javascripts/discourse/templates/post.hbs
+++ b/app/assets/javascripts/discourse/templates/post.hbs
@@ -8,7 +8,7 @@
   {{view 'reply-history' content=replyHistory}}
 </div>
 
-<article {{bind-attr class=":boxed via_email" id="postElementId" data-post-id="id" data-user-id="user_id"}}>
+<article class="boxed {{if via_email 'via-email'}}" id={{postElementId}} data-post-id={{id}} data-user-id={{user_id}}>
   <div class='row'>
 
     <div class='topic-avatar'>
@@ -45,15 +45,20 @@
           </div>
         {{/if}}
         {{#if wiki}}
-          <div class="post-info wiki" title="{{i18n 'post.wiki.about'}}" {{action "editPost" this}}>{{fa-icon "pencil-square-o"}}</div>
+          <div class="post-info wiki" title={{i18n 'post.wiki.about'}} {{action "editPost" this}}>{{fa-icon "pencil-square-o"}}</div>
         {{/if}}
         {{#if via_email}}
           {{#if canViewRawEmail}}
-            <div class="post-info via-email raw-email" title="{{i18n 'post.via_email'}}" {{action "showRawEmail" this}}>{{fa-icon "envelope-o"}}</div>
+            <div class="post-info via-email raw-email" title={{i18n 'post.via_email'}} {{action "showRawEmail" this}}>{{fa-icon "envelope-o"}}</div>
           {{else}}
-            <div class="post-info via-email" title="{{i18n 'post.via_email'}}">{{fa-icon "envelope-o"}}</div>
+            <div class="post-info via-email" title={{i18n 'post.via_email'}}>{{fa-icon "envelope-o"}}</div>
           {{/if}}
         {{/if}}
+
+        {{#if view.whisper}}
+          <div class="post-info whisper" title={{i18n 'post.whisper'}}>{{fa-icon "eye-slash"}}</div>
+        {{/if}}
+
         {{#if showUserReplyTab}}
           <a href {{action "toggleReplyHistory" this target="view"}} class='reply-to-tab'>
             {{#if loadingReplyHistory}}
diff --git a/app/assets/javascripts/discourse/views/avatar-selector.js.es6 b/app/assets/javascripts/discourse/views/avatar-selector.js.es6
index 15b8541ef..6fcc5c9bc 100644
--- a/app/assets/javascripts/discourse/views/avatar-selector.js.es6
+++ b/app/assets/javascripts/discourse/views/avatar-selector.js.es6
@@ -1,3 +1,4 @@
+import { on, observes } from "ember-addons/ember-computed-decorators";
 import ModalBodyView from "discourse/views/modal-body";
 
 export default ModalBodyView.extend({
@@ -6,11 +7,14 @@ export default ModalBodyView.extend({
   title: I18n.t('user.change_avatar.title'),
 
   // *HACK* used to select the proper radio button, because {{action}} stops the default behavior
-  selectedChanged: function() {
+  @on("didInsertElement")
+  @observes("controller.selected")
+  selectedChanged() {
     Em.run.next(() => $('input:radio[name="avatar"]').val([this.get('controller.selected')]));
-  }.observes('controller.selected').on("didInsertElement"),
+  },
 
-  _focusSelectedButton: function() {
+  @on("didInsertElement")
+  _focusSelectedButton() {
     Em.run.next(() => $('input:radio[value="' + this.get('controller.selected') + '"]').focus());
-  }.on("didInsertElement")
+  }
 });
diff --git a/app/assets/javascripts/discourse/views/composer.js.es6 b/app/assets/javascripts/discourse/views/composer.js.es6
index d96c6a55a..5d62db416 100644
--- a/app/assets/javascripts/discourse/views/composer.js.es6
+++ b/app/assets/javascripts/discourse/views/composer.js.es6
@@ -1,7 +1,6 @@
 import userSearch from 'discourse/lib/user-search';
 import afterTransition from 'discourse/lib/after-transition';
 import loadScript from 'discourse/lib/load-script';
-import avatarTemplate from 'discourse/lib/avatar-template';
 import positioningWorkaround from 'discourse/lib/safari-hacks';
 import debounce from 'discourse/lib/debounce';
 import { linkSeenMentions, fetchUnseenMentions } from 'discourse/lib/link-mentions';
@@ -251,10 +250,7 @@ const ComposerView = Ember.View.extend(Ember.Evented, {
         if (posts && topicId === self.get('controller.controllers.topic.model.id')) {
           const quotedPost = posts.findProperty("post_number", postNumber);
           if (quotedPost) {
-            const username = quotedPost.get('username'),
-                  uploadId = quotedPost.get('uploaded_avatar_id');
-
-            return Discourse.Utilities.tinyAvatar(avatarTemplate(username, uploadId));
+            return Discourse.Utilities.tinyAvatar(quotedPost.get('avatar_template'));
           }
         }
       }
diff --git a/app/assets/javascripts/discourse/views/list/post-count-or-badges.js.es6 b/app/assets/javascripts/discourse/views/list/post-count-or-badges.js.es6
new file mode 100644
index 000000000..9a8559f1a
--- /dev/null
+++ b/app/assets/javascripts/discourse/views/list/post-count-or-badges.js.es6
@@ -0,0 +1,4 @@
+export default Ember.Object.extend({
+  postCountsPresent: Ember.computed.or('topic.unread', 'topic.displayNewPosts', 'topic.unseen'),
+  showBadges: Ember.computed.and('postBadgesEnabled', 'postCountsPresent')
+});
diff --git a/app/assets/javascripts/discourse/views/post.js.es6 b/app/assets/javascripts/discourse/views/post.js.es6
index ef1869e90..e2c663177 100644
--- a/app/assets/javascripts/discourse/views/post.js.es6
+++ b/app/assets/javascripts/discourse/views/post.js.es6
@@ -1,6 +1,8 @@
 import ScreenTrack from 'discourse/lib/screen-track';
 import { number } from 'discourse/lib/formatter';
 import DiscourseURL from 'discourse/lib/url';
+import computed from 'ember-addons/ember-computed-decorators';
+import { fmt } from 'discourse/lib/computed';
 
 const DAY = 60 * 50 * 1000;
 
@@ -12,10 +14,18 @@ const PostView = Discourse.GroupedView.extend(Ember.Evented, {
                       'post.deleted:deleted',
                       'post.topicOwner:topic-owner',
                       'groupNameClass',
-                      'post.wiki:wiki'],
+                      'post.wiki:wiki',
+                      'whisper'],
 
   post: Ember.computed.alias('content'),
 
+  postElementId: fmt('post.post_number', 'post_%@'),
+
+  @computed('post.post_type')
+  whisper(postType) {
+    return postType === this.site.get('post_types.whisper');
+  },
+
   templateName: function() {
     return (this.get('post.post_type') === this.site.get('post_types.small_action')) ? 'post-small-action' : 'post';
   }.property('post.post_type'),
diff --git a/app/assets/javascripts/main_include.js b/app/assets/javascripts/main_include.js
index 797cab865..757f7308e 100644
--- a/app/assets/javascripts/main_include.js
+++ b/app/assets/javascripts/main_include.js
@@ -14,7 +14,6 @@
 //= require ./discourse/lib/load-script
 //= require ./discourse/lib/notification-levels
 //= require ./discourse/lib/app-events
-//= require ./discourse/lib/avatar-template
 //= require ./discourse/lib/url
 //= require ./discourse/lib/debounce
 //= require ./discourse/lib/quote
@@ -41,7 +40,6 @@
 //= require ./discourse/lib/autocomplete
 //= require ./discourse/lib/after-transition
 //= require ./discourse/lib/debounce
-//= require ./discourse/lib/avatar-template
 //= require ./discourse/lib/safari-hacks
 //= require_tree ./discourse/adapters
 //= require ./discourse/models/rest
diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss
index 3b0d62008..dab4f08f1 100644
--- a/app/assets/stylesheets/common/base/topic-post.scss
+++ b/app/assets/stylesheets/common/base/topic-post.scss
@@ -147,7 +147,7 @@ aside.quote {
 }
 
 .post-info {
-  &.wiki, &.via-email {
+  &.wiki, &.via-email, &.whisper {
     margin-right: 5px;
     i.fa {
       font-size: 1em;
@@ -317,3 +317,12 @@ table.md-table {
 
   clear: both;
 }
+
+.whisper {
+  .topic-body {
+    .cooked {
+      font-style: italic;
+      color: dark-light-diff($primary, $secondary, 55%, -40%);
+    }
+  }
+}
diff --git a/app/assets/stylesheets/desktop/compose.scss b/app/assets/stylesheets/desktop/compose.scss
index 9fea1acef..ca821824d 100644
--- a/app/assets/stylesheets/desktop/compose.scss
+++ b/app/assets/stylesheets/desktop/compose.scss
@@ -324,8 +324,8 @@
   }
   .title-input .popup-tip {
     width: 300px;
-    left: 0px;
-    top: -30px;
+    margin-top: 8px;
+    left: 150px;
   }
   .category-input .popup-tip {
     width: 240px;
diff --git a/app/assets/stylesheets/embed.css.scss b/app/assets/stylesheets/embed.css.scss
index 40eab2875..9eee09134 100644
--- a/app/assets/stylesheets/embed.css.scss
+++ b/app/assets/stylesheets/embed.css.scss
@@ -14,8 +14,8 @@ article.post {
   }
 
   .quote .title {
-    border-left: 5px solid darken(dark-light-diff($primary, $secondary, 90%, -60%), 10%);
-    background-color: dark-light-diff($primary, $secondary, 90%, -60%);
+    border-left: 5px solid darken(dark-light-diff($primary, $secondary, 97%, -45%), 10%);
+    background-color: dark-light-diff($primary, $secondary, 97%, -45%);
     padding: 10px 10px 0 12px;
     .avatar { margin-right: 7px; }
   }
@@ -27,8 +27,8 @@ article.post {
   blockquote {
     padding: 10px 8px 10px 13px;
     margin: 0 0 10px 0;
-    background-color: dark-light-diff($primary, $secondary, 90%, -60%);
-    border-left: 5px solid darken(dark-light-diff($primary, $secondary, 90%, -60%), 10%);
+    background-color: dark-light-diff($primary, $secondary, 97%, -45%);
+    border-left: 5px solid darken(dark-light-diff($primary, $secondary, 97%, -45%), 10%);
     overflow: hidden;
     p {
       margin: 0 0 10px 0;
@@ -137,9 +137,3 @@ footer {
   float: right;
   max-height: 30px;
 }
-
-.button {
-  background-color: #eee;
-  padding: 5px;
-  display: inline-block;
-}
diff --git a/app/assets/stylesheets/mobile/topic-list.scss b/app/assets/stylesheets/mobile/topic-list.scss
index 6922da71c..1b59b39e7 100644
--- a/app/assets/stylesheets/mobile/topic-list.scss
+++ b/app/assets/stylesheets/mobile/topic-list.scss
@@ -80,7 +80,17 @@
   .badge-notification {
     position: relative;
     top: -1px;
-      i {color: $secondary;}
+    font-size: 1.071em;
+    padding: 4px 6px 3px 6px;
+    i {color: $secondary;}
+
+    &.new-topic::before {
+      content: none;
+      margin-right: 0;
+    }
+    &.new-topic {
+      padding-right: 0;
+    }
   }
 
   .topic-item-stats {
@@ -91,7 +101,7 @@
     .category a {
       max-width: 160px;
     }
-    .num .fa {
+    .num .fa, a, a:visited {
       color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%));
     }
   }
@@ -110,6 +120,29 @@
 
 // Category list
 // --------------------------------------------------
+.categories-list .list-container {
+  margin-left: -10px; // Extend past the .wrap padding to the edge of the window
+}
+
+.category-list-item.category {
+  // Allow percentage widths on table cells to include their padding
+  box-sizing: border-box;
+  *, *:before, *:after {
+    box-sizing: inherit;
+  }
+
+  .main-link {
+    width: 80%;
+  }
+
+  .posts {
+    width: 10%;
+  }
+
+  .age {
+    width: 10%;
+  }
+}
 
 tr.category-topic-link {
   border-top: darken($secondary, 3%) 1px solid;
@@ -357,7 +390,7 @@ td .main-link {
     padding: 5px 10px 5px 0;
   }
 }
-.topic-list-item {
+.topic-list {
   .posts-map {
     font-size: 1.071em;
     padding-top: 2px;
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index a35e2f423..5ee72c315 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -465,6 +465,10 @@ class PostsController < ApplicationController
       result[:is_warning] = false
     end
 
+    if SiteSetting.enable_whispers? && params[:whisper] == "true"
+      result[:post_type] = Post.types[:whisper]
+    end
+
     PostRevisor.tracked_topic_fields.each_key do |f|
       params.permit(f => [])
       result[f] = params[f] if params.has_key?(f)
diff --git a/app/controllers/user_avatars_controller.rb b/app/controllers/user_avatars_controller.rb
index ed8f31e4a..266648107 100644
--- a/app/controllers/user_avatars_controller.rb
+++ b/app/controllers/user_avatars_controller.rb
@@ -13,7 +13,10 @@ class UserAvatarsController < ApplicationController
       user.create_user_avatar(user_id: user.id) unless user.user_avatar
       user.user_avatar.update_gravatar!
 
-      render json: { upload_id: user.user_avatar.gravatar_upload_id }
+      render json: {
+        gravatar_upload_id: user.user_avatar.gravatar_upload_id,
+        gravatar_avatar_template: User.avatar_template(user.username, user.user_avatar.gravatar_upload_id)
+      }
     else
       raise Discourse::NotFound
     end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index d537c6b7a..bb361de6f 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -515,13 +515,13 @@ class UsersController < ApplicationController
 
     results = UserSearch.new(term, topic_id: topic_id, topic_allowed_users: topic_allowed_users, searching_user: current_user).search
 
-    user_fields = [:username, :upload_avatar_template, :uploaded_avatar_id]
+    user_fields = [:username, :upload_avatar_template]
     user_fields << :name if SiteSetting.enable_names?
 
-    to_render = { users: results.as_json(only: user_fields, methods: :avatar_template) }
+    to_render = { users: results.as_json(only: user_fields, methods: [:avatar_template]) }
 
     if params[:include_groups] == "true"
-      to_render[:groups] = Group.search_group(term, current_user).map {|m| {:name=>m.name, :usernames=> m.usernames.split(",")} }
+      to_render[:groups] = Group.search_group(term, current_user).map { |m| { name: m.name, usernames: m.usernames.split(",") } }
     end
 
     render json: to_render
@@ -531,13 +531,15 @@ class UsersController < ApplicationController
     user = fetch_user_from_params
     guardian.ensure_can_edit!(user)
 
+    type = params[:type]
     upload_id = params[:upload_id]
 
     user.uploaded_avatar_id = upload_id
 
-    # ensure we associate the custom avatar properly
-    if upload_id && user.user_avatar.custom_upload_id != upload_id
+    if type == "uploaded" || type == "custom"
       user.user_avatar.custom_upload_id = upload_id
+    elsif type == "gravatar"
+      user.user_avatar.gravatar_upload_id = upload_id
     end
 
     user.save!
diff --git a/app/models/post.rb b/app/models/post.rb
index 3a6a8d515..3cf1fb367 100644
--- a/app/models/post.rb
+++ b/app/models/post.rb
@@ -74,7 +74,7 @@ class Post < ActiveRecord::Base
   end
 
   def self.types
-    @types ||= Enum.new(:regular, :moderator_action, :small_action)
+    @types ||= Enum.new(:regular, :moderator_action, :small_action, :whisper)
   end
 
   def self.cook_methods
@@ -96,15 +96,24 @@ class Post < ActiveRecord::Base
   end
 
   def publish_change_to_clients!(type)
-    # special failsafe for posts missing topics
-    # consistency checks should fix, but message
+
+    channel = "/topic/#{topic_id}"
+    msg = { id: id,
+            post_number: post_number,
+            updated_at: Time.now,
+            type: type }
+
+    # special failsafe for posts missing topics consistency checks should fix, but message
     # is safe to skip
-    MessageBus.publish("/topic/#{topic_id}", {
-      id: id,
-      post_number: post_number,
-      updated_at: Time.now,
-      type: type
-    }, group_ids: topic.secure_group_ids) if topic
+    return unless topic
+
+    # Whispers should not be published to everyone
+    if post_type == Post.types[:whisper]
+      user_ids = User.where('admin or moderator or id = ?', user_id).pluck(:id)
+      MessageBus.publish(channel, msg, user_ids: user_ids)
+    else
+      MessageBus.publish(channel, msg, group_ids: topic.secure_group_ids)
+    end
   end
 
   def trash!(trashed_by=nil)
diff --git a/app/models/topic.rb b/app/models/topic.rb
index 442fb43c2..703b54726 100644
--- a/app/models/topic.rb
+++ b/app/models/topic.rb
@@ -218,6 +218,13 @@ class Topic < ActiveRecord::Base
     end
   end
 
+  def visible_post_types(viewed_by=nil)
+    types = Post.types
+    result = [types[:regular], types[:moderator_action], types[:small_action]]
+    result << types[:whisper] if viewed_by.try(:staff?)
+    result
+  end
+
   def self.top_viewed(max = 10)
     Topic.listable_topics.visible.secured.order('views desc').limit(max)
   end
diff --git a/app/models/upload.rb b/app/models/upload.rb
index 39e5fb94e..bcc468d44 100644
--- a/app/models/upload.rb
+++ b/app/models/upload.rb
@@ -50,7 +50,7 @@ class Upload < ActiveRecord::Base
   end
 
   # list of image types that will be cropped
-  CROPPED_IMAGE_TYPES ||= ["avatar", "profile_background", "card_background"]
+  CROPPED_IMAGE_TYPES ||= %w{avatar profile_background card_background}
 
   # options
   #   - content_type
diff --git a/app/models/user.rb b/app/models/user.rb
index 2861fff88..cd4792ea8 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -454,26 +454,41 @@ class User < ActiveRecord::Base
           [((result << 5) - result) + char.ord].pack('L').unpack('l').first
         end
 
-        avatar_template = split_avatars[hash.abs % split_avatars.size]
+        split_avatars[hash.abs % split_avatars.size]
       end
+    else
+      system_avatar_template(username)
+    end
+  end
+
+  def self.avatar_template(username, uploaded_avatar_id)
+    username ||= ""
+    return default_template(username) if !uploaded_avatar_id
+    hostname = RailsMultisite::ConnectionManagement.current_hostname
+    UserAvatar.local_avatar_template(hostname, username.downcase, uploaded_avatar_id)
+  end
+
+  def self.system_avatar_template(username)
+    # TODO it may be worth caching this in a distributed cache, should be benched
+    if SiteSetting.external_system_avatars_enabled
+      url = SiteSetting.external_system_avatars_url.dup
+      url.gsub! "{color}", letter_avatar_color(username.downcase)
+      url.gsub! "{username}", username
+      url.gsub! "{first_letter}", username[0].downcase
+      url
     else
       "#{Discourse.base_uri}/letter_avatar/#{username.downcase}/{size}/#{LetterAvatar.version}.png"
     end
   end
 
-  def self.avatar_template(username,uploaded_avatar_id)
-    return default_template(username) if !uploaded_avatar_id
+  def self.letter_avatar_color(username)
     username ||= ""
-    hostname = RailsMultisite::ConnectionManagement.current_hostname
-    UserAvatar.local_avatar_template(hostname, username.downcase, uploaded_avatar_id)
-  end
-
-  def self.letter_avatar_template(username)
-    "#{Discourse.base_uri}/letter_avatar/#{username.downcase}/{size}/#{LetterAvatar.version}.png"
+    color = LetterAvatar::COLORS[Digest::MD5.hexdigest(username)[0...15].to_i(16) % LetterAvatar::COLORS.length]
+    color.map { |c| c.to_s(16).rjust(2, '0') }.join
   end
 
   def avatar_template
-    self.class.avatar_template(username,uploaded_avatar_id)
+    self.class.avatar_template(username, uploaded_avatar_id)
   end
 
   # The following count methods are somewhat slow - definitely don't use them in a loop.
diff --git a/app/models/user_avatar.rb b/app/models/user_avatar.rb
index f61df736b..8d57472c6 100644
--- a/app/models/user_avatar.rb
+++ b/app/models/user_avatar.rb
@@ -39,8 +39,7 @@ class UserAvatar < ActiveRecord::Base
   end
 
   def self.local_avatar_url(hostname, username, upload_id, size)
-    version = self.version(upload_id)
-    "#{Discourse.base_uri}/user_avatar/#{hostname}/#{username}/#{size}/#{version}.png"
+    self.local_avatar_template(hostname, username, upload_id).gsub("{size}", size.to_s)
   end
 
   def self.local_avatar_template(hostname, username, upload_id)
@@ -49,8 +48,7 @@ class UserAvatar < ActiveRecord::Base
   end
 
   def self.external_avatar_url(user_id, upload_id, size)
-    version = self.version(upload_id)
-    "#{Discourse.store.absolute_base_url}/avatars/#{user_id}/#{size}/#{version}.png"
+    self.external_avatar_template(user_id, upload_id).gsub("{size}", size.to_s)
   end
 
   def self.external_avatar_template(user_id, upload_id)
diff --git a/app/serializers/admin_post_serializer.rb b/app/serializers/admin_post_serializer.rb
index 2dd29df5c..a06328d83 100644
--- a/app/serializers/admin_post_serializer.rb
+++ b/app/serializers/admin_post_serializer.rb
@@ -3,7 +3,7 @@ class AdminPostSerializer < ApplicationSerializer
   attributes :id,
              :created_at,
              :post_number,
-             :name, :username, :avatar_template, :uploaded_avatar_id,
+             :name, :username, :avatar_template,
              :topic_id, :topic_slug, :topic_title,
              :category_id,
              :excerpt,
@@ -29,10 +29,6 @@ class AdminPostSerializer < ApplicationSerializer
     object.user.avatar_template
   end
 
-  def uploaded_avatar_id
-    object.user.uploaded_avatar_id
-  end
-
   def topic_slug
     topic.slug
   end
diff --git a/app/serializers/basic_post_serializer.rb b/app/serializers/basic_post_serializer.rb
index 04b91ecad..8969d19a0 100644
--- a/app/serializers/basic_post_serializer.rb
+++ b/app/serializers/basic_post_serializer.rb
@@ -4,7 +4,6 @@ class BasicPostSerializer < ApplicationSerializer
              :name,
              :username,
              :avatar_template,
-             :uploaded_avatar_id,
              :created_at,
              :cooked,
              :cooked_hidden
@@ -21,13 +20,10 @@ class BasicPostSerializer < ApplicationSerializer
     object.user.try(:avatar_template)
   end
 
-  def uploaded_avatar_id
-    object.user.try(:uploaded_avatar_id)
-  end
-
   def cooked_hidden
     object.hidden && !scope.is_staff?
   end
+
   def include_cooked_hidden?
     cooked_hidden
   end
diff --git a/app/serializers/basic_user_serializer.rb b/app/serializers/basic_user_serializer.rb
index 8911291f3..8880c8dbd 100644
--- a/app/serializers/basic_user_serializer.rb
+++ b/app/serializers/basic_user_serializer.rb
@@ -1,5 +1,5 @@
 class BasicUserSerializer < ApplicationSerializer
-  attributes :id, :username, :uploaded_avatar_id, :avatar_template
+  attributes :id, :username, :avatar_template
 
   def include_name?
     SiteSetting.enable_names?
@@ -9,7 +9,7 @@ class BasicUserSerializer < ApplicationSerializer
     if Hash === object
       User.avatar_template(user[:username], user[:uploaded_avatar_id])
     else
-      object.avatar_template
+      user.try(:avatar_template)
     end
   end
 
diff --git a/app/serializers/category_serializer.rb b/app/serializers/category_serializer.rb
index 1bf08f89f..6138f1d1b 100644
--- a/app/serializers/category_serializer.rb
+++ b/app/serializers/category_serializer.rb
@@ -11,6 +11,7 @@ class CategorySerializer < BasicCategorySerializer
              :suppress_from_homepage,
              :can_delete,
              :cannot_delete_reason,
+             :is_special,
              :allow_badges,
              :custom_fields
 
@@ -37,6 +38,15 @@ class CategorySerializer < BasicCategorySerializer
     true
   end
 
+  def include_is_special?
+    [SiteSetting.lounge_category_id, SiteSetting.meta_category_id, SiteSetting.staff_category_id, SiteSetting.uncategorized_category_id]
+    .include? object.id
+  end
+
+  def is_special
+    true
+  end
+
   def include_can_delete?
     scope && scope.can_delete?(object)
   end
diff --git a/app/serializers/post_action_user_serializer.rb b/app/serializers/post_action_user_serializer.rb
index b69a27348..72dbd8e4d 100644
--- a/app/serializers/post_action_user_serializer.rb
+++ b/app/serializers/post_action_user_serializer.rb
@@ -9,10 +9,6 @@ class PostActionUserSerializer < BasicUserSerializer
     object.user.username
   end
 
-  def uploaded_avatar_id
-    object.user.uploaded_avatar_id
-  end
-
   def avatar_template
     object.user.avatar_template
   end
diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb
index 5ba620ca2..a10f9dbf6 100644
--- a/app/serializers/post_serializer.rb
+++ b/app/serializers/post_serializer.rb
@@ -177,8 +177,7 @@ class PostSerializer < BasicPostSerializer
   def reply_to_user
     {
       username: object.reply_to_user.username,
-      avatar_template: object.reply_to_user.avatar_template,
-      uploaded_avatar_id: object.reply_to_user.uploaded_avatar_id
+      avatar_template: object.reply_to_user.avatar_template
     }
   end
 
diff --git a/app/serializers/topic_post_count_serializer.rb b/app/serializers/topic_post_count_serializer.rb
index 586f9f2d7..c780d1203 100644
--- a/app/serializers/topic_post_count_serializer.rb
+++ b/app/serializers/topic_post_count_serializer.rb
@@ -14,8 +14,4 @@ class TopicPostCountSerializer < BasicUserSerializer
     object[:post_count]
   end
 
-  def uploaded_avatar_id
-    object[:user].uploaded_avatar_id
-  end
-
 end
diff --git a/app/serializers/user_action_serializer.rb b/app/serializers/user_action_serializer.rb
index 8b3939963..d9609ff9b 100644
--- a/app/serializers/user_action_serializer.rb
+++ b/app/serializers/user_action_serializer.rb
@@ -26,10 +26,8 @@ class UserActionSerializer < ApplicationSerializer
              :action_code,
              :edit_reason,
              :category_id,
-             :uploaded_avatar_id,
              :closed,
-             :archived,
-             :acting_uploaded_avatar_id
+             :archived
 
   def excerpt
     cooked = object.cooked || PrettyText.cook(object.raw)
diff --git a/app/serializers/user_name_serializer.rb b/app/serializers/user_name_serializer.rb
index ac7beaa8d..3d7fc0d1f 100644
--- a/app/serializers/user_name_serializer.rb
+++ b/app/serializers/user_name_serializer.rb
@@ -1,20 +1,3 @@
-class UserNameSerializer < ApplicationSerializer
-  attributes :id, :username, :name, :title, :uploaded_avatar_id, :avatar_template
-
-  def include_name?
-    SiteSetting.enable_names?
-  end
-
-  def avatar_template
-    if Hash === object
-      User.avatar_template(user[:username], user[:uploaded_avatar_id])
-    else
-      object.avatar_template
-    end
-  end
-
-  def user
-    object[:user] || object
-  end
-
+class UserNameSerializer < BasicUserSerializer
+  attributes :name, :title
 end
diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb
index 895453990..334b20e2d 100644
--- a/app/serializers/user_serializer.rb
+++ b/app/serializers/user_serializer.rb
@@ -93,8 +93,12 @@ class UserSerializer < BasicUserSerializer
                      :watched_category_ids,
                      :private_messages_stats,
                      :disable_jump_reply,
+                     :system_avatar_upload_id,
+                     :system_avatar_template,
                      :gravatar_avatar_upload_id,
+                     :gravatar_avatar_template,
                      :custom_avatar_upload_id,
+                     :custom_avatar_template,
                      :has_title_badges,
                      :card_image_badge,
                      :card_image_badge_id,
@@ -278,14 +282,32 @@ class UserSerializer < BasicUserSerializer
     UserAction.private_messages_stats(object.id, scope)
   end
 
+  def system_avatar_upload_id
+    # should be left blank
+  end
+
+  def system_avatar_template
+    User.system_avatar_template(object.username)
+  end
+
   def gravatar_avatar_upload_id
     object.user_avatar.try(:gravatar_upload_id)
   end
 
+  def gravatar_avatar_template
+    return unless gravatar_upload_id = object.user_avatar.try(:gravatar_upload_id)
+    User.avatar_template(object.username, gravatar_upload_id)
+  end
+
   def custom_avatar_upload_id
     object.user_avatar.try(:custom_upload_id)
   end
 
+  def custom_avatar_template
+    return unless custom_upload_id = object.user_avatar.try(:custom_upload_id)
+    User.avatar_template(object.username, custom_upload_id)
+  end
+
   def has_title_badges
     object.badges.where(allow_title: true).count > 0
   end
@@ -323,4 +345,5 @@ class UserSerializer < BasicUserSerializer
   def pending_count
     0
   end
+
 end
diff --git a/app/views/list/list.erb b/app/views/list/list.erb
index a6768190d..b9ae8a086 100644
--- a/app/views/list/list.erb
+++ b/app/views/list/list.erb
@@ -22,7 +22,7 @@
         <span itemprop='name'><%= t.title %></span>
       </a>
       <%= page_links(t) %>
-      <% if !@category && t.category %>
+      <% if (!@category || @category.has_children?) && t.category %>
         <span>[<a href='<%= t.category.url %>'><%= t.category.name %></a>]</span>
       <% end %>
       <span title='<%= t 'posts' %>'>(<a href="<%=t.last_post_url%>"><%= t.posts_count %></a>)</span>
diff --git a/config/initializers/i18n.rb b/config/initializers/i18n.rb
index 00dab6775..43b729957 100644
--- a/config/initializers/i18n.rb
+++ b/config/initializers/i18n.rb
@@ -28,8 +28,10 @@ class NoFallbackLocaleList < FallbackLocaleList
   end
 end
 
-if Rails.env.production?
-  I18n.fallbacks = FallbackLocaleList.new
-else
+
+if Rails.env.development?
   I18n.fallbacks = NoFallbackLocaleList.new
+else
+  I18n.fallbacks = FallbackLocaleList.new
+  I18n.config.missing_interpolation_argument_handler = proc { throw(:exception) }
 end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 8bf64d56c..866e6f7fb 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -813,6 +813,7 @@ en:
       emoji: "Emoji :smile:"
 
       add_warning: "This is an official warning."
+      add_whisper: "This is a whisper only visible to moderators"
       posting_not_on_topic: "Which topic do you want to reply to?"
       saving_draft_tip: "saving..."
       saved_draft_tip: "saved"
@@ -1353,6 +1354,7 @@ en:
         yes_value: "Yes, abandon"
 
       via_email: "this post arrived via email"
+      whisper: "this post is a private whisper for moderators"
 
       wiki:
         about: "this post is a wiki; basic users can edit it"
@@ -1544,6 +1546,7 @@ en:
       change_in_category_topic: "Edit Description"
       already_used: 'This color has been used by another category'
       security: "Security"
+      special_warning: "Warning: This category is a pre-seeded category and the security settings cannot be edited. If you do not wish to use this category, delete it instead of repurposing it."
       images: "Images"
       auto_close_label: "Auto-close topics after:"
       auto_close_units: "hours"
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index fcd190dc8..6f765f159 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -94,7 +94,7 @@ en:
       min_username_length_exists: "You cannot set the minimum username length above the shortest username."
       min_username_length_range: "You cannot set the minimum above the maximum."
       max_username_length_exists: "You cannot set the maximum username length below the longest username."
-      max_username_length_range: "You cannot set the maximum above the minimum."
+      max_username_length_range: "You cannot set the maximum below the minimum."
       default_categories_already_selected: "You cannot select a category used in another list."
 
   activemodel:
@@ -880,6 +880,7 @@ en:
     email_token_grace_period_hours: "Forgot password / activate account tokens are still valid for a grace period of (n) hours after being redeemed."
 
     enable_badges: "Enable the badge system"
+    enable_whispers: "Allow users to whisper to moderators"
 
     allow_index_in_robots_txt: "Specify in robots.txt that this site is allowed to be indexed by web search engines."
     email_domains_blacklist: "A pipe-delimited list of email domains that users are not allowed to register accounts with. Example: mailinator.com|trashmail.net"
@@ -979,6 +980,9 @@ en:
 
     avatar_sizes: "List of automatically generated avatar sizes."
 
+    external_system_avatars_enabled: "Use external system avatars service."
+    external_system_avatars_url: "URL of the external system avatars service. Allowed substitions are {username} {first_letter} {color} {size}"
+
     enable_flash_video_onebox: "Enable embedding of swf and flv (Adobe Flash) links in oneboxes. WARNING: may introduce security risks."
 
     default_invitee_trust_level: "Default trust level (0-4) for invited users."
@@ -1199,8 +1203,8 @@ en:
     default_email_mailing_list_mode: "Send an email for every new post by default."
     default_email_always: "Send an email notification even when the user is active by default."
 
-    default_other_new_topic_duration_minutes: "Global default number of minutes a topic is considered new."
-    default_other_auto_track_topics_after_msecs: "Global default milliseconds before a topic is automatically tracked."
+    default_other_new_topic_duration_minutes: "Global default condition for which a topic is considered new."
+    default_other_auto_track_topics_after_msecs: "Global default time before a topic is automatically tracked."
     default_other_external_links_in_new_tab: "Open external links in a new tab by default."
     default_other_enable_quoting: "Enable quote reply for highlighted text by default."
     default_other_dynamic_favicon: "Show new/updated topic count on browser icon by default."
@@ -1344,7 +1348,7 @@ en:
       unique: "must be unique"
       blank: "must be present"
       must_begin_with_alphanumeric: "must begin with a letter or number or an underscore"
-      must_end_with_alphanumeric: "must end with a letter or number"
+      must_end_with_alphanumeric: "must end with a letter or number or an underscore"
       must_not_contain_two_special_chars_in_seq: "must not contain a sequence of 2 or more special chars (.-_)"
       must_not_contain_confusing_suffix: "must not contain a confusing suffix like .json or .png etc."
     email:
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 754e067b8..1c8e9f112 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -182,6 +182,9 @@ basic:
   enable_badges:
     client: true
     default: true
+  enable_whispers:
+    client: true
+    default: false
 
 login:
   invite_only:
@@ -418,9 +421,6 @@ posting:
   newuser_max_attachments:
     client: true
     default: 0
-  uncategorized_category_id:
-    default: -1
-    hidden: true
   post_excerpt_maxlength: 300
   display_name_on_posts:
     client: true
@@ -572,6 +572,14 @@ files:
   avatar_sizes:
     default: '20|25|32|45|60|120'
     type: list
+  external_system_avatars_enabled:
+    default: false
+    client: true
+    shadowed_by_global: true
+  external_system_avatars_url:
+    default: "https://avatars.discourse.org/letter/{first_letter}/{color}/{size}.png"
+    client: true
+    regex: '^https?:\/\/.+[^\/]'
 
 trust:
   default_trust_level:
@@ -922,14 +930,15 @@ uncategorized:
   lounge_category_id:
     default: -1
     hidden: true
-
   meta_category_id:
     default: -1
     hidden: true
-
   staff_category_id:
     default: -1
     hidden: true
+  uncategorized_category_id:
+    default: -1
+    hidden: true
 
   performance_report_topic_id:
     default: -1
diff --git a/lib/avatar_lookup.rb b/lib/avatar_lookup.rb
index b1fc1c616..4af184052 100644
--- a/lib/avatar_lookup.rb
+++ b/lib/avatar_lookup.rb
@@ -12,10 +12,7 @@ class AvatarLookup
   private
 
   def self.lookup_columns
-    @lookup_columns ||= [:id,
-                         :email,
-                         :username,
-                         :uploaded_avatar_id]
+    @lookup_columns ||= %i{id email username uploaded_avatar_id}
   end
 
   def users
diff --git a/lib/guardian/post_guardian.rb b/lib/guardian/post_guardian.rb
index e3f1a030b..5b03eff43 100644
--- a/lib/guardian/post_guardian.rb
+++ b/lib/guardian/post_guardian.rb
@@ -144,10 +144,13 @@ module PostGuardian
   end
 
   def can_see_post?(post)
-    post.present? &&
-      (is_admin? ||
-      ((is_moderator? || !post.deleted_at.present?) &&
-        can_see_topic?(post.topic)))
+    return false if post.blank?
+    return true if is_admin?
+    return false unless can_see_topic?(post.topic)
+    return false unless post.user == @user || post.topic.visible_post_types(@user).include?(post.post_type)
+    return false if !is_moderator? && post.deleted_at.present?
+
+    true
   end
 
   def can_view_edit_history?(post)
diff --git a/lib/topic_view.rb b/lib/topic_view.rb
index 54483efd5..52222a6d3 100644
--- a/lib/topic_view.rb
+++ b/lib/topic_view.rb
@@ -191,11 +191,9 @@ class TopicView
 
   # Find the sort order for a post in the topic
   def sort_order_for_post_number(post_number)
-    Post.where(topic_id: @topic.id, post_number: post_number)
-        .with_deleted
-        .select(:sort_order)
-        .first
-        .try(:sort_order)
+    posts = Post.where(topic_id: @topic.id, post_number: post_number).with_deleted
+    posts = filter_post_types(posts)
+    posts.select(:sort_order).first.try(:sort_order)
   end
 
   # Filter to all posts near a particular post number
@@ -332,11 +330,22 @@ class TopicView
 
   private
 
+  def filter_post_types(posts)
+    visible_types = @topic.visible_post_types(@user)
+
+    if @user.present?
+      posts.where("user_id = ? OR post_type IN (?)", @user.id, visible_types)
+    else
+      posts.where(post_type: visible_types)
+    end
+  end
+
   def filter_posts_by_ids(post_ids)
     # TODO: Sort might be off
     @posts = Post.where(id: post_ids, topic_id: @topic.id)
                  .includes(:user, :reply_to_user)
                  .order('sort_order')
+    @posts = filter_post_types(@posts)
     @posts = @posts.with_deleted if @guardian.can_see_deleted_posts?
     @posts
   end
@@ -355,13 +364,13 @@ class TopicView
   end
 
   def find_topic(topic_id)
-    finder = Topic.where(id: topic_id).includes(:category)
-    finder = finder.with_deleted if @guardian.can_see_deleted_topics?
+    # with_deleted covered in #check_and_raise_exceptions
+    finder = Topic.with_deleted.where(id: topic_id).includes(:category)
     finder.first
   end
 
   def unfiltered_posts
-    result = @topic.posts
+    result = filter_post_types(@topic.posts)
     result = result.with_deleted if @guardian.can_see_deleted_posts?
     result = @topic.posts.where("user_id IS NOT NULL") if @exclude_deleted_users
     result
diff --git a/lib/user_name_suggester.rb b/lib/user_name_suggester.rb
index 09e4db698..4819a3f87 100644
--- a/lib/user_name_suggester.rb
+++ b/lib/user_name_suggester.rb
@@ -38,6 +38,7 @@ module UserNameSuggester
     name = name.gsub(/^[^[:alnum:]]+|\W+$/, "")
                .gsub(/\W+/, "_")
                .gsub(/^\_+/, '')
+               .gsub(/[\-_\.]{2,}/, "_")
     name
   end
 
diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb
index 872766649..66430c8f8 100644
--- a/spec/components/guardian_spec.rb
+++ b/spec/components/guardian_spec.rb
@@ -437,6 +437,32 @@ describe Guardian do
         expect(Guardian.new(user).can_see?(post)).to be_falsey
         expect(Guardian.new(admin).can_see?(post)).to be_truthy
       end
+
+      it 'respects whispers' do
+        regular_post = Fabricate.build(:post)
+        whisper_post = Fabricate.build(:post, post_type: Post.types[:whisper])
+
+        anon_guardian = Guardian.new
+        expect(anon_guardian.can_see?(regular_post)).to eq(true)
+        expect(anon_guardian.can_see?(whisper_post)).to eq(false)
+
+        regular_user = Fabricate.build(:user)
+        regular_guardian = Guardian.new(regular_user)
+        expect(regular_guardian.can_see?(regular_post)).to eq(true)
+        expect(regular_guardian.can_see?(whisper_post)).to eq(false)
+
+        # can see your own whispers
+        regular_whisper = Fabricate.build(:post, post_type: Post.types[:whisper], user: regular_user)
+        expect(regular_guardian.can_see?(regular_whisper)).to eq(true)
+
+        mod_guardian = Guardian.new(Fabricate.build(:moderator))
+        expect(mod_guardian.can_see?(regular_post)).to eq(true)
+        expect(mod_guardian.can_see?(whisper_post)).to eq(true)
+
+        admin_guardian = Guardian.new(Fabricate.build(:admin))
+        expect(admin_guardian.can_see?(regular_post)).to eq(true)
+        expect(admin_guardian.can_see?(whisper_post)).to eq(true)
+      end
     end
 
     describe 'a PostRevision' do
diff --git a/spec/components/topic_view_spec.rb b/spec/components/topic_view_spec.rb
index dedc080b9..ad23e0ade 100644
--- a/spec/components/topic_view_spec.rb
+++ b/spec/components/topic_view_spec.rb
@@ -13,6 +13,7 @@ describe TopicView do
     expect { TopicView.new(1231232, coding_horror) }.to raise_error(Discourse::NotFound)
   end
 
+  # see also spec/controllers/topics_controller_spec.rb TopicsController::show::permission errors
   it "raises an error if the user can't see the topic" do
     Guardian.any_instance.expects(:can_see?).with(topic).returns(false)
     expect { topic_view }.to raise_error(Discourse::InvalidAccess)
@@ -21,7 +22,7 @@ describe TopicView do
   it "handles deleted topics" do
     admin = Fabricate(:admin)
     topic.trash!(admin)
-    expect { TopicView.new(topic.id, Fabricate(:user)) }.to raise_error(Discourse::NotFound)
+    expect { TopicView.new(topic.id, Fabricate(:user)) }.to raise_error(Discourse::InvalidAccess)
     expect { TopicView.new(topic.id, admin) }.not_to raise_error
   end
 
@@ -251,6 +252,23 @@ describe TopicView do
 
   end
 
+  context 'whispers' do
+    it "handles their visibility properly" do
+      p1 = Fabricate(:post, topic: topic, user: coding_horror)
+      p2 = Fabricate(:post, topic: topic, user: coding_horror, post_type: Post.types[:whisper])
+      p3 = Fabricate(:post, topic: topic, user: coding_horror)
+
+      ch_posts = TopicView.new(topic.id, coding_horror).posts
+      expect(ch_posts.map(&:id)).to eq([p1.id, p2.id, p3.id])
+
+      anon_posts = TopicView.new(topic.id).posts
+      expect(anon_posts.map(&:id)).to eq([p1.id, p3.id])
+
+      admin_posts = TopicView.new(topic.id, Fabricate(:moderator)).posts
+      expect(admin_posts.map(&:id)).to eq([p1.id, p2.id, p3.id])
+    end
+  end
+
   context '.posts' do
 
     # Create the posts in a different order than the sort_order
diff --git a/spec/components/user_name_suggester_spec.rb b/spec/components/user_name_suggester_spec.rb
index 8f63ba1b2..ec9eb7a46 100644
--- a/spec/components/user_name_suggester_spec.rb
+++ b/spec/components/user_name_suggester_spec.rb
@@ -75,6 +75,11 @@ describe UserNameSuggester do
       expect(UserNameSuggester.suggest("myname.")).to eq('myname')
     end
 
+    it 'handles usernames with a sequence of 2 or more special chars' do
+      expect(UserNameSuggester.suggest('Darth__Vader')).to eq('Darth_Vader')
+      expect(UserNameSuggester.suggest('Darth_-_Vader')).to eq('Darth_Vader')
+    end
+
     it 'should handle typical facebook usernames' do
       expect(UserNameSuggester.suggest('roger.nelson.3344913')).to eq('roger_nelson_33')
     end
diff --git a/spec/controllers/topics_controller_spec.rb b/spec/controllers/topics_controller_spec.rb
index fd927ebac..e87a27f48 100644
--- a/spec/controllers/topics_controller_spec.rb
+++ b/spec/controllers/topics_controller_spec.rb
@@ -1,5 +1,23 @@
 require 'spec_helper'
 
+def topics_controller_show_gen_perm_tests(expected, ctx)
+  expected.each do |sym, status|
+    params = "topic_id: #{sym}.id, slug: #{sym}.slug"
+    if sym == :nonexist
+      params = "topic_id: nonexist_topic_id"
+    end
+    ctx.instance_eval("
+it 'returns #{status} for #{sym}' do
+  begin
+    xhr :get, :show, #{params}
+    expect(response.status).to eq(#{status})
+  rescue Discourse::NotLoggedIn
+    expect(302).to eq(#{status})
+  end
+end")
+  end
+end
+
 describe TopicsController do
 
   context 'wordpress' do
@@ -554,6 +572,108 @@ describe TopicsController do
       end
     end
 
+    context 'permission errors' do
+      let(:allowed_user) { Fabricate(:user) }
+      let(:allowed_group) { Fabricate(:group) }
+      let(:secure_category) {
+        c = Fabricate(:category)
+        c.permissions = [[allowed_group, :full]]
+        c.save
+        allowed_user.groups = [allowed_group]
+        allowed_user.save
+        c }
+      let(:normal_topic) { Fabricate(:topic) }
+      let(:secure_topic) { Fabricate(:topic, category: secure_category) }
+      let(:private_topic) { Fabricate(:private_message_topic, user: allowed_user) }
+      let(:deleted_topic) { Fabricate(:deleted_topic) }
+      let(:nonexist_topic_id) { Topic.last.id + 10000 }
+
+      context 'anonymous' do
+        expected = {
+          :normal_topic => 200,
+          :secure_topic => 403,
+          :private_topic => 302,
+          :deleted_topic => 403,
+          :nonexist => 404
+        }
+        topics_controller_show_gen_perm_tests(expected, self)
+      end
+
+      context 'anonymous with login required' do
+        before do
+          SiteSetting.login_required = true
+        end
+        expected = {
+          :normal_topic => 302,
+          :secure_topic => 302,
+          :private_topic => 302,
+          :deleted_topic => 302,
+          :nonexist => 302
+        }
+        topics_controller_show_gen_perm_tests(expected, self)
+      end
+
+      context 'normal user' do
+        before do
+          log_in(:user)
+        end
+
+        expected = {
+          :normal_topic => 200,
+          :secure_topic => 403,
+          :private_topic => 403,
+          :deleted_topic => 403,
+          :nonexist => 404
+        }
+        topics_controller_show_gen_perm_tests(expected, self)
+      end
+
+      context 'allowed user' do
+        before do
+          log_in_user(allowed_user)
+        end
+
+        expected = {
+          :normal_topic => 200,
+          :secure_topic => 200,
+          :private_topic => 200,
+          :deleted_topic => 403,
+          :nonexist => 404
+        }
+        topics_controller_show_gen_perm_tests(expected, self)
+      end
+
+      context 'moderator' do
+        before do
+          log_in(:moderator)
+        end
+
+        expected = {
+          :normal_topic => 200,
+          :secure_topic => 403,
+          :private_topic => 403,
+          :deleted_topic => 200,
+          :nonexist => 404
+        }
+        topics_controller_show_gen_perm_tests(expected, self)
+      end
+
+      context 'admin' do
+        before do
+          log_in(:admin)
+        end
+
+        expected = {
+          :normal_topic => 200,
+          :secure_topic => 200,
+          :private_topic => 200,
+          :deleted_topic => 200,
+          :nonexist => 404
+        }
+        topics_controller_show_gen_perm_tests(expected, self)
+      end
+    end
+
     it 'records a view' do
       expect { xhr :get, :show, topic_id: topic.id, slug: topic.slug }.to change(TopicViewItem, :count).by(1)
     end
diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb
index 32544497d..884f3f3ff 100644
--- a/spec/controllers/users_controller_spec.rb
+++ b/spec/controllers/users_controller_spec.rb
@@ -1301,7 +1301,7 @@ describe UsersController do
   describe '.pick_avatar' do
 
     it 'raises an error when not logged in' do
-      expect { xhr :put, :pick_avatar, username: 'asdf', avatar_id: 1}.to raise_error(Discourse::NotLoggedIn)
+      expect { xhr :put, :pick_avatar, username: 'asdf', avatar_id: 1, type: "custom"}.to raise_error(Discourse::NotLoggedIn)
     end
 
     context 'while logged in' do
@@ -1310,12 +1310,12 @@ describe UsersController do
 
       it 'raises an error when you don\'t have permission to toggle the avatar' do
         another_user = Fabricate(:user)
-        xhr :put, :pick_avatar, username: another_user.username, upload_id: 1
+        xhr :put, :pick_avatar, username: another_user.username, upload_id: 1, type: "custom"
         expect(response).to be_forbidden
       end
 
       it 'it successful' do
-        xhr :put, :pick_avatar, username: user.username, upload_id: 111
+        xhr :put, :pick_avatar, username: user.username, upload_id: 111, type: "custom"
         expect(user.reload.uploaded_avatar_id).to eq(111)
         expect(user.user_avatar.reload.custom_upload_id).to eq(111)
         expect(response).to be_success
@@ -1326,13 +1326,6 @@ describe UsersController do
         expect(response).to be_success
       end
 
-      it 'returns success' do
-        xhr :put, :pick_avatar, username: user.username, upload_id: 111
-        expect(user.reload.uploaded_avatar_id).to eq(111)
-        expect(response).to be_success
-        json = ::JSON.parse(response.body)
-        expect(json['success']).to eq("OK")
-      end
     end
 
   end
diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb
index 7471c5cd7..b65ec208c 100644
--- a/spec/models/topic_spec.rb
+++ b/spec/models/topic_spec.rb
@@ -11,6 +11,40 @@ describe Topic do
 
   it { is_expected.to rate_limit }
 
+  context '#visible_post_types' do
+    let(:types) { Post.types }
+
+    it "returns the appropriate types for anonymous users" do
+      topic = Fabricate.build(:topic)
+      post_types = topic.visible_post_types
+
+      expect(post_types).to include(types[:regular])
+      expect(post_types).to include(types[:moderator_action])
+      expect(post_types).to include(types[:small_action])
+      expect(post_types).to_not include(types[:whisper])
+    end
+
+    it "returns the appropriate types for regular users" do
+      topic = Fabricate.build(:topic)
+      post_types = topic.visible_post_types(Fabricate.build(:user))
+
+      expect(post_types).to include(types[:regular])
+      expect(post_types).to include(types[:moderator_action])
+      expect(post_types).to include(types[:small_action])
+      expect(post_types).to_not include(types[:whisper])
+    end
+
+    it "returns the appropriate types for staff users" do
+      topic = Fabricate.build(:topic)
+      post_types = topic.visible_post_types(Fabricate.build(:moderator))
+
+      expect(post_types).to include(types[:regular])
+      expect(post_types).to include(types[:moderator_action])
+      expect(post_types).to include(types[:small_action])
+      expect(post_types).to include(types[:whisper])
+    end
+  end
+
   context 'slug' do
     let(:title) { "hello world topic" }
     let(:slug) { "hello-world-topic" }
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 63b3b45b0..701c0de01 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -144,7 +144,6 @@ Spork.prefork do
     FileUtils.cp("#{Rails.root}/spec/fixtures/images/#{filename}", "#{Rails.root}/tmp/spec/#{filename}")
     File.new("#{Rails.root}/tmp/spec/#{filename}")
   end
-
 end
 
 Spork.each_run do
diff --git a/test/javascripts/lib/avatar-template-test.js.es6 b/test/javascripts/lib/avatar-template-test.js.es6
deleted file mode 100644
index c2f004c8d..000000000
--- a/test/javascripts/lib/avatar-template-test.js.es6
+++ /dev/null
@@ -1,16 +0,0 @@
-import avatarTemplate from 'discourse/lib/avatar-template';
-
-module('lib:avatar-template');
-
-test("avatarTemplate", function(){
-  var oldCDN = Discourse.CDN;
-  var oldBase = Discourse.BaseUrl;
-  Discourse.BaseUrl = "frogs.com";
-
-  equal(avatarTemplate("sam", 1), "/user_avatar/frogs.com/sam/{size}/1.png");
-  Discourse.CDN = "http://awesome.cdn.com";
-  equal(avatarTemplate("sam", 1), "http://awesome.cdn.com/user_avatar/frogs.com/sam/{size}/1.png");
-  Discourse.CDN = oldCDN;
-  Discourse.BaseUrl = oldBase;
-});
-