custom avatar support

This commit is contained in:
Régis Hanol
2013-08-13 22:08:29 +02:00
parent e5e3164ea1
commit c867b67a0b
34 changed files with 576 additions and 201 deletions
@@ -252,12 +252,22 @@ Discourse.BBCode = {
// remove leading <br>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
@@ -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 + "</a>";
},
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 "<img width='" + size + "' height='" + size + "' src='" + url + "' class='" + classes + "'" + title + ">";
},
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'));
}
};
@@ -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") });
}
});
@@ -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.
@@ -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;
}
}
@@ -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' });
@@ -116,4 +116,33 @@ Discourse.PreferencesUsernameRoute = Discourse.RestrictedUserRoute.extend({
setupController: function(controller, user) {
controller.setProperties({ model: user, newUsername: user.get('username') });
}
});
});
/**
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 });
}
});
@@ -30,7 +30,7 @@
{{#unless showExtraInfo}}
<div class='current-username'>
{{#if currentUser}}
<span class='username'><a {{bindAttr href="currentUser.path"}}>{{currentUser.name}}</a></span>
<span class='username'><a {{bindAttr href="currentUser.path"}}>{{currentUser.name}}</a></span>
{{else}}
<button {{action showLogin}} class='btn btn-primary btn-small'>{{i18n log_in}}</button>
{{/if}}
@@ -85,7 +85,7 @@
</li>
<li class='current-user'>
{{#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}}
<div class="icon not-logged-in-avatar" {{action showLogin}}><i class='icon-user'></i></div>
{{/if}}
@@ -0,0 +1,39 @@
<form class="form-horizontal">
<div class="control-group">
<div class="controls">
<h3>{{i18n user.change_avatar.title}}</h3>
</div>
</div>
<div class="control-group">
<label class="control-label">{{i18n user.avatar.title}}</label>
<div class="controls">
<label class="radio">
<input type="radio" name="avatar" value="gravatar" {{action toggleUseUploadedAvatar false}}> {{avatar this imageSize="large" template="gravatar_template"}} {{i18n user.change_avatar.gravatar}} <a href="//gravatar.com/emails/" target="_blank" class="btn pad-left" title="{{i18n user.change_avatar.gravatar_title}}">{{i18n user.change}}</a>
</label>
{{#if has_uploaded_avatar}}
<label class="radio">
<input type="radio" name="avatar" value="uploaded_avatar" {{action toggleUseUploadedAvatar true}}> {{boundUploadedAvatar this imageSize="large"}} {{i18n user.change_avatar.uploaded_avatar}}
</label>
{{/if}}
</div>
</div>
<div class="control-group">
<div class="instructions">{{i18n user.change_avatar.upload_instructions}}</div>
<div class="controls">
<div>
<input type="file" id="avatar-input" accept="image/*">
</div>
<button {{action uploadAvatar}} {{bindAttr disabled="uploadDisabled"}} class="btn btn-primary">
<span class="add-upload"><i class="icon-picture"></i><i class="icon-plus"></i></span>
{{uploadButtonText}}
</button>
{{#if uploading}}
<span>{{i18n upload_selector.uploading}} {{uploadProgress}}%</span>
{{/if}}
</div>
</div>
</form>
@@ -47,7 +47,17 @@
{{avatar model imageSize="large"}}
</div>
<div class='instructions'>
{{{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}}
<a href="//gravatar.com/emails/" target="_blank" title="{{i18n user.change_avatar.gravatar_title}}" class="btn pad-left">{{i18n user.change}}</a>
{{/if}}
</div>
</div>
@@ -29,7 +29,7 @@
{{/if}}
</ul>
<div class='avatar-wrapper'>
{{boundAvatar model imageSize="120"}}
{{boundAvatar model imageSize="huge"}}
</div>
</div>
</div>
@@ -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')
});
@@ -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);
},
@@ -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')
});