From c3a5ddac8cf835e59b400121df8fc5d3589b3a51 Mon Sep 17 00:00:00 2001
From: Erlend Sogge Heggen <e.soghe@gmail.com>
Date: Thu, 10 Sep 2015 20:43:36 +0200
Subject: [PATCH 01/47] Repurposing CONTRIBUTING.md into a link portal, 2nd
 attempt

- Slight changes to the CLA paragraph, making it slightly easier to digest.
- Added brief synopsis of the Discourse Development Contribution Guidelines doc
- Replaced Bug Report, Feature Request and Contributing (commits) section with outgoing links

The aim of this change is to reduce the maintenance burden, since more detailed information about contribution guidelines is more naturally documented and maintained on Discourse Meta.
---
 CONTRIBUTING.md | 134 ++++++------------------------------------------
 1 file changed, 16 insertions(+), 118 deletions(-)

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f00d5d078..f59bf39dc 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/discourse/discourse](https://github.com/discourse/discourse) project **must read & sign our [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.
 
-## 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!*

From ef787b3828f81bbf100ff02eccac549edb9ded83 Mon Sep 17 00:00:00 2001
From: Erlend Sogge Heggen <e.soghe@gmail.com>
Date: Thu, 10 Sep 2015 20:46:36 +0200
Subject: [PATCH 02/47] GitHub link was missing the .com

The whole point of adding github.com to the link in the first place was to leave no room for misinterpretation.
---
 CONTRIBUTING.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f59bf39dc..4ad0aec09 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,7 +2,7 @@
 
 ## Important note for Developers
 
-Anyone wishing to contribute to the [github/discourse/discourse](https://github.com/discourse/discourse) project **must read & sign our [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 [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.
 
 For more information on
 

From 28cd0361d66269e0164886062f27366a54ab9054 Mon Sep 17 00:00:00 2001
From: Erlend Sogge Heggen <e.soghe@gmail.com>
Date: Thu, 10 Sep 2015 20:49:03 +0200
Subject: [PATCH 03/47] Proper long form for CLA

Seems it's most commonly spelled out as "*Contributor* License Agreement", not *Contribution*.
---
 CONTRIBUTING.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4ad0aec09..98aa05e4a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,7 +2,7 @@
 
 ## Important note for Developers
 
-Anyone wishing to contribute to the [github.com/discourse/discourse](https://github.com/discourse/discourse) project **must read & sign our [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.
 
 For more information on
 

From 20c8bb04943204ce79366961759048a5ced964bd Mon Sep 17 00:00:00 2001
From: scossar <scossar3@gmail.com>
Date: Thu, 10 Sep 2015 11:46:02 -0700
Subject: [PATCH 04/47] remove hardcoded left: auto

---
 app/assets/javascripts/discourse/components/menu-panel.js.es6 | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

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');
     }
 

From b68be6c5deac5e723b4a60f8f17db04f5d8b1671 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9gis=20Hanol?= <regis@hanol.fr>
Date: Thu, 10 Sep 2015 21:56:51 +0200
Subject: [PATCH 05/47] update onebox

---
 Gemfile.lock | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

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

From d86d4752cbb68acad381f0b30a6702f04d8726db Mon Sep 17 00:00:00 2001
From: Kane York <rikingcoding@gmail.com>
Date: Thu, 10 Sep 2015 14:04:21 -0700
Subject: [PATCH 06/47] FIX: Don't allow editing seeded category security
 settings

---
 .../components/edit-category-security.js.es6     | 16 ++++++++++++----
 .../components/edit-category-security.hbs        |  9 ++++++++-
 app/serializers/category_serializer.rb           |  6 ++++++
 config/locales/client.en.yml                     |  1 +
 config/site_settings.yml                         |  8 +++-----
 5 files changed, 30 insertions(+), 10 deletions(-)

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/templates/components/edit-category-security.hbs b/app/assets/javascripts/discourse/templates/components/edit-category-security.hbs
index 02361e3cf..d5468a496 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,9 @@
 <section class='field'>
+  {{#if category.is_special}}
+    <p class="warning">{{i18n 'category.special_warning'}}</p>
+  {{else}}
+
+  {{/if}}
   <ul class='permission-list'>
     {{#each category.permissions as |p|}}
       <li>
@@ -16,6 +21,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/serializers/category_serializer.rb b/app/serializers/category_serializer.rb
index 1bf08f89f..1f8ee5bf8 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,11 @@ class CategorySerializer < BasicCategorySerializer
     true
   end
 
+  def is_special
+    [SiteSetting.lounge_category_id, SiteSetting.meta_category_id, SiteSetting.staff_category_id, SiteSetting.uncategorized_category_id]
+    .include? object.id
+  end
+
   def include_can_delete?
     scope && scope.can_delete?(object)
   end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 7799a8254..bbc1833f0 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -1539,6 +1539,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/site_settings.yml b/config/site_settings.yml
index 754e067b8..c6ec25467 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -418,9 +418,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
@@ -922,14 +919,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

From a9d10f454bf32eaf3c427f3bdb0446987d2e5880 Mon Sep 17 00:00:00 2001
From: Kane York <rikingcoding@gmail.com>
Date: Thu, 10 Sep 2015 14:12:08 -0700
Subject: [PATCH 07/47] Oop

---
 .../discourse/templates/components/edit-category-security.hbs   | 2 --
 1 file changed, 2 deletions(-)

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 d5468a496..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,8 +1,6 @@
 <section class='field'>
   {{#if category.is_special}}
     <p class="warning">{{i18n 'category.special_warning'}}</p>
-  {{else}}
-
   {{/if}}
   <ul class='permission-list'>
     {{#each category.permissions as |p|}}

From cd774657889f38749a23f72d402835c57af8f60e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9gis=20Hanol?= <regis@hanol.fr>
Date: Fri, 11 Sep 2015 00:11:48 +0200
Subject: [PATCH 08/47] FEATURE: SVG letter avatars (based on @eviltrout's
 spike)

---
 .../discourse/lib/avatar-template.js.es6      | 14 +++-------
 app/controllers/user_avatars_controller.rb    | 26 ++++++++++++++++++-
 app/models/user.rb                            |  5 ++--
 config/locales/server.en.yml                  |  2 ++
 config/routes.rb                              |  1 +
 config/site_settings.yml                      |  3 +++
 lib/email/styles.rb                           |  2 --
 lib/letter_avatar.rb                          | 26 +++++++++----------
 8 files changed, 51 insertions(+), 28 deletions(-)

diff --git a/app/assets/javascripts/discourse/lib/avatar-template.js.es6 b/app/assets/javascripts/discourse/lib/avatar-template.js.es6
index 542e97959..15948aba2 100644
--- a/app/assets/javascripts/discourse/lib/avatar-template.js.es6
+++ b/app/assets/javascripts/discourse/lib/avatar-template.js.es6
@@ -4,6 +4,7 @@ let _splitAvatars;
 
 function defaultAvatar(username) {
   const defaultAvatars = Discourse.SiteSettings.default_avatars;
+
   if (defaultAvatars && defaultAvatars.length) {
     _splitAvatars = _splitAvatars || defaultAvatars.split("\n");
 
@@ -13,20 +14,13 @@ function defaultAvatar(username) {
     }
   }
 
-  return Discourse.getURLWithCDN("/letter_avatar/" +
-                                 username.toLowerCase() +
-                                 "/{size}/" +
-                                 Discourse.LetterAvatarVersion + ".png");
+  const extension = Discourse.SiteSettings.svg_letter_avatars ? "svg" : "png";
+  return Discourse.getURLWithCDN(`/letter_avatar/${username.toLowerCase()}/{size}/${Discourse.LetterAvatarVersion}.${extension}`);
 }
 
 export default function(username, uploadedAvatarId) {
   if (uploadedAvatarId) {
-    return Discourse.getURLWithCDN("/user_avatar/" +
-                                   Discourse.BaseUrl +
-                                   "/" +
-                                   username.toLowerCase() +
-                                   "/{size}/" +
-                                   uploadedAvatarId + ".png");
+    return Discourse.getURLWithCDN(`/user_avatar/${Discourse.BaseUrl}/${username.toLowerCase()}/{size}/${uploadedAvatarId}.png`);
   }
   return defaultAvatar(username);
 }
diff --git a/app/controllers/user_avatars_controller.rb b/app/controllers/user_avatars_controller.rb
index ed8f31e4a..f1d6e7d1d 100644
--- a/app/controllers/user_avatars_controller.rb
+++ b/app/controllers/user_avatars_controller.rb
@@ -3,7 +3,7 @@ require_dependency 'letter_avatar'
 class UserAvatarsController < ApplicationController
   DOT = Base64.decode64("R0lGODlhAQABALMAAAAAAIAAAACAAICAAAAAgIAAgACAgMDAwICAgP8AAAD/AP//AAAA//8A/wD//wBiZCH5BAEAAA8ALAAAAAABAAEAAAQC8EUAOw==")
 
-  skip_before_filter :preload_json, :redirect_to_login_if_required, :check_xhr, :verify_authenticity_token, only: [:show, :show_letter]
+  skip_before_filter :preload_json, :redirect_to_login_if_required, :check_xhr, :verify_authenticity_token, only: [:show, :show_letter, :show_letter_svg]
 
   def refresh_gravatar
     user = User.find_by(username_lower: params[:username].downcase)
@@ -19,6 +19,30 @@ class UserAvatarsController < ApplicationController
     end
   end
 
+  def show_letter_svg
+    params.require(:username)
+    params.require(:version)
+    params.require(:size)
+
+    no_cookies
+
+    size = params[:size].to_i
+    username = params[:username]
+
+    identity = LetterAvatar::Identity.from_username(username)
+    color = identity.color
+
+    svg = <<-SVG
+      <svg xmlns="http://www.w3.org/2000/svg" width="#{size}" height="#{size}">
+        <rect width="100%" height="100%" fill="rgb(#{color[0]},#{color[1]},#{color[2]})" />
+        <text font-size="#{size * 0.7}" font-weight="normal" font-family="Helvetica, sans-serif" fill="#FFF" fill-opacity=".8" text-anchor="middle" x="50%" y="75%">#{username[0].capitalize}</text>
+      </svg>
+    SVG
+
+    expires_in 1.year, public: true
+    render inline: svg, content_type: "image/svg+xml"
+  end
+
   def show_letter
     params.require(:username)
     params.require(:version)
diff --git a/app/models/user.rb b/app/models/user.rb
index 2861fff88..cd2344564 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -457,7 +457,7 @@ class User < ActiveRecord::Base
         avatar_template = split_avatars[hash.abs % split_avatars.size]
       end
     else
-      "#{Discourse.base_uri}/letter_avatar/#{username.downcase}/{size}/#{LetterAvatar.version}.png"
+      letter_avatar_template(username)
     end
   end
 
@@ -469,7 +469,8 @@ class User < ActiveRecord::Base
   end
 
   def self.letter_avatar_template(username)
-    "#{Discourse.base_uri}/letter_avatar/#{username.downcase}/{size}/#{LetterAvatar.version}.png"
+    extension = SiteSetting.svg_letter_avatars ? "svg" : "png"
+    "#{Discourse.base_uri}/letter_avatar/#{username.downcase}/{size}/#{LetterAvatar.version}.#{extension}"
   end
 
   def avatar_template
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index fcd190dc8..921edd59e 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -979,6 +979,8 @@ en:
 
     avatar_sizes: "List of automatically generated avatar sizes."
 
+    svg_letter_avatars: "Use SVG for letter avatars"
+
     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."
diff --git a/config/routes.rb b/config/routes.rb
index 66125570f..f5b5aa1b3 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -299,6 +299,7 @@ Discourse::Application.routes.draw do
   get "user-badges/:username" => "user_badges#username", constraints: {username: USERNAME_ROUTE_FORMAT}
 
   post "user_avatar/:username/refresh_gravatar" => "user_avatars#refresh_gravatar", constraints: {username: USERNAME_ROUTE_FORMAT}
+  get "letter_avatar/:username/:size/:version.svg" => "user_avatars#show_letter_svg", format: :svg, constraints: { hostname: /[\w\.-]+/, size: /\d+/, username: USERNAME_ROUTE_FORMAT}
   get "letter_avatar/:username/:size/:version.png" => "user_avatars#show_letter", format: false, constraints: { hostname: /[\w\.-]+/, size: /\d+/, username: USERNAME_ROUTE_FORMAT}
   get "user_avatar/:hostname/:username/:size/:version.png" => "user_avatars#show", format: false, constraints: { hostname: /[\w\.-]+/, size: /\d+/, username: USERNAME_ROUTE_FORMAT }
 
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 754e067b8..0c4950ad8 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -572,6 +572,9 @@ files:
   avatar_sizes:
     default: '20|25|32|45|60|120'
     type: list
+  svg_letter_avatars:
+    default: false
+    client: true
 
 trust:
   default_trust_level:
diff --git a/lib/email/styles.rb b/lib/email/styles.rb
index 22f9ffe1e..e82106b96 100644
--- a/lib/email/styles.rb
+++ b/lib/email/styles.rb
@@ -30,7 +30,6 @@ module Email
 
       # images
       @fragment.css('img').each do |img|
-
         next if img['class'] == 'site-logo'
 
         if img['class'] == "emoji" || img['src'] =~ /plugins\/emoji/
@@ -58,7 +57,6 @@ module Email
 
       # attachments
       @fragment.css('a.attachment').each do |a|
-
         # ensure all urls are absolute
         if a['href'] =~ /^\/[^\/]/
           a['href'] = "#{Discourse.base_url}#{a['href']}"
diff --git a/lib/letter_avatar.rb b/lib/letter_avatar.rb
index abde76ec0..e49ab6c41 100644
--- a/lib/letter_avatar.rb
+++ b/lib/letter_avatar.rb
@@ -7,20 +7,20 @@ class LetterAvatar
   FULLSIZE  = 120 * 3
   POINTSIZE = 280
 
-  class << self
+  class Identity
+    attr_accessor :color, :letter
 
-    class Identity
-      attr_accessor :color, :letter
-
-      def self.from_username(username)
-        identity = new
-        identity.color = LetterAvatar::COLORS[
-          Digest::MD5.hexdigest(username)[0...15].to_i(16) % LetterAvatar::COLORS.length
-        ]
-        identity.letter = username[0].upcase
-        identity
-      end
+    def self.from_username(username)
+      identity = new
+      identity.color = LetterAvatar::COLORS[
+        Digest::MD5.hexdigest(username)[0...15].to_i(16) % LetterAvatar::COLORS.length
+      ]
+      identity.letter = username[0].upcase
+      identity
     end
+  end
+
+  class << self
 
     def version
       "#{VERSION}_#{image_magick_version}"
@@ -32,7 +32,7 @@ class LetterAvatar
 
     def generate(username, size, opts = nil)
       DistributedMutex.synchronize("letter_avatar_#{version}_#{username}") do
-        identity = Identity.from_username(username)
+        identity = LetterAvatar::Identity.from_username(username)
 
         cache = true
         cache = false if opts && opts[:cache] == false

From e43034f08f2961ceddd3801efaca00c904c43d44 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9gis=20Hanol?= <regis@hanol.fr>
Date: Fri, 11 Sep 2015 00:23:52 +0200
Subject: [PATCH 09/47] Revert "FEATURE: SVG letter avatars (based on
 @eviltrout's spike)"

This reverts commit cd774657889f38749a23f72d402835c57af8f60e.
---
 .../discourse/lib/avatar-template.js.es6      | 14 +++++++---
 app/controllers/user_avatars_controller.rb    | 26 +----------------
 app/models/user.rb                            |  5 ++--
 config/locales/server.en.yml                  |  2 --
 config/routes.rb                              |  1 -
 config/site_settings.yml                      |  3 --
 lib/email/styles.rb                           |  2 ++
 lib/letter_avatar.rb                          | 28 +++++++++----------
 8 files changed, 29 insertions(+), 52 deletions(-)

diff --git a/app/assets/javascripts/discourse/lib/avatar-template.js.es6 b/app/assets/javascripts/discourse/lib/avatar-template.js.es6
index 15948aba2..542e97959 100644
--- a/app/assets/javascripts/discourse/lib/avatar-template.js.es6
+++ b/app/assets/javascripts/discourse/lib/avatar-template.js.es6
@@ -4,7 +4,6 @@ let _splitAvatars;
 
 function defaultAvatar(username) {
   const defaultAvatars = Discourse.SiteSettings.default_avatars;
-
   if (defaultAvatars && defaultAvatars.length) {
     _splitAvatars = _splitAvatars || defaultAvatars.split("\n");
 
@@ -14,13 +13,20 @@ function defaultAvatar(username) {
     }
   }
 
-  const extension = Discourse.SiteSettings.svg_letter_avatars ? "svg" : "png";
-  return Discourse.getURLWithCDN(`/letter_avatar/${username.toLowerCase()}/{size}/${Discourse.LetterAvatarVersion}.${extension}`);
+  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 Discourse.getURLWithCDN("/user_avatar/" +
+                                   Discourse.BaseUrl +
+                                   "/" +
+                                   username.toLowerCase() +
+                                   "/{size}/" +
+                                   uploadedAvatarId + ".png");
   }
   return defaultAvatar(username);
 }
diff --git a/app/controllers/user_avatars_controller.rb b/app/controllers/user_avatars_controller.rb
index f1d6e7d1d..ed8f31e4a 100644
--- a/app/controllers/user_avatars_controller.rb
+++ b/app/controllers/user_avatars_controller.rb
@@ -3,7 +3,7 @@ require_dependency 'letter_avatar'
 class UserAvatarsController < ApplicationController
   DOT = Base64.decode64("R0lGODlhAQABALMAAAAAAIAAAACAAICAAAAAgIAAgACAgMDAwICAgP8AAAD/AP//AAAA//8A/wD//wBiZCH5BAEAAA8ALAAAAAABAAEAAAQC8EUAOw==")
 
-  skip_before_filter :preload_json, :redirect_to_login_if_required, :check_xhr, :verify_authenticity_token, only: [:show, :show_letter, :show_letter_svg]
+  skip_before_filter :preload_json, :redirect_to_login_if_required, :check_xhr, :verify_authenticity_token, only: [:show, :show_letter]
 
   def refresh_gravatar
     user = User.find_by(username_lower: params[:username].downcase)
@@ -19,30 +19,6 @@ class UserAvatarsController < ApplicationController
     end
   end
 
-  def show_letter_svg
-    params.require(:username)
-    params.require(:version)
-    params.require(:size)
-
-    no_cookies
-
-    size = params[:size].to_i
-    username = params[:username]
-
-    identity = LetterAvatar::Identity.from_username(username)
-    color = identity.color
-
-    svg = <<-SVG
-      <svg xmlns="http://www.w3.org/2000/svg" width="#{size}" height="#{size}">
-        <rect width="100%" height="100%" fill="rgb(#{color[0]},#{color[1]},#{color[2]})" />
-        <text font-size="#{size * 0.7}" font-weight="normal" font-family="Helvetica, sans-serif" fill="#FFF" fill-opacity=".8" text-anchor="middle" x="50%" y="75%">#{username[0].capitalize}</text>
-      </svg>
-    SVG
-
-    expires_in 1.year, public: true
-    render inline: svg, content_type: "image/svg+xml"
-  end
-
   def show_letter
     params.require(:username)
     params.require(:version)
diff --git a/app/models/user.rb b/app/models/user.rb
index cd2344564..2861fff88 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -457,7 +457,7 @@ class User < ActiveRecord::Base
         avatar_template = split_avatars[hash.abs % split_avatars.size]
       end
     else
-      letter_avatar_template(username)
+      "#{Discourse.base_uri}/letter_avatar/#{username.downcase}/{size}/#{LetterAvatar.version}.png"
     end
   end
 
@@ -469,8 +469,7 @@ class User < ActiveRecord::Base
   end
 
   def self.letter_avatar_template(username)
-    extension = SiteSetting.svg_letter_avatars ? "svg" : "png"
-    "#{Discourse.base_uri}/letter_avatar/#{username.downcase}/{size}/#{LetterAvatar.version}.#{extension}"
+    "#{Discourse.base_uri}/letter_avatar/#{username.downcase}/{size}/#{LetterAvatar.version}.png"
   end
 
   def avatar_template
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 921edd59e..fcd190dc8 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -979,8 +979,6 @@ en:
 
     avatar_sizes: "List of automatically generated avatar sizes."
 
-    svg_letter_avatars: "Use SVG for letter avatars"
-
     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."
diff --git a/config/routes.rb b/config/routes.rb
index f5b5aa1b3..66125570f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -299,7 +299,6 @@ Discourse::Application.routes.draw do
   get "user-badges/:username" => "user_badges#username", constraints: {username: USERNAME_ROUTE_FORMAT}
 
   post "user_avatar/:username/refresh_gravatar" => "user_avatars#refresh_gravatar", constraints: {username: USERNAME_ROUTE_FORMAT}
-  get "letter_avatar/:username/:size/:version.svg" => "user_avatars#show_letter_svg", format: :svg, constraints: { hostname: /[\w\.-]+/, size: /\d+/, username: USERNAME_ROUTE_FORMAT}
   get "letter_avatar/:username/:size/:version.png" => "user_avatars#show_letter", format: false, constraints: { hostname: /[\w\.-]+/, size: /\d+/, username: USERNAME_ROUTE_FORMAT}
   get "user_avatar/:hostname/:username/:size/:version.png" => "user_avatars#show", format: false, constraints: { hostname: /[\w\.-]+/, size: /\d+/, username: USERNAME_ROUTE_FORMAT }
 
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 0c4950ad8..754e067b8 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -572,9 +572,6 @@ files:
   avatar_sizes:
     default: '20|25|32|45|60|120'
     type: list
-  svg_letter_avatars:
-    default: false
-    client: true
 
 trust:
   default_trust_level:
diff --git a/lib/email/styles.rb b/lib/email/styles.rb
index e82106b96..22f9ffe1e 100644
--- a/lib/email/styles.rb
+++ b/lib/email/styles.rb
@@ -30,6 +30,7 @@ module Email
 
       # images
       @fragment.css('img').each do |img|
+
         next if img['class'] == 'site-logo'
 
         if img['class'] == "emoji" || img['src'] =~ /plugins\/emoji/
@@ -57,6 +58,7 @@ module Email
 
       # attachments
       @fragment.css('a.attachment').each do |a|
+
         # ensure all urls are absolute
         if a['href'] =~ /^\/[^\/]/
           a['href'] = "#{Discourse.base_url}#{a['href']}"
diff --git a/lib/letter_avatar.rb b/lib/letter_avatar.rb
index e49ab6c41..abde76ec0 100644
--- a/lib/letter_avatar.rb
+++ b/lib/letter_avatar.rb
@@ -7,21 +7,21 @@ class LetterAvatar
   FULLSIZE  = 120 * 3
   POINTSIZE = 280
 
-  class Identity
-    attr_accessor :color, :letter
-
-    def self.from_username(username)
-      identity = new
-      identity.color = LetterAvatar::COLORS[
-        Digest::MD5.hexdigest(username)[0...15].to_i(16) % LetterAvatar::COLORS.length
-      ]
-      identity.letter = username[0].upcase
-      identity
-    end
-  end
-
   class << self
 
+    class Identity
+      attr_accessor :color, :letter
+
+      def self.from_username(username)
+        identity = new
+        identity.color = LetterAvatar::COLORS[
+          Digest::MD5.hexdigest(username)[0...15].to_i(16) % LetterAvatar::COLORS.length
+        ]
+        identity.letter = username[0].upcase
+        identity
+      end
+    end
+
     def version
       "#{VERSION}_#{image_magick_version}"
     end
@@ -32,7 +32,7 @@ class LetterAvatar
 
     def generate(username, size, opts = nil)
       DistributedMutex.synchronize("letter_avatar_#{version}_#{username}") do
-        identity = LetterAvatar::Identity.from_username(username)
+        identity = Identity.from_username(username)
 
         cache = true
         cache = false if opts && opts[:cache] == false

From 2742602254c177ca014109c5ab60bafa3ccbfa47 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9gis=20Hanol?= <regis@hanol.fr>
Date: Fri, 11 Sep 2015 02:12:40 +0200
Subject: [PATCH 10/47] FEATURE: support for external letter avatars service

---
 .../discourse/helpers/application.js.es6      |   8 +-
 .../discourse/helpers/user-avatar.js.es6      |  11 +-
 .../discourse/lib/avatar-template.js.es6      |  27 +-
 .../discourse/models/user-action.js.es6       |  55 +--
 .../javascripts/discourse/models/user.js.es6  | 336 +++++++-----------
 .../discourse/views/composer.js.es6           |   7 +-
 app/controllers/users_controller.rb           |   2 +-
 app/models/user.rb                            |  23 +-
 app/serializers/basic_post_serializer.rb      |   6 +
 app/serializers/basic_user_serializer.rb      |  10 +-
 app/serializers/post_serializer.rb            |   3 +-
 app/serializers/user_action_serializer.rb     |  12 +-
 app/serializers/user_name_serializer.rb       |  21 +-
 app/serializers/user_serializer.rb            |   1 +
 config/locales/server.en.yml                  |   3 +
 config/site_settings.yml                      |   7 +
 16 files changed, 250 insertions(+), 282 deletions(-)

diff --git a/app/assets/javascripts/discourse/helpers/application.js.es6 b/app/assets/javascripts/discourse/helpers/application.js.es6
index cac2ea1f0..02fa08fa8 100644
--- a/app/assets/javascripts/discourse/helpers/application.js.es6
+++ b/app/assets/javascripts/discourse/helpers/application.js.es6
@@ -9,12 +9,14 @@ Em.Handlebars.helper('bound-avatar', function(user, size, uploadId) {
     return new safe("<div class='avatar-placeholder'></div>");
   }
 
-  const username = Em.get(user, 'username');
+  const username = Em.get(user, 'username'),
+        letterAvatarColor = Em.get(user, 'letter_avatar_color');
+
   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') || avatarTemplate(username, uploadId, letterAvatarColor);
 
   return new safe(Discourse.Utilities.avatarImg({ size: size, avatarTemplate: avatar }));
-}, 'username', 'uploaded_avatar_id', 'avatar_template');
+}, 'username', 'uploaded_avatar_id', 'letter_avatar_color', 'avatar_template');
 
 /*
  * Used when we only have a template
diff --git a/app/assets/javascripts/discourse/helpers/user-avatar.js.es6 b/app/assets/javascripts/discourse/helpers/user-avatar.js.es6
index 1ab668ffc..aea2e9baa 100644
--- a/app/assets/javascripts/discourse/helpers/user-avatar.js.es6
+++ b/app/assets/javascripts/discourse/helpers/user-avatar.js.es6
@@ -5,20 +5,20 @@ function renderAvatar(user, options) {
   options = options || {};
 
   if (user) {
-    var username = Em.get(user, 'username');
+    let username = Em.get(user, 'username');
     if (!username) {
       if (!options.usernamePath) { return ''; }
       username = Em.get(user, options.usernamePath);
     }
 
-    var title;
+    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
@@ -28,13 +28,14 @@ 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');
+    const uploadedAvatarId = Em.get(user, 'uploaded_avatar_id') || Em.get(user, 'user.uploaded_avatar_id'),
+          letterAvatarColor = Em.get(user, 'letter_avatar_color') || Em.get(user, 'user.letter_avatar_color');
 
     return Discourse.Utilities.avatarImg({
       size: options.imageSize,
       extraClasses: Em.get(user, 'extras') || options.extraClasses,
       title: title || username,
-      avatarTemplate: avatarTemplate(username, uploadedAvatarId)
+      avatarTemplate: Em.get("avatar_template") || avatarTemplate(username, uploadedAvatarId, letterAvatarColor)
     });
   } else {
     return '';
diff --git a/app/assets/javascripts/discourse/lib/avatar-template.js.es6 b/app/assets/javascripts/discourse/lib/avatar-template.js.es6
index 542e97959..731a2047d 100644
--- a/app/assets/javascripts/discourse/lib/avatar-template.js.es6
+++ b/app/assets/javascripts/discourse/lib/avatar-template.js.es6
@@ -2,8 +2,10 @@ import { hashString } from 'discourse/lib/hash';
 
 let _splitAvatars;
 
-function defaultAvatar(username) {
-  const defaultAvatars = Discourse.SiteSettings.default_avatars;
+function defaultAvatar(username, letterAvatarColor) {
+  const defaultAvatars = Discourse.SiteSettings.default_avatars,
+        version = Discourse.LetterAvatarVersion;
+
   if (defaultAvatars && defaultAvatars.length) {
     _splitAvatars = _splitAvatars || defaultAvatars.split("\n");
 
@@ -13,20 +15,17 @@ function defaultAvatar(username) {
     }
   }
 
-  return Discourse.getURLWithCDN("/letter_avatar/" +
-                                 username.toLowerCase() +
-                                 "/{size}/" +
-                                 Discourse.LetterAvatarVersion + ".png");
+  if (Discourse.SiteSettings.external_letter_avatars_enabled) {
+    const url = Discourse.SiteSettings.external_letter_avatars_url;
+    return `${url}/letter/${username[0]}?color=${letterAvatarColor}&size={size}`;
+  } else {
+    return Discourse.getURLWithCDN(`/letter_avatar/${username.toLowerCase()}/{size}/${version}.png`);
+  }
 }
 
-export default function(username, uploadedAvatarId) {
+export default function(username, uploadedAvatarId, letterAvatarColor) {
   if (uploadedAvatarId) {
-    return Discourse.getURLWithCDN("/user_avatar/" +
-                                   Discourse.BaseUrl +
-                                   "/" +
-                                   username.toLowerCase() +
-                                   "/{size}/" +
-                                   uploadedAvatarId + ".png");
+    return Discourse.getURLWithCDN(`/user_avatar/${Discourse.BaseUrl}/${username.toLowerCase()}/{size}/${uploadedAvatarId}.png`);
   }
-  return defaultAvatar(username);
+  return defaultAvatar(username, letterAvatarColor);
 }
diff --git a/app/assets/javascripts/discourse/models/user-action.js.es6 b/app/assets/javascripts/discourse/models/user-action.js.es6
index 2d273c43e..05e1e4929 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,23 @@ 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'),
+      letter_avatar_color: this.get('action_letter_avatar_color'),
       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..8fac2812e 100644
--- a/app/assets/javascripts/discourse/models/user.js.es6
+++ b/app/assets/javascripts/discourse/models/user.js.es6
@@ -6,6 +6,7 @@ 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 +19,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 +35,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 +58,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 +83,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 +137,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 +150,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 +170,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 +235,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,30 +250,26 @@ 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'),
+  @computed("username", "uploaded_avatar_id", "letter_avatar_color")
+  avatarTemplate(username, uploadedAvatarId, letterAvatarColor) {
+    return avatarTemplate(username, uploadedAvatarId, letterAvatarColor);
+  },
 
   /*
     Change avatar selection
   */
-  pickAvatar: function(uploadId) {
-    var self = this;
-
-    return Discourse.ajax("/users/" + this.get("username_lower") + "/preferences/avatar/pick", {
+  pickAvatar(uploadId) {
+    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);
-    });
+    }).then(() => this.set('uploaded_avatar_id', uploadId));
   },
 
   /**
@@ -344,7 +279,7 @@ const User = RestModel.extend({
     @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;
@@ -357,35 +292,39 @@ const User = RestModel.extend({
     @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, groupNames) {
     return Discourse.ajax('/invites', {
       type: 'POST',
       data: {email: email, group_names: groupNames}
     });
   },
 
-  generateInviteLink: function(email, groupNames, topicId) {
+  generateInviteLink(email, groupNames, topicId) {
     return Discourse.ajax('/invites/link', {
       type: 'POST',
       data: {email: email, group_names: groupNames, topic_id: topicId}
     });
   },
 
-  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 +337,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 +364,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 +379,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/views/composer.js.es6 b/app/assets/javascripts/discourse/views/composer.js.es6
index d96c6a55a..6964d102a 100644
--- a/app/assets/javascripts/discourse/views/composer.js.es6
+++ b/app/assets/javascripts/discourse/views/composer.js.es6
@@ -252,9 +252,12 @@ const ComposerView = Ember.View.extend(Ember.Evented, {
           const quotedPost = posts.findProperty("post_number", postNumber);
           if (quotedPost) {
             const username = quotedPost.get('username'),
-                  uploadId = quotedPost.get('uploaded_avatar_id');
+                  uploadId = quotedPost.get('uploaded_avatar_id'),
+                  letterAvatarColor = quotedPost.get("letter_avatar_color");
 
-            return Discourse.Utilities.tinyAvatar(avatarTemplate(username, uploadId));
+            debugger;
+
+            return Discourse.Utilities.tinyAvatar(avatarTemplate(username, uploadId, letterAvatarColor));
           }
         }
       }
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index d537c6b7a..cf72afcf6 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -518,7 +518,7 @@ class UsersController < ApplicationController
     user_fields = [:username, :upload_avatar_template, :uploaded_avatar_id]
     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, :letter_avatar_color]) }
 
     if params[:include_groups] == "true"
       to_render[:groups] = Group.search_group(term, current_user).map {|m| {:name=>m.name, :usernames=> m.usernames.split(",")} }
diff --git a/app/models/user.rb b/app/models/user.rb
index 2861fff88..aa46dd7c7 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -457,11 +457,11 @@ class User < ActiveRecord::Base
         avatar_template = split_avatars[hash.abs % split_avatars.size]
       end
     else
-      "#{Discourse.base_uri}/letter_avatar/#{username.downcase}/{size}/#{LetterAvatar.version}.png"
+      letter_avatar_template(username)
     end
   end
 
-  def self.avatar_template(username,uploaded_avatar_id)
+  def self.avatar_template(username, uploaded_avatar_id)
     return default_template(username) if !uploaded_avatar_id
     username ||= ""
     hostname = RailsMultisite::ConnectionManagement.current_hostname
@@ -469,11 +469,26 @@ class User < ActiveRecord::Base
   end
 
   def self.letter_avatar_template(username)
-    "#{Discourse.base_uri}/letter_avatar/#{username.downcase}/{size}/#{LetterAvatar.version}.png"
+    if SiteSetting.external_letter_avatars_enabled
+      color = letter_avatar_color(username)
+      "#{SiteSetting.external_letter_avatars_url}/letter/#{username[0]}?color=#{color}&size={size}"
+    else
+      "#{Discourse.base_uri}/letter_avatar/#{username.downcase}/{size}/#{LetterAvatar.version}.png"
+    end
+  end
+
+  def letter_avatar_color
+    self.class.letter_avatar_color(username)
+  end
+
+  def self.letter_avatar_color(username)
+    username = username || ""
+    color = LetterAvatar::COLORS[Digest::MD5.hexdigest(username)[0...15].to_i(16) % LetterAvatar::COLORS.length]
+    color.map { |c| c.to_s(16) }.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/serializers/basic_post_serializer.rb b/app/serializers/basic_post_serializer.rb
index 04b91ecad..4edb2b5cc 100644
--- a/app/serializers/basic_post_serializer.rb
+++ b/app/serializers/basic_post_serializer.rb
@@ -5,6 +5,7 @@ class BasicPostSerializer < ApplicationSerializer
              :username,
              :avatar_template,
              :uploaded_avatar_id,
+             :letter_avatar_color,
              :created_at,
              :cooked,
              :cooked_hidden
@@ -25,9 +26,14 @@ class BasicPostSerializer < ApplicationSerializer
     object.user.try(:uploaded_avatar_id)
   end
 
+  def letter_avatar_color
+    object.user.try(:letter_avatar_color)
+  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..12ed3f363 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, :uploaded_avatar_id, :avatar_template, :letter_avatar_color
 
   def include_name?
     SiteSetting.enable_names?
@@ -17,4 +17,12 @@ class BasicUserSerializer < ApplicationSerializer
     object[:user] || object
   end
 
+  def letter_avatar_color
+    if Hash === object
+      User.letter_avatar_color(user[:username])
+    else
+      object.letter_avatar_color
+    end
+  end
+
 end
diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb
index 5ba620ca2..9bc469f04 100644
--- a/app/serializers/post_serializer.rb
+++ b/app/serializers/post_serializer.rb
@@ -178,7 +178,8 @@ class PostSerializer < BasicPostSerializer
     {
       username: object.reply_to_user.username,
       avatar_template: object.reply_to_user.avatar_template,
-      uploaded_avatar_id: object.reply_to_user.uploaded_avatar_id
+      uploaded_avatar_id: object.reply_to_user.uploaded_avatar_id,
+      letter_avatar_color: object.reply_to_user.letter_avatar_color,
     }
   end
 
diff --git a/app/serializers/user_action_serializer.rb b/app/serializers/user_action_serializer.rb
index 8b3939963..4bf665004 100644
--- a/app/serializers/user_action_serializer.rb
+++ b/app/serializers/user_action_serializer.rb
@@ -27,9 +27,11 @@ class UserActionSerializer < ApplicationSerializer
              :edit_reason,
              :category_id,
              :uploaded_avatar_id,
+             :letter_avatar_color,
              :closed,
              :archived,
-             :acting_uploaded_avatar_id
+             :acting_uploaded_avatar_id,
+             :acting_letter_avatar_color
 
   def excerpt
     cooked = object.cooked || PrettyText.cook(object.raw)
@@ -84,4 +86,12 @@ class UserActionSerializer < ApplicationSerializer
     object.topic_archived
   end
 
+  def letter_avatar_color
+    User.letter_avatar_color(username)
+  end
+
+  def acting_letter_avatar_color
+    User.letter_avatar_color(acting_username)
+  end
+
 end
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..2d216a515 100644
--- a/app/serializers/user_serializer.rb
+++ b/app/serializers/user_serializer.rb
@@ -323,4 +323,5 @@ class UserSerializer < BasicUserSerializer
   def pending_count
     0
   end
+
 end
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index fcd190dc8..bfab232cf 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -979,6 +979,9 @@ en:
 
     avatar_sizes: "List of automatically generated avatar sizes."
 
+    external_letter_avatars_enabled: "Use external letter avatars service."
+    external_letter_avatars_url: "URL of the external letter avatars service."
+
     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."
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 754e067b8..add19d2db 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -572,6 +572,13 @@ files:
   avatar_sizes:
     default: '20|25|32|45|60|120'
     type: list
+  external_letter_avatars_enabled:
+    default: false
+    client: true
+  external_letter_avatars_url:
+    default: "https://avatars.discourse.org"
+    client: true
+    regex: '^https?:\/\/.+[^\/]$'
 
 trust:
   default_trust_level:

From f6380c66efedeceb646fe0fb2b4507ac4f0d4d0d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9gis=20Hanol?= <regis@hanol.fr>
Date: Fri, 11 Sep 2015 02:15:45 +0200
Subject: [PATCH 11/47] oooops

---
 app/assets/javascripts/discourse/views/composer.js.es6 | 2 --
 1 file changed, 2 deletions(-)

diff --git a/app/assets/javascripts/discourse/views/composer.js.es6 b/app/assets/javascripts/discourse/views/composer.js.es6
index 6964d102a..63a8675ec 100644
--- a/app/assets/javascripts/discourse/views/composer.js.es6
+++ b/app/assets/javascripts/discourse/views/composer.js.es6
@@ -255,8 +255,6 @@ const ComposerView = Ember.View.extend(Ember.Evented, {
                   uploadId = quotedPost.get('uploaded_avatar_id'),
                   letterAvatarColor = quotedPost.get("letter_avatar_color");
 
-            debugger;
-
             return Discourse.Utilities.tinyAvatar(avatarTemplate(username, uploadId, letterAvatarColor));
           }
         }

From 90d49d1497ce0b4969d6e624e0e54dfae4fa759e Mon Sep 17 00:00:00 2001
From: Sam <sam.saffron@gmail.com>
Date: Fri, 11 Sep 2015 13:18:07 +1000
Subject: [PATCH 12/47] correct paths used for external service

---
 app/assets/javascripts/discourse/lib/avatar-template.js.es6 | 2 +-
 app/models/user.rb                                          | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/app/assets/javascripts/discourse/lib/avatar-template.js.es6 b/app/assets/javascripts/discourse/lib/avatar-template.js.es6
index 731a2047d..4d2fdb2bf 100644
--- a/app/assets/javascripts/discourse/lib/avatar-template.js.es6
+++ b/app/assets/javascripts/discourse/lib/avatar-template.js.es6
@@ -17,7 +17,7 @@ function defaultAvatar(username, letterAvatarColor) {
 
   if (Discourse.SiteSettings.external_letter_avatars_enabled) {
     const url = Discourse.SiteSettings.external_letter_avatars_url;
-    return `${url}/letter/${username[0]}?color=${letterAvatarColor}&size={size}`;
+    return `${url}/letter/${username[0]}/${letterAvatarColor}/{size}.png`;
   } else {
     return Discourse.getURLWithCDN(`/letter_avatar/${username.toLowerCase()}/{size}/${version}.png`);
   }
diff --git a/app/models/user.rb b/app/models/user.rb
index aa46dd7c7..75d732ea6 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -454,7 +454,7 @@ 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
       letter_avatar_template(username)
@@ -471,7 +471,7 @@ class User < ActiveRecord::Base
   def self.letter_avatar_template(username)
     if SiteSetting.external_letter_avatars_enabled
       color = letter_avatar_color(username)
-      "#{SiteSetting.external_letter_avatars_url}/letter/#{username[0]}?color=#{color}&size={size}"
+      "#{SiteSetting.external_letter_avatars_url}/letter/#{username[0]}/#{color}/{size}.png"
     else
       "#{Discourse.base_uri}/letter_avatar/#{username.downcase}/{size}/#{LetterAvatar.version}.png"
     end

From 98e8b16c34f1453772eaf3411561ee5c9ad82d40 Mon Sep 17 00:00:00 2001
From: Guo Xiang Tan <tgx_world@hotmail.com>
Date: Fri, 11 Sep 2015 11:54:08 +0800
Subject: [PATCH 13/47] FIX: Broken BasicUserSerializer.

---
 app/serializers/basic_user_serializer.rb | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/app/serializers/basic_user_serializer.rb b/app/serializers/basic_user_serializer.rb
index 12ed3f363..2c72eb342 100644
--- a/app/serializers/basic_user_serializer.rb
+++ b/app/serializers/basic_user_serializer.rb
@@ -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
 
@@ -21,7 +21,7 @@ class BasicUserSerializer < ApplicationSerializer
     if Hash === object
       User.letter_avatar_color(user[:username])
     else
-      object.letter_avatar_color
+      user.try(:letter_avatar_color)
     end
   end
 

From d73d4d476984d6de647423d3869cfdb1d72c427a Mon Sep 17 00:00:00 2001
From: Arpit Jalan <arpit@techapj.com>
Date: Fri, 11 Sep 2015 16:53:26 +0530
Subject: [PATCH 14/47] FIX: UserNameSuggester should not suggest usernames
 with a sequence of 2 or more special chars

---
 config/locales/server.en.yml                | 2 +-
 lib/user_name_suggester.rb                  | 1 +
 spec/components/user_name_suggester_spec.rb | 5 +++++
 3 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index bfab232cf..735f21496 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -1347,7 +1347,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/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/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

From 8ca2ab1b3b25a6bc1ebcdc4e7e02d1333043b766 Mon Sep 17 00:00:00 2001
From: ismail-arilik <arilik.ismail@gmail.com>
Date: Fri, 11 Sep 2015 15:02:12 +0300
Subject: [PATCH 15/47] Update some strings to meet referred options lists

The options which changed strings are referred, were changed to lists so these strings were supposed to be generalized.
---
 config/locales/server.en.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 735f21496..7dd33c874 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -1202,8 +1202,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."

From 6437cd03413a346976efef3e0a11a5eba0e2cf9c Mon Sep 17 00:00:00 2001
From: Sam <sam.saffron@gmail.com>
Date: Fri, 11 Sep 2015 18:14:34 +1000
Subject: [PATCH 16/47] FEATURE: add support for generic external avatar
 services

This changes it so we only ship an avatar template down to the client
it has no magic, all it knows is how to plug in size
---
 .../discourse/components/who-liked.js.es6     |  2 +-
 .../discourse/helpers/application.js.es6      | 11 ++-----
 .../discourse/helpers/user-avatar.js.es6      | 17 ++++------
 .../discourse/lib/avatar-template.js.es6      | 31 -------------------
 .../discourse/models/composer.js.es6          |  4 +--
 .../discourse/models/user-action.js.es6       |  2 --
 .../javascripts/discourse/models/user.js.es6  |  6 ----
 .../templates/components/stream-item.hbs      |  2 +-
 .../templates/list/posters-column.raw.hbs     |  2 +-
 .../discourse/views/composer.js.es6           |  7 +----
 app/assets/javascripts/main_include.js        |  2 --
 app/controllers/users_controller.rb           |  2 +-
 app/models/user.rb                            | 15 ++++++---
 app/serializers/basic_post_serializer.rb      | 10 ------
 app/serializers/basic_user_serializer.rb      | 10 +-----
 app/serializers/post_serializer.rb            |  4 +--
 app/serializers/user_action_serializer.rb     | 14 +--------
 config/locales/server.en.yml                  |  4 +--
 config/site_settings.yml                      |  8 ++---
 19 files changed, 35 insertions(+), 118 deletions(-)
 delete mode 100644 app/assets/javascripts/discourse/lib/avatar-template.js.es6

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/helpers/application.js.es6 b/app/assets/javascripts/discourse/helpers/application.js.es6
index 02fa08fa8..5c72c6fc8 100644
--- a/app/assets/javascripts/discourse/helpers/application.js.es6
+++ b/app/assets/javascripts/discourse/helpers/application.js.es6
@@ -1,22 +1,17 @@
 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', function(user, size) {
   if (Em.isEmpty(user)) {
     return new safe("<div class='avatar-placeholder'></div>");
   }
 
-  const username = Em.get(user, 'username'),
-        letterAvatarColor = Em.get(user, 'letter_avatar_color');
-
-  if (arguments.length < 4) { uploadId = Em.get(user, 'uploaded_avatar_id'); }
-  const avatar = Em.get(user, 'avatar_template') || avatarTemplate(username, uploadId, letterAvatarColor);
+  const avatar = Em.get(user, 'avatar_template');
 
   return new safe(Discourse.Utilities.avatarImg({ size: size, avatarTemplate: avatar }));
-}, 'username', 'uploaded_avatar_id', 'letter_avatar_color', 'avatar_template');
+}, 'username', 'avatar_template');
 
 /*
  * Used when we only have a template
diff --git a/app/assets/javascripts/discourse/helpers/user-avatar.js.es6 b/app/assets/javascripts/discourse/helpers/user-avatar.js.es6
index aea2e9baa..c5eac31ad 100644
--- a/app/assets/javascripts/discourse/helpers/user-avatar.js.es6
+++ b/app/assets/javascripts/discourse/helpers/user-avatar.js.es6
@@ -1,15 +1,14 @@
 import registerUnbound from 'discourse/helpers/register-unbound';
-import avatarTemplate from 'discourse/lib/avatar-template';
 
 function renderAvatar(user, options) {
   options = options || {};
 
   if (user) {
-    let username = Em.get(user, 'username');
-    if (!username) {
-      if (!options.usernamePath) { return ''; }
-      username = Em.get(user, options.usernamePath);
-    }
+
+    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) {
@@ -27,15 +26,11 @@ function renderAvatar(user, options) {
       }
     }
 
-    // this is simply done to ensure we cache images correctly
-    const uploadedAvatarId = Em.get(user, 'uploaded_avatar_id') || Em.get(user, 'user.uploaded_avatar_id'),
-          letterAvatarColor = Em.get(user, 'letter_avatar_color') || Em.get(user, 'user.letter_avatar_color');
-
     return Discourse.Utilities.avatarImg({
       size: options.imageSize,
       extraClasses: Em.get(user, 'extras') || options.extraClasses,
       title: title || username,
-      avatarTemplate: Em.get("avatar_template") || avatarTemplate(username, uploadedAvatarId, letterAvatarColor)
+      avatarTemplate: avatarTemplate
     });
   } else {
     return '';
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 4d2fdb2bf..000000000
--- a/app/assets/javascripts/discourse/lib/avatar-template.js.es6
+++ /dev/null
@@ -1,31 +0,0 @@
-import { hashString } from 'discourse/lib/hash';
-
-let _splitAvatars;
-
-function defaultAvatar(username, letterAvatarColor) {
-  const defaultAvatars = Discourse.SiteSettings.default_avatars,
-        version = Discourse.LetterAvatarVersion;
-
-  if (defaultAvatars && defaultAvatars.length) {
-    _splitAvatars = _splitAvatars || defaultAvatars.split("\n");
-
-    if (_splitAvatars.length) {
-      const hash = hashString(username);
-      return _splitAvatars[Math.abs(hash) % _splitAvatars.length];
-    }
-  }
-
-  if (Discourse.SiteSettings.external_letter_avatars_enabled) {
-    const url = Discourse.SiteSettings.external_letter_avatars_url;
-    return `${url}/letter/${username[0]}/${letterAvatarColor}/{size}.png`;
-  } else {
-    return Discourse.getURLWithCDN(`/letter_avatar/${username.toLowerCase()}/{size}/${version}.png`);
-  }
-}
-
-export default function(username, uploadedAvatarId, letterAvatarColor) {
-  if (uploadedAvatarId) {
-    return Discourse.getURLWithCDN(`/user_avatar/${Discourse.BaseUrl}/${username.toLowerCase()}/{size}/${uploadedAvatarId}.png`);
-  }
-  return defaultAvatar(username, letterAvatarColor);
-}
diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6
index 12cba4936..9cb5db069 100644
--- a/app/assets/javascripts/discourse/models/composer.js.es6
+++ b/app/assets/javascripts/discourse/models/composer.js.es6
@@ -567,7 +567,7 @@ 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'),
       actions_summary: [],
@@ -587,7 +587,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/user-action.js.es6 b/app/assets/javascripts/discourse/models/user-action.js.es6
index 05e1e4929..f03d81908 100644
--- a/app/assets/javascripts/discourse/models/user-action.js.es6
+++ b/app/assets/javascripts/discourse/models/user-action.js.es6
@@ -154,8 +154,6 @@ const UserAction = RestModel.extend({
   switchToActing() {
     this.setProperties({
       username: this.get('acting_username'),
-      uploaded_avatar_id: this.get('acting_uploaded_avatar_id'),
-      letter_avatar_color: this.get('action_letter_avatar_color'),
       name: this.get('actingDisplayName')
     });
   }
diff --git a/app/assets/javascripts/discourse/models/user.js.es6 b/app/assets/javascripts/discourse/models/user.js.es6
index 8fac2812e..4fab93f2d 100644
--- a/app/assets/javascripts/discourse/models/user.js.es6
+++ b/app/assets/javascripts/discourse/models/user.js.es6
@@ -1,6 +1,5 @@
 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';
@@ -257,11 +256,6 @@ const User = RestModel.extend({
     });
   },
 
-  @computed("username", "uploaded_avatar_id", "letter_avatar_color")
-  avatarTemplate(username, uploadedAvatarId, letterAvatarColor) {
-    return avatarTemplate(username, uploadedAvatarId, letterAvatarColor);
-  },
-
   /*
     Change avatar selection
   */
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/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/views/composer.js.es6 b/app/assets/javascripts/discourse/views/composer.js.es6
index 63a8675ec..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,11 +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'),
-                  letterAvatarColor = quotedPost.get("letter_avatar_color");
-
-            return Discourse.Utilities.tinyAvatar(avatarTemplate(username, uploadId, letterAvatarColor));
+            return Discourse.Utilities.tinyAvatar(quotedPost.get('avatar_template'));
           }
         }
       }
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/controllers/users_controller.rb b/app/controllers/users_controller.rb
index cf72afcf6..22334cc9b 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -518,7 +518,7 @@ class UsersController < ApplicationController
     user_fields = [:username, :upload_avatar_template, :uploaded_avatar_id]
     user_fields << :name if SiteSetting.enable_names?
 
-    to_render = { users: results.as_json(only: user_fields, methods: [:avatar_template, :letter_avatar_color]) }
+    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(",")} }
diff --git a/app/models/user.rb b/app/models/user.rb
index 75d732ea6..4c8e1d9aa 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -457,7 +457,7 @@ class User < ActiveRecord::Base
         split_avatars[hash.abs % split_avatars.size]
       end
     else
