diff --git a/.jshintrc b/.jshintrc index 962847d723..f7c6762ffa 100644 --- a/.jshintrc +++ b/.jshintrc @@ -33,6 +33,9 @@ "triggerEvent", "count", "exists", + "visible", + "invisible", + "selectDropdown", "asyncTestDiscourse", "fixture", "find", diff --git a/.travis.yml b/.travis.yml index cf7aa1b1be..64d25f1d16 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,9 @@ env: - "RAILS_MASTER=1" - "RAILS_MASTER=0" +addons: + postgresql: 9.3 + matrix: allow_failures: - rvm: 2.0.0 diff --git a/Gemfile b/Gemfile index 71286aeaa1..50a64cb139 100644 --- a/Gemfile +++ b/Gemfile @@ -244,8 +244,15 @@ gem 'simple-rss', require: false # TODO mri_22 should be here, but bundler was real slow to pick it up # not even in production bundler yet, monkey patching it in feels bad gem 'gctools', require: false, platform: :mri_21 -gem 'stackprof', require: false, platform: :mri_21 -gem 'memory_profiler', require: false, platform: :mri_21 + +begin + gem 'stackprof', require: false, platform: [:mri_21, :mri_22] + gem 'memory_profiler', require: false, platform: [:mri_21, :mri_22] +rescue Bundler::GemfileError + STDERR.puts "You are running an old version of bundler, please upgrade bundler ASAP, if you are using Discourse docker, rebuild your container." + gem 'stackprof', require: false, platform: [:mri_21] + gem 'memory_profiler', require: false, platform: [:mri_21] +end gem 'rmmseg-cpp', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 79946b29bf..865cf4252e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -207,7 +207,7 @@ GEM thor (~> 0.15) libv8 (3.16.14.7) listen (0.7.3) - logster (0.1.8) + logster (0.8.0) lru_redux (0.8.4) mail (2.5.4) mime-types (~> 1.16) @@ -422,7 +422,7 @@ GEM sprockets (~> 2.8) stackprof (0.2.7) stringex (2.5.2) - therubyracer (0.12.1) + therubyracer (0.12.2) libv8 (~> 3.16.14.0) ref thin (1.6.3) diff --git a/README.md b/README.md index d2859ca822..51450402ff 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ The original Discourse code contributors can be found in [**AUTHORS.MD**](docs/A ## Copyright / License -Copyright 2014 Civilized Discourse Construction Kit, Inc. +Copyright 2014 - 2015 Civilized Discourse Construction Kit, Inc. Licensed under the GNU General Public License Version 2.0 (or later); you may not use this work except in compliance with the License. diff --git a/app/assets/javascripts/admin/controllers/admin-logs-screened-emails.js.es6 b/app/assets/javascripts/admin/controllers/admin-logs-screened-emails.js.es6 index e467be5b17..c744f96c48 100644 --- a/app/assets/javascripts/admin/controllers/admin-logs-screened-emails.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-logs-screened-emails.js.es6 @@ -1,6 +1,7 @@ +import Presence from 'discourse/mixins/presence'; import { outputExportResult } from 'discourse/lib/export-result'; -export default Ember.ArrayController.extend(Discourse.Presence, { +export default Ember.ArrayController.extend(Presence, { loading: false, actions: { diff --git a/app/assets/javascripts/admin/controllers/admin-logs-screened-ip-addresses.js.es6 b/app/assets/javascripts/admin/controllers/admin-logs-screened-ip-addresses.js.es6 index dcbd6505e1..1bc3596973 100644 --- a/app/assets/javascripts/admin/controllers/admin-logs-screened-ip-addresses.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-logs-screened-ip-addresses.js.es6 @@ -1,6 +1,7 @@ +import Presence from 'discourse/mixins/presence'; import { outputExportResult } from 'discourse/lib/export-result'; -export default Ember.ArrayController.extend(Discourse.Presence, { +export default Ember.ArrayController.extend(Presence, { loading: false, itemController: 'admin-log-screened-ip-address', filter: null, diff --git a/app/assets/javascripts/admin/controllers/admin-logs-screened-urls.js.es6 b/app/assets/javascripts/admin/controllers/admin-logs-screened-urls.js.es6 index 003dedca08..06839b7456 100644 --- a/app/assets/javascripts/admin/controllers/admin-logs-screened-urls.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-logs-screened-urls.js.es6 @@ -1,6 +1,7 @@ +import Presence from 'discourse/mixins/presence'; import { outputExportResult } from 'discourse/lib/export-result'; -export default Ember.ArrayController.extend(Discourse.Presence, { +export default Ember.ArrayController.extend(Presence, { loading: false, show() { diff --git a/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 b/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 index 6784fa1d8f..e3f56d5786 100644 --- a/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 @@ -1,6 +1,7 @@ +import Presence from 'discourse/mixins/presence'; import { outputExportResult } from 'discourse/lib/export-result'; -export default Ember.ArrayController.extend(Discourse.Presence, { +export default Ember.ArrayController.extend(Presence, { loading: false, filters: null, diff --git a/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 b/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 index 95fdff940e..9e67e638b5 100644 --- a/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 @@ -1,4 +1,6 @@ -export default Ember.ArrayController.extend(Discourse.Presence, { +import Presence from 'discourse/mixins/presence'; + +export default Ember.ArrayController.extend(Presence, { filter: null, onlyOverridden: false, filtered: Ember.computed.notEmpty('filter'), diff --git a/app/assets/javascripts/admin/models/admin-user.js.es6 b/app/assets/javascripts/admin/models/admin-user.js.es6 index 98230fbafe..674ea5e6a3 100644 --- a/app/assets/javascripts/admin/models/admin-user.js.es6 +++ b/app/assets/javascripts/admin/models/admin-user.js.es6 @@ -1,6 +1,6 @@ const AdminUser = Discourse.User.extend({ - customGroups: Em.computed.filter("groups", (g) => !g.automatic && g.visible && Discourse.Group.create(g)), + customGroups: Em.computed.filter("groups", (g) => !g.automatic && Discourse.Group.create(g)), automaticGroups: Em.computed.filter("groups", (g) => g.automatic && Discourse.Group.create(g)), generateApiKey() { diff --git a/app/assets/javascripts/admin/routes/admin-badges-show.js.es6 b/app/assets/javascripts/admin/routes/admin-badges-show.js.es6 index 56d0f93daf..648730d9b0 100644 --- a/app/assets/javascripts/admin/routes/admin-badges-show.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-badges-show.js.es6 @@ -24,8 +24,8 @@ export default Ember.Route.extend({ }, editGroupings() { - const groupings = this.controllerFor('admin-badges').get('badgeGroupings'); - showModal('modals/admin-edit-badge-groupings', groupings); + const model = this.controllerFor('admin-badges').get('badgeGroupings'); + showModal('modals/admin-edit-badge-groupings', { model }); }, preview(badge, explain) { @@ -38,9 +38,9 @@ export default Ember.Route.extend({ trigger: badge.get('trigger'), explain } - }).then(function(json) { + }).then(function(model) { badge.set('preview_loading', false); - showModal('modals/admin-badge-preview', json); + showModal('modals/admin-badge-preview', { model }); }).catch(function(error) { badge.set('preview_loading', false); Em.Logger.error(error); diff --git a/app/assets/javascripts/admin/routes/admin-flags-list.js.es6 b/app/assets/javascripts/admin/routes/admin-flags-list.js.es6 index 99ceba7717..e277ef4417 100644 --- a/app/assets/javascripts/admin/routes/admin-flags-list.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-flags-list.js.es6 @@ -12,13 +12,13 @@ export default Discourse.Route.extend({ }, actions: { - showAgreeFlagModal(flaggedPost) { - showModal('modals/admin-agree-flag', flaggedPost); + showAgreeFlagModal(model) { + showModal('modals/admin-agree-flag', { model }); this.controllerFor('modal').set('modalClass', 'agree-flag-modal'); }, - showDeleteFlagModal(flaggedPost) { - showModal('modals/admin-delete-flag', flaggedPost); + showDeleteFlagModal(model) { + showModal('modals/admin-delete-flag', { model }); this.controllerFor('modal').set('modalClass', 'delete-flag-modal'); } diff --git a/app/assets/javascripts/admin/routes/admin-logs-staff-action-logs.js.es6 b/app/assets/javascripts/admin/routes/admin-logs-staff-action-logs.js.es6 index f0f08ab2f5..6ef900e843 100644 --- a/app/assets/javascripts/admin/routes/admin-logs-staff-action-logs.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-logs-staff-action-logs.js.es6 @@ -12,14 +12,14 @@ export default Discourse.Route.extend({ }, actions: { - showDetailsModal(logRecord) { - showModal('modals/admin-staff-action-log-details', logRecord); + showDetailsModal(model) { + showModal('modals/admin-staff-action-log-details', { model }); this.controllerFor('modal').set('modalClass', 'log-details-modal'); }, - showCustomDetailsModal(logRecord) { - const modalName = "modals/" + (logRecord.action_name + '_details').replace("_", "-"); - showModal(modalName, logRecord); + showCustomDetailsModal(model) { + const modalName = "modals/" + (model.action_name + '_details').replace("_", "-"); + showModal(modalName, { model }); this.controllerFor('modal').set('modalClass', 'tabbed-modal log-details-modal'); } } diff --git a/app/assets/javascripts/admin/routes/admin-user-index.js.es6 b/app/assets/javascripts/admin/routes/admin-user-index.js.es6 index 07cbfa8fc3..fdcf844abd 100644 --- a/app/assets/javascripts/admin/routes/admin-user-index.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-user-index.js.es6 @@ -24,8 +24,8 @@ export default Discourse.Route.extend({ }, actions: { - showSuspendModal(user) { - showModal('modals/admin-suspend-user', user); + showSuspendModal(model) { + showModal('modals/admin-suspend-user', { model }); this.controllerFor('modal').set('modalClass', 'suspend-user-modal'); } } diff --git a/app/assets/javascripts/admin/templates/badges-index.hbs b/app/assets/javascripts/admin/templates/badges-index.hbs index fcb54478d8..6ee80748f8 100644 --- a/app/assets/javascripts/admin/templates/badges-index.hbs +++ b/app/assets/javascripts/admin/templates/badges-index.hbs @@ -1,4 +1,4 @@ -
+

{{i18n 'admin.badges.none_selected'}}

