FEATURE: selectable avatars

This commit is contained in:
Régis Hanol 2018-07-18 12:57:43 +02:00
parent a24b9981c6
commit 6d6e026e3c
28 changed files with 435 additions and 80 deletions

View File

@ -0,0 +1,15 @@
import showModal from "discourse/lib/show-modal";
export default Ember.Component.extend({
actions: {
showUploadModal({ value, setting }) {
showModal("admin-uploaded-image-list", {
admin: true,
title: `admin.site_settings.${setting.setting}.title`,
model: { value, setting },
}).setProperties({
save: v => this.set("value", v)
});
}
}
});

View File

@ -0,0 +1,27 @@
import { on, observes } from "ember-addons/ember-computed-decorators";
import ModalFunctionality from 'discourse/mixins/modal-functionality';
export default Ember.Controller.extend(ModalFunctionality, {
@on("init")
@observes("model.value")
_setup() {
const value = this.get("model.value");
this.set("images", value && value.length ? value.split("\n") : []);
},
actions: {
uploadDone({ url }) {
this.get("images").addObject(url);
},
remove(url) {
this.get("images").removeObject(url);
},
close() {
this.save(this.get("images").join("\n"));
this.send("closeModal");
}
}
});

View File

@ -9,7 +9,8 @@ const CUSTOM_TYPES = [
"host_list",
"category_list",
"value_list",
"category"
"category",
"uploaded_image_list",
];
export default Ember.Mixin.create({

View File

@ -0,0 +1,2 @@
{{d-button label="admin.site_settings.uploaded_image_list.label" action="showUploadModal" actionParam=(hash value=value setting=setting)}}
<div class='desc'>{{{unbound setting.description}}}</div>

View File

@ -0,0 +1,15 @@
{{#d-modal-body class="uploaded-image-list"}}
<div class="selectable-avatars">
{{#each images as |image|}}
<div class="selectable-avatar" {{action "remove" image}}>
{{bound-avatar-template image "huge"}}
</div>
{{else}}
<p>{{i18n "admin.site_settings.uploaded_image_list.empty"}}</p>
{{/each}}
</div>
{{/d-modal-body}}
<div class="modal-footer">
{{d-button action=(action "close") label="close"}}
{{images-uploader uploading=uploading done="uploadDone" class="pull-right"}}
</div>

View File

@ -0,0 +1,20 @@
import computed from "ember-addons/ember-computed-decorators";
import UploadMixin from "discourse/mixins/upload";
export default Em.Component.extend(UploadMixin, {
type: "avatar",
tagName: "span",
@computed("uploading")
uploadButtonText(uploading) {
return uploading ? I18n.t("uploading") : I18n.t("user.change_avatar.upload_picture");
},
validateUploadedFilesOptions() {
return { imagesOnly: true };
},
uploadDone(upload) {
this.sendAction("done", upload);
},
});

View File

@ -1,7 +1,6 @@
import computed from "ember-addons/ember-computed-decorators";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { ajax } from "discourse/lib/ajax";
import { allowsImages } from "discourse/lib/utilities";
export default Ember.Controller.extend(ModalFunctionality, {

View File

@ -508,19 +508,21 @@ const User = RestModel.extend({
data: { upload_id, type }
}
).then(() =>
this.setProperties({
avatar_template,
uploaded_avatar_id: upload_id
})
this.setProperties({ avatar_template, uploaded_avatar_id: upload_id })
);
},
selectAvatar(avatarUrl) {
return ajax(
userPath(`${this.get("username_lower")}/preferences/avatar/select`),
{ type: "PUT", data: { url: avatarUrl } }
).then(result => this.setProperties(result));
},
isAllowedToUploadAFile(type) {
return (
this.get("staff") ||
return this.get("staff") ||
this.get("trust_level") > 0 ||
Discourse.SiteSettings["newuser_max_" + type + "s"] > 0
);
Discourse.SiteSettings[`newuser_max_${type}s`] > 0;
},
createInvite(email, group_names, custom_message) {

View File

@ -1,23 +1,15 @@
import RestrictedUserRoute from "discourse/routes/restricted-user";
import showModal from "discourse/lib/show-modal";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { ajax } from "discourse/lib/ajax";
export default RestrictedUserRoute.extend({
model() {
return this.modelFor("user");
},
setupController(controller, user) {
controller.setProperties({
model: user
});
},
actions: {
showAvatarSelector() {
showModal("avatar-selector");
// all the properties needed for displaying the avatar selector modal
const props = this.modelFor("user").getProperties(
"id",
"email",
@ -42,15 +34,32 @@ export default RestrictedUserRoute.extend({
props.selected = "uploaded";
}
this.controllerFor("avatar-selector").setProperties(props);
const controller = showModal("avatar-selector");
controller.setProperties(props);
if (this.siteSettings.selectable_avatars_enabled) {
ajax("/site/selectable-avatars.json")
.then(avatars => controller.set("selectableAvatars", avatars));
}
},
selectAvatar(url) {
const user = this.modelFor("user");
const controller = this.controllerFor("avatar-selector");
user
.selectAvatar(url)
.then(() => bootbox.alert(I18n.t("user.change_avatar.cache_notice")))
.catch(popupAjaxError)
.finally(() => controller.send("closeModal"));
},
saveAvatarSelection() {
const user = this.modelFor("user"),
controller = this.controllerFor("avatar-selector"),
selectedUploadId = controller.get("selectedUploadId"),
selectedAvatarTemplate = controller.get("selectedAvatarTemplate"),
type = controller.get("selected");
const user = this.modelFor("user");
const controller = this.controllerFor("avatar-selector");
const selectedUploadId = controller.get("selectedUploadId");
const selectedAvatarTemplate = controller.get("selectedAvatarTemplate");
const type = controller.get("selected");
user
.pickAvatar(selectedUploadId, type, selectedAvatarTemplate)
@ -64,10 +73,8 @@ export default RestrictedUserRoute.extend({
);
bootbox.alert(I18n.t("user.change_avatar.cache_notice"));
})
.catch(popupAjaxError);
// saves the data back
controller.send("closeModal");
.catch(popupAjaxError)
.finally(() => controller.send("closeModal"));
}
}
});

View File

@ -0,0 +1,7 @@
<label class="btn" disabled={{uploading}} title="{{i18n "admin.site_settings.uploaded_image_list.upload.title"}}">
{{d-icon "picture-o"}}&nbsp;{{uploadButtonText}}
<input disabled={{uploading}} type="file" accept="image/*" multiple style="visibility: hidden; position: absolute;" />
</label>
{{#if uploading}}
<span>{{i18n 'upload_selector.uploading'}} {{uploadProgress}}%</span>
{{/if}}

View File

@ -1,37 +1,49 @@
{{#d-modal-body title="user.change_avatar.title" class="avatar-selector"}}
<div class="avatar-choice">
{{radio-button id="system-avatar" name="avatar" value="system" selection=selected}}
<label class="radio" for="system-avatar">{{bound-avatar-template system_avatar_template "large"}} {{{i18n 'user.change_avatar.letter_based'}}}</label>
</div>
<div class="avatar-choice">
{{radio-button id="gravatar" name="avatar" value="gravatar" selection=selected}}
<label class="radio" for="gravatar">{{bound-avatar-template gravatar_avatar_template "large"}} {{{i18n 'user.change_avatar.gravatar'}}} {{email}}</label>
{{d-button action="refreshGravatar" title="user.change_avatar.refresh_gravatar_title" disabled=gravatarRefreshDisabled icon="refresh"}}
{{#if gravatarFailed}}
<p class="error">{{I18n 'user.change_avatar.gravatar_failed'}}</p>
{{/if}}
</div>
{{#if allowAvatarUpload}}
<div class="avatar-choice">
{{radio-button id="uploaded-avatar" name="avatar" value="uploaded" selection=selected}}
<label class="radio" for="uploaded-avatar">
{{#if custom_avatar_template}}
{{bound-avatar-template custom_avatar_template "large"}}
{{i18n 'user.change_avatar.uploaded_avatar'}}
{{else}}
{{i18n 'user.change_avatar.uploaded_avatar_empty'}}
{{/if}}
</label>
{{avatar-uploader user_id=id
uploadedAvatarTemplate=custom_avatar_template
uploadedAvatarId=custom_avatar_upload_id
uploading=uploading
done="uploadComplete"}}
{{#if siteSettings.selectable_avatars_enabled}}
<div class="selectable-avatars">
{{#each selectableAvatars as |avatar|}}
<div class="selectable-avatar" {{action "selectAvatar" avatar}}>
{{bound-avatar-template avatar "huge"}}
</div>
{{/each}}
</div>
{{else}}
<div class="avatar-choice">
{{radio-button id="system-avatar" name="avatar" value="system" selection=selected}}
<label class="radio" for="system-avatar">{{bound-avatar-template system_avatar_template "large"}} {{{i18n 'user.change_avatar.letter_based'}}}</label>
</div>
<div class="avatar-choice">
{{radio-button id="gravatar" name="avatar" value="gravatar" selection=selected}}
<label class="radio" for="gravatar">{{bound-avatar-template gravatar_avatar_template "large"}} {{{i18n 'user.change_avatar.gravatar'}}} {{email}}</label>
{{d-button action="refreshGravatar" title="user.change_avatar.refresh_gravatar_title" disabled=gravatarRefreshDisabled icon="refresh"}}
{{#if gravatarFailed}}
<p class="error">{{I18n 'user.change_avatar.gravatar_failed'}}</p>
{{/if}}
</div>
{{#if allowAvatarUpload}}
<div class="avatar-choice">
{{radio-button id="uploaded-avatar" name="avatar" value="uploaded" selection=selected}}
<label class="radio" for="uploaded-avatar">
{{#if custom_avatar_template}}
{{bound-avatar-template custom_avatar_template "large"}}
{{i18n 'user.change_avatar.uploaded_avatar'}}
{{else}}
{{i18n 'user.change_avatar.uploaded_avatar_empty'}}
{{/if}}
</label>
{{avatar-uploader user_id=id
uploadedAvatarTemplate=custom_avatar_template
uploadedAvatarId=custom_avatar_upload_id
uploading=uploading
done="uploadComplete"}}
</div>
{{/if}}
{{/if}}
{{/d-modal-body}}
<div class="modal-footer">
{{d-button action="saveAvatarSelection" class="btn-primary" disabled=uploading label="save"}}
{{d-modal-cancel close=(action "closeModal")}}
</div>
{{#unless siteSettings.selectable_avatars_enabled}}
<div class="modal-footer">
{{d-button action="saveAvatarSelection" class="btn-primary" disabled=uploading label="save"}}
{{d-modal-cancel close=(action "closeModal")}}
</div>
{{/unless}}

View File

@ -106,3 +106,4 @@
color: $danger;
}
}

View File

@ -661,3 +661,20 @@
#user-card .staged {
font-style: italic;
}
.selectable-avatars {
max-height: 350px;
margin-bottom: 1em;
text-align: justify;
.selectable-avatar {
margin: 5px;
display: inline-block;
.avatar {
width: 60px;
height: 60px;
&:hover {
box-shadow: 0 0 10px $primary;
}
}
}
}

View File

@ -25,6 +25,16 @@ class SiteController < ApplicationController
render json: custom_emoji
end
def selectable_avatars
avatars = if SiteSetting.selectable_avatars_enabled?
(SiteSetting.selectable_avatars.presence || "").split("\n")
else
[]
end
render json: avatars, root: false
end
def basic_info
results = {
logo_url: UrlHelper.absolute(SiteSetting.logo_url),

View File

@ -11,8 +11,9 @@ class UsersController < ApplicationController
requires_login only: [
:username, :update, :user_preferences_redirect, :upload_user_image,
:pick_avatar, :destroy_user_image, :destroy, :check_emails, :topic_tracking_state,
:preferences, :create_second_factor, :update_second_factor, :create_second_factor_backup
:pick_avatar, :destroy_user_image, :destroy, :check_emails,
:topic_tracking_state, :preferences, :create_second_factor,
:update_second_factor, :create_second_factor_backup, :select_avatar
]
skip_before_action :check_xhr, only: [
@ -885,6 +886,46 @@ class UsersController < ApplicationController
render json: success_json
end
def select_avatar
user = fetch_user_from_params
guardian.ensure_can_edit!(user)
url = params[:url]
if url.blank?
return render json: failed_json, status: 422
end
unless SiteSetting.selectable_avatars_enabled
return render json: failed_json, status: 422
end
if SiteSetting.selectable_avatars.blank?
return render json: failed_json, status: 422
end
unless SiteSetting.selectable_avatars[url]
return render json: failed_json, status: 422
end
unless upload = Upload.find_by(url: url)
return render json: failed_json, status: 422
end
user.uploaded_avatar_id = upload.id
user.save!
avatar = user.user_avatar || user.create_user_avatar
avatar.custom_upload_id = upload.id
avatar.save!
render json: {
avatar_template: user.avatar_template,
custom_avatar_template: user.avatar_template,
uploaded_avatar_id: upload.id,
}
end
def destroy_user_image
user = fetch_user_from_params
guardian.ensure_can_edit!(user)

View File

@ -24,7 +24,8 @@ module Jobs
SiteSetting.logo_small_url,
SiteSetting.favicon_url,
SiteSetting.apple_touch_icon_url,
].map do |url|
*SiteSetting.selectable_avatars.split("\n"),
].flatten.map do |url|
if url.present?
url = url.dup

View File

@ -104,6 +104,7 @@ class User < ActiveRecord::Base
after_create :create_user_stat
after_create :create_user_option
after_create :create_user_profile
after_create :set_random_avatar
after_create :ensure_in_trust_level_group
after_create :set_default_categories_preferences
@ -612,8 +613,7 @@ class User < ActiveRecord::Base
end
def self.gravatar_template(email)
email_hash = self.email_hash(email)
"//www.gravatar.com/avatar/#{email_hash}.png?s={size}&r=pg&d=identicon"
"//www.gravatar.com/avatar/#{self.email_hash(email)}.png?s={size}&r=pg&d=identicon"
end
# Don't pass this up to the client - it's meant for server side use
@ -628,19 +628,19 @@ class User < ActiveRecord::Base
UrlHelper.schemaless UrlHelper.absolute avatar_template
end
def self.username_hash(username)
username.each_char.reduce(0) do |result, char|
[((result << 5) - result) + char.ord].pack('L').unpack('l').first
end.abs
end
def self.default_template(username)
if SiteSetting.default_avatars.present?
split_avatars = SiteSetting.default_avatars.split("\n")
if split_avatars.present?
hash = username.each_char.reduce(0) do |result, char|
[((result << 5) - result) + char.ord].pack('L').unpack('l').first
end
split_avatars[hash.abs % split_avatars.size]
end
else
system_avatar_template(username)
urls = SiteSetting.default_avatars.split("\n")
return urls[username_hash(username) % urls.size] if urls.present?
end
system_avatar_template(username)
end
def self.avatar_template(username, uploaded_avatar_id)
@ -1018,6 +1018,18 @@ class User < ActiveRecord::Base
UserProfile.create(user_id: id)
end
def set_random_avatar
if SiteSetting.selectable_avatars_enabled? && SiteSetting.selectable_avatars.present?
urls = SiteSetting.selectable_avatars.split("\n")
if urls.present?
if upload = Upload.find_by(url: urls.sample)
update_column(:uploaded_avatar_id, upload.id)
UserAvatar.create(user_id: id, custom_upload_id: upload.id)
end
end
end
end
def anonymous?
SiteSetting.allow_anonymous_posting &&
trust_level >= 1 &&

View File

@ -3797,6 +3797,14 @@ en:
clear_filter: "Clear"
add_url: "add URL"
add_host: "add host"
uploaded_image_list:
label: "Edit list"
empty: "There are no pictures yet. Please upload one."
upload:
label: "Upload"
title: "Upload image(s)"
selectable_avatars:
title: "List of avatars users can choose from"
categories:
all_results: 'All'
required: 'Required'

View File

@ -1339,6 +1339,9 @@ en:
external_system_avatars_enabled: "Use external system avatars service."
external_system_avatars_url: "URL of the external system avatars service. Allowed substitutions are {username} {first_letter} {color} {size}"
selectable_avatars_enabled: "Force users to choose an avatar from the list."
selectable_avatars: "List of avatars users can choose from."
default_opengraph_image_url: "URL of the default opengraph image."
twitter_summary_large_image_url: "URL of the default Twitter summary card image (should be at least 280px in width, and at least 150px in height)."

View File

@ -54,6 +54,7 @@ Discourse::Application.routes.draw do
get "site/basic-info" => 'site#basic_info'
get "site/statistics" => 'site#statistics'
get "site/selectable-avatars" => "site#selectable_avatars"
get "srv/status" => "forums#status"
@ -405,6 +406,7 @@ Discourse::Application.routes.draw do
get "#{root_path}/:username/preferences/second-factor-backup" => "users#preferences", constraints: { username: RouteFormat.username }
delete "#{root_path}/:username/preferences/user_image" => "users#destroy_user_image", constraints: { username: RouteFormat.username }
put "#{root_path}/:username/preferences/avatar/pick" => "users#pick_avatar", constraints: { username: RouteFormat.username }
put "#{root_path}/:username/preferences/avatar/select" => "users#select_avatar", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/staff-info" => "users#staff_info", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/summary" => "users#summary", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/invited" => "users#invited", constraints: { username: RouteFormat.username }

View File

@ -959,6 +959,12 @@ files:
client: true
regex: '^((https?:)?\/)?\/.+[^\/]'
shadowed_by_global: true
selectable_avatars_enabled:
default: false
client: true
selectable_avatars:
default: ''
type: uploaded_image_list
allow_all_attachments_for_group_messages: false
png_to_jpg_quality:
default: 95

View File

@ -29,7 +29,8 @@ class SiteSettings::TypeSupervisor
regex: 13,
email: 14,
username: 15,
category: 16
category: 16,
uploaded_image_list: 17,
)
end

View File

@ -73,6 +73,12 @@ describe SiteSettings::TypeSupervisor do
it "'username' should be at 15th position" do
expect(SiteSettings::TypeSupervisor.types[:username]).to eq(15)
end
it "'category' should be at 16th position" do
expect(SiteSettings::TypeSupervisor.types[:category]).to eq(16)
end
it "'uploaded_image_list' should be at 17th position" do
expect(SiteSettings::TypeSupervisor.types[:uploaded_image_list]).to eq(17)
end
end
end

View File

@ -46,12 +46,27 @@ describe Jobs::CleanUpUploads do
it "does not clean up uploads in site settings" do
logo_upload = fabricate_upload
logo_small_upload = fabricate_upload
favicon_upload = fabricate_upload
apple_touch_icon_upload = fabricate_upload
avatar1_upload = fabricate_upload
avatar2_upload = fabricate_upload
SiteSetting.logo_url = logo_upload.url
SiteSetting.logo_small_url = logo_small_upload.url
SiteSetting.favicon_url = favicon_upload.url
SiteSetting.apple_touch_icon_url = apple_touch_icon_upload.url
SiteSetting.selectable_avatars = [avatar1_upload.url, avatar2_upload.url].join("\n")
Jobs::CleanUpUploads.new.execute(nil)
expect(Upload.exists?(id: @upload.id)).to eq(false)
expect(Upload.exists?(id: logo_upload.id)).to eq(true)
expect(Upload.exists?(id: logo_small_upload.id)).to eq(true)
expect(Upload.exists?(id: favicon_upload.id)).to eq(true)
expect(Upload.exists?(id: apple_touch_icon_upload.id)).to eq(true)
expect(Upload.exists?(id: avatar1_upload.id)).to eq(true)
expect(Upload.exists?(id: avatar2_upload.id)).to eq(true)
end
it "does not clean up uploads in site settings when they use the CDN" do

View File

@ -1771,4 +1771,18 @@ describe User do
end
end
describe "set_random_avatar" do
it "sets a random avatar when selectable avatars is enabled" do
avatar1 = Fabricate(:upload)
avatar2 = Fabricate(:upload)
SiteSetting.selectable_avatars_enabled = true
SiteSetting.selectable_avatars = [avatar1.url, avatar2.url].join("\n")
user = Fabricate(:user)
expect(user.uploaded_avatar_id).not_to be(nil)
expect([avatar1.id, avatar2.id]).to include(user.uploaded_avatar_id)
expect(user.user_avatar.custom_upload_id).to eq(user.uploaded_avatar_id)
end
end
end

View File

@ -56,4 +56,30 @@ describe SiteController do
expect(response).to redirect_to '/'
end
end
describe '.selectable_avatars' do
before do
SiteSetting.selectable_avatars = "https://www.discourse.org\nhttps://meta.discourse.org"
end
it 'returns empty array when selectable avatars is disabled' do
SiteSetting.selectable_avatars_enabled = false
get "/site/selectable-avatars.json"
json = JSON.parse(response.body)
expect(response.status).to eq(200)
expect(json).to eq([])
end
it 'returns an array when selectable avatars is enabled' do
SiteSetting.selectable_avatars_enabled = true
get "/site/selectable-avatars.json"
json = JSON.parse(response.body)
expect(response.status).to eq(200)
expect(json).to contain_exactly("https://www.discourse.org", "https://meta.discourse.org")
end
end
end

View File

@ -1785,6 +1785,61 @@ describe UsersController do
end
end
describe '#select_avatar' do
it 'raises an error when not logged in' do
put "/u/asdf/preferences/avatar/select.json", params: { url: "https://meta.discourse.org" }
expect(response.status).to eq(403)
end
context 'while logged in' do
let!(:user) { sign_in(Fabricate(:user)) }
let(:avatar1) { Fabricate(:upload) }
let(:avatar2) { Fabricate(:upload) }
let(:url) { "https://www.discourse.org" }
it 'raises an error when url is blank' do
put "/u/#{user.username}/preferences/avatar/select.json", params: { url: "" }
expect(response.status).to eq(422)
end
it 'raises an error when selectable avatars is disabled' do
put "/u/#{user.username}/preferences/avatar/select.json", params: { url: url }
expect(response.status).to eq(422)
end
context 'selectable avatars is enabled' do
before { SiteSetting.selectable_avatars_enabled = true }
it 'raises an error when selectable avatars is empty' do
put "/u/#{user.username}/preferences/avatar/select.json", params: { url: url }
expect(response.status).to eq(422)
end
context 'selectable avatars is properly setup' do
before do
SiteSetting.selectable_avatars = [avatar1.url, avatar2.url].join("\n")
end
it 'raises an error when url is not in selectable avatars list' do
put "/u/#{user.username}/preferences/avatar/select.json", params: { url: url }
expect(response.status).to eq(422)
end
it 'can successfully select an avatar' do
put "/u/#{user.username}/preferences/avatar/select.json", params: { url: avatar1.url }
expect(response.status).to eq(200)
expect(user.reload.uploaded_avatar_id).to eq(avatar1.id)
expect(user.user_avatar.reload.custom_upload_id).to eq(avatar1.id)
end
end
end
end
end
describe '#destroy_user_image' do
it 'raises an error when not logged in' do

View File

@ -163,11 +163,41 @@ QUnit.test("second factor backup", assert => {
});
});
QUnit.test("default avatar selector", assert => {
visit("/u/eviltrout/preferences");
click(".pref-avatar .btn");
andThen(() => {
assert.ok(exists(".avatar-choice", "opens the avatar selection modal"));
});
});
acceptance("Avatar selector when selectable avatars is enabled", {
loggedIn: true,
settings: { selectable_avatars_enabled: true },
beforeEach() {
// prettier-ignore
server.get("/site/selectable-avatars.json", () => { //eslint-disable-line
return [200, { "Content-Type": "application/json" }, [
"https://www.discourse.org",
"https://meta.discourse.org",
]];
});
}
});
QUnit.test("selectable avatars", assert => {
visit("/u/eviltrout/preferences");
click(".pref-avatar .btn");
andThen(() => {
assert.ok(exists(".selectable-avatars", "opens the avatar selection modal"));
});
});
acceptance("User Preferences when badges are disabled", {
loggedIn: true,
settings: {
enable_badges: false
}
settings: { enable_badges: false }
});
QUnit.test("visit my preferences", assert => {