-      letter_avatar_template(username)
+      system_avatar_template(username)
     end
   end
 
@@ -468,10 +468,15 @@ class User < ActiveRecord::Base
     UserAvatar.local_avatar_template(hostname, username.downcase, uploaded_avatar_id)
   end
 
-  def self.letter_avatar_template(username)
-    if SiteSetting.external_letter_avatars_enabled
+  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
       color = letter_avatar_color(username)
-      "#{SiteSetting.external_letter_avatars_url}/letter/#{username[0]}/#{color}/{size}.png"
+      url = SiteSetting.external_system_avatars_url.dup
+      url.gsub! "{color}", color
+      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
@@ -484,7 +489,7 @@ class User < ActiveRecord::Base
   def self.letter_avatar_color(username)
     username = username || ""
     color = LetterAvatar::COLORS[Digest::MD5.hexdigest(username)[0...15].to_i(16) % LetterAvatar::COLORS.length]
-    color.map { |c| c.to_s(16) }.join
+    color.map { |c| c.to_s(16).rjust(2, '0') }.join
   end
 
   def avatar_template
diff --git a/app/serializers/basic_post_serializer.rb b/app/serializers/basic_post_serializer.rb
index 4edb2b5cc..8969d19a0 100644
--- a/app/serializers/basic_post_serializer.rb
+++ b/app/serializers/basic_post_serializer.rb
@@ -4,8 +4,6 @@ class BasicPostSerializer < ApplicationSerializer
              :name,
              :username,
              :avatar_template,