diff --git a/app/assets/javascripts/admin/templates/users_list.hbs b/app/assets/javascripts/admin/templates/users_list.hbs index 7a5de029f4..2c4d8d4eb7 100644 --- a/app/assets/javascripts/admin/templates/users_list.hbs +++ b/app/assets/javascripts/admin/templates/users_list.hbs @@ -13,7 +13,9 @@
- {{d-button action="sendInvites" title="admin.invite.button_title" icon="user-plus" label="admin.invite.button_text"}} + {{#unless siteSettings.enable_sso}} + {{d-button action="sendInvites" title="admin.invite.button_title" icon="user-plus" label="admin.invite.button_text"}} + {{/unless}} {{d-button action="exportUsers" title="admin.export_csv.button_title.user" icon="download" label="admin.export_csv.button_text"}}
diff --git a/app/assets/javascripts/discourse/adapters/post.js.es6 b/app/assets/javascripts/discourse/adapters/post.js.es6 new file mode 100644 index 0000000000..1d0a375b58 --- /dev/null +++ b/app/assets/javascripts/discourse/adapters/post.js.es6 @@ -0,0 +1,20 @@ +import RestAdapter from 'discourse/adapters/rest'; +import { Result } from 'discourse/adapters/rest'; + +export default RestAdapter.extend({ + + find(store, type, findArgs) { + return this._super(store, type, findArgs).then(function(result) { + return {post: result}; + }); + }, + + createRecord(store, type, args) { + const typeField = Ember.String.underscore(type); + args.nested_post = true; + return Discourse.ajax(this.pathFor(store, type), { method: 'POST', data: args }).then(function (json) { + return new Result(json[typeField], json); + }); + } + +}); diff --git a/app/assets/javascripts/discourse/adapters/rest.js.es6 b/app/assets/javascripts/discourse/adapters/rest.js.es6 index fc9d7c4065..ec25f2d134 100644 --- a/app/assets/javascripts/discourse/adapters/rest.js.es6 +++ b/app/assets/javascripts/discourse/adapters/rest.js.es6 @@ -1,5 +1,21 @@ const ADMIN_MODELS = ['plugin']; +export function Result(payload, responseJson) { + this.payload = payload; + this.responseJson = responseJson; + this.target = null; +} + +const ajax = Discourse.ajax; + +// We use this to make sure 404s are caught +function rethrow(error) { + if (error.status === 404) { + throw "404: " + error.responseText; + } + throw(error); +} + export default Ember.Object.extend({ pathFor(store, type, findArgs) { let path = "/" + Ember.String.underscore(store.pluralize(type)); @@ -25,21 +41,33 @@ export default Ember.Object.extend({ }, findAll(store, type) { - return Discourse.ajax(this.pathFor(store, type)); + return ajax(this.pathFor(store, type)).catch(rethrow); }, + find(store, type, findArgs) { - return Discourse.ajax(this.pathFor(store, type, findArgs)); + return ajax(this.pathFor(store, type, findArgs)).catch(rethrow); }, update(store, type, id, attrs) { const data = {}; data[Ember.String.underscore(type)] = attrs; - return Discourse.ajax(this.pathFor(store, type, id), { method: 'PUT', data }); + return ajax(this.pathFor(store, type, id), { method: 'PUT', data }).then(function(json) { + return new Result(json[type], json); + }); + }, + + createRecord(store, type, attrs) { + const data = {}; + const typeField = Ember.String.underscore(type); + data[typeField] = attrs; + return ajax(this.pathFor(store, type), { method: 'POST', data }).then(function (json) { + return new Result(json[typeField], json); + }); }, destroyRecord(store, type, record) { - return Discourse.ajax(this.pathFor(store, type, record.get('id')), { method: 'DELETE' }); + return ajax(this.pathFor(store, type, record.get('id')), { method: 'DELETE' }); } }); diff --git a/app/assets/javascripts/discourse/adapters/topic-list.js.es6 b/app/assets/javascripts/discourse/adapters/topic-list.js.es6 new file mode 100644 index 0000000000..46a3a56683 --- /dev/null +++ b/app/assets/javascripts/discourse/adapters/topic-list.js.es6 @@ -0,0 +1,39 @@ +import RestAdapter from 'discourse/adapters/rest'; + +function finderFor(filter, params) { + return function() { + let url = Discourse.getURL("/") + filter + ".json"; + + if (params) { + const keys = Object.keys(params), + encoded = []; + + keys.forEach(function(p) { + const value = params[p]; + if (typeof value !== 'undefined') { + encoded.push(p + "=" + value); + } + }); + + if (encoded.length > 0) { + url += "?" + encoded.join('&'); + } + } + return Discourse.ajax(url); + }; +} + +export default RestAdapter.extend({ + + find(store, type, findArgs) { + const filter = findArgs.filter; + const params = findArgs.params; + + return PreloadStore.getAndRemove("topic_list_" + filter, finderFor(filter, params)).then(function(result) { + result.filter = filter; + result.params = params; + return result; + }); + } +}); + diff --git a/app/assets/javascripts/discourse/components/bulk-select-button.js.es6 b/app/assets/javascripts/discourse/components/bulk-select-button.js.es6 index ec47bbd56f..8c50f5e0e4 100644 --- a/app/assets/javascripts/discourse/components/bulk-select-button.js.es6 +++ b/app/assets/javascripts/discourse/components/bulk-select-button.js.es6 @@ -3,7 +3,7 @@ import showModal from 'discourse/lib/show-modal'; export default Ember.Component.extend({ actions: { showBulkActions() { - const controller = showModal('topicBulkActions', this.get('selected')); + const controller = showModal('topic-bulk-actions', { model: this.get('selected'), title: 'topics.bulk.actions' }); controller.set('refreshTarget', this.get('refreshTarget')); } } diff --git a/app/assets/javascripts/discourse/components/discourse-action-history.js.es6 b/app/assets/javascripts/discourse/components/discourse-action-history.js.es6 index 7dba3c5828..ec38fca1ad 100644 --- a/app/assets/javascripts/discourse/components/discourse-action-history.js.es6 +++ b/app/assets/javascripts/discourse/components/discourse-action-history.js.es6 @@ -10,20 +10,20 @@ export default Em.Component.extend(StringBuffer, { rerenderTriggers: ['actionsHistory.@each', 'actionsHistory.users.length', 'post.deleted'], // This was creating way too many bound ifs and subviews in the handlebars version. - renderString: function(buffer) { + renderString(buffer) { if (!this.get('emptyHistory')) { this.get('actionsHistory').forEach(function(c) { buffer.push("
"); - var renderActionIf = function(property, dataAttribute, text) { + const renderActionIf = function(property, dataAttribute, text) { if (!c.get(property)) { return; } buffer.push(" " + text + "."); }; // TODO multi line expansion for flags - var iconsHtml = ""; + let iconsHtml = ""; if (c.get('usersExpanded')) { - var postUrl; + let postUrl; c.get('users').forEach(function(u) { iconsHtml += ""; if (u.post_url) { @@ -37,7 +37,7 @@ export default Em.Component.extend(StringBuffer, { iconsHtml += ""; }); - var key = 'post.actions.people.' + c.get('actionType.name_key'); + let key = 'post.actions.people.' + c.get('actionType.name_key'); if (postUrl) { key = key + "_with_url"; } // TODO postUrl might be uninitialized? pick a good default @@ -52,7 +52,7 @@ export default Em.Component.extend(StringBuffer, { }); } - var post = this.get('post'); + const post = this.get('post'); if (post.get('deleted')) { buffer.push("
" + " " + @@ -62,32 +62,34 @@ export default Em.Component.extend(StringBuffer, { } }, - actionTypeById: function(actionTypeId) { + actionTypeById(actionTypeId) { return this.get('actionsHistory').findProperty('id', actionTypeId); }, - click: function(e) { - var $target = $(e.target), - actionTypeId; + click(e) { + const $target = $(e.target); + let actionTypeId; + + const post = this.get('post'); if (actionTypeId = $target.data('defer-flags')) { - this.actionTypeById(actionTypeId).deferFlags(); + this.actionTypeById(actionTypeId).deferFlags(post); return false; } // User wants to know who actioned it if (actionTypeId = $target.data('who-acted')) { - this.actionTypeById(actionTypeId).loadUsers(); + this.actionTypeById(actionTypeId).loadUsers(post); return false; } if (actionTypeId = $target.data('act')) { - this.get('actionsHistory').findProperty('id', actionTypeId).act(); + this.get('actionsHistory').findProperty('id', actionTypeId).act(post); return false; } if (actionTypeId = $target.data('undo')) { - this.get('actionsHistory').findProperty('id', actionTypeId).undo(); + this.get('actionsHistory').findProperty('id', actionTypeId).undo(post); return false; } diff --git a/app/assets/javascripts/discourse/components/user-link.js.es6 b/app/assets/javascripts/discourse/components/user-link.js.es6 new file mode 100644 index 0000000000..4c1665fd2a --- /dev/null +++ b/app/assets/javascripts/discourse/components/user-link.js.es6 @@ -0,0 +1,6 @@ +export default Ember.Component.extend({ + tagName: 'a', + attributeBindings: ['href', 'data-user-card'], + href: Ember.computed.alias('user.path'), + 'data-user-card': Ember.computed.alias('user.username_lower') +}); diff --git a/app/assets/javascripts/discourse/components/user-selector.js.es6 b/app/assets/javascripts/discourse/components/user-selector.js.es6 index d0d210a689..d3bed034e2 100644 --- a/app/assets/javascripts/discourse/components/user-selector.js.es6 +++ b/app/assets/javascripts/discourse/components/user-selector.js.es6 @@ -7,7 +7,8 @@ export default TextField.extend({ var self = this, selected = [], currentUser = this.currentUser, - includeGroups = this.get('includeGroups') === 'true'; + includeGroups = this.get('includeGroups') === 'true', + allowedUsers = this.get('allowedUsers') === 'true'; function excludedUsernames() { if (currentUser && self.get('excludeCurrentUser')) { @@ -27,7 +28,8 @@ export default TextField.extend({ term: term.replace(/[^a-zA-Z0-9_]/, ''), topicId: self.get('topicId'), exclude: excludedUsernames(), - includeGroups: includeGroups + includeGroups, + allowedUsers }); }, diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index cc61d4a3b2..caf3632ee1 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -61,7 +61,8 @@ export default DiscourseController.extend({ if (postId) { this.set('model.loading', true); const composer = this; - return Discourse.Post.load(postId).then(function(post) { + + return this.store.find('post', postId).then(function(post) { const quote = Discourse.Quote.build(post, post.get("raw")); composer.appendBlockAtCursor(quote); composer.set('model.loading', false); @@ -82,6 +83,13 @@ export default DiscourseController.extend({ }, hitEsc() { + + const messages = this.get('controllers.composer-messages.model'); + if (messages.length) { + messages.popObject(); + return; + } + if (this.get('model.viewOpen')) { this.shrink(); } @@ -217,13 +225,20 @@ export default DiscourseController.extend({ const promise = composer.save({ imageSizes: this.get('view').imageSizes(), editReason: this.get("editReason") - }).then(function(opts) { + }).then(function(result) { + + if (result.responseJson.action === "enqueued") { + self.send('postWasEnqueued'); + self.destroyDraft(); + self.close(); + return result; + } + // If we replied as a new topic successfully, remove the draft. if (self.get('replyAsNewTopicDraft')) { self.destroyDraft(); } - opts = opts || {}; self.close(); const currentUser = Discourse.User.current(); @@ -236,16 +251,16 @@ export default DiscourseController.extend({ // TODO disableJumpReply is super crude, it needs to provide some sort // of notification to the end user if (!composer.get('replyingToTopic') || !disableJumpReply) { - if (opts.post && !staged) { - Discourse.URL.routeTo(opts.post.get('url')); + const post = result.target; + if (post && !staged) { + Discourse.URL.routeTo(post.get('url')); } } }).catch(function(error) { composer.set('disableDrafts', false); - bootbox.alert(error); + self.appEvents.one('composer:opened', () => bootbox.alert(error)); }); - if (this.get('controllers.application.currentRouteName').split('.')[0] === 'topic' && composer.get('topic.id') === this.get('controllers.topic.model.id')) { staged = composer.get('stagedPost'); @@ -412,7 +427,7 @@ export default DiscourseController.extend({ composerModel.set('topic', opts.topic); } } else { - composerModel = composerModel || Discourse.Composer.create(); + composerModel = composerModel || this.store.createRecord('composer'); composerModel.open(opts); } diff --git a/app/assets/javascripts/discourse/controllers/controller.js.es6 b/app/assets/javascripts/discourse/controllers/controller.js.es6 index 94c3ba1d43..0be72a02a4 100644 --- a/app/assets/javascripts/discourse/controllers/controller.js.es6 +++ b/app/assets/javascripts/discourse/controllers/controller.js.es6 @@ -1 +1,3 @@ -export default Ember.Controller.extend(Discourse.Presence); +import Presence from 'discourse/mixins/presence'; + +export default Ember.Controller.extend(Presence); diff --git a/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 b/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 index 27ae37afd9..293d9c826c 100644 --- a/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 +++ b/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 @@ -48,7 +48,8 @@ var controllerOpts = { // router and ember throws an error due to missing `handlerInfos`. // Lesson learned: Don't call `loading` yourself. this.set('controllers.discovery.loading', true); - Discourse.TopicList.find(filter).then(function(list) { + + this.store.findFiltered('topicList', {filter}).then(function(list) { Discourse.TopicList.hideUniformCategory(list, self.get('category')); self.setProperties({ model: list }); diff --git a/app/assets/javascripts/discourse/controllers/flag.js.es6 b/app/assets/javascripts/discourse/controllers/flag.js.es6 index ed47740aa0..4691318c29 100644 --- a/app/assets/javascripts/discourse/controllers/flag.js.es6 +++ b/app/assets/javascripts/discourse/controllers/flag.js.es6 @@ -81,7 +81,8 @@ export default ObjectController.extend(ModalFunctionality, { if (opts) params = $.extend(params, opts); this.send('hideModal'); - postAction.act(params).then(function() { + + postAction.act(this.get('model'), params).then(function() { self.send('closeModal'); }, function(errors) { self.send('closeModal'); diff --git a/app/assets/javascripts/discourse/controllers/header.js.es6 b/app/assets/javascripts/discourse/controllers/header.js.es6 index 74e2e133ce..c0c4cb17dd 100644 --- a/app/assets/javascripts/discourse/controllers/header.js.es6 +++ b/app/assets/javascripts/discourse/controllers/header.js.es6 @@ -99,6 +99,7 @@ HeaderController.reopenClass({ }); addFlagProperty('currentUser.site_flagged_posts_count'); +addFlagProperty('currentUser.post_queue_new_count'); export { addFlagProperty }; export default HeaderController; diff --git a/app/assets/javascripts/discourse/controllers/invite.js.es6 b/app/assets/javascripts/discourse/controllers/invite.js.es6 index 0dcf67c695..2201c3eb57 100644 --- a/app/assets/javascripts/discourse/controllers/invite.js.es6 +++ b/app/assets/javascripts/discourse/controllers/invite.js.es6 @@ -15,11 +15,15 @@ export default ObjectController.extend(ModalFunctionality, { disabled: function() { if (this.get('saving')) return true; if (this.blank('emailOrUsername')) return true; + // when inviting to forum, email must be valid if (!this.get('invitingToTopic') && !Discourse.Utilities.emailValid(this.get('emailOrUsername'))) return true; + // normal users (not admin) can't invite users to private topic via email + if (!this.get('isAdmin') && this.get('isPrivateTopic') && Discourse.Utilities.emailValid(this.get('emailOrUsername'))) return true; + // when invting to private topic via email, group name must be specified + if (this.get('isPrivateTopic') && this.blank('groupNames') && Discourse.Utilities.emailValid(this.get('emailOrUsername'))) return true; if (this.get('model.details.can_invite_to')) return false; - if (this.get('isPrivateTopic') && this.blank('groupNames')) return true; return false; - }.property('emailOrUsername', 'invitingToTopic', 'isPrivateTopic', 'groupNames', 'saving'), + }.property('isAdmin', 'emailOrUsername', 'invitingToTopic', 'isPrivateTopic', 'groupNames', 'saving'), buttonTitle: function() { return this.get('saving') ? I18n.t('topic.inviting') : I18n.t('topic.invite_reply.action'); @@ -31,20 +35,23 @@ export default ObjectController.extend(ModalFunctionality, { return this.get('model') !== Discourse.User.current(); }.property('model'), + topicId: Ember.computed.alias('model.id'), + // Is Private Topic? (i.e. visible only to specific group members) isPrivateTopic: Em.computed.and('invitingToTopic', 'model.category.read_restricted'), + // Is Private Message? isMessage: Em.computed.equal('model.archetype', 'private_message'), // Allow Existing Members? (username autocomplete) allowExistingMembers: function() { - return this.get('invitingToTopic') && !this.get('isPrivateTopic'); - }.property('invitingToTopic', 'isPrivateTopic'), + return this.get('invitingToTopic'); + }.property('invitingToTopic'), // Show Groups? (add invited user to private group) showGroups: function() { - return this.get('isAdmin') && (Discourse.Utilities.emailValid(this.get('emailOrUsername')) || this.get('isPrivateTopic') || !this.get('invitingToTopic')) && !Discourse.SiteSettings.enable_sso; - }.property('isAdmin', 'emailOrUsername', 'isPrivateTopic', 'invitingToTopic'), + return this.get('isAdmin') && (Discourse.Utilities.emailValid(this.get('emailOrUsername')) || this.get('isPrivateTopic') || !this.get('invitingToTopic')) && !Discourse.SiteSettings.enable_sso && !this.get('isMessage'); + }.property('isAdmin', 'emailOrUsername', 'isPrivateTopic', 'isMessage', 'invitingToTopic'), // Instructional text for the modal. inviteInstructions: function() { @@ -55,13 +62,19 @@ export default ObjectController.extend(ModalFunctionality, { // inviting to a message return I18n.t('topic.invite_private.email_or_username'); } else if (this.get('invitingToTopic')) { - // when inviting to topic, display instructions based on provided entity - if (this.blank('emailOrUsername')) { - return I18n.t('topic.invite_reply.to_topic_blank'); - } else if (Discourse.Utilities.emailValid(this.get('emailOrUsername'))) { - return I18n.t('topic.invite_reply.to_topic_email'); + // inviting to a private/public topic + if (this.get('isPrivateTopic') && !this.get('isAdmin')) { + // inviting to a private topic and is not admin + return I18n.t('topic.invite_reply.to_username'); } else { - return I18n.t('topic.invite_reply.to_topic_username'); + // when inviting to a topic, display instructions based on provided entity + if (this.blank('emailOrUsername')) { + return I18n.t('topic.invite_reply.to_topic_blank'); + } else if (Discourse.Utilities.emailValid(this.get('emailOrUsername'))) { + return I18n.t('topic.invite_reply.to_topic_email'); + } else { + return I18n.t('topic.invite_reply.to_topic_username'); + } } } else { // inviting to forum diff --git a/app/assets/javascripts/discourse/controllers/notification.js.es6 b/app/assets/javascripts/discourse/controllers/notification.js.es6 index 256eccf9c2..ceb4816b14 100644 --- a/app/assets/javascripts/discourse/controllers/notification.js.es6 +++ b/app/assets/javascripts/discourse/controllers/notification.js.es6 @@ -20,7 +20,7 @@ export default ObjectController.extend({ var badgeId = this.safe("data.badge_id"); if (badgeId) { var badgeName = this.safe("data.badge_name"); - return '/badges/' + badgeId + '/' + badgeName.replace(/[^A-Za-z0-9_]+/g, '-').toLowerCase(); + return Discourse.getURL('/badges/' + badgeId + '/' + badgeName.replace(/[^A-Za-z0-9_]+/g, '-').toLowerCase()); } var topicId = this.safe('topic_id'); @@ -29,7 +29,7 @@ export default ObjectController.extend({ } if (this.get('notification_type') === INVITED_TYPE) { - return '/my/invited'; + return Discourse.getURL('/my/invited'); } }.property("data.{badge_id,badge_name}", "slug", "topic_id", "post_number"), diff --git a/app/assets/javascripts/discourse/controllers/notifications.js.es6 b/app/assets/javascripts/discourse/controllers/notifications.js.es6 index 1b2c71574c..78a80ed0ee 100644 --- a/app/assets/javascripts/discourse/controllers/notifications.js.es6 +++ b/app/assets/javascripts/discourse/controllers/notifications.js.es6 @@ -1,4 +1,6 @@ export default Ember.ArrayController.extend({ needs: ['header'], - loadingNotifications: Em.computed.alias('controllers.header.loadingNotifications') + loadingNotifications: Em.computed.alias('controllers.header.loadingNotifications'), + + myNotificationsUrl: Discourse.computed.url('/my/notifications') }); diff --git a/app/assets/javascripts/discourse/controllers/object.js.es6 b/app/assets/javascripts/discourse/controllers/object.js.es6 index 75a4714285..7351a0a174 100644 --- a/app/assets/javascripts/discourse/controllers/object.js.es6 +++ b/app/assets/javascripts/discourse/controllers/object.js.es6 @@ -1 +1,3 @@ -export default Ember.ObjectController.extend(Discourse.Presence); +import Presence from 'discourse/mixins/presence'; + +export default Ember.ObjectController.extend(Presence); diff --git a/app/assets/javascripts/discourse/controllers/preferences.js.es6 b/app/assets/javascripts/discourse/controllers/preferences.js.es6 index 07120a7c84..7b5810ff30 100644 --- a/app/assets/javascripts/discourse/controllers/preferences.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences.js.es6 @@ -67,7 +67,7 @@ export default ObjectController.extend(CanCheckEmails, { { name: I18n.t('user.email_digests.every_two_weeks'), value: 14 }], autoTrackDurations: [{ name: I18n.t('user.auto_track_options.never'), value: -1 }, - { name: I18n.t('user.auto_track_options.always'), value: 0 }, + { name: I18n.t('user.auto_track_options.immediately'), value: 0 }, { name: I18n.t('user.auto_track_options.after_n_seconds', { count: 30 }), value: 30000 }, { name: I18n.t('user.auto_track_options.after_n_minutes', { count: 1 }), value: 60000 }, { name: I18n.t('user.auto_track_options.after_n_minutes', { count: 2 }), value: 120000 }, diff --git a/app/assets/javascripts/discourse/controllers/queued-post.js.es6 b/app/assets/javascripts/discourse/controllers/queued-post.js.es6 new file mode 100644 index 0000000000..47ac01a006 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/queued-post.js.es6 @@ -0,0 +1,42 @@ +import BufferedContent from 'discourse/mixins/buffered-content'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; + +function updateState(state) { + return function() { + const post = this.get('post'); + post.update({ state }).then(() => { + this.get('controllers.queued-posts.model').removeObject(post); + }).catch(popupAjaxError); + }; +} + +export default Ember.Controller.extend(BufferedContent, { + needs: ['queued-posts'], + post: Ember.computed.alias('model'), + currentlyEditing: Ember.computed.alias('controllers.queued-posts.editing'), + + editing: Discourse.computed.propertyEqual('model', 'currentlyEditing'), + + actions: { + approve: updateState('approved'), + reject: updateState('rejected'), + + edit() { + // This is stupid but pagedown cannot be on the screen twice or it will break + this.set('currentlyEditing', null); + Ember.run.scheduleOnce('afterRender', () => this.set('currentlyEditing', this.get('model'))); + }, + + confirmEdit() { + this.get('post').update({ raw: this.get('buffered.raw') }).then(() => { + this.commitBuffer(); + this.set('currentlyEditing', null); + }); + }, + + cancelEdit() { + this.rollbackBuffer(); + this.set('currentlyEditing', null); + } + } +}); diff --git a/app/assets/javascripts/discourse/controllers/search.js.es6 b/app/assets/javascripts/discourse/controllers/search.js.es6 index 4efcd31dca..bc812e3efe 100644 --- a/app/assets/javascripts/discourse/controllers/search.js.es6 +++ b/app/assets/javascripts/discourse/controllers/search.js.es6 @@ -1,8 +1,9 @@ +import Presence from 'discourse/mixins/presence'; import searchForTerm from 'discourse/lib/search-for-term'; var _dontSearch = false; -export default Em.Controller.extend(Discourse.Presence, { +export default Em.Controller.extend(Presence, { contextType: function(key, value){ if(arguments.length > 1) { diff --git a/app/assets/javascripts/discourse/controllers/site-map.js.es6 b/app/assets/javascripts/discourse/controllers/site-map.js.es6 index 6ae95cda30..af9ccd08c4 100644 --- a/app/assets/javascripts/discourse/controllers/site-map.js.es6 +++ b/app/assets/javascripts/discourse/controllers/site-map.js.es6 @@ -8,7 +8,7 @@ export default Ember.ArrayController.extend({ return Discourse.SiteSettings.faq_url ? Discourse.SiteSettings.faq_url : Discourse.getURL('/faq'); }.property(), - badgesUrl: Discourse.getURL('/badges'), + badgesUrl: Discourse.computed.url('/badges'), showKeyboardShortcuts: function(){ return !Discourse.Mobile.mobileView && !this.capabilities.touch; diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index c532e55ea1..205c750075 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -150,7 +150,7 @@ export default ObjectController.extend(Discourse.SelectedPostsCount, BufferedCon toggleLike(post) { const likeAction = post.get('actionByName.like'); if (likeAction && likeAction.get('canToggle')) { - likeAction.toggle(); + likeAction.toggle(post); } }, diff --git a/app/assets/javascripts/discourse/controllers/user.js.es6 b/app/assets/javascripts/discourse/controllers/user.js.es6 index e3a055627b..4a0e666cfa 100644 --- a/app/assets/javascripts/discourse/controllers/user.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user.js.es6 @@ -19,10 +19,7 @@ export default ObjectController.extend(CanCheckEmails, { linkWebsite: Em.computed.not('isBasic'), - canSeePrivateMessages: function() { - return this.get('viewingSelf') || Discourse.User.currentProp('admin'); - }.property('viewingSelf'), - + canSeePrivateMessages: Ember.computed.or('viewingSelf', 'currentUser.admin'), canSeeNotificationHistory: Em.computed.alias('canSeePrivateMessages'), showBadges: function() { diff --git a/app/assets/javascripts/discourse/helpers/cook-text.js.es6 b/app/assets/javascripts/discourse/helpers/cook-text.js.es6 new file mode 100644 index 0000000000..c7acadce1b --- /dev/null +++ b/app/assets/javascripts/discourse/helpers/cook-text.js.es6 @@ -0,0 +1,6 @@ +import registerUnbound from 'discourse/helpers/register-unbound'; + +registerUnbound('cook-text', function(text) { + return new Handlebars.SafeString(Discourse.Markdown.cook(text)); +}); + diff --git a/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 b/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 index 9689614dde..bb808e5c28 100644 --- a/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 +++ b/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 @@ -27,10 +27,13 @@ export default { bus.callbackInterval = siteSettings.polling_interval; bus.enableLongPolling = true; - if (user.admin || user.moderator) { - bus.subscribe('/flagged_counts', function(data) { + if (user.get('staff')) { + bus.subscribe('/flagged_counts', (data) => { user.set('site_flagged_posts_count', data.total); }); + bus.subscribe('/queue_counts', (data) => { + user.set('post_queue_new_count', data.post_queue_new_count); + }); } bus.subscribe("/notification/" + user.get('id'), (function(data) { const oldUnread = user.get('unread_notifications'); diff --git a/app/assets/javascripts/discourse/initializers/url-redirects.js.es6 b/app/assets/javascripts/discourse/initializers/url-redirects.js.es6 index 46300a65d6..2164be0256 100644 --- a/app/assets/javascripts/discourse/initializers/url-redirects.js.es6 +++ b/app/assets/javascripts/discourse/initializers/url-redirects.js.es6 @@ -5,5 +5,6 @@ export default { // URL rewrites (usually due to refactoring) Discourse.URL.rewrite(/^\/category\//, "/c/"); Discourse.URL.rewrite(/^\/group\//, "/groups/"); + Discourse.URL.rewrite(/\/private-messages\/$/, "/messages/"); } }; diff --git a/app/assets/javascripts/discourse/lib/Markdown.Editor.js b/app/assets/javascripts/discourse/lib/Markdown.Editor.js index 7c6ef13587..3a45faaae7 100644 --- a/app/assets/javascripts/discourse/lib/Markdown.Editor.js +++ b/app/assets/javascripts/discourse/lib/Markdown.Editor.js @@ -323,7 +323,13 @@ // Adds a listener callback to a DOM element which is fired on a specified // event. util.addEvent = function (elem, event, listener) { - elem.addEventListener(event, listener, false); + var wrapped = function() { + var wrappedArgs = Array.prototype.slice.call(arguments); + Ember.run(function() { + listener.apply(this, wrappedArgs); + }); + }; + elem.addEventListener(event, wrapped, false); }; @@ -904,7 +910,7 @@ // TODO allow us to inject this in (its our debouncer) var debounce = function(func,wait,trickle) { var timeout = null; - return function(){ + return function() { var context = this; var args = arguments; @@ -924,8 +930,8 @@ currentWait = wait; } - if (timeout) { clearTimeout(timeout); } - timeout = setTimeout(later, currentWait); + if (timeout) { Ember.run.cancel(timeout); } + timeout = Ember.run.later(later, currentWait); } } diff --git a/app/assets/javascripts/discourse/lib/ajax-error.js.es6 b/app/assets/javascripts/discourse/lib/ajax-error.js.es6 new file mode 100644 index 0000000000..f1a34add67 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/ajax-error.js.es6 @@ -0,0 +1,38 @@ +function extractError(error) { + if (error instanceof Error) { + Ember.Logger.error(error.stack); + } + + if (typeof error === "string") { + Ember.Logger.error(error); + } + + let parsedError; + if (error.responseText) { + try { + const parsedJSON = $.parseJSON(error.responseText); + if (parsedJSON.errors) { + parsedError = parsedJSON.errors[0]; + } else if (parsedJSON.failed) { + parsedError = parsedJSON.message; + } + } catch(ex) { + // in case the JSON doesn't parse + Ember.Logger.error(ex.stack); + } + } + return parsedError || I18n.t('generic_error'); +} + +export function throwAjaxError(undoCallback) { + return function(error) { + // If we provided an `undo` callback + if (undoCallback) { undoCallback(error); } + + throw extractError(error); + }; +} + +export function popupAjaxError(err) { + bootbox.alert(extractError(err)); +} diff --git a/app/assets/javascripts/discourse/lib/onebox.js b/app/assets/javascripts/discourse/lib/onebox.js index bb282ce150..e39635bea5 100644 --- a/app/assets/javascripts/discourse/lib/onebox.js +++ b/app/assets/javascripts/discourse/lib/onebox.js @@ -51,7 +51,8 @@ Discourse.Onebox = { // Retrieve the onebox var promise = Discourse.ajax("/onebox", { dataType: 'html', - data: { url: url, refresh: refresh } + data: { url: url, refresh: refresh }, + cache: true }); // We can call this when loading is complete diff --git a/app/assets/javascripts/discourse/lib/show-modal.js.es6 b/app/assets/javascripts/discourse/lib/show-modal.js.es6 index 6007700930..01cc024021 100644 --- a/app/assets/javascripts/discourse/lib/show-modal.js.es6 +++ b/app/assets/javascripts/discourse/lib/show-modal.js.es6 @@ -1,13 +1,38 @@ -export default (name, model) => { +export default (name, opts) => { + opts = opts || {}; + + if (opts.__type) { + Ember.warn("showModal now takes `opts` as a second param instead of a model"); + opts = {model: opts}; + } + + const container = Discourse.__container__; + // We use the container here because modals are like singletons // in Discourse. Only one can be shown with a particular state. - const route = Discourse.__container__.lookup('route:application'); + const route = container.lookup('route:application'); + const modalController = route.controllerFor('modal'); - route.controllerFor('modal').set('modalClass', null); - route.render(name, { into: 'modal', outlet: 'modalBody' }); + modalController.set('modalClass', null); + + const viewClass = container.lookupFactory('view:' + name); + const controller = container.lookup('controller:' + name); + if (viewClass) { + route.render(name, { into: 'modal', outlet: 'modalBody' }); + } else { + const templateName = Ember.String.dasherize(name); + + const renderArgs = { into: 'modal', outlet: 'modalBody', view: 'modal-body'}; + if (controller) { renderArgs.controller = name; } + + route.render('modal/' + templateName, renderArgs); + if (opts.title) { + modalController.set('title', I18n.t(opts.title)); + } + } - const controller = route.controllerFor(name); if (controller) { + const model = opts.model; if (model) { controller.set('model', model); } if (controller.onShow) { controller.onShow(); } controller.set('flashMessage', null); diff --git a/app/assets/javascripts/discourse/lib/user-search.js.es6 b/app/assets/javascripts/discourse/lib/user-search.js.es6 index 3311f518d8..790a00ff09 100644 --- a/app/assets/javascripts/discourse/lib/user-search.js.es6 +++ b/app/assets/javascripts/discourse/lib/user-search.js.es6 @@ -6,7 +6,7 @@ var cache = {}, currentTerm, oldSearch; -function performSearch(term, topicId, includeGroups, resultsFn) { +function performSearch(term, topicId, includeGroups, allowedUsers, resultsFn) { var cached = cache[term]; if (cached) { resultsFn(cached); @@ -17,7 +17,8 @@ function performSearch(term, topicId, includeGroups, resultsFn) { oldSearch = $.ajax(Discourse.getURL('/users/search/users'), { data: { term: term, topic_id: topicId, - include_groups: includeGroups } + include_groups: includeGroups, + topic_allowed_users: allowedUsers } }); var returnVal = CANCELLED_STATUS; @@ -75,6 +76,7 @@ function organizeResults(r, options) { export default function userSearch(options) { var term = options.term || "", includeGroups = options.includeGroups, + allowedUsers = options.allowedUsers, topicId = options.topicId; @@ -101,7 +103,7 @@ export default function userSearch(options) { resolve(CANCELLED_STATUS); }, 5000); - debouncedSearch(term, topicId, includeGroups, function(r) { + debouncedSearch(term, topicId, includeGroups, allowedUsers, function(r) { clearTimeout(clearPromise); resolve(organizeResults(r, options)); }); diff --git a/app/assets/javascripts/discourse/lib/utilities.js b/app/assets/javascripts/discourse/lib/utilities.js index 11f5af2923..f51fd4d46f 100644 --- a/app/assets/javascripts/discourse/lib/utilities.js +++ b/app/assets/javascripts/discourse/lib/utilities.js @@ -51,7 +51,7 @@ Discourse.Utilities = { var classes = "avatar" + (options.extraClasses ? " " + options.extraClasses : ""); var title = (options.title) ? " title='" + Handlebars.Utils.escapeExpression(options.title || "") + "'" : ""; - return ""; + return ""; }, tinyAvatar: function(avatarTemplate, options) { diff --git a/app/assets/javascripts/discourse/mixins/presence.js b/app/assets/javascripts/discourse/mixins/presence.js deleted file mode 100644 index a09bea3d60..0000000000 --- a/app/assets/javascripts/discourse/mixins/presence.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - This mixin provides `blank` and `present` to determine whether properties are - there, accounting for more cases than just null and undefined. - - @class Discourse.Presence - @extends Ember.Mixin - @namespace Discourse - @module Discourse -**/ -Discourse.Presence = Em.Mixin.create({ - - /** - Returns whether a property is blank. It considers empty arrays, string, objects, undefined and null - to be blank, otherwise true. - - @method blank - @param {String} name the name of the property we want to check - @return {Boolean} - */ - blank: function(name) { - return Ember.isEmpty(this[name] || this.get(name)); - }, - - /** - Returns whether a property is present. A present property is the opposite of a `blank` one. - - @method present - @param {String} name the name of the property we want to check - @return {Boolean} - */ - present: function(name) { - return !this.blank(name); - } -}); - - diff --git a/app/assets/javascripts/discourse/mixins/presence.js.es6 b/app/assets/javascripts/discourse/mixins/presence.js.es6 new file mode 100644 index 0000000000..742f50f402 --- /dev/null +++ b/app/assets/javascripts/discourse/mixins/presence.js.es6 @@ -0,0 +1,20 @@ +/** + This mixin provides `blank` and `present` to determine whether properties are + there, accounting for more cases than just null and undefined. +**/ +export default Ember.Mixin.create({ + + /** + Returns whether a property is blank. It considers empty arrays, string, objects, undefined and null + to be blank, otherwise true. + */ + blank(name) { + return Ember.isEmpty(this[name] || this.get(name)); + }, + + // Returns whether a property is present. A present property is the opposite of a `blank` one. + present(name) { + return !this.blank(name); + } + +}); diff --git a/app/assets/javascripts/discourse/models/action_summary.js b/app/assets/javascripts/discourse/models/action_summary.js index c6140dc964..0f53c58d6f 100644 --- a/app/assets/javascripts/discourse/models/action_summary.js +++ b/app/assets/javascripts/discourse/models/action_summary.js @@ -1,11 +1,3 @@ -/** - A data model for summarizing actions a user has taken, for example liking a post. - - @class ActionSummary - @extends Discourse.Model - @namespace Discourse - @module Discourse -**/ Discourse.ActionSummary = Discourse.Model.extend({ // Description for the action @@ -44,16 +36,16 @@ Discourse.ActionSummary = Discourse.Model.extend({ } }, - toggle: function() { + toggle: function(post) { if (!this.get('acted')) { - this.act(); + this.act(post); } else { - this.undo(); + this.undo(post); } }, // Perform this action - act: function(opts) { + act: function(post, opts) { if (!opts) opts = {}; var action = this.get('actionType.name_key'); @@ -82,18 +74,14 @@ Discourse.ActionSummary = Discourse.Model.extend({ return Discourse.ajax("/post_actions", { type: 'POST', data: { - id: this.get('flagTopic') ? this.get('flagTopic.id') : this.get('post.id'), + id: this.get('flagTopic') ? this.get('flagTopic.id') : post.get('id'), post_action_type_id: this.get('id'), message: opts.message, take_action: opts.takeAction, flag_topic: this.get('flagTopic') ? true : false } }).then(function(result) { - var post = self.get('post'); - if (post && result && result.id === post.get('id')) { - post.updateFromJson(result); - } - return post; + return post.updateActionsSummary(result); }).catch(function (error) { self.removeAction(); var message = $.parseJSON(error.responseText).errors; @@ -102,43 +90,38 @@ Discourse.ActionSummary = Discourse.Model.extend({ }, // Undo this action - undo: function() { + undo: function(post) { this.removeAction(); // Remove our post action - var self = this; - return Discourse.ajax("/post_actions/" + (this.get('post.id')), { + return Discourse.ajax("/post_actions/" + post.get('id'), { type: 'DELETE', data: { post_action_type_id: this.get('id') } }).then(function(result) { - var post = self.get('post'); - if (post && result && result.id === post.get('id')) { - post.updateFromJson(result); - } - return post; + return post.updateActionsSummary(result); }); }, - deferFlags: function() { + deferFlags: function(post) { var self = this; return Discourse.ajax("/post_actions/defer_flags", { type: "POST", data: { post_action_type_id: this.get("id"), - id: this.get("post.id") + id: post.get('id') } }).then(function () { self.set("count", 0); }); }, - loadUsers: function() { + loadUsers: function(post) { var self = this; Discourse.ajax("/post_actions/users", { data: { - id: this.get('post.id'), + id: post.get('id'), post_action_type_id: this.get('id') } }).then(function (result) { diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index fa101a177c..4170748632 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -1,3 +1,7 @@ +import RestModel from 'discourse/models/rest'; +import Topic from 'discourse/models/topic'; +import { throwAjaxError } from 'discourse/lib/ajax-error'; + const CLOSED = 'closed', SAVING = 'saving', OPEN = 'open', @@ -26,10 +30,10 @@ const CLOSED = 'closed', categoryId: 'topic.category.id' }; -const Composer = Discourse.Model.extend({ +const Composer = RestModel.extend({ archetypes: function() { - return Discourse.Site.currentProp('archetypes'); + return this.site.get('archetypes'); }.property(), creatingTopic: Em.computed.equal('action', CREATE_TOPIC), @@ -127,21 +131,16 @@ const Composer = Discourse.Model.extend({ } else { // has a category? (when needed) return this.get('canCategorize') && - !Discourse.SiteSettings.allow_uncategorized_topics && + !this.siteSettings.allow_uncategorized_topics && !this.get('categoryId') && - !Discourse.User.currentProp('staff'); + !this.user.get('staff'); } }.property('loading', 'canEditTitle', 'titleLength', 'targetUsernames', 'replyLength', 'categoryId', 'missingReplyCharacters'), - /** - Is the title's length valid? - - @property titleLengthValid - **/ titleLengthValid: function() { - if (Discourse.User.currentProp('admin') && this.get('post.static_doc') && this.get('titleLength') > 0) return true; + if (this.user.get('admin') && this.get('post.static_doc') && this.get('titleLength') > 0) return true; if (this.get('titleLength') < this.get('minimumTitleLength')) return false; - return (this.get('titleLength') <= Discourse.SiteSettings.max_topic_title_length); + return (this.get('titleLength') <= this.siteSettings.max_topic_title_length); }.property('minimumTitleLength', 'titleLength', 'post.static_doc'), // The icon for the save button @@ -194,9 +193,9 @@ const Composer = Discourse.Model.extend({ **/ minimumTitleLength: function() { if (this.get('privateMessage')) { - return Discourse.SiteSettings.min_private_message_title_length; + return this.siteSettings.min_private_message_title_length; } else { - return Discourse.SiteSettings.min_topic_title_length; + return this.siteSettings.min_topic_title_length; } }.property('privateMessage'), @@ -216,12 +215,12 @@ const Composer = Discourse.Model.extend({ **/ minimumPostLength: function() { if( this.get('privateMessage') ) { - return Discourse.SiteSettings.min_private_message_post_length; + return this.siteSettings.min_private_message_post_length; } else if (this.get('topicFirstPost')) { // first post (topic body) - return Discourse.SiteSettings.min_first_post_length; + return this.siteSettings.min_first_post_length; } else { - return Discourse.SiteSettings.min_post_length; + return this.siteSettings.min_post_length; } }.property('privateMessage', 'topicFirstPost'), @@ -249,7 +248,7 @@ const Composer = Discourse.Model.extend({ _setupComposer: function() { const val = (Discourse.Mobile.mobileView ? false : (Discourse.KeyValueStore.get('composer.showPreview') || 'true')); this.set('showPreview', val === 'true'); - this.set('archetypeId', Discourse.Site.currentProp('default_archetype')); + this.set('archetypeId', this.site.get('default_archetype')); }.on('init'), /** @@ -349,15 +348,15 @@ const Composer = Discourse.Model.extend({ this.setProperties({ categoryId: opts.categoryId || this.get('topic.category.id'), - archetypeId: opts.archetypeId || Discourse.Site.currentProp('default_archetype'), + archetypeId: opts.archetypeId || this.site.get('default_archetype'), metaData: opts.metaData ? Em.Object.create(opts.metaData) : null, reply: opts.reply || this.get("reply") || "" }); if (opts.postId) { this.set('loading', true); - Discourse.Post.load(opts.postId).then(function(result) { - composer.set('post', result); + this.store.find('post', opts.postId).then(function(post) { + composer.set('post', post); composer.set('loading', false); }); } @@ -370,10 +369,10 @@ const Composer = Discourse.Model.extend({ this.setProperties(topicProps); - Discourse.Post.load(opts.post.get('id')).then(function(result) { + this.store.find('post', opts.post.get('id')).then(function(post) { composer.setProperties({ - reply: result.get('raw'), - originalText: result.get('raw'), + reply: post.get('raw'), + originalText: post.get('raw'), loading: false }); }); @@ -425,30 +424,29 @@ const Composer = Discourse.Model.extend({ post.get('post_number') === 1 && this.get('topic.details.can_edit')) { const topicProps = this.getProperties(Object.keys(_edit_topic_serializer)); - promise = Discourse.Topic.update(this.get('topic'), topicProps); + + promise = Topic.update(this.get('topic'), topicProps); } else { promise = Ember.RSVP.resolve(); } - post.setProperties({ + const props = { raw: this.get('reply'), - editReason: opts.editReason, - imageSizes: opts.imageSizes, + edit_reason: opts.editReason, + image_sizes: opts.imageSizes, cooked: this.getCookedHtml() - }); + }; this.set('composeState', CLOSED); return promise.then(function() { - return post.save(function(result) { - post.updateFromPost(result); + return post.save(props).then(function(result) { self.clearState(); - }, function (error) { + return result; + }).catch(throwAjaxError(function() { post.set('cooked', oldCooked); self.set('composeState', OPEN); - const response = $.parseJSON(error.responseText); - throw response && response.errors ? response.errors[0] : I18n.t('generic_error'); - }); + })); }); }, @@ -467,30 +465,30 @@ const Composer = Discourse.Model.extend({ createPost(opts) { const post = this.get('post'), topic = this.get('topic'), - currentUser = Discourse.User.current(), + user = this.user, postStream = this.get('topic.postStream'); let addedToStream = false; // Build the post object - const createdPost = Discourse.Post.create({ + const createdPost = this.store.createRecord('post', { imageSizes: opts.imageSizes, cooked: this.getCookedHtml(), reply_count: 0, - name: currentUser.get('name'), - display_username: currentUser.get('name'), - username: currentUser.get('username'), - user_id: currentUser.get('id'), - user_title: currentUser.get('title'), - uploaded_avatar_id: currentUser.get('uploaded_avatar_id'), - user_custom_fields: currentUser.get('custom_fields'), - post_type: Discourse.Site.currentProp('post_types.regular'), + name: user.get('name'), + display_username: user.get('name'), + username: user.get('username'), + user_id: user.get('id'), + user_title: user.get('title'), + uploaded_avatar_id: user.get('uploaded_avatar_id'), + user_custom_fields: user.get('custom_fields'), + post_type: this.site.get('post_types.regular'), actions_summary: [], - moderator: currentUser.get('moderator'), - admin: currentUser.get('admin'), + moderator: user.get('moderator'), + admin: user.get('admin'), yours: true, - newPost: true, - read: true + read: true, + wiki: false }); this.serialize(_create_serializer, createdPost); @@ -520,78 +518,55 @@ const Composer = Discourse.Model.extend({ // we would need to handle oneboxes and other bits that are not even in the // engine, staging will just cause a blank post to render if (!_.isEmpty(createdPost.get('cooked'))) { - state = postStream.stagePost(createdPost, currentUser); - - if(state === "alreadyStaging"){ - return; - } - + state = postStream.stagePost(createdPost, user); + if (state === "alreadyStaging") { return; } } } - const composer = this, - promise = new Ember.RSVP.Promise(function(resolve, reject) { - composer.set('composeState', SAVING); - - createdPost.save(function(result) { - let saving = true; - - createdPost.updateFromJson(result); - - if (topic) { - // It's no longer a new post - createdPost.set('newPost', false); - topic.set('draft_sequence', result.draft_sequence); - postStream.commitPost(createdPost); - addedToStream = true; - } else { - // We created a new topic, let's show it. - composer.set('composeState', CLOSED); - saving = false; - - // Update topic_count for the category - const category = Discourse.Site.currentProp('categories').find(function(x) { return x.get('id') === (parseInt(createdPost.get('category'),10) || 1); }); - if (category) category.incrementProperty('topic_count'); - Discourse.notifyPropertyChange('globalNotice'); - } - - composer.clearState(); - composer.set('createdPost', createdPost); - - if (addedToStream) { - composer.set('composeState', CLOSED); - } else if (saving) { - composer.set('composeState', SAVING); - } - - return resolve({ post: result }); - }, function(error) { - // If an error occurs - if (postStream) { - postStream.undoPost(createdPost); - } - composer.set('composeState', OPEN); - - // TODO extract error handling code - let parsedError; - try { - const parsedJSON = $.parseJSON(error.responseText); - if (parsedJSON.errors) { - parsedError = parsedJSON.errors[0]; - } else if (parsedJSON.failed) { - parsedError = parsedJSON.message; - } - } - catch(ex) { - parsedError = "Unknown error saving post, try again. Error: " + error.status + " " + error.statusText; - } - reject(parsedError); - }); - }); - + const composer = this; + composer.set('composeState', SAVING); composer.set("stagedPost", state === "staged" && createdPost); - return promise; + return createdPost.save().then(function(result) { + let saving = true; + + if (result.responseJson.action === "enqueued") { + if (postStream) { postStream.undoPost(createdPost); } + return result; + } + + if (topic) { + // It's no longer a new post + topic.set('draft_sequence', result.target.draft_sequence); + postStream.commitPost(createdPost); + addedToStream = true; + } else { + // We created a new topic, let's show it. + composer.set('composeState', CLOSED); + saving = false; + + // Update topic_count for the category + const category = composer.site.get('categories').find(function(x) { return x.get('id') === (parseInt(createdPost.get('category'),10) || 1); }); + if (category) category.incrementProperty('topic_count'); + Discourse.notifyPropertyChange('globalNotice'); + } + + composer.clearState(); + composer.set('createdPost', createdPost); + + if (addedToStream) { + composer.set('composeState', CLOSED); + } else if (saving) { + composer.set('composeState', SAVING); + } + + return result; + }).catch(throwAjaxError(function() { + if (postStream) { + postStream.undoPost(createdPost); + } + Ember.run.next(() => composer.set('composeState', OPEN)); + })); }, getCookedHtml() { @@ -604,7 +579,7 @@ const Composer = Discourse.Model.extend({ // Do not save when there is no reply if (!this.get('reply')) return; // Do not save when the reply's length is too small - if (this.get('replyLength') < Discourse.SiteSettings.min_post_length) return; + if (this.get('replyLength') < this.siteSettings.min_post_length) return; const data = { reply: this.get('reply'), @@ -673,6 +648,14 @@ Composer.reopenClass({ } }, + create(args) { + args = args || {}; + args.user = args.user || Discourse.User.current(); + args.site = args.site || Discourse.Site.current(); + args.siteSettings = args.siteSettings || Discourse.SiteSettings; + return this._super(args); + }, + serializeToTopic(fieldName, property) { if (!property) { property = fieldName; } _edit_topic_serializer[fieldName] = property; diff --git a/app/assets/javascripts/discourse/models/group.js.es6 b/app/assets/javascripts/discourse/models/group.js.es6 index 6f426073aa..984941e89f 100644 --- a/app/assets/javascripts/discourse/models/group.js.es6 +++ b/app/assets/javascripts/discourse/models/group.js.es6 @@ -52,6 +52,12 @@ const Group = Discourse.Model.extend({ }).then(function() { // reload member list self.findMembers(); + }).catch(function(error) { + if (error && error.responseText) { + bootbox.alert($.parseJSON(error.responseText).errors[0]); + } else { + bootbox.alert(I18n.t('generic_error')); + } }); }, diff --git a/app/assets/javascripts/discourse/models/model.js b/app/assets/javascripts/discourse/models/model.js.es6 similarity index 63% rename from app/assets/javascripts/discourse/models/model.js rename to app/assets/javascripts/discourse/models/model.js.es6 index 0c2619c7cb..4a5209bee5 100644 --- a/app/assets/javascripts/discourse/models/model.js +++ b/app/assets/javascripts/discourse/models/model.js.es6 @@ -1,6 +1,8 @@ -Discourse.Model = Ember.Object.extend(Discourse.Presence); +import Presence from 'discourse/mixins/presence'; -Discourse.Model.reopenClass({ +const Model = Ember.Object.extend(Presence); + +Model.reopenClass({ extractByKey: function(collection, klass) { var retval = {}; if (Ember.isEmpty(collection)) { return retval; } @@ -11,3 +13,5 @@ Discourse.Model.reopenClass({ return retval; } }); + +export default Model; diff --git a/app/assets/javascripts/discourse/models/post-stream.js.es6 b/app/assets/javascripts/discourse/models/post-stream.js.es6 index 7c3ae0cf26..532cc6a991 100644 --- a/app/assets/javascripts/discourse/models/post-stream.js.es6 +++ b/app/assets/javascripts/discourse/models/post-stream.js.es6 @@ -1,4 +1,6 @@ -const PostStream = Ember.Object.extend({ +import RestModel from 'discourse/models/rest'; + +const PostStream = RestModel.extend({ loading: Em.computed.or('loadingAbove', 'loadingBelow', 'loadingFilter', 'stagingPost'), notLoading: Em.computed.not('loading'), filteredPostsCount: Em.computed.alias("stream.length"), @@ -148,12 +150,16 @@ const PostStream = Ember.Object.extend({ opts = opts || {}; opts.nearPost = parseInt(opts.nearPost, 10); - const topic = this.get('topic'), - self = this; + const topic = this.get('topic'); + const self = this; // Do we already have the post in our list of posts? Jump there. - const postWeWant = this.get('posts').findProperty('post_number', opts.nearPost); - if (postWeWant) { return Ember.RSVP.resolve(); } + if (opts.forceLoad) { + this.set('loaded', false); + } else { + const postWeWant = this.get('posts').findProperty('post_number', opts.nearPost); + if (postWeWant) { return Ember.RSVP.resolve(); } + } // TODO: if we have all the posts in the filter, don't go to the server for them. self.set('loadingFilter', true); @@ -420,8 +426,9 @@ const PostStream = Ember.Object.extend({ } else { // need to insert into stream const url = "/posts/" + postId; + const store = this.store; Discourse.ajax(url).then(function(p){ - const post = Discourse.Post.create(p); + const post = store.createRecord('post', p); const stream = self.get("stream"); const posts = self.get("posts"); self.storePost(post); @@ -461,9 +468,10 @@ const PostStream = Ember.Object.extend({ if(existing){ const url = "/posts/" + postId; + const store = this.store; Discourse.ajax(url).then( function(p){ - self.storePost(Discourse.Post.create(p)); + self.storePost(store.createRecord('post', p)); }, function(){ self.removePosts([existing]); @@ -480,8 +488,9 @@ const PostStream = Ember.Object.extend({ if (existing && existing.updated_at !== updatedAt) { const url = "/posts/" + postId; + const store = this.store; Discourse.ajax(url).then(function(p){ - self.storePost(Discourse.Post.create(p)); + self.storePost(store.createRecord('post', p)); }); } }, @@ -491,9 +500,10 @@ const PostStream = Ember.Object.extend({ const postStream = this, url = "/posts/" + post.get('id') + "/reply-history.json?max_replies=" + Discourse.SiteSettings.max_reply_history; + const store = this.store; return Discourse.ajax(url).then(function(result) { return result.map(function (p) { - return postStream.storePost(Discourse.Post.create(p)); + return postStream.storePost(store.createRecord('post', p)); }); }).then(function (replyHistory) { post.set('replyHistory', replyHistory); @@ -594,8 +604,9 @@ const PostStream = Ember.Object.extend({ this.set('gaps', null); if (postStreamData) { // Load posts if present + const store = this.store; postStreamData.posts.forEach(function(p) { - postStream.appendPost(Discourse.Post.create(p)); + postStream.appendPost(store.createRecord('post', p)); }); delete postStreamData.posts; @@ -671,11 +682,12 @@ const PostStream = Ember.Object.extend({ data = { post_ids: postIds }, postStream = this; + const store = this.store; return Discourse.ajax(url, {data: data}).then(function(result) { const posts = Em.get(result, "post_stream.posts"); if (posts) { posts.forEach(function (p) { - postStream.storePost(Discourse.Post.create(p)); + postStream.storePost(store.createRecord('post', p)); }); } }); @@ -751,6 +763,8 @@ PostStream.reopenClass({ url += "/" + opts.nearPost; } delete opts.nearPost; + delete opts.__type; + delete opts.store; return PreloadStore.getAndRemove("topic_" + topicId, function() { return Discourse.ajax(url + ".json", {data: opts}); diff --git a/app/assets/javascripts/discourse/models/_post.js b/app/assets/javascripts/discourse/models/post.js.es6 similarity index 62% rename from app/assets/javascripts/discourse/models/_post.js rename to app/assets/javascripts/discourse/models/post.js.es6 index 7ae7058cbc..e424d8111c 100644 --- a/app/assets/javascripts/discourse/models/_post.js +++ b/app/assets/javascripts/discourse/models/post.js.es6 @@ -1,20 +1,15 @@ -/** - A data model representing a post in a topic +import RestModel from 'discourse/models/rest'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; - @class Post - @extends Discourse.Model - @namespace Discourse - @module Discourse -**/ -Discourse.Post = Discourse.Model.extend({ +const Post = RestModel.extend({ - init: function() { + init() { this.set('replyHistory', []); }, shareUrl: function() { - var user = Discourse.User.current(); - var userSuffix = user ? '?u=' + user.get('username_lower') : ''; + const user = Discourse.User.current(); + const userSuffix = user ? '?u=' + user.get('username_lower') : ''; if (this.get('firstPost')) { return this.get('topic.url') + userSuffix; @@ -33,7 +28,7 @@ Discourse.Post = Discourse.Model.extend({ userDeleted: Em.computed.empty('user_id'), showName: function() { - var name = this.get('name'); + const name = this.get('name'); return name && (name !== this.get('username')) && Discourse.SiteSettings.display_name_on_posts; }.property('name', 'username'), @@ -69,29 +64,23 @@ Discourse.Post = Discourse.Model.extend({ }.property("user_id"), wikiChanged: function() { - var data = { wiki: this.get("wiki") }; + const data = { wiki: this.get("wiki") }; this._updatePost("wiki", data); }.observes('wiki'), postTypeChanged: function () { - var data = { post_type: this.get("post_type") }; + const data = { post_type: this.get("post_type") }; this._updatePost("post_type", data); }.observes("post_type"), - _updatePost: function (field, data) { - var self = this; + _updatePost(field, data) { + const self = this; Discourse.ajax("/posts/" + this.get("id") + "/" + field, { type: "PUT", data: data }).then(function () { self.incrementProperty("version"); - }, function (error) { - if (error && error.responseText) { - bootbox.alert($.parseJSON(error.responseText).errors[0]); - } else { - bootbox.alert(I18n.t("generic_error")); - } - }); + }).catch(popupAjaxError); }, internalLinks: function() { @@ -103,7 +92,7 @@ Discourse.Post = Discourse.Model.extend({ editCount: function() { return this.get('version') - 1; }.property('version'), flagsAvailable: function() { - var post = this; + const post = this; return Discourse.Site.currentProp('flagTypes').filter(function(item) { return post.get("actionByName." + item.get('name_key') + ".can_act"); }); @@ -119,73 +108,46 @@ Discourse.Post = Discourse.Model.extend({ }); }.property('actions_summary.@each.users', 'actions_summary.@each.count'), - // Save a post and call the callback when done. - save: function(complete, error) { - var self = this; - if (!this.get('newPost')) { - // We're updating a post - return Discourse.ajax("/posts/" + (this.get('id')), { - type: 'PUT', - dataType: 'json', - data: { - post: { raw: this.get('raw'), edit_reason: this.get('editReason') }, - image_sizes: this.get('imageSizes') - } - }).then(function(result) { - // If we received a category update, update it - self.set('version', result.post.version); - if (result.category) Discourse.Site.current().updateCategory(result.category); - if (complete) complete(Discourse.Post.create(result.post)); - }).catch(function(result) { - // Post failed to update - if (error) error(result); - }); - - } else { - // We're saving a post - var data = this.getProperties(Discourse.Composer.serializedFieldsForCreate()); - data.reply_to_post_number = this.get('reply_to_post_number'); - data.image_sizes = this.get('imageSizes'); - - var metaData = this.get('metaData'); - // Put the metaData into the request - if (metaData) { - data.meta_data = {}; - Ember.keys(metaData).forEach(function(key) { data.meta_data[key] = metaData.get(key); }); - } - - return Discourse.ajax("/posts", { - type: 'POST', - data: data - }).then(function(result) { - // Post created - if (complete) complete(Discourse.Post.create(result)); - }).catch(function(result) { - // Failed to create a post - if (error) error(result); - }); + afterUpdate(res) { + if (res.category) { + Discourse.Site.current().updateCategory(res.category); } }, - /** - Expands the first post's content, if embedded and shortened. + updateProperties() { + return { + post: { raw: this.get('raw'), edit_reason: this.get('editReason') }, + image_sizes: this.get('imageSizes') + }; + }, - @method expandFirstPost - **/ - expand: function() { - var self = this; + createProperties() { + const data = this.getProperties(Discourse.Composer.serializedFieldsForCreate()); + data.reply_to_post_number = this.get('reply_to_post_number'); + data.image_sizes = this.get('imageSizes'); + + const metaData = this.get('metaData'); + + // Put the metaData into the request + if (metaData) { + data.meta_data = {}; + Ember.keys(metaData).forEach(function(key) { data.meta_data[key] = metaData.get(key); }); + } + + return data; + }, + + // Expands the first post's content, if embedded and shortened. + expand() { + const self = this; return Discourse.ajax("/posts/" + this.get('id') + "/expand-embed").then(function(post) { self.set('cooked', "
" + post.cooked + "
" ); }); }, - /** - Recover a deleted post - - @method recover - **/ - recover: function() { - var post = this; + // Recover a deleted post + recover() { + const post = this; post.setProperties({ deleted_at: null, deleted_by: null, @@ -207,11 +169,8 @@ Discourse.Post = Discourse.Model.extend({ /** Changes the state of the post to be deleted. Does not call the server, that should be done elsewhere. - - @method setDeletedState - @param {Discourse.User} deletedBy The user deleting the post **/ - setDeletedState: function(deletedBy) { + setDeletedState(deletedBy) { this.set('oldCooked', this.get('cooked')); // Moderators can delete posts. Users can only trigger a deleted at message, unless delete_removed_posts_after is 0. @@ -237,10 +196,8 @@ Discourse.Post = Discourse.Model.extend({ Changes the state of the post to NOT be deleted. Does not call the server. This can only be called after setDeletedState was called, but the delete failed on the server. - - @method undoDeletedState **/ - undoDeleteState: function() { + undoDeleteState() { if (this.get('oldCooked')) { this.setProperties({ deleted_at: null, @@ -253,13 +210,7 @@ Discourse.Post = Discourse.Model.extend({ } }, - /** - Deletes a post - - @method destroy - @param {Discourse.User} deletedBy The user deleting the post - **/ - destroy: function(deletedBy) { + destroy(deletedBy) { this.setDeletedState(deletedBy); return Discourse.ajax("/posts/" + this.get('id'), { data: { context: window.location.pathname }, @@ -270,14 +221,11 @@ Discourse.Post = Discourse.Model.extend({ /** Updates a post from another's attributes. This will normally happen when a post is loading but is already found in an identity map. - - @method updateFromPost - @param {Discourse.Post} otherPost The post we're updating from **/ - updateFromPost: function(otherPost) { - var self = this; + updateFromPost(otherPost) { + const self = this; Object.keys(otherPost).forEach(function (key) { - var value = otherPost[key], + let value = otherPost[key], oldValue = self[key]; if (key === "replyHistory") { @@ -287,7 +235,7 @@ Discourse.Post = Discourse.Model.extend({ if (!value) { value = null; } if (!oldValue) { oldValue = null; } - var skip = false; + let skip = false; if (typeof value !== "function" && oldValue !== value) { // wishing for an identity map if (key === "reply_to_user" && value && oldValue) { @@ -301,56 +249,8 @@ Discourse.Post = Discourse.Model.extend({ }); }, - /** - Updates a post from a JSON packet. This is normally done after the post is saved to refresh any - attributes. - - @method updateFromJson - @param {Object} obj The Json data to update with - **/ - updateFromJson: function(obj) { - if (!obj) return; - - var skip, oldVal; - - // Update all the properties - var post = this; - _.each(obj, function(val,key) { - if (key !== 'actions_summary'){ - oldVal = post[key]; - skip = false; - - if (val && val !== oldVal) { - - if (key === "reply_to_user" && val && oldVal) { - skip = val.username === oldVal.username || Em.get(val, "username") === Em.get(oldVal, "username"); - } - - if(!skip) { - post.set(key, val); - } - } - } - }); - - // Rebuild actions summary - this.set('actions_summary', Em.A()); - if (obj.actions_summary) { - var lookup = Em.Object.create(); - _.each(obj.actions_summary,function(a) { - var actionSummary; - a.post = post; - a.actionType = Discourse.Site.current().postActionTypeById(a.id); - actionSummary = Discourse.ActionSummary.create(a); - post.get('actions_summary').pushObject(actionSummary); - lookup.set(a.actionType.get('name_key'), actionSummary); - }); - this.set('actionByName', lookup); - } - }, - // Load replies to this post - loadReplies: function() { + loadReplies() { if(this.get('loadingReplies')){ return; } @@ -358,12 +258,12 @@ Discourse.Post = Discourse.Model.extend({ this.set('loadingReplies', true); this.set('replies', []); - var self = this; + const self = this; return Discourse.ajax("/posts/" + (this.get('id')) + "/replies") .then(function(loaded) { - var replies = self.get('replies'); + const replies = self.get('replies'); _.each(loaded,function(reply) { - var post = Discourse.Post.create(reply); + const post = Discourse.Post.create(reply); post.set('topic', self.get('topic')); replies.pushObject(post); }); @@ -375,7 +275,7 @@ Discourse.Post = Discourse.Model.extend({ // Whether to show replies directly below showRepliesBelow: function() { - var replyCount = this.get('reply_count'); + const replyCount = this.get('reply_count'); // We don't show replies if there aren't any if (replyCount === 0) return false; @@ -387,13 +287,13 @@ Discourse.Post = Discourse.Model.extend({ if (replyCount > 1) return true; // If we have *exactly* one reply, we have to consider if it's directly below us - var topic = this.get('topic'); + const topic = this.get('topic'); return !topic.isReplyDirectlyBelow(this); }.property('reply_count'), - expandHidden: function() { - var self = this; + expandHidden() { + const self = this; return Discourse.ajax("/posts/" + this.get('id') + "/cooked.json").then(function (result) { self.setProperties({ cooked: result.cooked, @@ -402,17 +302,17 @@ Discourse.Post = Discourse.Model.extend({ }); }, - rebake: function () { + rebake() { return Discourse.ajax("/posts/" + this.get("id") + "/rebake", { type: "PUT" }); }, - unhide: function () { + unhide() { return Discourse.ajax("/posts/" + this.get("id") + "/unhide", { type: "PUT" }); }, - toggleBookmark: function() { - var self = this, - bookmarkedTopic; + toggleBookmark() { + const self = this; + let bookmarkedTopic; this.toggleProperty("bookmarked"); @@ -432,43 +332,46 @@ Discourse.Post = Discourse.Model.extend({ if (bookmarkedTopic) {self.set("topic.bookmarked", false); } throw e; }); + }, + + updateActionsSummary(json) { + if (json && json.id === this.get('id')) { + json = Post.munge(json); + this.set('actions_summary', json.actions_summary); + } } + }); -Discourse.Post.reopenClass({ +Post.reopenClass({ - createActionSummary: function(result) { - if (result.actions_summary) { - var lookup = Em.Object.create(); + munge(json) { + if (json.actions_summary) { + const lookup = Em.Object.create(); // this area should be optimized, it is creating way too many objects per post - result.actions_summary = result.actions_summary.map(function(a) { - a.post = result; + json.actions_summary = json.actions_summary.map(function(a) { a.actionType = Discourse.Site.current().postActionTypeById(a.id); - var actionSummary = Discourse.ActionSummary.create(a); + const actionSummary = Discourse.ActionSummary.create(a); lookup[a.actionType.name_key] = actionSummary; return actionSummary; }); - result.set('actionByName', lookup); + json.actionByName = lookup; } + + if (json && json.reply_to_user) { + json.reply_to_user = Discourse.User.create(json.reply_to_user); + } + return json; }, - create: function(obj) { - var result = this._super.apply(this, arguments); - this.createActionSummary(result); - if (obj && obj.reply_to_user) { - result.set('reply_to_user', Discourse.User.create(obj.reply_to_user)); - } - return result; - }, - - updateBookmark: function(postId, bookmarked) { + updateBookmark(postId, bookmarked) { return Discourse.ajax("/posts/" + postId + "/bookmark", { type: 'PUT', data: { bookmarked: bookmarked } }); }, - deleteMany: function(selectedPosts, selectedReplies) { + deleteMany(selectedPosts, selectedReplies) { return Discourse.ajax("/posts/destroy_many", { type: 'DELETE', data: { @@ -478,37 +381,33 @@ Discourse.Post.reopenClass({ }); }, - loadRevision: function(postId, version) { + loadRevision(postId, version) { return Discourse.ajax("/posts/" + postId + "/revisions/" + version + ".json").then(function (result) { return Ember.Object.create(result); }); }, - hideRevision: function(postId, version) { + hideRevision(postId, version) { return Discourse.ajax("/posts/" + postId + "/revisions/" + version + "/hide", { type: 'PUT' }); }, - showRevision: function(postId, version) { + showRevision(postId, version) { return Discourse.ajax("/posts/" + postId + "/revisions/" + version + "/show", { type: 'PUT' }); }, - loadQuote: function(postId) { + loadQuote(postId) { return Discourse.ajax("/posts/" + postId + ".json").then(function (result) { - var post = Discourse.Post.create(result); + const post = Discourse.Post.create(result); return Discourse.Quote.build(post, post.get('raw')); }); }, - loadRawEmail: function(postId) { + loadRawEmail(postId) { return Discourse.ajax("/posts/" + postId + "/raw-email").then(function (result) { return result.raw_email; }); - }, - - load: function(postId) { - return Discourse.ajax("/posts/" + postId + ".json").then(function (result) { - return Discourse.Post.create(result); - }); } }); + +export default Post; diff --git a/app/assets/javascripts/discourse/models/rest.js.es6 b/app/assets/javascripts/discourse/models/rest.js.es6 index d85475644c..843e22eb1b 100644 --- a/app/assets/javascripts/discourse/models/rest.js.es6 +++ b/app/assets/javascripts/discourse/models/rest.js.es6 @@ -1,16 +1,69 @@ -export default Ember.Object.extend({ - update(attrs) { - const self = this, - type = this.get('__type'); - return this.store.update(type, this.get('id'), attrs).then(function(result) { - if (result && result[type]) { - Object.keys(result).forEach(function(k) { - attrs[k] = result[k]; - }); +import Presence from 'discourse/mixins/presence'; + +const RestModel = Ember.Object.extend(Presence, { + isNew: Ember.computed.equal('__state', 'new'), + isCreated: Ember.computed.equal('__state', 'created'), + isSaving: false, + + afterUpdate: Ember.K, + + update(props) { + if (this.get('isSaving')) { return Ember.RSVP.reject(); } + + props = props || this.updateProperties(); + + const type = this.get('__type'), + store = this.get('store'); + + const self = this; + self.set('isSaving', true); + return store.update(type, this.get('id'), props).then(function(res) { + + const payload = self.__munge(res.payload || res.responseJson); + + if (payload.success === "OK") { + Ember.warn("An update call should return the updated attributes"); + res = props; } - self.setProperties(attrs); - return result; - }); + + self.setProperties(payload); + self.afterUpdate(res); + return res; + }).finally(() => this.set('isSaving', false)); + }, + + _saveNew(props) { + if (this.get('isSaving')) { return Ember.RSVP.reject(); } + + props = props || this.createProperties(); + + const type = this.get('__type'), + store = this.get('store'), + adapter = store.adapterFor(type); + + const self = this; + self.set('isSaving', true); + return adapter.createRecord(store, type, props).then(function(res) { + if (!res) { throw "Received no data back from createRecord"; } + + // We can get a response back without properties, for example + // when a post is queued. + if (res.payload) { + self.setProperties(self.__munge(res.payload)); + self.set('__state', 'created'); + } + + res.target = self; + return res; + }).finally(() => this.set('isSaving', false)); + }, + + createProperties() { + throw "You must overwrite `createProperties()` before saving a record"; + }, + + save(props) { + return this.get('isNew') ? this._saveNew(props) : this.update(props); }, destroyRecord() { @@ -18,3 +71,25 @@ export default Ember.Object.extend({ return this.store.destroyRecord(type, this); } }); + +RestModel.reopenClass({ + + // Overwrite and JSON will be passed through here before `create` and `update` + munge(json) { + return json; + }, + + create(args) { + args = args || {}; + if (!args.store) { + const container = Discourse.__container__; + // Ember.warn('Use `store.createRecord` to create records instead of `.create()`'); + args.store = container.lookup('store:main'); + } + + args.__munge = this.munge; + return this._super(this.munge(args, args.store)); + } +}); + +export default RestModel; diff --git a/app/assets/javascripts/discourse/models/store.js.es6 b/app/assets/javascripts/discourse/models/store.js.es6 index 72f36db374..6708ea8def 100644 --- a/app/assets/javascripts/discourse/models/store.js.es6 +++ b/app/assets/javascripts/discourse/models/store.js.es6 @@ -1,7 +1,40 @@ import RestModel from 'discourse/models/rest'; import ResultSet from 'discourse/models/result-set'; -const _identityMap = {}; +let _identityMap; + +// You should only call this if you're a test scaffold +function flushMap() { + _identityMap = {}; +} + +function storeMap(type, id, obj) { + if (!id) { return; } + + _identityMap[type] = _identityMap[type] || {}; + _identityMap[type][id] = obj; +} + +function fromMap(type, id) { + const byType = _identityMap[type]; + if (byType) { return byType[id]; } +} + +function removeMap(type, id) { + const byType = _identityMap[type]; + if (byType) { delete byType[id]; } +} + +function findAndRemoveMap(type, id) { + const byType = _identityMap[type]; + if (byType) { + const result = byType[id]; + delete byType[id]; + return result; + } +} + +flushMap(); export default Ember.Object.extend({ pluralize(thing) { @@ -9,21 +42,27 @@ export default Ember.Object.extend({ }, findAll(type) { - const adapter = this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest'); const self = this; - return adapter.findAll(this, type).then(function(result) { + return this.adapterFor(type).findAll(this, type).then(function(result) { return self._resultSet(type, result); }); }, - find(type, findArgs) { - const adapter = this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest'); + // Mostly for legacy, things like TopicList without ResultSets + findFiltered(type, findArgs) { const self = this; - return adapter.find(this, type, findArgs).then(function(result) { + return this.adapterFor(type).find(this, type, findArgs).then(function(result) { + return self._build(type, result); + }); + }, + + find(type, findArgs) { + const self = this; + return this.adapterFor(type).find(this, type, findArgs).then(function(result) { if (typeof findArgs === "object") { return self._resultSet(type, result); } else { - return self._hydrate(type, result[Ember.String.underscore(type)]); + return self._hydrate(type, result[Ember.String.underscore(type)], result); } }); }, @@ -35,7 +74,7 @@ export default Ember.Object.extend({ const typeName = Ember.String.underscore(self.pluralize(type)), totalRows = result["total_rows_" + typeName] || result.get('totalRows'), loadMoreUrl = result["load_more_" + typeName], - content = result[typeName].map(obj => self._hydrate(type, obj)); + content = result[typeName].map(obj => self._hydrate(type, obj, result)); resultSet.setProperties({ totalRows, loadMoreUrl }); resultSet.get('content').pushObjects(content); @@ -48,58 +87,120 @@ export default Ember.Object.extend({ }, update(type, id, attrs) { - const adapter = this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest'); - return adapter.update(this, type, id, attrs, function(result) { + return this.adapterFor(type).update(this, type, id, attrs, function(result) { if (result && result[type] && result[type].id) { - const oldRecord = _identityMap[type][id]; - delete _identityMap[type][id]; - _identityMap[type][result[type].id] = oldRecord; + const oldRecord = findAndRemoveMap(type, id); + storeMap(type, result[type].id, oldRecord); } return result; }); }, createRecord(type, attrs) { - return this._hydrate(type, attrs); + attrs = attrs || {}; + return !!attrs.id ? this._hydrate(type, attrs) : this._build(type, attrs); }, destroyRecord(type, record) { - const adapter = this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest'); - return adapter.destroyRecord(this, type, record).then(function(result) { - const forType = _identityMap[type]; - if (forType) { delete forType[record.get('id')]; } + return this.adapterFor(type).destroyRecord(this, type, record).then(function(result) { + removeMap(type, record.get('id')); return result; }); }, _resultSet(type, result) { const typeName = Ember.String.underscore(this.pluralize(type)), - content = result[typeName].map(obj => this._hydrate(type, obj)), + content = result[typeName].map(obj => this._hydrate(type, obj, result)), totalRows = result["total_rows_" + typeName] || content.length, loadMoreUrl = result["load_more_" + typeName]; return ResultSet.create({ content, totalRows, loadMoreUrl, store: this, __type: type }); }, - _hydrate(type, obj) { - if (!obj) { throw "Can't hydrate " + type + " of `null`"; } - if (!obj.id) { throw "Can't hydrate " + type + " without an `id`"; } - - _identityMap[type] = _identityMap[type] || {}; - - const existing = _identityMap[type][obj.id]; - if (existing) { - delete obj.id; - existing.setProperties(obj); - return existing; - } - + _build(type, obj) { obj.store = this; obj.__type = type; + obj.__state = obj.id ? "created" : "new"; const klass = this.container.lookupFactory('model:' + type) || RestModel; const model = klass.create(obj); - _identityMap[type][obj.id] = model; + + storeMap(type, obj.id, model); return model; + }, + + adapterFor(type) { + return this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest'); + }, + + _lookupSubType(subType, id, root) { + + // cheat: we know we already have categories in memory + if (subType === 'category') { + return Discourse.Category.findById(id); + } + + const pluralType = this.pluralize(subType); + const collection = root[this.pluralize(subType)]; + if (collection) { + const hashedProp = "__hashed_" + pluralType; + let hashedCollection = root[hashedProp]; + if (!hashedCollection) { + hashedCollection = {}; + collection.forEach(function(it) { + hashedCollection[it.id] = it; + }); + root[hashedProp] = hashedCollection; + } + + const found = hashedCollection[id]; + if (found) { + const hydrated = this._hydrate(subType, found, root); + hashedCollection[id] = hydrated; + return hydrated; + } + } + }, + + _hydrateEmbedded(obj, root) { + const self = this; + Object.keys(obj).forEach(function(k) { + const m = /(.+)\_id$/.exec(k); + if (m) { + const subType = m[1]; + const hydrated = self._lookupSubType(subType, obj[k], root); + if (hydrated) { + obj[subType] = hydrated; + delete obj[k]; + } + } + }); + }, + + _hydrate(type, obj, root) { + if (!obj) { throw "Can't hydrate " + type + " of `null`"; } + if (!obj.id) { throw "Can't hydrate " + type + " without an `id`"; } + + root = root || obj; + + // Experimental: If serialized with a certain option we'll wire up embedded objects + // automatically. + if (root.__rest_serializer === "1") { + this._hydrateEmbedded(obj, root); + } + + const existing = fromMap(type, obj.id); + if (existing === obj) { return existing; } + + if (existing) { + delete obj.id; + const klass = this.container.lookupFactory('model:' + type) || RestModel; + existing.setProperties(klass.munge(obj)); + return existing; + } + + return this._build(type, obj); } }); + +export { flushMap }; diff --git a/app/assets/javascripts/discourse/models/topic-details.js.es6 b/app/assets/javascripts/discourse/models/topic-details.js.es6 index 6e6c1b05b2..9c8ca30e27 100644 --- a/app/assets/javascripts/discourse/models/topic-details.js.es6 +++ b/app/assets/javascripts/discourse/models/topic-details.js.es6 @@ -2,7 +2,9 @@ A model representing a Topic's details that aren't always present, such as a list of participants. When showing topics in lists and such this information should not be required. **/ -const TopicDetails = Discourse.Model.extend({ +import RestModel from 'discourse/models/rest'; + +const TopicDetails = RestModel.extend({ loaded: false, updateFromJson(details) { @@ -15,8 +17,9 @@ const TopicDetails = Discourse.Model.extend({ } if (details.suggested_topics) { + const store = this.store; details.suggested_topics = details.suggested_topics.map(function (st) { - return Discourse.Topic.create(st); + return store.createRecord('topic', st); }); } diff --git a/app/assets/javascripts/discourse/models/topic-list.js.es6 b/app/assets/javascripts/discourse/models/topic-list.js.es6 new file mode 100644 index 0000000000..9c6d580f00 --- /dev/null +++ b/app/assets/javascripts/discourse/models/topic-list.js.es6 @@ -0,0 +1,168 @@ +import RestModel from 'discourse/models/rest'; +import Model from 'discourse/models/model'; + + +function topicsFrom(result, store) { + if (!result) { return; } + + // Stitch together our side loaded data + const categories = Discourse.Category.list(), + users = Model.extractByKey(result.users, Discourse.User); + + return result.topic_list.topics.map(function (t) { + t.category = categories.findBy('id', t.category_id); + t.posters.forEach(function(p) { + p.user = users[p.user_id]; + }); + if (t.participants) { + t.participants.forEach(function(p) { + p.user = users[p.user_id]; + }); + } + return store.createRecord('topic', t); + }); +} + +const TopicList = RestModel.extend({ + canLoadMore: Em.computed.notEmpty("more_topics_url"), + + forEachNew: function(topics, callback) { + const topicIds = []; + _.each(this.get('topics'),function(topic) { + topicIds[topic.get('id')] = true; + }); + + _.each(topics,function(topic) { + if(!topicIds[topic.id]) { + callback(topic); + } + }); + }, + + refreshSort: function(order, ascending) { + const self = this, + params = this.get('params'); + + params.order = order || params.order; + + if (ascending === undefined) { + params.ascending = ascending; + } else { + params.ascending = ascending; + } + + this.set('loaded', false); + const store = this.store; + store.findFiltered('topicList', {filter: this.get('filter'), params}).then(function(tl) { + const newTopics = tl.get('topics'), + topics = self.get('topics'); + + topics.clear(); + topics.pushObjects(newTopics); + self.setProperties({ loaded: true, more_topics_url: newTopics.get('more_topics_url') }); + }); + }, + + loadMore: function() { + if (this.get('loadingMore')) { return Ember.RSVP.resolve(); } + + const moreUrl = this.get('more_topics_url'); + if (moreUrl) { + const self = this; + this.set('loadingMore', true); + + const store = this.store; + return Discourse.ajax({url: moreUrl}).then(function (result) { + let topicsAdded = 0; + + if (result) { + // the new topics loaded from the server + const newTopics = topicsFrom(result, store), + topics = self.get("topics"); + + self.forEachNew(newTopics, function(t) { + t.set('highlight', topicsAdded++ === 0); + topics.pushObject(t); + }); + + self.setProperties({ + loadingMore: false, + more_topics_url: result.topic_list.more_topics_url + }); + + Discourse.Session.currentProp('topicList', self); + return self.get('more_topics_url'); + } + }); + } else { + // Return a promise indicating no more results + return Ember.RSVP.resolve(); + } + }, + + + // loads topics with these ids "before" the current topics + loadBefore: function(topic_ids){ + const topicList = this, + topics = this.get('topics'); + + // refresh dupes + topics.removeObjects(topics.filter(function(topic){ + return topic_ids.indexOf(topic.get('id')) >= 0; + })); + + const url = Discourse.getURL("/") + this.get('filter') + "?topic_ids=" + topic_ids.join(","); + + const store = this.store; + return Discourse.ajax({ url }).then(function(result) { + let i = 0; + topicList.forEachNew(topicsFrom(result, store), function(t) { + // highlight the first of the new topics so we can get a visual feedback + t.set('highlight', true); + topics.insertAt(i,t); + i++; + }); + Discourse.Session.currentProp('topicList', topicList); + }); + } +}); + +TopicList.reopenClass({ + + munge(json, store) { + json.inserted = json.inserted || []; + json.can_create_topic = json.topic_list.can_create_topic; + json.more_topics_url = json.topic_list.more_topics_url; + json.draft_key = json.topic_list.draft_key; + json.draft_sequence = json.topic_list.draft_sequence; + json.draft = json.topic_list.draft; + json.for_period = json.topic_list.for_period; + json.loaded = true; + json.per_page = json.topic_list.per_page; + json.topics = topicsFrom(json, store); + + if (json.topic_list.filtered_category) { + json.category = Discourse.Category.create(json.topic_list.filtered_category); + } + return json; + }, + + find(filter, params) { + const store = Discourse.__container__.lookup('store:main'); + return store.findFiltered('topicList', {filter, params}); + }, + + list(filter) { + Ember.warn('`Discourse.TopicList.list` is deprecated. Use the store instead'); + return this.find(filter); + }, + + // Sets `hideCategory` if all topics in the last have a particular category + hideUniformCategory(list, category) { + const hideCategory = !list.get('topics').any(function (t) { return t.get('category') !== category; }); + list.set('hideCategory', hideCategory); + } + +}); + +export default TopicList; diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6 index a2e984ea30..abd3ef443f 100644 --- a/app/assets/javascripts/discourse/models/topic.js.es6 +++ b/app/assets/javascripts/discourse/models/topic.js.es6 @@ -1,7 +1,6 @@ -import TopicDetails from 'discourse/models/topic-details'; -import PostStream from 'discourse/models/post-stream'; +import RestModel from 'discourse/models/rest'; -const Topic = Discourse.Model.extend({ +const Topic = RestModel.extend({ // returns createdAt if there's no bumped date bumpedAt: function() { @@ -23,7 +22,7 @@ const Topic = Discourse.Model.extend({ }.property('created_at'), postStream: function() { - return PostStream.create({topic: this}); + return this.store.createRecord('postStream', {id: this.get('id'), topic: this}); }.property(), replyCount: function() { @@ -31,7 +30,7 @@ const Topic = Discourse.Model.extend({ }.property('posts_count'), details: function() { - return TopicDetails.create({topic: this}); + return this.store.createRecord('topicDetails', {id: this.get('id'), topic: this}); }.property(), invisible: Em.computed.not('visible'), @@ -41,18 +40,18 @@ const Topic = Discourse.Model.extend({ return ({ type: 'topic', id: this.get('id') }); }.property('id'), - category: function() { - const categoryId = this.get('category_id'); - if (categoryId) { - return Discourse.Category.list().findProperty('id', categoryId); - } + _categoryIdChanged: function() { + this.set('category', Discourse.Category.findById(this.get('category_id'))); + }.observes('category_id').on('init'), + _categoryNameChanged: function() { const categoryName = this.get('categoryName'); + let category; if (categoryName) { - return Discourse.Category.list().findProperty('name', categoryName); + category = Discourse.Category.list().findProperty('name', categoryName); } - return null; - }.property('category_id', 'categoryName'), + this.set('category', category); + }.observes('categoryName'), categoryClass: function() { return 'category-' + this.get('category.fullSlug'); @@ -408,7 +407,6 @@ Topic.reopenClass({ // The title can be cleaned up server side props.title = result.basic_topic.title; props.fancy_title = result.basic_topic.fancy_title; - topic.setProperties(props); }); }, diff --git a/app/assets/javascripts/discourse/models/topic_list.js b/app/assets/javascripts/discourse/models/topic_list.js deleted file mode 100644 index dce4524d0f..0000000000 --- a/app/assets/javascripts/discourse/models/topic_list.js +++ /dev/null @@ -1,272 +0,0 @@ -function finderFor(filter, params) { - return function() { - var url = Discourse.getURL("/") + filter + ".json"; - - if (params) { - var keys = Object.keys(params), - encoded = []; - - keys.forEach(function(p) { - var value = params[p]; - if (typeof value !== 'undefined') { - encoded.push(p + "=" + value); - } - }); - - if (encoded.length > 0) { - url += "?" + encoded.join('&'); - } - } - return Discourse.ajax(url); - }; -} - -Discourse.TopicList = Discourse.Model.extend({ - canLoadMore: Em.computed.notEmpty("more_topics_url"), - - forEachNew: function(topics, callback) { - var topicIds = []; - _.each(this.get('topics'),function(topic) { - topicIds[topic.get('id')] = true; - }); - - _.each(topics,function(topic) { - if(!topicIds[topic.id]) { - callback(topic); - } - }); - }, - - refreshSort: function(order, ascending) { - var self = this, - params = this.get('params'); - - params.order = order || params.order; - - if (ascending === undefined) { - params.ascending = ascending; - } else { - params.ascending = ascending; - } - - this.set('loaded', false); - var finder = finderFor(this.get('filter'), params); - finder().then(function (result) { - var newTopics = Discourse.TopicList.topicsFrom(result), - topics = self.get('topics'); - - topics.clear(); - topics.pushObjects(newTopics); - self.setProperties({ loaded: true, more_topics_url: result.topic_list.more_topics_url }); - }); - }, - - loadMore: function() { - if (this.get('loadingMore')) { return Ember.RSVP.resolve(); } - - var moreUrl = this.get('more_topics_url'); - if (moreUrl) { - var self = this; - this.set('loadingMore', true); - - return Discourse.ajax({url: moreUrl}).then(function (result) { - var topicsAdded = 0; - if (result) { - // the new topics loaded from the server - var newTopics = Discourse.TopicList.topicsFrom(result), - topics = self.get("topics"); - - self.forEachNew(newTopics, function(t) { - t.set('highlight', topicsAdded++ === 0); - topics.pushObject(t); - }); - - self.setProperties({ - loadingMore: false, - more_topics_url: result.topic_list.more_topics_url - }); - - Discourse.Session.currentProp('topicList', self); - return self.get('more_topics_url'); - } - }); - } else { - // Return a promise indicating no more results - return Ember.RSVP.resolve(); - } - }, - - - // loads topics with these ids "before" the current topics - loadBefore: function(topic_ids){ - var topicList = this, - topics = this.get('topics'); - - // refresh dupes - topics.removeObjects(topics.filter(function(topic){ - return topic_ids.indexOf(topic.get('id')) >= 0; - })); - - Discourse.TopicList.loadTopics(topic_ids, this.get('filter')) - .then(function(newTopics){ - var i = 0; - topicList.forEachNew(newTopics, function(t) { - // highlight the first of the new topics so we can get a visual feedback - t.set('highlight', true); - topics.insertAt(i,t); - i++; - }); - Discourse.Session.currentProp('topicList', topicList); - }); - } -}); - -Discourse.TopicList.reopenClass({ - - loadTopics: function(topic_ids, filter) { - return new Ember.RSVP.Promise(function(resolve, reject) { - var url = Discourse.getURL("/") + filter + "?topic_ids=" + topic_ids.join(","); - - Discourse.ajax({url: url}).then(function (result) { - if (result) { - // the new topics loaded from the server - var newTopics = Discourse.TopicList.topicsFrom(result); - resolve(newTopics); - } else { - reject(); - } - }).catch(reject); - }); - }, - - /** - Stitch together side loaded topic data - - @method topicsFrom - @param {Object} result JSON object with topic data - @returns {Array} the list of topics - **/ - topicsFrom: function(result) { - // Stitch together our side loaded data - var categories = Discourse.Category.list(), - users = this.extractByKey(result.users, Discourse.User); - - return result.topic_list.topics.map(function (t) { - t.category = categories.findBy('id', t.category_id); - t.posters.forEach(function(p) { - p.user = users[p.user_id]; - }); - if (t.participants) { - t.participants.forEach(function(p) { - p.user = users[p.user_id]; - }); - } - return Discourse.Topic.create(t); - }); - }, - - from: function(result, filter, params) { - var topicList = Discourse.TopicList.create({ - inserted: [], - filter: filter, - params: params || {}, - topics: Discourse.TopicList.topicsFrom(result), - can_create_topic: result.topic_list.can_create_topic, - more_topics_url: result.topic_list.more_topics_url, - draft_key: result.topic_list.draft_key, - draft_sequence: result.topic_list.draft_sequence, - draft: result.topic_list.draft, - for_period: result.topic_list.for_period, - loaded: true, - per_page: result.topic_list.per_page - }); - - if (result.topic_list.filtered_category) { - topicList.set('category', Discourse.Category.create(result.topic_list.filtered_category)); - } - - return topicList; - }, - - /** - Lists topics on a given menu item - - @method list - @param {Object} filter The menu item to filter to - @param {Object} params Any additional params to pass to TopicList.find() - @param {Object} extras Additional finding options, such as caching - @returns {Promise} a promise that resolves to the list of topics - **/ - list: function(filter, filterParams, extras) { - var tracking = Discourse.TopicTrackingState.current(); - - extras = extras || {}; - return new Ember.RSVP.Promise(function(resolve) { - var session = Discourse.Session.current(); - - if (extras.cached) { - var cachedList = session.get('topicList'); - - // Try to use the cached version if it exists and is greater than the topics per page - if (cachedList && (cachedList.get('filter') === filter) && - (cachedList.get('topics.length') || 0) > cachedList.get('per_page') && - _.isEqual(cachedList.get('listParams'), filterParams)) { - cachedList.set('loaded', true); - - if (tracking) { - tracking.updateTopics(cachedList.get('topics')); - } - return resolve(cachedList); - } - session.set('topicList', null); - } else { - // Clear the cache - session.setProperties({topicList: null, topicListScrollPosition: null}); - } - - - // Clean up any string parameters that might slip through - filterParams = filterParams || {}; - Ember.keys(filterParams).forEach(function(k) { - var val = filterParams[k]; - if (val === "undefined" || val === "null" || val === 'false') { - filterParams[k] = undefined; - } - }); - - var findParams = {}; - Discourse.SiteSettings.top_menu.split('|').forEach(function (i) { - if (i.indexOf(filter) === 0) { - var exclude = i.split("-"); - if (exclude && exclude.length === 2) { - findParams.exclude_category = exclude[1]; - } - } - }); - return resolve(Discourse.TopicList.find(filter, _.extend(findParams, filterParams || {}))); - - }).then(function(list) { - list.set('listParams', filterParams); - if (tracking) { - tracking.sync(list, list.filter); - tracking.trackIncoming(list.filter); - } - Discourse.Session.currentProp('topicList', list); - return list; - }); - }, - - find: function(filter, params) { - return PreloadStore.getAndRemove("topic_list_" + filter, finderFor(filter, params)).then(function(result) { - return Discourse.TopicList.from(result, filter, params); - }); - }, - - // Sets `hideCategory` if all topics in the last have a particular category - hideUniformCategory: function(list, category) { - var hideCategory = !list.get('topics').any(function (t) { return t.get('category') !== category; }); - list.set('hideCategory', hideCategory); - } - -}); - diff --git a/app/assets/javascripts/discourse/models/user.js.es6 b/app/assets/javascripts/discourse/models/user.js.es6 index b458c11888..4fc9d1a8f5 100644 --- a/app/assets/javascripts/discourse/models/user.js.es6 +++ b/app/assets/javascripts/discourse/models/user.js.es6 @@ -1,6 +1,7 @@ +import RestModel from 'discourse/models/rest'; import avatarTemplate from 'discourse/lib/avatar-template'; -const User = Discourse.Model.extend({ +const User = RestModel.extend({ hasPMs: Em.computed.gt("private_messages_stats.all", 0), hasStartedPMs: Em.computed.gt("private_messages_stats.mine", 0), diff --git a/app/assets/javascripts/discourse/models/user_action.js b/app/assets/javascripts/discourse/models/user_action.js index ba555e8483..2c5060258d 100644 --- a/app/assets/javascripts/discourse/models/user_action.js +++ b/app/assets/javascripts/discourse/models/user_action.js @@ -1,12 +1,3 @@ -/** - A data model representing actions users have taken - - @class UserAction - @extends Discourse.Model - @namespace Discourse - @module Discourse -**/ - var UserActionTypes = { likes_given: 1, likes_received: 2, @@ -18,7 +9,8 @@ var UserActionTypes = { quotes: 9, edits: 11, messages_sent: 12, - messages_received: 13 + messages_received: 13, + pending: 14 }, InvertedActionTypes = {}; diff --git a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 index b866d99a27..21d61cda21 100644 --- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 +++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 @@ -66,7 +66,7 @@ export default function() { this.route('flaggedPosts', { path: '/flagged-posts' }); this.route('deletedPosts', { path: '/deleted-posts' }); - this.resource('userPrivateMessages', { path: '/private-messages' }, function() { + this.resource('userPrivateMessages', { path: '/messages' }, function() { this.route('mine'); this.route('unread'); }); @@ -93,4 +93,6 @@ export default function() { this.resource('badges', function() { this.route('show', {path: '/:id/:slug'}); }); + + this.resource('queued-posts', { path: '/queued-posts' }); } diff --git a/app/assets/javascripts/discourse/routes/application.js.es6 b/app/assets/javascripts/discourse/routes/application.js.es6 index 45dbfbf77c..16bbdbebc5 100644 --- a/app/assets/javascripts/discourse/routes/application.js.es6 +++ b/app/assets/javascripts/discourse/routes/application.js.es6 @@ -37,6 +37,10 @@ const ApplicationRoute = Discourse.Route.extend({ this.controllerFor('topic-entrance').send('show', data); }, + postWasEnqueued() { + showModal('post-enqueued', {title: 'queue.approval.title' }); + }, + composePrivateMessage(user, post) { const self = this; this.transitionTo('userActivity', user).then(function () { @@ -76,12 +80,12 @@ const ApplicationRoute = Discourse.Route.extend({ showCreateAccount: unlessReadOnly('handleShowCreateAccount'), showForgotPassword() { - showModal('forgotPassword'); + showModal('forgotPassword', { title: 'forgot_password.title' }); }, showNotActivated(props) { - showModal('notActivated'); - this.controllerFor('notActivated').setProperties(props); + const controller = showModal('not-activated', {title: 'log_in' }); + controller.setProperties(props); }, showUploadSelector(composerView) { @@ -90,13 +94,13 @@ const ApplicationRoute = Discourse.Route.extend({ }, showKeyboardShortcutsHelp() { - showModal('keyboardShortcutsHelp'); + showModal('keyboard-shortcuts-help', { title: 'keyboard_shortcuts_help.title'}); }, showSearchHelp() { // TODO: @EvitTrout how do we get a loading indicator here? - Discourse.ajax("/static/search_help.html", { dataType: 'html' }).then(function(html){ - showModal('searchHelp', html); + Discourse.ajax("/static/search_help.html", { dataType: 'html' }).then(function(model){ + showModal('searchHelp', { model }); }); }, @@ -120,9 +124,9 @@ const ApplicationRoute = Discourse.Route.extend({ editCategory(category) { const self = this; - Discourse.Category.reloadById(category.get('id')).then(function (c) { - self.site.updateCategory(c); - showModal('editCategory', c); + Discourse.Category.reloadById(category.get('id')).then(function (model) { + self.site.updateCategory(model); + showModal('editCategory', { model }); self.controllerFor('editCategory').set('selectedTab', 'general'); }); }, @@ -140,7 +144,7 @@ const ApplicationRoute = Discourse.Route.extend({ const controllerName = w.replace('modal/', ''), factory = this.container.lookupFactory('controller:' + controllerName); - this.render(w, {into: 'topicBulkActions', outlet: 'bulkOutlet', controller: factory ? controllerName : 'topic-bulk-actions'}); + this.render(w, {into: 'modal/topic-bulk-actions', outlet: 'bulkOutlet', controller: factory ? controllerName : 'topic-bulk-actions'}); } }, diff --git a/app/assets/javascripts/discourse/routes/build-category-route.js.es6 b/app/assets/javascripts/discourse/routes/build-category-route.js.es6 index 8113104ea9..fe794b4482 100644 --- a/app/assets/javascripts/discourse/routes/build-category-route.js.es6 +++ b/app/assets/javascripts/discourse/routes/build-category-route.js.es6 @@ -1,4 +1,4 @@ -import { queryParams, filterQueryParams } from 'discourse/routes/build-topic-route'; +import { queryParams, filterQueryParams, findTopicList } from 'discourse/routes/build-topic-route'; // A helper function to create a category route with parameters export default function(filter, params) { @@ -52,7 +52,7 @@ export default function(filter, params) { var findOpts = filterQueryParams(transition.queryParams, params), extras = { cached: this.isPoppedState(transition) }; - return Discourse.TopicList.list(listFilter, findOpts, extras).then(function(list) { + return findTopicList(this.store, listFilter, findOpts, extras).then(function(list) { Discourse.TopicList.hideUniformCategory(list, model); self.set('topics', list); }); diff --git a/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 b/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 index 4b21e9b0bb..81b94b5a2d 100644 --- a/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 +++ b/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 @@ -11,6 +11,65 @@ function filterQueryParams(params, defaultParams) { return findOpts; } +function findTopicList(store, filter, filterParams, extras) { + const tracking = Discourse.TopicTrackingState.current(); + + extras = extras || {}; + return new Ember.RSVP.Promise(function(resolve) { + const session = Discourse.Session.current(); + + if (extras.cached) { + const cachedList = session.get('topicList'); + + // Try to use the cached version if it exists and is greater than the topics per page + if (cachedList && (cachedList.get('filter') === filter) && + (cachedList.get('topics.length') || 0) > cachedList.get('per_page') && + _.isEqual(cachedList.get('listParams'), filterParams)) { + cachedList.set('loaded', true); + + if (tracking) { + tracking.updateTopics(cachedList.get('topics')); + } + return resolve(cachedList); + } + session.set('topicList', null); + } else { + // Clear the cache + session.setProperties({topicList: null, topicListScrollPosition: null}); + } + + + // Clean up any string parameters that might slip through + filterParams = filterParams || {}; + Ember.keys(filterParams).forEach(function(k) { + const val = filterParams[k]; + if (val === "undefined" || val === "null" || val === 'false') { + filterParams[k] = undefined; + } + }); + + const findParams = {}; + Discourse.SiteSettings.top_menu.split('|').forEach(function (i) { + if (i.indexOf(filter) === 0) { + const exclude = i.split("-"); + if (exclude && exclude.length === 2) { + findParams.exclude_category = exclude[1]; + } + } + }); + return resolve(store.findFiltered('topicList', { filter, params:_.extend(findParams, filterParams || {})})); + + }).then(function(list) { + list.set('listParams', filterParams); + if (tracking) { + tracking.sync(list, list.filter); + tracking.trackIncoming(list.filter); + } + Discourse.Session.currentProp('topicList', list); + return list; + }); +} + export default function(filter, extras) { extras = extras || {}; return Discourse.Route.extend({ @@ -28,7 +87,7 @@ export default function(filter, extras) { const findOpts = filterQueryParams(transition.queryParams), extras = { cached: this.isPoppedState(transition) }; - return Discourse.TopicList.list(filter, findOpts, extras); + return findTopicList(this.store, filter, findOpts, extras); }, titleToken() { @@ -72,4 +131,4 @@ export default function(filter, extras) { }, extras); } -export { filterQueryParams }; +export { filterQueryParams, findTopicList }; diff --git a/app/assets/javascripts/discourse/routes/build-user-topic-list-route.js.es6 b/app/assets/javascripts/discourse/routes/build-user-topic-list-route.js.es6 index ae1ff7ba3b..c8c345e88b 100644 --- a/app/assets/javascripts/discourse/routes/build-user-topic-list-route.js.es6 +++ b/app/assets/javascripts/discourse/routes/build-user-topic-list-route.js.es6 @@ -14,7 +14,7 @@ export default function (viewName, path) { }, model: function() { - return Discourse.TopicList.find('topics/' + path + '/' + this.modelFor('user').get('username_lower')); + return this.store.findFiltered('topicList', {filter: 'topics/' + path + '/' + this.modelFor('user').get('username_lower')}); }, setupController: function() { diff --git a/app/assets/javascripts/discourse/routes/discourse.js.es6 b/app/assets/javascripts/discourse/routes/discourse.js.es6 index 9061124460..1f5bdab25f 100644 --- a/app/assets/javascripts/discourse/routes/discourse.js.es6 +++ b/app/assets/javascripts/discourse/routes/discourse.js.es6 @@ -1,5 +1,3 @@ -import showModal from 'discourse/lib/show-modal'; - const DiscourseRoute = Ember.Route.extend({ // Set to true to refresh a model without a transition if a query param @@ -210,11 +208,6 @@ DiscourseRoute.reopenClass({ this.route('unknown', {path: '*path'}); }); - }, - - showModal: function(route, name, model) { - Ember.warn('DEPRECATED `Discourse.Route.showModal` - use `showModal` instead'); - showModal(name, model); } }); diff --git a/app/assets/javascripts/discourse/routes/discovery-categories-route.js.es6 b/app/assets/javascripts/discourse/routes/discovery-categories-route.js.es6 index 30d36bb332..636a646db2 100644 --- a/app/assets/javascripts/discourse/routes/discovery-categories-route.js.es6 +++ b/app/assets/javascripts/discourse/routes/discovery-categories-route.js.es6 @@ -46,11 +46,13 @@ Discourse.DiscoveryCategoriesRoute = Discourse.Route.extend(Discourse.OpenCompos const groups = this.site.groups, everyoneName = groups.findBy('id', 0).name; - showModal('editCategory', Discourse.Category.create({ + const model = Discourse.Category.create({ color: 'AB9364', text_color: 'FFFFFF', group_permissions: [{group_name: everyoneName, permission_type: 1}], available_groups: groups.map(g => g.name), allow_badges: true - })); + }); + + showModal('editCategory', { model }); this.controllerFor('editCategory').set('selectedTab', 'general'); }, diff --git a/app/assets/javascripts/discourse/routes/queued-posts.js.es6 b/app/assets/javascripts/discourse/routes/queued-posts.js.es6 new file mode 100644 index 0000000000..700b9871a5 --- /dev/null +++ b/app/assets/javascripts/discourse/routes/queued-posts.js.es6 @@ -0,0 +1,7 @@ +import DiscourseRoute from 'discourse/routes/discourse'; + +export default DiscourseRoute.extend({ + model() { + return this.store.find('queuedPost', {status: 'new'}); + } +}); diff --git a/app/assets/javascripts/discourse/routes/topic-from-params.js.es6 b/app/assets/javascripts/discourse/routes/topic-from-params.js.es6 index 9c414daac0..b8f43a433d 100644 --- a/app/assets/javascripts/discourse/routes/topic-from-params.js.es6 +++ b/app/assets/javascripts/discourse/routes/topic-from-params.js.es6 @@ -1,6 +1,9 @@ // This route is used for retrieving a topic based on params export default Discourse.Route.extend({ + // Avoid default model hook + model: function(p) { return p; }, + setupController: function(controller, params) { params = params || {}; params.track_visit = true; @@ -15,6 +18,7 @@ export default Discourse.Route.extend({ if (params.nearPost === "last") { params.nearPost = 999999999; } var self = this; + params.forceLoad = true; postStream.refresh(params).then(function () { // TODO we are seeing errors where closest post is null and this is exploding diff --git a/app/assets/javascripts/discourse/routes/topic.js.es6 b/app/assets/javascripts/discourse/routes/topic.js.es6 index 725c895ead..381692afea 100644 --- a/app/assets/javascripts/discourse/routes/topic.js.es6 +++ b/app/assets/javascripts/discourse/routes/topic.js.es6 @@ -5,7 +5,6 @@ let isTransitioning = false, const SCROLL_DELAY = 500; import ShowFooter from "discourse/mixins/show-footer"; -import Topic from 'discourse/models/topic'; import showModal from 'discourse/lib/show-modal'; const TopicRoute = Discourse.Route.extend(ShowFooter, { @@ -44,52 +43,52 @@ const TopicRoute = Discourse.Route.extend(ShowFooter, { this.controllerFor("topic-admin-menu").send("show"); }, - showFlags(post) { - showModal('flag', post); + showFlags(model) { + showModal('flag', { model }); this.controllerFor('flag').setProperties({ selected: null }); }, - showFlagTopic(topic) { - showModal('flag', topic); + showFlagTopic(model) { + showModal('flag', { model }); this.controllerFor('flag').setProperties({ selected: null, flagTopic: true }); }, showAutoClose() { - showModal('editTopicAutoClose', this.modelFor('topic')); + showModal('edit-topic-auto-close', { model: this.modelFor('topic'), title: 'topic.auto_close_title' }); this.controllerFor('modal').set('modalClass', 'edit-auto-close-modal'); }, showFeatureTopic() { - showModal('featureTopic', this.modelFor('topic')); + showModal('featureTopic', { model: this.modelFor('topic'), title: 'topic.feature_topic.title' }); this.controllerFor('modal').set('modalClass', 'feature-topic-modal'); }, showInvite() { - showModal('invite', this.modelFor('topic')); + showModal('invite', { model: this.modelFor('topic') }); this.controllerFor('invite').reset(); }, - showHistory(post) { - showModal('history', post); - this.controllerFor('history').refresh(post.get("id"), "latest"); + showHistory(model) { + showModal('history', { model }); + this.controllerFor('history').refresh(model.get("id"), "latest"); this.controllerFor('modal').set('modalClass', 'history-modal'); }, - showRawEmail(post) { - showModal('raw-email', post); - this.controllerFor('raw_email').loadRawEmail(post.get("id")); + showRawEmail(model) { + showModal('raw-email', { model }); + this.controllerFor('raw_email').loadRawEmail(model.get("id")); }, mergeTopic() { - showModal('mergeTopic', this.modelFor('topic')); + showModal('merge-topic', { model: this.modelFor('topic'), title: 'topic.merge_topic.title' }); }, splitTopic() { - showModal('split-topic', this.modelFor('topic')); + showModal('split-topic', { model: this.modelFor('topic') }); }, changeOwner() { - showModal('changeOwner', this.modelFor('topic')); + showModal('change-owner', { model: this.modelFor('topic'), title: 'topic.change_owner.title' }); }, // Use replaceState to update the URL once it changes @@ -153,7 +152,7 @@ const TopicRoute = Discourse.Route.extend(ShowFooter, { model(params, transition) { const queryParams = transition.queryParams; - const topic = this.modelFor('topic'); + let topic = this.modelFor('topic'); if (topic && (topic.get('id') === parseInt(params.id, 10))) { this.setupParams(topic, queryParams); // If we have the existing model, refresh it @@ -161,7 +160,8 @@ const TopicRoute = Discourse.Route.extend(ShowFooter, { return topic; }); } else { - return this.setupParams(Topic.create(_.omit(params, 'username_filters', 'filter')), queryParams); + topic = this.store.createRecord('topic', _.omit(params, 'username_filters', 'filter')); + return this.setupParams(topic, queryParams); } }, diff --git a/app/assets/javascripts/discourse/routes/user-activity-pending.js.es6 b/app/assets/javascripts/discourse/routes/user-activity-pending.js.es6 new file mode 100644 index 0000000000..5d7e9e47b7 --- /dev/null +++ b/app/assets/javascripts/discourse/routes/user-activity-pending.js.es6 @@ -0,0 +1,5 @@ +import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; + +export default UserActivityStreamRoute.extend({ + userActionType: Discourse.UserAction.TYPES.pending +}); diff --git a/app/assets/javascripts/discourse/routes/user-activity-topics.js.es6 b/app/assets/javascripts/discourse/routes/user-activity-topics.js.es6 index ba5e6aa748..65210df7a7 100644 --- a/app/assets/javascripts/discourse/routes/user-activity-topics.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-activity-topics.js.es6 @@ -4,6 +4,6 @@ export default UserTopicListRoute.extend({ userActionType: Discourse.UserAction.TYPES.topics, model: function() { - return Discourse.TopicList.find('topics/created-by/' + this.modelFor('user').get('username_lower')); + return this.store.findFiltered('topicList', {filter: 'topics/created-by/' + this.modelFor('user').get('username_lower') }); } }); diff --git a/app/assets/javascripts/discourse/routes/user-invited.js.es6 b/app/assets/javascripts/discourse/routes/user-invited.js.es6 index b0e03559e6..a36f49af45 100644 --- a/app/assets/javascripts/discourse/routes/user-invited.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-invited.js.es6 @@ -21,7 +21,7 @@ export default Discourse.Route.extend(ShowFooter, { actions: { showInvite() { - showModal('invite', Discourse.User.current()); + showModal('invite', { model: this.currentUser }); this.controllerFor('invite').reset(); }, diff --git a/app/assets/javascripts/discourse/templates/components/topic-map.hbs b/app/assets/javascripts/discourse/templates/components/topic-map.hbs index bf572caa3e..11a5a14b6c 100644 --- a/app/assets/javascripts/discourse/templates/components/topic-map.hbs +++ b/app/assets/javascripts/discourse/templates/components/topic-map.hbs @@ -15,33 +15,33 @@
  • -

    {{i18n 'last_post_lowercase'}}

    +

    {{i18n 'last_reply_lowercase'}}

    {{avatar details.last_poster imageSize="tiny"}} {{format-date topic.last_posted_at}}
  • - {{number topic.posts_count}} -

    {{i18n 'posts_lowercase'}}

    + {{number topic.replyCount}} +

    {{i18n 'replies_lowercase' count=topic.replyCount}}

  • {{number topic.views class=topic.viewsHeat}} -

    {{i18n 'views_lowercase'}}

    +

    {{i18n 'views_lowercase' count=topic.views}}

  • {{number topic.participant_count}} -

    {{i18n 'users_lowercase'}}

    +

    {{i18n 'users_lowercase' count=topic.participant_count}}

  • {{#if topic.like_count}}
  • {{number topic.like_count}} -

    {{i18n 'likes_lowercase'}}

    +

    {{i18n 'likes_lowercase' count=topic.like_count}}

  • {{/if}} {{#if details.links.length}}
  • {{number details.links.length}} -

    {{i18n 'links_lowercase'}}

    +

    {{i18n 'links_lowercase' count=details.links.length}}

  • {{/if}} {{#if showPosterAvatar}} diff --git a/app/assets/javascripts/discourse/templates/discovery.hbs b/app/assets/javascripts/discourse/templates/discovery.hbs index b370b38ee4..1bf0814aaa 100644 --- a/app/assets/javascripts/discourse/templates/discovery.hbs +++ b/app/assets/javascripts/discourse/templates/discovery.hbs @@ -23,6 +23,7 @@
    + {{plugin-outlet "discovery-list-container-top"}} {{outlet "list-container"}}
    diff --git a/app/assets/javascripts/discourse/templates/modal/change_owner.hbs b/app/assets/javascripts/discourse/templates/modal/change-owner.hbs similarity index 100% rename from app/assets/javascripts/discourse/templates/modal/change_owner.hbs rename to app/assets/javascripts/discourse/templates/modal/change-owner.hbs diff --git a/app/assets/javascripts/discourse/templates/modal/auto_close.hbs b/app/assets/javascripts/discourse/templates/modal/edit-topic-auto-close.hbs similarity index 100% rename from app/assets/javascripts/discourse/templates/modal/auto_close.hbs rename to app/assets/javascripts/discourse/templates/modal/edit-topic-auto-close.hbs diff --git a/app/assets/javascripts/discourse/templates/modal/forgot_password.hbs b/app/assets/javascripts/discourse/templates/modal/forgot-password.hbs similarity index 100% rename from app/assets/javascripts/discourse/templates/modal/forgot_password.hbs rename to app/assets/javascripts/discourse/templates/modal/forgot-password.hbs diff --git a/app/assets/javascripts/discourse/templates/modal/invite.hbs b/app/assets/javascripts/discourse/templates/modal/invite.hbs index e5e17e32e2..5984aa86ab 100644 --- a/app/assets/javascripts/discourse/templates/modal/invite.hbs +++ b/app/assets/javascripts/discourse/templates/modal/invite.hbs @@ -10,7 +10,11 @@ {{else}} {{#if allowExistingMembers}} - {{user-selector single="true" allowAny=true excludeCurrentUser="true" usernames=emailOrUsername includeGroups="true" placeholderKey=placeholderKey}} + {{#if isPrivateTopic}} + {{user-selector single="true" allowAny=true excludeCurrentUser="true" usernames=emailOrUsername allowedUsers="true" topicId=topicId placeholderKey=placeholderKey}} + {{else}} + {{user-selector single="true" allowAny=true excludeCurrentUser="true" usernames=emailOrUsername placeholderKey=placeholderKey}} + {{/if}} {{else}} {{text-field value=emailOrUsername placeholderKey="topic.invite_reply.email_placeholder"}} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/modal/keyboard_shortcuts_help.hbs b/app/assets/javascripts/discourse/templates/modal/keyboard-shortcuts-help.hbs similarity index 100% rename from app/assets/javascripts/discourse/templates/modal/keyboard_shortcuts_help.hbs rename to app/assets/javascripts/discourse/templates/modal/keyboard-shortcuts-help.hbs diff --git a/app/assets/javascripts/discourse/templates/modal/merge_topic.hbs b/app/assets/javascripts/discourse/templates/modal/merge-topic.hbs similarity index 100% rename from app/assets/javascripts/discourse/templates/modal/merge_topic.hbs rename to app/assets/javascripts/discourse/templates/modal/merge-topic.hbs diff --git a/app/assets/javascripts/discourse/templates/modal/not_activated.hbs b/app/assets/javascripts/discourse/templates/modal/not-activated.hbs similarity index 100% rename from app/assets/javascripts/discourse/templates/modal/not_activated.hbs rename to app/assets/javascripts/discourse/templates/modal/not-activated.hbs diff --git a/app/assets/javascripts/discourse/templates/modal/post-enqueued.hbs b/app/assets/javascripts/discourse/templates/modal/post-enqueued.hbs new file mode 100644 index 0000000000..050d02dcf1 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/post-enqueued.hbs @@ -0,0 +1,6 @@ + + diff --git a/app/assets/javascripts/discourse/templates/modal/topic_bulk_actions.hbs b/app/assets/javascripts/discourse/templates/modal/topic-bulk-actions.hbs similarity index 100% rename from app/assets/javascripts/discourse/templates/modal/topic_bulk_actions.hbs rename to app/assets/javascripts/discourse/templates/modal/topic-bulk-actions.hbs diff --git a/app/assets/javascripts/discourse/templates/notifications.hbs b/app/assets/javascripts/discourse/templates/notifications.hbs index 75df3d8e30..916612b9b8 100644 --- a/app/assets/javascripts/discourse/templates/notifications.hbs +++ b/app/assets/javascripts/discourse/templates/notifications.hbs @@ -6,7 +6,7 @@ {{notification-item notification=n scope=n.scope}} {{/each}}
  • - {{i18n 'notifications.more'}}… + {{i18n 'notifications.more'}}…
  • {{else}} diff --git a/app/assets/javascripts/discourse/templates/popup_input_tip.hbs b/app/assets/javascripts/discourse/templates/popup_input_tip.hbs index 6a65bdeacf..73e4589d05 100644 --- a/app/assets/javascripts/discourse/templates/popup_input_tip.hbs +++ b/app/assets/javascripts/discourse/templates/popup_input_tip.hbs @@ -1,2 +1,2 @@ -{{view.validation.reason}} +{{{view.validation.reason}}} diff --git a/app/assets/javascripts/discourse/templates/queued-posts.hbs b/app/assets/javascripts/discourse/templates/queued-posts.hbs new file mode 100644 index 0000000000..9a72cd50f4 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/queued-posts.hbs @@ -0,0 +1,79 @@ +
    +
    + {{#each ctrl in model itemController='queued-post'}} +
    +
    + {{#user-link user=ctrl.post.user}} + {{avatar ctrl.post.user imageSize="large"}} + {{/user-link}} + +
    +
    +
    + + {{#user-link user=ctrl.post.user}} + {{ctrl.post.user.username}} + {{/user-link}} + +
    + +
    + + + {{i18n "queue.topic"}} + {{#if ctrl.post.topic}} + {{topic-link ctrl.post.topic}} + {{else}} + {{ctrl.post.post_options.title}} + {{/if}} + {{category-badge ctrl.post.category}} + + +
    + {{#if ctrl.editing}} + {{pagedown-editor value=ctrl.buffered.raw}} + {{else}} + {{{cook-text ctrl.post.raw}}} + {{/if}} +
    + +
    + {{#if ctrl.editing}} + {{d-button action="confirmEdit" + label="queue.confirm" + disabled=ctrl.post.isSaving + class="btn-primary confirm"}} + {{d-button action="cancelEdit" + label="queue.cancel" + icon="times" + disabled=ctrl.post.isSaving + class="btn-danger cancel"}} + + {{else}} + {{d-button action="approve" + disabled=ctrl.post.isSaving + label="queue.approve" + icon="check" + class="btn-primary approve"}} + {{d-button action="reject" + disabled=ctrl.post.isSaving + label="queue.reject" + icon="times" + class="btn-danger reject"}} + {{d-button action="edit" + disabled=ctrl.post.isSaving + label="queue.edit" + icon="pencil" + class="edit"}} + {{/if}} +
    +
    +
    +
    + {{else}} +

    {{i18n "queue.none"}}

    + {{/each}} +
    +
    diff --git a/app/assets/javascripts/discourse/templates/site-map.hbs b/app/assets/javascripts/discourse/templates/site-map.hbs index c781c3a91d..c4e4b45779 100644 --- a/app/assets/javascripts/discourse/templates/site-map.hbs +++ b/app/assets/javascripts/discourse/templates/site-map.hbs @@ -2,19 +2,23 @@
    +{{plugin-outlet "topic-above-post-stream"}} + {{#if postStream.loaded}} {{#if postStream.firstPostPresent}}
    @@ -22,8 +24,8 @@ {{plugin-outlet "edit-topic"}} - {{d-button action="finishedEditingTopic" class="btn-primary btn-small no-text" icon="check"}} - {{d-button action="cancelEditingTopic" class="btn-small no-text" icon="times"}} + {{d-button action="finishedEditingTopic" class="btn-primary btn-small no-text submit-edit" icon="check"}} + {{d-button action="cancelEditingTopic" class="btn-small no-text cancel-edit" icon="times"}} {{else}}

    {{#unless is_warning}} @@ -32,7 +34,7 @@ {{#if details.loaded}} {{topic-status topic=model}} - + {{{fancy_title}}} {{/if}} @@ -87,6 +89,8 @@ {{view 'topic-closing' topic=model}} {{view 'topic-footer-buttons' topic=model}} + {{plugin-outlet "topic-above-suggested"}} + {{#if details.suggested_topics.length}}

    {{i18n 'suggested_topics.title'}}

    diff --git a/app/assets/javascripts/discourse/templates/user/preferences.hbs b/app/assets/javascripts/discourse/templates/user/preferences.hbs index d8e5ff1e2b..0133f6537b 100644 --- a/app/assets/javascripts/discourse/templates/user/preferences.hbs +++ b/app/assets/javascripts/discourse/templates/user/preferences.hbs @@ -197,16 +197,17 @@
    -
    - - {{combo-box valueAttribute="value" content=autoTrackDurations value=auto_track_topics_after_msecs}} -
    {{combo-box valueAttribute="value" content=considerNewTopicOptions value=new_topic_duration_minutes}}
    +
    + + {{combo-box valueAttribute="value" content=autoTrackDurations value=auto_track_topics_after_msecs}} +
    + {{preference-checkbox labelKey="user.external_links_in_new_tab" checked=external_links_in_new_tab}} {{preference-checkbox labelKey="user.enable_quoting" checked=enable_quoting}} {{preference-checkbox labelKey="user.dynamic_favicon" checked=dynamic_favicon}} diff --git a/app/assets/javascripts/discourse/templates/user/user.hbs b/app/assets/javascripts/discourse/templates/user/user.hbs index 8fdb0ceeab..2aaa4a90cc 100644 --- a/app/assets/javascripts/discourse/templates/user/user.hbs +++ b/app/assets/javascripts/discourse/templates/user/user.hbs @@ -168,7 +168,7 @@ {{#if canSeeNotificationHistory}} {{#link-to 'user.notifications' tagName="li"}} {{#link-to 'user.notifications'}} - + {{fa-icon "comment" class="glyph"}} {{i18n 'user.notifications'}} ({{notification_count}}) {{/link-to}} diff --git a/app/assets/javascripts/discourse/views/archetype-options-modal.js.es6 b/app/assets/javascripts/discourse/views/archetype-options-modal.js.es6 deleted file mode 100644 index d6ebb0c277..0000000000 --- a/app/assets/javascripts/discourse/views/archetype-options-modal.js.es6 +++ /dev/null @@ -1,6 +0,0 @@ -import ModalBodyView from "discourse/views/modal-body"; - -export default ModalBodyView.extend({ - templateName: 'modal/archetype_options', - title: I18n.t('topic.options') -}); diff --git a/app/assets/javascripts/discourse/views/change-owner.js.es6 b/app/assets/javascripts/discourse/views/change-owner.js.es6 deleted file mode 100644 index 604dfbe562..0000000000 --- a/app/assets/javascripts/discourse/views/change-owner.js.es6 +++ /dev/null @@ -1,6 +0,0 @@ -import ModalBodyView from "discourse/views/modal-body"; - -export default ModalBodyView.extend({ - templateName: 'modal/change_owner', - title: I18n.t('topic.change_owner.title') -}); diff --git a/app/assets/javascripts/discourse/views/composer-messages.js.es6 b/app/assets/javascripts/discourse/views/composer-messages.js.es6 index beac06757d..39f911ec78 100644 --- a/app/assets/javascripts/discourse/views/composer-messages.js.es6 +++ b/app/assets/javascripts/discourse/views/composer-messages.js.es6 @@ -1,11 +1,3 @@ -/** - Renders a popup messages on the composer - - @class ComposerMessagesView - @extends Discourse.View - @namespace Discourse - @module Discourse -**/ export default Ember.CollectionView.extend({ classNameBindings: [':composer-popup-container', 'hidden'], content: Em.computed.alias('controller.content'), @@ -16,19 +8,18 @@ export default Ember.CollectionView.extend({ classNames: ['composer-popup', 'hidden'], templateName: Em.computed.alias('content.templateName'), - init: function() { + _setup: function() { this._super(); this.set('context', this.get('content')); if (this.get('content.extraClass')) { this.get('classNames').pushObject(this.get('content.extraClass')); } - }, + }.on('init'), - didInsertElement: function() { + _initCss: function() { var composerHeight = $('#reply-control').height() || 0; this.$().css('bottom', composerHeight + "px").show(); - } + }.on('didInsertElement') }) }); - diff --git a/app/assets/javascripts/discourse/views/composer.js.es6 b/app/assets/javascripts/discourse/views/composer.js.es6 index e67e5c0102..95138ebc95 100644 --- a/app/assets/javascripts/discourse/views/composer.js.es6 +++ b/app/assets/javascripts/discourse/views/composer.js.es6 @@ -573,6 +573,10 @@ const ComposerView = Discourse.View.extend(Ember.Evented, { reason = I18n.t('composer.error.post_missing'); } else if( missingChars > 0 ) { reason = I18n.t('composer.error.post_length', {min: this.get('model.minimumPostLength')}); + let tl = Discourse.User.currentProp("trust_level"); + if (tl === 0 || tl === 1) { + reason += "
    " + I18n.t('composer.error.try_like'); + } } if( reason ) { diff --git a/app/assets/javascripts/discourse/views/container.js.es6 b/app/assets/javascripts/discourse/views/container.js.es6 index f8cbc3bd60..68c6533532 100644 --- a/app/assets/javascripts/discourse/views/container.js.es6 +++ b/app/assets/javascripts/discourse/views/container.js.es6 @@ -1,4 +1,6 @@ -export default Ember.ContainerView.extend(Discourse.Presence, { +import Presence from 'discourse/mixins/presence'; + +export default Ember.ContainerView.extend(Presence, { attachViewWithArgs(viewArgs, viewClass) { if (!viewClass) { viewClass = Ember.View.extend(); } diff --git a/app/assets/javascripts/discourse/views/edit-topic-auto-close.js.es6 b/app/assets/javascripts/discourse/views/edit-topic-auto-close.js.es6 deleted file mode 100644 index b5b5c33287..0000000000 --- a/app/assets/javascripts/discourse/views/edit-topic-auto-close.js.es6 +++ /dev/null @@ -1,6 +0,0 @@ -import ModalBodyView from "discourse/views/modal-body"; - -export default ModalBodyView.extend({ - templateName: 'modal/auto_close', - title: I18n.t('topic.auto_close_title') -}); diff --git a/app/assets/javascripts/discourse/views/feature-topic.js.es6 b/app/assets/javascripts/discourse/views/feature-topic.js.es6 deleted file mode 100644 index 2a106541e0..0000000000 --- a/app/assets/javascripts/discourse/views/feature-topic.js.es6 +++ /dev/null @@ -1,6 +0,0 @@ -import ModalBodyView from "discourse/views/modal-body"; - -export default ModalBodyView.extend({ - templateName: 'modal/feature-topic', - title: I18n.t('topic.feature_topic.title') -}); diff --git a/app/assets/javascripts/discourse/views/forgot-password.js.es6 b/app/assets/javascripts/discourse/views/forgot-password.js.es6 deleted file mode 100644 index 40f4094b0f..0000000000 --- a/app/assets/javascripts/discourse/views/forgot-password.js.es6 +++ /dev/null @@ -1,6 +0,0 @@ -import ModalBodyView from "discourse/views/modal-body"; - -export default ModalBodyView.extend({ - templateName: 'modal/forgot_password', - title: I18n.t('forgot_password.title'), -}); diff --git a/app/assets/javascripts/discourse/views/grouped.js.es6 b/app/assets/javascripts/discourse/views/grouped.js.es6 new file mode 100644 index 0000000000..ed06f49b50 --- /dev/null +++ b/app/assets/javascripts/discourse/views/grouped.js.es6 @@ -0,0 +1,12 @@ +import Presence from 'discourse/mixins/presence'; + +export default Ember.View.extend(Presence, { + _groupInit: function() { + this.set('context', this.get('content')); + + const templateData = this.get('templateData'); + if (templateData) { + this.set('templateData.insideGroup', true); + } + }.on('init') +}); diff --git a/app/assets/javascripts/discourse/views/keyboard-shortcuts-help.js.es6 b/app/assets/javascripts/discourse/views/keyboard-shortcuts-help.js.es6 deleted file mode 100644 index eeb174f620..0000000000 --- a/app/assets/javascripts/discourse/views/keyboard-shortcuts-help.js.es6 +++ /dev/null @@ -1,6 +0,0 @@ -import ModalBodyView from "discourse/views/modal-body"; - -export default ModalBodyView.extend({ - templateName: 'modal/keyboard_shortcuts_help', - title: I18n.t('keyboard_shortcuts_help.title') -}); diff --git a/app/assets/javascripts/discourse/views/merge-topic.js.es6 b/app/assets/javascripts/discourse/views/merge-topic.js.es6 deleted file mode 100644 index bf31e00606..0000000000 --- a/app/assets/javascripts/discourse/views/merge-topic.js.es6 +++ /dev/null @@ -1,6 +0,0 @@ -import ModalBodyView from "discourse/views/modal-body"; - -export default ModalBodyView.extend({ - templateName: 'modal/merge_topic', - title: I18n.t('topic.merge_topic.title') -}); diff --git a/app/assets/javascripts/discourse/views/not-activated.js.es6 b/app/assets/javascripts/discourse/views/not-activated.js.es6 deleted file mode 100644 index 86e942fc3d..0000000000 --- a/app/assets/javascripts/discourse/views/not-activated.js.es6 +++ /dev/null @@ -1,6 +0,0 @@ -import ModalBodyView from "discourse/views/modal-body"; - -export default ModalBodyView.extend({ - templateName: 'modal/not_activated', - title: I18n.t('log_in') -}); diff --git a/app/assets/javascripts/discourse/views/topic-bulk-actions.js.es6 b/app/assets/javascripts/discourse/views/topic-bulk-actions.js.es6 deleted file mode 100644 index 8b8b693fb8..0000000000 --- a/app/assets/javascripts/discourse/views/topic-bulk-actions.js.es6 +++ /dev/null @@ -1,6 +0,0 @@ -import ModalBodyView from "discourse/views/modal-body"; - -export default ModalBodyView.extend({ - templateName: 'modal/topic-bulk-actions', - title: I18n.t('topics.bulk.actions') -}); diff --git a/app/assets/javascripts/discourse/views/view.js b/app/assets/javascripts/discourse/views/view.js deleted file mode 100644 index c62af2582e..0000000000 --- a/app/assets/javascripts/discourse/views/view.js +++ /dev/null @@ -1,52 +0,0 @@ -/** - A base view that gives us common functionality, for example `present` and `blank` - - @class View - @extends Ember.View - @uses Discourse.Presence - @namespace Discourse - @module Discourse -**/ -Discourse.View = Ember.View.extend(Discourse.Presence, {}); - -Discourse.GroupedView = Ember.View.extend(Discourse.Presence, { - init: function() { - this._super(); - this.set('context', this.get('content')); - - var templateData = this.get('templateData'); - if (templateData) { - this.set('templateData.insideGroup', true); - } - } -}); - -Discourse.View.reopenClass({ - - /** - Register a view helper for ease of use - - @method registerHelper - @param {String} helperName the name of the helper - @param {Ember.View} helperClass the view that will be inserted by the helper - **/ - registerHelper: function(helperName, helperClass) { - Ember.Handlebars.registerHelper(helperName, function(options) { - var hash = options.hash, - types = options.hashTypes; - - Discourse.Utilities.normalizeHash(hash, types); - return Ember.Handlebars.helpers.view.call(this, helperClass, options); - }); - }, - - renderIfChanged: function() { - Em.warn("`rerenderIfChanged` is deprecated. Use the `StringBuffer` mixin with `rerenderTriggers` instead."); - var args = Array.prototype.slice.call(arguments, 0); - args.unshift(function () { - Ember.run.once(this, 'rerender'); - }); - return Ember.observer.apply(this, args); - } - -}); diff --git a/app/assets/javascripts/discourse/views/view.js.es6 b/app/assets/javascripts/discourse/views/view.js.es6 new file mode 100644 index 0000000000..f939764ebb --- /dev/null +++ b/app/assets/javascripts/discourse/views/view.js.es6 @@ -0,0 +1,17 @@ +import Presence from 'discourse/mixins/presence'; + +const View = Ember.View.extend(Presence, {}); + +View.reopenClass({ + registerHelper(helperName, helperClass) { + Ember.Handlebars.registerHelper(helperName, function(options) { + var hash = options.hash, + types = options.hashTypes; + + Discourse.Utilities.normalizeHash(hash, types); + return Ember.Handlebars.helpers.view.call(this, helperClass, options); + }); + } +}); + +export default View; diff --git a/app/assets/javascripts/main_include.js b/app/assets/javascripts/main_include.js index 9816d42f55..9efe2cfe6c 100644 --- a/app/assets/javascripts/main_include.js +++ b/app/assets/javascripts/main_include.js @@ -15,6 +15,7 @@ //= require ./discourse/helpers/register-unbound //= require ./discourse/mixins/scrolling //= require_tree ./discourse/mixins +//= require ./discourse/lib/ajax-error //= require ./discourse/lib/markdown //= require ./discourse/lib/search-for-term //= require ./discourse/lib/user-search @@ -24,17 +25,20 @@ //= require ./discourse/lib/avatar-template //= require ./discourse/lib/safari-hacks //= require_tree ./discourse/adapters +//= require ./discourse/models/rest //= require ./discourse/models/model -//= require ./discourse/models/user_action -//= require ./discourse/models/composer +//= require ./discourse/models/post //= require ./discourse/models/post-stream //= require ./discourse/models/topic-details //= require ./discourse/models/topic +//= require ./discourse/models/user_action +//= require ./discourse/models/composer //= require ./discourse/controllers/controller //= require ./discourse/controllers/discovery-sortable //= require ./discourse/controllers/object //= require ./discourse/controllers/navigation/default //= require ./discourse/views/view +//= require ./discourse/views/grouped //= require ./discourse/views/container //= require ./discourse/views/modal-body //= require ./discourse/views/flag diff --git a/app/assets/stylesheets/common/base/onebox.scss b/app/assets/stylesheets/common/base/onebox.scss index cc2ce2c2d1..b8f2e93b36 100644 --- a/app/assets/stylesheets/common/base/onebox.scss +++ b/app/assets/stylesheets/common/base/onebox.scss @@ -131,7 +131,7 @@ aside.onebox { img { max-height: 80%; - max-width: 25%; + max-width: 20%; height: auto; float: left; margin-right: 10px; diff --git a/app/assets/stylesheets/common/base/pagedown.scss b/app/assets/stylesheets/common/base/pagedown.scss index 9de92d9715..ec26be9829 100644 --- a/app/assets/stylesheets/common/base/pagedown.scss +++ b/app/assets/stylesheets/common/base/pagedown.scss @@ -19,6 +19,7 @@ margin-top: 5px; padding: 0; height: 20px; + overflow: hidden; } .wmd-spacer { diff --git a/app/assets/stylesheets/common/base/topic.scss b/app/assets/stylesheets/common/base/topic.scss index 13201331cc..4eb62c3089 100644 --- a/app/assets/stylesheets/common/base/topic.scss +++ b/app/assets/stylesheets/common/base/topic.scss @@ -6,6 +6,13 @@ .btn-small { margin: 0 6px 0 0; } + + .badge-wrapper { + float: left; + &.bullet { + margin-top: 5px; + } + } } a.badge-category { @@ -24,3 +31,13 @@ top: 0; } } + +.extra-info-wrapper { + .badge-wrapper { + float: left; + &.bullet { + margin-top: 5px; + } + } + +} diff --git a/app/assets/stylesheets/common/components/badges.css.scss b/app/assets/stylesheets/common/components/badges.css.scss index 9e8ace19fb..d4ff996ce4 100644 --- a/app/assets/stylesheets/common/components/badges.css.scss +++ b/app/assets/stylesheets/common/components/badges.css.scss @@ -24,6 +24,8 @@ &.bar { //bar category style line-height: 1.25; + margin-right: 5px; + span.badge-category { color: $primary !important; padding: 3px; @@ -51,7 +53,7 @@ &.bullet { //bullet category style line-height: 1; - margin-right: 5px; + margin-right: 10px; h3 & { line-height: .9; @@ -98,6 +100,7 @@ &.box { //box category style (apply custom widths to the wrapper, not the children) line-height: 1.5; margin-top: 5px; + margin-right: 5px; span { display: block; diff --git a/app/assets/stylesheets/desktop.scss b/app/assets/stylesheets/desktop.scss index abeab14927..426146d3be 100644 --- a/app/assets/stylesheets/desktop.scss +++ b/app/assets/stylesheets/desktop.scss @@ -16,6 +16,7 @@ @import "desktop/upload"; @import "desktop/user"; @import "desktop/history"; +@import "desktop/queued-posts"; /* These files doesn't actually exist, they are injected by DiscourseSassImporter. */ diff --git a/app/assets/stylesheets/desktop/queued-posts.scss b/app/assets/stylesheets/desktop/queued-posts.scss new file mode 100644 index 0000000000..d927ce5b4c --- /dev/null +++ b/app/assets/stylesheets/desktop/queued-posts.scss @@ -0,0 +1,44 @@ +.queued-posts { + .queued-post { + padding: 1em 0; + + .poster { + width: 70px; + float: left; + } + .post-info { + display: inline-block; + float: right; + font-size: 0.929em; + margin-top: 1px; + span {color: scale-color($primary, $lightness: 50%);} + } + + .cooked { + width: $topic-body-width; + float: left; + + #wmd-input { + width: 98%; + height: 15em; + } + } + + .queue-controls { + button { + float: left; + margin-right: 0.5em; + } + } + .post-title { + color: darken(scale-color-diff(), 50%); + font-weight: bold; + + .badge-wrapper { + margin-left: 1em; + } + } + + } +} + diff --git a/app/assets/stylesheets/mobile/topic.scss b/app/assets/stylesheets/mobile/topic.scss index 70812a772f..313bcfcd7c 100644 --- a/app/assets/stylesheets/mobile/topic.scss +++ b/app/assets/stylesheets/mobile/topic.scss @@ -19,7 +19,7 @@ h1 { font-size: 1.5em; line-height: 1.25em; - margin:0; + margin: 0; a { color: $primary; vertical-align: middle; diff --git a/app/controllers/admin/admin_controller.rb b/app/controllers/admin/admin_controller.rb index 77d12479cf..30785689e1 100644 --- a/app/controllers/admin/admin_controller.rb +++ b/app/controllers/admin/admin_controller.rb @@ -7,11 +7,4 @@ class Admin::AdminController < ApplicationController render nothing: true end - protected - - # this is not really necessary cause the routes are secure - def ensure_staff - raise Discourse::InvalidAccess.new unless current_user.staff? - end - end diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index 69288921f4..42ed206bfd 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -83,7 +83,11 @@ class Admin::GroupsController < Admin::AdminController end users.each do |user| - group.add(user) + if !group.users.include?(user) + group.add(user) + else + return render_json_error I18n.t('groups.errors.member_already_exist', username: user.username) + end end if group.save diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index afaf3ef11e..146fe5771b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -198,9 +198,9 @@ class ApplicationController < ActionController::Base @guardian ||= Guardian.new(current_user) end - def serialize_data(obj, serializer, opts={}) + def serialize_data(obj, serializer, opts=nil) # If it's an array, apply the serializer as an each_serializer to the elements - serializer_opts = {scope: guardian}.merge!(opts) + serializer_opts = {scope: guardian}.merge!(opts || {}) if obj.respond_to?(:to_ary) serializer_opts[:each_serializer] = serializer ActiveModel::ArraySerializer.new(obj.to_ary, serializer_opts).as_json @@ -213,12 +213,14 @@ class ApplicationController < ActionController::Base # 20% slower than calling MultiJSON.dump ourselves. I'm not sure why # Rails doesn't call MultiJson.dump when you pass it json: obj but # it seems we don't need whatever Rails is doing. - def render_serialized(obj, serializer, opts={}) - render_json_dump(serialize_data(obj, serializer, opts)) + def render_serialized(obj, serializer, opts=nil) + render_json_dump(serialize_data(obj, serializer, opts), opts) end - def render_json_dump(obj) - render json: MultiJson.dump(obj) + def render_json_dump(obj, opts=nil) + opts ||= {} + obj['__rest_serializer'] = "1" if opts[:rest_serializer] + render json: MultiJson.dump(obj), status: opts[:status] || 200 end def can_cache_content? @@ -370,6 +372,10 @@ class ApplicationController < ActionController::Base raise Discourse::NotLoggedIn.new unless current_user.present? end + def ensure_staff + raise Discourse::InvalidAccess.new unless current_user && current_user.staff? + end + def redirect_to_login_if_required return if current_user || (request.format.json? && api_key_valid?) diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 74219e7d99..9de2c23383 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -87,6 +87,9 @@ class CategoriesController < ApplicationController if category_params.key? :email_in and category_params[:email_in].length == 0 # properly null the value so the database constrain doesn't catch us category_params[:email_in] = nil + elsif category_params.key? :email_in and existing_category = Category.find_by(email_in: category_params[:email_in]) and existing_category.id != @category.id + # check if email_in address is already in use for other category + return render_json_error I18n.t('category.errors.email_in_already_exist', {email_in: category_params[:email_in], category_name: existing_category.name}) end category_params.delete(:position) diff --git a/app/controllers/permalinks_controller.rb b/app/controllers/permalinks_controller.rb index c32b88e96c..3b243d955e 100644 --- a/app/controllers/permalinks_controller.rb +++ b/app/controllers/permalinks_controller.rb @@ -5,7 +5,7 @@ class PermalinksController < ApplicationController url = request.fullpath[1..-1] permalink = Permalink.find_by_url(url) if permalink && permalink.target_url - redirect_to permalink.target_url, status: :moved_permanently + redirect_to "#{Discourse::base_uri}#{permalink.target_url}", status: :moved_permanently else raise Discourse::NotFound end diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 0ebb3a766c..370eae68c3 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -1,6 +1,8 @@ +require_dependency 'new_post_manager' require_dependency 'post_creator' require_dependency 'post_destroyer' require_dependency 'distributed_memoizer' +require_dependency 'new_post_result_serializer' class PostsController < ApplicationController @@ -76,49 +78,21 @@ class PostsController < ApplicationController end def create - params = create_params + @manager_params = create_params + manager = NewPostManager.new(current_user, @manager_params) - key = params_key(params) - error_json = nil - - if (is_api?) - payload = DistributedMemoizer.memoize(key, 120) do - success, json = create_post(params) - unless success - error_json = json - raise Discourse::InvalidPost - end - json + if is_api? + memoized_payload = DistributedMemoizer.memoize(signature_for(@manager_params), 120) do + result = manager.perform + MultiJson.dump(serialize_data(result, NewPostResultSerializer, root: false)) end + + parsed_payload = JSON.parse(memoized_payload) + backwards_compatible_json(parsed_payload, parsed_payload['success']) else - success, payload = create_post(params) - unless success - error_json = payload - raise Discourse::InvalidPost - end - end - - render json: payload - - rescue Discourse::InvalidPost - render json: error_json, status: 422 - end - - def create_post(params) - post_creator = PostCreator.new(current_user, params) - post = post_creator.create - - if post_creator.errors.present? - # If the post was spam, flag all the user's posts as spam - current_user.flag_linked_posts_as_spam if post_creator.spam? - [false, MultiJson.dump(errors: post_creator.errors.full_messages)] - - else - DiscourseEvent.trigger(:topic_created, post.topic, params, current_user) unless params[:topic_id] - DiscourseEvent.trigger(:post_created, post, params, current_user) - post_serializer = PostSerializer.new(post, scope: guardian, root: false) - post_serializer.draft_sequence = DraftSequence.current(current_user, post.topic.draft_key) - [true, MultiJson.dump(post_serializer)] + result = manager.perform + json = serialize_data(result, NewPostResultSerializer, root: false) + backwards_compatible_json(json, result.success?) end end @@ -358,6 +332,19 @@ class PostsController < ApplicationController protected + # We can't break the API for making posts. The new, queue supporting API + # doesn't return the post as the root JSON object, but as a nested object. + # If a param is present it uses that result structure. + def backwards_compatible_json(json_obj, success) + json_obj.symbolize_keys! + if params[:nested_post].blank? && json_obj[:errors].blank? + json_obj = json_obj[:post] + end + + render json: json_obj, status: (!!success) ? 200 : 422 + end + + def find_post_revision_from_params post_id = params[:id] || params[:post_id] revision = params[:revision].to_i @@ -411,15 +398,6 @@ class PostsController < ApplicationController .limit(opts[:limit]) end - def params_key(params) - "post##" << Digest::SHA1.hexdigest(params - .to_a - .concat([["user", current_user.id]]) - .sort{|x,y| x[0] <=> y[0]}.join do |x,y| - "#{x}:#{y}" - end) - end - def create_params permitted = [ :raw, @@ -454,9 +432,11 @@ class PostsController < ApplicationController if current_user.staff? params.permit(:is_warning) result[:is_warning] = (params[:is_warning] == "true") + else + result[:is_warning] = false end - PostRevisor.tracked_topic_fields.keys.each do |f| + PostRevisor.tracked_topic_fields.each_key do |f| params.permit(f => []) result[f] = params[f] if params.has_key?(f) end @@ -469,6 +449,15 @@ class PostsController < ApplicationController result end + def signature_for(args) + "post##" << Digest::SHA1.hexdigest(args + .to_a + .concat([["user", current_user.id]]) + .sort{|x,y| x[0] <=> y[0]}.join do |x,y| + "#{x}:#{y}" + end) + end + def too_late_to(action, post) !guardian.send("can_#{action}?", post) && post.user_id == current_user.id && post.edit_time_limit_expired? end diff --git a/app/controllers/queued_posts_controller.rb b/app/controllers/queued_posts_controller.rb new file mode 100644 index 0000000000..78d2cd9249 --- /dev/null +++ b/app/controllers/queued_posts_controller.rb @@ -0,0 +1,32 @@ +require_dependency 'queued_post_serializer' + +class QueuedPostsController < ApplicationController + + before_filter :ensure_staff + + def index + state = QueuedPost.states[(params[:state] || 'new').to_sym] + state ||= QueuedPost.states[:new] + + @queued_posts = QueuedPost.visible.where(state: state).includes(:topic, :user) + render_serialized(@queued_posts, QueuedPostSerializer, root: :queued_posts, rest_serializer: true) + end + + def update + qp = QueuedPost.where(id: params[:id]).first + + if params[:queued_post][:raw].present? + qp.update_column(:raw, params[:queued_post][:raw]) + end + + state = params[:queued_post][:state] + if state == 'approved' + qp.approve!(current_user) + elsif state == 'rejected' + qp.reject!(current_user) + end + + render_serialized(qp, QueuedPostSerializer, root: :queued_posts) + end + +end diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index cdc7a2819f..d226f6f1c8 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -128,7 +128,7 @@ class TopicsController < ApplicationController guardian.ensure_can_edit!(topic) changes = {} - PostRevisor.tracked_topic_fields.keys.each do |f| + PostRevisor.tracked_topic_fields.each_key do |f| changes[f] = params[f] if params.has_key?(f) end diff --git a/app/controllers/user_actions_controller.rb b/app/controllers/user_actions_controller.rb index da2e042d48..e4d4079fec 100644 --- a/app/controllers/user_actions_controller.rb +++ b/app/controllers/user_actions_controller.rb @@ -8,16 +8,23 @@ class UserActionsController < ApplicationController user = fetch_user_from_params - opts = { - user_id: user.id, - offset: params[:offset].to_i, - limit: per_chunk, - action_types: (params[:filter] || "").split(",").map(&:to_i), - guardian: guardian, - ignore_private_messages: params[:filter] ? false : true - } + opts = { user_id: user.id, + user: user, + offset: params[:offset].to_i, + limit: per_chunk, + action_types: (params[:filter] || "").split(",").map(&:to_i), + guardian: guardian, + ignore_private_messages: params[:filter] ? false : true } - render_serialized(UserAction.stream(opts), UserActionSerializer, root: "user_actions") + # Pending is restricted + stream = if opts[:action_types].include?(UserAction::PENDING) + guardian.ensure_can_see_notifications!(user) + UserAction.stream_queued(opts) + else + UserAction.stream(opts) + end + + render_serialized(stream, UserActionSerializer, root: "user_actions") end def show diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 137cf9d6bd..d4a6d09e4c 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -446,8 +446,9 @@ class UsersController < ApplicationController term = params[:term].to_s.strip topic_id = params[:topic_id] topic_id = topic_id.to_i if topic_id + topic_allowed_users = params[:topic_allowed_users] || false - results = UserSearch.new(term, topic_id: topic_id, searching_user: current_user).search + results = UserSearch.new(term, topic_id: topic_id, topic_allowed_users: topic_allowed_users, searching_user: current_user).search user_fields = [:username, :upload_avatar_template, :uploaded_avatar_id] user_fields << :name if SiteSetting.enable_names? diff --git a/app/jobs/regular/post_alert.rb b/app/jobs/regular/post_alert.rb new file mode 100644 index 0000000000..99b1860c5f --- /dev/null +++ b/app/jobs/regular/post_alert.rb @@ -0,0 +1,11 @@ +module Jobs + class PostAlert < Jobs::Base + + def execute(args) + post = Post.find(args[:post_id]) + PostAlerter.post_created(post) + end + + end +end + diff --git a/app/jobs/scheduled/directory_refresh.rb b/app/jobs/scheduled/directory_refresh.rb deleted file mode 100644 index 2809ac210a..0000000000 --- a/app/jobs/scheduled/directory_refresh.rb +++ /dev/null @@ -1,9 +0,0 @@ -module Jobs - class DirectoryRefresh < Jobs::Scheduled - every 1.hour - - def execute(args) - DirectoryItem.refresh! - end - end -end diff --git a/app/jobs/scheduled/directory_refresh_daily.rb b/app/jobs/scheduled/directory_refresh_daily.rb new file mode 100644 index 0000000000..ea28067454 --- /dev/null +++ b/app/jobs/scheduled/directory_refresh_daily.rb @@ -0,0 +1,9 @@ +module Jobs + class DirectoryRefreshDaily < Jobs::Scheduled + every 1.hour + + def execute(args) + DirectoryItem.refresh_period!(:daily) + end + end +end diff --git a/app/jobs/scheduled/directory_refresh_older.rb b/app/jobs/scheduled/directory_refresh_older.rb new file mode 100644 index 0000000000..3c25c8151f --- /dev/null +++ b/app/jobs/scheduled/directory_refresh_older.rb @@ -0,0 +1,10 @@ +module Jobs + class DirectoryRefreshOlder < Jobs::Scheduled + every 1.day + + def execute(args) + periods = DirectoryItem.period_types.keys - [:daily] + periods.each {|p| DirectoryItem.refresh_period!(p)} + end + end +end diff --git a/app/models/admin_dashboard_data.rb b/app/models/admin_dashboard_data.rb index 202bd3c27e..90c573581b 100644 --- a/app/models/admin_dashboard_data.rb +++ b/app/models/admin_dashboard_data.rb @@ -25,7 +25,7 @@ class AdminDashboardData ruby_version_check, host_names_check, gc_checks, - sidekiq_check || queue_size_check, + sidekiq_check, ram_check, old_google_config_check, both_googles_config_check, @@ -100,11 +100,6 @@ class AdminDashboardData I18n.t('dashboard.sidekiq_warning') if Jobs.queued > 0 and (last_job_performed_at.nil? or last_job_performed_at < 2.minutes.ago) end - def queue_size_check - queue_size = Jobs.queued - I18n.t('dashboard.queue_size_warning', queue_size: queue_size) unless queue_size < 100 - end - def ram_check I18n.t('dashboard.memory_warning') if MemInfo.new.mem_total and MemInfo.new.mem_total < 1_000_000 end diff --git a/app/models/directory_item.rb b/app/models/directory_item.rb index 65d8dede2b..666749126f 100644 --- a/app/models/directory_item.rb +++ b/app/models/directory_item.rb @@ -17,10 +17,7 @@ class DirectoryItem < ActiveRecord::Base end def self.refresh! - ActiveRecord::Base.transaction do - exec_sql "TRUNCATE TABLE directory_items" - period_types.keys.each {|p| refresh_period!(p)} - end + period_types.each_key {|p| refresh_period!(p)} end def self.refresh_period!(period_type) @@ -36,41 +33,44 @@ class DirectoryItem < ActiveRecord::Base else 1000.years.ago end - exec_sql "INSERT INTO directory_items - (period_type, user_id, likes_received, likes_given, topics_entered, days_visited, posts_read, topic_count, post_count) - SELECT - :period_type, - u.id, - SUM(CASE WHEN ua.action_type = :was_liked_type THEN 1 ELSE 0 END), - SUM(CASE WHEN ua.action_type = :like_type THEN 1 ELSE 0 END), - COALESCE((SELECT COUNT(topic_id) FROM topic_views AS v WHERE v.user_id = u.id AND v.viewed_at >= :since), 0), - COALESCE((SELECT COUNT(id) FROM user_visits AS uv WHERE uv.user_id = u.id AND uv.visited_at >= :since), 0), - COALESCE((SELECT SUM(posts_read) FROM user_visits AS uv2 WHERE uv2.user_id = u.id AND uv2.visited_at >= :since), 0), - SUM(CASE WHEN ua.action_type = :new_topic_type THEN 1 ELSE 0 END), - SUM(CASE WHEN ua.action_type = :reply_type THEN 1 ELSE 0 END) - FROM users AS u - LEFT OUTER JOIN user_actions AS ua ON ua.user_id = u.id - LEFT OUTER JOIN topics AS t ON ua.target_topic_id = t.id - LEFT OUTER JOIN posts AS p ON ua.target_post_id = p.id - LEFT OUTER JOIN categories AS c ON t.category_id = c.id - WHERE u.active - AND NOT u.blocked - AND COALESCE(ua.created_at, :since) >= :since - AND t.deleted_at IS NULL - AND COALESCE(t.visible, true) - AND COALESCE(t.archetype, 'regular') = 'regular' - AND p.deleted_at IS NULL - AND (NOT (COALESCE(p.hidden, false))) - AND COALESCE(p.post_type, :regular_post_type) != :moderator_action - AND u.id > 0 - GROUP BY u.id", - period_type: period_types[period_type], - since: since, - like_type: UserAction::LIKE, - was_liked_type: UserAction::WAS_LIKED, - new_topic_type: UserAction::NEW_TOPIC, - reply_type: UserAction::REPLY, - regular_post_type: Post.types[:regular], - moderator_action: Post.types[:moderator_action] + ActiveRecord::Base.transaction do + exec_sql "DELETE FROM directory_items WHERE period_type = :period_type", period_type: period_types[period_type] + exec_sql "INSERT INTO directory_items + (period_type, user_id, likes_received, likes_given, topics_entered, days_visited, posts_read, topic_count, post_count) + SELECT + :period_type, + u.id, + SUM(CASE WHEN ua.action_type = :was_liked_type THEN 1 ELSE 0 END), + SUM(CASE WHEN ua.action_type = :like_type THEN 1 ELSE 0 END), + COALESCE((SELECT COUNT(topic_id) FROM topic_views AS v WHERE v.user_id = u.id AND v.viewed_at >= :since), 0), + COALESCE((SELECT COUNT(id) FROM user_visits AS uv WHERE uv.user_id = u.id AND uv.visited_at >= :since), 0), + COALESCE((SELECT SUM(posts_read) FROM user_visits AS uv2 WHERE uv2.user_id = u.id AND uv2.visited_at >= :since), 0), + SUM(CASE WHEN ua.action_type = :new_topic_type THEN 1 ELSE 0 END), + SUM(CASE WHEN ua.action_type = :reply_type THEN 1 ELSE 0 END) + FROM users AS u + LEFT OUTER JOIN user_actions AS ua ON ua.user_id = u.id + LEFT OUTER JOIN topics AS t ON ua.target_topic_id = t.id + LEFT OUTER JOIN posts AS p ON ua.target_post_id = p.id + LEFT OUTER JOIN categories AS c ON t.category_id = c.id + WHERE u.active + AND NOT u.blocked + AND COALESCE(ua.created_at, :since) >= :since + AND t.deleted_at IS NULL + AND COALESCE(t.visible, true) + AND COALESCE(t.archetype, 'regular') = 'regular' + AND p.deleted_at IS NULL + AND (NOT (COALESCE(p.hidden, false))) + AND COALESCE(p.post_type, :regular_post_type) != :moderator_action + AND u.id > 0 + GROUP BY u.id", + period_type: period_types[period_type], + since: since, + like_type: UserAction::LIKE, + was_liked_type: UserAction::WAS_LIKED, + new_topic_type: UserAction::NEW_TOPIC, + reply_type: UserAction::REPLY, + regular_post_type: Post.types[:regular], + moderator_action: Post.types[:moderator_action] + end end end diff --git a/app/models/group.rb b/app/models/group.rb index 8d1e38f42a..b36f5cee20 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -151,7 +151,7 @@ class Group < ActiveRecord::Base end def self.ensure_automatic_groups! - AUTO_GROUPS.keys.each do |name| + AUTO_GROUPS.each_key do |name| refresh_automatic_group!(name) unless lookup_group(name) end end diff --git a/app/models/incoming_links_report.rb b/app/models/incoming_links_report.rb index 2d1563f686..57531025e1 100644 --- a/app/models/incoming_links_report.rb +++ b/app/models/incoming_links_report.rb @@ -36,7 +36,7 @@ class IncomingLinksReport num_clicks = link_count_per_user num_topics = topic_count_per_user report.data = [] - num_clicks.keys.each do |username| + num_clicks.each_key do |username| report.data << {username: username, num_clicks: num_clicks[username], num_topics: num_topics[username]} end report.data = report.data.sort_by {|x| x[:num_clicks]}.reverse[0,10] @@ -67,7 +67,7 @@ class IncomingLinksReport num_clicks = link_count_per_domain num_topics = topic_count_per_domain(num_clicks.keys) report.data = [] - num_clicks.keys.each do |domain| + num_clicks.each_key do |domain| report.data << {domain: domain, num_clicks: num_clicks[domain], num_topics: num_topics[domain]} end report.data = report.data.sort_by {|x| x[:num_clicks]}.reverse[0,10] diff --git a/app/models/post.rb b/app/models/post.rb index 9e07e94f93..7dc8ab166f 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -221,7 +221,7 @@ class Post < ActiveRecord::Base TopicLink.where(domain: hosts.keys, user_id: acting_user.id) .group(:domain, :post_id) - .count.keys.each do |tuple| + .count.each_key do |tuple| domain = tuple[0] hosts[domain] = (hosts[domain] || 0) + 1 end @@ -369,7 +369,9 @@ class Post < ActiveRecord::Base problems end - def rebake!(opts={}) + def rebake!(opts=nil) + opts ||= {} + new_cooked = cook( raw, topic_id: topic_id, diff --git a/app/models/post_action.rb b/app/models/post_action.rb index eb72e7678b..5fc1330126 100644 --- a/app/models/post_action.rb +++ b/app/models/post_action.rb @@ -82,18 +82,26 @@ class PostAction < ActiveRecord::Base def self.lookup_for(user, topics, post_action_type_id) return if topics.blank? - + # in critical path 2x faster than AR + # + topic_ids = topics.map(&:id) map = {} - PostAction.where(user_id: user.id, post_action_type_id: post_action_type_id, deleted_at: nil) - .references(:post) - .includes(:post) - .where('posts.topic_id in (?)', topics.map(&:id)) - .order('posts.topic_id, posts.post_number') - .pluck('posts.topic_id, posts.post_number') - .each do |topic_id, post_number| - (map[topic_id] ||= []) << post_number + builder = SqlBuilder.new <= 2 + multiplier = SiteSetting.send("tl#{user.trust_level}_additional_likes_per_day_multiplier").to_f + multiplier = 1.0 if multiplier < 1.0 + + limit = (limit * multiplier ).to_i + end + + @rate_limiter = RateLimiter.new(user, "create_#{type}",limit, 1.day.to_i) return @rate_limiter end end diff --git a/app/models/post_timing.rb b/app/models/post_timing.rb index 245f512c6a..a1cf4dd60b 100644 --- a/app/models/post_timing.rb +++ b/app/models/post_timing.rb @@ -65,9 +65,11 @@ class PostTiming < ActiveRecord::Base def self.process_timings(current_user, topic_id, topic_time, timings) current_user.user_stat.update_time_read! + account_age_msecs = ((Time.now - current_user.created_at) * 1000.0) + highest_seen = 1 timings.each do |post_number, time| - if post_number >= 0 + if post_number >= 0 && time < account_age_msecs PostTiming.record_timing(topic_id: topic_id, post_number: post_number, user_id: current_user.id, diff --git a/app/models/queued_post.rb b/app/models/queued_post.rb new file mode 100644 index 0000000000..f93e7e8f11 --- /dev/null +++ b/app/models/queued_post.rb @@ -0,0 +1,82 @@ +class QueuedPost < ActiveRecord::Base + + class InvalidStateTransition < StandardError; end; + + belongs_to :user + belongs_to :topic + belongs_to :approved_by, class_name: "User" + belongs_to :rejected_by, class_name: "User" + + def self.states + @states ||= Enum.new(:new, :approved, :rejected) + end + + # By default queues are hidden from moderators + def self.visible_queues + @visible_queues ||= Set.new(['default']) + end + + def self.visible + where(queue: visible_queues.to_a) + end + + def self.new_count + visible.where(state: states[:new]).count + end + + def visible? + QueuedPost.visible_queues.include?(queue) + end + + def self.broadcast_new! + msg = { post_queue_new_count: QueuedPost.new_count } + MessageBus.publish('/queue_counts', msg, user_ids: User.staff.pluck(:id)) + end + + def reject!(rejected_by) + change_to!(:rejected, rejected_by) + end + + def create_options + opts = {raw: raw} + opts.merge!(post_options.symbolize_keys) + + opts[:cooking_options].symbolize_keys! if opts[:cooking_options] + opts[:topic_id] = topic_id if topic_id + opts + end + + def approve!(approved_by) + created_post = nil + QueuedPost.transaction do + change_to!(:approved, approved_by) + + creator = PostCreator.new(user, create_options.merge(skip_validations: true)) + created_post = creator.create + end + created_post + end + + private + + def change_to!(state, changed_by) + state_val = QueuedPost.states[state] + + updates = { state: state_val, + "#{state}_by_id" => changed_by.id, + "#{state}_at" => Time.now } + + # We use an update with `row_count` trick here to avoid stampeding requests to + # update the same row simultaneously. Only one state change should go through and + # we can use the DB to enforce this + row_count = QueuedPost.where('id = ? AND state <> ?', id, state_val).update_all(updates) + raise InvalidStateTransition.new if row_count == 0 + + # Update the record in memory too, and clear the dirty flag + updates.each {|k, v| send("#{k}=", v) } + changes_applied + + QueuedPost.broadcast_new! if visible? + end + +end diff --git a/app/models/topic.rb b/app/models/topic.rb index e214923886..89d380d96b 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -613,7 +613,7 @@ class Topic < ActiveRecord::Base end def update_action_counts - PostActionType.types.keys.each do |type| + PostActionType.types.each_key do |type| count_field = "#{type}_count" update_column(count_field, Post.where(topic_id: id).sum(count_field)) end diff --git a/app/models/topic_featured_users.rb b/app/models/topic_featured_users.rb index a192cfe942..d00aaeda14 100644 --- a/app/models/topic_featured_users.rb +++ b/app/models/topic_featured_users.rb @@ -24,6 +24,8 @@ class TopicFeaturedUsers def self.ensure_consistency!(topic_id=nil) + filter = "#{"AND t.id = #{topic_id.to_i}" if topic_id}" + sql = < t.user_id AND p.user_id <> t.last_post_user_id + #{filter} GROUP BY t.id, p.user_id ), @@ -69,7 +72,7 @@ WHERE x.id = tt.id AND COALESCE(featured_user2_id,-99) <> COALESCE(featured_user2,-99) OR COALESCE(featured_user3_id,-99) <> COALESCE(featured_user3,-99) OR COALESCE(featured_user4_id,-99) <> COALESCE(featured_user4,-99) -) #{"AND x.id = #{topic_id.to_i}" if topic_id} +) SQL Topic.exec_sql(sql) diff --git a/app/models/topic_tracking_state.rb b/app/models/topic_tracking_state.rb index 05334ccada..0bf3fa7096 100644 --- a/app/models/topic_tracking_state.rb +++ b/app/models/topic_tracking_state.rb @@ -148,14 +148,15 @@ class TopicTrackingState ((#{unread}) OR (#{new})) AND (topics.visible OR u.admin OR u.moderator) AND topics.deleted_at IS NULL AND - ( category_id IS NULL OR NOT c.read_restricted OR category_id IN ( + ( category_id IS NULL OR NOT c.read_restricted OR u.admin OR category_id IN ( SELECT c2.id FROM categories c2 JOIN category_groups cg ON cg.category_id = c2.id JOIN group_users gu ON gu.user_id = u.id AND cg.group_id = gu.group_id WHERE c2.read_restricted ) ) AND NOT EXISTS( SELECT 1 FROM category_users cu - WHERE cu.user_id = u.id AND + WHERE last_read_post_number IS NULL AND + cu.user_id = u.id AND cu.category_id = topics.category_id AND cu.notification_level = #{CategoryUser.notification_levels[:muted]}) diff --git a/app/models/user.rb b/app/models/user.rb index 62604333f7..53bc343b5e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -235,23 +235,51 @@ class User < ActiveRecord::Base User.email_hash(email) end - def unread_notifications_by_type - @unread_notifications_by_type ||= notifications.visible.where("id > ? and read = false", seen_notification_id).group(:notification_type).count - end - def reload - @unread_notifications_by_type = nil + @unread_notifications = nil @unread_total_notifications = nil @unread_pms = nil super end def unread_private_messages - @unread_pms ||= notifications.visible.where("read = false AND notification_type = ?", Notification.types[:private_message]).count + @unread_pms ||= + begin + # perf critical, much more efficient than AR + sql = " + SELECT COUNT(*) FROM notifications n + LEFT JOIN topics t ON n.topic_id = t.id + WHERE + t.deleted_at IS NULL AND + n.notification_type = :type AND + n.user_id = :user_id AND + NOT read" + + User.exec_sql(sql, user_id: id, + type: Notification.types[:private_message]) + .getvalue(0,0).to_i + end end def unread_notifications - unread_notifications_by_type.except(Notification.types[:private_message]).values.sum + @unread_notifications ||= + begin + # perf critical, much more efficient than AR + sql = " + SELECT COUNT(*) FROM notifications n + LEFT JOIN topics t ON n.topic_id = t.id + WHERE + t.deleted_at IS NULL AND + n.notification_type <> :pm AND + n.user_id = :user_id AND + NOT read AND + n.id > :seen_notification_id" + + User.exec_sql(sql, user_id: id, + seen_notification_id: seen_notification_id, + pm: Notification.types[:private_message]) + .getvalue(0,0).to_i + end end def total_unread_notifications @@ -389,7 +417,7 @@ class User < ActiveRecord::Base end def self.letter_avatar_template(username) - "/letter_avatar/#{username.downcase}/{size}/#{LetterAvatar::VERSION}.png" + "/letter_avatar/#{username.downcase}/{size}/#{LetterAvatar.version}.png" end def avatar_template diff --git a/app/models/user_action.rb b/app/models/user_action.rb index 95f87387d5..0e979991c0 100644 --- a/app/models/user_action.rb +++ b/app/models/user_action.rb @@ -17,10 +17,12 @@ class UserAction < ActiveRecord::Base EDIT = 11 NEW_PRIVATE_MESSAGE = 12 GOT_PRIVATE_MESSAGE = 13 + PENDING = 14 ORDER = Hash[*[ GOT_PRIVATE_MESSAGE, NEW_PRIVATE_MESSAGE, + PENDING, NEW_TOPIC, REPLY, RESPONSE, @@ -56,15 +58,14 @@ class UserAction < ActiveRecord::Base SELECT action_type, COUNT(*) count FROM user_actions a - JOIN topics t ON t.id = a.target_topic_id + LEFT JOIN topics t ON t.id = a.target_topic_id LEFT JOIN posts p on p.id = a.target_post_id - JOIN posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1 + LEFT JOIN posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1 LEFT JOIN categories c ON c.id = t.category_id /*where*/ GROUP BY action_type SQL - builder.where('a.user_id = :user_id', user_id: user_id) apply_common_filters(builder, user_id, guardian) @@ -91,48 +92,82 @@ SQL stream(action_id: action_id, guardian: guardian).first end - def self.stream(opts={}) - user_id = opts[:user_id] + def self.stream_queued(opts=nil) + opts ||= {} + offset = opts[:offset] || 0 limit = opts[:limit] || 60 - action_id = opts[:action_id] + + builder = SqlBuilder.new <<-SQL + SELECT + a.id, + t.title, a.action_type, a.created_at, t.id topic_id, + u.username, u.name, u.id AS user_id, + qp.raw, + t.category_id + FROM user_actions as a + JOIN queued_posts AS qp ON qp.id = a.queued_post_id + LEFT OUTER JOIN topics t on t.id = qp.topic_id + JOIN users u on u.id = a.user_id + LEFT JOIN categories c on c.id = t.category_id + /*where*/ + /*order_by*/ + /*offset*/ + /*limit*/ + SQL + + builder + .where('a.user_id = :user_id', user_id: opts[:user_id].to_i) + .where('action_type = :pending', pending: UserAction::PENDING) + .order_by("a.created_at desc") + .offset(offset.to_i) + .limit(limit.to_i) + .map_exec(UserActionRow) + end + + def self.stream(opts=nil) + opts ||= {} + action_types = opts[:action_types] + user_id = opts[:user_id] + action_id = opts[:action_id] guardian = opts[:guardian] ignore_private_messages = opts[:ignore_private_messages] + offset = opts[:offset] || 0 + limit = opts[:limit] || 60 # The weird thing is that target_post_id can be null, so it makes everything # ever so more complex. Should we allow this, not sure. - - builder = SqlBuilder.new(" -SELECT - a.id, - t.title, a.action_type, a.created_at, t.id topic_id, - a.user_id AS target_user_id, au.name AS target_name, au.username AS target_username, - coalesce(p.post_number, 1) post_number, p.id as post_id, - p.reply_to_post_number, - pu.email, pu.username, pu.name, pu.id user_id, - pu.uploaded_avatar_id, - u.email acting_email, u.username acting_username, u.name acting_name, u.id acting_user_id, - u.uploaded_avatar_id acting_uploaded_avatar_id, - coalesce(p.cooked, p2.cooked) cooked, - CASE WHEN coalesce(p.deleted_at, p2.deleted_at, t.deleted_at) IS NULL THEN false ELSE true END deleted, - p.hidden, - p.post_type, - p.edit_reason, - t.category_id -FROM user_actions as a -JOIN topics t on t.id = a.target_topic_id -LEFT JOIN posts p on p.id = a.target_post_id -JOIN posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1 -JOIN users u on u.id = a.acting_user_id -JOIN users pu on pu.id = COALESCE(p.user_id, t.user_id) -JOIN users au on au.id = a.user_id -LEFT JOIN categories c on c.id = t.category_id -/*where*/ -/*order_by*/ -/*offset*/ -/*limit*/ -") + builder = SqlBuilder.new <<-SQL + SELECT + a.id, + t.title, a.action_type, a.created_at, t.id topic_id, + a.user_id AS target_user_id, au.name AS target_name, au.username AS target_username, + coalesce(p.post_number, 1) post_number, p.id as post_id, + p.reply_to_post_number, + pu.username, pu.name, pu.id user_id, + pu.uploaded_avatar_id, + u.username acting_username, u.name acting_name, u.id acting_user_id, + u.uploaded_avatar_id acting_uploaded_avatar_id, + coalesce(p.cooked, p2.cooked) cooked, + CASE WHEN coalesce(p.deleted_at, p2.deleted_at, t.deleted_at) IS NULL THEN false ELSE true END deleted, + p.hidden, + p.post_type, + p.edit_reason, + t.category_id + FROM user_actions as a + JOIN topics t on t.id = a.target_topic_id + LEFT JOIN posts p on p.id = a.target_post_id + JOIN posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1 + JOIN users u on u.id = a.acting_user_id + JOIN users pu on pu.id = COALESCE(p.user_id, t.user_id) + JOIN users au on au.id = a.user_id + LEFT JOIN categories c on c.id = t.category_id + /*where*/ + /*order_by*/ + /*offset*/ + /*limit*/ + SQL apply_common_filters(builder, user_id, guardian, ignore_private_messages) @@ -151,7 +186,15 @@ LEFT JOIN categories c on c.id = t.category_id end def self.log_action!(hash) - required_parameters = [:action_type, :user_id, :acting_user_id, :target_topic_id, :target_post_id] + required_parameters = [:action_type, :user_id, :acting_user_id] + + if hash[:action_type] == UserAction::PENDING + required_parameters << :queued_post_id + else + required_parameters << :target_post_id + required_parameters << :target_topic_id + end + require_parameters(hash, *required_parameters) transaction(requires_new: true) do @@ -269,6 +312,10 @@ SQL builder.where("t.visible") end + unless guardian.can_see_notifications?(User.where(id: user_id).first) + builder.where('a.action_type <> :pending', pending: UserAction::PENDING) + end + if !guardian.can_see_private_messages?(user_id) || ignore_private_messages builder.where("t.archetype != :archetype", archetype: Archetype::private_message) end diff --git a/app/models/user_history.rb b/app/models/user_history.rb index 3fe017d69a..94f48adecd 100644 --- a/app/models/user_history.rb +++ b/app/models/user_history.rb @@ -38,7 +38,8 @@ class UserHistory < ActiveRecord::Base :change_username, :custom, :custom_staff, - :anonymize_user) + :anonymize_user, + :reviewed_post) end # Staff actions is a subset of all actions, used to audit actions taken by staff users. @@ -59,7 +60,8 @@ class UserHistory < ActiveRecord::Base :roll_up, :change_username, :custom_staff, - :anonymize_user] + :anonymize_user, + :reviewed_post] end def self.staff_action_ids diff --git a/app/models/user_search.rb b/app/models/user_search.rb index 535d1c29f0..cbb18312f8 100644 --- a/app/models/user_search.rb +++ b/app/models/user_search.rb @@ -5,6 +5,7 @@ class UserSearch @term = term @term_like = "#{term.downcase}%" @topic_id = opts[:topic_id] + @topic_allowed_users = opts[:topic_allowed_users] @searching_user = opts[:searching_user] @limit = opts[:limit] || 20 end @@ -36,6 +37,18 @@ class UserSearch users = users.not_suspended end + # Only show users who have access to private topic + if @topic_id && @topic_allowed_users == "true" + allowed_user_ids = [] + topic = Topic.find_by(id: @topic_id) + + if topic.category && topic.category.read_restricted + users = users.includes(:secure_categories) + .where("users.admin = TRUE OR categories.id = ?", topic.category.id) + .references(:categories) + end + end + users.order("CASE WHEN last_seen_at IS NULL THEN 0 ELSE 1 END DESC, last_seen_at DESC, username ASC") .limit(@limit) end diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb index e8b3e717a2..2a310295e1 100644 --- a/app/serializers/current_user_serializer.rb +++ b/app/serializers/current_user_serializer.rb @@ -1,3 +1,5 @@ +require_dependency 'new_post_manager' + class CurrentUserSerializer < BasicUserSerializer attributes :name, @@ -26,7 +28,9 @@ class CurrentUserSerializer < BasicUserSerializer :custom_fields, :muted_category_ids, :dismissed_banner_key, - :is_anonymous + :is_anonymous, + :post_queue_new_count, + :show_queued_posts def include_site_flagged_posts_count? object.staff? @@ -107,4 +111,20 @@ class CurrentUserSerializer < BasicUserSerializer object.anonymous? end + def post_queue_new_count + QueuedPost.new_count + end + + def include_post_queue_new_count? + object.staff? + end + + def show_queued_posts + true + end + + def include_show_queued_posts? + object.staff? && NewPostManager.queue_enabled? + end + end diff --git a/app/serializers/new_post_result_serializer.rb b/app/serializers/new_post_result_serializer.rb new file mode 100644 index 0000000000..ff7330cdd4 --- /dev/null +++ b/app/serializers/new_post_result_serializer.rb @@ -0,0 +1,39 @@ +require_dependency 'application_serializer' + +class NewPostResultSerializer < ApplicationSerializer + attributes :action, + :post, + :errors, + :success + + def post + post_serializer = PostSerializer.new(object.post, scope: scope, root: false) + post_serializer.draft_sequence = DraftSequence.current(scope.user, object.post.topic.draft_key) + post_serializer.as_json + end + + def include_post? + object.post.present? + end + + def success + true + end + + def include_success? + @object.success? + end + + def errors + object.errors.full_messages + end + + def include_errors? + !object.errors.empty? + end + + def action + object.action + end + +end diff --git a/app/serializers/post_revision_serializer.rb b/app/serializers/post_revision_serializer.rb index d7630a96d5..383d4142e9 100644 --- a/app/serializers/post_revision_serializer.rb +++ b/app/serializers/post_revision_serializer.rb @@ -178,7 +178,7 @@ class PostRevisionSerializer < ApplicationSerializer } # Retrieve any `tracked_topic_fields` - PostRevisor.tracked_topic_fields.keys.each do |field| + PostRevisor.tracked_topic_fields.each_key do |field| if topic.respond_to?(field) latest_modifications[field.to_s] = [topic.send(field)] end @@ -198,7 +198,7 @@ class PostRevisionSerializer < ApplicationSerializer revision[:revision] = pr.number revision[:hidden] = pr.hidden - pr.modifications.keys.each do |field| + pr.modifications.each_key do |field| revision[field] = pr.modifications[field][0] end @@ -210,7 +210,7 @@ class PostRevisionSerializer < ApplicationSerializer cur = @all_revisions[r] prev = @all_revisions[r - 1] - cur.keys.each do |field| + cur.each_key do |field| prev[field] = prev.has_key?(field) ? prev[field] : cur[field] end end diff --git a/app/serializers/queued_post_serializer.rb b/app/serializers/queued_post_serializer.rb new file mode 100644 index 0000000000..39017c2142 --- /dev/null +++ b/app/serializers/queued_post_serializer.rb @@ -0,0 +1,27 @@ +class QueuedPostSerializer < ApplicationSerializer + + attributes :id, + :queue, + :user_id, + :state, + :topic_id, + :approved_by_id, + :rejected_by_id, + :raw, + :post_options, + :created_at, + :category_id + + has_one :user, serializer: BasicUserSerializer + has_one :topic, serializer: BasicTopicSerializer + + def category_id + cat_id = object.topic.try(:category_id) || object.post_options['category'] + cat_id.to_i if cat_id + end + + def include_category_id? + category_id.present? + end + +end diff --git a/app/serializers/user_action_serializer.rb b/app/serializers/user_action_serializer.rb index 188ed1b69d..503aabe626 100644 --- a/app/serializers/user_action_serializer.rb +++ b/app/serializers/user_action_serializer.rb @@ -29,7 +29,8 @@ class UserActionSerializer < ApplicationSerializer :acting_uploaded_avatar_id def excerpt - PrettyText.excerpt(object.cooked, 300) if object.cooked + cooked = object.cooked || PrettyText.cook(object.raw) + PrettyText.excerpt(cooked, 300) if cooked end def avatar_template @@ -40,6 +41,10 @@ class UserActionSerializer < ApplicationSerializer User.avatar_template(object.acting_username, object.acting_uploaded_avatar_id) end + def include_acting_avatar_template? + object.acting_username.present? + end + def include_name? SiteSetting.enable_names? end @@ -56,6 +61,10 @@ class UserActionSerializer < ApplicationSerializer Slug.for(object.title) end + def include_slug? + object.title.present? + end + def moderator_action object.post_type == Post.types[:moderator_action] end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 350b026201..f9ba6563b6 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -64,7 +64,8 @@ class UserSerializer < BasicUserSerializer :edit_history_public, :custom_fields, :user_fields, - :topic_post_count + :topic_post_count, + :pending_count has_one :invited_by, embed: :object, serializer: BasicUserSerializer has_many :custom_groups, embed: :object, serializer: BasicGroupSerializer @@ -312,4 +313,8 @@ class UserSerializer < BasicUserSerializer {} end end + + def pending_count + 0 + end end diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb index 9064177433..819e4b2a05 100644 --- a/app/services/post_alerter.rb +++ b/app/services/post_alerter.rb @@ -16,7 +16,7 @@ class PostAlerter user.id == post.user_id end.each do |user| if TopicUser.get(post.topic, user).try(:notification_level) == TopicUser.notification_levels[:tracking] - next unless post.reply_to_post_number || post.reply_to_post.user_id == user.id + next unless post.reply_to_post_number || post.reply_to_post.try(:user_id) == user.id end create_notification(user, Notification.types[:private_message], post) end @@ -190,6 +190,7 @@ class PostAlerter exclude_user_ids << reply_to_user.id if reply_to_user.present? exclude_user_ids.flatten! + TopicUser .where(topic_id: post.topic_id, notification_level: TopicUser.notification_levels[:watching]) .includes(:user).each do |tu| diff --git a/app/views/layouts/crawler.html.erb b/app/views/layouts/crawler.html.erb index 3a448be191..bcfbf0bfbd 100644 --- a/app/views/layouts/crawler.html.erb +++ b/app/views/layouts/crawler.html.erb @@ -27,10 +27,10 @@

    <%= t 'powered_by_html' %>

    diff --git a/app/views/static/show.html.erb b/app/views/static/show.html.erb index 2f73233a03..0f84dbcaac 100644 --- a/app/views/static/show.html.erb +++ b/app/views/static/show.html.erb @@ -1,6 +1,7 @@