diff --git a/app/assets/javascripts/discourse/views/composer.js.es6 b/app/assets/javascripts/discourse/views/composer.js.es6
index 42b620b185..543705053a 100644
--- a/app/assets/javascripts/discourse/views/composer.js.es6
+++ b/app/assets/javascripts/discourse/views/composer.js.es6
@@ -3,7 +3,8 @@
import userSearch from 'discourse/lib/user-search';
import afterTransition from 'discourse/lib/after-transition';
-var ComposerView = Discourse.View.extend(Ember.Evented, {
+const ComposerView = Discourse.View.extend(Ember.Evented, {
+ _lastKeyTimeout: null,
templateName: 'composer',
elementId: 'reply-control',
classNameBindings: ['model.creatingPrivateMessage:private-message',
@@ -48,12 +49,12 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
Ember.run.scheduleOnce('afterRender', this, 'refreshPreview');
}.observes('model.reply', 'model.hidePreview'),
- focusIn: function() {
- var controller = this.get('controller');
+ focusIn() {
+ const controller = this.get('controller');
if (controller) controller.updateDraftStatus();
},
- movePanels: function(sizePx) {
+ movePanels(sizePx) {
$('#main-outlet').css('padding-bottom', sizePx);
$('.composer-popup').css('bottom', sizePx);
// signal the progress bar it should move!
@@ -61,14 +62,14 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
},
resize: function() {
- var self = this;
+ const self = this;
Em.run.scheduleOnce('afterRender', function() {
- var h = $('#reply-control').height() || 0;
+ const h = $('#reply-control').height() || 0;
self.movePanels.apply(self, [h + "px"]);
// Figure out the size of the fields
- var $fields = self.$('.composer-fields'),
- pos = $fields.position();
+ const $fields = self.$('.composer-fields');
+ let pos = $fields.position();
if (pos) {
self.$('.wmd-controls').css('top', $fields.height() + pos.top + 5);
@@ -83,17 +84,19 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
});
}.observes('model.composeState', 'model.action'),
- keyUp: function() {
- var controller = this.get('controller');
+ keyUp() {
+ const controller = this.get('controller');
controller.checkReplyLength();
- var lastKeyUp = new Date();
+ const lastKeyUp = new Date();
this.set('lastKeyUp', lastKeyUp);
// One second from now, check to see if the last key was hit when
// we recorded it. If it was, the user paused typing.
- var self = this;
- Em.run.later(function() {
+ const self = this;
+
+ Ember.run.cancel(this._lastKeyTimeout);
+ this._lastKeyTimeout = Ember.run.later(function() {
if (lastKeyUp !== self.get('lastKeyUp')) return;
// Search for similar topics if the user pauses typing
@@ -101,7 +104,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
}, 1000);
},
- keyDown: function(e) {
+ keyDown(e) {
if (e.which === 27) {
// ESC
this.get('controller').send('hitEsc');
@@ -114,12 +117,12 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
},
_enableResizing: function() {
- var $replyControl = $('#reply-control'),
+ const $replyControl = $('#reply-control'),
self = this;
$replyControl.DivResizer({
resize: this.resize.bind(self),
- onDrag: function (sizePx) { self.movePanels.apply(self, [sizePx]); }
+ onDrag(sizePx) { self.movePanels.apply(self, [sizePx]); }
});
afterTransition($replyControl, this.resize.bind(self));
this.ensureMaximumDimensionForImagesInPreview();
@@ -130,14 +133,14 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
this.set('controller.view', null);
}.on('willDestroyElement'),
- ensureMaximumDimensionForImagesInPreview: function() {
+ ensureMaximumDimensionForImagesInPreview() {
// This enforce maximum dimensions of images in the preview according
// to the current site settings.
// For interactivity, we immediately insert the locally cooked version
// of the post into the stream when the user hits reply. We therefore also
// need to enforce these rules on the .cooked version.
// Meanwhile, the server is busy post-processing the post and generating thumbnails.
- var style = Discourse.Mobile.mobileView ?
+ const style = Discourse.Mobile.mobileView ?
'max-width: 100%; height: auto;' :
'max-width:' + Discourse.SiteSettings.max_image_width + 'px;' +
'max-height:' + Discourse.SiteSettings.max_image_height + 'px;';
@@ -145,17 +148,17 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
$('').appendTo('head');
},
- click: function() {
+ click() {
this.get('controller').send('openIfDraft');
},
// Called after the preview renders. Debounced for performance
- afterRender: function() {
- var $wmdPreview = $('#wmd-preview');
+ afterRender() {
+ const $wmdPreview = $('#wmd-preview');
if ($wmdPreview.length === 0) return;
- var post = this.get('model.post'),
- refresh = false;
+ const post = this.get('model.post');
+ let refresh = false;
// If we are editing a post, we'll refresh its contents once. This is a feature that
// allows a user to refresh its contents once.
@@ -175,17 +178,17 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
this.trigger('previewRefreshed', $wmdPreview);
},
- _applyEmojiAutocomplete: function() {
+ _applyEmojiAutocomplete() {
if (!this.siteSettings.enable_emoji) { return; }
- var template = this.container.lookup('template:emoji-selector-autocomplete.raw');
+ const template = this.container.lookup('template:emoji-selector-autocomplete.raw');
$('#wmd-input').autocomplete({
template: template,
key: ":",
- transformComplete: function(v){ return v.code + ":"; },
- dataSource: function(term){
+ transformComplete(v) { return v.code + ":"; },
+ dataSource(term){
return new Ember.RSVP.Promise(function(resolve) {
- var full = ":" + term;
+ const full = ":" + term;
term = term.toLowerCase();
if (term === "") {
@@ -196,7 +199,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
return resolve([Discourse.Emoji.translations[full]]);
}
- var options = Discourse.Emoji.search(term, {maxResults: 5});
+ const options = Discourse.Emoji.search(term, {maxResults: 5});
return resolve(options);
}).then(function(list) {
@@ -208,10 +211,11 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
});
},
- initEditor: function() {
+ initEditor() {
// not quite right, need a callback to pass in, meaning this gets called once,
// but if you start replying to another topic it will get the avatars wrong
- var $wmdInput, editor, self = this;
+ let $wmdInput, editor;
+ const self = this;
this.wmdInput = $wmdInput = $('#wmd-input');
if ($wmdInput.length === 0 || $wmdInput.data('init') === true) return;
@@ -219,11 +223,11 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
ComposerView.trigger("initWmdEditor");
this._applyEmojiAutocomplete();
- var template = this.container.lookup('template:user-selector-autocomplete.raw');
+ const template = this.container.lookup('template:user-selector-autocomplete.raw');
$wmdInput.data('init', true);
$wmdInput.autocomplete({
template: template,
- dataSource: function(term) {
+ dataSource(term) {
return userSearch({
term: term,
topicId: self.get('controller.controllers.topic.model.id'),
@@ -231,7 +235,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
});
},
key: "@",
- transformComplete: function(v) {
+ transformComplete(v) {
if (v.username) {
return v.username;
} else {
@@ -241,10 +245,10 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
});
this.editor = editor = Discourse.Markdown.createEditor({
- lookupAvatarByPostNumber: function(postNumber) {
- var posts = self.get('controller.controllers.topic.postStream.posts');
+ lookupAvatarByPostNumber(postNumber) {
+ const posts = self.get('controller.controllers.topic.postStream.posts');
if (posts) {
- var quotedPost = posts.findProperty("post_number", postNumber);
+ const quotedPost = posts.findProperty("post_number", postNumber);
if (quotedPost) {
return Discourse.Utilities.tinyAvatar(quotedPost.get("avatar_template"));
}
@@ -273,7 +277,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
this.set('editor', this.editor);
this.loadingChanged();
- var saveDraft = Discourse.debounce((function() {
+ const saveDraft = Discourse.debounce((function() {
return self.get('controller').saveDraft();
}), 2000);
@@ -282,7 +286,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
return true;
});
- var $replyTitle = $('#reply-title');
+ const $replyTitle = $('#reply-title');
$replyTitle.keyup(function() {
saveDraft();
@@ -305,9 +309,9 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
// in case it's still bound somehow
this._unbindUploadTarget();
- var $uploadTarget = $('#reply-control'),
- csrf = Discourse.Session.currentProp('csrfToken'),
- cancelledByTheUser;
+ const $uploadTarget = $('#reply-control'),
+ csrf = Discourse.Session.currentProp('csrfToken');
+ let cancelledByTheUser;
// NOTE: we need both the .json extension and the CSRF token as a query parameter for IE9
$uploadTarget.fileupload({
@@ -318,7 +322,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
// submit - this event is triggered for each upload
$uploadTarget.on('fileuploadsubmit', function (e, data) {
- var result = Discourse.Utilities.validateUploadedFiles(data.files);
+ const result = Discourse.Utilities.validateUploadedFiles(data.files);
// reset upload status when everything is ok
if (result) self.setProperties({ uploadProgress: 0, isUploading: true });
return result;
@@ -331,7 +335,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
self.get('controller').send('closeModal');
// NOTE: IE9 doesn't support XHR
if (data["xhr"]) {
- var jqHXR = data.xhr();
+ const jqHXR = data.xhr();
if (jqHXR) {
// need to wait for the link to show up in the DOM
Em.run.schedule('afterRender', function() {
@@ -351,7 +355,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
// progress all
$uploadTarget.on('fileuploadprogressall', function (e, data) {
- var progress = parseInt(data.loaded / data.total * 100, 10);
+ const progress = parseInt(data.loaded / data.total * 100, 10);
self.set('uploadProgress', progress);
});
@@ -360,7 +364,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
if (!cancelledByTheUser) {
// make sure we have a url
if (data.result.url) {
- var markdown = Discourse.Utilities.getUploadMarkdown(data.result);
+ const markdown = Discourse.Utilities.getUploadMarkdown(data.result);
// appends a space at the end of the inserted markdown
self.addMarkdown(markdown + " ");
self.set('isUploading', false);
@@ -385,7 +389,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
// Firefox. This is pretty dangerous because it can potentially break
// Ctrl+v to paste so we should be conservative about what browsers this runs
// in.
- var uaMatch = navigator.userAgent.match(/Firefox\/(\d+)\.\d/);
+ const uaMatch = navigator.userAgent.match(/Firefox\/(\d+)\.\d/);
if (uaMatch && parseInt(uaMatch[1]) >= 24) {
self.$().append( Ember.$("
") );
self.$("textarea").off('keydown.contenteditable');
@@ -395,18 +399,18 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
// after we switch focus, probably because it is being executed too late.
if ((event.ctrlKey || event.metaKey) && (event.keyCode === 86)) {
// Save the current textarea selection.
- var textarea = self.$("textarea")[0],
+ const textarea = self.$("textarea")[0],
selectionStart = textarea.selectionStart,
selectionEnd = textarea.selectionEnd;
// Focus the contenteditable div.
- var contentEditableDiv = self.$('#contenteditable');
+ const contentEditableDiv = self.$('#contenteditable');
contentEditableDiv.focus();
// The paste doesn't finish immediately and we don't have any onpaste
// event, so wait for 100ms which _should_ be enough time.
setTimeout(function() {
- var pastedImg = contentEditableDiv.find('img');
+ const pastedImg = contentEditableDiv.find('img');
if ( pastedImg.length === 1 ) {
pastedImg.remove();
@@ -414,11 +418,11 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
// For restoring the selection.
textarea.focus();
- var textareaContent = $(textarea).val(),
+ const textareaContent = $(textarea).val(),
startContent = textareaContent.substring(0, selectionStart),
endContent = textareaContent.substring(selectionEnd);
- var restoreSelection = function(pastedText) {
+ const restoreSelection = function(pastedText) {
$(textarea).val( startContent + pastedText + endContent );
textarea.selectionStart = selectionStart + pastedText.length;
textarea.selectionEnd = textarea.selectionStart;
@@ -435,20 +439,20 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
// to a Blob and upload that, but if it is a regular URL that
// operation is prevented for security purposes. When we get a regular
// URL let's just create an
![]()
tag for the image.
- var imageSrc = pastedImg.attr('src');
+ const imageSrc = pastedImg.attr('src');
if (imageSrc.match(/^data:image/)) {
// Restore the cursor position, and remove any selected text.
restoreSelection("");
// Create a Blob to upload.
- var image = new Image();
+ const image = new Image();
image.onload = function() {
// Create a new canvas.
- var canvas = document.createElementNS('http://www.w3.org/1999/xhtml', 'canvas');
+ const canvas = document.createElementNS('http://www.w3.org/1999/xhtml', 'canvas');
canvas.height = image.height;
canvas.width = image.width;
- var ctx = canvas.getContext('2d');
+ const ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0);
canvas.toBlob(function(blob) {
@@ -488,8 +492,8 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
}, 400);
},
- addMarkdown: function(text) {
- var ctrl = $('#wmd-input').get(0),
+ addMarkdown(text) {
+ const ctrl = $('#wmd-input').get(0),
caretPosition = Discourse.Utilities.caretPosition(ctrl),
current = this.get('model.reply');
this.set('model.reply', current.substring(0, caretPosition) + text + current.substring(caretPosition, current.length));
@@ -500,10 +504,10 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
},
// Uses javascript to get the image sizes from the preview, if present
- imageSizes: function() {
- var result = {};
+ imageSizes() {
+ const result = {};
$('#wmd-preview img').each(function(i, e) {
- var $img = $(e),
+ const $img = $(e),
src = $img.prop('src');
if (src && src.length) {
@@ -513,12 +517,12 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
return result;
},
- childDidInsertElement: function() {
+ childDidInsertElement() {
return this.initEditor();
},
- childWillDestroyElement: function() {
- var self = this;
+ childWillDestroyElement() {
+ const self = this;
this._unbindUploadTarget();
@@ -532,9 +536,9 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
},
titleValidation: function() {
- var titleLength = this.get('model.titleLength'),
- missingChars = this.get('model.missingTitleCharacters'),
- reason;
+ const titleLength = this.get('model.titleLength'),
+ missingChars = this.get('model.missingTitleCharacters');
+ let reason;
if( titleLength < 1 ){
reason = I18n.t('composer.error.title_missing');
} else if( missingChars > 0 ) {
@@ -555,9 +559,9 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
}.property('model.categoryId'),
replyValidation: function() {
- var replyLength = this.get('model.replyLength'),
- missingChars = this.get('model.missingReplyCharacters'),
- reason;
+ const replyLength = this.get('model.replyLength'),
+ missingChars = this.get('model.missingReplyCharacters');
+ let reason;
if( replyLength < 1 ){
reason = I18n.t('composer.error.post_missing');
} else if( missingChars > 0 ) {
@@ -569,8 +573,8 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
}
}.property('model.reply', 'model.replyLength', 'model.missingReplyCharacters', 'model.minimumPostLength'),
- _unbindUploadTarget: function() {
- var $uploadTarget = $('#reply-control');
+ _unbindUploadTarget() {
+ const $uploadTarget = $('#reply-control');
try { $uploadTarget.fileupload('destroy'); }
catch (e) { /* wasn't initialized yet */ }
$uploadTarget.off();
diff --git a/app/assets/javascripts/discourse/views/container.js.es6 b/app/assets/javascripts/discourse/views/container.js.es6
index f538c90210..f8cbc3bd60 100644
--- a/app/assets/javascripts/discourse/views/container.js.es6
+++ b/app/assets/javascripts/discourse/views/container.js.es6
@@ -1,26 +1,11 @@
export default Ember.ContainerView.extend(Discourse.Presence, {
- /**
- Attaches a view and wires up the container properly
-
- @method attachViewWithArgs
- @param {Object} viewArgs The arguments to pass when creating the view
- @param {Class} viewClass The view class we want to create
- **/
- attachViewWithArgs: function(viewArgs, viewClass) {
+ attachViewWithArgs(viewArgs, viewClass) {
if (!viewClass) { viewClass = Ember.View.extend(); }
- var view = this.createChildView(viewClass, viewArgs);
- this.pushObject(view);
+ this.pushObject(this.createChildView(viewClass, viewArgs));
},
- /**
- Attaches a view with no arguments and wires up the container properly
-
- @method attachViewClass
- @param {Class} viewClass The view class we want to add
- **/
- attachViewClass: function(viewClass) {
+ attachViewClass(viewClass) {
this.attachViewWithArgs(null, viewClass);
}
-
});
diff --git a/app/assets/javascripts/discourse/views/list/posts-count-column.js.es6 b/app/assets/javascripts/discourse/views/list/posts-count-column.js.es6
index af020c59aa..19bed7d0d2 100644
--- a/app/assets/javascripts/discourse/views/list/posts-count-column.js.es6
+++ b/app/assets/javascripts/discourse/views/list/posts-count-column.js.es6
@@ -11,7 +11,7 @@ export default Ember.Object.extend({
title: function() {
return I18n.messageFormat('posts_likes_MF', {
- count: this.get('topic.posts_count'),
+ count: this.get('topic.replyCount'),
ratio: this.get('ratioText')
}).trim();
}.property(),
diff --git a/app/assets/javascripts/discourse/views/post.js.es6 b/app/assets/javascripts/discourse/views/post.js.es6
index 422771c061..42bf9dfc4e 100644
--- a/app/assets/javascripts/discourse/views/post.js.es6
+++ b/app/assets/javascripts/discourse/views/post.js.es6
@@ -48,12 +48,12 @@ var PostView = Discourse.GroupedView.extend(Ember.Evented, {
Em.run.scheduleOnce('afterRender', this, '_cookedWasChanged');
}.observes('post.cooked'),
- _cookedWasChanged: function() {
+ _cookedWasChanged() {
this.trigger('postViewUpdated', this.$());
this._insertQuoteControls();
},
- mouseUp: function(e) {
+ mouseUp(e) {
if (this.get('controller.multiSelect') && (e.metaKey || e.ctrlKey)) {
this.get('controller').toggledSelectedPost(this.get('post'));
}
@@ -74,7 +74,7 @@ var PostView = Discourse.GroupedView.extend(Ember.Evented, {
repliesShown: Em.computed.gt('post.replies.length', 0),
- _updateQuoteElements: function($aside, desc) {
+ _updateQuoteElements($aside, desc) {
var navLink = "",
quoteTitle = I18n.t("post.follow_quote"),
postNumber = $aside.data('post');
@@ -108,7 +108,7 @@ var PostView = Discourse.GroupedView.extend(Ember.Evented, {
$('.quote-controls', $aside).html(expandContract + navLink);
},
- _toggleQuote: function($aside) {
+ _toggleQuote($aside) {
if (this.get('expanding')) { return; }
this.set('expanding', true);
@@ -151,23 +151,29 @@ var PostView = Discourse.GroupedView.extend(Ember.Evented, {
},
// Show how many times links have been clicked on
- _showLinkCounts: function() {
- var self = this,
- link_counts = this.get('post.link_counts');
+ _showLinkCounts() {
+ const self = this,
+ link_counts = this.get('post.link_counts');
- if (!link_counts) return;
+ if (!link_counts) { return; }
link_counts.forEach(function(lc) {
- if (!lc.clicks || lc.clicks < 1) return;
+ if (!lc.clicks || lc.clicks < 1) { return; }
self.$(".cooked a[href]").each(function() {
- var link = $(this);
- if ((!lc.internal || lc.url[0] === "/") && link.attr('href') === lc.url) {
- // don't display badge counts on category badge
- if (link.closest('.badge-category').length === 0 && ((link.closest(".onebox-result").length === 0 && link.closest('.onebox-body').length === 0) || link.hasClass("track-link"))) {
- link.append("
" + Discourse.Formatter.number(lc.clicks) + "");
+ const $link = $(this),
+ href = $link.attr('href');
+
+ let valid = !lc.internal && href === lc.url;
+
+ // this might be an attachment
+ if (lc.internal) { valid = href.indexOf(lc.url) >= 0; }
+
+ if (valid) {
+ // don't display badge counts on category badge & oneboxes (unless when explicitely stated)
+ if ($link.hasClass("track-link") ||
+ $link.closest('.badge-category,.onebox-result,.onebox-body').length === 0) {
+ $link.append("
" + Discourse.Formatter.number(lc.clicks) + "");
}
}
});
@@ -176,7 +182,7 @@ var PostView = Discourse.GroupedView.extend(Ember.Evented, {
actions: {
// Toggle the replies this post is a reply to
- toggleReplyHistory: function(post) {
+ toggleReplyHistory(post) {
var replyHistory = post.get('replyHistory'),
topicController = this.get('controller'),
@@ -227,7 +233,7 @@ var PostView = Discourse.GroupedView.extend(Ember.Evented, {
},
// Add the quote controls to a post
- _insertQuoteControls: function() {
+ _insertQuoteControls() {
var self = this,
$quotes = this.$('aside.quote');
diff --git a/app/assets/javascripts/discourse/views/topic-footer-buttons.js.es6 b/app/assets/javascripts/discourse/views/topic-footer-buttons.js.es6
index f013611dc7..1598eb1ae9 100644
--- a/app/assets/javascripts/discourse/views/topic-footer-buttons.js.es6
+++ b/app/assets/javascripts/discourse/views/topic-footer-buttons.js.es6
@@ -5,23 +5,24 @@ import BookmarkButton from 'discourse/views/bookmark-button';
import ShareButton from 'discourse/views/share-button';
import InviteReplyButton from 'discourse/views/invite-reply-button';
import ReplyButton from 'discourse/views/reply-button';
-import PinnedButton from 'discourse/views/pinned-button';
-import TopicNotificationsButton from 'discourse/views/topic-notifications-button';
+import PinnedButton from 'discourse/components/pinned-button';
+import TopicNotificationsButton from 'discourse/components/topic-notifications-button';
import DiscourseContainerView from 'discourse/views/container';
export default DiscourseContainerView.extend({
elementId: 'topic-footer-buttons',
topicBinding: 'controller.content',
- init: function() {
+ init() {
this._super();
this.createButtons();
},
// Add the buttons below a topic
- createButtons: function() {
- var topic = this.get('topic');
+ createButtons() {
+ const topic = this.get('topic');
if (Discourse.User.current()) {
+ const viewArgs = {topic};
if (Discourse.User.currentProp("staff")) {
this.attachViewClass(TopicAdminMenuButton);
}
@@ -39,8 +40,8 @@ export default DiscourseContainerView.extend({
if (this.get('topic.details.can_create_post')) {
this.attachViewClass(ReplyButton);
}
- this.attachViewClass(PinnedButton);
- this.attachViewClass(TopicNotificationsButton);
+ this.attachViewWithArgs(viewArgs, PinnedButton);
+ this.attachViewWithArgs(viewArgs, TopicNotificationsButton);
this.trigger('additionalButtons', this);
} else {
diff --git a/app/assets/javascripts/discourse/views/topic-notifications-button.js.es6 b/app/assets/javascripts/discourse/views/topic-notifications-button.js.es6
deleted file mode 100644
index a58ae80dcc..0000000000
--- a/app/assets/javascripts/discourse/views/topic-notifications-button.js.es6
+++ /dev/null
@@ -1,20 +0,0 @@
-import NotificationsButton from 'discourse/views/notifications-button';
-
-export default NotificationsButton.extend({
- longDescriptionBinding: 'topic.details.notificationReasonText',
- topic: Em.computed.alias('controller.model'),
- target: Em.computed.alias('topic'),
- hidden: Em.computed.alias('topic.deleted'),
- notificationLevels: Discourse.Topic.NotificationLevel,
- notificationLevel: Em.computed.alias('topic.details.notification_level'),
- isPrivateMessage: Em.computed.alias('topic.isPrivateMessage'),
- i18nPrefix: 'topic.notifications',
-
- i18nPostfix: function() {
- return this.get('isPrivateMessage') ? '_pm' : '';
- }.property('isPrivateMessage'),
-
- clicked: function(id) {
- this.get('topic.details').updateNotifications(id);
- }
-});
diff --git a/app/assets/javascripts/discourse/views/topic.js.es6 b/app/assets/javascripts/discourse/views/topic.js.es6
index 772291a513..1050dc5a32 100644
--- a/app/assets/javascripts/discourse/views/topic.js.es6
+++ b/app/assets/javascripts/discourse/views/topic.js.es6
@@ -51,8 +51,8 @@ var TopicView = Discourse.View.extend(AddCategoryClass, Discourse.Scrolling, {
var $target = $(e.target);
if ($target.hasClass('mention') || $target.parents('.expanded-embed').length) { return false; }
- return Discourse.ClickTrack.trackClick(e);
+ return Discourse.ClickTrack.trackClick(e);
});
}.on('didInsertElement'),
@@ -126,7 +126,7 @@ var TopicView = Discourse.View.extend(AddCategoryClass, Discourse.Scrolling, {
var opts = { latestLink: "
" + I18n.t("topic.view_latest_topics") + "" },
category = this.get('controller.content.category');
- if(Em.get(category, 'id') === Discourse.Site.currentProp("uncategorized_category_id")) {
+ if(category && Em.get(category, 'id') === Discourse.Site.currentProp("uncategorized_category_id")) {
category = null;
}
diff --git a/app/assets/javascripts/discourse/views/user-card.js.es6 b/app/assets/javascripts/discourse/views/user-card.js.es6
index 2cf2801f27..4d009c82c8 100644
--- a/app/assets/javascripts/discourse/views/user-card.js.es6
+++ b/app/assets/javascripts/discourse/views/user-card.js.es6
@@ -6,7 +6,7 @@ var clickOutsideEventName = "mousedown.outside-user-card",
export default Discourse.View.extend(CleansUp, {
elementId: 'user-card',
- classNameBindings: ['controller.visible::hidden', 'controller.showBadges', 'controller.hasCardBadgeImage'],
+ classNameBindings: ['controller.visible:show', 'controller.showBadges', 'controller.hasCardBadgeImage'],
allowBackgrounds: Discourse.computed.setting('allow_profile_backgrounds'),
addBackground: function() {
diff --git a/app/assets/javascripts/locales/i18n.js b/app/assets/javascripts/locales/i18n.js
index 4e2bbb7c55..61ebac3941 100644
--- a/app/assets/javascripts/locales/i18n.js
+++ b/app/assets/javascripts/locales/i18n.js
@@ -522,7 +522,7 @@ I18n.enable_verbose_localization = function(){
if (!_.isEmpty(value)) {
message += ", parameters: " + JSON.stringify(value);
}
- //window.console.log(message);
+ Em.Logger.info(message);
}
return t.apply(I18n, [scope, value]) + " (t" + current + ")";
};
diff --git a/app/assets/javascripts/main_include.js b/app/assets/javascripts/main_include.js
index 15f6bd8f45..ea57272eae 100644
--- a/app/assets/javascripts/main_include.js
+++ b/app/assets/javascripts/main_include.js
@@ -10,6 +10,7 @@
//
// Stuff we need to load first
+//= require ./discourse/lib/notification-levels
//= require ./discourse/lib/app-events
//= require ./discourse/helpers/i18n
//= require ./discourse/helpers/fa-icon
@@ -42,9 +43,9 @@
//= require ./discourse/views/flag
//= require ./discourse/views/combo-box
//= require ./discourse/views/button
-//= require ./discourse/views/dropdown-button
-//= require ./discourse/views/notifications-button
-//= require ./discourse/views/topic-notifications-button
+//= require ./discourse/components/dropdown-button
+//= require ./discourse/components/notifications-button
+//= require ./discourse/components/topic-notifications-button
//= require ./discourse/views/pagedown-preview
//= require ./discourse/views/composer
//= require ./discourse/routes/discourse_route
diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss
index 9ff7cf1f6b..eb9b02fd34 100644
--- a/app/assets/stylesheets/common/admin/admin_base.scss
+++ b/app/assets/stylesheets/common/admin/admin_base.scss
@@ -1440,3 +1440,9 @@ tr.not-activated {
.preview {
margin-top: 5px;
}
+
+table#user-badges {
+ .reason {
+ max-width: 200px;
+ }
+}
diff --git a/app/assets/stylesheets/common/base/_topic-list.scss b/app/assets/stylesheets/common/base/_topic-list.scss
index a16cadd0af..b86750dbda 100644
--- a/app/assets/stylesheets/common/base/_topic-list.scss
+++ b/app/assets/stylesheets/common/base/_topic-list.scss
@@ -159,7 +159,7 @@
.badge-category {
padding: 4px 10px;
display: inline-block;
- line-height: 24px;
+ margin-bottom: 10px;
}
.category-dropdown-menu .badge-category {
diff --git a/app/assets/stylesheets/common/base/about.scss b/app/assets/stylesheets/common/base/about.scss
index fdaafacb7a..af5d06a5c1 100644
--- a/app/assets/stylesheets/common/base/about.scss
+++ b/app/assets/stylesheets/common/base/about.scss
@@ -6,13 +6,43 @@ section.about {
}
.user-small {
- padding: 5px;
- width: 200px;
+ padding: 8px;
+ width: 205px;
+ height: 60px;
float: left;
- img {
+ .user-image {
+ float: left;
padding-right: 4px;
}
+
+ .user-detail {
+ float: left;
+ width: 70%;
+
+ span {
+ float: left;
+ width: 90%;
+ padding-left: 5px;
+ }
+
+ .username a {
+ font-weight: bold;
+ font-size: 16px;
+ color: scale-color($primary, $lightness: 30%);
+ }
+
+ .name {
+ font-size: 13px;
+ color: scale-color($primary, $lightness: 30%);
+ }
+
+ .title {
+ font-size: 13px;
+ color: scale-color($primary, $lightness: 50%);
+ }
+
+ }
}
p {
diff --git a/app/assets/stylesheets/common/base/magnific-popup.scss b/app/assets/stylesheets/common/base/magnific-popup.scss
index 2d18d42d47..0c689265a0 100644
--- a/app/assets/stylesheets/common/base/magnific-popup.scss
+++ b/app/assets/stylesheets/common/base/magnific-popup.scss
@@ -651,15 +651,15 @@ button {
/* start state */
.mfp-content {
opacity: 0;
- transition: all 0.2s ease-in-out;
- -webkit-transform: scale(0.8);
- -ms-transform: scale(0.8);
- transform: scale(0.8);
+ transition: all .2s;
+ -webkit-transform: scale(.8);
+ -ms-transform: scale(.8);
+ transform: scale(.8);
}
&.mfp-bg {
opacity: 0;
- transition: all 0.3s ease-out;
+ transition: all .3s ease-out;
}
/* animate in */
@@ -679,9 +679,9 @@ button {
&.mfp-removing {
.mfp-content {
- -webkit-transform: scale(0.8);
- -ms-transform: scale(0.8);
- transform: scale(0.8);
+ -webkit-transform: scale(.8);
+ -ms-transform: scale(.8);
+ transform: scale(.8);
opacity: 0;
}
&.mfp-bg {
diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss
index 2d36cb8346..588eb44e8a 100644
--- a/app/assets/stylesheets/common/base/topic-post.scss
+++ b/app/assets/stylesheets/common/base/topic-post.scss
@@ -179,7 +179,11 @@ kbd
margin: 0 .1em;
padding: .1em .6em;
- * * { display: none; }
+ // don't allow more than 3 nested elements to prevent FF from crashing
+ // cf. http://what.thedailywtf.com/t/nested-elements/7927
+ // 3 levels are needed to prevent highlighted words being hidden
+ // cf. https://meta.discourse.org/t/word-disappears-when-searched-and-in-details-summary-kbd-b/25741
+ * * * { display: none; }
}
// we assume blockquotes have their own margins, so all blockquotes
diff --git a/app/assets/stylesheets/common/base/user-badges.scss b/app/assets/stylesheets/common/base/user-badges.scss
index 657aba3d2b..3c8405def3 100644
--- a/app/assets/stylesheets/common/base/user-badges.scss
+++ b/app/assets/stylesheets/common/base/user-badges.scss
@@ -159,3 +159,8 @@ table.badges-listing {
text-align: left;
}
}
+
+.long-description.banner {
+ width: 88%;
+ margin-bottom: 20px;
+}
diff --git a/app/assets/stylesheets/common/components/badges.css.scss b/app/assets/stylesheets/common/components/badges.css.scss
index e0d9922745..8506cb8917 100644
--- a/app/assets/stylesheets/common/components/badges.css.scss
+++ b/app/assets/stylesheets/common/components/badges.css.scss
@@ -132,12 +132,21 @@ header .title-wrapper .bar .badge-category {
.category-breadcrumb li.bar > .badge-category {
background: dark-light-diff($primary, $secondary, 95%, -65%) !important;
+ line-height: 24px;
&:not(.home):first-child {
border-left-width: 5px;
border-left-style: solid;
}
}
+.category-breadcrumb .box > a.badge-category {
+ margin-bottom: 0;
+ height: 24px;
+ // TODO clean this up
+ padding-top: 6px !important;
+ padding-bottom: 0 !important;
+}
+
.category-dropdown-menu .cat .badge-wrapper.box {
width: 110%;
}
diff --git a/app/assets/stylesheets/common/components/banner.css.scss b/app/assets/stylesheets/common/components/banner.css.scss
index 7047147b8e..f423c7356d 100644
--- a/app/assets/stylesheets/common/components/banner.css.scss
+++ b/app/assets/stylesheets/common/components/banner.css.scss
@@ -2,7 +2,7 @@
// Banner
// --------------------------------------------------
-#banner {
+#banner, .banner {
padding: 10px;
border-radius: 5px;
background: scale-color($tertiary, $lightness: 90%);
diff --git a/app/assets/stylesheets/desktop/discourse.scss b/app/assets/stylesheets/desktop/discourse.scss
index 2e9ee7e841..6b96e29599 100644
--- a/app/assets/stylesheets/desktop/discourse.scss
+++ b/app/assets/stylesheets/desktop/discourse.scss
@@ -123,6 +123,9 @@ body {
}
/* page not found styles */
+ h1.page-not-found {
+ line-height: 30px;
+ }
.page-not-found {
margin: 20px 0 40px 0;
diff --git a/app/assets/stylesheets/desktop/user-card.scss b/app/assets/stylesheets/desktop/user-card.scss
index 5a5703b743..90c5a60913 100644
--- a/app/assets/stylesheets/desktop/user-card.scss
+++ b/app/assets/stylesheets/desktop/user-card.scss
@@ -5,13 +5,26 @@
width: 500px;
left: 20px;
z-index: 990;
- box-shadow: 0 2px 6px rgba(0,0,0, .6);
+ box-shadow: 0 2px 6px rgba(0,0,0,.6);
margin-top: -2px;
background-color: $primary;
color: $secondary;
background-size: cover;
background-position: center center;
min-height: 175px;
+ opacity: 0;
+ -webkit-transform: scale(.9);
+ -ms-transform: scale(.9);
+ transform: scale(.9);
+ -webkit-transition: opacity .2s, -webkit-transform .2s;
+ transition: opacity .2s, transform .2s;
+
+ &.show {
+ opacity: 1;
+ -webkit-transform: scale(1);
+ -ms-transform: scale(1);
+ transform: scale(1);
+ }
.card-content {
padding: 12px 12px 0 12px;
@@ -25,13 +38,13 @@
}
}
-&.no-bg {
- min-height: 50px;
+ &.no-bg {
+ min-height: 50px;
- .card-content {
- margin-top: 0;
+ .card-content {
+ margin-top: 0;
+ }
}
-}
.avatar-placeholder {
width: 120px;
@@ -84,6 +97,7 @@
color: dark-light-diff($primary, $secondary, 50%, -50%);
}
}
+
.groups {
font-size: 0.929em;
font-weight: normal;
diff --git a/app/assets/stylesheets/mobile/topic-list.scss b/app/assets/stylesheets/mobile/topic-list.scss
index a8b293fb76..22ea3fc3b7 100644
--- a/app/assets/stylesheets/mobile/topic-list.scss
+++ b/app/assets/stylesheets/mobile/topic-list.scss
@@ -307,5 +307,6 @@ button.dismiss-read {
td.main-link {
a.title {
padding: 5px 10px 5px 0;
+ font-weight: bold;
}
}
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index 054392acd1..5389b578d5 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -70,14 +70,19 @@ class Admin::GroupsController < Admin::AdminController
def add_members
group = Group.find(params.require(:id))
- usernames = params.require(:usernames)
return can_not_modify_automatic if group.automatic
- usernames.split(",").each do |username|
- if user = User.find_by_username(username)
- group.add(user)
- end
+ if params[:usernames].present?
+ users = User.where(username: params[:usernames].split(","))
+ elsif params[:user_ids].present?
+ users = User.find(params[:user_ids].split(","))
+ else
+ raise Discourse::InvalidParameters.new('user_ids or usernames must be present')
+ end
+
+ users.each do |user|
+ group.add(user)
end
if group.save
@@ -89,14 +94,20 @@ class Admin::GroupsController < Admin::AdminController
def remove_member
group = Group.find(params.require(:id))
- user_id = params.require(:user_id).to_i
return can_not_modify_automatic if group.automatic
- user = User.find(user_id)
+ if params[:user_id].present?
+ user = User.find(params[:user_id])
+ elsif params[:username].present?
+ user = User.find_by_username(params[:username])
+ else
+ raise Discourse::InvalidParameters.new('user_id or username must be present')
+ end
+
user.primary_group_id = nil if user.primary_group_id == group.id
- group.users.delete(user_id)
+ group.users.delete(user.id)
if group.save && user.save
render json: success_json
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index fe60409f75..d36718044c 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -59,24 +59,10 @@ class ApplicationController < ActionController::Base
use_crawler_layout? ? 'crawler' : 'application'
end
- rescue_from Exception do |exception|
- unless [ActiveRecord::RecordNotFound,
- ActionController::RoutingError,
- ActionController::UnknownController,
- AbstractController::ActionNotFound].include? exception.class
- begin
- ErrorLog.report_async!(exception, self, request, current_user)
- rescue
- # dont care give up
- end
- end
- raise
- end
-
# Some exceptions
class RenderEmpty < Exception; end
- # Render nothing unless we are an xhr request
+ # Render nothing
rescue_from RenderEmpty do
render 'default/empty'
end
@@ -93,40 +79,43 @@ class ApplicationController < ActionController::Base
time_left = I18n.t("rate_limiter.hours", count: (e.available_in / 1.hour.to_i))
end
- render json: {errors: [I18n.t("rate_limiter.too_many_requests", time_left: time_left)]}, status: 429
+ render_json_error I18n.t("rate_limiter.too_many_requests", time_left: time_left), type: :rate_limit, status: 429
end
rescue_from Discourse::NotLoggedIn do |e|
raise e if Rails.env.test?
- if request.get?
- redirect_to "/"
+ if (request.format && request.format.json?) || request.xhr? || !request.get?
+ rescue_discourse_actions(:not_logged_in, 403, true)
else
- render status: 403, json: failed_json.merge(message: I18n.t(:not_logged_in))
+ redirect_to "/"
end
end
rescue_from Discourse::NotFound do
- rescue_discourse_actions("[error: 'not found']", 404) # TODO: this breaks json responses
+ rescue_discourse_actions(:not_found, 404)
end
rescue_from Discourse::InvalidAccess do
- rescue_discourse_actions("[error: 'invalid access']", 403, true) # TODO: this breaks json responses
+ rescue_discourse_actions(:invalid_access, 403, true)
end
rescue_from Discourse::ReadOnly do
- render status: 405, json: failed_json.merge(message: I18n.t("read_only_mode_enabled"))
+ render_json_error I18n.t('read_only_mode_enabled'), type: :read_only, status: 405
end
- def rescue_discourse_actions(message, error, include_ember=false)
- if request.format && request.format.json?
- # TODO: this doesn't make sense. Stuffing an html page into a json response will cause
- # $.parseJSON to fail in the browser. Also returning text like "[error: 'invalid access']"
- # from the above rescue_from blocks will fail because that isn't valid json.
- render status: error, layout: false, text: (error == 404) ? build_not_found_page(error) : message
+ def rescue_discourse_actions(type, status_code, include_ember=false)
+
+ if (request.format && request.format.json?) || (request.xhr?)
+ # HACK: do not use render_json_error for topics#show
+ if request.params[:controller] == 'topics' && request.params[:action] == 'show'
+ return render status: status_code, layout: false, text: (status_code == 404) ? build_not_found_page(status_code) : I18n.t(type)
+ end
+
+ render_json_error I18n.t(type), type: type, status: status_code
else
- render text: build_not_found_page(error, include_ember ? 'application' : 'no_ember')
+ render text: build_not_found_page(status_code, include_ember ? 'application' : 'no_ember')
end
end
@@ -318,8 +307,17 @@ class ApplicationController < ActionController::Base
MultiJson.dump(serializer)
end
- def render_json_error(obj)
- render json: MultiJson.dump(create_errors_json(obj)), status: 422
+ # Render action for a JSON error.
+ #
+ # obj - a translated string, an ActiveRecord model, or an array of translated strings
+ # opts:
+ # type - a machine-readable description of the error
+ # status - HTTP status code to return
+ def render_json_error(obj, opts={})
+ if opts.is_a? Fixnum
+ opts = {status: opts}
+ end
+ render json: MultiJson.dump(create_errors_json(obj, opts[:type])), status: opts[:status] || 422
end
def success_json
@@ -377,7 +375,15 @@ class ApplicationController < ActionController::Base
# save original URL in a cookie
cookies[:destination_url] = request.original_url unless request.original_url =~ /uploads/
- redirect_to :login if SiteSetting.login_required?
+
+ # redirect user to the SSO page if we need to log in AND SSO is enabled
+ if SiteSetting.login_required?
+ if SiteSetting.enable_sso?
+ redirect_to '/session/sso'
+ else
+ redirect_to :login
+ end
+ end
end
def block_if_readonly_mode
diff --git a/app/controllers/badges_controller.rb b/app/controllers/badges_controller.rb
index 77e4a164d0..728a3dbf69 100644
--- a/app/controllers/badges_controller.rb
+++ b/app/controllers/badges_controller.rb
@@ -17,7 +17,7 @@ class BadgesController < ApplicationController
if current_user
user_badges = Set.new(current_user.user_badges.select('distinct badge_id').pluck(:badge_id))
end
- serialized = MultiJson.dump(serialize_data(badges, BadgeIndexSerializer, root: "badges", user_badges: user_badges))
+ serialized = MultiJson.dump(serialize_data(badges, BadgeIndexSerializer, root: "badges", user_badges: user_badges, include_long_description: true))
respond_to do |format|
format.html do
store_preloaded "badges", serialized
@@ -38,7 +38,7 @@ class BadgesController < ApplicationController
end
end
- serialized = MultiJson.dump(serialize_data(badge, BadgeSerializer, root: "badge"))
+ serialized = MultiJson.dump(serialize_data(badge, BadgeSerializer, root: "badge", include_long_description: true))
respond_to do |format|
format.html do
store_preloaded "badge", serialized
diff --git a/app/controllers/clicks_controller.rb b/app/controllers/clicks_controller.rb
index 056a8e84d7..8472c9e7ff 100644
--- a/app/controllers/clicks_controller.rb
+++ b/app/controllers/clicks_controller.rb
@@ -8,12 +8,6 @@ class ClicksController < ApplicationController
if params[:topic_id].present? || params[:post_id].present?
params.merge!({ user_id: current_user.id }) if current_user.present?
@redirect_url = TopicLinkClick.create_from(params)
-
- if @redirect_url.blank? && params[:url].index('?')
- # Check the url without query parameters
- params[:url].sub!(/\?.*$/, '')
- @redirect_url = TopicLinkClick.create_from(params)
- end
end
# Sometimes we want to record a link without a 302. Since XHR has to load the redirected
diff --git a/app/controllers/list_controller.rb b/app/controllers/list_controller.rb
index 45a2d1aaad..55bdac73f1 100644
--- a/app/controllers/list_controller.rb
+++ b/app/controllers/list_controller.rb
@@ -82,6 +82,7 @@ class ListController < ApplicationController
end
define_method("category_#{filter}") do
+ canonical_url "#{Discourse.base_url}#{@category.url}"
self.send(filter, { category: @category.id })
end
@@ -90,6 +91,7 @@ class ListController < ApplicationController
end
define_method("parent_category_category_#{filter}") do
+ canonical_url "#{Discourse.base_url}#{@category.url}"
self.send(filter, { category: @category.id })
end
diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb
index 12c736d1d2..6a1317cb75 100644
--- a/app/controllers/session_controller.rb
+++ b/app/controllers/session_controller.rb
@@ -52,21 +52,24 @@ class SessionController < ApplicationController
def sso_login
unless SiteSetting.enable_sso
- render nothing: true, status: 404
- return
+ return render(nothing: true, status: 404)
end
sso = DiscourseSingleSignOn.parse(request.query_string)
if !sso.nonce_valid?
- render text: I18n.t("sso.timeout_expired"), status: 500
- return
+ return render(text: I18n.t("sso.timeout_expired"), status: 500)
+ end
+
+ if ScreenedIpAddress.should_block?(request.remote_ip)
+ return render(text: I18n.t("sso.unknown_error"), status: 500)
end
return_path = sso.return_path
sso.expire_nonce!
begin
- if user = sso.lookup_or_create_user
+ if user = sso.lookup_or_create_user(request.remote_ip)
+
if SiteSetting.must_approve_users? && !user.approved?
render text: I18n.t("sso.account_not_approved"), status: 403
else
@@ -92,7 +95,7 @@ class SessionController < ApplicationController
SingleSignOn::ACCESSORS.each do |a|
details[a] = sso.send(a)
end
- Discourse.handle_exception(e, details)
+ Discourse.handle_job_exception(e, details)
render text: I18n.t("sso.unknown_error"), status: 500
end
@@ -144,9 +147,12 @@ class SessionController < ApplicationController
return
end
- if ScreenedIpAddress.block_login?(user, request.remote_ip)
- not_allowed_from_ip_address(user)
- return
+ if ScreenedIpAddress.should_block?(request.remote_ip)
+ return not_allowed_from_ip_address(user)
+ end
+
+ if ScreenedIpAddress.block_admin_login?(user, request.remote_ip)
+ return admin_not_allowed_from_ip_address(user)
end
(user.active && user.email_confirmed?) ? login(user) : not_activated(user)
@@ -226,6 +232,10 @@ class SessionController < ApplicationController
render json: {error: I18n.t("login.not_allowed_from_ip_address", username: user.username)}
end
+ def admin_not_allowed_from_ip_address(user)
+ render json: {error: I18n.t("login.admin_not_allowed_from_ip_address", username: user.username)}
+ end
+
def failed_to_login(user)
message = user.suspend_reason ? "login.suspended_with_reason" : "login.suspended"
diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb
index 499fba7bb0..0f76977cc6 100644
--- a/app/controllers/topics_controller.rb
+++ b/app/controllers/topics_controller.rb
@@ -332,24 +332,15 @@ class TopicsController < ApplicationController
guardian.ensure_can_change_post_owner!
- post_ids = params[:post_ids].to_a
- topic = Topic.find_by(id: params[:topic_id].to_i)
- new_user = User.find_by(username: params[:username])
-
- return render json: failed_json, status: 422 unless post_ids && topic && new_user
-
- ActiveRecord::Base.transaction do
- post_ids.each do |post_id|
- post = Post.find(post_id)
- # update topic owner (first avatar)
- topic.user = new_user if post.is_first_post?
- post.set_owner(new_user, current_user)
- end
+ begin
+ PostOwnerChanger.new( post_ids: params[:post_ids].to_a,
+ topic_id: params[:topic_id].to_i,
+ new_owner: User.find_by(username: params[:username]),
+ acting_user: current_user ).change_owner!
+ render json: success_json
+ rescue ArgumentError
+ render json: failed_json, status: 422
end
-
- topic.update_statistics
-
- render json: success_json
end
def clear_pin
diff --git a/app/controllers/user_badges_controller.rb b/app/controllers/user_badges_controller.rb
index 7ee6555ab2..8ab55b2e26 100644
--- a/app/controllers/user_badges_controller.rb
+++ b/app/controllers/user_badges_controller.rb
@@ -10,7 +10,7 @@ class UserBadgesController < ApplicationController
user_badges = user_badges.offset(offset.to_i)
end
- render_serialized(user_badges, UserBadgeSerializer, root: "user_badges")
+ render_serialized(user_badges, UserBadgeSerializer, root: "user_badges", include_long_description: true)
end
def username
@@ -25,8 +25,10 @@ class UserBadgesController < ApplicationController
end
user_badges = user_badges.includes(badge: [:badge_grouping, :badge_type])
+ .includes(post: :topic)
+ .includes(:granted_by)
- render_serialized(user_badges, BasicUserBadgeSerializer, root: "user_badges")
+ render_serialized(user_badges, DetailedUserBadgeSerializer, root: "user_badges")
end
def create
@@ -39,9 +41,22 @@ class UserBadgesController < ApplicationController
end
badge = fetch_badge_from_params
- user_badge = BadgeGranter.grant(badge, user, granted_by: current_user)
+ post_id = nil
- render_serialized(user_badge, UserBadgeSerializer, root: "user_badge")
+ if params[:reason].present?
+ path = URI.parse(params[:reason]).path rescue nil
+ route = Rails.application.routes.recognize_path(path) if path
+ if route
+ topic_id = route[:topic_id].to_i
+ post_number = route[:post_number] || 1
+
+ post_id = Post.find_by(topic_id: topic_id, post_number: post_number).try(:id) if topic_id > 0
+ end
+ end
+
+ user_badge = BadgeGranter.grant(badge, user, granted_by: current_user, post_id: post_id)
+
+ render_serialized(user_badge, DetailedUserBadgeSerializer, root: "user_badge")
end
def destroy
diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb
index 8c9bf69c57..2e235935b4 100644
--- a/app/controllers/users/omniauth_callbacks_controller.rb
+++ b/app/controllers/users/omniauth_callbacks_controller.rb
@@ -85,8 +85,10 @@ class Users::OmniauthCallbacksController < ApplicationController
user.toggle(:active).save
end
- if ScreenedIpAddress.block_login?(user, request.remote_ip)
+ if ScreenedIpAddress.should_block?(request.remote_ip)
@data.not_allowed_from_ip_address = true
+ elsif ScreenedIpAddress.block_admin_login?(user, request.remote_ip)
+ @data.admin_not_allowed_from_ip_address = true
elsif Guardian.new(user).can_access_forum? && user.active # log on any account that is active with forum access
log_on_user(user)
Invite.invalidate_for_email(user.email) # invite link can't be used to log in anymore
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 7d261e048d..5673c3298d 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -28,6 +28,9 @@ class UsersController < ApplicationController
def show
@user = fetch_user_from_params
user_serializer = UserSerializer.new(@user, scope: guardian, root: 'user')
+ if params[:stats].to_s == "false"
+ user_serializer.omit_stats = true
+ end
respond_to do |format|
format.html do
@restrict_fields = guardian.restrict_user_fields?(@user)
@@ -70,6 +73,7 @@ class UsersController < ApplicationController
UserField.where(editable: true).each do |f|
val = params[:user_fields][f.id.to_s]
val = nil if val === "false"
+ val = val[0...UserField.max_length] if val
return render_json_error(I18n.t("login.missing_user_field")) if val.blank? && f.required?
params[:custom_fields]["user_field_#{f.id}"] = val
@@ -221,7 +225,7 @@ class UsersController < ApplicationController
if field_val.blank?
return fail_with("login.missing_user_field") if f.required?
else
- fields["user_field_#{f.id}"] = field_val
+ fields["user_field_#{f.id}"] = field_val[0...UserField.max_length]
end
end
@@ -281,7 +285,7 @@ class UsersController < ApplicationController
end
def password_reset
- expires_now()
+ expires_now
if EmailToken.valid_token_format?(params[:token])
@user = EmailToken.confirm(params[:token])
@@ -297,7 +301,7 @@ class UsersController < ApplicationController
end
if !@user
- flash[:error] = I18n.t('password_reset.no_token')
+ @error = I18n.t('password_reset.no_token')
elsif request.put?
@invalid_password = params[:password].blank? || params[:password].length > User.max_password_length
@@ -325,7 +329,7 @@ class UsersController < ApplicationController
'password_reset.success_unapproved'
end
- flash[:success] = I18n.t(message)
+ @success = I18n.t(message)
end
def change_email
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 9bf4db32db..59c8bbb7f5 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -137,6 +137,10 @@ module ApplicationHelper
end
end
+ def application_logo_url
+ @application_logo_url ||= (mobile_view? && SiteSetting.mobile_logo_url) || SiteSetting.logo_url
+ end
+
def login_path
"#{Discourse::base_uri}/login"
end
@@ -149,9 +153,12 @@ module ApplicationHelper
MobileDetection.mobile_device?(request.user_agent)
end
-
def customization_disabled?
- controller.class.name.split("::").first == "Admin" || session[:disable_customization]
+ session[:disable_customization]
+ end
+
+ def loading_admin?
+ controller.class.name.split("::").first == "Admin"
end
def category_badge(category, opts=nil)
diff --git a/app/jobs/base.rb b/app/jobs/base.rb
index b42b5c79f8..bd9f66c86a 100644
--- a/app/jobs/base.rb
+++ b/app/jobs/base.rb
@@ -108,7 +108,7 @@ module Jobs
begin
retval = execute(opts)
rescue => exc
- Discourse.handle_exception(exc, error_context(opts))
+ Discourse.handle_job_exception(exc, error_context(opts))
end
return retval
end
@@ -172,7 +172,7 @@ module Jobs
if exceptions.length > 0
exceptions.each do |exception_hash|
- Discourse.handle_exception(exception_hash[:ex],
+ Discourse.handle_job_exception(exception_hash[:ex],
error_context(opts, exception_hash[:code], exception_hash[:other]))
end
raise HandledExceptionWrapper.new exceptions[0][:ex]
diff --git a/app/jobs/regular/notify_mailing_list_subscribers.rb b/app/jobs/regular/notify_mailing_list_subscribers.rb
index 5ee7897974..b618624b71 100644
--- a/app/jobs/regular/notify_mailing_list_subscribers.rb
+++ b/app/jobs/regular/notify_mailing_list_subscribers.rb
@@ -36,7 +36,7 @@ module Jobs
message = UserNotifications.mailing_list_notify(user, post)
Email::Sender.new(message, :mailing_list, user).send
rescue => e
- Discourse.handle_exception(e, error_context(
+ Discourse.handle_job_exception(e, error_context(
args,
"Sending post to mailing list subscribers", {
user_id: user.id,
diff --git a/app/jobs/regular/resize_emoji.rb b/app/jobs/regular/resize_emoji.rb
index 272158e17c..61ae0248c2 100644
--- a/app/jobs/regular/resize_emoji.rb
+++ b/app/jobs/regular/resize_emoji.rb
@@ -6,8 +6,12 @@ module Jobs
path = args[:path]
return unless File.exists?(path)
+ opts = {
+ allow_animation: true,
+ force_aspect_ratio: SiteSetting.enforce_square_emoji
+ }
# make sure emoji aren't too big
- OptimizedImage.resize(path, path, 60, 60, true)
+ OptimizedImage.downsize(path, path, 60, 60, opts)
end
end
diff --git a/app/jobs/scheduled/periodical_updates.rb b/app/jobs/scheduled/periodical_updates.rb
index 60fdf0aa9a..189ae7bd70 100644
--- a/app/jobs/scheduled/periodical_updates.rb
+++ b/app/jobs/scheduled/periodical_updates.rb
@@ -22,7 +22,7 @@ module Jobs
unless UserAvatar.where("last_gravatar_download_attempt IS NULL").limit(1).first
problems = Post.rebake_old(250)
problems.each do |hash|
- Discourse.handle_exception(hash[:ex], error_context(args, "Rebaking post id #{hash[:post].id}", post_id: hash[:post].id))
+ Discourse.handle_job_exception(hash[:ex], error_context(args, "Rebaking post id #{hash[:post].id}", post_id: hash[:post].id))
end
end
@@ -30,7 +30,7 @@ module Jobs
problems = UserProfile.rebake_old(250)
problems.each do |hash|
user_id = hash[:profile].user_id
- Discourse.handle_exception(hash[:ex], error_context(args, "Rebaking user id #{user_id}", user_id: user_id))
+ Discourse.handle_job_exception(hash[:ex], error_context(args, "Rebaking user id #{user_id}", user_id: user_id))
end
end
diff --git a/app/jobs/scheduled/poll_mailbox.rb b/app/jobs/scheduled/poll_mailbox.rb
index 910b936888..6d6f708b61 100644
--- a/app/jobs/scheduled/poll_mailbox.rb
+++ b/app/jobs/scheduled/poll_mailbox.rb
@@ -74,7 +74,7 @@ module Jobs
client_message = RejectionMailer.send_rejection(message_template, message.from, template_args)
Email::Sender.new(client_message, message_template).send
else
- Discourse.handle_exception(e, error_context(@args, "Unrecognized error type when processing incoming email", mail: mail_string))
+ Discourse.handle_job_exception(e, error_context(@args, "Unrecognized error type when processing incoming email", mail: mail_string))
end
end
@@ -91,7 +91,7 @@ module Jobs
pop.finish
end
rescue Net::POPAuthenticationError => e
- Discourse.handle_exception(e, error_context(@args, "Signing in to poll incoming email"))
+ Discourse.handle_job_exception(e, error_context(@args, "Signing in to poll incoming email"))
end
end
diff --git a/app/models/category_featured_topic.rb b/app/models/category_featured_topic.rb
index 485fcfd9e6..66c05573fa 100644
--- a/app/models/category_featured_topic.rb
+++ b/app/models/category_featured_topic.rb
@@ -25,7 +25,7 @@ class CategoryFeaturedTopic < ActiveRecord::Base
visible: true,
no_definitions: true)
- results = query.list_category(c).topic_ids.uniq
+ results = query.list_category_topic_ids(c).uniq
return if results == existing
diff --git a/app/models/concerns/limited_edit.rb b/app/models/concerns/limited_edit.rb
new file mode 100644
index 0000000000..8e91315bd1
--- /dev/null
+++ b/app/models/concerns/limited_edit.rb
@@ -0,0 +1,11 @@
+module LimitedEdit
+ extend ActiveSupport::Concern
+
+ def edit_time_limit_expired?
+ if created_at && SiteSetting.post_edit_time_limit.to_i > 0
+ created_at < SiteSetting.post_edit_time_limit.to_i.minutes.ago
+ else
+ false
+ end
+ end
+end
diff --git a/app/models/discourse_single_sign_on.rb b/app/models/discourse_single_sign_on.rb
index 92af895200..1c3770d862 100644
--- a/app/models/discourse_single_sign_on.rb
+++ b/app/models/discourse_single_sign_on.rb
@@ -42,13 +42,13 @@ class DiscourseSingleSignOn < SingleSignOn
"SSO_NONCE_#{nonce}"
end
- def lookup_or_create_user
+ def lookup_or_create_user(ip_address=nil)
sso_record = SingleSignOnRecord.find_by(external_id: external_id)
if sso_record && user = sso_record.user
sso_record.last_payload = unsigned_payload
else
- user = match_email_or_create_user
+ user = match_email_or_create_user(ip_address)
sso_record = user.single_sign_on_record
end
@@ -67,6 +67,7 @@ class DiscourseSingleSignOn < SingleSignOn
user.custom_fields[k] = v
end
+ user.ip_address = ip_address
user.admin = admin unless admin.nil?
user.moderator = moderator unless moderator.nil?
@@ -79,16 +80,17 @@ class DiscourseSingleSignOn < SingleSignOn
private
- def match_email_or_create_user
+ def match_email_or_create_user(ip_address)
user = User.find_by_email(email)
try_name = name.blank? ? nil : name
try_username = username.blank? ? nil : username
user_params = {
- email: email,
- name: User.suggest_name(try_name || try_username || email),
- username: UserNameSuggester.suggest(try_username || try_name || email),
+ email: email,
+ name: User.suggest_name(try_name || try_username || email),
+ username: UserNameSuggester.suggest(try_username || try_name || email),
+ ip_address: ip_address
}
if user || user = User.create!(user_params)
diff --git a/app/models/discourse_version_check.rb b/app/models/discourse_version_check.rb
index 1ac0da47f0..c1be79fcd9 100644
--- a/app/models/discourse_version_check.rb
+++ b/app/models/discourse_version_check.rb
@@ -1,5 +1,5 @@
class DiscourseVersionCheck
include ActiveModel::Model
- attr_accessor :latest_version, :critical_updates, :installed_version, :installed_sha, :missing_versions_count, :updated_at, :version_check_pending
-end
\ No newline at end of file
+ attr_accessor :latest_version, :critical_updates, :installed_version, :installed_sha, :installed_describe, :missing_versions_count, :updated_at, :version_check_pending
+end
diff --git a/app/models/error_log.rb b/app/models/error_log.rb
deleted file mode 100644
index 8c7944e862..0000000000
--- a/app/models/error_log.rb
+++ /dev/null
@@ -1,94 +0,0 @@
-# TODO:
-# a mechanism to iterate through errors in reverse
-# async logging should queue, if dupe stack traces are found in batch error should be merged into prev one
-
-class ErrorLog
-
- @lock = Mutex.new
-
- def self.filename
- "#{Rails.root}/log/#{Rails.env}_errors.log"
- end
-
- def self.clear!(_guid)
- raise NotImplementedError
- end
-
- def self.clear_all!()
- File.delete(ErrorLog.filename) if File.exists?(ErrorLog.filename)
- end
-
- def self.report_async!(exception, controller, request, user)
- Thread.new do
- report!(exception, controller, request, user)
- end
- end
-
- def self.report!(exception, controller, request, user)
- add_row!(
- date: DateTime.now,
- guid: SecureRandom.uuid,
- user_id: user && user.id,
- parameters: request && request.filtered_parameters.to_json,
- action: controller.action_name,
- controller: controller.controller_name,
- backtrace: sanitize_backtrace(exception.backtrace).join("\n"),
- message: exception.message,
- url: "#{request.protocol}#{request.env["HTTP_X_FORWARDED_HOST"] || request.env["HTTP_HOST"]}#{request.fullpath}",
- exception_class: exception.class.to_s
- )
- end
-
- def self.add_row!(hash)
- data = hash.to_xml(skip_instruct: true)
- # use background thread to write the log cause it may block if it gets backed up
- @lock.synchronize do
- File.open(filename, "a") do |f|
- f.flock(File::LOCK_EX)
- f.write(data)
- f.close
- end
- end
- end
-
-
- def self.each(&blk)
- skip(0, &blk)
- end
-
- def self.skip(skip=0)
- pos = 0
- return [] unless File.exists?(filename)
-
- loop do
- lines = ""
- File.open(self.filename, "r") do |f|
- f.flock(File::LOCK_SH)
- f.pos = pos
- while !f.eof?
- line = f.readline
- lines << line
- break if line.starts_with? ""
- end
- pos = f.pos
- end
- if lines != "" && skip == 0
- h = {}
- e = Nokogiri.parse(lines).children[0]
- e.children.each do |inner|
- h[inner.name] = inner.text
- end
- yield h
- end
- skip-=1 if skip > 0
- break if lines == ""
- end
- end
-
- def self.sanitize_backtrace(trace)
- re = Regexp.new(/^#{Regexp.escape(Rails.root.to_s)}/)
- trace.map { |line| Pathname.new(line.gsub(re, "[RAILS_ROOT]")).cleanpath.to_s }
- end
-
- private_class_method :sanitize_backtrace
-end
diff --git a/app/models/invite.rb b/app/models/invite.rb
index 36194915ed..3ad2910328 100644
--- a/app/models/invite.rb
+++ b/app/models/invite.rb
@@ -190,7 +190,7 @@ class Invite < ActiveRecord::Base
end
def limit_invites_per_day
- RateLimiter.new(invited_by, "invites-per-day:#{Date.today}", SiteSetting.max_invites_per_day, 1.day.to_i)
+ RateLimiter.new(invited_by, "invites-per-day", SiteSetting.max_invites_per_day, 1.day.to_i)
end
def self.base_directory
diff --git a/app/models/invite_redeemer.rb b/app/models/invite_redeemer.rb
index 4f1eb56634..1b93d5849f 100644
--- a/app/models/invite_redeemer.rb
+++ b/app/models/invite_redeemer.rb
@@ -88,8 +88,9 @@ InviteRedeemer = Struct.new(:invite, :username, :name) do
end
def add_user_to_groups
- invite.groups.each do |g|
- invited_user.group_users.create(group_id: g.id)
+ new_group_ids = invite.groups.pluck(:id) - invited_user.group_users.pluck(:group_id)
+ new_group_ids.each do |id|
+ invited_user.group_users.create(group_id: id)
end
end
diff --git a/app/models/optimized_image.rb b/app/models/optimized_image.rb
index c921d462b0..f736ea4aaa 100644
--- a/app/models/optimized_image.rb
+++ b/app/models/optimized_image.rb
@@ -38,7 +38,7 @@ class OptimizedImage < ActiveRecord::Base
FileUtils.cp(original_path, temp_path)
resized = true
else
- resized = resize(original_path, temp_path, width, height, opts[:allow_animation])
+ resized = resize(original_path, temp_path, width, height, opts)
end
if resized
@@ -81,40 +81,80 @@ class OptimizedImage < ActiveRecord::Base
end
end
- def self.resize(from, to, width, height, allow_animation=false)
+ def self.resize_instructions(from, to, dimensions, opts={})
# NOTE: ORDER is important!
- instructions = if allow_animation && from =~ /\.GIF$/i
- %W{
- #{from}
- -coalesce
- -gravity center
- -thumbnail #{width}x#{height}^
- -extent #{width}x#{height}
- -layers optimize
- #{to}
- }.join(" ")
- else
- %W{
- #{from}[0]
- -background transparent
- -gravity center
- -thumbnail #{width}x#{height}^
- -extent #{width}x#{height}
- -interpolate bicubic
- -unsharp 2x0.5+0.7+0
- -quality 98
- #{to}
- }.join(" ")
- end
+ %W{
+ #{from}[0]
+ -gravity center
+ -background transparent
+ -thumbnail #{dimensions}^
+ -extent #{dimensions}
+ -interpolate bicubic
+ -unsharp 2x0.5+0.7+0
+ -quality 98
+ #{to}
+ }
+ end
- `convert #{instructions}`
+ def self.resize_instructions_animated(from, to, dimensions, opts={})
+ %W{
+ #{from}
+ -coalesce
+ -gravity center
+ -thumbnail #{dimensions}^
+ -extent #{dimensions}
+ #{to}
+ }
+ end
- if $?.exitstatus == 0
- ImageOptim.new.optimize_image(to) rescue nil
- true
- else
- false
- end
+ def self.downsize_instructions(from, to, dimensions, opts={})
+ %W{
+ #{from}[0]
+ -gravity center
+ -background transparent
+ -thumbnail #{dimensions}#{!!opts[:force_aspect_ratio] ? "\\!" : "\\>"}
+ #{to}
+ }
+ end
+
+ def self.downsize_instructions_animated(from, to, dimensions, opts={})
+ %W{
+ #{from}
+ -coalesce
+ -gravity center
+ -background transparent
+ -thumbnail #{dimensions}#{!!opts[:force_aspect_ratio] ? "\\!" : "\\>"}
+ #{to}
+ }
+ end
+
+ def self.resize(from, to, width, height, opts={})
+ optimize("resize", from, to, width, height, opts)
+ end
+
+ def self.downsize(from, to, max_width, max_height, opts={})
+ optimize("downsize", from, to, max_width, max_height, opts)
+ end
+
+ def self.optimize(operation, from, to, width, height, opts={})
+ dim = dimensions(width, height)
+ method_name = "#{operation}_instructions"
+ method_name += "_animated" if !!opts[:allow_animation] && from =~ /\.GIF$/i
+ instructions = self.send(method_name.to_sym, from, to, dim, opts)
+ convert_with(instructions)
+ end
+
+ def self.dimensions(width, height)
+ "#{width}x#{height}"
+ end
+
+ def self.convert_with(instructions)
+ `convert #{instructions.join(" ")}`
+
+ return false if $?.exitstatus != 0
+
+ ImageOptim.new.optimize_image(to) rescue nil
+ true
end
end
diff --git a/app/models/post.rb b/app/models/post.rb
index 3e4e2433a1..b4bf023d56 100644
--- a/app/models/post.rb
+++ b/app/models/post.rb
@@ -14,6 +14,7 @@ class Post < ActiveRecord::Base
include RateLimiter::OnCreateRecord
include Trashable
include HasCustomFields
+ include LimitedEdit
# increase this number to force a system wide post rebake
BAKED_VERSION = 1
@@ -88,7 +89,7 @@ class Post < ActiveRecord::Base
def limit_posts_per_day
if user.created_at > 1.day.ago && post_number > 1
- RateLimiter.new(user, "first-day-replies-per-day:#{Date.today}", SiteSetting.max_replies_in_first_day, 1.day.to_i)
+ RateLimiter.new(user, "first-day-replies-per-day", SiteSetting.max_replies_in_first_day, 1.day.to_i)
end
end
@@ -524,14 +525,6 @@ class Post < ActiveRecord::Base
end
end
- def edit_time_limit_expired?
- if created_at && SiteSetting.post_edit_time_limit.to_i > 0
- created_at < SiteSetting.post_edit_time_limit.to_i.minutes.ago
- else
- false
- end
- end
-
private
def parse_quote_into_arguments(quote)
diff --git a/app/models/post_action.rb b/app/models/post_action.rb
index cb7cda7ece..03f055a1e2 100644
--- a/app/models/post_action.rb
+++ b/app/models/post_action.rb
@@ -256,6 +256,7 @@ class PostAction < ActiveRecord::Base
if post_action
post_action.recover!
+ action_attrs.each { |attr, val| post_action.send("#{attr}=", val) }
post_action.save
else
post_action = create(where_attrs.merge(action_attrs))
@@ -318,7 +319,7 @@ class PostAction < ActiveRecord::Base
%w(like flag bookmark).each do |type|
if send("is_#{type}?")
- @rate_limiter = RateLimiter.new(user, "create_#{type}:#{Date.today}", SiteSetting.send("max_#{type}s_per_day"), 1.day.to_i)
+ @rate_limiter = RateLimiter.new(user, "create_#{type}", SiteSetting.send("max_#{type}s_per_day"), 1.day.to_i)
return @rate_limiter
end
end
diff --git a/app/models/screened_ip_address.rb b/app/models/screened_ip_address.rb
index d25540a853..3b05ce1e93 100644
--- a/app/models/screened_ip_address.rb
+++ b/app/models/screened_ip_address.rb
@@ -74,7 +74,7 @@ class ScreenedIpAddress < ActiveRecord::Base
found
end
- def self.block_login?(user, ip_address)
+ def self.block_admin_login?(user, ip_address)
return false if user.nil?
return false if !user.admin?
return false if ScreenedIpAddress.where(action_type: actions[:allow_admin]).count == 0
diff --git a/app/models/site.rb b/app/models/site.rb
index bfe31fb6e7..4d3c159051 100644
--- a/app/models/site.rb
+++ b/app/models/site.rb
@@ -43,7 +43,12 @@ class Site
.secured(@guardian)
.includes(:topic_only_relative_url)
.order(:position)
- .to_a
+
+ unless SiteSetting.allow_uncategorized_topics
+ categories = categories.where('categories.id <> ?', SiteSetting.uncategorized_category_id)
+ end
+
+ categories = categories.to_a
allowed_topic_create = Set.new(Category.topic_create_allowed(@guardian).pluck(:id))
diff --git a/app/models/topic.rb b/app/models/topic.rb
index 4e160b6b3a..4982259bae 100644
--- a/app/models/topic.rb
+++ b/app/models/topic.rb
@@ -11,6 +11,7 @@ class Topic < ActiveRecord::Base
include RateLimiter::OnCreateRecord
include HasCustomFields
include Trashable
+ include LimitedEdit
extend Forwardable
def_delegator :featured_users, :user_ids, :featured_user_ids
@@ -821,7 +822,7 @@ class Topic < ActiveRecord::Base
end
def apply_per_day_rate_limit_for(key, method_name)
- RateLimiter.new(user, "#{key}-per-day:#{Date.today}", SiteSetting.send(method_name), 1.day.to_i)
+ RateLimiter.new(user, "#{key}-per-day", SiteSetting.send(method_name), 1.day.to_i)
end
end
diff --git a/app/models/topic_link_click.rb b/app/models/topic_link_click.rb
index e06ad2ce7e..0082635670 100644
--- a/app/models/topic_link_click.rb
+++ b/app/models/topic_link_click.rb
@@ -1,5 +1,10 @@
require_dependency 'discourse'
require 'ipaddr'
+require 'url_helper'
+
+class TopicLinkClickHelper
+ include UrlHelper
+end
class TopicLinkClick < ActiveRecord::Base
belongs_to :topic_link, counter_cache: :clicks
@@ -10,15 +15,27 @@ class TopicLinkClick < ActiveRecord::Base
# Create a click from a URL and post_id
def self.create_from(args={})
+ url = args[:url]
+ return nil if url.blank?
- # If the URL is absolute, allow HTTPS and HTTP versions of it
- if args[:url] =~ /^http/
- http_url = args[:url].sub(/^https/, 'http')
- https_url = args[:url].sub(/^http\:/, 'https:')
- link = TopicLink.select([:id, :user_id]).where('url = ? OR url = ?', http_url, https_url)
- else
- link = TopicLink.select([:id, :user_id]).where(url: args[:url])
+ helper = TopicLinkClickHelper.new
+ uri = URI.parse(url) rescue nil
+
+ urls = Set.new
+ urls << url
+ if url =~ /^http/
+ urls << url.sub(/^https/, 'http')
+ urls << url.sub(/^http:/, 'https:')
+ urls << helper.schemaless(url)
end
+ urls << helper.absolute_without_cdn(url)
+ urls << uri.path if uri.try(:host) == Discourse.current_hostname
+ urls << url.sub(/\?.*$/, '') if url.include?('?')
+
+ link = TopicLink.select([:id, :user_id])
+
+ # test for all possible URLs
+ link = link.where(Array.new(urls.count, "url = ?").join(" OR "), *urls)
# Find the forum topic link
link = link.where(post_id: args[:post_id]) if args[:post_id].present?
@@ -27,23 +44,18 @@ class TopicLinkClick < ActiveRecord::Base
link = link.where(topic_id: args[:topic_id]) if args[:topic_id].present?
link = link.first
- # If no link is found, return the url for relative links
+ # If no link is found...
unless link.present?
- return args[:url] if args[:url] =~ /^\//
+ # ... return the url for relative links or when using the same host
+ return url if url =~ /^\// || uri.try(:host) == Discourse.current_hostname
- begin
- uri = URI.parse(args[:url])
- return args[:url] if uri.host == URI.parse(Discourse.base_url).host
- rescue
- end
-
- # If we have it somewhere else on the site, just allow the redirect. This is
- # likely due to a onebox of another topic.
- link = TopicLink.find_by(url: args[:url])
+ # If we have it somewhere else on the site, just allow the redirect.
+ # This is likely due to a onebox of another topic.
+ link = TopicLink.find_by(url: url)
return link.present? ? link.url : nil
end
- return args[:url] if (args[:user_id] && (link.user_id == args[:user_id]))
+ return url if args[:user_id] && link.user_id == args[:user_id]
# Rate limit the click counts to once in 24 hours
rate_key = "link-clicks:#{link.id}:#{args[:user_id] || args[:ip]}"
@@ -52,7 +64,7 @@ class TopicLinkClick < ActiveRecord::Base
create!(topic_link_id: link.id, user_id: args[:user_id], ip_address: args[:ip])
end
- args[:url]
+ url
end
end
diff --git a/app/models/topic_list.rb b/app/models/topic_list.rb
index 1e6a8bd57b..0fb296171b 100644
--- a/app/models/topic_list.rb
+++ b/app/models/topic_list.rb
@@ -32,16 +32,7 @@ class TopicList
def topics
return @topics if @topics.present?
- # copy side-loaded data (allowed users) before dumping it with the .to_a
- @topics_input.each do |t|
- t.allowed_user_ids = if @filter == :private_messages
- t.allowed_users.map { |u| u.id }.to_a
- else
- []
- end
- end
-
- @topics = @topics_input.to_a
+ @topics = @topics_input
# Attach some data for serialization to each topic
@topic_lookup = TopicUser.lookup_for(@current_user, @topics) if @current_user
@@ -88,11 +79,6 @@ class TopicList
@topics
end
- def topic_ids
- return [] unless @topics_input
- @topics_input.pluck(:id)
- end
-
def attributes
{'more_topics_url' => page}
end
diff --git a/app/models/topic_user.rb b/app/models/topic_user.rb
index d0255e1b90..2611039f31 100644
--- a/app/models/topic_user.rb
+++ b/app/models/topic_user.rb
@@ -28,7 +28,8 @@ class TopicUser < ActiveRecord::Base
:auto_watch,
:auto_watch_category,
:auto_mute_category,
- :auto_track_category
+ :auto_track_category,
+ :plugin_changed
)
end
diff --git a/app/models/user_field.rb b/app/models/user_field.rb
index 7ca0c997e9..648163326a 100644
--- a/app/models/user_field.rb
+++ b/app/models/user_field.rb
@@ -1,5 +1,9 @@
class UserField < ActiveRecord::Base
validates_presence_of :name, :description, :field_type
+
+ def self.max_length
+ 2048
+ end
end
# == Schema Information
diff --git a/app/serializers/about_serializer.rb b/app/serializers/about_serializer.rb
index 63d795499e..df8eebc177 100644
--- a/app/serializers/about_serializer.rb
+++ b/app/serializers/about_serializer.rb
@@ -1,6 +1,6 @@
class AboutSerializer < ApplicationSerializer
- has_many :moderators, serializer: BasicUserSerializer, embed: :objects
- has_many :admins, serializer: BasicUserSerializer, embed: :objects
+ has_many :moderators, serializer: UserNameSerializer, embed: :objects
+ has_many :admins, serializer: UserNameSerializer, embed: :objects
attributes :stats,
:description,
diff --git a/app/serializers/badge_serializer.rb b/app/serializers/badge_serializer.rb
index 0ac048ae2d..415404d5cd 100644
--- a/app/serializers/badge_serializer.rb
+++ b/app/serializers/badge_serializer.rb
@@ -1,10 +1,28 @@
class BadgeSerializer < ApplicationSerializer
attributes :id, :name, :description, :grant_count, :allow_title,
:multiple_grant, :icon, :image, :listable, :enabled, :badge_grouping_id,
- :system
+ :system, :long_description
+
has_one :badge_type
def system
object.system?
end
+
+ def include_long_description?
+ options[:include_long_description]
+ end
+
+ def long_description
+ if object.long_description.present?
+ object.long_description
+ else
+ key = "badges.long_descriptions.#{object.name.downcase.gsub(" ", "_")}"
+ if I18n.exists?(key)
+ I18n.t(key)
+ else
+ ""
+ end
+ end
+ end
end
diff --git a/app/serializers/detailed_user_badge_serializer.rb b/app/serializers/detailed_user_badge_serializer.rb
new file mode 100644
index 0000000000..1fa408844d
--- /dev/null
+++ b/app/serializers/detailed_user_badge_serializer.rb
@@ -0,0 +1,26 @@
+class DetailedUserBadgeSerializer < BasicUserBadgeSerializer
+ has_one :granted_by
+
+ attributes :post_number, :topic_id, :topic_title
+
+ def include_post_number?
+ object.post
+ end
+
+ alias :include_topic_id? :include_post_number?
+ alias :include_topic_title? :include_post_number?
+
+
+ def post_number
+ object.post.post_number if object.post
+ end
+
+ def topic_id
+ object.post.topic_id if object.post
+ end
+
+ def topic_title
+ object.post.topic.title if object.post && object.post.topic
+ end
+
+end
diff --git a/app/serializers/post_revision_serializer.rb b/app/serializers/post_revision_serializer.rb
index 0565a1a003..d7630a96d5 100644
--- a/app/serializers/post_revision_serializer.rb
+++ b/app/serializers/post_revision_serializer.rb
@@ -112,9 +112,11 @@ class PostRevisionSerializer < ApplicationSerializer
end
def title_changes
- prev = "
#{CGI::escapeHTML(previous["title"])}
"
- cur = "
#{CGI::escapeHTML(current["title"])}
"
- return if prev == cur
+ prev = "
#{previous["title"] && CGI::escapeHTML(previous["title"])}
"
+ cur = "
#{current["title"] && CGI::escapeHTML(current["title"])}
"
+
+ # always show the title for post_number == 1
+ return if object.post.post_number > 1 && prev == cur
diff = DiscourseDiff.new(prev, cur)
diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb
index 3d2149e8ef..38c30737b2 100644
--- a/app/serializers/site_serializer.rb
+++ b/app/serializers/site_serializer.rb
@@ -10,7 +10,8 @@ class SiteSerializer < ApplicationSerializer
:anonymous_top_menu_items,
:uncategorized_category_id, # this is hidden so putting it here
:is_readonly,
- :disabled_plugins
+ :disabled_plugins,
+ :user_field_max_length
has_many :categories, serializer: BasicCategorySerializer, embed: :objects
has_many :post_action_types, embed: :objects
@@ -19,7 +20,6 @@ class SiteSerializer < ApplicationSerializer
has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer
has_many :user_fields, embed: :objects, serialzer: UserFieldSerializer
-
def default_archetype
Archetype.default
end
@@ -56,4 +56,8 @@ class SiteSerializer < ApplicationSerializer
Discourse.disabled_plugin_names
end
+ def user_field_max_length
+ UserField.max_length
+ end
+
end
diff --git a/app/serializers/user_name_serializer.rb b/app/serializers/user_name_serializer.rb
new file mode 100644
index 0000000000..ac7beaa8d2
--- /dev/null
+++ b/app/serializers/user_name_serializer.rb
@@ -0,0 +1,20 @@
+class UserNameSerializer < ApplicationSerializer
+ attributes :id, :username, :name, :title, :uploaded_avatar_id, :avatar_template
+
+ def include_name?
+ SiteSetting.enable_names?
+ end
+
+ def avatar_template
+ if Hash === object
+ User.avatar_template(user[:username], user[:uploaded_avatar_id])
+ else
+ object.avatar_template
+ end
+ end
+
+ def user
+ object[:user] || object
+ end
+
+end
diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb
index 6cc710935f..f589b329fe 100644
--- a/app/serializers/user_serializer.rb
+++ b/app/serializers/user_serializer.rb
@@ -1,5 +1,7 @@
class UserSerializer < BasicUserSerializer
+ attr_accessor :omit_stats
+
def self.staff_attributes(*attrs)
attributes(*attrs)
attrs.each do |attr|
@@ -171,6 +173,10 @@ class UserSerializer < BasicUserSerializer
scope.can_edit_name?(object)
end
+ def include_stats?
+ !omit_stats == true
+ end
+
def stats
UserAction.stats(object.id, scope)
end
@@ -246,6 +252,10 @@ class UserSerializer < BasicUserSerializer
CategoryUser.lookup(object, :watching).pluck(:category_id)
end
+ def include_private_message_stats?
+ can_edit && !(omit_stats == true)
+ end
+
def private_messages_stats
UserAction.private_messages_stats(object.id, scope)
end
diff --git a/app/services/post_owner_changer.rb b/app/services/post_owner_changer.rb
new file mode 100644
index 0000000000..bf1311d0f6
--- /dev/null
+++ b/app/services/post_owner_changer.rb
@@ -0,0 +1,23 @@
+class PostOwnerChanger
+
+ def initialize(params)
+ @post_ids = params[:post_ids]
+ @topic = Topic.find_by(id: params[:topic_id].to_i)
+ @new_owner = params[:new_owner]
+ @acting_user = params[:acting_user]
+
+ raise ArgumentError unless @post_ids && @topic && @new_owner && @acting_user
+ end
+
+ def change_owner!
+ ActiveRecord::Base.transaction do
+ @post_ids.each do |post_id|
+ post = Post.find(post_id)
+ @topic.user = @new_owner if post.is_first_post?
+ post.set_owner(@new_owner, @acting_user)
+ end
+ end
+
+ @topic.update_statistics
+ end
+end
diff --git a/app/services/random_topic_selector.rb b/app/services/random_topic_selector.rb
new file mode 100644
index 0000000000..7c2026b5ef
--- /dev/null
+++ b/app/services/random_topic_selector.rb
@@ -0,0 +1,82 @@
+class RandomTopicSelector
+
+ BACKFILL_SIZE = 3000
+ BACKFILL_LOW_WATER_MARK = 500
+
+ def self.backfill(category=nil)
+
+ exclude = category.try(:topic_id)
+
+ # don't leak private categories into the "everything" group
+ user = category ? CategoryFeaturedTopic.fake_admin : nil
+
+ options = {
+ per_page: SiteSetting.category_featured_topics,
+ visible: true,
+ no_definitions: true
+ }
+
+ options[:except_topic_ids] = [category.topic_id] if exclude
+ options[:category] = category.id if category
+
+ query = TopicQuery.new(user, options)
+ results = query.latest_results.order('RANDOM()')
+ .where(closed: false, archived: false)
+ .limit(BACKFILL_SIZE)
+ .reorder('RANDOM()')
+ .pluck(:id)
+
+ key = cache_key(category)
+ results.each do |id|
+ $redis.rpush(key, id)
+ end
+ $redis.expire(key, 2.days)
+
+ results
+ end
+
+ def self.next(count, category=nil)
+ key = cache_key(category)
+
+ results = []
+
+ left = count
+
+ while left > 0
+ id = $redis.lpop key
+ break unless id
+
+ results << id.to_i
+ left -= 1
+ end
+
+ backfilled = false
+ if left > 0
+ ids = backfill(category)
+ backfilled = true
+ results += ids[0...count]
+ results.uniq!
+ results = results[0...count]
+ end
+
+ if !backfilled && $redis.llen(key) < BACKFILL_LOW_WATER_MARK
+ Scheduler::Defer.later("backfill") do
+ backfill(category)
+ end
+ end
+
+ results
+ end
+
+ def self.clear_cache!
+ Category.select(:id).each do |c|
+ $redis.del cache_key(c)
+ end
+ $redis.del cache_key
+ end
+
+ def self.cache_key(category=nil)
+ "random_topic_cache_#{category.try(:id)}"
+ end
+
+end
diff --git a/app/services/staff_action_logger.rb b/app/services/staff_action_logger.rb
index 10b13092a2..0e420e5f4a 100644
--- a/app/services/staff_action_logger.rb
+++ b/app/services/staff_action_logger.rb
@@ -100,7 +100,17 @@ class StaffActionLogger
}))
end
- SITE_CUSTOMIZATION_LOGGED_ATTRS = ['stylesheet', 'header', 'position', 'enabled', 'key']
+ SITE_CUSTOMIZATION_LOGGED_ATTRS = [
+ 'stylesheet', 'mobile_stylesheet',
+ 'header', 'mobile_header',
+ 'top', 'mobile_top',
+ 'footer', 'mobile_footer',
+ 'head_tag',
+ 'body_tag',
+ 'position',
+ 'enabled',
+ 'key'
+ ]
def log_site_customization_change(old_record, site_customization_params, opts={})
raise Discourse::InvalidParameters.new(:site_customization_params) unless site_customization_params
diff --git a/app/services/user_updater.rb b/app/services/user_updater.rb
index 74a785c4b9..8fd14c2362 100644
--- a/app/services/user_updater.rb
+++ b/app/services/user_updater.rb
@@ -6,17 +6,17 @@ class UserUpdater
muted_category_ids: :muted
}
- USER_ATTR = [
- :email_digests,
- :email_always,
- :email_direct,
- :email_private_messages,
- :external_links_in_new_tab,
- :enable_quoting,
- :dynamic_favicon,
- :mailing_list_mode,
- :disable_jump_reply,
- :edit_history_public
+ USER_ATTR = [
+ :email_digests,
+ :email_always,
+ :email_direct,
+ :email_private_messages,
+ :external_links_in_new_tab,
+ :enable_quoting,
+ :dynamic_favicon,
+ :mailing_list_mode,
+ :disable_jump_reply,
+ :edit_history_public
]
PROFILE_ATTR = [
diff --git a/app/views/application/_header.html.erb b/app/views/application/_header.html.erb
new file mode 100644
index 0000000000..d0f3c8b39f
--- /dev/null
+++ b/app/views/application/_header.html.erb
@@ -0,0 +1,22 @@
+
diff --git a/app/views/common/_discourse_javascript.html.erb b/app/views/common/_discourse_javascript.html.erb
index a72accad15..737ff157e6 100644
--- a/app/views/common/_discourse_javascript.html.erb
+++ b/app/views/common/_discourse_javascript.html.erb
@@ -9,7 +9,6 @@
<%= script 'browser-update' %>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index f4c61ef136..baf1f7cbd0 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -37,17 +37,7 @@