-             :uploaded_avatar_id,
-             :letter_avatar_color,
              :created_at,
              :cooked,
              :cooked_hidden
@@ -22,14 +20,6 @@ class BasicPostSerializer < ApplicationSerializer
     object.user.try(:avatar_template)
   end
 
-  def uploaded_avatar_id
-    object.user.try(:uploaded_avatar_id)
-  end
-
-  def letter_avatar_color
-    object.user.try(:letter_avatar_color)
-  end
-
   def cooked_hidden
     object.hidden && !scope.is_staff?
   end
diff --git a/app/serializers/basic_user_serializer.rb b/app/serializers/basic_user_serializer.rb
index 2c72eb342..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, :letter_avatar_color
+  attributes :id, :username, :avatar_template
 
   def include_name?
     SiteSetting.enable_names?
@@ -17,12 +17,4 @@ class BasicUserSerializer < ApplicationSerializer
     object[:user] || object
   end
 
-  def letter_avatar_color
-    if Hash === object
-      User.letter_avatar_color(user[:username])
-    else
-      user.try(:letter_avatar_color)
-    end
-  end
-
 end
diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb
index 9bc469f04..a10f9dbf6 100644
--- a/app/serializers/post_serializer.rb
+++ b/app/serializers/post_serializer.rb
@@ -177,9 +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,
-      letter_avatar_color: object.reply_to_user.letter_avatar_color,
+      avatar_template: object.reply_to_user.avatar_template
     }
   end
 
