diff --git a/app/assets/images/default-favicon.ico b/app/assets/images/default-favicon.ico index d20ae8eeded..cab552a2320 100644 Binary files a/app/assets/images/default-favicon.ico and b/app/assets/images/default-favicon.ico differ diff --git a/app/assets/images/favicon.ico b/app/assets/images/favicon.ico index db5a23d7592..ac54b34b9fa 100644 Binary files a/app/assets/images/favicon.ico and b/app/assets/images/favicon.ico differ diff --git a/app/assets/javascripts/admin/controllers/admin_users_list_controller.js b/app/assets/javascripts/admin/controllers/admin_users_list_controller.js index c9e8bd93907..5794941d3fa 100644 --- a/app/assets/javascripts/admin/controllers/admin_users_list_controller.js +++ b/app/assets/javascripts/admin/controllers/admin_users_list_controller.js @@ -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(); + }); } }); diff --git a/app/assets/javascripts/admin/models/admin_user.js b/app/assets/javascripts/admin/models/admin_user.js index 853cf484a6f..6e43b8698f2 100644 --- a/app/assets/javascripts/admin/models/admin_user.js +++ b/app/assets/javascripts/admin/models/admin_user.js @@ -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; diff --git a/app/assets/javascripts/admin/templates/users_list.js.handlebars b/app/assets/javascripts/admin/templates/users_list.js.handlebars index 22fa72799f8..f08e54b6066 100644 --- a/app/assets/javascripts/admin/templates/users_list.js.handlebars +++ b/app/assets/javascripts/admin/templates/users_list.js.handlebars @@ -20,6 +20,7 @@ {{#if hasSelection}}
+
{{/if}} diff --git a/app/assets/javascripts/discourse/controllers/avatar_selector_controller.js b/app/assets/javascripts/discourse/controllers/avatar_selector_controller.js new file mode 100644 index 00000000000..d4aebbc9793 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/avatar_selector_controller.js @@ -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")); + } + } +}); diff --git a/app/assets/javascripts/discourse/controllers/preferences_avatar_controller.js b/app/assets/javascripts/discourse/controllers/preferences_avatar_controller.js deleted file mode 100644 index 41180495972..00000000000 --- a/app/assets/javascripts/discourse/controllers/preferences_avatar_controller.js +++ /dev/null @@ -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") }); - } -}); diff --git a/app/assets/javascripts/discourse/helpers/application_helpers.js b/app/assets/javascripts/discourse/helpers/application_helpers.js index b941f29144b..34ee470db11 100644 --- a/app/assets/javascripts/discourse/helpers/application_helpers.js +++ b/app/assets/javascripts/discourse/helpers/application_helpers.js @@ -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. diff --git a/app/assets/javascripts/discourse/routes/preferences_routes.js b/app/assets/javascripts/discourse/routes/preferences_routes.js index 55427f4b9ff..0ddc31a4579 100644 --- a/app/assets/javascripts/discourse/routes/preferences_routes.js +++ b/app/assets/javascripts/discourse/routes/preferences_routes.js @@ -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 }); - } -}); diff --git a/app/assets/javascripts/discourse/templates/embedded_post.js.handlebars b/app/assets/javascripts/discourse/templates/embedded_post.js.handlebars index 8c1e7d6f8b6..76da62dcae1 100644 --- a/app/assets/javascripts/discourse/templates/embedded_post.js.handlebars +++ b/app/assets/javascripts/discourse/templates/embedded_post.js.handlebars @@ -2,7 +2,7 @@
- {{avatar this imageSize="medium"}} + {{avatar this imageSize="large"}}
{{breakUp username}}
diff --git a/app/assets/javascripts/discourse/templates/modal/auto_close.js.handlebars b/app/assets/javascripts/discourse/templates/modal/auto_close.js.handlebars index 49d1d824e7d..7b400d1c8fd 100644 --- a/app/assets/javascripts/discourse/templates/modal/auto_close.js.handlebars +++ b/app/assets/javascripts/discourse/templates/modal/auto_close.js.handlebars @@ -5,6 +5,6 @@
\ No newline at end of file + diff --git a/app/assets/javascripts/discourse/templates/modal/avatar_selector.js.handlebars b/app/assets/javascripts/discourse/templates/modal/avatar_selector.js.handlebars new file mode 100644 index 00000000000..46e2be87b9d --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/avatar_selector.js.handlebars @@ -0,0 +1,29 @@ + + + diff --git a/app/assets/javascripts/discourse/templates/user/avatar.js.handlebars b/app/assets/javascripts/discourse/templates/user/avatar.js.handlebars deleted file mode 100644 index e72fb175e27..00000000000 --- a/app/assets/javascripts/discourse/templates/user/avatar.js.handlebars +++ /dev/null @@ -1,39 +0,0 @@ -
- -
-
-

