diff --git a/.travis.yml b/.travis.yml index cb8c3c021c..465c6366df 100644 --- a/.travis.yml +++ b/.travis.yml @@ -42,6 +42,7 @@ before_install: - git clone --depth=1 https://github.com/discourse/discourse-spoiler-alert.git plugins/discourse-spoiler-alert - git clone --depth=1 https://github.com/discourse/discourse-cakeday.git plugins/discourse-cakeday - git clone --depth=1 https://github.com/discourse/discourse-canned-replies.git plugins/discourse-canned-replies + - git clone --depth=1 https://github.com/discourse/discourse-slack-official.git plugins/discourse-slack-official - npm i -g eslint babel-eslint - eslint app/assets/javascripts - eslint --ext .es6 app/assets/javascripts diff --git a/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 b/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 index 14bc031c0a..175463c0a1 100644 --- a/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 @@ -1,8 +1,12 @@ import { ajax } from 'discourse/lib/ajax'; import CanCheckEmails from 'discourse/mixins/can-check-emails'; import { propertyNotEqual, setting } from 'discourse/lib/computed'; +import { userPath } from 'discourse/lib/url'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; export default Ember.Controller.extend(CanCheckEmails, { + editingUsername: false, + editingName: false, editingTitle: false, originalPrimaryGroupId: null, availableGroups: null, @@ -54,23 +58,58 @@ export default Ember.Controller.extend(CanCheckEmails, { anonymize() { return this.get('model').anonymize(); }, destroy() { return this.get('model').destroy(); }, + toggleUsernameEdit() { + this.set('userUsernameValue', this.get('model.username')); + this.toggleProperty('editingUsername'); + }, + + saveUsername() { + const oldUsername = this.get('model.username'); + this.set('model.username', this.get('userUsernameValue')); + + return ajax(`/users/${oldUsername.toLowerCase()}/preferences/username`, { + data: { new_username: this.get('userUsernameValue') }, + type: 'PUT' + }).catch(e => { + this.set('model.username', oldUsername); + popupAjaxError(e); + }).finally(() => this.toggleProperty('editingUsername')); + }, + + toggleNameEdit() { + this.set('userNameValue', this.get('model.name')); + this.toggleProperty('editingName'); + }, + + saveName() { + const oldName = this.get('model.name'); + this.set('model.name', this.get('userNameValue')); + + return ajax(userPath(`${this.get('model.username').toLowerCase()}.json`), { + data: { name: this.get('userNameValue') }, + type: 'PUT' + }).catch(e => { + this.set('model.name', oldName); + popupAjaxError(e); + }).finally(() => this.toggleProperty('editingName')); + }, + toggleTitleEdit() { this.set('userTitleValue', this.get('model.title')); this.toggleProperty('editingTitle'); }, saveTitle() { - const self = this; + const prevTitle = this.get('userTitleValue'); - return ajax(`/users/${this.get('model.username').toLowerCase()}.json`, { + this.set('model.title', this.get('userTitleValue')); + return ajax(userPath(`${this.get('model.username').toLowerCase()}.json`), { data: {title: this.get('userTitleValue')}, type: 'PUT' - }).catch(function(e) { - bootbox.alert(I18n.t("generic_error_with_reason", {error: "http: " + e.status + " - " + e.body})); - }).finally(function() { - self.set('model.title', self.get('userTitleValue')); - self.toggleProperty('editingTitle'); - }); + }).catch(e => { + this.set('model.title', prevTitle); + popupAjaxError(e); + }).finally(() => this.toggleProperty('editingTitle')); }, generateApiKey() { diff --git a/app/assets/javascripts/admin/controllers/admin-users-list-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-users-list-show.js.es6 index 0d8aec0771..43af644266 100644 --- a/app/assets/javascripts/admin/controllers/admin-users-list-show.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-users-list-show.js.es6 @@ -7,7 +7,7 @@ import { observes } from 'ember-addons/ember-computed-decorators'; export default Ember.Controller.extend({ query: null, queryParams: ['order', 'ascending'], - order: 'seen', + order: null, ascending: null, showEmails: false, refreshing: false, diff --git a/app/assets/javascripts/admin/models/admin-user.js.es6 b/app/assets/javascripts/admin/models/admin-user.js.es6 index 48764b671d..b1e1202a90 100644 --- a/app/assets/javascripts/admin/models/admin-user.js.es6 +++ b/app/assets/javascripts/admin/models/admin-user.js.es6 @@ -5,6 +5,7 @@ import { popupAjaxError } from 'discourse/lib/ajax-error'; import ApiKey from 'admin/models/api-key'; import Group from 'discourse/models/group'; import TL3Requirements from 'admin/models/tl3-requirements'; +import { userPath } from 'discourse/lib/url'; const AdminUser = Discourse.User.extend({ @@ -114,11 +115,10 @@ const AdminUser = Discourse.User.extend({ }, revokeAdmin() { - const self = this; - return ajax("/admin/users/" + this.get('id') + "/revoke_admin", { + return ajax(`/admin/users/${this.get('id')}/revoke_admin`, { type: 'PUT' - }).then(function() { - self.setProperties({ + }).then(() => { + this.setProperties({ admin: false, can_grant_admin: true, can_revoke_admin: false @@ -127,15 +127,10 @@ const AdminUser = Discourse.User.extend({ }, grantAdmin() { - const self = this; - return ajax("/admin/users/" + this.get('id') + "/grant_admin", { + return ajax(`/admin/users/${this.get('id')}/grant_admin`, { type: 'PUT' - }).then(function() { - self.setProperties({ - admin: true, - can_grant_admin: false, - can_revoke_admin: true - }); + }).then(() => { + bootbox.alert(I18n.t("admin.user.grant_admin_confirm")); }).catch(popupAjaxError); }, @@ -346,7 +341,7 @@ const AdminUser = Discourse.User.extend({ }, sendActivationEmail() { - return ajax('/users/action/send_activation_email', { + return ajax(userPath('action/send_activation_email'), { type: 'POST', data: { username: this.get('username') } }).then(function() { diff --git a/app/assets/javascripts/admin/templates/user-index.hbs b/app/assets/javascripts/admin/templates/user-index.hbs index 923fb1c09b..f172c65124 100644 --- a/app/assets/javascripts/admin/templates/user-index.hbs +++ b/app/assets/javascripts/admin/templates/user-index.hbs @@ -22,18 +22,40 @@
{{i18n 'user.username.title'}}
-
{{model.username}}
+
+ {{#if editingUsername}} + {{text-field value=userUsernameValue autofocus="autofocus"}} + {{else}} + {{model.username}} + {{/if}} +
- {{#link-to 'preferences.username' model class="btn"}} - {{fa-icon "pencil"}} - {{i18n 'user.change_username.title'}} - {{/link-to}} + {{#if editingUsername}} + {{d-button action="saveUsername" label="admin.user_fields.save"}} + {{i18n 'cancel'}} + {{else}} + {{d-button action="toggleUsernameEdit" icon="pencil"}} + {{/if}}
{{i18n 'user.name.title'}}
-
{{model.name}}
+
+ {{#if editingName}} + {{text-field value=userNameValue autofocus="autofocus"}} + {{else}} + {{model.name}} + {{/if}} +
+
+ {{#if editingName}} + {{d-button action="saveName" label="admin.user_fields.save"}} + {{i18n 'cancel'}} + {{else}} + {{d-button action="toggleNameEdit" icon="pencil"}} + {{/if}} +
{{#if canCheckEmails}} @@ -90,10 +112,10 @@
{{#if editingTitle}} - {{d-button action="saveTitle" label="admin.user.save_title"}} + {{d-button action="saveTitle" label="admin.user_fields.save"}} {{i18n 'cancel'}} {{else}} - {{d-button action="toggleTitleEdit" icon="pencil" label="admin.user.edit_title"}} + {{d-button action="toggleTitleEdit" icon="pencil"}} {{/if}}
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 6f7f08f23b..29c8e300b1 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -25,7 +25,7 @@ //= require ./discourse/lib/computed //= require ./discourse/lib/formatter //= require ./discourse/lib/eyeline -//= require ./discourse/mixins/scrolling +//= require ./discourse/lib/show-modal //= require ./discourse/mixins/scrolling //= require ./discourse/models/model //= require ./discourse/models/rest @@ -69,7 +69,6 @@ //= require ./discourse/lib/emoji/groups //= require ./discourse/lib/emoji/toolbar //= require ./discourse/components/d-editor -//= require ./discourse/lib/show-modal //= require ./discourse/lib/screen-track //= require ./discourse/routes/discourse //= require ./discourse/routes/build-topic-route diff --git a/app/assets/javascripts/discourse/components/auto-close-form.js.es6 b/app/assets/javascripts/discourse/components/auto-close-form.js.es6 deleted file mode 100644 index f6ab041c7c..0000000000 --- a/app/assets/javascripts/discourse/components/auto-close-form.js.es6 +++ /dev/null @@ -1,46 +0,0 @@ -import computed from "ember-addons/ember-computed-decorators"; -import { observes } from "ember-addons/ember-computed-decorators"; - -export default Ember.Component.extend({ - limited: false, - autoCloseValid: false, - - @computed("limited") - autoCloseUnits(limited) { - const key = limited ? "composer.auto_close.limited.units" : "composer.auto_close.all.units"; - return I18n.t(key); - }, - - @computed("limited") - autoCloseExamples(limited) { - const key = limited ? "composer.auto_close.limited.examples" : "composer.auto_close.all.examples"; - return I18n.t(key); - }, - - @observes("autoCloseTime", "limited") - _updateAutoCloseValid() { - const limited = this.get("limited"), - autoCloseTime = this.get("autoCloseTime"), - isValid = this._isAutoCloseValid(autoCloseTime, limited); - this.set("autoCloseValid", isValid); - }, - - _isAutoCloseValid(autoCloseTime, limited) { - const t = (autoCloseTime || "").toString().trim(); - if (t.length === 0) { - // "empty" is always valid - return true; - } else if (limited) { - // only # of hours in limited mode - return t.match(/^(\d+\.)?\d+$/); - } else { - if (t.match(/^\d{4}-\d{1,2}-\d{1,2}(?: \d{1,2}:\d{2}(\s?[AP]M)?){0,1}$/i)) { - // timestamp must be in the future - return moment(t).isAfter(); - } else { - // either # of hours or absolute time - return (t.match(/^(\d+\.)?\d+$/) || t.match(/^\d{1,2}:\d{2}(\s?[AP]M)?$/i)) !== null; - } - } - } -}); diff --git a/app/assets/javascripts/discourse/components/auto-update-input.js.es6 b/app/assets/javascripts/discourse/components/auto-update-input.js.es6 new file mode 100644 index 0000000000..1d44119778 --- /dev/null +++ b/app/assets/javascripts/discourse/components/auto-update-input.js.es6 @@ -0,0 +1,47 @@ +import { default as computed, observes } from "ember-addons/ember-computed-decorators"; + +export default Ember.Component.extend({ + limited: false, + + didInsertElement() { + this._super(); + this._updateInputValid(); + }, + + @computed("limited") + inputUnitsKey(limited) { + return limited ? "topic.auto_update_input.limited.units" : "topic.auto_update_input.all.units"; + }, + + @computed("limited") + inputExamplesKey(limited) { + return limited ? "topic.auto_update_input.limited.examples" : "topic.auto_update_input.all.examples"; + }, + + @observes("input", "limited") + _updateInputValid() { + this.set( + "inputValid", this._isInputValid(this.get("input"), this.get("limited")) + ); + }, + + _isInputValid(input, limited) { + const t = (input || "").toString().trim(); + + if (t.length === 0) { + return true; + // "empty" is always valid + } else if (limited) { + // only # of hours in limited mode + return t.match(/^(\d+\.)?\d+$/); + } else { + if (t.match(/^\d{4}-\d{1,2}-\d{1,2}(?: \d{1,2}:\d{2}(\s?[AP]M)?){0,1}$/i)) { + // timestamp must be in the future + return moment(t).isAfter(); + } else { + // either # of hours or absolute time + return (t.match(/^(\d+\.)?\d+$/) || t.match(/^\d{1,2}:\d{2}(\s?[AP]M)?$/i)) !== null; + } + } + } +}); diff --git a/app/assets/javascripts/discourse/components/category-chooser.js.es6 b/app/assets/javascripts/discourse/components/category-chooser.js.es6 index ab0c704b59..b79e8ef188 100644 --- a/app/assets/javascripts/discourse/components/category-chooser.js.es6 +++ b/app/assets/javascripts/discourse/components/category-chooser.js.es6 @@ -3,6 +3,7 @@ import { categoryBadgeHTML } from 'discourse/helpers/category-link'; import computed from 'ember-addons/ember-computed-decorators'; import { observes, on } from 'ember-addons/ember-computed-decorators'; import PermissionType from 'discourse/models/permission-type'; +import Category from 'discourse/models/category'; export default ComboboxView.extend({ classNames: ['combobox category-combobox'], @@ -14,13 +15,16 @@ export default ComboboxView.extend({ content(scopedCategoryId, categories) { // Always scope to the parent of a category, if present if (scopedCategoryId) { - const scopedCat = Discourse.Category.findById(scopedCategoryId); + const scopedCat = Category.findById(scopedCategoryId); scopedCategoryId = scopedCat.get('parent_category_id') || scopedCat.get('id'); } + const excludeCategoryId = this.get('excludeCategoryId'); + return categories.filter(c => { - if (scopedCategoryId && c.get('id') !== scopedCategoryId && c.get('parent_category_id') !== scopedCategoryId) { return false; } - if (c.get('isUncategorizedCategory')) { return false; } + const categoryId = c.get('id'); + if (scopedCategoryId && categoryId !== scopedCategoryId && c.get('parent_category_id') !== scopedCategoryId) { return false; } + if (c.get('isUncategorizedCategory') || excludeCategoryId === categoryId) { return false; } return c.get('permission') === PermissionType.FULL; }); }, @@ -30,19 +34,19 @@ export default ComboboxView.extend({ _updateCategories() { if (!this.get('categories')) { const categories = Discourse.SiteSettings.fixed_category_positions_on_create ? - Discourse.Category.list() : - Discourse.Category.listByActivity(); + Category.list() : + Category.listByActivity(); this.set('categories', categories); } }, - @computed("rootNone") - none(rootNone) { + @computed("rootNone", "rootNoneLabel") + none(rootNone, rootNoneLabel) { if (Discourse.SiteSettings.allow_uncategorized_topics || this.get('allowUncategorized')) { if (rootNone) { - return "category.none"; + return rootNoneLabel || "category.none"; } else { - return Discourse.Category.findUncategorized(); + return Category.findUncategorized(); } } else { return 'category.choose'; @@ -54,12 +58,12 @@ export default ComboboxView.extend({ // If we have no id, but text with the uncategorized name, we can use that badge. if (Ember.isEmpty(item.id)) { - const uncat = Discourse.Category.findUncategorized(); + const uncat = Category.findUncategorized(); if (uncat && uncat.get('name') === item.text) { category = uncat; } } else { - category = Discourse.Category.findById(parseInt(item.id,10)); + category = Category.findById(parseInt(item.id,10)); } if (!category) return item.text; @@ -67,7 +71,7 @@ export default ComboboxView.extend({ const parentCategoryId = category.get('parent_category_id'); if (parentCategoryId) { - result = categoryBadgeHTML(Discourse.Category.findById(parentCategoryId), {link: false}) + " " + result; + result = categoryBadgeHTML(Category.findById(parentCategoryId), {link: false}) + " " + result; } result += ` × ${category.get('topic_count')}`; diff --git a/app/assets/javascripts/discourse/components/composer-editor.js.es6 b/app/assets/javascripts/discourse/components/composer-editor.js.es6 index a851ae8860..b0213647fe 100644 --- a/app/assets/javascripts/discourse/components/composer-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-editor.js.es6 @@ -3,6 +3,7 @@ import { default as computed, on } from 'ember-addons/ember-computed-decorators' import { linkSeenMentions, fetchUnseenMentions } from 'discourse/lib/link-mentions'; import { linkSeenCategoryHashtags, fetchUnseenCategoryHashtags } from 'discourse/lib/link-category-hashtags'; import { linkSeenTagHashtags, fetchUnseenTagHashtags } from 'discourse/lib/link-tag-hashtag'; +import Composer from 'discourse/models/composer'; import { load } from 'pretty-text/oneboxer'; import { ajax } from 'discourse/lib/ajax'; import InputValidation from 'discourse/models/input-validation'; @@ -138,7 +139,7 @@ export default Ember.Component.extend({ _renderUnseenMentions($preview, unseen) { // 'Create a New Topic' scenario is not supported (per conversation with codinghorror) // https://meta.discourse.org/t/taking-another-1-7-release-task/51986/7 - fetchUnseenMentions(unseen, this.get('topic.id')).then(() => { + fetchUnseenMentions(unseen, this.get('composer.topic.id')).then(() => { linkSeenMentions($preview, this.siteSettings); this._warnMentionedGroups($preview); this._warnCannotSeeMention($preview); @@ -187,13 +188,25 @@ export default Ember.Component.extend({ }, _warnCannotSeeMention($preview) { + const composerDraftKey = this.get('composer.draftKey'); + + if (composerDraftKey === Composer.CREATE_TOPIC || + composerDraftKey === Composer.NEW_PRIVATE_MESSAGE_KEY || + composerDraftKey === Composer.REPLY_AS_NEW_TOPIC_KEY || + composerDraftKey === Composer.REPLY_AS_NEW_PRIVATE_MESSAGE_KEY) { + + return; + } + Ember.run.scheduleOnce('afterRender', () => { - var found = this.get('warnedCannotSeeMentions') || []; + let found = this.get('warnedCannotSeeMentions') || []; + $preview.find('.mention.cannot-see').each((idx,e) => { const $e = $(e); - var name = $e.data('name'); + let name = $e.data('name'); + if (found.indexOf(name) === -1) { - this.sendAction('cannotSeeMention', [{name: name}]); + this.sendAction('cannotSeeMention', [{ name: name }]); found.push(name); } }); diff --git a/app/assets/javascripts/discourse/components/discourse-topic.js.es6 b/app/assets/javascripts/discourse/components/discourse-topic.js.es6 index 5a07d9516d..ba81a63bf3 100644 --- a/app/assets/javascripts/discourse/components/discourse-topic.js.es6 +++ b/app/assets/javascripts/discourse/components/discourse-topic.js.es6 @@ -37,7 +37,7 @@ export default Ember.Component.extend(AddArchetypeClass, Scrolling, { const enteredAt = this.get('enteredAt'); if (enteredAt && (this.get('lastEnteredAt') !== enteredAt)) { this._lastShowTopic = null; - this.scrolled(); + Ember.run.schedule('afterRender', () => this.scrolled()); this.set('lastEnteredAt', enteredAt); } }, @@ -131,18 +131,22 @@ export default Ember.Component.extend(AddArchetypeClass, Scrolling, { } } + this.set('hasScrolled', offset > 0); const topic = this.get('topic'); const showTopic = this.showTopicInHeader(topic, offset); if (showTopic !== this._lastShowTopic) { - this._lastShowTopic = showTopic; - if (showTopic) { this.appEvents.trigger('header:show-topic', topic); + this._lastShowTopic = true; } else { if (!DiscourseURL.isJumpScheduled()) { - this.appEvents.trigger('header:hide-topic'); + const loadingNear = topic.get('postStream.loadingNearPost') || 1; + if (loadingNear === 1) { + this.appEvents.trigger('header:hide-topic'); + this._lastShowTopic = false; + } } } } diff --git a/app/assets/javascripts/discourse/components/notifications-button.js.es6 b/app/assets/javascripts/discourse/components/notifications-button.js.es6 index 2695cfc918..10f5d54f26 100644 --- a/app/assets/javascripts/discourse/components/notifications-button.js.es6 +++ b/app/assets/javascripts/discourse/components/notifications-button.js.es6 @@ -22,7 +22,7 @@ export default DropdownButton.extend({ id: l.id, title: I18n.t(`${start}.title`), description: I18n.t(`${start}.description`), - styleClasses: `${l.key} fa fa-${l.icon}` + styleClasses: `${l.key.dasherize()} fa fa-${l.icon}` }; }); }, @@ -31,7 +31,7 @@ export default DropdownButton.extend({ text(notificationLevel) { const details = buttonDetails(notificationLevel); const { key } = details; - const icon = iconHTML(details.icon, { class: key }); + const icon = iconHTML(details.icon, { class: key.dasherize() }); if (this.get('buttonIncludesText')) { const prefix = this.get('i18nPrefix'); diff --git a/app/assets/javascripts/discourse/components/quote-button.js.es6 b/app/assets/javascripts/discourse/components/quote-button.js.es6 index a8c8f5cfa5..41de3de0c6 100644 --- a/app/assets/javascripts/discourse/components/quote-button.js.es6 +++ b/app/assets/javascripts/discourse/components/quote-button.js.es6 @@ -55,7 +55,8 @@ export default Ember.Component.extend({ const { isIOS, isAndroid, isSafari } = this.capabilities; const showAtEnd = isMobileDevice || isIOS || isAndroid; - // used to work around Safari losing selection + // Don't mess with the original range as it results in weird behaviours + // where certain browsers will deselect the selection const clone = firstRange.cloneRange(); // create a marker element containing a single invisible character @@ -63,9 +64,9 @@ export default Ember.Component.extend({ markerElement.appendChild(document.createTextNode("\ufeff")); // on mobile, collapse the range at the end of the selection - if (showAtEnd) { firstRange.collapse(); } + if (showAtEnd) { clone.collapse(); } // insert the marker - firstRange.insertNode(markerElement); + clone.insertNode(markerElement); // retrieve the position of the marker const $markerElement = $(markerElement); diff --git a/app/assets/javascripts/discourse/components/small-action.js.es6 b/app/assets/javascripts/discourse/components/small-action.js.es6 index 8a17c41031..114d35801c 100644 --- a/app/assets/javascripts/discourse/components/small-action.js.es6 +++ b/app/assets/javascripts/discourse/components/small-action.js.es6 @@ -1,4 +1,5 @@ import { autoUpdatingRelativeAge } from 'discourse/lib/formatter'; +import { userPath } from 'discourse/lib/url'; export function actionDescriptionHtml(actionCode, createdAt, username) { const dt = new Date(createdAt); @@ -9,7 +10,7 @@ export function actionDescriptionHtml(actionCode, createdAt, username) { if (actionCode === "invited_group" || actionCode === "removed_group") { who = `@${username}`; } else { - who = `@${username}`; + who = `@${username}`; } } return I18n.t(`action_codes.${actionCode}`, { who, when }).htmlSafe(); diff --git a/app/assets/javascripts/discourse/components/topic-closing.js.es6 b/app/assets/javascripts/discourse/components/topic-closing.js.es6 deleted file mode 100644 index f4ab5748ce..0000000000 --- a/app/assets/javascripts/discourse/components/topic-closing.js.es6 +++ /dev/null @@ -1,51 +0,0 @@ -import { bufferedRender } from 'discourse-common/lib/buffered-render'; - -export default Ember.Component.extend(bufferedRender({ - elementId: 'topic-closing-info', - delayedRerender: null, - - rerenderTriggers: ['topic.closed', - 'topic.details.auto_close_at', - 'topic.details.auto_close_based_on_last_post', - 'topic.details.auto_close_hours'], - - buildBuffer(buffer) { - if (!!Ember.isEmpty(this.get('topic.details.auto_close_at'))) return; - if (this.get("topic.closed")) return; - - var autoCloseAt = moment(this.get('topic.details.auto_close_at')); - if (autoCloseAt < new Date()) return; - - var duration = moment.duration(autoCloseAt - moment()); - var minutesLeft = duration.asMinutes(); - var timeLeftString = duration.humanize(true); - var rerenderDelay = 1000; - - if (minutesLeft > 2160) { - rerenderDelay = 12 * 60 * 60000; - } else if (minutesLeft > 1410) { - rerenderDelay = 60 * 60000; - } else if (minutesLeft > 90) { - rerenderDelay = 30 * 60000; - } else if (minutesLeft > 2) { - rerenderDelay = 60000; - } - - var basedOnLastPost = this.get("topic.details.auto_close_based_on_last_post"); - var key = basedOnLastPost ? 'topic.auto_close_notice_based_on_last_post' : 'topic.auto_close_notice'; - var autoCloseHours = this.get("topic.details.auto_close_hours") || 0; - - buffer.push('

'); - buffer.push( I18n.t(key, { timeLeft: timeLeftString, duration: moment.duration(autoCloseHours, "hours").humanize() }) ); - buffer.push('

'); - - // TODO Sam: concerned this can cause a heavy rerender loop - this.set('delayedRerender', Em.run.later(this, this.rerender, rerenderDelay)); - }, - - willDestroyElement() { - if( this.delayedRerender ) { - Em.run.cancel(this.get('delayedRerender')); - } - } -})); diff --git a/app/assets/javascripts/discourse/components/topic-entrance.js.es6 b/app/assets/javascripts/discourse/components/topic-entrance.js.es6 index d3b5983ce4..6e4ba25d14 100644 --- a/app/assets/javascripts/discourse/components/topic-entrance.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-entrance.js.es6 @@ -94,11 +94,15 @@ export default Ember.Component.extend(CleansUp, { actions: { enterTop() { - DiscourseURL.routeTo(this.get('topic.url')); + const topic = this.get('topic'); + this.appEvents.trigger('header:update-topic', topic); + DiscourseURL.routeTo(topic.get('url')); }, enterBottom() { - DiscourseURL.routeTo(this.get('topic.lastPostUrl')); + const topic = this.get('topic'); + this.appEvents.trigger('header:update-topic', topic); + DiscourseURL.routeTo(topic.get('lastPostUrl')); } } }); diff --git a/app/assets/javascripts/discourse/components/topic-navigation.js.es6 b/app/assets/javascripts/discourse/components/topic-navigation.js.es6 index ecae615fc5..1f06f87162 100644 --- a/app/assets/javascripts/discourse/components/topic-navigation.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-navigation.js.es6 @@ -3,7 +3,12 @@ import showModal from 'discourse/lib/show-modal'; export default Ember.Component.extend({ composerOpen: null, - info: Em.Object.create(), + info: null, + + init() { + this._super(); + this.set('info', Ember.Object.create()); + }, _performCheckSize() { if (!this.element || this.isDestroying || this.isDestroyed) { return; } diff --git a/app/assets/javascripts/discourse/components/topic-status-info.js.es6 b/app/assets/javascripts/discourse/components/topic-status-info.js.es6 new file mode 100644 index 0000000000..a7b5d784b0 --- /dev/null +++ b/app/assets/javascripts/discourse/components/topic-status-info.js.es6 @@ -0,0 +1,78 @@ +import { bufferedRender } from 'discourse-common/lib/buffered-render'; +import Category from 'discourse/models/category'; + +export default Ember.Component.extend(bufferedRender({ + elementId: 'topic-status-info', + delayedRerender: null, + + rerenderTriggers: [ + 'topic.topic_status_update', + 'topic.topic_status_update.execute_at', + 'topic.topic_status_update.based_on_last_post', + 'topic.topic_status_update.duration', + 'topic.topic_status_update.category_id', + ], + + buildBuffer(buffer) { + if (!this.get('topic.topic_status_update.execute_at')) return; + + let statusUpdateAt = moment(this.get('topic.topic_status_update.execute_at')); + if (statusUpdateAt < new Date()) return; + + let duration = moment.duration(statusUpdateAt - moment()); + let minutesLeft = duration.asMinutes(); + let rerenderDelay = 1000; + + if (minutesLeft > 2160) { + rerenderDelay = 12 * 60 * 60000; + } else if (minutesLeft > 1410) { + rerenderDelay = 60 * 60000; + } else if (minutesLeft > 90) { + rerenderDelay = 30 * 60000; + } else if (minutesLeft > 2) { + rerenderDelay = 60000; + } + + let autoCloseHours = this.get("topic.topic_status_update.duration") || 0; + + buffer.push('

'); + + let options = { + timeLeft: duration.humanize(true), + duration: moment.duration(autoCloseHours, "hours").humanize(), + }; + + const categoryId = this.get('topic.topic_status_update.category_id'); + + if (categoryId) { + const category = Category.findById(categoryId); + + options = _.assign({ + categoryName: category.get('slug'), + categoryUrl: category.get('url') + }, options); + } + + buffer.push(I18n.t(this._noticeKey(), options)); + buffer.push('

'); + + // TODO Sam: concerned this can cause a heavy rerender loop + this.set('delayedRerender', Em.run.later(this, this.rerender, rerenderDelay)); + }, + + willDestroyElement() { + if( this.delayedRerender ) { + Em.run.cancel(this.get('delayedRerender')); + } + }, + + _noticeKey() { + const statusType = this.get('topic.topic_status_update.status_type'); + + if (this.get("topic.topic_status_update.based_on_last_post")) { + return `topic.status_update_notice.auto_${statusType}_based_on_last_post`; + } else { + return `topic.status_update_notice.auto_${statusType}`; + } + } +})); diff --git a/app/assets/javascripts/discourse/components/user-card-contents.js.es6 b/app/assets/javascripts/discourse/components/user-card-contents.js.es6 index 6aefcaa2f2..969a6a931c 100644 --- a/app/assets/javascripts/discourse/components/user-card-contents.js.es6 +++ b/app/assets/javascripts/discourse/components/user-card-contents.js.es6 @@ -5,6 +5,7 @@ import afterTransition from 'discourse/lib/after-transition'; import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; import DiscourseURL from 'discourse/lib/url'; import User from 'discourse/models/user'; +import { userPath } from 'discourse/lib/url'; const clickOutsideEventName = "mousedown.outside-user-card"; const clickDataExpand = "click.discourse-user-card"; @@ -92,7 +93,7 @@ export default Ember.Component.extend(CleansUp, { // Don't show on mobile if (this.site.mobileView) { - DiscourseURL.routeTo(`/users/${username}`); + DiscourseURL.routeTo(userPath(username)); return false; } diff --git a/app/assets/javascripts/discourse/components/user-info.js.es6 b/app/assets/javascripts/discourse/components/user-info.js.es6 index 05dde9fffe..3293e4d46e 100644 --- a/app/assets/javascripts/discourse/components/user-info.js.es6 +++ b/app/assets/javascripts/discourse/components/user-info.js.es6 @@ -1,5 +1,5 @@ -import { url } from 'discourse/lib/computed'; import computed from 'ember-addons/ember-computed-decorators'; +import { userPath } from 'discourse/lib/url'; function normalize(name) { return name.replace(/[\-\_ \.]/g, '').toLowerCase(); @@ -8,7 +8,11 @@ function normalize(name) { export default Ember.Component.extend({ classNameBindings: [':user-info', 'size'], size: 'small', - userPath: url('user.username', '/users/%@'), + + @computed('user.username') + userPath(username) { + return userPath(username); + }, // TODO: In later ember releases `hasBlock` works without this hasBlock: Ember.computed.alias('template'), diff --git a/app/assets/javascripts/discourse/controllers/activation-edit.js.es6 b/app/assets/javascripts/discourse/controllers/activation-edit.js.es6 new file mode 100644 index 0000000000..ddd8a2a562 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/activation-edit.js.es6 @@ -0,0 +1,36 @@ +import computed from 'ember-addons/ember-computed-decorators'; +import ModalFunctionality from 'discourse/mixins/modal-functionality'; +import { ajax } from 'discourse/lib/ajax'; +import { extractError } from 'discourse/lib/ajax-error'; +import { userPath } from 'discourse/lib/url'; + +export default Ember.Controller.extend(ModalFunctionality, { + login: Ember.inject.controller(), + + currentEmail: null, + newEmail: null, + password: null, + + @computed('newEmail', 'currentEmail') + submitDisabled(newEmail, currentEmail) { + return newEmail === currentEmail; + }, + + actions: { + changeEmail() { + const login = this.get('login'); + + ajax(userPath('update-activation-email'), { + data: { + username: login.get('loginName'), + password: login.get('loginPassword'), + email: this.get('newEmail') + }, + type: 'PUT' + }).then(() => { + const modal = this.showModal('activation-resent', {title: 'log_in'}); + modal.set('currentEmail', this.get('newEmail')); + }).catch(err => this.flash(extractError(err), 'error')); + } + } +}); diff --git a/app/assets/javascripts/discourse/controllers/basic-modal-body.js.es6 b/app/assets/javascripts/discourse/controllers/basic-modal-body.js.es6 new file mode 100644 index 0000000000..f7001555a9 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/basic-modal-body.js.es6 @@ -0,0 +1,5 @@ +import ModalFunctionality from 'discourse/mixins/modal-functionality'; + +export default Ember.Controller.extend(ModalFunctionality, { + modal: null +}); diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index 13d3f291b9..b40f7efdc8 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -367,7 +367,7 @@ export default Ember.Controller.extend({ cannotSeeMention(mentions) { mentions.forEach(mention => { - const translation = (this.get('topic.isPrivateMessage')) ? + const translation = (this.get('model.topic.isPrivateMessage')) ? 'composer.cannot_see_mention.private' : 'composer.cannot_see_mention.category'; const body = I18n.t(translation, { diff --git a/app/assets/javascripts/discourse/controllers/create-account.js.es6 b/app/assets/javascripts/discourse/controllers/create-account.js.es6 index d04a03821c..d77a4db489 100644 --- a/app/assets/javascripts/discourse/controllers/create-account.js.es6 +++ b/app/assets/javascripts/discourse/controllers/create-account.js.es6 @@ -6,6 +6,7 @@ import { emailValid } from 'discourse/lib/utilities'; import InputValidation from 'discourse/models/input-validation'; import PasswordValidation from "discourse/mixins/password-validation"; import UsernameValidation from "discourse/mixins/username-validation"; +import { userPath } from 'discourse/lib/url'; export default Ember.Controller.extend(ModalFunctionality, PasswordValidation, UsernameValidation, { login: Ember.inject.controller(), @@ -164,7 +165,7 @@ export default Ember.Controller.extend(ModalFunctionality, PasswordValidation, U @on('init') fetchConfirmationValue() { - return ajax('/users/hp.json').then(json => { + return ajax(userPath('hp.json')).then(json => { this.set('accountPasswordConfirm', json.value); this.set('accountChallenge', json.challenge.split("").reverse().join("")); }); @@ -196,7 +197,7 @@ export default Ember.Controller.extend(ModalFunctionality, PasswordValidation, U const $hidden_login_form = $('#hidden-login-form'); $hidden_login_form.find('input[name=username]').val(attrs.accountUsername); $hidden_login_form.find('input[name=password]').val(attrs.accountPassword); - $hidden_login_form.find('input[name=redirect]').val(Discourse.getURL('/users/account-created')); + $hidden_login_form.find('input[name=redirect]').val(userPath('account-created')); $hidden_login_form.submit(); } else { self.flash(result.message || I18n.t('create_account.failed'), 'error'); diff --git a/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 b/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 index 516c53daf4..706ee73241 100644 --- a/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 +++ b/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 @@ -3,6 +3,7 @@ import { queryParams } from 'discourse/controllers/discovery-sortable'; import BulkTopicSelection from 'discourse/mixins/bulk-topic-selection'; import { endWith } from 'discourse/lib/computed'; import showModal from 'discourse/lib/show-modal'; +import { userPath } from 'discourse/lib/url'; const controllerOpts = { discovery: Ember.inject.controller(), @@ -133,14 +134,14 @@ const controllerOpts = { }.property('allLoaded', 'model.topics.length'), footerEducation: function() { - if (!this.get('allLoaded') || this.get('model.topics.length') > 0 || !Discourse.User.current()) { return; } + if (!this.get('allLoaded') || this.get('model.topics.length') > 0 || !this.currentUser) { return; } const split = (this.get('model.filter') || '').split('/'); if (split[0] !== 'new' && split[0] !== 'unread') { return; } return I18n.t("topics.none.educate." + split[0], { - userPrefsUrl: Discourse.getURL("/users/") + (Discourse.User.currentProp("username_lower")) + "/preferences" + userPrefsUrl: userPath(`${this.currentUser.get('username_lower')}/preferences`) }); }.property('allLoaded', 'model.topics.length') diff --git a/app/assets/javascripts/discourse/controllers/edit-topic-auto-close.js.es6 b/app/assets/javascripts/discourse/controllers/edit-topic-auto-close.js.es6 deleted file mode 100644 index 81aa4f5b06..0000000000 --- a/app/assets/javascripts/discourse/controllers/edit-topic-auto-close.js.es6 +++ /dev/null @@ -1,78 +0,0 @@ -import { ajax } from 'discourse/lib/ajax'; -import { observes } from "ember-addons/ember-computed-decorators"; -import ModalFunctionality from 'discourse/mixins/modal-functionality'; - -// Modal related to auto closing of topics -export default Ember.Controller.extend(ModalFunctionality, { - auto_close_valid: true, - auto_close_invalid: Em.computed.not('auto_close_valid'), - disable_submit: Em.computed.or('auto_close_invalid', 'loading'), - loading: false, - - @observes("model.details.auto_close_at", "model.details.auto_close_hours") - setAutoCloseTime() { - let autoCloseTime = null; - - if (this.get("model.details.auto_close_based_on_last_post")) { - autoCloseTime = this.get("model.details.auto_close_hours"); - } else if (this.get("model.details.auto_close_at")) { - const closeTime = new Date(this.get("model.details.auto_close_at")); - if (closeTime > new Date()) { - autoCloseTime = moment(closeTime).format("YYYY-MM-DD HH:mm"); - } - } - - this.set("model.auto_close_time", autoCloseTime); - }, - - actions: { - saveAutoClose() { this.setAutoClose(this.get("model.auto_close_time")); }, - removeAutoClose() { this.setAutoClose(null); } - }, - - setAutoClose(time) { - const self = this; - this.set('loading', true); - ajax({ - url: `/t/${this.get('model.id')}/autoclose`, - type: 'PUT', - dataType: 'json', - data: { - auto_close_time: time, - auto_close_based_on_last_post: this.get("model.details.auto_close_based_on_last_post"), - timezone_offset: (new Date().getTimezoneOffset()) - } - }).then(result => { - self.set('loading', false); - if (result.success) { - this.send('closeModal'); - this.set('model.details.auto_close_at', result.auto_close_at); - this.set('model.details.auto_close_hours', result.auto_close_hours); - } else { - bootbox.alert(I18n.t('composer.auto_close.error')); - } - }).catch(() => { - // TODO - incorrectly responds to network errors as bad input - bootbox.alert(I18n.t('composer.auto_close.error')); - self.set('loading', false); - }); - }, - - willCloseImmediately: function() { - if (!this.get('model.details.auto_close_based_on_last_post')) { - return false; - } - let closeDate = new Date(this.get('model.last_posted_at')); - closeDate.setHours(closeDate.getHours() + this.get('model.auto_close_time')); - return closeDate < new Date(); - }.property('model.details.auto_close_based_on_last_post', 'model.auto_close_time', 'model.last_posted_at'), - - willCloseI18n: function() { - if (this.get('model.details.auto_close_based_on_last_post')) { - let closeDate = new Date(this.get('model.last_posted_at')); - let diff = Math.round((new Date() - closeDate)/(1000*60*60)); - return I18n.t('topic.auto_close_immediate', {count: diff}); - } - }.property('model.details.auto_close_based_on_last_post', 'model.last_posted_at') - -}); diff --git a/app/assets/javascripts/discourse/controllers/edit-topic-status-update.js.es6 b/app/assets/javascripts/discourse/controllers/edit-topic-status-update.js.es6 new file mode 100644 index 0000000000..3dbeddeca5 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/edit-topic-status-update.js.es6 @@ -0,0 +1,122 @@ +import { default as computed, observes } from "ember-addons/ember-computed-decorators"; +import ModalFunctionality from 'discourse/mixins/modal-functionality'; +import TopicStatusUpdate from 'discourse/models/topic-status-update'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; + +const CLOSE_STATUS_TYPE = 'close'; +const OPEN_STATUS_TYPE = 'open'; +const PUBLISH_TO_CATEGORY_STATUS_TYPE = 'publish_to_category'; + +export default Ember.Controller.extend(ModalFunctionality, { + closeStatusType: CLOSE_STATUS_TYPE, + openStatusType: OPEN_STATUS_TYPE, + publishToCategoryStatusType: PUBLISH_TO_CATEGORY_STATUS_TYPE, + updateTimeValid: null, + updateTimeInvalid: Em.computed.not('updateTimeValid'), + loading: false, + updateTime: null, + topicStatusUpdate: Ember.computed.alias("model.topic_status_update"), + selection: Ember.computed.alias('model.topic_status_update.status_type'), + autoOpen: Ember.computed.equal('selection', OPEN_STATUS_TYPE), + autoClose: Ember.computed.equal('selection', CLOSE_STATUS_TYPE), + publishToCategory: Ember.computed.equal('selection', PUBLISH_TO_CATEGORY_STATUS_TYPE), + + @computed('autoClose', 'updateTime') + disableAutoClose(autoClose, updateTime) { + return updateTime && !autoClose; + }, + + @computed('autoOpen', 'updateTime') + disableAutoOpen(autoOpen, updateTime) { + return updateTime && !autoOpen; + }, + + @computed('publishToCatgory', 'updateTime') + disablePublishToCategory(publishToCatgory, updateTime) { + return updateTime && !publishToCatgory; + }, + + @computed('topicStatusUpdate.based_on_last_post', 'updateTime', 'model.last_posted_at') + willCloseImmediately(basedOnLastPost, updateTime, lastPostedAt) { + if (!basedOnLastPost) { + return false; + } + const closeDate = new Date(lastPostedAt); + closeDate.setHours(closeDate.getHours() + updateTime); + return closeDate < new Date(); + }, + + @computed('topicStatusUpdate.based_on_last_post', 'model.last_posted_at') + willCloseI18n(basedOnLastPost, lastPostedAt) { + if (basedOnLastPost) { + const diff = Math.round((new Date() - new Date(lastPostedAt)) / (1000*60*60)); + return I18n.t('topic.auto_close_immediate', { count: diff }); + } + }, + + @computed('updateTime', 'updateTimeInvalid', 'loading') + saveDisabled(updateTime, updateTimeInvalid, loading) { + return Ember.isEmpty(updateTime) || updateTimeInvalid || loading; + }, + + @computed("model.visible") + excludeCategoryId(visible) { + if (visible) return this.get('model.category_id'); + }, + + @observes("topicStatusUpdate.execute_at", "topicStatusUpdate.duration") + _setUpdateTime() { + let time = null; + + if (this.get("topicStatusUpdate.based_on_last_post")) { + time = this.get("topicStatusUpdate.duration"); + } else if (this.get("topicStatusUpdate.execute_at")) { + const closeTime = new Date(this.get("topicStatusUpdate.execute_at")); + + if (closeTime > new Date()) { + time = moment(closeTime).format("YYYY-MM-DD HH:mm"); + } + } + + this.set("updateTime", time); + }, + + _setStatusUpdate(time, status_type) { + this.set('loading', true); + + TopicStatusUpdate.updateStatus( + this.get('model.id'), + time, + this.get('topicStatusUpdate.based_on_last_post'), + status_type, + this.get('categoryId') + ).then(result => { + if (time) { + this.send('closeModal'); + + this.get("topicStatusUpdate").setProperties({ + execute_at: result.execute_at, + duration: result.duration, + category_id: result.category_id + }); + + this.set('model.closed', result.closed); + } else { + this.set('topicStatusUpdate', Ember.Object.create({})); + this.set('selection', null); + } + }).catch(error => { + popupAjaxError(error); + }).finally(() => this.set('loading', false)); + }, + + actions: { + saveStatusUpdate() { + this._setStatusUpdate(this.get("updateTime"), this.get('selection')); + }, + + removeStatusUpdate() { + this._setStatusUpdate(null, this.get('selection')); + } + } +}); diff --git a/app/assets/javascripts/discourse/controllers/full-page-search.js.es6 b/app/assets/javascripts/discourse/controllers/full-page-search.js.es6 index 91e5b907d2..a6751b639e 100644 --- a/app/assets/javascripts/discourse/controllers/full-page-search.js.es6 +++ b/app/assets/javascripts/discourse/controllers/full-page-search.js.es6 @@ -11,6 +11,8 @@ const SortOrders = [ {name: I18n.t('search.latest_post'), id: 1, term: 'order:latest'}, {name: I18n.t('search.most_liked'), id: 2, term: 'order:likes'}, {name: I18n.t('search.most_viewed'), id: 3, term: 'order:views'}, + {name: I18n.t('search.latest_topic'), id: 4, term: 'order:latest_topic'}, + ]; export default Ember.Controller.extend({ @@ -73,14 +75,7 @@ export default Ember.Controller.extend({ @computed('q') noSortQ(q) { - if (q) { - SortOrders.forEach((order) => { - if (q.indexOf(order.term) > -1){ - q = q.replace(order.term, ""); - q = q.trim(); - } - }); - } + q = this.cleanTerm(q); return escapeExpression(q); }, @@ -88,17 +83,23 @@ export default Ember.Controller.extend({ setSearchTerm(term) { this._searchOnSortChange = false; + term = this.cleanTerm(term); + this._searchOnSortChange = true; + this.set('searchTerm', term); + }, + + cleanTerm(term) { if (term) { SortOrders.forEach(order => { - if (term.indexOf(order.term) > -1){ + let matches = term.match(new RegExp(`${order.term}\\b`)); + if (matches) { this.set('sortOrder', order.id); - term = term.replace(order.term, ""); + term = term.replace(new RegExp(`${order.term}\\b`, 'g'), ""); term = term.trim(); } }); } - this._searchOnSortChange = true; - this.set('searchTerm', term); + return term; }, @observes('sortOrder') diff --git a/app/assets/javascripts/discourse/controllers/not-activated.js.es6 b/app/assets/javascripts/discourse/controllers/not-activated.js.es6 index 86afe2ddd7..976e1311bd 100644 --- a/app/assets/javascripts/discourse/controllers/not-activated.js.es6 +++ b/app/assets/javascripts/discourse/controllers/not-activated.js.es6 @@ -1,23 +1,26 @@ import { ajax } from 'discourse/lib/ajax'; import { popupAjaxError } from 'discourse/lib/ajax-error'; import ModalFunctionality from 'discourse/mixins/modal-functionality'; +import { userPath } from 'discourse/lib/url'; export default Ember.Controller.extend(ModalFunctionality, { - emailSent: false, - - onShow() { - this.set("emailSent", false); - }, - actions: { sendActivationEmail() { - ajax('/users/action/send_activation_email', { + ajax(userPath('action/send_activation_email'), { data: { username: this.get('username') }, type: 'POST' }).then(() => { - this.set('emailSent', true); + const modal = this.showModal('activation-resent', {title: 'log_in'}); + modal.set('currentEmail', this.get('currentEmail')); }).catch(popupAjaxError); + }, + + editActivationEmail() { + const modal = this.showModal('activation-edit', {title: 'login.change_email'}); + + const currentEmail = this.get('currentEmail'); + modal.set('currentEmail', currentEmail); + modal.set('newEmail', currentEmail); } } - }); diff --git a/app/assets/javascripts/discourse/controllers/password-reset.js.es6 b/app/assets/javascripts/discourse/controllers/password-reset.js.es6 index 34b911c23d..e7694a2301 100644 --- a/app/assets/javascripts/discourse/controllers/password-reset.js.es6 +++ b/app/assets/javascripts/discourse/controllers/password-reset.js.es6 @@ -3,6 +3,7 @@ import getUrl from 'discourse-common/lib/get-url'; import DiscourseURL from 'discourse/lib/url'; import { ajax } from 'discourse/lib/ajax'; import PasswordValidation from "discourse/mixins/password-validation"; +import { userPath } from 'discourse/lib/url'; export default Ember.Controller.extend(PasswordValidation, { isDeveloper: Ember.computed.alias('model.is_developer'), @@ -27,7 +28,7 @@ export default Ember.Controller.extend(PasswordValidation, { actions: { submit() { ajax({ - url: `/users/password-reset/${this.get('model.token')}.json`, + url: userPath(`password-reset/${this.get('model.token')}.json`), type: 'PUT', data: { password: this.get('accountPassword') diff --git a/app/assets/javascripts/discourse/controllers/preferences/username.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/username.js.es6 index a597df810a..f6cfa7be70 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/username.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/username.js.es6 @@ -1,5 +1,6 @@ import { setting, propertyEqual } from 'discourse/lib/computed'; import DiscourseURL from 'discourse/lib/url'; +import { userPath } from 'discourse/lib/url'; export default Ember.Controller.extend({ taken: false, @@ -48,7 +49,7 @@ export default Ember.Controller.extend({ if (result) { this.set('saving', true); this.get('content').changeUsername(this.get('newUsername')).then(() => { - DiscourseURL.redirectTo("/users/" + this.get('newUsername').toLowerCase() + "/preferences"); + DiscourseURL.redirectTo(userPath(this.get('newUsername').toLowerCase() + "/preferences")); }) .catch(() => this.set('error', true)) .finally(() => this.set('saving', false)); diff --git a/app/assets/javascripts/discourse/controllers/static.js.es6 b/app/assets/javascripts/discourse/controllers/static.js.es6 index 166a0676a7..369c6bddf5 100644 --- a/app/assets/javascripts/discourse/controllers/static.js.es6 +++ b/app/assets/javascripts/discourse/controllers/static.js.es6 @@ -1,5 +1,6 @@ import { ajax } from 'discourse/lib/ajax'; import computed from 'ember-addons/ember-computed-decorators'; +import { userPath } from 'discourse/lib/url'; export default Ember.Controller.extend({ application: Ember.inject.controller(), @@ -18,7 +19,7 @@ export default Ember.Controller.extend({ markFaqRead() { const currentUser = this.currentUser; if (currentUser) { - ajax("/users/read-faq", { method: "POST" }).then(() => { + ajax(userPath("read-faq"), { method: "POST" }).then(() => { currentUser.set('read_faq', true); }); } diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 32b824fc25..6fe51c4936 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -12,6 +12,7 @@ import Post from 'discourse/models/post'; import debounce from 'discourse/lib/debounce'; import isElementInViewport from "discourse/lib/is-element-in-viewport"; import QuoteState from 'discourse/lib/quote-state'; +import { userPath } from 'discourse/lib/url'; export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { composer: Ember.inject.controller(), @@ -126,7 +127,7 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { showCategoryChooser: Ember.computed.not("model.isPrivateMessage"), gotoInbox(name) { - var url = '/users/' + this.get('currentUser.username_lower') + '/messages'; + let url = userPath(this.get('currentUser.username_lower') + '/messages'); if (name) { url = url + '/group/' + name; } @@ -160,10 +161,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { return this.send(name, model); }, - openAutoClose() { - this.send('showAutoClose'); - }, - openFeatureTopic() { this.send('showFeatureTopic'); }, @@ -591,7 +588,11 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { }, toggleClosed() { - this.get('content').toggleStatus('closed'); + const topic = this.get('content'); + + this.get('content').toggleStatus('closed').then(result => { + topic.set('topic_status_update', result.topic_status_update); + }); }, recoverTopic() { @@ -867,7 +868,7 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { const refresh = (args) => this.appEvents.trigger('post-stream:refresh', args); - this.messageBus.subscribe("/topic/" + this.get('model.id'), data => { + this.messageBus.subscribe(`/topic/${this.get('model.id')}`, data => { const topic = this.get('model'); if (data.notification_level_change) { @@ -877,9 +878,24 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { } const postStream = this.get('model.postStream'); + + if (data.reload_topic) { + topic.reload().then(() => { + this.send('postChangedRoute', topic.get('post_number') || 1); + this.appEvents.trigger('header:update-topic', topic); + if (data.refresh_stream) postStream.refresh(); + }); + + return; + } + switch (data.type) { case "acted": - postStream.triggerChangedPost(data.id, data.updated_at).then(() => refresh({ id: data.id, refreshLikes: true })); + postStream.triggerChangedPost( + data.id, + data.updated_at, + { preserveCooked: true } + ).then(() => refresh({ id: data.id, refreshLikes: true })); break; case "revised": case "rebaked": { @@ -914,27 +930,20 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { } } - if (data.reload_topic) { - topic.reload().then(() => { - this.send('postChangedRoute', topic.get('post_number') || 1); - this.appEvents.trigger('header:update-topic', topic); - }); - } else { - if (topic.get('isPrivateMessage') && - this.currentUser && - this.currentUser.get('id') !== data.user_id && - data.type === 'created') { + if (topic.get('isPrivateMessage') && + this.currentUser && + this.currentUser.get('id') !== data.user_id && + data.type === 'created') { - const postNumber = data.post_number; - const notInPostStream = topic.get('highest_post_number') <= postNumber; - const postNumberDifference = postNumber - topic.get('currentPost'); + const postNumber = data.post_number; + const notInPostStream = topic.get('highest_post_number') <= postNumber; + const postNumberDifference = postNumber - topic.get('currentPost'); - if (notInPostStream && - postNumberDifference > 0 && - postNumberDifference < 7) { + if (notInPostStream && + postNumberDifference > 0 && + postNumberDifference < 7) { - this._scrollToPost(data.post_number); - } + this._scrollToPost(data.post_number); } } }); diff --git a/app/assets/javascripts/discourse/initializers/url-redirects.js.es6 b/app/assets/javascripts/discourse/initializers/url-redirects.js.es6 index b0e9c79c10..e183bda742 100644 --- a/app/assets/javascripts/discourse/initializers/url-redirects.js.es6 +++ b/app/assets/javascripts/discourse/initializers/url-redirects.js.es6 @@ -12,12 +12,14 @@ export default { DiscourseURL.rewrite(/^\/category\//, "/c/"); DiscourseURL.rewrite(/^\/group\//, "/groups/"); DiscourseURL.rewrite(/\/private-messages\/$/, "/messages/"); + DiscourseURL.rewrite(/^\/users$/, "/u"); + DiscourseURL.rewrite(/^\/users\//, "/u/"); if (currentUser) { const username = currentUser.get('username'); - DiscourseURL.rewrite(new RegExp(`^/users/${username}/?$`, "i"), `/users/${username}/activity`); + DiscourseURL.rewrite(new RegExp(`^/u/${username}/?$`, "i"), `/u/${username}/activity`); } - DiscourseURL.rewrite(/^\/users\/([^\/]+)\/?$/, "/users/$1/activity"); + DiscourseURL.rewrite(/^\/u\/([^\/]+)\/?$/, "/u/$1/summary"); } }; diff --git a/app/assets/javascripts/discourse/lib/discourse-location.js.es6 b/app/assets/javascripts/discourse/lib/discourse-location.js.es6 index 43b74ab1ef..c2e4fe0437 100644 --- a/app/assets/javascripts/discourse/lib/discourse-location.js.es6 +++ b/app/assets/javascripts/discourse/lib/discourse-location.js.es6 @@ -66,12 +66,10 @@ const DiscourseLocation = Ember.Object.extend({ getURL() { const location = get(this, 'location'); let url = location.pathname; - url = url.replace(Discourse.BaseUri, ''); const search = location.search || ''; url += search; - return url; }, diff --git a/app/assets/javascripts/discourse/lib/link-mentions.js.es6 b/app/assets/javascripts/discourse/lib/link-mentions.js.es6 index 20876bd72e..d8cd970299 100644 --- a/app/assets/javascripts/discourse/lib/link-mentions.js.es6 +++ b/app/assets/javascripts/discourse/lib/link-mentions.js.es6 @@ -1,4 +1,5 @@ import { ajax } from 'discourse/lib/ajax'; +import { userPath } from 'discourse/lib/url'; function replaceSpan($e, username, opts) { let extra = ""; @@ -15,7 +16,7 @@ function replaceSpan($e, username, opts) { extra = `data-name='${username}'`; extraClass = "cannot-see"; } - $e.replaceWith(`@${username}`); + $e.replaceWith(`@${username}`); } } @@ -54,7 +55,7 @@ export function linkSeenMentions($elem, siteSettings) { // 'Create a New Topic' scenario is not supported (per conversation with codinghorror) // https://meta.discourse.org/t/taking-another-1-7-release-task/51986/7 export function fetchUnseenMentions(usernames, topic_id) { - return ajax("/users/is_local_username", { data: { usernames, topic_id } }).then(r => { + return ajax(userPath("is_local_username"), { data: { usernames, topic_id } }).then(r => { r.valid.forEach(v => found[v] = true); r.valid_groups.forEach(vg => foundGroups[vg] = true); r.mentionable_groups.forEach(mg => mentionableGroups[mg.name] = mg); diff --git a/app/assets/javascripts/discourse/lib/lock-on.js.es6 b/app/assets/javascripts/discourse/lib/lock-on.js.es6 index d6b9594584..10c3542670 100644 --- a/app/assets/javascripts/discourse/lib/lock-on.js.es6 +++ b/app/assets/javascripts/discourse/lib/lock-on.js.es6 @@ -55,6 +55,8 @@ export default class LockOn { const interval = setInterval(() => { let top = this.elementTop(); + if (top < 0) { top = 0; } + const scrollTop = $(window).scrollTop(); if (typeof(top) === "undefined" || isNaN(top)) { diff --git a/app/assets/javascripts/discourse/lib/plugin-api.js.es6 b/app/assets/javascripts/discourse/lib/plugin-api.js.es6 index 0b3e85739b..1bc7638c83 100644 --- a/app/assets/javascripts/discourse/lib/plugin-api.js.es6 +++ b/app/assets/javascripts/discourse/lib/plugin-api.js.es6 @@ -18,9 +18,11 @@ import { addTagsHtmlCallback } from 'discourse/lib/render-tags'; import { addUserMenuGlyph } from 'discourse/widgets/user-menu'; import { addPostClassesCallback } from 'discourse/widgets/post'; import { addPostTransformCallback } from 'discourse/widgets/post-stream'; +import { attachAdditionalPanel } from 'discourse/widgets/header'; + // If you add any methods to the API ensure you bump up this number -const PLUGIN_API_VERSION = '0.8.5'; +const PLUGIN_API_VERSION = '0.8.6'; class PluginApi { constructor(version, container) { @@ -333,6 +335,26 @@ class PluginApi { return addFlagProperty(property); } + /** + * Adds a panel to the header + * + * takes a widget name, a value to toggle on, and a function which returns the attrs for the widget + * Example: + * ```javascript + * api.addHeaderPanel('widget-name', 'widgetVisible', function(attrs, state) { + * return { name: attrs.name, description: state.description }; + * }); + * ``` + * 'toggle' is an attribute on the state of the header widget, + * + * 'transformAttrs' is a function which is passed the current attrs and state of the widget, + * and returns a hash of values to pass to attach + * + **/ + addHeaderPanel(name, toggle, transformAttrs) { + attachAdditionalPanel(name, toggle, transformAttrs); + } + /** * Adds a pluralization to the store * diff --git a/app/assets/javascripts/discourse/lib/search.js.es6 b/app/assets/javascripts/discourse/lib/search.js.es6 index 0824f8edca..30ab32629f 100644 --- a/app/assets/javascripts/discourse/lib/search.js.es6 +++ b/app/assets/javascripts/discourse/lib/search.js.es6 @@ -5,6 +5,7 @@ import { SEPARATOR } from 'discourse/lib/category-hashtags'; import Category from 'discourse/models/category'; import { search as searchCategoryTag } from 'discourse/lib/category-tag-search'; import userSearch from 'discourse/lib/user-search'; +import { userPath } from 'discourse/lib/url'; export function translateResults(results, opts) { @@ -29,7 +30,7 @@ export function translateResults(results, opts) { results.posts = results.posts.map(post => { if (post.username) { - post.userPath = Discourse.getURL(`/users/${post.username.toLowerCase()}`); + post.userPath = userPath(post.username.toLowerCase()); } post = Post.create(post); post.set('topic', topicMap[post.topic_id]); diff --git a/app/assets/javascripts/discourse/lib/show-modal.js.es6 b/app/assets/javascripts/discourse/lib/show-modal.js.es6 index ed2457e065..739fdd17b3 100644 --- a/app/assets/javascripts/discourse/lib/show-modal.js.es6 +++ b/app/assets/javascripts/discourse/lib/show-modal.js.es6 @@ -11,17 +11,23 @@ export default function(name, opts) { const controllerName = opts.admin ? `modals/${name}` : name; - const controller = container.lookup('controller:' + controllerName); + let controller = container.lookup('controller:' + controllerName); const templateName = opts.templateName || Ember.String.dasherize(name); const renderArgs = { into: 'modal', outlet: 'modalBody'}; - if (controller) { renderArgs.controller = controllerName; } + if (controller) { + renderArgs.controller = controllerName; + } else { + // use a basic controller + renderArgs.controller = 'basic-modal-body'; + controller = container.lookup(`controller:${renderArgs.controller}`); + } + if (opts.addModalBodyView) { renderArgs.view = 'modal-body'; } - const modalName = `modal/${templateName}`; const fullName = opts.admin ? `admin/templates/${modalName}` : modalName; route.render(fullName, renderArgs); @@ -29,13 +35,11 @@ export default function(name, opts) { modalController.set('title', I18n.t(opts.title)); } - if (controller) { - controller.set('modal', modalController); - const model = opts.model; - if (model) { controller.set('model', model); } - if (controller.onShow) { controller.onShow(); } - controller.set('flashMessage', null); - } + controller.set('modal', modalController); + const model = opts.model; + if (model) { controller.set('model', model); } + if (controller.onShow) { controller.onShow(); } + controller.set('flashMessage', null); return controller; }; diff --git a/app/assets/javascripts/discourse/lib/transform-post.js.es6 b/app/assets/javascripts/discourse/lib/transform-post.js.es6 index d9742501d6..7df3954b30 100644 --- a/app/assets/javascripts/discourse/lib/transform-post.js.es6 +++ b/app/assets/javascripts/discourse/lib/transform-post.js.es6 @@ -1,3 +1,5 @@ +import { userPath } from 'discourse/lib/url'; + function actionDescription(action, acted, count) { if (acted) { if (count <= 1) { @@ -39,7 +41,7 @@ export function transformBasicPost(post) { via_email: post.via_email, isAutoGenerated: post.is_auto_generated, user_id: post.user_id, - usernameUrl: Discourse.getURL(`/users/${post.username}`), + usernameUrl: userPath(post.username), username: post.username, avatar_template: post.avatar_template, bookmarked: post.bookmarked, diff --git a/app/assets/javascripts/discourse/lib/url.js.es6 b/app/assets/javascripts/discourse/lib/url.js.es6 index 203126a6c8..a08aa5810c 100644 --- a/app/assets/javascripts/discourse/lib/url.js.es6 +++ b/app/assets/javascripts/discourse/lib/url.js.es6 @@ -18,6 +18,27 @@ const SERVER_SIDE_ONLY = [ /\.json$/, ]; +export function rewritePath(path) { + const params = path.split("?"); + + let result = params[0]; + rewrites.forEach(rw => result = result.replace(rw.regexp, rw.replacement)); + + if (params.length > 1) { + result += `?${params[1]}`; + } + + return result; +} + +export function clearRewrites() { + rewrites.length = 0; +} + +export function userPath(subPath) { + return Discourse.getURL(subPath ? `/u/${subPath}` : '/u'); +} + let _jumpScheduled = false; export function jumpToElement(elementId) { if (_jumpScheduled || Ember.isEmpty(elementId)) { return; } @@ -47,8 +68,8 @@ const DiscourseURL = Ember.Object.extend({ opts = opts || {}; const holderId = `#post_${postNumber}`; - _transitioning = true; - Em.run.schedule('afterRender', () => { + _transitioning = postNumber > 1; + Ember.run.schedule('afterRender', () => { let elementId; let holder; @@ -87,6 +108,10 @@ const DiscourseURL = Ember.Object.extend({ } lockon.lock(); + if (lockon.elementTop() < 1) { + _transitioning = false; + return; + } }); }, @@ -95,7 +120,6 @@ const DiscourseURL = Ember.Object.extend({ if (window.history && window.history.pushState && window.history.replaceState && - !navigator.userAgent.match(/((iPod|iPhone|iPad).+\bOS\s+[1-4]|WebApps\/.+CFNetwork)/) && (window.location.pathname !== path)) { // Always use replaceState in the next runloop to prevent weird routes changing @@ -173,15 +197,14 @@ const DiscourseURL = Ember.Object.extend({ if (path.indexOf('/my/') === 0) { const currentUser = Discourse.User.current(); if (currentUser) { - path = path.replace('/my/', '/users/' + currentUser.get('username_lower') + "/"); + path = path.replace('/my/', userPath(currentUser.get('username_lower') + "/")); } else { document.location.href = "/404"; return; } } - rewrites.forEach(rw => path = path.replace(rw.regexp, rw.replacement)); - + path = rewritePath(path); if (this.navigatedToPost(oldPath, path, opts)) { return; } if (oldPath === path) { diff --git a/app/assets/javascripts/discourse/lib/user-search.js.es6 b/app/assets/javascripts/discourse/lib/user-search.js.es6 index 6f99fa0699..36612631f5 100644 --- a/app/assets/javascripts/discourse/lib/user-search.js.es6 +++ b/app/assets/javascripts/discourse/lib/user-search.js.es6 @@ -1,4 +1,5 @@ import { CANCELLED_STATUS } from 'discourse/lib/autocomplete'; +import { userPath } from 'discourse/lib/url'; var cache = {}, cacheTopicId, @@ -14,7 +15,7 @@ function performSearch(term, topicId, includeGroups, includeMentionableGroups, a } // need to be able to cancel this - oldSearch = $.ajax(Discourse.getURL('/users/search/users'), { + oldSearch = $.ajax(userPath('search/users'), { data: { term: term, topic_id: topicId, include_groups: includeGroups, diff --git a/app/assets/javascripts/discourse/lib/utilities.js.es6 b/app/assets/javascripts/discourse/lib/utilities.js.es6 index 67f795bf3d..d80b84cabc 100644 --- a/app/assets/javascripts/discourse/lib/utilities.js.es6 +++ b/app/assets/javascripts/discourse/lib/utilities.js.es6 @@ -65,10 +65,6 @@ export function postUrl(slug, topicId, postNumber) { return url; } -export function userUrl(username) { - return Discourse.getURL("/users/" + username.toLowerCase()); -} - export function emailValid(email) { // see: http://stackoverflow.com/questions/46155/validate-email-address-in-javascript const re = /^[a-zA-Z0-9!#$%&'*+\/=?\^_`{|}~\-]+(?:\.[a-zA-Z0-9!#$%&'\*+\/=?\^_`{|}~\-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?$/; diff --git a/app/assets/javascripts/discourse/mapping-router.js.es6 b/app/assets/javascripts/discourse/mapping-router.js.es6 index e88b7f746d..6d30be749c 100644 --- a/app/assets/javascripts/discourse/mapping-router.js.es6 +++ b/app/assets/javascripts/discourse/mapping-router.js.es6 @@ -1,4 +1,6 @@ import { defaultHomepage } from 'discourse/lib/utilities'; +import { rewritePath } from 'discourse/lib/url'; + const rootURL = Discourse.BaseUri; const BareRouter = Ember.Router.extend({ @@ -6,6 +8,7 @@ const BareRouter = Ember.Router.extend({ location: Ember.testing ? 'none': 'discourse-location', handleURL(url) { + url = rewritePath(url); const params = url.split('?'); if (params[0] === "/") { diff --git a/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 b/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 index 23bc790e15..d78478dae6 100644 --- a/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 +++ b/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 @@ -1,5 +1,17 @@ +import showModal from 'discourse/lib/show-modal'; + export default Ember.Mixin.create({ flash(text, messageClass) { this.appEvents.trigger('modal-body:flash', { text, messageClass }); + }, + + showModal(...args) { + return showModal(...args); + }, + + actions: { + closeModal() { + this.get('modal').send('closeModal'); + } } }); diff --git a/app/assets/javascripts/discourse/mixins/open-composer.js.es6 b/app/assets/javascripts/discourse/mixins/open-composer.js.es6 index 13f7000da9..2303281b63 100644 --- a/app/assets/javascripts/discourse/mixins/open-composer.js.es6 +++ b/app/assets/javascripts/discourse/mixins/open-composer.js.es6 @@ -32,7 +32,7 @@ export default Ember.Mixin.create({ topicTitle, topicBody, archetypeId: 'private_message', - draftKey: 'new_private_message' + draftKey: Composer.NEW_PRIVATE_MESSAGE_KEY }); } diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index b0897c0203..68ec25cfdd 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -15,6 +15,7 @@ const CLOSED = 'closed', // The actions the composer can take CREATE_TOPIC = 'createTopic', PRIVATE_MESSAGE = 'privateMessage', + NEW_PRIVATE_MESSAGE_KEY = 'new_private_message', REPLY = 'reply', EDIT = 'edit', REPLY_AS_NEW_TOPIC_KEY = "reply_as_new_topic", @@ -815,6 +816,7 @@ Composer.reopenClass({ EDIT, // Draft key + NEW_PRIVATE_MESSAGE_KEY, REPLY_AS_NEW_TOPIC_KEY, REPLY_AS_NEW_PRIVATE_MESSAGE_KEY }); diff --git a/app/assets/javascripts/discourse/models/invite.js.es6 b/app/assets/javascripts/discourse/models/invite.js.es6 index 8f89cfc684..1425e63b25 100644 --- a/app/assets/javascripts/discourse/models/invite.js.es6 +++ b/app/assets/javascripts/discourse/models/invite.js.es6 @@ -1,5 +1,6 @@ import { ajax } from 'discourse/lib/ajax'; import { popupAjaxError } from 'discourse/lib/ajax-error'; +import { userPath } from 'discourse/lib/url'; const Invite = Discourse.Model.extend({ @@ -41,7 +42,7 @@ Invite.reopenClass({ if (!Em.isNone(search)) { data.search = search; } data.offset = offset || 0; - return ajax("/users/" + user.get('username_lower') + "/invited.json", {data}).then(function (result) { + return ajax(userPath(user.get('username_lower') + "/invited.json"), {data}).then(function (result) { result.invites = result.invites.map(function (i) { return Invite.create(i); }); @@ -52,7 +53,7 @@ Invite.reopenClass({ findInvitedCount(user) { if (!user) { return Em.RSVP.resolve(); } - return ajax("/users/" + user.get('username_lower') + "/invited_count.json").then(result => Em.Object.create(result.counts)); + return ajax(userPath(user.get('username_lower') + "/invited_count.json")).then(result => Em.Object.create(result.counts)); }, reinviteAll() { diff --git a/app/assets/javascripts/discourse/models/post-stream.js.es6 b/app/assets/javascripts/discourse/models/post-stream.js.es6 index 32cfa6427f..419baf7ccb 100644 --- a/app/assets/javascripts/discourse/models/post-stream.js.es6 +++ b/app/assets/javascripts/discourse/models/post-stream.js.es6 @@ -15,6 +15,7 @@ export default RestModel.extend({ loadingAbove: null, loadingBelow: null, loadingFilter: null, + loadingNearPost: null, stagingPost: null, postsWithPlaceholders: null, timelineLookup: null, @@ -206,6 +207,7 @@ export default RestModel.extend({ // TODO: if we have all the posts in the filter, don't go to the server for them. this.set('loadingFilter', true); + this.set('loadingNearPost', opts.nearPost); opts = _.merge(opts, this.get('streamFilters')); @@ -216,6 +218,8 @@ export default RestModel.extend({ }).catch(result => { this.errorLoading(result); throw result; + }).finally(() => { + this.set('loadingNearPost', null); }); }, @@ -540,7 +544,9 @@ export default RestModel.extend({ return Ember.RSVP.Promise.resolve(); }, - triggerChangedPost(postId, updatedAt) { + triggerChangedPost(postId, updatedAt, opts) { + opts = opts || {}; + const resolved = Ember.RSVP.Promise.resolve(); if (!postId) { return resolved; } @@ -548,7 +554,13 @@ export default RestModel.extend({ if (existing && existing.updated_at !== updatedAt) { const url = "/posts/" + postId; const store = this.store; - return ajax(url).then(p => this.storePost(store.createRecord('post', p))); + return ajax(url).then(p => { + if (opts.preserveCooked) { + p.cooked = existing.get('cooked'); + } + + this.storePost(store.createRecord('post', p)); + }); } return resolved; }, diff --git a/app/assets/javascripts/discourse/models/post.js.es6 b/app/assets/javascripts/discourse/models/post.js.es6 index 127b81f861..7800d3be38 100644 --- a/app/assets/javascripts/discourse/models/post.js.es6 +++ b/app/assets/javascripts/discourse/models/post.js.es6 @@ -2,11 +2,12 @@ import { ajax } from 'discourse/lib/ajax'; import RestModel from 'discourse/models/rest'; import { popupAjaxError } from 'discourse/lib/ajax-error'; import ActionSummary from 'discourse/models/action-summary'; -import { url, propertyEqual } from 'discourse/lib/computed'; +import { propertyEqual } from 'discourse/lib/computed'; import Quote from 'discourse/lib/quote'; import computed from 'ember-addons/ember-computed-decorators'; import { postUrl } from 'discourse/lib/utilities'; import { cook } from 'discourse/lib/text'; +import { userPath } from 'discourse/lib/url'; const Post = RestModel.extend({ @@ -60,7 +61,10 @@ const Post = RestModel.extend({ return postNumber === 1 ? baseUrl + "/1" : baseUrl; }, - usernameUrl: url('username', '/users/%@'), + @computed('username') + usernameUrl(username) { + return userPath(username); + }, topicOwner: propertyEqual('topic.details.created_by.id', 'user_id'), diff --git a/app/assets/javascripts/discourse/models/topic-status-update.js.es6 b/app/assets/javascripts/discourse/models/topic-status-update.js.es6 new file mode 100644 index 0000000000..fae76de94b --- /dev/null +++ b/app/assets/javascripts/discourse/models/topic-status-update.js.es6 @@ -0,0 +1,25 @@ +import { ajax } from 'discourse/lib/ajax'; +import RestModel from 'discourse/models/rest'; + +const TopicStatusUpdate = RestModel.extend({}); + +TopicStatusUpdate.reopenClass({ + updateStatus(topicId, time, basedOnLastPost, statusType, categoryId) { + let data = { + time: time, + timezone_offset: (new Date().getTimezoneOffset()), + status_type: statusType + }; + + if (basedOnLastPost) data.based_on_last_post = basedOnLastPost; + if (categoryId) data.category_id = categoryId; + + return ajax({ + url: `/t/${topicId}/status_update`, + type: 'POST', + data + }); + } +}); + +export default TopicStatusUpdate; diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6 index 139b2ddcf7..34bb795a8b 100644 --- a/app/assets/javascripts/discourse/models/topic.js.es6 +++ b/app/assets/javascripts/discourse/models/topic.js.es6 @@ -9,6 +9,7 @@ import { popupAjaxError } from 'discourse/lib/ajax-error'; import { censor } from 'pretty-text/censored-words'; import { emojiUnescape } from 'discourse/lib/text'; import PreloadStore from 'preload-store'; +import { userPath } from 'discourse/lib/url'; export function loadTopicView(topic, args) { const topicId = topic.get('id'); @@ -182,9 +183,10 @@ const Topic = RestModel.extend({ return this.urlForPostNumber(1) + (this.get('has_summary') ? "?filter=summary" : ""); }.property('url'), - lastPosterUrl: function() { - return Discourse.getURL("/users/") + this.get("last_poster.username"); - }.property('last_poster'), + @computed('last_poster.username') + lastPosterUrl(username) { + return userPath(username); + }, // The amount of new posts to display. It might be different than what the server // tells us if we are still asynchronously flushing our "recently read" data. @@ -221,16 +223,12 @@ const Topic = RestModel.extend({ toggleStatus(property) { this.toggleProperty(property); - this.saveStatus(property, !!this.get(property)); + return this.saveStatus(property, !!this.get(property)); }, saveStatus(property, value, until) { if (property === 'closed') { this.incrementProperty('posts_count'); - - if (value === true) { - this.set('details.auto_close_at', null); - } } return ajax(this.get('url') + "/status", { type: 'PUT', @@ -378,9 +376,8 @@ const Topic = RestModel.extend({ }, reload() { - const self = this; - return ajax('/t/' + this.get('id'), { type: 'GET' }).then(function(topic_json) { - self.updateFromJson(topic_json); + return ajax(`/t/${this.get('id')}`, { type: 'GET' }).then(topic_json => { + this.updateFromJson(topic_json); }); }, diff --git a/app/assets/javascripts/discourse/models/user-action.js.es6 b/app/assets/javascripts/discourse/models/user-action.js.es6 index ebc4be7381..c4d5b7a161 100644 --- a/app/assets/javascripts/discourse/models/user-action.js.es6 +++ b/app/assets/javascripts/discourse/models/user-action.js.es6 @@ -1,9 +1,9 @@ import RestModel from 'discourse/models/rest'; -import { url } from 'discourse/lib/computed'; import { on } from 'ember-addons/ember-computed-decorators'; import computed from 'ember-addons/ember-computed-decorators'; import UserActionGroup from 'discourse/models/user-action-group'; import { postUrl } from 'discourse/lib/utilities'; +import { userPath } from 'discourse/lib/url'; const UserActionTypes = { likes_given: 1, @@ -79,14 +79,21 @@ const UserAction = RestModel.extend({ presentName: Ember.computed.or('name', 'username'), targetDisplayName: Ember.computed.or('target_name', 'target_username'), actingDisplayName: Ember.computed.or('acting_name', 'acting_username'), - targetUserUrl: url('target_username', '/users/%@'), + + @computed('target_username') + targetUserUrl(username) { + return userPath(username); + }, @computed("username") usernameLower(username) { return username.toLowerCase(); }, - userUrl: url('usernameLower', '/users/%@'), + @computed('usernameLower') + userUrl(usernameLower) { + return userPath(usernameLower); + }, @computed() postUrl() { diff --git a/app/assets/javascripts/discourse/models/user.js.es6 b/app/assets/javascripts/discourse/models/user.js.es6 index 5086c54cbc..6b45997078 100644 --- a/app/assets/javascripts/discourse/models/user.js.es6 +++ b/app/assets/javascripts/discourse/models/user.js.es6 @@ -15,6 +15,7 @@ import Topic from 'discourse/models/topic'; import { emojiUnescape } from 'discourse/lib/text'; import PreloadStore from 'preload-store'; import { defaultHomepage } from 'discourse/lib/utilities'; +import { userPath } from 'discourse/lib/url'; const User = RestModel.extend({ @@ -71,7 +72,7 @@ const User = RestModel.extend({ @computed() path() { // no need to observe, requires a hard refresh to update - return Discourse.getURL(`/users/${this.get('username_lower')}`); + return userPath(this.get('username_lower')); }, @computed() @@ -124,11 +125,10 @@ const User = RestModel.extend({ // directly targetted so go to inbox if (!groups || (allowedUsers && allowedUsers.findBy("id", userId))) { - return Discourse.getURL(`/users/${username}/messages`); + return userPath(`${username}/messages`); } else { - if (groups && groups[0]) - { - return Discourse.getURL(`/users/${username}/messages/group/${groups[0].name}`); + if (groups && groups[0]) { + return userPath(`${username}/messages/group/${groups[0].name}`); } } @@ -146,6 +146,11 @@ const User = RestModel.extend({ return defaultHomepage() === "latest" ? Discourse.getURL('/?state=watching') : Discourse.getURL('/latest?state=watching'); }, + @computed() + trackingTopicsPath() { + return defaultHomepage() === "latest" ? Discourse.getURL('/?state=tracking') : Discourse.getURL('/latest?state=tracking'); + }, + @computed("username") username_lower(username) { return username.toLowerCase(); @@ -179,14 +184,14 @@ const User = RestModel.extend({ }, changeUsername(new_username) { - return ajax(`/users/${this.get('username_lower')}/preferences/username`, { + return ajax(userPath(`${this.get('username_lower')}/preferences/username`), { type: 'PUT', data: { new_username } }); }, changeEmail(email) { - return ajax(`/users/${this.get('username_lower')}/preferences/email`, { + return ajax(userPath(`${this.get('username_lower')}/preferences/email`), { type: 'PUT', data: { email } }); @@ -254,7 +259,7 @@ const User = RestModel.extend({ // TODO: We can remove this when migrated fully to rest model. this.set('isSaving', true); - return ajax(`/users/${this.get('username_lower')}.json`, { + return ajax(userPath(`${this.get('username_lower')}.json`), { data: data, type: 'PUT' }).then(result => { @@ -330,7 +335,7 @@ const User = RestModel.extend({ const user = this; return PreloadStore.getAndRemove(`user_${user.get('username')}`, () => { - return ajax(`/users/${user.get('username')}.json`, { data: options }); + return ajax(userPath(`${user.get('username')}.json`), { data: options }); }).then(json => { if (!Em.isEmpty(json.user.stats)) { @@ -375,13 +380,13 @@ const User = RestModel.extend({ findStaffInfo() { if (!Discourse.User.currentProp("staff")) { return Ember.RSVP.resolve(null); } - return ajax(`/users/${this.get("username_lower")}/staff-info.json`).then(info => { + return ajax(userPath(`${this.get("username_lower")}/staff-info.json`)).then(info => { this.setProperties(info); }); }, pickAvatar(upload_id, type, avatar_template) { - return ajax(`/users/${this.get("username_lower")}/preferences/avatar/pick`, { + return ajax(userPath(`${this.get("username_lower")}/preferences/avatar/pick`), { type: 'PUT', data: { upload_id, type } }).then(() => this.setProperties({ @@ -437,7 +442,7 @@ const User = RestModel.extend({ "delete": function() { if (this.get('can_delete_account')) { - return ajax("/users/" + this.get('username'), { + return ajax(userPath(this.get('username')), { type: 'DELETE', data: {context: window.location.pathname} }); @@ -448,14 +453,14 @@ const User = RestModel.extend({ dismissBanner(bannerKey) { this.set("dismissed_banner_key", bannerKey); - ajax(`/users/${this.get('username')}`, { + ajax(userPath(this.get('username')), { type: 'PUT', data: { dismissed_banner_key: bannerKey } }); }, checkEmail() { - return ajax(`/users/${this.get("username_lower")}/emails.json`, { + return ajax(userPath(`${this.get("username_lower")}/emails.json`), { data: { context: window.location.pathname } }).then(result => { if (result) { @@ -468,7 +473,7 @@ const User = RestModel.extend({ }, summary() { - return ajax(`/users/${this.get("username_lower")}/summary.json`) + return ajax(userPath(`${this.get("username_lower")}/summary.json`)) .then(json => { const summary = json["user_summary"]; const topicMap = {}; @@ -526,7 +531,7 @@ User.reopenClass(Singleton, { }, checkUsername(username, email, for_user_id) { - return ajax('/users/check_username', { + return ajax(userPath('check_username'), { data: { username, email, for_user_id } }); }, @@ -557,7 +562,7 @@ User.reopenClass(Singleton, { }, createAccount(attrs) { - return ajax("/users", { + return ajax(userPath(), { data: { name: attrs.accountName, email: attrs.accountEmail, 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 99a2aa0886..cb0bdb8d5d 100644 --- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 +++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 @@ -62,9 +62,9 @@ export default function() { }); // User routes - this.route('users', { resetNamespace: true }); - this.route('password-reset', { path: '/users/password-reset/:token' }); - this.route('user', { path: '/users/:username', resetNamespace: true }, function() { + this.route('users', { resetNamespace: true, path: '/u' }); + this.route('password-reset', { path: '/u/password-reset/:token' }); + this.route('user', { path: '/u/:username', resetNamespace: true }, function() { this.route('summary'); this.route('userActivity', { path: '/activity', resetNamespace: true }, function() { this.route('topics'); diff --git a/app/assets/javascripts/discourse/routes/application.js.es6 b/app/assets/javascripts/discourse/routes/application.js.es6 index 1c3fed9a6b..2c800ff199 100644 --- a/app/assets/javascripts/discourse/routes/application.js.es6 +++ b/app/assets/javascripts/discourse/routes/application.js.es6 @@ -7,6 +7,7 @@ import Category from 'discourse/models/category'; import mobile from 'discourse/lib/mobile'; import { findAll } from 'discourse/models/login-method'; import { getOwner } from 'discourse-common/lib/get-owner'; +import { userPath } from 'discourse/lib/url'; function unlessReadOnly(method, message) { return function() { @@ -23,7 +24,7 @@ const ApplicationRoute = Discourse.Route.extend(OpenComposer, { actions: { toggleAnonymous() { - ajax("/users/toggle-anon", {method: 'POST'}).then(() => { + ajax(userPath("toggle-anon"), {method: 'POST'}).then(() => { window.location.reload(); }); }, diff --git a/app/assets/javascripts/discourse/routes/password-reset.js.es6 b/app/assets/javascripts/discourse/routes/password-reset.js.es6 index 1f4cf2102c..ff5fde0fad 100644 --- a/app/assets/javascripts/discourse/routes/password-reset.js.es6 +++ b/app/assets/javascripts/discourse/routes/password-reset.js.es6 @@ -1,5 +1,6 @@ import PreloadStore from 'preload-store'; import { ajax } from 'discourse/lib/ajax'; +import { userPath } from 'discourse/lib/url'; export default Discourse.Route.extend({ titleToken() { @@ -15,7 +16,7 @@ export default Discourse.Route.extend({ afterModel(model) { // confirm token here so email clients who crawl URLs don't invalidate the link if (model) { - return ajax({ url: `/users/confirm-email-token/${model.token}.json`, dataType: 'json' }); + return ajax({ url: userPath(`confirm-email-token/${model.token}.json`), dataType: 'json' }); } } }); diff --git a/app/assets/javascripts/discourse/routes/topic.js.es6 b/app/assets/javascripts/discourse/routes/topic.js.es6 index 105d0e6ba0..3fcb115fbf 100644 --- a/app/assets/javascripts/discourse/routes/topic.js.es6 +++ b/app/assets/javascripts/discourse/routes/topic.js.es6 @@ -50,9 +50,11 @@ const TopicRoute = Discourse.Route.extend({ this.controllerFor('flag').setProperties({ selected: null, flagTopic: true }); }, - showAutoClose() { - showModal('edit-topic-auto-close', { model: this.modelFor('topic') }); - this.controllerFor('modal').set('modalClass', 'edit-auto-close-modal'); + showTopicStatusUpdate() { + const model = this.modelFor('topic'); + model.set('topic_status_update', Ember.Object.create(model.get('topic_status_update'))); + showModal('edit-topic-status-update', { model }); + this.controllerFor('modal').set('modalClass', 'topic-close-modal'); }, showChangeTimestamp() { diff --git a/app/assets/javascripts/discourse/routes/user.js.es6 b/app/assets/javascripts/discourse/routes/user.js.es6 index 936ebadfcc..2ab3296925 100644 --- a/app/assets/javascripts/discourse/routes/user.js.es6 +++ b/app/assets/javascripts/discourse/routes/user.js.es6 @@ -83,14 +83,14 @@ export default Discourse.Route.extend({ activate() { this._super(); const user = this.modelFor('user'); - this.messageBus.subscribe("/users/" + user.get('username_lower'), function(data) { + this.messageBus.subscribe("/u/" + user.get('username_lower'), function(data) { user.loadUserAction(data); }); }, deactivate() { this._super(); - this.messageBus.unsubscribe("/users/" + this.modelFor('user').get('username_lower')); + this.messageBus.unsubscribe("/u/" + this.modelFor('user').get('username_lower')); // Remove the search context this.searchService.set('searchContext', null); diff --git a/app/assets/javascripts/discourse/templates/components/auto-close-form.hbs b/app/assets/javascripts/discourse/templates/components/auto-close-form.hbs deleted file mode 100644 index 34d53e2f70..0000000000 --- a/app/assets/javascripts/discourse/templates/components/auto-close-form.hbs +++ /dev/null @@ -1,19 +0,0 @@ -
-
- -
-
- {{autoCloseExamples}} -
-
- -
-
diff --git a/app/assets/javascripts/discourse/templates/components/auto-update-input.hbs b/app/assets/javascripts/discourse/templates/components/auto-update-input.hbs new file mode 100644 index 0000000000..455f17eea3 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/auto-update-input.hbs @@ -0,0 +1,24 @@ +
+
+ + + {{#if inputExamplesKey}} +
+ {{i18n inputExamplesKey}} +
+ {{/if}} +
+ + {{#unless hideBasedOnLastPost}} +
+ +
+ {{/unless}} +
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 aae72706cd..e1fec09080 100644 --- a/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs +++ b/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs @@ -1,8 +1,10 @@
- {{auto-close-form autoCloseTime=category.auto_close_hours - autoCloseBasedOnLastPost=category.auto_close_based_on_last_post - autoCloseExamples="" - limited="true" }} + {{auto-update-input + inputLabelKey='topic.auto_close.label' + input=category.auto_close_hours + basedOnLastPost=category.auto_close_based_on_last_post + inputExamplesKey='' + limited=true}}
diff --git a/app/assets/javascripts/discourse/templates/components/modal-footer-close.hbs b/app/assets/javascripts/discourse/templates/components/modal-footer-close.hbs new file mode 100644 index 0000000000..a1e59ab16b --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/modal-footer-close.hbs @@ -0,0 +1,3 @@ + diff --git a/app/assets/javascripts/discourse/templates/components/topic-footer-buttons.hbs b/app/assets/javascripts/discourse/templates/components/topic-footer-buttons.hbs index ec1e9eac88..7e28cc0f9a 100644 --- a/app/assets/javascripts/discourse/templates/components/topic-footer-buttons.hbs +++ b/app/assets/javascripts/discourse/templates/components/topic-footer-buttons.hbs @@ -8,7 +8,7 @@ toggleClosed=toggleClosed toggleArchived=toggleArchived toggleVisibility=toggleVisibility - showAutoClose=showAutoClose + showTopicStatusUpdate=showTopicStatusUpdate showFeatureTopic=showFeatureTopic showChangeTimestamp=showChangeTimestamp convertToPublicTopic=convertToPublicTopic diff --git a/app/assets/javascripts/discourse/templates/mobile/discovery/topics.hbs b/app/assets/javascripts/discourse/templates/mobile/discovery/topics.hbs index 0fb3cb216d..c17937e618 100644 --- a/app/assets/javascripts/discourse/templates/mobile/discovery/topics.hbs +++ b/app/assets/javascripts/discourse/templates/mobile/discovery/topics.hbs @@ -35,7 +35,7 @@ {{/if}} {{#if showResetNew}} - + {{/if}} {{#if latest}} diff --git a/app/assets/javascripts/discourse/templates/modal.hbs b/app/assets/javascripts/discourse/templates/modal.hbs index 3d778a0476..5783f544aa 100644 --- a/app/assets/javascripts/discourse/templates/modal.hbs +++ b/app/assets/javascripts/discourse/templates/modal.hbs @@ -3,10 +3,13 @@