diff --git a/app/serializers/user_action_serializer.rb b/app/serializers/user_action_serializer.rb
index 4bf665004..d9609ff9b 100644
--- a/app/serializers/user_action_serializer.rb
+++ b/app/serializers/user_action_serializer.rb
@@ -26,12 +26,8 @@ class UserActionSerializer < ApplicationSerializer
              :action_code,
              :edit_reason,
              :category_id,
-             :uploaded_avatar_id,
-             :letter_avatar_color,
              :closed,
-             :archived,
-             :acting_uploaded_avatar_id,
-             :acting_letter_avatar_color
+             :archived
 
   def excerpt
     cooked = object.cooked || PrettyText.cook(object.raw)
@@ -86,12 +82,4 @@ class UserActionSerializer < ApplicationSerializer
     object.topic_archived
   end
 
-  def letter_avatar_color
-    User.letter_avatar_color(username)
-  end
-
-  def acting_letter_avatar_color
-    User.letter_avatar_color(acting_username)
-  end
-
 end
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 7dd33c874..1309a124e 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -979,8 +979,8 @@ en:
 
     avatar_sizes: "List of automatically generated avatar sizes."
 
-    external_letter_avatars_enabled: "Use external letter avatars service."
-    external_letter_avatars_url: "URL of the external letter avatars service."
+    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."
 
