diff --git a/.eslintignore b/.eslintignore index 87cbecfae7..47d86595d9 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,6 +7,7 @@ app/assets/javascripts/vendor.js app/assets/javascripts/locales/i18n.js app/assets/javascripts/defer/html-sanitizer-bundle.js app/assets/javascripts/ember-addons/ +app/assets/javascripts/admin/lib/autosize.js.es6 lib/javascripts/locale/ lib/javascripts/messageformat.js lib/javascripts/moment.js @@ -16,8 +17,8 @@ lib/es6_module_transpiler/support/es6-module-transpiler.js public/javascripts/ spec/phantom_js/smoke_test.js vendor/ -test/javascripts/helpers/ test/javascripts/test_helper.js test/javascripts/test_helper.js test/javascripts/fixtures +test/javascripts/helpers/assertions.js app/assets/javascripts/ember-addons/ diff --git a/.gitignore b/.gitignore index 7b2156d4d5..3bdbb95c84 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ log/ !/plugins/emoji/ !/plugins/lazyYT/ !/plugins/poll/ +!/plugins/discourse-details/ /plugins/*/auto_generated/ /spec/fixtures/plugins/my_plugin/auto_generated diff --git a/Gemfile b/Gemfile index 10f209d3d6..595932d342 100644 --- a/Gemfile +++ b/Gemfile @@ -47,7 +47,7 @@ gem 'aws-sdk', require: false gem 'excon', require: false gem 'unf', require: false -gem 'email_reply_parser' +gem 'discourse_email_parser' # note: for image_optim to correctly work you need to follow # https://github.com/toy/image_optim diff --git a/Gemfile.lock b/Gemfile.lock index 8458b5767f..348516d926 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -113,9 +113,9 @@ GEM diff-lcs (1.2.5) discourse-qunit-rails (0.0.8) railties + discourse_email_parser (0.6.1) docile (1.1.5) dotenv (2.0.2) - email_reply_parser (0.5.8) ember-data-source (1.0.0.beta.16.1) ember-source (~> 1.8) ember-handlebars-template (0.1.5) @@ -186,14 +186,14 @@ GEM thor (~> 0.15) libv8 (3.16.14.11) listen (0.7.3) - logster (1.0.0.3.pre) + logster (1.0.1) loofah (2.0.3) nokogiri (>= 1.5.9) lru_redux (1.1.0) mail (2.6.3) mime-types (>= 1.16, < 3) memory_profiler (0.9.4) - message_bus (1.0.16) + message_bus (1.1.1) rack (>= 1.1.3) redis metaclass (0.0.4) @@ -247,7 +247,7 @@ GEM omniauth-twitter (1.2.1) json (~> 1.3) omniauth-oauth (~> 1.1) - onebox (1.5.28) + onebox (1.5.29) moneta (~> 0.8) multi_json (~> 1.11) mustache @@ -315,7 +315,7 @@ GEM ffi (>= 1.0.6) msgpack (>= 0.4.3) trollop (>= 1.16.2) - redis (3.2.1) + redis (3.2.2) redis-namespace (1.5.2) redis (~> 3.0, >= 3.0.4) ref (2.0.0) @@ -451,7 +451,7 @@ DEPENDENCIES byebug certified discourse-qunit-rails - email_reply_parser + discourse_email_parser ember-rails ember-source (= 1.12.1) excon diff --git a/README.md b/README.md index e7dba166c0..612c4091da 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,9 @@ Before contributing to Discourse: 1. Please read the complete mission statements on [**discourse.org**](http://www.discourse.org). Yes we actually believe this stuff; you should too. 2. Read and sign the [**Electronic Discourse Forums Contribution License Agreement**](http://discourse.org/cla). 3. Dig into [**CONTRIBUTING.MD**](CONTRIBUTING.md), which covers submitting bugs, requesting new features, preparing your code for a pull request, etc. -4. Not sure what to work on? [**We've got some ideas.**](http://meta.discourse.org/t/so-you-want-to-help-out-with-discourse/3823) +4. Always strive to collaborate [with mutual respect](https://github.com/discourse/discourse/blob/master/docs/code-of-conduct.md). +5. Not sure what to work on? [**We've got some ideas.**](http://meta.discourse.org/t/so-you-want-to-help-out-with-discourse/3823) + We look forward to seeing your pull requests! diff --git a/app/assets/fonts/FontAwesome.otf b/app/assets/fonts/FontAwesome.otf index 681bdd4d4c..3ed7f8b48a 100644 Binary files a/app/assets/fonts/FontAwesome.otf and b/app/assets/fonts/FontAwesome.otf differ diff --git a/app/assets/fonts/fontawesome-webfont.eot b/app/assets/fonts/fontawesome-webfont.eot index a30335d748..9b6afaedc0 100644 Binary files a/app/assets/fonts/fontawesome-webfont.eot and b/app/assets/fonts/fontawesome-webfont.eot differ diff --git a/app/assets/fonts/fontawesome-webfont.svg b/app/assets/fonts/fontawesome-webfont.svg index 6fd19abcb9..d05688e9e2 100644 --- a/app/assets/fonts/fontawesome-webfont.svg +++ b/app/assets/fonts/fontawesome-webfont.svg @@ -1,6 +1,6 @@ - + @@ -219,8 +219,8 @@ - - + + @@ -362,7 +362,7 @@ - + @@ -410,7 +410,7 @@ - + @@ -454,7 +454,7 @@ - + @@ -555,7 +555,7 @@ - + @@ -600,11 +600,11 @@ - - + + - + @@ -621,20 +621,35 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/assets/fonts/fontawesome-webfont.ttf b/app/assets/fonts/fontawesome-webfont.ttf index d7994e1308..26dea7951a 100644 Binary files a/app/assets/fonts/fontawesome-webfont.ttf and b/app/assets/fonts/fontawesome-webfont.ttf differ diff --git a/app/assets/fonts/fontawesome-webfont.woff b/app/assets/fonts/fontawesome-webfont.woff index 6fd4ede0f3..dc35ce3c2c 100644 Binary files a/app/assets/fonts/fontawesome-webfont.woff and b/app/assets/fonts/fontawesome-webfont.woff differ diff --git a/app/assets/fonts/fontawesome-webfont.woff2 b/app/assets/fonts/fontawesome-webfont.woff2 index 5560193ccc..500e517253 100644 Binary files a/app/assets/fonts/fontawesome-webfont.woff2 and b/app/assets/fonts/fontawesome-webfont.woff2 differ diff --git a/app/assets/javascripts/admin/adapters/site-text-type.js.es6 b/app/assets/javascripts/admin/adapters/site-text-type.js.es6 deleted file mode 100644 index b547b06f3c..0000000000 --- a/app/assets/javascripts/admin/adapters/site-text-type.js.es6 +++ /dev/null @@ -1,2 +0,0 @@ -import CustomizationBase from 'admin/adapters/customization-base'; -export default CustomizationBase; diff --git a/app/assets/javascripts/admin/components/expanding-text-area.js.es6 b/app/assets/javascripts/admin/components/expanding-text-area.js.es6 new file mode 100644 index 0000000000..3b5a260690 --- /dev/null +++ b/app/assets/javascripts/admin/components/expanding-text-area.js.es6 @@ -0,0 +1,24 @@ +import { on, observes } from 'ember-addons/ember-computed-decorators'; +import autosize from 'admin/lib/autosize'; + +export default Ember.TextArea.extend({ + @on('didInsertElement') + _startWatching() { + Ember.run.scheduleOnce('afterRender', () => { + this.$().focus(); + autosize(this.element); + }); + }, + + @observes('value') + _updateAutosize() { + const evt = document.createEvent('Event'); + evt.initEvent('autosize:update', true, false); + this.element.dispatchEvent(evt); + }, + + @on('willDestroyElement') + _disableAutosize() { + autosize.destroy(this.$()); + } +}); diff --git a/app/assets/javascripts/admin/components/screened-ip-address-form-component.js.es6 b/app/assets/javascripts/admin/components/screened-ip-address-form-component.js.es6 deleted file mode 100644 index 8702dbb2f9..0000000000 --- a/app/assets/javascripts/admin/components/screened-ip-address-form-component.js.es6 +++ /dev/null @@ -1,79 +0,0 @@ -import ScreenedIpAddress from 'admin/models/screened-ip-address'; -/** - A form to create an IP address that will be blocked or whitelisted. - Example usage: - - {{screened-ip-address-form action="recordAdded"}} - - where action is a callback on the controller or route that will get called after - the new record is successfully saved. It is called with the new ScreenedIpAddress record - as an argument. - - @class ScreenedIpAddressFormComponent - @extends Ember.Component - @namespace Discourse - @module Discourse -**/ -const ScreenedIpAddressFormComponent = Ember.Component.extend({ - classNames: ['screened-ip-address-form'], - formSubmitted: false, - actionName: 'block', - - adminWhitelistEnabled: function() { - return Discourse.SiteSettings.use_admin_ip_whitelist; - }.property(), - - actionNames: function() { - if (this.get('adminWhitelistEnabled')) { - return [ - {id: 'block', name: I18n.t('admin.logs.screened_ips.actions.block')}, - {id: 'do_nothing', name: I18n.t('admin.logs.screened_ips.actions.do_nothing')}, - {id: 'allow_admin', name: I18n.t('admin.logs.screened_ips.actions.allow_admin')} - ]; - } else { - return [ - {id: 'block', name: I18n.t('admin.logs.screened_ips.actions.block')}, - {id: 'do_nothing', name: I18n.t('admin.logs.screened_ips.actions.do_nothing')} - ]; - } - }.property('adminWhitelistEnabled'), - - actions: { - submit: function() { - if (!this.get('formSubmitted')) { - var self = this; - this.set('formSubmitted', true); - var screenedIpAddress = ScreenedIpAddress.create({ip_address: this.get('ip_address'), action_name: this.get('actionName')}); - screenedIpAddress.save().then(function(result) { - self.set('ip_address', ''); - self.set('formSubmitted', false); - self.sendAction('action', ScreenedIpAddress.create(result.screened_ip_address)); - Em.run.schedule('afterRender', function() { self.$('.ip-address-input').focus(); }); - }, function(e) { - self.set('formSubmitted', false); - var msg; - if (e.responseJSON && e.responseJSON.errors) { - msg = I18n.t("generic_error_with_reason", {error: e.responseJSON.errors.join('. ')}); - } else { - msg = I18n.t("generic_error"); - } - bootbox.alert(msg, function() { self.$('.ip-address-input').focus(); }); - }); - } - } - }, - - didInsertElement: function() { - var self = this; - this._super(); - Em.run.schedule('afterRender', function() { - self.$('.ip-address-input').keydown(function(e) { - if (e.keyCode === 13) { // enter key - self.send('submit'); - } - }); - }); - } -}); - -export default ScreenedIpAddressFormComponent; diff --git a/app/assets/javascripts/admin/components/screened-ip-address-form.js.es6 b/app/assets/javascripts/admin/components/screened-ip-address-form.js.es6 new file mode 100644 index 0000000000..0f8a29ba8c --- /dev/null +++ b/app/assets/javascripts/admin/components/screened-ip-address-form.js.es6 @@ -0,0 +1,75 @@ +/** + A form to create an IP address that will be blocked or whitelisted. + Example usage: + + {{screened-ip-address-form action="recordAdded"}} + + where action is a callback on the controller or route that will get called after + the new record is successfully saved. It is called with the new ScreenedIpAddress record + as an argument. +**/ + +import ScreenedIpAddress from 'admin/models/screened-ip-address'; +import computed from 'ember-addons/ember-computed-decorators'; +import { on } from 'ember-addons/ember-computed-decorators'; + +export default Ember.Component.extend({ + classNames: ['screened-ip-address-form'], + formSubmitted: false, + actionName: 'block', + + @computed + adminWhitelistEnabled() { + return Discourse.SiteSettings.use_admin_ip_whitelist; + }, + + @computed("adminWhitelistEnabled") + actionNames(adminWhitelistEnabled) { + if (adminWhitelistEnabled) { + return [ + {id: 'block', name: I18n.t('admin.logs.screened_ips.actions.block')}, + {id: 'do_nothing', name: I18n.t('admin.logs.screened_ips.actions.do_nothing')}, + {id: 'allow_admin', name: I18n.t('admin.logs.screened_ips.actions.allow_admin')} + ]; + } else { + return [ + {id: 'block', name: I18n.t('admin.logs.screened_ips.actions.block')}, + {id: 'do_nothing', name: I18n.t('admin.logs.screened_ips.actions.do_nothing')} + ]; + } + }, + + actions: { + submit() { + if (!this.get('formSubmitted')) { + this.set('formSubmitted', true); + const screenedIpAddress = ScreenedIpAddress.create({ + ip_address: this.get('ip_address'), + action_name: this.get('actionName') + }); + screenedIpAddress.save().then(result => { + this.setProperties({ ip_address: '', formSubmitted: false }); + this.sendAction('action', ScreenedIpAddress.create(result.screened_ip_address)); + Ember.run.schedule('afterRender', () => this.$('.ip-address-input').focus()); + }).catch(e => { + this.set('formSubmitted', false); + const msg = (e.responseJSON && e.responseJSON.errors) ? + I18n.t("generic_error_with_reason", {error: e.responseJSON.errors.join('. ')}) : + I18n.t("generic_error"); + bootbox.alert(msg, () => this.$('.ip-address-input').focus()); + }); + } + } + }, + + @on("didInsertElement") + _init() { + Ember.run.schedule('afterRender', () => { + this.$('.ip-address-input').keydown(e => { + if (e.keyCode === 13) { + this.send('submit'); + } + }); + }); + } +}); diff --git a/app/assets/javascripts/admin/components/site-text-summary.js.es6 b/app/assets/javascripts/admin/components/site-text-summary.js.es6 new file mode 100644 index 0000000000..642164f871 --- /dev/null +++ b/app/assets/javascripts/admin/components/site-text-summary.js.es6 @@ -0,0 +1,25 @@ +import { on } from 'ember-addons/ember-computed-decorators'; + +export default Ember.Component.extend({ + classNames: ['site-text'], + classNameBindings: ['siteText.overridden'], + + @on('didInsertElement') + highlightTerm() { + const term = this.get('term'); + if (term) { + this.$('.site-text-id, .site-text-value').highlight(term, {className: 'text-highlight'}); + } + this.$('.site-text-value').ellipsis(); + }, + + click() { + this.send('edit'); + }, + + actions: { + edit() { + this.sendAction('editAction', this.get('siteText')); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-customize-email-templates-edit.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-email-templates-edit.js.es6 index 5e787b6bbb..1c8eb6b086 100644 --- a/app/assets/javascripts/admin/controllers/admin-customize-email-templates-edit.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-customize-email-templates-edit.js.es6 @@ -4,6 +4,15 @@ import { bufferedProperty } from 'discourse/mixins/buffered-content'; export default Ember.Controller.extend(bufferedProperty('emailTemplate'), { saved: false, + hasMultipleSubjects: function() { + const buffered = this.get('buffered'); + if (buffered.getProperties('subject')['subject']) { + return false; + } else { + return buffered.getProperties('id')['id']; + } + }.property("buffered"), + actions: { saveChanges() { const buffered = this.get('buffered'); diff --git a/app/assets/javascripts/admin/controllers/admin-site-text-edit.js.es6 b/app/assets/javascripts/admin/controllers/admin-site-text-edit.js.es6 index 159d26e89b..79de9efc35 100644 --- a/app/assets/javascripts/admin/controllers/admin-site-text-edit.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-site-text-edit.js.es6 @@ -1,14 +1,29 @@ -export default Ember.Controller.extend({ - saved: false, +import { popupAjaxError } from 'discourse/lib/ajax-error'; +import { bufferedProperty } from 'discourse/mixins/buffered-content'; - saveDisabled: function() { - return ((!this.get('allow_blank')) && Ember.isEmpty(this.get('model.value'))); - }.property('model.iSaving', 'model.value'), +export default Ember.Controller.extend(bufferedProperty('siteText'), { + saved: false, actions: { saveChanges() { - const model = this.get('model'); - model.save(model.getProperties('value')).then(() => this.set('saved', true)); + const buffered = this.get('buffered'); + this.get('siteText').save(buffered.getProperties('value')).then(() => { + this.commitBuffer(); + this.set('saved', true); + }).catch(popupAjaxError); + }, + + revertChanges() { + this.set('saved', false); + bootbox.confirm(I18n.t('admin.site_text.revert_confirm'), result => { + if (result) { + this.get('siteText').revert().then(props => { + const buffered = this.get('buffered'); + buffered.setProperties(props); + this.commitBuffer(); + }).catch(popupAjaxError); + } + }); } } }); diff --git a/app/assets/javascripts/admin/controllers/admin-site-text-index.js.es6 b/app/assets/javascripts/admin/controllers/admin-site-text-index.js.es6 new file mode 100644 index 0000000000..7be4269884 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-site-text-index.js.es6 @@ -0,0 +1,51 @@ +import { default as computed } from 'ember-addons/ember-computed-decorators'; + +export default Ember.Controller.extend({ + _q: null, + searching: false, + siteTexts: null, + preferred: false, + _overridden: null, + queryParams: ['q', 'overridden'], + + @computed + overridden: { + set(value) { + if (!value || value === "false") { value = false; } + this._overridden = value; + return value; + }, + get() { + return this._overridden; + } + }, + + @computed + q: { + set(value) { + if (Ember.isEmpty(value)) { value = null; } + this._q = value; + return value; + }, + get() { + return this._q; + } + }, + + _performSearch() { + this.store.find('site-text', this.getProperties('q', 'overridden')).then(results => { + this.set('siteTexts', results); + }).finally(() => this.set('searching', false)); + }, + + actions: { + edit(siteText) { + this.transitionToRoute('adminSiteText.edit', siteText.get('id')); + }, + + search() { + this.set('searching', true); + Ember.run.debounce(this, this._performSearch, 400); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-site-text.js.es6 b/app/assets/javascripts/admin/controllers/admin-site-text.js.es6 deleted file mode 100644 index 24c4c05139..0000000000 --- a/app/assets/javascripts/admin/controllers/admin-site-text.js.es6 +++ /dev/null @@ -1 +0,0 @@ -export default Ember.ArrayController.extend(); diff --git a/app/assets/javascripts/admin/helpers/preserve-newlines.js.es6 b/app/assets/javascripts/admin/helpers/preserve-newlines.js.es6 new file mode 100644 index 0000000000..aeb9f30b37 --- /dev/null +++ b/app/assets/javascripts/admin/helpers/preserve-newlines.js.es6 @@ -0,0 +1,3 @@ +Em.Handlebars.helper('preserve-newlines', str => { + return new Handlebars.SafeString(Discourse.Utilities.escapeExpression(str).replace(/\n/g, "
")); +}); diff --git a/app/assets/javascripts/admin/lib/autosize.js.es6 b/app/assets/javascripts/admin/lib/autosize.js.es6 new file mode 100644 index 0000000000..dbdb858093 --- /dev/null +++ b/app/assets/javascripts/admin/lib/autosize.js.es6 @@ -0,0 +1,200 @@ +const set = (typeof Set === "function") ? new Set() : (function () { + const list = []; + + return { + has(key) { + return Boolean(list.indexOf(key) > -1); + }, + add(key) { + list.push(key); + }, + delete(key) { + list.splice(list.indexOf(key), 1); + }, + }; +})(); + +function assign(ta, {setOverflowX = true, setOverflowY = true} = {}) { + if (!ta || !ta.nodeName || ta.nodeName !== 'TEXTAREA' || set.has(ta)) return; + + let heightOffset = null; + let overflowY = null; + let clientWidth = ta.clientWidth; + + function init() { + const style = window.getComputedStyle(ta, null); + + overflowY = style.overflowY; + + if (style.resize === 'vertical') { + ta.style.resize = 'none'; + } else if (style.resize === 'both') { + ta.style.resize = 'horizontal'; + } + + if (style.boxSizing === 'content-box') { + heightOffset = -(parseFloat(style.paddingTop)+parseFloat(style.paddingBottom)); + } else { + heightOffset = parseFloat(style.borderTopWidth)+parseFloat(style.borderBottomWidth); + } + // Fix when a textarea is not on document body and heightOffset is Not a Number + if (isNaN(heightOffset)) { + heightOffset = 0; + } + + update(); + } + + function changeOverflow(value) { + { + // Chrome/Safari-specific fix: + // When the textarea y-overflow is hidden, Chrome/Safari do not reflow the text to account for the space + // made available by removing the scrollbar. The following forces the necessary text reflow. + const width = ta.style.width; + ta.style.width = '0px'; + // Force reflow: + /* jshint ignore:start */ + ta.offsetWidth; + /* jshint ignore:end */ + ta.style.width = width; + } + + overflowY = value; + + if (setOverflowY) { + ta.style.overflowY = value; + } + + resize(); + } + + function resize() { + const htmlTop = window.pageYOffset; + const bodyTop = document.body.scrollTop; + const originalHeight = ta.style.height; + + ta.style.height = 'auto'; + + let endHeight = ta.scrollHeight+heightOffset; + + if (ta.scrollHeight === 0) { + // If the scrollHeight is 0, then the element probably has display:none or is detached from the DOM. + ta.style.height = originalHeight; + return; + } + + ta.style.height = endHeight+'px'; + + // used to check if an update is actually necessary on window.resize + clientWidth = ta.clientWidth; + + // prevents scroll-position jumping + document.documentElement.scrollTop = htmlTop; + document.body.scrollTop = bodyTop; + } + + function update() { + const startHeight = ta.style.height; + + resize(); + + const style = window.getComputedStyle(ta, null); + + if (style.height !== ta.style.height) { + if (overflowY !== 'visible') { + changeOverflow('visible'); + } + } else { + if (overflowY !== 'hidden') { + changeOverflow('hidden'); + } + } + + if (startHeight !== ta.style.height) { + const evt = document.createEvent('Event'); + evt.initEvent('autosize:resized', true, false); + ta.dispatchEvent(evt); + } + } + + const pageResize = () => { + if (ta.clientWidth !== clientWidth) { + update(); + } + }; + + const destroy = style => { + window.removeEventListener('resize', pageResize, false); + ta.removeEventListener('input', update, false); + ta.removeEventListener('keyup', update, false); + ta.removeEventListener('autosize:destroy', destroy, false); + ta.removeEventListener('autosize:update', update, false); + set.delete(ta); + + Object.keys(style).forEach(key => { + ta.style[key] = style[key]; + }); + }.bind(ta, { + height: ta.style.height, + resize: ta.style.resize, + overflowY: ta.style.overflowY, + overflowX: ta.style.overflowX, + wordWrap: ta.style.wordWrap, + }); + + ta.addEventListener('autosize:destroy', destroy, false); + + // IE9 does not fire onpropertychange or oninput for deletions, + // so binding to onkeyup to catch most of those events. + // There is no way that I know of to detect something like 'cut' in IE9. + if ('onpropertychange' in ta && 'oninput' in ta) { + ta.addEventListener('keyup', update, false); + } + + window.addEventListener('resize', pageResize, false); + ta.addEventListener('input', update, false); + ta.addEventListener('autosize:update', update, false); + set.add(ta); + + if (setOverflowX) { + ta.style.overflowX = 'hidden'; + ta.style.wordWrap = 'break-word'; + } + + init(); +} + +function exportDestroy(ta) { + if (!(ta && ta.nodeName && ta.nodeName === 'TEXTAREA')) return; + const evt = document.createEvent('Event'); + evt.initEvent('autosize:destroy', true, false); + ta.dispatchEvent(evt); +} + +function exportUpdate(ta) { + if (!(ta && ta.nodeName && ta.nodeName === 'TEXTAREA')) return; + const evt = document.createEvent('Event'); + evt.initEvent('autosize:update', true, false); + ta.dispatchEvent(evt); +} + +let autosize = (el, options) => { + if (el) { + Array.prototype.forEach.call(el.length ? el : [el], x => assign(x, options)); + } + return el; +}; +autosize.destroy = el => { + if (el) { + Array.prototype.forEach.call(el.length ? el : [el], exportDestroy); + } + return el; +}; +autosize.update = el => { + if (el) { + Array.prototype.forEach.call(el.length ? el : [el], exportUpdate); + } + return el; +}; + +export default autosize; diff --git a/app/assets/javascripts/admin/models/report.js.es6 b/app/assets/javascripts/admin/models/report.js.es6 index a28601d0ce..b4b5912eec 100644 --- a/app/assets/javascripts/admin/models/report.js.es6 +++ b/app/assets/javascripts/admin/models/report.js.es6 @@ -147,7 +147,7 @@ Report.reopenClass({ if (maxY > 0) { json.report.data.forEach(row => row.percentage = Math.round((row.y / maxY) * 100)); } - const model = Discourse.Report.create({ type: type }); + const model = Report.create({ type: type }); model.setProperties(json.report); return model; }); diff --git a/app/assets/javascripts/admin/models/site-text-type.js.es6 b/app/assets/javascripts/admin/models/site-text-type.js.es6 deleted file mode 100644 index 7cb1171e90..0000000000 --- a/app/assets/javascripts/admin/models/site-text-type.js.es6 +++ /dev/null @@ -1,2 +0,0 @@ -import RestModel from 'discourse/models/rest'; -export default RestModel.extend(); diff --git a/app/assets/javascripts/admin/models/site-text.js.es6 b/app/assets/javascripts/admin/models/site-text.js.es6 index edbaf2a444..0d18ad7ea8 100644 --- a/app/assets/javascripts/admin/models/site-text.js.es6 +++ b/app/assets/javascripts/admin/models/site-text.js.es6 @@ -1,8 +1,10 @@ import RestModel from 'discourse/models/rest'; +const { getProperties } = Ember; export default RestModel.extend({ - markdown: Em.computed.equal('format', 'markdown'), - plainText: Em.computed.equal('format', 'plain'), - html: Em.computed.equal('format', 'html'), - css: Em.computed.equal('format', 'css'), + revert() { + return Discourse.ajax(`/admin/customize/site_texts/${this.get('id')}`, { + method: 'DELETE' + }).then(result => getProperties(result.site_text, 'value', 'can_revert')); + } }); diff --git a/app/assets/javascripts/admin/routes/admin-customize-email-templates-edit.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-email-templates-edit.js.es6 index 4e049afba6..b6e4e36bf9 100644 --- a/app/assets/javascripts/admin/routes/admin-customize-email-templates-edit.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-customize-email-templates-edit.js.es6 @@ -6,8 +6,8 @@ export default Ember.Route.extend({ return all.findProperty('id', params.id); }, - setupController(controller, model) { - controller.set('emailTemplate', model); + setupController(controller, emailTemplate) { + controller.setProperties({ emailTemplate, saved: false }); scrollTop(); } }); diff --git a/app/assets/javascripts/admin/routes/admin-reports.js.es6 b/app/assets/javascripts/admin/routes/admin-reports.js.es6 index 829799d794..f3209a4e77 100644 --- a/app/assets/javascripts/admin/routes/admin-reports.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-reports.js.es6 @@ -8,7 +8,8 @@ **/ export default Discourse.Route.extend({ model: function(params) { - return Discourse.Report.find(params.type); + const Report = require('admin/models/report').default; + return Report.find(params.type); }, setupController: function(controller, model) { diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 index b7c7144187..406a001170 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -22,8 +22,9 @@ export default { }); this.resource('adminSiteText', { path: '/site_texts' }, function() { - this.route('edit', {path: '/:text_type'}); + this.route('edit', { path: '/:id' }); }); + this.resource('adminUserFields', { path: '/user_fields' }); this.resource('adminEmojis', { path: '/emojis' }); this.resource('adminPermalinks', { path: '/permalinks' }); diff --git a/app/assets/javascripts/admin/routes/admin-site-text-edit.js.es6 b/app/assets/javascripts/admin/routes/admin-site-text-edit.js.es6 index 847746d039..2774f0aec9 100644 --- a/app/assets/javascripts/admin/routes/admin-site-text-edit.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-site-text-edit.js.es6 @@ -1,5 +1,9 @@ -export default Discourse.Route.extend({ +export default Ember.Route.extend({ model(params) { - return this.store.find('site-text', params.text_type); + return this.store.find('site-text', params.id); + }, + + setupController(controller, siteText) { + controller.setProperties({ siteText, saved: false }); } }); diff --git a/app/assets/javascripts/admin/routes/admin-site-text-index.js.es6 b/app/assets/javascripts/admin/routes/admin-site-text-index.js.es6 new file mode 100644 index 0000000000..510fd93dee --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-site-text-index.js.es6 @@ -0,0 +1,14 @@ +export default Ember.Route.extend({ + queryParams: { + q: { replace: true }, + overridden: { replace: true } + }, + + model(params) { + return this.store.find('site-text', Ember.getProperties(params, 'q', 'overridden')); + }, + + setupController(controller, model) { + controller.set('siteTexts', model); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-site-text.js.es6 b/app/assets/javascripts/admin/routes/admin-site-text.js.es6 deleted file mode 100644 index f69c3c3954..0000000000 --- a/app/assets/javascripts/admin/routes/admin-site-text.js.es6 +++ /dev/null @@ -1,5 +0,0 @@ -export default Discourse.Route.extend({ - model() { - return this.store.findAll('site-text-type'); - } -}); diff --git a/app/assets/javascripts/admin/templates/components/save-controls.hbs b/app/assets/javascripts/admin/templates/components/save-controls.hbs index e8e13cb9c0..00ac91331f 100644 --- a/app/assets/javascripts/admin/templates/components/save-controls.hbs +++ b/app/assets/javascripts/admin/templates/components/save-controls.hbs @@ -1,5 +1,7 @@ -{{d-button action="saveChanges" disabled=buttonDisabled label=savingText class="btn-primary"}} +{{d-button action="saveChanges" disabled=buttonDisabled label=savingText class="btn-primary save-changes"}} {{yield}}
- {{#if saved}}{{i18n 'saved'}}{{/if}} + {{#if saved}} +
{{i18n 'saved'}}
+ {{/if}}
diff --git a/app/assets/javascripts/discourse/templates/components/screened-ip-address-form.hbs b/app/assets/javascripts/admin/templates/components/screened-ip-address-form.hbs similarity index 65% rename from app/assets/javascripts/discourse/templates/components/screened-ip-address-form.hbs rename to app/assets/javascripts/admin/templates/components/screened-ip-address-form.hbs index 5f58a282c4..e846b1c652 100644 --- a/app/assets/javascripts/discourse/templates/components/screened-ip-address-form.hbs +++ b/app/assets/javascripts/admin/templates/components/screened-ip-address-form.hbs @@ -1,4 +1,4 @@ {{i18n 'admin.logs.screened_ips.form.label'}} {{text-field value=ip_address disabled=formSubmitted class="ip-address-input" placeholderKey="admin.logs.screened_ips.form.ip_address" autocorrect="off" autocapitalize="off"}} {{combo-box content=actionNames value=actionName}} - +{{d-button action="submit" disabled=formSubmitted label="admin.logs.screened_ips.form.add"}} diff --git a/app/assets/javascripts/admin/templates/components/site-text-summary.hbs b/app/assets/javascripts/admin/templates/components/site-text-summary.hbs new file mode 100644 index 0000000000..bf5ee8c7f0 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/site-text-summary.hbs @@ -0,0 +1,5 @@ +{{d-button label="admin.site_text.edit" class='edit' action="edit"}} +

{{siteText.id}}

+
{{siteText.value}}
+ +
diff --git a/app/assets/javascripts/admin/templates/customize-email-templates-edit.hbs b/app/assets/javascripts/admin/templates/customize-email-templates-edit.hbs index 97ceb083cd..c23e3caef0 100644 --- a/app/assets/javascripts/admin/templates/customize-email-templates-edit.hbs +++ b/app/assets/javascripts/admin/templates/customize-email-templates-edit.hbs @@ -1,13 +1,14 @@
- + {{/if}} +
- + + {{d-editor value=buffered.body}} {{#save-controls model=emailTemplate action="saveChanges" saved=saved}} {{#if emailTemplate.can_revert}} diff --git a/app/assets/javascripts/admin/templates/group.hbs b/app/assets/javascripts/admin/templates/group.hbs index 81e2da28de..51dbc23cef 100644 --- a/app/assets/javascripts/admin/templates/group.hbs +++ b/app/assets/javascripts/admin/templates/group.hbs @@ -92,6 +92,13 @@ {{combo-box name="grant_trust_level" valueAttribute="value" value=model.grant_trust_level content=trustLevelOptions}}
+ + {{#if siteSettings.email_in}} +
+ + {{text-field name="incoming_email" value=model.incoming_email placeholderKey="admin.groups.incoming_email_placeholder"}} +
+ {{/if}} {{/unless}}
diff --git a/app/assets/javascripts/admin/templates/logs/screened_ip_addresses.hbs b/app/assets/javascripts/admin/templates/logs/screened_ip_addresses.hbs index 515c22a3a0..5aaada9ea2 100644 --- a/app/assets/javascripts/admin/templates/logs/screened_ip_addresses.hbs +++ b/app/assets/javascripts/admin/templates/logs/screened_ip_addresses.hbs @@ -1,11 +1,14 @@

{{i18n 'admin.logs.screened_ips.description'}}

+
{{text-field value=filter class="ip-address-input" placeholderKey="admin.logs.screened_ips.form.filter" autocorrect="off" autocapitalize="off"}} - - + {{d-button action="rollUp" title="admin.logs.screened_ips.roll_up.title" label="admin.logs.screened_ips.roll_up.text"}} + {{d-button action="exportScreenedIpList" icon="download" title="admin.export_csv.button_title.screened_ip" label="admin.export_csv.button_text"}} +
+ +
+ {{screened-ip-address-form action="recordAdded"}}
-{{screened-ip-address-form action="recordAdded"}} -
{{#conditional-loading-spinner condition=loading}} {{#if model.length}} diff --git a/app/assets/javascripts/admin/templates/site-settings.hbs b/app/assets/javascripts/admin/templates/site-settings.hbs index 03042fef84..d7e157a929 100644 --- a/app/assets/javascripts/admin/templates/site-settings.hbs +++ b/app/assets/javascripts/admin/templates/site-settings.hbs @@ -19,7 +19,7 @@ {{#link-to 'adminSiteSettingsCategory' category.nameKey class=category.nameKey}} {{category.name}} {{#if filtered}} - ({{category.count}}) + {{#if category.count}}({{category.count}}){{/if}} {{/if}} {{/link-to}} {{/link-to}} diff --git a/app/assets/javascripts/admin/templates/site-text-edit.hbs b/app/assets/javascripts/admin/templates/site-text-edit.hbs index 5ff141d0c9..5e02eb0731 100644 --- a/app/assets/javascripts/admin/templates/site-text-edit.hbs +++ b/app/assets/javascripts/admin/templates/site-text-edit.hbs @@ -1,17 +1,20 @@ -

{{model.title}}

-

{{model.description}}

+
-{{#if model.markdown}} - {{d-editor value=model.value}} -{{/if}} -{{#if model.plainText}} - {{textarea value=model.value class="plain"}} -{{/if}} -{{#if model.html}} - {{ace-editor content=model.value mode="html"}} -{{/if}} -{{#if model.css}} - {{ace-editor content=model.value mode="css"}} -{{/if}} +
+

{{siteText.id}}

+
-{{save-controls model=model action="saveChanges" saveDisabled=saveDisabled saved=saved}} + {{expanding-text-area value=buffered.value rows="1" class="site-text-value"}} + + {{#save-controls model=siteText action="saveChanges" saved=saved}} + {{#if siteText.can_revert}} + {{d-button action="revertChanges" label="admin.site_text.revert" class="revert-site-text"}} + {{/if}} + {{/save-controls}} + + {{#link-to 'adminSiteText.index' class="go-back"}} + {{fa-icon 'arrow-left'}} + {{i18n 'admin.site_text.go_back'}} + {{/link-to}} + +
diff --git a/app/assets/javascripts/admin/templates/site-text-index.hbs b/app/assets/javascripts/admin/templates/site-text-index.hbs index 5a448def37..7caf9b124e 100644 --- a/app/assets/javascripts/admin/templates/site-text-index.hbs +++ b/app/assets/javascripts/admin/templates/site-text-index.hbs @@ -1 +1,23 @@ -

{{i18n 'admin.site_text.none'}}

+
+

{{i18n "admin.site_text.description"}}

+ + {{text-field value=q + placeholderKey="admin.site_text.search" + class="no-blur site-text-search" + autofocus="true" + key-up="search"}} + +
+ {{d-checkbox label="admin.site_text.show_overriden" checked=overridden change="search"}} +
+
+ +{{#conditional-loading-spinner condition=searching}} + {{#if siteTexts.extras.recommended}} +

{{i18n "admin.site_text.recommended"}}

+ {{/if}} + + {{#each siteTexts as |siteText|}} + {{site-text-summary siteText=siteText editAction="edit" term=q}} + {{/each}} +{{/conditional-loading-spinner}} diff --git a/app/assets/javascripts/admin/templates/site-text.hbs b/app/assets/javascripts/admin/templates/site-text.hbs index 25924f99e9..e12a542d79 100644 --- a/app/assets/javascripts/admin/templates/site-text.hbs +++ b/app/assets/javascripts/admin/templates/site-text.hbs @@ -1,15 +1,3 @@ -
-
-
    - {{#each c in model}} -
  • - {{#link-to 'adminSiteText.edit' c.text_type}}{{c.title}}{{/link-to}} -
  • - {{/each}} -
-
- -
- {{outlet}} -
+
+ {{outlet}}
diff --git a/app/assets/javascripts/admin/templates/user-index.hbs b/app/assets/javascripts/admin/templates/user-index.hbs index bdbee2e536..32190c9670 100644 --- a/app/assets/javascripts/admin/templates/user-index.hbs +++ b/app/assets/javascripts/admin/templates/user-index.hbs @@ -434,26 +434,26 @@

{{i18n 'admin.user.sso.title'}}

- {{#with model.single_sign_on_record}} + {{#with model.single_sign_on_record as |sso|}}
{{i18n 'admin.user.sso.external_id'}}
-
{{external_id}}
+
{{sso.external_id}}
{{i18n 'admin.user.sso.external_username'}}
-
{{external_username}}
+
{{sso.external_username}}
{{i18n 'admin.user.sso.external_name'}}
-
{{external_name}}
+
{{sso.external_name}}
{{i18n 'admin.user.sso.external_email'}}
-
{{external_email}}
+
{{sso.external_email}}
{{i18n 'admin.user.sso.external_avatar_url'}}
-
{{external_avatar_url}}
+
{{sso.external_avatar_url}}
{{/with}}
diff --git a/app/assets/javascripts/discourse/components/activity-filter.js.es6 b/app/assets/javascripts/discourse/components/activity-filter.js.es6 index 876e7bdb26..c0cf5b4e4e 100644 --- a/app/assets/javascripts/discourse/components/activity-filter.js.es6 +++ b/app/assets/javascripts/discourse/components/activity-filter.js.es6 @@ -1,4 +1,5 @@ import StringBuffer from 'discourse/mixins/string-buffer'; +import UserAction from "discourse/models/user-action"; export default Ember.Component.extend(StringBuffer, { tagName: 'li', @@ -27,9 +28,9 @@ export default Ember.Component.extend(StringBuffer, { typeKey: function() { const actionType = this.get('content.action_type'); - if (actionType === Discourse.UserAction.TYPES.messages_received) { return ""; } + if (actionType === UserAction.TYPES.messages_received) { return ""; } - const result = Discourse.UserAction.TYPES_INVERTED[actionType]; + const result = UserAction.TYPES_INVERTED[actionType]; if (!result) { return ""; } // We like our URLS to have hyphens, not underscores @@ -55,11 +56,11 @@ export default Ember.Component.extend(StringBuffer, { icon: function() { switch(parseInt(this.get('content.action_type'), 10)) { - case Discourse.UserAction.TYPES.likes_received: return "heart"; - case Discourse.UserAction.TYPES.bookmarks: return "bookmark"; - case Discourse.UserAction.TYPES.edits: return "pencil"; - case Discourse.UserAction.TYPES.replies: return "reply"; - case Discourse.UserAction.TYPES.mentions: return "at"; + case UserAction.TYPES.likes_received: return "heart"; + case UserAction.TYPES.bookmarks: return "bookmark"; + case UserAction.TYPES.edits: return "pencil"; + case UserAction.TYPES.replies: return "reply"; + case UserAction.TYPES.mentions: return "at"; } }.property("content.action_type") }); diff --git a/app/assets/javascripts/discourse/components/autofocus-text-field.js.es6 b/app/assets/javascripts/discourse/components/autofocus-text-field.js.es6 deleted file mode 100644 index dd1ccd9871..0000000000 --- a/app/assets/javascripts/discourse/components/autofocus-text-field.js.es6 +++ /dev/null @@ -1,12 +0,0 @@ -import { on } from "ember-addons/ember-computed-decorators"; - -export default Ember.TextField.extend({ - - @on("didInsertElement") - becomeFocused() { - const input = this.get("element"); - input.focus(); - input.selectionStart = input.selectionEnd = input.value.length; - } - -}); diff --git a/app/assets/javascripts/discourse/components/category-chooser.js.es6 b/app/assets/javascripts/discourse/components/category-chooser.js.es6 index 24fd8b34a8..1611fcbe5d 100644 --- a/app/assets/javascripts/discourse/components/category-chooser.js.es6 +++ b/app/assets/javascripts/discourse/components/category-chooser.js.es6 @@ -2,6 +2,7 @@ import ComboboxView from 'discourse/components/combo-box'; import { categoryBadgeHTML } from 'discourse/helpers/category-link'; import computed from 'ember-addons/ember-computed-decorators'; import { observes, on } from 'ember-addons/ember-computed-decorators'; +import PermissionType from 'discourse/models/permission-type'; export default ComboboxView.extend({ classNames: ['combobox category-combobox'], @@ -21,7 +22,8 @@ export default ComboboxView.extend({ return categories.filter(c => { if (scopedCategoryId && c.get('id') !== scopedCategoryId && c.get('parent_category_id') !== scopedCategoryId) { return false; } if (c.get('isUncategorizedCategory')) { return false; } - return c.get('permission') === Discourse.PermissionType.FULL; + if (c.get('contains_messages')) { return false; } + return c.get('permission') === PermissionType.FULL; }); }, diff --git a/app/assets/javascripts/discourse/components/category-title-link.js.es6 b/app/assets/javascripts/discourse/components/category-title-link.js.es6 index e00efa6836..bc30157ca6 100644 --- a/app/assets/javascripts/discourse/components/category-title-link.js.es6 +++ b/app/assets/javascripts/discourse/components/category-title-link.js.es6 @@ -1,19 +1,17 @@ +import { iconHTML } from 'discourse/helpers/fa-icon'; + export default Em.Component.extend({ tagName: 'h3', - render: function(buffer) { - var category = this.get('category'), - logoUrl = category.get('logo_url'), - categoryUrl = Discourse.getURL('/c/') + Discourse.Category.slugFor(category), - categoryName = Handlebars.Utils.escapeExpression(category.get('name')); + render(buffer) { + const category = this.get('category'); + const categoryUrl = Discourse.getURL('/c/') + Discourse.Category.slugFor(category); + const categoryName = Handlebars.Utils.escapeExpression(category.get('name')); - if (category.get('read_restricted')) { buffer.push(""); } + if (category.get('read_restricted')) { buffer.push(iconHTML('lock')); } - buffer.push(""); - buffer.push("" + categoryName + ""); - - if (!Em.isEmpty(logoUrl)) { buffer.push(""); } - - buffer.push(""); + buffer.push(``); + buffer.push(`${categoryName}`); + buffer.push(``); } }); diff --git a/app/assets/javascripts/discourse/components/combo-box.js.es6 b/app/assets/javascripts/discourse/components/combo-box.js.es6 index 340ba96615..c314db566a 100644 --- a/app/assets/javascripts/discourse/components/combo-box.js.es6 +++ b/app/assets/javascripts/discourse/components/combo-box.js.es6 @@ -72,17 +72,16 @@ export default Ember.Component.extend({ } const $elem = this.$(); - const minimumResultsForSearch = this.capabilities.touch ? -1 : 5; + const minimumResultsForSearch = this.capabilities.isIOS ? -1 : 5; $elem.select2({formatResult: this.comboTemplate, minimumResultsForSearch, width: 'resolve'}); const castInteger = this.get('castInteger'); - const self = this; - $elem.on("change", function (e) { + $elem.on("change", e => { let val = $(e.target).val(); if (val && val.length && castInteger) { val = parseInt(val, 10); } - self.set('value', val); + this.set('value', val); }); $elem.trigger('change'); }.on('didInsertElement'), diff --git a/app/assets/javascripts/discourse/components/composer-editor.js.es6 b/app/assets/javascripts/discourse/components/composer-editor.js.es6 index b6ffc78ce2..51a7a8fe14 100644 --- a/app/assets/javascripts/discourse/components/composer-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-editor.js.es6 @@ -53,13 +53,13 @@ export default Ember.Component.extend({ template, dataSource: term => userSearch({ term, topicId, includeGroups: true }), key: "@", - transformComplete: v => v.username || v.usernames.join(", @") + transformComplete: v => v.username || v.name }); $input.on('scroll', () => Ember.run.throttle(this, this._syncEditorAndPreviewScroll, 20)); // Focus on the body unless we have a title - if (!this.get('composer.canEditTitle') && !this.capabilities.touch) { + if (!this.get('composer.canEditTitle') && !this.capabilities.isIOS) { this.$('.d-editor-input').putCursorAtEnd(); } @@ -114,6 +114,25 @@ export default Ember.Component.extend({ _renderUnseen: function($preview, unseen) { fetchUnseenMentions($preview, unseen, this.siteSettings).then(() => { linkSeenMentions($preview, this.siteSettings); + this._warnMentionedGroups($preview); + }); + }, + + _warnMentionedGroups($preview) { + Ember.run.scheduleOnce('afterRender', () => { + this._warnedMentions = this._warnedMentions || []; + var found = []; + $preview.find('.mention-group.notify').each((idx,e) => { + const $e = $(e); + var name = $e.data('name'); + found.push(name); + if (this._warnedMentions.indexOf(name) === -1){ + this._warnedMentions.push(name); + this.sendAction('groupsMentioned', [{name: name, user_count: $e.data('mentionable-user-count')}]); + } + }); + + this._warnedMentions = found; }); }, @@ -370,6 +389,8 @@ export default Ember.Component.extend({ Ember.run.debounce(this, this._renderUnseen, $preview, unseen, 500); } + this._warnMentionedGroups($preview); + const post = this.get('composer.post'); let refresh = false; diff --git a/app/assets/javascripts/discourse/components/composer-title.js.es6 b/app/assets/javascripts/discourse/components/composer-title.js.es6 index 6dc5f6e56b..2eae670557 100644 --- a/app/assets/javascripts/discourse/components/composer-title.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-title.js.es6 @@ -5,7 +5,7 @@ export default Ember.Component.extend({ @on('didInsertElement') _focusOnTitle() { - if (!this.capabilities.touch) { + if (!this.capabilities.isIOS) { this.$('input').putCursorAtEnd(); } }, diff --git a/app/assets/javascripts/discourse/components/d-checkbox.js.es6 b/app/assets/javascripts/discourse/components/d-checkbox.js.es6 new file mode 100644 index 0000000000..217ec2c3ae --- /dev/null +++ b/app/assets/javascripts/discourse/components/d-checkbox.js.es6 @@ -0,0 +1,18 @@ +import { on } from "ember-addons/ember-computed-decorators"; + +export default Ember.Component.extend({ + tagName: 'label', + + @on('didInsertElement') + _watchChanges() { + // In Ember 13.3 we can use action on the checkbox `{{input}}` but not in 1.11 + this.$('input').on('click.d-checkbox', () => { + Ember.run.scheduleOnce('afterRender', () => this.sendAction('change')); + }); + }, + + @on('willDestroyElement') + _stopWatching() { + this.$('input').off('click.d-checkbox'); + } +}); diff --git a/app/assets/javascripts/discourse/components/d-editor-modal.js.es6 b/app/assets/javascripts/discourse/components/d-editor-modal.js.es6 index fc2206b832..1b2ff96d78 100644 --- a/app/assets/javascripts/discourse/components/d-editor-modal.js.es6 +++ b/app/assets/javascripts/discourse/components/d-editor-modal.js.es6 @@ -29,9 +29,11 @@ export default Ember.Component.extend({ if (key.keyCode === 27) { this.send('cancel'); + return false; } if (key.keyCode === 13) { this.send('ok'); + return false; } }); }, diff --git a/app/assets/javascripts/discourse/components/d-editor.js.es6 b/app/assets/javascripts/discourse/components/d-editor.js.es6 index 8c71ad9de7..0898b2cd1d 100644 --- a/app/assets/javascripts/discourse/components/d-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/d-editor.js.es6 @@ -318,6 +318,9 @@ export default Ember.Component.extend({ _selectText(from, length) { Ember.run.scheduleOnce('afterRender', () => { const textarea = this.$('textarea.d-editor-input')[0]; + if (!this.capabilities.isIOS) { + textarea.focus(); + } textarea.selectionStart = from; textarea.selectionEnd = textarea.selectionStart + length; }); @@ -412,7 +415,7 @@ export default Ember.Component.extend({ const insert = `${sel.pre}${text}`; this.set('value', `${insert}${sel.post}`); this._selectText(insert.length, 0); - Ember.run.once("afterRender", () => { $("textarea.d-editor-input").focus(); } ); + Ember.run.scheduleOnce("afterRender", () => this.$("textarea.d-editor-input").focus()); }, actions: { diff --git a/app/assets/javascripts/discourse/components/edit-category-security.js.es6 b/app/assets/javascripts/discourse/components/edit-category-security.js.es6 index 60c3f4ddc1..502ed2325d 100644 --- a/app/assets/javascripts/discourse/components/edit-category-security.js.es6 +++ b/app/assets/javascripts/discourse/components/edit-category-security.js.es6 @@ -1,4 +1,5 @@ import { buildCategoryPanel } from 'discourse/components/edit-category-panel'; +import PermissionType from 'discourse/models/permission-type'; export default buildCategoryPanel('security', { editingPermissions: false, @@ -16,7 +17,7 @@ export default buildCategoryPanel('security', { if (!this.get('category.is_special')) { this.get('category').addPermission({ group_name: group + "", - permission: Discourse.PermissionType.create({id}) + permission: PermissionType.create({id}) }); } }, diff --git a/app/assets/javascripts/discourse/components/group-notifications-button.js.es6 b/app/assets/javascripts/discourse/components/group-notifications-button.js.es6 new file mode 100644 index 0000000000..6010cf02a2 --- /dev/null +++ b/app/assets/javascripts/discourse/components/group-notifications-button.js.es6 @@ -0,0 +1,11 @@ +import NotificationsButton from 'discourse/components/notifications-button'; + +export default NotificationsButton.extend({ + classNames: ['notification-options', 'group-notification-menu'], + notificationLevel: Em.computed.alias('group.notification_level'), + i18nPrefix: 'groups.notifications', + + clicked(id) { + this.get('group').setNotification(id); + } +}); diff --git a/app/assets/javascripts/discourse/components/menu-panel.js.es6 b/app/assets/javascripts/discourse/components/menu-panel.js.es6 index c35a77923a..988dc50e79 100644 --- a/app/assets/javascripts/discourse/components/menu-panel.js.es6 +++ b/app/assets/javascripts/discourse/components/menu-panel.js.es6 @@ -100,7 +100,7 @@ export default Ember.Component.extend({ this._watchSizeChanges(); // iOS does not handle scroll events well - if (!this.capabilities.touch) { + if (!this.capabilities.isIOS) { $(window).on('scroll.discourse-menu-panel', () => this.performLayout()); } } else { diff --git a/app/assets/javascripts/discourse/components/popup-menu.js.es6 b/app/assets/javascripts/discourse/components/popup-menu.js.es6 index a40096153f..9be0b8f3ef 100644 --- a/app/assets/javascripts/discourse/components/popup-menu.js.es6 +++ b/app/assets/javascripts/discourse/components/popup-menu.js.es6 @@ -1,7 +1,7 @@ import { on } from 'ember-addons/ember-computed-decorators'; export default Ember.Component.extend({ - classNameBindings: ["visible::hidden", ":popup-menu"], + classNameBindings: ["visible::hidden", ":popup-menu", "extraClasses"], @on('didInsertElement') _setup() { diff --git a/app/assets/javascripts/discourse/components/topic-footer-mobile-dropdown.js.es6 b/app/assets/javascripts/discourse/components/topic-footer-mobile-dropdown.js.es6 new file mode 100644 index 0000000000..a975ade19d --- /dev/null +++ b/app/assets/javascripts/discourse/components/topic-footer-mobile-dropdown.js.es6 @@ -0,0 +1,60 @@ +import Combobox from 'discourse/components/combo-box'; +import { on, observes } from 'ember-addons/ember-computed-decorators'; + +export default Combobox.extend({ + none: "topic.controls", + + @on('init') + _createContent() { + const content = []; + const topic = this.get('topic'); + const details = topic.get('details'); + + if (details.get('can_invite_to')) { + content.push({ id: 'invite', name: I18n.t('topic.invite_reply.title') }); + } + + if (topic.get('bookmarked')) { + content.push({ id: 'bookmark', name: I18n.t('bookmarked.clear_bookmarks') }); + } else { + content.push({ id: 'bookmark', name: I18n.t('bookmarked.title') }); + } + content.push({ id: 'share', name: I18n.t('topic.share.title') }); + + if (details.get('can_flag_topic')) { + content.push({ id: 'flag', name: I18n.t('topic.flag_topic.title') }); + } + + this.set('content', content); + }, + + @observes('value') + _valueChanged() { + const value = this.get('value'); + const controller = this.get('parentView.controller'); + const topic = this.get('topic'); + + const refresh = () => { + this._createContent(); + this.set('value', null); + }; + + switch(value) { + case 'invite': + controller.send('showInvite'); + refresh(); + break; + case 'bookmark': + topic.toggleBookmark().then(() => refresh()); + break; + case 'share': + this.appEvents.trigger('share:url', topic.get('shareUrl'), $('#topic-footer-buttons')); + refresh(); + break; + case 'flag': + controller.send('showFlagTopic', topic); + refresh(); + break; + } + } +}); diff --git a/app/assets/javascripts/discourse/components/user-selector.js.es6 b/app/assets/javascripts/discourse/components/user-selector.js.es6 index 5b0be280c1..aa1c4b7e81 100644 --- a/app/assets/javascripts/discourse/components/user-selector.js.es6 +++ b/app/assets/javascripts/discourse/components/user-selector.js.es6 @@ -6,7 +6,9 @@ export default TextField.extend({ _initializeAutocomplete: function() { var self = this, selected = [], + groups = [], currentUser = this.currentUser, + includeMentionableGroups = this.get('includeMentionableGroups') === 'true', includeGroups = this.get('includeGroups') === 'true', allowedUsers = this.get('allowedUsers') === 'true'; @@ -24,18 +26,22 @@ export default TextField.extend({ allowAny: this.get('allowAny'), dataSource: function(term) { - return userSearch({ + var results = userSearch({ term: term.replace(/[^a-zA-Z0-9_\-\.]/, ''), topicId: self.get('topicId'), exclude: excludedUsernames(), includeGroups, - allowedUsers + allowedUsers, + includeMentionableGroups }); + + return results; }, transformComplete: function(v) { - if (v.username) { - return v.username; + if (v.username || v.name) { + if (!v.username) { groups.push(v.name); } + return v.username || v.name; } else { var excludes = excludedUsernames(); return v.usernames.filter(function(item){ @@ -45,10 +51,14 @@ export default TextField.extend({ }, onChangeItems: function(items) { + var hasGroups = false; items = items.map(function(i) { + if (groups.indexOf(i) > -1) { hasGroups = true; } return i.username ? i.username : i; }); self.set('usernames', items.join(",")); + self.set('hasGroups', hasGroups); + selected = items; }, diff --git a/app/assets/javascripts/discourse/controllers/composer-messages.js.es6 b/app/assets/javascripts/discourse/controllers/composer-messages.js.es6 index 345837474f..7a712ba748 100644 --- a/app/assets/javascripts/discourse/controllers/composer-messages.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer-messages.js.es6 @@ -48,6 +48,23 @@ export default Ember.ArrayController.extend({ this.get('queuedForTyping').forEach(msg => this.send("popup", msg)); }, + groupsMentioned(groups) { + // reset existing messages, this should always win it is critical + this.reset(); + groups.forEach(group => { + const msg = I18n.t('composer.group_mentioned', { + group: "@" + group.name, + count: group.user_count, + group_link: Discourse.getURL(`/group/${group.name}/members`) + }); + this.send("popup", + Em.Object.create({ + templateName: 'composer/group-mentioned', + body: msg}) + ); + }); + }, + // Figure out if there are any messages that should be displayed above the composer. queryFor(composer) { if (this.get('checkedMessages')) { return; } diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index d77c061c4d..1c7384c254 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -75,9 +75,10 @@ export default Ember.Controller.extend({ if (!Discourse.User.currentProp('staff')) { return false; } var usernames = this.get('model.targetUsernames'); + var hasTargetGroups = this.get('model.hasTargetGroups'); // We need exactly one user to issue a warning - if (Ember.isEmpty(usernames) || usernames.split(',').length !== 1) { + if (Ember.isEmpty(usernames) || usernames.split(',').length !== 1 || hasTargetGroups) { return false; } return this.get('model.creatingPrivateMessage'); @@ -114,7 +115,7 @@ export default Ember.Controller.extend({ // If there is no current post, use the first post id from the stream if (!postId && postStream) { - postId = postStream.get('firstPostId'); + postId = postStream.get('stream.firstObject'); } // If we're editing a post, fetch the reply when importing a quote @@ -170,6 +171,12 @@ export default Ember.Controller.extend({ } }, + groupsMentioned(groups) { + if (!this.get('model.creatingPrivateMessage') && !this.get('model.topic.isPrivateMessage')) { + this.get('controllers.composer-messages').groupsMentioned(groups); + } + } + }, categories: function() { diff --git a/app/assets/javascripts/discourse/controllers/create-account.js.es6 b/app/assets/javascripts/discourse/controllers/create-account.js.es6 index b98a3285ac..adbd78f6c5 100644 --- a/app/assets/javascripts/discourse/controllers/create-account.js.es6 +++ b/app/assets/javascripts/discourse/controllers/create-account.js.es6 @@ -42,7 +42,7 @@ export default Ember.Controller.extend(ModalFunctionality, { }, submitDisabled: function() { - if (!this.get('passwordRequired')) return false; // 3rd party auth + if (!this.get('emailValidation.failed') && !this.get('passwordRequired')) return false; // 3rd party auth if (this.get('formSubmitted')) return true; if (this.get('nameValidation.failed')) return true; if (this.get('emailValidation.failed')) return true; diff --git a/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 b/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 index c4d639f361..83b493076f 100644 --- a/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 +++ b/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 @@ -12,7 +12,6 @@ export default DiscoveryController.extend({ actions: { refresh() { - // Don't refresh if we're still loading if (this.get('controllers.discovery.loading')) { return; } @@ -21,9 +20,10 @@ export default DiscoveryController.extend({ // Lesson learned: Don't call `loading` yourself. this.set('controllers.discovery.loading', true); + const CategoryList = require('discourse/models/category-list').default; const parentCategory = this.get('model.parentCategory'); - const promise = parentCategory ? Discourse.CategoryList.listForParent(this.store, parentCategory) : - Discourse.CategoryList.list(this.store); + const promise = parentCategory ? CategoryList.listForParent(this.store, parentCategory) : + CategoryList.list(this.store); const self = this; promise.then(function(list) { @@ -38,7 +38,7 @@ export default DiscoveryController.extend({ }.property(), latestTopicOnly: function() { - return this.get('model.categories').find(function(c) { return c.get('featuredTopics.length') > 1; }) === undefined; + return this.get('model.categories').find(c => c.get('featuredTopics.length') > 1) === undefined; }.property('model.categories.@each.featuredTopics.length') }); diff --git a/app/assets/javascripts/discourse/controllers/edit-topic-auto-close.js.es6 b/app/assets/javascripts/discourse/controllers/edit-topic-auto-close.js.es6 index 0c8795dd1d..9cd0e00e75 100644 --- a/app/assets/javascripts/discourse/controllers/edit-topic-auto-close.js.es6 +++ b/app/assets/javascripts/discourse/controllers/edit-topic-auto-close.js.es6 @@ -5,6 +5,8 @@ import ModalFunctionality from 'discourse/mixins/modal-functionality'; export default Ember.Controller.extend(ModalFunctionality, { auto_close_valid: true, auto_close_invalid: Em.computed.not('auto_close_valid'), + disable_submit: Em.computed.or('auto_close_invalid', 'loading'), + loading: false, @observes("model.details.auto_close_at", "model.details.auto_close_hours") setAutoCloseTime() { @@ -29,7 +31,7 @@ export default Ember.Controller.extend(ModalFunctionality, { setAutoClose(time) { const self = this; - this.send('hideModal'); + this.set('loading', true); Discourse.ajax({ url: `/t/${this.get('model.id')}/autoclose`, type: 'PUT', @@ -40,16 +42,34 @@ export default Ember.Controller.extend(ModalFunctionality, { timezone_offset: (new Date().getTimezoneOffset()) } }).then(result => { + self.set('loading', false); if (result.success) { this.send('closeModal'); this.set('model.details.auto_close_at', result.auto_close_at); this.set('model.details.auto_close_hours', result.auto_close_hours); } else { - bootbox.alert(I18n.t('composer.auto_close.error'), function() { self.send('reopenModal'); } ); + bootbox.alert(I18n.t('composer.auto_close.error')); } }).catch(() => { - bootbox.alert(I18n.t('composer.auto_close.error'), function() { self.send('reopenModal'); } ); + // TODO - incorrectly responds to network errors as bad input + bootbox.alert(I18n.t('composer.auto_close.error')); + self.set('loading', false); }); - } + }, + + willCloseImmediately: function() { + if (!this.get('model.details.auto_close_based_on_last_post')) { + return false; + } + let closeDate = new Date(this.get('model.last_posted_at')); + closeDate.setHours(closeDate.getHours() + this.get('model.auto_close_time')); + return closeDate < new Date(); + }.property('model.details.auto_close_based_on_last_post', 'model.auto_close_time', 'model.last_posted_at'), + + willCloseI18n: function() { + if (this.get('model.details.auto_close_based_on_last_post')) { + return I18n.t('topic.auto_close_immediate', {hours: this.get('model.auto_close_time')}); + } + }.property('model.details.auto_close_based_on_last_post', 'model.auto_close_time') }); diff --git a/app/assets/javascripts/discourse/controllers/flag.js.es6 b/app/assets/javascripts/discourse/controllers/flag.js.es6 index bc0a311e5d..07db5951b1 100644 --- a/app/assets/javascripts/discourse/controllers/flag.js.es6 +++ b/app/assets/javascripts/discourse/controllers/flag.js.es6 @@ -1,4 +1,5 @@ import ModalFunctionality from 'discourse/mixins/modal-functionality'; +import ActionSummary from 'discourse/models/action-summary'; import { MAX_MESSAGE_LENGTH } from 'discourse/models/post-action-type'; export default Ember.Controller.extend(ModalFunctionality, { @@ -33,7 +34,7 @@ export default Ember.Controller.extend(ModalFunctionality, { _.each(this.get("model.actions_summary"),function(a) { a.flagTopic = self.get('model'); a.actionType = self.site.topicFlagTypeById(a.id); - const actionSummary = Discourse.ActionSummary.create(a); + const actionSummary = ActionSummary.create(a); lookup.set(a.actionType.get('name_key'), actionSummary); }); this.set('topicActionByName', lookup); diff --git a/app/assets/javascripts/discourse/controllers/forgot-password.js.es6 b/app/assets/javascripts/discourse/controllers/forgot-password.js.es6 index 2a1c46fa41..00d551bd82 100644 --- a/app/assets/javascripts/discourse/controllers/forgot-password.js.es6 +++ b/app/assets/javascripts/discourse/controllers/forgot-password.js.es6 @@ -4,7 +4,7 @@ export default Ember.Controller.extend(ModalFunctionality, { // You need a value in the field to submit it. submitDisabled: function() { - return Ember.isEmpty(this.get('accountEmailOrUsername')) || this.get('disabled'); + return Ember.isEmpty(this.get('accountEmailOrUsername').trim()) || this.get('disabled'); }.property('accountEmailOrUsername', 'disabled'), actions: { @@ -43,7 +43,7 @@ export default Ember.Controller.extend(ModalFunctionality, { }; Discourse.ajax('/session/forgot_password', { - data: { login: this.get('accountEmailOrUsername') }, + data: { login: this.get('accountEmailOrUsername').trim() }, type: 'POST' }).then(success, fail).finally(function(){ setTimeout(function(){ diff --git a/app/assets/javascripts/discourse/controllers/group.js.es6 b/app/assets/javascripts/discourse/controllers/group.js.es6 index dcd408673d..5f31424533 100644 --- a/app/assets/javascripts/discourse/controllers/group.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group.js.es6 @@ -1,9 +1,39 @@ +import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; + +var Tab = Em.Object.extend({ + @computed('name') + location(name) { + return 'group.' + name; + } +}); + + export default Ember.Controller.extend({ counts: null, - showing: null, + showing: 'posts', - // It would be nice if bootstrap marked action lists as selected when their links - // were 'active' not the `li` tags. - showingIndex: Em.computed.equal('showing', 'index'), - showingMembers: Em.computed.equal('showing', 'members') + @observes('counts') + countsChanged() { + const counts = this.get('counts'); + this.get('tabs').forEach(tab => { + tab.set('count', counts.get(tab.get('name'))); + }); + }, + + @observes('showing') + showingChanged() { + const showing = this.get('showing'); + + this.get('tabs').forEach(tab => { + tab.set('active', showing === tab.get('name')); + }); + }, + + tabs: [ + Tab.create({ name: 'posts', active: true, 'location': 'group.index' }), + Tab.create({ name: 'topics' }), + Tab.create({ name: 'mentions' }), + Tab.create({ name: 'members' }), + Tab.create({ name: 'messages' }), + ] }); diff --git a/app/assets/javascripts/discourse/controllers/group/index.js.es6 b/app/assets/javascripts/discourse/controllers/group/index.js.es6 index 7cac540845..60df6a2cdf 100644 --- a/app/assets/javascripts/discourse/controllers/group/index.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group/index.js.es6 @@ -1,10 +1,5 @@ /** Handles displaying posts within a group - - @class GroupIndexController - @extends Ember.ArrayController - @namespace Discourse - @module Discourse **/ export default Ember.ArrayController.extend({ needs: ['group'], @@ -21,7 +16,8 @@ export default Ember.ArrayController.extend({ var lastPostId = posts[posts.length-1].get('id'), group = this.get('controllers.group.model'); - group.findPosts({beforePostId: lastPostId}).then(function(newPosts) { + var opts = {beforePostId: lastPostId, type: this.get('type')}; + group.findPosts(opts).then(function(newPosts) { posts.addObjects(newPosts); self.set('loading', false); }); diff --git a/app/assets/javascripts/discourse/controllers/group/members.js.es6 b/app/assets/javascripts/discourse/controllers/group/members.js.es6 index 0391cc4764..246a5f80cd 100644 --- a/app/assets/javascripts/discourse/controllers/group/members.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group/members.js.es6 @@ -1,20 +1,21 @@ import { popupAjaxError } from 'discourse/lib/ajax-error'; +import computed from 'ember-addons/ember-computed-decorators'; export default Ember.Controller.extend({ loading: false, limit: null, offset: null, - isOwner: function() { + @computed('model.owners.@each') + isOwner(owners) { if (this.get('currentUser.admin')) { return true; } - const owners = this.get('model.owners'); const currentUserId = this.get('currentUser.id'); if (currentUserId) { return !!owners.findBy('id', currentUserId); } - }.property('model.owners.@each'), + }, actions: { removeMember(user) { diff --git a/app/assets/javascripts/discourse/controllers/group/mentions.js.es6 b/app/assets/javascripts/discourse/controllers/group/mentions.js.es6 new file mode 100644 index 0000000000..81fa5a8ffd --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/group/mentions.js.es6 @@ -0,0 +1,3 @@ +import IndexController from 'discourse/controllers/group/index'; + +export default IndexController.extend({type: 'mentions'}); diff --git a/app/assets/javascripts/discourse/controllers/group/topics.js.es6 b/app/assets/javascripts/discourse/controllers/group/topics.js.es6 new file mode 100644 index 0000000000..9423350320 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/group/topics.js.es6 @@ -0,0 +1,3 @@ +import IndexController from 'discourse/controllers/group/index'; + +export default IndexController.extend({type: 'topics'}); diff --git a/app/assets/javascripts/discourse/controllers/login.js.es6 b/app/assets/javascripts/discourse/controllers/login.js.es6 index 29db5b1d2c..b2eed5ceed 100644 --- a/app/assets/javascripts/discourse/controllers/login.js.es6 +++ b/app/assets/javascripts/discourse/controllers/login.js.es6 @@ -81,7 +81,7 @@ export default Ember.Controller.extend(ModalFunctionality, { const ssoDestinationUrl = $.cookie('sso_destination_url'); $hidden_login_form.find('input[name=username]').val(self.get('loginName')); $hidden_login_form.find('input[name=password]').val(self.get('loginPassword')); - + if (ssoDestinationUrl) { $.cookie('sso_destination_url', null); window.location.assign(ssoDestinationUrl); @@ -203,10 +203,14 @@ export default Ember.Controller.extend(ModalFunctionality, { // Reload the page if we're authenticated if (options.authenticated) { const destinationUrl = $.cookie('destination_url'); + const shouldRedirectToUrl = self.session.get("shouldRedirectToUrl"); if (self.get('loginRequired') && destinationUrl) { // redirect client to the original URL $.cookie('destination_url', null); window.location.href = destinationUrl; + } else if (shouldRedirectToUrl) { + self.session.set("shouldRedirectToUrl", null); + window.location.href = shouldRedirectToUrl; } else if (window.location.pathname === Discourse.getURL('/login')) { window.location.pathname = Discourse.getURL('/'); } else { diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 8f27f093d6..2ab47ccc6f 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -34,6 +34,17 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { } }.observes('model.title', 'category'), + @computed('model.postStream.posts') + postsToRender() { + return this.capabilities.isAndroid ? this.get('model.postStream.posts') + : this.get('model.postStream.postsWithPlaceholders'); + }, + + @computed('model.postStream.loadingFilter') + androidLoading(loading) { + return this.capabilities.isAndroid && loading; + }, + @computed('model.postStream.summary') show_deleted: { set(value) { @@ -78,6 +89,13 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { this.set('selectedReplies', []); }.on('init'), + @computed("model.isPrivateMessage", "model.category_id") + showCategoryChooser(isPrivateMessage, categoryId) { + const category = Discourse.Category.findById(categoryId); + const containsMessages = category && category.get("contains_messages"); + return !isPrivateMessage && !containsMessages; + }, + actions: { showTopicAdminMenu() { this.set('adminMenuVisible', true); @@ -141,6 +159,9 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { if (post.get('post_number') === 1) { this.deleteTopic(); return; + } else if (!post.can_delete) { + // check if current user can delete post + return false; } const user = Discourse.User.current(), @@ -182,6 +203,11 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { return bootbox.alert(I18n.t('post.controls.edit_anonymous')); } + // check if current user can edit post + if (!post.can_edit) { + return false; + } + const composer = this.get('controllers.composer'), composerModel = composer.get('model'), opts = { @@ -394,7 +420,11 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { draftKey: Composer.REPLY_AS_NEW_TOPIC_KEY, categoryId: this.get('category.id') }).then(() => { - return Em.isEmpty(quotedText) ? Discourse.Post.loadQuote(post.get('id')) : quotedText; + if (Em.isEmpty(quotedText)) { + return Discourse.Post.loadQuote(post.get('id')); + } else { + composerController.get('model').appendText(quotedText); + } }).then(q => { const postUrl = `${location.protocol}//${location.host}${post.get('url')}`; const postLink = `[${Handlebars.escapeExpression(self.get('model.title'))}](${postUrl})`; @@ -661,8 +691,8 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { topVisibleChanged(post) { if (!post) { return; } - const postStream = this.get('model.postStream'), - firstLoadedPost = postStream.get('firstLoadedPost'); + const postStream = this.get('model.postStream'); + const firstLoadedPost = postStream.get('posts.firstObject'); this.set('model.currentPost', post.get('post_number')); @@ -673,15 +703,17 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { // trigger a scroll after a promise resolves in a controller? We need // to do this to preserve upwards infinte scrolling. const $body = $('body'); - let $elem = $('#post-cloak-' + post.get('post_number')); - const distToElement = $body.scrollTop() - $elem.position().top; + const elemId = `#post_${post.get('post_number')}`; + const $elem = $(elemId).closest('.post-cloak'); + const elemPos = $elem.position(); + const distToElement = elemPos ? $body.scrollTop() - elemPos.top : 0; postStream.prependMore().then(function() { Em.run.next(function () { - $elem = $('#post-cloak-' + post.get('post_number')); + const $refreshedElem = $(elemId).closest('.post-cloak'); // Quickly going back might mean the element is destroyed - const position = $elem.position(); + const position = $refreshedElem.position(); if (position && position.top) { $('html, body').scrollTop(position.top + distToElement); } @@ -699,8 +731,8 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { bottomVisibleChanged(post) { if (!post) { return; } - const postStream = this.get('model.postStream'), - lastLoadedPost = postStream.get('lastLoadedPost'); + const postStream = this.get('model.postStream'); + const lastLoadedPost = postStream.get('posts.lastObject'); this.set('controllers.topic-progress.progressPosition', postStream.progressIndexOfPost(post)); diff --git a/app/assets/javascripts/discourse/controllers/user.js.es6 b/app/assets/javascripts/discourse/controllers/user.js.es6 index 934a835783..49889e5c02 100644 --- a/app/assets/javascripts/discourse/controllers/user.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user.js.es6 @@ -1,6 +1,8 @@ import { exportUserArchive } from 'discourse/lib/export-csv'; import CanCheckEmails from 'discourse/mixins/can-check-emails'; import computed from 'ember-addons/ember-computed-decorators'; +import UserAction from 'discourse/models/user-action'; +import User from 'discourse/models/user'; export default Ember.Controller.extend(CanCheckEmails, { indexStream: false, @@ -10,7 +12,7 @@ export default Ember.Controller.extend(CanCheckEmails, { @computed("content.username") viewingSelf(username) { - return username === Discourse.User.currentProp('username'); + return username === User.currentProp('username'); }, @computed('indexStream', 'viewingSelf', 'forceExpand') @@ -39,13 +41,13 @@ export default Ember.Controller.extend(CanCheckEmails, { @computed("userActionType") privateMessageView(userActionType) { - return (userActionType === Discourse.UserAction.TYPES.messages_sent) || - (userActionType === Discourse.UserAction.TYPES.messages_received); + return (userActionType === UserAction.TYPES.messages_sent) || + (userActionType === UserAction.TYPES.messages_received); }, @computed() canInviteToForum() { - return Discourse.User.currentProp('can_invite_to_forum'); + return User.currentProp('can_invite_to_forum'); }, canDeleteUser: Ember.computed.and("model.can_be_deleted", "model.can_delete_all_posts"), @@ -66,13 +68,28 @@ export default Ember.Controller.extend(CanCheckEmails, { privateMessagesMineActive: Em.computed.equal('pmView', 'mine'), privateMessagesUnreadActive: Em.computed.equal('pmView', 'unread'), + @computed('model.private_messages_stats.groups', 'groupFilter', 'pmView') + groupPMStats(stats,filter,pmView) { + if (stats) { + return stats.map(g => { + return { + name: g.name, + count: g.count, + active: (g.name === filter && pmView === 'groups') + }; + }); + } + }, + actions: { expandProfile() { this.set('forceExpand', true); }, adminDelete() { - Discourse.AdminUser.find(this.get('model.username').toLowerCase()) + // I really want this deferred, don't want to bring in all this code till used + const AdminUser = require('admin/models/admin-user').default; + AdminUser.find(this.get('model.username').toLowerCase()) .then(user => user.destroy({deletePosts: true})); }, diff --git a/app/assets/javascripts/discourse/dialects/mention_dialect.js b/app/assets/javascripts/discourse/dialects/mention_dialect.js index 9579f04afe..20af912c6d 100644 --- a/app/assets/javascripts/discourse/dialects/mention_dialect.js +++ b/app/assets/javascripts/discourse/dialects/mention_dialect.js @@ -14,8 +14,11 @@ Discourse.Dialect.inlineRegexp({ var username = matches[1], mentionLookup = this.dialect.options.mentionLookup; - if (mentionLookup && mentionLookup(username.substr(1))) { + var type = mentionLookup && mentionLookup(username.substr(1)); + if (type === "user") { return ['a', {'class': 'mention', href: Discourse.getURL("/users/") + username.substr(1).toLowerCase()}, username]; + } else if (type === "group") { + return ['a', {'class': 'mention-group', href: Discourse.getURL("/groups/") + username.substr(1)}, username]; } else { return ['span', {'class': 'mention'}, username]; } diff --git a/app/assets/javascripts/discourse/initializers/live-development.js.es6 b/app/assets/javascripts/discourse/initializers/live-development.js.es6 index 701789cf7f..a7318b1a79 100644 --- a/app/assets/javascripts/discourse/initializers/live-development.js.es6 +++ b/app/assets/javascripts/discourse/initializers/live-development.js.es6 @@ -32,7 +32,10 @@ export default { // Observe file changes messageBus.subscribe("/file-change", function(data) { - Ember.TEMPLATES.empty = Handlebars.compile("
"); + if (Handlebars.compile && !Ember.TEMPLATES.empty) { + // hbs notifications only happen in dev + Ember.TEMPLATES.empty = Handlebars.compile("
"); + } _.each(data,function(me) { if (me === "refresh") { diff --git a/app/assets/javascripts/discourse/initializers/localization.js.es6 b/app/assets/javascripts/discourse/initializers/localization.js.es6 index f59a680b0d..0ad607d3f4 100644 --- a/app/assets/javascripts/discourse/initializers/localization.js.es6 +++ b/app/assets/javascripts/discourse/initializers/localization.js.es6 @@ -12,6 +12,7 @@ export default { const overrides = PreloadStore.get('translationOverrides') || {}; Object.keys(overrides).forEach(k => { const v = overrides[k]; + k = k.replace('admin_js', 'js'); const segs = k.split('.'); let node = I18n.translations[I18n.locale]; diff --git a/app/assets/javascripts/discourse/lib/autocomplete.js.es6 b/app/assets/javascripts/discourse/lib/autocomplete.js.es6 index 77ae807571..78690f624b 100644 --- a/app/assets/javascripts/discourse/lib/autocomplete.js.es6 +++ b/app/assets/javascripts/discourse/lib/autocomplete.js.es6 @@ -6,7 +6,7 @@ export var CANCELLED_STATUS = "__CANCELLED"; -const allowedLettersRegex = /[\s\t\[\{\(]/; +const allowedLettersRegex = /[\s\t\[\{\(\/]/; var keys = { backSpace: 8, diff --git a/app/assets/javascripts/discourse/lib/click-track.js.es6 b/app/assets/javascripts/discourse/lib/click-track.js.es6 index 65800a3750..a0e8264b78 100644 --- a/app/assets/javascripts/discourse/lib/click-track.js.es6 +++ b/app/assets/javascripts/discourse/lib/click-track.js.es6 @@ -6,7 +6,7 @@ export default { if (Discourse.Utilities.selectedText() !== "") { return false; } var $link = $(e.currentTarget); - if ($link.hasClass('lightbox')) { return true; } + if ($link.hasClass('lightbox') || $link.hasClass('mention-group')) { return true; } var href = $link.attr('href') || $link.data('href'), $article = $link.closest('article'), diff --git a/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 b/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 index b556316232..d20a1c0109 100644 --- a/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 +++ b/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 @@ -11,7 +11,8 @@ let lastAction = -1; const focusTrackerKey = "focus-tracker"; const idleThresholdTime = 1000 * 10; // 10 seconds -const keyValueStore = new KeyValueStore("discourse_desktop_notifications_"); +const context = "discourse_desktop_notifications_"; +const keyValueStore = new KeyValueStore(context); // Called from an initializer function init(messageBus) { @@ -60,7 +61,7 @@ function setupNotifications() { window.addEventListener("storage", function(e) { // note: This event only fires when other tabs setItem() const key = e.key; - if (key !== focusTrackerKey) { + if (key !== `${context}${focusTrackerKey}`) { return true; } primaryTab = false; diff --git a/app/assets/javascripts/discourse/lib/ember_compat_handlebars.js b/app/assets/javascripts/discourse/lib/ember_compat_handlebars.js index d6e85b19a7..a6666cc6c6 100644 --- a/app/assets/javascripts/discourse/lib/ember_compat_handlebars.js +++ b/app/assets/javascripts/discourse/lib/ember_compat_handlebars.js @@ -57,57 +57,59 @@ stringCompatHelper("with"); - RawHandlebars.Compiler = function() {}; - RawHandlebars.Compiler.prototype = objectCreate(Handlebars.Compiler.prototype); - RawHandlebars.Compiler.prototype.compiler = RawHandlebars.Compiler; + if (Handlebars.Compiler) { + RawHandlebars.Compiler = function() {}; + RawHandlebars.Compiler.prototype = objectCreate(Handlebars.Compiler.prototype); + RawHandlebars.Compiler.prototype.compiler = RawHandlebars.Compiler; - RawHandlebars.JavaScriptCompiler = function() {}; + RawHandlebars.JavaScriptCompiler = function() {}; - RawHandlebars.JavaScriptCompiler.prototype = objectCreate(Handlebars.JavaScriptCompiler.prototype); - RawHandlebars.JavaScriptCompiler.prototype.compiler = RawHandlebars.JavaScriptCompiler; - RawHandlebars.JavaScriptCompiler.prototype.namespace = "Discourse.EmberCompatHandlebars"; + RawHandlebars.JavaScriptCompiler.prototype = objectCreate(Handlebars.JavaScriptCompiler.prototype); + RawHandlebars.JavaScriptCompiler.prototype.compiler = RawHandlebars.JavaScriptCompiler; + RawHandlebars.JavaScriptCompiler.prototype.namespace = "Discourse.EmberCompatHandlebars"; - RawHandlebars.Compiler.prototype.mustache = function(mustache) { - if ( !(mustache.params.length || mustache.hash)) { + RawHandlebars.Compiler.prototype.mustache = function(mustache) { + if ( !(mustache.params.length || mustache.hash)) { - var id = new Handlebars.AST.IdNode([{ part: 'get' }]); - mustache = new Handlebars.AST.MustacheNode([id].concat([mustache.id]), mustache.hash, mustache.escaped); - } + var id = new Handlebars.AST.IdNode([{ part: 'get' }]); + mustache = new Handlebars.AST.MustacheNode([id].concat([mustache.id]), mustache.hash, mustache.escaped); + } - return Handlebars.Compiler.prototype.mustache.call(this, mustache); - }; - - RawHandlebars.precompile = function(value, asObject) { - var ast = Handlebars.parse(value); - - var options = { - knownHelpers: { - get: true - }, - data: true, - stringParams: true + return Handlebars.Compiler.prototype.mustache.call(this, mustache); }; - asObject = asObject === undefined ? true : asObject; + RawHandlebars.precompile = function(value, asObject) { + var ast = Handlebars.parse(value); - var environment = new RawHandlebars.Compiler().compile(ast, options); - return new RawHandlebars.JavaScriptCompiler().compile(environment, options, undefined, asObject); - }; + var options = { + knownHelpers: { + get: true + }, + data: true, + stringParams: true + }; + + asObject = asObject === undefined ? true : asObject; + + var environment = new RawHandlebars.Compiler().compile(ast, options); + return new RawHandlebars.JavaScriptCompiler().compile(environment, options, undefined, asObject); + }; - RawHandlebars.compile = function(string) { - var ast = Handlebars.parse(string); - // this forces us to rewrite helpers - var options = { data: true, stringParams: true }; - var environment = new RawHandlebars.Compiler().compile(ast, options); - var templateSpec = new RawHandlebars.JavaScriptCompiler().compile(environment, options, undefined, true); + RawHandlebars.compile = function(string) { + var ast = Handlebars.parse(string); + // this forces us to rewrite helpers + var options = { data: true, stringParams: true }; + var environment = new RawHandlebars.Compiler().compile(ast, options); + var templateSpec = new RawHandlebars.JavaScriptCompiler().compile(environment, options, undefined, true); - var template = RawHandlebars.template(templateSpec); - template.isMethod = false; + var template = RawHandlebars.template(templateSpec); + template.isMethod = false; - return template; - }; + return template; + }; + } RawHandlebars.get = function(ctx, property, options){ if (options.types && options.data.view) { diff --git a/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 b/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 index 00204ca426..45e888395f 100644 --- a/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 +++ b/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 @@ -97,7 +97,7 @@ export default { }, quoteReply() { - this._replyToPost(); + this.sendToSelectedPost("replyToPost"); // lazy but should work for now setTimeout(function() { $('.d-editor .quote').click(); diff --git a/app/assets/javascripts/discourse/lib/link-mentions.js.es6 b/app/assets/javascripts/discourse/lib/link-mentions.js.es6 index 27d4f02387..476ae64fc4 100644 --- a/app/assets/javascripts/discourse/lib/link-mentions.js.es6 +++ b/app/assets/javascripts/discourse/lib/link-mentions.js.es6 @@ -1,10 +1,23 @@ -function replaceSpan($e, username) { - $e.replaceWith("@" + username + ""); + } else { + $e.replaceWith("@" + username + ""); + } } const found = []; +const foundGroups = []; +const mentionableGroups = []; const checked = []; function updateFound($mentions, usernames) { @@ -14,6 +27,9 @@ function updateFound($mentions, usernames) { const username = usernames[i]; if (found.indexOf(username.toLowerCase()) !== -1) { replaceSpan($e, username); + } else if (foundGroups.indexOf(username) !== -1) { + const mentionable = _(mentionableGroups).where({name: username}).first(); + replaceSpan($e, username, {group: true, mentionable: mentionable}); } else if (checked.indexOf(username) !== -1) { $e.addClass('mention-tested'); } @@ -38,6 +54,9 @@ export function linkSeenMentions($elem, siteSettings) { export function fetchUnseenMentions($elem, usernames) { return Discourse.ajax("/users/is_local_username", { data: { usernames } }).then(function(r) { found.push.apply(found, r.valid); + foundGroups.push.apply(foundGroups, r.valid_groups); + mentionableGroups.push.apply(mentionableGroups, r.mentionable_groups); checked.push.apply(checked, usernames); + return r; }); } diff --git a/app/assets/javascripts/discourse/lib/markdown.js b/app/assets/javascripts/discourse/lib/markdown.js index ee7ba25737..4b9e3ce1e8 100644 --- a/app/assets/javascripts/discourse/lib/markdown.js +++ b/app/assets/javascripts/discourse/lib/markdown.js @@ -238,6 +238,7 @@ RSVP.EventTarget.mixin(Discourse.Markdown); Discourse.Markdown.whiteListTag('a', 'class', 'attachment'); Discourse.Markdown.whiteListTag('a', 'class', 'onebox'); Discourse.Markdown.whiteListTag('a', 'class', 'mention'); +Discourse.Markdown.whiteListTag('a', 'class', 'mention-group'); Discourse.Markdown.whiteListTag('a', 'target', '_blank'); Discourse.Markdown.whiteListTag('a', 'rel', 'nofollow'); diff --git a/app/assets/javascripts/discourse/lib/mobile.js b/app/assets/javascripts/discourse/lib/mobile.js index 688482fa44..552e3f6873 100644 --- a/app/assets/javascripts/discourse/lib/mobile.js +++ b/app/assets/javascripts/discourse/lib/mobile.js @@ -1,9 +1,4 @@ -/** - An object that is responsible for logic related to mobile devices. - - @namespace Discourse - @module Mobile -**/ +// An object that is responsible for logic related to mobile devices. Discourse.Mobile = { isMobileDevice: false, mobileView: false, diff --git a/app/assets/javascripts/discourse/lib/page-tracker.js.es6 b/app/assets/javascripts/discourse/lib/page-tracker.js.es6 index 9a7dcc9c9d..351c78211e 100644 --- a/app/assets/javascripts/discourse/lib/page-tracker.js.es6 +++ b/app/assets/javascripts/discourse/lib/page-tracker.js.es6 @@ -21,7 +21,7 @@ const PageTracker = Ember.Object.extend(Ember.Evented, { router.on('didTransition', function() { this.send('refreshTitle'); - var url = this.get('url'); + var url = Discourse.getURL(this.get('url')); // Refreshing the title is debounced, so we need to trigger this in the // next runloop to have the correct title. diff --git a/app/assets/javascripts/discourse/lib/posts-with-placeholders.js.es6 b/app/assets/javascripts/discourse/lib/posts-with-placeholders.js.es6 new file mode 100644 index 0000000000..db7f1238a7 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/posts-with-placeholders.js.es6 @@ -0,0 +1,59 @@ +import { Placeholder } from 'discourse/views/cloaked'; +import { default as computed } from 'ember-addons/ember-computed-decorators'; + + +export default Ember.Object.extend(Ember.Array, { + posts: null, + _appendingIds: null, + + init() { + this._appendingIds = {}; + }, + + @computed + length() { + return this.get('posts.length') + Object.keys(this._appendingIds || {}).length; + }, + + _changeArray(cb, offset, removed, inserted) { + this.arrayContentWillChange(offset, removed, inserted); + cb(); + this.arrayContentDidChange(offset, removed, inserted); + this.propertyDidChange('length'); + }, + + clear(cb) { + this._changeArray(cb, 0, this.get('posts.length'), 0); + }, + + appendPost(cb) { + this._changeArray(cb, this.get('posts.length'), 0, 1); + }, + + removePost(cb) { + this._changeArray(cb, this.get('posts.length') - 1, 1, 0); + }, + + appending(postIds) { + this._changeArray(() => { + const appendingIds = this._appendingIds; + postIds.forEach(pid => appendingIds[pid] = true); + }, this.get('length'), 0, postIds.length); + }, + + finishedAppending(postIds) { + this._changeArray(() => { + const appendingIds = this._appendingIds; + postIds.forEach(pid => delete appendingIds[pid]); + }, this.get('posts.length') - postIds.length, postIds.length, postIds.length); + }, + + finishedPrepending(postIds) { + this._changeArray(Ember.K, 0, 0, postIds.length); + }, + + objectAt(index) { + const posts = this.get('posts'); + return (index < posts.length) ? posts[index] : new Placeholder('post-placeholder'); + }, +}); diff --git a/app/assets/javascripts/discourse/lib/url.js.es6 b/app/assets/javascripts/discourse/lib/url.js.es6 index 14e2392744..c35cffba30 100644 --- a/app/assets/javascripts/discourse/lib/url.js.es6 +++ b/app/assets/javascripts/discourse/lib/url.js.es6 @@ -15,14 +15,13 @@ const DiscourseURL = Ember.Object.createWithMixins({ Jumps to a particular post in the stream **/ jumpToPost: function(postNumber, opts) { - const holderId = '#post-cloak-' + postNumber; + const holderId = `.post-cloak[data-post-number=${postNumber}]`; + const offset = function() { - const offset = function(){ - - const $header = $('header'), - $title = $('#topic-title'), - windowHeight = $(window).height() - $title.height(), - expectedOffset = $title.height() - $header.find('.contents').height() + (windowHeight / 5); + const $header = $('header'); + const $title = $('#topic-title'); + const windowHeight = $(window).height() - $title.height(); + const expectedOffset = $title.height() - $header.find('.contents').height() + (windowHeight / 5); return $header.outerHeight(true) + ((expectedOffset < 0) ? 0 : expectedOffset); }; @@ -203,40 +202,40 @@ const DiscourseURL = Ember.Object.createWithMixins({ @param {String} oldPath the previous path we were on @param {String} path the path we're navigating to **/ - navigatedToPost: function(oldPath, path) { - const newMatches = this.TOPIC_REGEXP.exec(path), - newTopicId = newMatches ? newMatches[2] : null; + navigatedToPost(oldPath, path) { + const newMatches = this.TOPIC_REGEXP.exec(path); + const newTopicId = newMatches ? newMatches[2] : null; if (newTopicId) { - const oldMatches = this.TOPIC_REGEXP.exec(oldPath), - oldTopicId = oldMatches ? oldMatches[2] : null; + const oldMatches = this.TOPIC_REGEXP.exec(oldPath); + const oldTopicId = oldMatches ? oldMatches[2] : null; // If the topic_id is the same if (oldTopicId === newTopicId) { DiscourseURL.replaceState(path); - const container = Discourse.__container__, - topicController = container.lookup('controller:topic'), - opts = {}, - postStream = topicController.get('model.postStream'); + const container = Discourse.__container__; + const topicController = container.lookup('controller:topic'); + const opts = {}; + const postStream = topicController.get('model.postStream'); - if (newMatches[3]) opts.nearPost = newMatches[3]; + if (newMatches[3]) { opts.nearPost = newMatches[3]; } if (path.match(/last$/)) { opts.nearPost = topicController.get('model.highest_post_number'); } const closest = opts.nearPost || 1; - const self = this; - postStream.refresh(opts).then(function() { + postStream.refresh(opts).then(() => { topicController.setProperties({ 'model.currentPost': closest, enteredAt: new Date().getTime().toString() }); - const closestPost = postStream.closestPostForPostNumber(closest), - progress = postStream.progressIndexOfPost(closestPost), - progressController = container.lookup('controller:topic-progress'); + + const closestPost = postStream.closestPostForPostNumber(closest); + const progress = postStream.progressIndexOfPost(closestPost); + const progressController = container.lookup('controller:topic-progress'); progressController.set('progressPosition', progress); - self.appEvents.trigger('post:highlight', closest); - }).then(function() { + this.appEvents.trigger('post:highlight', closest); + }).then(() => { DiscourseURL.jumpToPost(closest, {skipIfOnScreen: true}); }); diff --git a/app/assets/javascripts/discourse/lib/user-search.js.es6 b/app/assets/javascripts/discourse/lib/user-search.js.es6 index 3600814bb8..08d5c6bd63 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, allowedUsers, resultsFn) { +function performSearch(term, topicId, includeGroups, includeMentionableGroups, allowedUsers, resultsFn) { var cached = cache[term]; if (cached) { resultsFn(cached); @@ -18,6 +18,7 @@ function performSearch(term, topicId, includeGroups, allowedUsers, resultsFn) { data: { term: term, topic_id: topicId, include_groups: includeGroups, + include_mentionable_groups: includeMentionableGroups, topic_allowed_users: allowedUsers } }); @@ -76,6 +77,7 @@ function organizeResults(r, options) { export default function userSearch(options) { var term = options.term || "", includeGroups = options.includeGroups, + includeMentionableGroups = options.includeMentionableGroups, allowedUsers = options.allowedUsers, topicId = options.topicId; @@ -103,7 +105,7 @@ export default function userSearch(options) { resolve(CANCELLED_STATUS); }, 5000); - debouncedSearch(term, topicId, includeGroups, allowedUsers, function(r) { + debouncedSearch(term, topicId, includeGroups, includeMentionableGroups, allowedUsers, function(r) { clearTimeout(clearPromise); resolve(organizeResults(r, options)); }); diff --git a/app/assets/javascripts/discourse/mixins/string-buffer.js.es6 b/app/assets/javascripts/discourse/mixins/string-buffer.js.es6 index ee40bf5ec0..943a972c6e 100644 --- a/app/assets/javascripts/discourse/mixins/string-buffer.js.es6 +++ b/app/assets/javascripts/discourse/mixins/string-buffer.js.es6 @@ -25,7 +25,17 @@ export default Ember.Mixin.create({ const buffer = []; this.renderString(buffer); + // Chrome likes scrolling after HTML is set + // This happens if you navigate back and forth a few times + // Before removing this code confirm that this does not cause scrolling + // 1. Sort by views + // 2. Go to last post on one of the topics + // 3. Hit back + // 4. Go to last post on same topic + // 5. Expand likes + const scrollTop = $(window).scrollTop(); $sel.html(buffer.join('')); + $(window).scrollTop(scrollTop); }, rerenderString() { diff --git a/app/assets/javascripts/discourse/models/category-list.js.es6 b/app/assets/javascripts/discourse/models/category-list.js.es6 index 810a3e4902..3a0b9e47f1 100644 --- a/app/assets/javascripts/discourse/models/category-list.js.es6 +++ b/app/assets/javascripts/discourse/models/category-list.js.es6 @@ -11,7 +11,7 @@ CategoryList.reopenClass({ const users = Discourse.Model.extractByKey(result.featured_users, Discourse.User); const list = Discourse.Category.list(); - result.category_list.categories.forEach(function(c) { + result.category_list.categories.forEach(c => { if (c.parent_category_id) { c.parentCategory = list.findBy('id', c.parent_category_id); } diff --git a/app/assets/javascripts/discourse/models/category.js.es6 b/app/assets/javascripts/discourse/models/category.js.es6 index 01e281c62b..ed82eb9786 100644 --- a/app/assets/javascripts/discourse/models/category.js.es6 +++ b/app/assets/javascripts/discourse/models/category.js.es6 @@ -1,5 +1,6 @@ import RestModel from 'discourse/models/rest'; import { on } from 'ember-addons/ember-computed-decorators'; +import PermissionType from 'discourse/models/permission-type'; const Category = RestModel.extend({ @@ -15,16 +16,16 @@ const Category = RestModel.extend({ availableGroups.removeObject(elem.group_name); return { group_name: elem.group_name, - permission: Discourse.PermissionType.create({id: elem.permission_type}) + permission: PermissionType.create({id: elem.permission_type}) }; })); } }, availablePermissions: function(){ - return [ Discourse.PermissionType.create({id: Discourse.PermissionType.FULL}), - Discourse.PermissionType.create({id: Discourse.PermissionType.CREATE_POST}), - Discourse.PermissionType.create({id: Discourse.PermissionType.READONLY}) + return [ PermissionType.create({id: PermissionType.FULL}), + PermissionType.create({id: PermissionType.CREATE_POST}), + PermissionType.create({id: PermissionType.READONLY}) ]; }.property(), @@ -86,6 +87,7 @@ const Category = RestModel.extend({ custom_fields: this.get('custom_fields'), topic_template: this.get('topic_template'), suppress_from_homepage: this.get('suppress_from_homepage'), + contains_messages: this.get("contains_messages"), }, type: this.get('id') ? 'PUT' : 'POST' }); @@ -116,9 +118,9 @@ const Category = RestModel.extend({ permissions: function(){ return Em.A([ - {group_name: "everyone", permission: Discourse.PermissionType.create({id: 1})}, - {group_name: "admins", permission: Discourse.PermissionType.create({id: 2}) }, - {group_name: "crap", permission: Discourse.PermissionType.create({id: 3}) } + {group_name: "everyone", permission: PermissionType.create({id: 1})}, + {group_name: "admins", permission: PermissionType.create({id: 2}) }, + {group_name: "crap", permission: PermissionType.create({id: 3}) } ]); }.property(), diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index aedac56c9a..f0bc7eaca1 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -67,11 +67,13 @@ const Composer = RestModel.extend({ creatingPrivateMessage: Em.computed.equal('action', PRIVATE_MESSAGE), notCreatingPrivateMessage: Em.computed.not('creatingPrivateMessage'), - showCategoryChooser: function(){ + @computed("privateMessage", "archetype.hasOptions", "categoryId") + showCategoryChooser(isPrivateMessage, hasOptions, categoryId) { const manyCategories = Discourse.Category.list().length > 1; - const hasOptions = this.get('archetype.hasOptions'); - return !this.get('privateMessage') && (hasOptions || manyCategories); - }.property('privateMessage'), + const category = Discourse.Category.findById(categoryId); + const containsMessages = category && category.get("contains_messages"); + return !isPrivateMessage && !containsMessages && (hasOptions || manyCategories); + }, privateMessage: function(){ return this.get('creatingPrivateMessage') || this.get('topic.archetype') === 'private_message'; diff --git a/app/assets/javascripts/discourse/models/group.js.es6 b/app/assets/javascripts/discourse/models/group.js.es6 index 49f0e4ae0d..d0048cc957 100644 --- a/app/assets/javascripts/discourse/models/group.js.es6 +++ b/app/assets/javascripts/discourse/models/group.js.es6 @@ -98,7 +98,8 @@ const Group = Discourse.Model.extend({ automatic_membership_retroactive: !!this.get('automatic_membership_retroactive'), title: this.get('title'), primary_group: !!this.get('primary_group'), - grant_trust_level: this.get('grant_trust_level') + grant_trust_level: this.get('grant_trust_level'), + incoming_email: this.get("incoming_email"), }; }, @@ -121,16 +122,26 @@ const Group = Discourse.Model.extend({ findPosts(opts) { opts = opts || {}; + const type = opts['type'] || 'posts'; + var data = {}; if (opts.beforePostId) { data.before_post_id = opts.beforePostId; } - return Discourse.ajax("/groups/" + this.get('name') + "/posts.json", { data: data }).then(function (posts) { - return posts.map(function (p) { + return Discourse.ajax(`/groups/${this.get('name')}/${type}.json`, { data: data }).then(posts => { + return posts.map(p => { p.user = Discourse.User.create(p.user); return Em.Object.create(p); }); }); - } + }, + + setNotification(notification_level) { + this.set("notification_level", notification_level); + return Discourse.ajax(`/groups/${this.get("name")}/notifications`, { + data: { notification_level }, + type: "POST" + }); + }, }); Group.reopenClass({ diff --git a/app/assets/javascripts/discourse/models/permission_type.js b/app/assets/javascripts/discourse/models/permission-type.js.es6 similarity index 65% rename from app/assets/javascripts/discourse/models/permission_type.js rename to app/assets/javascripts/discourse/models/permission-type.js.es6 index a37ba818c8..04e3771b07 100644 --- a/app/assets/javascripts/discourse/models/permission_type.js +++ b/app/assets/javascripts/discourse/models/permission-type.js.es6 @@ -1,5 +1,4 @@ - -Discourse.PermissionType = Discourse.Model.extend({ +const PermissionType = Discourse.Model.extend({ description: function(){ var key = ""; @@ -18,6 +17,8 @@ Discourse.PermissionType = Discourse.Model.extend({ }.property("id") }); -Discourse.PermissionType.FULL = 1; -Discourse.PermissionType.CREATE_POST = 2; -Discourse.PermissionType.READONLY = 3; +PermissionType.FULL = 1; +PermissionType.CREATE_POST = 2; +PermissionType.READONLY = 3; + +export default PermissionType; diff --git a/app/assets/javascripts/discourse/models/post-stream.js.es6 b/app/assets/javascripts/discourse/models/post-stream.js.es6 index 0368b2c2ec..8cfeda00a0 100644 --- a/app/assets/javascripts/discourse/models/post-stream.js.es6 +++ b/app/assets/javascripts/discourse/models/post-stream.js.es6 @@ -1,5 +1,8 @@ import DiscourseURL from 'discourse/lib/url'; import RestModel from 'discourse/models/rest'; +import PostsWithPlaceholders from 'discourse/lib/posts-with-placeholders'; +import { default as computed } from 'ember-addons/ember-computed-decorators'; +import { loadTopicView } from 'discourse/models/topic'; function calcDayDiff(p1, p2) { if (!p1) { return; } @@ -16,84 +19,105 @@ function calcDayDiff(p1, p2) { } } -const PostStream = RestModel.extend({ - loading: Em.computed.or('loadingAbove', 'loadingBelow', 'loadingFilter', 'stagingPost'), - notLoading: Em.computed.not('loading'), - filteredPostsCount: Em.computed.alias("stream.length"), +export default RestModel.extend({ + _identityMap: null, + posts: null, + stream: null, + userFilters: null, + summary: null, + loaded: null, + loadingAbove: null, + loadingBelow: null, + loadingFilter: null, + stagingPost: null, + postsWithPlaceholders: null, - hasPosts: function() { + init() { + this._identityMap = {}; + const posts = []; + const postsWithPlaceholders = PostsWithPlaceholders.create({ posts, store: this.store }); + + this.setProperties({ + posts, + postsWithPlaceholders, + stream: [], + userFilters: [], + summary: false, + loaded: false, + loadingAbove: false, + loadingBelow: false, + loadingFilter: false, + stagingPost: false, + }); + }, + + loading: Ember.computed.or('loadingAbove', 'loadingBelow', 'loadingFilter', 'stagingPost'), + notLoading: Ember.computed.not('loading'), + filteredPostsCount: Ember.computed.alias("stream.length"), + + @computed('posts.@each') + hasPosts() { return this.get('posts.length') > 0; - }.property("posts.@each"), + }, - hasStream: Em.computed.gt('filteredPostsCount', 0), - canAppendMore: Em.computed.and('notLoading', 'hasPosts', 'lastPostNotLoaded'), - canPrependMore: Em.computed.and('notLoading', 'hasPosts', 'firstPostNotLoaded'), + @computed('hasPosts', 'filteredPostsCount') + hasLoadedData(hasPosts, filteredPostsCount) { + return hasPosts && filteredPostsCount > 0; + }, - firstPostPresent: function() { - if (!this.get('hasLoadedData')) { return false; } - return !!this.get('posts').findProperty('id', this.get('firstPostId')); - }.property('hasLoadedData', 'posts.@each', 'firstPostId'), + canAppendMore: Ember.computed.and('notLoading', 'hasPosts', 'lastPostNotLoaded'), + canPrependMore: Ember.computed.and('notLoading', 'hasPosts', 'firstPostNotLoaded'), - firstPostNotLoaded: Em.computed.not('firstPostPresent'), + @computed('hasLoadedData', 'firstPostId', 'posts.@each') + firstPostPresent(hasLoadedData, firstPostId) { + if (!hasLoadedData) { return false; } + return !!this.get('posts').findProperty('id', firstPostId); + }, - firstLoadedPost: function() { - return _.first(this.get('posts')); - }.property('posts.@each'), + firstPostNotLoaded: Ember.computed.not('firstPostPresent'), + firstPostId: Ember.computed.alias('stream.firstObject'), + lastPostId: Ember.computed.alias('stream.lastObject'), - lastLoadedPost: function() { - return _.last(this.get('posts')); - }.property('posts.@each'), + @computed('hasLoadedData', 'lastPostId', 'posts.@each.id') + loadedAllPosts(hasLoadedData, lastPostId) { + if (!hasLoadedData) { return false; } + if (lastPostId === -1) { return true; } - firstPostId: function() { - return this.get('stream')[0]; - }.property('stream.@each'), + return !!this.get('posts').findProperty('id', lastPostId); + }, - lastPostId: function() { - return _.last(this.get('stream')); - }.property('stream.@each'), - - loadedAllPosts: function() { - if (!this.get('hasLoadedData')) { - return false; - } - - // if we are staging a post assume all is loaded - if (this.get('lastPostId') === -1) { - return true; - } - - return !!this.get('posts').findProperty('id', this.get('lastPostId')); - }.property('hasLoadedData', 'posts.@each.id', 'lastPostId'), - - lastPostNotLoaded: Em.computed.not('loadedAllPosts'), + lastPostNotLoaded: Ember.computed.not('loadedAllPosts'), /** Returns a JS Object of current stream filter options. It should match the query params for the stream. **/ - streamFilters: function() { + @computed('summary', 'show_deleted', 'userFilters.[]') + streamFilters(summary, showDeleted) { const result = {}; - if (this.get('summary')) { result.filter = "summary"; } - if (this.get('show_deleted')) { result.show_deleted = true; } + if (summary) { result.filter = "summary"; } + if (showDeleted) { result.show_deleted = true; } const userFilters = this.get('userFilters'); - if (!Em.isEmpty(userFilters)) { + if (!Ember.isEmpty(userFilters)) { result.username_filters = userFilters.join(","); } return result; - }.property('userFilters.[]', 'summary', 'show_deleted'), + }, - hasNoFilters: function() { + @computed('streamFilters.[]', 'topic.posts_count', 'posts.length') + hasNoFilters() { const streamFilters = this.get('streamFilters'); return !(streamFilters && ((streamFilters.filter === 'summary') || streamFilters.username_filters)); - }.property('streamFilters.[]', 'topic.posts_count', 'posts.length'), + }, /** Returns the window of posts above the current set in the stream, bound to the top of the stream. This is the collection we'll ask for when scrolling upwards. **/ - previousWindow: function() { + @computed('posts.@each', 'stream.@each') + previousWindow() { // If we can't find the last post loaded, bail const firstPost = _.first(this.get('posts')); if (!firstPost) { return []; } @@ -106,16 +130,15 @@ const PostStream = RestModel.extend({ let startIndex = firstIndex - this.get('topic.chunk_size'); if (startIndex < 0) { startIndex = 0; } return stream.slice(startIndex, firstIndex); - - }.property('posts.@each', 'stream.@each'), + }, /** Returns the window of posts below the current set in the stream, bound by the bottom of the stream. This is the collection we use when scrolling downwards. **/ - nextWindow: function() { + @computed('posts.lastObject', 'stream.@each') + nextWindow(lastLoadedPost) { // If we can't find the last post loaded, bail - const lastLoadedPost = this.get('lastLoadedPost'); if (!lastLoadedPost) { return []; } // Find the index of the last post loaded, if not found, bail @@ -126,7 +149,7 @@ const PostStream = RestModel.extend({ // find our window of posts return stream.slice(lastIndex+1, lastIndex + this.get('topic.chunk_size') + 1); - }.property('lastLoadedPost', 'stream.@each'), + }, cancelFilter() { this.set('summary', false); @@ -138,10 +161,9 @@ const PostStream = RestModel.extend({ this.get('userFilters').clear(); this.toggleProperty('summary'); - const self = this; - return this.refresh().then(function() { - if (self.get('summary')) { - self.jumpToSecondVisible(); + return this.refresh().then(() => { + if (this.get('summary')) { + this.jumpToSecondVisible(); } }); }, @@ -172,10 +194,9 @@ const PostStream = RestModel.extend({ userFilters.addObject(username); jump = true; } - const self = this; - return this.refresh().then(function() { + return this.refresh().then(() => { if (jump) { - self.jumpToSecondVisible(); + this.jumpToSecondVisible(); } }); }, @@ -189,7 +210,6 @@ const PostStream = RestModel.extend({ opts.nearPost = parseInt(opts.nearPost, 10); const topic = this.get('topic'); - const self = this; // Do we already have the post in our list of posts? Jump there. if (opts.forceLoad) { @@ -200,25 +220,23 @@ const PostStream = RestModel.extend({ } // TODO: if we have all the posts in the filter, don't go to the server for them. - self.set('loadingFilter', true); + this.set('loadingFilter', true); - opts = _.merge(opts, self.get('streamFilters')); + opts = _.merge(opts, this.get('streamFilters')); // Request a topicView - return PostStream.loadTopicView(topic.get('id'), opts).then(function (json) { - topic.updateFromJson(json); - self.updateFromJson(json.post_stream); - self.setProperties({ loadingFilter: false, loaded: true }); - }).catch(function(result) { - self.errorLoading(result); + return loadTopicView(topic, opts).then(json => { + this.updateFromJson(json.post_stream); + this.setProperties({ loadingFilter: false, loaded: true }); + }).catch(result => { + this.errorLoading(result); throw result; }); }, - hasLoadedData: Em.computed.and('hasPosts', 'hasStream'), collapsePosts(from, to){ const posts = this.get('posts'); - const remove = posts.filter(function(post){ + const remove = posts.filter(post => { const postNumber = post.get('post_number'); return postNumber >= from && postNumber <= to; }); @@ -228,44 +246,39 @@ const PostStream = RestModel.extend({ // make gap this.set('gaps', this.get('gaps') || {before: {}, after: {}}); const before = this.get('gaps.before'); + const post = posts.find(p => p.get('post_number') > to); - const post = posts.find(function(p){ - return p.get('post_number') > to; - }); - - before[post.get('id')] = remove.map(function(p){ - return p.get('id'); - }); + before[post.get('id')] = remove.map(p => p.get('id')); post.set('hasGap', true); this.get('stream').enumerableContentDidChange(); }, - // Fill in a gap of posts before a particular post fillGapBefore(post, gap) { const postId = post.get('id'), - stream = this.get('stream'), - idx = stream.indexOf(postId), - currentPosts = this.get('posts'), - self = this; + stream = this.get('stream'), + idx = stream.indexOf(postId), + currentPosts = this.get('posts'); if (idx !== -1) { // Insert the gap at the appropriate place stream.splice.apply(stream, [idx, 0].concat(gap)); let postIdx = currentPosts.indexOf(post); + const origIdx = postIdx; if (postIdx !== -1) { - return this.findPostsByIds(gap).then(function(posts) { - posts.forEach(function(p) { - const stored = self.storePost(p); + return this.findPostsByIds(gap).then(posts => { + posts.forEach(p => { + const stored = this.storePost(p); if (!currentPosts.contains(stored)) { currentPosts.insertAt(postIdx++, stored); } }); - delete self.get('gaps.before')[postId]; - self.get('stream').enumerableContentDidChange(); + delete this.get('gaps.before')[postId]; + this.get('stream').enumerableContentDidChange(); + this.get('postsWithPlaceholders').arrayContentDidChange(origIdx, 0, posts.length); post.set('hasGap', false); }); } @@ -297,31 +310,32 @@ const PostStream = RestModel.extend({ if (Ember.isEmpty(postIds)) { return Ember.RSVP.resolve(); } this.set('loadingBelow', true); - - const stopLoading = () => this.set('loadingBelow', false); - - return this.findPostsByIds(postIds).then((posts) => { + const postsWithPlaceholders = this.get('postsWithPlaceholders'); + postsWithPlaceholders.appending(postIds); + return this.findPostsByIds(postIds).then(posts => { posts.forEach(p => this.appendPost(p)); - stopLoading(); - }, stopLoading); + return posts; + }).finally(() => { + postsWithPlaceholders.finishedAppending(postIds); + this.set('loadingBelow', false); + }); }, // Prepend the previous window of posts to the stream. Call it when scrolling upwards. prependMore() { - const postStream = this; - // Make sure we can append more posts - if (!postStream.get('canPrependMore')) { return Ember.RSVP.resolve(); } + if (!this.get('canPrependMore')) { return Ember.RSVP.resolve(); } - const postIds = postStream.get('previousWindow'); + const postIds = this.get('previousWindow'); if (Ember.isEmpty(postIds)) { return Ember.RSVP.resolve(); } - postStream.set('loadingAbove', true); - return postStream.findPostsByIds(postIds.reverse()).then(function(posts) { - posts.forEach(function(p) { - postStream.prependPost(p); - }); - postStream.set('loadingAbove', false); + this.set('loadingAbove', true); + return this.findPostsByIds(postIds.reverse()).then(posts => { + posts.forEach(p => this.prependPost(p)); + }).finally(() => { + const postsWithPlaceholders = this.get('postsWithPlaceholders'); + postsWithPlaceholders.finishedPrepending(postIds); + this.set('loadingAbove', false); }); }, @@ -372,8 +386,7 @@ const PostStream = RestModel.extend({ } this.get('stream').removeObject(-1); - this.get('postIdentityMap').set(-1, null); - + this._identityMap[-1] = null; this.set('stagingPost', false); }, @@ -383,8 +396,8 @@ const PostStream = RestModel.extend({ **/ undoPost(post) { this.get('stream').removeObject(-1); - this.posts.removeObject(post); - this.get('postIdentityMap').set(-1, null); + this.get('postsWithPlaceholders').removePost(() => this.posts.removeObject(post)); + this._identityMap[-1] = null; const topic = this.get('topic'); this.set('stagingPost', false); @@ -414,7 +427,13 @@ const PostStream = RestModel.extend({ const posts = this.get('posts'); calcDayDiff(stored, this.get('lastAppended')); - posts.addObject(stored); + if (!posts.contains(stored)) { + if (!this.get('loadingBelow')) { + this.get('postsWithPlaceholders').appendPost(() => posts.pushObject(stored)); + } else { + posts.pushObject(stored); + } + } if (stored.get('id') !== -1) { this.set('lastAppended', stored); @@ -424,21 +443,19 @@ const PostStream = RestModel.extend({ }, removePosts(posts) { - if (Em.isEmpty(posts)) { return; } + if (Ember.isEmpty(posts)) { return; } - const postIds = posts.map(function (p) { return p.get('id'); }); - const identityMap = this.get('postIdentityMap'); + const postIds = posts.map(p => p.get('id')); + const identityMap = this._identityMap; this.get('stream').removeObjects(postIds); this.get('posts').removeObjects(posts); - postIds.forEach(function(id){ - identityMap.delete(id); - }); + postIds.forEach(id => delete identityMap[id]); }, // Returns a post from the identity map if it's been inserted. findLoadedPost(id) { - return this.get('postIdentityMap').get(id); + return this._identityMap[id]; }, loadPost(postId){ @@ -465,34 +482,34 @@ const PostStream = RestModel.extend({ this.get('stream').addObject(postId); if (loadedAllPosts) { this.set('loadingLastPost', true); - this.appendMore().finally( - ()=>this.set('loadingLastPost', true) - ); + this.findPostsByIds([postId]).then(posts => { + posts.forEach(p => this.appendPost(p)); + }).finally(() => { + this.set('loadingLastPost', false); + }); } } }, - triggerRecoveredPost(postId){ - const self = this, - postIdentityMap = this.get('postIdentityMap'), - existing = postIdentityMap.get(postId); + triggerRecoveredPost(postId) { + const existing = this._identityMap[postId]; - if(existing){ + if (existing) { this.triggerChangedPost(postId, new Date()); } else { // need to insert into stream const url = "/posts/" + postId; const store = this.store; - Discourse.ajax(url).then(function(p){ + Discourse.ajax(url).then(p => { const post = store.createRecord('post', p); - const stream = self.get("stream"); - const posts = self.get("posts"); - self.storePost(post); + const stream = this.get("stream"); + const posts = this.get("posts"); + this.storePost(post); // we need to zip this into the stream let index = 0; - stream.forEach(function(pid){ - if (pid < p.id){ + stream.forEach(pid => { + if (pid < p.id) { index+= 1; } }); @@ -500,17 +517,17 @@ const PostStream = RestModel.extend({ stream.insertAt(index, p.id); index = 0; - posts.forEach(function(_post){ - if(_post.id < p.id){ + posts.forEach(_post => { + if (_post.id < p.id) { index+= 1; } }); - if(index < posts.length){ + if (index < posts.length) { posts.insertAt(index, post); } else { - if(post.post_number < posts[posts.length-1].post_number + 5){ - self.appendMore(); + if (post.post_number < posts[posts.length-1].post_number + 5) { + this.appendMore(); } } }); @@ -518,50 +535,38 @@ const PostStream = RestModel.extend({ }, triggerDeletedPost(postId){ - const self = this, - postIdentityMap = this.get('postIdentityMap'), - existing = postIdentityMap.get(postId); + const existing = this._identityMap[postId]; - if(existing){ + if (existing) { const url = "/posts/" + postId; const store = this.store; - Discourse.ajax(url).then( - function(p){ - self.storePost(store.createRecord('post', p)); - }, - function(){ - self.removePosts([existing]); - }); + + Discourse.ajax(url).then(p => { + this.storePost(store.createRecord('post', p)); + }).catch(() => { + this.removePosts([existing]); + }); } }, triggerChangedPost(postId, updatedAt) { if (!postId) { return; } - const postIdentityMap = this.get('postIdentityMap'), - existing = postIdentityMap.get(postId), - self = this; - + const existing = this._identityMap[postId]; if (existing && existing.updated_at !== updatedAt) { const url = "/posts/" + postId; const store = this.store; - Discourse.ajax(url).then(function(p){ - self.storePost(store.createRecord('post', p)); - }); + Discourse.ajax(url).then(p => this.storePost(store.createRecord('post', p))); } }, // Returns the "thread" of posts in the history of a post. findReplyHistory(post) { - const postStream = this, - url = "/posts/" + post.get('id') + "/reply-history.json?max_replies=" + Discourse.SiteSettings.max_reply_history; - + const 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(store.createRecord('post', p)); - }); - }).then(function (replyHistory) { + return Discourse.ajax(url).then(result => { + return result.map(p => this.storePost(store.createRecord('post', p))); + }).then(replyHistory => { post.set('replyHistory', replyHistory); }); }, @@ -575,7 +580,7 @@ const PostStream = RestModel.extend({ if (!this.get('hasPosts')) { return; } let closest = null; - this.get('posts').forEach(function (p) { + this.get('posts').forEach(p => { if (!closest) { closest = p; return; @@ -589,20 +594,14 @@ const PostStream = RestModel.extend({ return closest; }, - /** - Get the index of a post in the stream. (Use this for the topic progress bar.) - - @param post the post to get the index of - @returns {Number} 1-starting index of the post, or 0 if not found - @see PostStream.progressIndexOfPostId - **/ + // Get the index of a post in the stream. (Use this for the topic progress bar.) progressIndexOfPost(post) { return this.progressIndexOfPostId(post.get('id')); }, // Get the index in the stream of a post id. (Use this for the topic progress bar.) - progressIndexOfPostId(post_id) { - return this.get('stream').indexOf(post_id) + 1; + progressIndexOfPostId(postId) { + return this.get('stream').indexOf(postId) + 1; }, /** @@ -614,7 +613,7 @@ const PostStream = RestModel.extend({ if (!this.get('hasPosts')) { return; } let closest = null; - this.get('posts').forEach(function (p) { + this.get('posts').forEach(p => { if (closest === postNumber) { return; } if (!closest) { closest = p.get('post_number'); } @@ -653,21 +652,20 @@ const PostStream = RestModel.extend({ }, updateFromJson(postStreamData) { - const postStream = this, - posts = this.get('posts'); + const posts = this.get('posts'); + + const postsWithPlaceholders = this.get('postsWithPlaceholders'); + postsWithPlaceholders.clear(() => posts.clear()); - posts.clear(); this.set('gaps', null); if (postStreamData) { // Load posts if present const store = this.store; - postStreamData.posts.forEach(function(p) { - postStream.appendPost(store.createRecord('post', p)); - }); + postStreamData.posts.forEach(p => this.appendPost(store.createRecord('post', p))); delete postStreamData.posts; // Update our attributes - postStream.setProperties(postStreamData); + this.setProperties(postStreamData); } }, @@ -677,13 +675,12 @@ const PostStream = RestModel.extend({ than you supplied if the post has already been loaded. **/ storePost(post) { - // Calling `Em.get(undefined` raises an error + // Calling `Ember.get(undefined)` raises an error if (!post) { return; } - const postId = Em.get(post, 'id'); + const postId = Ember.get(post, 'id'); if (postId) { - const postIdentityMap = this.get('postIdentityMap'), - existing = postIdentityMap.get(post.get('id')); + const existing = this._identityMap[post.get('id')]; // Update the `highest_post_number` if this post is higher. const postNumber = post.get('post_number'); @@ -698,67 +695,41 @@ const PostStream = RestModel.extend({ } post.set('topic', this.get('topic')); - postIdentityMap.set(post.get('id'), post); + this._identityMap[post.get('id')] = post; } return post; }, - /** - Given a list of postIds, returns a list of the posts we don't have in our - identity map and need to load. - **/ - listUnloadedIds(postIds) { - const unloaded = Em.A(), - postIdentityMap = this.get('postIdentityMap'); - postIds.forEach(function(p) { - if (!postIdentityMap.has(p)) { unloaded.pushObject(p); } - }); - return unloaded; - }, - findPostsByIds(postIds) { - const unloaded = this.listUnloadedIds(postIds), - postIdentityMap = this.get('postIdentityMap'); + const identityMap = this._identityMap; + const unloaded = postIds.filter(p => !identityMap[p]); // Load our unloaded posts by id - return this.loadIntoIdentityMap(unloaded).then(function() { - return postIds.map(function (p) { - return postIdentityMap.get(p); - }).compact(); + return this.loadIntoIdentityMap(unloaded).then(() => { + return postIds.map(p => identityMap[p]).compact(); }); }, loadIntoIdentityMap(postIds) { - // If we don't want any posts, return a promise that resolves right away - if (Em.isEmpty(postIds)) { - return Ember.RSVP.resolve(); - } - - const url = "/t/" + this.get('topic.id') + "/posts.json", - data = { post_ids: postIds }, - postStream = this; + if (Ember.isEmpty(postIds)) { return Ember.RSVP.resolve([]); } + const url = "/t/" + this.get('topic.id') + "/posts.json"; + const data = { post_ids: postIds }; const store = this.store; - return Discourse.ajax(url, {data: data}).then(function(result) { - const posts = Em.get(result, "post_stream.posts"); + return Discourse.ajax(url, {data}).then(result => { + const posts = Ember.get(result, "post_stream.posts"); if (posts) { - posts.forEach(function (p) { - postStream.storePost(store.createRecord('post', p)); - }); + posts.forEach(p => this.storePost(store.createRecord('post', p))); } }); }, - indexOf(post) { return this.get('stream').indexOf(post.get('id')); }, - - /** - Handles an error loading a topic based on a HTTP status code. Updates - the text to the correct values. - **/ + // Handles an error loading a topic based on a HTTP status code. Updates + // the text to the correct values. errorLoading(result) { const status = result.jqXHR.status; @@ -786,45 +757,4 @@ const PostStream = RestModel.extend({ // Otherwise supply a generic error message topic.set('message', I18n.t('topic.server_error.description')); } - }); - - -PostStream.reopenClass({ - - create() { - const postStream = this._super.apply(this, arguments); - postStream.setProperties({ - posts: [], - stream: [], - userFilters: [], - postIdentityMap: Em.Map.create(), - summary: false, - loaded: false, - loadingAbove: false, - loadingBelow: false, - loadingFilter: false, - stagingPost: false - }); - return postStream; - }, - - loadTopicView(topicId, args) { - const opts = _.merge({}, args); - let url = Discourse.getURL("/t/") + topicId; - if (opts.nearPost) { - 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}); - }); - - } - -}); - -export default PostStream; diff --git a/app/assets/javascripts/discourse/models/result-set.js.es6 b/app/assets/javascripts/discourse/models/result-set.js.es6 index 754fdadd20..6d1b558a57 100644 --- a/app/assets/javascripts/discourse/models/result-set.js.es6 +++ b/app/assets/javascripts/discourse/models/result-set.js.es6 @@ -4,6 +4,13 @@ export default Ember.ArrayProxy.extend({ totalRows: 0, refreshing: false, + content: null, + loadMoreUrl: null, + refreshUrl: null, + findArgs: null, + store: null, + __type: null, + canLoadMore: function() { return this.get('length') < this.get('totalRows'); }.property('totalRows', 'length'), diff --git a/app/assets/javascripts/discourse/models/store.js.es6 b/app/assets/javascripts/discourse/models/store.js.es6 index 57d60b0bbc..a7f4edcf9b 100644 --- a/app/assets/javascripts/discourse/models/store.js.es6 +++ b/app/assets/javascripts/discourse/models/store.js.es6 @@ -63,7 +63,7 @@ export default Ember.Object.extend({ _hydrateFindResults(result, type, findArgs) { if (typeof findArgs === "object") { - return this._resultSet(type, result); + return this._resultSet(type, result, findArgs); } else { return this._hydrate(type, result[Ember.String.underscore(type)], result); } @@ -81,16 +81,16 @@ export default Ember.Object.extend({ }, find(type, findArgs, opts) { - return this.adapterFor(type).find(this, type, findArgs, opts).then((result) => { + return this.adapterFor(type).find(this, type, findArgs, opts).then(result => { return this._hydrateFindResults(result, type, findArgs, opts); }); }, refreshResults(resultSet, type, url) { const self = this; - return Discourse.ajax(url).then(function(result) { - const typeName = Ember.String.underscore(self.pluralize(type)), - content = result[typeName].map(obj => self._hydrate(type, obj, result)); + return Discourse.ajax(url).then(result => { + const typeName = Ember.String.underscore(self.pluralize(type)); + const content = result[typeName].map(obj => self._hydrate(type, obj, result)); resultSet.set('content', content); }); }, @@ -142,14 +142,25 @@ export default Ember.Object.extend({ }); }, - _resultSet(type, result) { - const typeName = Ember.String.underscore(this.pluralize(type)), - content = result[typeName].map(obj => this._hydrate(type, obj, result)), - totalRows = result["total_rows_" + typeName] || content.length, - loadMoreUrl = result["load_more_" + typeName], - refreshUrl = result['refresh_' + typeName]; + _resultSet(type, result, findArgs) { + const typeName = Ember.String.underscore(this.pluralize(type)); + const content = result[typeName].map(obj => this._hydrate(type, obj, result)); - return ResultSet.create({ content, totalRows, loadMoreUrl, refreshUrl, store: this, __type: type }); + const createArgs = { + content, + findArgs, + totalRows: result["total_rows_" + typeName] || content.length, + loadMoreUrl: result["load_more_" + typeName], + refreshUrl: result['refresh_' + typeName], + store: this, + __type: type + }; + + if (result.extras) { + createArgs.extras = result.extras; + } + + return ResultSet.create(createArgs); }, _build(type, obj) { diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6 index 9fc50d38df..c7588f096b 100644 --- a/app/assets/javascripts/discourse/models/topic.js.es6 +++ b/app/assets/javascripts/discourse/models/topic.js.es6 @@ -3,6 +3,25 @@ import RestModel from 'discourse/models/rest'; import { propertyEqual } from 'discourse/lib/computed'; import { longDate } from 'discourse/lib/formatter'; import computed from 'ember-addons/ember-computed-decorators'; +import ActionSummary from 'discourse/models/action-summary'; + +export function loadTopicView(topic, args) { + const topicId = topic.get('id'); + const data = _.merge({}, args); + const url = Discourse.getURL("/t/") + topicId; + const jsonUrl = (data.nearPost ? `${url}/${data.nearPost}` : url) + '.json'; + + delete data.nearPost; + delete data.__type; + delete data.store; + + return PreloadStore.getAndRemove(`topic_${topicId}`, () => { + return Discourse.ajax(jsonUrl, {data}); + }).then(json => { + topic.updateFromJson(json); + return json; + }); +} const Topic = RestModel.extend({ message: null, @@ -15,12 +34,12 @@ const Topic = RestModel.extend({ @computed('posters.@each') lastPoster(posters) { + var user; if (posters && posters.length > 0) { const latest = posters.filter(p => p.extras && p.extras.indexOf("latest") >= 0)[0]; - return latest.user; - } else { - return this.get("creator"); + user = latest && latest.user; } + return user || this.get("creator"); }, @computed('fancy_title') @@ -318,11 +337,14 @@ const Topic = RestModel.extend({ keys.removeObject('details'); keys.removeObject('post_stream'); - const topic = this; - keys.forEach(function (key) { - topic.set(key, json[key]); - }); + keys.forEach(key => this.set(key, json[key])); + }, + reload() { + const self = this; + return Discourse.ajax('/t/' + this.get('id'), { type: 'GET' }).then(function(topic_json) { + self.updateFromJson(topic_json); + }); }, isPinnedUncategorized: function() { @@ -415,7 +437,7 @@ Topic.reopenClass({ result.actions_summary = result.actions_summary.map(function(a) { a.post = result; a.actionType = Discourse.Site.current().postActionTypeById(a.id); - const actionSummary = Discourse.ActionSummary.create(a); + const actionSummary = ActionSummary.create(a); lookup.set(a.actionType.get('name_key'), actionSummary); return actionSummary; }); diff --git a/app/assets/javascripts/discourse/models/user_action_group.js b/app/assets/javascripts/discourse/models/user-action-group.js.es6 similarity index 54% rename from app/assets/javascripts/discourse/models/user_action_group.js rename to app/assets/javascripts/discourse/models/user-action-group.js.es6 index 9edbcaadb9..3953780ee9 100644 --- a/app/assets/javascripts/discourse/models/user_action_group.js +++ b/app/assets/javascripts/discourse/models/user-action-group.js.es6 @@ -1,12 +1,7 @@ /** A data model representing a group of UserActions - - @class UserActionGroup - @extends Discourse.Model - @namespace Discourse - @module Discourse **/ -Discourse.UserActionGroup = Discourse.Model.extend({ +export default Discourse.Model.extend({ push: function(item) { if (!this.items) { this.items = []; diff --git a/app/assets/javascripts/discourse/models/user-action.js.es6 b/app/assets/javascripts/discourse/models/user-action.js.es6 index f03d819083..50af402544 100644 --- a/app/assets/javascripts/discourse/models/user-action.js.es6 +++ b/app/assets/javascripts/discourse/models/user-action.js.es6 @@ -2,6 +2,7 @@ import RestModel from 'discourse/models/rest'; import { url } from 'discourse/lib/computed'; import { on } from 'ember-addons/ember-computed-decorators'; import computed from 'ember-addons/ember-computed-decorators'; +import UserActionGroup from 'discourse/models/user-action-group'; const UserActionTypes = { likes_given: 1, @@ -35,7 +36,7 @@ const UserAction = RestModel.extend({ @computed("action_type") descriptionKey(action) { - if (action === null || Discourse.UserAction.TO_SHOW.indexOf(action) >= 0) { + if (action === null || UserAction.TO_SHOW.indexOf(action) >= 0) { if (this.get('isPM')) { return this.get('sameUser') ? 'sent_by_you' : 'sent_by_user'; } else { @@ -111,10 +112,10 @@ const UserAction = RestModel.extend({ let groups = this.get("childGroups"); if (!groups) { groups = { - likes: Discourse.UserActionGroup.create({ icon: "fa fa-heart" }), - stars: Discourse.UserActionGroup.create({ icon: "fa fa-star" }), - edits: Discourse.UserActionGroup.create({ icon: "fa fa-pencil" }), - bookmarks: Discourse.UserActionGroup.create({ icon: "fa fa-bookmark" }) + likes: UserActionGroup.create({ icon: "fa fa-heart" }), + stars: UserActionGroup.create({ icon: "fa fa-star" }), + edits: UserActionGroup.create({ icon: "fa fa-pencil" }), + bookmarks: UserActionGroup.create({ icon: "fa fa-bookmark" }) }; } this.set("childGroups", groups); @@ -171,7 +172,7 @@ UserAction.reopenClass({ if (found === void 0) { let current; - if (Discourse.UserAction.TO_COLLAPSE.indexOf(item.action_type) >= 0) { + if (UserAction.TO_COLLAPSE.indexOf(item.action_type) >= 0) { current = UserAction.create(item); item.switchToActing(); current.addChild(item); diff --git a/app/assets/javascripts/discourse/models/user-stream.js.es6 b/app/assets/javascripts/discourse/models/user-stream.js.es6 index fe20fe7c54..706102e079 100644 --- a/app/assets/javascripts/discourse/models/user-stream.js.es6 +++ b/app/assets/javascripts/discourse/models/user-stream.js.es6 @@ -1,5 +1,6 @@ import { url } from 'discourse/lib/computed'; import RestModel from 'discourse/models/rest'; +import UserAction from 'discourse/models/user-action'; export default RestModel.extend({ loaded: false, @@ -11,13 +12,13 @@ export default RestModel.extend({ filterParam: function() { const filter = this.get('filter'); if (filter === Discourse.UserAction.TYPES.replies) { - return [Discourse.UserAction.TYPES.replies, - Discourse.UserAction.TYPES.quotes].join(","); + return [UserAction.TYPES.replies, + UserAction.TYPES.quotes].join(","); } if(!filter) { - return [Discourse.UserAction.TYPES.topics, - Discourse.UserAction.TYPES.posts].join(","); + return [UserAction.TYPES.topics, + UserAction.TYPES.posts].join(","); } return filter; @@ -70,10 +71,10 @@ export default RestModel.extend({ const copy = Em.A(); result.user_actions.forEach(function(action) { action.title = Discourse.Emoji.unescape(Handlebars.Utils.escapeExpression(action.title)); - copy.pushObject(Discourse.UserAction.create(action)); + copy.pushObject(UserAction.create(action)); }); - self.get('content').pushObjects(Discourse.UserAction.collapseStream(copy)); + self.get('content').pushObjects(UserAction.collapseStream(copy)); self.setProperties({ loaded: true, itemsLoaded: self.get('itemsLoaded') + result.user_actions.length diff --git a/app/assets/javascripts/discourse/pre-initializers/register-dom-templates.js.es6 b/app/assets/javascripts/discourse/pre-initializers/register-dom-templates.js.es6 index 2ceaf46a51..41e72df397 100644 --- a/app/assets/javascripts/discourse/pre-initializers/register-dom-templates.js.es6 +++ b/app/assets/javascripts/discourse/pre-initializers/register-dom-templates.js.es6 @@ -2,16 +2,14 @@ export default { name: "register-discourse-dom-templates", before: 'domTemplates', - // a bit smarter than the default one (domTemplates) - // figures out raw vs non-raw automatically - // allows overriding initialize: function() { $('script[type="text/x-handlebars"]').each(function(){ var $this = $(this); var name = $this.attr("name") || $this.data("template-name"); - Ember.TEMPLATES[name] = name.match(/\.raw$/) ? - Discourse.EmberCompatHandlebars.compile($this.text()) : - Ember.Handlebars.compile($this.text()); + + if (window.console) { + window.console.log("WARNING: you have a handlebars template named " + name + " this is an unsupported setup, precompile your templates"); + } $this.remove(); }); } diff --git a/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 b/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 index 382d7749da..9a409fcc82 100644 --- a/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 +++ b/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 @@ -24,6 +24,8 @@ export default { caps.isChrome = !!window.chrome && !caps.isOpera; caps.canPasteImages = caps.isChrome || caps.isFirefox; } + + caps.isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; } // We consider high res a device with 1280 horizontal pixels. High DPI tablets like 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 64078554fd..d67e0a7866 100644 --- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 +++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 @@ -48,7 +48,10 @@ export default function() { }); this.resource('group', { path: '/groups/:name' }, function() { + this.route('topics'); + this.route('mentions'); this.route('members'); + this.route('messages'); }); // User routes @@ -68,6 +71,7 @@ export default function() { this.resource('userPrivateMessages', { path: '/messages' }, function() { this.route('mine'); this.route('unread'); + this.route('group', { path: 'group/:name'}); }); this.resource('preferences', function() { 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 18c959979f..f182d2fea5 100644 --- a/app/assets/javascripts/discourse/routes/build-category-route.js.es6 +++ b/app/assets/javascripts/discourse/routes/build-category-route.js.es6 @@ -1,6 +1,7 @@ import { filterQueryParams, findTopicList } from 'discourse/routes/build-topic-route'; import { queryParams } from 'discourse/controllers/discovery-sortable'; import TopicList from 'discourse/models/topic-list'; +import PermissionType from 'discourse/models/permission-type'; // A helper function to create a category route with parameters export default (filter, params) => { @@ -37,8 +38,8 @@ export default (filter, params) => { _createSubcategoryList(category) { this._categoryList = null; if (Em.isNone(category.get('parentCategory')) && Discourse.SiteSettings.show_subcategory_list) { - return Discourse.CategoryList.listForParent(this.store, category) - .then(list => this._categoryList = list); + const CategoryList = require('discourse/models/category-list').default; + return CategoryList.listForParent(this.store, category).then(list => this._categoryList = list); } // If we're not loading a subcategory list just resolve @@ -68,7 +69,7 @@ export default (filter, params) => { const topics = this.get('topics'), category = model.category, canCreateTopic = topics.get('can_create_topic'), - canCreateTopicOnCategory = category.get('permission') === Discourse.PermissionType.FULL; + canCreateTopicOnCategory = category.get('permission') === PermissionType.FULL; this.controllerFor('navigation/category').setProperties({ canCreateTopicOnCategory: canCreateTopicOnCategory, diff --git a/app/assets/javascripts/discourse/routes/group-index.js.es6 b/app/assets/javascripts/discourse/routes/group-index.js.es6 index 89dec1edd2..bb8ecaeb03 100644 --- a/app/assets/javascripts/discourse/routes/group-index.js.es6 +++ b/app/assets/javascripts/discourse/routes/group-index.js.es6 @@ -9,6 +9,6 @@ export default Discourse.Route.extend({ setupController(controller, model) { controller.set("model", model); - this.controllerFor("group").set("showing", "index"); + this.controllerFor("group").set("showing", "posts"); } }); diff --git a/app/assets/javascripts/discourse/routes/group-mentions.js.es6 b/app/assets/javascripts/discourse/routes/group-mentions.js.es6 new file mode 100644 index 0000000000..1054fb18d3 --- /dev/null +++ b/app/assets/javascripts/discourse/routes/group-mentions.js.es6 @@ -0,0 +1,11 @@ +export default Discourse.Route.extend({ + + model() { + return this.modelFor("group").findPosts({type: 'mentions'}); + }, + + setupController(controller, model) { + controller.set("model", model); + this.controllerFor("group").set("showing", "mentions"); + } +}); diff --git a/app/assets/javascripts/discourse/routes/group-messages.js.es6 b/app/assets/javascripts/discourse/routes/group-messages.js.es6 new file mode 100644 index 0000000000..34e077e362 --- /dev/null +++ b/app/assets/javascripts/discourse/routes/group-messages.js.es6 @@ -0,0 +1,11 @@ +export default Discourse.Route.extend({ + + model() { + return this.modelFor("group").findPosts({type: 'messages'}); + }, + + setupController(controller, model) { + controller.set("model", model); + this.controllerFor("group").set("showing", "messages"); + } +}); diff --git a/app/assets/javascripts/discourse/routes/group-topics.js.es6 b/app/assets/javascripts/discourse/routes/group-topics.js.es6 new file mode 100644 index 0000000000..397572e77b --- /dev/null +++ b/app/assets/javascripts/discourse/routes/group-topics.js.es6 @@ -0,0 +1,11 @@ +export default Discourse.Route.extend({ + + model() { + return this.modelFor("group").findPosts({type: 'topics'}); + }, + + setupController(controller, model) { + controller.set("model", model); + this.controllerFor("group").set("showing", "topics"); + } +}); diff --git a/app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6 b/app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6 index 10e77c47b4..2faf69d0fb 100644 --- a/app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6 +++ b/app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6 @@ -1,16 +1,12 @@ -import PostStream from "discourse/models/post-stream"; +import { loadTopicView } from 'discourse/models/topic'; export default Discourse.Route.extend({ model(params) { const topic = this.store.createRecord("topic", { id: params.id }); - return PostStream.loadTopicView(params.id).then(json => { - topic.updateFromJson(json); - return topic; - }); + return loadTopicView(topic).then(() => topic); }, afterModel(topic) { - // hide the notification reason text topic.set("details.notificationReasonText", null); }, diff --git a/app/assets/javascripts/discourse/routes/user-activity-bookmarks.js.es6 b/app/assets/javascripts/discourse/routes/user-activity-bookmarks.js.es6 index ad2b2bd986..cb14c18153 100644 --- a/app/assets/javascripts/discourse/routes/user-activity-bookmarks.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-activity-bookmarks.js.es6 @@ -1,5 +1,6 @@ import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; +import UserAction from "discourse/models/user-action"; export default UserActivityStreamRoute.extend({ - userActionType: Discourse.UserAction.TYPES["bookmarks"] + userActionType: UserAction.TYPES["bookmarks"] }); diff --git a/app/assets/javascripts/discourse/routes/user-activity-edits.js.es6 b/app/assets/javascripts/discourse/routes/user-activity-edits.js.es6 index a3192d2a1d..32c624edfb 100644 --- a/app/assets/javascripts/discourse/routes/user-activity-edits.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-activity-edits.js.es6 @@ -1,5 +1,6 @@ import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; +import UserAction from "discourse/models/user-action"; export default UserActivityStreamRoute.extend({ - userActionType: Discourse.UserAction.TYPES["edits"] + userActionType: UserAction.TYPES["edits"] }); diff --git a/app/assets/javascripts/discourse/routes/user-activity-likes-given.js.es6 b/app/assets/javascripts/discourse/routes/user-activity-likes-given.js.es6 index 84ed7b4501..13a0e5b986 100644 --- a/app/assets/javascripts/discourse/routes/user-activity-likes-given.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-activity-likes-given.js.es6 @@ -1,5 +1,6 @@ import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; +import UserAction from "discourse/models/user-action"; export default UserActivityStreamRoute.extend({ - userActionType: Discourse.UserAction.TYPES["likes_given"] + userActionType: UserAction.TYPES["likes_given"] }); diff --git a/app/assets/javascripts/discourse/routes/user-activity-likes-received.js.es6 b/app/assets/javascripts/discourse/routes/user-activity-likes-received.js.es6 index 3e7e5cdc4c..a533005648 100644 --- a/app/assets/javascripts/discourse/routes/user-activity-likes-received.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-activity-likes-received.js.es6 @@ -1,5 +1,6 @@ import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; +import UserAction from "discourse/models/user-action"; export default UserActivityStreamRoute.extend({ - userActionType: Discourse.UserAction.TYPES["likes_received"] + userActionType: UserAction.TYPES["likes_received"] }); diff --git a/app/assets/javascripts/discourse/routes/user-activity-mentions.js.es6 b/app/assets/javascripts/discourse/routes/user-activity-mentions.js.es6 index ae90417db6..aecde13106 100644 --- a/app/assets/javascripts/discourse/routes/user-activity-mentions.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-activity-mentions.js.es6 @@ -1,5 +1,6 @@ import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; +import UserAction from "discourse/models/user-action"; export default UserActivityStreamRoute.extend({ - userActionType: Discourse.UserAction.TYPES["mentions"] + userActionType: UserAction.TYPES["mentions"] }); diff --git a/app/assets/javascripts/discourse/routes/user-activity-pending.js.es6 b/app/assets/javascripts/discourse/routes/user-activity-pending.js.es6 index 5d7e9e47b7..05faabbb30 100644 --- a/app/assets/javascripts/discourse/routes/user-activity-pending.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-activity-pending.js.es6 @@ -1,5 +1,6 @@ import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; +import UserAction from "discourse/models/user-action"; export default UserActivityStreamRoute.extend({ - userActionType: Discourse.UserAction.TYPES.pending + userActionType: UserAction.TYPES.pending }); diff --git a/app/assets/javascripts/discourse/routes/user-activity-posts.js.es6 b/app/assets/javascripts/discourse/routes/user-activity-posts.js.es6 index 0be8704abf..62607c9a16 100644 --- a/app/assets/javascripts/discourse/routes/user-activity-posts.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-activity-posts.js.es6 @@ -1,5 +1,6 @@ import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; +import UserAction from "discourse/models/user-action"; export default UserActivityStreamRoute.extend({ - userActionType: Discourse.UserAction.TYPES["posts"] + userActionType: UserAction.TYPES["posts"] }); diff --git a/app/assets/javascripts/discourse/routes/user-activity-replies.js.es6 b/app/assets/javascripts/discourse/routes/user-activity-replies.js.es6 index 29fd25a2a2..28e11587f4 100644 --- a/app/assets/javascripts/discourse/routes/user-activity-replies.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-activity-replies.js.es6 @@ -1,5 +1,6 @@ import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; +import UserAction from "discourse/models/user-action"; export default UserActivityStreamRoute.extend({ - userActionType: Discourse.UserAction.TYPES["replies"] + userActionType: UserAction.TYPES["replies"] }); 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 65210df7a7..8c1a443d8e 100644 --- a/app/assets/javascripts/discourse/routes/user-activity-topics.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-activity-topics.js.es6 @@ -1,7 +1,8 @@ import UserTopicListRoute from "discourse/routes/user-topic-list"; +import UserAction from "discourse/models/user-action"; export default UserTopicListRoute.extend({ - userActionType: Discourse.UserAction.TYPES.topics, + userActionType: UserAction.TYPES.topics, model: function() { return this.store.findFiltered('topicList', {filter: 'topics/created-by/' + this.modelFor('user').get('username_lower') }); diff --git a/app/assets/javascripts/discourse/routes/user-private-messages-group.js.es6 b/app/assets/javascripts/discourse/routes/user-private-messages-group.js.es6 new file mode 100644 index 0000000000..173641b1db --- /dev/null +++ b/app/assets/javascripts/discourse/routes/user-private-messages-group.js.es6 @@ -0,0 +1,25 @@ +import Group from 'discourse/models/group'; +import createPMRoute from "discourse/routes/build-user-topic-list-route"; + +export default createPMRoute('groups', 'private-messages-groups').extend({ + model(params) { + const username = this.modelFor("user").get("username_lower"); + return this.store.findFiltered("topicList", { + filter: `topics/private-messages-group/${username}/${params.name}` + }); + }, + + afterModel(model) { + const groupName = _.last(model.get("filter").split('/')); + Group.findAll().then(groups => { + const group = _.first(groups.filterBy("name", groupName)); + this.controllerFor("user-topics-list").set("group", group); + }); + }, + + setupController(controller, model) { + this._super.apply(this, arguments); + const group = _.last(model.get("filter").split('/')); + this.controllerFor("user").set("groupFilter", group); + } +}); diff --git a/app/assets/javascripts/discourse/templates/components/d-checkbox.hbs b/app/assets/javascripts/discourse/templates/components/d-checkbox.hbs new file mode 100644 index 0000000000..b4bbae64a2 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/d-checkbox.hbs @@ -0,0 +1,2 @@ +{{input type="checkbox" checked=checked}} +{{i18n label}} diff --git a/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs b/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs index 722a9f5e1f..1a7fa9b421 100644 --- a/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs +++ b/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs @@ -20,6 +20,12 @@ {{#if emailInEnabled}} +
+ +
diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index ac3a9a9bc9..ae02b7b9cc 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -15,9 +15,10 @@ {{#if editingTopic}} {{#if model.isPrivateMessage}} {{fa-icon "envelope"}} - {{autofocus-text-field id="edit-title" value=buffered.title maxlength=siteSettings.max_topic_title_length}} - {{else}} - {{autofocus-text-field id="edit-title" value=buffered.title maxlength=siteSettings.max_topic_title_length}} + {{/if}} + + {{text-field id="edit-title" value=buffered.title maxlength=siteSettings.max_topic_title_length autofocus="true"}} + {{#if showCategoryChooser}}
{{category-chooser valueAttribute="id" value=buffered.category_id source=buffered.category_id}} {{/if}} @@ -67,9 +68,8 @@ {{#unless model.postStream.loadingFilter}} {{cloaked-collection itemViewClass="post" - idProperty="post_number" defaultHeight="200" - content=model.postStream.posts + content=postsToRender slackRatio="15" loadingHTML="" preservesContext="true" @@ -77,8 +77,6 @@ offsetFixedTop="header" offsetFixedBottom="#reply-control"}} {{/unless}} - - {{conditional-loading-spinner condition=model.postStream.loadingBelow}}
@@ -155,24 +153,24 @@ {{#if currentUser.canManageTopic}} {{show-popup-button action="showTopicAdminMenu" class="show-topic-admin" title="topic_admin_menu" icon="wrench"}} - {{#popup-menu visible=adminMenuVisible hide="hideTopicAdminMenu" title="admin_title"}} -
  • + {{#popup-menu visible=adminMenuVisible hide="hideTopicAdminMenu" title="admin_title" extraClasses="topic-admin-popup-menu"}} +
  • {{d-button action="toggleMultiSelect" icon="tasks" label="topic.actions.multi_select"}}
  • {{#if model.details.can_delete}} -
  • +
  • {{d-button action="deleteTopic" icon="trash-o" label="topic.actions.delete" class="btn-danger"}}
  • {{/if}} {{#if showRecover}} -
  • +
  • {{d-button action="recoverTopic" icon="undo" label="topic.actions.recover"}}
  • {{/if}} -
  • +
  • {{#if model.closed}} {{d-button action="toggleClosed" icon="unlock" label="topic.actions.open"}} {{else}} @@ -183,7 +181,7 @@ {{#unless model.isPrivateMessage}} {{#if model.visible}} -
  • +
  • {{#if isFeatured}} {{d-button action="showFeatureTopic" icon="thumb-tack" label="topic.actions.unpin"}} {{else}} @@ -193,11 +191,11 @@ {{/if}} {{/unless}} -
  • +
  • {{d-button action="showChangeTimestamp" icon="calendar" label="topic.change_timestamp.title"}}
  • -
  • +
  • {{#if model.archived}} {{d-button action="toggleArchived" icon="folder" label="topic.actions.unarchive"}} {{else}} @@ -205,7 +203,7 @@ {{/if}}
  • -
  • +
  • {{#if model.visible}} {{d-button action="toggleVisibility" icon="eye-slash" label="topic.actions.invisible"}} {{else}} diff --git a/app/assets/javascripts/discourse/templates/user-card.hbs b/app/assets/javascripts/discourse/templates/user-card.hbs index a6b30ca57e..6c0762d228 100644 --- a/app/assets/javascripts/discourse/templates/user-card.hbs +++ b/app/assets/javascripts/discourse/templates/user-card.hbs @@ -1,7 +1,7 @@ {{#if controller.visible}}
    - {{bound-avatar avatar "huge"}} + {{bound-avatar avatar "huge"}}
    diff --git a/app/assets/javascripts/discourse/templates/user-selector-autocomplete.raw.hbs b/app/assets/javascripts/discourse/templates/user-selector-autocomplete.raw.hbs index e0f26deae8..795c473539 100644 --- a/app/assets/javascripts/discourse/templates/user-selector-autocomplete.raw.hbs +++ b/app/assets/javascripts/discourse/templates/user-selector-autocomplete.raw.hbs @@ -10,11 +10,10 @@
  • {{/each}} {{#if options.groups}} - {{#if options.users}}
    {{/if}} {{#each group in options.groups}}
  • - + {{group.name}} {{max-usernames group.usernames max="3"}} diff --git a/app/assets/javascripts/discourse/templates/user-topics-list.hbs b/app/assets/javascripts/discourse/templates/user-topics-list.hbs index a63fc87f94..a2aae2de1b 100644 --- a/app/assets/javascripts/discourse/templates/user-topics-list.hbs +++ b/app/assets/javascripts/discourse/templates/user-topics-list.hbs @@ -1,8 +1,11 @@ -{{#if showNewPM}} -
    - {{fa-icon "envelope"}}{{i18n 'user.new_private_message'}} -
    -{{/if}} +
    + {{#if group}} + {{group-notifications-button group=group}} + {{/if}} + {{#if showNewPM}} + {{d-button class="btn-primary pull-right new-private-message" action="composePrivateMessage" icon="envelope" label="user.new_private_message"}} + {{/if}} +
    {{basic-topic-list topicList=model hideCategory=hideCategory diff --git a/app/assets/javascripts/discourse/templates/user/user.hbs b/app/assets/javascripts/discourse/templates/user/user.hbs index 1f61a722de..ae0d50b06a 100644 --- a/app/assets/javascripts/discourse/templates/user/user.hbs +++ b/app/assets/javascripts/discourse/templates/user/user.hbs @@ -197,6 +197,15 @@ {{#if model.hasUnreadPMs}}{{model.private_messages_stats.unread}}{{/if}} {{/link-to}}
  • + {{#each groupPMStats as |group|}} +
  • + {{#link-to 'userPrivateMessages.group' group.name}} + + {{group.name}} + ({{group.count}}) + {{/link-to}} +
  • + {{/each}} {{/if}} diff --git a/app/assets/javascripts/discourse/views/bookmark-button.js.es6 b/app/assets/javascripts/discourse/views/bookmark-button.js.es6 index 4fc63222c0..6135feae89 100644 --- a/app/assets/javascripts/discourse/views/bookmark-button.js.es6 +++ b/app/assets/javascripts/discourse/views/bookmark-button.js.es6 @@ -1,4 +1,5 @@ import ButtonView from 'discourse/views/button'; +import { iconHTML } from 'discourse/helpers/fa-icon'; export default ButtonView.extend({ classNames: ['bookmark'], @@ -16,12 +17,12 @@ export default ButtonView.extend({ return this.get("bookmarked") ? "bookmarked.help.unbookmark" : "bookmarked.help.bookmark"; }.property("bookmarked"), - click: function() { + click() { this.get('controller').send('toggleBookmark'); }, - renderIcon: function(buffer) { - var className = this.get("bookmarked") ? "bookmarked" : ""; - buffer.push(""); + renderIcon(buffer) { + const className = this.get("bookmarked") ? "bookmarked" : ""; + buffer.push(iconHTML('bookmark', { class: className })); } }); diff --git a/app/assets/javascripts/discourse/views/cloaked-collection.js.es6 b/app/assets/javascripts/discourse/views/cloaked-collection.js.es6 index b15e5c60a6..4bf5f9f2f8 100644 --- a/app/assets/javascripts/discourse/views/cloaked-collection.js.es6 +++ b/app/assets/javascripts/discourse/views/cloaked-collection.js.es6 @@ -27,7 +27,6 @@ const CloakedCollectionView = Ember.CollectionView.extend({ init() { this._super(); - if (idProperty) { this.set('elementId', cloakView + '-cloak-' + this.get('content.' + idProperty)); } @@ -84,7 +83,12 @@ const CloakedCollectionView = Ember.CollectionView.extend({ const mid = Math.floor((min + max) / 2), // in case of not full-window scrolling $view = childViews[mid].$(), - viewBottom = $view.position().top + wrapperTop + $view.height(); + + // .position is quite expensive, shortcut here to get a slightly rougher + // but much faster value + parentOffsetTop = $view.offsetParent().offset().top, + offsetTop = $view.offset().top, + viewBottom = (offsetTop - parentOffsetTop) + wrapperTop + $view.height(); if (viewBottom > viewportTop) { max = mid-1; @@ -124,8 +128,9 @@ const CloakedCollectionView = Ember.CollectionView.extend({ const viewportTop = windowTop - slack, topView = this.findTopView(childViews, viewportTop, 0, childViews.length-1); - let windowBottom = windowTop + windowHeight, - viewportBottom = windowBottom + slack; + let windowBottom = windowTop + windowHeight; + let viewportBottom = windowBottom + slack; + if (windowBottom > bodyHeight) { windowBottom = bodyHeight; } if (viewportBottom > bodyHeight) { viewportBottom = bodyHeight; } @@ -139,22 +144,28 @@ const CloakedCollectionView = Ember.CollectionView.extend({ // Find the bottom view and what's onscreen let bottomView = topView; + let bottomVisible = null; while (bottomView < childViews.length) { - const view = childViews[bottomView], - $view = view.$(); + const view = childViews[bottomView]; + const $view = view.$(); if (!$view) { break; } // in case of not full-window scrolling - const scrollOffset = this.get('wrapperTop') || 0, - viewTop = $view.offset().top + scrollOffset, - viewBottom = viewTop + $view.height(); + const scrollOffset = this.get('wrapperTop') || 0; + const viewTop = $view.offset().top + scrollOffset; + const viewBottom = viewTop + $view.height(); if (viewTop > viewportBottom) { break; } toUncloak.push(view); if (viewBottom > windowTop && viewTop <= windowBottom) { - onscreen.push(view.get('content')); + const content = view.get('content'); + onscreen.push(content); + + if (!view.get('isPlaceholder')) { + bottomVisible = content; + } onscreenCloaks.push(view); } @@ -165,7 +176,7 @@ const CloakedCollectionView = Ember.CollectionView.extend({ // If our controller has a `sawObjects` method, pass the on screen objects to it. const controller = this.get('controller'); if (onscreen.length) { - this.setProperties({topVisible: onscreen[0], bottomVisible: onscreen[onscreen.length-1]}); + this.setProperties({topVisible: onscreen[0], bottomVisible }); if (controller && controller.sawObjects) { Em.run.schedule('afterRender', function() { controller.sawObjects(onscreen); diff --git a/app/assets/javascripts/discourse/views/cloaked.js.es6 b/app/assets/javascripts/discourse/views/cloaked.js.es6 index ddb3a00ae2..ae96413c76 100644 --- a/app/assets/javascripts/discourse/views/cloaked.js.es6 +++ b/app/assets/javascripts/discourse/views/cloaked.js.es6 @@ -1,9 +1,14 @@ +export function Placeholder(viewName) { + this.viewName = viewName; +} + export default Ember.View.extend({ attributeBindings: ['style'], _containedView: null, _scheduled: null, + isPlaceholder: null, - init: function() { + init() { this._super(); this._scheduled = false; this._childViews = []; @@ -15,6 +20,8 @@ export default Ember.View.extend({ this._childViews[0] = cv; } + this.set('isPlaceholder', cv && (cv.get('content') instanceof Placeholder)); + if (cv) { cv.set('_parentView', this); cv.set('templateData', this.get('templateData')); @@ -56,8 +63,8 @@ export default Ember.View.extend({ if (state !== 'inDOM' && state !== 'preRender') { return; } if (!this._containedView) { - const model = this.get('content'), - container = this.get('container'); + const model = this.get('content'); + const container = this.get('container'); let controller; @@ -80,8 +87,8 @@ export default Ember.View.extend({ controller = factory.create({ model, parentController, target: parentController }); } - const createArgs = {}, - target = controller || model; + const createArgs = {}; + const target = controller || model; if (this.get('preservesContext')) { createArgs.content = target; @@ -89,12 +96,10 @@ export default Ember.View.extend({ createArgs.context = target; } if (controller) { createArgs.controller = controller; } - this.setProperties({ - style: null, - loading: false - }); + this.setProperties({ style: ''.htmlSafe(), loading: false }); - this.setContainedView(this.createChildView(this.get('cloaks'), createArgs)); + const cloaks = target && (target instanceof Placeholder) ? target.viewName : this.get('cloaks'); + this.setContainedView(this.createChildView(cloaks, createArgs)); } }, @@ -107,7 +112,7 @@ export default Ember.View.extend({ const self = this; if (this._containedView && (this._state || this.state) === 'inDOM') { - const style = 'height: ' + this.$().height() + 'px;'; + const style = `height: ${this.$().height()}px;`.htmlSafe(); this.set('style', style); this.$().prop('style', style); diff --git a/app/assets/javascripts/discourse/views/flag-topic-button.js.es6 b/app/assets/javascripts/discourse/views/flag-topic-button.js.es6 index f6ac7241fc..ec3296ac09 100644 --- a/app/assets/javascripts/discourse/views/flag-topic-button.js.es6 +++ b/app/assets/javascripts/discourse/views/flag-topic-button.js.es6 @@ -1,15 +1,16 @@ import ButtonView from 'discourse/views/button'; +import { iconHTML } from 'discourse/helpers/fa-icon'; export default ButtonView.extend({ classNames: ['flag-topic'], textKey: 'topic.flag_topic.title', helpKey: 'topic.flag_topic.help', - click: function() { + click() { this.get('controller').send('showFlagTopic', this.get('controller.content')); }, - renderIcon: function(buffer) { - buffer.push(""); + renderIcon(buffer) { + buffer.push(iconHTML('flag')); } }); diff --git a/app/assets/javascripts/discourse/views/group-mentions.js.es6 b/app/assets/javascripts/discourse/views/group-mentions.js.es6 new file mode 100644 index 0000000000..4e25173395 --- /dev/null +++ b/app/assets/javascripts/discourse/views/group-mentions.js.es6 @@ -0,0 +1,6 @@ +import ScrollTop from 'discourse/mixins/scroll-top'; +import LoadMore from "discourse/mixins/load-more"; + +export default Ember.View.extend(ScrollTop, LoadMore, { + eyelineSelector: '.user-stream .item', +}); diff --git a/app/assets/javascripts/discourse/views/group-topics.js.es6 b/app/assets/javascripts/discourse/views/group-topics.js.es6 new file mode 100644 index 0000000000..4e25173395 --- /dev/null +++ b/app/assets/javascripts/discourse/views/group-topics.js.es6 @@ -0,0 +1,6 @@ +import ScrollTop from 'discourse/mixins/scroll-top'; +import LoadMore from "discourse/mixins/load-more"; + +export default Ember.View.extend(ScrollTop, LoadMore, { + eyelineSelector: '.user-stream .item', +}); diff --git a/app/assets/javascripts/discourse/views/invite-reply-button.js.es6 b/app/assets/javascripts/discourse/views/invite-reply-button.js.es6 index 5e3279d5d0..2c856376f0 100644 --- a/app/assets/javascripts/discourse/views/invite-reply-button.js.es6 +++ b/app/assets/javascripts/discourse/views/invite-reply-button.js.es6 @@ -1,4 +1,5 @@ import ButtonView from 'discourse/views/button'; +import { iconHTML } from 'discourse/helpers/fa-icon'; export default ButtonView.extend({ textKey: 'topic.invite_reply.title', @@ -7,7 +8,7 @@ export default ButtonView.extend({ disabled: Em.computed.or('controller.model.archived', 'controller.model.closed', 'controller.model.deleted'), renderIcon(buffer) { - buffer.push(""); + buffer.push(iconHTML('users')); }, click() { diff --git a/app/assets/javascripts/discourse/views/post-placeholder.js.es6 b/app/assets/javascripts/discourse/views/post-placeholder.js.es6 new file mode 100644 index 0000000000..d73c6ce754 --- /dev/null +++ b/app/assets/javascripts/discourse/views/post-placeholder.js.es6 @@ -0,0 +1 @@ +export default Ember.View.extend({ templateName: 'post-placeholder' }); diff --git a/app/assets/javascripts/discourse/views/post.js.es6 b/app/assets/javascripts/discourse/views/post.js.es6 index dbab61f421..28c4fcbe7c 100644 --- a/app/assets/javascripts/discourse/views/post.js.es6 +++ b/app/assets/javascripts/discourse/views/post.js.es6 @@ -327,6 +327,7 @@ const PostView = Discourse.GroupedView.extend(Ember.Evented, { // Find all the quotes Em.run.scheduleOnce('afterRender', this, '_insertQuoteControls'); + $post.closest('.post-cloak').attr('data-post-number', postNumber); this._applySearchHighlight(); }.on('didInsertElement'), diff --git a/app/assets/javascripts/discourse/views/share-button.js.es6 b/app/assets/javascripts/discourse/views/share-button.js.es6 index 93ccdea03a..a7920b4afd 100644 --- a/app/assets/javascripts/discourse/views/share-button.js.es6 +++ b/app/assets/javascripts/discourse/views/share-button.js.es6 @@ -1,4 +1,5 @@ import ButtonView from 'discourse/views/button'; +import { iconHTML } from 'discourse/helpers/fa-icon'; export default ButtonView.extend({ classNames: ['share'], @@ -7,8 +8,7 @@ export default ButtonView.extend({ 'data-share-url': Em.computed.alias('topic.shareUrl'), topic: Em.computed.alias('controller.model'), - renderIcon: function(buffer) { - buffer.push(""); + renderIcon(buffer) { + buffer.push(iconHTML("link")); } }); - diff --git a/app/assets/javascripts/discourse/views/share.js.es6 b/app/assets/javascripts/discourse/views/share.js.es6 index dc0925d7ae..6c1d0f1848 100644 --- a/app/assets/javascripts/discourse/views/share.js.es6 +++ b/app/assets/javascripts/discourse/views/share.js.es6 @@ -56,26 +56,17 @@ export default Ember.View.extend({ return true; }); - $html.on('click.discoure-share-link', '[data-share-url]', function(e) { - // if they want to open in a new tab, let it so - if (e.shiftKey || e.metaKey || e.ctrlKey || e.which === 2) { return true; } - - e.preventDefault(); - - var $currentTarget = $(e.currentTarget), - $currentTargetOffset = $currentTarget.offset(), - $shareLink = $('#share-link'), - url = $currentTarget.data('share-url'), - postNumber = $currentTarget.data('post-number'), - date = $currentTarget.children().data('time'); + function showPanel($target, url, postNumber, date) { + const $currentTargetOffset = $target.offset(); + const $shareLink = $('#share-link'); // Relative urls if (url.indexOf("/") === 0) { url = window.location.protocol + "//" + window.location.host + url; } - var shareLinkWidth = $shareLink.width(); - var x = $currentTargetOffset.left - (shareLinkWidth / 2); + const shareLinkWidth = $shareLink.width(); + let x = $currentTargetOffset.left - (shareLinkWidth / 2); if (x < 25) { x = 25; } @@ -83,8 +74,8 @@ export default Ember.View.extend({ x -= shareLinkWidth / 2; } - var header = $('.d-header'); - var y = $currentTargetOffset.top - ($shareLink.height() + 20); + const header = $('.d-header'); + let y = $currentTargetOffset.top - ($shareLink.height() + 20); if (y < header.offset().top + header.height()) { y = $currentTargetOffset.top + 10; } @@ -98,7 +89,21 @@ export default Ember.View.extend({ self.set('controller.link', url); self.set('controller.postNumber', postNumber); self.set('controller.date', date); + } + this.appEvents.on('share:url', (url, $target) => showPanel($target, url)); + + $html.on('click.discoure-share-link', '[data-share-url]', function(e) { + // if they want to open in a new tab, let it so + if (e.shiftKey || e.metaKey || e.ctrlKey || e.which === 2) { return true; } + + e.preventDefault(); + + const $currentTarget = $(e.currentTarget), + url = $currentTarget.data('share-url'), + postNumber = $currentTarget.data('post-number'), + date = $currentTarget.children().data('time'); + showPanel($currentTarget, url, postNumber, date); return false; }); diff --git a/app/assets/javascripts/discourse/views/topic-footer-main-buttons.js.es6 b/app/assets/javascripts/discourse/views/topic-footer-main-buttons.js.es6 index 8ba5288553..b35cfc9ddb 100644 --- a/app/assets/javascripts/discourse/views/topic-footer-main-buttons.js.es6 +++ b/app/assets/javascripts/discourse/views/topic-footer-main-buttons.js.es6 @@ -6,22 +6,30 @@ export default ContainerView.extend({ @on('init') createButtons() { - if (this.currentUser.get('staff')) { + const mobileView = Discourse.Mobile.mobileView; + + if (!mobileView && this.currentUser.get('staff')) { const viewArgs = {action: 'showTopicAdminMenu', title: 'topic_admin_menu', icon: 'wrench', position: 'absolute'}; this.attachViewWithArgs(viewArgs, 'show-popup-button'); } const topic = this.get('topic'); if (!topic.get('isPrivateMessage')) { - // We hide some controls from private messages - if (this.get('topic.details.can_invite_to')) { - this.attachViewClass('invite-reply-button'); - } - this.attachViewClass('bookmark-button'); - this.attachViewClass('share-button'); - if (this.get('topic.details.can_flag_topic')) { - this.attachViewClass('flag-topic-button'); + + if (mobileView) { + this.attachViewWithArgs({ topic }, 'topic-footer-mobile-dropdown'); + } else { + // We hide some controls from private messages + if (this.get('topic.details.can_invite_to')) { + this.attachViewClass('invite-reply-button'); + } + this.attachViewClass('bookmark-button'); + this.attachViewClass('share-button'); + if (this.get('topic.details.can_flag_topic')) { + this.attachViewClass('flag-topic-button'); + } } + } if (this.get('topic.details.can_create_post')) { this.attachViewClass('reply-button'); diff --git a/app/assets/javascripts/discourse/views/topic-progress.js.es6 b/app/assets/javascripts/discourse/views/topic-progress.js.es6 index 7fc2047204..56e3c0ca71 100644 --- a/app/assets/javascripts/discourse/views/topic-progress.js.es6 +++ b/app/assets/javascripts/discourse/views/topic-progress.js.es6 @@ -76,7 +76,7 @@ export default Ember.View.extend({ _focusWhenOpened: function() { // Don't focus on mobile or touch - if (Discourse.Mobile.mobileView || this.capabilities.touch) { + if (Discourse.Mobile.mobileView || this.capabilities.isIOS) { return; } diff --git a/app/assets/javascripts/discourse/views/topic.js.es6 b/app/assets/javascripts/discourse/views/topic.js.es6 index 186dc0f9ec..a0520d58b8 100644 --- a/app/assets/javascripts/discourse/views/topic.js.es6 +++ b/app/assets/javascripts/discourse/views/topic.js.es6 @@ -154,7 +154,7 @@ const TopicView = Ember.View.extend(AddCategoryClass, AddArchetypeClass, Scrolli } else { return I18n.t("topic.read_more", opts); } - }.property('topicTrackingState.messageCount') + }.property('topicTrackingState.messageCount', 'controller.content.category') }); function highlight(postNumber) { diff --git a/app/assets/javascripts/main_include.js b/app/assets/javascripts/main_include.js index 04914bd0e9..4858e42019 100644 --- a/app/assets/javascripts/main_include.js +++ b/app/assets/javascripts/main_include.js @@ -24,9 +24,12 @@ //= require ./discourse/lib/eyeline //= require ./discourse/helpers/register-unbound //= require ./discourse/mixins/scrolling +//= require ./discourse/models/model //= require ./discourse/models/rest //= require ./discourse/models/badge-grouping //= require ./discourse/models/badge +//= require ./discourse/models/permission-type +//= require ./discourse/models/user-action-group //= require ./discourse/models/category //= require ./discourse/lib/ajax-error //= require ./discourse/lib/markdown @@ -38,13 +41,12 @@ //= require ./discourse/lib/debounce //= require ./discourse/lib/safari-hacks //= require_tree ./discourse/adapters -//= require ./discourse/models/rest -//= require ./discourse/models/model //= require ./discourse/models/result-set //= require ./discourse/models/store //= require ./discourse/models/post-action-type //= require ./discourse/models/action-summary //= require ./discourse/models/post +//= require ./discourse/lib/posts-with-placeholders //= require ./discourse/models/post-stream //= require ./discourse/models/topic-details //= require ./discourse/models/topic diff --git a/app/assets/javascripts/main_include_admin.js b/app/assets/javascripts/main_include_admin.js index 7816da8382..1217131b4f 100644 --- a/app/assets/javascripts/main_include_admin.js +++ b/app/assets/javascripts/main_include_admin.js @@ -9,6 +9,7 @@ //= require admin/routes/admin-email-logs //= require admin/controllers/admin-email-skipped //= require discourse/lib/export-result +//= require_tree ./admin/lib //= require_tree ./admin //= require resumable.js diff --git a/app/assets/javascripts/template_include.js.erb b/app/assets/javascripts/template_include.js.erb new file mode 100644 index 0000000000..ff7c323aea --- /dev/null +++ b/app/assets/javascripts/template_include.js.erb @@ -0,0 +1,8 @@ +<% +if Rails.env.development? || Rails.env.test? + require_asset ("handlebars.js") + require_asset ("ember-template-compiler.js") +else + require_asset ("handlebars.runtime.js") +end +%> diff --git a/app/assets/javascripts/vendor.js b/app/assets/javascripts/vendor.js index ae33b711b8..29ee2c5328 100644 --- a/app/assets/javascripts/vendor.js +++ b/app/assets/javascripts/vendor.js @@ -2,8 +2,7 @@ //= require ./env //= require probes.js -//= require ember-template-compiler -//= require handlebars.js +//= require template_include.js //= require i18n-patches //= require loader diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index 8a3bdd297e..646e2b3dfb 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -43,6 +43,71 @@ td.flaggers td { border-top: none; } +.site-texts { + .search-area { + margin-bottom: 2em; + p { + margin-top: 0; + } + + .site-text-search { + padding: 0.5em; + font-size: 1em; + width: 50%; + } + + .extra-options { + float: right; + input[type=checkbox] { + margin-right: 0.5em; + } + } + + } + .text-highlight { + font-weight: bold; + } + + .site-text { + cursor: pointer; + border-bottom: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); + margin-bottom: 0.5em; + + &.overridden { + background-color: dark-light-diff($highlight, $secondary, 50%, -60%); + } + + h3 { + font-weight: normal; + font-size: 1.1em; + } + + button.edit { + float: right; + } + .site-text-value { + margin: 0.5em 5em 0.5em 0; + max-height: 100px; + color: dark-light-diff($primary, $secondary, 40%, -10%); + } + + } + + .edit-site-text { + textarea { + width: 80%; + } + + .save-messages, .title { + margin-bottom: 1em; + } + + .go-back { + margin-top: 1em; + } + } +} + .content-list li a span.count { font-size: 0.857em; float: right; diff --git a/app/assets/stylesheets/common/base/_topic-list.scss b/app/assets/stylesheets/common/base/_topic-list.scss index 71a5ef8054..d0a66d7a8a 100644 --- a/app/assets/stylesheets/common/base/_topic-list.scss +++ b/app/assets/stylesheets/common/base/_topic-list.scss @@ -266,7 +266,7 @@ ol.category-breadcrumb { padding: 5px; background: $secondary; position: absolute; - z-index: 1110; + z-index: 999; box-shadow: 0 2px 2px rgba(0,0,0, .4); ul { diff --git a/app/assets/stylesheets/common/base/compose.scss b/app/assets/stylesheets/common/base/compose.scss index 4d2c8ad658..4bb02e82dd 100644 --- a/app/assets/stylesheets/common/base/compose.scss +++ b/app/assets/stylesheets/common/base/compose.scss @@ -9,6 +9,10 @@ padding: 0; margin: 0; li { + .fa-users { + color: lighten($primary, 40%); + padding: 0 2px; + } border-bottom: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); a[href] { padding: 5px; diff --git a/app/assets/stylesheets/common/base/header.scss b/app/assets/stylesheets/common/base/header.scss index 51d5835c2b..63c41255d7 100644 --- a/app/assets/stylesheets/common/base/header.scss +++ b/app/assets/stylesheets/common/base/header.scss @@ -118,14 +118,14 @@ margin-left: 0; } .unread-notifications { - right: -4px; + right: 0; background-color: scale-color($tertiary, $lightness: 50%); } .unread-private-messages { - left: 82px; + right: 25px; } .flagged-posts { - left: 40px; + right: 65px; } } .flagged-posts { diff --git a/app/assets/stylesheets/common/base/topic-admin-menu.scss b/app/assets/stylesheets/common/base/topic-admin-menu.scss index 3e7e846e76..651c7ba203 100644 --- a/app/assets/stylesheets/common/base/topic-admin-menu.scss +++ b/app/assets/stylesheets/common/base/topic-admin-menu.scss @@ -13,7 +13,7 @@ width: 205px; padding: 10px; border: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); - z-index: 1001; + z-index: 999; ul { list-style: none; diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index 02cacad328..fc931a22cd 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -1,3 +1,19 @@ +.placeholder-avatar { + display: inline-block; + background-color: dark-light-diff($primary, $secondary, 90%, -75%); + width: 45px; + height: 45px; + border-radius: 50%; +} + +.placeholder-text { + display: inline-block; + background-color: dark-light-diff($primary, $secondary, 90%, -75%); + width: 100%; + height: 1.5em; + margin-bottom: 0.6em; +} + .names { float: left; .username { @@ -131,6 +147,10 @@ aside.quote { } } +.topic-avatar .poster-avatar-extra { + display: none; +} + .topic-body { &.highlighted { background-color: dark-light-diff($tertiary, $secondary, 85%, -65%); diff --git a/app/assets/stylesheets/common/base/topic.scss b/app/assets/stylesheets/common/base/topic.scss index 93a634f55a..11e552d0fd 100644 --- a/app/assets/stylesheets/common/base/topic.scss +++ b/app/assets/stylesheets/common/base/topic.scss @@ -46,12 +46,12 @@ #suggested-topics h3 .badge-wrapper.box span, #suggested-topics h3 .badge-wrapper.bar span { display: inline; - overflow: inherit; } #suggested-topics h3 .badge-wrapper.bullet span.badge-category, { // Override vertical-align: text-top from `badges.css.scss` vertical-align: baseline; + line-height: 1.2; } #suggested-topics h3 .badge-wrapper.bullet, diff --git a/app/assets/stylesheets/common/d-editor.scss b/app/assets/stylesheets/common/d-editor.scss index aa03a1edc4..9cae7e2744 100644 --- a/app/assets/stylesheets/common/d-editor.scss +++ b/app/assets/stylesheets/common/d-editor.scss @@ -46,7 +46,6 @@ float: left; margin-right: 6px; } - } .d-editor-spacer { diff --git a/app/assets/stylesheets/desktop/compose.scss b/app/assets/stylesheets/desktop/compose.scss index 08bc47ebce..0bbb292b88 100644 --- a/app/assets/stylesheets/desktop/compose.scss +++ b/app/assets/stylesheets/desktop/compose.scss @@ -29,7 +29,7 @@ background: dark-light-diff($danger, $secondary, 50%, -40%); } - &.old-topic { + &.education-message { background-color: dark-light-diff($tertiary, $secondary, 85%, -65%); } @@ -359,6 +359,9 @@ .d-editor-input { width: 100%; } + .d-editor-button-bar { + width: 100%; + } .d-editor-preview-wrapper { display: none; } diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss index 625261bcc5..0e1b719540 100644 --- a/app/assets/stylesheets/desktop/topic-post.scss +++ b/app/assets/stylesheets/desktop/topic-post.scss @@ -562,7 +562,7 @@ video { position: relative; } -a.mention { +a.mention, a.mention-group { padding: 2px 4px; color: $primary; background: dark-light-diff($primary, $secondary, 90%, -60%); @@ -685,6 +685,7 @@ blockquote { $topic-body-width: 690px; $topic-body-width-padding: 11px; $topic-avatar-width: 45px; + .topic-body { width: $topic-body-width; float: left; @@ -734,7 +735,7 @@ $topic-avatar-width: 45px; position: absolute; bottom: 115%; left: 0; - z-index: 1000; + z-index: 999; display: none; float: left; width: 550px; diff --git a/app/assets/stylesheets/desktop/user-card.scss b/app/assets/stylesheets/desktop/user-card.scss index f1dcc066ca..a34ca4bd84 100644 --- a/app/assets/stylesheets/desktop/user-card.scss +++ b/app/assets/stylesheets/desktop/user-card.scss @@ -37,6 +37,10 @@ $user_card_background: #222; display: block; clear: both; } + + a.card-huge-avatar { + outline: none; + } } &.no-bg { diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index 71d1481196..1cfdfab7ef 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -630,6 +630,10 @@ clear: both; margin-bottom: 10px; } + .group-notification-menu .dropdown-menu { + top: 30px; + bottom: auto; + } } .paginated-topics-list { diff --git a/app/assets/stylesheets/embed.css.scss b/app/assets/stylesheets/embed.css.scss index acf5fc3031..3b1fd39740 100644 --- a/app/assets/stylesheets/embed.css.scss +++ b/app/assets/stylesheets/embed.css.scss @@ -4,7 +4,6 @@ @import "./common/foundation/variables"; @import "./common/foundation/colors"; @import "./common/foundation/mixins"; -@import "./common/base/onebox"; article.post { border-bottom: 1px solid #ddd; @@ -140,3 +139,6 @@ footer { float: right; max-height: 30px; } + +// load onebox CSS at the end +@import "./common/base/onebox"; diff --git a/app/assets/stylesheets/mobile/topic-post.scss b/app/assets/stylesheets/mobile/topic-post.scss index c58ab353a7..c0ffdc2350 100644 --- a/app/assets/stylesheets/mobile/topic-post.scss +++ b/app/assets/stylesheets/mobile/topic-post.scss @@ -300,6 +300,13 @@ a.star { border-top: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); padding: 20px 0 0 0; .fa-bookmark.bookmarked { color: $tertiary; } + + .combobox { + float: left; + margin-right: 1em; + width: 160px; + margin-bottom: 0.5em; + } } /* this is to force the drop-down notification state description para below the button */ diff --git a/app/assets/stylesheets/vendor/font_awesome/_icons.scss b/app/assets/stylesheets/vendor/font_awesome/_icons.scss index 62d97677c9..6f9375989a 100644 --- a/app/assets/stylesheets/vendor/font_awesome/_icons.scss +++ b/app/assets/stylesheets/vendor/font_awesome/_icons.scss @@ -675,3 +675,23 @@ .#{$fa-css-prefix}-vimeo:before { content: $fa-var-vimeo; } .#{$fa-css-prefix}-black-tie:before { content: $fa-var-black-tie; } .#{$fa-css-prefix}-fonticons:before { content: $fa-var-fonticons; } +.#{$fa-css-prefix}-reddit-alien:before { content: $fa-var-reddit-alien; } +.#{$fa-css-prefix}-edge:before { content: $fa-var-edge; } +.#{$fa-css-prefix}-credit-card-alt:before { content: $fa-var-credit-card-alt; } +.#{$fa-css-prefix}-codiepie:before { content: $fa-var-codiepie; } +.#{$fa-css-prefix}-modx:before { content: $fa-var-modx; } +.#{$fa-css-prefix}-fort-awesome:before { content: $fa-var-fort-awesome; } +.#{$fa-css-prefix}-usb:before { content: $fa-var-usb; } +.#{$fa-css-prefix}-product-hunt:before { content: $fa-var-product-hunt; } +.#{$fa-css-prefix}-mixcloud:before { content: $fa-var-mixcloud; } +.#{$fa-css-prefix}-scribd:before { content: $fa-var-scribd; } +.#{$fa-css-prefix}-pause-circle:before { content: $fa-var-pause-circle; } +.#{$fa-css-prefix}-pause-circle-o:before { content: $fa-var-pause-circle-o; } +.#{$fa-css-prefix}-stop-circle:before { content: $fa-var-stop-circle; } +.#{$fa-css-prefix}-stop-circle-o:before { content: $fa-var-stop-circle-o; } +.#{$fa-css-prefix}-shopping-bag:before { content: $fa-var-shopping-bag; } +.#{$fa-css-prefix}-shopping-basket:before { content: $fa-var-shopping-basket; } +.#{$fa-css-prefix}-hashtag:before { content: $fa-var-hashtag; } +.#{$fa-css-prefix}-bluetooth:before { content: $fa-var-bluetooth; } +.#{$fa-css-prefix}-bluetooth-b:before { content: $fa-var-bluetooth-b; } +.#{$fa-css-prefix}-percent:before { content: $fa-var-percent; } diff --git a/app/assets/stylesheets/vendor/font_awesome/_variables.scss b/app/assets/stylesheets/vendor/font_awesome/_variables.scss index c10cd47f73..0a471102c4 100644 --- a/app/assets/stylesheets/vendor/font_awesome/_variables.scss +++ b/app/assets/stylesheets/vendor/font_awesome/_variables.scss @@ -4,9 +4,9 @@ $fa-font-path: "../fonts" !default; $fa-font-size-base: 14px !default; $fa-line-height-base: 1 !default; -//$fa-font-path: "//netdna.bootstrapcdn.com/font-awesome/4.4.0/fonts" !default; // for referencing Bootstrap CDN font files directly +//$fa-font-path: "//netdna.bootstrapcdn.com/font-awesome/4.5.0/fonts" !default; // for referencing Bootstrap CDN font files directly $fa-css-prefix: fa !default; -$fa-version: "4.4.0" !default; +$fa-version: "4.5.0" !default; $fa-border-color: #eee !default; $fa-inverse: #fff !default; $fa-li-width: (30em / 14) !default; @@ -86,6 +86,8 @@ $fa-var-bitbucket: "\f171"; $fa-var-bitbucket-square: "\f172"; $fa-var-bitcoin: "\f15a"; $fa-var-black-tie: "\f27e"; +$fa-var-bluetooth: "\f293"; +$fa-var-bluetooth-b: "\f294"; $fa-var-bold: "\f032"; $fa-var-bolt: "\f0e7"; $fa-var-bomb: "\f1e2"; @@ -164,6 +166,7 @@ $fa-var-cny: "\f157"; $fa-var-code: "\f121"; $fa-var-code-fork: "\f126"; $fa-var-codepen: "\f1cb"; +$fa-var-codiepie: "\f284"; $fa-var-coffee: "\f0f4"; $fa-var-cog: "\f013"; $fa-var-cogs: "\f085"; @@ -182,6 +185,7 @@ $fa-var-copy: "\f0c5"; $fa-var-copyright: "\f1f9"; $fa-var-creative-commons: "\f25e"; $fa-var-credit-card: "\f09d"; +$fa-var-credit-card-alt: "\f283"; $fa-var-crop: "\f125"; $fa-var-crosshairs: "\f05b"; $fa-var-css3: "\f13c"; @@ -204,6 +208,7 @@ $fa-var-download: "\f019"; $fa-var-dribbble: "\f17d"; $fa-var-dropbox: "\f16b"; $fa-var-drupal: "\f1a9"; +$fa-var-edge: "\f282"; $fa-var-edit: "\f044"; $fa-var-eject: "\f052"; $fa-var-ellipsis-h: "\f141"; @@ -273,6 +278,7 @@ $fa-var-folder-open: "\f07c"; $fa-var-folder-open-o: "\f115"; $fa-var-font: "\f031"; $fa-var-fonticons: "\f280"; +$fa-var-fort-awesome: "\f286"; $fa-var-forumbee: "\f211"; $fa-var-forward: "\f04e"; $fa-var-foursquare: "\f180"; @@ -319,6 +325,7 @@ $fa-var-hand-rock-o: "\f255"; $fa-var-hand-scissors-o: "\f257"; $fa-var-hand-spock-o: "\f259"; $fa-var-hand-stop-o: "\f256"; +$fa-var-hashtag: "\f292"; $fa-var-hdd-o: "\f0a0"; $fa-var-header: "\f1dc"; $fa-var-headphones: "\f025"; @@ -418,8 +425,10 @@ $fa-var-minus: "\f068"; $fa-var-minus-circle: "\f056"; $fa-var-minus-square: "\f146"; $fa-var-minus-square-o: "\f147"; +$fa-var-mixcloud: "\f289"; $fa-var-mobile: "\f10b"; $fa-var-mobile-phone: "\f10b"; +$fa-var-modx: "\f285"; $fa-var-money: "\f0d6"; $fa-var-moon-o: "\f186"; $fa-var-mortar-board: "\f19d"; @@ -446,11 +455,14 @@ $fa-var-paperclip: "\f0c6"; $fa-var-paragraph: "\f1dd"; $fa-var-paste: "\f0ea"; $fa-var-pause: "\f04c"; +$fa-var-pause-circle: "\f28b"; +$fa-var-pause-circle-o: "\f28c"; $fa-var-paw: "\f1b0"; $fa-var-paypal: "\f1ed"; $fa-var-pencil: "\f040"; $fa-var-pencil-square: "\f14b"; $fa-var-pencil-square-o: "\f044"; +$fa-var-percent: "\f295"; $fa-var-phone: "\f095"; $fa-var-phone-square: "\f098"; $fa-var-photo: "\f03e"; @@ -472,6 +484,7 @@ $fa-var-plus-square: "\f0fe"; $fa-var-plus-square-o: "\f196"; $fa-var-power-off: "\f011"; $fa-var-print: "\f02f"; +$fa-var-product-hunt: "\f288"; $fa-var-puzzle-piece: "\f12e"; $fa-var-qq: "\f1d6"; $fa-var-qrcode: "\f029"; @@ -484,6 +497,7 @@ $fa-var-random: "\f074"; $fa-var-rebel: "\f1d0"; $fa-var-recycle: "\f1b8"; $fa-var-reddit: "\f1a1"; +$fa-var-reddit-alien: "\f281"; $fa-var-reddit-square: "\f1a2"; $fa-var-refresh: "\f021"; $fa-var-registered: "\f25d"; @@ -508,6 +522,7 @@ $fa-var-rupee: "\f156"; $fa-var-safari: "\f267"; $fa-var-save: "\f0c7"; $fa-var-scissors: "\f0c4"; +$fa-var-scribd: "\f28a"; $fa-var-search: "\f002"; $fa-var-search-minus: "\f010"; $fa-var-search-plus: "\f00e"; @@ -525,6 +540,8 @@ $fa-var-sheqel: "\f20b"; $fa-var-shield: "\f132"; $fa-var-ship: "\f21a"; $fa-var-shirtsinbulk: "\f214"; +$fa-var-shopping-bag: "\f290"; +$fa-var-shopping-basket: "\f291"; $fa-var-shopping-cart: "\f07a"; $fa-var-sign-in: "\f090"; $fa-var-sign-out: "\f08b"; @@ -572,6 +589,8 @@ $fa-var-stethoscope: "\f0f1"; $fa-var-sticky-note: "\f249"; $fa-var-sticky-note-o: "\f24a"; $fa-var-stop: "\f04d"; +$fa-var-stop-circle: "\f28d"; +$fa-var-stop-circle-o: "\f28e"; $fa-var-street-view: "\f21d"; $fa-var-strikethrough: "\f0cc"; $fa-var-stumbleupon: "\f1a4"; @@ -642,6 +661,7 @@ $fa-var-unlock: "\f09c"; $fa-var-unlock-alt: "\f13e"; $fa-var-unsorted: "\f0dc"; $fa-var-upload: "\f093"; +$fa-var-usb: "\f287"; $fa-var-usd: "\f155"; $fa-var-user: "\f007"; $fa-var-user-md: "\f0f0"; diff --git a/app/assets/stylesheets/vendor/font_awesome/font-awesome.scss b/app/assets/stylesheets/vendor/font_awesome/font-awesome.scss index 8ffb05ed39..4cd98d36f4 100644 --- a/app/assets/stylesheets/vendor/font_awesome/font-awesome.scss +++ b/app/assets/stylesheets/vendor/font_awesome/font-awesome.scss @@ -1,5 +1,5 @@ /*! - * Font Awesome 4.4.0 by @davegandy - http://fontawesome.io - @fontawesome + * Font Awesome 4.5.0 by @davegandy - http://fontawesome.io - @fontawesome * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) */ diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index f614a15b5f..62653c427e 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -81,6 +81,8 @@ class Admin::GroupsController < Admin::AdminController group.primary_group = group.automatic ? false : params["primary_group"] == "true" + group.incoming_email = group.automatic ? nil : params[:incoming_email] + title = params[:title] if params[:title].present? group.title = group.automatic ? nil : title diff --git a/app/controllers/admin/site_text_types_controller.rb b/app/controllers/admin/site_text_types_controller.rb deleted file mode 100644 index f13e1ce704..0000000000 --- a/app/controllers/admin/site_text_types_controller.rb +++ /dev/null @@ -1,7 +0,0 @@ -class Admin::SiteTextTypesController < Admin::AdminController - - def index - render_serialized(SiteText.text_types, SiteTextTypeSerializer, root: 'site_text_types') - end - -end diff --git a/app/controllers/admin/site_texts_controller.rb b/app/controllers/admin/site_texts_controller.rb index 416d990186..d228229512 100644 --- a/app/controllers/admin/site_texts_controller.rb +++ b/app/controllers/admin/site_texts_controller.rb @@ -1,21 +1,70 @@ class Admin::SiteTextsController < Admin::AdminController + def self.preferred_keys + ['system_messages.usage_tips.text_body_template', + 'education.new-topic', + 'education.new-reply', + 'login_required.welcome_message'] + end + + def index + overridden = params[:overridden] == 'true' + extras = {} + + query = params[:q] || "" + if query.blank? && !overridden + extras[:recommended] = true + results = self.class.preferred_keys.map {|k| {id: k, value: I18n.t(k) }} + else + results = [] + translations = I18n.search(query, overridden: overridden) + translations.each do |k, v| + results << {id: k, value: v} + end + + results.sort! do |x, y| + if x[:value].casecmp(query) == 0 + -1 + elsif y[:value].casecmp(query) == 0 + 1 + else + (x[:id].size + x[:value].size) <=> (y[:id].size + y[:value].size) + end + end + end + + render_serialized(results[0..50], SiteTextSerializer, root: 'site_texts', rest_serializer: true, extras: extras) + end + def show - site_text = SiteText.find_or_new(params[:id].to_s) - render_serialized(site_text, SiteTextSerializer, root: 'site_text') + site_text = find_site_text + render_serialized(site_text, SiteTextSerializer, root: 'site_text', rest_serializer: true) end def update - site_text = SiteText.find_or_new(params[:id].to_s) + site_text = find_site_text + site_text[:value] = params[:site_text][:value] + old_text = I18n.t(site_text[:id]) + StaffActionLogger.new(current_user).log_site_text_change(site_text[:id], site_text[:value], old_text) - # Updating to nothing is the same as removing it - if params[:site_text][:value].present? - site_text.value = params[:site_text][:value] - site_text.save! - else - site_text.destroy + TranslationOverride.upsert!(I18n.locale, site_text[:id], site_text[:value]) + render_serialized(site_text, SiteTextSerializer, root: 'site_text', rest_serializer: true) + end + + def revert + site_text = find_site_text + old_text = I18n.t(site_text[:id]) + TranslationOverride.revert!(I18n.locale, site_text[:id]) + site_text = find_site_text + StaffActionLogger.new(current_user).log_site_text_change(site_text[:id], site_text[:value], old_text) + render_serialized(site_text, SiteTextSerializer, root: 'site_text', rest_serializer: true) + end + + protected + + def find_site_text + raise Discourse::NotFound unless I18n.exists?(params[:id]) + {id: params[:id], value: I18n.t(params[:id]) } end - render_serialized(site_text, SiteTextSerializer, root: 'site_text') - end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b9e5971511..8bbbc55a2c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -229,8 +229,9 @@ class ApplicationController < ActionController::Base opts.each do |k, v| obj[k] = v if k.to_s.start_with?("refresh_") end - end + obj['extras'] = opts[:extras] if opts[:extras] + end render json: MultiJson.dump(obj), status: opts[:status] || 200 end diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 52437468d3..5e6a25f1b8 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -178,6 +178,7 @@ class CategoriesController < ApplicationController :position, :email_in, :email_in_allow_strangers, + :contains_messages, :suppress_from_homepage, :parent_category_id, :auto_close_hours, diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 605d04b7d2..e33dfa449e 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -1,13 +1,26 @@ class GroupsController < ApplicationController + before_filter :ensure_logged_in, only: [:set_notifications] + def show render_serialized(find_group(:id), BasicGroupSerializer) end def counts group = find_group(:group_id) - render json: {counts: { posts: group.posts_for(guardian).count, - members: group.users.count } } + + counts = { + posts: group.posts_for(guardian).count, + topics: group.posts_for(guardian).where(post_number: 1).count, + mentions: group.mentioned_posts_for(guardian).count, + members: group.users.count, + } + + if guardian.can_see_group_messages?(group) + counts[:messages] = group.messages_for(guardian).where(post_number: 1).count + end + + render json: { counts: counts } end def posts @@ -16,6 +29,28 @@ class GroupsController < ApplicationController render_serialized posts.to_a, GroupPostSerializer end + def topics + group = find_group(:group_id) + posts = group.posts_for(guardian, params[:before_post_id]).where(post_number: 1).limit(20) + render_serialized posts.to_a, GroupPostSerializer + end + + def mentions + group = find_group(:group_id) + posts = group.mentioned_posts_for(guardian, params[:before_post_id]).limit(20) + render_serialized posts.to_a, GroupPostSerializer + end + + def messages + group = find_group(:group_id) + posts = if guardian.can_see_group_messages?(group) + group.messages_for(guardian, params[:before_post_id]).where(post_number: 1).limit(20).to_a + else + [] + end + render_serialized posts, GroupPostSerializer + end + def members group = find_group(:group_id) @@ -90,6 +125,17 @@ class GroupsController < ApplicationController end + def set_notifications + group = find_group(:id) + notification_level = params.require(:notification_level) + + GroupUser.where(group_id: group.id) + .where(user_id: current_user.id) + .update_all(notification_level: notification_level) + + render json: success_json + end + private def find_group(param_name) @@ -99,8 +145,4 @@ class GroupsController < ApplicationController group end - def the_group - @the_group ||= find_group(:group_id) - end - end diff --git a/app/controllers/list_controller.rb b/app/controllers/list_controller.rb index f700c81d8a..1e7a3245ff 100644 --- a/app/controllers/list_controller.rb +++ b/app/controllers/list_controller.rb @@ -102,10 +102,10 @@ class ListController < ApplicationController end end - [:topics_by, :private_messages, :private_messages_sent, :private_messages_unread].each do |action| + [:topics_by, :private_messages, :private_messages_sent, :private_messages_unread, :private_messages_group].each do |action| define_method("#{action}") do list_opts = build_topic_list_options - target_user = fetch_user_from_params + target_user = fetch_user_from_params(include_inactive: current_user.try(:staff?)) guardian.ensure_can_see_private_messages!(target_user.id) unless action == :topics_by list = generate_list_for(action.to_s, target_user, list_opts) url_prefix = "topics" unless action == :topics_by @@ -252,7 +252,8 @@ class ListController < ApplicationController filter: params[:filter], state: params[:state], search: params[:search], - q: params[:q] + q: params[:q], + group_name: params[:group_name] } options[:no_subcategories] = true if params[:no_subcategories] == 'true' options[:slow_platform] = true if slow_platform? diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 0c20e433a2..2b172ed8c4 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -507,6 +507,14 @@ class PostsController < ApplicationController result[:user_agent] = request.user_agent result[:referrer] = request.env["HTTP_REFERER"] + if usernames = result[:target_usernames] + usernames = usernames.split(",") + groups = Group.mentionable(current_user).where('name in (?)', usernames).pluck('name') + usernames -= groups + result[:target_usernames] = usernames.join(",") + result[:target_group_names] = groups.join(",") + end + result end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 770e1763e2..047283b449 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -71,11 +71,15 @@ class UploadsController < ApplicationController return { errors: I18n.t("upload.file_missing") } if tempfile.nil? - # allow users to upload large images that will be automatically reduced to allowed size - if SiteSetting.max_image_size_kb > 0 && FileHelper.is_image?(filename) && File.size(tempfile.path) > 0 + # allow users to upload (not that) large images that will be automatically reduced to allowed size + uploaded_size = File.size(tempfile.path) + if SiteSetting.max_image_size_kb > 0 && FileHelper.is_image?(filename) && uploaded_size > 0 && uploaded_size < 10.megabytes attempt = 2 allow_animation = type == "avatar" ? SiteSetting.allow_animated_avatars : SiteSetting.allow_animated_thumbnails - while attempt > 0 && File.size(tempfile.path) > SiteSetting.max_image_size_kb.kilobytes + while attempt > 0 + downsized_size = File.size(tempfile.path) + break if downsized_size > uploaded_size + break if downsized_size < SiteSetting.max_image_size_kb.kilobytes image_info = FastImage.new(tempfile.path) rescue nil w, h = *(image_info.try(:size) || [0, 0]) break if w == 0 || h == 0 diff --git a/app/controllers/user_actions_controller.rb b/app/controllers/user_actions_controller.rb index c20f9c8d23..6ff74ec995 100644 --- a/app/controllers/user_actions_controller.rb +++ b/app/controllers/user_actions_controller.rb @@ -4,9 +4,9 @@ class UserActionsController < ApplicationController params.require(:username) params.permit(:filter, :offset) - per_chunk = 60 + per_chunk = 30 - user = fetch_user_from_params + user = fetch_user_from_params(include_inactive: current_user.try(:staff?)) opts = { user_id: user.id, user: user, @@ -34,6 +34,7 @@ class UserActionsController < ApplicationController def private_messages # DO NOT REMOVE + # TODO should preload messages to avoid extra http req end end diff --git a/app/controllers/user_avatars_controller.rb b/app/controllers/user_avatars_controller.rb index 7468fd905e..3b57d0578d 100644 --- a/app/controllers/user_avatars_controller.rb +++ b/app/controllers/user_avatars_controller.rb @@ -1,7 +1,6 @@ require_dependency 'letter_avatar' class UserAvatarsController < ApplicationController - DOT = Base64.decode64("R0lGODlhAQABALMAAAAAAIAAAACAAICAAAAAgIAAgACAgMDAwICAgP8AAAD/AP//AAAA//8A/wD//wBiZCH5BAEAAA8ALAAAAAABAAEAAAQC8EUAOw==") skip_before_filter :preload_json, :redirect_to_login_if_required, :check_xhr, :verify_authenticity_token, only: [:show, :show_letter, :show_proxy_letter] @@ -49,7 +48,7 @@ class UserAvatarsController < ApplicationController no_cookies - return render_dot if params[:version] != LetterAvatar.version + return render_blank if params[:version] != LetterAvatar.version image = LetterAvatar.generate(params[:username].to_s, params[:size].to_i) @@ -71,19 +70,20 @@ class UserAvatarsController < ApplicationController protected def show_in_site(hostname) + username = params[:username].to_s - return render_dot unless user = User.find_by(username_lower: username.downcase) + return render_blank unless user = User.find_by(username_lower: username.downcase) upload_id, version = params[:version].split("_") version = (version || OptimizedImage::VERSION).to_i - return render_dot if version != OptimizedImage::VERSION + return render_blank if version != OptimizedImage::VERSION upload_id = upload_id.to_i - return render_dot unless upload_id > 0 && user_avatar = user.user_avatar + return render_blank unless upload_id > 0 && user_avatar = user.user_avatar size = params[:size].to_i - return render_dot if size < 8 || size > 500 + return render_blank if size < 8 || size > 500 if !Discourse.avatar_sizes.include?(size) && Discourse.store.external? closest = Discourse.avatar_sizes.to_a.min { |a,b| (size-a).abs <=> (size-b).abs } @@ -102,8 +102,7 @@ class UserAvatarsController < ApplicationController optimized_path = Discourse.store.path_for(optimized) image = optimized_path if File.exists?(optimized_path) else - expires_in 1.day, public: true - return redirect_to Discourse.store.cdn_url(optimized.url) + return proxy_avatar(Discourse.store.cdn_url(optimized.url)) end end @@ -113,14 +112,41 @@ class UserAvatarsController < ApplicationController expires_in 1.year, public: true send_file image, disposition: nil else - render_dot + render_blank end end + PROXY_PATH = Rails.root + "tmp/avatar_proxy" + def proxy_avatar(url) + + if url[0..1] == "//" + url = (SiteSetting.use_https ? "https:" : "http:") + url + end + + sha = Digest::SHA1.hexdigest(url) + filename = "#{sha}#{File.extname(url)}" + path = "#{PROXY_PATH}/#{filename}" + + unless File.exist? path + FileUtils.mkdir_p PROXY_PATH + tmp = FileHelper.download(url, 1.megabyte, filename, true) + FileUtils.mv tmp.path, path + end + + # putting a bogus date cause download is not retaining the data + response.headers["Last-Modified"] = DateTime.parse("1-1-2000").httpdate + response.headers["Content-Length"] = File.size(path).to_s + expires_in 1.year, public: true + send_file path, disposition: nil + end + # this protects us from a DoS - def render_dot + def render_blank + path = Rails.root + "public/images/avatar.png" expires_in 10.minutes, public: true - render text: DOT, content_type: "image/png" + response.headers["Last-Modified"] = DateTime.parse("1-1-2000").httpdate + response.headers["Content-Length"] = File.size(path).to_s + send_file path, disposition: nil end def get_optimized_image(upload, size) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index de7722fe58..213442d11d 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -31,7 +31,7 @@ class UsersController < ApplicationController def show raise Discourse::InvalidAccess if SiteSetting.hide_user_profiles_from_public && !current_user - @user = fetch_user_from_params + @user = fetch_user_from_params(include_inactive: current_user.try(:staff?)) user_serializer = UserSerializer.new(@user, scope: guardian, root: 'user') if params[:stats].to_s == "false" user_serializer.omit_stats = true @@ -201,12 +201,26 @@ class UsersController < ApplicationController end def is_local_username - users = params[:usernames] - users = [params[:username]] if users.blank? - users.each(&:downcase!) + usernames = params[:usernames] + usernames = [params[:username]] if usernames.blank? + usernames.each(&:downcase!) - result = User.where(username_lower: users).pluck(:username_lower) - render json: {valid: result} + groups = Group.where(name: usernames).pluck(:name) + mentionable_groups = + if current_user + Group.mentionable(current_user) + .where(name: usernames) + .pluck(:name, :user_count) + .map{ |name,user_count| {name: name, user_count: user_count} } + end + + usernames -= groups + + result = User.where(staged: false) + .where(username_lower: usernames) + .pluck(:username_lower) + + render json: {valid: result, valid_groups: groups, mentionable_groups: mentionable_groups} end def render_available_true @@ -537,7 +551,17 @@ class UsersController < ApplicationController to_render = { users: results.as_json(only: user_fields, methods: [:avatar_template]) } if params[:include_groups] == "true" - to_render[:groups] = Group.search_group(term, current_user).map { |m| { name: m.name, usernames: m.usernames.split(",") } } + to_render[:groups] = Group.search_group(term).map do |m| + {name: m.name, usernames: []} + end + end + + if params[:include_mentionable_groups] == "true" && current_user + to_render[:groups] = Group.mentionable(current_user) + .where("name ILIKE :term_like", term_like: "#{term}%") + .map do |m| + {name: m.name, usernames: []} + end end render json: to_render @@ -618,7 +642,7 @@ class UsersController < ApplicationController end def staff_info - @user = fetch_user_from_params + @user = fetch_user_from_params(include_inactive: true) guardian.ensure_can_see_staff_info!(@user) result = {} diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 022141a73a..9a12e5dbb0 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -149,18 +149,6 @@ module ApplicationHelper result.join("\n") end - # Look up site content for a key. If the key is blank, you can supply a block and that - # will be rendered instead. - def markdown_content(key, replacements=nil) - result = PrettyText.cook(SiteText.text_for(key, replacements || {})).html_safe - if result.blank? && block_given? - yield - nil - else - result - end - end - def application_logo_url @application_logo_url ||= (mobile_view? && SiteSetting.mobile_logo_url) || SiteSetting.logo_url end diff --git a/app/jobs/regular/user_email.rb b/app/jobs/regular/user_email.rb index da5703e360..817b0701a5 100644 --- a/app/jobs/regular/user_email.rb +++ b/app/jobs/regular/user_email.rb @@ -23,7 +23,7 @@ module Jobs return if @user.staged && args[:type] == :digest seen_recently = (@user.last_seen_at.present? && @user.last_seen_at > SiteSetting.email_time_window_mins.minutes.ago) - seen_recently = false if @user.email_always + seen_recently = false if @user.email_always || @user.staged email_args = {} @@ -57,9 +57,10 @@ module Jobs return skip(skip_reason) if skip_reason # Make sure that mailer exists - raise Discourse::InvalidParameters.new(:type) unless UserNotifications.respond_to?(args[:type]) + raise Discourse::InvalidParameters.new("type=#{args[:type]}") unless UserNotifications.respond_to?(args[:type]) message = UserNotifications.send(args[:type], @user, email_args) + # Update the to address if we have a custom one if args[:to_address].present? message.to = [args[:to_address]] diff --git a/app/jobs/scheduled/poll_mailbox.rb b/app/jobs/scheduled/poll_mailbox.rb index 68a1f98632..552833731a 100644 --- a/app/jobs/scheduled/poll_mailbox.rb +++ b/app/jobs/scheduled/poll_mailbox.rb @@ -14,9 +14,7 @@ module Jobs def execute(args) @args = args - if SiteSetting.pop3_polling_enabled? - poll_pop3 - end + poll_pop3 if SiteSetting.pop3_polling_enabled? end def handle_mail(mail) @@ -31,7 +29,6 @@ module Jobs end def handle_failure(mail_string, e) - Rails.logger.warn("Email can not be processed: #{e}\n\n#{mail_string}") if SiteSetting.log_mail_processing_failures template_args = {} @@ -65,7 +62,6 @@ module Jobs message_template = :email_reject_post_error_specified template_args[:post_error] = e.message end - else message_template = nil end @@ -90,14 +86,14 @@ module Jobs connection.start(SiteSetting.pop3_polling_username, SiteSetting.pop3_polling_password) do |pop| unless pop.mails.empty? - pop.each do |mail| - handle_mail(mail) - end + pop.each { |mail| handle_mail(mail) } end pop.finish end rescue Net::POPAuthenticationError => e Discourse.handle_job_exception(e, error_context(@args, "Signing in to poll incoming email")) + rescue Net::POPError => e + Discourse.handle_job_exception(e, error_context(@args, "Generic POP error")) end end diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index e48a604f8c..acc2ff7ed8 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -18,7 +18,7 @@ class UserNotifications < ActionMailer::Base build_email(user.email, template: 'user_notifications.signup_after_approval', email_token: opts[:email_token], - new_user_tips: SiteText.text_for(:usage_tips, base_url: Discourse.base_url)) + new_user_tips: I18n.t('system_messages.usage_tips.text_body_template', base_url: Discourse.base_url)) end def authorize_email(user, opts={}) @@ -113,6 +113,13 @@ class UserNotifications < ActionMailer::Base notification_email(user, opts) end + def group_mentioned(user, opts) + opts[:allow_reply_by_email] = true + opts[:use_site_subject] = true + opts[:show_category_in_subject] = true + notification_email(user, opts) + end + def user_posted(user, opts) opts[:allow_reply_by_email] = true opts[:use_site_subject] = true @@ -149,7 +156,9 @@ class UserNotifications < ActionMailer::Base title: post.topic.title, post: post, username: post.user.username, - from_alias: (SiteSetting.enable_names && SiteSetting.display_name_on_posts && post.user.name.present?) ? post.user.name : post.user.username, + from_alias: (SiteSetting.enable_names && + SiteSetting.display_name_on_email_from && + post.user.name.present?) ? post.user.name : post.user.username, allow_reply_by_email: true, use_site_subject: true, add_re_to_subject: true, @@ -195,7 +204,8 @@ class UserNotifications < ActionMailer::Base return unless @post = opts[:post] user_name = @notification.data_hash[:original_username] - if @post && SiteSetting.enable_names && SiteSetting.display_name_on_posts + + if @post && SiteSetting.enable_names && SiteSetting.display_name_on_email_from name = User.where(id: @post.user_id).pluck(:name).first user_name = name unless name.blank? end @@ -203,7 +213,7 @@ class UserNotifications < ActionMailer::Base notification_type = opts[:notification_type] || Notification.types[@notification.notification_type].to_s return if user.mailing_list_mode && !@post.topic.private_message? && - ["replied", "mentioned", "quoted", "posted"].include?(notification_type) + ["replied", "mentioned", "quoted", "posted", "group_mentioned"].include?(notification_type) title = @notification.data_hash[:topic_title] allow_reply_by_email = opts[:allow_reply_by_email] unless user.suspended? @@ -282,7 +292,10 @@ class UserNotifications < ActionMailer::Base end template = "user_notifications.user_#{notification_type}" - template << "_pm" if post.topic.private_message? + if post.topic.private_message? + template << "_pm" + template << "_staged" if user.staged? + end email_opts = { topic_title: title, @@ -293,7 +306,7 @@ class UserNotifications < ActionMailer::Base topic_id: post.topic_id, context: context, username: username, - add_unsubscribe_link: true, + add_unsubscribe_link: !user.staged, unsubscribe_url: post.topic.unsubscribe_url, allow_reply_by_email: allow_reply_by_email, use_site_subject: use_site_subject, diff --git a/app/models/directory_item.rb b/app/models/directory_item.rb index 6e21ca7bad..61fc7e400c 100644 --- a/app/models/directory_item.rb +++ b/app/models/directory_item.rb @@ -28,8 +28,8 @@ class DirectoryItem < ActiveRecord::Base since = case period_type when :daily then 1.day.ago when :weekly then 1.week.ago - when :quarterly then 3.weeks.ago when :monthly then 1.month.ago + when :quarterly then 3.months.ago when :yearly then 1.year.ago else 1000.years.ago end diff --git a/app/models/email_log.rb b/app/models/email_log.rb index bb324e1a73..d806f3ef96 100644 --- a/app/models/email_log.rb +++ b/app/models/email_log.rb @@ -10,19 +10,25 @@ class EmailLog < ActiveRecord::Base after_create do # Update last_emailed_at if the user_id is present and email was sent - User.where(id: user_id).update_all("last_emailed_at = CURRENT_TIMESTAMP") if user_id.present? and !skipped + User.where(id: user_id).update_all("last_emailed_at = CURRENT_TIMESTAMP") if user_id.present? && !skipped end def self.count_per_day(start_date, end_date) - where('created_at >= ? and created_at < ? AND skipped = false', start_date, end_date).group('date(created_at)').order('date(created_at)').count + sent.where("created_at BETWEEN ? AND ?", start_date, end_date) + .group("DATE(created_at)") + .order("DATE(created_at)") + .count end def self.for(reply_key) - EmailLog.find_by(reply_key: reply_key) + self.find_by(reply_key: reply_key) end def self.last_sent_email_address - where(email_type: 'signup').order('created_at DESC').first.try(:to_address) + self.where(email_type: "signup") + .order(created_at: :desc) + .first + .try(:to_address) end end diff --git a/app/models/embeddable_host.rb b/app/models/embeddable_host.rb index 822f8a8ea2..ee51105bcd 100644 --- a/app/models/embeddable_host.rb +++ b/app/models/embeddable_host.rb @@ -1,5 +1,5 @@ class EmbeddableHost < ActiveRecord::Base - validates_format_of :host, :with => /\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?\Z/i + validates_format_of :host, :with => /\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,7}(:[0-9]{1,5})?(\/.*)?\Z/i belongs_to :category before_validation do diff --git a/app/models/group.rb b/app/models/group.rb index d43704bf69..9748090a3b 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -3,10 +3,13 @@ class Group < ActiveRecord::Base has_many :category_groups, dependent: :destroy has_many :group_users, dependent: :destroy + has_many :group_mentions, dependent: :destroy has_many :categories, through: :category_groups has_many :users, through: :group_users + before_save :downcase_incoming_email + after_save :destroy_deletions after_save :automatic_group_membership after_save :update_primary_group @@ -21,6 +24,8 @@ class Group < ActiveRecord::Base validate :name_format_validator validates_uniqueness_of :name, case_sensitive: false + validate :automatic_membership_email_domains_format_validator + validate :incoming_email_validator AUTO_GROUPS = { :everyone => 0, @@ -46,9 +51,44 @@ class Group < ActiveRecord::Base validates :alias_level, inclusion: { in: ALIAS_LEVELS.values} + scope :mentionable, lambda {|user| + + levels = [ALIAS_LEVELS[:everyone]] + + if user && user.admin? + levels = [ALIAS_LEVELS[:everyone], + ALIAS_LEVELS[:only_admins], + ALIAS_LEVELS[:mods_and_admins], + ALIAS_LEVELS[:members_mods_and_admins]] + elsif user && user.moderator? + levels = [ALIAS_LEVELS[:everyone], + ALIAS_LEVELS[:mods_and_admins], + ALIAS_LEVELS[:members_mods_and_admins]] + end + + where("alias_level in (:levels) OR + ( + alias_level = #{ALIAS_LEVELS[:members_mods_and_admins]} AND id in ( + SELECT group_id FROM group_users WHERE user_id = :user_id) + )", levels: levels, user_id: user && user.id ) + } + + def downcase_incoming_email + self.incoming_email = (incoming_email || "").strip.downcase.presence + end + + def incoming_email_validator + return if self.automatic || self.incoming_email.blank? + unless Email.is_valid?(incoming_email) + self.errors.add(:base, I18n.t('groups.errors.invalid_incoming_email', incoming_email: incoming_email)) + end + end + def posts_for(guardian, before_post_id=nil) - user_ids = group_users.map {|gu| gu.user_id} - result = Post.where(user_id: user_ids).includes(:user, :topic, :topic => :category).references(:posts, :topics, :category) + user_ids = group_users.map { |gu| gu.user_id } + result = Post.includes(:user, :topic, topic: :category) + .references(:posts, :topics, :category) + .where(user_id: user_ids) .where('topics.archetype <> ?', Archetype.private_message) .where(post_type: Post.types[:regular]) @@ -57,14 +97,37 @@ class Group < ActiveRecord::Base result.order('posts.created_at desc') end + def messages_for(guardian, before_post_id=nil) + result = Post.includes(:user, :topic, topic: :category) + .references(:posts, :topics, :category) + .where('topics.archetype = ?', Archetype.private_message) + .where(post_type: Post.types[:regular]) + .where('topics.id IN (SELECT topic_id FROM topic_allowed_groups WHERE group_id = ?)', self.id) + + result = guardian.filter_allowed_categories(result) + result = result.where('posts.id < ?', before_post_id) if before_post_id + result.order('posts.created_at desc') + end + + def mentioned_posts_for(guardian, before_post_id=nil) + result = Post.joins(:group_mentions) + .includes(:user, :topic, topic: :category) + .references(:posts, :topics, :category) + .where('topics.archetype <> ?', Archetype.private_message) + .where(post_type: Post.types[:regular]) + .where('group_mentions.group_id = ?', self.id) + + result = guardian.filter_allowed_categories(result) + result = result.where('posts.id < ?', before_post_id) if before_post_id + result.order('posts.created_at desc') + end + def self.trust_group_ids (10..19).to_a end def self.refresh_automatic_group!(name) - - id = AUTO_GROUPS[name] - return unless id + return unless id = AUTO_GROUPS[name] unless group = self.lookup_group(name) group = Group.new(name: name.to_s, automatic: true) @@ -164,26 +227,8 @@ class Group < ActiveRecord::Base lookup_group(name) || refresh_automatic_group!(name) end - def self.search_group(name, current_user) - levels = [ALIAS_LEVELS[:everyone]] - - if current_user.admin? - levels = [ALIAS_LEVELS[:everyone], - ALIAS_LEVELS[:only_admins], - ALIAS_LEVELS[:mods_and_admins], - ALIAS_LEVELS[:members_mods_and_admins]] - elsif current_user.moderator? - levels = [ALIAS_LEVELS[:everyone], - ALIAS_LEVELS[:mods_and_admins], - ALIAS_LEVELS[:members_mods_and_admins]] - end - - Group.where("name ILIKE :term_like AND (" + - " alias_level in (:levels)" + - " OR (alias_level = #{ALIAS_LEVELS[:members_mods_and_admins]} AND id in (" + - "SELECT group_id FROM group_users WHERE user_id= :user_id)" + - ")" + - ")", term_like: "#{name.downcase}%", levels: levels, user_id: current_user.id) + def self.search_group(name) + Group.where(visible: true).where("name ILIKE :term_like", term_like: "#{name}%") end def self.lookup_group(name) @@ -284,12 +329,28 @@ class Group < ActiveRecord::Base self.group_users.create(user_id: user.id, owner: true) end + def self.find_by_email(email) + self.find_by(incoming_email: Email.downcase(email)) + end + protected def name_format_validator UsernameValidator.perform_validation(self, 'name') end + def automatic_membership_email_domains_format_validator + return if self.automatic_membership_email_domains.blank? + + domains = self.automatic_membership_email_domains.split("|") + domains.each do |domain| + domain.sub!(/^https?:\/\//, '') + domain.sub!(/\/.*$/, '') + self.errors.add :base, (I18n.t('groups.errors.invalid_domain', domain: domain)) unless domain =~ /\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?\Z/i + end + self.automatic_membership_email_domains = domains.join("|") + end + # hack around AR def destroy_deletions if @deletions diff --git a/app/models/group_mention.rb b/app/models/group_mention.rb new file mode 100644 index 0000000000..65faeb6f03 --- /dev/null +++ b/app/models/group_mention.rb @@ -0,0 +1,4 @@ +class GroupMention < ActiveRecord::Base + belongs_to :post + belongs_to :group +end diff --git a/app/models/notification.rb b/app/models/notification.rb index 61da20b0cf..f9611d2a6c 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -30,12 +30,13 @@ class Notification < ActiveRecord::Base @types ||= Enum.new( :mentioned, :replied, :quoted, :edited, :liked, :private_message, :invited_to_private_message, :invitee_accepted, :posted, :moved_post, - :linked, :granted_badge, :invited_to_topic, :custom + :linked, :granted_badge, :invited_to_topic, :custom, :group_mentioned ) end def self.mark_posts_read(user, topic_id, post_numbers) Notification.where(user_id: user.id, topic_id: topic_id, post_number: post_numbers, read: false).update_all "read = 't'" + user.publish_notifications_state end def self.interesting_after(min_date) @@ -114,7 +115,7 @@ class Notification < ActiveRecord::Base if notifications.present? notifications += user .notifications - .order('notifications.created_at desc') + .order('notifications.created_at DESC') .where(read: false, notification_type: Notification.types[:private_message]) .joins(:topic) .where('notifications.id < ?', notifications.last.id) diff --git a/app/models/post.rb b/app/models/post.rb index 5d121f3973..7b3a0b3c8d 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -32,6 +32,7 @@ class Post < ActiveRecord::Base has_many :replies, through: :post_replies has_many :post_actions has_many :topic_links + has_many :group_mentions, dependent: :destroy has_many :post_uploads has_many :uploads, through: :post_uploads diff --git a/app/models/site_customization.rb b/app/models/site_customization.rb index 45397f6ab5..501c720845 100644 --- a/app/models/site_customization.rb +++ b/app/models/site_customization.rb @@ -10,6 +10,10 @@ class SiteCustomization < ActiveRecord::Base %w(stylesheet mobile_stylesheet embedded_css) end + def self.html_fields + %w(body_tag head_tag header mobile_header footer mobile_footer) + end + before_create do self.enabled ||= false self.key ||= SecureRandom.uuid @@ -23,7 +27,32 @@ class SiteCustomization < ActiveRecord::Base raise e end + def process_html(html) + doc = Nokogiri::HTML.fragment(html) + doc.css('script[type="text/x-handlebars"]').each do |node| + name = node["name"] || node["data-template-name"] || "broken" + precompiled = + if name =~ /\.raw$/ + "Discourse.EmberCompatHandlebars.template(#{Barber::EmberCompatPrecompiler.compile(node.inner_html)})" + else + "Ember.HTMLBars.template(#{Barber::Ember::Precompiler.compile(node.inner_html)})" + end + compiled = <") + end + + doc.to_s + end + before_save do + SiteCustomization.html_fields.each do |html_attr| + if self.send("#{html_attr}_changed?") + self.send("#{html_attr}_baked=", process_html(self.send(html_attr))) + end + end + SiteCustomization.css_fields.each do |stylesheet_attr| if self.send("#{stylesheet_attr}_changed?") begin @@ -126,7 +155,12 @@ class SiteCustomization < ActiveRecord::Base val = if styles.present? styles.map do |style| lookup = target == :mobile ? "mobile_#{field}" : field - style.send(lookup) + if html_fields.include?(lookup.to_s) + style.ensure_baked!(lookup) + style.send("#{lookup}_baked") + else + style.send(lookup) + end end.compact.join("\n") end @@ -142,6 +176,15 @@ class SiteCustomization < ActiveRecord::Base @cache.clear end + def ensure_baked!(field) + unless self.send("#{field}_baked") + if val = self.send(field) + val = process_html(val) rescue "" + self.update_columns("#{field}_baked" => val) + end + end + end + def remove_from_cache! self.class.remove_from_cache!(self.class.enabled_key) self.class.remove_from_cache!(key) @@ -177,6 +220,7 @@ end # name :string(255) not null # stylesheet :text # header :text +# header_baked :text # user_id :integer not null # enabled :boolean not null # key :string(255) not null @@ -184,12 +228,17 @@ end # updated_at :datetime not null # stylesheet_baked :text default(""), not null # mobile_stylesheet :text -# mobile_header :text # mobile_stylesheet_baked :text # footer :text +# footer_baked :text +# mobile_header :text # mobile_footer :text +# mobile_header_baked :text +# mobile_footer_baked :text # head_tag :text # body_tag :text +# head_tag_baked :text +# body_tag_baked :text # top :text # mobile_top :text # embedded_css :text diff --git a/app/models/site_text.rb b/app/models/site_text.rb deleted file mode 100644 index 82a0f37407..0000000000 --- a/app/models/site_text.rb +++ /dev/null @@ -1,47 +0,0 @@ -require_dependency 'site_text_type' -require_dependency 'site_text_class_methods' -require_dependency 'distributed_cache' - -class SiteText < ActiveRecord::Base - extend SiteTextClassMethods - - self.primary_key = 'text_type' - - validates_presence_of :value - - after_save do - SiteText.text_for_cache.clear - end - - after_destroy do - SiteText.text_for_cache.clear - end - - def self.formats - @formats ||= Enum.new(:plain, :markdown, :html, :css) - end - - add_text_type :usage_tips, default_18n_key: 'system_messages.usage_tips.text_body_template' - add_text_type :education_new_topic, default_18n_key: 'education.new-topic' - add_text_type :education_new_reply, default_18n_key: 'education.new-reply' - add_text_type :login_required_welcome_message, default_18n_key: 'login_required.welcome_message' - - def site_text_type - @site_text_type ||= SiteText.find_text_type(text_type) - end - -end - -# == Schema Information -# -# Table name: site_texts -# -# text_type :string(255) not null, primary key -# value :text not null -# created_at :datetime not null -# updated_at :datetime not null -# -# Indexes -# -# index_site_texts_on_text_type (text_type) UNIQUE -# diff --git a/app/models/site_text_type.rb b/app/models/site_text_type.rb deleted file mode 100644 index 3b505e870c..0000000000 --- a/app/models/site_text_type.rb +++ /dev/null @@ -1,27 +0,0 @@ -class SiteTextType - - attr_accessor :text_type, :format - - def initialize(text_type, format, opts=nil) - @opts = opts || {} - @text_type = text_type - @format = format - end - - def title - I18n.t("content_types.#{text_type}.title") - end - - def description - I18n.t("content_types.#{text_type}.description") - end - - def allow_blank? - !!@opts[:allow_blank] - end - - def default_text - @opts[:default_18n_key].present? ? I18n.t(@opts[:default_18n_key]) : "" - end - -end diff --git a/app/models/topic.rb b/app/models/topic.rb index deda3d2256..ace0481d2c 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -19,9 +19,9 @@ class Topic < ActiveRecord::Base def_delegator :featured_users, :choose, :feature_topic_users def_delegator :notifier, :watch!, :notify_watch! - def_delegator :notifier, :tracking!, :notify_tracking! + def_delegator :notifier, :track!, :notify_tracking! def_delegator :notifier, :regular!, :notify_regular! - def_delegator :notifier, :muted!, :notify_muted! + def_delegator :notifier, :mute!, :notify_muted! def_delegator :notifier, :toggle_mute, :toggle_mute attr_accessor :allowed_user_ids @@ -540,6 +540,7 @@ class Topic < ActiveRecord::Base def change_category_to_id(category_id) return false if private_message? + return false if category.try(:contains_messages) new_category_id = category_id.to_i # if the category name is blank, reset the attribute @@ -834,10 +835,12 @@ class Topic < ActiveRecord::Base self.auto_close_at = utc.local(now.year, now.month, now.day, m[1].to_i, m[2].to_i) self.auto_close_at += offset_minutes * 60 if offset_minutes self.auto_close_at += 1.day if self.auto_close_at < now + self.auto_close_hours = -1 elsif arg.is_a?(String) && arg.include?("-") && timestamp = utc.parse(arg) # a timestamp in client's time zone, like "2015-5-27 12:00" self.auto_close_at = timestamp self.auto_close_at += offset_minutes * 60 if offset_minutes + self.auto_close_hours = -1 self.errors.add(:auto_close_at, :invalid) if timestamp < Time.zone.now else num_hours = arg.to_f @@ -863,6 +866,10 @@ class Topic < ActiveRecord::Base else self.auto_close_user ||= (self.user.staff? || self.user.trust_level == TrustLevel[4] ? self.user : Discourse.system_user) end + + if self.auto_close_at.try(:<, Time.zone.now) + auto_close(auto_close_user) + end end self diff --git a/app/models/topic_notifier.rb b/app/models/topic_notifier.rb index ee25a15924..db30a8e471 100644 --- a/app/models/topic_notifier.rb +++ b/app/models/topic_notifier.rb @@ -3,10 +3,10 @@ class TopicNotifier @topic = topic end - { :watch! => :watching, - :tracking! => :tracking, - :regular! => :regular, - :muted! => :muted }.each_pair do |method_name, level| + { :watch! => :watching, + :track! => :tracking, + :regular! => :regular, + :mute! => :muted }.each_pair do |method_name, level| define_method method_name do |user_id| change_level user_id, level diff --git a/app/models/topic_status_update.rb b/app/models/topic_status_update.rb index 64dcc256e8..d3befc9f0b 100644 --- a/app/models/topic_status_update.rb +++ b/app/models/topic_status_update.rb @@ -52,7 +52,16 @@ TopicStatusUpdate = Struct.new(:topic, :user) do end def message_for_autoclosed(locale_key) - num_minutes = topic.auto_close_started_at ? ((Time.zone.now - topic.auto_close_started_at) / 1.minute).round : topic.age_in_minutes + num_minutes = (( + if topic.auto_close_based_on_last_post + topic.auto_close_hours.hours + elsif topic.auto_close_started_at + Time.zone.now - topic.auto_close_started_at + else + Time.zone.now - topic.created_at + end + ) / 1.minute).round + if num_minutes.minutes >= 2.days I18n.t("#{locale_key}_days", count: (num_minutes.minutes / 1.day).round) else diff --git a/app/models/topic_user.rb b/app/models/topic_user.rb index 89c452146c..c49f13a43e 100644 --- a/app/models/topic_user.rb +++ b/app/models/topic_user.rb @@ -103,7 +103,7 @@ class TopicUser < ActiveRecord::Base if rows == 0 now = DateTime.now - auto_track_after = User.select(:auto_track_topics_after_msecs).find_by(id: user_id).auto_track_topics_after_msecs + auto_track_after = User.select(:auto_track_topics_after_msecs).find_by(id: user_id).try(:auto_track_topics_after_msecs) auto_track_after ||= SiteSetting.default_other_auto_track_topics_after_msecs if auto_track_after >= 0 && auto_track_after <= (attrs[:total_msecs_viewed].to_i || 0) diff --git a/app/models/upload.rb b/app/models/upload.rb index 32998238b6..8e2150541f 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -107,12 +107,12 @@ class Upload < ActiveRecord::Base end end - # optimize image - ImageOptim.new.optimize_image!(file.path) rescue nil - - # correct size so it displays the optimized image size which is the only - # one that is stored - filesize = File.size(file.path) + # optimize image (but not for GIFs) + if filename !~ /\.GIF$/i + ImageOptim.new.optimize_image!(file.path) rescue nil + # update the file size + filesize = File.size(file.path) + end end # compute the sha of the file diff --git a/app/models/user.rb b/app/models/user.rb index 56d3c62b04..4599876781 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -560,7 +560,7 @@ class User < ActiveRecord::Base # Takes into account admin, etc. def has_trust_level?(level) raise "Invalid trust level #{level}" unless TrustLevel.valid?(level) - admin? || moderator? || TrustLevel.compare(trust_level, level) + admin? || moderator? || staged? || TrustLevel.compare(trust_level, level) end # a touch faster than automatic diff --git a/app/models/user_action.rb b/app/models/user_action.rb index fa87a744b4..320b94d078 100644 --- a/app/models/user_action.rb +++ b/app/models/user_action.rb @@ -78,23 +78,41 @@ SQL def self.private_messages_stats(user_id, guardian) return unless guardian.can_see_private_messages?(user_id) - # list the stats for: all/mine/unread (topic-based) - sql = < ?", post.user_id) + def expand_group_mentions(groups, post) + return unless post.user && groups + + Group.mentionable(post.user).where(id: groups.map(&:id)).each do |group| + next if group.user_count >= SiteSetting.max_users_notified_per_group_mention + yield group, group.users + end + end + # TODO: Move to post-analyzer? + def extract_mentions(post) + mentions = post.raw_mentions + + return unless mentions && mentions.length > 0 + + groups = Group.where('LOWER(name) IN (?)', mentions) + mentions -= groups.map(&:name).map(&:downcase) + + return [groups, nil] unless mentions && mentions.length > 0 + + users = User.where(username_lower: mentions).where.not(id: post.user_id) + + [groups, users] + end + + # TODO: Move to post-analyzer? # Returns a list of users who were quoted in the post def extract_quoted_users(post) post.raw.scan(/\[quote=\"([^,]+),.+\"\]/).uniq.map do |m| - User.find_by("username_lower = :username and id != :id", username: m.first.strip.downcase, id: post.user_id) + User.find_by("username_lower = :username AND id != :id", username: m.first.strip.downcase, id: post.user_id) end.compact end @@ -197,37 +288,29 @@ class PostAlerter end # Notify a bunch of users - def notify_users(users, type, post) + def notify_users(users, type, post, opts=nil) users = [users] unless users.is_a?(Array) if post.topic.try(:private_message?) - whitelist = allowed_users(post) - users.reject! {|u| !whitelist.include?(u)} + whitelist = all_allowed_users(post) + users.reject! { |u| !whitelist.include?(u) } end users.each do |u| - create_notification(u, Notification.types[type], post) + create_notification(u, Notification.types[type], post, opts) end end - # TODO: This should use javascript for parsing rather than re-doing it this way. - def notify_post_users(post) - # Is this post a reply to a user? - reply_to_user = post.reply_notification_target - notify_users(reply_to_user, :replied, post) + def notify_post_users(post, notified) + notify = TopicUser.where(topic_id: post.topic_id) + .where(notification_level: TopicUser.notification_levels[:watching]) - exclude_user_ids = [] << - post.user_id << - extract_mentioned_users(post).map(&:id) << - extract_quoted_users(post).map(&:id) + exclude_user_ids = notified.map(&:id) + notify = notify.where("user_id NOT IN (?)", exclude_user_ids) if exclude_user_ids.present? - 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| - create_notification(tu.user, Notification.types[:posted], post) unless exclude_user_ids.include?(tu.user_id) - end + notify.includes(:user).each do |tu| + create_notification(tu.user, Notification.types[:posted], post) + end end + end diff --git a/app/services/staff_action_logger.rb b/app/services/staff_action_logger.rb index cbb56693a6..c076655102 100644 --- a/app/services/staff_action_logger.rb +++ b/app/services/staff_action_logger.rb @@ -134,6 +134,18 @@ class StaffActionLogger })) end + def log_site_text_change(subject, new_text, old_text, opts={}) + raise Discourse::InvalidParameters.new(:subject) unless subject.present? + raise Discourse::InvalidParameters.new(:new_text) unless new_text.present? + raise Discourse::InvalidParameters.new(:old_text) unless old_text.present? + UserHistory.create( params(opts).merge({ + action: UserHistory.actions[:change_site_text], + subject: subject, + previous_value: old_text, + new_value: new_text + })) + end + def log_username_change(user, old_username, new_username, opts={}) raise Discourse::InvalidParameters.new(:user) unless user UserHistory.create( params(opts).merge({ @@ -215,7 +227,7 @@ class StaffActionLogger changed_attributes = category.previous_changes.slice(*category_params.keys) - if old_permissions != category_params[:permissions] + if !old_permissions.empty? && (old_permissions != category_params[:permissions]) changed_attributes.merge!({ permissions: [old_permissions.to_json, category_params[:permissions].to_json] }) end diff --git a/app/services/user_anonymizer.rb b/app/services/user_anonymizer.rb index 5225326dce..48699b176a 100644 --- a/app/services/user_anonymizer.rb +++ b/app/services/user_anonymizer.rb @@ -28,6 +28,7 @@ class UserAnonymizer @user.email_direct = false @user.email_always = false @user.mailing_list_mode = false + @user.uploaded_avatar_id = nil @user.save profile = @user.user_profile diff --git a/app/views/common/_special_font_face.html.erb b/app/views/common/_special_font_face.html.erb index 5e180838e8..5090a18d15 100644 --- a/app/views/common/_special_font_face.html.erb +++ b/app/views/common/_special_font_face.html.erb @@ -13,11 +13,11 @@