{{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 f8c86ba31fe..a3faeecaa04 100644 --- a/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars +++ b/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars @@ -37,14 +37,15 @@
- {{passwordProgress}} + {{i18n user.change_password.action}} {{passwordProgress}}
- {{avatar model imageSize="large"}} + {{boundAvatar model imageSize="large"}} +
{{#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}} {{i18n user.change}} diff --git a/app/assets/javascripts/discourse/views/list/topic_list_item_view.js b/app/assets/javascripts/discourse/views/list/topic_list_item_view.js index 2d1f6d71456..393f662b942 100644 --- a/app/assets/javascripts/discourse/views/list/topic_list_item_view.js +++ b/app/assets/javascripts/discourse/views/list/topic_list_item_view.js @@ -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() { diff --git a/app/assets/javascripts/discourse/views/modal/avatar_selector_view.js b/app/assets/javascripts/discourse/views/modal/avatar_selector_view.js new file mode 100644 index 00000000000..dde8c1c0df0 --- /dev/null +++ b/app/assets/javascripts/discourse/views/modal/avatar_selector_view.js @@ -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") + +}); diff --git a/app/assets/javascripts/discourse/views/user/preferences_avatar_view.js b/app/assets/javascripts/discourse/views/user/preferences_avatar_view.js deleted file mode 100644 index 4eb5d6eff6c..00000000000 --- a/app/assets/javascripts/discourse/views/user/preferences_avatar_view.js +++ /dev/null @@ -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') - -}); diff --git a/app/assets/javascripts/external/jquery.faviconNotify.js b/app/assets/javascripts/external/jquery.faviconNotify.js index e0c370b9d0c..984bd2dd0d3 100644 --- a/app/assets/javascripts/external/jquery.faviconNotify.js +++ b/app/assets/javascripts/external/jquery.faviconNotify.js @@ -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 || $('')[0]; - if(canvas.getContext){ - // Load the icon - $('').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').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 || $('')[0], + 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( + $('').attr( + 'href', canvas.toDataURL('image/png') + ) + ); + }; + img.src = iconUrl; + } + }; })(jQuery); diff --git a/app/assets/stylesheets/application/topic-list.css.scss b/app/assets/stylesheets/application/topic-list.css.scss index 87a5e7b5cce..1eeb58355d2 100644 --- a/app/assets/stylesheets/application/topic-list.css.scss +++ b/app/assets/stylesheets/application/topic-list.css.scss @@ -59,6 +59,9 @@ color: $nav-pills-border-color-active; } } + &.highlighted { + background-color: $highlight; + } } th, td { diff --git a/app/assets/stylesheets/application/topic-post.css.scss b/app/assets/stylesheets/application/topic-post.css.scss index 14c5197344a..806e10ae36f 100644 --- a/app/assets/stylesheets/application/topic-post.css.scss +++ b/app/assets/stylesheets/application/topic-post.css.scss @@ -461,7 +461,7 @@ background-color: transparent; @include box-shadow(none); h5 { - margin-top: 1px; + margin-top: 5px; font-size: 11px; line-height: 13px; } diff --git a/app/assets/stylesheets/application/upload.scss b/app/assets/stylesheets/application/upload.css.scss similarity index 100% rename from app/assets/stylesheets/application/upload.scss rename to app/assets/stylesheets/application/upload.css.scss diff --git a/app/assets/stylesheets/application/user.css.scss b/app/assets/stylesheets/application/user.css.scss index a79ea581470..2fadb72b905 100644 --- a/app/assets/stylesheets/application/user.css.scss +++ b/app/assets/stylesheets/application/user.css.scss @@ -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; + } +} diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index d158ca42775..3a3c0a70a95 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -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) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 4137f24b311..681b4e9fef3 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -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 diff --git a/app/mailers/version_mailer.rb b/app/mailers/version_mailer.rb new file mode 100644 index 00000000000..42019f0ad07 --- /dev/null +++ b/app/mailers/version_mailer.rb @@ -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 diff --git a/app/models/site_setting.rb b/app/models/site_setting.rb index b2d2acee20d..9803d4fd6f4 100644 --- a/app/models/site_setting.rb +++ b/app/models/site_setting.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 56557b0e00c..b014563c42f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/views/user_notifications/digest.text.erb b/app/views/user_notifications/digest.text.erb index 088f237afdf..65f5754f4fd 100644 --- a/app/views/user_notifications/digest.text.erb +++ b/app/views/user_notifications/digest.text.erb @@ -35,9 +35,9 @@ <%= raw(@markdown_linker.references) %> - + unsubscribe_link: raw(@markdown_linker.create(t('user_notifications.digest.click_here'), email_unsubscribe_path(key: @user.temporary_key)))) %> <%= raw(@markdown_linker.references) %> diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index ce9e289f19f..ccf8f58ea6b 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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: "Gravatar, 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}}" diff --git a/config/locales/client.zh_CN.yml b/config/locales/client.zh_CN.yml index 228da6e1a66..cf07724b14f 100644 --- a/config/locales/client.zh_CN.yml +++ b/config/locales/client.zh_CN.yml @@ -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: "我们目前使用 Gravatar 来基于你的邮箱生成头像" + instructions: + gravatar: "正在使用Gravatar头像" + 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: "你确定要永久地从本站删除此用户?该操作无法撤销!" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index dd55cddd1c3..43b2fc42c16 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -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: 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]. + 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" diff --git a/config/nginx.sample.conf b/config/nginx.sample.conf index fe6cd8019e3..efb23d93963 100644 --- a/config/nginx.sample.conf +++ b/config/nginx.sample.conf @@ -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; diff --git a/config/routes.rb b/config/routes.rb index 6f1863bf84d..632ec2db4c4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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' diff --git a/docs/INSTALL-ubuntu.md b/docs/INSTALL-ubuntu.md index 71dc67e9524..7270b92d5a8 100644 --- a/docs/INSTALL-ubuntu.md +++ b/docs/INSTALL-ubuntu.md @@ -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. diff --git a/lib/email/styles.rb b/lib/email/styles.rb index 464113642d6..522c0431722 100644 --- a/lib/email/styles.rb +++ b/lib/email/styles.rb @@ -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) diff --git a/lib/guardian.rb b/lib/guardian.rb index 14b3064270d..d03cd89fb82 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -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 diff --git a/lib/jobs/generate_avatars.rb b/lib/jobs/generate_avatars.rb index 9a22220eee7..abc4960a07f 100644 --- a/lib/jobs/generate_avatars.rb +++ b/lib/jobs/generate_avatars.rb @@ -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 diff --git a/lib/jobs/version_check.rb b/lib/jobs/version_check.rb index dd9b6605607..91528bf7c54 100644 --- a/lib/jobs/version_check.rb +++ b/lib/jobs/version_check.rb @@ -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 diff --git a/lib/oneboxer/discourse_local_onebox.rb b/lib/oneboxer/discourse_local_onebox.rb index 3d500acb926..8329737dc9c 100644 --- a/lib/oneboxer/discourse_local_onebox.rb +++ b/lib/oneboxer/discourse_local_onebox.rb @@ -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'), diff --git a/lib/oneboxer/templates/discourse_topic_onebox.hbrs b/lib/oneboxer/templates/discourse_topic_onebox.hbrs index bcced049435..cce69d1baef 100644 --- a/lib/oneboxer/templates/discourse_topic_onebox.hbrs +++ b/lib/oneboxer/templates/discourse_topic_onebox.hbrs @@ -1,23 +1,20 @@ - - - diff --git a/lib/search/search_result.rb b/lib/search/search_result.rb index 6e45bdb5324..17ef418da0d 100644 --- a/lib/search/search_result.rb +++ b/lib/search/search_result.rb @@ -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 diff --git a/script/bench.rb b/script/bench.rb index 305b628b2be..37dacb551a6 100644 --- a/script/bench.rb +++ b/script/bench.rb @@ -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") diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 9661ac05579..b3ea66a2fd1 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -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) diff --git a/spec/mailers/version_mailer_spec.rb b/spec/mailers/version_mailer_spec.rb new file mode 100644 index 00000000000..eb0df5fbecf --- /dev/null +++ b/spec/mailers/version_mailer_spec.rb @@ -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