diff --git a/config/site_settings.yml b/config/site_settings.yml
index add19d2db..5c96a0385 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -572,13 +572,13 @@ files:
   avatar_sizes:
     default: '20|25|32|45|60|120'
     type: list
-  external_letter_avatars_enabled:
+  external_system_avatars_enabled:
     default: false
     client: true
-  external_letter_avatars_url:
-    default: "https://avatars.discourse.org"
+  external_system_avatars_url:
+    default: "https://avatars.discourse.org/letter/{first_letter}/{color}/{size}.png"
     client: true
-    regex: '^https?:\/\/.+[^\/]$'
+    regex: '^https?:\/\/.+[^\/]'
 
 trust:
   default_trust_level:

From 0c58f08207677eae93aad0ead17ef5a91d3dfb63 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9gis=20Hanol?= <regis@hanol.fr>
Date: Fri, 11 Sep 2015 12:56:34 +0200
Subject: [PATCH 17/47] FIX: profile picture selector

---
 .../components/avatar-uploader.js.es6         | 17 +++++---
 .../controllers/avatar-selector.js.es6        | 41 +++++++++++-------
 .../discourse/helpers/application.js.es6      | 15 +++----
 .../javascripts/discourse/models/user.js.es6  | 34 +++++----------
 .../discourse/routes/preferences.js.es6       | 43 ++++++++++---------
 .../templates/modal/avatar_selector.hbs       | 21 ++++-----
 .../discourse/views/avatar-selector.js.es6    | 12 ++++--
 app/controllers/user_avatars_controller.rb    |  5 ++-
 app/models/user.rb                            |  7 +--
 app/serializers/user_serializer.rb            | 22 ++++++++++
 .../lib/avatar-template-test.js.es6           | 16 -------
 11 files changed, 116 insertions(+), 117 deletions(-)
 delete mode 100644 test/javascripts/lib/avatar-template-test.js.es6

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/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/helpers/application.js.es6 b/app/assets/javascripts/discourse/helpers/application.js.es6
index 5c72c6fc8..2f4ea292c 100644
--- a/app/assets/javascripts/discourse/helpers/application.js.es6
+++ b/app/assets/javascripts/discourse/helpers/application.js.es6
@@ -3,32 +3,27 @@ import { longDate, autoUpdatingRelativeAge, number } from 'discourse/lib/formatt
 
 const safe = Handlebars.SafeString;
 
