diff --git a/.travis.yml b/.travis.yml index 513f3c15ad..0f46f1ebca 100644 --- a/.travis.yml +++ b/.travis.yml @@ -55,4 +55,4 @@ install: - bash -c "if [ '$RAILS_MASTER' == '1' ]; then bundle update --retry=3 --jobs=3 arel rails rails-observers seed-fu; fi" - bash -c "if [ '$RAILS_MASTER' == '0' ]; then bundle install --without development --deployment --retry=3 --jobs=3; fi" -script: 'bundle exec rspec && bundle exec rake plugin:spec && bundle exec rake qunit:test' +script: "bundle exec rspec && bundle exec rake plugin:spec && bundle exec rake qunit:test['200000']" diff --git a/Gemfile.lock b/Gemfile.lock index 64bf4bc6d2..f48e3ff72e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -212,7 +212,7 @@ GEM omniauth-twitter (1.2.1) json (~> 1.3) omniauth-oauth (~> 1.1) - onebox (1.6.0) + onebox (1.6.2) htmlentities (~> 4.3.4) moneta (~> 0.8) multi_json (~> 1.11) diff --git a/app/assets/javascripts/admin/controllers/admin-group.js.es6 b/app/assets/javascripts/admin/controllers/admin-group.js.es6 index e7d53800e7..660fbafd3b 100644 --- a/app/assets/javascripts/admin/controllers/admin-group.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-group.js.es6 @@ -1,7 +1,5 @@ import { popupAjaxError } from 'discourse/lib/ajax-error'; import { propertyEqual } from 'discourse/lib/computed'; -import { escapeExpression } from 'discourse/lib/utilities'; -import computed from 'ember-addons/ember-computed-decorators'; export default Ember.Controller.extend({ adminGroupsType: Ember.inject.controller(), @@ -37,43 +35,6 @@ export default Ember.Controller.extend({ ]; }.property(), - @computed - demoAvatarUrl() { - return Discourse.getURL('/images/avatar.png'); - }, - - @computed('model.flair_url') - flairPreviewIcon() { - return this.get('model.flair_url') && this.get('model.flair_url').substr(0,3) === 'fa-'; - }, - - @computed('flairPreviewIcon') - flairPreviewImage() { - return this.get('model.flair_url') && !this.get('flairPreviewIcon'); - }, - - @computed('flairPreviewImage', 'model.flair_url', 'model.flairBackgroundHexColor', 'model.flairHexColor') - flairPreviewStyle() { - var style = ''; - if (this.get('flairPreviewImage')) { - style += 'background-image: url(' + escapeExpression(this.get('model.flair_url')) + '); '; - } - if (this.get('model.flairBackgroundHexColor')) { - style += 'background-color: #' + this.get('model.flairBackgroundHexColor') + ';'; - } - if (this.get('model.flairHexColor')) { - style += 'color: #' + this.get('model.flairHexColor') + ';'; - } - return style; - }, - - @computed('model.flairBackgroundHexColor') - flairPreviewClasses() { - if (this.get('model.flairBackgroundHexColor')) { - return 'rounded'; - } - }, - actions: { next() { if (this.get("showingLast")) { return; } diff --git a/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 b/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 index 02a907d7af..9cd65adf10 100644 --- a/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 @@ -1,6 +1,7 @@ import debounce from 'discourse/lib/debounce'; export default Ember.Controller.extend({ + queryParams: ["filter"], filter: null, onlyOverridden: false, filtered: Ember.computed.notEmpty('filter'), diff --git a/app/assets/javascripts/admin/controllers/admin-user-fields.js.es6 b/app/assets/javascripts/admin/controllers/admin-user-fields.js.es6 index 70963a147e..6b106b075d 100644 --- a/app/assets/javascripts/admin/controllers/admin-user-fields.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-user-fields.js.es6 @@ -6,12 +6,8 @@ export default Ember.Controller.extend({ fieldTypes: null, createDisabled: Em.computed.gte('model.length', MAX_FIELDS), - arrangedContent: function() { - return Ember.ArrayProxy.extend(Ember.SortableMixin).create({ - sortProperties: ['position'], - content: this.get('model') - }); - }.property('model'), + fieldSortOrder: ['position'], + sortedFields: Ember.computed.sort('model', 'fieldSortOrder'), actions: { createField() { @@ -20,9 +16,9 @@ export default Ember.Controller.extend({ }, moveUp(f) { - const idx = this.get('arrangedContent').indexOf(f); + const idx = this.get('sortedFields').indexOf(f); if (idx) { - const prev = this.get('arrangedContent').objectAt(idx-1); + const prev = this.get('sortedFields').objectAt(idx-1); const prevPos = prev.get('position'); prev.update({ position: f.get('position') }); @@ -31,9 +27,9 @@ export default Ember.Controller.extend({ }, moveDown(f) { - const idx = this.get('arrangedContent').indexOf(f); + const idx = this.get('sortedFields').indexOf(f); if (idx > -1) { - const next = this.get('arrangedContent').objectAt(idx+1); + const next = this.get('sortedFields').objectAt(idx+1); const nextPos = next.get('position'); next.update({ position: f.get('position') }); 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 9b1b2c72c2..bd38784bb7 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -1,7 +1,5 @@ -export default { - resource: 'admin', - - map() { +export default function() { + this.route('admin', { resetNamespace: true }, function() { this.route('dashboard', { path: '/' }); this.route('adminSiteSettings', { path: '/site_settings', resetNamespace: true }, function() { this.route('adminSiteSettingsCategory', { path: 'category/:category_id', resetNamespace: true} ); @@ -84,5 +82,9 @@ export default { this.route('adminBadges', { path: '/badges', resetNamespace: true }, function() { this.route('show', { path: '/:badge_id' }); }); - } + + this.route('adminPlugins', { path: '/plugins', resetNamespace: true }, function() { + this.route('index', { path: '/' }); + }); + }); }; diff --git a/app/assets/javascripts/admin/templates/group.hbs b/app/assets/javascripts/admin/templates/group.hbs index 6dfd5f6a56..42993a1e92 100644 --- a/app/assets/javascripts/admin/templates/group.hbs +++ b/app/assets/javascripts/admin/templates/group.hbs @@ -4,13 +4,18 @@ {{#if model.automatic}}

{{model.name}}

{{else}} - - {{text-field name="name" value=model.name placeholderKey="admin.groups.name_placeholder"}} + + {{text-field name="name" value=model.name placeholderKey="group.name_placeholder"}} {{/if}} {{#if model.id}} {{#unless model.automatic}} +
+ + {{d-editor value=model.bio_raw}} +
+ {{#if model.hasOwners}}
@@ -101,63 +106,7 @@ {{/unless}} {{#unless model.automatic}} -
-
-
- - {{text-field name="flair_url" value=model.flair_url placeholderKey="admin.groups.flair_url_placeholder"}} -
- -
- - {{text-field name="flair_bg_color" class="flair_bg_color" value=model.flair_bg_color placeholderKey="admin.groups.flair_bg_color_placeholder"}} -
- - {{#if flairPreviewIcon}} -
- - {{text-field name="flair_color" class="flair_color" value=model.flair_color placeholderKey="admin.groups.flair_color_placeholder"}} -
- {{/if}} - -
-
- {{i18n 'admin.groups.flair_note'}} -
-
- - {{#if flairPreviewIcon}} -
-
- -
-
- -
-
- -
-
-
-
- {{/if}} - - {{#if flairPreviewImage}} -
-
- -
-
- -
-
-
-
-
- {{/if}} - -
-
+ {{group-flair-inputs model=model}} {{/unless}}
diff --git a/app/assets/javascripts/admin/templates/user-fields.hbs b/app/assets/javascripts/admin/templates/user-fields.hbs index 3a89a0e06e..72e4933052 100644 --- a/app/assets/javascripts/admin/templates/user-fields.hbs +++ b/app/assets/javascripts/admin/templates/user-fields.hbs @@ -4,11 +4,11 @@

{{i18n 'admin.user_fields.help'}}

{{#if model}} - {{#each arrangedContent as |uf|}} + {{#each sortedFields as |uf|}} {{admin-user-field-item userField=uf fieldTypes=fieldTypes - firstField=arrangedContent.firstObject - lastField=arrangedContent.lastObject + firstField=sortedFields.firstObject + lastField=sortedFields.lastObject destroyAction="destroy" moveUpAction="moveUp" moveDownAction="moveDown"}} diff --git a/app/assets/javascripts/discourse-common/resolver.js.es6 b/app/assets/javascripts/discourse-common/resolver.js.es6 index a0089ae7d3..a69efc4e30 100644 --- a/app/assets/javascripts/discourse-common/resolver.js.es6 +++ b/app/assets/javascripts/discourse-common/resolver.js.es6 @@ -135,7 +135,7 @@ export function buildResolver(baseName) { }, findPluginTemplate(parsedName) { - var pluginParsedName = this.parseName(parsedName.fullName.replace("template:", "template:javascripts/")); + const pluginParsedName = this.parseName(parsedName.fullName.replace("template:", "template:javascripts/")); return this.findTemplate(pluginParsedName); }, diff --git a/app/assets/javascripts/discourse/components/avatar-flair.js.es6 b/app/assets/javascripts/discourse/components/avatar-flair.js.es6 new file mode 100644 index 0000000000..4beaa9cdf5 --- /dev/null +++ b/app/assets/javascripts/discourse/components/avatar-flair.js.es6 @@ -0,0 +1,20 @@ +import { observes } from 'ember-addons/ember-computed-decorators'; +import MountWidget from 'discourse/components/mount-widget'; + +export default MountWidget.extend({ + widget: 'avatar-flair', + + @observes('flairURL', 'flairBgColor', 'flairColor') + _rerender() { + this.queueRerender(); + }, + + buildArgs() { + return { + primary_group_flair_url: this.get('flairURL'), + primary_group_flair_bg_color: this.get('flairBgColor'), + primary_group_flair_color: this.get('flairColor'), + primary_group_name: this.get('groupName') + }; + } +}); diff --git a/app/assets/javascripts/discourse/components/composer-body.js.es6 b/app/assets/javascripts/discourse/components/composer-body.js.es6 index ec3bc602cd..aba22cf728 100644 --- a/app/assets/javascripts/discourse/components/composer-body.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-body.js.es6 @@ -13,7 +13,8 @@ export default Ember.Component.extend({ 'composer.canEditTitle:edit-title', 'composer.createdPost:created-post', 'composer.creatingTopic:topic', - 'composer.whisper:composing-whisper'], + 'composer.whisper:composing-whisper', + 'composer.showComposerEditor::topic-featured-link-only'], @computed('composer.composeState') composeState(composeState) { @@ -27,7 +28,7 @@ export default Ember.Component.extend({ this.appEvents.trigger("composer:resized"); }, - @observes('composeState', 'composer.action') + @observes('composeState', 'composer.action', 'composer.canEditTopicFeaturedLink') resize() { Ember.run.scheduleOnce('afterRender', () => { if (!this.element || this.isDestroying || this.isDestroyed) { return; } @@ -76,6 +77,13 @@ export default Ember.Component.extend({ } }, + @observes('composeState') + disableFullscreen() { + if (this.get('composeState') !== Composer.OPEN && positioningWorkaround.blur) { + positioningWorkaround.blur(); + } + }, + didInsertElement() { this._super(); const $replyControl = $('#reply-control'); diff --git a/app/assets/javascripts/discourse/components/csv-uploader.js.es6 b/app/assets/javascripts/discourse/components/csv-uploader.js.es6 new file mode 100644 index 0000000000..3e6505b190 --- /dev/null +++ b/app/assets/javascripts/discourse/components/csv-uploader.js.es6 @@ -0,0 +1,17 @@ +import computed from "ember-addons/ember-computed-decorators"; +import UploadMixin from "discourse/mixins/upload"; + +export default Em.Component.extend(UploadMixin, { + type: "csv", + tagName: "span", + uploadUrl: "/invites/upload_csv", + + @computed("uploading") + uploadButtonText(uploading) { + return uploading ? I18n.t("uploading") : I18n.t("user.invited.bulk_invite.text"); + }, + + uploadDone() { + bootbox.alert(I18n.t("user.invited.bulk_invite.success")); + } +}); diff --git a/app/assets/javascripts/discourse/components/d-modal-body.js.es6 b/app/assets/javascripts/discourse/components/d-modal-body.js.es6 index 638db7d885..42c797ddf9 100644 --- a/app/assets/javascripts/discourse/components/d-modal-body.js.es6 +++ b/app/assets/javascripts/discourse/components/d-modal-body.js.es6 @@ -28,7 +28,7 @@ export default Ember.Component.extend({ } } - this.appEvents.trigger('modal:body-shown', this.getProperties('title')); + this.appEvents.trigger('modal:body-shown', this.getProperties('title', 'rawTitle')); }, _flash(msg) { diff --git a/app/assets/javascripts/discourse/components/d-modal.js.es6 b/app/assets/javascripts/discourse/components/d-modal.js.es6 index a8de2b82ea..475c79d049 100644 --- a/app/assets/javascripts/discourse/components/d-modal.js.es6 +++ b/app/assets/javascripts/discourse/components/d-modal.js.es6 @@ -19,6 +19,8 @@ export default Ember.Component.extend({ this.appEvents.on('modal:body-shown', data => { if (data.title) { this.set('title', I18n.t(data.title)); + } else if (data.rawTitle) { + this.set('title', data.rawTitle); } }); }, diff --git a/app/assets/javascripts/discourse/components/group-flair-inputs.js.es6 b/app/assets/javascripts/discourse/components/group-flair-inputs.js.es6 new file mode 100644 index 0000000000..f14d45da63 --- /dev/null +++ b/app/assets/javascripts/discourse/components/group-flair-inputs.js.es6 @@ -0,0 +1,50 @@ +import computed from 'ember-addons/ember-computed-decorators'; +import { escapeExpression } from 'discourse/lib/utilities'; + +export default Ember.Component.extend({ + + classNames: ['group-flair-inputs'], + + @computed + demoAvatarUrl() { + return Discourse.getURL('/images/avatar.png'); + }, + + @computed('model.flair_url') + flairPreviewIcon(flairURL) { + return flairURL && flairURL.substr(0,3) === 'fa-'; + }, + + @computed('model.flair_url', 'flairPreviewIcon') + flairPreviewImage(flairURL, flairPreviewIcon) { + return flairURL && !flairPreviewIcon; + }, + + @computed('model.flair_url', 'flairPreviewImage', 'model.flairBackgroundHexColor', 'model.flairHexColor') + flairPreviewStyle(flairURL, flairPreviewImage, flairBackgroundHexColor, flairHexColor) { + let style = ''; + + if (flairPreviewImage) { + style += `background-image: url(${escapeExpression(flairURL)});`; + } + + if (flairBackgroundHexColor) { + style += `background-color: #${flairBackgroundHexColor};`; + } + + if (flairHexColor) style += `color: #${flairHexColor};`; + + return style; + }, + + @computed('model.flairBackgroundHexColor') + flairPreviewClasses(flairBackgroundHexColor) { + if (flairBackgroundHexColor) return 'rounded'; + }, + + @computed('flairPreviewImage') + flairPreviewLabel(flairPreviewImage) { + const key = flairPreviewImage ? 'image' : 'icon'; + return I18n.t(`group.flair_preview_${key}`); + } +}); diff --git a/app/assets/javascripts/discourse/components/login-buttons.js.es6 b/app/assets/javascripts/discourse/components/login-buttons.js.es6 index ccaabff239..4c68d1740f 100644 --- a/app/assets/javascripts/discourse/components/login-buttons.js.es6 +++ b/app/assets/javascripts/discourse/components/login-buttons.js.es6 @@ -1,4 +1,5 @@ import { findAll } from 'discourse/models/login-method'; +import computed from 'ember-addons/ember-computed-decorators'; export default Ember.Component.extend({ elementId: 'login-buttons', @@ -6,9 +7,10 @@ export default Ember.Component.extend({ hidden: Ember.computed.equal('buttons.length', 0), - buttons: function() { - return findAll(this.siteSettings); - }.property(), + @computed + buttons() { + return findAll(this.siteSettings, this.capabilities, this.site.isMobileDevice); + }, actions: { externalLogin: function(provider) { diff --git a/app/assets/javascripts/discourse/components/quote-button.js.es6 b/app/assets/javascripts/discourse/components/quote-button.js.es6 index b6012ed455..77a5c8192a 100644 --- a/app/assets/javascripts/discourse/components/quote-button.js.es6 +++ b/app/assets/javascripts/discourse/components/quote-button.js.es6 @@ -15,6 +15,7 @@ export default Ember.Component.extend({ visible: buffer => buffer && buffer.length > 0, _isMouseDown: false, + _reselected: false, _selectionChanged() { const selection = window.getSelection(); @@ -43,9 +44,12 @@ export default Ember.Component.extend({ // on Desktop, shows the button at the beginning of the selection // on Mobile, shows the button at the end of the selection const isMobileDevice = this.site.isMobileDevice; - const { isIOS, isAndroid } = this.capabilities; + const { isIOS, isAndroid, isSafari } = this.capabilities; const showAtEnd = isMobileDevice || isIOS || isAndroid; + // used to work around Safari losing selection + const clone = firstRange.cloneRange(); + // create a marker element containing a single invisible character const markerElement = document.createElement("span"); markerElement.appendChild(document.createTextNode("\ufeff")); @@ -64,6 +68,13 @@ export default Ember.Component.extend({ // remove the marker markerElement.parentNode.removeChild(markerElement); + // work around Safari that would sometimes lose the selection + if (isSafari) { + this._reselected = true; + selection.removeAllRanges(); + selection.addRange(clone); + } + // change the position of the button Ember.run.scheduleOnce("afterRender", () => { let top = markerOffset.top; @@ -88,6 +99,7 @@ export default Ember.Component.extend({ $(document).on("mousedown.quote-button", (e) => { this._isMouseDown = true; + this._reselected = false; if (!willQuote(e)) { this.sendAction("deselectText"); } @@ -95,7 +107,7 @@ export default Ember.Component.extend({ this._isMouseDown = false; onSelectionChanged(); }).on("selectionchange.quote-button", () => { - if (!this._isMouseDown) { + if (!this._isMouseDown && !this._reselected) { onSelectionChanged(); } }); diff --git a/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 index 4d154491b5..ba68acd8bb 100644 --- a/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 +++ b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 @@ -1,16 +1,16 @@ import { observes } from 'ember-addons/ember-computed-decorators'; -const REGEXP_BLOCKS = /(([^" \t\n\x0B\f\r]+)?(("[^"]+")?))/g; +const REGEXP_BLOCKS = /(([^" \t\n\x0B\f\r]+)?(("[^"]+")?))/g; -const REGEXP_USERNAME_PREFIX = /(user:|@)/ig; -const REGEXP_CATEGORY_PREFIX = /(category:|#)/ig; -const REGEXP_GROUP_PREFIX = /group:/ig; -const REGEXP_BADGE_PREFIX = /badge:/ig; -const REGEXP_TAGS_PREFIX = /tags?:/ig; -const REGEXP_IN_PREFIX = /in:/ig; -const REGEXP_STATUS_PREFIX = /status:/ig; -const REGEXP_POST_COUNT_PREFIX = /posts_count:/ig; -const REGEXP_POST_TIME_PREFIX = /(before|after):/ig; +const REGEXP_USERNAME_PREFIX = /(user:|@)/ig; +const REGEXP_CATEGORY_PREFIX = /(category:|#)/ig; +const REGEXP_GROUP_PREFIX = /group:/ig; +const REGEXP_BADGE_PREFIX = /badge:/ig; +const REGEXP_TAGS_PREFIX = /tags?:/ig; +const REGEXP_IN_PREFIX = /in:/ig; +const REGEXP_STATUS_PREFIX = /status:/ig; +const REGEXP_MIN_POST_COUNT_PREFIX = /min_post_count:/ig; +const REGEXP_POST_TIME_PREFIX = /(before|after):/ig; const REGEXP_IN_MATCH = /in:(posted|watching|tracking|bookmarks|first|pinned|unpinned)/ig; const REGEXP_SPECIAL_IN_LIKES_MATCH = /in:likes/ig; @@ -73,7 +73,7 @@ export default Em.Component.extend({ } }, status: '', - posts_count: '', + min_post_count: '', time: { when: 'before', days: '' @@ -99,7 +99,7 @@ export default Em.Component.extend({ this.setSearchedTermSpecialInValue('searchedTerms.special.in.wiki', REGEXP_SPECIAL_IN_WIKI_MATCH); this.setSearchedTermValue('searchedTerms.status', REGEXP_STATUS_PREFIX); this.setSearchedTermValueForPostTime(); - this.setSearchedTermValue('searchedTerms.posts_count', REGEXP_POST_COUNT_PREFIX); + this.setSearchedTermValue('searchedTerms.min_post_count', REGEXP_MIN_POST_COUNT_PREFIX); }, findSearchTerms() { @@ -490,17 +490,17 @@ export default Em.Component.extend({ } }, - @observes('searchedTerms.posts_count') - updateSearchTermForPostsCount() { - const match = this.filterBlocks(REGEXP_POST_COUNT_PREFIX); - const postsCountFilter = this.get('searchedTerms.posts_count'); + @observes('searchedTerms.min_post_count') + updateSearchTermForMinPostCount() { + const match = this.filterBlocks(REGEXP_MIN_POST_COUNT_PREFIX); + const postsCountFilter = this.get('searchedTerms.min_post_count'); let searchTerm = this.get('searchTerm') || ''; if (postsCountFilter) { if (match.length !== 0) { - searchTerm = searchTerm.replace(match[0], `posts_count:${postsCountFilter}`); + searchTerm = searchTerm.replace(match[0], `min_post_count:${postsCountFilter}`); } else { - searchTerm += ` posts_count:${postsCountFilter}`; + searchTerm += ` min_post_count:${postsCountFilter}`; } this.set('searchTerm', searchTerm.trim()); diff --git a/app/assets/javascripts/discourse/components/topic-progress.js.es6 b/app/assets/javascripts/discourse/components/topic-progress.js.es6 index 768ad10356..85cb520955 100644 --- a/app/assets/javascripts/discourse/components/topic-progress.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-progress.js.es6 @@ -65,7 +65,7 @@ export default Ember.Component.extend({ const prevEvent = this.get('prevEvent'); if (prevEvent) { - this._topicScrolled(prevEvent); + Ember.run.scheduleOnce('afterRender', this, this._topicScrolled, prevEvent); } else { Ember.run.scheduleOnce('afterRender', this, this._updateProgressBar); } diff --git a/app/assets/javascripts/discourse/components/topic-status.js.es6 b/app/assets/javascripts/discourse/components/topic-status.js.es6 index 5afba3db76..bede7d57a6 100644 --- a/app/assets/javascripts/discourse/components/topic-status.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-status.js.es6 @@ -27,8 +27,6 @@ export default Ember.Component.extend(bufferedRender({ }.property('disableActions'), buildBuffer(buffer) { - const self = this; - const renderIcon = function(name, key, actionable) { const title = escapeExpression(I18n.t(`topic_statuses.${key}.help`)), startTag = actionable ? "a href" : "span", @@ -39,8 +37,8 @@ export default Ember.Component.extend(bufferedRender({ buffer.push(`<${startTag} title='${title}' class='topic-status'>${icon}`); }; - const renderIconIf = function(conditionProp, name, key, actionable) { - if (!self.get(conditionProp)) { return; } + const renderIconIf = (conditionProp, name, key, actionable) => { + if (!this.get(conditionProp)) { return; } renderIcon(name, key, actionable); }; diff --git a/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 b/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 index 5b435b8d9a..19d6ae6b80 100644 --- a/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 +++ b/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 @@ -12,7 +12,7 @@ export default DiscoveryController.extend({ return Discourse.User.currentProp('staff'); }, - @computed("model.categories.@each.featuredTopics.length") + @computed("model.categories.[].featuredTopics.length") latestTopicOnly() { return this.get("model.categories").find(c => c.get("featuredTopics.length") > 1) === undefined; }, diff --git a/app/assets/javascripts/discourse/controllers/edit-group.js.es6 b/app/assets/javascripts/discourse/controllers/edit-group.js.es6 new file mode 100644 index 0000000000..01db8c7ad4 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/edit-group.js.es6 @@ -0,0 +1,20 @@ +import { popupAjaxError } from 'discourse/lib/ajax-error'; + +export default Ember.Controller.extend({ + saving: false, + + actions: { + save() { + this.set('saving', true); + + this.get('model').save().then(() => { + this.transitionToRoute('group', this.get('model.name')); + this.send('closeModal'); + }).catch(error => { + popupAjaxError(error); + }).finally(() => { + this.set('saving', false); + }); + } + } +}); diff --git a/app/assets/javascripts/discourse/controllers/group-index.js.es6 b/app/assets/javascripts/discourse/controllers/group-index.js.es6 index 306ff8aa80..162ac3e898 100644 --- a/app/assets/javascripts/discourse/controllers/group-index.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group-index.js.es6 @@ -1,22 +1,11 @@ import { popupAjaxError } from 'discourse/lib/ajax-error'; -import computed from 'ember-addons/ember-computed-decorators'; import Group from 'discourse/models/group'; export default Ember.Controller.extend({ loading: false, limit: null, offset: null, - - @computed('model.owners.[]') - isOwner(owners) { - if (this.get('currentUser.admin')) { - return true; - } - const currentUserId = this.get('currentUser.id'); - if (currentUserId) { - return !!owners.findBy('id', currentUserId); - } - }, + isOwner: Ember.computed.alias('model.is_group_owner'), actions: { removeMember(user) { diff --git a/app/assets/javascripts/discourse/controllers/group.js.es6 b/app/assets/javascripts/discourse/controllers/group.js.es6 index 56a5325d2a..c4c4eae326 100644 --- a/app/assets/javascripts/discourse/controllers/group.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group.js.es6 @@ -12,7 +12,6 @@ var Tab = Em.Object.extend({ } }); - export default Ember.Controller.extend({ counts: null, showing: 'members', @@ -24,12 +23,29 @@ export default Ember.Controller.extend({ Tab.create({ name: 'messages', requiresMembership: true }) ], - @observes('counts') - countsChanged() { - const counts = this.get('counts'); - this.get('tabs').forEach(tab => { - tab.set('count', counts.get(tab.get('name'))); - }); + @computed('model.is_group_owner', 'model.automatic') + canEditGroup(isGroupOwner, automatic) { + return !automatic && isGroupOwner; + }, + + @computed('model.name', 'model.title') + groupName(name, title) { + return (title || name).capitalize(); + }, + + @computed('model.name', 'model.flair_url', 'model.flair_bg_color', 'model.flair_color') + avatarFlairAttributes(groupName, flairURL, flairBgColor, flairColor) { + return { + primary_group_flair_url: flairURL, + primary_group_flair_bg_color: flairBgColor, + primary_group_flair_color: flairColor, + primary_group_name: groupName + }; + }, + + @observes('model.user_count') + _setMembersTabCount() { + this.get('tabs')[0].set('count', this.get('model.user_count')); }, @observes('showing') diff --git a/app/assets/javascripts/discourse/controllers/history.js.es6 b/app/assets/javascripts/discourse/controllers/history.js.es6 index 42550be880..e94852cf3e 100644 --- a/app/assets/javascripts/discourse/controllers/history.js.es6 +++ b/app/assets/javascripts/discourse/controllers/history.js.es6 @@ -21,6 +21,9 @@ export default Ember.Controller.extend(ModalFunctionality, { if (this.site.mobileView) { this.set("viewMode", "inline"); } }.on("init"), + previousFeaturedLink: Em.computed.alias('model.featured_link_changes.previous'), + currentFeaturedLink: Em.computed.alias('model.featured_link_changes.current'), + previousTagChanges: customTagArray('model.tags_changes.previous'), currentTagChanges: customTagArray('model.tags_changes.current'), diff --git a/app/assets/javascripts/discourse/controllers/preferences.js.es6 b/app/assets/javascripts/discourse/controllers/preferences.js.es6 index 4e96336563..5ee945d4da 100644 --- a/app/assets/javascripts/discourse/controllers/preferences.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences.js.es6 @@ -157,9 +157,7 @@ export default Ember.Controller.extend(CanCheckEmails, { // Cook the bio for preview model.set('name', this.get('newNameInput')); - var options = {}; - - return model.save(options).then(() => { + return model.save().then(() => { if (Discourse.User.currentProp('id') === model.get('id')) { Discourse.User.currentProp('name', model.get('name')); } diff --git a/app/assets/javascripts/discourse/controllers/reorder-categories.js.es6 b/app/assets/javascripts/discourse/controllers/reorder-categories.js.es6 index 18a000a388..c975eb5bfb 100644 --- a/app/assets/javascripts/discourse/controllers/reorder-categories.js.es6 +++ b/app/assets/javascripts/discourse/controllers/reorder-categories.js.es6 @@ -5,8 +5,6 @@ import { popupAjaxError } from 'discourse/lib/ajax-error'; import { on, default as computed } from "ember-addons/ember-computed-decorators"; import Ember from 'ember'; -const SortableArrayProxy = Ember.ArrayProxy.extend(Ember.SortableMixin); - export default Ember.Controller.extend(ModalFunctionality, Ember.Evented, { @on('init') @@ -20,12 +18,8 @@ export default Ember.Controller.extend(ModalFunctionality, Ember.Evented, { return categories.map(c => bufProxy.create({ content: c })); }, - categoriesOrdered: function() { - return SortableArrayProxy.create({ - sortProperties: ['content.position'], - content: this.get('categoriesBuffered') - }); - }.property('categoriesBuffered'), + categoriesSorting: ['position'], + categoriesOrdered: Ember.computed.sort('categoriesBuffered', 'categoriesSorting'), showFixIndices: function() { const cats = this.get('categoriesOrdered'); diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 377aebd76d..4b9650fd98 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -160,6 +160,14 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { return post => this.postSelected(post); }.property(), + @computed('model.isPrivateMessage', 'model.category.id') + canEditTopicFeaturedLink(isPrivateMessage, categoryId) { + if (!this.siteSettings.topic_featured_link_enabled || isPrivateMessage) { return false; } + + const categoryIds = this.site.get('topic_featured_link_allowed_category_ids'); + return categoryIds === undefined || !categoryIds.length || categoryIds.indexOf(categoryId) !== -1; + }, + @computed('model.isPrivateMessage') canEditTags(isPrivateMessage) { return !isPrivateMessage && this.site.get('can_tag_topics'); diff --git a/app/assets/javascripts/discourse/controllers/user-activity.js.es6 b/app/assets/javascripts/discourse/controllers/user-activity.js.es6 index 4e9a063060..624a8b0ae7 100644 --- a/app/assets/javascripts/discourse/controllers/user-activity.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-activity.js.es6 @@ -23,7 +23,7 @@ export default Ember.Controller.extend({ actions: { exportUserArchive() { bootbox.confirm( - I18n.t("user.download_archive_confirm"), + I18n.t("user.download_archive.confirm"), I18n.t("no_value"), I18n.t("yes_value"), function(confirmed) { diff --git a/app/assets/javascripts/discourse/controllers/user-invited-show.js.es6 b/app/assets/javascripts/discourse/controllers/user-invited-show.js.es6 index 15d7134358..78c786847e 100644 --- a/app/assets/javascripts/discourse/controllers/user-invited-show.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-invited-show.js.es6 @@ -18,8 +18,6 @@ export default Ember.Controller.extend({ this.set('searchTerm', ''); }, - uploadText: function() { return I18n.t("user.invited.bulk_invite.text"); }.property(), - /** Observe the search term box with a debouncer and change the results. diff --git a/app/assets/javascripts/discourse/helpers/discouse-tag.js.es6 b/app/assets/javascripts/discourse/helpers/discourse-tag.js.es6 similarity index 100% rename from app/assets/javascripts/discourse/helpers/discouse-tag.js.es6 rename to app/assets/javascripts/discourse/helpers/discourse-tag.js.es6 diff --git a/app/assets/javascripts/discourse/helpers/topic-featured-link.js.es6 b/app/assets/javascripts/discourse/helpers/topic-featured-link.js.es6 new file mode 100644 index 0000000000..686599e2b1 --- /dev/null +++ b/app/assets/javascripts/discourse/helpers/topic-featured-link.js.es6 @@ -0,0 +1,6 @@ +import { registerUnbound } from 'discourse-common/lib/helpers'; +import renderTopicFeaturedLink from 'discourse/lib/render-topic-featured-link'; + +export default registerUnbound('topic-featured-link', function(topic, params) { + return new Handlebars.SafeString(renderTopicFeaturedLink(topic, params)); +}); diff --git a/app/assets/javascripts/discourse/lib/export-csv.js.es6 b/app/assets/javascripts/discourse/lib/export-csv.js.es6 index a88558b3d1..fe4d10c89b 100644 --- a/app/assets/javascripts/discourse/lib/export-csv.js.es6 +++ b/app/assets/javascripts/discourse/lib/export-csv.js.es6 @@ -8,9 +8,9 @@ function exportEntityByType(type, entity, args) { export function exportUserArchive() { return exportEntityByType('user', 'user_archive').then(function() { - bootbox.alert(I18n.t("admin.export_csv.success")); + bootbox.alert(I18n.t("user.download_archive.success")); }).catch(function() { - bootbox.alert(I18n.t("admin.export_csv.rate_limit_error")); + bootbox.alert(I18n.t("user.download_archive.rate_limit_error")); }); } diff --git a/app/assets/javascripts/discourse/lib/intercept-click.js.es6 b/app/assets/javascripts/discourse/lib/intercept-click.js.es6 index 6bedce5861..758164e784 100644 --- a/app/assets/javascripts/discourse/lib/intercept-click.js.es6 +++ b/app/assets/javascripts/discourse/lib/intercept-click.js.es6 @@ -21,10 +21,11 @@ export default function interceptClick(e) { $currentTarget.data('ember-action') || $currentTarget.data('auto-route') || $currentTarget.data('share-url') || - $currentTarget.data('user-card') || $currentTarget.hasClass('widget-link') || $currentTarget.hasClass('mention') || - (!$currentTarget.hasClass('d-link') && $currentTarget.hasClass('ember-view')) || + (!$currentTarget.hasClass('d-link') && + !$currentTarget.data('user-card') && + $currentTarget.hasClass('ember-view')) || $currentTarget.hasClass('lightbox') || href.indexOf("mailto:") === 0 || (href.match(/^http[s]?:\/\//i) && !href.match(new RegExp("^https?:\\/\\/" + window.location.hostname, "i")))) { diff --git a/app/assets/javascripts/discourse/lib/render-topic-featured-link.js.es6 b/app/assets/javascripts/discourse/lib/render-topic-featured-link.js.es6 new file mode 100644 index 0000000000..c8c3d640f8 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/render-topic-featured-link.js.es6 @@ -0,0 +1,46 @@ +import { extractDomainFromUrl } from 'discourse/lib/utilities'; +import { h } from 'virtual-dom'; + +const _decorators = []; + +export function addFeaturedLinkMetaDecorator(decorator) { + _decorators.push(decorator); +} + +function extractLinkMeta(topic) { + const href = topic.featured_link, target = Discourse.SiteSettings.open_topic_featured_link_in_external_window ? '_blank' : ''; + if (!href) { return; } + + let domain = extractDomainFromUrl(href); + if (!domain) { return; } + + // www appears frequently, so we truncate it + if (domain && domain.substr(0, 4) === 'www.') { + domain = domain.substring(4); + } + + const meta = { target, href, domain, rel: 'nofollow' }; + if (_decorators.length) { + _decorators.forEach(cb => cb(meta)); + } + return meta; +} + +export default function renderTopicFeaturedLink(topic) { + const meta = extractLinkMeta(topic); + if (meta) { + return `${meta.domain}`; + } else { + return ''; + } +}; + +export function topicFeaturedLinkNode(topic) { + const meta = extractLinkMeta(topic); + if (meta) { + return h('a.topic-featured-link', { + attributes: { href: meta.href, rel: meta.rel, target: meta.target } + }, meta.domain); + } +} + diff --git a/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 b/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 index 1aa215f534..03b6dfff3a 100644 --- a/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 +++ b/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 @@ -7,6 +7,8 @@ function applicable() { } let workaroundActive = false; +let composingTopic = false; + export function isWorkaroundActive() { return workaroundActive; } @@ -22,27 +24,38 @@ function positioningWorkaround($fixedElement) { var done = false; var originalScrollTop = 0; + positioningWorkaround.blur = function(evt) { + if (workaroundActive) { + done = true; + + $('#main-outlet').show(); + $('header').show(); + + fixedElement.style.position = ''; + fixedElement.style.top = ''; + fixedElement.style.height = ''; + + $(window).scrollTop(originalScrollTop); + + if (evt) { + evt.target.removeEventListener('blur', blurred); + } + workaroundActive = false; + } + }; + var blurredNow = function(evt) { if (!done && _.include($(document.activeElement).parents(), fixedElement)) { // something in focus so skip return; } - done = true; - - $('#main-outlet').show(); - $('header').show(); - - fixedElement.style.position = ''; - fixedElement.style.top = ''; - fixedElement.style.height = ''; - - $(window).scrollTop(originalScrollTop); - - if (evt) { - evt.target.removeEventListener('blur', blurred); + if (composingTopic) { + return false; } - workaroundActive = false; + + positioningWorkaround.blur(evt); + }; var blurred = _.debounce(blurredNow, 250); @@ -73,7 +86,20 @@ function positioningWorkaround($fixedElement) { fixedElement.style.top = '0px'; - const height = Math.max(parseInt(window.innerHeight*0.6), 350); + let ratio = 0.6; + let min = 350; + + composingTopic = false; + + if ($('#reply-control select.category-combobox').length > 0) { + composingTopic = true; + // creating a topic, less height + ratio = 0.54; + min = 300; + } + + const height = Math.max(parseInt(window.innerHeight*ratio), min); + fixedElement.style.height = height + "px"; // I used to do this, but it seems like we don't need to with position diff --git a/app/assets/javascripts/discourse/lib/utilities.js.es6 b/app/assets/javascripts/discourse/lib/utilities.js.es6 index 579e7dbc6d..fcf6737527 100644 --- a/app/assets/javascripts/discourse/lib/utilities.js.es6 +++ b/app/assets/javascripts/discourse/lib/utilities.js.es6 @@ -234,7 +234,7 @@ export function uploadLocation(url) { } else { var protocol = window.location.protocol + '//', hostname = window.location.hostname, - port = ':' + window.location.port; + port = window.location.port ? ':' + window.location.port : ''; return protocol + hostname + port + url; } } diff --git a/app/assets/javascripts/discourse/mapping-router.js.es6 b/app/assets/javascripts/discourse/mapping-router.js.es6 index 306910624c..31d7c2c2ea 100644 --- a/app/assets/javascripts/discourse/mapping-router.js.es6 +++ b/app/assets/javascripts/discourse/mapping-router.js.es6 @@ -5,11 +5,94 @@ const BareRouter = Ember.Router.extend({ location: Ember.testing ? 'none': 'discourse-location' }); -export function mapRoutes() { +// Ember's router can't be extended. We need to allow plugins to add routes to routes that were defined +// in the core app. This class has the same API as Ember's `Router.map` but saves the results in a tree. +// The tree is applied after all plugins are defined. +class RouteNode { + constructor(name, opts={}, depth=0) { + this.name = name; + this.opts = opts; + this.depth = depth; + this.children = []; + this.childrenByName = {}; + this.paths = {}; - var Router = BareRouter.extend(); - const resources = {}; - const paths = {}; + if (!opts.path) { + opts.path = name; + } + + this.paths[opts.path] = true; + } + + route(name, opts, fn) { + if (typeof opts === 'function') { + fn = opts; + opts = {}; + } else { + opts = opts || {}; + } + + const existing = this.childrenByName[name]; + if (existing) { + if (opts.path) { + existing.paths[opts.path] = true; + } + existing.extract(fn); + } else { + const node = new RouteNode(name, opts, this.depth+1); + node.extract(fn); + this.childrenByName[name] = node; + this.children.push(node); + } + } + + extract(fn) { + if (!fn) { return; } + fn.call(this); + } + + mapRoutes(router) { + const children = this.children; + if (this.name === 'root') { + children.forEach(c => c.mapRoutes(router)); + } else { + + const builder = (children.length === 0) ? undefined : function() { + children.forEach(c => c.mapRoutes(this)); + }; + router.route(this.name, this.opts, builder); + + // We can have multiple paths to the same route + const paths = Object.keys(this.paths); + if (paths.length > 1) { + paths.filter(p => p !== this.opts.path).forEach(path => { + const newOpts = jQuery.extend({}, this.opts, { path }); + router.route(this.name, newOpts, builder); + }); + } + } + } + + findSegment(segments) { + if (segments && segments.length) { + const first = segments.shift(); + const node = this.childrenByName[first]; + if (node) { + return (segments.length === 0) ? node : node.findSegment(segments); + } + } + } + + findPath(path) { + if (path) { + return this.findSegment(path.split('.')); + } + } +} + +export function mapRoutes() { + const tree = new RouteNode('root'); + const extras = []; // If a module is defined as `route-map` in discourse or a plugin, its routes // will be built automatically. You can supply a `resource` property to @@ -20,62 +103,24 @@ export function mapRoutes() { var module = require(key, null, null, true); if (!module || !module.default) { throw new Error(key + ' must export a route map.'); } - var mapObj = module.default; + const mapObj = module.default; if (typeof mapObj === 'function') { - mapObj = { resource: 'root', map: mapObj }; + tree.extract(mapObj); + } else { + extras.push(mapObj); } - - if (!resources[mapObj.resource]) { resources[mapObj.resource] = []; } - resources[mapObj.resource].push(mapObj.map); - if (mapObj.path) { paths[mapObj.resource] = mapObj.path; } } }); - return Router.map(function() { - var router = this; - - // Do the root resources first - if (resources.root) { - resources.root.forEach(function(m) { - m.call(router); - }); - delete resources.root; + extras.forEach(extra => { + const node = tree.findPath(extra.resource); + if (node) { + node.extract(extra.map); } + }); - // Even if no plugins set it up, we need an `adminPlugins` route - var adminPlugins = 'admin.adminPlugins'; - resources[adminPlugins] = resources[adminPlugins] || [Ember.K]; - paths[adminPlugins] = paths[adminPlugins] || "/plugins"; - - var segments = {}, - standalone = []; - - Object.keys(resources).forEach(function(r) { - var m = /^([^\.]+)\.(.*)$/.exec(r); - if (m) { - segments[m[1]] = m[2]; - } else { - standalone.push(r); - } - }); - - // Apply other resources next. A little hacky but works! - standalone.forEach(function(r) { - router.route(r, {path: paths[r], resetNamespace: true}, function() { - var res = this; - resources[r].forEach(function(m) { m.call(res); }); - - var s = segments[r]; - if (s) { - var full = r + '.' + s; - res.route(s, {path: paths[full], resetNamespace: true}, function() { - var nestedRes = this; - resources[full].forEach(function(m) { m.call(nestedRes); }); - }); - } - }); - }); - + return BareRouter.extend().map(function() { + tree.mapRoutes(this); this.route('unknown', {path: '*path'}); }); } diff --git a/app/assets/javascripts/discourse/models/category.js.es6 b/app/assets/javascripts/discourse/models/category.js.es6 index b77a4abdf3..fc56790194 100644 --- a/app/assets/javascripts/discourse/models/category.js.es6 +++ b/app/assets/javascripts/discourse/models/category.js.es6 @@ -169,6 +169,18 @@ const Category = RestModel.extend({ @computed("id") isUncategorizedCategory(id) { return id === Discourse.Site.currentProp("uncategorized_category_id"); + }, + + @computed('custom_fields.topic_featured_link_allowed') + topicFeaturedLinkAllowed: { + get(allowed) { + return allowed === "true"; + }, + set(value) { + value = value ? "true" : "false"; + this.set("custom_fields.topic_featured_link_allowed", value); + return value; + } } }); diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index e782c4074a..52d8cde6ae 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -32,13 +32,15 @@ const CLOSED = 'closed', target_usernames: 'targetUsernames', typing_duration_msecs: 'typingTime', composer_open_duration_msecs: 'composerTime', - tags: 'tags' + tags: 'tags', + featured_link: 'featuredLink' }, _edit_topic_serializer = { title: 'topic.title', categoryId: 'topic.category.id', - tags: 'topic.tags' + tags: 'topic.tags', + featuredLink: 'topic.featured_link' }; const Composer = RestModel.extend({ @@ -136,6 +138,14 @@ const Composer = RestModel.extend({ canEditTitle: Em.computed.or('creatingTopic', 'creatingPrivateMessage', 'editingFirstPost'), canCategorize: Em.computed.and('canEditTitle', 'notCreatingPrivateMessage'), + @computed('canEditTitle', 'creatingPrivateMessage', 'categoryId') + canEditTopicFeaturedLink(canEditTitle, creatingPrivateMessage, categoryId) { + if (!this.siteSettings.topic_featured_link_enabled || !canEditTitle || creatingPrivateMessage) { return false; } + + const categoryIds = this.site.get('topic_featured_link_allowed_category_ids'); + return categoryIds === undefined || !categoryIds.length || categoryIds.indexOf(categoryId) !== -1; + }, + // Determine the appropriate title for this action actionTitle: function() { const topic = this.get('topic'); @@ -180,6 +190,10 @@ const Composer = RestModel.extend({ }.property('action', 'post', 'topic', 'topic.title'), + @computed('canEditTopicFeaturedLink') + showComposerEditor(canEditTopicFeaturedLink) { + return canEditTopicFeaturedLink ? !this.siteSettings.topic_featured_link_onebox : true; + }, // whether to disable the post button cantSubmitPost: function() { @@ -269,11 +283,12 @@ const Composer = RestModel.extend({ } }.property('privateMessage'), - missingReplyCharacters: function() { - const postType = this.get('post.post_type'); - if (postType === this.site.get('post_types.small_action')) { return 0; } - return this.get('minimumPostLength') - this.get('replyLength'); - }.property('minimumPostLength', 'replyLength'), + @computed('minimumPostLength', 'replyLength', 'canEditTopicFeaturedLink') + missingReplyCharacters(minimumPostLength, replyLength, canEditTopicFeaturedLink) { + if (this.get('post.post_type') === this.site.get('post_types.small_action') || + canEditTopicFeaturedLink && this.siteSettings.topic_featured_link_onebox) { return 0; } + return minimumPostLength - replyLength; + }, /** Minimum number of characters for a post body to be valid. @@ -492,6 +507,14 @@ const Composer = RestModel.extend({ save(opts) { if (!this.get('cantSubmitPost')) { + + // change category may result in some effect for topic featured link + if (this.get('canEditTopicFeaturedLink')) { + if (this.siteSettings.topic_featured_link_onebox) { this.set('reply', null); } + } else { + this.set('featuredLink', null); + } + return this.get('editingPost') ? this.editPost(opts) : this.createPost(opts); } }, @@ -512,7 +535,8 @@ const Composer = RestModel.extend({ stagedPost: false, typingTime: 0, composerOpened: null, - composerTotalOpened: 0 + composerTotalOpened: 0, + featuredLink: null }); }, diff --git a/app/assets/javascripts/discourse/models/group.js.es6 b/app/assets/javascripts/discourse/models/group.js.es6 index 4566c154ab..510149b6ba 100644 --- a/app/assets/javascripts/discourse/models/group.js.es6 +++ b/app/assets/javascripts/discourse/models/group.js.es6 @@ -114,18 +114,25 @@ const Group = Discourse.Model.extend({ flair_url: this.get('flair_url'), flair_bg_color: this.get('flairBackgroundHexColor'), flair_color: this.get('flairHexColor'), + bio_raw: this.get('bio_raw') }; }, create() { var self = this; - return ajax("/admin/groups", { type: "POST", data: this.asJSON() }).then(function(resp) { + return ajax("/admin/groups", { type: "POST", data: { group: this.asJSON() } }).then(function(resp) { self.set('id', resp.basic_group.id); }); }, save() { - return ajax("/admin/groups/" + this.get('id'), { type: "PUT", data: this.asJSON() }); + const id = this.get('id'); + const url = this.get('is_group_owner') ? `/groups/${id}` : `/admin/groups/${id}`; + + return ajax(url, { + type: "PUT", + data: { group: this.asJSON() } + }); }, destroy() { @@ -166,10 +173,6 @@ Group.reopenClass({ }); }, - findGroupCounts(name) { - return ajax("/groups/" + name + "/counts.json").then(result => Em.Object.create(result.counts)); - }, - find(name) { return ajax("/groups/" + name + ".json").then(result => Group.create(result.basic_group)); }, diff --git a/app/assets/javascripts/discourse/models/user-stream.js.es6 b/app/assets/javascripts/discourse/models/user-stream.js.es6 index 49c02b4e61..fc80dc94ef 100644 --- a/app/assets/javascripts/discourse/models/user-stream.js.es6 +++ b/app/assets/javascripts/discourse/models/user-stream.js.es6 @@ -28,11 +28,21 @@ export default RestModel.extend({ baseUrl: url('itemsLoaded', 'user.username_lower', '/user_actions.json?offset=%@&username=%@'), - filterBy(filter) { - this.setProperties({ filter, itemsLoaded: 0, content: [], lastLoadedUrl: null }); + filterBy(filter, noContentHelpKey) { + this.setProperties({ + filter, + itemsLoaded: 0, + content: [], + noContentHelpKey: noContentHelpKey, + lastLoadedUrl: null + }); return this.findItems(); }, + noContent: function() { + return this.get('loaded') && this.get('content').length === 0; + }.property('loaded', 'content.@each'), + remove(userAction) { // 1) remove the user action from the child groups this.get("content").forEach(function (ua) { @@ -61,6 +71,9 @@ export default RestModel.extend({ if (this.get('filterParam')) { findUrl += "&filter=" + this.get('filterParam'); } + if (this.get('noContentHelpKey')) { + findUrl += "&no_results_help_key=" + this.get('noContentHelpKey'); + } // Don't load the same stream twice. We're probably at the end. const lastLoadedUrl = this.get('lastLoadedUrl'); @@ -69,6 +82,9 @@ export default RestModel.extend({ if (this.get('loading')) { return Ember.RSVP.resolve(); } this.set('loading', true); return ajax(findUrl, {cache: 'false'}).then( function(result) { + if (result && result.no_results_help) { + self.set('noContentHelp', result.no_results_help); + } if (result && result.user_actions) { const copy = Em.A(); result.user_actions.forEach(function(action) { @@ -78,11 +94,11 @@ export default RestModel.extend({ self.get('content').pushObjects(UserAction.collapseStream(copy)); self.setProperties({ - loaded: true, itemsLoaded: self.get('itemsLoaded') + result.user_actions.length }); } }).finally(function() { + self.set('loaded', true); self.set('loading', false); self.set('lastLoadedUrl', findUrl); }); 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 9a409fcc82..50bd993956 100644 --- a/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 +++ b/app/assets/javascripts/discourse/pre-initializers/sniff-capabilities.js.es6 @@ -1,4 +1,4 @@ -/*global Modernizr:true*/ +/*global Modernizr:true safari:true*/ // Initializes an object that lets us know about our capabilities. export default { @@ -20,7 +20,7 @@ export default { caps.isOpera = !!window.opera || ua.indexOf(' OPR/') >= 0; caps.isFirefox = typeof InstallTrigger !== 'undefined'; - caps.isSafari = Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') > 0; + caps.isSafari = Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') > 0 || (function (p) { return p.toString() === "[object SafariRemoteNotification]"; })(!window['safari'] || safari.pushNotification); caps.isChrome = !!window.chrome && !caps.isOpera; caps.canPasteImages = caps.isChrome || caps.isFirefox; } 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 76f3803c9f..a1de0bfc74 100644 --- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 +++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 @@ -132,7 +132,7 @@ export default function() { this.route('showCategory' + filter.capitalize(), {path: '/c/:category/:tag_id/l/' + filter}); this.route('showParentCategory' + filter.capitalize(), {path: '/c/:parent_category/:category/:tag_id/l/' + filter}); }); - this.route('show', {path: 'intersection/:tag_id/*additional_tags'}); + this.route('intersection', {path: 'intersection/:tag_id/*additional_tags'}); }); this.route('tagGroups', {path: '/tag_groups', resetNamespace: true}, function() { diff --git a/app/assets/javascripts/discourse/routes/application.js.es6 b/app/assets/javascripts/discourse/routes/application.js.es6 index 187199ff53..1c3fed9a6b 100644 --- a/app/assets/javascripts/discourse/routes/application.js.es6 +++ b/app/assets/javascripts/discourse/routes/application.js.es6 @@ -146,10 +146,9 @@ const ApplicationRoute = Discourse.Route.extend(OpenComposer, { }, changeBulkTemplate(w) { - const controllerName = w.replace('modal/', ''), - factory = getOwner(this).lookupFactory('controller:' + controllerName); - - this.render(w, {into: 'modal/topic-bulk-actions', outlet: 'bulkOutlet', controller: factory ? controllerName : 'topic-bulk-actions'}); + const controllerName = w.replace('modal/', ''); + const controller = getOwner(this).lookup('controller:' + controllerName); + this.render(w, {into: 'modal/topic-bulk-actions', outlet: 'bulkOutlet', controller: controller ? controllerName : 'topic-bulk-actions'}); }, createNewTopicViaParams(title, body, category_id, category, tags) { diff --git a/app/assets/javascripts/discourse/routes/group.js.es6 b/app/assets/javascripts/discourse/routes/group.js.es6 index 51724c18b4..51731bccb6 100644 --- a/app/assets/javascripts/discourse/routes/group.js.es6 +++ b/app/assets/javascripts/discourse/routes/group.js.es6 @@ -1,4 +1,5 @@ import Group from 'discourse/models/group'; +import showModal from 'discourse/lib/show-modal'; export default Discourse.Route.extend({ @@ -14,13 +15,14 @@ export default Discourse.Route.extend({ return { name: model.get('name').toLowerCase() }; }, - afterModel(model) { - return Group.findGroupCounts(model.get('name')).then(counts => { - this.set('counts', counts); - }); - }, - setupController(controller, model) { controller.setProperties({ model, counts: this.get('counts') }); + }, + + actions: { + showGroupEditor() { + showModal('edit-group'); + this.controllerFor('edit-group').set('model', this.modelFor('group')); + } } }); diff --git a/app/assets/javascripts/discourse/routes/tags-intersection.js.es6 b/app/assets/javascripts/discourse/routes/tags-intersection.js.es6 new file mode 100644 index 0000000000..5b0e7d8e9f --- /dev/null +++ b/app/assets/javascripts/discourse/routes/tags-intersection.js.es6 @@ -0,0 +1,9 @@ +import TagsShowRoute from 'discourse/routes/tags-show'; + +export default TagsShowRoute.extend({}); + +// The tags-intersection route is exactly the same as the tags-show route, but the wildcard at the +// end of the route (*additional_tags) will cause a match when query parameters are present, +// breaking all other tags-show routes. Ember thinks the query params are addition tags and should +// be handled by the intersection logic. Defining tags-intersection as something separate avoids +// that confusion. 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 cb14c18153..49ad23d1bc 100644 --- a/app/assets/javascripts/discourse/routes/user-activity-bookmarks.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-activity-bookmarks.js.es6 @@ -2,5 +2,6 @@ import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; import UserAction from "discourse/models/user-action"; export default UserActivityStreamRoute.extend({ - userActionType: UserAction.TYPES["bookmarks"] + userActionType: UserAction.TYPES["bookmarks"], + noContentHelpKey: "user_activity.no_bookmarks" }); 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 13a0e5b986..89bbbc46b2 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 @@ -2,5 +2,6 @@ import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; import UserAction from "discourse/models/user-action"; export default UserActivityStreamRoute.extend({ - userActionType: UserAction.TYPES["likes_given"] + userActionType: UserAction.TYPES["likes_given"], + noContentHelpKey: 'user_activity.no_likes_given' }); diff --git a/app/assets/javascripts/discourse/routes/user-activity-stream.js.es6 b/app/assets/javascripts/discourse/routes/user-activity-stream.js.es6 index 7447d71217..4fbf8e4300 100644 --- a/app/assets/javascripts/discourse/routes/user-activity-stream.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-activity-stream.js.es6 @@ -6,7 +6,7 @@ export default Discourse.Route.extend(ViewingActionType, { }, afterModel() { - return this.modelFor("user").get("stream").filterBy(this.get("userActionType")); + return this.modelFor("user").get("stream").filterBy(this.get("userActionType"), this.get("noContentHelpKey")); }, renderTemplate() { diff --git a/app/assets/javascripts/discourse/routes/user-invited-show.js.es6 b/app/assets/javascripts/discourse/routes/user-invited-show.js.es6 index c9465fa7d1..9be0fc4823 100644 --- a/app/assets/javascripts/discourse/routes/user-invited-show.js.es6 +++ b/app/assets/javascripts/discourse/routes/user-invited-show.js.es6 @@ -33,14 +33,6 @@ export default Discourse.Route.extend({ showInvite() { showModal("invite", { model: this.currentUser }); this.controllerFor("invite").reset(); - }, - - uploadSuccess(filename) { - bootbox.alert(I18n.t("user.invited.bulk_invite.success", { filename: filename })); - }, - - uploadError(filename, message) { - bootbox.alert(I18n.t("user.invited.bulk_invite.error", { filename: filename, message: message })); } } }); diff --git a/app/assets/javascripts/discourse/templates/components/csv-uploader.hbs b/app/assets/javascripts/discourse/templates/components/csv-uploader.hbs new file mode 100644 index 0000000000..eab8d71fc9 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/csv-uploader.hbs @@ -0,0 +1,7 @@ + +{{#if uploading}} + {{i18n 'upload_selector.uploading'}} {{uploadProgress}}% +{{/if}} 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 72883cba5a..790e61e939 100644 --- a/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs +++ b/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs @@ -19,6 +19,17 @@ +{{#if siteSettings.topic_featured_link_enabled}} +
+ +
+{{/if}} +
{{/load-more}} + +{{conditional-loading-spinner condition=loading}} diff --git a/app/assets/javascripts/discourse/templates/components/group-post.hbs b/app/assets/javascripts/discourse/templates/components/group-post.hbs index 45a1642281..09ac259ce2 100644 --- a/app/assets/javascripts/discourse/templates/components/group-post.hbs +++ b/app/assets/javascripts/discourse/templates/components/group-post.hbs @@ -7,12 +7,12 @@ {{category-link post.category}}
- {{#if post.user_long_name}} - {{post.user_long_name}}{{#if post.user_title}}, {{post.user_title}}{{/if}} + {{#if post.user.name}} + {{post.user.name}}{{#if post.user.title}}, {{post.user.title}}{{/if}} {{/if}}

- {{{unbound post.cooked}}} + {{{unbound post.excerpt}}}

diff --git a/app/assets/javascripts/discourse/templates/components/latest-topic-list-item.hbs b/app/assets/javascripts/discourse/templates/components/latest-topic-list-item.hbs index 5224716451..e42241f0bb 100644 --- a/app/assets/javascripts/discourse/templates/components/latest-topic-list-item.hbs +++ b/app/assets/javascripts/discourse/templates/components/latest-topic-list-item.hbs @@ -12,6 +12,9 @@ {{topic-status topic=topic}} {{topic-link topic}} + {{#if topic.featured_link}} + {{topic-featured-link topic}} + {{/if}} {{topic-post-badges newPosts=topic.totalUnread unseen=topic.unseen url=topic.lastUnreadUrl}} diff --git a/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs b/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs index 21322e0f3d..6df6fcb44d 100644 --- a/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs +++ b/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs @@ -75,9 +75,9 @@
- +
- {{input type="number" value=searchedTerms.posts_count class="input-small" id='search-posts-count'}} + {{input type="number" value=searchedTerms.min_post_count class="input-small" id='search-min-post-count'}}
diff --git a/app/assets/javascripts/discourse/templates/components/topic-category.hbs b/app/assets/javascripts/discourse/templates/components/topic-category.hbs index c311422560..cf717a0992 100644 --- a/app/assets/javascripts/discourse/templates/components/topic-category.hbs +++ b/app/assets/javascripts/discourse/templates/components/topic-category.hbs @@ -2,12 +2,16 @@ {{bound-category-link topic.category.parentCategory}} {{/if}} {{bound-category-link topic.category hideParent=true}} -{{#if siteSettings.tagging_enabled}} -
- {{#each topic.tags as |t|}} - {{discourse-tag t}} - {{/each}} -
-{{/if}} - +
+ {{#if siteSettings.tagging_enabled}} +
+ {{#each topic.tags as |t|}} + {{discourse-tag t}} + {{/each}} +
+ {{/if}} + {{#if siteSettings.topic_featured_link_enabled}} + {{topic-featured-link topic}} + {{/if}} +
{{plugin-outlet "topic-category"}} diff --git a/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs b/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs index 641351cc00..c88b7cc9d6 100644 --- a/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs +++ b/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs @@ -4,7 +4,11 @@
{{bound-avatar avatar "huge"}} {{#if user.primary_group_name}} - {{mount-widget widget="avatar-flair" args=user}} + {{avatar-flair + flairURL=user.primary_group_flair_url + flairBgColor=user.primary_group_flair_bg_color + flairColor=user.primary_group_flair_color + groupName=user.primary_group_name}} {{/if}}
diff --git a/app/assets/javascripts/discourse/templates/composer.hbs b/app/assets/javascripts/discourse/templates/composer.hbs index ffed59d239..bf40be927c 100644 --- a/app/assets/javascripts/discourse/templates/composer.hbs +++ b/app/assets/javascripts/discourse/templates/composer.hbs @@ -80,9 +80,13 @@ {{/if}} {{render "additional-composer-buttons" model}} {{/if}} + {{#if model.canEditTopicFeaturedLink}} + + {{/if}} {{/if}} - {{plugin-outlet "composer-fields"}} diff --git a/app/assets/javascripts/discourse/templates/group-index.hbs b/app/assets/javascripts/discourse/templates/group-index.hbs index 41ad1fd922..5e1fd167ee 100644 --- a/app/assets/javascripts/discourse/templates/group-index.hbs +++ b/app/assets/javascripts/discourse/templates/group-index.hbs @@ -1,45 +1,48 @@ {{#if model}} {{#if isOwner}} -
-
- {{user-selector usernames=usernames placeholderKey="groups.selector_placeholder" id="user-search-selector" name="usernames"}} - {{d-button action="addMembers" class="add" icon="plus" label="groups.add"}} -
-
+
+ {{user-selector usernames=usernames placeholderKey="groups.selector_placeholder" id="user-search-selector" name="usernames"}} + {{d-button action="addMembers" class="add" icon="plus" label="groups.add"}} +
{{/if}} {{#load-more selector=".group-members tr" action="loadMore"}} - - - - {{#if isOwner}} + - {{/if}} - - {{#each model.members as |m|}} - - - - - {{#if isOwner}} - + + + + + + {{#each model.members as |m|}} + + - {{/if}} - - {{/each}} + + + + + {{/each}} +
{{i18n 'last_post'}}{{i18n 'last_seen'}}
- {{user-info user=m}} - {{#if m.owner}}{{i18n "groups.owner"}}{{/if}} - - {{bound-date m.last_posted_at}} - - {{bound-date m.last_seen_at}} - - {{#unless m.owner}} - - {{/unless}} + {{i18n 'last_post'}}{{i18n 'last_seen'}}
+ {{#user-info user=m skipName=skipName}} + {{#if m.owner}}{{i18n "groups.owner"}}{{/if}} + {{/user-info}}
+ {{bound-date m.last_posted_at}} + + {{bound-date m.last_seen_at}} + + {{#if isOwner}} + {{#unless m.owner}} + + {{/unless}} + {{/if}} +
{{/load-more}} + + {{conditional-loading-spinner condition=loading}} {{else}}
{{i18n "groups.empty.users"}}
{{/if}} diff --git a/app/assets/javascripts/discourse/templates/group-posts.hbs b/app/assets/javascripts/discourse/templates/group-posts.hbs index f38104880f..7dbeebb300 100644 --- a/app/assets/javascripts/discourse/templates/group-posts.hbs +++ b/app/assets/javascripts/discourse/templates/group-posts.hbs @@ -1 +1 @@ -{{group-post-stream posts=model emptyText=emptyText loadMore="loadMore"}} +{{group-post-stream posts=model emptyText=emptyText loadMore="loadMore" loading=loading}} diff --git a/app/assets/javascripts/discourse/templates/group.hbs b/app/assets/javascripts/discourse/templates/group.hbs index 416b356f40..fac08c8428 100644 --- a/app/assets/javascripts/discourse/templates/group.hbs +++ b/app/assets/javascripts/discourse/templates/group.hbs @@ -1,27 +1,56 @@ -
-
-
- -
+
+
+
+ {{#if model.flair_url}} + + {{avatar-flair + flairURL=model.flair_url + flairBgColor=model.flair_bg_color + flairColor=model.flair_color + groupName=model.name}} + + {{/if}} -
-
-
-
-

{{model.name}}

-
-
+ +

+ {{groupName}} +

+ + {{#if model.title}} +

@{{model.name}}

+ {{/if}} +
+ + {{#if canEditGroup}} + + {{d-button action="showGroupEditor" label="group.edit.title" class="group-edit-btn" icon="pencil"}} + + {{/if}} +
+ + {{#if model.bio_cooked}} +
+ +
+

{{{model.bio_cooked}}}

+
+ {{/if}} +
+ + {{#mobile-nav class='group-nav' desktopClass="pull-left nav nav-stacked" currentPath=currentPath}} + {{#each getTabs as |tab|}} +
  • + {{#link-to tab.location model title=tab.message}} + {{tab.message}} + {{#if tab.count}}({{tab.count}}){{/if}} + {{/link-to}} +
  • + {{/each}} + {{/mobile-nav}} + +
    +
    {{outlet}} - - +
    diff --git a/app/assets/javascripts/discourse/templates/list/topic-list-item.raw.hbs b/app/assets/javascripts/discourse/templates/list/topic-list-item.raw.hbs index bc21dfd6a7..da825c4774 100644 --- a/app/assets/javascripts/discourse/templates/list/topic-list-item.raw.hbs +++ b/app/assets/javascripts/discourse/templates/list/topic-list-item.raw.hbs @@ -7,6 +7,9 @@ {{raw "topic-status" topic=topic}} {{topic-link topic}} + {{#if topic.featured_link}} + {{topic-featured-link topic}} + {{/if}} {{plugin-outlet "topic-list-after-title"}} {{#if showTopicPostBadges}} {{raw "topic-post-badges" unread=topic.unread newPosts=topic.displayNewPosts unseen=topic.unseen url=topic.lastUnreadUrl}} diff --git a/app/assets/javascripts/discourse/templates/modal/edit-group.hbs b/app/assets/javascripts/discourse/templates/modal/edit-group.hbs new file mode 100644 index 0000000000..60a762eb7e --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/edit-group.hbs @@ -0,0 +1,16 @@ +{{#d-modal-body title="group.edit.title" class="edit-group groups"}} +
    + + {{input type='text' name='title' value=model.title class='edit-group-title'}} + + + {{d-editor value=model.bio_raw class="edit-group-bio"}} + + {{group-flair-inputs model=model}} +
    +{{/d-modal-body}} + + diff --git a/app/assets/javascripts/discourse/templates/modal/history.hbs b/app/assets/javascripts/discourse/templates/modal/history.hbs index eac25480d5..ce860fefcb 100644 --- a/app/assets/javascripts/discourse/templates/modal/history.hbs +++ b/app/assets/javascripts/discourse/templates/modal/history.hbs @@ -86,6 +86,13 @@ {{/each}}
    {{/if}} + {{#if model.featured_link_changes}} +
    + {{model.featured_link_changes.previous}} + → + {{model.featured_link_changes.current}} +
    + {{/if}} {{plugin-outlet "post-revisions"}} diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index 65c0f3f6a8..aed888a39f 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -25,6 +25,9 @@ {{category-chooser valueAttribute="id" value=buffered.category_id}} {{/if}} + {{#if canEditTopicFeaturedLink}} + {{text-field type="url" value=buffered.featured_link id='topic-featured-link' placeholderKey="composer.topic_featured_link_placeholder"}} + {{/if}} {{#if canEditTags}}
    {{tag-chooser tags=buffered.tags categoryId=buffered.category_id}} diff --git a/app/assets/javascripts/discourse/templates/user-invited-show.hbs b/app/assets/javascripts/discourse/templates/user-invited-show.hbs index 6588a53e01..fc8657b0d4 100644 --- a/app/assets/javascripts/discourse/templates/user-invited-show.hbs +++ b/app/assets/javascripts/discourse/templates/user-invited-show.hbs @@ -16,7 +16,7 @@
    {{d-button icon="plus" action="showInvite" label="user.invited.create" class="btn"}} {{#if canBulkInvite}} - {{resumable-upload target="/invites/upload" success="uploadSuccess" error="uploadError" uploadText=uploadText}} + {{csv-uploader uploading=uploading}} {{/if}} {{#if showReinviteAllButton}} {{#if reinvitedAll}} diff --git a/app/assets/javascripts/discourse/templates/user/activity.hbs b/app/assets/javascripts/discourse/templates/user/activity.hbs index c5243f1ffb..f1818f2ec9 100644 --- a/app/assets/javascripts/discourse/templates/user/activity.hbs +++ b/app/assets/javascripts/discourse/templates/user/activity.hbs @@ -23,12 +23,12 @@ {{/link-to}} {{/if}} - {{plugin-outlet "user-activity-bottom"}} + {{plugin-outlet "user-activity-bottom" tagName='li'}} {{/mobile-nav}} {{#if viewingSelf}}
    - {{d-button action="exportUserArchive" label="user.download_archive" icon="download"}} + {{d-button action="exportUserArchive" label="user.download_archive.button_text" icon="download"}}
    {{/if}} {{/d-section}} diff --git a/app/assets/javascripts/discourse/templates/user/stream.hbs b/app/assets/javascripts/discourse/templates/user/stream.hbs index 0a06e728c8..220ae57ba8 100644 --- a/app/assets/javascripts/discourse/templates/user/stream.hbs +++ b/app/assets/javascripts/discourse/templates/user/stream.hbs @@ -1,3 +1,8 @@ +{{#if model.noContent}} +
    + {{{model.noContentHelp}}} +
    +{{/if}} {{#user-stream stream=model}} {{#each model.content as |item|}} {{stream-item item=item removeBookmark="removeBookmark"}} diff --git a/app/assets/javascripts/discourse/templates/user/summary.hbs b/app/assets/javascripts/discourse/templates/user/summary.hbs index ffb578edcb..e36928a986 100644 --- a/app/assets/javascripts/discourse/templates/user/summary.hbs +++ b/app/assets/javascripts/discourse/templates/user/summary.hbs @@ -37,6 +37,7 @@
  • {{user-stat value=model.likes_received label="user.summary.likes_received"}}
  • + {{plugin-outlet "user-summary-stat" tagName="li"}}
    diff --git a/app/assets/javascripts/discourse/widgets/avatar-flair.js.es6 b/app/assets/javascripts/discourse/widgets/avatar-flair.js.es6 index 55beefc8cd..265d6a69b5 100644 --- a/app/assets/javascripts/discourse/widgets/avatar-flair.js.es6 +++ b/app/assets/javascripts/discourse/widgets/avatar-flair.js.es6 @@ -37,4 +37,4 @@ createWidget('avatar-flair', { return []; } } -}); \ No newline at end of file +}); diff --git a/app/assets/javascripts/discourse/widgets/header-topic-info.js.es6 b/app/assets/javascripts/discourse/widgets/header-topic-info.js.es6 index 8704830bbd..a3d5ad585d 100644 --- a/app/assets/javascripts/discourse/widgets/header-topic-info.js.es6 +++ b/app/assets/javascripts/discourse/widgets/header-topic-info.js.es6 @@ -4,6 +4,7 @@ import { iconNode } from 'discourse/helpers/fa-icon-node'; import DiscourseURL from 'discourse/lib/url'; import RawHtml from 'discourse/widgets/raw-html'; import { tagNode } from 'discourse/lib/render-tag'; +import { topicFeaturedLinkNode } from 'discourse/lib/render-topic-featured-link'; export default createWidget('header-topic-info', { tagName: 'div.extra-info-wrapper', @@ -44,12 +45,19 @@ export default createWidget('header-topic-info', { title.push(this.attach('category-link', { category })); } + const extra = []; if (this.siteSettings.tagging_enabled) { const tags = topic.get('tags') || []; if (tags.length) { - title.push(h('div.list-tags', tags.map(tagNode))); + extra.push(h('div.list-tags', tags.map(tagNode))); } } + if (this.siteSettings.topic_featured_link_enabled) { + extra.push(topicFeaturedLinkNode(attrs.topic)); + } + if (extra) { + title.push(h('div.topic-header-extra', extra)); + } } const contents = h('div.title-wrapper', title); diff --git a/app/assets/javascripts/discourse/widgets/widget.js.es6 b/app/assets/javascripts/discourse/widgets/widget.js.es6 index bb35076a5e..1b6b436e80 100644 --- a/app/assets/javascripts/discourse/widgets/widget.js.es6 +++ b/app/assets/javascripts/discourse/widgets/widget.js.es6 @@ -5,7 +5,6 @@ import { WidgetClickHook, WidgetDragHook } from 'discourse/widgets/hooks'; import { h } from 'virtual-dom'; import DecoratorHelper from 'discourse/widgets/decorator-helper'; -import { TARGET_NAME } from 'discourse/mixins/delegated-actions'; function emptyContent() { } @@ -272,7 +271,7 @@ export default class Widget { if (target) { // TODO: Use ember closure actions - const actions = target[TARGET_NAME] || target.actionHooks || {}; + const actions = target.actions || target.actionHooks || {}; const method = actions[actionName]; if (method) { promise = method.call(target, param); diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index cb7bb105ce..13893d7cc2 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -696,38 +696,6 @@ section.details { width: 100%; border-color: dark-light-choose(scale-color($primary, $lightness: 75%), scale-color($secondary, $lightness: 25%)); } - .avatar-flair-preview { - position: relative; - width: 45px; - - .avatar-wrapper { - background-color: #f4f4f4; - } - } - .form-horizontal { - .flair_inputs { - margin-top: 30px; - margin-bottom: 30px; - - .flair_left { - float: left; - width: 60%; - input[name=flair_url] { - width: 90%; - } - } - - .flair_right { - float: left; - margin-left: 30px; - } - } - } -} -.row.groups { - input[type='text'].flair_bg_color, input[type='text'].flair_color { - width: 200px; - } } // Customise area diff --git a/app/assets/stylesheets/common/base/_topic-list.scss b/app/assets/stylesheets/common/base/_topic-list.scss index 34abe80e41..cb1bccb1fc 100644 --- a/app/assets/stylesheets/common/base/_topic-list.scss +++ b/app/assets/stylesheets/common/base/_topic-list.scss @@ -88,6 +88,10 @@ html.anon .topic-list a.title:visited:not(.badge-notification) {color: dark-ligh } } + .topic-featured-link { + padding-left: 5px; + } + .topic-excerpt { font-size: 0.929em; margin-top: 8px; diff --git a/app/assets/stylesheets/common/base/compose.scss b/app/assets/stylesheets/common/base/compose.scss index dfadbd6fb8..4e53da0fc2 100644 --- a/app/assets/stylesheets/common/base/compose.scss +++ b/app/assets/stylesheets/common/base/compose.scss @@ -187,6 +187,10 @@ div.ac-wrap { } } +#reply-control.topic-featured-link-only.open { + .wmd-controls { display: none; } +} + #cancel-file-upload { font-size: 1.6em; } diff --git a/app/assets/stylesheets/common/base/group.scss b/app/assets/stylesheets/common/base/group.scss new file mode 100644 index 0000000000..2157423bd3 --- /dev/null +++ b/app/assets/stylesheets/common/base/group.scss @@ -0,0 +1,121 @@ +.group-header { + font-size: 2.142em; + font-weight: normal; +} + +.group-name { + font-weight: normal; + margin-top: 5px; + color: dark-light-diff($primary, $secondary, 50%, -50%); +} + +.group-details-container { + background: rgba(230, 230, 230, 0.3); + padding: 20px; + margin-bottom: 30px; +} + +table.group-members { + width: 100%; + + th, tr { + border-bottom: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); + } + + th { + text-align: left; + } + + tr { + .user-info { + display: block; + } + + td { + color: dark-light-diff($primary, $secondary, 50%, -50%); + padding: 0.8em 0; + } + } +} + +.group-owner-label { + color: $primary; +} + +.group-details { + width: 100%; +} + +.group-details { + span { + display: inline-block; + vertical-align: middle; + } + + .avatar-flair { + $size: 50px; + + background-size: $size; + height: $size; + width: $size; + + i { + font-size: $size !important; + } + } +} + +.group-edit { + float: right; +} + +.groups.edit-group .form-horizontal { + textarea { + width: 99%; + } + + label { + font-weight: bold; + } + + input[type="text"] { + width: 80% !important; + margin-bottom: 10px; + } + + .group-flair-inputs { + display: inline-block; + margin-top: 30px; + margin-bottom: 30px; + + .group-flair-left { + float: left; + } + + .group-flair-right { + float: left; + margin-left: 30px; + } + } + + .avatar-flair-preview { + position: relative; + width: 45px; + + .avatar-wrapper { + background-color: #f4f4f4; + } + } +} + +#add-user-to-group { + margin: 0px; + + .ac-wrap { + width: 100% !important; + } + + .add { + margin-top: 10px; + } +} diff --git a/app/assets/stylesheets/common/base/login.scss b/app/assets/stylesheets/common/base/login.scss index b24e1ccc5c..7024567bae 100644 --- a/app/assets/stylesheets/common/base/login.scss +++ b/app/assets/stylesheets/common/base/login.scss @@ -30,6 +30,7 @@ $input-width: 220px; .disclaimer { font-size: 0.9em; color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%)); + clear: both; } .user-field.confirm { diff --git a/app/assets/stylesheets/common/base/tagging.scss b/app/assets/stylesheets/common/base/tagging.scss index 332614c4ac..2a32c29d3b 100644 --- a/app/assets/stylesheets/common/base/tagging.scss +++ b/app/assets/stylesheets/common/base/tagging.scss @@ -27,18 +27,11 @@ } } -.extra-info-wrapper { - .list-tags { - padding-top: 5px; - } - - .discourse-tag { - -webkit-animation: fadein .7s; - animation: fadein .7s; - } +.topic-header-extra .discourse-tag { + -webkit-animation: fadein .7s; + animation: fadein .7s; } - .add-tags .select2 { margin: 0; } @@ -136,11 +129,11 @@ $tag-color: scale-color($primary, $lightness: 40%); top: -0.1em; } -header .discourse-tag {color: $tag-color !important; } +header .discourse-tag {color: $tag-color } .list-tags { + margin-right: 3px; display: inline; - margin: 0 0 0 5px; font-size: 0.857em; } @@ -171,24 +164,6 @@ header .discourse-tag {color: $tag-color !important; } left: auto; } -.bullet + .list-tags { - display: block; - line-height: 15px; -} - -.bar + .list-tags { - line-height: 1.25; - .discourse-tag { - vertical-align: middle; - } -} - -.box + .list-tags { - display: inline-block; - margin: 5px 0 0 5px; - padding-top: 2px; -} - .tag-sort-options { margin-bottom: 20px; a { diff --git a/app/assets/stylesheets/common/base/topic.scss b/app/assets/stylesheets/common/base/topic.scss index 38e17a48da..def557e03e 100644 --- a/app/assets/stylesheets/common/base/topic.scss +++ b/app/assets/stylesheets/common/base/topic.scss @@ -9,6 +9,10 @@ .badge-wrapper { float: left; } + + a.topic-featured-link { + display: inline-block; + } } a.badge-category { @@ -47,7 +51,7 @@ display: inline; } -#suggested-topics h3 .badge-wrapper.bullet span.badge-category, { +#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; @@ -133,3 +137,18 @@ } } } + +a.topic-featured-link { + display: inline-block; + text-transform: lowercase; + color: #858585; + font-size: 0.875rem; + + &::before { + position: relative; + top: 0.1em; + padding-right: 3px; + font-family: FontAwesome; + content: "\f08e"; + } +} diff --git a/app/assets/stylesheets/common/components/badges.css.scss b/app/assets/stylesheets/common/components/badges.css.scss index ed14534894..8273d97c8b 100644 --- a/app/assets/stylesheets/common/components/badges.css.scss +++ b/app/assets/stylesheets/common/components/badges.css.scss @@ -133,6 +133,10 @@ } } +.extra-info-wrapper .title-wrapper .badge-wrapper.bar { + margin-top: 6px; +} + .autocomplete, td.category { .badge-wrapper { max-width: 230px; diff --git a/app/assets/stylesheets/desktop.scss b/app/assets/stylesheets/desktop.scss index 989d6cb286..807b43c726 100644 --- a/app/assets/stylesheets/desktop.scss +++ b/app/assets/stylesheets/desktop.scss @@ -19,6 +19,7 @@ @import "desktop/history"; @import "desktop/queued-posts"; @import "desktop/menu-panel"; +@import "desktop/group"; /* These files doesn't actually exist, they are injected by DiscourseSassImporter. */ diff --git a/app/assets/stylesheets/desktop/category-list.scss b/app/assets/stylesheets/desktop/category-list.scss index 151c01c2cd..d33120135f 100644 --- a/app/assets/stylesheets/desktop/category-list.scss +++ b/app/assets/stylesheets/desktop/category-list.scss @@ -151,6 +151,10 @@ font-size: 0.75em; } + .topic-featured-link { + padding-left: 8px; + } + .topic-list { .posts { width: 100%; diff --git a/app/assets/stylesheets/desktop/compose.scss b/app/assets/stylesheets/desktop/compose.scss index 738fb583e8..fbfcd4e874 100644 --- a/app/assets/stylesheets/desktop/compose.scss +++ b/app/assets/stylesheets/desktop/compose.scss @@ -298,6 +298,11 @@ background-color: dark-light-diff($primary, $secondary, 90%, -60%); } } + #topic-featured-link { + padding: 7px 10px; + margin: 6px 10px 3px 0; + width: 400px; + } .d-editor-input:disabled { background-color: dark-light-diff($primary, $secondary, 90%, -60%); } @@ -465,6 +470,10 @@ } } +#reply-control.topic-featured-link-only.open { + height: 200px; +} + .control-row.reply-area { padding-left: 20px; padding-right: 20px; diff --git a/app/assets/stylesheets/desktop/group.scss b/app/assets/stylesheets/desktop/group.scss new file mode 100644 index 0000000000..e368da25fd --- /dev/null +++ b/app/assets/stylesheets/desktop/group.scss @@ -0,0 +1,12 @@ +.group-outlet { + width: 75%; +} + +.group-nav { + width: 20%; + margin-right: 30px; +} + +.group-details { + margin-bottom: 20px; +} diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss index a53b90f9ba..2c0c57e4fd 100644 --- a/app/assets/stylesheets/desktop/topic-post.scss +++ b/app/assets/stylesheets/desktop/topic-post.scss @@ -505,13 +505,13 @@ video { .extra-info-wrapper { overflow: hidden; - .badge-wrapper, i, .topic-link { + .badge-wrapper, i, .topic-link { -webkit-animation: fadein .7s; animation: fadein .7s; } .topic-statuses { - i { color: $header_primary; } + i { color: $header_primary; } i.fa-envelope { color: $danger; } .unpinned { color: $header_primary; } } @@ -523,6 +523,26 @@ video { overflow: hidden; text-overflow: ellipsis; } + + .topic-header-extra { + margin: 0 0 0 5px; + padding-top: 5px; + } +} + +.bullet + .topic-header-extra { + display: block; + line-height: 12px; +} + +.bar + .topic-header-extra { + line-height: 1.25; +} + +.box + .topic-header-extra { + display: inline-block; + margin: 0 0 0 5px; + padding-top: 5px; } /* default docked header CSS for all topics, including those without categories */ diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index 08036e0eb5..5ec7d0ecd8 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -133,50 +133,6 @@ } } - table.group-members { - width: 100%; - p { - max-width: 600px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - th { - padding: 0.5em; - text-align: right; - border-bottom: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); - } - td.avatar { - width: 60px; - position: relative; - .is-owner { - position: absolute; - right: 0; - top: 20px; - color: dark-light-diff($primary, $secondary, 50%, -50%); - } - } - td.remove-user { - text-align: right; - } - td { - padding: 0.5em; - border-bottom: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); - img { - margin-right: 10px; - } - span.text { - float: right; - font-size: 1.2em; - color: dark-light-diff($primary, $secondary, 50%, -50%); - } - } - } - - .user-right.groups { - margin-top: 0; - } - .user-right { width: 900px; margin-top: 20px; diff --git a/app/assets/stylesheets/mobile.scss b/app/assets/stylesheets/mobile.scss index 4152083286..ce220071ee 100644 --- a/app/assets/stylesheets/mobile.scss +++ b/app/assets/stylesheets/mobile.scss @@ -22,6 +22,7 @@ @import "mobile/search"; @import "mobile/emoji"; @import "mobile/ring"; +@import "mobile/group"; /* These files doesn't actually exist, they are injected by DiscourseSassImporter. */ diff --git a/app/assets/stylesheets/mobile/compose.scss b/app/assets/stylesheets/mobile/compose.scss index 088aa7d0cd..f4b149a618 100644 --- a/app/assets/stylesheets/mobile/compose.scss +++ b/app/assets/stylesheets/mobile/compose.scss @@ -16,7 +16,10 @@ display: none !important; // can be removed if inline JS CSS is removed from com input { background: $secondary; color: $primary; - border-color: blend-primary-secondary(15%); + padding: 4px; + border-radius: 3px; + box-shadow: inset 0 1px 1px rgba(0,0,0, .3); + border: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); } #reply-control { diff --git a/app/assets/stylesheets/mobile/group.scss b/app/assets/stylesheets/mobile/group.scss new file mode 100644 index 0000000000..120e980d0f --- /dev/null +++ b/app/assets/stylesheets/mobile/group.scss @@ -0,0 +1,49 @@ +.group { + margin-top: 15px; +} + +.group-header { + margin: 0px; +} + +.group-name { + margin: 5px 0px 0px 0px; +} + +.group-nav, .group-outlet { + width: 100%; +} + +.group-details-container { + margin-bottom: 15px; +} + +.group-nav.mobile-nav { + margin-bottom: 15px; + + > li { + a { + color: white; + + .fa { color: white; } + } + } + + background-color: $quaternary; +} + +table.group-members { + th { + text-align: center; + } + + tr { + .user-info { + width: 130px; + } + + td { + padding-left: 0.5em; + } + } +} diff --git a/app/assets/stylesheets/mobile/login.scss b/app/assets/stylesheets/mobile/login.scss index bcc1a06424..9359b1c47f 100644 --- a/app/assets/stylesheets/mobile/login.scss +++ b/app/assets/stylesheets/mobile/login.scss @@ -19,7 +19,11 @@ color: dark-light-choose(scale-color($primary, $lightness: 35%), scale-color($secondary, $lightness: 65%)); } label { float: left; display: block; } - textarea, input, select {font-size: 1.143em; clear: left; margin-top: 0; } + textarea, input, select { + font-size: 1.143em; + clear: left; + margin-top: 0; + } td { padding: 4px; } } diff --git a/app/assets/stylesheets/mobile/modal.scss b/app/assets/stylesheets/mobile/modal.scss index bc1689b97c..13e41e2a25 100644 --- a/app/assets/stylesheets/mobile/modal.scss +++ b/app/assets/stylesheets/mobile/modal.scss @@ -38,15 +38,16 @@ width: 95%; } -// an ember metamorph is inserted between btn's sometimes, breaking this rule, but only on mobile for some reason: -// .modal-footer .btn + .btn { -.modal-footer .btn { +// we need a little extra room on mobile for the +// stuff inside the footer to fit +.modal-footer { + padding-right: 0; +} + +.modal-footer .btn + .btn { margin-right: 5px; margin-bottom: 5px; } -.modal-footer .btn-group .btn + .btn { - margin-left: -1px; -} .modal-header { // we need tighter spacing on mobile for header diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index daca310124..e363aff74a 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -36,7 +36,7 @@ class Admin::GroupsController < Admin::AdminController def create group = Group.new - group.name = (params[:name] || '').strip + group.name = (group_params[:name] || '').strip save_group(group) end @@ -44,29 +44,29 @@ class Admin::GroupsController < Admin::AdminController group = Group.find(params[:id]) # group rename is ignored for automatic groups - group.name = params[:name] if params[:name] && !group.automatic + group.name = group_params[:name] if group_params[:name] && !group.automatic save_group(group) end def save_group(group) - group.alias_level = params[:alias_level].to_i if params[:alias_level].present? - group.visible = params[:visible] == "true" - grant_trust_level = params[:grant_trust_level].to_i + group.alias_level = group_params[:alias_level].to_i if group_params[:alias_level].present? + group.visible = group_params[:visible] == "true" + grant_trust_level = group_params[:grant_trust_level].to_i group.grant_trust_level = (grant_trust_level > 0 && grant_trust_level <= 4) ? grant_trust_level : nil - group.automatic_membership_email_domains = params[:automatic_membership_email_domains] unless group.automatic - group.automatic_membership_retroactive = params[:automatic_membership_retroactive] == "true" unless group.automatic + group.automatic_membership_email_domains = group_params[:automatic_membership_email_domains] unless group.automatic + group.automatic_membership_retroactive = group_params[:automatic_membership_retroactive] == "true" unless group.automatic - group.primary_group = group.automatic ? false : params["primary_group"] == "true" + group.primary_group = group.automatic ? false : group_params["primary_group"] == "true" - group.incoming_email = group.automatic ? nil : params[:incoming_email] + group.incoming_email = group.automatic ? nil : group_params[:incoming_email] - title = params[:title] if params[:title].present? + title = group_params[:title] if group_params[:title].present? group.title = group.automatic ? nil : title - group.flair_url = params[:flair_url].presence - group.flair_bg_color = params[:flair_bg_color].presence - group.flair_color = params[:flair_color].presence + group.flair_url = group_params[:flair_url].presence + group.flair_bg_color = group_params[:flair_bg_color].presence + group.flair_color = group_params[:flair_color].presence if group.save Group.reset_counters(group.id, :group_users) @@ -124,7 +124,18 @@ class Admin::GroupsController < Admin::AdminController protected - def can_not_modify_automatic - render json: {errors: I18n.t('groups.errors.can_not_modify_automatic')}, status: 422 - end + def can_not_modify_automatic + render json: {errors: I18n.t('groups.errors.can_not_modify_automatic')}, status: 422 + end + + private + + def group_params + params.require(:group).permit( + :name, :alias_level, :visible, :automatic_membership_email_domains, + :automatic_membership_retroactive, :title, :primary_group, + :grant_trust_level, :incoming_email, :flair_url, :flair_bg_color, + :flair_color + ) + end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 223189b28d..f02c9c9759 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -110,6 +110,32 @@ class ApplicationController < ActionController::Base end end + def self.last_ar_cache_reset + @last_ar_cache_reset + end + + def self.last_ar_cache_reset=(val) + @last_ar_cache_reset + end + + rescue_from ActiveRecord::StatementInvalid do |e| + + last_cache_reset = ApplicationController.last_ar_cache_reset + + if e.message =~ /UndefinedColumn/ && (last_cache_reset.nil? || last_cache_reset < 30.seconds.ago) + Rails.logger.warn "Clear Active Record cache cause schema appears to have changed!" + + ApplicationController.last_ar_cache_reset = Time.zone.now + + ActiveRecord::Base.connection.query_cache.clear + (ActiveRecord::Base.connection.tables - %w[schema_migrations]).each do |table| + table.classify.constantize.reset_column_information rescue nil + end + end + + raise e + end + class PluginDisabled < StandardError; end # Handles requests for giant IDs that throw pg exceptions @@ -130,7 +156,7 @@ class ApplicationController < ActionController::Base end rescue_from Discourse::ReadOnly do - render_json_error I18n.t('read_only_mode_enabled'), type: :read_only, status: 405 + render_json_error I18n.t('read_only_mode_enabled'), type: :read_only, status: 503 end def rescue_discourse_actions(type, status_code, include_ember=false) @@ -382,7 +408,7 @@ class ApplicationController < ActionController::Base def preload_current_user_data store_preloaded("currentUser", MultiJson.dump(CurrentUserSerializer.new(current_user, scope: guardian, root: false))) - report = TopicTrackingState.report(current_user.id) + report = TopicTrackingState.report(current_user) serializer = ActiveModel::ArraySerializer.new(report, each_serializer: TopicTrackingStateSerializer) store_preloaded("topicTrackingStates", MultiJson.dump(serializer)) end @@ -465,7 +491,7 @@ class ApplicationController < ActionController::Base end def mini_profiler_enabled? - defined?(Rack::MiniProfiler) && guardian.is_developer? + defined?(Rack::MiniProfiler) && (guardian.is_developer? || Rails.env.development?) end def authorize_mini_profiler diff --git a/app/controllers/finish_installation_controller.rb b/app/controllers/finish_installation_controller.rb index 01bbf5bfcc..c9a30724d4 100644 --- a/app/controllers/finish_installation_controller.rb +++ b/app/controllers/finish_installation_controller.rb @@ -59,6 +59,7 @@ class FinishInstallationController < ApplicationController end def ensure_no_admins + preload_anonymous_data raise Discourse::InvalidAccess.new unless SiteSetting.has_login_hint? end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index a39bdb3c98..6c449113ee 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -1,27 +1,26 @@ class GroupsController < ApplicationController - before_filter :ensure_logged_in, only: [:set_notifications, :mentionable] + before_filter :ensure_logged_in, only: [ + :set_notifications, + :mentionable, + :update + ] + skip_before_filter :preload_json, :check_xhr, only: [:posts_feed, :mentions_feed] def show render_serialized(find_group(:id), GroupShowSerializer, root: 'basic_group') end - def counts - group = find_group(:group_id) + def update + group = Group.find(params[:id]) + guardian.ensure_can_edit!(group) - 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 + if group.update_attributes(group_params) + render json: success_json + else + render_json_error(group) end - - render json: { counts: counts } end def posts @@ -169,11 +168,21 @@ class GroupsController < ApplicationController private - def find_group(param_name) - name = params.require(param_name) - group = Group.find_by("lower(name) = ?", name.downcase) - guardian.ensure_can_see!(group) - group - end + def group_params + params.require(:group).permit( + :flair_url, + :flair_bg_color, + :flair_color, + :bio_raw, + :title + ) + end + + def find_group(param_name) + name = params.require(param_name) + group = Group.find_by("lower(name) = ?", name.downcase) + guardian.ensure_can_see!(group) + group + end end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 01936f6871..ae62b9b873 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -6,7 +6,7 @@ class InvitesController < ApplicationController skip_before_filter :check_xhr, :preload_json skip_before_filter :redirect_to_login_if_required - before_filter :ensure_logged_in, only: [:destroy, :create, :create_invite_link, :resend_invite, :resend_all_invites, :check_csv_chunk, :upload_csv_chunk] + before_filter :ensure_logged_in, only: [:destroy, :create, :create_invite_link, :resend_invite, :resend_all_invites, :upload_csv] before_filter :ensure_new_registrations_allowed, only: [:show, :redeem_disposable_invite] before_filter :ensure_not_logged_in, only: [:show, :redeem_disposable_invite] @@ -147,48 +147,29 @@ class InvitesController < ApplicationController render nothing: true end - def check_csv_chunk + def upload_csv guardian.ensure_can_bulk_invite_to_forum!(current_user) - filename = params.fetch(:resumableFilename) - identifier = params.fetch(:resumableIdentifier) - chunk_number = params.fetch(:resumableChunkNumber) - current_chunk_size = params.fetch(:resumableCurrentChunkSize).to_i + file = params[:file] || params[:files].first + name = params[:name] || File.basename(file.original_filename, ".*") + extension = File.extname(file.original_filename) - # path to chunk file - chunk = Invite.chunk_path(identifier, filename, chunk_number) - # check chunk upload status - status = HandleChunkUpload.check_chunk(chunk, current_chunk_size: current_chunk_size) - - render nothing: true, status: status - end - - def upload_csv_chunk - guardian.ensure_can_bulk_invite_to_forum!(current_user) - - filename = params.fetch(:resumableFilename) - return render status: 415, text: I18n.t("bulk_invite.file_should_be_csv") unless (filename.to_s.end_with?(".csv") || filename.to_s.end_with?(".txt")) - - file = params.fetch(:file) - identifier = params.fetch(:resumableIdentifier) - chunk_number = params.fetch(:resumableChunkNumber).to_i - chunk_size = params.fetch(:resumableChunkSize).to_i - total_size = params.fetch(:resumableTotalSize).to_i - current_chunk_size = params.fetch(:resumableCurrentChunkSize).to_i - - # path to chunk file - chunk = Invite.chunk_path(identifier, filename, chunk_number) - # upload chunk - HandleChunkUpload.upload_chunk(chunk, file: file) - - uploaded_file_size = chunk_number * chunk_size - # when all chunks are uploaded - if uploaded_file_size + current_chunk_size >= total_size - # handle bulk_invite processing in a background thread - Jobs.enqueue(:bulk_invite, filename: filename, identifier: identifier, chunks: chunk_number, current_user_id: current_user.id) + Scheduler::Defer.later("Upload CSV") do + begin + data = if extension == ".csv" + path = Invite.create_csv(file, name) + Jobs.enqueue(:bulk_invite, filename: "#{name}.csv", current_user_id: current_user.id) + {url: path} + else + failed_json.merge(errors: [I18n.t("bulk_invite.file_should_be_csv")]) + end + rescue + failed_json.merge(errors: [I18n.t("bulk_invite.error")]) + end + MessageBus.publish("/uploads/csv", data.as_json, user_ids: [current_user.id]) end - render nothing: true + render json: success_json end def fetch_username diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index b2c3c85a3f..7f68a648ef 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -574,7 +574,6 @@ class PostsController < ApplicationController end - params.require(:raw) result = params.permit(*permitted).tap do |whitelisted| whitelisted[:image_sizes] = params[:image_sizes] # TODO this does not feel right, we should name what meta_data is allowed diff --git a/app/controllers/static_controller.rb b/app/controllers/static_controller.rb index d7dee2377b..1641c01956 100644 --- a/app/controllers/static_controller.rb +++ b/app/controllers/static_controller.rb @@ -4,7 +4,7 @@ require_dependency 'file_helper' class StaticController < ApplicationController skip_before_filter :check_xhr, :redirect_to_login_if_required - skip_before_filter :verify_authenticity_token, only: [:cdn_asset, :enter, :favicon] + skip_before_filter :verify_authenticity_token, only: [:brotli_asset, :cdn_asset, :enter, :favicon] PAGES_WITH_EMAIL_PARAM = ['login', 'password_reset', 'signup'] @@ -123,7 +123,35 @@ class StaticController < ApplicationController response.headers["Last-Modified"] = Time.new('2000-01-01').httpdate render text: data, content_type: "image/png" end + end + def brotli_asset + path = File.expand_path(Rails.root + "public/assets/" + params[:path]) + path += ".br" + + # SECURITY what if path has /../ + raise Discourse::NotFound unless path.start_with?(Rails.root.to_s + "/public/assets") + + opts = { disposition: nil } + opts[:type] = "application/javascript" if path =~ /\.js.br$/ + + response.headers["Expires"] = 1.year.from_now.httpdate + response.headers["Cache-Control"] = 'max-age=31557600, public' + response.headers["Content-Encoding"] = 'br' + begin + response.headers["Last-Modified"] = File.ctime(path).httpdate + response.headers["Content-Length"] = File.size(path).to_s + rescue Errno::ENOENT + raise Discourse::NotFound + end + + expires_in 1.year, public: true, must_revalidate: false + + if File.exists?(path) + send_file(path, opts) + else + raise Discourse::NotFound + end end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index dad7ee9512..f72c1ede90 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -8,7 +8,14 @@ class TagsController < ::ApplicationController before_filter :ensure_tags_enabled skip_before_filter :check_xhr, only: [:tag_feed, :show, :index] - before_filter :ensure_logged_in, only: [:notifications, :update_notifications, :update] + before_filter :ensure_logged_in, except: [ + :index, + :show, + :tag_feed, + :search, + :check_hashtag, + Discourse.anonymous_filters.map { |f| :"show_#{f}"} + ].flatten before_filter :set_category_from_params, except: [:index, :update, :destroy, :tag_feed, :search, :notifications, :update_notifications] def index @@ -40,30 +47,14 @@ class TagsController < ::ApplicationController end end - # TODO: move all this to ListController Discourse.filters.each do |filter| define_method("show_#{filter}") do @tag_id = params[:tag_id] @additional_tags = params[:additional_tag_ids].to_s.split('/') - page = params[:page].to_i list_opts = build_topic_list_options - query = TopicQuery.new(current_user, list_opts) - - results = query.send("#{filter}_results") - - if @filter_on_category - category_ids = [@filter_on_category.id] - - unless list_opts[:no_subcategories] - category_ids += @filter_on_category.subcategories.pluck(:id) - end - - results = results.where(category_id: category_ids) - end - - @list = query.create_list(:by_tag, {}, results) + @list = TopicQuery.new(current_user, list_opts).public_send("list_#{filter}") @list.draft_key = Draft::NEW_TOPIC @list.draft_sequence = DraftSequence.current(current_user, Draft::NEW_TOPIC) diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 4961793ffa..ef0cd8ba69 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -574,7 +574,7 @@ class TopicsController < ApplicationController topic_ids = params[:topic_ids].map {|t| t.to_i} elsif params[:filter] == 'unread' tq = TopicQuery.new(current_user) - topics = TopicQuery.unread_filter(tq.joined_topic_user).listable_topics + topics = TopicQuery.unread_filter(tq.joined_topic_user, staff: guardian.is_staff?).listable_topics topics = topics.where('category_id = ?', params[:category_id]) if params[:category_id] topic_ids = topics.pluck(:id) else diff --git a/app/controllers/user_actions_controller.rb b/app/controllers/user_actions_controller.rb index 6ff74ec995..71dd2a5a12 100644 --- a/app/controllers/user_actions_controller.rb +++ b/app/controllers/user_actions_controller.rb @@ -24,7 +24,21 @@ class UserActionsController < ApplicationController UserAction.stream(opts) end - render_serialized(stream, UserActionSerializer, root: 'user_actions') + stream = stream.to_a + if stream.length == 0 && (help_key = params['no_results_help_key']) + if user.id == guardian.user.try(:id) + help_key += ".self" + else + help_key += ".others" + end + render json: { + user_action: [], + no_results_help: I18n.t(help_key) + } + else + render_serialized(stream, UserActionSerializer, root: 'user_actions') + end + end def show diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 0c226d93b2..d67c12455e 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -152,7 +152,7 @@ class UsersController < ApplicationController user = fetch_user_from_params guardian.ensure_can_edit!(user) - report = TopicTrackingState.report(user.id) + report = TopicTrackingState.report(user) serializer = ActiveModel::ArraySerializer.new(report, each_serializer: TopicTrackingStateSerializer) render json: MultiJson.dump(serializer) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index d79ace3f34..e13539e647 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -46,10 +46,12 @@ module ApplicationHelper end def script(*args) - if SiteSetting.enable_cdn_js_debugging && GlobalSetting.cdn_url - tags = javascript_include_tag(*args, "crossorigin" => "anonymous") - tags.gsub!("/assets/", "/cdn_asset/#{Discourse.current_hostname.tr(".","_")}/") - tags.gsub!(".js\"", ".js?v=1&origin=#{CGI.escape request.base_url}\"") + if GlobalSetting.cdn_url && + GlobalSetting.cdn_url.start_with?("https") && + ENV["COMPRESS_BROTLI"] == "1" && + request.env["HTTP_ACCEPT_ENCODING"] =~ /br/ + tags = javascript_include_tag(*args) + tags.gsub!("#{GlobalSetting.cdn_url}/assets/", "#{GlobalSetting.cdn_url}/brotli_asset/") tags.html_safe else javascript_include_tag(*args) @@ -280,4 +282,15 @@ module ApplicationHelper result.html_safe end + def topic_featured_link_domain(link) + begin + uri = URI.encode(link) + uri = URI.parse(uri) + uri = URI.parse("http://#{uri}") if uri.scheme.nil? + host = uri.host.downcase + host.start_with?('www.') ? host[4..-1] : host + rescue + '' + end + end end diff --git a/app/jobs/regular/bulk_invite.rb b/app/jobs/regular/bulk_invite.rb index 44b14abe64..6fb1eeb863 100644 --- a/app/jobs/regular/bulk_invite.rb +++ b/app/jobs/regular/bulk_invite.rb @@ -14,21 +14,12 @@ module Jobs end def execute(args) - filename = args[:filename] - identifier = args[:identifier] - chunks = args[:chunks].to_i + filename = args[:filename] @current_user = User.find_by(id: args[:current_user_id]) - - raise Discourse::InvalidParameters.new(:filename) if filename.blank? - raise Discourse::InvalidParameters.new(:identifier) if identifier.blank? - raise Discourse::InvalidParameters.new(:chunks) if chunks <= 0 - - # merge chunks, and get csv path - csv_path = get_csv_path(filename, identifier, chunks) + raise Discourse::InvalidParameters.new(:filename) if filename.blank? # read csv file, and send out invitations - read_csv_file(csv_path) - + read_csv_file("#{Invite.base_directory}/#{filename}") ensure # send notification to user regarding progress notify_user @@ -37,17 +28,6 @@ module Jobs FileUtils.rm_rf(csv_path) rescue nil end - def get_csv_path(filename, identifier, chunks) - csv_path = "#{Invite.base_directory}/#{filename}" - tmp_csv_path = "#{csv_path}.tmp" - # path to tmp directory - tmp_directory = File.dirname(Invite.chunk_path(identifier, filename, 0)) - # merge all chunks - HandleChunkUpload.merge_chunks(chunks, upload_path: csv_path, tmp_upload_path: tmp_csv_path, model: Invite, identifier: identifier, filename: filename, tmp_directory: tmp_directory) - - return csv_path - end - def read_csv_file(csv_path) CSV.foreach(csv_path, encoding: "iso-8859-1:UTF-8") do |csv_info| if csv_info[0] diff --git a/app/jobs/scheduled/poll_feed.rb b/app/jobs/scheduled/poll_feed.rb index 8cfed44571..8c0e1e75b3 100644 --- a/app/jobs/scheduled/poll_feed.rb +++ b/app/jobs/scheduled/poll_feed.rb @@ -55,6 +55,9 @@ module Jobs def topics feed_topics = [] + rss = fetch_rss + return feed_topics unless rss.present? + rss.items.each do |i| current_feed_topic = FeedTopic.new(i) feed_topics << current_feed_topic if current_feed_topic.content @@ -65,8 +68,10 @@ module Jobs private - def rss + def fetch_rss SimpleRSS.parse open(@feed_url, allow_redirections: :all) + rescue OpenURI::HTTPError + nil end end diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index a41e83c584..be7956973b 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -102,33 +102,45 @@ class UserNotifications < ActionMailer::Base @preheader_text = I18n.t('user_notifications.digest.preheader', last_seen_at: @last_seen_at) # Try to find 3 interesting stats for the top of the digest - @counts = [{label_key: 'user_notifications.digest.new_topics', value: Topic.new_since_last_seen(user, min_date).count}] + @counts = [{label_key: 'user_notifications.digest.new_topics', + value: Topic.new_since_last_seen(user, min_date).count, + href: "#{Discourse.base_url}/new"}] value = user.unread_notifications - @counts << {label_key: 'user_notifications.digest.unread_notifications', value: value} if value > 0 + @counts << {label_key: 'user_notifications.digest.unread_notifications', value: value, href: "#{Discourse.base_url}/my/notifications"} if value > 0 value = user.unread_private_messages - @counts << {label_key: 'user_notifications.digest.unread_messages', value: value} if value > 0 + @counts << {label_key: 'user_notifications.digest.unread_messages', value: value, href: "#{Discourse.base_url}/my/messages"} if value > 0 if @counts.size < 3 - @counts << {label_key: 'user_notifications.digest.new_posts', value: Post.for_mailing_list(user, min_date).where("posts.post_number > ?", 1).count} + @counts << { + label_key: 'user_notifications.digest.new_posts', + value: Post.for_mailing_list(user, min_date).where("posts.post_number > ?", 1).count, + href: "#{Discourse.base_url}/new" + } end if @counts.size < 3 value = User.real.where(active: true, staged: false).not_suspended.where("created_at > ?", min_date).count - @counts << {label_key: 'user_notifications.digest.new_users', value: value } if value > 0 + @counts << { + label_key: 'user_notifications.digest.new_users', + value: value, + href: "#{Discourse.base_url}/about" + } if value > 0 end # Now fetch some topics and posts to show - topics_for_digest = Topic.for_digest(user, min_date, limit: SiteSetting.digest_topics + 3, top_order: true).to_a + topics_for_digest = Topic.for_digest(user, min_date, limit: SiteSetting.digest_topics + SiteSetting.digest_other_topics, top_order: true).to_a @popular_topics = topics_for_digest[0,SiteSetting.digest_topics] @other_new_for_you = topics_for_digest.size > SiteSetting.digest_topics ? topics_for_digest[SiteSetting.digest_topics..-1] : [] @popular_posts = if SiteSetting.digest_posts > 0 - Post.for_mailing_list(user, min_date) - .where("posts.post_number > ? AND posts.score > ?", 1, 5.0) - .order("posts.score DESC") + Post.order("posts.score DESC") + .for_mailing_list(user, min_date) + .where('posts.post_type = ?', Post.types[:regular]) + .where('posts.deleted_at IS NULL AND posts.hidden = false AND posts.user_deleted = false') + .where("posts.post_number > ? AND posts.score > ?", 1, ScoreCalculator.default_score_weights[:like_score] * 5.0) .limit(SiteSetting.digest_posts) else [] diff --git a/app/models/category.rb b/app/models/category.rb index 2a044088c8..e2dd126701 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -213,11 +213,11 @@ SQL end def description_text - return nil unless description + return nil unless self.description @@cache ||= LruRedux::ThreadSafeCache.new(1000) @@cache.getset(self.description) do - Nokogiri::HTML(self.description).text + Nokogiri::HTML.fragment(self.description).text.strip end end diff --git a/app/models/group.rb b/app/models/group.rb index 9d488ac762..dde84c2922 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -13,6 +13,7 @@ class Group < ActiveRecord::Base has_and_belongs_to_many :web_hooks before_save :downcase_incoming_email + before_save :cook_bio after_save :destroy_deletions after_save :automatic_group_membership @@ -83,6 +84,12 @@ class Group < ActiveRecord::Base self.incoming_email = (incoming_email || "").strip.downcase.presence end + def cook_bio + if !self.bio_raw.blank? + self.bio_cooked = PrettyText.cook(self.bio_raw) + end + end + def incoming_email_validator return if self.automatic || self.incoming_email.blank? @@ -349,7 +356,11 @@ class Group < ActiveRecord::Base end def add_owner(user) - self.group_users.create(user_id: user.id, owner: true) + if group_user = self.group_users.find_by(user: user) + group_user.update_attributes!(owner: true) if !group_user.owner + else + GroupUser.create!(user: user, group: self, owner: true) + end end def self.find_by_email(email) diff --git a/app/models/invite.rb b/app/models/invite.rb index 989631d2bd..8ecef5eda0 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -265,8 +265,12 @@ class Invite < ActiveRecord::Base File.join(Rails.root, "public", "uploads", "csv", RailsMultisite::ConnectionManagement.current_db) end - def self.chunk_path(identifier, filename, chunk_number) - File.join(Invite.base_directory, "tmp", identifier, "#{filename}.part#{chunk_number}") + def self.create_csv(file, name) + extension = File.extname(file.original_filename) + path = "#{Invite.base_directory}/#{name}#{extension}" + FileUtils.mkdir_p(Pathname.new(path).dirname) + File.open(path, "wb") { |f| f << file.tempfile.read } + path end end diff --git a/app/models/post.rb b/app/models/post.rb index 84566d5a8a..e262f259ba 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -24,7 +24,7 @@ class Post < ActiveRecord::Base rate_limit :limit_posts_per_day belongs_to :user - belongs_to :topic, counter_cache: :posts_count + belongs_to :topic belongs_to :reply_to_user, class_name: "User" diff --git a/app/models/post_action.rb b/app/models/post_action.rb index 5cee56bdde..5bc031ccae 100644 --- a/app/models/post_action.rb +++ b/app/models/post_action.rb @@ -435,8 +435,10 @@ SQL post_action_type: post_action_type_key) end - topic_count = Post.where(topic_id: topic_id).sum(column) - Topic.where(id: topic_id).update_all ["#{column} = ?", topic_count] + if column == "like_count" + topic_count = Post.where(topic_id: topic_id).sum(column) + Topic.where(id: topic_id).update_all ["#{column} = ?", topic_count] + end if PostActionType.notify_flag_type_ids.include?(post_action_type_id) PostAction.update_flagged_posts_count diff --git a/app/models/post_analyzer.rb b/app/models/post_analyzer.rb index f5ff4117b1..09f7cd9932 100644 --- a/app/models/post_analyzer.rb +++ b/app/models/post_analyzer.rb @@ -19,7 +19,7 @@ class PostAnalyzer result = Oneboxer.apply(cooked, topic_id: @topic_id) do |url, _| @found_oneboxes = true Oneboxer.invalidate(url) if args.last[:invalidate_oneboxes] - Oneboxer.cached_onebox url + Oneboxer.cached_onebox(url) end cooked = result.to_html if result.changed? @@ -30,10 +30,9 @@ class PostAnalyzer def image_count return 0 unless @raw.present? - cooked_document.search("img").reject do |t| - dom_class = t["class"] - if dom_class - (Post.white_listed_image_classes & dom_class.split(" ")).count > 0 + cooked_stripped.css("img").reject do |t| + if dom_class = t["class"] + (Post.white_listed_image_classes & dom_class.split).count > 0 end end.count end @@ -42,8 +41,8 @@ class PostAnalyzer def attachment_count return 0 unless @raw.present? - attachments = cooked_document.css("a.attachment[href^=\"#{Discourse.store.absolute_base_url}\"]") - attachments += cooked_document.css("a.attachment[href^=\"#{Discourse.store.relative_base_url}\"]") if Discourse.store.internal? + attachments = cooked_stripped.css("a.attachment[href^=\"#{Discourse.store.absolute_base_url}\"]") + attachments += cooked_stripped.css("a.attachment[href^=\"#{Discourse.store.relative_base_url}\"]") if Discourse.store.internal? attachments.count end @@ -51,13 +50,6 @@ class PostAnalyzer return [] if @raw.blank? return @raw_mentions if @raw_mentions.present? - # strip quotes, code blocks and oneboxes - cooked_stripped = cooked_document - cooked_stripped.css("aside.quote").remove - cooked_stripped.css("pre").remove - cooked_stripped.css("code").remove - cooked_stripped.css(".onebox").remove - raw_mentions = cooked_stripped.css('.mention, .mention-group').map do |e| if name = e.inner_text name = name[1..-1] @@ -105,11 +97,10 @@ class PostAnalyzer @raw_links = [] - cooked_document.search("a").each do |l| + cooked_stripped.css("a[href]").each do |l| # Don't include @mentions in the link count - next if l.attributes['href'].nil? || link_is_a_mention?(l) - url = l.attributes['href'].to_s - @raw_links << url + next if l['href'].blank? || link_is_a_mention?(l) + @raw_links << l['href'].to_s end @raw_links @@ -122,13 +113,18 @@ class PostAnalyzer private - def cooked_document - @cooked_document ||= Nokogiri::HTML.fragment(cook(@raw, topic_id: @topic_id)) - end + def cooked_stripped + @cooked_stripped ||= begin + doc = Nokogiri::HTML.fragment(cook(@raw, topic_id: @topic_id)) + doc.css("pre, code, aside.quote, .onebox, .elided").remove + doc + end + end + + def link_is_a_mention?(l) + html_class = l['class'] + return false if html_class.blank? + html_class.to_s['mention'] && l['href'].to_s[/^\/users\//] + end - def link_is_a_mention?(l) - html_class = l.attributes['class'] - return false if html_class.nil? - html_class.to_s == 'mention' && l.attributes['href'].to_s =~ /^\/users\// - end end diff --git a/app/models/quoted_post.rb b/app/models/quoted_post.rb index 5a4ad07673..2bd06fce81 100644 --- a/app/models/quoted_post.rb +++ b/app/models/quoted_post.rb @@ -20,22 +20,22 @@ class QuotedPost < ActiveRecord::Base next if uniq[[topic_id,post_number]] uniq[[topic_id,post_number]] = true + begin + # It would be so much nicer if we used post_id in quotes + results = exec_sql "INSERT INTO quoted_posts(post_id, quoted_post_id, created_at, updated_at) + SELECT :post_id, p.id, current_timestamp, current_timestamp + FROM posts p + LEFT JOIN quoted_posts q on q.post_id = :post_id AND q.quoted_post_id = p.id + WHERE post_number = :post_number AND + topic_id = :topic_id AND + q.id IS NULL + RETURNING quoted_post_id + ", post_id: post.id, post_number: post_number, topic_id: topic_id - # It would be so much nicer if we used post_id in quotes - results = exec_sql "INSERT INTO quoted_posts(post_id, quoted_post_id, created_at, updated_at) - SELECT :post_id, p.id, current_timestamp, current_timestamp - FROM posts p - LEFT JOIN quoted_posts q on q.post_id = :post_id AND q.quoted_post_id = p.id - WHERE post_number = :post_number AND - topic_id = :topic_id AND - q.id IS NULL - RETURNING quoted_post_id - ", post_id: post.id, post_number: post_number, topic_id: topic_id - - results = results.to_a - - if results.length > 0 - ids << results[0]["quoted_post_id"].to_i + results = results.to_a + ids << results[0]["quoted_post_id"].to_i if results.length > 0 + rescue ActiveRecord::RecordNotUnique, PG::UniqueViolation + # it's fine end end diff --git a/app/models/topic.rb b/app/models/topic.rb index a8786ac781..a511711323 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -7,6 +7,7 @@ require_dependency 'text_cleaner' require_dependency 'archetype' require_dependency 'html_prettify' require_dependency 'discourse_tagging' +require_dependency 'discourse_featured_link' class Topic < ActiveRecord::Base include ActionView::Helpers::SanitizeHelper @@ -73,6 +74,10 @@ class Topic < ActiveRecord::Base (!t.user_id || !t.user.staff?) } + validates :featured_link, allow_nil: true, format: URI::regexp(%w(http https)) + validate if: :featured_link do + errors.add(:featured_link, :invalid_category) unless Guardian.new.can_edit_featured_link?(category_id) + end before_validation do self.title = TextCleaner.clean_title(TextSentinel.title_sentinel(title).text) if errors[:title].empty? @@ -378,6 +383,14 @@ class Topic < ActiveRecord::Base featured_topic_ids ? topics.where("topics.id NOT IN (?)", featured_topic_ids) : topics end + def featured_link + custom_fields[DiscourseFeaturedLink::CUSTOM_FIELD_NAME] + end + + def featured_link=(link) + custom_fields[DiscourseFeaturedLink::CUSTOM_FIELD_NAME] = link.strip + end + def meta_data=(data) custom_fields.replace(data) end @@ -466,23 +479,103 @@ class Topic < ActiveRecord::Base end # Atomically creates the next post number - def self.next_post_number(topic_id, reply = false) + def self.next_post_number(topic_id, reply = false, whisper = false) highest = exec_sql("select coalesce(max(post_number),0) as max from posts where topic_id = ?", topic_id).first['max'].to_i - reply_sql = reply ? ", reply_count = reply_count + 1" : "" - result = exec_sql("UPDATE topics SET highest_post_number = ? + 1#{reply_sql} - WHERE id = ? RETURNING highest_post_number", highest, topic_id) - result.first['highest_post_number'].to_i + if whisper + + result = exec_sql("UPDATE topics + SET highest_staff_post_number = ? + 1 + WHERE id = ? + RETURNING highest_staff_post_number", highest, topic_id) + + result.first['highest_staff_post_number'].to_i + + else + + reply_sql = reply ? ", reply_count = reply_count + 1" : "" + + result = exec_sql("UPDATE topics + SET highest_staff_post_number = :highest + 1, + highest_post_number = :highest + 1#{reply_sql}, + posts_count = posts_count + 1 + WHERE id = :topic_id + RETURNING highest_post_number", highest: highest, topic_id: topic_id) + + result.first['highest_post_number'].to_i + end end + + def self.reset_all_highest! + exec_sql < 4 + GROUP BY topic_id +) +UPDATE topics +SET + highest_staff_post_number = X.highest_post_number, + highest_post_number = Y.highest_post_number, + last_posted_at = Y.last_posted_at, + posts_count = Y.posts_count +FROM X, Y +WHERE + X.topic_id = topics.id AND + Y.topic_id = topics.id AND ( + topics.highest_staff_post_number <> X.highest_post_number OR + topics.highest_post_number <> Y.highest_post_number OR + topics.last_posted_at <> Y.last_posted_at OR + topics.posts_count <> Y.posts_count + ) +SQL + end + + # If a post is deleted we have to update our highest post counters def self.reset_highest(topic_id) result = exec_sql "UPDATE topics - SET highest_post_number = (SELECT COALESCE(MAX(post_number), 0) FROM posts WHERE topic_id = :topic_id AND deleted_at IS NULL), - posts_count = (SELECT count(*) FROM posts WHERE deleted_at IS NULL AND topic_id = :topic_id), - last_posted_at = (SELECT MAX(created_at) FROM POSTS WHERE topic_id = :topic_id AND deleted_at IS NULL) + SET + highest_staff_post_number = ( + SELECT COALESCE(MAX(post_number), 0) FROM posts + WHERE topic_id = :topic_id AND + deleted_at IS NULL + ), + highest_post_number = ( + SELECT COALESCE(MAX(post_number), 0) FROM posts + WHERE topic_id = :topic_id AND + deleted_at IS NULL AND + post_type <> 4 + ), + posts_count = ( + SELECT count(*) FROM posts + WHERE deleted_at IS NULL AND + topic_id = :topic_id AND + post_type <> 4 + ), + + last_posted_at = ( + SELECT MAX(created_at) FROM posts + WHERE topic_id = :topic_id AND + deleted_at IS NULL AND + post_type <> 4 + ) WHERE id = :topic_id RETURNING highest_post_number", topic_id: topic_id + highest_post_number = result.first['highest_post_number'].to_i # Update the forum topic user records @@ -724,10 +817,7 @@ class Topic < ActiveRecord::Base end def update_action_counts - PostActionType.types.each_key do |type| - count_field = "#{type}_count" - update_column(count_field, Post.where(topic_id: id).sum(count_field)) - end + update_column(:like_count, Post.where(topic_id: id).sum(:like_count)) end def posters_summary(options = {}) diff --git a/app/models/topic_link.rb b/app/models/topic_link.rb index 182c3d64d9..12ae7dcaab 100644 --- a/app/models/topic_link.rb +++ b/app/models/topic_link.rb @@ -37,7 +37,7 @@ class TopicLink < ActiveRecord::Base def self.topic_map(guardian, topic_id) # Sam: complicated reports are really hard in AR - builder = SqlBuilder.new < 0 && user.last_seen_at && user.last_seen_at > 1.month.ago + # top must be in the top_menu return unless SiteSetting.top_menu =~ /(^|\|)top(\||$)/i + + # not enough topics return unless period = SiteSetting.min_redirected_to_top_period(1.days.ago) diff --git a/app/models/web_hook.rb b/app/models/web_hook.rb index 42bbfd5b1e..9884bbf52e 100644 --- a/app/models/web_hook.rb +++ b/app/models/web_hook.rb @@ -40,8 +40,12 @@ class WebHook < ActiveRecord::Base end end - def self.enqueue_topic_hooks(event, topic, user) - WebHook.enqueue_hooks(:topic, topic_id: topic.id, user_id: user&.id, category_id: topic&.category&.id, event_name: event.to_s) + def self.enqueue_topic_hooks(event, topic, user=nil) + WebHook.enqueue_hooks(:topic, topic_id: topic.id, user_id: user&.id, category_id: topic&.category_id, event_name: event.to_s) + end + + def self.enqueue_post_hooks(event, post, user=nil) + WebHook.enqueue_hooks(:post, post_id: post.id, topic_id: post&.topic_id, user_id: user&.id, category_id: post&.topic&.category_id, event_name: event.to_s) end %i(topic_destroyed topic_recovered).each do |event| @@ -57,18 +61,16 @@ class WebHook < ActiveRecord::Base %i(post_created post_destroyed post_recovered).each do |event| - DiscourseEvent.on(event) do |post, _, user| - WebHook.enqueue_hooks(:post, - post_id: post.id, - topic_id: post&.topic&.id, - user_id: user&.id, - category_id: post.topic&.category&.id, - event_name: event.to_s - ) + WebHook.enqueue_post_hooks(event, post, user) end end + DiscourseEvent.on(:post_edited) do |post, topic_changed| + WebHook.enqueue_post_hooks(:post_edited, post) + WebHook.enqueue_topic_hooks(:topic_edited, post.topic) if post.is_first_post? && topic_changed + end + %i(user_created user_approved).each do |event| DiscourseEvent.on(event) do |user| WebHook.enqueue_hooks(:user, user_id: user.id, event_name: event.to_s) diff --git a/app/serializers/basic_group_serializer.rb b/app/serializers/basic_group_serializer.rb index 783fdafb94..401a88b2ff 100644 --- a/app/serializers/basic_group_serializer.rb +++ b/app/serializers/basic_group_serializer.rb @@ -14,9 +14,25 @@ class BasicGroupSerializer < ApplicationSerializer :has_messages, :flair_url, :flair_bg_color, - :flair_color + :flair_color, + :bio_raw, + :bio_cooked def include_incoming_email? - scope.is_staff? + staff? + end + + def include_has_messsages + staff? + end + + def include_bio_raw + staff? + end + + private + + def staff? + @staff ||= scope.is_staff? end end diff --git a/app/serializers/group_post_serializer.rb b/app/serializers/group_post_serializer.rb index 77f1eb95b3..1fb6305e59 100644 --- a/app/serializers/group_post_serializer.rb +++ b/app/serializers/group_post_serializer.rb @@ -1,42 +1,22 @@ class GroupPostSerializer < ApplicationSerializer attributes :id, - :cooked, + :excerpt, :created_at, :title, :url, - :user_title, - :user_long_name, - :topic, :category - has_one :user, serializer: BasicUserSerializer, embed: :objects + has_one :user, serializer: GroupPostUserSerializer, embed: :object + has_one :topic, serializer: BasicTopicSerializer, embed: :object def title object.topic.title end - def user_long_name - object.user.try(:name) - end - - def user_title - object.user.try(:title) - end - def include_user_long_name? SiteSetting.enable_names? end - def topic - object.topic - end - - def cooked - fragment = Nokogiri::HTML.fragment(object.cooked) - DiscourseEvent.trigger(:reduce_cooked, fragment, object) - fragment.to_html - end - def category object.topic.category end diff --git a/app/serializers/group_post_user_serializer.rb b/app/serializers/group_post_user_serializer.rb new file mode 100644 index 0000000000..5a69f23fbc --- /dev/null +++ b/app/serializers/group_post_user_serializer.rb @@ -0,0 +1,3 @@ +class GroupPostUserSerializer < BasicUserSerializer + attributes :title, :name +end diff --git a/app/serializers/group_show_serializer.rb b/app/serializers/group_show_serializer.rb index 58358c1bac..dc9e0c7649 100644 --- a/app/serializers/group_show_serializer.rb +++ b/app/serializers/group_show_serializer.rb @@ -1,11 +1,25 @@ class GroupShowSerializer < BasicGroupSerializer - attributes :is_group_user + attributes :is_group_user, :is_group_owner def include_is_group_user? scope.authenticated? end def is_group_user - object.users.include?(scope.user) + !!fetch_group_user + end + + def include_is_group_owner? + scope.authenticated? + end + + def is_group_owner + scope.is_admin? || fetch_group_user&.owner + end + + private + + def fetch_group_user + @group_user ||= object.group_users.find_by(user: scope.user) end end diff --git a/app/serializers/listable_topic_serializer.rb b/app/serializers/listable_topic_serializer.rb index 3589d7a231..13a87235c1 100644 --- a/app/serializers/listable_topic_serializer.rb +++ b/app/serializers/listable_topic_serializer.rb @@ -26,6 +26,10 @@ class ListableTopicSerializer < BasicTopicSerializer has_one :last_poster, serializer: BasicUserSerializer, embed: :objects + def highest_post_number + (scope.is_staff? && object.highest_staff_post_number) || object.highest_post_number + end + def liked object.user_data && object.user_data.liked end @@ -109,7 +113,7 @@ class ListableTopicSerializer < BasicTopicSerializer protected def unread_helper - @unread_helper ||= Unread.new(object, object.user_data) + @unread_helper ||= Unread.new(object, object.user_data, scope) end end diff --git a/app/serializers/post_revision_serializer.rb b/app/serializers/post_revision_serializer.rb index 4df9012e8d..8ff8b1826e 100644 --- a/app/serializers/post_revision_serializer.rb +++ b/app/serializers/post_revision_serializer.rb @@ -193,6 +193,10 @@ class PostRevisionSerializer < ApplicationSerializer end end + if SiteSetting.topic_featured_link_enabled + latest_modifications["featured_link"] = [post.topic.featured_link] + end + if SiteSetting.tagging_enabled latest_modifications["tags"] = [post.topic.tags.map(&:name)] end diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb index 718cb1221a..d43c165a96 100644 --- a/app/serializers/site_serializer.rb +++ b/app/serializers/site_serializer.rb @@ -23,7 +23,8 @@ class SiteSerializer < ApplicationSerializer :can_tag_topics, :tags_filter_regexp, :top_tags, - :wizard_required + :wizard_required, + :topic_featured_link_allowed_category_ids has_many :categories, serializer: BasicCategorySerializer, embed: :objects has_many :trust_levels, embed: :objects @@ -121,4 +122,12 @@ class SiteSerializer < ApplicationSerializer def include_wizard_required? Wizard.user_requires_completion?(scope.user) end + + def include_topic_featured_link_allowed_category_ids? + SiteSetting.topic_featured_link_enabled + end + + def topic_featured_link_allowed_category_ids + scope.topic_featured_link_allowed_category_ids + end end diff --git a/app/serializers/suggested_topic_serializer.rb b/app/serializers/suggested_topic_serializer.rb index 3d4b8f380f..a0817917f5 100644 --- a/app/serializers/suggested_topic_serializer.rb +++ b/app/serializers/suggested_topic_serializer.rb @@ -7,7 +7,7 @@ class SuggestedTopicSerializer < ListableTopicSerializer has_one :user, serializer: BasicUserSerializer, embed: :objects end - attributes :archetype, :like_count, :views, :category_id, :tags + attributes :archetype, :like_count, :views, :category_id, :tags, :featured_link has_many :posters, serializer: SuggestedPosterSerializer, embed: :objects def posters @@ -21,4 +21,12 @@ class SuggestedTopicSerializer < ListableTopicSerializer def tags object.tags.map(&:name) end + + def include_featured_link? + SiteSetting.topic_featured_link_enabled + end + + def featured_link + object.featured_link + end end diff --git a/app/serializers/topic_list_item_serializer.rb b/app/serializers/topic_list_item_serializer.rb index a04c1eeae3..9e1e642a15 100644 --- a/app/serializers/topic_list_item_serializer.rb +++ b/app/serializers/topic_list_item_serializer.rb @@ -10,7 +10,8 @@ class TopicListItemSerializer < ListableTopicSerializer :pinned_globally, :bookmarked_post_numbers, :liked_post_numbers, - :tags + :tags, + :featured_link has_many :posters, serializer: TopicPosterSerializer, embed: :objects has_many :participants, serializer: TopicPosterSerializer, embed: :objects @@ -72,4 +73,12 @@ class TopicListItemSerializer < ListableTopicSerializer object.tags.map(&:name) end + def include_featured_link? + SiteSetting.topic_featured_link_enabled + end + + def featured_link + object.featured_link + end + end diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb index e9e308b5df..780d2b80c3 100644 --- a/app/serializers/topic_view_serializer.rb +++ b/app/serializers/topic_view_serializer.rb @@ -56,7 +56,8 @@ class TopicViewSerializer < ApplicationSerializer :chunk_size, :bookmarked, :message_archived, - :tags + :tags, + :featured_link # TODO: Split off into proper object / serializer def details @@ -243,8 +244,17 @@ class TopicViewSerializer < ApplicationSerializer def include_tags? SiteSetting.tagging_enabled end + def tags object.topic.tags.map(&:name) end + def include_featured_link? + SiteSetting.topic_featured_link_enabled + end + + def featured_link + object.topic.featured_link + end + end diff --git a/app/views/user_notifications/digest.html.erb b/app/views/user_notifications/digest.html.erb index d8819a9da7..1a2e03dec8 100644 --- a/app/views/user_notifications/digest.html.erb +++ b/app/views/user_notifications/digest.html.erb @@ -18,9 +18,18 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo <%= @preheader_text %> +<%- if I18n.t('user_notifications.digest.custom.html.header').present? %> + + + + +
    + <%= raw(t 'user_notifications.digest.custom.html.header') %> +
    +<%- else %> -
    + <%- if logo_url.blank? %> @@ -33,6 +42,7 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo
    +<%- end %> @@ -63,14 +73,14 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo <%- @counts.each do |count| -%> <%- end -%> <%- @counts.each do |count| -%> <%- end -%> @@ -117,6 +127,9 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo <%= t.title -%> + <%- if SiteSetting.show_topic_featured_link_in_digest && t.featured_link %> + <%= raw topic_featured_link_domain(t.featured_link) %> + <%- end %> @@ -129,13 +142,15 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo <%- if show_image_with_url(t.image_url) -%> - <%- end -%> @@ -147,7 +162,7 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo
    - <%= count[:value] -%> + <%= count[:value] -%>
    - <%=t count[:label_key] -%> + <%=t count[:label_key] -%>
    -
    <%= t.user.try(:username) -%>
    - <% if t.user.try(:name).present? %> -

    <%= t.user.name -%>

    + <% if t.user %> +
    <%= t.user.username -%>
    + <% if SiteSetting.enable_names? && t.user.name.present? && t.user.name.downcase != t.user.username.downcase %> +

    <%= t.user.name -%>

    + <% end %> <% end %>
    +
    - @@ -157,24 +172,24 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo
    + <%= email_excerpt(t.first_post.cooked) %>
    - - + - - - @@ -219,7 +234,7 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo
    +

    <%= t.like_count -%>

    +

    <%= t.posts_count - 1 -%>

    + <% t.posters_summary.each do |ps| %> <% if ps.user %> <% end %> <% end %> - + + <%=t 'user_notifications.digest.join_the_discussion' %>
    @@ -261,8 +276,12 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo + + +
    -

    +

    <%=t 'user_notifications.digest.popular_posts' %>

    -
    <%= post.user.username -%>
    -

    <%= post.user.name -%>

    + <% if post.user %> +
    <%= post.user.username -%>
    + <% if SiteSetting.enable_names? && post.user.name && post.user.name.downcase != post.user.username.downcase %> +

    <%= post.user.name -%>

    + <% end %> + <% end %>

    @@ -279,22 +298,30 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo

    - +
     
     
    <% end %> +
     
    + <% end %> +<%= digest_custom_html("above_popular_topics") %> <% if @other_new_for_you.present? %> -
    <%=t 'user_notifications.digest.more_new' %>
    +
    <%=t 'user_notifications.digest.more_new' %>
    - -<%= digest_custom_html("above_popular_topics") %> + + + + + + +
      @@ -312,6 +339,9 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo <%= t.title -%> + <%- if SiteSetting.show_topic_featured_link_in_digest && t.featured_link %> + <%= raw topic_featured_link_domain(t.featured_link) %> + <%- end %>

    <%= category_badge(t.category, inline_style: true, absolute_url: true) %>

    @@ -342,10 +372,15 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo
    -<%= digest_custom_html("below_popular_topics") %> +
     
    <% end %> +<%= digest_custom_html("below_popular_topics") %> +   diff --git a/config/initializers/100-logster.rb b/config/initializers/100-logster.rb index c39494f6a0..d913051336 100644 --- a/config/initializers/100-logster.rb +++ b/config/initializers/100-logster.rb @@ -87,5 +87,6 @@ RailsMultisite::ConnectionManagement.each_connection do end if Rails.configuration.multisite - Rails.logger.instance_variable_get(:@chained).first.formatter = RailsMultisite::Formatter.new + chained = Rails.logger.instance_variable_get(:@chained) + chained && chained.first.formatter = RailsMultisite::Formatter.new end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 1ec5a52c01..4255aaa32a 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -487,8 +487,11 @@ en: profile: "Profile" mute: "Mute" edit: "Edit Preferences" - download_archive: "Download My Posts" - download_archive_confirm: "Are you sure you want to download your posts?" + download_archive: + button_text: "Download My Posts" + confirm: "Are you sure you want to download your posts?" + success: "Download initiated, you will be notified via message when the process is complete." + rate_limit_error: "Posts can be downloaded once per day, please try again tomorrow." new_private_message: "New Message" private_message: "Message" private_messages: "Messages" @@ -775,11 +778,9 @@ en: link_generated: "Invite link generated successfully!" valid_for: "Invite link is only valid for this email address: %{email}" bulk_invite: - none: "You haven't invited anyone here yet. You can send individual invites, or invite a bunch of people at once by uploading a bulk invite file." + none: "You haven't invited anyone here yet. You can send individual invites, or invite a bunch of people at once by uploading a CSV file." text: "Bulk Invite from File" - uploading: "Uploading..." success: "File uploaded successfully, you will be notified via message when the process is complete." - error: "There was an error uploading '{{filename}}': {{message}}" password: title: "Password" @@ -1073,6 +1074,7 @@ en: title_placeholder: "What is this discussion about in one brief sentence?" edit_reason_placeholder: "why are you editing?" show_edit_reason: "(add edit reason)" + topic_featured_link_placeholder: "Enter link shown with title." reply_placeholder: "Type here. Use Markdown, BBCode, or HTML to format. Drag or paste images." view_new_post: "View your new post." saving: "Saving" @@ -1842,6 +1844,23 @@ en: title: "Show the raw source diffs side-by-side" button: ' Raw' + group: + edit: + title: 'Edit Group' + title: 'Title' + name: "Name" + bio: "About Group" + name_placeholder: "Group name, no spaces, same as username rule" + flair_url: "Avatar Flair Image" + flair_url_placeholder: "(Optional) Image URL or Font Awesome class" + flair_bg_color: "Avatar Flair Background Color" + flair_bg_color_placeholder: "(Optional) Hex color value" + flair_color: "Avatar Flair Color" + flair_color_placeholder: "(Optional) Hex color value" + flair_preview_icon: "Preview Icon" + flair_preview_image: "Preview Image" + flair_note: "Note: Flair will only show for a user's primary group." + category: can: 'can… ' none: '(no category)' @@ -1858,6 +1877,7 @@ en: tags_allowed_tag_groups: "Tag groups that can only be used in this category:" tags_placeholder: "(Optional) list of allowed tags" tag_groups_placeholder: "(Optional) list of allowed tag groups" + topic_featured_link_allowed: "Restricts editing the topic featured link in this category. Require site setting topic_featured_link_enabled is checked." delete: 'Delete Category' create: 'New Category' create_long: 'Create a new category' @@ -2448,7 +2468,6 @@ en: refresh: "Refresh" new: "New" selector_placeholder: "enter username" - name_placeholder: "Group name, no spaces, same as username rule" about: "Edit your group membership and names here" group_members: "Group members" delete: "Delete" @@ -2456,7 +2475,6 @@ en: delete_failed: "Unable to delete group. If this is an automatic group, it cannot be destroyed." delete_member_confirm: "Remove '%{username}' from the '%{group}' group?" delete_owner_confirm: "Remove owner privilege for '%{username}'?" - name: "Name" add: "Add" add_members: "Add members" custom: "Custom" @@ -2473,14 +2491,6 @@ en: add_owners: Add owners incoming_email: "Custom incoming email address" incoming_email_placeholder: "enter email address" - flair_url: "Avatar Flair Image" - flair_url_placeholder: "(Optional) Image URL or Font Awesome class" - flair_bg_color: "Avatar Flair Background Color" - flair_bg_color_placeholder: "(Optional) Hex color value" - flair_color: "Avatar Flair Color" - flair_color_placeholder: "(Optional) Hex color value" - flair_preview: "Preview" - flair_note: "Note: Flair will only show for a user's primary group." api: generate_master: "Generate Master API Key" @@ -2636,7 +2646,6 @@ en: export_csv: success: "Export initiated, you will be notified via message when the process is complete." failed: "Export failed. Please check the logs." - rate_limit_error: "Posts can be downloaded once per day, please try again tomorrow." button_text: "Export" button_title: user: "Export full user list in CSV format." diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 573685dca4..3c1d84d833 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -134,7 +134,8 @@ en: <<: *errors bulk_invite: - file_should_be_csv: "The uploaded file should be of csv or txt format." + file_should_be_csv: "The uploaded file should be of csv format." + error: "There was an error uploading that file. Please try again later." backup: operation_already_running: "An operation is currently running. Can't start a new job right now." @@ -319,6 +320,7 @@ en: name: "Category Name" topic: title: 'Title' + featured_link: 'Featured Link' post: raw: "Body" user_profile: @@ -335,6 +337,9 @@ en: too_many_users: "You can only send warnings to one user at a time." cant_send_pm: "Sorry, you cannot send a private message to that user." no_user_selected: "You must select a valid user." + featured_link: + invalid: "is invalid. URL should include http:// or https://." + invalid_category: "can't be edited in this category." user: attributes: password: @@ -599,6 +604,14 @@ en: description: 'Vote for this post' long_form: 'voted for this post' + user_activity: + no_bookmarks: + self: "You have no bookmarked posts, bookmarking posts allows you to easily access them later on." + others: "No bookmarks." + no_likes_given: + self: "You have not liked any posts." + others: "No liked posts." + topic_flag_types: spam: title: 'Spam' @@ -837,6 +850,10 @@ en: min_first_post_length: "Minimum allowed first post (topic body) length in characters" min_private_message_post_length: "Minimum allowed post length in characters for messages" max_post_length: "Maximum allowed post length in characters" + topic_featured_link_enabled: "Enable posting a link with topics." + topic_featured_link_onebox: "Show an onebox in the post body if possible and prevent editing post content." + open_topic_featured_link_in_external_window: "Open topic featured link in a external window." + show_topic_featured_link_in_digest: "Show the topic featured link in the digest email." min_topic_title_length: "Minimum allowed topic title length in characters" max_topic_title_length: "Maximum allowed topic title length in characters" min_private_message_title_length: "Minimum allowed title length for a message in characters" @@ -959,7 +976,7 @@ en: email_token_grace_period_hours: "Forgot password / activate account tokens are still valid for a grace period of (n) hours after being redeemed." enable_badges: "Enable the badge system" - enable_whispers: "Allow staff private communication within topic. (experimental)" + enable_whispers: "Allow staff private communication within topics." allow_index_in_robots_txt: "Specify in robots.txt that this site is allowed to be indexed by web search engines." email_domains_blacklist: "A pipe-delimited list of email domains that users are not allowed to register accounts with. Example: mailinator.com|trashmail.net" @@ -1278,8 +1295,9 @@ en: allow_animated_thumbnails: "Generates animated thumbnails of animated gifs." default_avatars: "URLs to avatars that will be used by default for new users until they change them." automatically_download_gravatars: "Download Gravatars for users upon account creation or email change." - digest_topics: "The maximum number of topics to display in the email summary." + digest_topics: "The maximum number of popular topics to display in the email summary." digest_posts: "The maximum number of popular posts to display in the email summary." + digest_other_topics: "The maximum number of topics to show in the 'New in topics and categories you follow' section of the email summary." digest_min_excerpt_length: "Minimum post excerpt in the email summary, in characters." delete_digest_email_after_days: "Suppress summary emails for users not seen on the site for more than (n) days." digest_suppress_categories: "Suppress these categories from summary emails." @@ -1345,7 +1363,6 @@ en: embed_whitelist_selector: "CSS selector for elements that are allowed in embeds." embed_blacklist_selector: "CSS selector for elements that are removed from embeds." notify_about_flags_after: "If there are flags that haven't been handled after this many hours, send an email to the contact_email. Set to 0 to disable." - enable_cdn_js_debugging: "Allow /logs to display proper errors by adding crossorigin permissions on all js includes." show_create_topics_notice: "If the site has fewer than 5 public topics, show a notice asking admins to create some topics." delete_drafts_older_than_n_days: Delete drafts older than (n) days. diff --git a/config/routes.rb b/config/routes.rb index f465e56a38..5bffa05f74 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -617,12 +617,8 @@ Discourse::Application.routes.draw do resources :queued_posts, constraints: StaffConstraint.new get 'queued-posts' => 'queued_posts#index' - resources :invites do - collection do - get "upload" => "invites#check_csv_chunk" - post "upload" => "invites#upload_csv_chunk" - end - end + resources :invites + post "invites/upload_csv" => "invites#upload_csv" post "invites/reinvite" => "invites#resend_invite" post "invites/reinvite-all" => "invites#resend_all_invites" post "invites/link" => "invites#create_invite_link" @@ -650,6 +646,7 @@ Discourse::Application.routes.draw do delete "draft" => "draft#destroy" get "cdn_asset/:site/*path" => "static#cdn_asset", format: false + get "brotli_asset/*path" => "static#brotli_asset", format: false get "favicon/proxied" => "static#favicon", format: false diff --git a/config/site_settings.yml b/config/site_settings.yml index 9be481803e..aae34135a6 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -433,6 +433,15 @@ posting: max_post_length: client: true default: 32000 + topic_featured_link_enabled: + client: true + default: false + topic_featured_link_onebox: + client: true + default: false + open_topic_featured_link_in_external_window: + client: true + default: true body_min_entropy: 7 min_topic_title_length: client: true @@ -588,6 +597,7 @@ email: default: 5 min: 1 digest_posts: 3 + digest_other_topics: 5 delete_digest_email_after_days: 365 digest_suppress_categories: type: category_list @@ -595,6 +605,7 @@ email: disable_digest_emails: default: false client: true + show_topic_featured_link_in_digest: true email_custom_headers: 'Auto-Submitted: auto-generated' email_subject: '[%{site_name}] %{optional_pm}%{optional_cat}%{topic_title}' reply_by_email_enabled: @@ -669,7 +680,7 @@ email: reset_bounce_score_after_days: 30 attachment_content_type_blacklist: type: list - default: "pkcs7" + default: "pkcs7|x-vcard" attachment_filename_blacklist: type: list default: "smime.p7s|signature.asc" @@ -685,7 +696,7 @@ files: default: 3072 authorized_extensions: client: true - default: 'jpg|jpeg|png|gif' + default: 'jpg|jpeg|png|gif|csv' refresh: true type: list crawl_images: @@ -1196,8 +1207,6 @@ uncategorized: notify_about_flags_after: 48 - enable_cdn_js_debugging: false - show_create_topics_notice: client: true default: true diff --git a/db/fixtures/009_users.rb b/db/fixtures/009_users.rb index d65c8f2ef7..dd8a203e6b 100644 --- a/db/fixtures/009_users.rb +++ b/db/fixtures/009_users.rb @@ -32,7 +32,7 @@ duration = Rails.env.production? ? 60 : 0 if User.exec_sql("SELECT 1 FROM schema_migration_details WHERE EXISTS( SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'users' AND column_name = 'last_redirected_to_top_at' + WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'last_redirected_to_top_at' ) AND name = 'MoveTrackingOptionsToUserOptions' AND created_at < (current_timestamp at time zone 'UTC' - interval '#{duration} minutes') diff --git a/db/fixtures/999_topics.rb b/db/fixtures/999_topics.rb index cfe48615bc..e46419fd20 100644 --- a/db/fixtures/999_topics.rb +++ b/db/fixtures/999_topics.rb @@ -64,3 +64,32 @@ if seed_welcome_topics skip_validations: true, category: staff ? staff.name : nil) end + + + +# run this later, cause we need to make sure new application controller resilience is in place first +duration = Rails.env.production? ? 60 : 0 +if Topic.exec_sql("SELECT 1 FROM schema_migration_details + WHERE EXISTS( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_schema = 'public' AND table_name = 'topics' AND column_name = 'inappropriate_count' + ) AND + name = 'AddTopicColumnsBack' AND + created_at < (current_timestamp at time zone 'UTC' - interval '#{duration} minutes') + ").to_a.length > 0 + + + Topic.transaction do + STDERR.puts "Removing superflous topic columns!" + %w[ + inappropriate_count + bookmark_count + off_topic_count + illegal_count + notify_user_count +].each do |column| + User.exec_sql("ALTER TABLE topics DROP COLUMN IF EXISTS #{column}") + end + + end +end diff --git a/db/migrate/20161202011139_add_whisper_support_to_topics.rb b/db/migrate/20161202011139_add_whisper_support_to_topics.rb new file mode 100644 index 0000000000..0e065889e8 --- /dev/null +++ b/db/migrate/20161202011139_add_whisper_support_to_topics.rb @@ -0,0 +1,16 @@ +class AddWhisperSupportToTopics < ActiveRecord::Migration + def up + remove_column :topics, :bookmark_count + remove_column :topics, :off_topic_count + remove_column :topics, :illegal_count + remove_column :topics, :inappropriate_count + remove_column :topics, :notify_user_count + + add_column :topics, :highest_staff_post_number, :int, default: 0, null: false + execute "UPDATE topics SET highest_staff_post_number = highest_post_number" + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20161205001727_add_topic_columns_back.rb b/db/migrate/20161205001727_add_topic_columns_back.rb new file mode 100644 index 0000000000..d464c54a9e --- /dev/null +++ b/db/migrate/20161205001727_add_topic_columns_back.rb @@ -0,0 +1,21 @@ +class AddTopicColumnsBack < ActiveRecord::Migration + + # This really sucks big time, we have no use for these columns yet can not remove them + # if we remove them then sites will be down during migration + + def up + add_column :topics, :bookmark_count, :int + add_column :topics, :off_topic_count, :int + add_column :topics, :illegal_count, :int + add_column :topics, :inappropriate_count, :int + add_column :topics, :notify_user_count, :int + end + + def down + remove_column :topics, :bookmark_count + remove_column :topics, :off_topic_count + remove_column :topics, :illegal_count + remove_column :topics, :inappropriate_count + remove_column :topics, :notify_user_count + end +end diff --git a/db/migrate/20161205065743_add_bio_to_groups.rb b/db/migrate/20161205065743_add_bio_to_groups.rb new file mode 100644 index 0000000000..846b3a1c97 --- /dev/null +++ b/db/migrate/20161205065743_add_bio_to_groups.rb @@ -0,0 +1,6 @@ +class AddBioToGroups < ActiveRecord::Migration + def change + add_column :groups, :bio_raw, :text + add_column :groups, :bio_cooked, :text + end +end diff --git a/lib/active_record/connection_adapters/postgresql_fallback_adapter.rb b/lib/active_record/connection_adapters/postgresql_fallback_adapter.rb index b52a6c12ac..2f2e75030c 100644 --- a/lib/active_record/connection_adapters/postgresql_fallback_adapter.rb +++ b/lib/active_record/connection_adapters/postgresql_fallback_adapter.rb @@ -6,23 +6,42 @@ class PostgreSQLFallbackHandler include Singleton def initialize - @master = {} - @running = {} - @mutex = {} - @last_check = {} - - setup! + @masters_down = {} + @mutex = Mutex.new end def verify_master - @mutex[namespace].synchronize do - return if running || recently_checked? - @running[namespace] = true - end + synchronize { return if @thread && @thread.alive? } - current_namespace = namespace - Thread.new do - RailsMultisite::ConnectionManagement.with_connection(current_namespace) do + @thread = Thread.new do + while true do + begin + thread = Thread.new { initiate_fallback_to_master } + thread.join + break if synchronize { @masters_down.empty? } + sleep 10 + ensure + thread.kill + end + end + end + end + + def master_down? + synchronize { @masters_down[namespace] } + end + + def master_down=(args) + synchronize { @masters_down[namespace] = args } + end + + def master_up(namespace) + synchronize { @masters_down.delete(namespace) } + end + + def initiate_fallback_to_master + @masters_down.keys.each do |key| + RailsMultisite::ConnectionManagement.with_connection(key) do begin logger.warn "#{log_prefix}: Checking master server..." connection = ActiveRecord::Base.postgresql_connection(config) @@ -32,54 +51,19 @@ class PostgreSQLFallbackHandler ActiveRecord::Base.clear_all_connections! logger.warn "#{log_prefix}: Master server is active. Reconnecting..." - if namespace == RailsMultisite::ConnectionManagement::DEFAULT - ActiveRecord::Base.establish_connection(config) - else - RailsMultisite::ConnectionManagement.establish_connection(db: namespace) - end - + self.master_up(key) Discourse.disable_readonly_mode - self.master = true end rescue => e - if e.message.include?("could not connect to server") - logger.warn "#{log_prefix}: Connection to master PostgreSQL server failed with '#{e.message}'" - else - raise e - end - ensure - @mutex[namespace].synchronize do - @last_check[namespace] = Time.zone.now - @running[namespace] = false - end + logger.warn "#{log_prefix}: Connection to master PostgreSQL server failed with '#{e.message}'" end end end end - def master - @master[namespace] - end - - def master=(args) - @master[namespace] = args - end - - def running - @running[namespace] - end - + # Use for testing def setup! - RailsMultisite::ConnectionManagement.all_dbs.each do |db| - @master[db] = true - @running[db] = false - @mutex[db] = Mutex.new - @last_check[db] = nil - end - end - - def verify? - !master && !running + @masters_down = {} end private @@ -96,17 +80,13 @@ class PostgreSQLFallbackHandler "#{self.class} [#{namespace}]" end - def recently_checked? - if @last_check[namespace] - Time.zone.now <= (@last_check[namespace] + 5.seconds) - else - false - end - end - def namespace RailsMultisite::ConnectionManagement.current_db end + + def synchronize + @mutex.synchronize { yield } + end end module ActiveRecord @@ -115,7 +95,9 @@ module ActiveRecord fallback_handler = ::PostgreSQLFallbackHandler.instance config = config.symbolize_keys - if fallback_handler.verify? + if fallback_handler.master_down? + fallback_handler.verify_master + connection = postgresql_connection(config.dup.merge({ host: config[:replica_host], port: config[:replica_port] })) @@ -126,7 +108,8 @@ module ActiveRecord begin connection = postgresql_connection(config) rescue PG::ConnectionBad => e - fallback_handler.master = false + fallback_handler.master_down = true + fallback_handler.verify_master raise e end end @@ -141,20 +124,4 @@ module ActiveRecord raise "Replica database server is not in recovery mode." if value == 'f' end end - - module ConnectionAdapters - class PostgreSQLAdapter - set_callback :checkout, :before, :switch_back? - - private - - def fallback_handler - @fallback_handler ||= ::PostgreSQLFallbackHandler.instance - end - - def switch_back? - fallback_handler.verify_master if fallback_handler.verify? - end - end - end end diff --git a/lib/discourse.rb b/lib/discourse.rb index 1ac6e1b00d..9f27827f4c 100644 --- a/lib/discourse.rb +++ b/lib/discourse.rb @@ -228,10 +228,12 @@ module Discourse def self.keep_readonly_mode # extend the expiry by 1 minute every 30 seconds - Thread.new do - while readonly_mode? - $redis.expire(READONLY_MODE_KEY, READONLY_MODE_KEY_TTL) - sleep 30.seconds + unless Rails.env.test? + Thread.new do + while readonly_mode? + $redis.expire(READONLY_MODE_KEY, READONLY_MODE_KEY_TTL) + sleep 30.seconds + end end end end @@ -369,9 +371,11 @@ module Discourse end end + SIDEKIQ_NAMESPACE ||= 'sidekiq'.freeze + def self.sidekiq_redis_config conf = GlobalSetting.redis_config.dup - conf[:namespace] = 'sidekiq' + conf[:namespace] = SIDEKIQ_NAMESPACE conf end diff --git a/lib/discourse_featured_link.rb b/lib/discourse_featured_link.rb new file mode 100644 index 0000000000..304383e923 --- /dev/null +++ b/lib/discourse_featured_link.rb @@ -0,0 +1,27 @@ +module DiscourseFeaturedLink + CUSTOM_FIELD_NAME = 'featured_link'.freeze + + AdminDashboardData::GLOBAL_REPORTS << CUSTOM_FIELD_NAME + + Report.add_report(CUSTOM_FIELD_NAME) do |report| + report.data = [] + link_topics = TopicCustomField.where(name: CUSTOM_FIELD_NAME) + link_topics = link_topics.joins(:topic).where("topics.category_id = ?", report.category_id) if report.category_id + link_topics.where("topic_custom_fields.created_at >= ?", report.start_date) + .where("topic_custom_fields.created_at <= ?", report.end_date) + .group("DATE(topic_custom_fields.created_at)") + .order("DATE(topic_custom_fields.created_at)") + .count + .each { |date, count| report.data << { x: date, y: count } } + report.total = link_topics.count + report.prev30Days = link_topics.where("topic_custom_fields.created_at >= ?", report.start_date - 30.days) + .where("topic_custom_fields.created_at <= ?", report.start_date) + .count + end + + def self.cache_onebox_link(link) + # If the link is pasted swiftly, onebox may not have time to cache it + Oneboxer.onebox(link, invalidate_oneboxes: false) + link + end +end diff --git a/lib/email/receiver.rb b/lib/email/receiver.rb index faaf55fe46..b64c250957 100644 --- a/lib/email/receiver.rb +++ b/lib/email/receiver.rb @@ -244,14 +244,21 @@ module Email address_field.decoded from_address = address_field.address from_display_name = address_field.display_name.try(:to_s) - return [from_address.downcase, from_display_name] if from_address["@"] + return [from_address&.downcase, from_display_name&.strip] if from_address["@"] end end - from_address = mail.from[/<([^>]+)>/, 1] - from_display_name = mail.from[/^([^<]+)/, 1] + if mail.from[/<[^>]+>/] + from_address = mail.from[/<([^>]+)>/, 1] + from_display_name = mail.from[/^([^<]+)/, 1] + end - [from_address.downcase, from_display_name] + if (from_address.blank? || !from_address["@"]) && mail.from[/\[mailto:[^\]]+\]/] + from_address = mail.from[/\[mailto:([^\]]+)\]/, 1] + from_display_name = mail.from[/^([^\[]+)/, 1] + end + + [from_address&.downcase, from_display_name&.strip] end def subject @@ -376,6 +383,9 @@ module Email def process_forwarded_email(destination, user) embedded = Mail.new(@embedded_email_raw) email, display_name = parse_from_field(embedded) + + return false if email.blank? || !email["@"] + embedded_user = find_or_create_user(email, display_name) raw = try_to_encode(embedded.decoded, "UTF-8").presence || embedded.to_s title = embedded.subject.presence || subject @@ -387,6 +397,7 @@ module Email raw: raw, title: title, archetype: Archetype.private_message, + target_usernames: [user.username], target_group_names: [group.name], is_group_message: true, skip_validations: true, @@ -409,11 +420,14 @@ module Email end if post && post.topic && @before_embedded.present? + post_type = Post.types[:regular] + post_type = Post.types[:whisper] if post.topic.private_message? && group.usernames[user.username] + create_reply(user: user, raw: @before_embedded, post: post, topic: post.topic, - post_type: Post.types[:whisper]) + post_type: post_type) end true diff --git a/lib/email/styles.rb b/lib/email/styles.rb index cb20f3dca1..39adcf88cd 100644 --- a/lib/email/styles.rb +++ b/lib/email/styles.rb @@ -67,6 +67,11 @@ module Email add_styles(img, 'max-width: 100%;') if img['style'] !~ /max-width/ end + # topic featured link + @fragment.css('a.topic-featured-link').each do |e| + e['style'] = "color:#858585;padding:2px 8px;border:1px solid #e6e6e6;border-radius:2px;box-shadow:0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);" + end + # attachments @fragment.css('a.attachment').each do |a| # ensure all urls are absolute @@ -114,12 +119,12 @@ module Email style('blockquote > p', 'padding: 1em;') # Oneboxes - style('aside.onebox', "padding: 12px 25px 2px 12px; border-left: 5px solid #bebebe; background: #eee; margin-bottom: 10px;") - style('aside.onebox img', "max-height: 80%; max-width: 25%; height: auto; float: left; margin-right: 10px; margin-bottom: 10px") - style('aside.onebox h3', "border-bottom: 0") - style('aside.onebox .source', "margin-bottom: 8px") - style('aside.onebox .source a[href]', "color: #333; font-weight: normal") - style('aside.clearfix', "clear: both") + style('aside.onebox', "border: 5px solid #e9e9e9; padding: 12px 25px 12px 12px;") + style('aside.onebox header a[href]', "color: #222222; text-decoration: none;") + style('aside.onebox .onebox-body', "clear: both") + style('aside.onebox .onebox-body img', "max-height: 80%; max-width: 20%; height: auto; float: left; margin-right: 10px;") + style('aside.onebox .onebox-body h3, aside.onebox .onebox-body h4', "font-size: 1.17em; margin: 10px 0;") + style('.onebox-metadata', "color: #919191") # Finally, convert all `aside` tags to `div`s @fragment.css('aside, article, header').each do |n| diff --git a/lib/guardian/category_guardian.rb b/lib/guardian/category_guardian.rb index dbe5f86819..ccebde7dbf 100644 --- a/lib/guardian/category_guardian.rb +++ b/lib/guardian/category_guardian.rb @@ -68,4 +68,9 @@ module CategoryGuardian def topic_create_allowed_category_ids @topic_create_allowed_category_ids ||= @user.topic_create_allowed_category_ids end + + def topic_featured_link_allowed_category_ids + @topic_featured_link_allowed_category_ids = CategoryCustomField.where(name: "topic_featured_link_allowed", value: "true") + .pluck(:category_id) + end end diff --git a/lib/guardian/topic_guardian.rb b/lib/guardian/topic_guardian.rb index 892a8517d9..b107fc7e66 100644 --- a/lib/guardian/topic_guardian.rb +++ b/lib/guardian/topic_guardian.rb @@ -105,4 +105,9 @@ module TopicGuardian records end + def can_edit_featured_link?(category_id) + SiteSetting.topic_featured_link_enabled && + (topic_featured_link_allowed_category_ids.empty? || # no per category restrictions + category_id && topic_featured_link_allowed_category_ids.include?(category_id.to_i)) # category restriction exists + end end diff --git a/lib/middleware/anonymous_cache.rb b/lib/middleware/anonymous_cache.rb index a79b422566..149ac3f009 100644 --- a/lib/middleware/anonymous_cache.rb +++ b/lib/middleware/anonymous_cache.rb @@ -11,6 +11,7 @@ module Middleware class Helper USER_AGENT = "HTTP_USER_AGENT".freeze RACK_SESSION = "rack.session".freeze + ACCEPT_ENCODING = "HTTP_ACCEPT_ENCODING".freeze def initialize(env) @env = env @@ -35,6 +36,14 @@ module Middleware @is_mobile == :true end + def has_brotli? + @has_brotli ||= + begin + @env[ACCEPT_ENCODING].to_s =~ /br/ ? :true : :false + end + @has_brotli == :true + end + def is_crawler? @is_crawler ||= begin @@ -45,7 +54,7 @@ module Middleware end def cache_key - @cache_key ||= "ANON_CACHE_#{@env["HTTP_ACCEPT"]}_#{@env["HTTP_HOST"]}#{@env["REQUEST_URI"]}|m=#{is_mobile?}|c=#{is_crawler?}" + @cache_key ||= "ANON_CACHE_#{@env["HTTP_ACCEPT"]}_#{@env["HTTP_HOST"]}#{@env["REQUEST_URI"]}|m=#{is_mobile?}|c=#{is_crawler?}|b=#{has_brotli?}" end def cache_key_body diff --git a/lib/post_creator.rb b/lib/post_creator.rb index 0cd26aa33d..6a788cf5bd 100644 --- a/lib/post_creator.rb +++ b/lib/post_creator.rb @@ -5,6 +5,7 @@ require_dependency 'topic_creator' require_dependency 'post_jobs_enqueuer' require_dependency 'distributed_mutex' require_dependency 'has_errors' +require_dependency 'discourse_featured_link' class PostCreator include HasErrors @@ -103,6 +104,11 @@ class PostCreator end end + onebox_featured_link = SiteSetting.topic_featured_link_enabled && SiteSetting.topic_featured_link_onebox && guardian.can_edit_featured_link?(find_category_id) + if onebox_featured_link + @opts[:raw] = DiscourseFeaturedLink.cache_onebox_link(@opts[:featured_link]) + end + setup_post return true if skip_validations? @@ -116,7 +122,7 @@ class PostCreator DiscourseEvent.trigger :before_create_post, @post DiscourseEvent.trigger :validate_post, @post - post_validator = Validators::PostValidator.new(skip_topic: true) + post_validator = Validators::PostValidator.new(skip_topic: true, skip_post_body: onebox_featured_link) post_validator.validate(@post) valid = @post.errors.blank? @@ -146,6 +152,9 @@ class PostCreator end if @post && errors.blank? + # update counters etc. + @post.topic.reload + publish track_latest_on_category @@ -199,7 +208,9 @@ class PostCreator set_reply_info(post) post.word_count = post.raw.scan(/[[:word:]]+/).size - post.post_number ||= Topic.next_post_number(post.topic_id, post.reply_to_post_number.present?) + + whisper = post.post_type == Post.types[:whisper] + post.post_number ||= Topic.next_post_number(post.topic_id, post.reply_to_post_number.present?, whisper) cooking_options = post.cooking_options || {} cooking_options[:topic_id] = post.topic_id @@ -333,6 +344,18 @@ class PostCreator private + # TODO: merge the similar function in TopicCreator and fix parameter naming for `category` + def find_category_id + @opts.delete(:category) if @opts[:archetype].present? && @opts[:archetype] == Archetype.private_message + + category = if (@opts[:category].is_a? Integer) || (@opts[:category] =~ /^\d+$/) + Category.find_by(id: @opts[:category]) + else + Category.find_by(name_lower: @opts[:category].try(:downcase)) + end + category&.id + end + def create_topic return if @topic begin diff --git a/lib/post_jobs_enqueuer.rb b/lib/post_jobs_enqueuer.rb index c5f1f6a9d1..0e05c565f9 100644 --- a/lib/post_jobs_enqueuer.rb +++ b/lib/post_jobs_enqueuer.rb @@ -35,7 +35,7 @@ class PostJobsEnqueuer def after_post_create TopicTrackingState.publish_unread(@post) if @post.post_number > 1 - TopicTrackingState.publish_latest(@topic) + TopicTrackingState.publish_latest(@topic, @post.post_type == Post.types[:whisper]) Jobs.enqueue_in( SiteSetting.email_time_window_mins.minutes, diff --git a/lib/post_revisor.rb b/lib/post_revisor.rb index b9cccc9f9d..a5451affba 100644 --- a/lib/post_revisor.rb +++ b/lib/post_revisor.rb @@ -95,6 +95,23 @@ class PostRevisor end end + track_topic_field(:featured_link) do |topic_changes, featured_link| + if SiteSetting.topic_featured_link_enabled && + featured_link.present? && + topic_changes.guardian.can_edit_featured_link?(topic_changes.topic.category_id) + + topic_changes.record_change('featured_link', topic_changes.topic.featured_link, featured_link) + topic_changes.topic.featured_link = featured_link + + if SiteSetting.topic_featured_link_onebox + post = topic_changes.topic.first_post + post.raw = DiscourseFeaturedLink.cache_onebox_link(featured_link) + post.save! + post.rebake! + end + end + end + # AVAILABLE OPTIONS: # - revised_at: changes the date of the revision # - force_new_version: bypass ninja-edit window @@ -425,18 +442,14 @@ class PostRevisor def update_category_description return unless category = Category.find_by(topic_id: @topic.id) - body = @post.cooked - matches = body.scan(/\(.*)\<\/p\>/) + doc = Nokogiri::HTML.fragment(@post.cooked) + doc.css("img").remove - matches.each do |match| - next if match[0] =~ /\= ?", match.to_i) + end + advanced_filter(/in:first/) do |posts| posts.where("posts.post_number = 1") end diff --git a/lib/system_message.rb b/lib/system_message.rb index 98280b5e9c..d95cbf00be 100644 --- a/lib/system_message.rb +++ b/lib/system_message.rb @@ -33,7 +33,7 @@ class SystemMessage post = creator.create if creator.errors.present? - raise StandardError, creator.errors.to_s + raise StandardError, creator.errors.full_messages.join(" ") end UserArchivedMessage.create!(user: Discourse.site_contact_user, topic: post.topic) diff --git a/lib/tasks/qunit.rake b/lib/tasks/qunit.rake index 70ff6d5e45..b4e9e22887 100644 --- a/lib/tasks/qunit.rake +++ b/lib/tasks/qunit.rake @@ -1,6 +1,6 @@ desc "Runs the qunit test suite" -task "qunit:test" => :environment do +task "qunit:test", [:timeout] => :environment do |_, args| require "rack" require "socket" @@ -35,7 +35,7 @@ task "qunit:test" => :environment do begin success = true test_path = "#{Rails.root}/vendor/assets/javascripts" - cmd = "phantomjs #{test_path}/run-qunit.js http://localhost:#{port}/qunit" + cmd = "phantomjs #{test_path}/run-qunit.js http://localhost:#{port}/qunit #{args[:timeout]}" options = {} diff --git a/lib/tasks/redis.rake b/lib/tasks/redis.rake new file mode 100644 index 0000000000..4300f0686c --- /dev/null +++ b/lib/tasks/redis.rake @@ -0,0 +1,30 @@ +task 'redis:clean_up' => ['environment'] do + return unless Rails.configuration.multisite + + dbs = RailsMultisite::ConnectionManagement.all_dbs + dbs << Discourse::SIDEKIQ_NAMESPACE + + regexp = /((\$(?\w+)$)|(^?(?\w+):))/ + + cursor = 0 + redis = $redis.without_namespace + + loop do + cursor, keys = redis.scan(cursor) + cursor = cursor.to_i + + redis.multi do + keys.each do |key| + if match = key.match(regexp) + db_name = match[:message_bus] || match[:namespace] + + if !dbs.include?(db_name) + redis.del(key) + end + end + end + end + + break if cursor == 0 + end +end diff --git a/lib/topic_creator.rb b/lib/topic_creator.rb index 9e76327a5a..8c0621d507 100644 --- a/lib/topic_creator.rb +++ b/lib/topic_creator.rb @@ -124,6 +124,10 @@ class TopicCreator topic_params[:pinned_at] = Time.zone.parse(@opts[:pinned_at].to_s) if @opts[:pinned_at].present? topic_params[:pinned_globally] = @opts[:pinned_globally] if @opts[:pinned_globally].present? + if SiteSetting.topic_featured_link_enabled && @opts[:featured_link].present? && @guardian.can_edit_featured_link?(topic_params[:category_id]) + topic_params[:featured_link] = @opts[:featured_link] + end + topic_params end diff --git a/lib/topic_query.rb b/lib/topic_query.rb index f13cbbd8bc..3da879c6a3 100644 --- a/lib/topic_query.rb +++ b/lib/topic_query.rb @@ -242,9 +242,12 @@ class TopicQuery .where("COALESCE(tu.notification_level, :tracking) >= :tracking", tracking: TopicUser.notification_levels[:tracking]) end - def self.unread_filter(list) - list.where("tu.last_read_post_number < topics.highest_post_number") - .where("COALESCE(tu.notification_level, :regular) >= :tracking", regular: TopicUser.notification_levels[:regular], tracking: TopicUser.notification_levels[:tracking]) + def self.unread_filter(list, opts) + col_name = opts[:staff] ? "highest_staff_post_number" : "highest_post_number" + + list.where("tu.last_read_post_number < topics.#{col_name}") + .where("COALESCE(tu.notification_level, :regular) >= :tracking", + regular: TopicUser.notification_levels[:regular], tracking: TopicUser.notification_levels[:tracking]) end def prioritize_pinned_topics(topics, options) @@ -320,7 +323,7 @@ class TopicQuery end def unread_results(options={}) - result = TopicQuery.unread_filter(default_results(options.reverse_merge(:unordered => true))) + result = TopicQuery.unread_filter(default_results(options.reverse_merge(:unordered => true)), staff: @user.try(:staff?)) .order('CASE WHEN topics.user_id = tu.user_id THEN 1 ELSE 2 END') self.class.results_filter_callbacks.each do |filter_callback| @@ -656,7 +659,7 @@ class TopicQuery end def unread_messages(params) - TopicQuery.unread_filter(messages_for_groups_or_user(params[:my_group_ids])) + TopicQuery.unread_filter(messages_for_groups_or_user(params[:my_group_ids]), staff: @user.try(:staff?)) .limit(params[:count]) end diff --git a/lib/unread.rb b/lib/unread.rb index a5a062d319..f04ffcd228 100644 --- a/lib/unread.rb +++ b/lib/unread.rb @@ -2,7 +2,8 @@ class Unread # This module helps us calculate unread and new post counts - def initialize(topic, topic_user) + def initialize(topic, topic_user, guardian) + @guardian = guardian @topic = topic @topic_user = topic_user end @@ -18,9 +19,12 @@ class Unread def new_posts return 0 if @topic_user.highest_seen_post_number.blank? return 0 if do_not_notify?(@topic_user.notification_level) - return 0 if (@topic_user.last_read_post_number||0) > @topic.highest_post_number - new_posts = (@topic.highest_post_number - @topic_user.highest_seen_post_number) + highest_post_number = @guardian.is_staff? ? @topic.highest_staff_post_number : @topic.highest_post_number + + return 0 if (@topic_user.last_read_post_number||0) > highest_post_number + + new_posts = (highest_post_number - @topic_user.highest_seen_post_number) new_posts = 0 if new_posts < 0 return new_posts end diff --git a/lib/validators/post_validator.rb b/lib/validators/post_validator.rb index 32f6063596..30e68bafad 100644 --- a/lib/validators/post_validator.rb +++ b/lib/validators/post_validator.rb @@ -10,8 +10,7 @@ class Validators::PostValidator < ActiveModel::Validator return if record.acting_user.try(:staged?) return if record.acting_user.try(:admin?) && Discourse.static_doc_topic_ids.include?(record.topic_id) - stripped_length(record) - raw_quality(record) + post_body_validator(record) max_posts_validator(record) max_mention_validator(record) max_images_validator(record) @@ -21,8 +20,6 @@ class Validators::PostValidator < ActiveModel::Validator end def presence(post) - post.errors.add(:raw, :blank, options) if post.raw.blank? - unless options[:skip_topic] post.errors.add(:topic_id, :blank, options) if post.topic_id.blank? end @@ -32,6 +29,12 @@ class Validators::PostValidator < ActiveModel::Validator end end + def post_body_validator(post) + return if options[:skip_post_body] + stripped_length(post) + raw_quality(post) + end + def stripped_length(post) range = if private_message?(post) # private message diff --git a/lib/version.rb b/lib/version.rb index 430e4776fb..11ffbe5033 100644 --- a/lib/version.rb +++ b/lib/version.rb @@ -5,7 +5,7 @@ module Discourse MAJOR = 1 MINOR = 7 TINY = 0 - PRE = 'beta8' + PRE = 'beta9' STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.') end diff --git a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 index 9631c3ad99..77a25aaf03 100644 --- a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 +++ b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 @@ -88,8 +88,16 @@ function initializePolls(api) { votes[pollName] ); + // Destroy a poll view if we're replacing it + if (_pollViews && _pollViews[pollId]) { + _pollViews[pollId].destroy(); + } + $poll.replaceWith($div); - Em.run.schedule('afterRender', () => pollComponent.renderer.replaceIn(pollComponent, $div[0])); + Ember.run.scheduleOnce('afterRender', () => { + pollComponent.renderer.appendTo(pollComponent, $div[0]); + }); + postPollViews[pollId] = pollComponent; }); diff --git a/plugins/poll/assets/javascripts/lib/md5.js.es6 b/plugins/poll/assets/javascripts/lib/md5.js.es6 deleted file mode 100644 index 8b13789179..0000000000 --- a/plugins/poll/assets/javascripts/lib/md5.js.es6 +++ /dev/null @@ -1 +0,0 @@ - diff --git a/plugins/poll/test/javascripts/acceptance/polls-test.js.es6 b/plugins/poll/test/javascripts/acceptance/polls-test.js.es6 new file mode 100644 index 0000000000..401838b610 --- /dev/null +++ b/plugins/poll/test/javascripts/acceptance/polls-test.js.es6 @@ -0,0 +1,32 @@ +import { acceptance, controllerFor } from "helpers/qunit-helpers"; +import PostCooked from 'discourse/widgets/post-cooked'; + +acceptance("Rendering polls", { + loggedIn: true, + settings: { poll_enabled: true }, + setup() { + const response = object => { + return [ + 200, + { "Content-Type": "application/json" }, + object + ]; + } + + server.get('/t/13.json', () => { + return response({"post_stream":{"posts":[{"id":19,"name":null,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png","created_at":"2016-12-01T02:39:49.199Z","cooked":"
    \n
    \n
      \n
    • test
    • \n
    • haha
    • \n
    \n

    0voters

    \n
    \n\n
    \n\n
    \n
    \n
      \n
    • donkey
    • \n
    • kong
    • \n
    \n

    0voters

    \n
    \n\n
    ","post_number":1,"post_type":1,"updated_at":"2016-12-01T02:47:18.317Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":13,"topic_slug":"this-is-a-test-topic-for-polls","display_username":null,"primary_group_name":null,"primary_group_flair_url":null,"primary_group_flair_bg_color":null,"primary_group_flair_color":null,"version":2,"can_edit":true,"can_delete":false,"can_recover":true,"can_wiki":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false,"polls":{"poll":{"options":[{"id":"57ddd734344eb7436d64a7d68a0df444","html":"test","votes":0},{"id":"b5b78d79ab5b5d75d4d33d8b87f5d2aa","html":"haha","votes":0}],"voters":2,"status":"open","name":"poll"},"test":{"options":[{"id":"c26ad90783b0d80936e5fdb292b7963c","html":"donkey","votes":0},{"id":"99f2b9ac452ba73b115fcf3556e6d2d4","html":"kong","votes":0}],"voters":3,"status":"open","name":"test"}}}],"stream":[19]},"timeline_lookup":[[1,0]],"id":13,"title":"This is a test topic for polls","fancy_title":"This is a test topic for polls","posts_count":1,"created_at":"2016-12-01T02:39:48.055Z","views":1,"reply_count":0,"participant_count":1,"like_count":0,"last_posted_at":"2016-12-01T02:39:49.199Z","visible":true,"closed":false,"archived":false,"has_summary":false,"archetype":"regular","slug":"this-is-a-test-topic-for-polls","category_id":1,"word_count":10,"deleted_at":null,"user_id":1,"draft":null,"draft_key":"topic_13","draft_sequence":4,"posted":true,"unpinned":null,"pinned_globally":false,"pinned":false,"pinned_at":null,"pinned_until":null,"details":{"auto_close_at":null,"auto_close_hours":null,"auto_close_based_on_last_post":false,"created_by":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"},"last_poster":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"},"participants":[{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png","post_count":1}],"suggested_topics":[{"id":8,"title":"Welcome to Discourse","fancy_title":"Welcome to Discourse","slug":"welcome-to-discourse","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2016-11-24T02:10:54.328Z","last_posted_at":"2016-11-24T02:10:54.393Z","bumped":true,"bumped_at":"2016-11-24T02:10:54.393Z","unseen":false,"pinned":true,"unpinned":null,"excerpt":"The first paragraph of this pinned topic will be visible as a welcome message to all new visitors on your homepage. It's important! \n\nEdit this into a brief description of your community: \n\n\nWho is it for?\nWhat can they …","visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":0,"views":0,"category_id":1,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user":{"id":-1,"username":"system","avatar_template":"/letter_avatar_proxy/v2/letter/s/bcef8e/{size}.png"}}]},{"id":12,"title":"Some testing topic testing","fancy_title":"Some testing topic testing","slug":"some-testing-topic-testing","posts_count":4,"reply_count":0,"highest_post_number":4,"image_url":null,"created_at":"2016-11-24T08:36:08.773Z","last_posted_at":"2016-12-01T01:15:52.008Z","bumped":true,"bumped_at":"2016-12-01T01:15:52.008Z","unseen":false,"last_read_post_number":4,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":3,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":2,"category_id":1,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"}}]},{"id":11,"title":"Some testing topic","fancy_title":"Some testing topic","slug":"some-testing-topic","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2016-11-24T08:35:26.758Z","last_posted_at":"2016-11-24T08:35:26.894Z","bumped":true,"bumped_at":"2016-11-24T08:35:26.894Z","unseen":false,"last_read_post_number":1,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":3,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":0,"category_id":1,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"}}]}],"notification_level":3,"notifications_reason_id":1,"can_move_posts":true,"can_edit":true,"can_delete":true,"can_recover":true,"can_remove_allowed_users":true,"can_invite_to":true,"can_create_post":true,"can_reply_as_new_topic":true,"can_flag_topic":true},"highest_post_number":1,"last_read_post_number":1,"last_read_post_id":19,"deleted_by":null,"has_deleted":false,"actions_summary":[{"id":4,"count":0,"hidden":false,"can_act":true},{"id":7,"count":0,"hidden":false,"can_act":true},{"id":8,"count":0,"hidden":false,"can_act":true}],"chunk_size":20,"bookmarked":false}); + }); + } +}); + +test("Single Poll", () => { + visit("/t/this-is-a-test-topic-for-polls/13"); + + andThen(() => { + const polls = find('.poll'); + + equal(polls.length, 2, 'it should render the polls correctly'); + equal(find('.info-number', polls[0]).text(), '2', 'it should display the right number of votes'); + equal(find('.info-number', polls[1]).text(), '3', 'it should display the right number of votes'); + }); +}); diff --git a/public/images/emoji/apple/eight.png b/public/images/emoji/apple/eight.png index e69de29bb2..ba8ed5cf21 100644 Binary files a/public/images/emoji/apple/eight.png and b/public/images/emoji/apple/eight.png differ diff --git a/public/images/emoji/apple/five.png b/public/images/emoji/apple/five.png index e69de29bb2..97fcc34bf7 100644 Binary files a/public/images/emoji/apple/five.png and b/public/images/emoji/apple/five.png differ diff --git a/public/images/emoji/apple/four.png b/public/images/emoji/apple/four.png index e69de29bb2..3ed87090ed 100644 Binary files a/public/images/emoji/apple/four.png and b/public/images/emoji/apple/four.png differ diff --git a/public/images/emoji/apple/nine.png b/public/images/emoji/apple/nine.png index e69de29bb2..52c6c63dcf 100644 Binary files a/public/images/emoji/apple/nine.png and b/public/images/emoji/apple/nine.png differ diff --git a/public/images/emoji/apple/one.png b/public/images/emoji/apple/one.png index e69de29bb2..fbd9bcf592 100644 Binary files a/public/images/emoji/apple/one.png and b/public/images/emoji/apple/one.png differ diff --git a/public/images/emoji/apple/seven.png b/public/images/emoji/apple/seven.png index e69de29bb2..0c675d1252 100644 Binary files a/public/images/emoji/apple/seven.png and b/public/images/emoji/apple/seven.png differ diff --git a/public/images/emoji/apple/six.png b/public/images/emoji/apple/six.png index e69de29bb2..0c7618b6ca 100644 Binary files a/public/images/emoji/apple/six.png and b/public/images/emoji/apple/six.png differ diff --git a/public/images/emoji/apple/three.png b/public/images/emoji/apple/three.png index e69de29bb2..6a0af40ae7 100644 Binary files a/public/images/emoji/apple/three.png and b/public/images/emoji/apple/three.png differ diff --git a/public/images/emoji/apple/two.png b/public/images/emoji/apple/two.png index e69de29bb2..17e3962e6f 100644 Binary files a/public/images/emoji/apple/two.png and b/public/images/emoji/apple/two.png differ diff --git a/public/images/emoji/emoji_one/eight.png b/public/images/emoji/emoji_one/eight.png index e69de29bb2..3b8024f65c 100644 Binary files a/public/images/emoji/emoji_one/eight.png and b/public/images/emoji/emoji_one/eight.png differ diff --git a/public/images/emoji/emoji_one/five.png b/public/images/emoji/emoji_one/five.png index e69de29bb2..a4ccd7fd73 100644 Binary files a/public/images/emoji/emoji_one/five.png and b/public/images/emoji/emoji_one/five.png differ diff --git a/public/images/emoji/emoji_one/four.png b/public/images/emoji/emoji_one/four.png index e69de29bb2..15f5cf1977 100644 Binary files a/public/images/emoji/emoji_one/four.png and b/public/images/emoji/emoji_one/four.png differ diff --git a/public/images/emoji/emoji_one/nine.png b/public/images/emoji/emoji_one/nine.png index e69de29bb2..61af7b1553 100644 Binary files a/public/images/emoji/emoji_one/nine.png and b/public/images/emoji/emoji_one/nine.png differ diff --git a/public/images/emoji/emoji_one/one.png b/public/images/emoji/emoji_one/one.png index e69de29bb2..2e02f0dc59 100644 Binary files a/public/images/emoji/emoji_one/one.png and b/public/images/emoji/emoji_one/one.png differ diff --git a/public/images/emoji/emoji_one/seven.png b/public/images/emoji/emoji_one/seven.png index e69de29bb2..082c0c853f 100644 Binary files a/public/images/emoji/emoji_one/seven.png and b/public/images/emoji/emoji_one/seven.png differ diff --git a/public/images/emoji/emoji_one/six.png b/public/images/emoji/emoji_one/six.png index e69de29bb2..e63c0ca97f 100644 Binary files a/public/images/emoji/emoji_one/six.png and b/public/images/emoji/emoji_one/six.png differ diff --git a/public/images/emoji/emoji_one/three.png b/public/images/emoji/emoji_one/three.png index e69de29bb2..f0ba2b4db5 100644 Binary files a/public/images/emoji/emoji_one/three.png and b/public/images/emoji/emoji_one/three.png differ diff --git a/public/images/emoji/emoji_one/two.png b/public/images/emoji/emoji_one/two.png index e69de29bb2..0f8b3c87e8 100644 Binary files a/public/images/emoji/emoji_one/two.png and b/public/images/emoji/emoji_one/two.png differ diff --git a/public/images/emoji/google/eight.png b/public/images/emoji/google/eight.png index e69de29bb2..af151748ca 100644 Binary files a/public/images/emoji/google/eight.png and b/public/images/emoji/google/eight.png differ diff --git a/public/images/emoji/google/five.png b/public/images/emoji/google/five.png index e69de29bb2..2ea1be6607 100644 Binary files a/public/images/emoji/google/five.png and b/public/images/emoji/google/five.png differ diff --git a/public/images/emoji/google/four.png b/public/images/emoji/google/four.png index e69de29bb2..32d830771a 100644 Binary files a/public/images/emoji/google/four.png and b/public/images/emoji/google/four.png differ diff --git a/public/images/emoji/google/nine.png b/public/images/emoji/google/nine.png index e69de29bb2..589d508d5a 100644 Binary files a/public/images/emoji/google/nine.png and b/public/images/emoji/google/nine.png differ diff --git a/public/images/emoji/google/one.png b/public/images/emoji/google/one.png index e69de29bb2..fd231c9dcc 100644 Binary files a/public/images/emoji/google/one.png and b/public/images/emoji/google/one.png differ diff --git a/public/images/emoji/google/seven.png b/public/images/emoji/google/seven.png index e69de29bb2..8a1fe0417c 100644 Binary files a/public/images/emoji/google/seven.png and b/public/images/emoji/google/seven.png differ diff --git a/public/images/emoji/google/six.png b/public/images/emoji/google/six.png index e69de29bb2..2cf9e489f3 100644 Binary files a/public/images/emoji/google/six.png and b/public/images/emoji/google/six.png differ diff --git a/public/images/emoji/google/three.png b/public/images/emoji/google/three.png index e69de29bb2..600c2b652e 100644 Binary files a/public/images/emoji/google/three.png and b/public/images/emoji/google/three.png differ diff --git a/public/images/emoji/google/two.png b/public/images/emoji/google/two.png index e69de29bb2..1de23db789 100644 Binary files a/public/images/emoji/google/two.png and b/public/images/emoji/google/two.png differ diff --git a/public/images/emoji/twitter/eight.png b/public/images/emoji/twitter/eight.png index e69de29bb2..90f774c392 100644 Binary files a/public/images/emoji/twitter/eight.png and b/public/images/emoji/twitter/eight.png differ diff --git a/public/images/emoji/twitter/five.png b/public/images/emoji/twitter/five.png index e69de29bb2..75a85c48ad 100644 Binary files a/public/images/emoji/twitter/five.png and b/public/images/emoji/twitter/five.png differ diff --git a/public/images/emoji/twitter/four.png b/public/images/emoji/twitter/four.png index e69de29bb2..77de1b164b 100644 Binary files a/public/images/emoji/twitter/four.png and b/public/images/emoji/twitter/four.png differ diff --git a/public/images/emoji/twitter/nine.png b/public/images/emoji/twitter/nine.png index e69de29bb2..1e425523f8 100644 Binary files a/public/images/emoji/twitter/nine.png and b/public/images/emoji/twitter/nine.png differ diff --git a/public/images/emoji/twitter/one.png b/public/images/emoji/twitter/one.png index e69de29bb2..9369fcc06b 100644 Binary files a/public/images/emoji/twitter/one.png and b/public/images/emoji/twitter/one.png differ diff --git a/public/images/emoji/twitter/seven.png b/public/images/emoji/twitter/seven.png index e69de29bb2..6547329774 100644 Binary files a/public/images/emoji/twitter/seven.png and b/public/images/emoji/twitter/seven.png differ diff --git a/public/images/emoji/twitter/six.png b/public/images/emoji/twitter/six.png index e69de29bb2..990c260628 100644 Binary files a/public/images/emoji/twitter/six.png and b/public/images/emoji/twitter/six.png differ diff --git a/public/images/emoji/twitter/three.png b/public/images/emoji/twitter/three.png index e69de29bb2..cdd7ca9db3 100644 Binary files a/public/images/emoji/twitter/three.png and b/public/images/emoji/twitter/three.png differ diff --git a/public/images/emoji/twitter/two.png b/public/images/emoji/twitter/two.png index e69de29bb2..3ca9cd0232 100644 Binary files a/public/images/emoji/twitter/two.png and b/public/images/emoji/twitter/two.png differ diff --git a/public/images/emoji/win10/eight.png b/public/images/emoji/win10/eight.png index e69de29bb2..941ddf7da0 100644 Binary files a/public/images/emoji/win10/eight.png and b/public/images/emoji/win10/eight.png differ diff --git a/public/images/emoji/win10/five.png b/public/images/emoji/win10/five.png index e69de29bb2..f8b21706da 100644 Binary files a/public/images/emoji/win10/five.png and b/public/images/emoji/win10/five.png differ diff --git a/public/images/emoji/win10/four.png b/public/images/emoji/win10/four.png index e69de29bb2..3a22419dc5 100644 Binary files a/public/images/emoji/win10/four.png and b/public/images/emoji/win10/four.png differ diff --git a/public/images/emoji/win10/nine.png b/public/images/emoji/win10/nine.png index e69de29bb2..655f80a7f9 100644 Binary files a/public/images/emoji/win10/nine.png and b/public/images/emoji/win10/nine.png differ diff --git a/public/images/emoji/win10/one.png b/public/images/emoji/win10/one.png index e69de29bb2..eba530a82c 100644 Binary files a/public/images/emoji/win10/one.png and b/public/images/emoji/win10/one.png differ diff --git a/public/images/emoji/win10/seven.png b/public/images/emoji/win10/seven.png index e69de29bb2..c1329dd773 100644 Binary files a/public/images/emoji/win10/seven.png and b/public/images/emoji/win10/seven.png differ diff --git a/public/images/emoji/win10/six.png b/public/images/emoji/win10/six.png index e69de29bb2..8e4b23b3ba 100644 Binary files a/public/images/emoji/win10/six.png and b/public/images/emoji/win10/six.png differ diff --git a/public/images/emoji/win10/three.png b/public/images/emoji/win10/three.png index e69de29bb2..bf930925c4 100644 Binary files a/public/images/emoji/win10/three.png and b/public/images/emoji/win10/three.png differ diff --git a/public/images/emoji/win10/two.png b/public/images/emoji/win10/two.png index e69de29bb2..5a7e481947 100644 Binary files a/public/images/emoji/win10/two.png and b/public/images/emoji/win10/two.png differ diff --git a/script/import_scripts/vbulletin.rb b/script/import_scripts/vbulletin.rb index 90b943b71e..8ff7c0340a 100644 --- a/script/import_scripts/vbulletin.rb +++ b/script/import_scripts/vbulletin.rb @@ -31,6 +31,7 @@ class ImportScripts::VBulletin < ImportScripts::Base def execute import_groups import_users + create_groups_membership import_categories import_topics import_posts @@ -40,7 +41,7 @@ class ImportScripts::VBulletin < ImportScripts::Base close_topics post_process_posts - create_permalinks + create_permalink_file suspend_users end @@ -89,7 +90,7 @@ class ImportScripts::VBulletin < ImportScripts::Base email: user["email"].presence || fake_email, website: user["homepage"].strip, title: @htmlentities.decode(user["usertitle"]).strip, - primary_group_id: group_id_from_imported_group_id(user["usergroupid"]), + primary_group_id: group_id_from_imported_group_id(user["usergroupid"].to_i), created_at: parse_timestamp(user["joindate"]), last_seen_at: parse_timestamp(user["lastvisit"]), post_create_action: proc do |u| @@ -102,6 +103,32 @@ class ImportScripts::VBulletin < ImportScripts::Base end end + def create_groups_membership + puts "", "Creating groups membership..." + + Group.find_each do |group| + begin + next if group.automatic + puts "\t#{group.name}" + next if GroupUser.where(group_id: group.id).count > 0 + user_ids_in_group = User.where(primary_group_id: group.id).pluck(:id).to_a + next if user_ids_in_group.size == 0 + values = user_ids_in_group.map { |user_id| "(#{group.id}, #{user_id}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" }.join(",") + + User.exec_sql <<-SQL + BEGIN; + INSERT INTO group_users (group_id, user_id, created_at, updated_at) VALUES #{values}; + COMMIT; + SQL + + Group.reset_counters(group.id, :group_users) + rescue Exception => e + puts e.message + puts e.backtrace.join("\n") + end + end + end + def import_profile_picture(old_user, imported_user) query = mysql_query <<-SQL SELECT filedata, filename @@ -163,9 +190,9 @@ class ImportScripts::VBulletin < ImportScripts::Base categories = mysql_query("SELECT forumid, title, description, displayorder, parentid FROM #{TABLE_PREFIX}forum ORDER BY forumid").to_a - # top_level_categories = categories.select { |c| c["parentid"] == -1 } + top_level_categories = categories.select { |c| c["parentid"] == -1 } - create_categories(categories) do |category| + create_categories(top_level_categories) do |category| { id: category["forumid"], name: @htmlentities.decode(category["title"]).strip, @@ -174,27 +201,27 @@ class ImportScripts::VBulletin < ImportScripts::Base } end - # puts "", "importing children categories..." - # - # children_categories = categories.select { |c| c["parentid"] != -1 } - # top_level_category_ids = Set.new(top_level_categories.map { |c| c["forumid"] }) - # - # # cut down the tree to only 2 levels of categories - # children_categories.each do |cc| - # while !top_level_category_ids.include?(cc["parentid"]) - # cc["parentid"] = categories.detect { |c| c["forumid"] == cc["parentid"] }["parentid"] - # end - # end - # - # create_categories(children_categories) do |category| - # { - # id: category["forumid"], - # name: @htmlentities.decode(category["title"]).strip, - # position: category["displayorder"], - # description: @htmlentities.decode(category["description"]).strip, - # parent_category_id: category_id_from_imported_category_id(category["parentid"]) - # } - # end + puts "", "importing children categories..." + + children_categories = categories.select { |c| c["parentid"] != -1 } + top_level_category_ids = Set.new(top_level_categories.map { |c| c["forumid"] }) + + # cut down the tree to only 2 levels of categories + children_categories.each do |cc| + while !top_level_category_ids.include?(cc["parentid"]) + cc["parentid"] = categories.detect { |c| c["forumid"] == cc["parentid"] }["parentid"] + end + end + + create_categories(children_categories) do |category| + { + id: category["forumid"], + name: @htmlentities.decode(category["title"]).strip, + position: category["displayorder"], + description: @htmlentities.decode(category["description"]).strip, + parent_category_id: category_id_from_imported_category_id(category["parentid"]) + } + end end def import_topics @@ -223,7 +250,7 @@ class ImportScripts::VBulletin < ImportScripts::Base raw = preprocess_post_raw(topic["raw"]) rescue nil next if raw.blank? topic_id = "thread-#{topic["threadid"]}" - @closed_topic_ids << topic_id if topic["open"] == "0" + @closed_topic_ids << topic_id if topic["open"] == 0 t = { id: topic_id, user_id: user_id_from_imported_user_id(topic["postuserid"]) || Discourse::SYSTEM_USER_ID, @@ -237,6 +264,18 @@ class ImportScripts::VBulletin < ImportScripts::Base t[:pinned_at] = t[:created_at] if topic["sticky"].to_i == 1 t end + + # uncomment below lines to create permalink + # topics.each do |thread| + # topic_id = "thread-#{thread["threadid"]}" + # topic = topic_lookup_from_imported_post_id(topic_id) + # if topic.present? + # title_slugified = thread["title"].gsub(" ","-").gsub(".","-") if thread["title"].present? + # url_slug = "threads/#{thread["threadid"]}-#{title_slugified}" if thread["title"].present? + # Permalink.create(url: url_slug, topic_id: topic[:topic_id].to_i) if url_slug.present? && topic[:topic_id].present? + # end + # end + end end @@ -244,7 +283,11 @@ class ImportScripts::VBulletin < ImportScripts::Base puts "", "importing posts..." # make sure `firstpostid` is indexed - mysql_query("CREATE INDEX firstpostid_index ON #{TABLE_PREFIX}thread (firstpostid)") + begin + mysql_query("CREATE INDEX firstpostid_index ON #{TABLE_PREFIX}thread (firstpostid)") + rescue Mysql2::Error + puts 'Index already exists' + end post_count = mysql_query("SELECT COUNT(postid) count FROM #{TABLE_PREFIX}post WHERE postid NOT IN (SELECT firstpostid FROM #{TABLE_PREFIX}thread)").first["count"] @@ -469,15 +512,15 @@ class ImportScripts::VBulletin < ImportScripts::Base sql = <<-SQL WITH closed_topic_ids AS ( SELECT t.id AS topic_id - FROM #{TABLE_PREFIX}post_custom_fields pcf - JOIN #{TABLE_PREFIX}posts p ON p.id = pcf.post_id - JOIN #{TABLE_PREFIX}topics t ON t.id = p.topic_id + FROM post_custom_fields pcf + JOIN posts p ON p.id = pcf.post_id + JOIN topics t ON t.id = p.topic_id WHERE pcf.name = 'import_id' AND pcf.value IN (?) ) UPDATE topics SET closed = true - WHERE id IN (SELECT topic_id FROM #{TABLE_PREFIX}closed_topic_ids) + WHERE id IN (SELECT topic_id FROM closed_topic_ids) SQL Topic.exec_sql(sql, @closed_topic_ids) @@ -511,39 +554,39 @@ class ImportScripts::VBulletin < ImportScripts::Base raw = @htmlentities.decode(raw) # fix whitespaces - raw = raw.gsub(/(\\r)?\\n/, "\n") - .gsub("\\t", "\t") + raw.gsub!(/(\\r)?\\n/, "\n") + raw.gsub!("\\t", "\t") # [HTML]...[/HTML] - raw = raw.gsub(/\[html\]/i, "\n```html\n") - .gsub(/\[\/html\]/i, "\n```\n") + raw.gsub!(/\[html\]/i, "\n```html\n") + raw.gsub!(/\[\/html\]/i, "\n```\n") # [PHP]...[/PHP] - raw = raw.gsub(/\[php\]/i, "\n```php\n") - .gsub(/\[\/php\]/i, "\n```\n") + raw.gsub!(/\[php\]/i, "\n```php\n") + raw.gsub!(/\[\/php\]/i, "\n```\n") # [HIGHLIGHT="..."] - raw = raw.gsub(/\[highlight="?(\w+)"?\]/i) { "\n```#{$1.downcase}\n" } + raw.gsub!(/\[highlight="?(\w+)"?\]/i) { "\n```#{$1.downcase}\n" } # [CODE]...[/CODE] # [HIGHLIGHT]...[/HIGHLIGHT] - raw = raw.gsub(/\[\/?code\]/i, "\n```\n") - .gsub(/\[\/?highlight\]/i, "\n```\n") + raw.gsub!(/\[\/?code\]/i, "\n```\n") + raw.gsub!(/\[\/?highlight\]/i, "\n```\n") # [SAMP]...[/SAMP] - raw = raw.gsub(/\[\/?samp\]/i, "`") + raw.gsub!(/\[\/?samp\]/i, "`") # replace all chevrons with HTML entities # NOTE: must be done # - AFTER all the "code" processing # - BEFORE the "quote" processing - raw = raw.gsub(/`([^`]+)`/im) { "`" + $1.gsub("<", "\u2603") + "`" } - .gsub("<", "<") - .gsub("\u2603", "<") + raw.gsub!(/`([^`]+)`/im) { "`" + $1.gsub("<", "\u2603") + "`" } + raw.gsub!("<", "<") + raw.gsub!("\u2603", "<") - raw = raw.gsub(/`([^`]+)`/im) { "`" + $1.gsub(">", "\u2603") + "`" } - .gsub(">", ">") - .gsub("\u2603", ">") + raw.gsub!(/`([^`]+)`/im) { "`" + $1.gsub(">", "\u2603") + "`" } + raw.gsub!(">", ">") + raw.gsub!("\u2603", ">") # [URL=...]...[/URL] raw.gsub!(/\[url="?([^"]+?)"?\](.*?)\[\/url\]/im) { "[#{$2.strip}](#{$1})" } @@ -551,11 +594,11 @@ class ImportScripts::VBulletin < ImportScripts::Base # [URL]...[/URL] # [MP3]...[/MP3] - raw = raw.gsub(/\[\/?url\]/i, "") - .gsub(/\[\/?mp3\]/i, "") + raw.gsub!(/\[\/?url\]/i, "") + raw.gsub!(/\[\/?mp3\]/i, "") # [MENTION][/MENTION] - raw = raw.gsub(/\[mention\](.+?)\[\/mention\]/i) do + raw.gsub!(/\[mention\](.+?)\[\/mention\]/i) do old_username = $1 if @old_username_to_new_usernames.has_key?(old_username) old_username = @old_username_to_new_usernames[old_username] @@ -563,6 +606,24 @@ class ImportScripts::VBulletin < ImportScripts::Base "@#{old_username}" end + # [FONT=blah] and [COLOR=blah] + raw.gsub! /\[FONT=.*?\](.*?)\[\/FONT\]/im, '\1' + raw.gsub! /\[COLOR=.*?\](.*?)\[\/COLOR\]/im, '\1' + raw.gsub! /\[COLOR=#.*?\](.*?)\[\/COLOR\]/im, '\1' + + raw.gsub! /\[SIZE=.*?\](.*?)\[\/SIZE\]/im, '\1' + raw.gsub! /\[h=.*?\](.*?)\[\/h\]/im, '\1' + + # [CENTER]...[/CENTER] + raw.gsub! /\[CENTER\](.*?)\[\/CENTER\]/im, '\1' + + # [INDENT]...[/INDENT] + raw.gsub! /\[INDENT\](.*?)\[\/INDENT\]/im, '\1' + raw.gsub! /\[TABLE\](.*?)\[\/TABLE\]/im, '\1' + raw.gsub! /\[TR\](.*?)\[\/TR\]/im, '\1' + raw.gsub! /\[TD\](.*?)\[\/TD\]/im, '\1' + raw.gsub! /\[TD="?.*?"?\](.*?)\[\/TD\]/im, '\1' + # [QUOTE]...[/QUOTE] raw.gsub!(/\[quote\](.+?)\[\/quote\]/im) { |quote| quote.gsub!(/\[quote\](.+?)\[\/quote\]/im) { "\n#{$1}\n" } @@ -570,7 +631,7 @@ class ImportScripts::VBulletin < ImportScripts::Base } # [QUOTE=]...[/QUOTE] - raw = raw.gsub(/\[quote=([^;\]]+)\](.+?)\[\/quote\]/im) do + raw.gsub!(/\[quote=([^;\]]+)\](.+?)\[\/quote\]/im) do old_username, quote = $1, $2 if @old_username_to_new_usernames.has_key?(old_username) old_username = @old_username_to_new_usernames[old_username] @@ -579,10 +640,10 @@ class ImportScripts::VBulletin < ImportScripts::Base end # [YOUTUBE][/YOUTUBE] - raw = raw.gsub(/\[youtube\](.+?)\[\/youtube\]/i) { "\n//youtu.be/#{$1}\n" } + raw.gsub!(/\[youtube\](.+?)\[\/youtube\]/i) { "\n//youtu.be/#{$1}\n" } # [VIDEO=youtube;]...[/VIDEO] - raw = raw.gsub(/\[video=youtube;([^\]]+)\].*?\[\/video\]/i) { "\n//youtu.be/#{$1}\n" } + raw.gsub!(/\[video=youtube;([^\]]+)\].*?\[\/video\]/i) { "\n//youtu.be/#{$1}\n" } # More Additions .... @@ -603,14 +664,14 @@ class ImportScripts::VBulletin < ImportScripts::Base raw.gsub!(/\[\*\]\n/, '') raw.gsub!(/\[\*\](.*?)\[\/\*:m\]/, '[li]\1[/li]') raw.gsub!(/\[\*\](.*?)\n/, '[li]\1[/li]') - + raw.gsub!(/\[\*=1\]/, '') raw end def postprocess_post_raw(raw) # [QUOTE=;]...[/QUOTE] - raw = raw.gsub(/\[quote=([^;]+);(\d+)\](.+?)\[\/quote\]/im) do + raw.gsub!(/\[quote=([^;]+);(\d+)\](.+?)\[\/quote\]/im) do old_username, post_id, quote = $1, $2, $3 if @old_username_to_new_usernames.has_key?(old_username) @@ -627,11 +688,11 @@ class ImportScripts::VBulletin < ImportScripts::Base end # remove attachments - raw = raw.gsub(/\[attach[^\]]*\]\d+\[\/attach\]/i, "") + raw.gsub!(/\[attach[^\]]*\]\d+\[\/attach\]/i, "") # [THREAD][/THREAD] # ==> http://my.discourse.org/t/slug/ - raw = raw.gsub(/\[thread\](\d+)\[\/thread\]/i) do + raw.gsub!(/\[thread\](\d+)\[\/thread\]/i) do thread_id = $1 if topic_lookup = topic_lookup_from_imported_post_id("thread-#{thread_id}") topic_lookup[:url] @@ -642,7 +703,7 @@ class ImportScripts::VBulletin < ImportScripts::Base # [THREAD=]...[/THREAD] # ==> [...](http://my.discourse.org/t/slug/) - raw = raw.gsub(/\[thread=(\d+)\](.+?)\[\/thread\]/i) do + raw.gsub!(/\[thread=(\d+)\](.+?)\[\/thread\]/i) do thread_id, link = $1, $2 if topic_lookup = topic_lookup_from_imported_post_id("thread-#{thread_id}") url = topic_lookup[:url] @@ -654,7 +715,7 @@ class ImportScripts::VBulletin < ImportScripts::Base # [POST][/POST] # ==> http://my.discourse.org/t/slug// - raw = raw.gsub(/\[post\](\d+)\[\/post\]/i) do + raw.gsub!(/\[post\](\d+)\[\/post\]/i) do post_id = $1 if topic_lookup = topic_lookup_from_imported_post_id(post_id) topic_lookup[:url] @@ -665,7 +726,7 @@ class ImportScripts::VBulletin < ImportScripts::Base # [POST=]...[/POST] # ==> [...](http://my.discourse.org/t///) - raw = raw.gsub(/\[post=(\d+)\](.+?)\[\/post\]/i) do + raw.gsub!(/\[post=(\d+)\](.+?)\[\/post\]/i) do post_id, link = $1, $2 if topic_lookup = topic_lookup_from_imported_post_id(post_id) url = topic_lookup[:url] @@ -679,8 +740,8 @@ class ImportScripts::VBulletin < ImportScripts::Base end - def create_permalinks - puts '', 'Creating Permalinks...', '' + def create_permalink_file + puts '', 'Creating Permalink File...', '' id_mapping = [] @@ -719,7 +780,7 @@ class ImportScripts::VBulletin < ImportScripts::Base system_user = Discourse.system_user mysql_query("SELECT userid, bandate FROM #{TABLE_PREFIX}userban").each do |b| - user = User.find_by_id(b['userid']) + user = User.find_by_id(user_id_from_imported_user_id(b['userid'])) if user user.suspended_at = parse_timestamp(user["bandate"]) user.suspended_till = 200.years.from_now diff --git a/spec/components/active_record/connection_adapters/postgresql_fallback_adapter_spec.rb b/spec/components/active_record/connection_adapters/postgresql_fallback_adapter_spec.rb index 05e6b68cd9..a9e61dbea1 100644 --- a/spec/components/active_record/connection_adapters/postgresql_fallback_adapter_spec.rb +++ b/spec/components/active_record/connection_adapters/postgresql_fallback_adapter_spec.rb @@ -48,8 +48,9 @@ describe ActiveRecord::ConnectionHandling do end it 'should failover to a replica server' do + current_threads = Thread.list + RailsMultisite::ConnectionManagement.stubs(:all_dbs).returns(['default', multisite_db]) - ::PostgreSQLFallbackHandler.instance.setup! [config, multisite_config].each do |configuration| ActiveRecord::Base.expects(:postgresql_connection).with(configuration).raises(PG::ConnectionBad) @@ -60,7 +61,7 @@ describe ActiveRecord::ConnectionHandling do })).returns(@replica_connection) end - expect(postgresql_fallback_handler.master).to eq(true) + expect(postgresql_fallback_handler.master_down?).to eq(nil) expect { ActiveRecord::Base.postgresql_fallback_connection(config) } .to raise_error(PG::ConnectionBad) @@ -68,10 +69,10 @@ describe ActiveRecord::ConnectionHandling do expect{ ActiveRecord::Base.postgresql_fallback_connection(config) } .to change{ Discourse.readonly_mode? }.from(false).to(true) - expect(postgresql_fallback_handler.master).to eq(false) + expect(postgresql_fallback_handler.master_down?).to eq(true) with_multisite_db(multisite_db) do - expect(postgresql_fallback_handler.master).to eq(true) + expect(postgresql_fallback_handler.master_down?).to eq(nil) expect { ActiveRecord::Base.postgresql_fallback_connection(multisite_config) } .to raise_error(PG::ConnectionBad) @@ -79,30 +80,18 @@ describe ActiveRecord::ConnectionHandling do expect{ ActiveRecord::Base.postgresql_fallback_connection(multisite_config) } .to change{ Discourse.readonly_mode? }.from(false).to(true) - expect(postgresql_fallback_handler.master).to eq(false) + expect(postgresql_fallback_handler.master_down?).to eq(true) end + postgresql_fallback_handler.master_up(multisite_db) + ActiveRecord::Base.unstub(:postgresql_connection) - current_threads = Thread.list - - expect{ ActiveRecord::Base.connection_pool.checkout } - .to change{ Thread.list.size }.by(1) - - # Ensure that we don't try to connect back to the replica when a thread - # is running - begin - ActiveRecord::Base.postgresql_fallback_connection(config) - rescue PG::ConnectionBad => e - # This is expected if the thread finishes before the above is called. - end - - # Wait for the thread to finish execution - (Thread.list - current_threads).each(&:join) + postgresql_fallback_handler.initiate_fallback_to_master expect(Discourse.readonly_mode?).to eq(false) - expect(PostgreSQLFallbackHandler.instance.master).to eq(true) + expect(postgresql_fallback_handler.master_down?).to eq(nil) expect(ActiveRecord::Base.connection_pool.connections.count).to eq(0) diff --git a/spec/components/email/receiver_spec.rb b/spec/components/email/receiver_spec.rb index 29ba74adfc..0a7b0a1bed 100644 --- a/spec/components/email/receiver_spec.rb +++ b/spec/components/email/receiver_spec.rb @@ -383,6 +383,39 @@ describe Email::Receiver do expect(Post.last.raw).to match(/discourse\.rb/) end + it "handles forwarded emails" do + SiteSetting.enable_forwarded_emails = true + expect { process(:forwarded_email_1) }.to change(Topic, :count) + + forwarded_post, last_post = *Post.last(2) + + expect(forwarded_post.user.email).to eq("some@one.com") + expect(last_post.user.email).to eq("ba@bar.com") + + expect(forwarded_post.raw).to match(/XoXo/) + expect(last_post.raw).to match(/can you have a look at this email below/) + + expect(last_post.post_type).to eq(Post.types[:regular]) + end + + it "handles weirdly forwarded emails" do + group.add(Fabricate(:user, email: "ba@bar.com")) + group.save + + SiteSetting.enable_forwarded_emails = true + expect { process(:forwarded_email_2) }.to change(Topic, :count) + + forwarded_post, last_post = *Post.last(2) + + expect(forwarded_post.user.email).to eq("some@one.com") + expect(last_post.user.email).to eq("ba@bar.com") + + expect(forwarded_post.raw).to match(/XoXo/) + expect(last_post.raw).to match(/can you have a look at this email below/) + + expect(last_post.post_type).to eq(Post.types[:whisper]) + end + end context "new topic in a category" do diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb index c5e637f189..bf51aadfe4 100644 --- a/spec/components/guardian_spec.rb +++ b/spec/components/guardian_spec.rb @@ -2280,4 +2280,27 @@ describe Guardian do end end end + + context 'topic featured link category restriction' do + before { SiteSetting.topic_featured_link_enabled = true } + let(:guardian) { Guardian.new } + + it 'returns true if no category restricts editing link' do + expect(guardian.can_edit_featured_link?(nil)).to eq(true) + expect(guardian.can_edit_featured_link?(5)).to eq(true) + end + + context 'when exist' do + let!(:category) { Fabricate(:category) } + let!(:link_category) { Fabricate(:link_category) } + + it 'returns true if the category is listed' do + expect(guardian.can_edit_featured_link?(link_category.id)).to eq(true) + end + + it 'returns false if the category is not listed' do + expect(guardian.can_edit_featured_link?(category.id)).to eq(false) + end + end + end end diff --git a/spec/components/middleware/anonymous_cache_spec.rb b/spec/components/middleware/anonymous_cache_spec.rb index 8dce0e5bcc..db0c481c4a 100644 --- a/spec/components/middleware/anonymous_cache_spec.rb +++ b/spec/components/middleware/anonymous_cache_spec.rb @@ -45,6 +45,16 @@ describe Middleware::AnonymousCache::Helper do crawler.clear_cache end + it "handles brotli switching" do + helper.cache([200, {"HELLO" => "WORLD"}, ["hello ", "my world"]]) + + helper = new_helper("ANON_CACHE_DURATION" => 10) + expect(helper.cached).to eq([200, {"X-Discourse-Cached" => "true", "HELLO" => "WORLD"}, ["hello my world"]]) + + helper = new_helper("ANON_CACHE_DURATION" => 10, "HTTP_ACCEPT_ENCODING" => "gz, br") + expect(helper.cached).to eq(nil) + end + it "returns cached data for cached requests" do helper.is_mobile = true expect(helper.cached).to eq(nil) diff --git a/spec/components/post_creator_spec.rb b/spec/components/post_creator_spec.rb index ab1bd54866..197e6be9d4 100644 --- a/spec/components/post_creator_spec.rb +++ b/spec/components/post_creator_spec.rb @@ -20,6 +20,7 @@ describe PostCreator do let(:creator_with_category) { PostCreator.new(user, basic_topic_params.merge(category: category.id )) } let(:creator_with_meta_data) { PostCreator.new(user, basic_topic_params.merge(meta_data: {hello: "world"} )) } let(:creator_with_image_sizes) { PostCreator.new(user, basic_topic_params.merge(image_sizes: image_sizes)) } + let(:creator_with_featured_link) { PostCreator.new(user, title: "featured link topic", archetype_id: 1, featured_link: "http://discourse.org") } it "can create a topic with null byte central" do post = PostCreator.create(user, title: "hello\u0000world this is title", raw: "this is my\u0000 first topic") @@ -243,6 +244,14 @@ describe PostCreator do end end + it 'creates a post without raw' do + SiteSetting.topic_featured_link_enabled = true + SiteSetting.topic_featured_link_onebox = true + post = creator_with_featured_link.create + expect(post.topic.featured_link).to eq('http://discourse.org') + expect(post.raw).to eq('http://discourse.org') + end + describe "topic's auto close" do it "doesn't update topic's auto close when it's not based on last post" do @@ -334,7 +343,12 @@ describe PostCreator do context 'whisper' do let!(:topic) { Fabricate(:topic, user: user) } - it 'forces replies to whispers to be whispers' do + it 'whispers do not mess up the public view' do + + first = PostCreator.new(user, + topic_id: topic.id, + raw: 'this is the first post').create + whisper = PostCreator.new(user, topic_id: topic.id, reply_to_post_number: 1, @@ -344,6 +358,7 @@ describe PostCreator do expect(whisper).to be_present expect(whisper.post_type).to eq(Post.types[:whisper]) + whisper_reply = PostCreator.new(user, topic_id: topic.id, reply_to_post_number: whisper.post_number, @@ -352,6 +367,29 @@ describe PostCreator do expect(whisper_reply).to be_present expect(whisper_reply.post_type).to eq(Post.types[:whisper]) + + + first.reload + # does not leak into the OP + expect(first.reply_count).to eq(0) + + topic.reload + + # cause whispers should not muck up that number + expect(topic.highest_post_number).to eq(1) + expect(topic.reply_count).to eq(0) + expect(topic.posts_count).to eq(1) + expect(topic.highest_staff_post_number).to eq(3) + + topic.update_columns(highest_staff_post_number:0, highest_post_number:0, posts_count: 0, last_posted_at: 1.year.ago) + + Topic.reset_highest(topic.id) + + topic.reload + expect(topic.highest_post_number).to eq(1) + expect(topic.posts_count).to eq(1) + expect(topic.last_posted_at).to eq(first.created_at) + expect(topic.highest_staff_post_number).to eq(3) end end @@ -624,6 +662,8 @@ describe PostCreator do _post2 = create_post(user: post1.user, topic_id: post1.topic_id) post1.topic.reload + + expect(post1.topic.posts_count).to eq(3) expect(post1.topic.closed).to eq(true) end end diff --git a/spec/components/search_spec.rb b/spec/components/search_spec.rb index 20c2046e0b..8d054157b1 100644 --- a/spec/components/search_spec.rb +++ b/spec/components/search_spec.rb @@ -532,6 +532,7 @@ describe Search do expect(Search.execute('test status:closed').posts.length).to eq(0) expect(Search.execute('test status:open').posts.length).to eq(1) expect(Search.execute('test posts_count:1').posts.length).to eq(1) + expect(Search.execute('test min_post_count:1').posts.length).to eq(1) topic.closed = true topic.save diff --git a/spec/components/topic_query_spec.rb b/spec/components/topic_query_spec.rb index c50319ff27..619a080aae 100644 --- a/spec/components/topic_query_spec.rb +++ b/spec/components/topic_query_spec.rb @@ -409,6 +409,29 @@ describe TopicQuery do end end + context 'with whispers' do + + it 'correctly shows up in unread for staff' do + + first = create_post(raw: 'this is the first post', title: 'super amazing title') + + _whisper = create_post(topic_id: first.topic.id, + post_type: Post.types[:whisper], + raw: 'this is a whispered reply') + + topic_id = first.topic.id + + TopicUser.update_last_read(user, topic_id, first.post_number, 1) + TopicUser.update_last_read(admin, topic_id, first.post_number, 1) + + TopicUser.change(user.id, topic_id, notification_level: TopicUser.notification_levels[:tracking]) + TopicUser.change(admin.id, topic_id, notification_level: TopicUser.notification_levels[:tracking]) + + expect(TopicQuery.new(user).list_unread.topics).to eq([]) + expect(TopicQuery.new(admin).list_unread.topics).to eq([first.topic]) + end + end + context 'with read data' do let!(:partially_read) { Fabricate(:post, user: creator).topic } let!(:fully_read) { Fabricate(:post, user: creator).topic } @@ -419,8 +442,9 @@ describe TopicQuery do end context 'list_unread' do - it 'contains no topics' do + it 'lists topics correctly' do expect(topic_query.list_unread.topics).to eq([]) + expect(topic_query.list_read.topics).to match_array([fully_read, partially_read]) end end @@ -435,11 +459,6 @@ describe TopicQuery do end end - context 'list_read' do - it 'contain both topics ' do - expect(topic_query.list_read.topics).to match_array([fully_read, partially_read]) - end - end end end @@ -630,7 +649,6 @@ describe TopicQuery do related_by_group_pm = create_pm(sender, target_group_names: [group_with_user.name]) read(user, related_by_group_pm, 1) - expect(TopicQuery.new(user).list_suggested_for(pm_to_group).topics.map(&:id)).to( eq([related_by_group_pm.id, related_by_user_pm.id, pm_to_user.id]) ) diff --git a/spec/components/unread_spec.rb b/spec/components/unread_spec.rb index cfbf457053..21e4b28b59 100644 --- a/spec/components/unread_spec.rb +++ b/spec/components/unread_spec.rb @@ -3,62 +3,86 @@ require 'unread' describe Unread do - - before do - @topic = Fabricate(:topic, posts_count: 13, highest_post_number: 13) - @topic.notifier.watch_topic!(@topic.user_id) - @topic_user = TopicUser.get(@topic, @topic.user) - @topic_user.stubs(:notification_level).returns(TopicUser.notification_levels[:tracking]) - @topic_user.notification_level = TopicUser.notification_levels[:tracking] - @unread = Unread.new(@topic, @topic_user) + let (:user) { Fabricate.build(:user, id: 1) } + let (:topic) do + Fabricate.build(:topic, + posts_count: 13, + highest_staff_post_number: 15, + highest_post_number: 13, + id: 1) end + let (:topic_user) do + Fabricate.build(:topic_user, + notification_level: TopicUser.notification_levels[:tracking], + topic_id: topic.id, + user_id: user.id) + end + + def unread + Unread.new(topic, topic_user, Guardian.new(user)) + end + + describe 'staff counts' do + it 'shoule correctly return based on staff post number' do + + user.admin = true + + topic_user.last_read_post_number = 13 + topic_user.highest_seen_post_number = 13 + + expect(unread.unread_posts).to eq(0) + expect(unread.new_posts).to eq(2) + end + end + + describe 'unread_posts' do it 'should have 0 unread posts if the user has seen all posts' do - @topic_user.stubs(:last_read_post_number).returns(13) - @topic_user.stubs(:highest_seen_post_number).returns(13) - expect(@unread.unread_posts).to eq(0) + topic_user.last_read_post_number = 13 + topic_user.highest_seen_post_number = 13 + expect(unread.unread_posts).to eq(0) end it 'should have 6 unread posts if the user has seen all but 6 posts' do - @topic_user.stubs(:last_read_post_number).returns(5) - @topic_user.stubs(:highest_seen_post_number).returns(11) - expect(@unread.unread_posts).to eq(6) + topic_user.last_read_post_number = 5 + topic_user.highest_seen_post_number = 11 + expect(unread.unread_posts).to eq(6) end it 'should have 0 unread posts if the user has seen more posts than exist (deleted)' do - @topic_user.stubs(:last_read_post_number).returns(100) - @topic_user.stubs(:highest_seen_post_number).returns(13) - expect(@unread.unread_posts).to eq(0) + topic_user.last_read_post_number = 100 + topic_user.highest_seen_post_number = 13 + expect(unread.unread_posts).to eq(0) end end describe 'new_posts' do it 'should have 0 new posts if the user has read all posts' do - @topic_user.stubs(:last_read_post_number).returns(13) - expect(@unread.new_posts).to eq(0) + topic_user.last_read_post_number = 13 + expect(unread.new_posts).to eq(0) end it 'returns 0 when the topic is the same length as when you last saw it' do - @topic_user.stubs(:highest_seen_post_number).returns(13) - expect(@unread.new_posts).to eq(0) + topic_user.highest_seen_post_number = 13 + expect(unread.new_posts).to eq(0) end it 'has 3 new posts if the user has read 10 posts' do - @topic_user.stubs(:highest_seen_post_number).returns(10) - expect(@unread.new_posts).to eq(3) + topic_user.highest_seen_post_number = 10 + expect(unread.new_posts).to eq(3) end it 'has 0 new posts if the user has read 10 posts but is not tracking' do - @topic_user.stubs(:highest_seen_post_number).returns(10) - @topic_user.stubs(:notification_level).returns(TopicUser.notification_levels[:regular]) - expect(@unread.new_posts).to eq(0) + topic_user.highest_seen_post_number = 10 + topic_user.notification_level = TopicUser.notification_levels[:regular] + expect(unread.new_posts).to eq(0) end it 'has 0 new posts if the user read more posts than exist (deleted)' do - @topic_user.stubs(:highest_seen_post_number).returns(16) - expect(@unread.new_posts).to eq(0) + topic_user.highest_seen_post_number = 16 + expect(unread.new_posts).to eq(0) end - end + end diff --git a/spec/components/validators/post_validator_spec.rb b/spec/components/validators/post_validator_spec.rb index b497d4454c..093cff9ff5 100644 --- a/spec/components/validators/post_validator_spec.rb +++ b/spec/components/validators/post_validator_spec.rb @@ -5,6 +5,16 @@ describe Validators::PostValidator do let(:post) { build(:post) } let(:validator) { Validators::PostValidator.new({}) } + context "when empty raw can bypass post body validation" do + let(:validator) { Validators::PostValidator.new(skip_post_body: true) } + + it "should be allowed for empty raw based on site setting" do + post.raw = "" + validator.post_body_validator(post) + expect(post.errors).to be_empty + end + end + context "stripped_length" do it "adds an error for short raw" do post.raw = "abc" diff --git a/spec/controllers/admin/groups_controller_spec.rb b/spec/controllers/admin/groups_controller_spec.rb index 6971184dd4..53cb83436a 100644 --- a/spec/controllers/admin/groups_controller_spec.rb +++ b/spec/controllers/admin/groups_controller_spec.rb @@ -37,7 +37,9 @@ describe Admin::GroupsController do "has_messages"=>false, "flair_url"=>nil, "flair_bg_color"=>nil, - "flair_color"=>nil + "flair_color"=>nil, + "bio_raw"=>nil, + "bio_cooked"=>nil }]) end @@ -66,7 +68,7 @@ describe Admin::GroupsController do context ".create" do it "strip spaces on the group name" do - xhr :post, :create, name: " bob " + xhr :post, :create, { group: { name: " bob " } } expect(response.status).to eq(200) @@ -81,7 +83,7 @@ describe Admin::GroupsController do context ".update" do it "ignore name change on automatic group" do - xhr :put, :update, id: 1, name: "WAT", visible: "true" + xhr :put, :update, { id: 1, group: { name: "WAT", visible: "true" } } expect(response).to be_success group = Group.find(1) @@ -92,14 +94,14 @@ describe Admin::GroupsController do it "doesn't launch the 'automatic group membership' job when it's not retroactive" do Jobs.expects(:enqueue).never group = Fabricate(:group) - xhr :put, :update, id: group.id, automatic_membership_retroactive: "false" + xhr :put, :update, { id: group.id, group: { automatic_membership_retroactive: "false" } } expect(response).to be_success end it "launches the 'automatic group membership' job when it's retroactive" do group = Fabricate(:group) Jobs.expects(:enqueue).with(:automatic_group_membership, group_id: group.id) - xhr :put, :update, id: group.id, automatic_membership_retroactive: "true" + xhr :put, :update, { id: group.id, group: { automatic_membership_retroactive: "true" } } expect(response).to be_success end diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index b926e4d6c6..5cd791ba18 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -25,19 +25,6 @@ describe GroupsController do end end - describe "counts" do - it "returns counts if it can be seen" do - xhr :get, :counts, group_id: group.name - expect(response).to be_success - end - - it "returns no counts if it can not be seen" do - group.update_columns(visible: false) - xhr :get, :counts, group_id: group.name - expect(response).not_to be_success - end - end - describe "posts" do it "ensures the group can be seen" do Guardian.any_instance.expects(:can_see?).with(group).returns(false) diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb index 745c781f9c..be07bb7894 100644 --- a/spec/controllers/invites_controller_spec.rb +++ b/spec/controllers/invites_controller_spec.rb @@ -367,33 +367,10 @@ describe InvitesController do end - context '.check_csv_chunk' do + context '.upload_csv' do it 'requires you to be logged in' do expect { - post :check_csv_chunk - }.to raise_error(Discourse::NotLoggedIn) - end - - context 'while logged in' do - let(:resumableChunkNumber) { 1 } - let(:resumableCurrentChunkSize) { 46 } - let(:resumableIdentifier) { '46-discoursecsv' } - let(:resumableFilename) { 'discourse.csv' } - - it "fails if you can't bulk invite to the forum" do - log_in - post :check_csv_chunk, resumableChunkNumber: resumableChunkNumber, resumableCurrentChunkSize: resumableCurrentChunkSize.to_i, resumableIdentifier: resumableIdentifier, resumableFilename: resumableFilename - expect(response).not_to be_success - end - - end - - end - - context '.upload_csv_chunk' do - it 'requires you to be logged in' do - expect { - post :upload_csv_chunk + xhr :post, :upload_csv }.to raise_error(Discourse::NotLoggedIn) end @@ -402,27 +379,19 @@ describe InvitesController do let(:file) do ActionDispatch::Http::UploadedFile.new({ filename: 'discourse.csv', tempfile: csv_file }) end - let(:resumableChunkNumber) { 1 } - let(:resumableChunkSize) { 1048576 } - let(:resumableCurrentChunkSize) { 46 } - let(:resumableTotalSize) { 46 } - let(:resumableType) { 'text/csv' } - let(:resumableIdentifier) { '46-discoursecsv' } - let(:resumableFilename) { 'discourse.csv' } - let(:resumableRelativePath) { 'discourse.csv' } + let(:filename) { 'discourse.csv' } it "fails if you can't bulk invite to the forum" do log_in - post :upload_csv_chunk, file: file, resumableChunkNumber: resumableChunkNumber.to_i, resumableChunkSize: resumableChunkSize.to_i, resumableCurrentChunkSize: resumableCurrentChunkSize.to_i, resumableTotalSize: resumableTotalSize.to_i, resumableType: resumableType, resumableIdentifier: resumableIdentifier, resumableFilename: resumableFilename + xhr :post, :upload_csv, file: file, name: filename expect(response).not_to be_success end - it "allows admins to bulk invite" do + it "allows admin to bulk invite" do log_in(:admin) - post :upload_csv_chunk, file: file, resumableChunkNumber: resumableChunkNumber.to_i, resumableChunkSize: resumableChunkSize.to_i, resumableCurrentChunkSize: resumableCurrentChunkSize.to_i, resumableTotalSize: resumableTotalSize.to_i, resumableType: resumableType, resumableIdentifier: resumableIdentifier, resumableFilename: resumableFilename + xhr :post, :upload_csv, file: file, name: filename expect(response).to be_success end - end end diff --git a/spec/controllers/posts_controller_spec.rb b/spec/controllers/posts_controller_spec.rb index 99af04c81a..9f29a45639 100644 --- a/spec/controllers/posts_controller_spec.rb +++ b/spec/controllers/posts_controller_spec.rb @@ -579,10 +579,6 @@ describe PostsController do let(:moderator) { log_in(:moderator) } let(:new_post) { Fabricate.build(:post, user: user) } - it "raises an exception without a raw parameter" do - expect { xhr :post, :create }.to raise_error(ActionController::ParameterMissing) - end - context "fast typing" do before do SiteSetting.min_first_post_typing_time = 3000 @@ -771,8 +767,8 @@ describe PostsController do end it "passes category through" do - xhr :post, :create, {raw: 'hello', category: 'cool'} - expect(assigns(:manager_params)['category']).to eq('cool') + xhr :post, :create, {raw: 'hello', category: 1} + expect(assigns(:manager_params)['category']).to eq('1') end it "passes target_usernames through" do diff --git a/spec/controllers/static_controller_spec.rb b/spec/controllers/static_controller_spec.rb index b5b8eb0b74..f8ba662984 100644 --- a/spec/controllers/static_controller_spec.rb +++ b/spec/controllers/static_controller_spec.rb @@ -2,6 +2,26 @@ require 'rails_helper' describe StaticController do + context 'brotli_asset' do + it 'has correct headers for brotli assets' do + begin + assets_path = Rails.root.join("public/assets") + + FileUtils.mkdir_p(assets_path) + + file_path = assets_path.join("test.js.br") + File.write(file_path, 'fake brotli file') + + get :brotli_asset, path: 'test.js' + + expect(response.status).to eq(200) + expect(response.headers["Cache-Control"]).to match(/public/) + ensure + File.delete(file_path) + end + end + end + context 'show' do before do post = create_post diff --git a/spec/controllers/tags_controller_spec.rb b/spec/controllers/tags_controller_spec.rb index 7c2e7aff06..75696f18cc 100644 --- a/spec/controllers/tags_controller_spec.rb +++ b/spec/controllers/tags_controller_spec.rb @@ -81,6 +81,12 @@ describe TagsController do expect(response).to be_success expect(assigns(:list).topics).to include(t) end + + it "can filter by bookmarked" do + log_in(:user) + xhr :get, :show_bookmarks, tag_id: tag.name + expect(response).to be_success + end end end diff --git a/spec/controllers/topics_controller_spec.rb b/spec/controllers/topics_controller_spec.rb index 05a7b50477..452470abda 100644 --- a/spec/controllers/topics_controller_spec.rb +++ b/spec/controllers/topics_controller_spec.rb @@ -1224,6 +1224,12 @@ describe TopicsController do expect { xhr :put, :bulk, topic_ids: topic_ids, operation: {}}.to raise_error(ActionController::ParameterMissing) end + it "can find unread" do + # mark all unread muted + xhr :put, :bulk, filter: 'unread', operation: {type: :change_notification_level, notification_level_id: 0} + expect(response.status).to eq(200) + end + it "delegates work to `TopicsBulkAction`" do topics_bulk_action = mock TopicsBulkAction.expects(:new).with(user, topic_ids, operation, group: nil).returns(topics_bulk_action) diff --git a/spec/controllers/user_actions_controller_spec.rb b/spec/controllers/user_actions_controller_spec.rb index a161c2f90d..52583c7f86 100644 --- a/spec/controllers/user_actions_controller_spec.rb +++ b/spec/controllers/user_actions_controller_spec.rb @@ -24,6 +24,28 @@ describe UserActionsController do expect(action["post_number"]).to eq(1) end + it 'renders help text if provided for self' do + logged_in = log_in + + xhr :get, :index, filter: UserAction::LIKE, username: logged_in.username, no_results_help_key: "user_activity.no_bookmarks" + + expect(response.status).to eq(200) + parsed = JSON.parse(response.body) + + expect(parsed["no_results_help"]).to eq(I18n.t("user_activity.no_bookmarks.self")) + + end + + it 'renders help text for others' do + user = Fabricate(:user) + xhr :get, :index, filter: UserAction::LIKE, username: user.username, no_results_help_key: "user_activity.no_bookmarks" + + expect(response.status).to eq(200) + parsed = JSON.parse(response.body) + + expect(parsed["no_results_help"]).to eq(I18n.t("user_activity.no_bookmarks.others")) + end + context "queued posts" do context "without access" do let(:user) { Fabricate(:user) } diff --git a/spec/fabricators/embeddable_host_fabricator.rb b/spec/fabricators/embeddable_host_fabricator.rb index a37f5b4c8b..9f589d389e 100644 --- a/spec/fabricators/embeddable_host_fabricator.rb +++ b/spec/fabricators/embeddable_host_fabricator.rb @@ -25,3 +25,7 @@ Fabricator(:private_category, from: :category) do cat.category_groups.build(group_id: transients[:group].id, permission_type: CategoryGroup.permission_types[:full]) end end + +Fabricator(:link_category, from: :category) do + before_validation { |category, transients| category.custom_fields['topic_featured_link_allowed'] = 'true' } +end diff --git a/spec/fabricators/topic_user_fabricator.rb b/spec/fabricators/topic_user_fabricator.rb new file mode 100644 index 0000000000..b299806f70 --- /dev/null +++ b/spec/fabricators/topic_user_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:topic_user) do + user + topic +end diff --git a/spec/fixtures/emails/forwarded_email_1.eml b/spec/fixtures/emails/forwarded_email_1.eml new file mode 100644 index 0000000000..30fb904190 --- /dev/null +++ b/spec/fixtures/emails/forwarded_email_1.eml @@ -0,0 +1,18 @@ +Message-ID: <58@foo.bar.mail> +From: Ba Bar +To: Team +Date: Mon, 1 Dec 2016 13:37:42 +0100 +Subject: FW: Discoursing much? + +@team, can you have a look at this email below? + +From: Some One +To: Ba Bar +Date: Mon, 1 Dec 2016 00:13:37 +0100 +Subject: Discoursing much? + +Hello Ba Bar, + +Discoursing much today? + +XoXo diff --git a/spec/fixtures/emails/forwarded_email_2.eml b/spec/fixtures/emails/forwarded_email_2.eml new file mode 100644 index 0000000000..d31d5c44ea --- /dev/null +++ b/spec/fixtures/emails/forwarded_email_2.eml @@ -0,0 +1,18 @@ +Message-ID: <59@foo.bar.mail> +From: Ba Bar +To: Team +Date: Mon, 1 Dec 2016 13:37:42 +0100 +Subject: Re: Discoursing much? + +@team, can you have a look at this email below? + +From: Some One [mailto:some@one.com] +To: Ba Bar +Date: Mon, 1 Dec 2016 00:13:37 +0100 +Subject: Discoursing much? + +Hello Ba Bar, + +Discoursing much today? + +XoXo diff --git a/spec/integration/groups_spec.rb b/spec/integration/groups_spec.rb index 396d7df9f4..c063e7d78d 100644 --- a/spec/integration/groups_spec.rb +++ b/spec/integration/groups_spec.rb @@ -1,22 +1,22 @@ require 'rails_helper' describe "Groups" do - describe "checking if a group can be mentioned" do - let(:password) { 'somecomplicatedpassword' } - let(:email_token) { Fabricate(:email_token, confirmed: true) } - let(:user) { email_token.user } - let(:group) { Fabricate(:group, name: 'test', users: [user]) } + let(:password) { 'somecomplicatedpassword' } + let(:email_token) { Fabricate(:email_token, confirmed: true) } + let(:user) { email_token.user } - before do - user.update_attributes!(password: password) - end + before do + user.update_attributes!(password: password) + post "/session.json", { login: user.username, password: password } + expect(response).to be_success + end + + describe "checking if a group can be mentioned" do + let(:group) { Fabricate(:group, name: 'test', users: [user]) } it "should return the right response" do group - post "/session.json", { login: user.username, password: password } - expect(response).to be_success - get "/groups/test/mentionable.json", { name: group.name } expect(response).to be_success @@ -33,4 +33,55 @@ describe "Groups" do expect(response_body["mentionable"]).to eq(true) end end + + describe "group can be updated" do + let(:group) { Fabricate(:group, name: 'test', users: [user]) } + + context "when user is group owner" do + before do + group.add_owner(user) + end + + it "should be able update the group" do + xhr :put, "/groups/#{group.id}", { group: { + flair_bg_color: 'FFF', + flair_color: 'BBB', + flair_url: 'fa-adjust', + bio_raw: 'testing', + title: 'awesome team' + } } + + expect(response).to be_success + + group.reload + + expect(group.flair_bg_color).to eq('FFF') + expect(group.flair_color).to eq('BBB') + expect(group.flair_url).to eq('fa-adjust') + expect(group.bio_raw).to eq('testing') + expect(group.title).to eq('awesome team') + end + end + + context "when user is group admin" do + before do + user.update_attributes!(admin: true) + end + + it 'should be able to update the group' do + xhr :put, "/groups/#{group.id}", { group: { flair_color: 'BBB' } } + + expect(response).to be_success + expect(group.reload.flair_color).to eq('BBB') + end + end + + context "when user is not a group owner or admin" do + it 'should not be able to update the group' do + xhr :put, "/groups/#{group.id}", { group: { name: 'testing' } } + + expect(response.status).to eq(403) + end + end + end end diff --git a/spec/jobs/bulk_invite_spec.rb b/spec/jobs/bulk_invite_spec.rb index 77629a6a82..0739a41574 100644 --- a/spec/jobs/bulk_invite_spec.rb +++ b/spec/jobs/bulk_invite_spec.rb @@ -5,15 +5,8 @@ describe Jobs::BulkInvite do context '.execute' do it 'raises an error when the filename is missing' do - expect { Jobs::BulkInvite.new.execute(identifier: '46-discoursecsv', chunks: '1') }.to raise_error(Discourse::InvalidParameters) - end - - it 'raises an error when the identifier is missing' do - expect { Jobs::BulkInvite.new.execute(filename: 'discourse.csv', chunks: '1') }.to raise_error(Discourse::InvalidParameters) - end - - it 'raises an error when the chunks is missing' do - expect { Jobs::BulkInvite.new.execute(filename: 'discourse.csv', identifier: '46-discoursecsv') }.to raise_error(Discourse::InvalidParameters) + user = Fabricate(:user) + expect { Jobs::BulkInvite.new.execute(current_user_id: user.id) }.to raise_error(Discourse::InvalidParameters) end context '.read_csv_file' do diff --git a/spec/mailers/user_notifications_spec.rb b/spec/mailers/user_notifications_spec.rb index 51f73a58ae..a427b6d3b0 100644 --- a/spec/mailers/user_notifications_spec.rb +++ b/spec/mailers/user_notifications_spec.rb @@ -155,8 +155,7 @@ describe UserNotifications do context "with new topics" do before do - Topic.stubs(:for_digest).returns([Fabricate(:topic, user: Fabricate(:coding_horror))]) - Topic.stubs(:new_since_last_seen).returns(Topic.none) + Fabricate(:topic, user: Fabricate(:coding_horror)) end it "works" do @@ -184,6 +183,28 @@ describe UserNotifications do expect(html).to_not include deleted.title expect(html).to_not include post.raw end + + it "excludes whispers and other post types that don't belong" do + t = Fabricate(:topic, user: Fabricate(:user), title: "Who likes the same stuff I like?") + whisper = Fabricate(:post, topic: t, score: 100.0, post_number: 2, raw: "You like weird stuff", post_type: Post.types[:whisper]) + mod_action = Fabricate(:post, topic: t, score: 100.0, post_number: 3, raw: "This topic unlisted", post_type: Post.types[:moderator_action]) + small_action = Fabricate(:post, topic: t, score: 100.0, post_number: 4, raw: "A small action", post_type: Post.types[:small_action]) + html = subject.html_part.body.to_s + expect(html).to_not include whisper.raw + expect(html).to_not include mod_action.raw + expect(html).to_not include small_action.raw + end + + it "excludes deleted and hidden posts" do + t = Fabricate(:topic, user: Fabricate(:user), title: "Post objectionable stuff here") + deleted = Fabricate(:post, topic: t, score: 100.0, post_number: 2, raw: "This post is uncalled for", deleted_at: 5.minutes.ago) + hidden = Fabricate(:post, topic: t, score: 100.0, post_number: 3, raw: "Try to find this post", hidden: true, hidden_at: 5.minutes.ago, hidden_reason_id: Post.hidden_reasons[:flagged_by_tl3_user]) + user_deleted = Fabricate(:post, topic: t, score: 100.0, post_number: 4, raw: "I regret this post", user_deleted: true) + html = subject.html_part.body.to_s + expect(html).to_not include deleted.raw + expect(html).to_not include hidden.raw + expect(html).to_not include user_deleted.raw + end end end diff --git a/spec/models/category_spec.rb b/spec/models/category_spec.rb index d603077ebf..efe28cb6be 100644 --- a/spec/models/category_spec.rb +++ b/spec/models/category_spec.rb @@ -421,14 +421,14 @@ describe Category do describe 'latest' do it 'should be updated correctly' do category = Fabricate(:category) - post = create_post(category: category.name) + post = create_post(category: category.id) category.reload expect(category.latest_post_id).to eq(post.id) expect(category.latest_topic_id).to eq(post.topic_id) - post2 = create_post(category: category.name) - post3 = create_post(topic_id: post.topic_id, category: category.name) + post2 = create_post(category: category.id) + post3 = create_post(topic_id: post.topic_id, category: category.id) category.reload expect(category.latest_post_id).to eq(post3.id) @@ -451,7 +451,7 @@ describe Category do context 'with regular topics' do before do - create_post(user: @category.user, category: @category.name) + create_post(user: @category.user, category: @category.id) Category.update_stats @category.reload end @@ -491,7 +491,7 @@ describe Category do context 'with revised post' do before do - post = create_post(user: @category.user, category: @category.name) + post = create_post(user: @category.user, category: @category.id) SiteSetting.stubs(:editing_grace_period).returns(1.minute.to_i) post.revise(post.user, { raw: 'updated body' }, revised_at: post.updated_at + 2.minutes) diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index dfc54ba1a8..8a6477ed73 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -322,7 +322,6 @@ describe Group do expect(Group.desired_trust_level_groups(2).sort).to eq [10,11,12] end - it "correctly handles trust level changes" do user = Fabricate(:user, trust_level: 2) Group.user_trust_level_change!(user.id, 2) @@ -369,4 +368,11 @@ describe Group do expect(u3.reload.trust_level).to eq(3) end + it 'should cook the bio' do + group = Fabricate(:group) + group.update_attributes!(bio_raw: 'This is a group for :unicorn: lovers') + + expect(group.bio_cooked).to include("unicorn.png") + end + end diff --git a/spec/models/post_action_spec.rb b/spec/models/post_action_spec.rb index 9321dedd81..fb621269df 100644 --- a/spec/models/post_action_spec.rb +++ b/spec/models/post_action_spec.rb @@ -199,10 +199,6 @@ describe PostAction do expect { bookmark.save; post.reload }.to change(post, :bookmark_count).by(1) end - it "increases the forum topic's bookmark count when saved" do - expect { bookmark.save; post.topic.reload }.to change(post.topic, :bookmark_count).by(1) - end - describe 'when deleted' do before do @@ -218,9 +214,6 @@ describe PostAction do expect { post.reload }.to change(post, :bookmark_count).by(-1) end - it 'reduces the bookmark count of the forum topic' do - expect { @topic.reload }.to change(post.topic, :bookmark_count).by(-1) - end end end @@ -291,19 +284,24 @@ describe PostAction do end end - describe 'when a user votes for something' do - it 'should increase the vote counts when a user votes' do + describe 'when a user likes something' do + it 'should increase the like counts when a user votes' do expect { - PostAction.act(codinghorror, post, PostActionType.types[:vote]) + PostAction.act(codinghorror, post, PostActionType.types[:like]) post.reload - }.to change(post, :vote_count).by(1) + }.to change(post, :like_count).by(1) end it 'should increase the forum topic vote count when a user votes' do expect { - PostAction.act(codinghorror, post, PostActionType.types[:vote]) + PostAction.act(codinghorror, post, PostActionType.types[:like]) post.topic.reload - }.to change(post.topic, :vote_count).by(1) + }.to change(post.topic, :like_count).by(1) + + expect { + PostAction.remove_act(codinghorror, post, PostActionType.types[:like]) + post.topic.reload + }.to change(post.topic, :like_count).by(-1) end end diff --git a/spec/models/post_analyzer_spec.rb b/spec/models/post_analyzer_spec.rb index 91cefd382b..ecda5cc6a0 100644 --- a/spec/models/post_analyzer_spec.rb +++ b/spec/models/post_analyzer_spec.rb @@ -38,8 +38,9 @@ describe PostAnalyzer do context "links" do let(:raw_no_links) { "hello world my name is evil trout" } let(:raw_one_link_md) { "[jlawr](http://www.imdb.com/name/nm2225369)" } - let(:raw_two_links_html) { "disney reddit"} - let(:raw_three_links) { "http://discourse.org and http://discourse.org/another_url and http://www.imdb.com/name/nm2225369"} + let(:raw_two_links_html) { "disney reddit" } + let(:raw_three_links) { "http://discourse.org and http://discourse.org/another_url and http://www.imdb.com/name/nm2225369" } + let(:raw_elided) { "
    \n···\nhttp://discourse.org\n
    " } describe "raw_links" do it "returns a blank collection for a post with no links" do @@ -61,6 +62,12 @@ describe PostAnalyzer do post_analyzer = PostAnalyzer.new(raw_three_links, default_topic_id) expect(post_analyzer.raw_links).to eq(["http://discourse.org", "http://discourse.org/another_url", "http://www.imdb.com/name/nm2225369"]) end + + it "doesn't extract links from elided part" do + post_analyzer = PostAnalyzer.new(raw_elided, default_topic_id) + post_analyzer.expects(:cook).returns("

    \n···\ndiscourse.org\n

    ") + expect(post_analyzer.raw_links).to be_blank + end end describe "linked_hosts" do diff --git a/spec/models/quoted_post_spec.rb b/spec/models/quoted_post_spec.rb index 77bb741339..4c61952c69 100644 --- a/spec/models/quoted_post_spec.rb +++ b/spec/models/quoted_post_spec.rb @@ -15,9 +15,15 @@ describe QuotedPost do post1 = Fabricate(:post) post2 = Fabricate(:post) - post2.cooked = <
    techAPJ:

    When the user will v

    -HTML + post2.cooked = <<-HTML + + HTML QuotedPost.create!(post_id: post2.id, quoted_post_id: 999) diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index 6b84eff2e6..0f35c71448 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -1724,4 +1724,55 @@ describe Topic do expect(@topic_status_event_triggered).to eq(true) end + + it 'allows users to normalize counts' do + + topic = Fabricate(:topic, last_posted_at: 1.year.ago) + post1 = Fabricate(:post, topic: topic, post_number: 1) + post2 = Fabricate(:post, topic: topic, post_type: Post.types[:whisper], post_number: 2) + + Topic.reset_all_highest! + topic.reload + + expect(topic.posts_count).to eq(1) + expect(topic.highest_post_number).to eq(post1.post_number) + expect(topic.highest_staff_post_number).to eq(post2.post_number) + expect(topic.last_posted_at).to be_within(1.second).of (post1.created_at) + end + + context 'featured link' do + before { SiteSetting.topic_featured_link_enabled = true } + let(:topic) { Fabricate(:topic) } + + it 'can validate featured link' do + topic.featured_link = ' invalid string' + + expect(topic).not_to be_valid + expect(topic.errors[:featured_link]).to be_present + end + + it 'can properly save the featured link' do + topic.featured_link = ' https://github.com/discourse/discourse' + + expect(topic.save).to be_truthy + expect(topic.custom_fields['featured_link']).to eq('https://github.com/discourse/discourse') + end + + context 'when category restricts present' do + let!(:link_category) { Fabricate(:link_category) } + let(:topic) { Fabricate(:topic) } + let(:link_topic) { Fabricate(:topic, category: link_category) } + + it 'can save the featured link if it belongs to that category' do + link_topic.featured_link = 'https://github.com/discourse/discourse' + expect(link_topic.save).to be_truthy + expect(link_topic.custom_fields['featured_link']).to eq('https://github.com/discourse/discourse') + end + + it 'can not save the featured link if it belongs to that category' do + topic.featured_link = 'https://github.com/discourse/discourse' + expect(topic.save).to be_falsey + end + end + end end diff --git a/spec/models/topic_tracking_state_spec.rb b/spec/models/topic_tracking_state_spec.rb index 5410f69eed..cd51c5ff76 100644 --- a/spec/models/topic_tracking_state_spec.rb +++ b/spec/models/topic_tracking_state_spec.rb @@ -20,7 +20,7 @@ describe TopicTrackingState do user = Fabricate(:user) post - report = TopicTrackingState.report(user.id) + report = TopicTrackingState.report(user) expect(report.length).to eq(1) CategoryUser.create!(user_id: user.id, @@ -30,12 +30,12 @@ describe TopicTrackingState do create_post(topic_id: post.topic_id) - report = TopicTrackingState.report(user.id) + report = TopicTrackingState.report(user) expect(report.length).to eq(0) TopicUser.create!(user_id: user.id, topic_id: post.topic_id, last_read_post_number: 1, notification_level: 3) - report = TopicTrackingState.report(user.id) + report = TopicTrackingState.report(user) expect(report.length).to eq(1) end @@ -62,18 +62,18 @@ describe TopicTrackingState do TopicUser.change(user.id, post2.topic_id, tracking) TopicUser.change(user.id, post3.topic_id, tracking) - report = TopicTrackingState.report(user.id) + report = TopicTrackingState.report(user) expect(report.length).to eq(3) end it "correctly gets the tracking state" do - report = TopicTrackingState.report(user.id) + report = TopicTrackingState.report(user) expect(report.length).to eq(0) post.topic.notifier.watch_topic!(post.topic.user_id) - report = TopicTrackingState.report(user.id) + report = TopicTrackingState.report(user) expect(report.length).to eq(1) row = report[0] @@ -84,18 +84,18 @@ describe TopicTrackingState do expect(row.user_id).to eq(user.id) # lets not leak out random users - expect(TopicTrackingState.report(post.user_id)).to be_empty + expect(TopicTrackingState.report(post.user)).to be_empty # lets not return anything if we scope on non-existing topic - expect(TopicTrackingState.report(user.id, post.topic_id + 1)).to be_empty + expect(TopicTrackingState.report(user, post.topic_id + 1)).to be_empty # when we reply the poster should have an unread row create_post(user: user, topic: post.topic) - report = TopicTrackingState.report(user.id) + report = TopicTrackingState.report(user) expect(report.length).to eq(0) - report = TopicTrackingState.report(post.user_id) + report = TopicTrackingState.report(post.user) expect(report.length).to eq(1) row = report[0] @@ -111,7 +111,7 @@ describe TopicTrackingState do post.topic.category_id = category.id post.topic.save - expect(TopicTrackingState.report(post.user_id)).to be_empty - expect(TopicTrackingState.report(user.id)).to be_empty + expect(TopicTrackingState.report(post.user)).to be_empty + expect(TopicTrackingState.report(user)).to be_empty end end diff --git a/spec/models/web_hook_spec.rb b/spec/models/web_hook_spec.rb index 88eaee6cb0..0022c0e9d6 100644 --- a/spec/models/web_hook_spec.rb +++ b/spec/models/web_hook_spec.rb @@ -120,14 +120,14 @@ describe WebHook do end it 'should enqueue the right hooks for post events' do - user # bypass a user_created event - WebHook.expects(:enqueue_hooks).once + WebHook.expects(:enqueue_post_hooks).once PostCreator.create(user, { raw: 'post', topic_id: topic.id, reply_to_post_number: 1, skip_validations: true }) - WebHook.expects(:enqueue_hooks).once + # post destroy or recover triggers a moderator post + WebHook.expects(:enqueue_post_hooks).twice PostDestroyer.new(user, post2).destroy - WebHook.expects(:enqueue_hooks).once + WebHook.expects(:enqueue_post_hooks).twice PostDestroyer.new(user, post2).recover end diff --git a/spec/serializers/group_show_serializer_spec.rb b/spec/serializers/group_show_serializer_spec.rb new file mode 100644 index 0000000000..5641ccd8d0 --- /dev/null +++ b/spec/serializers/group_show_serializer_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +describe GroupShowSerializer do + context 'admin user' do + let(:user) { Fabricate(:admin) } + let(:group) { Fabricate(:group, users: [user]) } + + it 'should return the right attributes' do + json = GroupShowSerializer.new(group, scope: Guardian.new(user)).as_json + + expect(json[:group_show][:is_group_owner]).to eq(true) + expect(json[:group_show][:is_group_user]).to eq(true) + end + end + + context 'group owner' do + let(:user) { Fabricate(:user) } + let(:group) { Fabricate(:group) } + + before do + group.add_owner(user) + end + + it 'should return the right attributes' do + json = GroupShowSerializer.new(group, scope: Guardian.new(user)).as_json + + expect(json[:group_show][:is_group_owner]).to eq(true) + expect(json[:group_show][:is_group_user]).to eq(true) + end + end +end diff --git a/spec/support/helpers.rb b/spec/support/helpers.rb index 39457073a3..36863e798d 100644 --- a/spec/support/helpers.rb +++ b/spec/support/helpers.rb @@ -28,7 +28,7 @@ module Helpers args[:title] ||= "This is my title #{Helpers.next_seq}" user = args.delete(:user) || Fabricate(:user) guardian = Guardian.new(user) - args[:category] = args[:category].name if args[:category].is_a?(Category) + args[:category] = args[:category].id if args[:category].is_a?(Category) TopicCreator.create(user, guardian, args) end @@ -37,7 +37,7 @@ module Helpers args[:raw] ||= "This is the raw body of my post, it is cool #{Helpers.next_seq}" args[:topic_id] = args[:topic].id if args[:topic] user = args.delete(:user) || Fabricate(:user) - args[:category] = args[:category].name if args[:category].is_a?(Category) + args[:category] = args[:category].id if args[:category].is_a?(Category) creator = PostCreator.new(user, args) post = creator.create diff --git a/spec/tasks/redis_spec.rb b/spec/tasks/redis_spec.rb new file mode 100644 index 0000000000..64e0305e2f --- /dev/null +++ b/spec/tasks/redis_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +RSpec.describe "Redis rake tasks" do + let(:redis) { $redis.without_namespace } + + before do + @multisite = Rails.configuration.multisite + Rails.configuration.multisite = true + Discourse::Application.load_tasks + end + + after do + Rails.configuration.multisite = @multisite + end + + describe 'clean up' do + it 'should clean up orphan Redis keys' do + active_keys = [ + '__mb_backlog_id_n_/users/someusername$|$default', + 'default:user-last-seen:607', + 'sidekiq:something:do:something', + 'somekeytonotbetouched' + ] + + orphan_keys = [ + 'tgxworld:user-last-seen:607', + '__mb_backlog_id_n_/users/someusername$|$tgxworld' + ] + + (active_keys | orphan_keys).each do |key| + redis.set(key, 1) + end + + Rake::Task['redis:clean_up'].invoke + + active_keys.each do |key| + expect(redis.get(key)).to eq('1') + end + + orphan_keys.each do |key| + expect(redis.get(key)).to eq(nil) + end + end + end +end diff --git a/test/javascripts/acceptance/groups-test.js.es6 b/test/javascripts/acceptance/groups-test.js.es6 index 0fcc909999..bc6a6ee44a 100644 --- a/test/javascripts/acceptance/groups-test.js.es6 +++ b/test/javascripts/acceptance/groups-test.js.es6 @@ -4,7 +4,9 @@ acceptance("Groups"); test("Browsing Groups", () => { visit("/groups/discourse"); + andThen(() => { + ok(count('.avatar-flair .fa-adjust') === 1, "it displays the group's avatar flair"); ok(count('.group-members tr') > 0, "it lists group members"); }); @@ -25,18 +27,28 @@ test("Browsing Groups", () => { visit("/groups/discourse/messages"); andThen(() => { - ok($('.action-list li').length === 4, 'it should not show messages tab'); + ok($('.nav-stacked li').length === 4, 'it should not show messages tab'); ok(count('.user-stream .item') > 0, "it lists stream items"); }); }); -test("Messages tab", () => { +test("Admin Browsing Groups", () => { logIn(); Discourse.reset(); visit("/groups/discourse"); andThen(() => { - ok($('.action-list li').length === 5, 'it should show messages tab if user is admin'); + ok(find('.nav-stacked li').length === 5, 'it should show messages tab if user is admin'); + equal(find('.group-title').text(), 'Awesome Team', 'it should display the group title'); + equal(find('.group-name').text(), '@discourse', 'it should display the group name'); + }); + + click('.group-edit-btn'); + + andThen(() => { + ok(find('.group-flair-inputs').length === 1, 'it should display avatar flair inputs'); + ok(find('.edit-group-bio').length === 1, 'it should display group bio input'); + ok(find('.edit-group-title').length === 1, 'it should display group title input'); }); }); diff --git a/test/javascripts/acceptance/search-full-test.js.es6 b/test/javascripts/acceptance/search-full-test.js.es6 index 2bc511f93b..f62879adb4 100644 --- a/test/javascripts/acceptance/search-full-test.js.es6 +++ b/test/javascripts/acceptance/search-full-test.js.es6 @@ -73,7 +73,7 @@ test("open advanced search", assert => { test("validate population of advanced search", assert => { visit("/search"); - fillIn('.search input.full-page-search', 'test user:admin #bug group:moderators badge:Reader tags:monkey in:likes in:private in:wiki in:bookmarks status:open after:2016-10-05 posts_count:10'); + fillIn('.search input.full-page-search', 'test user:admin #bug group:moderators badge:Reader tags:monkey in:likes in:private in:wiki in:bookmarks status:open after:2016-10-05 min_post_count:10'); click('.search-advanced-btn'); andThen(() => { @@ -89,7 +89,7 @@ test("validate population of advanced search", assert => { assert.ok(exists('.search-advanced-options .combobox .select2-choice .select2-chosen:contains("are open")'), 'has "are open" pre-populated'); assert.ok(exists('.search-advanced-options .combobox .select2-choice .select2-chosen:contains("after")'), 'has "after" pre-populated'); assert.equal(find('.search-advanced-options #search-post-date').val(), "2016-10-05", 'has "2016-10-05" pre-populated'); - assert.equal(find('.search-advanced-options #search-posts-count').val(), "10", 'has "10" pre-populated'); + assert.equal(find('.search-advanced-options #search-min-post-count').val(), "10", 'has "10" pre-populated'); }); }); @@ -274,15 +274,15 @@ test("update post time through advanced search ui", assert => { }); }); -test("update posts count through advanced search ui", assert => { +test("update min post count through advanced search ui", assert => { visit("/search"); fillIn('.search input.full-page-search', 'none'); click('.search-advanced-btn'); - fillIn('#search-posts-count', '5'); + fillIn('#search-min-post-count', '5'); andThen(() => { - assert.equal(find('.search-advanced-options #search-posts-count').val(), "5", 'has "5" populated'); - assert.equal(find('.search input.full-page-search').val(), "none posts_count:5", 'has updated search term to "none posts_count:5"'); + assert.equal(find('.search-advanced-options #search-min-post-count').val(), "5", 'has "5" populated'); + assert.equal(find('.search input.full-page-search').val(), "none min_post_count:5", 'has updated search term to "none min_post_count:5"'); }); }); diff --git a/test/javascripts/controllers/group-test.js.es6 b/test/javascripts/controllers/group-test.js.es6 new file mode 100644 index 0000000000..822b14dd42 --- /dev/null +++ b/test/javascripts/controllers/group-test.js.es6 @@ -0,0 +1,19 @@ +moduleFor("controller:group"); + +test("canEditGroup", function() { + const GroupController = this.subject(); + + GroupController.setProperties({ + model: { is_group_owner: true, automatic: true } + }); + + equal(GroupController.get("canEditGroup"), false, "automatic groups cannot be edited"); + + GroupController.set("model.automatic", false); + + equal(GroupController.get("canEditGroup"), true, "owners can edit groups"); + + GroupController.set("model.is_group_owner", false); + + equal(GroupController.get("canEditGroup"), false, "normal users cannot edit groups"); +}); diff --git a/test/javascripts/fixtures/group-fixtures.js.es6 b/test/javascripts/fixtures/group-fixtures.js.es6 index 8a2d2fa252..55041ac96f 100644 --- a/test/javascripts/fixtures/group-fixtures.js.es6 +++ b/test/javascripts/fixtures/group-fixtures.js.es6 @@ -4,9 +4,12 @@ export default { "id":47, "automatic":false, "name":"discourse", + "title":"Awesome Team", "user_count":8, "alias_level":0, - "visible":true + "visible":true, + "flair_url": 'fa-adjust', + "is_group_owner":true } }, "/groups/discourse/counts.json":{ diff --git a/test/javascripts/models/composer-test.js.es6 b/test/javascripts/models/composer-test.js.es6 index d541aaeb08..1ed5a691c3 100644 --- a/test/javascripts/models/composer-test.js.es6 +++ b/test/javascripts/models/composer-test.js.es6 @@ -40,6 +40,10 @@ test('missingReplyCharacters', function() { missingReplyCharacters('hi', false, false, Discourse.SiteSettings.min_post_length - 2, 'too short public post'); missingReplyCharacters('hi', false, true, Discourse.SiteSettings.min_first_post_length - 2, 'too short first post'); missingReplyCharacters('hi', true, false, Discourse.SiteSettings.min_private_message_post_length - 2, 'too short private message'); + + Discourse.SiteSettings.topic_featured_link_onebox = true; + const composer = createComposer({ canEditTopicFeaturedLink: true }); + equal(composer.get('missingReplyCharacters'), 0, "don't require any post content"); }); test('missingTitleCharacters', function() { @@ -105,7 +109,7 @@ test("prependText", function() { composer.prependText("world "); equal(composer.get('reply'), "world hello", "it prepends text to existing text"); - + composer.prependText("before new line", {new_line: true}); equal(composer.get('reply'), "before new line\n\nworld hello", "it prepends text with new line to existing text"); }); diff --git a/vendor/assets/javascripts/run-qunit.js b/vendor/assets/javascripts/run-qunit.js index 97fce3dc94..7f0335fea5 100644 --- a/vendor/assets/javascripts/run-qunit.js +++ b/vendor/assets/javascripts/run-qunit.js @@ -36,7 +36,7 @@ page.open(args[0], function(status) { } else { page.evaluate(logQUnit); - var timeout = parseInt(args[1] || 130000, 10), + var timeout = parseInt(args[1] || 200000, 10), start = Date.now(); var interval = setInterval(function() {