diff --git a/app/assets/javascripts/discourse/components/utilities.js b/app/assets/javascripts/discourse/components/utilities.js index bf2b0f0ec32..f9bbd02e745 100644 --- a/app/assets/javascripts/discourse/components/utilities.js +++ b/app/assets/javascripts/discourse/components/utilities.js @@ -291,6 +291,54 @@ Discourse.Utilities = { } // otherwise, display a generic error message bootbox.alert(I18n.t('post.errors.upload')); + }, + + /** + Crop an image to be used as avatar. + Simulate the "centered square thumbnail" generation done server-side. + Uses only the first frame of animated gifs when they are disabled. + + @method cropAvatar + @param {String} url The url of the avatar + @param {String} fileType The file type of the uploaded file + @returns {Ember.Deferred} a promise that will eventually be the cropped avatar. + **/ + cropAvatar: function(url, fileType) { + if (Discourse.SiteSettings.allow_animated_avatars && fileType === "image/gif") { + // can't crop animated gifs... let the browser stretch the gif + return Ember.RSVP.resolve(url); + } else { + return Ember.Deferred.promise(function(promise) { + var image = document.createElement("img"); + // this event will be fired as soon as the image is loaded + image.onload = function(e) { + var img = e.target; + // computes the dimension & position (x, y) of the largest square we can fit in the image + var width = img.width, height = img.height, dimension, center, x, y; + if (width <= height) { + dimension = width; + center = height / 2; + x = 0; + y = center - (dimension / 2); + } else { + dimension = height; + center = width / 2; + x = center - (dimension / 2); + y = 0; + } + // set the size of the canvas to the maximum available size for avatars (browser will take care of downsizing the image) + var canvas = document.createElement("canvas"); + var size = Discourse.Utilities.getRawSize(Discourse.Utilities.translateSize("huge")); + canvas.height = canvas.width = size; + // draw the image into the canvas + canvas.getContext("2d").drawImage(img, x, y, dimension, dimension, 0, 0, size, size); + // retrieve the image from the canvas + promise.resolve(canvas.toDataURL(fileType)); + }; + // launch the onload event + image.src = url; + }); + } } }; diff --git a/app/assets/javascripts/discourse/views/modal/avatar_selector_view.js b/app/assets/javascripts/discourse/views/modal/avatar_selector_view.js index 0f685ed8ac5..287a1697369 100644 --- a/app/assets/javascripts/discourse/views/modal/avatar_selector_view.js +++ b/app/assets/javascripts/discourse/views/modal/avatar_selector_view.js @@ -51,11 +51,17 @@ Discourse.AvatarSelectorView = Discourse.ModalBodyView.extend({ // when the upload is successful $upload.on("fileuploaddone", function (e, data) { - // set some properties + // indicates the users is using an uploaded avatar view.get("controller").setProperties({ has_uploaded_avatar: true, - use_uploaded_avatar: true, - uploaded_avatar_template: data.result.url + use_uploaded_avatar: true + }); + // in order to be as much responsive as possible, we're cheating a bit here + // indeed, the server gives us back the url to the file we've just uploaded + // often, this file is not a square, so we need to crop it properly + // this will also capture the first frame of animated avatars when they're not allowed + Discourse.Utilities.cropAvatar(data.result.url, data.files[0].type).then(function(avatarTemplate) { + view.get("controller").set("uploaded_avatar_template", avatarTemplate); }); }); diff --git a/app/models/optimized_image.rb b/app/models/optimized_image.rb index 681059c8c2c..80a7b204504 100644 --- a/app/models/optimized_image.rb +++ b/app/models/optimized_image.rb @@ -19,7 +19,7 @@ class OptimizedImage < ActiveRecord::Base temp_file = Tempfile.new(["discourse-thumbnail", File.extname(original_path)]) temp_path = temp_file.path - if ImageSorcery.new(original_path).convert(temp_path, resize: "#{width}x#{height}") + if ImageSorcery.new("#{original_path}[0]").convert(temp_path, resize: "#{width}x#{height}") thumbnail = OptimizedImage.create!( upload_id: upload.id, sha1: Digest::SHA1.file(temp_path).hexdigest, diff --git a/app/models/site_setting.rb b/app/models/site_setting.rb index bf560381f35..062099c108c 100644 --- a/app/models/site_setting.rb +++ b/app/models/site_setting.rb @@ -245,6 +245,7 @@ class SiteSetting < ActiveRecord::Base setting(:username_change_period, 3) # days client_setting(:allow_uploaded_avatars, true) + client_setting(:allow_animated_avatars, false) def self.generate_api_key! self.api_key = SecureRandom.hex(32) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index ca78b45b199..015edf89c63 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -665,7 +665,8 @@ en: delete_all_posts_max: "The maximum number of posts that can be deleted at once with the Delete All Posts button. If a user has more than this many posts, the posts cannot all be deleted at once and the user can't be deleted." username_change_period: "The number of days after registration that accounts can change their username." - allow_uploaded_avatars: "Allow support for uploaded avatars" + allow_uploaded_avatars: "Allow users to upload their custom avatars" + allow_animated_avatars: "Allow users to use animated gif for avatars" notification_types: mentioned: "%{display_username} mentioned you in %{link}" diff --git a/lib/jobs/generate_avatars.rb b/lib/jobs/generate_avatars.rb index abc4960a07f..eac7e739a64 100644 --- a/lib/jobs/generate_avatars.rb +++ b/lib/jobs/generate_avatars.rb @@ -15,6 +15,10 @@ module Jobs Discourse.store.path_for(upload) end + # we'll extract the first frame when it's a gif + source = original_path + source << "[0]" unless SiteSetting.allow_animated_avatars + [120, 45, 32, 25, 20].each do |s| # handle retina too [s, s * 2].each do |size| @@ -22,7 +26,7 @@ module Jobs temp_file = Tempfile.new(["discourse-avatar", File.extname(original_path)]) temp_path = temp_file.path # create a centered square thumbnail - if ImageSorcery.new(original_path).convert(temp_path, gravity: "center", thumbnail: "#{size}x#{size}^", extent: "#{size}x#{size}", background: "transparent") + if ImageSorcery.new(source).convert(temp_path, gravity: "center", thumbnail: "#{size}x#{size}^", extent: "#{size}x#{size}", background: "transparent") Discourse.store.store_avatar(temp_file, upload, size) end # close && remove temp file diff --git a/test/javascripts/components/utilities_test.js b/test/javascripts/components/utilities_test.js index a0dae30b8ea..7981dd46570 100644 --- a/test/javascripts/components/utilities_test.js +++ b/test/javascripts/components/utilities_test.js @@ -132,3 +132,16 @@ test("avatarImg", function() { blank(Discourse.Utilities.avatarImg({avatarTemplate: "", size: 'tiny'}), "it doesn't render avatars for invalid avatar template"); }); + +module("Discourse.Utilities.cropAvatar with animated avatars", { + setup: function() { Discourse.SiteSettings.allow_animated_avatars = true; } +}); + +asyncTestDiscourse("cropAvatar", function() { + expect(1); + + Discourse.Utilities.cropAvatar("/path/to/avatar.gif", "image/gif").then(function(avatarTemplate) { + equal(avatarTemplate, "/path/to/avatar.gif", "returns the url to the gif when animated gif are enabled"); + start(); + }); +});