-Em.Handlebars.helper('bound-avatar', function(user, size) {
+Em.Handlebars.helper('bound-avatar', (user, size) => {
   if (Em.isEmpty(user)) {
     return new safe("<div class='avatar-placeholder'></div>");
   }
 
   const avatar = Em.get(user, 'avatar_template');
-
   return new safe(Discourse.Utilities.avatarImg({ size: size, avatarTemplate: avatar }));
 }, '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/models/user.js.es6 b/app/assets/javascripts/discourse/models/user.js.es6
index 4fab93f2d..48b84011b 100644
--- a/app/assets/javascripts/discourse/models/user.js.es6
+++ b/app/assets/javascripts/discourse/models/user.js.es6
@@ -256,47 +256,33 @@ const User = RestModel.extend({
     });
   },
 
-  /*
-    Change avatar selection
-  */
-  pickAvatar(uploadId) {
+  pickAvatar(upload_id, avatar_template) {
     return Discourse.ajax(`/users/${this.get("username_lower")}/preferences/avatar/pick`, {
       type: 'PUT',
-      data: { upload_id: uploadId }
-    }).then(() => this.set('uploaded_avatar_id', uploadId));
+      data: { upload_id }
+    }).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(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(email, groupNames) {
+  createInvite(email, group_names) {
     return Discourse.ajax('/invites', {
       type: 'POST',
-      data: {email: email, group_names: groupNames}
+      data: { email, group_names }
     });
   },
 
-  generateInviteLink(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 }
     });
   },
 
diff --git a/app/assets/javascripts/discourse/routes/preferences.js.es6 b/app/assets/javascripts/discourse/routes/preferences.js.es6
index d748689f4..e1f9d597d 100644
--- a/app/assets/javascripts/discourse/routes/preferences.js.es6
+++ b/app/assets/javascripts/discourse/routes/preferences.js.es6
@@ -18,50 +18,51 @@ 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");
 
-      // 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, 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/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/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/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/models/user.rb b/app/models/user.rb
index 4c8e1d9aa..b00ac21b3 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -471,9 +471,8 @@ class User < ActiveRecord::Base
   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
-      color = letter_avatar_color(username)
       url = SiteSetting.external_system_avatars_url.dup
-      url.gsub! "{color}", color
+      url.gsub! "{color}", letter_avatar_color(username)
       url.gsub! "{username}", username
       url.gsub! "{first_letter}", username[0].downcase
       url
@@ -482,10 +481,6 @@ class User < ActiveRecord::Base
     end
   end
 
-  def letter_avatar_color
-    self.class.letter_avatar_color(username)
-  end
-
   def self.letter_avatar_color(username)
     username = username || ""
     color = LetterAvatar::COLORS[Digest::MD5.hexdigest(username)[0...15].to_i(16) % LetterAvatar::COLORS.length]
diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb
index 2d216a515..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
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;
-});
-

From 569f2815d1cff5c9bd749cb4c67cc22424eea0e7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9gis=20Hanol?= <regis@hanol.fr>
Date: Fri, 11 Sep 2015 14:44:14 +0200
Subject: [PATCH 18/47] FIX: ensure we still works with cookies off

---
 app/assets/javascripts/discourse/lib/key-value-store.js.es6 | 1 +
 1 file changed, 1 insertion(+)

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);
   },
 

From 93f9dcfcec9fbbdef247076e3930243843cb0943 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9gis=20Hanol?= <regis@hanol.fr>
Date: Fri, 11 Sep 2015 15:04:29 +0200
Subject: [PATCH 19/47] FIX: don't overwrite custom uploaded avatar when
 selecting gravatar FIX: remove unecessary serialized fields

---
 app/assets/javascripts/discourse/models/user.js.es6 |  4 ++--
 .../javascripts/discourse/routes/preferences.js.es6 |  7 +++++--
 app/controllers/users_controller.rb                 | 13 ++++++-------
 app/models/upload.rb                                |  2 +-
 app/models/user.rb                                  |  4 ++--
 app/models/user_avatar.rb                           |  6 ++----
 app/serializers/admin_post_serializer.rb            |  6 +-----
 app/serializers/post_action_user_serializer.rb      |  4 ----
 app/serializers/topic_post_count_serializer.rb      |  4 ----
 lib/avatar_lookup.rb                                |  5 +----
 10 files changed, 20 insertions(+), 35 deletions(-)

diff --git a/app/assets/javascripts/discourse/models/user.js.es6 b/app/assets/javascripts/discourse/models/user.js.es6
index 48b84011b..bd0e36c5f 100644
--- a/app/assets/javascripts/discourse/models/user.js.es6
+++ b/app/assets/javascripts/discourse/models/user.js.es6
@@ -256,10 +256,10 @@ const User = RestModel.extend({
     });
   },
 
