From c3a5ddac8cf835e59b400121df8fc5d3589b3a51 Mon Sep 17 00:00:00 2001 From: Erlend Sogge Heggen 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 f00d5d078e2..f59bf39dcb3 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:/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 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 f59bf39dcb3..4ad0aec0999 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 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 4ad0aec0999..98aa05e4acc 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 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 8a5b2ea2756..02304afee7f 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?= 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 44397f9df5c..e33b7a10e39 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 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 593a604e7ad..60c3f4ddc1a 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 02361e3cf7b..d5468a49663 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 @@
+ {{#if category.is_special}} +

{{i18n 'category.special_warning'}}

+ {{else}} + + {{/if}}
    {{#each category.permissions as |p|}}
  • @@ -16,6 +21,8 @@ {{view 'select' class="permission-selector" optionValuePath="content.id" optionLabelPath="content.description" content=category.availablePermissions value=selectedPermission}} {{else}} - + {{#unless category.is_special}} + + {{/unless}} {{/if}}
diff --git a/app/serializers/category_serializer.rb b/app/serializers/category_serializer.rb index 1bf08f89fcb..1f8ee5bf882 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 7799a825451..bbc1833f034 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 754e067b827..c6ec2546701 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 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 d5468a49663..1b323792a9e 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 @@
{{#if category.is_special}}

{{i18n 'category.special_warning'}}

- {{else}} - {{/if}}
    {{#each category.permissions as |p|}} From cd774657889f38749a23f72d402835c57af8f60e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= 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 542e9795961..15948aba2a6 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 ed8f31e4af0..f1d6e7d1db5 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 + + + #{username[0].capitalize} + + 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 2861fff881a..cd23445641f 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 fcd190dc8e4..921edd59ed4 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 66125570f49..f5b5aa1b3db 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 754e067b827..0c4950ad843 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 22f9ffe1e89..e82106b96d1 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 abde76ec080..e49ab6c417a 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?= 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 15948aba2a6..542e9795961 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 f1d6e7d1db5..ed8f31e4af0 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 - - - #{username[0].capitalize} - - 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 cd23445641f..2861fff881a 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 921edd59ed4..fcd190dc8e4 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 f5b5aa1b3db..66125570f49 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 0c4950ad843..754e067b827 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 e82106b96d1..22f9ffe1e89 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 e49ab6c417a..abde76ec080 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?= 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 cac2ea1f0ad..02fa08fa84f 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("
    "); } - 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 1ab668ffc03..aea2e9baaef 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 542e9795961..731a2047d89 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 2d273c43e16..05e1e4929cd 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 01aef870afa..8fac2812ed3 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 d96c6a55a45..6964d102a52 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 d537c6b7a8f..cf72afcf6fe 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 2861fff881a..aa46dd7c77f 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 04b91ecad48..4edb2b5cc5d 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 8911291f369..12ed3f36397 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 5ba620ca27a..9bc469f047d 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 8b393996392..4bf6650048b 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 ac7beaa8d2a..3d7fc0d1f87 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 89545399028..2d216a51502 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 fcd190dc8e4..bfab232cf1d 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 754e067b827..add19d2db07 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?= 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 6964d102a52..63a8675ec86 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 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 731a2047d89..4d2fdb2bf74 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 aa46dd7c77f..75d732ea679 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 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 12ed3f36397..2c72eb34221 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 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 bfab232cf1d..735f2149612 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 09e4db698ba..4819a3f879b 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 8f63ba1b2e8..ec9eb7a46b7 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 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 735f2149612..7dd33c874a2 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 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 34ba672236f..5c12a91d94b 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 += ""; iconsHtml += Discourse.Utilities.avatarImg({ size: 'small', - avatarTemplate: u.get('avatarTemplate'), + avatarTemplate: u.get('avatar_template'), title: u.get('username') }); iconsHtml += ""; diff --git a/app/assets/javascripts/discourse/helpers/application.js.es6 b/app/assets/javascripts/discourse/helpers/application.js.es6 index 02fa08fa84f..5c72c6fc8bd 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("
    "); } - 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 aea2e9baaef..c5eac31ad61 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 4d2fdb2bf74..00000000000 --- 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 12cba4936a7..9cb5db0693d 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 05e1e4929cd..f03d8190834 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 8fac2812ed3..4fab93f2de8 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 c84082519bd..22900bbb276 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"}} {{else}} -
    {{avatar grandChild imageSize="tiny" extraClasses="actor" ignoreTitle="true"}}
    +
    {{avatar grandChild imageSize="tiny" extraClasses="actor" ignoreTitle="true" avatarTemplatePath="acting_avatar_template"}}
    {{#if grandChild.edit_reason}} — {{grandChild.edit_reason}}{{/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 1b837fb5a73..5adbfd3d726 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 @@ {{#each poster in posters}} -{{avatar poster usernamePath="user.username" imageSize="small"}} +{{avatar poster avatarTemplatePath="user.avatar_template" usernamePath="user.username" imageSize="small"}} {{/each}} diff --git a/app/assets/javascripts/discourse/views/composer.js.es6 b/app/assets/javascripts/discourse/views/composer.js.es6 index 63a8675ec86..5d62db4161b 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 797cab86581..757f7308ec3 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 cf72afcf6fe..22334cc9b99 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 75d732ea679..4c8e1d9aad5 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 4edb2b5cc5d..8969d19a06b 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 2c72eb34221..8880c8dbd7e 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 9bc469f047d..a10f9dbf63b 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 4bf6650048b..d9609ff9b47 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 7dd33c874a2..1309a124ed5 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 add19d2db07..5c96a03857e 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?= 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 8379606460b..539171bc9a1 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 f0bb9fcb3f3..29aae8bf67d 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 5c72c6fc8bd..2f4ea292c81 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("
    "); } 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 4fab93f2de8..48b84011b48 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 d748689f40f..e1f9d597da7 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 12f0309d6c8..e7d070d814a 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 @@
    - +
    - + {{d-button action="refreshGravatar" title="user.change_avatar.refresh_gravatar_title" disabled=gravatarRefreshDisabled icon="refresh"}}
    {{#if allowImageUpload}}
    - {{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"}}
    @@ -36,6 +31,6 @@
    diff --git a/app/assets/javascripts/discourse/views/avatar-selector.js.es6 b/app/assets/javascripts/discourse/views/avatar-selector.js.es6 index 15b8541ef36..6fcc5c9bc49 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 ed8f31e4af0..26664810702 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 4c8e1d9aad5..b00ac21b3a2 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 2d216a51502..334b20e2d24 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 c2f004c8da2..00000000000 --- 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?= 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 30f86b16ec7..243146833e1 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?= 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 48b84011b48..bd0e36c5fab 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 e1f9d597da7..8bea9b46047 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 22334cc9b99..ab1104e8d44 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 39e5fb94efc..bcc468d4450 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 b00ac21b3a2..a17e4e9baa4 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 f61df736bf7..dd17fd6e3ed 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 2dd29df5c03..a06328d832f 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 b69a2734822..72dbd8e4d82 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 586f9f2d79d..c780d120312 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 b1fc1c61692..4af184052a1 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?= 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 8bea9b46047..fd9bba95d20 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?= 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 ee3e86e9d89..a7aa333fbca 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?= 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 dd17fd6e3ed..8d57472c688 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?= 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 fd9bba95d20..3ae6aed002b 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 ab1104e8d44..bb361de6fde 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 32544497de5..884f3f3ff20 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 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 54483efd5df..333e155b404 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 fd927ebac3f..e87a27f4824 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 63b3b45b02a..701c0de01b7 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 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 00000000000..6ac240e7585 --- /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 611d012ccf7..06af1df5d14 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}} - -
    +
    @@ -45,15 +45,20 @@
    {{/if}} {{#if wiki}} - + {{/if}} {{#if via_email}} {{#if canViewRawEmail}} - + {{else}} - + {{/if}} {{/if}} + + {{#if view.whisper}} + + {{/if}} + {{#if showUserReplyTab}} {{#if loadingReplyHistory}} diff --git a/app/assets/javascripts/discourse/views/post.js.es6 b/app/assets/javascripts/discourse/views/post.js.es6 index ef1869e90a5..e2c66317753 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 3b0d6200875..d11b4ac3a6f 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 41409878ff5..84730deccf1 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 a35e2f423e2..5ee72c315ac 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 3a6a8d51591..3cf1fb367d2 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 442fb43c2d1..703b547265f 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 bbc1833f034..881c30a5710 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 1309a124ed5..1da6daf5bb0 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 70992a968de..fb6adceedf2 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 e3f1a030b8b..5b03eff43a3 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 333e155b404..52222a6d3fc 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 87276664909..66430c8f82d 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 dedc080b9f2..fe2658d975c 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 7471c5cd7bd..b65ec208c67 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 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 fe2658d975c..ad23e0ade59 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 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 886e354878d..41dd47b8b95 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 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 41dd47b8b95..1b59b39e7b6 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 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 8beccb2b663..ff990eb66a0 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}} - + {{/if}} {{#if showUserReplyTab}} From 1e739e8c96393aedf9cbccf4d1c487b27ce5a139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= 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 d11b4ac3a6f..dab4f08f13f 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 84730deccf1..41409878ff5 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 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 a6768190d38..b9ae8a0868a 100644 --- a/app/views/list/list.erb +++ b/app/views/list/list.erb @@ -22,7 +22,7 @@ <%= t.title %> <%= page_links(t) %> - <% if !@category && t.category %> + <% if (!@category || @category.has_children?) && t.category %> [<%= t.category.name %>] <% end %> '>(<%= t.posts_count %>) From b4974f5876d53969e7b523ac4592cf4fe58fb34c Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan 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 8a4f2ddecef..153929ecd6b 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 fce0fecd836..2e909601fb6 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 21fd8afeb65..9d7bec2d67c 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 10072d99f55..3464250a74f 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 db60849ff9a..43e3e835ad5 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 @@