diff --git a/app/assets/javascripts/discourse/components/bbcode.js b/app/assets/javascripts/discourse/components/bbcode.js index 50cd2fdbd65..27c8372fa42 100644 --- a/app/assets/javascripts/discourse/components/bbcode.js +++ b/app/assets/javascripts/discourse/components/bbcode.js @@ -252,12 +252,22 @@ Discourse.BBCode = { // remove leading
s var content = matches[2].trim(); + var avatarImg; + if (opts.lookupAvatarByPostNumber) { + // client-side, we can retrieve the avatar from the post + var postNumber = parseInt(_.find(params, { 'key' : 'post' }).value, 10); + avatarImg = opts.lookupAvatarByPostNumber(postNumber); + } else if (opts.lookupAvatar) { + // server-side, we need to lookup the avatar from the username + avatarImg = opts.lookupAvatar(username); + } + // Arguments for formatting args = { - username: I18n.t('user.said',{username: username}), + username: I18n.t('user.said', {username: username}), params: params, quote: content, - avatarImg: opts.lookupAvatar ? opts.lookupAvatar(username) : void 0 + avatarImg: avatarImg }; // Name of the template diff --git a/app/assets/javascripts/discourse/components/utilities.js b/app/assets/javascripts/discourse/components/utilities.js index 454e0c61834..bf2b0f0ec32 100644 --- a/app/assets/javascripts/discourse/components/utilities.js +++ b/app/assets/javascripts/discourse/components/utilities.js @@ -16,6 +16,7 @@ Discourse.Utilities = { case 'small': return 25; case 'medium': return 32; case 'large': return 45; + case 'huge': return 120; } return size; }, @@ -50,18 +51,20 @@ Discourse.Utilities = { return result + "style=\"background-color: #" + color + "; color: #" + textColor + ";\">" + name + ""; }, - avatarUrl: function(username, size, template) { - if (!username) return ""; - var rawSize = (Discourse.Utilities.translateSize(size) * (window.devicePixelRatio || 1)).toFixed(); + avatarUrl: function(template, size) { + if (!template) { return ""; } + var rawSize = Discourse.Utilities.getRawSize(Discourse.Utilities.translateSize(size)); + return template.replace(/\{size\}/g, rawSize); + }, - if (username.match(/[^A-Za-z0-9_]/)) { return ""; } - if (template) return template.replace(/\{size\}/g, rawSize); - return Discourse.getURL("/users/") + username.toLowerCase() + "/avatar/" + rawSize + "?__ws=" + encodeURIComponent(Discourse.BaseUrl || ""); + getRawSize: function(size) { + var pixelRatio = window.devicePixelRatio || 1; + return pixelRatio >= 1.5 ? size * 2 : size; }, avatarImg: function(options) { var size = Discourse.Utilities.translateSize(options.size); - var url = Discourse.Utilities.avatarUrl(options.username, options.size, options.avatarTemplate); + var url = Discourse.Utilities.avatarUrl(options.avatarTemplate, size); // We won't render an invalid url if (!url || url.length === 0) { return ""; } @@ -71,8 +74,8 @@ Discourse.Utilities = { return ""; }, - tinyAvatar: function(username) { - return Discourse.Utilities.avatarImg({ username: username, size: 'tiny' }); + tinyAvatar: function(avatarTemplate) { + return Discourse.Utilities.avatarImg({avatarTemplate: avatarTemplate, size: 'tiny' }); }, postUrl: function(slug, topicId, postNumber) { @@ -266,6 +269,28 @@ Discourse.Utilities = { authorizedExtensions: function() { return Discourse.SiteSettings.authorized_extensions.replace(/\|/g, ", "); + }, + + displayErrorForUpload: function(data) { + // deal with meaningful errors first + if (data.jqXHR) { + switch (data.jqXHR.status) { + // cancel from the user + case 0: return; + // entity too large, usually returned from the web server + case 413: + var maxSizeKB = Discourse.SiteSettings.max_image_size_kb; + bootbox.alert(I18n.t('post.errors.image_too_large', { max_size_kb: maxSizeKB })); + return; + // the error message is provided by the server + case 415: // media type not authorized + case 422: // there has been an error on the server (mostly due to FastImage) + bootbox.alert(data.jqXHR.responseText); + return; + } + } + // otherwise, display a generic error message + bootbox.alert(I18n.t('post.errors.upload')); } }; diff --git a/app/assets/javascripts/discourse/controllers/preferences_avatar_controller.js b/app/assets/javascripts/discourse/controllers/preferences_avatar_controller.js new file mode 100644 index 00000000000..41180495972 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/preferences_avatar_controller.js @@ -0,0 +1,84 @@ +/** + This controller supports actions related to updating one's avatar + + @class PreferencesAvatarController + @extends Discourse.ObjectController + @namespace Discourse + @module Discourse +**/ +Discourse.PreferencesAvatarController = Discourse.ObjectController.extend({ + uploading: false, + uploadProgress: 0, + uploadDisabled: Em.computed.or("uploading"), + useGravatar: Em.computed.not("use_uploaded_avatar"), + useUploadedAvatar: Em.computed.alias("use_uploaded_avatar"), + + toggleUseUploadedAvatar: function(toggle) { + if (this.get("use_uploaded_avatar") !== toggle) { + var controller = this; + this.set("use_uploaded_avatar", toggle); + Discourse.ajax("/users/" + this.get("username") + "/preferences/avatar/toggle", { type: 'PUT', data: { use_uploaded_avatar: toggle }}) + .then(function(result) { controller.set("avatar_template", result.avatar_template); }); + } + }, + + uploadButtonText: function() { + return this.get("uploading") ? I18n.t("user.change_avatar.uploading") : I18n.t("user.change_avatar.upload"); + }.property("uploading"), + + uploadAvatar: function() { + var controller = this; + var $upload = $("#avatar-input"); + + // do nothing if no file is selected + if (Em.isEmpty($upload.val())) { return; } + + this.set("uploading", true); + + // define the upload endpoint + $upload.fileupload({ + url: Discourse.getURL("/users/" + this.get("username") + "/preferences/avatar"), + dataType: "json", + timeout: 20000 + }); + + // when there is a progression for the upload + $upload.on("fileuploadprogressall", function (e, data) { + var progress = parseInt(data.loaded / data.total * 100, 10); + controller.set("uploadProgress", progress); + }); + + // when the upload is successful + $upload.on("fileuploaddone", function (e, data) { + // set some properties + controller.setProperties({ + has_uploaded_avatar: true, + use_uploaded_avatar: true, + avatar_template: data.result.url, + uploaded_avatar_template: data.result.url + }); + }); + + // when there has been an error with the upload + $upload.on("fileuploadfail", function (e, data) { + Discourse.Utilities.displayErrorForUpload(data); + }); + + // when the upload is done + $upload.on("fileuploadalways", function (e, data) { + // prevent automatic upload when selecting a file + $upload.fileupload("destroy"); + $upload.off(); + // clear file input + $upload.val(""); + // indicate upload is done + controller.setProperties({ + uploading: false, + uploadProgress: 0 + }); + }); + + // *actually* launch the upload + $("#avatar-input").fileupload("add", { fileInput: $("#avatar-input") }); + } +}); diff --git a/app/assets/javascripts/discourse/helpers/application_helpers.js b/app/assets/javascripts/discourse/helpers/application_helpers.js index e806482412a..b941f29144b 100644 --- a/app/assets/javascripts/discourse/helpers/application_helpers.js +++ b/app/assets/javascripts/discourse/helpers/application_helpers.js @@ -116,16 +116,22 @@ Handlebars.registerHelper('lower', function(property, options) { @for Handlebars **/ Handlebars.registerHelper('avatar', function(user, options) { - if (typeof user === 'string') { user = Ember.Handlebars.get(this, user, options); } - if( user ) { + if (user) { var username = Em.get(user, 'username'); if (!username) username = Em.get(user, options.hash.usernamePath); - var avatarTemplate = Ember.get(user, 'avatar_template'); + var avatarTemplate; + var template = options.hash.template; + if (template && template !== 'avatar_template') { + avatarTemplate = Em.get(user, template); + if (!avatarTemplate) avatarTemplate = Em.get(user, 'user.' + template); + } + + if (!avatarTemplate) avatarTemplate = Em.get(user, 'avatar_template'); if (!avatarTemplate) avatarTemplate = Em.get(user, 'user.avatar_template'); var title; @@ -147,7 +153,6 @@ Handlebars.registerHelper('avatar', function(user, options) { return new Handlebars.SafeString(Discourse.Utilities.avatarImg({ size: options.hash.imageSize, extraClasses: Em.get(user, 'extras') || options.hash.extraClasses, - username: username, title: title || username, avatarTemplate: avatarTemplate })); @@ -158,18 +163,32 @@ Handlebars.registerHelper('avatar', function(user, options) { /** Bound avatar helper. + Will rerender whenever the "avatar_template" changes. @method boundAvatar @for Handlebars **/ Ember.Handlebars.registerBoundHelper('boundAvatar', function(user, options) { - var username = Em.get(user, 'username'); return new Handlebars.SafeString(Discourse.Utilities.avatarImg({ size: options.hash.imageSize, - username: username, - avatarTemplate: Ember.get(user, 'avatar_template') + avatarTemplate: Em.get(user, 'avatar_template') })); -}); +}, 'avatar_template'); + +/** + Bound avatar helper. + Will rerender whenever the "uploaded_avatar_template" changes. + Only available for the current user. + + @method boundUploadedAvatar + @for Handlebars +**/ +Ember.Handlebars.registerBoundHelper('boundUploadedAvatar', function(user, options) { + return new Handlebars.SafeString(Discourse.Utilities.avatarImg({ + size: options.hash.imageSize, + avatarTemplate: Em.get(user, 'uploaded_avatar_template') + })); +}, 'uploaded_avatar_template'); /** Nicely format a date without a binding since the date doesn't need to change. diff --git a/app/assets/javascripts/discourse/models/composer.js b/app/assets/javascripts/discourse/models/composer.js index 72cbff882f3..dbe43748da7 100644 --- a/app/assets/javascripts/discourse/models/composer.js +++ b/app/assets/javascripts/discourse/models/composer.js @@ -70,14 +70,14 @@ Discourse.Composer = Discourse.Model.extend({ if (post) { postDescription = I18n.t('post.' + this.get('action'), { link: postLink, - replyAvatar: Discourse.Utilities.tinyAvatar(post.get('username')), + replyAvatar: Discourse.Utilities.tinyAvatar(post.get('avatar_template')), username: this.get('post.username') }); var replyUsername = post.get('reply_to_user.username'); - if (replyUsername && this.get('action') === EDIT) { - postDescription += " " + I18n.t("post.in_reply_to") + " " + - Discourse.Utilities.tinyAvatar(replyUsername) + " " + replyUsername; + var replyAvatarTemplate = post.get('reply_to_user.avatar_template'); + if (replyUsername && replyAvatarTemplate && this.get('action') === EDIT) { + postDescription += " " + I18n.t("post.in_reply_to") + " " + Discourse.Utilities.tinyAvatar(replyAvatarTemplate) + " " + replyUsername; } } diff --git a/app/assets/javascripts/discourse/routes/application_routes.js b/app/assets/javascripts/discourse/routes/application_routes.js index c60b937844c..5eadf489a7a 100644 --- a/app/assets/javascripts/discourse/routes/application_routes.js +++ b/app/assets/javascripts/discourse/routes/application_routes.js @@ -57,6 +57,7 @@ Discourse.Route.buildRoutes(function() { this.route('username', { path: '/username' }); this.route('email', { path: '/email' }); this.route('about', { path: '/about-me' }); + this.route('avatar', { path: '/avatar' }); }); this.route('invited', { path: 'invited' }); diff --git a/app/assets/javascripts/discourse/routes/preferences_routes.js b/app/assets/javascripts/discourse/routes/preferences_routes.js index 6e6e14160ee..55427f4b9ff 100644 --- a/app/assets/javascripts/discourse/routes/preferences_routes.js +++ b/app/assets/javascripts/discourse/routes/preferences_routes.js @@ -116,4 +116,33 @@ Discourse.PreferencesUsernameRoute = Discourse.RestrictedUserRoute.extend({ setupController: function(controller, user) { controller.setProperties({ model: user, newUsername: user.get('username') }); } -}); \ No newline at end of file +}); + + +/** + The route for updating a user's avatar + + @class PreferencesAvatarRoute + @extends Discourse.RestrictedUserRoute + @namespace Discourse + @module Discourse +**/ +Discourse.PreferencesAvatarRoute = Discourse.RestrictedUserRoute.extend({ + model: function() { + return this.modelFor('user'); + }, + + renderTemplate: function() { + return this.render({ into: 'user', outlet: 'userOutlet' }); + }, + + // A bit odd, but if we leave to /preferences we need to re-render that outlet + exit: function() { + this._super(); + this.render('preferences', { into: 'user', outlet: 'userOutlet', controller: 'preferences' }); + }, + + setupController: function(controller, user) { + controller.setProperties({ model: user }); + } +}); diff --git a/app/assets/javascripts/discourse/templates/header.js.handlebars b/app/assets/javascripts/discourse/templates/header.js.handlebars index d1784efaa39..7349a442a93 100644 --- a/app/assets/javascripts/discourse/templates/header.js.handlebars +++ b/app/assets/javascripts/discourse/templates/header.js.handlebars @@ -30,7 +30,7 @@ {{#unless showExtraInfo}}
{{#if currentUser}} - {{currentUser.name}} + {{currentUser.name}} {{else}} {{/if}} @@ -85,7 +85,7 @@
  • {{#if currentUser}} - {{#linkTo 'userActivity.index' currentUser titleKey="current_user" class="icon"}}{{avatar currentUser imageSize="medium" }}{{/linkTo}} + {{#linkTo 'userActivity.index' currentUser titleKey="current_user" class="icon"}}{{boundAvatar currentUser imageSize="medium" }}{{/linkTo}} {{else}}
    {{/if}} diff --git a/app/assets/javascripts/discourse/templates/user/avatar.js.handlebars b/app/assets/javascripts/discourse/templates/user/avatar.js.handlebars new file mode 100644 index 00000000000..e72fb175e27 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/user/avatar.js.handlebars @@ -0,0 +1,39 @@ +
    + +
    +
    +

    {{i18n user.change_avatar.title}}

    +
    +
    + +
    + +
    + + {{#if has_uploaded_avatar}} + + {{/if}} +
    +
    + +
    +
    {{i18n user.change_avatar.upload_instructions}}
    +
    +
    + +
    + + {{#if uploading}} + {{i18n upload_selector.uploading}} {{uploadProgress}}% + {{/if}} +
    +
    + +
    diff --git a/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars b/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars index 16211759515..e2aece57396 100644 --- a/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars +++ b/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars @@ -47,7 +47,17 @@ {{avatar model imageSize="large"}}
  • - {{{i18n user.avatar.instructions}}} {{email}} + {{#if Discourse.SiteSettings.allow_uploaded_avatars}} + {{#if use_uploaded_avatar}} + {{{i18n user.avatar.instructions.uploaded_avatar}}} + {{else}} + {{{i18n user.avatar.instructions.gravatar}}} {{email}} + {{/if}} + {{#linkTo "preferences.avatar" class="btn pad-left"}}{{i18n user.change}}{{/linkTo}} + {{else}} + {{{i18n user.avatar.instructions.gravatar}}} {{email}} + {{i18n user.change}} + {{/if}}
    diff --git a/app/assets/javascripts/discourse/templates/user/user.js.handlebars b/app/assets/javascripts/discourse/templates/user/user.js.handlebars index 680c6c9a8ea..bfa14de9446 100644 --- a/app/assets/javascripts/discourse/templates/user/user.js.handlebars +++ b/app/assets/javascripts/discourse/templates/user/user.js.handlebars @@ -29,7 +29,7 @@ {{/if}}
    - {{boundAvatar model imageSize="120"}} + {{boundAvatar model imageSize="huge"}}
    diff --git a/app/assets/javascripts/discourse/views/actions_history_view.js b/app/assets/javascripts/discourse/views/actions_history_view.js index c112ffaf779..ece98f66ede 100644 --- a/app/assets/javascripts/discourse/views/actions_history_view.js +++ b/app/assets/javascripts/discourse/views/actions_history_view.js @@ -38,7 +38,6 @@ Discourse.ActionsHistoryView = Discourse.View.extend({ } iconsHtml += Discourse.Utilities.avatarImg({ size: 'small', - username: u.get('username'), avatarTemplate: u.get('avatar_template'), title: u.get('username') }); diff --git a/app/assets/javascripts/discourse/views/composer_view.js b/app/assets/javascripts/discourse/views/composer_view.js index ed365503a46..57fada42edb 100644 --- a/app/assets/javascripts/discourse/views/composer_view.js +++ b/app/assets/javascripts/discourse/views/composer_view.js @@ -191,8 +191,11 @@ Discourse.ComposerView = Discourse.View.extend({ }); this.editor = editor = Discourse.Markdown.createEditor({ - lookupAvatar: function(username) { - return Discourse.Utilities.avatarImg({ username: username, size: 'tiny' }); + lookupAvatarByPostNumber: function(postNumber) { + var quotedPost = composerView.get('controller.controllers.topic.postStream.posts').findProperty("post_number", postNumber); + if (quotedPost) { + return Discourse.Utilities.tinyAvatar(quotedPost.get("avatar_template")); + } } }); @@ -295,27 +298,8 @@ Discourse.ComposerView = Discourse.View.extend({ $uploadTarget.on('fileuploadfail', function (e, data) { // hide upload status composerView.set('isUploading', false); - // deal with meaningful errors first - if (data.jqXHR) { - switch (data.jqXHR.status) { - // 0 == cancel from the user - case 0: return; - // 413 == entity too large, usually returned from the web server - case 413: - var type = Discourse.Utilities.isAnImage(data.files[0].name) ? "image" : "attachment"; - var maxSizeKB = Discourse.SiteSettings['max_' + type + '_size_kb']; - bootbox.alert(I18n.t('post.errors.' + type + '_too_large', { max_size_kb: maxSizeKB })); - return; - // 415 == media type not authorized - case 415: - // 422 == there has been an error on the server (mostly due to FastImage) - case 422: - bootbox.alert(data.jqXHR.responseText); - return; - } - } - // otherwise, display a generic error message - bootbox.alert(I18n.t('post.errors.upload')); + // display an error message + Discourse.Utilities.displayErrorForUpload(data); }); // I hate to use Em.run.later, but I don't think there's a way of waiting for a CSS transition @@ -323,11 +307,7 @@ Discourse.ComposerView = Discourse.View.extend({ return Em.run.later(jQuery, (function() { var replyTitle = $('#reply-title'); composerView.resize(); - if (replyTitle.length) { - return replyTitle.putCursorAtEnd(); - } else { - return $wmdInput.putCursorAtEnd(); - } + return replyTitle.length ? replyTitle.putCursorAtEnd() : $wmdInput.putCursorAtEnd(); }), 300); }, diff --git a/app/assets/javascripts/discourse/views/user/preferences_avatar_view.js b/app/assets/javascripts/discourse/views/user/preferences_avatar_view.js new file mode 100644 index 00000000000..4eb5d6eff6c --- /dev/null +++ b/app/assets/javascripts/discourse/views/user/preferences_avatar_view.js @@ -0,0 +1,21 @@ +/** + This view handles rendering of a user's avatar uploader + + @class PreferencesAvatarView + @extends Discourse.View + @namespace Discourse + @module Discourse +**/ +Discourse.PreferencesAvatarView = Discourse.View.extend({ + templateName: "user/avatar", + classNames: ["user-preferences"], + + selectedChanged: function() { + var view = this; + Em.run.next(function() { + var value = view.get("controller.use_uploaded_avatar") ? "uploaded_avatar" : "gravatar"; + view.$('input:radio[name="avatar"]').val([value]); + }); + }.observes('controller.use_uploaded_avatar') + +}); diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index a6eccb61dd6..c2acad8b738 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -3,7 +3,6 @@ require_dependency 'promotion' class TopicsController < ApplicationController - # Avatar is an image request, not XHR before_filter :ensure_logged_in, only: [:timings, :destroy_timings, :update, @@ -22,8 +21,7 @@ class TopicsController < ApplicationController before_filter :consider_user_for_promotion, only: :show - skip_before_filter :check_xhr, only: [:avatar, :show, :feed] - caches_action :avatar, cache_path: Proc.new {|c| "#{c.params[:post_number]}-#{c.params[:topic_id]}" } + skip_before_filter :check_xhr, only: [:show, :feed] def show diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 4cb28ec4e28..23769012658 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -3,8 +3,7 @@ require_dependency 'user_name_suggester' class UsersController < ApplicationController - skip_before_filter :check_xhr, only: [:show, :password_reset, :update, :activate_account, :avatar, :authorize_email, :user_preferences_redirect] - skip_before_filter :authorize_mini_profiler, only: [:avatar] + skip_before_filter :check_xhr, only: [:show, :password_reset, :update, :activate_account, :authorize_email, :user_preferences_redirect] before_filter :ensure_logged_in, only: [:username, :update, :change_email, :user_preferences_redirect] @@ -220,25 +219,6 @@ class UsersController < ApplicationController render json: {value: honeypot_value, challenge: challenge_value} end - # all avatars are funneled through here - def avatar - - # TEMP to catch all missing spots - # raise ActiveRecord::RecordNotFound - - user = User.select(:email).where(username_lower: params[:username].downcase).first - if user.present? - # for now we only support gravatar in square (redirect cached for a day), - # later we can use x-sendfile and/or a cdn to serve local - size = determine_avatar_size(params[:size]) - url = user.avatar_template.gsub("{size}", size.to_s) - expires_in 1.day - redirect_to url - else - raise ActiveRecord::RecordNotFound - end - end - def password_reset expires_now() @@ -336,6 +316,46 @@ class UsersController < ApplicationController methods: :avatar_template) } end + def avatar + user = fetch_user_from_params + guardian.ensure_can_edit!(user) + + file = params[:file] || params[:files].first + + # check the file size (note: this might also be done in the web server) + filesize = File.size(file.tempfile) + max_size_kb = SiteSetting.max_image_size_kb * 1024 + return render status: 413, text: I18n.t("upload.images.too_large", max_size_kb: max_size_kb) if filesize > max_size_kb + + upload = Upload.create_for(user.id, file, filesize) + + user.uploaded_avatar = upload + user.use_uploaded_avatar = true + user.save! + + Jobs.enqueue(:generate_avatars, upload_id: upload.id) + + render json: { url: upload.url } + + rescue FastImage::ImageFetchFailure + render status: 422, text: I18n.t("upload.images.fetch_failure") + rescue FastImage::UnknownImageType + render status: 422, text: I18n.t("upload.images.unknown_image_type") + rescue FastImage::SizeNotFound + render status: 422, text: I18n.t("upload.images.size_not_found") + end + + def toggle_avatar + params.require(:use_uploaded_avatar) + user = fetch_user_from_params + guardian.ensure_can_edit!(user) + + user.use_uploaded_avatar = params[:use_uploaded_avatar] + user.save! + + render json: { avatar_template: user.avatar_template } + end + private def honeypot_value @@ -405,12 +425,4 @@ class UsersController < ApplicationController auth[:github_user_id] && auth[:github_screen_name] && GithubUserInfo.find_by_github_user_id(auth[:github_user_id]).nil? end - - def determine_avatar_size(size) - size = size.to_i - size = 64 if size == 0 - size = 10 if size < 10 - size = 128 if size > 128 - size - end end diff --git a/app/models/upload.rb b/app/models/upload.rb index 2b1c35f9699..a7fcafecb1c 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -31,11 +31,15 @@ class Upload < ActiveRecord::Base def destroy Upload.transaction do - Discourse.store.remove_file(url) + Discourse.store.remove_upload(self) super end end + def extension + File.extname(original_filename) + end + def self.create_for(user_id, file, filesize) # compute the sha sha1 = Digest::SHA1.file(file.tempfile).hexdigest diff --git a/app/models/user.rb b/app/models/user.rb index b350637af16..da795d0ab3f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -28,6 +28,7 @@ class User < ActiveRecord::Base has_many :user_visits has_many :invites has_many :topic_links + has_many :uploads has_one :facebook_user_info, dependent: :destroy has_one :twitter_user_info, dependent: :destroy @@ -41,6 +42,8 @@ class User < ActiveRecord::Base has_one :user_search_data + belongs_to :uploaded_avatar, class_name: 'Upload', dependent: :destroy + validates_presence_of :username validate :username_validator validates :email, presence: true, uniqueness: true @@ -295,24 +298,38 @@ class User < ActiveRecord::Base end def self.avatar_template(email) + user = User.select([:email, :use_uploaded_avatar, :uploaded_avatar_template, :uploaded_avatar_id]) + .where(email: email.downcase) + .first + if user.present? + if SiteSetting.allow_uploaded_avatars? && user.use_uploaded_avatar + # the avatars might take a while to generate + # so return the url of the original image in the meantime + user.uploaded_avatar_template.present? ? user.uploaded_avatar_template : user.uploaded_avatar.url + else + User.gravatar_template(email) + end + end + end + + def self.gravatar_template(email) email_hash = self.email_hash(email) - # robohash was possibly causing caching issues - # robohash = CGI.escape("http://robohash.org/size_") << "{size}x{size}" << CGI.escape("/#{email_hash}.png") - "https://www.gravatar.com/avatar/#{email_hash}.png?s={size}&r=pg&d=identicon" + "//www.gravatar.com/avatar/#{email_hash}.png?s={size}&r=pg&d=identicon" end # Don't pass this up to the client - it's meant for server side use - # The only spot this is now used is for self oneboxes in open graph data + # This is used in + # - self oneboxes in open graph data + # - emails def small_avatar_url - "https://www.gravatar.com/avatar/#{email_hash}.png?s=60&r=pg&d=identicon" + template = User.avatar_template(email) + template.gsub(/\{size\}/, "60") end - # return null for local avatars, a template for gravatar def avatar_template User.avatar_template(email) end - # Updates the denormalized view counts for all users def self.update_view_counts # Update denormalized topics_entered @@ -506,6 +523,9 @@ class User < ActiveRecord::Base end end + def has_uploaded_avatar + uploaded_avatar.present? + end protected @@ -529,7 +549,6 @@ class User < ActiveRecord::Base end end - def create_email_token email_tokens.create(email: email) end @@ -571,13 +590,13 @@ class User < ActiveRecord::Base end end - def send_approval_email - Jobs.enqueue(:user_email, - type: :signup_after_approval, - user_id: id, - email_token: email_tokens.first.token - ) - end + def send_approval_email + Jobs.enqueue(:user_email, + type: :signup_after_approval, + user_id: id, + email_token: email_tokens.first.token + ) + end private @@ -647,6 +666,9 @@ end # blocked :boolean default(FALSE) # dynamic_favicon :boolean default(FALSE), not null # title :string(255) +# use_uploaded_avatar :boolean default(FALSE) +# uploaded_avatar_template :string(255) +# uploaded_avatar_id :integer # # Indexes # diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb index 69079b4f369..59c28c7f46f 100644 --- a/app/serializers/current_user_serializer.rb +++ b/app/serializers/current_user_serializer.rb @@ -14,7 +14,11 @@ class CurrentUserSerializer < BasicUserSerializer :external_links_in_new_tab, :dynamic_favicon, :trust_level, - :can_edit + :can_edit, + :use_uploaded_avatar, + :has_uploaded_avatar, + :gravatar_template, + :uploaded_avatar_template def include_site_flagged_posts_count? object.staff? @@ -36,4 +40,8 @@ class CurrentUserSerializer < BasicUserSerializer true end + def gravatar_template + User.gravatar_template(object.email) + end + end diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb index 48e1cef6001..09f4d9bec89 100644 --- a/app/serializers/post_serializer.rb +++ b/app/serializers/post_serializer.rb @@ -111,7 +111,8 @@ class PostSerializer < BasicPostSerializer def reply_to_user { username: object.reply_to_user.username, - name: object.reply_to_user.name + name: object.reply_to_user.name, + avatar_template: object.reply_to_user.avatar_template } end diff --git a/config/routes.rb b/config/routes.rb index 406e2e6a6ae..53d1a66864a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -135,7 +135,9 @@ Discourse::Application.routes.draw do get 'users/:username/preferences/about-me' => 'users#preferences', constraints: {username: USERNAME_ROUTE_FORMAT} get 'users/:username/preferences/username' => 'users#preferences', constraints: {username: USERNAME_ROUTE_FORMAT} put 'users/:username/preferences/username' => 'users#username', constraints: {username: USERNAME_ROUTE_FORMAT} - get 'users/:username/avatar(/:size)' => 'users#avatar', constraints: {username: USERNAME_ROUTE_FORMAT} + get 'users/:username/preferences/avatar' => 'users#preferences', constraints: {username: USERNAME_ROUTE_FORMAT} + put 'users/:username/preferences/avatar/toggle' => 'users#toggle_avatar', constraints: {username: USERNAME_ROUTE_FORMAT} + post 'users/:username/preferences/avatar' => 'users#avatar', constraints: {username: USERNAME_ROUTE_FORMAT} get 'users/:username/invited' => 'users#invited', constraints: {username: USERNAME_ROUTE_FORMAT} post 'users/:username/send_activation_email' => 'users#send_activation_email', constraints: {username: USERNAME_ROUTE_FORMAT} get 'users/:username/activity' => 'users#show', constraints: {username: USERNAME_ROUTE_FORMAT} @@ -143,7 +145,6 @@ Discourse::Application.routes.draw do resources :uploads - get 'posts/by_number/:topic_id/:post_number' => 'posts#by_number' get 'posts/:id/reply-history' => 'posts#reply_history' resources :posts do @@ -211,9 +212,6 @@ Discourse::Application.routes.draw do get 'topics/similar_to' get 'topics/created-by/:username' => 'list#topics_by', as: 'topics_by', constraints: {username: USERNAME_ROUTE_FORMAT} - # Legacy route for old avatars - get 'threads/:topic_id/:post_number/avatar' => 'topics#avatar', constraints: {topic_id: /\d+/, post_number: /\d+/} - # Topic routes get 't/:slug/:topic_id/wordpress' => 'topics#wordpress', constraints: {topic_id: /\d+/} get 't/:slug/:topic_id/moderator-liked' => 'topics#moderator_liked', constraints: {topic_id: /\d+/} diff --git a/db/migrate/20130809211409_add_avatar_to_users.rb b/db/migrate/20130809211409_add_avatar_to_users.rb new file mode 100644 index 00000000000..4d570aeee7d --- /dev/null +++ b/db/migrate/20130809211409_add_avatar_to_users.rb @@ -0,0 +1,7 @@ +class AddAvatarToUsers < ActiveRecord::Migration + def change + add_column :users, :use_uploaded_avatar, :boolean, default: false + add_column :users, :uploaded_avatar_template, :string + add_column :users, :uploaded_avatar_id, :integer + end +end diff --git a/lib/file_store/local_store.rb b/lib/file_store/local_store.rb index 01bdf3516e4..2b8ea166cef 100644 --- a/lib/file_store/local_store.rb +++ b/lib/file_store/local_store.rb @@ -1,41 +1,31 @@ class LocalStore def store_upload(file, upload) - unique_sha1 = Digest::SHA1.hexdigest("#{Time.now.to_s}#{file.original_filename}")[0,16] - extension = File.extname(file.original_filename) - clean_name = "#{unique_sha1}#{extension}" - path = "#{relative_base_url}/#{upload.id}/#{clean_name}" - # copy the file to the right location - copy_file(file, "#{public_dir}#{path}") - # url - Discourse.base_uri + path + path = get_path_for_upload(file, upload) + store_file(file, path) end def store_optimized_image(file, optimized_image) - # 1234567890ABCDEF_100x200.jpg - filename = [ - optimized_image.sha1[6..16], - "_#{optimized_image.width}x#{optimized_image.height}", - optimized_image.extension, - ].join - # /public/uploads/site/_optimized/123/456/ - path = File.join( - relative_base_url, - "_optimized", - optimized_image.sha1[0..2], - optimized_image.sha1[3..5], - filename - ) - # copy the file to the right location - copy_file(file, "#{public_dir}#{path}") - # url - Discourse.base_uri + path + path = get_path_for_optimized_image(file, optimized_image) + store_file(file, path) end - def remove_file(url) - File.delete("#{public_dir}#{url}") if has_been_uploaded?(url) - rescue Errno::ENOENT - # don't care if the file isn't there + def store_avatar(file, upload, size) + path = get_path_for_avatar(file, upload, size) + store_file(file, path) + end + + def remove_upload(upload) + remove_file(upload.url) + end + + def remove_optimized_image(optimized_image) + remove_file(optimized_image.url) + end + + def remove_avatars(upload) + return unless upload.url =~ /avatars/ + remove_directory(File.dirname(upload.url)) end def has_been_uploaded?(url) @@ -63,8 +53,63 @@ class LocalStore "#{public_dir}#{upload.url}" end + def absolute_avatar_template(upload) + avatar_template(upload, absolute_base_url) + end + private + def get_path_for_upload(file, upload) + unique_sha1 = Digest::SHA1.hexdigest("#{Time.now.to_s}#{file.original_filename}")[0..15] + extension = File.extname(file.original_filename) + clean_name = "#{unique_sha1}#{extension}" + # path + "#{relative_base_url}/#{upload.id}/#{clean_name}" + end + + def get_path_for_optimized_image(file, optimized_image) + # 1234567890ABCDEF_100x200.jpg + filename = [ + optimized_image.sha1[6..15], + "_#{optimized_image.width}x#{optimized_image.height}", + optimized_image.extension, + ].join + # /uploads//_optimized/<1A3>// + File.join( + relative_base_url, + "_optimized", + optimized_image.sha1[0..2], + optimized_image.sha1[3..5], + filename + ) + end + + def get_path_for_avatar(file, upload, size) + relative_avatar_template(upload).gsub(/\{size\}/, size.to_s) + end + + def relative_avatar_template(upload) + avatar_template(upload, relative_base_url) + end + + def avatar_template(upload, base_url) + File.join( + base_url, + "avatars", + upload.sha1[0..2], + upload.sha1[3..5], + upload.sha1[6..15], + "{size}#{upload.extension}" + ) + end + + def store_file(file, path) + # copy the file to the right location + copy_file(file, "#{public_dir}#{path}") + # url + Discourse.base_uri + path + end + def copy_file(file, path) FileUtils.mkdir_p Pathname.new(path).dirname # move the file to the right location @@ -74,6 +119,17 @@ class LocalStore end end + def remove_file(url) + File.delete("#{public_dir}#{url}") if has_been_uploaded?(url) + rescue Errno::ENOENT + # don't care if the file isn't there + end + + def remove_directory(path) + directory = "#{public_dir}/#{path}" + FileUtils.rm_rf(directory) + end + def is_relative?(url) url.start_with?(relative_base_url) end diff --git a/lib/file_store/s3_store.rb b/lib/file_store/s3_store.rb index e864591d86d..02f9a29c7a2 100644 --- a/lib/file_store/s3_store.rb +++ b/lib/file_store/s3_store.rb @@ -4,34 +4,60 @@ require 'open-uri' class S3Store def store_upload(file, upload) - extension = File.extname(file.original_filename) - remote_filename = "#{upload.id}#{upload.sha1}#{extension}" + # + path = "#{upload.id}#{upload.sha1}#{upload.extension}" # if this fails, it will throw an exception - upload(file.tempfile, remote_filename, file.content_type) + upload(file.tempfile, path, file.content_type) # returns the url of the uploaded file - "#{absolute_base_url}/#{remote_filename}" + "#{absolute_base_url}/#{path}" end def store_optimized_image(file, optimized_image) - extension = File.extname(file.path) - remote_filename = [ + # _x + path = [ optimized_image.id, optimized_image.sha1, "_#{optimized_image.width}x#{optimized_image.height}", - extension + optimized_image.extension ].join # if this fails, it will throw an exception - upload(file, remote_filename) + upload(file, path) # returns the url of the uploaded file - "#{absolute_base_url}/#{remote_filename}" + "#{absolute_base_url}/#{path}" + end + + def store_avatar(file, upload, size) + # /avatars//200.jpg + path = File.join( + "avatars", + upload.sha1, + "#{size}#{upload.extension}" + ) + + # if this fails, it will throw an exception + upload(file, path) + + # returns the url of the avatar + "#{absolute_base_url}/#{path}" + end + + def remove_upload(upload) + remove_file(upload.url) + end + + def remove_optimized_image(optimized_image) + remove_file(optimized_image.url) + end + + def remove_avatars(upload) + end def remove_file(url) - check_missing_site_settings return unless has_been_uploaded?(url) name = File.basename(url) remove(name) diff --git a/lib/oneboxer/discourse_local_onebox.rb b/lib/oneboxer/discourse_local_onebox.rb index c339c162645..3d500acb926 100644 --- a/lib/oneboxer/discourse_local_onebox.rb +++ b/lib/oneboxer/discourse_local_onebox.rb @@ -23,7 +23,7 @@ module Oneboxer return @url unless Guardian.new.can_see?(user) - args.merge! avatar: PrettyText.avatar_img(user.username, 'tiny'), username: user.username + args.merge! avatar: PrettyText.avatar_img(user.avatar_template, 'tiny'), username: user.username args[:bio] = user.bio_cooked if user.bio_cooked.present? @template = 'user' @@ -58,7 +58,7 @@ module Oneboxer posters = topic.posters_summary.map do |p| {username: p[:user][:username], - avatar: PrettyText.avatar_img(p[:user][:username], 'tiny'), + avatar: PrettyText.avatar_img(p[:user][:avatar_template], 'tiny'), description: p[:description], extras: p[:extras]} end diff --git a/lib/pretty_text.rb b/lib/pretty_text.rb index 9d20a022abe..b1329ee7f6c 100644 --- a/lib/pretty_text.rb +++ b/lib/pretty_text.rb @@ -64,7 +64,7 @@ module PrettyText return "" unless username user = User.where(username_lower: username.downcase).first - if user + if user.present? user.avatar_template end end @@ -139,7 +139,7 @@ module PrettyText v8['opts'] = opts || {} v8['raw'] = text v8.eval('opts["mentionLookup"] = function(u){return helpers.is_username_valid(u);}') - v8.eval('opts["lookupAvatar"] = function(p){return Discourse.Utilities.avatarImg({username: p, size: "tiny", avatarTemplate: helpers.avatar_template(p)});}') + v8.eval('opts["lookupAvatar"] = function(p){return Discourse.Utilities.avatarImg({size: "tiny", avatarTemplate: helpers.avatar_template(p)});}') baked = v8.eval('Discourse.Markdown.markdownConverter(opts).makeHtml(raw)') end @@ -149,15 +149,15 @@ module PrettyText end # leaving this here, cause it invokes v8, don't want to implement twice - def self.avatar_img(username, size) + def self.avatar_img(avatar_template, size) r = nil @mutex.synchronize do - v8['username'] = username + v8['avatarTemplate'] = avatar_template v8['size'] = size v8.eval("Discourse.SiteSettings = #{SiteSetting.client_settings_json};") v8.eval("Discourse.CDN = '#{Rails.configuration.action_controller.asset_host}';") v8.eval("Discourse.BaseUrl = '#{RailsMultisite::ConnectionManagement.current_hostname}';") - r = v8.eval("Discourse.Utilities.avatarImg({ username: username, size: size });") + r = v8.eval("Discourse.Utilities.avatarImg({ avatarTemplate: avatarTemplate, size: size });") end r end diff --git a/spec/components/cooked_post_processor_spec.rb b/spec/components/cooked_post_processor_spec.rb index bd9d1f57787..b0339b8883f 100644 --- a/spec/components/cooked_post_processor_spec.rb +++ b/spec/components/cooked_post_processor_spec.rb @@ -135,7 +135,7 @@ describe CookedPostProcessor do it "generates overlay information" do cpp.post_process_images - cpp.html.should match_html '
    + cpp.html.should match_html '' cpp.should be_dirty diff --git a/spec/components/file_store/local_store_spec.rb b/spec/components/file_store/local_store_spec.rb index aca9e4b48e9..684a9a273b5 100644 --- a/spec/components/file_store/local_store_spec.rb +++ b/spec/components/file_store/local_store_spec.rb @@ -35,25 +35,38 @@ describe LocalStore do it "returns a relative url" do store.expects(:copy_file) - store.store_optimized_image({}, optimized_image).should == "/uploads/default/_optimized/86f/7e4/37faa5a7fce_100x200.png" + store.store_optimized_image({}, optimized_image).should == "/uploads/default/_optimized/86f/7e4/37faa5a7fc_100x200.png" end end - describe "remove_file" do + describe "remove_upload" do - it "does not delete any file" do + it "does not delete non uploaded" do File.expects(:delete).never - store.remove_file("/path/to/file") + upload = Upload.new + upload.stubs(:url).returns("/path/to/file") + store.remove_upload(upload) end it "deletes the file locally" do File.expects(:delete) - store.remove_file("/uploads/default/42/253dc8edf9d4ada1.png") + upload = Upload.new + upload.stubs(:url).returns("/uploads/default/42/253dc8edf9d4ada1.png") + store.remove_upload(upload) end end + describe "remove_optimized_image" do + + end + + describe "remove_avatar" do + + end + + describe "has_been_uploaded?" do it "identifies local or relatives urls" do diff --git a/spec/components/file_store/s3_store_spec.rb b/spec/components/file_store/s3_store_spec.rb index 8b38a4d9160..fc43173f5db 100644 --- a/spec/components/file_store/s3_store_spec.rb +++ b/spec/components/file_store/s3_store_spec.rb @@ -40,6 +40,7 @@ describe S3Store do it "returns a relative url" do upload.stubs(:id).returns(42) + upload.stubs(:extension).returns(".png") store.store_upload(uploaded_file, upload).should == "//s3_upload_bucket.s3.amazonaws.com/42e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98.png" end @@ -54,20 +55,32 @@ describe S3Store do end - describe "remove_file" do + describe "remove_upload" do - it "does not delete any file" do + it "does not delete non uploaded file" do store.expects(:remove).never - store.remove_file("//other_bucket.s3.amazonaws.com/42.png") + upload = Upload.new + upload.stubs(:url).returns("//other_bucket.s3.amazonaws.com/42.png") + store.remove_upload(upload) end it "deletes the file on s3" do store.expects(:remove) - store.remove_file("//s3_upload_bucket.s3.amazonaws.com/42.png") + upload = Upload.new + upload.stubs(:url).returns("//s3_upload_bucket.s3.amazonaws.com/42.png") + store.remove_upload(upload) end end + describe "remove_optimized_image" do + + end + + describe "remove_avatar" do + + end + describe "has_been_uploaded?" do it "identifies S3 uploads" do diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb index 10440313e1c..41c8ff3a91c 100644 --- a/spec/components/guardian_spec.rb +++ b/spec/components/guardian_spec.rb @@ -628,7 +628,7 @@ describe Guardian do Guardian.new(nil).can_see_flags?(post).should be_false end - it "allow regular uses to see flags" do + it "allow regular users to see flags" do Guardian.new(user).can_see_flags?(post).should be_false end diff --git a/spec/components/pretty_text_spec.rb b/spec/components/pretty_text_spec.rb index 3ce512cb7eb..6fe8c978e9c 100644 --- a/spec/components/pretty_text_spec.rb +++ b/spec/components/pretty_text_spec.rb @@ -14,18 +14,27 @@ test PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"][sam][/quote]").should =~ /\[sam\]/ end - it "produces a quote even with new lines in it" do - PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"]ddd\n[/quote]").should match_html "

    " - end + describe "with avatar" do - it "should produce a quote" do - PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"]ddd[/quote]").should match_html "

    " - end + before(:each) do + eviltrout = User.new + eviltrout.stubs(:avatar_template).returns("http://test.localhost/uploads/default/avatars/42d/57c/46ce7ee487/{size}.png") + User.expects(:where).with(username_lower: "eviltrout").returns([eviltrout]) + end - it "trims spaces on quote params" do - PrettyText.cook("[quote=\"EvilTrout, post:555, topic: 666\"]ddd[/quote]").should match_html "

    " - end + it "produces a quote even with new lines in it" do + PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"]ddd\n[/quote]").should match_html "

    " + end + it "should produce a quote" do + PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"]ddd[/quote]").should match_html "

    " + end + + it "trims spaces on quote params" do + PrettyText.cook("[quote=\"EvilTrout, post:555, topic: 666\"]ddd[/quote]").should match_html "

    " + end + + end it "should handle 3 mentions in a row" do PrettyText.cook('@hello @hello @hello').should match_html "

    @hello @hello @hello

    " diff --git a/spec/models/optimized_image_spec.rb b/spec/models/optimized_image_spec.rb index b2d2db798d2..743c3bb0fcf 100644 --- a/spec/models/optimized_image_spec.rb +++ b/spec/models/optimized_image_spec.rb @@ -19,7 +19,7 @@ describe OptimizedImage do oi.extension.should == ".jpg" oi.width.should == 100 oi.height.should == 200 - oi.url.should == "/uploads/default/_optimized/da3/9a3/ee5e6b4b0d3_100x200.jpg" + oi.url.should == "/uploads/default/_optimized/da3/9a3/ee5e6b4b0d_100x200.jpg" end end diff --git a/test/javascripts/components/bbcode_test.js b/test/javascripts/components/bbcode_test.js index 5941ed812d3..a80a2bfec20 100644 --- a/test/javascripts/components/bbcode_test.js +++ b/test/javascripts/components/bbcode_test.js @@ -2,7 +2,6 @@ module("Discourse.BBCode"); var format = function(input, expected, text) { - var cooked = Discourse.Markdown.cook(input, {lookupAvatar: false}); equal(cooked, "

    " + expected + "

    ", text); }; diff --git a/test/javascripts/components/utilities_test.js b/test/javascripts/components/utilities_test.js index ec4ac8cfbf2..a0dae30b8ea 100644 --- a/test/javascripts/components/utilities_test.js +++ b/test/javascripts/components/utilities_test.js @@ -110,33 +110,25 @@ test("isAnImage", function() { }); test("avatarUrl", function() { - blank(Discourse.Utilities.avatarUrl('', 'tiny'), "no avatar url returns blank"); - blank(Discourse.Utilities.avatarUrl('this is not a username', 'tiny'), "invalid username returns blank"); - - equal(Discourse.Utilities.avatarUrl('eviltrout', 'tiny'), "/users/eviltrout/avatar/20?__ws=", "simple avatar url"); - equal(Discourse.Utilities.avatarUrl('eviltrout', 'large'), "/users/eviltrout/avatar/45?__ws=", "different size"); - equal(Discourse.Utilities.avatarUrl('EvilTrout', 'tiny'), "/users/eviltrout/avatar/20?__ws=", "lowercases username"); - equal(Discourse.Utilities.avatarUrl('eviltrout', 'tiny', 'test{size}'), "test20", "replaces the size in a template"); -}); - -test("avatarUrl with a baseUrl", function() { - Discourse.BaseUrl = "http://try.discourse.org"; - equal(Discourse.Utilities.avatarUrl('eviltrout', 'tiny'), "/users/eviltrout/avatar/20?__ws=http%3A%2F%2Ftry.discourse.org", "simple avatar url"); + blank(Discourse.Utilities.avatarUrl('', 'tiny'), "no template returns blank"); + equal(Discourse.Utilities.avatarUrl('/fake/template/{size}.png', 'tiny'), "/fake/template/20.png", "simple avatar url"); + equal(Discourse.Utilities.avatarUrl('/fake/template/{size}.png', 'large'), "/fake/template/45.png", "different size"); }); test("avatarImg", function() { - equal(Discourse.Utilities.avatarImg({username: 'eviltrout', size: 'tiny'}), - "", + var avatarTemplate = "/path/to/avatar/{size}.png"; + equal(Discourse.Utilities.avatarImg({avatarTemplate: avatarTemplate, size: 'tiny'}), + "", "it returns the avatar html"); - equal(Discourse.Utilities.avatarImg({username: 'eviltrout', size: 'tiny', title: 'evilest trout'}), - "", + equal(Discourse.Utilities.avatarImg({avatarTemplate: avatarTemplate, size: 'tiny', title: 'evilest trout'}), + "", "it adds a title if supplied"); - equal(Discourse.Utilities.avatarImg({username: 'eviltrout', size: 'tiny', extraClasses: 'evil fish'}), - "", + equal(Discourse.Utilities.avatarImg({avatarTemplate: avatarTemplate, size: 'tiny', extraClasses: 'evil fish'}), + "", "it adds extra classes if supplied"); - blank(Discourse.Utilities.avatarImg({username: 'weird*username', size: 'tiny'}), - "it doesn't render avatars for invalid usernames"); + blank(Discourse.Utilities.avatarImg({avatarTemplate: "", size: 'tiny'}), + "it doesn't render avatars for invalid avatar template"); });