-  pickAvatar(upload_id, avatar_template) {
+  pickAvatar(upload_id, type, avatar_template) {
     return Discourse.ajax(`/users/${this.get("username_lower")}/preferences/avatar/pick`, {
       type: 'PUT',
-      data: { upload_id }
+      data: { upload_id, type }
     }).then(() => this.setProperties({
       avatar_template,
       uploaded_avatar_id: upload_id
diff --git a/app/assets/javascripts/discourse/routes/preferences.js.es6 b/app/assets/javascripts/discourse/routes/preferences.js.es6
index e1f9d597d..8bea9b460 100644
--- a/app/assets/javascripts/discourse/routes/preferences.js.es6
+++ b/app/assets/javascripts/discourse/routes/preferences.js.es6
@@ -49,9 +49,12 @@ export default RestrictedUserRoute.extend({
       const user = this.modelFor('user'),
             controller = this.controllerFor('avatar-selector'),
             selectedUploadId = controller.get("selectedUploadId"),
-            selectedAvatarTemplate = controller.get("selectedAvatarTemplate");
+            selectedAvatarTemplate = controller.get("selectedAvatarTemplate"),
+            type = controller.get("selected");
 
-      user.pickAvatar(selectedUploadId, selectedAvatarTemplate)
+      if (type === "uploaded") { type = "custom" }
+
+      user.pickAvatar(selectedUploadId, type, selectedAvatarTemplate)
           .then(() => {
             user.setProperties(controller.getProperties(
               'system_avatar_template',
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 22334cc9b..ab1104e8d 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]) }
 
     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
@@ -533,12 +533,11 @@ class UsersController < ApplicationController
 
     upload_id = params[:upload_id]
 
-    user.uploaded_avatar_id = upload_id
+    type = params[:type]
+    type = "custom" if type == "uploaded"
 
-    # ensure we associate the custom avatar properly
-    if upload_id && user.user_avatar.custom_upload_id != upload_id
-      user.user_avatar.custom_upload_id = upload_id
-    end
+    user.uploaded_avatar_id = upload_id
+    user.user_avatar.send("#{type}_upload_id=", upload_id)
 
     user.save!
     user.user_avatar.save!
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 b00ac21b3..a17e4e9ba 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -462,8 +462,8 @@ class User < ActiveRecord::Base
   end
 
   def self.avatar_template(username, uploaded_avatar_id)
-    return default_template(username) if !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
@@ -482,7 +482,7 @@ class User < ActiveRecord::Base
   end
 
   def self.letter_avatar_color(username)
-    username = username || ""
+    username ||= ""
     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
diff --git a/app/models/user_avatar.rb b/app/models/user_avatar.rb
index f61df736b..dd17fd6e3 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)
   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)
   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/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/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/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

From a28df555181b75a1fd2ac8e6f1e8dcf6d974e7b4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9gis=20Hanol?= <regis@hanol.fr>
Date: Fri, 11 Sep 2015 15:06:17 +0200
Subject: [PATCH 20/47] fix the build

---
 app/assets/javascripts/discourse/routes/preferences.js.es6 | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/app/assets/javascripts/discourse/routes/preferences.js.es6 b/app/assets/javascripts/discourse/routes/preferences.js.es6
index 8bea9b460..fd9bba95d 100644
--- a/app/assets/javascripts/discourse/routes/preferences.js.es6
+++ b/app/assets/javascripts/discourse/routes/preferences.js.es6
@@ -49,9 +49,9 @@ export default RestrictedUserRoute.extend({
       const user = this.modelFor('user'),
             controller = this.controllerFor('avatar-selector'),
             selectedUploadId = controller.get("selectedUploadId"),
-            selectedAvatarTemplate = controller.get("selectedAvatarTemplate"),
-            type = controller.get("selected");
+            selectedAvatarTemplate = controller.get("selectedAvatarTemplate");
 
+      let type = controller.get("selected");
       if (type === "uploaded") { type = "custom" }
 
       user.pickAvatar(selectedUploadId, type, selectedAvatarTemplate)

From 8128abe6b91b2d8f6496384ac6f32d0d3e1c6701 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9gis=20Hanol?= <regis@hanol.fr>
Date: Fri, 11 Sep 2015 15:10:38 +0200
Subject: [PATCH 21/47] ES6ify user preferences controller

---
 .../discourse/controllers/preferences.js.es6  | 67 ++++++++++---------
 1 file changed, 35 insertions(+), 32 deletions(-)

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")
           });

From 29f25dbf6e9bdfa17e41a3a660cac68e695c1759 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9gis=20Hanol?= <regis@hanol.fr>
Date: Fri, 11 Sep 2015 15:18:17 +0200
Subject: [PATCH 22/47] fix the build

---
 app/models/user_avatar.rb | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/app/models/user_avatar.rb b/app/models/user_avatar.rb
index dd17fd6e3..8d57472c6 100644
--- a/app/models/user_avatar.rb
+++ b/app/models/user_avatar.rb
@@ -39,7 +39,7 @@ class UserAvatar < ActiveRecord::Base
   end
 
   def self.local_avatar_url(hostname, username, upload_id, size)
-    self.local_avatar_template(hostname, username, upload_id).gsub("{size}", size)
+    self.local_avatar_template(hostname, username, upload_id).gsub("{size}", size.to_s)
   end
 
   def self.local_avatar_template(hostname, username, upload_id)
@@ -48,7 +48,7 @@ class UserAvatar < ActiveRecord::Base
   end
 
   def self.external_avatar_url(user_id, upload_id, size)
-    self.external_avatar_template(user_id, upload_id).gsub("{size}", size)
+    self.external_avatar_template(user_id, upload_id).gsub("{size}", size.to_s)
   end
 
   def self.external_avatar_template(user_id, upload_id)

From 18d7c1c75d7f346b68a63ee1dd8943a674a3a719 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9gis=20Hanol?= <regis@hanol.fr>
Date: Fri, 11 Sep 2015 15:47:48 +0200
Subject: [PATCH 23/47] fix the build - take 2

---
 .../javascripts/discourse/routes/preferences.js.es6 |  6 ++----
 app/controllers/users_controller.rb                 | 11 +++++++----
 spec/controllers/users_controller_spec.rb           | 13 +++----------
 3 files changed, 12 insertions(+), 18 deletions(-)

diff --git a/app/assets/javascripts/discourse/routes/preferences.js.es6 b/app/assets/javascripts/discourse/routes/preferences.js.es6
index fd9bba95d..3ae6aed00 100644
--- a/app/assets/javascripts/discourse/routes/preferences.js.es6
+++ b/app/assets/javascripts/discourse/routes/preferences.js.es6
@@ -49,10 +49,8 @@ export default RestrictedUserRoute.extend({
       const user = this.modelFor('user'),
             controller = this.controllerFor('avatar-selector'),
             selectedUploadId = controller.get("selectedUploadId"),
-            selectedAvatarTemplate = controller.get("selectedAvatarTemplate");
-
-      let type = controller.get("selected");
-      if (type === "uploaded") { type = "custom" }
+            selectedAvatarTemplate = controller.get("selectedAvatarTemplate"),
+            type = controller.get("selected");
 
       user.pickAvatar(selectedUploadId, type, selectedAvatarTemplate)
           .then(() => {
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index ab1104e8d..bb361de6f 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -531,13 +531,16 @@ class UsersController < ApplicationController
     user = fetch_user_from_params
     guardian.ensure_can_edit!(user)
 
+    type = params[:type]
     upload_id = params[:upload_id]
 
-    type = params[:type]
-    type = "custom" if type == "uploaded"
-
     user.uploaded_avatar_id = upload_id
-    user.user_avatar.send("#{type}_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!
     user.user_avatar.save!
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

From 460243d7a319781f1742ccb08c859943098910a5 Mon Sep 17 00:00:00 2001
From: Kane York <rikingcoding@gmail.com>
Date: Fri, 11 Sep 2015 08:29:44 -0700
Subject: [PATCH 24/47] FIX: Give 403 for deleted topics, +lots of tests

---
 lib/topic_view.rb                          |   4 +-
 spec/controllers/topics_controller_spec.rb | 120 +++++++++++++++++++++
 spec/spec_helper.rb                        |   1 -
 3 files changed, 122 insertions(+), 3 deletions(-)

diff --git a/lib/topic_view.rb b/lib/topic_view.rb
index 54483efd5..333e155b4 100644
--- a/lib/topic_view.rb
+++ b/lib/topic_view.rb
@@ -355,8 +355,8 @@ 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
 
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/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

From 4b43edee91e0007c7f8b8d0baadcaea9cf3210e1 Mon Sep 17 00:00:00 2001
From: Neil Lalonde <neillalonde@gmail.com>
Date: Thu, 10 Sep 2015 18:17:00 -0400
Subject: [PATCH 25/47] UX: mobile topic list and suggested topics: show
 new/unread counts OR total post count, not both.

---
 .../templates/list/post-count-or-badges.raw.hbs      |  5 +++++
 .../templates/mobile/components/basic-topic-list.hbs | 12 ++++++------
 .../templates/mobile/list/topic_list_item.raw.hbs    |  7 ++-----
 .../discourse/views/list/post-count-or-badges.js.es6 |  6 ++++++
 app/assets/stylesheets/mobile/topic-list.scss        |  2 +-
 5 files changed, 20 insertions(+), 12 deletions(-)
 create mode 100644 app/assets/javascripts/discourse/templates/list/post-count-or-badges.raw.hbs
 create mode 100644 app/assets/javascripts/discourse/views/list/post-count-or-badges.js.es6

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/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/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..3c968935e
--- /dev/null
+++ b/app/assets/javascripts/discourse/views/list/post-count-or-badges.js.es6
@@ -0,0 +1,6 @@
+import computed from "ember-addons/ember-computed-decorators";
+
+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/stylesheets/mobile/topic-list.scss b/app/assets/stylesheets/mobile/topic-list.scss
index 6922da71c..886e35487 100644
--- a/app/assets/stylesheets/mobile/topic-list.scss
+++ b/app/assets/stylesheets/mobile/topic-list.scss
@@ -357,7 +357,7 @@ td .main-link {
     padding: 5px 10px 5px 0;
   }
 }
-.topic-list-item {
+.topic-list {
   .posts-map {
     font-size: 1.071em;
     padding-top: 2px;

From 637b110e76fb72b9983ffc2b34423674d8a0081f Mon Sep 17 00:00:00 2001
From: Neil Lalonde <neillalonde@gmail.com>
Date: Fri, 11 Sep 2015 11:38:25 -0400
Subject: [PATCH 26/47] use setter in topic-tracking-state or else get
 assertion error

---
 .../javascripts/discourse/models/topic-tracking-state.js.es6  | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

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);
         }
       }
     }

From 7e50af75473658e83a00697f85e2e855c960646a Mon Sep 17 00:00:00 2001
From: Kane York <rikingcoding@gmail.com>
Date: Fri, 11 Sep 2015 09:10:08 -0700
Subject: [PATCH 27/47] Temporarily lock eslint to 1.3.1

---
 .travis.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

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

From b706c59ab9d12a7b776327bebc8ad40a882cc490 Mon Sep 17 00:00:00 2001
From: Kane York <rikingcoding@gmail.com>
Date: Fri, 11 Sep 2015 09:14:45 -0700
Subject: [PATCH 28/47] Use the json boolean trick

---
 app/serializers/category_serializer.rb | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/app/serializers/category_serializer.rb b/app/serializers/category_serializer.rb
index 1f8ee5bf8..6138f1d1b 100644
--- a/app/serializers/category_serializer.rb
+++ b/app/serializers/category_serializer.rb
@@ -38,11 +38,15 @@ class CategorySerializer < BasicCategorySerializer
     true
   end
 
-  def is_special
+  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

From 3c9a818a2f81d3e1a4076970913119a86e78fa5b Mon Sep 17 00:00:00 2001
From: Neil Lalonde <neillalonde@gmail.com>
Date: Fri, 11 Sep 2015 12:22:34 -0400
Subject: [PATCH 29/47] fix broken js tests

---
 .../discourse/views/list/post-count-or-badges.js.es6            | 2 --
 1 file changed, 2 deletions(-)

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
index 3c968935e..9a8559f1a 100644
--- 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
@@ -1,5 +1,3 @@
-import computed from "ember-addons/ember-computed-decorators";
-
 export default Ember.Object.extend({
   postCountsPresent: Ember.computed.or('topic.unread', 'topic.displayNewPosts', 'topic.unseen'),
   showBadges: Ember.computed.and('postBadgesEnabled', 'postCountsPresent')

From 6119d9fdc0d22a1b077c7be8db30b6d5c12a408f Mon Sep 17 00:00:00 2001
From: Kane York <rikingcoding@gmail.com>
Date: Fri, 11 Sep 2015 09:25:03 -0700
Subject: [PATCH 30/47] FIX: Fallbacks for missing interpolation arguments

This takes effect when an interpolation is removed from a translation in
a Discourse update.

The I18n::Backend::Fallbacks loops with a catch(:exception), so calling
throw(:exception) will cause it to use the next locale, until it reaches
English which is assumed to be correct.

Also, enable fallbacks in everything except development (#3724 for more
discussion) - we should be able to test this
---
 config/initializers/i18n.rb | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

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

From 62cc029886051aeed6774849b13416bd87243773 Mon Sep 17 00:00:00 2001
From: Robin Ward <robin.ward@gmail.com>
Date: Fri, 11 Sep 2015 12:44:37 -0400
Subject: [PATCH 31/47] FIX: Support using enter to change the hash, clicks
 should update hash

---
 .../discourse/initializers/click-interceptor.js.es6            | 2 ++
 app/assets/javascripts/discourse/lib/url.js.es6                | 3 ++-
 2 files changed, 4 insertions(+), 1 deletion(-)

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/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;
     }
 

From 5af0f5f80ea5f03c75a02e18a40d56e4e4bad47c Mon Sep 17 00:00:00 2001
From: Robin Ward <robin.ward@gmail.com>
Date: Thu, 10 Sep 2015 16:01:23 -0400
Subject: [PATCH 32/47] FEATURE: Whisper posts

---
 .../discourse/controllers/composer.js.es6     |  7 +++-
 .../discourse/models/composer.js.es6          |  6 +++-
 .../javascripts/discourse/models/post.js.es6  |  3 +-
 .../discourse/templates/composer.hbs          | 10 ++++++
 .../javascripts/discourse/templates/post.hbs  | 13 ++++---
 .../javascripts/discourse/views/post.js.es6   | 12 ++++++-
 .../stylesheets/common/base/topic-post.scss   |  2 +-
 .../stylesheets/desktop/topic-post.scss       |  9 +++++
 app/controllers/posts_controller.rb           |  4 +++
 app/models/post.rb                            | 27 ++++++++++-----
 app/models/topic.rb                           |  7 ++++
 config/locales/client.en.yml                  |  2 ++
 config/locales/server.en.yml                  |  1 +
 config/site_settings.yml                      |  3 ++
 lib/guardian/post_guardian.rb                 | 11 +++---
 lib/topic_view.rb                             | 21 ++++++++----
 spec/components/guardian_spec.rb              | 26 ++++++++++++++
 spec/components/topic_view_spec.rb            | 17 ++++++++++
 spec/models/topic_spec.rb                     | 34 +++++++++++++++++++
 19 files changed, 186 insertions(+), 29 deletions(-)

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/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6
index 9cb5db069..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,
@@ -569,7 +573,7 @@ const Composer = RestModel.extend({
       user_title: user.get('title'),
       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'),
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/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/post.hbs b/app/assets/javascripts/discourse/templates/post.hbs
index 29b95d5aa..8beccb2b6 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 "user-secret"}}</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/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/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss
index 3b0d62008..d11b4ac3a 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;
diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss
index 41409878f..84730decc 100644
--- a/app/assets/stylesheets/desktop/topic-post.scss
+++ b/app/assets/stylesheets/desktop/topic-post.scss
@@ -582,6 +582,15 @@ a.mention {
   }
 }
 
+.whisper {
+  .topic-body {
+    .cooked {
+      font-style: italic;
+      color: dark-light-diff($primary, $secondary, 55%, -40%);
+    }
+  }
+}
+
 #share-link {
   width: 365px;
   margin-left: -4px;
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/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/config/locales/client.en.yml b/config/locales/client.en.yml
index bbc1833f0..881c30a57 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -809,6 +809,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"
@@ -1349,6 +1350,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"
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 1309a124e..1da6daf5b 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -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"
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 70992a968..fb6adceed 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:
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 333e155b4..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
@@ -361,7 +370,7 @@ class TopicView
   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/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..fe2658d97 100644
--- a/spec/components/topic_view_spec.rb
+++ b/spec/components/topic_view_spec.rb
@@ -251,6 +251,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/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" }

From b6febb0638b1d8232c0941bd94f07c4f0ef30ae5 Mon Sep 17 00:00:00 2001
From: Kane York <rikingcoding@gmail.com>
Date: Fri, 11 Sep 2015 11:37:36 -0700
Subject: [PATCH 33/47] fix the build (460243d7)

---
 spec/components/topic_view_spec.rb | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/spec/components/topic_view_spec.rb b/spec/components/topic_view_spec.rb
index fe2658d97..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
 

From e5ade5a7611ac0279dbcf781b0c552a73db1e6e6 Mon Sep 17 00:00:00 2001
From: scossar <scossar3@gmail.com>
Date: Fri, 11 Sep 2015 11:28:18 -0700
Subject: [PATCH 34/47] set widths on table cells

---
 app/assets/stylesheets/mobile/topic-list.scss | 23 +++++++++++++++++++
 1 file changed, 23 insertions(+)

diff --git a/app/assets/stylesheets/mobile/topic-list.scss b/app/assets/stylesheets/mobile/topic-list.scss
index 886e35487..41dd47b8b 100644
--- a/app/assets/stylesheets/mobile/topic-list.scss
+++ b/app/assets/stylesheets/mobile/topic-list.scss
@@ -110,6 +110,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;

From 3b46ec7ae3dee62960149c79c498f5987d8dc6bf Mon Sep 17 00:00:00 2001
From: Neil Lalonde <neillalonde@gmail.com>
Date: Fri, 11 Sep 2015 16:34:27 -0400
Subject: [PATCH 35/47] visual tweaks for topic lists on mobile

---
 app/assets/stylesheets/mobile/topic-list.scss | 14 ++++++++++++--
 1 file changed, 12 insertions(+), 2 deletions(-)

diff --git a/app/assets/stylesheets/mobile/topic-list.scss b/app/assets/stylesheets/mobile/topic-list.scss
index 41dd47b8b..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%));
     }
   }

From 4252a2ee1e78daff1267e47a4caebe3386d6cd25 Mon Sep 17 00:00:00 2001
From: Jeff Atwood <jatwood@codinghorror.com>
Date: Fri, 11 Sep 2015 16:53:20 -0700
Subject: [PATCH 36/47] switch to eye-slash on whisper, similar to unlisted

---
 app/assets/javascripts/discourse/templates/post.hbs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/assets/javascripts/discourse/templates/post.hbs b/app/assets/javascripts/discourse/templates/post.hbs
index 8beccb2b6..ff990eb66 100644
--- a/app/assets/javascripts/discourse/templates/post.hbs
+++ b/app/assets/javascripts/discourse/templates/post.hbs
@@ -56,7 +56,7 @@
         {{/if}}
 
         {{#if view.whisper}}
-          <div class="post-info whisper" title={{i18n 'post.whisper'}}>{{fa-icon "user-secret"}}</div>
+          <div class="post-info whisper" title={{i18n 'post.whisper'}}>{{fa-icon "eye-slash"}}</div>
         {{/if}}
 
         {{#if showUserReplyTab}}

From 1e739e8c96393aedf9cbccf4d1c487b27ce5a139 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9gis=20Hanol?= <regis@hanol.fr>
Date: Sat, 12 Sep 2015 20:44:20 +0200
Subject: [PATCH 37/47] FIX: move whisper styling to common

---
 app/assets/stylesheets/common/base/topic-post.scss | 9 +++++++++
 app/assets/stylesheets/desktop/topic-post.scss     | 9 ---------
 2 files changed, 9 insertions(+), 9 deletions(-)

diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss
index d11b4ac3a..dab4f08f1 100644
--- a/app/assets/stylesheets/common/base/topic-post.scss
+++ b/app/assets/stylesheets/common/base/topic-post.scss
@@ -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/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss
index 84730decc..41409878f 100644
--- a/app/assets/stylesheets/desktop/topic-post.scss
+++ b/app/assets/stylesheets/desktop/topic-post.scss
@@ -582,15 +582,6 @@ a.mention {
   }
 }
 
-.whisper {
-  .topic-body {
-    .cooked {
-      font-style: italic;
-      color: dark-light-diff($primary, $secondary, 55%, -40%);
-    }
-  }
-}
-
 #share-link {
   width: 365px;
   margin-left: -4px;

From 1e6bf67b5bc8078ec2db47fe24560198c1b0280f Mon Sep 17 00:00:00 2001
From: Arpit Jalan <arpit@techapj.com>
Date: Sat, 12 Sep 2015 23:58:18 +0530
Subject: [PATCH 38/47] FIX: show category links if category has sub-categories
 in nojs view

---
 app/views/list/list.erb | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

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>

From b4974f5876d53969e7b523ac4592cf4fe58fb34c Mon Sep 17 00:00:00 2001
From: Guo Xiang Tan <tgx_world@hotmail.com>
Date: Sat, 12 Sep 2015 13:38:20 +0800
Subject: [PATCH 39/47] UX: Don't allow search if searchTerm is not valid.

---
 .../discourse/components/search-menu.js.es6            |  9 ++++-----
 .../discourse/controllers/full-page-search.js.es6      | 10 ++++++++--
 app/assets/javascripts/discourse/lib/search.js.es6     | 10 +++++++++-
 .../discourse/routes/full-page-search.js.es6           |  4 ++--
 .../discourse/templates/full-page-search.hbs           |  2 +-
 5 files changed, 24 insertions(+), 11 deletions(-)

diff --git a/app/assets/javascripts/discourse/components/search-menu.js.es6 b/app/assets/javascripts/discourse/components/search-menu.js.es6
index 8a4f2ddec..153929ecd 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 {
@@ -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/controllers/full-page-search.js.es6 b/app/assets/javascripts/discourse/controllers/full-page-search.js.es6
index fce0fecd8..2e909601f 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';
@@ -37,7 +37,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 +134,7 @@ export default Ember.Controller.extend({
     },
 
     search() {
+      if (this.get("isNotValidSearchTerm")) return;
       this.search();
     }
   }
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/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/templates/full-page-search.hbs b/app/assets/javascripts/discourse/templates/full-page-search.hbs
index db60849ff..43e3e835a 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"}}
+  {{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"}}

From 2ae0ef0ad92a585c2c28b8d79f39b620506a2551 Mon Sep 17 00:00:00 2001
From: Guo Xiang Tan <tgx_world@hotmail.com>
Date: Sat, 12 Sep 2015 14:06:46 +0800
Subject: [PATCH 40/47] UX: Select search term when focus returns to search
 box.

---
 .../javascripts/discourse/components/search-menu.js.es6       | 2 +-
 .../javascripts/discourse/components/search-text-field.js.es6 | 4 ++++
 .../javascripts/discourse/templates/full-page-search.hbs      | 2 +-
 3 files changed, 6 insertions(+), 2 deletions(-)

diff --git a/app/assets/javascripts/discourse/components/search-menu.js.es6 b/app/assets/javascripts/discourse/components/search-menu.js.es6
index 153929ecd..413525280 100644
--- a/app/assets/javascripts/discourse/components/search-menu.js.es6
+++ b/app/assets/javascripts/discourse/components/search-menu.js.es6
@@ -134,7 +134,7 @@ export default Ember.Component.extend({
     },
 
     showedSearch() {
-      $('#search-term').focus();
+      $('#search-term').focus().select();
     },
 
     showSearchHelp() {
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..bb83304bc 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,9 @@ export default TextField.extend({
   @computed('searchService.searchContextEnabled')
   placeholder: function(searchContextEnabled) {
     return searchContextEnabled ? "" : I18n.t('search.title');
+  },
+
+  focusIn: function() {
+    Em.run.later(() => { this.$().select(); });
   }
 });
diff --git a/app/assets/javascripts/discourse/templates/full-page-search.hbs b/app/assets/javascripts/discourse/templates/full-page-search.hbs
index 43e3e835a..3b9bebb96 100644
--- a/app/assets/javascripts/discourse/templates/full-page-search.hbs
+++ b/app/assets/javascripts/discourse/templates/full-page-search.hbs
@@ -1,5 +1,5 @@
 <div class="search row clearfix">
-  {{input type="text" value=searchTerm class="input-xxlarge search no-blur" action="search"}}
+  {{search-text-field value=searchTerm class="input-xxlarge search no-blur" action="search"}}
   {{d-button action="search" icon="search" class="btn-primary" disabled=isNotValidSearchTerm}}
   {{#if canBulkSelect}}
     {{#if model.posts}}

From c1deee772b5eddf262f0be1ea5ffcb5f09b13018 Mon Sep 17 00:00:00 2001
From: Guo Xiang Tan <tgx_world@hotmail.com>
Date: Sun, 13 Sep 2015 09:41:05 +0800
Subject: [PATCH 41/47] UX: Autofocus when input is empty.

---
 .../discourse/components/search-text-field.js.es6           | 6 +++++-
 .../discourse/controllers/full-page-search.js.es6           | 5 +++++
 .../javascripts/discourse/templates/full-page-search.hbs    | 2 +-
 3 files changed, 11 insertions(+), 2 deletions(-)

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 bb83304bc..80dcc0ba2 100644
--- a/app/assets/javascripts/discourse/components/search-text-field.js.es6
+++ b/app/assets/javascripts/discourse/components/search-text-field.js.es6
@@ -9,5 +9,9 @@ export default TextField.extend({
 
   focusIn: function() {
     Em.run.later(() => { this.$().select(); });
-  }
+  },
+
+  becomeFocused: function() {
+    if (this.get('hasAutofocus')) this.$().focus();
+  }.on('didInsertElement')
 });
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 2e909601f..f4fb1e787 100644
--- a/app/assets/javascripts/discourse/controllers/full-page-search.js.es6
+++ b/app/assets/javascripts/discourse/controllers/full-page-search.js.es6
@@ -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){
diff --git a/app/assets/javascripts/discourse/templates/full-page-search.hbs b/app/assets/javascripts/discourse/templates/full-page-search.hbs
index 3b9bebb96..8f1874345 100644
--- a/app/assets/javascripts/discourse/templates/full-page-search.hbs
+++ b/app/assets/javascripts/discourse/templates/full-page-search.hbs
@@ -1,5 +1,5 @@
 <div class="search row clearfix">
-  {{search-text-field value=searchTerm class="input-xxlarge search no-blur" action="search"}}
+  {{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}}

From 63e96580c4e438bf91a1bdc1a86e47875db95782 Mon Sep 17 00:00:00 2001
From: Jeff Atwood <jatwood@codinghorror.com>
Date: Sun, 13 Sep 2015 03:18:44 -0700
Subject: [PATCH 42/47] correct minor default embedding style issues

---
 app/assets/stylesheets/embed.css.scss | 10 ++--------
 1 file changed, 2 insertions(+), 8 deletions(-)

diff --git a/app/assets/stylesheets/embed.css.scss b/app/assets/stylesheets/embed.css.scss
index 40eab2875..ef9347c60 100644
--- a/app/assets/stylesheets/embed.css.scss
+++ b/app/assets/stylesheets/embed.css.scss
@@ -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;
-}

From 9ba22b5155598c0e3ed97db9cacdd43c94687ef0 Mon Sep 17 00:00:00 2001
From: Jeff Atwood <jatwood@codinghorror.com>
Date: Sun, 13 Sep 2015 04:02:58 -0700
Subject: [PATCH 43/47] minor embedding css fix

---
 app/assets/stylesheets/embed.css.scss | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/app/assets/stylesheets/embed.css.scss b/app/assets/stylesheets/embed.css.scss
index ef9347c60..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; }
   }

From 08dccaa874182e82a0235c10b14d8121c4d5cc34 Mon Sep 17 00:00:00 2001
From: Guo Xiang Tan <tgx_world@hotmail.com>
Date: Mon, 14 Sep 2015 00:30:39 +0800
Subject: [PATCH 44/47] FIX: Title input validation position.

---
 app/assets/stylesheets/desktop/compose.scss | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

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;

From 6891c7f8aa09309bae0277c0d87530856273bbe4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Garc=C3=ADa-Navas=20L=C3=B3pez=20de=20Cu=C3=A9llar?=
 <davidgarcianavas@gmail.com>
Date: Sun, 13 Sep 2015 22:45:26 +0200
Subject: [PATCH 45/47] little typo?

---
 config/locales/server.en.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 1da6daf5b..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:

From f948ee9e2618793d0ec0dab48562d9593213d4aa Mon Sep 17 00:00:00 2001
From: Sam <sam.saffron@gmail.com>
Date: Mon, 14 Sep 2015 09:42:21 +1000
Subject: [PATCH 46/47] FIX: ensure letter avatar service uses same algorithm

---
 app/models/user.rb | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/models/user.rb b/app/models/user.rb
index a17e4e9ba..cd4792ea8 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -472,7 +472,7 @@ class User < ActiveRecord::Base
     # 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)
+      url.gsub! "{color}", letter_avatar_color(username.downcase)
       url.gsub! "{username}", username
       url.gsub! "{first_letter}", username[0].downcase
       url

From d39faf7ddfa85db9cac8c596d2118f8aa8217c50 Mon Sep 17 00:00:00 2001
From: Sam <sam.saffron@gmail.com>
Date: Mon, 14 Sep 2015 09:43:40 +1000
Subject: [PATCH 47/47] FEATURE: allow external avatars to be shadowed by
 global

---
 config/site_settings.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/config/site_settings.yml b/config/site_settings.yml
index fb6adceed..1c8e9f112 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -575,6 +575,7 @@ files:
   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