Merge branch 'master' of github.com:discourse/discourse

This commit is contained in:
Sam 2013-08-18 21:30:14 +10:00
commit 3f5b5f1581
44 changed files with 508 additions and 501 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

View file

@ -119,6 +119,24 @@ Discourse.AdminUsersListController = Ember.ArrayController.extend(Discourse.Pres
approveUsers: function() {
Discourse.AdminUser.bulkApprove(this.get('content').filterProperty('selected'));
this.refreshUsers();
},
/**
Reject all the currently selected users.
@method rejectUsers
**/
rejectUsers: function() {
var controller = this;
Discourse.AdminUser.bulkReject(this.get('content').filterProperty('selected')).then(function(result){
var message = I18n.t("admin.users.reject_successful", {count: result.success});
if (result.failed > 0) {
message += ' ' + I18n.t("admin.users.reject_failures", {count: result.failed});
message += ' ' + I18n.t("admin.user.delete_forbidden", {count: Discourse.SiteSettings.delete_user_max_age});
}
bootbox.alert(message);
controller.refreshUsers();
});
}
});

View file

@ -343,6 +343,21 @@ Discourse.AdminUser.reopenClass({
});
},
bulkReject: function(users) {
_.each(users, function(user){
user.set('can_approve', false);
user.set('selected', false);
});
return Discourse.ajax("/admin/users/reject-bulk", {
type: 'DELETE',
data: {
users: users.map(function(u) { return u.id; }),
context: window.location.pathname
}
});
},
find: function(username) {
return Discourse.ajax("/admin/users/" + username).then(function (result) {
result.loadedDetails = true;

View file

@ -20,6 +20,7 @@
{{#if hasSelection}}
<div id='selected-controls'>
<button {{action approveUsers}} class='btn'>{{countI18n admin.users.approved_selected countBinding="selectedCount"}}</button>
<button {{action rejectUsers}} class='btn btn-danger'>{{countI18n admin.users.reject_selected countBinding="selectedCount"}}</button>
</div>
{{/if}}

View file

@ -0,0 +1,46 @@
/**
The modal for selecting an avatar
@class AvatarSelectorController
@extends Discourse.Controller
@namespace Discourse
@uses Discourse.ModalFunctionality
@module Discourse
**/
Discourse.AvatarSelectorController = Discourse.Controller.extend(Discourse.ModalFunctionality, {
init: function() {
// copy some data to support the cancel action
this.setProperties(this.get("currentUser").getProperties(
"username",
"has_uploaded_avatar",
"use_uploaded_avatar",
"gravatar_template",
"uploaded_avatar_template"
));
},
toggleUseUploadedAvatar: function(toggle) {
this.set("use_uploaded_avatar", toggle);
},
saveAvatarSelection: function() {
// sends the information to the server if it has changed
if (this.get("use_uploaded_avatar") !== this.get("currentUser.use_uploaded_avatar")) {
var data = { use_uploaded_avatar: this.get("use_uploaded_avatar") };
Discourse.ajax("/users/" + this.get("currentUser.username") + "/preferences/avatar/toggle", { type: 'PUT', data: data });
}
// saves the data back to the currentUser object
var currentUser = this.get("currentUser");
currentUser.setProperties(this.getProperties(
"has_uploaded_avatar",
"use_uploaded_avatar",
"gravatar_template",
"uploaded_avatar_template"
));
if (this.get("use_uploaded_avatar")) {
currentUser.set("avatar_template", this.get("uploaded_avatar_template"));
} else {
currentUser.set("avatar_template", this.get("gravatar_template"));
}
}
});

View file

@ -1,84 +0,0 @@
/**
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") });
}
});

View file

@ -171,24 +171,9 @@ Handlebars.registerHelper('avatar', function(user, options) {
Ember.Handlebars.registerBoundHelper('boundAvatar', function(user, options) {
return new Handlebars.SafeString(Discourse.Utilities.avatarImg({
size: options.hash.imageSize,
avatarTemplate: Em.get(user, 'avatar_template')
avatarTemplate: Em.get(user, options.hash.template || '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');
}, 'avatar_template', 'uploaded_avatar_template', 'gravatar_template');
/**
Nicely format a date without a binding since the date doesn't need to change.

View file

@ -13,6 +13,13 @@ Discourse.PreferencesRoute = Discourse.RestrictedUserRoute.extend({
renderTemplate: function() {
this.render('preferences', { into: 'user', outlet: 'userOutlet', controller: 'preferences' });
},
events: {
showAvatarSelector: function() {
Discourse.Route.showModal(this, 'avatarSelector');
this.controllerFor("avatarSelector").init();
}
}
});
@ -117,32 +124,3 @@ Discourse.PreferencesUsernameRoute = Discourse.RestrictedUserRoute.extend({
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 });
}
});

View file

@ -2,7 +2,7 @@
<div class='topic-meta-data span2'>
<div class='contents'>
<div>
<a href='/users/{{unbound username}}'>{{avatar this imageSize="medium"}}</a>
<a href='/users/{{unbound username}}'>{{avatar this imageSize="large"}}</a>
</div>
<h5 {{bindAttr class="staff new_user"}}><a href='{{unbound usernameUrl}}'>{{breakUp username}}</a></h5>
</div>

View file

@ -5,6 +5,6 @@
</div>
<div class="modal-footer">
<button class='btn btn-primary' {{action saveAutoClose}} data-dismiss="modal">{{i18n topic.auto_close_save}}</button>
<button class='btn' data-dismiss="modal">{{i18n topic.auto_close_cancel}}</button>
<a data-dismiss="modal">{{i18n topic.auto_close_cancel}}</a>
<button class='btn pull-right' {{action removeAutoClose}} data-dismiss="modal">{{i18n topic.auto_close_remove}}</button>
</div>
</div>

View file

@ -0,0 +1,29 @@
<div class="modal-body">
<div>
<input type="radio" id="avatar" name="avatar" value="gravatar" {{action toggleUseUploadedAvatar false}}>
<label class="radio" for="avatar">{{avatar controller imageSize="large" template="gravatar_template"}} {{{i18n user.change_avatar.gravatar}}} {{currentUser.email}}</label>
<a href="//gravatar.com/emails" target="_blank" title="{{i18n user.change_avatar.gravatar_title}}" class="btn"><i class="icon-pencil"></i></a>
<div>
<input type="radio" id="uploaded_avatar" name="avatar" value="uploaded_avatar" {{action toggleUseUploadedAvatar true}} {{bindAttr disabled="view.uploadedAvatarDisabled" }}>
<label class="radio" for="uploaded_avatar">
{{#if has_uploaded_avatar}}
{{boundAvatar controller imageSize="large" template="uploaded_avatar_template"}} {{i18n user.change_avatar.uploaded_avatar}}
{{else}}
{{i18n user.change_avatar.uploaded_avatar_empty}}
{{/if}}
</label>
<button id="fake-avatar-input" class="btn" {{bindAttr disabled="view.uploading"}} title="{{i18n user.change_avatar.upload_title}}">
<i class="icon-picture"></i>&nbsp;{{view.uploadButtonText}}
</button>
<input type="file" id="avatar-input" accept="image/*" style="display:none">
{{#if view.uploading}}
<span>{{i18n upload_selector.uploading}} {{view.uploadProgress}}%</span>
{{/if}}
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" {{action saveAvatarSelection}} data-dismiss="modal">{{i18n save}}</button>
<a data-dismiss="modal">{{i18n cancel}}</a>
</div>

View file

@ -1,39 +0,0 @@
<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>

View file

@ -37,14 +37,15 @@
<div class="control-group">
<label class="control-label">{{i18n user.password.title}}</label>
<div class="controls">
<a href="#" {{action changePassword}} class='btn'><i class="icon-pencil"></i></a> {{passwordProgress}}
<a href="#" {{action changePassword}} class='btn'>{{i18n user.change_password.action}}</a> {{passwordProgress}}
</div>
</div>
<div class="control-group">
<label class="control-label">{{i18n user.avatar.title}}</label>
<div class="controls">
{{avatar model imageSize="large"}}
{{boundAvatar model imageSize="large"}}
<button {{action showAvatarSelector}} class="btn pad-left">{{i18n user.change}}</button>
</div>
<div class='instructions'>
{{#if Discourse.SiteSettings.allow_uploaded_avatars}}
@ -53,7 +54,6 @@
{{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>

View file

@ -17,11 +17,12 @@ Discourse.TopicListItemView = Discourse.GroupedView.extend({
highlight: function() {
var $topic = this.$();
var originalCol = $topic.css('backgroundColor');
$topic.css({
backgroundColor: "#ffffcc"
}).animate({
backgroundColor: originalCol
}, 2500);
$topic
.addClass('highlighted')
.stop()
.animate({ backgroundColor: originalCol }, 2500, 'swing', function(){
$topic.removeClass('highlighted');
});
},
didInsertElement: function() {

View file

@ -0,0 +1,89 @@
/**
This view handles the avatar selection interface
@class AvatarSelectorView
@extends Discourse.ModalBodyView
@namespace Discourse
@module Discourse
**/
Discourse.AvatarSelectorView = Discourse.ModalBodyView.extend({
templateName: 'modal/avatar_selector',
classNames: ['avatar-selector'],
title: I18n.t('user.change_avatar.title'),
uploading: false,
uploadProgress: 0,
uploadedAvatarDisabled: Em.computed.not("controller.has_uploaded_avatar"),
didInsertElement: function() {
var view = this;
var $upload = $("#avatar-input");
this._super();
// simulate a click on the hidden file input when clicking on our fake file input
$("#fake-avatar-input").on("click", function(e) {
// do *NOT* use the cached `$upload` variable, because fileupload is cloning & replacing the input
// cf. https://github.com/blueimp/jQuery-File-Upload/wiki/Frequently-Asked-Questions#why-is-the-file-input-field-cloned-and-replaced-after-each-selection
$("#avatar-input").click();
e.preventDefault();
});
// define the upload endpoint
$upload.fileupload({
url: Discourse.getURL("/users/" + this.get("controller.username") + "/preferences/avatar"),
dataType: "json",
timeout: 20000,
fileInput: $upload
});
// when a file has been selected
$upload.on("fileuploadadd", function (e, data) {
view.set("uploading", true);
});
// when there is a progression for the upload
$upload.on("fileuploadprogressall", function (e, data) {
var progress = parseInt(data.loaded / data.total * 100, 10);
view.set("uploadProgress", progress);
});
// when the upload is successful
$upload.on("fileuploaddone", function (e, data) {
// set some properties
view.get("controller").setProperties({
has_uploaded_avatar: true,
use_uploaded_avatar: true,
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) {
view.setProperties({ uploading: false, uploadProgress: 0 });
});
},
willDestroyElement: function() {
$("#fake-avatar-input").off("click");
$("#avatar-input").fileupload("destroy");
},
// *HACK* used to select the proper radio button
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'),
uploadButtonText: function() {
return this.get("uploading") ? I18n.t("uploading") : I18n.t("upload");
}.property("uploading")
});

View file

@ -1,21 +0,0 @@
/**
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')
});

View file

@ -1,226 +1,53 @@
/**
* jQuery Favicon Notify
*
* Updates the favicon to notify the user of changes. In the original tests I
* had an embedded font collection to allow any charachers - I decided that the
* ~130Kb and added complexity was overkill. As such it now uses a manual glyph
* set meaning that only numerical notifications are possible.
*
* Dual licensed under the MIT and GPL licenses:
*
* http://www.opensource.org/licenses/mit-license.php
* http://www.gnu.org/licenses/gpl.html
*
* @author David King
* @copyright Copyright (c) 2011 +
* @url oodavid.com
*/
* jQuery Favicon Notify
*
* Updates the favicon with a number to notify the user of changes.
*
* iconUrl: Url of favicon image or icon
* count: Integer count to place above favicon
*
* $.faviconNotify(iconUrl, count)
*/
(function($){
var canvas;
var bg = '#000000';
var fg = '#FFFFFF';
var pos = 'br';
$.faviconNotify = function(icon, num, myPos, myBg, myFg){
// Default the positions
myPos = myPos || pos;
myFg = myFg || fg;
myBg = myBg || bg;
// Create a canvas if we need one
canvas = canvas || $('<canvas />')[0];
if(canvas.getContext){
// Load the icon
$('<img />').load(function(e){
// Load the icon into the canvas
canvas.height = canvas.width = 16;
var ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(this, 0, 0);
// We gots num?
if(num !== undefined){
num = parseFloat(num, 10);
// Convert the num into a glyphs array
var myGlyphs = [];
if(num > 99){
myGlyphs.push(glyphs['LOTS']);
} else {
num = num.toString().split('');
$.each(num, function(k,v){
myGlyphs.push(glyphs[v]);
});
}
if(num>0) {
// Merge the glyphs together
var combined = [];
var glyphHeight = myGlyphs[0].length;
$.each(myGlyphs, function(k,v){
for(y=0; y<glyphHeight; y++){
// First pass?
if(combined[y] === undefined) {
combined[y] = v[y];
} else {
// Merge the glyph parts, careful of the boundaries
var l = combined[y].length;
if(combined[y][(l-1)] === ' '){
combined[y] = combined[y].substring(0, (l-1)) + v[y];
} else {
combined[y] += v[y].substring(1);
}
}
}
});
// Figure out our starting position
var glyphWidth = combined[0].length;
var x = (myPos.indexOf('l') !== -1) ? 0 : (16 - glyphWidth);
var y = (myPos.indexOf('t') !== -1) ? 0 : (16 - glyphHeight);
// Draw them pixels!
for(dX=0; dX<glyphWidth; dX++){
for(dY=0; dY<glyphHeight; dY++){
var pixel = combined[dY][dX];
if(pixel !== ' '){
ctx.fillStyle = (pixel === '@') ? myFg : myBg;
ctx.fillRect((x+dX), (y+dY), 1, 1);
}
}
}
}
}
// Update the favicon
$('link[rel$=icon]').remove();
$('head').append($('<link rel="shortcut icon" type="image/x-icon"/>').attr('href', canvas.toDataURL('image/png')));
}).attr('src', icon)
}
};
var glyphs = {
'0': [
' --- ',
' -@@@- ',
'-@---@-',
'-@- -@-',
'-@- -@-',
'-@- -@-',
'-@---@-',
' -@@@- ',
' --- ' ],
'1': [
' - ',
' -@- ',
'-@@- ',
' -@- ',
' -@- ',
' -@- ',
' -@- ',
'-@@@-',
' --- ' ],
'2': [
' --- ',
' -@@@- ',
'-@---@-',
' - --@-',
' -@@- ',
' -@-- ',
'-@---- ',
'-@@@@@-',
' ----- ' ],
'3': [
' --- ',
' -@@@- ',
'-@---@-',
' - --@-',
' -@@- ',
' - --@-',
'-@---@-',
' -@@@- ',
' --- ' ],
'4': [
' -- ',
' -@@-',
' -@-@-',
' -@--@-',
'-@---@-',
'-@@@@@-',
' ----@-',
' -@-',
' - ' ],
'5': [
' ----- ',
'-@@@@@-',
'-@---- ',
'-@--- ',
'-@@@@- ',
' ----@-',
'-@---@-',
' -@@@- ',
' --- ' ],
'6': [
' --- ',
' -@@@- ',
'-@---@-',
'-@---- ',
'-@@@@- ',
'-@---@-',
'-@---@-',
' -@@@- ',
' --- ' ],
'7': [
' ----- ',
'-@@@@@-',
' ----@-',
' -@- ',
' -@- ',
' -@- ',
' -@- ',
' -@- ',
' - ' ],
'8': [
' --- ',
' -@@@- ',
'-@---@-',
'-@---@-',
' -@@@- ',
'-@---@-',
'-@---@-',
' -@@@- ',
' --- ' ],
'9': [
' --- ',
' -@@@- ',
'-@---@-',
'-@---@-',
' -@@@@-',
' ----@-',
'-@---@-',
' -@@@- ',
' --- ' ],
'!': [
' - ',
'-@-',
'-@-',
'-@-',
'-@-',
'-@-',
' - ',
'-@-',
' - ' ],
'.': [
' ',
' ',
' ',
' ',
' ',
' ',
' - ',
'-@-',
' - ' ],
'LOTS': [
' - -- --- -- ',
'-@- -@@-@@@--@@-',
'-@--@--@-@--@- ',
'-@--@--@-@--@- ',
'-@--@--@-@- -@- ',
'-@--@--@-@- -@-',
'-@--@--@-@----@-',
'-@@@-@@--@-@@@- ',
' --- -- - --- '
]
};
$.faviconNotify = function(iconUrl, count){
var canvas = canvas || $('<canvas />')[0],
img = $('<img />')[0],
multiplier, fontSize, context, xOffset, yOffset;
if (canvas.getContext) {
if (count < 1) { count = '' }
else if (count < 10) { count = ' ' + count }
else if (count > 99) { count = '99' }
img.onload = function () {
canvas.height = canvas.width = this.width;
multiplier = (this.width / 16);
fontSize = multiplier * 11;
xOffset = multiplier;
yOffset = multiplier * 11;
context = canvas.getContext('2d');
context.drawImage(this, 0, 0);
context.font = 'bold ' + fontSize + 'px "helvetica", sans-serif';
context.fillStyle = '#FFF';
context.fillText(count, xOffset, yOffset);
context.fillText(count, xOffset + 2, yOffset);
context.fillText(count, xOffset, yOffset + 2);
context.fillText(count, xOffset + 2, yOffset + 2);
context.fillStyle = '#000';
context.fillText(count, xOffset + 1, yOffset + 1);
$('link[rel$=icon]').remove();
$('head').append(
$('<link rel="shortcut icon" type="image/x-icon"/>').attr(
'href', canvas.toDataURL('image/png')
)
);
};
img.src = iconUrl;
}
};
})(jQuery);

View file

@ -59,6 +59,9 @@
color: $nav-pills-border-color-active;
}
}
&.highlighted {
background-color: $highlight;
}
}
th,
td {

View file

@ -461,7 +461,7 @@
background-color: transparent;
@include box-shadow(none);
h5 {
margin-top: 1px;
margin-top: 5px;
font-size: 11px;
line-height: 13px;
}

View file

@ -323,3 +323,18 @@
width: 680px;
}
}
.avatar-selector {
label {
display: inline-block;
margin-right: 10px;
}
#avatar-input {
width: 0;
height: 0;
overflow: hidden;
}
.avatar {
margin: 5px 10px 5px 0;
}
}

View file

@ -114,6 +114,15 @@ class Admin::UsersController < Admin::AdminController
render nothing: true
end
def reject_bulk
d = UserDestroyer.new(current_user)
success_count = 0
User.where(id: params[:users]).each do |u|
success_count += 1 if guardian.can_delete_user?(u) and d.destroy(u, params.slice(:context)) rescue UserDestroyer::PostsExistError
end
render json: {success: success_count, failed: (params[:users].try(:size) || 0) - success_count}
end
def destroy
user = User.where(id: params[:id]).first
guardian.ensure_can_delete_user!(user)

View file

@ -376,7 +376,7 @@ class UsersController < ApplicationController
user.use_uploaded_avatar = params[:use_uploaded_avatar]
user.save!
render json: { avatar_template: user.avatar_template }
render nothing: true
end
private

View file

@ -0,0 +1,14 @@
require_dependency 'email/message_builder'
class VersionMailer < ActionMailer::Base
include Email::BuildEmailHelper
def send_notice
if SiteSetting.contact_email.present?
build_email( SiteSetting.contact_email,
template: 'new_version_mailer',
new_version: DiscourseUpdates.latest_version,
installed_version: Discourse::VERSION::STRING )
end
end
end

View file

@ -45,6 +45,7 @@ class SiteSetting < ActiveRecord::Base
client_setting(:email_domains_blacklist, 'mailinator.com')
client_setting(:email_domains_whitelist)
client_setting(:version_checks, true)
setting(:new_version_emails, true)
client_setting(:min_title_similar_length, 10)
client_setting(:min_body_similar_length, 15)
# cf. https://github.com/discourse/discourse/pull/462#issuecomment-14991562

View file

@ -297,13 +297,6 @@ class User < ActiveRecord::Base
end
end
def self.avatar_template(email)
user = User.select([:email, :use_uploaded_avatar, :uploaded_avatar_template, :uploaded_avatar_id])
.where(email: Email.downcase(email))
.first
user.avatar_template if user.present?
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"
@ -314,8 +307,8 @@ class User < ActiveRecord::Base
# - self oneboxes in open graph data
# - emails
def small_avatar_url
template = User.avatar_template(email)
template.gsub("{size}", "60")
template = avatar_template
template.gsub("{size}", "45")
end
def avatar_template

View file

@ -35,9 +35,9 @@
<%= raw(@markdown_linker.references) %>
<div class='footer-notice'><%=raw(t :'user_notifications.digest.unsubscribe',
<span class='footer-notice'><%=raw(t :'user_notifications.digest.unsubscribe',
site_link: site_link,
unsubscribe_link: raw(@markdown_linker.create(t('user_notifications.digest.click_here'), email_unsubscribe_path(key: @user.temporary_key)))) %></div>
unsubscribe_link: raw(@markdown_linker.create(t('user_notifications.digest.click_here'), email_unsubscribe_path(key: @user.temporary_key)))) %></span>
<%= raw(@markdown_linker.references) %>

View file

@ -198,6 +198,7 @@ en:
success: "(email sent)"
in_progress: "(sending email)"
error: "(error)"
action: "Send Password Reset Email"
change_about:
title: "Change About Me"
@ -217,12 +218,11 @@ en:
change_avatar:
title: "Change your avatar"
upload_instructions: "Or you could upload an image"
upload: "Upload a picture"
uploading: "Uploading the picture..."
gravatar: "Gravatar"
gravatar: "<a href='//gravatar.com/emails' target='_blank'>Gravatar</a>, based on"
gravatar_title: "Change your avatar on Gravatar's website"
uploaded_avatar: "Uploaded picture"
uploaded_avatar: "Custom picture"
uploaded_avatar_empty: "Add a custom picture"
upload_title: "Upload your picture"
email:
title: "Email"
@ -1232,6 +1232,9 @@ en:
approved_selected:
one: "approve user"
other: "approve users ({{count}})"
reject_selected:
one: "reject user"
other: "reject users ({{count}})"
titles:
active: 'Active Users'
new: 'New Users'
@ -1245,6 +1248,12 @@ en:
moderators: 'Moderators'
blocked: 'Blocked Users'
banned: 'Banned Users'
reject_successful:
one: "Successfully rejected 1 user."
other: "Successfully rejected %{count} users."
reject_failures:
one: "Failed to reject 1 user."
other: "Failed to reject %{count} users."
user:
ban_failed: "Something went wrong banning this user {{error}}"

View file

@ -51,23 +51,23 @@ zh_CN:
other: "%{count}年"
medium:
x_minutes:
one: "1分钟"
one: "1分钟"
other: "%{count}分钟"
x_hours:
one: "1小时"
one: "1小时"
other: "%{count}小时"
x_days:
one: "1天"
one: "1天"
other: "%{count}天"
medium_with_ago:
x_minutes:
one: "1分钟前"
one: "1分钟前"
other: "%{count}分钟前"
x_hours:
one: "1小时之前"
one: "1小时之前"
other: "%{count}小时之前"
x_days:
one: "1天前"
one: "1天前"
other: "%{count}天前"
share:
topic: '分享一个到本主题的链接'
@ -86,7 +86,7 @@ zh_CN:
generic_error: "抱歉,发生了一个错误。"
generic_error_with_reason: "发生一个错误:%{error}"
log_in: "登录"
age: "寿命"
age: "时间"
last_post: "最后一帖"
admin_title: "管理员"
flags_title: "报告"
@ -132,6 +132,10 @@ zh_CN:
saving: "保存中……"
saved: "已保存!"
upload: "上传"
uploading: "上传中……"
uploaded: "上传完成!"
choose_topic:
none_found: "没有找到主题"
title:
@ -211,6 +215,15 @@ zh_CN:
error: "抱歉在修改你的电子邮箱时发生了错误,可能此邮箱已经被使用了?"
success: "我们发送了一封确认信到此邮箱地址,请按照邮箱内指示完成确认。"
change_avatar:
title: "修改头像"
upload_instructions: "也可上传头像"
upload: "上传图片"
uploading: "正在上传图片……"
gravatar: "Gravatar"
gravatar_title: "修改你在Gravatar的头像"
uploaded_avatar: "已上传图片"
email:
title: "电子邮箱"
instructions: "你的电子邮箱绝不会公开给他人。"
@ -304,7 +317,9 @@ zh_CN:
title: "最后使用的IP地址"
avatar:
title: "头像"
instructions: "我们目前使用 <a href='https://gravatar.com' target='_blank'>Gravatar</a> 来基于你的邮箱生成头像"
instructions:
gravatar: "正在使用<a href='https://gravatar.com' target='_blank'>Gravatar</a>头像"
uploaded_avatar: "正在使用上传的头像"
title:
title: "头衔"
@ -753,7 +768,7 @@ zh_CN:
reply_as_new_topic: "回复为新主题"
continue_discussion: "从 {{postLink}} 继续讨论:"
follow_quote: "跳转至所引用的帖子"
deleted_by_author:
deleted_by_author:
one: "(该帖已被作者撤销,如无报告则将在 %{count} 小时后自动被删除。)"
other: "(该帖已被作者撤销,如无报告则将在 %{count} 小时后自动被删除。)"
deleted_by: "删除者为"
@ -1166,15 +1181,11 @@ zh_CN:
title: "日志"
action: "操作"
created_at: "创建"
screened_emails:
title: "被屏蔽的邮件地址"
description: "当有人试图用以下邮件地址注册时,将受到阻止或其它系统操作。"
email: "邮件地址"
last_match_at: "最近匹配"
match_count: "匹配"
actions:
block: "阻挡"
do_nothing: "无操作"
last_match_at: "最近匹配"
match_count: "匹配"
screened_actions:
block: "阻挡"
do_nothing: "无操作"
staff_actions:
title: "管理人员操作"
instructions: "点击用户名和操作可以过滤列表。点击头像可以访问用户个人页面。"
@ -1187,6 +1198,14 @@ zh_CN:
actions:
delete_user: "删除用户"
change_trust_level: "更改信任等级"
screened_emails:
title: "被屏蔽的邮件地址"
description: "当有人试图用以下邮件地址注册时,将受到阻止或其它系统操作。"
email: "邮件地址"
screened_urls:
title: "被屏蔽的URL"
description: "The URLs listed here were used in posts by users who have been identified as spammers."
url: "URL"
impersonate:
title: "假冒用户"
@ -1232,6 +1251,7 @@ zh_CN:
unban_failed: "解禁此用户时发生了错误 {{error}}"
ban_duration: "你计划禁止该用户多久?(天)"
delete_all_posts: "删除所有帖子"
delete_all_posts_confirm: "You are about to delete %{posts} posts and %{topics} topics. Are you sure?"
ban: "禁止"
unban: "解禁"
banned: "已禁止?"
@ -1266,7 +1286,7 @@ zh_CN:
approve_bulk_success: "成功!所有选定的用户已批准并通知。"
time_read: "阅读次数"
delete: "删除用户"
delete_forbidden:
delete_forbidden:
one: "用户已注册 %{count} 天或已有发帖后,则无法被删除。请先删除该用户的所有发帖后再试。"
other: "用户已注册 %{count} 天或已有发帖后,则无法被删除。请先删除该用户的所有发帖后再试。"
delete_confirm: "你确定要永久地从本站删除此用户?该操作无法撤销!"

View file

@ -529,6 +529,7 @@ en:
email_domains_blacklist: "A pipe-delimited list of email domains that are not allowed. Example: mailinator.com|trashmail.net"
email_domains_whitelist: "A pipe-delimited list of email domains that users may register with. WARNING: Users with email domains other than those listed will not be allowed."
version_checks: "Ping the Discourse Hub for version updates and show version messages on the /admin dashboard"
new_version_emails: "Send an email to the contact_email address when a new version is available."
port: "DEVELOPER ONLY! WARNING! Use this HTTP port rather than the default of port 80. Leave blank for default of 80."
force_hostname: "DEVELOPER ONLY! WARNING! Specify a hostname in the URL. Leave blank for default."
@ -794,6 +795,17 @@ en:
<small>There should be an unsubscribe footer on every email you send, so let's mock one up. This email was sent by Name of Company, 55 Main Street, Anytown, USA 12345. If you would like to opt out of future emails, [click here to unsubscribe][5].</small>
new_version_mailer:
subject_template: "[%{site_name}] Updates Are Available"
text_body_template: |
A new version of Discourse is available.
**New version: %{new_version}**
Your version: %{installed_version}
Please upgrade as soon as possible to get the latest fixes and new features.
system_messages:
post_hidden:
subject_template: "Post hidden due to community flagging"

View file

@ -1,3 +1,8 @@
# Additional MIME types that you'd like nginx to handle go in here
types {
text/csv csv;
}
upstream discourse {
server unix:/var/www/discourse/tmp/sockets/thin.0.sock;
server unix:/var/www/discourse/tmp/sockets/thin.1.sock;

View file

@ -36,6 +36,7 @@ Discourse::Application.routes.draw do
collection do
get 'list/:query' => 'users#index'
put 'approve-bulk' => 'users#approve_bulk'
delete 'reject-bulk' => 'users#reject_bulk'
end
put 'ban'
put 'delete_all_posts'

View file

@ -50,6 +50,9 @@ Install necessary packages:
# Run these commands as your normal login (e.g. "michael")
sudo apt-get -y install build-essential libssl-dev libyaml-dev git libtool libxslt-dev libxml2-dev libpq-dev gawk curl pngcrush python-software-properties
# If you're on Ubuntu >= 12.10, change:
# python-software-properties to software-properties-common
## Caching: Redis
Redis is a networked, in memory key-value store cache. Without the Redis caching layer, we'd have to go to the database a lot more often for common information and the site would be slower as a result.

View file

@ -24,8 +24,6 @@ module Email
img['src'] = "#{Discourse.base_url}#{img['src']}"
end
end
style('div.post-indent',' margin-left: 15px; margin-top: 20px; max-width: 694px;')
end
def format_notification
@ -54,7 +52,7 @@ module Email
style('li', 'padding-bottom: 10px')
style('div.digest-post', 'margin-left: 15px; margin-top: 20px; max-width: 694px;')
style('div.digest-post h1', 'font-size: 20px;')
style('div.footer-notice', 'color:#666; font-size:80%')
style('span.footer-notice', 'color:#666; font-size:80%')
@fragment.css('pre').each do |pre|
pre.replace(pre.text)

View file

@ -46,18 +46,12 @@ class Guardian
# Can the user edit the obj
def can_edit?(obj)
if obj && authenticated?
edit_method = method_name_for :edit, obj
return (edit_method ? send(edit_method, obj) : true)
end
can_do?(:edit, obj)
end
# Can we delete the object
def can_delete?(obj)
if obj && authenticated?
delete_method = method_name_for :delete, obj
return (delete_method ? send(delete_method, obj) : true)
end
can_do?(:delete, obj)
end
def can_moderate?(obj)
@ -428,4 +422,11 @@ class Guardian
return method_name if respond_to?(method_name)
end
def can_do?(action, obj)
if obj && authenticated?
action_method = method_name_for action, obj
return (action_method ? send(action_method, obj) : true)
end
end
end

View file

@ -21,8 +21,10 @@ module Jobs
# create a temp file with the same extension as the original
temp_file = Tempfile.new(["discourse-avatar", File.extname(original_path)])
temp_path = temp_file.path
#
Discourse.store.store_avatar(temp_file, upload, size) if ImageSorcery.new(original_path).convert(temp_path, gravity: "center", thumbnail: "#{size}x#{size}^", extent: "#{size}x#{size}", background: "transparent")
# 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")
Discourse.store.store_avatar(temp_file, upload, size)
end
# close && remove temp file
temp_file.close!
end

View file

@ -8,11 +8,18 @@ module Jobs
def execute(args)
if SiteSetting.version_checks? and (DiscourseUpdates.updated_at.nil? or DiscourseUpdates.updated_at < 1.minute.ago)
begin
should_send_email = (SiteSetting.new_version_emails and DiscourseUpdates.missing_versions_count and DiscourseUpdates.missing_versions_count == 0)
json = DiscourseHub.discourse_version_check
DiscourseUpdates.latest_version = json['latestVersion']
DiscourseUpdates.critical_updates_available = json['criticalUpdates']
DiscourseUpdates.missing_versions_count = json['missingVersionsCount']
DiscourseUpdates.updated_at = Time.zone.now
if should_send_email and json['missingVersionsCount'] > 0
message = VersionMailer.send_notice
Email::Sender.new(message, :new_version).send
end
rescue => e
raise e unless Rails.env == 'development' # Fail version check silently in development mode
end

View file

@ -57,10 +57,12 @@ module Oneboxer
post = topic.posts.first
posters = topic.posters_summary.map do |p|
{username: p[:user][:username],
avatar: PrettyText.avatar_img(p[:user][:avatar_template], 'tiny'),
description: p[:description],
extras: p[:extras]}
{
username: p[:user].username,
avatar: PrettyText.avatar_img(p[:user].avatar_template, 'tiny'),
description: p[:description],
extras: p[:extras]
}
end
category = topic.category
@ -70,7 +72,7 @@ module Oneboxer
quote = post.excerpt(SiteSetting.post_onebox_maxlength)
args.merge! title: topic.title,
avatar: PrettyText.avatar_img(topic.user.username, 'tiny'),
avatar: PrettyText.avatar_img(topic.user.avatar_template, 'tiny'),
posts_count: topic.posts_count,
last_post: FreedomPatches::Rails4.time_ago_in_words(topic.last_posted_at, false, scope: :'datetime.distance_in_words_verbose'),
age: FreedomPatches::Rails4.time_ago_in_words(topic.created_at, false, scope: :'datetime.distance_in_words_verbose'),

View file

@ -1,23 +1,20 @@
<aside class='quote' data-post="1" data-topic="{{topic}}">
<div class='title'>
<div class='quote-controls'></div>
{{{avatar}}}
<a href="{{original_url}}">{{title}}</a> {{{category}}} </div>
<blockquote>{{{quote}}}
<div class='topic-info'>
<div class='info-line'>
{{posts_count}} posts, last post {{last_post}}, created {{age}}, {{views}} views
</div>
<div class='posters'>
{{#posters}}
{{{avatar}}}
{{/posters}}
</div>
<div class='clearfix'>
</div>
<a href="{{original_url}}">{{title}}</a> {{{category}}}
</div>
<blockquote>{{{quote}}}
<div class='topic-info'>
<div class='info-line'>
{{posts_count}} posts, last post {{last_post}}, created {{age}}, {{views}} views
</div>
<div class='posters'>
{{#posters}}
{{{avatar}}}
{{/posters}}
</div>
<div class='clearfix'></div>
</div>
</blockquote>
</aside>

View file

@ -32,7 +32,7 @@ class Search
def self.from_user(u)
SearchResult.new(type: :user, id: u.username_lower, title: u.username, url: "/users/#{u.username_lower}").tap do |r|
r.avatar_template = User.avatar_template(u.email)
r.avatar_template = u.avatar_template
end
end
@ -43,7 +43,7 @@ class Search
def self.from_post(p)
if p.post_number == 1
# we want the topic link when it's the OP
SearchResult.from_topic(p.topic)
SearchResult.from_topic(p.topic)
else
SearchResult.new(type: :topic, id: p.topic.id, title: p.topic.title, url: p.url)
end

View file

@ -57,9 +57,8 @@ ENV["RUBY_HEAP_SLOTS_GROWTH_FACTOR"] = "1.25"
ENV["RUBY_HEAP_MIN_SLOTS"] = "800000"
ENV["RUBY_FREE_MIN"] = "600000"
def port_available? port
server = TCPServer.open port
server = TCPServer.open("0.0.0.0", port)
server.close
true
rescue Errno::EADDRINUSE
@ -86,9 +85,9 @@ run("bundle exec ruby script/profile_db_generator.rb")
def bench(path)
puts "Running apache bench warmup"
`ab -n 100 http://localhost:#{@port}#{path}`
`ab -n 100 http://127.0.0.1:#{@port}#{path}`
puts "Benchmarking #{path}"
`ab -n 100 -e tmp/ab.csv http://localhost:#{@port}#{path}`
`ab -n 100 -e tmp/ab.csv http://127.0.0.1:#{@port}#{path}`
percentiles = Hash[*[50, 75, 90, 99].zip([]).flatten]
CSV.foreach("tmp/ab.csv") do |percent, time|
@ -105,6 +104,8 @@ begin
sleep 1
end
puts "Starting benchmark..."
home_page = bench("/")
topic_page = bench("/t/oh-how-i-wish-i-could-shut-up-like-a-tunnel-for-so/69")

View file

@ -196,6 +196,57 @@ describe Admin::UsersController do
end
end
context '.reject_bulk' do
let(:reject_me) { Fabricate(:user) }
let(:reject_me_too) { Fabricate(:user) }
it 'does nothing without users' do
UserDestroyer.any_instance.expects(:destroy).never
xhr :delete, :reject_bulk
end
it "won't delete users if not allowed" do
Guardian.any_instance.stubs(:can_delete_user?).returns(false)
UserDestroyer.any_instance.expects(:destroy).never
xhr :delete, :reject_bulk, users: [reject_me.id]
end
it "reports successes" do
Guardian.any_instance.stubs(:can_delete_user?).returns(true)
UserDestroyer.any_instance.stubs(:destroy).returns(true)
xhr :delete, :reject_bulk, users: [reject_me.id, reject_me_too.id]
response.should be_success
json = ::JSON.parse(response.body)
json['success'].to_i.should == 2
json['failed'].to_i.should == 0
end
context 'failures' do
before do
Guardian.any_instance.stubs(:can_delete_user?).returns(true)
end
it 'can handle some successes and some failures' do
UserDestroyer.any_instance.stubs(:destroy).with(reject_me, anything).returns(false)
UserDestroyer.any_instance.stubs(:destroy).with(reject_me_too, anything).returns(true)
xhr :delete, :reject_bulk, users: [reject_me.id, reject_me_too.id]
response.should be_success
json = ::JSON.parse(response.body)
json['success'].to_i.should == 1
json['failed'].to_i.should == 1
end
it 'reports failure due to a user still having posts' do
UserDestroyer.any_instance.expects(:destroy).with(reject_me, anything).raises(UserDestroyer::PostsExistError)
xhr :delete, :reject_bulk, users: [reject_me.id]
response.should be_success
json = ::JSON.parse(response.body)
json['success'].to_i.should == 0
json['failed'].to_i.should == 1
end
end
end
context '.destroy' do
before do
@delete_me = Fabricate(:user)

View file

@ -0,0 +1,18 @@
require "spec_helper"
describe VersionMailer do
subject { VersionMailer.send_notice }
context 'contact_email is blank' do
before { SiteSetting.stubs(:contact_email).returns('') }
its(:to) { should be_blank }
end
context 'contact_email is set' do
before { SiteSetting.stubs(:contact_email).returns('me@example.com') }
its(:to) { should == ['me@example.com'] }
its(:subject) { should be_present }
its(:from) { should == [SiteSetting.notification_email] }
its(:body) { should be_present }
end
end